mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
feat(skills): manage bundled skill updates
This commit is contained in:
@@ -15,6 +15,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
|
||||
@@ -69,6 +71,11 @@ jobs:
|
||||
- name: Check reliability gate manifest
|
||||
run: pnpm run check:reliability-gates
|
||||
|
||||
# Why: skill revisions are append-only; checking merge-queue and main
|
||||
# heads prevents independently green PRs from claiming one revision.
|
||||
- name: Verify bundled skill manifest
|
||||
run: pnpm run verify:skill-bundle-manifest -- --base-ref=origin/${{ github.base_ref }}
|
||||
|
||||
# Why: oxlint fails any file over max-lines that is NOT suppressed, so this
|
||||
# ratchet forbids ADDING a new suppression (inline disable or mobile max
|
||||
# bump). Existing oversized files are grandfathered in
|
||||
|
||||
@@ -472,7 +472,10 @@ jobs:
|
||||
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
|
||||
# assumptions and any lifecycle scripts that would run on bump).
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
git add package.json
|
||||
# Why: the versioned release commit is the mechanical authority that
|
||||
# records exactly which generated skill revisions shipped.
|
||||
node config/scripts/generate-skill-bundle-manifest.mjs --write --record-release
|
||||
git add package.json resources/skills
|
||||
commit_message="release: v$VERSION"
|
||||
if [[ "$EVENT_NAME" == "schedule" ]]; then
|
||||
commit_message="$commit_message [rc-slot:$SLOT]"
|
||||
@@ -1220,7 +1223,6 @@ jobs:
|
||||
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
|
||||
}
|
||||
# ── End Windows inner-binary signing ───────────────────────────────
|
||||
|
||||
- name: Upload unsigned Windows installer for SignPath
|
||||
if: matrix.platform == 'win'
|
||||
id: upload-unsigned-windows-installer
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Skill CLI round trip
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'skills/**'
|
||||
- 'resources/skills/**'
|
||||
- 'config/scripts/generate-skill-bundle-manifest.mjs'
|
||||
- 'config/scripts/verify-skill-cli-roundtrip.mjs'
|
||||
- '.github/workflows/skill-cli-roundtrip.yml'
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
roundtrip:
|
||||
continue-on-error: ${{ matrix.skills-cli == 'latest' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||
skills-cli: ['1.5.16', latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- name: Verify LF and CRLF installer identities
|
||||
shell: bash
|
||||
run: |
|
||||
node config/scripts/verify-skill-cli-roundtrip.mjs --cli=${{ matrix.skills-cli }} --autocrlf=false --shape=symlink
|
||||
node config/scripts/verify-skill-cli-roundtrip.mjs --cli=${{ matrix.skills-cli }} --autocrlf=false --shape=copy
|
||||
node config/scripts/verify-skill-cli-roundtrip.mjs --cli=${{ matrix.skills-cli }} --autocrlf=true --shape=symlink
|
||||
node config/scripts/verify-skill-cli-roundtrip.mjs --cli=${{ matrix.skills-cli }} --autocrlf=true --shape=copy
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Skill manifest merge gate
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
# Why: rerunning generation on the integrated head prevents two skill
|
||||
# changes from independently claiming the same immutable revision.
|
||||
- run: node config/scripts/generate-skill-bundle-manifest.mjs --base-ref=HEAD^
|
||||
@@ -18,6 +18,12 @@ const featureWallResources = {
|
||||
from: 'resources/onboarding/feature-wall',
|
||||
to: 'onboarding/feature-wall'
|
||||
}
|
||||
// Why: the updater must compare against the exact skill bundle belonging to
|
||||
// the running app instead of mutable checkout or network content.
|
||||
const skillBundleResources = [
|
||||
{ from: 'skills', to: 'skills/packages' },
|
||||
{ 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,
|
||||
...skillBundleResources
|
||||
]
|
||||
const macSpeechNativeResource = {
|
||||
from: 'node_modules/sherpa-onnx-darwin-${arch}',
|
||||
to: 'node_modules/sherpa-onnx-darwin-${arch}'
|
||||
|
||||
@@ -3582,6 +3582,162 @@
|
||||
"No live Electron reload-survival assertion yet — the gate proves the main-side accounting reset, not the full renderer reattach paint."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.authoritative-exit-status",
|
||||
"title": "Install ownership requires an authoritative successful terminal exit",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "renderer-provider-contract",
|
||||
"surfaces": [
|
||||
"local terminal",
|
||||
"daemon and WSL terminal",
|
||||
"SSH and remote-runtime terminal",
|
||||
"skill install ownership"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coverageNotes": "Deterministic provider-contract tests prove that the shared local/daemon/WSL IPC path preserves numeric process status, while remote runtime stream retirement reports unknown status and ownership accepts only numeric zero.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca"],
|
||||
"invariant": "A transport may report numeric zero only when its terminal provider supplies an authoritative process exit; disconnect, stale-handle retirement, and stream end remain unknown and cannot grant skill ownership.",
|
||||
"oracle": "Drive the IPC PTY transport with zero and nonzero process exits, drive remote runtime close/end/stale events without a process status, and assert the install decision accepts only code zero after an active install window.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/settings/skill-install-terminal-exit.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
|
||||
"src/renderer/src/components/settings/skill-install-terminal-exit.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"assertions": [
|
||||
"the shared local IPC transport forwards authoritative zero and nonzero process exit codes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
|
||||
"assertions": [
|
||||
"remote stream end, disconnect, and stale-handle retirement report a null exit status"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/settings/skill-install-terminal-exit.test.ts",
|
||||
"assertions": [
|
||||
"skill ownership accepts numeric zero only after the install window begins and rejects null or nonzero"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/settings/skill-install-terminal-exit.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.11,
|
||||
"summary": "120 deterministic provider-contract and install-ownership tests passed."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 15,
|
||||
"scope": "local renderer unit tests"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "New deterministic contract gate; soak history has not accumulated."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The remote oracle fails if stream end invents zero, and the ownership oracle fails if null or nonzero is accepted."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": false,
|
||||
"evidence": "The change only propagates an event payload and adds no loop, polling, or hot-path work."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Accumulate the required soak runs across all required CI platforms.",
|
||||
"Add a live provider-contract run when SSH and WSL terminal harnesses are available in CI."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Daemon and WSL use the same IPC transport contract as local but do not yet have live provider processes in this unit gate.",
|
||||
"Remote runtimes currently expose stream completion rather than an authoritative subprocess exit status."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the gate flakes once without a product or harness issue filed to the owner."
|
||||
},
|
||||
{
|
||||
"id": "skill-management.inventory-bounded-work",
|
||||
"title": "Mounted skill inventory has deterministic filesystem work limits",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "skill-management",
|
||||
"layer": "main-process-filesystem",
|
||||
"surfaces": ["managed skill adoption nudge", "focus inventory refresh"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "Deterministic fixtures prove depth, entry, file-count, per-file-byte, and aggregate-byte limits and prove one inaccessible candidate cannot hide healthy siblings.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca"],
|
||||
"invariant": "Always-mounted and focus-triggered skill inventory performs bounded directory and byte work per candidate, and a pathological candidate cannot reject or hide healthy sibling rows.",
|
||||
"oracle": "Drive tiny injected limits across depth, entries, files, single-file bytes, and aggregate bytes; then inventory an oversized and an access-denied candidate beside a healthy official copy and assert stable fail-closed rows plus the healthy sibling.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-package-observation-limits.test.ts src/main/skills/skill-management-inventory.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/skills/skill-package-observation-limits.test.ts",
|
||||
"src/main/skills/skill-management-inventory.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/skills/skill-package-observation-limits.test.ts",
|
||||
"assertions": [
|
||||
"depth, entry, file-count, single-file-byte, and aggregate-byte limits fail with stable categories"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/skills/skill-management-inventory.test.ts",
|
||||
"assertions": [
|
||||
"oversized and access-denied candidates fail closed without hiding healthy siblings"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-package-observation-limits.test.ts src/main/skills/skill-management-inventory.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.183,
|
||||
"summary": "15 deterministic bounded-observation and sibling-isolation tests passed."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": { "p95Seconds": 10, "scope": "local main-process unit tests" },
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "New deterministic filesystem fixtures; soak history has not accumulated."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The limit fixtures fail if their corresponding counter or byte guard is removed; saved CI evidence remains pending."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Production observation is capped at depth 16, 2,048 entries, 512 files, 4 MiB per file, and 32 MiB aggregate per candidate with no polling or subprocesses."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Accumulate the required soak runs across macOS, Linux, and Windows.",
|
||||
"Attach saved red/green evidence for every limit guard."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The deterministic gate runs locally on macOS; Linux and Windows CI evidence is pending."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the gate flakes once without a product or harness issue filed to the owner."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -65,6 +65,14 @@ describe('electron-builder config', () => {
|
||||
})
|
||||
])
|
||||
)
|
||||
for (const platform of ['mac', 'linux', 'win']) {
|
||||
expect(electronBuilderConfig[platform].extraResources).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ from: 'skills', to: 'skills/packages' },
|
||||
{ from: 'resources/skills', to: 'skills' }
|
||||
])
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
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'
|
||||
|
||||
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 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) => Buffer.from(left.name).compare(Buffer.from(right.name)))
|
||||
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 })
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
||||
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}`)
|
||||
}
|
||||
|
||||
const bytes = await readFile(absolutePath)
|
||||
const executable = (fileStat.mode & 0o111) !== 0
|
||||
files.push(describeFile(manifestPath, bytes, executable))
|
||||
}
|
||||
}
|
||||
|
||||
await visit(packageRoot)
|
||||
return files
|
||||
}
|
||||
|
||||
function collectGitPackageFiles(ref, name) {
|
||||
const sourcePrefix = `skills/${name}/`
|
||||
const output = execFileSync('git', ['ls-tree', '-r', '-z', ref, '--', `skills/${name}`])
|
||||
.toString('utf8')
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
const caseFoldedPaths = new Map()
|
||||
return output.map((line) => {
|
||||
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line)
|
||||
if (!match) {
|
||||
throw new Error(`Unexpected git tree entry at ${ref}: ${line}`)
|
||||
}
|
||||
const [, mode, type, objectSha, sourcePath] = match
|
||||
if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) {
|
||||
throw new Error(`Unsupported shipped skill entry at ${ref}: ${line}`)
|
||||
}
|
||||
const manifestPath = sourcePath.slice(sourcePrefix.length)
|
||||
assertSafeRelativePath(manifestPath)
|
||||
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
|
||||
const collision = caseFoldedPaths.get(foldedPath)
|
||||
if (collision && collision !== manifestPath) {
|
||||
throw new Error(`Case-colliding skill paths at ${ref}: ${collision} and ${manifestPath}`)
|
||||
}
|
||||
caseFoldedPaths.set(foldedPath, manifestPath)
|
||||
const bytes = execFileSync('git', ['cat-file', 'blob', objectSha])
|
||||
return describeFile(manifestPath, bytes, mode === '100755')
|
||||
})
|
||||
}
|
||||
|
||||
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 skillsTreeShaAtRef(ref) {
|
||||
try {
|
||||
const output = execFileSync('git', ['ls-tree', ref, 'skills'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}).trim()
|
||||
return output ? output.split(/\s+/)[2] : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function skillNamesAtRef(ref) {
|
||||
try {
|
||||
return execFileSync('git', ['ls-tree', '-d', '--name-only', `${ref}:skills`], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.localeCompare(right, 'en'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function bootstrapReleaseHistory() {
|
||||
const registry = { schemaVersion: SCHEMA_VERSION, skills: {} }
|
||||
const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] }
|
||||
let previousSkillsTreeSha = null
|
||||
|
||||
for (const tag of releaseTags()) {
|
||||
const skillsTreeSha = skillsTreeShaAtRef(tag)
|
||||
// Why: one mapping per shipped package set proves provenance without
|
||||
// repeating hundreds of app releases that shipped identical skill bytes.
|
||||
if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) {
|
||||
continue
|
||||
}
|
||||
previousSkillsTreeSha = skillsTreeSha
|
||||
const revisions = {}
|
||||
for (const name of skillNamesAtRef(tag)) {
|
||||
const filesWithGitHashes = collectGitPackageFiles(tag, name)
|
||||
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 }
|
||||
}
|
||||
|
||||
function packageDigest(files) {
|
||||
const identity = files.map((file) => ({
|
||||
path: file.path,
|
||||
executable: file.executable,
|
||||
classification: file.classification,
|
||||
identitySha256: file.identitySha256
|
||||
}))
|
||||
return sha256(Buffer.from(JSON.stringify(identity), 'utf8'))
|
||||
}
|
||||
|
||||
async function readJsonIfPresent(filePath, fallback) {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'))
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return fallback
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonAtRef(ref, relativePath) {
|
||||
try {
|
||||
return JSON.parse(
|
||||
execFileSync('git', ['show', `${ref}:${relativePath}`], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function assertBaseHistoryPreserved(baseRef, artifacts) {
|
||||
if (!baseRef) {
|
||||
return
|
||||
}
|
||||
const baseRegistry = readJsonAtRef(baseRef, 'resources/skills/snapshot-registry.json')
|
||||
const baseMapping = readJsonAtRef(baseRef, 'resources/skills/release-mapping.json')
|
||||
if (baseRegistry) {
|
||||
for (const [name, snapshots] of Object.entries(baseRegistry.skills ?? {})) {
|
||||
const current = artifacts.snapshotRegistry.skills[name] ?? []
|
||||
if (JSON.stringify(current.slice(0, snapshots.length)) !== JSON.stringify(snapshots)) {
|
||||
throw new Error(`Skill snapshot history for ${name} differs from immutable base ${baseRef}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
baseMapping &&
|
||||
JSON.stringify(artifacts.releaseMapping.releases.slice(0, baseMapping.releases.length)) !==
|
||||
JSON.stringify(baseMapping.releases)
|
||||
) {
|
||||
throw new Error(`Skill release mapping differs from immutable base ${baseRef}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildArtifacts({ appVersion, recordRelease, bootstrapHistory = false }) {
|
||||
const history = bootstrapHistory ? bootstrapReleaseHistory() : null
|
||||
const existingRegistry =
|
||||
history?.registry ??
|
||||
(await readJsonIfPresent(SNAPSHOT_REGISTRY_PATH, {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
skills: {}
|
||||
}))
|
||||
if (existingRegistry.schemaVersion !== SCHEMA_VERSION) {
|
||||
throw new Error(`Unsupported skill snapshot registry schema: ${existingRegistry.schemaVersion}`)
|
||||
}
|
||||
|
||||
const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort((left, right) => left.localeCompare(right, 'en'))
|
||||
const currentSkills = []
|
||||
const nextRegistry = structuredClone(existingRegistry)
|
||||
|
||||
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 existingSnapshots = nextRegistry.skills[name] ?? []
|
||||
const latest = existingSnapshots.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
|
||||
}
|
||||
existingSnapshots.push(snapshot)
|
||||
nextRegistry.skills[name] = existingSnapshots
|
||||
}
|
||||
const currentFiles = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
|
||||
currentSkills.push({
|
||||
name,
|
||||
sourcePath: `skills/${name}`,
|
||||
appVersion,
|
||||
releaseRevision: snapshot.releaseRevision,
|
||||
packageDigest: digest,
|
||||
gitTreeSha: gitTreeSha(filesWithGitHashes),
|
||||
files: currentFiles
|
||||
})
|
||||
}
|
||||
|
||||
const currentManifest = {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
appVersion,
|
||||
skills: currentSkills
|
||||
}
|
||||
const existingMapping =
|
||||
history?.mapping ??
|
||||
(await readJsonIfPresent(RELEASE_MAPPING_PATH, {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
releases: []
|
||||
}))
|
||||
if (existingMapping.schemaVersion !== SCHEMA_VERSION) {
|
||||
throw new Error(`Unsupported skill release mapping schema: ${existingMapping.schemaVersion}`)
|
||||
}
|
||||
const releaseMapping = structuredClone(existingMapping)
|
||||
if (recordRelease) {
|
||||
const revisions = Object.fromEntries(
|
||||
currentSkills.map((skill) => [skill.name, skill.releaseRevision])
|
||||
)
|
||||
const prior = releaseMapping.releases.find((release) => release.appVersion === appVersion)
|
||||
if (prior && JSON.stringify(prior.skills) !== JSON.stringify(revisions)) {
|
||||
throw new Error(`Release mapping for ${appVersion} is immutable`)
|
||||
}
|
||||
if (!prior) {
|
||||
releaseMapping.releases.push({ appVersion, skills: revisions })
|
||||
}
|
||||
}
|
||||
|
||||
return { currentManifest, snapshotRegistry: nextRegistry, releaseMapping }
|
||||
}
|
||||
|
||||
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 write = process.argv.includes('--write')
|
||||
const recordRelease = process.argv.includes('--record-release')
|
||||
const bootstrapHistory = process.argv.includes('--bootstrap-release-history')
|
||||
const baseRef = process.argv
|
||||
.find((arg) => arg.startsWith('--base-ref='))
|
||||
?.slice('--base-ref='.length)
|
||||
const artifacts = await buildArtifacts({
|
||||
appVersion: packageJson.version,
|
||||
recordRelease,
|
||||
bootstrapHistory
|
||||
})
|
||||
assertBaseHistoryPreserved(baseRef, artifacts)
|
||||
await (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 {
|
||||
buildArtifacts,
|
||||
classifyFile,
|
||||
collectPackageFiles,
|
||||
gitTreeSha,
|
||||
normalizeText,
|
||||
packageDigest
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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 {
|
||||
classifyFile,
|
||||
collectPackageFiles,
|
||||
gitTreeSha,
|
||||
normalizeText,
|
||||
packageDigest
|
||||
} 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', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await writeFile(path.join(packageRoot, 'SKILL.md'), 'line one\r\nline two\r\n')
|
||||
await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n')
|
||||
await chmod(path.join(packageRoot, 'run.sh'), 0o755)
|
||||
|
||||
const files = await collectPackageFiles(packageRoot)
|
||||
const skillFile = files.find((file) => file.path === 'SKILL.md')
|
||||
const executable = files.find((file) => file.path === 'run.sh')
|
||||
|
||||
expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256)
|
||||
expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256)
|
||||
expect(executable.identitySha256).toBe(executable.exactSha256)
|
||||
expect(packageDigest(files)).toMatch(/^[a-f0-9]{64}$/)
|
||||
})
|
||||
|
||||
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('includes nested directory names in Git tree identity', async () => {
|
||||
const packageRoot = await createPackage()
|
||||
await mkdir(path.join(packageRoot, 'assets'))
|
||||
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n')
|
||||
await writeFile(path.join(packageRoot, 'assets', 'prompt.txt'), 'prompt\n')
|
||||
|
||||
const files = await collectPackageFiles(packageRoot)
|
||||
expect(gitTreeSha(files)).toMatch(/^[a-f0-9]{40}$/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmod, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs'
|
||||
|
||||
const cliVersion = process.argv.find((value) => value.startsWith('--cli='))?.slice('--cli='.length)
|
||||
const autocrlf = process.argv
|
||||
.find((value) => value.startsWith('--autocrlf='))
|
||||
?.slice('--autocrlf='.length)
|
||||
const shape = process.argv.find((value) => value.startsWith('--shape='))?.slice('--shape='.length)
|
||||
if (
|
||||
!cliVersion ||
|
||||
(autocrlf !== 'true' && autocrlf !== 'false') ||
|
||||
(shape !== 'symlink' && shape !== 'copy')
|
||||
) {
|
||||
throw new Error(
|
||||
'Usage: verify-skill-cli-roundtrip.mjs --cli=<version> --autocrlf=true|false --shape=symlink|copy'
|
||||
)
|
||||
}
|
||||
|
||||
const sandbox = await mkdtemp(join(tmpdir(), 'orca-skill-cli-roundtrip-'))
|
||||
const home = join(sandbox, 'home')
|
||||
const source = join(sandbox, 'source')
|
||||
const manifest = JSON.parse(
|
||||
await readFile(resolve('resources/skills/current-manifest.json'), 'utf8')
|
||||
)
|
||||
const manifestByName = new Map(manifest.skills.map((skill) => [skill.name, skill]))
|
||||
const fixtureName = 'orca-roundtrip-fixture'
|
||||
const skillNames = [...manifest.skills.map((skill) => skill.name), fixtureName]
|
||||
|
||||
async function createRepresentativeFixture() {
|
||||
const fixtureRoot = join(source, 'skills', fixtureName)
|
||||
const executablePath = join(fixtureRoot, 'scripts', 'check.sh')
|
||||
await mkdir(join(fixtureRoot, 'assets'), { recursive: true })
|
||||
await mkdir(join(fixtureRoot, 'scripts'), { recursive: true })
|
||||
await writeFile(
|
||||
join(fixtureRoot, 'SKILL.md'),
|
||||
`---\nname: ${fixtureName}\ndescription: Exercises real multi-file CLI installation.\n---\n\n# Round-trip fixture\n`
|
||||
)
|
||||
await writeFile(join(fixtureRoot, 'assets', 'guide.txt'), 'nested text asset\nsecond line\n')
|
||||
await writeFile(join(fixtureRoot, 'assets', 'payload.bin'), Buffer.from([0, 1, 2, 13, 10, 255]))
|
||||
await writeFile(executablePath, '#!/bin/sh\nprintf fixture\\n\n')
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(executablePath, 0o755)
|
||||
}
|
||||
const files = await collectPackageFiles(fixtureRoot)
|
||||
manifestByName.set(fixtureName, { name: fixtureName, packageDigest: packageDigest(files) })
|
||||
}
|
||||
|
||||
async function verifyPackage(name, packageRoot) {
|
||||
const expected = manifestByName.get(name)
|
||||
if (!expected) {
|
||||
throw new Error(`Missing current manifest entry for ${name}`)
|
||||
}
|
||||
const files = await collectPackageFiles(packageRoot)
|
||||
const digest = packageDigest(files)
|
||||
if (digest !== expected.packageDigest) {
|
||||
throw new Error(
|
||||
`${name} identity mismatch with core.autocrlf=${autocrlf}: ${digest} != ${expected.packageDigest}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
`core.autocrlf=${autocrlf}`,
|
||||
'clone',
|
||||
'--quiet',
|
||||
'--no-hardlinks',
|
||||
process.cwd(),
|
||||
source
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
// Why: shipped packages are currently single-file, so this temporary source
|
||||
// package keeps nested, binary, and POSIX executable semantics in the real CLI oracle.
|
||||
await createRepresentativeFixture()
|
||||
execFileSync(
|
||||
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
[
|
||||
'--yes',
|
||||
`skills@${cliVersion}`,
|
||||
'add',
|
||||
source,
|
||||
'--skill',
|
||||
...skillNames,
|
||||
'--agent',
|
||||
'codex',
|
||||
'claude-code',
|
||||
'--global',
|
||||
'--yes',
|
||||
...(shape === 'copy' ? ['--copy'] : [])
|
||||
],
|
||||
{
|
||||
cwd: source,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
XDG_STATE_HOME: join(home, '.state'),
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'core.autocrlf',
|
||||
GIT_CONFIG_VALUE_0: autocrlf
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
|
||||
for (const name of skillNames) {
|
||||
const canonical = join(home, '.agents', 'skills', name)
|
||||
const canonicalStat = await lstat(canonical)
|
||||
if (!canonicalStat.isDirectory() || canonicalStat.isSymbolicLink()) {
|
||||
throw new Error(`${name} canonical CLI placement is not a real directory`)
|
||||
}
|
||||
await verifyPackage(name, canonical)
|
||||
|
||||
const provider = join(home, '.claude', 'skills', name)
|
||||
const providerStat = await lstat(provider)
|
||||
if (shape === 'symlink' && !providerStat.isSymbolicLink()) {
|
||||
throw new Error(`${name} provider CLI placement is not a symlink or junction`)
|
||||
}
|
||||
if (shape === 'copy' && (!providerStat.isDirectory() || providerStat.isSymbolicLink())) {
|
||||
throw new Error(`${name} provider CLI placement is not a copy`)
|
||||
}
|
||||
await verifyPackage(name, await realpath(provider))
|
||||
}
|
||||
} finally {
|
||||
await rm(sandbox, { recursive: true, force: true })
|
||||
}
|
||||
+5
-1
@@ -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: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: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",
|
||||
@@ -21,6 +21,10 @@
|
||||
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
|
||||
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
|
||||
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
|
||||
"generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write",
|
||||
"bootstrap:skill-release-history": "node config/scripts/generate-skill-bundle-manifest.mjs --write --record-release --bootstrap-release-history",
|
||||
"record-release:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write --record-release",
|
||||
"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.138-rc.2",
|
||||
"skills": [
|
||||
{
|
||||
"name": "computer-use",
|
||||
"sourcePath": "skills/computer-use",
|
||||
"appVersion": "1.4.138-rc.2",
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "35b6d91d1d3d38f3828c60e5b856a91324bdd09273db33664f508392c5c48dfc",
|
||||
"gitTreeSha": "b4e2d7e7af8f8f29c6d0df6e92f23c1b2c439e3b",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 10625,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b",
|
||||
"textNormalizedSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b",
|
||||
"identitySha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "linear-tickets",
|
||||
"sourcePath": "skills/linear-tickets",
|
||||
"appVersion": "1.4.138-rc.2",
|
||||
"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.138-rc.2",
|
||||
"releaseRevision": 31,
|
||||
"packageDigest": "c52a999a35f8e96ec72c97a537ce5906ef098879495382d85f50c70a888ba901",
|
||||
"gitTreeSha": "77be03ea57ddbc4c4067bca22ab58c3af5ae3d2c",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 20862,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14",
|
||||
"textNormalizedSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14",
|
||||
"identitySha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "orca-emulator",
|
||||
"sourcePath": "skills/orca-emulator",
|
||||
"appVersion": "1.4.138-rc.2",
|
||||
"releaseRevision": 3,
|
||||
"packageDigest": "1807d287d2c08b1c535ba4e14ca2f29afe39274c4bde5737995a1792e44d5412",
|
||||
"gitTreeSha": "dbb8e0daba768527885715ee5efebeaa1ee38f0f",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 10853,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834",
|
||||
"textNormalizedSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834",
|
||||
"identitySha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "orca-emulator-android",
|
||||
"sourcePath": "skills/orca-emulator-android",
|
||||
"appVersion": "1.4.138-rc.2",
|
||||
"releaseRevision": 1,
|
||||
"packageDigest": "cf86dd085b981febd8e4e11b7796c90d69a60bd9c1dfaab5a16b99f77841aaa4",
|
||||
"gitTreeSha": "6299122f69f0218ec01214e2a14c479b1811cdf6",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 8189,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844",
|
||||
"textNormalizedSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844",
|
||||
"identitySha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "orca-linear",
|
||||
"sourcePath": "skills/orca-linear",
|
||||
"appVersion": "1.4.138-rc.2",
|
||||
"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.138-rc.2",
|
||||
"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.138-rc.2",
|
||||
"releaseRevision": 23,
|
||||
"packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b",
|
||||
"gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 22850,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
|
||||
"textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
|
||||
"identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
{
|
||||
"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.2",
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+235
-12
@@ -1,13 +1,25 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
|
||||
const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock } = vi.hoisted(
|
||||
() => ({
|
||||
handleMock: vi.fn(),
|
||||
discoverSkillsMock: vi.fn(),
|
||||
getDefaultWslDistroMock: vi.fn(),
|
||||
getWslHomeMock: vi.fn()
|
||||
})
|
||||
)
|
||||
const {
|
||||
handleMock,
|
||||
discoverSkillsMock,
|
||||
getDefaultWslDistroMock,
|
||||
getWslHomeMock,
|
||||
inventoryManagedSkillsMock,
|
||||
adoptExactSkillSnapshotMock,
|
||||
updateManagedSkillMock,
|
||||
statMock
|
||||
} = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
discoverSkillsMock: vi.fn(),
|
||||
getDefaultWslDistroMock: vi.fn(),
|
||||
getWslHomeMock: vi.fn(),
|
||||
inventoryManagedSkillsMock: vi.fn(),
|
||||
adoptExactSkillSnapshotMock: vi.fn(),
|
||||
updateManagedSkillMock: vi.fn(),
|
||||
statMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
@@ -24,13 +36,39 @@ vi.mock('../wsl', () => ({
|
||||
getWslHome: getWslHomeMock
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async () => ({
|
||||
...(await vi.importActual<typeof FsPromises>('node:fs/promises')),
|
||||
stat: statMock
|
||||
}))
|
||||
|
||||
vi.mock('../skills/skill-management-inventory', () => ({
|
||||
inventoryManagedSkills: inventoryManagedSkillsMock
|
||||
}))
|
||||
|
||||
vi.mock('../skills/skill-adoption', () => ({
|
||||
adoptExactSkillSnapshot: adoptExactSkillSnapshotMock,
|
||||
skillAdoptionDismissal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../skills/skill-managed-update', () => ({
|
||||
updateManagedSkill: updateManagedSkillMock
|
||||
}))
|
||||
|
||||
vi.mock('../skills/skill-explicit-replacement', () => ({
|
||||
previewExplicitSkillReplacement: vi.fn(),
|
||||
replaceSkillWithCurrentBundle: vi.fn()
|
||||
}))
|
||||
|
||||
import { registerSkillsHandlers } from './skills'
|
||||
|
||||
describe('registerSkillsHandlers', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const repos = [{ id: 'repo-1', path: 'C:\\Users\\alice\\repo' }]
|
||||
const store = {
|
||||
getRepos: vi.fn(() => repos)
|
||||
getRepos: vi.fn(() => repos),
|
||||
getSkillManagementLedger: vi.fn(),
|
||||
setManagedSkillDestination: vi.fn(),
|
||||
dismissSkillAdoptionCandidate: vi.fn()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -38,6 +76,18 @@ describe('registerSkillsHandlers', () => {
|
||||
discoverSkillsMock.mockReset()
|
||||
getDefaultWslDistroMock.mockReset()
|
||||
getWslHomeMock.mockReset()
|
||||
inventoryManagedSkillsMock.mockReset()
|
||||
adoptExactSkillSnapshotMock.mockReset()
|
||||
updateManagedSkillMock.mockReset()
|
||||
statMock.mockReset()
|
||||
store.getSkillManagementLedger.mockReset()
|
||||
store.setManagedSkillDestination.mockReset()
|
||||
store.dismissSkillAdoptionCandidate.mockReset()
|
||||
store.getSkillManagementLedger.mockReturnValue({
|
||||
schemaVersion: 1,
|
||||
destinations: {},
|
||||
dismissedAdoptionCandidates: []
|
||||
})
|
||||
discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 })
|
||||
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice')
|
||||
Object.defineProperty(process, 'platform', {
|
||||
@@ -53,10 +103,22 @@ describe('registerSkillsHandlers', () => {
|
||||
})
|
||||
|
||||
function getDiscoverHandler() {
|
||||
registerSkillsHandlers(store as never)
|
||||
const call = handleMock.mock.calls.find((entry: unknown[]) => entry[0] === 'skills:discover')
|
||||
return getHandler('skills:discover')
|
||||
}
|
||||
|
||||
function getHandler(
|
||||
channel: string,
|
||||
runtimeFacts: NonNullable<Parameters<typeof registerSkillsHandlers>[1]> = {
|
||||
isPackaged: true,
|
||||
buildIdentity: 'stable',
|
||||
userDataDir: '/isolated/user-data',
|
||||
temporaryRoot: '/isolated'
|
||||
}
|
||||
) {
|
||||
registerSkillsHandlers(store as never, runtimeFacts)
|
||||
const call = handleMock.mock.calls.find((entry: unknown[]) => entry[0] === channel)
|
||||
if (!call) {
|
||||
throw new Error('skills:discover handler was not registered')
|
||||
throw new Error(`${channel} handler was not registered`)
|
||||
}
|
||||
return call[1] as (_event: unknown, target?: unknown) => Promise<unknown>
|
||||
}
|
||||
@@ -136,4 +198,165 @@ describe('registerSkillsHandlers', () => {
|
||||
).rejects.toThrow('Project runtime requires repair before skill discovery')
|
||||
expect(discoverSkillsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects WSL skill mutations before touching a distro path', async () => {
|
||||
const handler = getHandler('skills:adopt')
|
||||
|
||||
await expect(
|
||||
handler(null, {
|
||||
installationId: 'candidate',
|
||||
target: { runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
})
|
||||
).rejects.toThrow('Skill management actions are not available on this host yet')
|
||||
|
||||
expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects remote execution-host discovery instead of scanning local paths', async () => {
|
||||
const handler = getDiscoverHandler()
|
||||
|
||||
await expect(
|
||||
handler(null, { executionHostId: 'ssh:production', cwd: '/srv/repo' })
|
||||
).rejects.toThrow('Skill management is not available for remote execution hosts in Phase 1')
|
||||
|
||||
expect(discoverSkillsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects remote execution-host mutations before inventory or filesystem work', async () => {
|
||||
const handler = getHandler('skills:adopt')
|
||||
|
||||
await expect(
|
||||
handler(null, {
|
||||
installationId: 'candidate',
|
||||
target: { executionHostId: 'runtime:environment-1' }
|
||||
})
|
||||
).rejects.toThrow('Skill management is not available for remote execution hosts in Phase 1')
|
||||
|
||||
expect(inventoryManagedSkillsMock).not.toHaveBeenCalled()
|
||||
expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unpackaged mutations against the real home', async () => {
|
||||
const handler = getHandler('skills:adopt', {
|
||||
isPackaged: false,
|
||||
buildIdentity: null,
|
||||
userDataDir: '/real/user-data',
|
||||
temporaryRoot: '/tmp'
|
||||
})
|
||||
|
||||
await expect(handler(null, { installationId: 'candidate' })).rejects.toThrow(
|
||||
'official stable build or isolated dev roots'
|
||||
)
|
||||
expect(inventoryManagedSkillsMock).not.toHaveBeenCalled()
|
||||
expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists the exact adoption tuple supplied by the displayed candidate', async () => {
|
||||
inventoryManagedSkillsMock.mockResolvedValue({
|
||||
schemaVersion: 1,
|
||||
hostId: 'local',
|
||||
installations: [],
|
||||
adoptionCandidateCount: 0,
|
||||
scannedAt: 1
|
||||
})
|
||||
const handler = getHandler('skills:dismissAdoption')
|
||||
const candidate = {
|
||||
hostId: 'local',
|
||||
physicalIdentity: '1:2',
|
||||
skillName: 'orca-cli',
|
||||
snapshotDigest: 'a'.repeat(64)
|
||||
}
|
||||
|
||||
await handler(null, { candidate })
|
||||
|
||||
expect(store.dismissSkillAdoptionCandidate).toHaveBeenCalledWith({
|
||||
...candidate,
|
||||
dismissedAt: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not claim a canonical install that predates the terminal attempt', async () => {
|
||||
const startedAt = Date.now() - 1_000
|
||||
const inventory = {
|
||||
schemaVersion: 1,
|
||||
hostId: 'local',
|
||||
installations: [
|
||||
{
|
||||
id: 'candidate',
|
||||
name: 'orca-cli',
|
||||
rootId: 'home-agents',
|
||||
unresolvedPath: '/home/alice/.agents/skills/orca-cli',
|
||||
eligible: true,
|
||||
managed: false
|
||||
}
|
||||
],
|
||||
adoptionCandidateCount: 1,
|
||||
scannedAt: 1
|
||||
}
|
||||
inventoryManagedSkillsMock.mockResolvedValue(inventory)
|
||||
statMock.mockResolvedValue({ mtimeMs: startedAt - 10_000 })
|
||||
const handler = getHandler('skills:recordOrcaInstall')
|
||||
|
||||
await handler(null, { skillNames: ['orca-cli'], startedAt })
|
||||
|
||||
expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
|
||||
expect(store.setManagedSkillDestination).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects install ownership without a valid terminal start time', async () => {
|
||||
const handler = getHandler('skills:recordOrcaInstall')
|
||||
|
||||
await expect(handler(null, { skillNames: ['orca-cli'], startedAt: 0 })).rejects.toThrow(
|
||||
'Invalid skill installation start time'
|
||||
)
|
||||
|
||||
expect(inventoryManagedSkillsMock).not.toHaveBeenCalled()
|
||||
expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not claim an install whose marker mtime is implausibly in the future', async () => {
|
||||
inventoryManagedSkillsMock.mockResolvedValue({
|
||||
schemaVersion: 1,
|
||||
hostId: 'local',
|
||||
installations: [
|
||||
{
|
||||
id: 'candidate',
|
||||
name: 'orca-cli',
|
||||
rootId: 'home-agents',
|
||||
unresolvedPath: '/home/alice/.agents/skills/orca-cli',
|
||||
eligible: true,
|
||||
managed: false
|
||||
}
|
||||
],
|
||||
adoptionCandidateCount: 1,
|
||||
scannedAt: 1
|
||||
})
|
||||
statMock.mockResolvedValue({ mtimeMs: Date.now() + 60_000 })
|
||||
const handler = getHandler('skills:recordOrcaInstall')
|
||||
|
||||
await handler(null, { skillNames: ['orca-cli'], startedAt: Date.now() - 1_000 })
|
||||
|
||||
expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists a failed managed update for retry diagnostics', async () => {
|
||||
const prior = { id: 'managed', hostId: 'local', lastOutcome: 'adopted' }
|
||||
store.getSkillManagementLedger.mockReturnValue({
|
||||
schemaVersion: 1,
|
||||
destinations: { managed: prior },
|
||||
dismissedAdoptionCandidates: []
|
||||
})
|
||||
updateManagedSkillMock.mockRejectedValue(Object.assign(new Error('busy'), { code: 'EBUSY' }))
|
||||
const handler = getHandler('skills:updateManaged')
|
||||
|
||||
await expect(handler(null, { installationId: 'managed' })).rejects.toThrow('busy')
|
||||
|
||||
expect(store.setManagedSkillDestination).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'managed',
|
||||
lastOutcome: 'failed',
|
||||
lastErrorCategory: 'filesystem-ebusy'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+265
-2
@@ -1,16 +1,60 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Store } from '../persistence'
|
||||
import { discoverSkills } from '../skills/discovery'
|
||||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills'
|
||||
import type {
|
||||
OrcaSkillInstallResultArgs,
|
||||
SkillAdoptionDismissalArgs,
|
||||
SkillManagementActionArgs,
|
||||
SkillManagementInventory,
|
||||
SkillReplacementPreview
|
||||
} from '../../shared/skill-management'
|
||||
import { getDefaultWslDistro, getWslHome } from '../wsl'
|
||||
import { LOCAL_EXECUTION_HOST_ID, toWslExecutionHostId } from '../../shared/execution-host'
|
||||
import { inventoryManagedSkills } from '../skills/skill-management-inventory'
|
||||
import { adoptExactSkillSnapshot } from '../skills/skill-adoption'
|
||||
import {
|
||||
previewExplicitSkillReplacement,
|
||||
replaceSkillWithCurrentBundle
|
||||
} from '../skills/skill-explicit-replacement'
|
||||
import { updateManagedSkill } from '../skills/skill-managed-update'
|
||||
import { createSkillAdoptionDismissal } from '../skills/skill-adoption-dismissal'
|
||||
import { buildSkillDiscoverySources } from '../skills/skill-discovery-sources'
|
||||
import { sweepOrphanedSkillTransactions } from '../skills/skill-orphan-transaction-sweep'
|
||||
import { resolveDiagnosticBuildIdentity } from '../observability/diagnostic-upload-endpoint'
|
||||
import {
|
||||
canMutateManagedSkills,
|
||||
type SkillMutationRuntimeFacts
|
||||
} from '../skills/skill-mutation-authority'
|
||||
|
||||
type SkillDiscoveryRuntimeTarget =
|
||||
| { runtime: 'host' }
|
||||
| { runtime: 'wsl'; wslDistro: string | null | undefined }
|
||||
|
||||
const MAX_ORCA_SKILL_INSTALL_ATTEMPT_MS = 15 * 60 * 1_000
|
||||
|
||||
type SkillHandlerRuntimeFacts = Omit<SkillMutationRuntimeFacts, 'homeDir'>
|
||||
|
||||
function productionSkillHandlerRuntimeFacts(): SkillHandlerRuntimeFacts {
|
||||
return {
|
||||
isPackaged: app.isPackaged,
|
||||
buildIdentity: resolveDiagnosticBuildIdentity(),
|
||||
userDataDir: app.getPath('userData'),
|
||||
isolatedDevHomeDir: process.env.ORCA_E2E_SKILL_HOME_DIR,
|
||||
isolatedDevUserDataDir: process.env.ORCA_E2E_USER_DATA_DIR,
|
||||
temporaryRoot: tmpdir()
|
||||
}
|
||||
}
|
||||
|
||||
function getSkillDiscoveryRuntimeTarget(
|
||||
target: SkillDiscoveryTarget | undefined
|
||||
): SkillDiscoveryRuntimeTarget {
|
||||
if (target?.executionHostId && target.executionHostId !== LOCAL_EXECUTION_HOST_ID) {
|
||||
throw new Error('Skill management is not available for remote execution hosts in Phase 1.')
|
||||
}
|
||||
const projectRuntime = target?.projectRuntime
|
||||
if (!projectRuntime) {
|
||||
return target?.runtime === 'wsl'
|
||||
@@ -31,7 +75,75 @@ function getSkillDiscoveryRuntimeTarget(
|
||||
return { runtime: 'host' }
|
||||
}
|
||||
|
||||
export function registerSkillsHandlers(store: Store): void {
|
||||
function getSkillManagementHostContext(target: SkillDiscoveryTarget | undefined): {
|
||||
hostId: ReturnType<typeof toWslExecutionHostId> | typeof LOCAL_EXECUTION_HOST_ID
|
||||
homeDir: string
|
||||
} {
|
||||
const runtimeTarget = getSkillDiscoveryRuntimeTarget(target)
|
||||
if (runtimeTarget.runtime === 'host') {
|
||||
return { hostId: LOCAL_EXECUTION_HOST_ID, homeDir: homedir() }
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('WSL skill management is only available on Windows.')
|
||||
}
|
||||
const distro = runtimeTarget.wslDistro?.trim() || getDefaultWslDistro()
|
||||
if (!distro) {
|
||||
throw new Error('No WSL distribution is available for skill management.')
|
||||
}
|
||||
const homeDir = getWslHome(distro)
|
||||
if (!homeDir) {
|
||||
throw new Error(`Could not resolve the WSL home directory for ${distro}.`)
|
||||
}
|
||||
return { hostId: toWslExecutionHostId(distro), homeDir }
|
||||
}
|
||||
|
||||
function requireSkillMutation(
|
||||
context: { hostId: string; homeDir: string },
|
||||
runtimeFacts: SkillHandlerRuntimeFacts
|
||||
): void {
|
||||
if (context.hostId !== LOCAL_EXECUTION_HOST_ID) {
|
||||
// Why: WSL publication belongs inside the distro runtime; UNC mutation
|
||||
// would apply Windows lock and mode semantics to Linux-owned files.
|
||||
throw new Error('Skill management actions are not available on this host yet.')
|
||||
}
|
||||
if (!canMutateManagedSkills({ ...runtimeFacts, homeDir: context.homeDir })) {
|
||||
throw new Error(
|
||||
'Skill management actions require an official stable build or isolated dev roots.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverSkillTransactionsForExplicitAction(
|
||||
context: { hostId: string; homeDir: string },
|
||||
store: Store,
|
||||
runtimeFacts: SkillHandlerRuntimeFacts
|
||||
): Promise<void> {
|
||||
requireSkillMutation(context, runtimeFacts)
|
||||
const roots = buildSkillDiscoverySources({
|
||||
homeDir: context.homeDir,
|
||||
cwd: context.homeDir
|
||||
}).filter((root) => root.sourceKind === 'home')
|
||||
// Why: orphan recovery can write, so it runs only inside an explicit
|
||||
// foreground management action, never inventory or the App-mounted nudge.
|
||||
await Promise.all(
|
||||
roots.map((root) => sweepOrphanedSkillTransactions(root.path, store.getSkillManagementLedger()))
|
||||
)
|
||||
}
|
||||
|
||||
function skillFailureCategory(error: unknown): string {
|
||||
if (error instanceof Error && /^skill-[a-z-]+$/.test(error.message)) {
|
||||
return error.message
|
||||
}
|
||||
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') {
|
||||
return `filesystem-${error.code.toLowerCase()}`
|
||||
}
|
||||
return 'unknown-error'
|
||||
}
|
||||
|
||||
export function registerSkillsHandlers(
|
||||
store: Store,
|
||||
runtimeFacts: SkillHandlerRuntimeFacts = productionSkillHandlerRuntimeFacts()
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
'skills:discover',
|
||||
async (_event, target?: SkillDiscoveryTarget): Promise<SkillDiscoveryResult> => {
|
||||
@@ -55,4 +167,155 @@ export function registerSkillsHandlers(store: Store): void {
|
||||
return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() })
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'skills:managementInventory',
|
||||
async (_event, target?: SkillDiscoveryTarget): Promise<SkillManagementInventory> => {
|
||||
const context = getSkillManagementHostContext(target)
|
||||
return inventoryManagedSkills({
|
||||
...context,
|
||||
ledger: store.getSkillManagementLedger(),
|
||||
actionsSupported: canMutateManagedSkills({ ...runtimeFacts, homeDir: context.homeDir })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'skills:adopt',
|
||||
async (_event, args: SkillManagementActionArgs): Promise<SkillManagementInventory> => {
|
||||
const context = getSkillManagementHostContext(args.target)
|
||||
await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts)
|
||||
await adoptExactSkillSnapshot({
|
||||
...context,
|
||||
ledger: store.getSkillManagementLedger(),
|
||||
installationId: args.installationId,
|
||||
commit: (next) => store.setManagedSkillDestination(next)
|
||||
})
|
||||
return inventoryManagedSkills({ ...context, ledger: store.getSkillManagementLedger() })
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'skills:dismissAdoption',
|
||||
async (_event, args: SkillAdoptionDismissalArgs): Promise<SkillManagementInventory> => {
|
||||
const context = getSkillManagementHostContext(args.target)
|
||||
requireSkillMutation(context, runtimeFacts)
|
||||
store.dismissSkillAdoptionCandidate(
|
||||
createSkillAdoptionDismissal(args?.candidate, context.hostId)
|
||||
)
|
||||
return inventoryManagedSkills({ ...context, ledger: store.getSkillManagementLedger() })
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'skills:previewReplacement',
|
||||
async (_event, args: SkillManagementActionArgs): Promise<SkillReplacementPreview> => {
|
||||
const context = getSkillManagementHostContext(args.target)
|
||||
return previewExplicitSkillReplacement({
|
||||
...context,
|
||||
ledger: store.getSkillManagementLedger(),
|
||||
installationId: args.installationId
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'skills:replace',
|
||||
async (_event, args: SkillManagementActionArgs): Promise<SkillManagementInventory> => {
|
||||
const context = getSkillManagementHostContext(args.target)
|
||||
await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts)
|
||||
await replaceSkillWithCurrentBundle({
|
||||
...context,
|
||||
ledger: store.getSkillManagementLedger(),
|
||||
installationId: args.installationId,
|
||||
commit: (next) => store.setManagedSkillDestination(next)
|
||||
})
|
||||
return inventoryManagedSkills({ ...context, ledger: store.getSkillManagementLedger() })
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'skills:updateManaged',
|
||||
async (_event, args: SkillManagementActionArgs): Promise<SkillManagementInventory> => {
|
||||
const context = getSkillManagementHostContext(args.target)
|
||||
await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts)
|
||||
const ledger = store.getSkillManagementLedger()
|
||||
try {
|
||||
await updateManagedSkill({
|
||||
...context,
|
||||
ledger,
|
||||
installationId: args.installationId,
|
||||
commit: (next) => store.setManagedSkillDestination(next)
|
||||
})
|
||||
} catch (error) {
|
||||
const prior = ledger.destinations[args.installationId]
|
||||
if (prior) {
|
||||
store.setManagedSkillDestination({
|
||||
...prior,
|
||||
lastOutcome: 'failed',
|
||||
lastErrorCategory: skillFailureCategory(error),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return inventoryManagedSkills({ ...context, ledger: store.getSkillManagementLedger() })
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'skills:recordOrcaInstall',
|
||||
async (_event, args: OrcaSkillInstallResultArgs): Promise<SkillManagementInventory> => {
|
||||
const context = getSkillManagementHostContext(args.target)
|
||||
const completedAt = Date.now()
|
||||
if (
|
||||
!Number.isFinite(args.startedAt) ||
|
||||
args.startedAt <= 0 ||
|
||||
args.startedAt > completedAt ||
|
||||
completedAt - args.startedAt > MAX_ORCA_SKILL_INSTALL_ATTEMPT_MS
|
||||
) {
|
||||
throw new Error('Invalid skill installation start time.')
|
||||
}
|
||||
await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts)
|
||||
let inventory = await inventoryManagedSkills({
|
||||
...context,
|
||||
ledger: store.getSkillManagementLedger()
|
||||
})
|
||||
for (const skillName of new Set(args.skillNames)) {
|
||||
// Why: the global skills CLI owns the canonical .agents placement;
|
||||
// provider-specific copies discovered beside it were not necessarily
|
||||
// created by this terminal run and must retain separate consent.
|
||||
const installation = inventory.installations.find(
|
||||
(entry) =>
|
||||
entry.name === skillName &&
|
||||
entry.rootId === 'home-agents' &&
|
||||
entry.eligible &&
|
||||
!entry.managed
|
||||
)
|
||||
if (!installation) {
|
||||
continue
|
||||
}
|
||||
const modifiedAt = await stat(join(installation.unresolvedPath, 'SKILL.md'))
|
||||
.then((file) => file.mtimeMs)
|
||||
.catch(() => 0)
|
||||
// Why: exact content proves provenance, but only an mtime from this
|
||||
// terminal window attributes ownership to Orca's install attempt.
|
||||
if (modifiedAt + 2_000 < args.startedAt || modifiedAt > completedAt + 2_000) {
|
||||
continue
|
||||
}
|
||||
await adoptExactSkillSnapshot({
|
||||
...context,
|
||||
ledger: store.getSkillManagementLedger(),
|
||||
installationId: installation.id,
|
||||
commit: (next) =>
|
||||
store.setManagedSkillDestination({ ...next, adoptedFrom: 'orca-install' })
|
||||
})
|
||||
inventory = await inventoryManagedSkills({
|
||||
...context,
|
||||
ledger: store.getSkillManagementLedger()
|
||||
})
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6711,6 +6711,48 @@ describe('Store', () => {
|
||||
expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false)
|
||||
})
|
||||
|
||||
it('durably persists managed skill ownership before returning', async () => {
|
||||
const store = await createStore()
|
||||
const destination = {
|
||||
id: 'local-skill',
|
||||
hostId: 'local' as const,
|
||||
skillName: 'orca-cli',
|
||||
unresolvedPath: '/home/alice/.agents/skills/orca-cli',
|
||||
installedPackageDigest: 'a'.repeat(64)
|
||||
}
|
||||
|
||||
store.setManagedSkillDestination(destination as never)
|
||||
|
||||
expect(
|
||||
(
|
||||
readDataFile() as {
|
||||
skillManagementLedger: { destinations: Record<string, unknown> }
|
||||
}
|
||||
).skillManagementLedger.destinations['local-skill']
|
||||
).toMatchObject(destination)
|
||||
})
|
||||
|
||||
it('durably persists an exact skill adoption dismissal before returning', async () => {
|
||||
const store = await createStore()
|
||||
const candidate = {
|
||||
hostId: 'local' as const,
|
||||
physicalIdentity: '1:2',
|
||||
skillName: 'orca-cli',
|
||||
snapshotDigest: 'a'.repeat(64),
|
||||
dismissedAt: 1
|
||||
}
|
||||
|
||||
store.dismissSkillAdoptionCandidate(candidate)
|
||||
|
||||
expect(
|
||||
(
|
||||
readDataFile() as {
|
||||
skillManagementLedger: { dismissedAdoptionCandidates: unknown[] }
|
||||
}
|
||||
).skillManagementLedger.dismissedAdoptionCandidates
|
||||
).toContainEqual(candidate)
|
||||
})
|
||||
|
||||
it('reads legacy terminal scrollback snapshots for explicit profile data files', async () => {
|
||||
const profileDataDirectory = join(testState.dir, 'profiles', 'local-default')
|
||||
const profileDataFile = join(profileDataDirectory, 'orca-data.json')
|
||||
|
||||
@@ -73,6 +73,11 @@ import {
|
||||
} from '../shared/project-execution-runtime'
|
||||
import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection'
|
||||
import type { GitRemoteIdentity } from '../shared/git-remote-identity'
|
||||
import type {
|
||||
DismissedSkillAdoptionCandidate,
|
||||
ManagedSkillDestination,
|
||||
SkillManagementLedger
|
||||
} from '../shared/skill-management'
|
||||
import {
|
||||
buildTaskSourceContextFromRepo,
|
||||
buildWorkspaceRunContext
|
||||
@@ -236,6 +241,10 @@ import {
|
||||
import { track } from './telemetry/client'
|
||||
import { getCohortAtEmit } from './telemetry/cohort-classifier'
|
||||
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics'
|
||||
import {
|
||||
normalizeSkillManagementLedger,
|
||||
skillAdoptionDismissalKey
|
||||
} from './skills/skill-management-ledger'
|
||||
|
||||
function encrypt(plaintext: string): string {
|
||||
if (!plaintext || !safeStorage.isEncryptionAvailable()) {
|
||||
@@ -3106,6 +3115,9 @@ export class Store {
|
||||
featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets(
|
||||
parsed.featureInteractionTelemetryBuckets
|
||||
),
|
||||
// Why: missing or malformed ownership state must never grant write
|
||||
// authority; exact-snapshot adoption can reconstruct it explicitly.
|
||||
skillManagementLedger: normalizeSkillManagementLedger(parsed.skillManagementLedger),
|
||||
projectGroups: normalizedProjectGroups,
|
||||
folderWorkspaces: normalizeFolderWorkspaces(
|
||||
parsed.folderWorkspaces,
|
||||
@@ -3804,6 +3816,43 @@ export class Store {
|
||||
return this.state.repos.map((repo) => this.hydrateRepo(repo))
|
||||
}
|
||||
|
||||
getSkillManagementLedger(): SkillManagementLedger {
|
||||
return structuredClone(this.state.skillManagementLedger)
|
||||
}
|
||||
|
||||
setManagedSkillDestination(destination: ManagedSkillDestination): void {
|
||||
const previous = this.state.skillManagementLedger.destinations[destination.id]
|
||||
this.state.skillManagementLedger.destinations[destination.id] = structuredClone(destination)
|
||||
try {
|
||||
// Why: transaction cleanup may delete the only rollback copy immediately
|
||||
// after this callback, so ownership must reach disk before it returns.
|
||||
this.flushOrThrow()
|
||||
} catch (error) {
|
||||
if (previous) {
|
||||
this.state.skillManagementLedger.destinations[destination.id] = previous
|
||||
} else {
|
||||
delete this.state.skillManagementLedger.destinations[destination.id]
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
dismissSkillAdoptionCandidate(candidate: DismissedSkillAdoptionCandidate): void {
|
||||
const previous = this.state.skillManagementLedger.dismissedAdoptionCandidates
|
||||
const key = skillAdoptionDismissalKey(candidate)
|
||||
const retained = previous.filter((entry) => skillAdoptionDismissalKey(entry) !== key)
|
||||
retained.push(structuredClone(candidate))
|
||||
this.state.skillManagementLedger.dismissedAdoptionCandidates = retained
|
||||
try {
|
||||
// Why: toast closure is the user's one suppression action, so the tuple
|
||||
// must be durable before the renderer treats that lifecycle as complete.
|
||||
this.flushOrThrow()
|
||||
} catch (error) {
|
||||
this.state.skillManagementLedger.dismissedAdoptionCandidates = previous
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
getProjects(): Project[] {
|
||||
return [...this.state.projects]
|
||||
}
|
||||
|
||||
@@ -57,6 +57,25 @@ describe('skill discovery', () => {
|
||||
expect(rootPaths).toContain('/workspace/current/.claude/skills')
|
||||
})
|
||||
|
||||
it('hard-excludes reserved updater transactions from skill discovery', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
|
||||
const home = join(root, 'home')
|
||||
const stagedSkill = join(
|
||||
home,
|
||||
'.agents',
|
||||
'skills',
|
||||
'.orca-skill-transactions',
|
||||
'transaction-1',
|
||||
'stage'
|
||||
)
|
||||
await mkdir(stagedSkill, { recursive: true })
|
||||
await writeFile(join(stagedSkill, 'SKILL.md'), '# Staged package')
|
||||
|
||||
const result = await discoverSkills({ homeDir: home, cwd: home, repos: [] })
|
||||
|
||||
expect(result.skills).toEqual([])
|
||||
})
|
||||
|
||||
it('discovers skill packages through symlinked skill directories', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
|
||||
const home = join(root, 'home')
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
stablePathId,
|
||||
type SkillScanRoot
|
||||
} from './skill-discovery-sources'
|
||||
import { RESERVED_SKILL_TRANSACTION_DIRECTORY } from './skill-transaction-paths'
|
||||
|
||||
export { buildSkillDiscoverySources } from './skill-discovery-sources'
|
||||
|
||||
@@ -92,6 +93,11 @@ async function findSkillFiles(rootPath: string, maxDepth: number): Promise<strin
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name === RESERVED_SKILL_TRANSACTION_DIRECTORY) {
|
||||
// Why: crash-recovery packages may contain a valid SKILL.md but are
|
||||
// transaction state, never install candidates.
|
||||
continue
|
||||
}
|
||||
const entryPath = join(dirPath, entry.name)
|
||||
if (entry.name === SKILL_FILE_NAME) {
|
||||
if (entry.isFile()) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { DismissedSkillAdoptionCandidate } from '../../shared/skill-management'
|
||||
|
||||
export function createSkillAdoptionDismissal(
|
||||
value: unknown,
|
||||
hostId: ExecutionHostId,
|
||||
dismissedAt = Date.now()
|
||||
): DismissedSkillAdoptionCandidate {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error('skill-adoption-candidate-invalid')
|
||||
}
|
||||
const candidate = value as Partial<DismissedSkillAdoptionCandidate>
|
||||
if (
|
||||
candidate.hostId !== hostId ||
|
||||
typeof candidate.physicalIdentity !== 'string' ||
|
||||
candidate.physicalIdentity.length === 0 ||
|
||||
typeof candidate.skillName !== 'string' ||
|
||||
candidate.skillName.length === 0 ||
|
||||
typeof candidate.snapshotDigest !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/.test(candidate.snapshotDigest)
|
||||
) {
|
||||
throw new Error('skill-adoption-candidate-invalid')
|
||||
}
|
||||
return {
|
||||
hostId,
|
||||
physicalIdentity: candidate.physicalIdentity,
|
||||
skillName: candidate.skillName,
|
||||
snapshotDigest: candidate.snapshotDigest,
|
||||
dismissedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { SkillManagementInventory } from '../../shared/skill-management'
|
||||
import { adoptExactSkillSnapshot } from './skill-adoption'
|
||||
import { emptySkillManagementLedger } from './skill-management-ledger'
|
||||
import { inventoryManagedSkills } from './skill-management-inventory'
|
||||
|
||||
const resourceRoot = resolve('resources')
|
||||
const temporaryHomes: string[] = []
|
||||
|
||||
async function installHistoricalOrcaCli(crlf = false): Promise<{
|
||||
home: string
|
||||
inventory: SkillManagementInventory
|
||||
}> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-skill-adoption-'))
|
||||
temporaryHomes.push(home)
|
||||
const destination = join(home, '.agents', 'skills', 'orca-cli')
|
||||
await mkdir(destination, { recursive: true })
|
||||
const releaseMapping = JSON.parse(
|
||||
await readFile(resolve('resources/skills/release-mapping.json'), 'utf8')
|
||||
)
|
||||
const firstRelease = releaseMapping.releases.find(
|
||||
(release: { skills: Record<string, number> }) => release.skills['orca-cli'] === 1
|
||||
)
|
||||
const source = execFileSync(
|
||||
'git',
|
||||
['show', `v${firstRelease.appVersion}:skills/orca-cli/SKILL.md`],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
await writeFile(join(destination, 'SKILL.md'), crlf ? source.replace(/\n/g, '\r\n') : source)
|
||||
const inventory = await inventoryManagedSkills({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot
|
||||
})
|
||||
return { home, inventory }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryHomes.splice(0).map((home) => rm(home, { recursive: true })))
|
||||
})
|
||||
|
||||
describe('exact skill snapshot adoption', () => {
|
||||
it('adopts and foreground-updates a released stale package', async () => {
|
||||
const { home, inventory } = await installHistoricalOrcaCli()
|
||||
const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')
|
||||
expect(installation?.status).toBe('known-update-available')
|
||||
expect(installation?.installedAppVersion).toBe('1.0.81')
|
||||
|
||||
const record = await adoptExactSkillSnapshot({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
installationId: installation!.id,
|
||||
resourceRoot,
|
||||
commit: () => undefined,
|
||||
now: 42
|
||||
})
|
||||
|
||||
expect(record).toMatchObject({
|
||||
installedReleaseRevision: installation!.currentReleaseRevision,
|
||||
installedPackageDigest: installation!.currentPackageDigest,
|
||||
lastOutcome: 'updated',
|
||||
adoptedAt: 42,
|
||||
updatedAt: 42
|
||||
})
|
||||
expect(await readFile(join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md'), 'utf8')).toBe(
|
||||
await readFile(resolve('skills/orca-cli/SKILL.md'), 'utf8')
|
||||
)
|
||||
const managedInventory = await inventoryManagedSkills({
|
||||
ledger: {
|
||||
schemaVersion: 1,
|
||||
destinations: { [record.id]: record },
|
||||
dismissedAdoptionCandidates: []
|
||||
},
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot
|
||||
})
|
||||
expect(managedInventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject(
|
||||
{ id: record.id, status: 'managed-current', managed: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a Windows-shaped CRLF text installation while updating', async () => {
|
||||
const { home, inventory } = await installHistoricalOrcaCli(true)
|
||||
const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')
|
||||
|
||||
await adoptExactSkillSnapshot({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
installationId: installation!.id,
|
||||
resourceRoot,
|
||||
commit: () => undefined
|
||||
})
|
||||
|
||||
const installed = await readFile(join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md'))
|
||||
const current = await readFile(resolve('skills/orca-cli/SKILL.md'), 'utf8')
|
||||
expect(installed.includes(13)).toBe(true)
|
||||
expect(installed.toString('utf8').replace(/\r\n/g, '\n')).toBe(current)
|
||||
})
|
||||
|
||||
it('restores the prior package when durable ledger persistence fails', async () => {
|
||||
const { home, inventory } = await installHistoricalOrcaCli()
|
||||
const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')!
|
||||
const skillMarkdownPath = join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md')
|
||||
const before = await readFile(skillMarkdownPath)
|
||||
|
||||
await expect(
|
||||
adoptExactSkillSnapshot({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
installationId: installation.id,
|
||||
resourceRoot,
|
||||
commit: () => {
|
||||
throw new Error('ledger-write-failed')
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('ledger-write-failed')
|
||||
|
||||
expect(await readFile(skillMarkdownPath)).toEqual(before)
|
||||
})
|
||||
|
||||
it('refuses adoption after unknown content replaces the official snapshot', async () => {
|
||||
const { home, inventory } = await installHistoricalOrcaCli()
|
||||
const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')
|
||||
await writeFile(join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md'), 'custom')
|
||||
|
||||
await expect(
|
||||
adoptExactSkillSnapshot({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
installationId: installation!.id,
|
||||
resourceRoot,
|
||||
commit: () => undefined
|
||||
})
|
||||
).rejects.toThrow('skill-installation-not-adoptable')
|
||||
})
|
||||
|
||||
it('revalidates exact content immediately before committing current adoption', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-skill-adoption-race-'))
|
||||
temporaryHomes.push(home)
|
||||
const destination = join(home, '.agents', 'skills', 'orca-cli')
|
||||
await mkdir(destination, { recursive: true })
|
||||
await writeFile(
|
||||
join(destination, 'SKILL.md'),
|
||||
await readFile(resolve('skills/orca-cli/SKILL.md'))
|
||||
)
|
||||
const inventory = await inventoryManagedSkills({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot
|
||||
})
|
||||
const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')!
|
||||
let committed = false
|
||||
|
||||
await expect(
|
||||
adoptExactSkillSnapshot({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
installationId: installation.id,
|
||||
resourceRoot,
|
||||
beforeFinalValidation: () =>
|
||||
writeFile(join(destination, 'SKILL.md'), 'post-observation independent edit'),
|
||||
commit: () => {
|
||||
committed = true
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('skill-content-changed')
|
||||
expect(committed).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { lstat, realpath } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type {
|
||||
DismissedSkillAdoptionCandidate,
|
||||
ManagedSkillDestination,
|
||||
SkillManagementLedger
|
||||
} from '../../shared/skill-management'
|
||||
import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
|
||||
import {
|
||||
matchingReleasedSnapshot,
|
||||
observeSkillPackage,
|
||||
persistedObservedFiles
|
||||
} from './skill-package-identity'
|
||||
import { inventoryManagedSkills } from './skill-management-inventory'
|
||||
import { normalizedSkillIdentityPath } from './skill-installation-topology'
|
||||
import { publishBundledSkill } from './skill-package-publish'
|
||||
|
||||
export async function adoptExactSkillSnapshot(args: {
|
||||
ledger: SkillManagementLedger
|
||||
hostId: ExecutionHostId
|
||||
homeDir: string
|
||||
installationId: string
|
||||
resourceRoot?: string
|
||||
now?: number
|
||||
// Why: deterministic fault injection must land after advisory observation
|
||||
// but before the same final validation used by production commits.
|
||||
beforeFinalValidation?: () => void | Promise<void>
|
||||
commit: (destination: ManagedSkillDestination) => void | Promise<void>
|
||||
}): Promise<ManagedSkillDestination> {
|
||||
const inventory = await inventoryManagedSkills(args)
|
||||
const installation = inventory.installations.find((entry) => entry.id === args.installationId)
|
||||
if (
|
||||
!installation?.eligible ||
|
||||
installation.managed ||
|
||||
!installation.resolvedPath ||
|
||||
!installation.physicalIdentity ||
|
||||
installation.installedReleaseRevision === null ||
|
||||
!installation.installedPackageDigest
|
||||
) {
|
||||
throw new Error('skill-installation-not-adoptable')
|
||||
}
|
||||
|
||||
const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
|
||||
const current = artifacts.manifest.skills.find((entry) => entry.name === installation.name)
|
||||
const observed = await observeSkillPackage(installation.resolvedPath)
|
||||
const snapshot = matchingReleasedSnapshot(
|
||||
observed,
|
||||
artifacts.releasedSnapshots[installation.name] ?? []
|
||||
)
|
||||
// Why: inventory is only advisory; consent records ownership only after a
|
||||
// fresh exact-content check closes the user/CLI mutation race.
|
||||
if (
|
||||
!current ||
|
||||
!snapshot ||
|
||||
snapshot.packageDigest !== installation.installedPackageDigest ||
|
||||
normalizedSkillIdentityPath(await realpath(installation.resolvedPath)) !==
|
||||
normalizedSkillIdentityPath(installation.resolvedPath)
|
||||
) {
|
||||
throw new Error('skill-installation-changed-before-adoption')
|
||||
}
|
||||
|
||||
const logicalStat = await lstat(installation.unresolvedPath)
|
||||
const now = args.now ?? Date.now()
|
||||
const record: ManagedSkillDestination = {
|
||||
id: installation.id,
|
||||
hostId: args.hostId,
|
||||
homeIdentity: normalizedSkillIdentityPath(
|
||||
await realpath(args.homeDir).catch(() => resolve(args.homeDir))
|
||||
),
|
||||
rootId: installation.rootId,
|
||||
unresolvedPath: installation.unresolvedPath,
|
||||
resolvedPath: installation.resolvedPath,
|
||||
physicalIdentity: installation.physicalIdentity,
|
||||
entryType: logicalStat.isSymbolicLink()
|
||||
? process.platform === 'win32'
|
||||
? 'junction'
|
||||
: 'symlink'
|
||||
: 'directory',
|
||||
skillName: installation.name,
|
||||
source: 'stablyai/orca',
|
||||
sourcePath: current.sourcePath,
|
||||
sourceRef: snapshot.gitTreeSha,
|
||||
installedReleaseRevision: snapshot.releaseRevision,
|
||||
installedPackageDigest: snapshot.packageDigest,
|
||||
installedFiles: persistedObservedFiles(observed),
|
||||
lastWrittenPackageDigest: null,
|
||||
lastAttemptedBundleFingerprint: current.packageDigest,
|
||||
lastOutcome: 'adopted',
|
||||
lastErrorCategory: null,
|
||||
adoptedFrom: 'exact-snapshot',
|
||||
adoptedAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
await args.beforeFinalValidation?.()
|
||||
return publishBundledSkill({
|
||||
record,
|
||||
current,
|
||||
releasedSnapshots: artifacts.releasedSnapshots[installation.name] ?? [],
|
||||
packagesRoot: artifacts.packagesRoot,
|
||||
commit: args.commit,
|
||||
now
|
||||
})
|
||||
}
|
||||
|
||||
export async function skillAdoptionDismissal(args: {
|
||||
ledger: SkillManagementLedger
|
||||
hostId: ExecutionHostId
|
||||
homeDir: string
|
||||
installationId: string
|
||||
resourceRoot?: string
|
||||
now?: number
|
||||
}): Promise<DismissedSkillAdoptionCandidate> {
|
||||
const inventory = await inventoryManagedSkills(args)
|
||||
const installation = inventory.installations.find((entry) => entry.id === args.installationId)
|
||||
if (
|
||||
!installation?.eligible ||
|
||||
!installation.physicalIdentity ||
|
||||
!installation.installedPackageDigest
|
||||
) {
|
||||
throw new Error('skill-adoption-candidate-not-found')
|
||||
}
|
||||
return {
|
||||
hostId: args.hostId,
|
||||
physicalIdentity: installation.physicalIdentity,
|
||||
skillName: installation.name,
|
||||
snapshotDigest: installation.installedPackageDigest,
|
||||
dismissedAt: args.now ?? Date.now()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { app } from 'electron'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import type {
|
||||
SkillBundleManifest,
|
||||
SkillReleaseMapping,
|
||||
SkillReleasedSnapshot,
|
||||
SkillSnapshotRegistry
|
||||
} from '../../shared/skill-management'
|
||||
|
||||
export type SkillBundleArtifacts = {
|
||||
manifest: SkillBundleManifest
|
||||
registry: SkillSnapshotRegistry
|
||||
releaseMapping: SkillReleaseMapping
|
||||
releasedSnapshots: Record<string, SkillReleasedSnapshot[]>
|
||||
releasedAppVersions: Record<string, Record<number, string>>
|
||||
packagesRoot: string
|
||||
}
|
||||
|
||||
function assertSupportedSchema(
|
||||
value: unknown,
|
||||
label: string
|
||||
): asserts value is { schemaVersion: 1 } {
|
||||
if (
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!('schemaVersion' in value) ||
|
||||
value.schemaVersion !== 1
|
||||
) {
|
||||
throw new Error(`Unsupported ${label} schema`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSkillBundleArtifacts(
|
||||
resourceRoot = app.isPackaged ? process.resourcesPath : resolve(process.cwd(), 'resources')
|
||||
): Promise<SkillBundleArtifacts> {
|
||||
const bundleRoot = join(resourceRoot, 'skills')
|
||||
const [manifest, registry, releaseMapping] = 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)
|
||||
])
|
||||
assertSupportedSchema(manifest, 'skill bundle manifest')
|
||||
assertSupportedSchema(registry, 'skill snapshot registry')
|
||||
assertSupportedSchema(releaseMapping, 'skill release mapping')
|
||||
if (!('skills' in manifest) || !Array.isArray(manifest.skills)) {
|
||||
throw new Error('Invalid skill bundle manifest')
|
||||
}
|
||||
if (
|
||||
!('appVersion' in manifest) ||
|
||||
typeof manifest.appVersion !== 'string' ||
|
||||
!('skills' in registry) ||
|
||||
typeof registry.skills !== 'object' ||
|
||||
registry.skills === null
|
||||
) {
|
||||
throw new Error('Invalid skill snapshot registry')
|
||||
}
|
||||
if (!('releases' in releaseMapping) || !Array.isArray(releaseMapping.releases)) {
|
||||
throw new Error('Invalid skill release mapping')
|
||||
}
|
||||
const mappedRevisions = new Map<string, Set<number>>()
|
||||
const releasedAppVersions: Record<string, Record<number, string>> = {}
|
||||
for (const release of releaseMapping.releases) {
|
||||
if (
|
||||
typeof release !== 'object' ||
|
||||
release === null ||
|
||||
!('appVersion' in release) ||
|
||||
typeof release.appVersion !== 'string' ||
|
||||
!('skills' in release) ||
|
||||
typeof release.skills !== 'object' ||
|
||||
release.skills === null
|
||||
) {
|
||||
throw new Error('Invalid skill release mapping')
|
||||
}
|
||||
for (const [name, revision] of Object.entries(release.skills)) {
|
||||
if (!Number.isSafeInteger(revision) || (revision as number) <= 0) {
|
||||
throw new Error('Invalid skill release mapping')
|
||||
}
|
||||
const revisions = mappedRevisions.get(name) ?? new Set<number>()
|
||||
revisions.add(revision as number)
|
||||
mappedRevisions.set(name, revisions)
|
||||
releasedAppVersions[name] ??= {}
|
||||
releasedAppVersions[name][revision as number] ??= release.appVersion
|
||||
}
|
||||
}
|
||||
const releasedSnapshots = Object.fromEntries(
|
||||
Object.entries((registry as SkillSnapshotRegistry).skills).map(([name, snapshots]) => [
|
||||
name,
|
||||
snapshots.filter((snapshot) => mappedRevisions.get(name)?.has(snapshot.releaseRevision))
|
||||
])
|
||||
)
|
||||
for (const [name, revisions] of mappedRevisions) {
|
||||
const known = new Set(
|
||||
((registry as SkillSnapshotRegistry).skills[name] ?? []).map(
|
||||
(snapshot) => snapshot.releaseRevision
|
||||
)
|
||||
)
|
||||
if ([...revisions].some((revision) => !known.has(revision))) {
|
||||
throw new Error('Skill release mapping references an unknown snapshot')
|
||||
}
|
||||
}
|
||||
return {
|
||||
manifest: manifest as SkillBundleManifest,
|
||||
registry: registry as SkillSnapshotRegistry,
|
||||
releaseMapping: releaseMapping as SkillReleaseMapping,
|
||||
releasedSnapshots,
|
||||
releasedAppVersions,
|
||||
packagesRoot: app?.isPackaged ? join(bundleRoot, 'packages') : resolve(process.cwd(), 'skills')
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,8 @@ export function buildSkillDiscoverySources(
|
||||
const roots: SkillScanRoot[] = [
|
||||
source('home-codex', 'Codex home', join(home, '.codex', 'skills'), 'home', ['codex']),
|
||||
source('home-agents', 'Agent skills home', join(home, '.agents', 'skills'), 'home', [
|
||||
'agent-skills'
|
||||
'agent-skills',
|
||||
'codex'
|
||||
]),
|
||||
source('home-claude', 'Claude home', join(home, '.claude', 'skills'), 'home', ['claude']),
|
||||
source(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { cp, 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 { emptySkillManagementLedger } from './skill-management-ledger'
|
||||
import { inventoryManagedSkills } from './skill-management-inventory'
|
||||
import {
|
||||
previewExplicitSkillReplacement,
|
||||
replaceSkillWithCurrentBundle
|
||||
} from './skill-explicit-replacement'
|
||||
|
||||
const temporaryHomes: string[] = []
|
||||
const resourceRoot = resolve('resources')
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryHomes.splice(0).map((home) => rm(home, { recursive: true })))
|
||||
})
|
||||
|
||||
async function customInstallation() {
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-skill-replacement-'))
|
||||
temporaryHomes.push(home)
|
||||
const destination = join(home, '.agents', 'skills', 'orca-cli')
|
||||
await mkdir(destination, { recursive: true })
|
||||
await cp(resolve('skills/orca-cli'), destination, { recursive: true })
|
||||
await writeFile(join(destination, 'SKILL.md'), 'custom instructions\n')
|
||||
await writeFile(join(destination, 'notes.txt'), 'keep me until confirmed\n')
|
||||
const inventory = await inventoryManagedSkills({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot
|
||||
})
|
||||
return {
|
||||
home,
|
||||
destination,
|
||||
installation: inventory.installations.find((entry) => entry.name === 'orca-cli')!
|
||||
}
|
||||
}
|
||||
|
||||
describe('explicit skill replacement', () => {
|
||||
it('previews every local file that replacement changes or removes', async () => {
|
||||
const { home, installation } = await customInstallation()
|
||||
const preview = await previewExplicitSkillReplacement({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
installationId: installation.id,
|
||||
resourceRoot
|
||||
})
|
||||
|
||||
expect(preview.files).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: 'SKILL.md', change: 'modified' }),
|
||||
expect.objectContaining({ path: 'notes.txt', change: 'removed' })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces only after explicit invocation and records the verified current bundle', async () => {
|
||||
const { home, destination, installation } = await customInstallation()
|
||||
expect(await readFile(join(destination, 'notes.txt'), 'utf8')).toContain('keep me')
|
||||
|
||||
const record = await replaceSkillWithCurrentBundle({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
installationId: installation.id,
|
||||
resourceRoot,
|
||||
commit: () => undefined,
|
||||
now: 99
|
||||
})
|
||||
|
||||
expect(record).toMatchObject({
|
||||
adoptedFrom: 'explicit-replacement',
|
||||
lastOutcome: 'replaced',
|
||||
adoptedAt: 99,
|
||||
updatedAt: 99
|
||||
})
|
||||
expect(await readFile(join(destination, 'SKILL.md'), 'utf8')).toBe(
|
||||
await readFile(resolve('skills/orca-cli/SKILL.md'), 'utf8')
|
||||
)
|
||||
await expect(readFile(join(destination, 'notes.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { readFile, realpath } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type {
|
||||
ManagedSkillDestination,
|
||||
SkillManagementLedger,
|
||||
SkillReplacementFilePreview,
|
||||
SkillReplacementPreview,
|
||||
SkillReleasedSnapshot
|
||||
} from '../../shared/skill-management'
|
||||
import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
|
||||
import { normalizedSkillIdentityPath } from './skill-installation-topology'
|
||||
import { inventoryManagedSkills } from './skill-management-inventory'
|
||||
import {
|
||||
observeSkillPackage,
|
||||
persistedObservedFiles,
|
||||
type ObservedSkillPackage
|
||||
} from './skill-package-identity'
|
||||
import { publishBundledSkill } from './skill-package-publish'
|
||||
|
||||
function decodedText(bytes: Buffer): string | null {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function replacementFiles(
|
||||
observed: ObservedSkillPackage,
|
||||
packageRoot: string,
|
||||
currentPaths: readonly string[]
|
||||
): Promise<SkillReplacementFilePreview[]> {
|
||||
const observedByPath = new Map(observed.files.map((file) => [file.path, file]))
|
||||
const paths = [...new Set([...observedByPath.keys(), ...currentPaths])].sort()
|
||||
const previews = await Promise.all(
|
||||
paths.map(async (path): Promise<SkillReplacementFilePreview | null> => {
|
||||
const before = observedByPath.get(path)
|
||||
const afterBytes = currentPaths.includes(path)
|
||||
? await readFile(join(packageRoot, ...path.split('/')))
|
||||
: null
|
||||
if (before && afterBytes && before.bytes.equals(afterBytes)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
path,
|
||||
change: before ? (afterBytes ? 'modified' : 'removed') : 'added',
|
||||
beforeText: before ? decodedText(before.bytes) : null,
|
||||
afterText: afterBytes ? decodedText(afterBytes) : null
|
||||
}
|
||||
})
|
||||
)
|
||||
return previews.filter((preview): preview is SkillReplacementFilePreview => preview !== null)
|
||||
}
|
||||
|
||||
async function replacementContext(args: {
|
||||
ledger: SkillManagementLedger
|
||||
hostId: ExecutionHostId
|
||||
homeDir: string
|
||||
installationId: string
|
||||
resourceRoot?: string
|
||||
}) {
|
||||
const inventory = await inventoryManagedSkills(args)
|
||||
const installation = inventory.installations.find((entry) => entry.id === args.installationId)
|
||||
if (
|
||||
!installation ||
|
||||
(installation.status !== 'modified' && installation.status !== 'unknown') ||
|
||||
!installation.resolvedPath ||
|
||||
!installation.physicalIdentity ||
|
||||
(installation.topology !== 'canonical-copy' && installation.topology !== 'independent-copy')
|
||||
) {
|
||||
throw new Error('skill-installation-not-replaceable')
|
||||
}
|
||||
const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
|
||||
const current = artifacts.manifest.skills.find((entry) => entry.name === installation.name)
|
||||
if (!current) {
|
||||
throw new Error('skill-bundle-entry-missing')
|
||||
}
|
||||
const observed = await observeSkillPackage(installation.resolvedPath)
|
||||
return { installation, artifacts, current, observed }
|
||||
}
|
||||
|
||||
export async function previewExplicitSkillReplacement(args: {
|
||||
ledger: SkillManagementLedger
|
||||
hostId: ExecutionHostId
|
||||
homeDir: string
|
||||
installationId: string
|
||||
resourceRoot?: string
|
||||
}): Promise<SkillReplacementPreview> {
|
||||
const { installation, artifacts, current, observed } = await replacementContext(args)
|
||||
return {
|
||||
installationId: installation.id,
|
||||
skillName: installation.name,
|
||||
files: await replacementFiles(
|
||||
observed,
|
||||
join(artifacts.packagesRoot, installation.name),
|
||||
current.files.map((file) => file.path)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function replaceSkillWithCurrentBundle(args: {
|
||||
ledger: SkillManagementLedger
|
||||
hostId: ExecutionHostId
|
||||
homeDir: string
|
||||
installationId: string
|
||||
resourceRoot?: string
|
||||
now?: number
|
||||
commit: (destination: ManagedSkillDestination) => void | Promise<void>
|
||||
}): Promise<ManagedSkillDestination> {
|
||||
const { installation, artifacts, current, observed } = await replacementContext(args)
|
||||
const now = args.now ?? Date.now()
|
||||
const installedFiles = persistedObservedFiles(observed)
|
||||
const observedSnapshot: SkillReleasedSnapshot = {
|
||||
releaseRevision: 0,
|
||||
packageDigest: observed.observedDigest,
|
||||
gitTreeSha: '',
|
||||
files: installedFiles
|
||||
}
|
||||
const homeIdentity = normalizedSkillIdentityPath(
|
||||
await realpath(args.homeDir).catch(() => resolve(args.homeDir))
|
||||
)
|
||||
const record: ManagedSkillDestination = {
|
||||
id: installation.id,
|
||||
hostId: args.hostId,
|
||||
homeIdentity,
|
||||
rootId: installation.rootId,
|
||||
unresolvedPath: installation.unresolvedPath,
|
||||
resolvedPath: installation.resolvedPath!,
|
||||
physicalIdentity: installation.physicalIdentity!,
|
||||
entryType: 'directory',
|
||||
skillName: installation.name,
|
||||
source: 'stablyai/orca',
|
||||
sourcePath: current.sourcePath,
|
||||
sourceRef: null,
|
||||
installedReleaseRevision: 0,
|
||||
installedPackageDigest: observed.observedDigest,
|
||||
installedFiles,
|
||||
lastWrittenPackageDigest: null,
|
||||
lastAttemptedBundleFingerprint: current.packageDigest,
|
||||
lastOutcome: 'replaced',
|
||||
lastErrorCategory: null,
|
||||
adoptedFrom: 'explicit-replacement',
|
||||
adoptedAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
const published = await publishBundledSkill({
|
||||
record,
|
||||
current,
|
||||
releasedSnapshots: [observedSnapshot],
|
||||
packagesRoot: artifacts.packagesRoot,
|
||||
commit: (destination) =>
|
||||
args.commit({
|
||||
...destination,
|
||||
lastOutcome: 'replaced',
|
||||
adoptedFrom: 'explicit-replacement'
|
||||
}),
|
||||
now
|
||||
})
|
||||
return { ...published, lastOutcome: 'replaced', adoptedFrom: 'explicit-replacement' }
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
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 { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { SkillInstallationTopology } from '../../shared/skill-management'
|
||||
import type { ManagedSkillDestination } from '../../shared/skill-management'
|
||||
import type { SkillScanRoot } from './skill-discovery-sources'
|
||||
|
||||
export type ClassifiedSkillTopology = {
|
||||
topology: SkillInstallationTopology
|
||||
resolvedPath: string | null
|
||||
identity: string | null
|
||||
entryType: 'directory' | 'symlink' | 'junction'
|
||||
errorCategory: string | null
|
||||
}
|
||||
|
||||
export function skillDestinationId(
|
||||
hostId: ExecutionHostId,
|
||||
unresolvedPath: string,
|
||||
name: string
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(hostId)
|
||||
.update('\0')
|
||||
.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':
|
||||
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 assertManagedSkillTopology(record: ManagedSkillDestination): Promise<string> {
|
||||
const logical = await lstat(record.unresolvedPath)
|
||||
const actualEntryType = logical.isSymbolicLink()
|
||||
? process.platform === 'win32'
|
||||
? 'junction'
|
||||
: 'symlink'
|
||||
: logical.isDirectory()
|
||||
? 'directory'
|
||||
: null
|
||||
const homeBoundary = dirname(dirname(dirname(record.unresolvedPath)))
|
||||
if (
|
||||
actualEntryType !== record.entryType ||
|
||||
(await hasSymlinkedAncestor(dirname(record.unresolvedPath), homeBoundary))
|
||||
) {
|
||||
throw new Error('skill-topology-changed')
|
||||
}
|
||||
const [resolved, resolvedParent] = await Promise.all([
|
||||
realpath(record.unresolvedPath),
|
||||
realpath(dirname(record.unresolvedPath))
|
||||
])
|
||||
const resolvedStat = await stat(resolved)
|
||||
if (
|
||||
!resolvedStat.isDirectory() ||
|
||||
normalizedSkillIdentityPath(resolved) !== normalizedSkillIdentityPath(record.resolvedPath) ||
|
||||
normalizedSkillIdentityPath(resolvedParent) !==
|
||||
normalizedSkillIdentityPath(dirname(record.resolvedPath)) ||
|
||||
skillPhysicalIdentity(resolved, resolvedStat) !== record.physicalIdentity
|
||||
) {
|
||||
throw new Error('skill-topology-changed')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
export async function assertManagedSkillParentTopology(
|
||||
record: ManagedSkillDestination
|
||||
): Promise<void> {
|
||||
const homeBoundary = dirname(dirname(dirname(record.unresolvedPath)))
|
||||
if (await hasSymlinkedAncestor(dirname(record.unresolvedPath), homeBoundary)) {
|
||||
throw new Error('skill-topology-changed')
|
||||
}
|
||||
const resolvedParent = await realpath(dirname(record.unresolvedPath))
|
||||
if (
|
||||
normalizedSkillIdentityPath(resolvedParent) !==
|
||||
normalizedSkillIdentityPath(dirname(record.resolvedPath))
|
||||
) {
|
||||
throw new Error('skill-topology-changed')
|
||||
}
|
||||
}
|
||||
|
||||
export async function classifySkillInstallationTopology(
|
||||
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,
|
||||
entryType: 'symlink',
|
||||
errorCategory: 'missing'
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const linked = logicalStat.isSymbolicLink()
|
||||
const entryType =
|
||||
linked && process.platform === 'win32' ? 'junction' : linked ? 'symlink' : 'directory'
|
||||
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,
|
||||
entryType,
|
||||
errorCategory: 'dangling-link'
|
||||
}
|
||||
}
|
||||
if (!resolvedStat.isDirectory()) {
|
||||
return {
|
||||
topology: 'broken-link',
|
||||
resolvedPath,
|
||||
identity: null,
|
||||
entryType,
|
||||
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, entryType, errorCategory: null }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { ManagedSkillDestination, SkillManagementLedger } from '../../shared/skill-management'
|
||||
import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
|
||||
import { publishBundledSkill } from './skill-package-publish'
|
||||
import { inventoryManagedSkills } from './skill-management-inventory'
|
||||
|
||||
export async function updateManagedSkill(args: {
|
||||
ledger: SkillManagementLedger
|
||||
hostId: ExecutionHostId
|
||||
homeDir: string
|
||||
installationId: string
|
||||
resourceRoot?: string
|
||||
now?: number
|
||||
commit: (destination: ManagedSkillDestination) => void | Promise<void>
|
||||
}): Promise<ManagedSkillDestination> {
|
||||
const record = args.ledger.destinations[args.installationId]
|
||||
if (!record || record.hostId !== args.hostId) {
|
||||
throw new Error('managed-skill-not-found')
|
||||
}
|
||||
const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
|
||||
const inventory = await inventoryManagedSkills(args)
|
||||
const installation = inventory.installations.find((entry) => entry.id === record.id)
|
||||
if (
|
||||
!installation?.managed ||
|
||||
installation.hostId !== record.hostId ||
|
||||
installation.physicalIdentity !== record.physicalIdentity ||
|
||||
installation.resolvedPath !== record.resolvedPath
|
||||
) {
|
||||
throw new Error('managed-skill-identity-changed')
|
||||
}
|
||||
const current = artifacts.manifest.skills.find((entry) => entry.name === record.skillName)
|
||||
if (!current) {
|
||||
throw new Error('skill-bundle-entry-missing')
|
||||
}
|
||||
return publishBundledSkill({
|
||||
record,
|
||||
current,
|
||||
releasedSnapshots: artifacts.releasedSnapshots[record.skillName] ?? [],
|
||||
packagesRoot: artifacts.packagesRoot,
|
||||
commit: args.commit,
|
||||
now: args.now
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type {
|
||||
SkillCurrentBundleEntry,
|
||||
SkillManagementInstallation
|
||||
} from '../../shared/skill-management'
|
||||
import type { SkillScanRoot } from './skill-discovery-sources'
|
||||
import { skillDestinationId } from './skill-installation-topology'
|
||||
|
||||
export type ClassifiedSkillCandidate = {
|
||||
installation: SkillManagementInstallation
|
||||
logicalEntryType: 'directory' | 'symlink' | 'junction'
|
||||
}
|
||||
|
||||
function errorCategory(error: unknown, fallback: string): string {
|
||||
return error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
(error.code === 'EACCES' || error.code === 'EPERM')
|
||||
? 'skill-candidate-access-denied'
|
||||
: fallback
|
||||
}
|
||||
|
||||
export function createInaccessibleSkillCandidate(args: {
|
||||
root: SkillScanRoot
|
||||
name: string
|
||||
current: SkillCurrentBundleEntry
|
||||
hostId: ExecutionHostId
|
||||
unresolvedPath: string
|
||||
error: unknown
|
||||
fallbackErrorCategory?: string
|
||||
}): ClassifiedSkillCandidate {
|
||||
return {
|
||||
logicalEntryType: 'directory',
|
||||
installation: {
|
||||
id: skillDestinationId(args.hostId, args.unresolvedPath, args.name),
|
||||
hostId: args.hostId,
|
||||
name: args.name,
|
||||
description: null,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
unresolvedPath: args.unresolvedPath,
|
||||
resolvedPath: null,
|
||||
physicalIdentity: null,
|
||||
topology: 'read-only',
|
||||
status: 'inaccessible',
|
||||
eligible: false,
|
||||
managed: false,
|
||||
installedReleaseRevision: null,
|
||||
installedAppVersion: null,
|
||||
installedPackageDigest: null,
|
||||
currentReleaseRevision: args.current.releaseRevision,
|
||||
currentPackageDigest: args.current.packageDigest,
|
||||
currentAppVersion: args.current.appVersion,
|
||||
errorCategory: errorCategory(
|
||||
args.error,
|
||||
args.fallbackErrorCategory ?? 'skill-candidate-lstat-failed'
|
||||
),
|
||||
adoptionPromptEligible: false,
|
||||
lockCorroborated: false,
|
||||
actionsSupported: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
cp,
|
||||
link,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
truncate,
|
||||
unlink,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, relative, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { ManagedSkillDestination, SkillManagementLedger } from '../../shared/skill-management'
|
||||
import { emptySkillManagementLedger } from './skill-management-ledger'
|
||||
import { inventoryManagedSkills } from './skill-management-inventory'
|
||||
import { classifySkillInstallationTopology } from './skill-installation-topology'
|
||||
|
||||
const resourceRoot = resolve('resources')
|
||||
const temporaryHomes: string[] = []
|
||||
|
||||
async function createHome(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-skill-inventory-'))
|
||||
temporaryHomes.push(home)
|
||||
return home
|
||||
}
|
||||
|
||||
async function installSkill(
|
||||
home: string,
|
||||
root: '.agents' | '.codex',
|
||||
name: string
|
||||
): Promise<string> {
|
||||
const destination = join(home, root, 'skills', name)
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
await cp(resolve('skills', name), destination, { recursive: true })
|
||||
return destination
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryHomes.splice(0).map((home) => rm(home, { recursive: true })))
|
||||
})
|
||||
|
||||
describe('managed skill inventory', () => {
|
||||
it('recognizes an exact current canonical installation as adoptable', async () => {
|
||||
const home = await createHome()
|
||||
await installSkill(home, '.agents', 'orca-cli')
|
||||
|
||||
const inventory = await inventoryManagedSkills({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot
|
||||
})
|
||||
const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')
|
||||
|
||||
expect(installation).toMatchObject({
|
||||
topology: 'canonical-copy',
|
||||
status: 'known-current',
|
||||
eligible: true,
|
||||
managed: false
|
||||
})
|
||||
expect(inventory.adoptionCandidateCount).toBe(1)
|
||||
})
|
||||
|
||||
it('suppresses only the explicitly dismissed destination snapshot tuple', async () => {
|
||||
const home = await createHome()
|
||||
await installSkill(home, '.agents', 'orca-cli')
|
||||
await installSkill(home, '.agents', 'orchestration')
|
||||
const initial = await inventoryManagedSkills({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot
|
||||
})
|
||||
const dismissed = initial.installations.find((entry) => entry.name === 'orca-cli')!
|
||||
|
||||
const next = await inventoryManagedSkills({
|
||||
ledger: {
|
||||
schemaVersion: 1,
|
||||
destinations: {},
|
||||
dismissedAdoptionCandidates: [
|
||||
{
|
||||
hostId: dismissed.hostId,
|
||||
physicalIdentity: dismissed.physicalIdentity!,
|
||||
skillName: dismissed.name,
|
||||
snapshotDigest: dismissed.installedPackageDigest!,
|
||||
dismissedAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot
|
||||
})
|
||||
|
||||
expect(next.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
|
||||
adoptionPromptEligible: false
|
||||
})
|
||||
expect(next.installations.find((entry) => entry.name === 'orchestration')).toMatchObject({
|
||||
adoptionPromptEligible: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not adopt a registry snapshot absent from the release mapping', async () => {
|
||||
const home = await createHome()
|
||||
await installSkill(home, '.agents', 'orca-cli')
|
||||
const isolatedResources = join(home, 'resources')
|
||||
await cp(resolve('resources/skills'), join(isolatedResources, 'skills'), { recursive: true })
|
||||
const mappingPath = join(isolatedResources, 'skills', 'release-mapping.json')
|
||||
const mapping = JSON.parse(await readFile(mappingPath, 'utf8')) as {
|
||||
releases: { skills: Record<string, number> }[]
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
await readFile(join(isolatedResources, 'skills', 'current-manifest.json'), 'utf8')
|
||||
) as { skills: { name: string; releaseRevision: number }[] }
|
||||
const currentRevision = manifest.skills.find(
|
||||
(skill) => skill.name === 'orca-cli'
|
||||
)!.releaseRevision
|
||||
for (const release of mapping.releases) {
|
||||
if (release.skills['orca-cli'] === currentRevision) {
|
||||
delete release.skills['orca-cli']
|
||||
}
|
||||
}
|
||||
await writeFile(mappingPath, JSON.stringify(mapping))
|
||||
|
||||
const inventory = await inventoryManagedSkills({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot: isolatedResources
|
||||
})
|
||||
|
||||
expect(inventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
|
||||
status: 'unknown',
|
||||
eligible: false
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes exact hardlinked files because publication replaces directory entries', async () => {
|
||||
const home = await createHome()
|
||||
const destination = await installSkill(home, '.agents', 'orca-cli')
|
||||
const skillMarkdown = join(destination, 'SKILL.md')
|
||||
const alias = join(home, 'hardlink-alias')
|
||||
await link(skillMarkdown, alias)
|
||||
|
||||
const inventory = await inventoryManagedSkills({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot
|
||||
})
|
||||
// Why: staged publish uses rename/temp+rename, which breaks the hardlink
|
||||
// instead of writing through to the other linked file.
|
||||
expect(inventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
|
||||
status: 'known-current',
|
||||
eligible: true
|
||||
})
|
||||
await unlink(alias)
|
||||
})
|
||||
|
||||
it('treats CRLF-only installer output as the same released text snapshot', async () => {
|
||||
const home = await createHome()
|
||||
const destination = await installSkill(home, '.agents', 'orca-cli')
|
||||
const skillPath = join(destination, 'SKILL.md')
|
||||
const source = await readFile(skillPath, 'utf8')
|
||||
await writeFile(skillPath, source.replace(/\n/g, '\r\n'))
|
||||
|
||||
const inventory = await inventoryManagedSkills({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot
|
||||
})
|
||||
|
||||
expect(inventory.installations.find((entry) => entry.name === 'orca-cli')?.status).toBe(
|
||||
'known-current'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed after an adopted package is edited', async () => {
|
||||
const home = await createHome()
|
||||
const destination = await installSkill(home, '.agents', 'orca-cli')
|
||||
const initial = await inventoryManagedSkills({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot
|
||||
})
|
||||
const installation = initial.installations.find((entry) => entry.name === 'orca-cli')
|
||||
expect(installation?.physicalIdentity).toBeTruthy()
|
||||
expect(installation?.installedPackageDigest).toBeTruthy()
|
||||
const record: ManagedSkillDestination = {
|
||||
id: installation!.id,
|
||||
hostId: 'local',
|
||||
homeIdentity: home,
|
||||
rootId: 'home-agents',
|
||||
unresolvedPath: destination,
|
||||
resolvedPath: destination,
|
||||
physicalIdentity: installation!.physicalIdentity!,
|
||||
entryType: 'directory',
|
||||
skillName: 'orca-cli',
|
||||
source: 'stablyai/orca',
|
||||
sourcePath: 'skills/orca-cli',
|
||||
sourceRef: null,
|
||||
installedReleaseRevision: installation!.installedReleaseRevision!,
|
||||
installedPackageDigest: installation!.installedPackageDigest!,
|
||||
installedFiles: [],
|
||||
lastWrittenPackageDigest: null,
|
||||
lastAttemptedBundleFingerprint: null,
|
||||
lastOutcome: 'adopted',
|
||||
lastErrorCategory: null,
|
||||
adoptedFrom: 'exact-snapshot',
|
||||
adoptedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const ledger: SkillManagementLedger = {
|
||||
schemaVersion: 1,
|
||||
destinations: { [record.id]: record },
|
||||
dismissedAdoptionCandidates: []
|
||||
}
|
||||
await writeFile(join(destination, 'extra.txt'), 'local edit')
|
||||
|
||||
const inventory = await inventoryManagedSkills({
|
||||
ledger,
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot
|
||||
})
|
||||
|
||||
expect(inventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
|
||||
status: 'modified',
|
||||
eligible: false,
|
||||
managed: false
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds one oversized candidate without hiding healthy siblings', async () => {
|
||||
const home = await createHome()
|
||||
const oversized = await installSkill(home, '.agents', 'orca-cli')
|
||||
await installSkill(home, '.agents', 'orchestration')
|
||||
const oversizedFile = join(oversized, 'oversized.bin')
|
||||
await writeFile(oversizedFile, '')
|
||||
await truncate(oversizedFile, 4 * 1024 * 1024 + 1)
|
||||
|
||||
const inventory = await inventoryManagedSkills({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot
|
||||
})
|
||||
|
||||
expect(inventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
|
||||
status: 'unknown',
|
||||
errorCategory: 'skill-package-file-size-limit'
|
||||
})
|
||||
expect(inventory.installations.find((entry) => entry.name === 'orchestration')).toMatchObject({
|
||||
status: 'known-current',
|
||||
eligible: true
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies one candidate lstat failure without rejecting healthy siblings', async () => {
|
||||
const home = await createHome()
|
||||
await installSkill(home, '.agents', 'orca-cli')
|
||||
|
||||
const inventory = await inventoryManagedSkills({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot,
|
||||
candidateLstat: async (path) => {
|
||||
if (path.includes(`${join(home, '.codex')}`) && path.endsWith('orca-cli')) {
|
||||
throw Object.assign(new Error('denied'), { code: 'EACCES' })
|
||||
}
|
||||
return lstat(path)
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
inventory.installations.find(
|
||||
(entry) => entry.name === 'orca-cli' && entry.status === 'inaccessible'
|
||||
)
|
||||
).toMatchObject({ errorCategory: 'skill-candidate-access-denied' })
|
||||
expect(
|
||||
inventory.installations.find(
|
||||
(entry) => entry.name === 'orca-cli' && entry.status === 'known-current'
|
||||
)
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('classifies a topology race without rejecting healthy siblings', async () => {
|
||||
const home = await createHome()
|
||||
await installSkill(home, '.agents', 'orca-cli')
|
||||
await installSkill(home, '.codex', 'orca-cli')
|
||||
|
||||
const inventory = await inventoryManagedSkills({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot,
|
||||
classifyTopology: async (root, path, canonicalRootPath) => {
|
||||
if (root.id === 'home-codex' && path.endsWith('orca-cli')) {
|
||||
throw Object.assign(new Error('raced'), { code: 'EIO' })
|
||||
}
|
||||
return classifySkillInstallationTopology(root, path, canonicalRootPath)
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
inventory.installations.find(
|
||||
(entry) => entry.name === 'orca-cli' && entry.status === 'inaccessible'
|
||||
)
|
||||
).toMatchObject({ errorCategory: 'skill-candidate-topology-failed' })
|
||||
expect(
|
||||
inventory.installations.find(
|
||||
(entry) => entry.name === 'orca-cli' && entry.status === 'known-current'
|
||||
)
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'deduplicates a provider alias into its canonical physical copy',
|
||||
async () => {
|
||||
const home = await createHome()
|
||||
const destination = await installSkill(home, '.agents', 'orca-cli')
|
||||
const alias = join(home, '.claude', 'skills', 'orca-cli')
|
||||
await mkdir(dirname(alias), { recursive: true })
|
||||
await symlink(relative(dirname(alias), destination), alias)
|
||||
|
||||
const inventory = await inventoryManagedSkills({
|
||||
ledger: emptySkillManagementLedger(),
|
||||
hostId: 'local',
|
||||
homeDir: home,
|
||||
resourceRoot
|
||||
})
|
||||
const matches = inventory.installations.filter((entry) => entry.name === 'orca-cli')
|
||||
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0]).toMatchObject({
|
||||
topology: 'canonical-copy',
|
||||
providers: expect.arrayContaining(['agent-skills', 'codex', 'claude'])
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { Stats } from 'node:fs'
|
||||
import { lstat, realpath } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { summarizeSkillMarkdown } from '../../shared/skill-metadata'
|
||||
import type {
|
||||
SkillCurrentBundleEntry,
|
||||
SkillManagementInstallation,
|
||||
SkillManagementInventory,
|
||||
SkillManagementLedger,
|
||||
SkillManagementStatus
|
||||
} from '../../shared/skill-management'
|
||||
import { buildSkillDiscoverySources, type SkillScanRoot } from './skill-discovery-sources'
|
||||
import { loadSkillBundleArtifacts, type SkillBundleArtifacts } from './skill-bundle-artifacts'
|
||||
import { matchingReleasedSnapshot, observeSkillPackage } from './skill-package-identity'
|
||||
import { skillAdoptionDismissalKey } from './skill-management-ledger'
|
||||
import {
|
||||
createInaccessibleSkillCandidate,
|
||||
type ClassifiedSkillCandidate
|
||||
} from './skill-management-inaccessible-candidate'
|
||||
import {
|
||||
classifySkillInstallationTopology,
|
||||
type ClassifiedSkillTopology,
|
||||
normalizedSkillIdentityPath,
|
||||
skillDestinationId,
|
||||
skillTopologyPriority
|
||||
} from './skill-installation-topology'
|
||||
import { readOfficialSkillsCliLockEntries } from './skills-cli-lock-provenance'
|
||||
|
||||
type CandidateLstat = (path: string) => Promise<Stats>
|
||||
type CandidateTopology = (
|
||||
root: SkillScanRoot,
|
||||
unresolvedPath: string,
|
||||
canonicalRootPath: string
|
||||
) => Promise<ClassifiedSkillTopology>
|
||||
|
||||
function knownStatus(
|
||||
managed: boolean,
|
||||
installedRevision: number,
|
||||
currentRevision: number
|
||||
): SkillManagementStatus {
|
||||
if (installedRevision > currentRevision) {
|
||||
return 'newer-known'
|
||||
}
|
||||
if (installedRevision < currentRevision) {
|
||||
return managed ? 'managed-update-available' : 'known-update-available'
|
||||
}
|
||||
return managed ? 'managed-current' : 'known-current'
|
||||
}
|
||||
|
||||
async function classifyCandidate(args: {
|
||||
root: SkillScanRoot
|
||||
name: string
|
||||
current: SkillCurrentBundleEntry
|
||||
artifacts: SkillBundleArtifacts
|
||||
ledger: SkillManagementLedger
|
||||
hostId: ExecutionHostId
|
||||
homeIdentity: string
|
||||
canonicalRootPath: string
|
||||
lockCorroboratedNames: ReadonlySet<string>
|
||||
actionsSupported: boolean
|
||||
candidateLstat: CandidateLstat
|
||||
classifyTopology: CandidateTopology
|
||||
}): Promise<ClassifiedSkillCandidate | null> {
|
||||
const unresolvedPath = join(args.root.path, args.name)
|
||||
try {
|
||||
await args.candidateLstat(unresolvedPath)
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
return createInaccessibleSkillCandidate({ ...args, unresolvedPath, error })
|
||||
}
|
||||
let topology: ClassifiedSkillTopology
|
||||
try {
|
||||
topology = await args.classifyTopology(args.root, unresolvedPath, args.canonicalRootPath)
|
||||
} catch (error) {
|
||||
return createInaccessibleSkillCandidate({
|
||||
...args,
|
||||
unresolvedPath,
|
||||
error,
|
||||
fallbackErrorCategory: 'skill-candidate-topology-failed'
|
||||
})
|
||||
}
|
||||
const base = {
|
||||
hostId: args.hostId,
|
||||
name: args.name,
|
||||
description: null,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
unresolvedPath,
|
||||
resolvedPath: topology.resolvedPath,
|
||||
physicalIdentity: topology.identity,
|
||||
topology: topology.topology,
|
||||
currentReleaseRevision: args.current.releaseRevision,
|
||||
currentPackageDigest: args.current.packageDigest,
|
||||
currentAppVersion: args.current.appVersion,
|
||||
errorCategory: topology.errorCategory,
|
||||
adoptionPromptEligible: false,
|
||||
lockCorroborated: args.root.id === 'home-agents' && args.lockCorroboratedNames.has(args.name),
|
||||
actionsSupported: args.hostId === 'local' && args.actionsSupported
|
||||
}
|
||||
if (!topology.resolvedPath || !topology.identity) {
|
||||
return {
|
||||
logicalEntryType: topology.entryType,
|
||||
installation: {
|
||||
...base,
|
||||
id: skillDestinationId(args.hostId, unresolvedPath, args.name),
|
||||
status: 'inaccessible',
|
||||
eligible: false,
|
||||
managed: false,
|
||||
installedReleaseRevision: null,
|
||||
installedAppVersion: null,
|
||||
installedPackageDigest: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const id = skillDestinationId(args.hostId, unresolvedPath, args.name)
|
||||
const managedRecord = args.ledger.destinations[id]
|
||||
if (topology.topology === 'external-link' || topology.topology === 'read-only') {
|
||||
return {
|
||||
logicalEntryType: topology.entryType,
|
||||
installation: {
|
||||
...base,
|
||||
id,
|
||||
status: 'externally-managed',
|
||||
eligible: false,
|
||||
managed: false,
|
||||
installedReleaseRevision: null,
|
||||
installedAppVersion: null,
|
||||
installedPackageDigest: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const observed = await observeSkillPackage(topology.resolvedPath)
|
||||
const skillMarkdown = observed.files.find((file) => file.path === 'SKILL.md')
|
||||
const description = skillMarkdown
|
||||
? summarizeSkillMarkdown(skillMarkdown.bytes.toString('utf8')).description
|
||||
: null
|
||||
const match = matchingReleasedSnapshot(
|
||||
observed,
|
||||
args.artifacts.releasedSnapshots[args.name] ?? []
|
||||
)
|
||||
const managed = Boolean(
|
||||
managedRecord &&
|
||||
managedRecord.hostId === args.hostId &&
|
||||
managedRecord.homeIdentity === args.homeIdentity &&
|
||||
managedRecord.physicalIdentity === topology.identity &&
|
||||
match &&
|
||||
managedRecord.installedPackageDigest === match.packageDigest
|
||||
)
|
||||
return {
|
||||
logicalEntryType: topology.entryType,
|
||||
installation: {
|
||||
...base,
|
||||
description,
|
||||
id,
|
||||
status: match
|
||||
? managed && managedRecord?.lastOutcome === 'failed'
|
||||
? 'update-failed'
|
||||
: knownStatus(managed, match.releaseRevision, args.current.releaseRevision)
|
||||
: managedRecord
|
||||
? 'modified'
|
||||
: 'unknown',
|
||||
eligible: Boolean(match),
|
||||
managed,
|
||||
installedReleaseRevision: match?.releaseRevision ?? null,
|
||||
installedAppVersion: match
|
||||
? (args.artifacts.releasedAppVersions[args.name]?.[match.releaseRevision] ?? null)
|
||||
: null,
|
||||
installedPackageDigest: match?.packageDigest ?? observed.observedDigest
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
logicalEntryType: topology.entryType,
|
||||
installation: {
|
||||
...base,
|
||||
id,
|
||||
status: managedRecord ? 'modified' : 'unknown',
|
||||
eligible: false,
|
||||
managed: false,
|
||||
installedReleaseRevision: null,
|
||||
installedAppVersion: null,
|
||||
installedPackageDigest: null,
|
||||
errorCategory: error instanceof Error ? error.message : 'read-failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function inventoryManagedSkills(args: {
|
||||
ledger: SkillManagementLedger
|
||||
hostId: ExecutionHostId
|
||||
homeDir: string
|
||||
resourceRoot?: string
|
||||
xdgStateHome?: string | null
|
||||
actionsSupported?: boolean
|
||||
candidateLstat?: CandidateLstat
|
||||
classifyTopology?: CandidateTopology
|
||||
}): Promise<SkillManagementInventory> {
|
||||
const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
|
||||
const roots = buildSkillDiscoverySources({ homeDir: args.homeDir, cwd: args.homeDir }).filter(
|
||||
(root) => root.sourceKind === 'home'
|
||||
)
|
||||
const canonicalRootPath = roots.find((root) => root.id === 'home-agents')?.path
|
||||
if (!canonicalRootPath) {
|
||||
throw new Error('Missing canonical agent skills root')
|
||||
}
|
||||
const homeIdentity = normalizedSkillIdentityPath(
|
||||
await realpath(args.homeDir).catch(() => resolve(args.homeDir))
|
||||
)
|
||||
const lockCorroboratedNames = await readOfficialSkillsCliLockEntries({
|
||||
homeDir: args.homeDir,
|
||||
xdgStateHome:
|
||||
args.xdgStateHome === undefined
|
||||
? args.hostId === 'local'
|
||||
? process.env.XDG_STATE_HOME
|
||||
: null
|
||||
: args.xdgStateHome
|
||||
})
|
||||
const candidates = await Promise.all(
|
||||
artifacts.manifest.skills.flatMap((current) =>
|
||||
roots.map((root) =>
|
||||
classifyCandidate({
|
||||
root,
|
||||
name: current.name,
|
||||
current,
|
||||
artifacts,
|
||||
ledger: args.ledger,
|
||||
hostId: args.hostId,
|
||||
homeIdentity,
|
||||
canonicalRootPath,
|
||||
lockCorroboratedNames,
|
||||
actionsSupported: args.actionsSupported ?? true,
|
||||
candidateLstat: args.candidateLstat ?? ((path) => lstat(path)),
|
||||
classifyTopology: args.classifyTopology ?? classifySkillInstallationTopology
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const deduped = new Map<string, SkillManagementInstallation>()
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
const installation = candidate.installation
|
||||
const key =
|
||||
installation.physicalIdentity && installation.topology !== 'external-link'
|
||||
? `${installation.hostId}\0${installation.name}\0${installation.physicalIdentity}`
|
||||
: `${installation.hostId}\0${installation.unresolvedPath}`
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const dismissed = new Set(args.ledger.dismissedAdoptionCandidates.map(skillAdoptionDismissalKey))
|
||||
const installations = [...deduped.values()].sort(
|
||||
(left, right) =>
|
||||
left.name.localeCompare(right.name) || left.unresolvedPath.localeCompare(right.unresolvedPath)
|
||||
)
|
||||
for (const installation of installations) {
|
||||
installation.adoptionPromptEligible = Boolean(
|
||||
installation.eligible &&
|
||||
!installation.managed &&
|
||||
installation.physicalIdentity &&
|
||||
installation.installedPackageDigest &&
|
||||
!dismissed.has(
|
||||
skillAdoptionDismissalKey({
|
||||
hostId: installation.hostId,
|
||||
physicalIdentity: installation.physicalIdentity,
|
||||
skillName: installation.name,
|
||||
snapshotDigest: installation.installedPackageDigest
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
const adoptionCandidateCount = installations.filter(
|
||||
(installation) => installation.adoptionPromptEligible
|
||||
).length
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
hostId: args.hostId,
|
||||
installations,
|
||||
adoptionCandidateCount,
|
||||
scannedAt: Date.now()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ManagedSkillDestination } from '../../shared/skill-management'
|
||||
import { normalizeSkillManagementLedger } from './skill-management-ledger'
|
||||
|
||||
function destination(): ManagedSkillDestination {
|
||||
const digest = 'a'.repeat(64)
|
||||
return {
|
||||
id: 'destination',
|
||||
hostId: 'local',
|
||||
homeIdentity: '/home/alice',
|
||||
rootId: 'home-agents',
|
||||
unresolvedPath: '/home/alice/.agents/skills/orca-cli',
|
||||
resolvedPath: '/home/alice/.agents/skills/orca-cli',
|
||||
physicalIdentity: '1:2',
|
||||
entryType: 'directory',
|
||||
skillName: 'orca-cli',
|
||||
source: 'stablyai/orca',
|
||||
sourcePath: 'skills/orca-cli',
|
||||
sourceRef: null,
|
||||
installedReleaseRevision: 1,
|
||||
installedPackageDigest: digest,
|
||||
installedFiles: [
|
||||
{
|
||||
path: 'SKILL.md',
|
||||
size: 1,
|
||||
executable: false,
|
||||
classification: 'text',
|
||||
exactSha256: digest,
|
||||
textNormalizedSha256: digest,
|
||||
identitySha256: digest
|
||||
}
|
||||
],
|
||||
lastWrittenPackageDigest: null,
|
||||
lastAttemptedBundleFingerprint: digest,
|
||||
lastOutcome: 'adopted',
|
||||
lastErrorCategory: null,
|
||||
adoptedFrom: 'exact-snapshot',
|
||||
adoptedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('skill management ledger parsing', () => {
|
||||
it('retains a completely valid destination', () => {
|
||||
const record = destination()
|
||||
expect(
|
||||
normalizeSkillManagementLedger({
|
||||
schemaVersion: 1,
|
||||
destinations: { [record.id]: record },
|
||||
dismissedAdoptionCandidates: []
|
||||
}).destinations[record.id]
|
||||
).toEqual(record)
|
||||
})
|
||||
|
||||
it('drops a destination when any write-authority identity is malformed', () => {
|
||||
const record = destination()
|
||||
const malformed = { ...record, physicalIdentity: undefined }
|
||||
expect(
|
||||
normalizeSkillManagementLedger({
|
||||
schemaVersion: 1,
|
||||
destinations: { [record.id]: malformed },
|
||||
dismissedAdoptionCandidates: []
|
||||
}).destinations
|
||||
).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import type {
|
||||
DismissedSkillAdoptionCandidate,
|
||||
ManagedSkillDestination,
|
||||
SkillManagementLedger
|
||||
} from '../../shared/skill-management'
|
||||
import { normalizeExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/
|
||||
|
||||
export function emptySkillManagementLedger(): SkillManagementLedger {
|
||||
return { schemaVersion: 1, destinations: {}, dismissedAdoptionCandidates: [] }
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === 'string'
|
||||
}
|
||||
|
||||
function isFileIdentity(value: unknown): boolean {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.path === 'string' &&
|
||||
value.path.length > 0 &&
|
||||
!value.path.startsWith('/') &&
|
||||
!value.path.includes('\\') &&
|
||||
!value.path.split('/').includes('..') &&
|
||||
Number.isSafeInteger(value.size) &&
|
||||
(value.size as number) >= 0 &&
|
||||
typeof value.executable === 'boolean' &&
|
||||
(value.classification === 'text' || value.classification === 'binary') &&
|
||||
typeof value.exactSha256 === 'string' &&
|
||||
SHA256.test(value.exactSha256) &&
|
||||
(value.textNormalizedSha256 === null ||
|
||||
(typeof value.textNormalizedSha256 === 'string' &&
|
||||
SHA256.test(value.textNormalizedSha256))) &&
|
||||
typeof value.identitySha256 === 'string' &&
|
||||
SHA256.test(value.identitySha256)
|
||||
)
|
||||
}
|
||||
|
||||
function isManagedDestination(key: string, value: unknown): value is ManagedSkillDestination {
|
||||
if (!isRecord(value)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
value.id === key &&
|
||||
typeof value.hostId === 'string' &&
|
||||
normalizeExecutionHostId(value.hostId) === value.hostId &&
|
||||
typeof value.homeIdentity === 'string' &&
|
||||
typeof value.rootId === 'string' &&
|
||||
typeof value.unresolvedPath === 'string' &&
|
||||
typeof value.resolvedPath === 'string' &&
|
||||
typeof value.physicalIdentity === 'string' &&
|
||||
(value.entryType === 'directory' ||
|
||||
value.entryType === 'symlink' ||
|
||||
value.entryType === 'junction') &&
|
||||
typeof value.skillName === 'string' &&
|
||||
value.source === 'stablyai/orca' &&
|
||||
typeof value.sourcePath === 'string' &&
|
||||
isNullableString(value.sourceRef) &&
|
||||
Number.isSafeInteger(value.installedReleaseRevision) &&
|
||||
(value.installedReleaseRevision as number) >= 0 &&
|
||||
typeof value.installedPackageDigest === 'string' &&
|
||||
SHA256.test(value.installedPackageDigest) &&
|
||||
Array.isArray(value.installedFiles) &&
|
||||
value.installedFiles.every(isFileIdentity) &&
|
||||
isNullableString(value.lastWrittenPackageDigest) &&
|
||||
(value.lastWrittenPackageDigest === null || SHA256.test(value.lastWrittenPackageDigest)) &&
|
||||
isNullableString(value.lastAttemptedBundleFingerprint) &&
|
||||
(value.lastAttemptedBundleFingerprint === null ||
|
||||
SHA256.test(value.lastAttemptedBundleFingerprint)) &&
|
||||
(value.lastOutcome === 'adopted' ||
|
||||
value.lastOutcome === 'updated' ||
|
||||
value.lastOutcome === 'replaced' ||
|
||||
value.lastOutcome === 'failed') &&
|
||||
isNullableString(value.lastErrorCategory) &&
|
||||
(value.adoptedFrom === 'orca-install' ||
|
||||
value.adoptedFrom === 'exact-snapshot' ||
|
||||
value.adoptedFrom === 'explicit-replacement') &&
|
||||
typeof value.adoptedAt === 'number' &&
|
||||
Number.isFinite(value.adoptedAt) &&
|
||||
typeof value.updatedAt === 'number' &&
|
||||
Number.isFinite(value.updatedAt)
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeSkillManagementLedger(value: unknown): SkillManagementLedger {
|
||||
if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.destinations)) {
|
||||
return emptySkillManagementLedger()
|
||||
}
|
||||
|
||||
const destinations = Object.fromEntries(
|
||||
Object.entries(value.destinations).filter((entry): entry is [string, ManagedSkillDestination] =>
|
||||
isManagedDestination(entry[0], entry[1])
|
||||
)
|
||||
)
|
||||
const dismissedAdoptionCandidates = Array.isArray(value.dismissedAdoptionCandidates)
|
||||
? value.dismissedAdoptionCandidates.filter(
|
||||
(candidate): candidate is DismissedSkillAdoptionCandidate =>
|
||||
isRecord(candidate) &&
|
||||
typeof candidate.hostId === 'string' &&
|
||||
typeof candidate.physicalIdentity === 'string' &&
|
||||
typeof candidate.skillName === 'string' &&
|
||||
typeof candidate.snapshotDigest === 'string' &&
|
||||
typeof candidate.dismissedAt === 'number'
|
||||
)
|
||||
: []
|
||||
|
||||
return { schemaVersion: 1, destinations, dismissedAdoptionCandidates }
|
||||
}
|
||||
|
||||
export function skillAdoptionDismissalKey(
|
||||
candidate: Pick<
|
||||
DismissedSkillAdoptionCandidate,
|
||||
'hostId' | 'physicalIdentity' | 'skillName' | 'snapshotDigest'
|
||||
>
|
||||
): string {
|
||||
return [
|
||||
candidate.hostId,
|
||||
candidate.physicalIdentity,
|
||||
candidate.skillName,
|
||||
candidate.snapshotDigest
|
||||
].join('\0')
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canMutateManagedSkills, type SkillMutationRuntimeFacts } from './skill-mutation-authority'
|
||||
|
||||
const temporaryRoot = join(process.cwd(), '.tmp-authority')
|
||||
|
||||
function facts(overrides: Partial<SkillMutationRuntimeFacts> = {}): SkillMutationRuntimeFacts {
|
||||
return {
|
||||
isPackaged: true,
|
||||
buildIdentity: 'stable',
|
||||
homeDir: '/real/home',
|
||||
userDataDir: '/real/user-data',
|
||||
temporaryRoot,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('skill mutation authority', () => {
|
||||
it('allows only the packaged stable build channel by default', () => {
|
||||
expect(canMutateManagedSkills(facts())).toBe(true)
|
||||
expect(canMutateManagedSkills(facts({ buildIdentity: 'rc' }))).toBe(false)
|
||||
expect(canMutateManagedSkills(facts({ buildIdentity: null }))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unpackaged access to a real home', () => {
|
||||
expect(canMutateManagedSkills(facts({ isPackaged: false, buildIdentity: null }))).toBe(false)
|
||||
})
|
||||
|
||||
it('allows unpackaged tests only when both actual roots match isolated temporary roots', () => {
|
||||
const homeDir = join(temporaryRoot, 'home')
|
||||
const userDataDir = join(temporaryRoot, 'user-data')
|
||||
expect(
|
||||
canMutateManagedSkills(
|
||||
facts({
|
||||
isPackaged: false,
|
||||
buildIdentity: null,
|
||||
homeDir,
|
||||
userDataDir,
|
||||
isolatedDevHomeDir: homeDir,
|
||||
isolatedDevUserDataDir: userDataDir
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects channel and root-isolation mismatches', () => {
|
||||
const homeDir = join(temporaryRoot, 'home')
|
||||
const userDataDir = join(temporaryRoot, 'user-data')
|
||||
expect(
|
||||
canMutateManagedSkills(
|
||||
facts({
|
||||
isPackaged: false,
|
||||
buildIdentity: 'rc',
|
||||
homeDir,
|
||||
userDataDir,
|
||||
isolatedDevHomeDir: '/real/home',
|
||||
isolatedDevUserDataDir: userDataDir
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
canMutateManagedSkills(
|
||||
facts({
|
||||
isPackaged: false,
|
||||
buildIdentity: null,
|
||||
homeDir,
|
||||
userDataDir: homeDir,
|
||||
isolatedDevHomeDir: homeDir,
|
||||
isolatedDevUserDataDir: homeDir
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
|
||||
export type SkillMutationRuntimeFacts = {
|
||||
isPackaged: boolean
|
||||
buildIdentity: 'stable' | 'rc' | null
|
||||
homeDir: string
|
||||
userDataDir: string
|
||||
isolatedDevHomeDir?: string | null
|
||||
isolatedDevUserDataDir?: string | null
|
||||
temporaryRoot: string
|
||||
}
|
||||
|
||||
function samePath(left: string, right: string): boolean {
|
||||
const normalize = (value: string): string =>
|
||||
process.platform === 'win32' ? resolve(value).toLocaleLowerCase('en-US') : resolve(value)
|
||||
return normalize(left) === normalize(right)
|
||||
}
|
||||
|
||||
function isStrictChild(path: string, root: string): boolean {
|
||||
const candidate = relative(resolve(root), resolve(path))
|
||||
return (
|
||||
candidate.length > 0 &&
|
||||
candidate !== '..' &&
|
||||
!candidate.startsWith(`..${sep}`) &&
|
||||
!isAbsolute(candidate)
|
||||
)
|
||||
}
|
||||
|
||||
export function canMutateManagedSkills(facts: SkillMutationRuntimeFacts): boolean {
|
||||
if (facts.isPackaged) {
|
||||
// Why: the build identity is CI-pinned for official artifacts; runtime or
|
||||
// renderer flags cannot promote an RC/custom package into a stable writer.
|
||||
return facts.buildIdentity === 'stable'
|
||||
}
|
||||
if (!facts.isolatedDevHomeDir || !facts.isolatedDevUserDataDir) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
samePath(facts.homeDir, facts.isolatedDevHomeDir) &&
|
||||
samePath(facts.userDataDir, facts.isolatedDevUserDataDir) &&
|
||||
!samePath(facts.homeDir, facts.userDataDir) &&
|
||||
isStrictChild(facts.homeDir, facts.temporaryRoot) &&
|
||||
isStrictChild(facts.userDataDir, facts.temporaryRoot) &&
|
||||
!isStrictChild(facts.homeDir, facts.userDataDir) &&
|
||||
!isStrictChild(facts.userDataDir, facts.homeDir)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { lstat, mkdir, readFile, readdir, realpath, rm } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import type { SkillManagementLedger } from '../../shared/skill-management'
|
||||
import { normalizedSkillIdentityPath } from './skill-installation-topology'
|
||||
import {
|
||||
parseSkillTransactionMarker,
|
||||
type SkillTransactionMarker
|
||||
} from './skill-transaction-marker'
|
||||
import { RESERVED_SKILL_TRANSACTION_DIRECTORY } from './skill-transaction-paths'
|
||||
import { acquireSkillDestinationLock } from './skill-transaction-recovery'
|
||||
import { recoverTransactionToPrior } from './skill-transaction-restore'
|
||||
|
||||
async function assertApprovedDestinationParent(
|
||||
marker: SkillTransactionMarker,
|
||||
rootIdentity: string
|
||||
): Promise<void> {
|
||||
if (normalizedSkillIdentityPath(dirname(resolve(marker.destinationPath))) !== rootIdentity) {
|
||||
throw new Error('skill-transaction-destination-outside-root')
|
||||
}
|
||||
const [parentStat, parentIdentity] = await Promise.all([
|
||||
lstat(dirname(marker.destinationPath)),
|
||||
realpath(dirname(marker.destinationPath)).then(normalizedSkillIdentityPath)
|
||||
])
|
||||
if (parentStat.isSymbolicLink() || parentIdentity !== rootIdentity) {
|
||||
throw new Error('skill-transaction-destination-outside-root')
|
||||
}
|
||||
}
|
||||
|
||||
export async function sweepOrphanedSkillTransactions(
|
||||
approvedSkillsRoot: string,
|
||||
ledger: SkillManagementLedger
|
||||
): Promise<void> {
|
||||
const linkedRoot = await Promise.all([
|
||||
lstat(approvedSkillsRoot),
|
||||
lstat(dirname(approvedSkillsRoot))
|
||||
])
|
||||
.then((entries) => entries.some((entry) => entry.isSymbolicLink()))
|
||||
.catch(() => true)
|
||||
if (linkedRoot) {
|
||||
return
|
||||
}
|
||||
const rootIdentity = normalizedSkillIdentityPath(await realpath(approvedSkillsRoot))
|
||||
const reservedRoots = [
|
||||
join(dirname(approvedSkillsRoot), RESERVED_SKILL_TRANSACTION_DIRECTORY),
|
||||
join(approvedSkillsRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY)
|
||||
]
|
||||
for (const reservedRoot of reservedRoots) {
|
||||
const reservedRootSafe = await lstat(reservedRoot)
|
||||
.then((entry) => entry.isDirectory() && !entry.isSymbolicLink())
|
||||
.catch(() => false)
|
||||
if (!reservedRootSafe) {
|
||||
continue
|
||||
}
|
||||
const entries = await readdir(reservedRoot, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === 'locks') {
|
||||
continue
|
||||
}
|
||||
const transactionRoot = join(reservedRoot, entry.name)
|
||||
const marker = await readFile(join(transactionRoot, 'transaction.json'), 'utf8')
|
||||
.then(parseSkillTransactionMarker)
|
||||
.catch(() => null)
|
||||
if (!marker || marker.hostId !== 'local') {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await assertApprovedDestinationParent(marker, rootIdentity)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const locksRoot = join(reservedRoot, 'locks')
|
||||
await mkdir(locksRoot, { recursive: true })
|
||||
const locksRootSafe = await lstat(locksRoot).then(
|
||||
(lockStat) => lockStat.isDirectory() && !lockStat.isSymbolicLink()
|
||||
)
|
||||
if (!locksRootSafe) {
|
||||
continue
|
||||
}
|
||||
let release: (() => Promise<void>) | null = null
|
||||
try {
|
||||
release = await acquireSkillDestinationLock(join(locksRoot, marker.destinationId))
|
||||
const record = ledger.destinations[marker.destinationId]
|
||||
const committed = Boolean(
|
||||
marker.phase === 'verified' &&
|
||||
record?.hostId === marker.hostId &&
|
||||
normalizedSkillIdentityPath(record.resolvedPath) ===
|
||||
normalizedSkillIdentityPath(marker.destinationPath) &&
|
||||
record.installedPackageDigest === marker.currentSnapshot.packageDigest
|
||||
)
|
||||
await recoverTransactionToPrior({
|
||||
transactionRoot,
|
||||
marker,
|
||||
assertParentAuthority: () => assertApprovedDestinationParent(marker, rootIdentity),
|
||||
currentDisposition: committed ? 'committed' : 'restore'
|
||||
})
|
||||
await rm(transactionRoot, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.message !== 'skill-update-busy') {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
await release?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, open, readFile, type FileHandle } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { SkillBundleFileIdentity } from '../../shared/skill-management'
|
||||
import { skillPhysicalIdentity } from './skill-installation-topology'
|
||||
import { describeObservedSkillFile, matchesFileIdentity } from './skill-package-identity'
|
||||
import type { PublicationCallbacks } from './skill-package-publication'
|
||||
import { retrySkillRename } from './skill-transaction-rename'
|
||||
|
||||
export type SkillOwnedTempRuntime = {
|
||||
readSource?: (path: string) => Promise<Buffer>
|
||||
writeBytes?: (handle: FileHandle, bytes: Buffer) => Promise<void>
|
||||
chmodTemp?: (handle: FileHandle, mode: number) => Promise<void>
|
||||
syncTemp?: (handle: FileHandle) => Promise<void>
|
||||
}
|
||||
|
||||
export async function publishSkillFileThroughOwnedTemp(args: {
|
||||
destinationRoot: string
|
||||
sourcePath: string
|
||||
expectedFile: SkillBundleFileIdentity
|
||||
direction: 'forward' | 'rollback'
|
||||
packagePhysicalIdentity: string
|
||||
operationIndex: number
|
||||
callbacks: PublicationCallbacks
|
||||
runtime?: SkillOwnedTempRuntime
|
||||
}): Promise<void> {
|
||||
const livePath = join(args.destinationRoot, ...args.expectedFile.path.split('/'))
|
||||
const temporaryRelativePath = `${args.expectedFile.path}.orca-${randomUUID()}.tmp`
|
||||
const temporaryPath = join(args.destinationRoot, ...temporaryRelativePath.split('/'))
|
||||
const applying = {
|
||||
kind: 'in-place',
|
||||
direction: args.direction,
|
||||
physicalIdentity: args.packagePhysicalIdentity,
|
||||
temporary: null,
|
||||
operationIndex: args.operationIndex,
|
||||
operationState: 'applying'
|
||||
} as const
|
||||
await args.callbacks.assertBoundary(applying)
|
||||
await mkdir(dirname(livePath), { recursive: true })
|
||||
await args.callbacks.assertBoundary(applying)
|
||||
const mode = args.expectedFile.executable ? 0o755 : 0o644
|
||||
const handle = await open(temporaryPath, 'wx', mode)
|
||||
const creating = {
|
||||
...applying,
|
||||
temporary: {
|
||||
path: temporaryRelativePath,
|
||||
physicalIdentity: skillPhysicalIdentity(temporaryPath, await handle.stat()),
|
||||
expectedFile: args.expectedFile,
|
||||
state: 'creating'
|
||||
}
|
||||
} as const
|
||||
try {
|
||||
// Why: partial bytes are recoverable only after the exclusively created
|
||||
// inode is durably recorded as transaction-owned.
|
||||
await args.callbacks.journal(creating)
|
||||
await args.callbacks.assertBoundary(creating)
|
||||
const bytes = await (args.runtime?.readSource ?? readFile)(args.sourcePath)
|
||||
if (
|
||||
!matchesFileIdentity(
|
||||
describeObservedSkillFile(args.expectedFile.path, bytes, args.expectedFile.executable),
|
||||
args.expectedFile
|
||||
)
|
||||
) {
|
||||
throw new Error('skill-owned-temp-source-changed')
|
||||
}
|
||||
await args.callbacks.assertBoundary(creating)
|
||||
await (args.runtime?.writeBytes ?? ((target, value) => target.writeFile(value)))(handle, bytes)
|
||||
const written = {
|
||||
...creating,
|
||||
temporary: { ...creating.temporary, state: 'written' as const }
|
||||
}
|
||||
await args.callbacks.assertBoundary(written)
|
||||
await args.callbacks.journal(written)
|
||||
if (process.platform !== 'win32') {
|
||||
await args.callbacks.assertBoundary(written)
|
||||
await (args.runtime?.chmodTemp ?? ((target, nextMode) => target.chmod(nextMode)))(
|
||||
handle,
|
||||
mode
|
||||
)
|
||||
}
|
||||
const ready = {
|
||||
...written,
|
||||
temporary: { ...written.temporary, state: 'ready' as const }
|
||||
}
|
||||
await args.callbacks.assertBoundary(ready)
|
||||
await args.callbacks.journal(ready)
|
||||
await args.callbacks.assertBoundary(ready)
|
||||
await (args.runtime?.syncTemp ?? ((target) => target.sync()))(handle)
|
||||
await args.callbacks.assertBoundary(ready)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
const ready = {
|
||||
...creating,
|
||||
temporary: { ...creating.temporary, state: 'ready' as const }
|
||||
}
|
||||
await args.callbacks.assertBoundary(ready)
|
||||
await retrySkillRename(temporaryPath, livePath, () => args.callbacks.assertBoundary(ready))
|
||||
const applied = {
|
||||
...ready,
|
||||
temporary: { ...ready.temporary, state: 'applied' as const }
|
||||
}
|
||||
await args.callbacks.assertBoundary(applied)
|
||||
await args.callbacks.journal(applied)
|
||||
// Why: do not discard the temp inode proof if another writer replaces the
|
||||
// exact-content target while the applied marker is being persisted.
|
||||
await args.callbacks.assertBoundary(applied)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
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, SkillReleasedSnapshot } from '../../shared/skill-management'
|
||||
|
||||
type ObservedSkillFile = SkillBundleFileIdentity & { bytes: Buffer }
|
||||
|
||||
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 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: normalized && !executable ? sha256(normalized) : exactSha256,
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
||||
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')
|
||||
}
|
||||
if (fileStat.size > limits.maximumSingleFileBytes) {
|
||||
throw new Error('skill-package-file-size-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 matchingReleasedSnapshot(
|
||||
observed: ObservedSkillPackage,
|
||||
snapshots: readonly SkillReleasedSnapshot[]
|
||||
): SkillReleasedSnapshot | null {
|
||||
for (const snapshot of snapshots.toReversed()) {
|
||||
if (snapshot.files.length !== observed.files.length) {
|
||||
continue
|
||||
}
|
||||
const matches = snapshot.files.every((expected, index) => {
|
||||
const actual = observed.files[index]
|
||||
return Boolean(actual && matchesFileIdentity(actual, expected))
|
||||
})
|
||||
if (matches) {
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function persistedObservedFiles(observed: ObservedSkillPackage): SkillBundleFileIdentity[] {
|
||||
return observed.files.map(({ bytes: _bytes, ...file }) => file)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { observeSkillPackage } from './skill-package-identity'
|
||||
|
||||
const roots: string[] = []
|
||||
const baseLimits = {
|
||||
maximumDepth: 4,
|
||||
maximumEntries: 8,
|
||||
maximumFiles: 4,
|
||||
maximumSingleFileBytes: 8,
|
||||
maximumTotalBytes: 16
|
||||
}
|
||||
|
||||
async function root(): Promise<string> {
|
||||
const value = await mkdtemp(join(tmpdir(), 'orca-skill-observation-'))
|
||||
roots.push(value)
|
||||
return value
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((value) => rm(value, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('skill package observation limits', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'depth',
|
||||
limits: { ...baseLimits, maximumDepth: 1 },
|
||||
prepare: async (directory: string) =>
|
||||
mkdir(join(directory, 'one', 'two'), { recursive: true }),
|
||||
error: 'skill-package-depth-limit'
|
||||
},
|
||||
{
|
||||
name: 'entries',
|
||||
limits: { ...baseLimits, maximumEntries: 1 },
|
||||
prepare: async (directory: string) => {
|
||||
await writeFile(join(directory, 'a'), 'a')
|
||||
await writeFile(join(directory, 'b'), 'b')
|
||||
},
|
||||
error: 'skill-package-entry-limit'
|
||||
},
|
||||
{
|
||||
name: 'files',
|
||||
limits: { ...baseLimits, maximumFiles: 1 },
|
||||
prepare: async (directory: string) => {
|
||||
await writeFile(join(directory, 'a'), 'a')
|
||||
await writeFile(join(directory, 'b'), 'b')
|
||||
},
|
||||
error: 'skill-package-file-count-limit'
|
||||
},
|
||||
{
|
||||
name: 'single file bytes',
|
||||
limits: { ...baseLimits, maximumSingleFileBytes: 1 },
|
||||
prepare: async (directory: string) => writeFile(join(directory, 'a'), 'ab'),
|
||||
error: 'skill-package-file-size-limit'
|
||||
},
|
||||
{
|
||||
name: 'aggregate bytes',
|
||||
limits: { ...baseLimits, maximumTotalBytes: 2 },
|
||||
prepare: async (directory: string) => {
|
||||
await writeFile(join(directory, 'a'), 'ab')
|
||||
await writeFile(join(directory, 'b'), 'c')
|
||||
},
|
||||
error: 'skill-package-total-size-limit'
|
||||
}
|
||||
])('fails closed at the $name limit', async ({ limits, prepare, error }) => {
|
||||
const directory = await root()
|
||||
await prepare(directory)
|
||||
await expect(observeSkillPackage(directory, limits)).rejects.toThrow(error)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,445 @@
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { ManagedSkillDestination, SkillReleasedSnapshot } from '../../shared/skill-management'
|
||||
import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
|
||||
import type { SkillOwnedTempRuntime } from './skill-owned-temp-publication'
|
||||
import { publishSkillPackageInPlace } from './skill-package-publication'
|
||||
import { skillPhysicalIdentity } from './skill-installation-topology'
|
||||
import type { SkillTransactionMarker } from './skill-transaction-marker'
|
||||
import { assertTransactionOwnedState } from './skill-transaction-owned-state'
|
||||
import type { SkillPublicationState } from './skill-transaction-publication-state'
|
||||
import { retrySkillRename } from './skill-transaction-rename'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function createPackage(root: string, values: Record<string, string>): Promise<void> {
|
||||
await mkdir(root, { recursive: true })
|
||||
for (const [path, contents] of Object.entries(values)) {
|
||||
await writeFile(join(root, path), contents)
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshot(root: string, releaseRevision: number): Promise<SkillReleasedSnapshot> {
|
||||
const observed = await observeSkillPackage(root)
|
||||
return {
|
||||
releaseRevision,
|
||||
packageDigest: observed.observedDigest,
|
||||
gitTreeSha: '',
|
||||
files: persistedObservedFiles(observed)
|
||||
}
|
||||
}
|
||||
|
||||
async function swapFixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-swap-authority-'))
|
||||
roots.push(root)
|
||||
const live = join(root, 'skills', 'orca-cli')
|
||||
const transactionRoot = join(root, 'transaction')
|
||||
const stageRoot = join(transactionRoot, 'stage')
|
||||
const backupRoot = join(transactionRoot, 'backup')
|
||||
await createPackage(live, { 'SKILL.md': 'old entry', 'asset.txt': 'old asset' })
|
||||
await createPackage(stageRoot, { 'SKILL.md': 'new entry', 'asset.txt': 'new asset' })
|
||||
await cp(live, backupRoot, { recursive: true })
|
||||
const prior = await snapshot(live, 1)
|
||||
const current = await snapshot(stageRoot, 2)
|
||||
const record = {
|
||||
resolvedPath: live,
|
||||
physicalIdentity: skillPhysicalIdentity(live, await stat(live))
|
||||
} as ManagedSkillDestination
|
||||
let marker: SkillTransactionMarker = {
|
||||
schemaVersion: 1,
|
||||
transactionId: 'test',
|
||||
createdAt: 1,
|
||||
destinationId: '000000000000000000000000',
|
||||
destinationPath: live,
|
||||
physicalIdentity: record.physicalIdentity,
|
||||
hostId: 'local',
|
||||
skillName: 'orca-cli',
|
||||
phase: 'publishing',
|
||||
stagePhysicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
|
||||
backupPhysicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
|
||||
stageBuild: {
|
||||
state: 'ready',
|
||||
physicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
|
||||
operationIndex: current.files.length,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
},
|
||||
backupBuild: {
|
||||
state: 'ready',
|
||||
physicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
|
||||
operationIndex: prior.files.length,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
},
|
||||
publication: null,
|
||||
priorSnapshot: prior,
|
||||
currentSnapshot: current
|
||||
}
|
||||
const history: SkillPublicationState[] = []
|
||||
const journal = async (publication: SkillPublicationState): Promise<void> => {
|
||||
marker = { ...marker, publication }
|
||||
history.push(publication)
|
||||
}
|
||||
const assertBoundary = async (
|
||||
publication: SkillPublicationState,
|
||||
expectedLiveState?: Awaited<ReturnType<typeof assertTransactionOwnedState>>
|
||||
): Promise<void> => {
|
||||
const liveState = await assertTransactionOwnedState({
|
||||
transactionRoot,
|
||||
marker: { ...marker, publication },
|
||||
assertParent: async () => undefined,
|
||||
allowAppliedOperation: false
|
||||
})
|
||||
if (expectedLiveState && liveState !== expectedLiveState) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
}
|
||||
return {
|
||||
live,
|
||||
transactionRoot,
|
||||
stageRoot,
|
||||
oldLiveRoot: join(transactionRoot, 'old-live'),
|
||||
record,
|
||||
prior,
|
||||
current: { ...current, name: 'orca-cli', sourcePath: '', appVersion: '1.0.0' },
|
||||
callbacks: { journal, assertBoundary },
|
||||
history,
|
||||
marker: () => marker
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('skill package publication boundaries', () => {
|
||||
it('rejects a temp replacement made while the creating journal is awaited', async () => {
|
||||
const fixture = await swapFixture()
|
||||
let replaced = false
|
||||
const journal = async (publication: SkillPublicationState): Promise<void> => {
|
||||
await fixture.callbacks.journal(publication)
|
||||
if (
|
||||
!replaced &&
|
||||
publication.kind === 'in-place' &&
|
||||
publication.temporary?.state === 'creating'
|
||||
) {
|
||||
replaced = true
|
||||
const temporaryPath = join(fixture.live, ...publication.temporary.path.split('/'))
|
||||
await rm(temporaryPath)
|
||||
await cp(join(fixture.stageRoot, publication.temporary.expectedFile.path), temporaryPath)
|
||||
}
|
||||
}
|
||||
|
||||
await expect(
|
||||
publishSkillPackageInPlace({
|
||||
record: fixture.record,
|
||||
prior: fixture.prior,
|
||||
current: fixture.current,
|
||||
stageRoot: fixture.stageRoot,
|
||||
callbacks: { ...fixture.callbacks, journal }
|
||||
})
|
||||
).rejects.toThrow('skill-transaction-recovery-required')
|
||||
expect(replaced).toBe(true)
|
||||
expect(await readFile(join(fixture.live, 'asset.txt'), 'utf8')).toBe('old asset')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
boundary: 'source read',
|
||||
runtime: (): SkillOwnedTempRuntime => ({
|
||||
readSource: async (source) => {
|
||||
const bytes = await readFile(source)
|
||||
await writeFile(source, 'independent source edit')
|
||||
return bytes
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
boundary: 'handle write',
|
||||
runtime: (fixture: Awaited<ReturnType<typeof swapFixture>>): SkillOwnedTempRuntime => ({
|
||||
writeBytes: async (handle, bytes) => {
|
||||
await handle.writeFile(bytes)
|
||||
await writeFile(join(fixture.live, 'SKILL.md'), 'independent live edit')
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
boundary: 'chmod',
|
||||
runtime: (fixture: Awaited<ReturnType<typeof swapFixture>>): SkillOwnedTempRuntime => ({
|
||||
chmodTemp: async (handle, mode) => {
|
||||
await handle.chmod(mode)
|
||||
await writeFile(join(fixture.live, 'SKILL.md'), 'independent live edit')
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
boundary: 'sync',
|
||||
runtime: (fixture: Awaited<ReturnType<typeof swapFixture>>): SkillOwnedTempRuntime => ({
|
||||
syncTemp: async (handle) => {
|
||||
await handle.sync()
|
||||
await writeFile(join(fixture.live, 'SKILL.md'), 'independent live edit')
|
||||
}
|
||||
})
|
||||
}
|
||||
])('rechecks authority after the $boundary await', async ({ runtime }) => {
|
||||
const fixture = await swapFixture()
|
||||
await expect(
|
||||
publishSkillPackageInPlace({
|
||||
record: fixture.record,
|
||||
prior: fixture.prior,
|
||||
current: fixture.current,
|
||||
stageRoot: fixture.stageRoot,
|
||||
callbacks: fixture.callbacks,
|
||||
ownedTempRuntime: runtime(fixture)
|
||||
})
|
||||
).rejects.toThrow('skill-transaction-recovery-required')
|
||||
})
|
||||
|
||||
it('retains creating evidence when handle write mutates then throws', async () => {
|
||||
const fixture = await swapFixture()
|
||||
await expect(
|
||||
publishSkillPackageInPlace({
|
||||
record: fixture.record,
|
||||
prior: fixture.prior,
|
||||
current: fixture.current,
|
||||
stageRoot: fixture.stageRoot,
|
||||
callbacks: fixture.callbacks,
|
||||
ownedTempRuntime: {
|
||||
writeBytes: async (handle, bytes) => {
|
||||
await handle.write(bytes.subarray(0, 1))
|
||||
throw new Error('injected-write-failure')
|
||||
}
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('injected-write-failure')
|
||||
expect(fixture.history.at(-1)).toMatchObject({
|
||||
kind: 'in-place',
|
||||
temporary: { state: 'creating' }
|
||||
})
|
||||
expect(await readFile(join(fixture.live, 'asset.txt'), 'utf8')).toBe('old asset')
|
||||
})
|
||||
|
||||
it('journals each forward temp inode through creating, written, ready, and applied', async () => {
|
||||
const fixture = await swapFixture()
|
||||
await publishSkillPackageInPlace({
|
||||
record: fixture.record,
|
||||
prior: fixture.prior,
|
||||
current: fixture.current,
|
||||
stageRoot: fixture.stageRoot,
|
||||
callbacks: fixture.callbacks
|
||||
})
|
||||
|
||||
const states = fixture.history.flatMap((publication) =>
|
||||
publication.kind === 'in-place' && publication.temporary ? [publication.temporary.state] : []
|
||||
)
|
||||
expect(states).toEqual([
|
||||
'creating',
|
||||
'written',
|
||||
'ready',
|
||||
'applied',
|
||||
'creating',
|
||||
'written',
|
||||
'ready',
|
||||
'applied'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an exact wrong-inode target replacement after temp rename', async () => {
|
||||
const fixture = await swapFixture()
|
||||
let replaced = false
|
||||
const journal = async (publication: SkillPublicationState): Promise<void> => {
|
||||
await fixture.callbacks.journal(publication)
|
||||
if (
|
||||
!replaced &&
|
||||
publication.kind === 'in-place' &&
|
||||
publication.temporary?.state === 'applied'
|
||||
) {
|
||||
replaced = true
|
||||
const target = join(fixture.live, publication.temporary.expectedFile.path)
|
||||
await rm(target)
|
||||
await cp(join(fixture.stageRoot, publication.temporary.expectedFile.path), target)
|
||||
}
|
||||
}
|
||||
|
||||
await expect(
|
||||
publishSkillPackageInPlace({
|
||||
record: fixture.record,
|
||||
prior: fixture.prior,
|
||||
current: fixture.current,
|
||||
stageRoot: fixture.stageRoot,
|
||||
callbacks: { ...fixture.callbacks, journal }
|
||||
})
|
||||
).rejects.toThrow('skill-transaction-recovery-required')
|
||||
expect(replaced).toBe(true)
|
||||
})
|
||||
|
||||
it('does not overwrite an in-place edit made between ordered file operations', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-publication-boundary-'))
|
||||
roots.push(root)
|
||||
const live = join(root, 'skills', 'orca-cli')
|
||||
const transactionRoot = join(root, 'transaction')
|
||||
const stageRoot = join(transactionRoot, 'stage')
|
||||
const backupRoot = join(transactionRoot, 'backup')
|
||||
await createPackage(live, { 'SKILL.md': 'old entry', 'asset.txt': 'old asset' })
|
||||
await createPackage(stageRoot, { 'SKILL.md': 'new entry', 'asset.txt': 'new asset' })
|
||||
await cp(live, backupRoot, { recursive: true })
|
||||
const prior = await snapshot(live, 1)
|
||||
const current = await snapshot(stageRoot, 2)
|
||||
const record = {
|
||||
resolvedPath: live,
|
||||
physicalIdentity: skillPhysicalIdentity(live, await stat(live))
|
||||
} as ManagedSkillDestination
|
||||
let marker: SkillTransactionMarker = {
|
||||
schemaVersion: 1,
|
||||
transactionId: 'test',
|
||||
createdAt: 1,
|
||||
destinationId: '000000000000000000000000',
|
||||
destinationPath: live,
|
||||
physicalIdentity: record.physicalIdentity,
|
||||
hostId: 'local',
|
||||
skillName: 'orca-cli',
|
||||
phase: 'publishing',
|
||||
stagePhysicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
|
||||
backupPhysicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
|
||||
stageBuild: {
|
||||
state: 'ready',
|
||||
physicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
|
||||
operationIndex: current.files.length,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
},
|
||||
backupBuild: {
|
||||
state: 'ready',
|
||||
physicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
|
||||
operationIndex: prior.files.length,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
},
|
||||
publication: null,
|
||||
priorSnapshot: prior,
|
||||
currentSnapshot: current
|
||||
}
|
||||
const journal = async (publication: SkillPublicationState): Promise<void> => {
|
||||
marker = { ...marker, publication }
|
||||
}
|
||||
let edited = false
|
||||
const assertBoundary = async (publication: SkillPublicationState): Promise<void> => {
|
||||
if (
|
||||
!edited &&
|
||||
publication.kind === 'in-place' &&
|
||||
publication.operationState === 'applying' &&
|
||||
publication.operationIndex === 1
|
||||
) {
|
||||
edited = true
|
||||
await writeFile(join(live, 'SKILL.md'), 'independent edit')
|
||||
}
|
||||
await assertTransactionOwnedState({
|
||||
transactionRoot,
|
||||
marker: { ...marker, publication },
|
||||
assertParent: async () => undefined,
|
||||
allowAppliedOperation: false
|
||||
})
|
||||
}
|
||||
|
||||
await expect(
|
||||
publishSkillPackageInPlace({
|
||||
record,
|
||||
current: { ...current, name: 'orca-cli', sourcePath: '', appVersion: '1.0.0' },
|
||||
prior,
|
||||
stageRoot,
|
||||
callbacks: { journal, assertBoundary }
|
||||
})
|
||||
).rejects.toThrow('skill-transaction-recovery-required')
|
||||
|
||||
expect(await readFile(join(live, 'asset.txt'), 'utf8')).toBe('new asset')
|
||||
expect(await readFile(join(live, 'SKILL.md'), 'utf8')).toBe('independent edit')
|
||||
expect(marker.publication).toMatchObject({ operationIndex: 1, operationState: 'applying' })
|
||||
})
|
||||
|
||||
it('revalidates after a sharing-violation retry window', async () => {
|
||||
let renameAttempts = 0
|
||||
let boundaryChecks = 0
|
||||
let changedDuringWait = false
|
||||
const renameEntry = async (): Promise<void> => {
|
||||
renameAttempts += 1
|
||||
if (renameAttempts === 1) {
|
||||
throw Object.assign(new Error('busy'), { code: 'EPERM' })
|
||||
}
|
||||
}
|
||||
await expect(
|
||||
retrySkillRename(
|
||||
'source',
|
||||
'destination',
|
||||
async () => {
|
||||
boundaryChecks += 1
|
||||
if (changedDuringWait) {
|
||||
throw new Error('skill-content-changed')
|
||||
}
|
||||
},
|
||||
renameEntry,
|
||||
async () => {
|
||||
changedDuringWait = true
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('skill-content-changed')
|
||||
expect(renameAttempts).toBe(1)
|
||||
expect(boundaryChecks).toBe(2)
|
||||
})
|
||||
|
||||
it.each(['replaced', 'missing'] as const)(
|
||||
'does not rename a %s journaled temp file',
|
||||
async (mutation) => {
|
||||
const fixture = await swapFixture()
|
||||
let activeMarker = fixture.marker()
|
||||
let mutated = false
|
||||
const journal = async (publication: SkillPublicationState): Promise<void> => {
|
||||
activeMarker = { ...activeMarker, publication }
|
||||
}
|
||||
const assertBoundary = async (publication: SkillPublicationState): Promise<void> => {
|
||||
if (!mutated && publication.kind === 'in-place' && publication.temporary) {
|
||||
mutated = true
|
||||
const temporaryPath = join(fixture.live, ...publication.temporary.path.split('/'))
|
||||
await rm(temporaryPath)
|
||||
if (mutation === 'replaced') {
|
||||
await cp(
|
||||
join(fixture.stageRoot, publication.temporary.expectedFile.path),
|
||||
temporaryPath
|
||||
)
|
||||
}
|
||||
}
|
||||
await assertTransactionOwnedState({
|
||||
transactionRoot: fixture.transactionRoot,
|
||||
marker: { ...activeMarker, publication },
|
||||
assertParent: async () => undefined,
|
||||
allowAppliedOperation: false
|
||||
})
|
||||
}
|
||||
await expect(
|
||||
publishSkillPackageInPlace({
|
||||
record: fixture.record,
|
||||
prior: fixture.prior,
|
||||
current: fixture.current,
|
||||
stageRoot: fixture.stageRoot,
|
||||
callbacks: { journal, assertBoundary }
|
||||
})
|
||||
).rejects.toThrow('skill-transaction-recovery-required')
|
||||
expect(await readFile(join(fixture.live, 'asset.txt'), 'utf8')).toBe('old asset')
|
||||
if (mutation === 'replaced') {
|
||||
expect(
|
||||
await readFile(
|
||||
join(
|
||||
fixture.live,
|
||||
...(activeMarker.publication?.kind === 'in-place' &&
|
||||
activeMarker.publication.temporary
|
||||
? activeMarker.publication.temporary.path.split('/')
|
||||
: [])
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).toBe('new asset')
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { rmdir, unlink } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type {
|
||||
ManagedSkillDestination,
|
||||
SkillCurrentBundleEntry,
|
||||
SkillReleasedSnapshot
|
||||
} from '../../shared/skill-management'
|
||||
import {
|
||||
skillInPlaceOperations,
|
||||
type SkillPublicationState
|
||||
} from './skill-transaction-publication-state'
|
||||
import type { SkillTransactionLiveState } from './skill-transaction-owned-state'
|
||||
import { publishSkillFileThroughOwnedTemp } from './skill-owned-temp-publication'
|
||||
import type { SkillOwnedTempRuntime } from './skill-owned-temp-publication'
|
||||
import { retrySkillRename, type SkillRenameRuntime } from './skill-transaction-rename'
|
||||
|
||||
export type PublicationCallbacks = {
|
||||
journal: (state: SkillPublicationState) => Promise<void>
|
||||
assertBoundary: (
|
||||
state: SkillPublicationState,
|
||||
expectedLiveState?: SkillTransactionLiveState
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
export type SkillPackageSwapOutcome =
|
||||
| { kind: 'complete' }
|
||||
| { kind: 'pre-mutation-sharing-violation'; error: unknown }
|
||||
|
||||
export function destinationUsesCrlf(record: ManagedSkillDestination): boolean {
|
||||
return record.installedFiles.some(
|
||||
(file) =>
|
||||
file.classification === 'text' &&
|
||||
file.textNormalizedSha256 !== null &&
|
||||
file.exactSha256 !== file.textNormalizedSha256
|
||||
)
|
||||
}
|
||||
|
||||
export async function publishSkillPackageInPlace(args: {
|
||||
record: ManagedSkillDestination
|
||||
current: SkillCurrentBundleEntry
|
||||
prior: SkillReleasedSnapshot
|
||||
stageRoot: string
|
||||
callbacks: PublicationCallbacks
|
||||
ownedTempRuntime?: SkillOwnedTempRuntime
|
||||
}): Promise<void> {
|
||||
const operations = skillInPlaceOperations(args.prior, args.current)
|
||||
const currentByPath = new Map(args.current.files.map((file) => [file.path, file]))
|
||||
await args.callbacks.journal({
|
||||
kind: 'in-place',
|
||||
direction: 'forward',
|
||||
physicalIdentity: args.record.physicalIdentity,
|
||||
temporary: null,
|
||||
operationIndex: 0,
|
||||
operationState: 'ready'
|
||||
})
|
||||
for (const [operationIndex, operation] of operations.entries()) {
|
||||
const applying = {
|
||||
kind: 'in-place',
|
||||
direction: 'forward',
|
||||
physicalIdentity: args.record.physicalIdentity,
|
||||
temporary: null,
|
||||
operationIndex,
|
||||
operationState: 'applying'
|
||||
} as const
|
||||
await args.callbacks.journal(applying)
|
||||
const livePath = join(args.record.resolvedPath, ...operation.path.split('/'))
|
||||
if (operation.kind === 'publish') {
|
||||
const file = currentByPath.get(operation.path)
|
||||
if (!file) {
|
||||
throw new Error('skill-publication-operation-invalid')
|
||||
}
|
||||
await publishSkillFileThroughOwnedTemp({
|
||||
destinationRoot: args.record.resolvedPath,
|
||||
sourcePath: join(args.stageRoot, ...file.path.split('/')),
|
||||
expectedFile: file,
|
||||
direction: 'forward',
|
||||
packagePhysicalIdentity: args.record.physicalIdentity,
|
||||
operationIndex,
|
||||
callbacks: args.callbacks,
|
||||
runtime: args.ownedTempRuntime
|
||||
})
|
||||
} else {
|
||||
await args.callbacks.assertBoundary(applying)
|
||||
await unlink(livePath)
|
||||
}
|
||||
const ready = {
|
||||
kind: 'in-place',
|
||||
direction: 'forward',
|
||||
physicalIdentity: args.record.physicalIdentity,
|
||||
temporary: null,
|
||||
operationIndex: operationIndex + 1,
|
||||
operationState: 'ready'
|
||||
} as const
|
||||
await args.callbacks.journal(ready)
|
||||
if (operation.kind === 'remove') {
|
||||
let parent = dirname(livePath)
|
||||
while (parent !== args.record.resolvedPath) {
|
||||
await args.callbacks.assertBoundary(ready)
|
||||
await rmdir(parent).catch(() => undefined)
|
||||
parent = dirname(parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function publishPackageSwap(args: {
|
||||
record: ManagedSkillDestination
|
||||
stageRoot: string
|
||||
transactionRoot: string
|
||||
callbacks: PublicationCallbacks
|
||||
renameRuntime?: SkillRenameRuntime
|
||||
}): Promise<SkillPackageSwapOutcome> {
|
||||
const oldLiveRoot = join(args.transactionRoot, 'old-live')
|
||||
const movingLive = { kind: 'package-swap', step: 'moving-live' } as const
|
||||
await args.callbacks.journal(movingLive)
|
||||
try {
|
||||
await retrySkillRename(
|
||||
args.record.resolvedPath,
|
||||
oldLiveRoot,
|
||||
() => args.callbacks.assertBoundary(movingLive, 'prior'),
|
||||
args.renameRuntime?.renameEntry,
|
||||
args.renameRuntime?.wait
|
||||
)
|
||||
} catch (error) {
|
||||
if (
|
||||
isSharingViolation(error, args.renameRuntime?.windowsFallback ?? process.platform === 'win32')
|
||||
) {
|
||||
return { kind: 'pre-mutation-sharing-violation', error }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
await args.callbacks.journal({ kind: 'package-swap', step: 'live-moved' })
|
||||
const movingStage = { kind: 'package-swap', step: 'moving-stage' } as const
|
||||
await args.callbacks.journal(movingStage)
|
||||
try {
|
||||
await retrySkillRename(
|
||||
args.stageRoot,
|
||||
args.record.resolvedPath,
|
||||
() => args.callbacks.assertBoundary(movingStage, 'swap-absent'),
|
||||
args.renameRuntime?.renameEntry,
|
||||
args.renameRuntime?.wait
|
||||
)
|
||||
} catch (error) {
|
||||
// Why: only the marker-owned old-live directory may fill the verified gap.
|
||||
await retrySkillRename(
|
||||
oldLiveRoot,
|
||||
args.record.resolvedPath,
|
||||
() => args.callbacks.assertBoundary(movingStage, 'swap-absent'),
|
||||
args.renameRuntime?.renameEntry,
|
||||
args.renameRuntime?.wait
|
||||
)
|
||||
await args.callbacks.journal(movingLive)
|
||||
throw error
|
||||
}
|
||||
await args.callbacks.journal({ kind: 'package-swap', step: 'stage-moved' })
|
||||
// Why: old-live retains the original directory identity until the ledger commit.
|
||||
await args.callbacks.journal({ kind: 'package-swap', step: 'complete' })
|
||||
return { kind: 'complete' }
|
||||
}
|
||||
|
||||
function isSharingViolation(error: unknown, enabled: boolean): boolean {
|
||||
return Boolean(
|
||||
enabled &&
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
(error.code === 'EPERM' || error.code === 'EBUSY')
|
||||
)
|
||||
}
|
||||
|
||||
export async function rollbackSkillPackageSwap(args: {
|
||||
record: ManagedSkillDestination
|
||||
transactionRoot: string
|
||||
callbacks: PublicationCallbacks
|
||||
renameRuntime?: SkillRenameRuntime
|
||||
}): Promise<void> {
|
||||
const stageRoot = join(args.transactionRoot, 'stage')
|
||||
const oldLiveRoot = join(args.transactionRoot, 'old-live')
|
||||
const movingCurrent = { kind: 'package-swap', step: 'rollback-moving-current' } as const
|
||||
await args.callbacks.journal(movingCurrent)
|
||||
await retrySkillRename(
|
||||
args.record.resolvedPath,
|
||||
stageRoot,
|
||||
() => args.callbacks.assertBoundary(movingCurrent, 'current'),
|
||||
args.renameRuntime?.renameEntry,
|
||||
args.renameRuntime?.wait
|
||||
)
|
||||
await args.callbacks.journal({ kind: 'package-swap', step: 'rollback-current-moved' })
|
||||
const movingOldLive = { kind: 'package-swap', step: 'rollback-moving-old-live' } as const
|
||||
await args.callbacks.journal(movingOldLive)
|
||||
await retrySkillRename(
|
||||
oldLiveRoot,
|
||||
args.record.resolvedPath,
|
||||
() => args.callbacks.assertBoundary(movingOldLive, 'swap-absent'),
|
||||
args.renameRuntime?.renameEntry,
|
||||
args.renameRuntime?.wait
|
||||
)
|
||||
await args.callbacks.journal({ kind: 'package-swap', step: 'moving-live' })
|
||||
}
|
||||
|
||||
export async function replaceSkillPackage(args: {
|
||||
record: ManagedSkillDestination
|
||||
current: SkillCurrentBundleEntry
|
||||
prior: SkillReleasedSnapshot
|
||||
stageRoot: string
|
||||
transactionRoot: string
|
||||
callbacks: PublicationCallbacks
|
||||
renameRuntime?: SkillRenameRuntime
|
||||
}): Promise<void> {
|
||||
const outcome = await publishPackageSwap(args)
|
||||
if (outcome.kind === 'pre-mutation-sharing-violation') {
|
||||
// Why: the in-place journal may replace swap state only while live is untouched.
|
||||
await args.callbacks.assertBoundary({ kind: 'package-swap', step: 'moving-live' }, 'prior')
|
||||
await publishSkillPackageInPlace(args)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
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 {
|
||||
ManagedSkillDestination,
|
||||
SkillCurrentBundleEntry,
|
||||
SkillReleasedSnapshot
|
||||
} from '../../shared/skill-management'
|
||||
import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
|
||||
import { publishBundledSkill } from './skill-package-publish'
|
||||
import { skillPhysicalIdentity } from './skill-installation-topology'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function writePackage(root: string, prefix: string): Promise<void> {
|
||||
await mkdir(root, { recursive: true })
|
||||
await writeFile(join(root, 'SKILL.md'), `${prefix} entry`)
|
||||
await writeFile(join(root, 'asset.txt'), `${prefix} asset`)
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-publish-identity-'))
|
||||
roots.push(root)
|
||||
const live = join(root, 'home', '.agents', 'skills', 'orca-cli')
|
||||
const packagesRoot = join(root, 'packages')
|
||||
const currentRoot = join(packagesRoot, 'orca-cli')
|
||||
await writePackage(live, 'old')
|
||||
await writePackage(currentRoot, 'new')
|
||||
const priorObserved = await observeSkillPackage(live)
|
||||
const currentObserved = await observeSkillPackage(currentRoot)
|
||||
const prior: SkillReleasedSnapshot = {
|
||||
releaseRevision: 1,
|
||||
packageDigest: priorObserved.observedDigest,
|
||||
gitTreeSha: '',
|
||||
files: persistedObservedFiles(priorObserved)
|
||||
}
|
||||
const current: SkillCurrentBundleEntry = {
|
||||
name: 'orca-cli',
|
||||
sourcePath: 'skills/orca-cli',
|
||||
appVersion: '2.0.0',
|
||||
releaseRevision: 2,
|
||||
packageDigest: currentObserved.observedDigest,
|
||||
gitTreeSha: '',
|
||||
files: persistedObservedFiles(currentObserved)
|
||||
}
|
||||
const resolvedLive = await realpath(live)
|
||||
const record: ManagedSkillDestination = {
|
||||
id: '000000000000000000000000',
|
||||
hostId: 'local',
|
||||
homeIdentity: join(root, 'home'),
|
||||
rootId: 'home-agents',
|
||||
unresolvedPath: live,
|
||||
resolvedPath: resolvedLive,
|
||||
physicalIdentity: skillPhysicalIdentity(resolvedLive, await stat(resolvedLive)),
|
||||
entryType: 'directory',
|
||||
skillName: 'orca-cli',
|
||||
source: 'stablyai/orca',
|
||||
sourcePath: 'skills/orca-cli',
|
||||
sourceRef: null,
|
||||
installedReleaseRevision: 1,
|
||||
installedPackageDigest: prior.packageDigest,
|
||||
installedFiles: prior.files,
|
||||
lastWrittenPackageDigest: null,
|
||||
lastAttemptedBundleFingerprint: null,
|
||||
lastOutcome: 'adopted',
|
||||
lastErrorCategory: null,
|
||||
adoptedFrom: 'exact-snapshot',
|
||||
adoptedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
return { root, live, packagesRoot, prior, current, record }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('skill package durable identity', () => {
|
||||
it.each(['package-swap', 'in-place'] as const)(
|
||||
'does not commit an exact-content independent replacement after %s publication',
|
||||
async (mode) => {
|
||||
const value = await fixture()
|
||||
const replacement = join(value.root, 'independent-replacement')
|
||||
await cp(join(value.packagesRoot, 'orca-cli'), replacement, { recursive: true })
|
||||
let committed = false
|
||||
let replacementInjected = false
|
||||
|
||||
await expect(
|
||||
publishBundledSkill({
|
||||
record: value.record,
|
||||
current: value.current,
|
||||
releasedSnapshots: [value.prior, value.current],
|
||||
packagesRoot: value.packagesRoot,
|
||||
commit: () => {
|
||||
committed = true
|
||||
},
|
||||
publicationRenameRuntime:
|
||||
mode === 'in-place'
|
||||
? {
|
||||
windowsFallback: true,
|
||||
renameEntry: async () => {
|
||||
throw Object.assign(new Error('busy'), { code: 'EPERM' })
|
||||
},
|
||||
wait: async () => undefined
|
||||
}
|
||||
: undefined,
|
||||
beforeDurableCommitValidation: async () => {
|
||||
replacementInjected = true
|
||||
await rm(value.live, { recursive: true })
|
||||
await rename(replacement, value.live)
|
||||
}
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(replacementInjected).toBe(true)
|
||||
expect(committed).toBe(false)
|
||||
expect((await observeSkillPackage(value.live)).observedDigest).toBe(
|
||||
value.current.packageDigest
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['before-entry', 'before-workspace'] as const)(
|
||||
'writes no workspace through a parent link swapped %s',
|
||||
async (timing) => {
|
||||
const value = await fixture()
|
||||
const agentsRoot = join(value.root, 'home', '.agents')
|
||||
const originalAgentsRoot = join(value.root, 'original-agents')
|
||||
const externalAgentsRoot = join(value.root, 'external-agents')
|
||||
const swapParent = async (): Promise<void> => {
|
||||
await rename(agentsRoot, originalAgentsRoot)
|
||||
await mkdir(join(externalAgentsRoot, 'skills'), { recursive: true })
|
||||
await cp(
|
||||
join(originalAgentsRoot, 'skills', 'orca-cli'),
|
||||
join(externalAgentsRoot, 'skills', 'orca-cli'),
|
||||
{ recursive: true }
|
||||
)
|
||||
await symlink(
|
||||
externalAgentsRoot,
|
||||
agentsRoot,
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
}
|
||||
if (timing === 'before-entry') {
|
||||
await swapParent()
|
||||
}
|
||||
|
||||
await expect(
|
||||
publishBundledSkill({
|
||||
record: value.record,
|
||||
current: value.current,
|
||||
releasedSnapshots: [value.prior, value.current],
|
||||
packagesRoot: value.packagesRoot,
|
||||
commit: () => undefined,
|
||||
beforeWorkspaceCreationValidation: timing === 'before-workspace' ? swapParent : undefined
|
||||
})
|
||||
).rejects.toThrow('skill-topology-changed')
|
||||
|
||||
expect(
|
||||
await lstat(join(externalAgentsRoot, '.orca-skill-transactions')).catch(() => null)
|
||||
).toBeNull()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { mkdir, rm, stat } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type {
|
||||
ManagedSkillDestination,
|
||||
SkillCurrentBundleEntry
|
||||
} from '../../shared/skill-management'
|
||||
import { skillPhysicalIdentity } from './skill-installation-topology'
|
||||
import {
|
||||
matchingReleasedSnapshot,
|
||||
observeSkillPackage,
|
||||
persistedObservedFiles
|
||||
} from './skill-package-identity'
|
||||
import {
|
||||
acquireSkillDestinationLock,
|
||||
recoverMarkedSkillTransactions
|
||||
} from './skill-transaction-recovery'
|
||||
import { destinationUsesCrlf, replaceSkillPackage } from './skill-package-publication'
|
||||
import type { SkillRenameRuntime } from './skill-transaction-rename'
|
||||
import { createSkillTransactionContext } from './skill-package-transaction-context'
|
||||
import {
|
||||
assertManagedDestinationUnchanged,
|
||||
assertPublishedDestinationOwned
|
||||
} from './skill-published-destination-authority'
|
||||
import { recoverTransactionToPrior } from './skill-transaction-restore'
|
||||
import { buildSkillTransactionPackage } from './skill-transaction-package-build'
|
||||
import { assertSkillTransactionWorkspace } from './skill-transaction-workspace'
|
||||
|
||||
export async function publishBundledSkill(args: {
|
||||
record: ManagedSkillDestination
|
||||
current: SkillCurrentBundleEntry
|
||||
releasedSnapshots: Parameters<typeof matchingReleasedSnapshot>[1]
|
||||
packagesRoot: string
|
||||
commit: (destination: ManagedSkillDestination) => void | Promise<void>
|
||||
now?: number
|
||||
beforeDurableCommitValidation?: () => void | Promise<void>
|
||||
beforeWorkspaceCreationValidation?: () => void | Promise<void>
|
||||
publicationRenameRuntime?: SkillRenameRuntime
|
||||
}): Promise<ManagedSkillDestination> {
|
||||
if (args.current.releaseRevision <= args.record.installedReleaseRevision) {
|
||||
await assertManagedDestinationUnchanged(args.record, args.releasedSnapshots)
|
||||
await args.commit(args.record)
|
||||
return args.record
|
||||
}
|
||||
await assertManagedDestinationUnchanged(args.record, args.releasedSnapshots)
|
||||
const transaction = await createSkillTransactionContext({
|
||||
record: args.record,
|
||||
current: args.current,
|
||||
releasedSnapshots: args.releasedSnapshots,
|
||||
now: args.now ?? Date.now(),
|
||||
beforeWorkspaceValidation: args.beforeWorkspaceCreationValidation
|
||||
})
|
||||
// Why: no workspace directory may be created after a parent or mount swap.
|
||||
await transaction.assertAuthority(true)
|
||||
await mkdir(dirname(transaction.paths.lockRoot), { recursive: true })
|
||||
await transaction.assertAuthority(true)
|
||||
const releaseLock = await acquireSkillDestinationLock(transaction.paths.lockRoot, () =>
|
||||
transaction.assertAuthority(true)
|
||||
)
|
||||
let retainTransaction = false
|
||||
let publicationStarted = false
|
||||
try {
|
||||
await transaction.assertAuthority(true)
|
||||
await recoverMarkedSkillTransactions({
|
||||
reservedRoot: transaction.paths.reservedRoot,
|
||||
record: args.record
|
||||
})
|
||||
await transaction.assertAuthority(true)
|
||||
await mkdir(transaction.paths.transactionRoot, { recursive: true })
|
||||
await transaction.assertAuthority(true)
|
||||
await transaction.writeMarker('staging', true)
|
||||
await buildSkillTransactionPackage({
|
||||
sourceRoot: join(args.packagesRoot, args.record.skillName),
|
||||
root: transaction.paths.stageRoot,
|
||||
snapshot: transaction.marker('staging').currentSnapshot,
|
||||
crlf: destinationUsesCrlf(args.record),
|
||||
assertSourceAuthority: () => transaction.assertAuthority(true),
|
||||
journal: transaction.journalStageBuild,
|
||||
assertBoundary: transaction.assertStageBuild
|
||||
})
|
||||
await buildSkillTransactionPackage({
|
||||
sourceRoot: args.record.resolvedPath,
|
||||
root: transaction.paths.backupRoot,
|
||||
snapshot: transaction.priorSnapshot,
|
||||
crlf: false,
|
||||
assertSourceAuthority: () => transaction.assertAuthority(true),
|
||||
journal: transaction.journalBackupBuild,
|
||||
assertBoundary: transaction.assertBackupBuild
|
||||
})
|
||||
await transaction.assertAuthority(true)
|
||||
publicationStarted = true
|
||||
await replaceSkillPackage({
|
||||
...args,
|
||||
prior: transaction.priorSnapshot,
|
||||
stageRoot: transaction.paths.stageRoot,
|
||||
transactionRoot: transaction.paths.transactionRoot,
|
||||
callbacks: transaction.callbacks,
|
||||
renameRuntime: args.publicationRenameRuntime
|
||||
})
|
||||
const installed = await observeSkillPackage(args.record.resolvedPath)
|
||||
if (!matchingReleasedSnapshot(installed, [args.current])) {
|
||||
throw new Error('published-skill-package-invalid')
|
||||
}
|
||||
const now = args.now ?? Date.now()
|
||||
const publishingMarker = transaction.marker('publishing')
|
||||
const nextResolvedPath = await assertPublishedDestinationOwned({
|
||||
record: args.record,
|
||||
transactionRoot: transaction.paths.transactionRoot,
|
||||
marker: publishingMarker
|
||||
})
|
||||
const nextPhysicalIdentity = skillPhysicalIdentity(
|
||||
nextResolvedPath,
|
||||
await stat(nextResolvedPath)
|
||||
)
|
||||
const destination: ManagedSkillDestination = {
|
||||
...args.record,
|
||||
resolvedPath: nextResolvedPath,
|
||||
physicalIdentity: nextPhysicalIdentity,
|
||||
installedReleaseRevision: args.current.releaseRevision,
|
||||
installedPackageDigest: args.current.packageDigest,
|
||||
installedFiles: persistedObservedFiles(installed),
|
||||
lastWrittenPackageDigest: args.current.packageDigest,
|
||||
lastAttemptedBundleFingerprint: args.current.packageDigest,
|
||||
lastOutcome: 'updated',
|
||||
lastErrorCategory: null,
|
||||
updatedAt: now
|
||||
}
|
||||
const verifiedMarker = transaction.marker('verified')
|
||||
await transaction.writeVerifiedMarker(async () => {
|
||||
await assertPublishedDestinationOwned({
|
||||
record: args.record,
|
||||
transactionRoot: transaction.paths.transactionRoot,
|
||||
marker: verifiedMarker
|
||||
})
|
||||
})
|
||||
// Why: the rollback package remains marked until durable Orca ownership
|
||||
// state agrees with the verified live package.
|
||||
await args.beforeDurableCommitValidation?.()
|
||||
await assertPublishedDestinationOwned({
|
||||
record: args.record,
|
||||
transactionRoot: transaction.paths.transactionRoot,
|
||||
marker: verifiedMarker
|
||||
})
|
||||
await args.commit(destination)
|
||||
return destination
|
||||
} catch (error) {
|
||||
try {
|
||||
await recoverTransactionToPrior({
|
||||
transactionRoot: transaction.paths.transactionRoot,
|
||||
marker: transaction.marker(publicationStarted ? 'publishing' : 'staging'),
|
||||
assertParentAuthority: transaction.assertParentAuthority,
|
||||
currentDisposition: 'restore'
|
||||
})
|
||||
} catch (restoreError) {
|
||||
retainTransaction = true
|
||||
throw new AggregateError([error, restoreError], 'skill-transaction-recovery-required')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
const workspaceSafe = await assertSkillTransactionWorkspace(args.record, transaction.workspace)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (workspaceSafe && !retainTransaction) {
|
||||
await rm(transaction.paths.transactionRoot, { recursive: true, force: true }).catch(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
if (workspaceSafe) {
|
||||
await releaseLock().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { lstat, rmdir, unlink } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { ManagedSkillDestination, SkillReleasedSnapshot } from '../../shared/skill-management'
|
||||
import type { PublicationCallbacks } from './skill-package-publication'
|
||||
import { publishSkillFileThroughOwnedTemp } from './skill-owned-temp-publication'
|
||||
import type { SkillOwnedTempRuntime } from './skill-owned-temp-publication'
|
||||
import { skillInPlaceOperations } from './skill-transaction-publication-state'
|
||||
import type { SkillInPlacePublicationState } from './skill-transaction-publication-state'
|
||||
|
||||
export async function clearSkillPublicationTemporary(args: {
|
||||
record: ManagedSkillDestination
|
||||
publication: SkillInPlacePublicationState
|
||||
callbacks: PublicationCallbacks
|
||||
ownedTempRuntime?: SkillOwnedTempRuntime
|
||||
}): Promise<void> {
|
||||
if (!args.publication.temporary) {
|
||||
return
|
||||
}
|
||||
await args.callbacks.assertBoundary(args.publication)
|
||||
const temporaryPath = join(
|
||||
args.record.resolvedPath,
|
||||
...args.publication.temporary.path.split('/')
|
||||
)
|
||||
if (await lstat(temporaryPath).catch(() => null)) {
|
||||
await args.callbacks.assertBoundary(args.publication)
|
||||
await unlink(temporaryPath)
|
||||
}
|
||||
await args.callbacks.journal({ ...args.publication, temporary: null })
|
||||
}
|
||||
|
||||
export async function rollbackSkillPackageInPlace(args: {
|
||||
record: ManagedSkillDestination
|
||||
prior: SkillReleasedSnapshot
|
||||
current: SkillReleasedSnapshot
|
||||
backupRoot: string
|
||||
startingIndex: number
|
||||
physicalIdentity: string
|
||||
callbacks: PublicationCallbacks
|
||||
ownedTempRuntime?: SkillOwnedTempRuntime
|
||||
}): Promise<void> {
|
||||
const operations = skillInPlaceOperations(args.prior, args.current)
|
||||
const priorByPath = new Map(args.prior.files.map((file) => [file.path, file]))
|
||||
for (let operationIndex = args.startingIndex; operationIndex > 0; operationIndex -= 1) {
|
||||
const operation = operations[operationIndex - 1]
|
||||
if (!operation) {
|
||||
throw new Error('skill-publication-operation-invalid')
|
||||
}
|
||||
const priorFile = priorByPath.get(operation.path)
|
||||
const applying = {
|
||||
kind: 'in-place',
|
||||
direction: 'rollback',
|
||||
physicalIdentity: args.physicalIdentity,
|
||||
temporary: null,
|
||||
operationIndex,
|
||||
operationState: 'applying'
|
||||
} as const
|
||||
await args.callbacks.journal(applying)
|
||||
const livePath = join(args.record.resolvedPath, ...operation.path.split('/'))
|
||||
if (operation.kind === 'publish' && !priorFile) {
|
||||
await args.callbacks.assertBoundary(applying)
|
||||
await unlink(livePath)
|
||||
} else {
|
||||
if (!priorFile) {
|
||||
throw new Error('skill-publication-operation-invalid')
|
||||
}
|
||||
await publishSkillFileThroughOwnedTemp({
|
||||
destinationRoot: args.record.resolvedPath,
|
||||
sourcePath: join(args.backupRoot, ...priorFile.path.split('/')),
|
||||
expectedFile: priorFile,
|
||||
direction: 'rollback',
|
||||
packagePhysicalIdentity: args.physicalIdentity,
|
||||
operationIndex,
|
||||
callbacks: args.callbacks,
|
||||
runtime: args.ownedTempRuntime
|
||||
})
|
||||
}
|
||||
const ready = {
|
||||
kind: 'in-place',
|
||||
direction: 'rollback',
|
||||
physicalIdentity: args.physicalIdentity,
|
||||
temporary: null,
|
||||
operationIndex: operationIndex - 1,
|
||||
operationState: 'ready'
|
||||
} as const
|
||||
await args.callbacks.journal(ready)
|
||||
if (operation.kind === 'publish' && !priorFile) {
|
||||
let parent = dirname(livePath)
|
||||
while (parent !== args.record.resolvedPath) {
|
||||
await args.callbacks.assertBoundary(ready)
|
||||
await rmdir(parent).catch(() => undefined)
|
||||
parent = dirname(parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { cp, mkdir, mkdtemp, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { ManagedSkillDestination, SkillReleasedSnapshot } from '../../shared/skill-management'
|
||||
import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
|
||||
import { replaceSkillPackage, rollbackSkillPackageSwap } from './skill-package-publication'
|
||||
import { skillPhysicalIdentity } from './skill-installation-topology'
|
||||
import type { SkillTransactionMarker } from './skill-transaction-marker'
|
||||
import { assertTransactionOwnedState } from './skill-transaction-owned-state'
|
||||
import type { SkillPublicationState } from './skill-transaction-publication-state'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function createPackage(root: string, values: Record<string, string>): Promise<void> {
|
||||
await mkdir(root, { recursive: true })
|
||||
for (const [path, contents] of Object.entries(values)) {
|
||||
await writeFile(join(root, path), contents)
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshot(root: string, releaseRevision: number): Promise<SkillReleasedSnapshot> {
|
||||
const observed = await observeSkillPackage(root)
|
||||
return {
|
||||
releaseRevision,
|
||||
packageDigest: observed.observedDigest,
|
||||
gitTreeSha: '',
|
||||
files: persistedObservedFiles(observed)
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-swap-authority-'))
|
||||
roots.push(root)
|
||||
const live = join(root, 'skills', 'orca-cli')
|
||||
const transactionRoot = join(root, 'transaction')
|
||||
const stageRoot = join(transactionRoot, 'stage')
|
||||
const backupRoot = join(transactionRoot, 'backup')
|
||||
await createPackage(live, { 'SKILL.md': 'old entry', 'asset.txt': 'old asset' })
|
||||
await createPackage(stageRoot, { 'SKILL.md': 'new entry', 'asset.txt': 'new asset' })
|
||||
await cp(live, backupRoot, { recursive: true })
|
||||
const prior = await snapshot(live, 1)
|
||||
const current = await snapshot(stageRoot, 2)
|
||||
const record = {
|
||||
resolvedPath: live,
|
||||
physicalIdentity: skillPhysicalIdentity(live, await stat(live))
|
||||
} as ManagedSkillDestination
|
||||
let marker: SkillTransactionMarker = {
|
||||
schemaVersion: 1,
|
||||
transactionId: 'test',
|
||||
createdAt: 1,
|
||||
destinationId: '000000000000000000000000',
|
||||
destinationPath: live,
|
||||
physicalIdentity: record.physicalIdentity,
|
||||
hostId: 'local',
|
||||
skillName: 'orca-cli',
|
||||
phase: 'publishing',
|
||||
stagePhysicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
|
||||
backupPhysicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
|
||||
stageBuild: {
|
||||
state: 'ready',
|
||||
physicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
|
||||
operationIndex: current.files.length,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
},
|
||||
backupBuild: {
|
||||
state: 'ready',
|
||||
physicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
|
||||
operationIndex: prior.files.length,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
},
|
||||
publication: null,
|
||||
priorSnapshot: prior,
|
||||
currentSnapshot: current
|
||||
}
|
||||
const history: SkillPublicationState[] = []
|
||||
const journal = async (publication: SkillPublicationState): Promise<void> => {
|
||||
marker = { ...marker, publication }
|
||||
history.push(publication)
|
||||
}
|
||||
const assertBoundary = async (
|
||||
publication: SkillPublicationState,
|
||||
expectedLiveState?: Awaited<ReturnType<typeof assertTransactionOwnedState>>
|
||||
): Promise<void> => {
|
||||
const liveState = await assertTransactionOwnedState({
|
||||
transactionRoot,
|
||||
marker: { ...marker, publication },
|
||||
assertParent: async () => undefined,
|
||||
allowAppliedOperation: false
|
||||
})
|
||||
if (expectedLiveState && liveState !== expectedLiveState) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
}
|
||||
return {
|
||||
live,
|
||||
transactionRoot,
|
||||
stageRoot,
|
||||
oldLiveRoot: join(transactionRoot, 'old-live'),
|
||||
record,
|
||||
prior,
|
||||
current: { ...current, name: 'orca-cli', sourcePath: '', appVersion: '1.0.0' },
|
||||
callbacks: { journal, assertBoundary },
|
||||
history,
|
||||
marker: () => marker
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('skill package swap authority', () => {
|
||||
it('falls back in place only when the first live rename never mutated', async () => {
|
||||
const value = await fixture()
|
||||
await replaceSkillPackage({
|
||||
...value,
|
||||
callbacks: value.callbacks,
|
||||
renameRuntime: {
|
||||
windowsFallback: true,
|
||||
renameEntry: async () => {
|
||||
throw Object.assign(new Error('busy'), { code: 'EPERM' })
|
||||
},
|
||||
wait: async () => undefined
|
||||
}
|
||||
})
|
||||
expect((await observeSkillPackage(value.live)).observedDigest).toBe(value.current.packageDigest)
|
||||
expect(value.history.some((state) => state.kind === 'in-place')).toBe(true)
|
||||
})
|
||||
|
||||
it('never falls back after live moved and the stage rename exhausted retries', async () => {
|
||||
const value = await fixture()
|
||||
const renameEntry: typeof rename = async (source, destination) => {
|
||||
if (source.toString() === value.stageRoot) {
|
||||
throw Object.assign(new Error('busy'), { code: 'EPERM' })
|
||||
}
|
||||
await rename(source, destination)
|
||||
}
|
||||
await expect(
|
||||
replaceSkillPackage({
|
||||
...value,
|
||||
callbacks: value.callbacks,
|
||||
renameRuntime: { windowsFallback: true, renameEntry, wait: async () => undefined }
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'EPERM' })
|
||||
expect(value.marker().publication).toEqual({ kind: 'package-swap', step: 'moving-live' })
|
||||
expect(value.history.some((state) => state.kind === 'in-place')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects fallback when a failing first rename nevertheless moved live', async () => {
|
||||
const value = await fixture()
|
||||
let firstAttempt = true
|
||||
const renameEntry: typeof rename = async (source, destination) => {
|
||||
if (firstAttempt) {
|
||||
firstAttempt = false
|
||||
await rename(source, destination)
|
||||
throw Object.assign(new Error('ambiguous busy result'), { code: 'EPERM' })
|
||||
}
|
||||
await rename(source, destination)
|
||||
}
|
||||
await expect(
|
||||
replaceSkillPackage({
|
||||
...value,
|
||||
callbacks: value.callbacks,
|
||||
renameRuntime: { windowsFallback: true, renameEntry, wait: async () => undefined }
|
||||
})
|
||||
).rejects.toThrow('skill-transaction-recovery-required')
|
||||
expect(value.history.some((state) => state.kind === 'in-place')).toBe(false)
|
||||
expect(await stat(value.oldLiveRoot)).toBeTruthy()
|
||||
await expect(stat(value.live)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('retains swap authority when stage publication and old-live restore are busy', async () => {
|
||||
const value = await fixture()
|
||||
let firstRename = true
|
||||
const renameEntry: typeof rename = async (source, destination) => {
|
||||
if (firstRename) {
|
||||
firstRename = false
|
||||
await rename(source, destination)
|
||||
return
|
||||
}
|
||||
throw Object.assign(new Error('busy'), { code: 'EBUSY' })
|
||||
}
|
||||
await expect(
|
||||
replaceSkillPackage({
|
||||
...value,
|
||||
callbacks: value.callbacks,
|
||||
renameRuntime: { windowsFallback: true, renameEntry, wait: async () => undefined }
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'EBUSY' })
|
||||
expect(value.marker().publication).toEqual({ kind: 'package-swap', step: 'moving-stage' })
|
||||
expect(value.history.some((state) => state.kind === 'in-place')).toBe(false)
|
||||
expect(await stat(value.oldLiveRoot)).toBeTruthy()
|
||||
await expect(stat(value.live)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it.each(['current', 'old-live'] as const)(
|
||||
'retains the package-swap rollback journal when %s rename stays busy',
|
||||
async (failure) => {
|
||||
const value = await fixture()
|
||||
await rename(value.live, value.oldLiveRoot)
|
||||
await rename(value.stageRoot, value.live)
|
||||
await value.callbacks.journal({ kind: 'package-swap', step: 'complete' })
|
||||
const renameEntry: typeof rename = async (source, destination) => {
|
||||
const shouldFail =
|
||||
(failure === 'current' && source.toString() === value.live) ||
|
||||
(failure === 'old-live' && source.toString() === value.oldLiveRoot)
|
||||
if (shouldFail) {
|
||||
throw Object.assign(new Error('busy'), { code: 'EPERM' })
|
||||
}
|
||||
await rename(source, destination)
|
||||
}
|
||||
await expect(
|
||||
rollbackSkillPackageSwap({
|
||||
record: value.record,
|
||||
transactionRoot: value.transactionRoot,
|
||||
callbacks: value.callbacks,
|
||||
renameRuntime: { windowsFallback: true, renameEntry, wait: async () => undefined }
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'EPERM' })
|
||||
expect(value.marker().publication).toEqual({
|
||||
kind: 'package-swap',
|
||||
step: failure === 'current' ? 'rollback-moving-current' : 'rollback-moving-old-live'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
ManagedSkillDestination,
|
||||
SkillCurrentBundleEntry,
|
||||
SkillReleasedSnapshot
|
||||
} from '../../shared/skill-management'
|
||||
import { assertManagedSkillParentTopology } from './skill-installation-topology'
|
||||
import type { PublicationCallbacks } from './skill-package-publication'
|
||||
import { assertManagedDestinationUnchanged } from './skill-published-destination-authority'
|
||||
import {
|
||||
writeSkillTransactionMarker,
|
||||
type SkillTransactionMarker
|
||||
} from './skill-transaction-marker'
|
||||
import { assertTransactionOwnedState } from './skill-transaction-owned-state'
|
||||
import type { SkillPublicationState } from './skill-transaction-publication-state'
|
||||
import {
|
||||
assertSkillTransactionPackageBuild,
|
||||
type SkillTransactionPackageBuildEvidence
|
||||
} from './skill-transaction-package-build'
|
||||
import {
|
||||
assertSkillTransactionWorkspace,
|
||||
resolveSkillTransactionWorkspace,
|
||||
type SkillTransactionWorkspace
|
||||
} from './skill-transaction-workspace'
|
||||
|
||||
export type SkillTransactionContext = {
|
||||
workspace: SkillTransactionWorkspace
|
||||
paths: {
|
||||
reservedRoot: string
|
||||
transactionRoot: string
|
||||
stageRoot: string
|
||||
backupRoot: string
|
||||
lockRoot: string
|
||||
}
|
||||
priorSnapshot: SkillReleasedSnapshot
|
||||
callbacks: PublicationCallbacks
|
||||
assertAuthority: (checkContent: boolean) => Promise<void>
|
||||
assertParentAuthority: () => Promise<void>
|
||||
journalStageBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise<void>
|
||||
assertStageBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise<void>
|
||||
journalBackupBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise<void>
|
||||
assertBackupBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise<void>
|
||||
marker: (phase: SkillTransactionMarker['phase']) => SkillTransactionMarker
|
||||
writeMarker: (phase: SkillTransactionMarker['phase'], checkContent: boolean) => Promise<void>
|
||||
writeVerifiedMarker: (assertPublished: () => Promise<void>) => Promise<void>
|
||||
}
|
||||
|
||||
export async function createSkillTransactionContext(args: {
|
||||
record: ManagedSkillDestination
|
||||
current: SkillCurrentBundleEntry
|
||||
releasedSnapshots: readonly SkillReleasedSnapshot[]
|
||||
now: number
|
||||
beforeWorkspaceValidation?: () => void | Promise<void>
|
||||
}): Promise<SkillTransactionContext> {
|
||||
const workspace = await resolveSkillTransactionWorkspace(args.record)
|
||||
await args.beforeWorkspaceValidation?.()
|
||||
const assertAuthority = async (checkContent: boolean): Promise<void> => {
|
||||
if (checkContent) {
|
||||
await assertManagedDestinationUnchanged(args.record, args.releasedSnapshots)
|
||||
}
|
||||
await assertSkillTransactionWorkspace(args.record, workspace)
|
||||
}
|
||||
await assertAuthority(true)
|
||||
const reservedRoot = workspace.reservedRoot
|
||||
const transactionRoot = join(reservedRoot, randomUUID())
|
||||
const stageRoot = join(transactionRoot, 'stage')
|
||||
const backupRoot = join(transactionRoot, 'backup')
|
||||
const priorSnapshot: SkillReleasedSnapshot = {
|
||||
releaseRevision: args.record.installedReleaseRevision,
|
||||
packageDigest: args.record.installedPackageDigest,
|
||||
gitTreeSha: args.record.sourceRef ?? '',
|
||||
files: args.record.installedFiles
|
||||
}
|
||||
const currentSnapshot: SkillReleasedSnapshot = {
|
||||
releaseRevision: args.current.releaseRevision,
|
||||
packageDigest: args.current.packageDigest,
|
||||
gitTreeSha: args.current.gitTreeSha,
|
||||
files: args.current.files
|
||||
}
|
||||
let stagePhysicalIdentity: string | null = null
|
||||
let backupPhysicalIdentity: string | null = null
|
||||
let stageBuild: SkillTransactionPackageBuildEvidence | null = null
|
||||
let backupBuild: SkillTransactionPackageBuildEvidence | null = null
|
||||
let publication: SkillPublicationState | null = null
|
||||
const transactionId = randomUUID()
|
||||
const marker = (phase: SkillTransactionMarker['phase']): SkillTransactionMarker => ({
|
||||
schemaVersion: 1,
|
||||
transactionId,
|
||||
createdAt: args.now,
|
||||
destinationId: args.record.id,
|
||||
destinationPath: args.record.resolvedPath,
|
||||
physicalIdentity: args.record.physicalIdentity,
|
||||
hostId: args.record.hostId,
|
||||
skillName: args.record.skillName,
|
||||
phase,
|
||||
stagePhysicalIdentity,
|
||||
backupPhysicalIdentity,
|
||||
stageBuild,
|
||||
backupBuild,
|
||||
publication,
|
||||
priorSnapshot,
|
||||
currentSnapshot
|
||||
})
|
||||
const assertParentAuthority = () => assertManagedSkillParentTopology(args.record)
|
||||
const assertBuildAuthority = async (): Promise<void> => {
|
||||
await assertAuthority(true)
|
||||
if (stageBuild) {
|
||||
await assertSkillTransactionPackageBuild({
|
||||
root: stageRoot,
|
||||
evidence: stageBuild,
|
||||
snapshot: currentSnapshot
|
||||
})
|
||||
}
|
||||
if (backupBuild) {
|
||||
await assertSkillTransactionPackageBuild({
|
||||
root: backupRoot,
|
||||
evidence: backupBuild,
|
||||
snapshot: priorSnapshot
|
||||
})
|
||||
}
|
||||
}
|
||||
const writeBuildMarker = async (): Promise<void> => {
|
||||
await writeSkillTransactionMarker(transactionRoot, marker('staging'), assertBuildAuthority)
|
||||
}
|
||||
const journal = async (next: SkillPublicationState): Promise<void> => {
|
||||
publication = next
|
||||
await assertAuthority(false)
|
||||
await writeSkillTransactionMarker(transactionRoot, marker('publishing'), () =>
|
||||
assertAuthority(false)
|
||||
)
|
||||
}
|
||||
const assertBoundary: PublicationCallbacks['assertBoundary'] = async (
|
||||
state,
|
||||
expectedLiveState
|
||||
) => {
|
||||
const liveState = await assertTransactionOwnedState({
|
||||
transactionRoot,
|
||||
marker: { ...marker('publishing'), publication: state },
|
||||
assertParent: assertParentAuthority,
|
||||
allowAppliedOperation: false
|
||||
})
|
||||
await assertAuthority(false)
|
||||
if (expectedLiveState && liveState !== expectedLiveState) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
}
|
||||
return {
|
||||
workspace,
|
||||
paths: {
|
||||
reservedRoot,
|
||||
transactionRoot,
|
||||
stageRoot,
|
||||
backupRoot,
|
||||
lockRoot: join(reservedRoot, 'locks', args.record.id)
|
||||
},
|
||||
priorSnapshot,
|
||||
callbacks: { journal, assertBoundary },
|
||||
assertAuthority,
|
||||
assertParentAuthority,
|
||||
journalStageBuild: async (evidence) => {
|
||||
stageBuild = evidence
|
||||
stagePhysicalIdentity = evidence.physicalIdentity
|
||||
await writeBuildMarker()
|
||||
},
|
||||
assertStageBuild: async (evidence) => {
|
||||
await assertAuthority(true)
|
||||
await assertSkillTransactionPackageBuild({
|
||||
root: stageRoot,
|
||||
evidence,
|
||||
snapshot: currentSnapshot
|
||||
})
|
||||
},
|
||||
journalBackupBuild: async (evidence) => {
|
||||
backupBuild = evidence
|
||||
backupPhysicalIdentity = evidence.physicalIdentity
|
||||
await writeBuildMarker()
|
||||
},
|
||||
assertBackupBuild: async (evidence) => {
|
||||
await assertAuthority(true)
|
||||
await assertSkillTransactionPackageBuild({
|
||||
root: backupRoot,
|
||||
evidence,
|
||||
snapshot: priorSnapshot
|
||||
})
|
||||
},
|
||||
marker,
|
||||
writeMarker: async (phase, checkContent) => {
|
||||
await writeSkillTransactionMarker(transactionRoot, marker(phase), () =>
|
||||
assertAuthority(checkContent)
|
||||
)
|
||||
},
|
||||
writeVerifiedMarker: async (assertPublished) => {
|
||||
await writeSkillTransactionMarker(transactionRoot, marker('verified'), async () => {
|
||||
// Why: marker durability must never outlive the exact destination
|
||||
// identity and content proof it claims.
|
||||
await assertAuthority(false)
|
||||
await assertPublished()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ManagedSkillDestination } from '../../shared/skill-management'
|
||||
import {
|
||||
assertManagedSkillParentTopology,
|
||||
assertManagedSkillTopology
|
||||
} from './skill-installation-topology'
|
||||
import { matchingReleasedSnapshot, observeSkillPackage } from './skill-package-identity'
|
||||
import type { SkillTransactionMarker } from './skill-transaction-marker'
|
||||
import { assertTransactionOwnedState } from './skill-transaction-owned-state'
|
||||
|
||||
export async function assertManagedDestinationUnchanged(
|
||||
record: ManagedSkillDestination,
|
||||
snapshots: Parameters<typeof matchingReleasedSnapshot>[1]
|
||||
): Promise<void> {
|
||||
const resolved = await assertManagedSkillTopology(record)
|
||||
const observed = await observeSkillPackage(resolved)
|
||||
const snapshot = matchingReleasedSnapshot(observed, snapshots)
|
||||
if (!snapshot || snapshot.packageDigest !== record.installedPackageDigest) {
|
||||
throw new Error('skill-content-changed')
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertPublishedDestinationOwned(args: {
|
||||
record: ManagedSkillDestination
|
||||
transactionRoot: string
|
||||
marker: SkillTransactionMarker
|
||||
}): Promise<string> {
|
||||
const publication = args.marker.publication
|
||||
if (!publication) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
const expectedPhysicalIdentity =
|
||||
publication.kind === 'package-swap'
|
||||
? args.marker.stagePhysicalIdentity
|
||||
: args.record.physicalIdentity
|
||||
if (!expectedPhysicalIdentity) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
const state = await assertTransactionOwnedState({
|
||||
transactionRoot: args.transactionRoot,
|
||||
marker: args.marker,
|
||||
assertParent: () => assertManagedSkillParentTopology(args.record),
|
||||
allowAppliedOperation: false
|
||||
})
|
||||
if (state !== 'current') {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
// Why: exact bytes do not authorize claiming a directory replaced after publish.
|
||||
return assertManagedSkillTopology({
|
||||
...args.record,
|
||||
physicalIdentity: expectedPhysicalIdentity
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { SkillReleasedSnapshot } from '../../shared/skill-management'
|
||||
import {
|
||||
observeSkillPackage,
|
||||
persistedObservedFiles,
|
||||
skillPackageDigest
|
||||
} from './skill-package-identity'
|
||||
import {
|
||||
parseSkillTransactionMarker,
|
||||
type SkillTransactionMarker
|
||||
} from './skill-transaction-marker'
|
||||
import { skillInPlaceOperations } from './skill-transaction-publication-state'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function snapshot(contents: string, revision: number): Promise<SkillReleasedSnapshot> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-marker-snapshot-'))
|
||||
roots.push(root)
|
||||
await writeFile(join(root, 'SKILL.md'), contents)
|
||||
await writeFile(join(root, 'asset.txt'), `${contents} asset`)
|
||||
const observed = await observeSkillPackage(root)
|
||||
return {
|
||||
releaseRevision: revision,
|
||||
packageDigest: observed.observedDigest,
|
||||
gitTreeSha: '',
|
||||
files: persistedObservedFiles(observed)
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture(): Promise<SkillTransactionMarker> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-marker-'))
|
||||
roots.push(root)
|
||||
const destinationPath = join(root, 'skills', 'orca-cli')
|
||||
await mkdir(destinationPath, { recursive: true })
|
||||
const priorSnapshot = await snapshot('old', 1)
|
||||
const currentSnapshot = await snapshot('new', 2)
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
transactionId: 'transaction',
|
||||
createdAt: 1,
|
||||
destinationId: '000000000000000000000000',
|
||||
destinationPath,
|
||||
physicalIdentity: 'live-identity',
|
||||
hostId: 'local',
|
||||
skillName: 'orca-cli',
|
||||
phase: 'verified',
|
||||
stagePhysicalIdentity: 'stage-identity',
|
||||
backupPhysicalIdentity: 'backup-identity',
|
||||
stageBuild: {
|
||||
state: 'ready',
|
||||
physicalIdentity: 'stage-identity',
|
||||
operationIndex: currentSnapshot.files.length,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
},
|
||||
backupBuild: {
|
||||
state: 'ready',
|
||||
physicalIdentity: 'backup-identity',
|
||||
operationIndex: priorSnapshot.files.length,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
},
|
||||
publication: { kind: 'package-swap', step: 'complete' },
|
||||
priorSnapshot,
|
||||
currentSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
function parse(marker: unknown): SkillTransactionMarker | null {
|
||||
return parseSkillTransactionMarker(JSON.stringify(marker))
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('skill transaction marker semantics', () => {
|
||||
it('requires phase-valid package build evidence', async () => {
|
||||
const marker = await fixture()
|
||||
expect(parse({ ...marker, stageBuild: null })).toBeNull()
|
||||
expect(
|
||||
parse({
|
||||
...marker,
|
||||
phase: 'staging',
|
||||
publication: null,
|
||||
stageBuild: { ...marker.stageBuild, state: 'creating' }
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
parse({
|
||||
...marker,
|
||||
phase: 'staging',
|
||||
publication: null,
|
||||
backupBuild: { ...marker.backupBuild, state: 'creating' },
|
||||
stageBuild: { ...marker.stageBuild, state: 'creating' }
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
parse({
|
||||
...marker,
|
||||
phase: 'staging',
|
||||
publication: null,
|
||||
stageBuild: {
|
||||
state: 'creating',
|
||||
physicalIdentity: marker.stagePhysicalIdentity,
|
||||
operationIndex: 0,
|
||||
pendingPath: null,
|
||||
activeFile: {
|
||||
path: marker.currentSnapshot.files[0]!.path,
|
||||
physicalIdentity: 'active',
|
||||
contentState: 'invalid'
|
||||
}
|
||||
},
|
||||
backupBuild: null,
|
||||
backupPhysicalIdentity: null
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts only terminal verified publication states', async () => {
|
||||
const marker = await fixture()
|
||||
expect(parse(marker)).not.toBeNull()
|
||||
expect(
|
||||
parse({ ...marker, publication: { kind: 'package-swap', step: 'moving-stage' } })
|
||||
).toBeNull()
|
||||
const finalIndex = skillInPlaceOperations(marker.priorSnapshot, marker.currentSnapshot).length
|
||||
const inPlace = {
|
||||
kind: 'in-place',
|
||||
direction: 'forward',
|
||||
physicalIdentity: marker.physicalIdentity,
|
||||
temporary: null,
|
||||
operationIndex: finalIndex,
|
||||
operationState: 'ready'
|
||||
} as const
|
||||
expect(parse({ ...marker, publication: inPlace })).not.toBeNull()
|
||||
expect(
|
||||
parse({ ...marker, publication: { ...inPlace, operationIndex: finalIndex - 1 } })
|
||||
).toBeNull()
|
||||
expect(parse({ ...marker, publication: { ...inPlace, direction: 'rollback' } })).toBeNull()
|
||||
expect(parse({ ...marker, publication: { ...inPlace, operationState: 'applying' } })).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['digest', 'order', 'duplicate', 'unsafe'])(
|
||||
'rejects a snapshot with corrupted %s semantics',
|
||||
async (corruption) => {
|
||||
const marker = await fixture()
|
||||
const files = marker.currentSnapshot.files.map((file) => ({ ...file }))
|
||||
if (corruption === 'order') {
|
||||
files.reverse()
|
||||
}
|
||||
if (corruption === 'duplicate') {
|
||||
files[1] = { ...files[0]! }
|
||||
}
|
||||
if (corruption === 'unsafe') {
|
||||
files[0] = { ...files[0]!, path: '../escape' }
|
||||
}
|
||||
const currentSnapshot = {
|
||||
...marker.currentSnapshot,
|
||||
files,
|
||||
packageDigest: corruption === 'digest' ? '0'.repeat(64) : skillPackageDigest(files)
|
||||
}
|
||||
expect(parse({ ...marker, currentSnapshot })).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects inconsistent revisions and malformed temp authority', async () => {
|
||||
const marker = await fixture()
|
||||
expect(
|
||||
parse({
|
||||
...marker,
|
||||
currentSnapshot: {
|
||||
...marker.currentSnapshot,
|
||||
releaseRevision: marker.priorSnapshot.releaseRevision
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
const operation = skillInPlaceOperations(marker.priorSnapshot, marker.currentSnapshot)[0]!
|
||||
const expectedFile = marker.currentSnapshot.files.find((file) => file.path === operation.path)!
|
||||
const publication = {
|
||||
kind: 'in-place',
|
||||
direction: 'forward',
|
||||
physicalIdentity: marker.physicalIdentity,
|
||||
temporary: {
|
||||
path: 'asset.txt.orca-test.tmp',
|
||||
physicalIdentity: '',
|
||||
expectedFile,
|
||||
state: 'ready'
|
||||
},
|
||||
operationIndex: 0,
|
||||
operationState: 'applying'
|
||||
} as const
|
||||
expect(parse({ ...marker, phase: 'publishing', publication })).toBeNull()
|
||||
const validPublication = {
|
||||
...publication,
|
||||
temporary: { ...publication.temporary, physicalIdentity: 'temp-identity' }
|
||||
}
|
||||
expect(parse({ ...marker, phase: 'publishing', publication: validPublication })).not.toBeNull()
|
||||
expect(
|
||||
parse({
|
||||
...marker,
|
||||
phase: 'publishing',
|
||||
publication: {
|
||||
...validPublication,
|
||||
temporary: { ...validPublication.temporary, state: 'unknown' }
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
parse({
|
||||
...marker,
|
||||
phase: 'publishing',
|
||||
publication: {
|
||||
...validPublication,
|
||||
temporary: { ...validPublication.temporary, path: 'other.orca-test.tmp' }
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
parse({
|
||||
...marker,
|
||||
phase: 'publishing',
|
||||
publication: {
|
||||
...validPublication,
|
||||
temporary: {
|
||||
...validPublication.temporary,
|
||||
expectedFile: marker.priorSnapshot.files.find((file) => file.path === operation.path)!
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, isAbsolute, join, posix } from 'node:path'
|
||||
import { normalizeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { SkillReleasedSnapshot } from '../../shared/skill-management'
|
||||
import { normalizedSkillIdentityPath } from './skill-installation-topology'
|
||||
import {
|
||||
isSkillTransactionPackageBuildEvidence,
|
||||
type SkillTransactionPackageBuildEvidence
|
||||
} from './skill-transaction-package-build'
|
||||
import {
|
||||
sameCompleteFileIdentity,
|
||||
skillInPlaceOperations,
|
||||
type SkillInPlacePublicationState,
|
||||
type SkillPublicationState
|
||||
} from './skill-transaction-publication-state'
|
||||
import {
|
||||
isSkillBundleFileIdentity,
|
||||
isSkillReleasedSnapshot
|
||||
} from './skill-transaction-snapshot-validation'
|
||||
|
||||
export type SkillTransactionMarker = {
|
||||
schemaVersion: 1
|
||||
transactionId: string
|
||||
createdAt: number
|
||||
destinationId: string
|
||||
destinationPath: string
|
||||
physicalIdentity: string
|
||||
hostId: ExecutionHostId
|
||||
skillName: string
|
||||
phase: 'staging' | 'publishing' | 'verified'
|
||||
stagePhysicalIdentity: string | null
|
||||
backupPhysicalIdentity: string | null
|
||||
stageBuild: SkillTransactionPackageBuildEvidence | null
|
||||
backupBuild: SkillTransactionPackageBuildEvidence | null
|
||||
publication: SkillPublicationState | null
|
||||
priorSnapshot: SkillReleasedSnapshot
|
||||
currentSnapshot: SkillReleasedSnapshot
|
||||
}
|
||||
|
||||
function validInPlaceSemantics(
|
||||
publication: SkillInPlacePublicationState,
|
||||
prior: SkillReleasedSnapshot,
|
||||
current: SkillReleasedSnapshot
|
||||
): boolean {
|
||||
const operations = skillInPlaceOperations(prior, current)
|
||||
if (publication.operationIndex > operations.length) {
|
||||
return false
|
||||
}
|
||||
if (publication.operationState === 'ready') {
|
||||
return publication.temporary === null
|
||||
}
|
||||
const operation =
|
||||
operations[
|
||||
publication.direction === 'forward'
|
||||
? publication.operationIndex
|
||||
: publication.operationIndex - 1
|
||||
]
|
||||
if (!operation) {
|
||||
return false
|
||||
}
|
||||
const expected =
|
||||
publication.direction === 'forward'
|
||||
? current.files.find((file) => file.path === operation.path)
|
||||
: prior.files.find((file) => file.path === operation.path)
|
||||
if (!publication.temporary) {
|
||||
return true
|
||||
}
|
||||
return Boolean(
|
||||
expected &&
|
||||
publication.temporary.path.startsWith(`${operation.path}.orca-`) &&
|
||||
sameCompleteFileIdentity(publication.temporary.expectedFile, expected)
|
||||
)
|
||||
}
|
||||
|
||||
function validBuildSemantics(
|
||||
evidence: SkillTransactionPackageBuildEvidence | null,
|
||||
snapshot: SkillReleasedSnapshot
|
||||
): boolean {
|
||||
if (!evidence) {
|
||||
return true
|
||||
}
|
||||
if (evidence.operationIndex > snapshot.files.length) {
|
||||
return false
|
||||
}
|
||||
if (evidence.state === 'ready') {
|
||||
return (
|
||||
evidence.operationIndex === snapshot.files.length &&
|
||||
evidence.pendingPath === null &&
|
||||
evidence.activeFile === null
|
||||
)
|
||||
}
|
||||
if (evidence.pendingPath !== null) {
|
||||
return (
|
||||
evidence.activeFile === null &&
|
||||
snapshot.files[evidence.operationIndex]?.path === evidence.pendingPath
|
||||
)
|
||||
}
|
||||
if (!evidence.activeFile) {
|
||||
return true
|
||||
}
|
||||
return snapshot.files[evidence.operationIndex]?.path === evidence.activeFile.path
|
||||
}
|
||||
|
||||
function validMarkerSemantics(marker: SkillTransactionMarker): boolean {
|
||||
if (marker.currentSnapshot.releaseRevision <= marker.priorSnapshot.releaseRevision) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!validBuildSemantics(marker.stageBuild, marker.currentSnapshot) ||
|
||||
!validBuildSemantics(marker.backupBuild, marker.priorSnapshot) ||
|
||||
marker.stagePhysicalIdentity !== (marker.stageBuild?.physicalIdentity ?? null) ||
|
||||
marker.backupPhysicalIdentity !== (marker.backupBuild?.physicalIdentity ?? null) ||
|
||||
(marker.backupBuild && marker.stageBuild?.state !== 'ready')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (!marker.publication) {
|
||||
return marker.phase === 'staging'
|
||||
}
|
||||
if (marker.stageBuild?.state !== 'ready' || marker.backupBuild?.state !== 'ready') {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
marker.publication.kind === 'in-place' &&
|
||||
!validInPlaceSemantics(marker.publication, marker.priorSnapshot, marker.currentSnapshot)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (marker.phase !== 'verified') {
|
||||
return true
|
||||
}
|
||||
if (marker.publication.kind === 'package-swap') {
|
||||
return marker.publication.step === 'complete'
|
||||
}
|
||||
return (
|
||||
marker.publication.direction === 'forward' &&
|
||||
marker.publication.operationState === 'ready' &&
|
||||
marker.publication.temporary === null &&
|
||||
marker.publication.operationIndex ===
|
||||
skillInPlaceOperations(marker.priorSnapshot, marker.currentSnapshot).length
|
||||
)
|
||||
}
|
||||
|
||||
function isPublicationState(value: unknown): value is SkillPublicationState {
|
||||
if (typeof value !== 'object' || value === null || !('kind' in value)) {
|
||||
return false
|
||||
}
|
||||
if (value.kind === 'in-place') {
|
||||
return (
|
||||
'operationIndex' in value &&
|
||||
typeof value.operationIndex === 'number' &&
|
||||
Number.isSafeInteger(value.operationIndex) &&
|
||||
value.operationIndex >= 0 &&
|
||||
'direction' in value &&
|
||||
(value.direction === 'forward' || value.direction === 'rollback') &&
|
||||
'physicalIdentity' in value &&
|
||||
typeof value.physicalIdentity === 'string' &&
|
||||
value.physicalIdentity.length > 0 &&
|
||||
'temporary' in value &&
|
||||
(value.temporary === null ||
|
||||
(typeof value.temporary === 'object' &&
|
||||
value.temporary !== null &&
|
||||
'path' in value.temporary &&
|
||||
typeof value.temporary.path === 'string' &&
|
||||
value.temporary.path.endsWith('.tmp') &&
|
||||
!posix.isAbsolute(value.temporary.path) &&
|
||||
posix.normalize(value.temporary.path) === value.temporary.path &&
|
||||
!value.temporary.path.split('/').includes('..') &&
|
||||
!value.temporary.path.includes('\\') &&
|
||||
'physicalIdentity' in value.temporary &&
|
||||
typeof value.temporary.physicalIdentity === 'string' &&
|
||||
value.temporary.physicalIdentity.length > 0 &&
|
||||
'state' in value.temporary &&
|
||||
(value.temporary.state === 'creating' ||
|
||||
value.temporary.state === 'written' ||
|
||||
value.temporary.state === 'ready' ||
|
||||
value.temporary.state === 'applied') &&
|
||||
'expectedFile' in value.temporary &&
|
||||
isSkillBundleFileIdentity(value.temporary.expectedFile))) &&
|
||||
'operationState' in value &&
|
||||
(value.operationState === 'ready' || value.operationState === 'applying')
|
||||
)
|
||||
}
|
||||
return (
|
||||
value.kind === 'package-swap' &&
|
||||
'step' in value &&
|
||||
(value.step === 'moving-live' ||
|
||||
value.step === 'live-moved' ||
|
||||
value.step === 'moving-stage' ||
|
||||
value.step === 'stage-moved' ||
|
||||
value.step === 'rollback-moving-current' ||
|
||||
value.step === 'rollback-current-moved' ||
|
||||
value.step === 'rollback-moving-old-live' ||
|
||||
value.step === 'complete')
|
||||
)
|
||||
}
|
||||
|
||||
export function parseSkillTransactionMarker(value: string): SkillTransactionMarker | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return null
|
||||
}
|
||||
const marker = parsed as Partial<SkillTransactionMarker>
|
||||
const hostId = normalizeExecutionHostId(marker.hostId)
|
||||
if (
|
||||
marker.schemaVersion !== 1 ||
|
||||
typeof marker.transactionId !== 'string' ||
|
||||
marker.transactionId.length === 0 ||
|
||||
typeof marker.createdAt !== 'number' ||
|
||||
!Number.isFinite(marker.createdAt) ||
|
||||
typeof marker.destinationId !== 'string' ||
|
||||
!/^[a-f0-9]{24}$/.test(marker.destinationId) ||
|
||||
typeof marker.destinationPath !== 'string' ||
|
||||
!isAbsolute(marker.destinationPath) ||
|
||||
typeof marker.physicalIdentity !== 'string' ||
|
||||
marker.physicalIdentity.length === 0 ||
|
||||
typeof marker.skillName !== 'string' ||
|
||||
marker.skillName.length === 0 ||
|
||||
(marker.stagePhysicalIdentity !== null &&
|
||||
(typeof marker.stagePhysicalIdentity !== 'string' ||
|
||||
marker.stagePhysicalIdentity.length === 0)) ||
|
||||
(marker.backupPhysicalIdentity !== null &&
|
||||
(typeof marker.backupPhysicalIdentity !== 'string' ||
|
||||
marker.backupPhysicalIdentity.length === 0)) ||
|
||||
(marker.stageBuild !== null && !isSkillTransactionPackageBuildEvidence(marker.stageBuild)) ||
|
||||
(marker.backupBuild !== null &&
|
||||
!isSkillTransactionPackageBuildEvidence(marker.backupBuild)) ||
|
||||
!hostId ||
|
||||
(marker.phase !== 'staging' &&
|
||||
marker.phase !== 'publishing' &&
|
||||
marker.phase !== 'verified') ||
|
||||
!isSkillReleasedSnapshot(marker.priorSnapshot) ||
|
||||
!isSkillReleasedSnapshot(marker.currentSnapshot)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const validated = { ...marker, hostId } as SkillTransactionMarker
|
||||
if (!validMarkerSemantics(validated)) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
(marker.phase === 'staging' && marker.publication !== null) ||
|
||||
(marker.phase !== 'staging' && !isPublicationState(marker.publication)) ||
|
||||
(marker.phase !== 'staging' &&
|
||||
(marker.stageBuild?.state !== 'ready' || marker.backupBuild?.state !== 'ready'))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
normalizedSkillIdentityPath(basename(marker.destinationPath)) !==
|
||||
normalizedSkillIdentityPath(marker.skillName)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return validated
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeSkillTransactionMarker(
|
||||
transactionRoot: string,
|
||||
marker: SkillTransactionMarker,
|
||||
beforeMutation: () => Promise<void> = async () => undefined
|
||||
): Promise<void> {
|
||||
const markerPath = join(transactionRoot, 'transaction.json')
|
||||
const temporaryPath = join(transactionRoot, `.transaction-${randomUUID()}.tmp`)
|
||||
const serialized = JSON.stringify(marker)
|
||||
if (!parseSkillTransactionMarker(serialized)) {
|
||||
throw new Error('skill-transaction-marker-invalid')
|
||||
}
|
||||
try {
|
||||
await beforeMutation()
|
||||
await writeFile(temporaryPath, serialized)
|
||||
// Why: recovery must observe either the old or new phase, never a torn
|
||||
// marker that could discard the only verified rollback package.
|
||||
await beforeMutation()
|
||||
await rename(temporaryPath, markerPath)
|
||||
} finally {
|
||||
// Why: a redirected transaction path is no longer Orca-owned cleanup.
|
||||
if (
|
||||
await beforeMutation()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
) {
|
||||
await rm(temporaryPath, { force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { lstat, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { matchingReleasedSnapshot, observeSkillPackage } from './skill-package-identity'
|
||||
import { skillPhysicalIdentity } from './skill-installation-topology'
|
||||
import type { SkillTransactionMarker } from './skill-transaction-marker'
|
||||
import { assertSkillTransactionPackageBuild } from './skill-transaction-package-build'
|
||||
import {
|
||||
matchesInPlacePublicationState,
|
||||
skillInPlaceOperations
|
||||
} from './skill-transaction-publication-state'
|
||||
|
||||
export type SkillTransactionLiveState = 'prior' | 'partial' | 'current' | 'swap-absent'
|
||||
|
||||
async function isAbsent(path: string): Promise<boolean> {
|
||||
return lstat(path)
|
||||
.then(() => false)
|
||||
.catch((error) => {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
return true
|
||||
}
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
async function matchesOwnedPackage(args: {
|
||||
path: string
|
||||
physicalIdentity: string
|
||||
snapshot: SkillTransactionMarker['priorSnapshot']
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const entry = await lstat(args.path)
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
return false
|
||||
}
|
||||
const pathStat = await stat(args.path)
|
||||
if (skillPhysicalIdentity(args.path, pathStat) !== args.physicalIdentity) {
|
||||
return false
|
||||
}
|
||||
return Boolean(matchingReleasedSnapshot(await observeSkillPackage(args.path), [args.snapshot]))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function assertStageCurrent(
|
||||
transactionRoot: string,
|
||||
marker: SkillTransactionMarker
|
||||
): Promise<void> {
|
||||
if (
|
||||
!marker.stageBuild ||
|
||||
marker.stageBuild.state !== 'ready' ||
|
||||
marker.stageBuild.physicalIdentity !== marker.stagePhysicalIdentity
|
||||
) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
await assertSkillTransactionPackageBuild({
|
||||
root: join(transactionRoot, 'stage'),
|
||||
evidence: marker.stageBuild,
|
||||
snapshot: marker.currentSnapshot
|
||||
}).catch(() => {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
})
|
||||
}
|
||||
|
||||
async function assertOldLivePrior(
|
||||
transactionRoot: string,
|
||||
marker: SkillTransactionMarker
|
||||
): Promise<void> {
|
||||
if (
|
||||
!(await matchesOwnedPackage({
|
||||
path: join(transactionRoot, 'old-live'),
|
||||
physicalIdentity: marker.physicalIdentity,
|
||||
snapshot: marker.priorSnapshot
|
||||
}))
|
||||
) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBackupPrior(
|
||||
transactionRoot: string,
|
||||
marker: SkillTransactionMarker
|
||||
): Promise<void> {
|
||||
if (
|
||||
!marker.backupBuild ||
|
||||
marker.backupBuild.state !== 'ready' ||
|
||||
marker.backupBuild.physicalIdentity !== marker.backupPhysicalIdentity
|
||||
) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
await assertSkillTransactionPackageBuild({
|
||||
root: join(transactionRoot, 'backup'),
|
||||
evidence: marker.backupBuild,
|
||||
snapshot: marker.priorSnapshot
|
||||
}).catch(() => {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
})
|
||||
}
|
||||
|
||||
async function liveOwnedSnapshot(
|
||||
marker: SkillTransactionMarker,
|
||||
snapshot: SkillTransactionMarker['priorSnapshot'],
|
||||
physicalIdentity: string
|
||||
): Promise<boolean> {
|
||||
return matchesOwnedPackage({ path: marker.destinationPath, physicalIdentity, snapshot })
|
||||
}
|
||||
|
||||
async function assertInPlaceState(args: {
|
||||
transactionRoot: string
|
||||
marker: SkillTransactionMarker
|
||||
allowAppliedOperation: boolean
|
||||
}): Promise<SkillTransactionLiveState> {
|
||||
const publication = args.marker.publication
|
||||
if (!publication || publication.kind !== 'in-place' || !args.marker.stagePhysicalIdentity) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
if (publication.direction === 'forward') {
|
||||
await assertStageCurrent(args.transactionRoot, args.marker)
|
||||
}
|
||||
const liveStat = await stat(args.marker.destinationPath).catch(() => null)
|
||||
if (
|
||||
!liveStat ||
|
||||
skillPhysicalIdentity(args.marker.destinationPath, liveStat) !== publication.physicalIdentity
|
||||
) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
if (publication.temporary) {
|
||||
const temporaryPath = join(
|
||||
args.marker.destinationPath,
|
||||
...publication.temporary.path.split('/')
|
||||
)
|
||||
const temporaryEntry = await lstat(temporaryPath).catch(() => null)
|
||||
if (
|
||||
temporaryEntry &&
|
||||
(!temporaryEntry.isFile() ||
|
||||
temporaryEntry.isSymbolicLink() ||
|
||||
skillPhysicalIdentity(temporaryPath, await stat(temporaryPath)) !==
|
||||
publication.temporary.physicalIdentity)
|
||||
) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
if (!temporaryEntry) {
|
||||
const operations = skillInPlaceOperations(
|
||||
args.marker.priorSnapshot,
|
||||
args.marker.currentSnapshot
|
||||
)
|
||||
const operation =
|
||||
operations[
|
||||
publication.direction === 'forward'
|
||||
? publication.operationIndex
|
||||
: publication.operationIndex - 1
|
||||
]
|
||||
const targetPath = operation
|
||||
? join(args.marker.destinationPath, ...operation.path.split('/'))
|
||||
: null
|
||||
const targetEntry = targetPath ? await lstat(targetPath).catch(() => null) : null
|
||||
// Why: a missing ready temp proves a completed rename only when its inode
|
||||
// is now the exact operation target; equal bytes from another writer do not.
|
||||
if (
|
||||
publication.temporary.state === 'creating' ||
|
||||
publication.temporary.state === 'written' ||
|
||||
!targetPath ||
|
||||
!targetEntry?.isFile() ||
|
||||
targetEntry.isSymbolicLink() ||
|
||||
skillPhysicalIdentity(targetPath, await stat(targetPath)) !==
|
||||
publication.temporary.physicalIdentity
|
||||
) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
} else if (publication.temporary.state === 'applied') {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
}
|
||||
const observed = await observeSkillPackage(args.marker.destinationPath).catch(() => null)
|
||||
if (
|
||||
!observed ||
|
||||
!matchesInPlacePublicationState({
|
||||
observed,
|
||||
prior: args.marker.priorSnapshot,
|
||||
current: args.marker.currentSnapshot,
|
||||
publication,
|
||||
allowAppliedOperation: args.allowAppliedOperation
|
||||
})
|
||||
) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
if (matchingReleasedSnapshot(observed, [args.marker.priorSnapshot])) {
|
||||
return 'prior'
|
||||
}
|
||||
if (matchingReleasedSnapshot(observed, [args.marker.currentSnapshot])) {
|
||||
return 'current'
|
||||
}
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
async function assertSwapState(args: {
|
||||
transactionRoot: string
|
||||
marker: SkillTransactionMarker
|
||||
}): Promise<SkillTransactionLiveState> {
|
||||
const publication = args.marker.publication
|
||||
if (!publication || publication.kind !== 'package-swap' || !args.marker.stagePhysicalIdentity) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
const stageRoot = join(args.transactionRoot, 'stage')
|
||||
const oldLiveRoot = join(args.transactionRoot, 'old-live')
|
||||
if (publication.step === 'moving-live') {
|
||||
const livePrior = await liveOwnedSnapshot(
|
||||
args.marker,
|
||||
args.marker.priorSnapshot,
|
||||
args.marker.physicalIdentity
|
||||
)
|
||||
if (livePrior && (await isAbsent(oldLiveRoot))) {
|
||||
await assertStageCurrent(args.transactionRoot, args.marker)
|
||||
return 'prior'
|
||||
}
|
||||
}
|
||||
if (publication.step === 'moving-stage') {
|
||||
const liveCurrent = await liveOwnedSnapshot(
|
||||
args.marker,
|
||||
args.marker.currentSnapshot,
|
||||
args.marker.stagePhysicalIdentity
|
||||
)
|
||||
if (liveCurrent && (await isAbsent(stageRoot))) {
|
||||
await assertOldLivePrior(args.transactionRoot, args.marker)
|
||||
return 'current'
|
||||
}
|
||||
}
|
||||
if (
|
||||
publication.step === 'moving-live' ||
|
||||
publication.step === 'live-moved' ||
|
||||
publication.step === 'moving-stage'
|
||||
) {
|
||||
if (!(await isAbsent(args.marker.destinationPath))) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
await Promise.all([
|
||||
assertOldLivePrior(args.transactionRoot, args.marker),
|
||||
assertStageCurrent(args.transactionRoot, args.marker)
|
||||
])
|
||||
return 'swap-absent'
|
||||
}
|
||||
if (publication.step === 'rollback-moving-old-live') {
|
||||
const livePrior = await liveOwnedSnapshot(
|
||||
args.marker,
|
||||
args.marker.priorSnapshot,
|
||||
args.marker.physicalIdentity
|
||||
)
|
||||
if (livePrior && (await isAbsent(oldLiveRoot))) {
|
||||
await assertStageCurrent(args.transactionRoot, args.marker)
|
||||
return 'prior'
|
||||
}
|
||||
}
|
||||
if (
|
||||
publication.step === 'rollback-moving-current' ||
|
||||
publication.step === 'rollback-current-moved' ||
|
||||
publication.step === 'rollback-moving-old-live'
|
||||
) {
|
||||
if (publication.step === 'rollback-moving-current') {
|
||||
const liveCurrent = await liveOwnedSnapshot(
|
||||
args.marker,
|
||||
args.marker.currentSnapshot,
|
||||
args.marker.stagePhysicalIdentity
|
||||
)
|
||||
if (liveCurrent && (await isAbsent(stageRoot))) {
|
||||
await assertOldLivePrior(args.transactionRoot, args.marker)
|
||||
return 'current'
|
||||
}
|
||||
}
|
||||
if (!(await isAbsent(args.marker.destinationPath))) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
await Promise.all([
|
||||
assertOldLivePrior(args.transactionRoot, args.marker),
|
||||
assertStageCurrent(args.transactionRoot, args.marker)
|
||||
])
|
||||
return 'swap-absent'
|
||||
}
|
||||
const liveCurrent = await liveOwnedSnapshot(
|
||||
args.marker,
|
||||
args.marker.currentSnapshot,
|
||||
args.marker.stagePhysicalIdentity
|
||||
)
|
||||
if (!liveCurrent || !(await isAbsent(stageRoot))) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
await assertOldLivePrior(args.transactionRoot, args.marker)
|
||||
return 'current'
|
||||
}
|
||||
|
||||
export async function assertTransactionOwnedState(args: {
|
||||
transactionRoot: string
|
||||
marker: SkillTransactionMarker
|
||||
assertParent: () => Promise<void>
|
||||
allowAppliedOperation: boolean
|
||||
}): Promise<SkillTransactionLiveState> {
|
||||
await args.assertParent()
|
||||
if (args.marker.phase === 'staging' || !args.marker.publication) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
await assertBackupPrior(args.transactionRoot, args.marker)
|
||||
if (args.marker.publication.kind === 'in-place') {
|
||||
return assertInPlaceState(args)
|
||||
}
|
||||
return assertSwapState(args)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { chmod, mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { SkillReleasedSnapshot } from '../../shared/skill-management'
|
||||
import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
|
||||
import {
|
||||
assertSkillTransactionPackageBuild,
|
||||
buildSkillTransactionPackage,
|
||||
type SkillTransactionPackageBuildEvidence,
|
||||
type SkillTransactionPackageBuildRuntime
|
||||
} from './skill-transaction-package-build'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-package-build-'))
|
||||
roots.push(root)
|
||||
const sourceRoot = join(root, 'source')
|
||||
const packageRoot = join(root, 'stage')
|
||||
await mkdir(join(sourceRoot, 'assets'), { recursive: true })
|
||||
await writeFile(join(sourceRoot, 'SKILL.md'), '# Test\n')
|
||||
await writeFile(join(sourceRoot, 'assets', 'run.bin'), Buffer.from([0, 1, 2, 3]))
|
||||
await chmod(join(sourceRoot, 'assets', 'run.bin'), 0o755)
|
||||
const observed = await observeSkillPackage(sourceRoot)
|
||||
const snapshot: SkillReleasedSnapshot = {
|
||||
releaseRevision: 2,
|
||||
packageDigest: observed.observedDigest,
|
||||
gitTreeSha: '',
|
||||
files: persistedObservedFiles(observed)
|
||||
}
|
||||
let lastEvidence: SkillTransactionPackageBuildEvidence | null = null
|
||||
const build = (runtime?: SkillTransactionPackageBuildRuntime) =>
|
||||
buildSkillTransactionPackage({
|
||||
root: packageRoot,
|
||||
sourceRoot,
|
||||
snapshot,
|
||||
crlf: false,
|
||||
assertSourceAuthority: async () => undefined,
|
||||
journal: async (evidence) => {
|
||||
lastEvidence = structuredClone(evidence)
|
||||
},
|
||||
assertBoundary: async (evidence) =>
|
||||
assertSkillTransactionPackageBuild({ root: packageRoot, evidence, snapshot }),
|
||||
runtime
|
||||
})
|
||||
return { root, sourceRoot, packageRoot, snapshot, build, lastEvidence: () => lastEvidence }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('skill transaction package build authority', () => {
|
||||
it('journals deterministic prefixes and a ready exact package', async () => {
|
||||
const value = await fixture()
|
||||
const ready = await value.build()
|
||||
expect(ready).toEqual({
|
||||
state: 'ready',
|
||||
physicalIdentity: expect.any(String),
|
||||
operationIndex: value.snapshot.files.length,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
})
|
||||
await expect(
|
||||
assertSkillTransactionPackageBuild({
|
||||
root: value.packageRoot,
|
||||
evidence: ready,
|
||||
snapshot: value.snapshot
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not fill a replacement root introduced while the root journal is awaited', async () => {
|
||||
const value = await fixture()
|
||||
const ownedRoot = `${value.packageRoot}-owned`
|
||||
let replaced = false
|
||||
await expect(
|
||||
buildSkillTransactionPackage({
|
||||
root: value.packageRoot,
|
||||
sourceRoot: value.sourceRoot,
|
||||
snapshot: value.snapshot,
|
||||
crlf: false,
|
||||
assertSourceAuthority: async () => undefined,
|
||||
journal: async () => {
|
||||
if (!replaced) {
|
||||
replaced = true
|
||||
await rename(value.packageRoot, ownedRoot)
|
||||
await mkdir(value.packageRoot)
|
||||
}
|
||||
},
|
||||
assertBoundary: async (evidence) =>
|
||||
assertSkillTransactionPackageBuild({
|
||||
root: value.packageRoot,
|
||||
evidence,
|
||||
snapshot: value.snapshot
|
||||
})
|
||||
})
|
||||
).rejects.toThrow('skill-transaction-package-build-changed')
|
||||
expect(await observeSkillPackage(value.packageRoot)).toMatchObject({ files: [] })
|
||||
expect(await observeSkillPackage(ownedRoot)).toMatchObject({ files: [] })
|
||||
})
|
||||
|
||||
it.each(['write', 'chmod', 'sync'] as const)(
|
||||
'retains phase-valid evidence when %s mutates and throws',
|
||||
async (boundary) => {
|
||||
const value = await fixture()
|
||||
const runtime: SkillTransactionPackageBuildRuntime = {}
|
||||
if (boundary === 'write') {
|
||||
runtime.writeBytes = async (handle, bytes) => {
|
||||
await handle.writeFile(bytes.subarray(0, 1))
|
||||
throw new Error('injected-write-failure')
|
||||
}
|
||||
} else if (boundary === 'chmod') {
|
||||
runtime.chmodFile = async (handle, mode) => {
|
||||
await handle.chmod(mode)
|
||||
throw new Error('injected-chmod-failure')
|
||||
}
|
||||
} else {
|
||||
runtime.syncFile = async (handle) => {
|
||||
await handle.sync()
|
||||
throw new Error('injected-sync-failure')
|
||||
}
|
||||
}
|
||||
await expect(value.build(runtime)).rejects.toThrow(`injected-${boundary}-failure`)
|
||||
const evidence = value.lastEvidence()
|
||||
expect(evidence?.state).toBe('creating')
|
||||
await expect(
|
||||
assertSkillTransactionPackageBuild({
|
||||
root: value.packageRoot,
|
||||
evidence: evidence!,
|
||||
snapshot: value.snapshot
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['parent', 'file'] as const)(
|
||||
'classifies a mutate-and-throw %s boundary exactly',
|
||||
async (boundary) => {
|
||||
const value = await fixture()
|
||||
const runtime: SkillTransactionPackageBuildRuntime =
|
||||
boundary === 'parent'
|
||||
? {
|
||||
createParent: async (path) => {
|
||||
await mkdir(path, { recursive: true })
|
||||
throw new Error('injected-parent-failure')
|
||||
}
|
||||
}
|
||||
: {
|
||||
openFile: async (path, mode) => {
|
||||
const handle = await open(path, 'wx', mode)
|
||||
await handle.close()
|
||||
throw new Error('injected-file-failure')
|
||||
}
|
||||
}
|
||||
await expect(value.build(runtime)).rejects.toThrow(`injected-${boundary}-failure`)
|
||||
const evidence = value.lastEvidence()
|
||||
expect(evidence?.activeFile).toBeNull()
|
||||
const assertion = assertSkillTransactionPackageBuild({
|
||||
root: value.packageRoot,
|
||||
evidence: evidence!,
|
||||
snapshot: value.snapshot
|
||||
})
|
||||
if (boundary === 'parent') {
|
||||
expect(evidence?.pendingPath).toBe(value.snapshot.files[0]!.path)
|
||||
await expect(assertion).resolves.toBeUndefined()
|
||||
} else {
|
||||
await expect(assertion).rejects.toThrow('skill-transaction-package-build-changed')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects unknown directory extras even when file content is exact', async () => {
|
||||
const value = await fixture()
|
||||
const ready = await value.build()
|
||||
await mkdir(join(value.packageRoot, 'unknown'))
|
||||
await expect(
|
||||
assertSkillTransactionPackageBuild({
|
||||
root: value.packageRoot,
|
||||
evidence: ready,
|
||||
snapshot: value.snapshot
|
||||
})
|
||||
).rejects.toThrow('skill-transaction-package-build-changed')
|
||||
expect(await readFile(join(value.packageRoot, 'SKILL.md'), 'utf8')).toBe('# Test\n')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
import { lstat, mkdir, open, readFile, readdir, stat, type FileHandle } from 'node:fs/promises'
|
||||
import { dirname, join, posix } from 'node:path'
|
||||
import type { SkillBundleFileIdentity, SkillReleasedSnapshot } from '../../shared/skill-management'
|
||||
import { skillPhysicalIdentity } from './skill-installation-topology'
|
||||
import {
|
||||
describeObservedSkillFile,
|
||||
matchesFileIdentity,
|
||||
observeSkillPackage
|
||||
} from './skill-package-identity'
|
||||
|
||||
export type SkillTransactionPackageBuildEvidence = {
|
||||
state: 'creating' | 'ready'
|
||||
physicalIdentity: string
|
||||
operationIndex: number
|
||||
pendingPath: string | null
|
||||
activeFile: {
|
||||
path: string
|
||||
physicalIdentity: string
|
||||
contentState: 'created' | 'written' | 'ready'
|
||||
} | null
|
||||
}
|
||||
|
||||
export type SkillTransactionPackageBuildRuntime = {
|
||||
createRoot?: (path: string) => Promise<void>
|
||||
createParent?: (path: string) => Promise<void>
|
||||
openFile?: (path: string, mode: number) => Promise<FileHandle>
|
||||
readSource?: (path: string) => Promise<Buffer>
|
||||
writeBytes?: (handle: FileHandle, bytes: Buffer) => Promise<void>
|
||||
chmodFile?: (handle: FileHandle, mode: number) => Promise<void>
|
||||
syncFile?: (handle: FileHandle) => Promise<void>
|
||||
}
|
||||
|
||||
export function isSkillTransactionPackageBuildEvidence(
|
||||
value: unknown
|
||||
): value is SkillTransactionPackageBuildEvidence {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false
|
||||
}
|
||||
const evidence = value as Partial<SkillTransactionPackageBuildEvidence>
|
||||
const validPath = (path: unknown): path is string =>
|
||||
typeof path === 'string' &&
|
||||
path.length > 0 &&
|
||||
!path.includes('\\') &&
|
||||
!posix.isAbsolute(path) &&
|
||||
posix.normalize(path) === path &&
|
||||
!path.split('/').includes('..')
|
||||
const activeFile = evidence.activeFile
|
||||
return Boolean(
|
||||
(evidence.state === 'creating' || evidence.state === 'ready') &&
|
||||
typeof evidence.physicalIdentity === 'string' &&
|
||||
evidence.physicalIdentity.length > 0 &&
|
||||
typeof evidence.operationIndex === 'number' &&
|
||||
Number.isSafeInteger(evidence.operationIndex) &&
|
||||
evidence.operationIndex >= 0 &&
|
||||
(evidence.pendingPath === null || validPath(evidence.pendingPath)) &&
|
||||
(activeFile === null ||
|
||||
(typeof activeFile === 'object' &&
|
||||
activeFile !== null &&
|
||||
validPath(activeFile.path) &&
|
||||
typeof activeFile.physicalIdentity === 'string' &&
|
||||
activeFile.physicalIdentity.length > 0 &&
|
||||
(activeFile.contentState === 'created' ||
|
||||
activeFile.contentState === 'written' ||
|
||||
activeFile.contentState === 'ready')))
|
||||
)
|
||||
}
|
||||
|
||||
function expectedDirectories(paths: readonly string[]): Set<string> {
|
||||
const directories = new Set<string>()
|
||||
for (const path of paths) {
|
||||
const parts = path.split('/')
|
||||
for (let index = 1; index < parts.length; index += 1) {
|
||||
directories.add(parts.slice(0, index).join('/'))
|
||||
}
|
||||
}
|
||||
return directories
|
||||
}
|
||||
|
||||
async function assertDirectoryShape(root: string, paths: readonly string[]): Promise<void> {
|
||||
const allowed = expectedDirectories(paths)
|
||||
const visit = async (directory: string, relativeDirectory: string): Promise<void> => {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name
|
||||
if (entry.isDirectory()) {
|
||||
if (!allowed.has(relativePath)) {
|
||||
throw new Error('skill-transaction-package-build-changed')
|
||||
}
|
||||
await visit(join(directory, entry.name), relativePath)
|
||||
} else if (!entry.isFile()) {
|
||||
throw new Error('skill-transaction-package-build-changed')
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit(root, '')
|
||||
}
|
||||
|
||||
function matchesPrefix(
|
||||
observed: Awaited<ReturnType<typeof observeSkillPackage>>,
|
||||
snapshot: SkillReleasedSnapshot,
|
||||
count: number
|
||||
): boolean {
|
||||
return (
|
||||
observed.files.length === count &&
|
||||
snapshot.files
|
||||
.slice(0, count)
|
||||
.every((expected, index) => matchesFileIdentity(observed.files[index]!, expected))
|
||||
)
|
||||
}
|
||||
|
||||
export async function assertSkillTransactionPackageBuild(args: {
|
||||
root: string
|
||||
evidence: SkillTransactionPackageBuildEvidence
|
||||
snapshot: SkillReleasedSnapshot
|
||||
}): Promise<void> {
|
||||
const rootEntry = await lstat(args.root)
|
||||
if (
|
||||
!rootEntry.isDirectory() ||
|
||||
rootEntry.isSymbolicLink() ||
|
||||
skillPhysicalIdentity(args.root, rootEntry) !== args.evidence.physicalIdentity
|
||||
) {
|
||||
throw new Error('skill-transaction-package-build-changed')
|
||||
}
|
||||
const observed = await observeSkillPackage(args.root)
|
||||
const expectedPaths = args.snapshot.files
|
||||
.slice(0, args.evidence.operationIndex)
|
||||
.map((file) => file.path)
|
||||
if (args.evidence.activeFile) {
|
||||
const expected = args.snapshot.files[args.evidence.operationIndex]
|
||||
const activePath = join(args.root, ...args.evidence.activeFile.path.split('/'))
|
||||
const activeEntry = await lstat(activePath)
|
||||
const withoutActive = {
|
||||
...observed,
|
||||
files: observed.files.filter((file) => file.path !== args.evidence.activeFile?.path)
|
||||
}
|
||||
if (
|
||||
!expected ||
|
||||
expected.path !== args.evidence.activeFile.path ||
|
||||
!activeEntry.isFile() ||
|
||||
activeEntry.isSymbolicLink() ||
|
||||
skillPhysicalIdentity(activePath, await stat(activePath)) !==
|
||||
args.evidence.activeFile.physicalIdentity ||
|
||||
observed.files.length !== withoutActive.files.length + 1 ||
|
||||
!matchesPrefix(withoutActive, args.snapshot, args.evidence.operationIndex)
|
||||
) {
|
||||
throw new Error('skill-transaction-package-build-changed')
|
||||
}
|
||||
const activeObserved = observed.files.find(
|
||||
(file) => file.path === args.evidence.activeFile?.path
|
||||
)
|
||||
if (
|
||||
!activeObserved ||
|
||||
(args.evidence.activeFile.contentState === 'written' &&
|
||||
!matchesFileIdentity({ ...activeObserved, executable: expected.executable }, expected)) ||
|
||||
(args.evidence.activeFile.contentState === 'ready' &&
|
||||
!matchesFileIdentity(activeObserved, expected))
|
||||
) {
|
||||
throw new Error('skill-transaction-package-build-changed')
|
||||
}
|
||||
await assertDirectoryShape(args.root, [...expectedPaths, expected.path])
|
||||
return
|
||||
}
|
||||
if (!matchesPrefix(observed, args.snapshot, args.evidence.operationIndex)) {
|
||||
throw new Error('skill-transaction-package-build-changed')
|
||||
}
|
||||
await assertDirectoryShape(
|
||||
args.root,
|
||||
args.evidence.pendingPath ? [...expectedPaths, args.evidence.pendingPath] : expectedPaths
|
||||
)
|
||||
}
|
||||
|
||||
function renderedBytes(source: Buffer, expected: SkillBundleFileIdentity, crlf: boolean): Buffer {
|
||||
if (expected.classification !== 'text' || expected.executable || !crlf) {
|
||||
return source
|
||||
}
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(source)
|
||||
return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'), 'utf8')
|
||||
}
|
||||
|
||||
export async function buildSkillTransactionPackage(args: {
|
||||
root: string
|
||||
sourceRoot: string
|
||||
snapshot: SkillReleasedSnapshot
|
||||
crlf: boolean
|
||||
assertSourceAuthority: () => Promise<void>
|
||||
journal: (evidence: SkillTransactionPackageBuildEvidence) => Promise<void>
|
||||
assertBoundary: (evidence: SkillTransactionPackageBuildEvidence) => Promise<void>
|
||||
runtime?: SkillTransactionPackageBuildRuntime
|
||||
}): Promise<SkillTransactionPackageBuildEvidence> {
|
||||
await args.assertSourceAuthority()
|
||||
await (args.runtime?.createRoot ?? ((path) => mkdir(path)))(args.root)
|
||||
const rootEntry = await lstat(args.root)
|
||||
let evidence: SkillTransactionPackageBuildEvidence = {
|
||||
state: 'creating',
|
||||
physicalIdentity: skillPhysicalIdentity(args.root, rootEntry),
|
||||
operationIndex: 0,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
}
|
||||
await args.journal(evidence)
|
||||
for (const [operationIndex, expected] of args.snapshot.files.entries()) {
|
||||
evidence = { ...evidence, operationIndex, pendingPath: expected.path, activeFile: null }
|
||||
await args.journal(evidence)
|
||||
await args.assertBoundary(evidence)
|
||||
const destination = join(args.root, ...expected.path.split('/'))
|
||||
await (args.runtime?.createParent ?? ((path) => mkdir(path, { recursive: true })))(
|
||||
dirname(destination)
|
||||
)
|
||||
await args.assertBoundary(evidence)
|
||||
const mode = expected.executable ? 0o755 : 0o644
|
||||
const handle = await (
|
||||
args.runtime?.openFile ?? ((path, fileMode) => open(path, 'wx', fileMode))
|
||||
)(destination, mode)
|
||||
const activeFileIdentity = skillPhysicalIdentity(destination, await handle.stat())
|
||||
evidence = {
|
||||
...evidence,
|
||||
operationIndex,
|
||||
pendingPath: null,
|
||||
activeFile: {
|
||||
path: expected.path,
|
||||
physicalIdentity: activeFileIdentity,
|
||||
contentState: 'created'
|
||||
}
|
||||
}
|
||||
try {
|
||||
await args.journal(evidence)
|
||||
await args.assertSourceAuthority()
|
||||
const bytes = renderedBytes(
|
||||
await (args.runtime?.readSource ?? readFile)(
|
||||
join(args.sourceRoot, ...expected.path.split('/'))
|
||||
),
|
||||
expected,
|
||||
args.crlf
|
||||
)
|
||||
const observedSource = describeObservedSkillFile(expected.path, bytes, expected.executable)
|
||||
if (!matchesFileIdentity(observedSource, expected)) {
|
||||
throw new Error('skill-transaction-package-source-changed')
|
||||
}
|
||||
await args.assertSourceAuthority()
|
||||
await args.assertBoundary(evidence)
|
||||
await (args.runtime?.writeBytes ?? ((target, value) => target.writeFile(value)))(
|
||||
handle,
|
||||
bytes
|
||||
)
|
||||
evidence = {
|
||||
...evidence,
|
||||
activeFile: {
|
||||
path: expected.path,
|
||||
physicalIdentity: activeFileIdentity,
|
||||
contentState: 'written'
|
||||
}
|
||||
}
|
||||
await args.assertBoundary(evidence)
|
||||
await args.journal(evidence)
|
||||
if (process.platform !== 'win32') {
|
||||
await args.assertBoundary(evidence)
|
||||
await (args.runtime?.chmodFile ?? ((target, fileMode) => target.chmod(fileMode)))(
|
||||
handle,
|
||||
mode
|
||||
)
|
||||
}
|
||||
evidence = {
|
||||
...evidence,
|
||||
activeFile: {
|
||||
path: expected.path,
|
||||
physicalIdentity: activeFileIdentity,
|
||||
contentState: 'ready'
|
||||
}
|
||||
}
|
||||
await args.assertBoundary(evidence)
|
||||
await args.journal(evidence)
|
||||
await args.assertBoundary(evidence)
|
||||
await (args.runtime?.syncFile ?? ((target) => target.sync()))(handle)
|
||||
await args.assertBoundary(evidence)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
evidence = {
|
||||
...evidence,
|
||||
operationIndex: operationIndex + 1,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
}
|
||||
await args.assertBoundary(evidence)
|
||||
await args.journal(evidence)
|
||||
}
|
||||
evidence = { ...evidence, state: 'ready' }
|
||||
await args.assertBoundary(evidence)
|
||||
await args.journal(evidence)
|
||||
return evidence
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const RESERVED_SKILL_TRANSACTION_DIRECTORY = '.orca-skill-transactions'
|
||||
@@ -0,0 +1,220 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { SkillReleasedSnapshot } from '../../shared/skill-management'
|
||||
import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
|
||||
import {
|
||||
matchesInPlacePublicationState,
|
||||
skillInPlaceOperations
|
||||
} from './skill-transaction-publication-state'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function packageRoot(files: Record<string, string | Buffer>): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-publication-state-'))
|
||||
roots.push(root)
|
||||
for (const [path, contents] of Object.entries(files)) {
|
||||
const destination = join(root, ...path.split('/'))
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
await writeFile(destination, contents)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
async function snapshot(root: string, revision: number): Promise<SkillReleasedSnapshot> {
|
||||
const observed = await observeSkillPackage(root)
|
||||
return {
|
||||
releaseRevision: revision,
|
||||
packageDigest: observed.observedDigest,
|
||||
gitTreeSha: '',
|
||||
files: persistedObservedFiles(observed)
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const priorRoot = await packageRoot({
|
||||
'SKILL.md': 'old entry',
|
||||
'asset-a.txt': 'old a',
|
||||
'obsolete-a.txt': 'old obsolete a',
|
||||
'obsolete-b.txt': 'old obsolete b'
|
||||
})
|
||||
const currentRoot = await packageRoot({
|
||||
'SKILL.md': 'new entry',
|
||||
'asset-a.txt': 'new a',
|
||||
'asset-b.txt': 'new b'
|
||||
})
|
||||
return {
|
||||
priorRoot,
|
||||
currentRoot,
|
||||
prior: await snapshot(priorRoot, 1),
|
||||
current: await snapshot(currentRoot, 2)
|
||||
}
|
||||
}
|
||||
|
||||
async function stateRoot(currentRoot: string, completed: number): Promise<string> {
|
||||
const root = await packageRoot({
|
||||
'SKILL.md': 'old entry',
|
||||
'asset-a.txt': 'old a',
|
||||
'obsolete-a.txt': 'old obsolete a',
|
||||
'obsolete-b.txt': 'old obsolete b'
|
||||
})
|
||||
const operations = [
|
||||
['publish', 'asset-a.txt'],
|
||||
['publish', 'asset-b.txt'],
|
||||
['publish', 'SKILL.md'],
|
||||
['remove', 'obsolete-a.txt'],
|
||||
['remove', 'obsolete-b.txt']
|
||||
] as const
|
||||
for (const [kind, path] of operations.slice(0, completed)) {
|
||||
await (kind === 'remove'
|
||||
? unlink(join(root, path))
|
||||
: writeFile(join(root, path), await readFile(join(currentRoot, path))))
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('skill transaction publication state', () => {
|
||||
it('accepts every exact publication prefix in asset, semantic-commit, removal order', async () => {
|
||||
const { currentRoot, prior, current } = await fixture()
|
||||
expect(skillInPlaceOperations(prior, current)).toEqual([
|
||||
{ kind: 'publish', path: 'asset-a.txt' },
|
||||
{ kind: 'publish', path: 'asset-b.txt' },
|
||||
{ kind: 'publish', path: 'SKILL.md' },
|
||||
{ kind: 'remove', path: 'obsolete-a.txt' },
|
||||
{ kind: 'remove', path: 'obsolete-b.txt' }
|
||||
])
|
||||
for (let operationIndex = 0; operationIndex <= 5; operationIndex += 1) {
|
||||
const observed = await observeSkillPackage(await stateRoot(currentRoot, operationIndex))
|
||||
expect(
|
||||
matchesInPlacePublicationState({
|
||||
observed,
|
||||
prior,
|
||||
current,
|
||||
publication: {
|
||||
kind: 'in-place',
|
||||
direction: 'forward',
|
||||
physicalIdentity: 'test-identity',
|
||||
temporary: null,
|
||||
operationIndex,
|
||||
operationState: 'ready'
|
||||
},
|
||||
allowAppliedOperation: false
|
||||
})
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['semantic commit before assets', { 'SKILL.md': 'new entry' }],
|
||||
['later asset before earlier asset', { 'asset-b.txt': 'new b' }],
|
||||
['obsolete removal before commit', { 'obsolete-a.txt': null }],
|
||||
['unknown extra', { 'unknown.txt': 'unknown' }]
|
||||
])('rejects impossible state: %s', async (_name, edits) => {
|
||||
const { priorRoot, prior, current } = await fixture()
|
||||
const root = await stateRoot(priorRoot, 0)
|
||||
for (const [path, contents] of Object.entries(edits)) {
|
||||
await (contents === null ? unlink(join(root, path)) : writeFile(join(root, path), contents))
|
||||
}
|
||||
expect(
|
||||
matchesInPlacePublicationState({
|
||||
observed: await observeSkillPackage(root),
|
||||
prior,
|
||||
current,
|
||||
publication: {
|
||||
kind: 'in-place',
|
||||
direction: 'forward',
|
||||
physicalIdentity: 'test-identity',
|
||||
temporary: null,
|
||||
operationIndex: 0,
|
||||
operationState: 'applying'
|
||||
},
|
||||
allowAppliedOperation: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a mode-only same-content edit', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
const { priorRoot, prior, current } = await fixture()
|
||||
const root = await stateRoot(priorRoot, 0)
|
||||
await chmod(join(root, 'asset-a.txt'), 0o755)
|
||||
expect(
|
||||
matchesInPlacePublicationState({
|
||||
observed: await observeSkillPackage(root),
|
||||
prior,
|
||||
current,
|
||||
publication: {
|
||||
kind: 'in-place',
|
||||
direction: 'forward',
|
||||
physicalIdentity: 'test-identity',
|
||||
temporary: null,
|
||||
operationIndex: 0,
|
||||
operationState: 'ready'
|
||||
},
|
||||
allowAppliedOperation: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects same-inode independent edits at every publication prefix', async () => {
|
||||
const { currentRoot, prior, current } = await fixture()
|
||||
for (let operationIndex = 0; operationIndex <= 5; operationIndex += 1) {
|
||||
const root = await stateRoot(currentRoot, operationIndex)
|
||||
await writeFile(join(root, 'SKILL.md'), `independent edit at ${operationIndex}`)
|
||||
expect(
|
||||
matchesInPlacePublicationState({
|
||||
observed: await observeSkillPackage(root),
|
||||
prior,
|
||||
current,
|
||||
publication: {
|
||||
kind: 'in-place',
|
||||
direction: 'forward',
|
||||
physicalIdentity: 'same-inode',
|
||||
temporary: null,
|
||||
operationIndex,
|
||||
operationState: 'ready'
|
||||
},
|
||||
allowAppliedOperation: false
|
||||
})
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a binary byte mismatch outside both exact snapshots', async () => {
|
||||
const priorRoot = await packageRoot({
|
||||
'SKILL.md': 'old entry',
|
||||
'payload.bin': Buffer.from([0, 1])
|
||||
})
|
||||
const currentRoot = await packageRoot({
|
||||
'SKILL.md': 'new entry',
|
||||
'payload.bin': Buffer.from([0, 2])
|
||||
})
|
||||
const observedRoot = await packageRoot({
|
||||
'SKILL.md': 'old entry',
|
||||
'payload.bin': Buffer.from([0, 3])
|
||||
})
|
||||
expect(
|
||||
matchesInPlacePublicationState({
|
||||
observed: await observeSkillPackage(observedRoot),
|
||||
prior: await snapshot(priorRoot, 1),
|
||||
current: await snapshot(currentRoot, 2),
|
||||
publication: {
|
||||
kind: 'in-place',
|
||||
direction: 'forward',
|
||||
physicalIdentity: 'same-inode',
|
||||
temporary: null,
|
||||
operationIndex: 0,
|
||||
operationState: 'applying'
|
||||
},
|
||||
allowAppliedOperation: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { SkillBundleFileIdentity, SkillReleasedSnapshot } from '../../shared/skill-management'
|
||||
import { matchesFileIdentity, type ObservedSkillPackage } from './skill-package-identity'
|
||||
|
||||
export type SkillInPlacePublicationState = {
|
||||
kind: 'in-place'
|
||||
direction: 'forward' | 'rollback'
|
||||
physicalIdentity: string
|
||||
temporary: SkillPublicationTemporary | null
|
||||
operationIndex: number
|
||||
operationState: 'ready' | 'applying'
|
||||
}
|
||||
|
||||
export type SkillPublicationTemporary = {
|
||||
path: string
|
||||
physicalIdentity: string
|
||||
expectedFile: SkillBundleFileIdentity
|
||||
state: 'creating' | 'written' | 'ready' | 'applied'
|
||||
}
|
||||
|
||||
export type SkillPackageSwapState = {
|
||||
kind: 'package-swap'
|
||||
step:
|
||||
| 'moving-live'
|
||||
| 'live-moved'
|
||||
| 'moving-stage'
|
||||
| 'stage-moved'
|
||||
| 'rollback-moving-current'
|
||||
| 'rollback-current-moved'
|
||||
| 'rollback-moving-old-live'
|
||||
| 'complete'
|
||||
}
|
||||
|
||||
export type SkillPublicationState = SkillInPlacePublicationState | SkillPackageSwapState
|
||||
|
||||
export type SkillInPlaceOperation = {
|
||||
kind: 'publish' | 'remove'
|
||||
path: string
|
||||
}
|
||||
|
||||
function sameFileIdentity(
|
||||
left: SkillBundleFileIdentity | undefined,
|
||||
right: SkillBundleFileIdentity
|
||||
): boolean {
|
||||
return Boolean(
|
||||
left &&
|
||||
left.path === right.path &&
|
||||
left.executable === right.executable &&
|
||||
left.classification === right.classification &&
|
||||
left.identitySha256 === right.identitySha256
|
||||
)
|
||||
}
|
||||
|
||||
export function sameCompleteFileIdentity(
|
||||
left: SkillBundleFileIdentity | undefined,
|
||||
right: SkillBundleFileIdentity
|
||||
): boolean {
|
||||
return Boolean(
|
||||
sameFileIdentity(left, right) &&
|
||||
left?.size === right.size &&
|
||||
left.exactSha256 === right.exactSha256 &&
|
||||
left.textNormalizedSha256 === right.textNormalizedSha256
|
||||
)
|
||||
}
|
||||
|
||||
export function skillInPlaceOperations(
|
||||
prior: SkillReleasedSnapshot,
|
||||
current: SkillReleasedSnapshot
|
||||
): SkillInPlaceOperation[] {
|
||||
const priorByPath = new Map(prior.files.map((file) => [file.path, file]))
|
||||
const currentPaths = new Set(current.files.map((file) => file.path))
|
||||
const changed = current.files
|
||||
.filter((file) => !sameFileIdentity(priorByPath.get(file.path), file))
|
||||
.sort((left, right) => left.path.localeCompare(right.path, 'en'))
|
||||
const entryPoint = changed.filter((file) => file.path === 'SKILL.md')
|
||||
const assets = changed.filter((file) => file.path !== 'SKILL.md')
|
||||
const obsolete = prior.files
|
||||
.filter((file) => !currentPaths.has(file.path))
|
||||
.sort((left, right) => left.path.localeCompare(right.path, 'en'))
|
||||
return [
|
||||
...assets.map((file) => ({ kind: 'publish' as const, path: file.path })),
|
||||
...entryPoint.map((file) => ({ kind: 'publish' as const, path: file.path })),
|
||||
...obsolete.map((file) => ({ kind: 'remove' as const, path: file.path }))
|
||||
]
|
||||
}
|
||||
|
||||
function expectedFilesAfter(
|
||||
prior: SkillReleasedSnapshot,
|
||||
current: SkillReleasedSnapshot,
|
||||
completedOperations: number
|
||||
): SkillBundleFileIdentity[] {
|
||||
const files = new Map(prior.files.map((file) => [file.path, file]))
|
||||
const currentByPath = new Map(current.files.map((file) => [file.path, file]))
|
||||
for (const operation of skillInPlaceOperations(prior, current).slice(0, completedOperations)) {
|
||||
if (operation.kind === 'remove') {
|
||||
files.delete(operation.path)
|
||||
} else {
|
||||
const next = currentByPath.get(operation.path)
|
||||
if (next) {
|
||||
files.set(operation.path, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...files.values()].sort((left, right) => left.path.localeCompare(right.path, 'en'))
|
||||
}
|
||||
|
||||
function matchesExactFiles(
|
||||
observed: ObservedSkillPackage,
|
||||
expected: readonly SkillBundleFileIdentity[]
|
||||
): boolean {
|
||||
return (
|
||||
observed.files.length === expected.length &&
|
||||
expected.every((file, index) => {
|
||||
const actual = observed.files[index]
|
||||
return Boolean(actual && matchesFileIdentity(actual, file))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function matchingInPlaceOperationIndex(args: {
|
||||
observed: ObservedSkillPackage
|
||||
prior: SkillReleasedSnapshot
|
||||
current: SkillReleasedSnapshot
|
||||
}): number | null {
|
||||
const operationCount = skillInPlaceOperations(args.prior, args.current).length
|
||||
for (let index = 0; index <= operationCount; index += 1) {
|
||||
if (matchesExactFiles(args.observed, expectedFilesAfter(args.prior, args.current, index))) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function matchesInPlacePublicationState(args: {
|
||||
observed: ObservedSkillPackage
|
||||
prior: SkillReleasedSnapshot
|
||||
current: SkillReleasedSnapshot
|
||||
publication: SkillInPlacePublicationState
|
||||
allowAppliedOperation: boolean
|
||||
}): boolean {
|
||||
const operations = skillInPlaceOperations(args.prior, args.current)
|
||||
if (args.publication.operationIndex < 0 || args.publication.operationIndex > operations.length) {
|
||||
return false
|
||||
}
|
||||
let observed = args.observed
|
||||
const operation =
|
||||
operations[
|
||||
args.publication.direction === 'forward'
|
||||
? args.publication.operationIndex
|
||||
: args.publication.operationIndex - 1
|
||||
]
|
||||
const expectedTemporaryFile =
|
||||
operation &&
|
||||
(args.publication.direction === 'forward'
|
||||
? args.current.files.find((file) => file.path === operation.path)
|
||||
: args.prior.files.find((file) => file.path === operation.path))
|
||||
let temporaryPresent = false
|
||||
if (args.publication.temporary) {
|
||||
const temporary = observed.files.find((file) => file.path === args.publication.temporary?.path)
|
||||
temporaryPresent = Boolean(temporary)
|
||||
if (
|
||||
!expectedTemporaryFile ||
|
||||
!sameCompleteFileIdentity(args.publication.temporary.expectedFile, expectedTemporaryFile) ||
|
||||
(temporary &&
|
||||
args.publication.temporary.state === 'written' &&
|
||||
!matchesFileIdentity(
|
||||
{
|
||||
...temporary,
|
||||
executable: args.publication.temporary.expectedFile.executable
|
||||
},
|
||||
{
|
||||
...args.publication.temporary.expectedFile,
|
||||
path: args.publication.temporary.path
|
||||
}
|
||||
)) ||
|
||||
(temporary &&
|
||||
(args.publication.temporary.state === 'ready' ||
|
||||
args.publication.temporary.state === 'applied') &&
|
||||
!matchesFileIdentity(temporary, {
|
||||
...args.publication.temporary.expectedFile,
|
||||
path: args.publication.temporary.path
|
||||
}))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (temporary) {
|
||||
observed = { ...observed, files: observed.files.filter((file) => file !== temporary) }
|
||||
}
|
||||
}
|
||||
const candidates = [args.publication.operationIndex]
|
||||
const operationCanHaveApplied =
|
||||
operation?.kind === 'remove' ||
|
||||
args.publication.temporary?.state === 'ready' ||
|
||||
args.publication.temporary?.state === 'applied'
|
||||
if (
|
||||
(args.allowAppliedOperation || args.publication.temporary?.state === 'applied') &&
|
||||
operationCanHaveApplied &&
|
||||
args.publication.operationState === 'applying' &&
|
||||
(args.publication.direction === 'forward'
|
||||
? args.publication.operationIndex < operations.length
|
||||
: args.publication.operationIndex > 0)
|
||||
) {
|
||||
const appliedIndex =
|
||||
args.publication.direction === 'forward'
|
||||
? args.publication.operationIndex + 1
|
||||
: args.publication.operationIndex - 1
|
||||
candidates.push(appliedIndex)
|
||||
if (args.publication.temporary && !temporaryPresent) {
|
||||
candidates.shift()
|
||||
}
|
||||
}
|
||||
if (
|
||||
args.publication.temporary &&
|
||||
!temporaryPresent &&
|
||||
(!args.allowAppliedOperation || args.publication.temporary.state === 'creating') &&
|
||||
args.publication.temporary.state !== 'applied'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return candidates
|
||||
.filter((count) => count >= 0)
|
||||
.some((count) =>
|
||||
matchesExactFiles(observed, expectedFilesAfter(args.prior, args.current, count))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { ManagedSkillDestination, SkillReleasedSnapshot } from '../../shared/skill-management'
|
||||
import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
|
||||
import { skillDestinationId, skillPhysicalIdentity } from './skill-installation-topology'
|
||||
import {
|
||||
acquireSkillDestinationLock,
|
||||
recoverMarkedSkillTransactions
|
||||
} from './skill-transaction-recovery'
|
||||
import { sweepOrphanedSkillTransactions } from './skill-orphan-transaction-sweep'
|
||||
import {
|
||||
parseSkillTransactionMarker,
|
||||
writeSkillTransactionMarker,
|
||||
type SkillTransactionMarker
|
||||
} from './skill-transaction-marker'
|
||||
import { emptySkillManagementLedger } from './skill-management-ledger'
|
||||
|
||||
const temporaryRoots: string[] = []
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-recovery-'))
|
||||
temporaryRoots.push(root)
|
||||
const live = join(root, 'skills', 'orca-cli')
|
||||
await mkdir(join(root, 'skills'), { recursive: true })
|
||||
await cp(resolve('skills/orca-cli'), live, { recursive: true })
|
||||
const resolvedLive = await realpath(live)
|
||||
const observed = await observeSkillPackage(resolvedLive)
|
||||
const files = persistedObservedFiles(observed)
|
||||
const snapshot: SkillReleasedSnapshot = {
|
||||
releaseRevision: 1,
|
||||
packageDigest: observed.observedDigest,
|
||||
gitTreeSha: '',
|
||||
files
|
||||
}
|
||||
const record = {
|
||||
id: skillDestinationId('local', live, 'orca-cli'),
|
||||
hostId: 'local',
|
||||
skillName: 'orca-cli',
|
||||
resolvedPath: resolvedLive,
|
||||
unresolvedPath: live,
|
||||
physicalIdentity: skillPhysicalIdentity(resolvedLive, await stat(resolvedLive)),
|
||||
entryType: 'directory',
|
||||
topology: 'canonical-copy',
|
||||
installedReleaseRevision: snapshot.releaseRevision,
|
||||
installedFiles: files,
|
||||
installedPackageDigest: observed.observedDigest
|
||||
} as unknown as ManagedSkillDestination
|
||||
return { root, live, snapshot, record }
|
||||
}
|
||||
|
||||
function marker(
|
||||
record: ManagedSkillDestination,
|
||||
snapshot: SkillReleasedSnapshot,
|
||||
phase: SkillTransactionMarker['phase']
|
||||
): SkillTransactionMarker {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
transactionId: 'test-transaction',
|
||||
createdAt: 1,
|
||||
destinationId: record.id,
|
||||
destinationPath: record.resolvedPath,
|
||||
physicalIdentity: record.physicalIdentity,
|
||||
hostId: record.hostId,
|
||||
skillName: record.skillName,
|
||||
phase,
|
||||
stagePhysicalIdentity: null,
|
||||
backupPhysicalIdentity: null,
|
||||
stageBuild: null,
|
||||
backupBuild: null,
|
||||
publication: null,
|
||||
priorSnapshot: snapshot,
|
||||
currentSnapshot: { ...snapshot, releaseRevision: snapshot.releaseRevision + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
async function writePublishingMarker(args: {
|
||||
transaction: string
|
||||
record: ManagedSkillDestination
|
||||
prior: SkillReleasedSnapshot
|
||||
current?: SkillReleasedSnapshot
|
||||
stageSource: string
|
||||
operationIndex?: number
|
||||
}): Promise<void> {
|
||||
const stageRoot = join(args.transaction, 'stage')
|
||||
await cp(args.stageSource, stageRoot, { recursive: true })
|
||||
const stagePhysicalIdentity = skillPhysicalIdentity(stageRoot, await stat(stageRoot))
|
||||
const backupRoot = join(args.transaction, 'backup')
|
||||
const backupPhysicalIdentity = skillPhysicalIdentity(backupRoot, await stat(backupRoot))
|
||||
const currentSnapshot = args.current ?? {
|
||||
...args.prior,
|
||||
releaseRevision: args.prior.releaseRevision + 1
|
||||
}
|
||||
await writeSkillTransactionMarker(args.transaction, {
|
||||
...marker(args.record, args.prior, 'publishing'),
|
||||
currentSnapshot,
|
||||
stagePhysicalIdentity,
|
||||
backupPhysicalIdentity,
|
||||
stageBuild: {
|
||||
state: 'ready',
|
||||
physicalIdentity: stagePhysicalIdentity,
|
||||
operationIndex: currentSnapshot.files.length,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
},
|
||||
backupBuild: {
|
||||
state: 'ready',
|
||||
physicalIdentity: backupPhysicalIdentity,
|
||||
operationIndex: args.prior.files.length,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
},
|
||||
publication: {
|
||||
kind: 'in-place',
|
||||
direction: 'forward',
|
||||
physicalIdentity: args.record.physicalIdentity,
|
||||
temporary: null,
|
||||
operationIndex: args.operationIndex ?? 0,
|
||||
operationState: 'ready'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function writeSwapMarker(args: {
|
||||
transaction: string
|
||||
record: ManagedSkillDestination
|
||||
snapshot: SkillReleasedSnapshot
|
||||
stageSource: string
|
||||
step: Extract<SkillTransactionMarker['publication'], { kind: 'package-swap' }>['step']
|
||||
}): Promise<void> {
|
||||
const stageRoot = join(args.transaction, 'stage')
|
||||
const backupRoot = join(args.transaction, 'backup')
|
||||
await cp(args.stageSource, stageRoot, { recursive: true })
|
||||
await cp(args.stageSource, backupRoot, { recursive: true })
|
||||
const stagePhysicalIdentity = skillPhysicalIdentity(stageRoot, await stat(stageRoot))
|
||||
const backupPhysicalIdentity = skillPhysicalIdentity(backupRoot, await stat(backupRoot))
|
||||
await writeSkillTransactionMarker(args.transaction, {
|
||||
...marker(args.record, args.snapshot, 'publishing'),
|
||||
stagePhysicalIdentity,
|
||||
backupPhysicalIdentity,
|
||||
stageBuild: {
|
||||
state: 'ready',
|
||||
physicalIdentity: stagePhysicalIdentity,
|
||||
operationIndex: args.snapshot.files.length,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
},
|
||||
backupBuild: {
|
||||
state: 'ready',
|
||||
physicalIdentity: backupPhysicalIdentity,
|
||||
operationIndex: args.snapshot.files.length,
|
||||
pendingPath: null,
|
||||
activeFile: null
|
||||
},
|
||||
publication: { kind: 'package-swap', step: args.step }
|
||||
})
|
||||
}
|
||||
|
||||
async function writeCreatingStageMarker(args: {
|
||||
transaction: string
|
||||
record: ManagedSkillDestination
|
||||
snapshot: SkillReleasedSnapshot
|
||||
}): Promise<string> {
|
||||
const stageRoot = join(args.transaction, 'stage')
|
||||
const firstFile = args.snapshot.files[0]!
|
||||
const activePath = join(stageRoot, ...firstFile.path.split('/'))
|
||||
await mkdir(dirname(activePath), { recursive: true })
|
||||
await writeFile(activePath, 'partial')
|
||||
const stagePhysicalIdentity = skillPhysicalIdentity(stageRoot, await stat(stageRoot))
|
||||
await writeSkillTransactionMarker(args.transaction, {
|
||||
...marker(args.record, args.snapshot, 'staging'),
|
||||
stagePhysicalIdentity,
|
||||
stageBuild: {
|
||||
state: 'creating',
|
||||
physicalIdentity: stagePhysicalIdentity,
|
||||
operationIndex: 0,
|
||||
pendingPath: null,
|
||||
activeFile: {
|
||||
path: firstFile.path,
|
||||
physicalIdentity: skillPhysicalIdentity(activePath, await stat(activePath)),
|
||||
contentState: 'created'
|
||||
}
|
||||
}
|
||||
})
|
||||
return stageRoot
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true })))
|
||||
})
|
||||
|
||||
describe('skill transaction recovery', () => {
|
||||
it('removes only marked staging transactions', async () => {
|
||||
const { root, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const marked = join(reservedRoot, 'marked')
|
||||
const unmarked = join(reservedRoot, 'user-data')
|
||||
await mkdir(marked, { recursive: true })
|
||||
await mkdir(unmarked, { recursive: true })
|
||||
await writeSkillTransactionMarker(marked, marker(record, snapshot, 'staging'))
|
||||
|
||||
await recoverMarkedSkillTransactions({
|
||||
reservedRoot,
|
||||
record
|
||||
})
|
||||
|
||||
await expect(readFile(join(marked, 'transaction.json'))).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
await expect(readFile(join(unmarked, 'transaction.json'))).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
expect(await import('node:fs/promises').then(({ stat }) => stat(unmarked))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('cleans only an identity-matched partial stage package', async () => {
|
||||
const { root, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'partial-stage')
|
||||
await mkdir(transaction, { recursive: true })
|
||||
await writeCreatingStageMarker({ transaction, record, snapshot })
|
||||
|
||||
await recoverMarkedSkillTransactions({ reservedRoot, record })
|
||||
|
||||
await expect(lstat(transaction)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('retains a transaction when the recorded partial stage root was replaced', async () => {
|
||||
const { root, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'replaced-stage')
|
||||
await mkdir(transaction, { recursive: true })
|
||||
const stageRoot = await writeCreatingStageMarker({ transaction, record, snapshot })
|
||||
await rm(stageRoot, { recursive: true })
|
||||
await mkdir(stageRoot)
|
||||
|
||||
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
|
||||
'skill-transaction-recovery-required'
|
||||
)
|
||||
await expect(lstat(transaction)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('retains rollback instead of overwriting an unknown same-inode edit', async () => {
|
||||
const { root, live, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'interrupted')
|
||||
await cp(live, join(transaction, 'backup'), { recursive: true })
|
||||
await writePublishingMarker({
|
||||
transaction,
|
||||
record,
|
||||
prior: snapshot,
|
||||
stageSource: live
|
||||
})
|
||||
await writeFile(join(live, 'SKILL.md'), 'partial new package')
|
||||
|
||||
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
|
||||
'skill-transaction-recovery-required'
|
||||
)
|
||||
|
||||
expect(await readFile(join(live, 'SKILL.md'), 'utf8')).toBe('partial new package')
|
||||
expect(await readFile(join(transaction, 'transaction.json'), 'utf8')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('restores a genuine mixed old/new package owned by the transaction', async () => {
|
||||
const { root, live, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'expected-partial')
|
||||
const currentRoot = join(root, 'current-package')
|
||||
await cp(live, join(transaction, 'backup'), { recursive: true })
|
||||
await cp(live, currentRoot, { recursive: true })
|
||||
await writeFile(join(currentRoot, 'SKILL.md'), 'expected current entry point')
|
||||
await writeFile(join(currentRoot, 'asset.txt'), 'expected new asset')
|
||||
const currentObserved = await observeSkillPackage(currentRoot)
|
||||
const currentSnapshot: SkillReleasedSnapshot = {
|
||||
releaseRevision: 2,
|
||||
packageDigest: currentObserved.observedDigest,
|
||||
gitTreeSha: '',
|
||||
files: persistedObservedFiles(currentObserved)
|
||||
}
|
||||
await writePublishingMarker({
|
||||
transaction,
|
||||
record,
|
||||
prior: snapshot,
|
||||
current: currentSnapshot,
|
||||
stageSource: currentRoot,
|
||||
operationIndex: 1
|
||||
})
|
||||
await writeFile(join(live, 'asset.txt'), 'expected new asset')
|
||||
|
||||
await recoverMarkedSkillTransactions({ reservedRoot, record })
|
||||
|
||||
expect(await observeSkillPackage(live)).toMatchObject({
|
||||
observedDigest: snapshot.packageDigest
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['creating', 'ready'] as const)(
|
||||
'removes an identity-owned %s publication temp before resuming recovery',
|
||||
async (temporaryState) => {
|
||||
const { root, live, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'publication-temp')
|
||||
const currentRoot = join(root, 'temp-current')
|
||||
await cp(live, join(transaction, 'backup'), { recursive: true })
|
||||
await cp(live, currentRoot, { recursive: true })
|
||||
await writeFile(join(currentRoot, 'asset.txt'), 'new asset')
|
||||
const currentObserved = await observeSkillPackage(currentRoot)
|
||||
const currentSnapshot: SkillReleasedSnapshot = {
|
||||
releaseRevision: 2,
|
||||
packageDigest: currentObserved.observedDigest,
|
||||
gitTreeSha: '',
|
||||
files: persistedObservedFiles(currentObserved)
|
||||
}
|
||||
await writePublishingMarker({
|
||||
transaction,
|
||||
record,
|
||||
prior: snapshot,
|
||||
current: currentSnapshot,
|
||||
stageSource: currentRoot
|
||||
})
|
||||
const writtenMarker = parseSkillTransactionMarker(
|
||||
await readFile(join(transaction, 'transaction.json'), 'utf8')
|
||||
)
|
||||
expect(writtenMarker).not.toBeNull()
|
||||
const temporaryPath = 'asset.txt.orca-test.tmp'
|
||||
await writeFile(
|
||||
join(live, temporaryPath),
|
||||
temporaryState === 'creating' ? 'partial' : await readFile(join(currentRoot, 'asset.txt'))
|
||||
)
|
||||
await writeSkillTransactionMarker(transaction, {
|
||||
...writtenMarker!,
|
||||
publication: {
|
||||
kind: 'in-place',
|
||||
direction: 'forward',
|
||||
physicalIdentity: record.physicalIdentity,
|
||||
temporary: {
|
||||
path: temporaryPath,
|
||||
physicalIdentity: skillPhysicalIdentity(
|
||||
join(live, temporaryPath),
|
||||
await stat(join(live, temporaryPath))
|
||||
),
|
||||
expectedFile: currentSnapshot.files.find((file) => file.path === 'asset.txt')!,
|
||||
state: temporaryState
|
||||
},
|
||||
operationIndex: 0,
|
||||
operationState: 'applying'
|
||||
}
|
||||
})
|
||||
|
||||
await recoverMarkedSkillTransactions({ reservedRoot, record })
|
||||
|
||||
expect((await observeSkillPackage(live)).observedDigest).toBe(snapshot.packageDigest)
|
||||
await expect(stat(join(live, temporaryPath))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
}
|
||||
)
|
||||
|
||||
it('does not overwrite unknown content with a different physical identity', async () => {
|
||||
const { root, live, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'independent-replacement')
|
||||
await cp(live, join(transaction, 'backup'), { recursive: true })
|
||||
await writePublishingMarker({
|
||||
transaction,
|
||||
record,
|
||||
prior: snapshot,
|
||||
stageSource: live
|
||||
})
|
||||
await rm(live, { recursive: true })
|
||||
await mkdir(live, { recursive: true })
|
||||
await writeFile(join(live, 'SKILL.md'), 'independently replaced')
|
||||
|
||||
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
|
||||
'skill-transaction-recovery-required'
|
||||
)
|
||||
expect(await readFile(join(live, 'SKILL.md'), 'utf8')).toBe('independently replaced')
|
||||
})
|
||||
|
||||
it('fails closed when the approved parent is replaced by a link before recovery', async () => {
|
||||
const { root, live, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'parent-link-swap')
|
||||
const originalSkills = join(root, 'original-skills')
|
||||
const outsideSkills = join(root, 'outside-skills')
|
||||
await cp(live, join(transaction, 'backup'), { recursive: true })
|
||||
await writePublishingMarker({
|
||||
transaction,
|
||||
record,
|
||||
prior: snapshot,
|
||||
stageSource: live
|
||||
})
|
||||
await writeFile(join(live, 'SKILL.md'), 'interrupted publication')
|
||||
await mkdir(outsideSkills)
|
||||
await rename(join(root, 'skills'), originalSkills)
|
||||
await symlink(
|
||||
outsideSkills,
|
||||
join(root, 'skills'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
|
||||
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
|
||||
'skill-topology-changed'
|
||||
)
|
||||
await expect(readFile(join(outsideSkills, 'orca-cli', 'SKILL.md'))).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers the one marker-owned package-swap gap with an absent destination', async () => {
|
||||
const { root, live, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'swap-gap')
|
||||
await writeSwapMarker({
|
||||
transaction,
|
||||
record,
|
||||
snapshot,
|
||||
stageSource: live,
|
||||
step: 'moving-stage'
|
||||
})
|
||||
await rename(live, join(transaction, 'old-live'))
|
||||
|
||||
await recoverMarkedSkillTransactions({ reservedRoot, record })
|
||||
|
||||
expect((await observeSkillPackage(live)).observedDigest).toBe(snapshot.packageDigest)
|
||||
await expect(stat(transaction)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects an absent destination outside the package-swap gap phase', async () => {
|
||||
const { root, live, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'invalid-swap-gap')
|
||||
await writeSwapMarker({
|
||||
transaction,
|
||||
record,
|
||||
snapshot,
|
||||
stageSource: live,
|
||||
step: 'complete'
|
||||
})
|
||||
await rename(live, join(transaction, 'old-live'))
|
||||
|
||||
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
|
||||
'skill-transaction-recovery-required'
|
||||
)
|
||||
expect(await stat(join(transaction, 'old-live'))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('retains the package-swap gap when old-live was edited in place', async () => {
|
||||
const { root, live, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'edited-old-live')
|
||||
await writeSwapMarker({
|
||||
transaction,
|
||||
record,
|
||||
snapshot,
|
||||
stageSource: live,
|
||||
step: 'moving-stage'
|
||||
})
|
||||
const oldLive = join(transaction, 'old-live')
|
||||
await rename(live, oldLive)
|
||||
await writeFile(join(oldLive, 'SKILL.md'), 'independent same-inode edit')
|
||||
|
||||
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
|
||||
'skill-transaction-recovery-required'
|
||||
)
|
||||
expect(await readFile(join(oldLive, 'SKILL.md'), 'utf8')).toBe('independent same-inode edit')
|
||||
})
|
||||
|
||||
it('rejects an exact stage copy whose recorded physical identity was replaced', async () => {
|
||||
const { root, live, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'replaced-stage')
|
||||
await writeSwapMarker({
|
||||
transaction,
|
||||
record,
|
||||
snapshot,
|
||||
stageSource: live,
|
||||
step: 'moving-stage'
|
||||
})
|
||||
await rename(live, join(transaction, 'old-live'))
|
||||
const stage = join(transaction, 'stage')
|
||||
const replacement = join(transaction, 'replacement-stage')
|
||||
await cp(stage, replacement, { recursive: true })
|
||||
await rm(stage, { recursive: true })
|
||||
await rename(replacement, stage)
|
||||
|
||||
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
|
||||
'skill-transaction-recovery-required'
|
||||
)
|
||||
await expect(stat(live)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rolls back a completed package swap without losing the original identity', async () => {
|
||||
const { root, live, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'completed-swap')
|
||||
await writeSwapMarker({
|
||||
transaction,
|
||||
record,
|
||||
snapshot,
|
||||
stageSource: live,
|
||||
step: 'complete'
|
||||
})
|
||||
await rename(live, join(transaction, 'old-live'))
|
||||
await rename(join(transaction, 'stage'), live)
|
||||
|
||||
await recoverMarkedSkillTransactions({ reservedRoot, record })
|
||||
|
||||
expect(skillPhysicalIdentity(live, await stat(live))).toBe(record.physicalIdentity)
|
||||
expect((await observeSkillPackage(live)).observedDigest).toBe(snapshot.packageDigest)
|
||||
})
|
||||
|
||||
it('recovers when the stage rename completed before its journal advancement', async () => {
|
||||
const { root, live, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'stage-rename-boundary')
|
||||
await writeSwapMarker({
|
||||
transaction,
|
||||
record,
|
||||
snapshot,
|
||||
stageSource: live,
|
||||
step: 'moving-stage'
|
||||
})
|
||||
await rename(live, join(transaction, 'old-live'))
|
||||
await rename(join(transaction, 'stage'), live)
|
||||
|
||||
await recoverMarkedSkillTransactions({ reservedRoot, record })
|
||||
|
||||
expect(skillPhysicalIdentity(live, await stat(live))).toBe(record.physicalIdentity)
|
||||
expect((await observeSkillPackage(live)).observedDigest).toBe(snapshot.packageDigest)
|
||||
})
|
||||
|
||||
it('reclaims only a marked lock whose owner process is gone', async () => {
|
||||
const { root } = await fixture()
|
||||
const lockRoot = join(root, 'lock')
|
||||
await mkdir(lockRoot)
|
||||
await writeFile(
|
||||
join(lockRoot, 'owner.json'),
|
||||
JSON.stringify({ schemaVersion: 1, pid: 2_147_483_647 })
|
||||
)
|
||||
|
||||
const release = await acquireSkillDestinationLock(lockRoot)
|
||||
expect(JSON.parse(await readFile(join(lockRoot, 'owner.json'), 'utf8')).pid).toBe(process.pid)
|
||||
await release()
|
||||
})
|
||||
|
||||
it('restores a self-contained orphan before inventory has loaded a ledger', async () => {
|
||||
const { root, live, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'orphan')
|
||||
const unmarked = join(reservedRoot, 'keep-me')
|
||||
await cp(live, join(transaction, 'backup'), { recursive: true })
|
||||
await mkdir(unmarked, { recursive: true })
|
||||
await writePublishingMarker({
|
||||
transaction,
|
||||
record,
|
||||
prior: snapshot,
|
||||
stageSource: live
|
||||
})
|
||||
await writeFile(join(live, 'SKILL.md'), 'interrupted publication')
|
||||
|
||||
await expect(
|
||||
sweepOrphanedSkillTransactions(join(root, 'skills'), emptySkillManagementLedger())
|
||||
).rejects.toThrow('skill-transaction-recovery-required')
|
||||
|
||||
expect(await readFile(join(live, 'SKILL.md'), 'utf8')).toBe('interrupted publication')
|
||||
expect(await readFile(join(transaction, 'transaction.json'), 'utf8')).toBeTruthy()
|
||||
expect(await import('node:fs/promises').then(({ stat }) => stat(unmarked))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('leaves a transaction untouched while its destination lock is live', async () => {
|
||||
const { root, record, snapshot } = await fixture()
|
||||
const reservedRoot = join(root, '.orca-skill-transactions')
|
||||
const transaction = join(reservedRoot, 'busy')
|
||||
await mkdir(transaction, { recursive: true })
|
||||
await writeSkillTransactionMarker(transaction, marker(record, snapshot, 'staging'))
|
||||
await mkdir(join(reservedRoot, 'locks'), { recursive: true })
|
||||
const release = await acquireSkillDestinationLock(join(reservedRoot, 'locks', record.id))
|
||||
|
||||
await sweepOrphanedSkillTransactions(join(root, 'skills'), emptySkillManagementLedger())
|
||||
|
||||
expect(
|
||||
parseSkillTransactionMarker(await readFile(join(transaction, 'transaction.json'), 'utf8'))
|
||||
).not.toBeNull()
|
||||
await release()
|
||||
})
|
||||
|
||||
it('ignores a valid marker whose destination is outside the approved root', async () => {
|
||||
const { root, snapshot } = await fixture()
|
||||
const outside = join(root, 'outside', 'orca-cli')
|
||||
await mkdir(outside, { recursive: true })
|
||||
const outsideRecord = {
|
||||
id: skillDestinationId('local', outside, 'orca-cli'),
|
||||
hostId: 'local',
|
||||
skillName: 'orca-cli',
|
||||
resolvedPath: outside,
|
||||
physicalIdentity: 'outside-identity'
|
||||
} as ManagedSkillDestination
|
||||
const transaction = join(root, '.orca-skill-transactions', 'outside-target')
|
||||
await mkdir(transaction, { recursive: true })
|
||||
await writeSkillTransactionMarker(transaction, marker(outsideRecord, snapshot, 'staging'))
|
||||
|
||||
await sweepOrphanedSkillTransactions(join(root, 'skills'), emptySkillManagementLedger())
|
||||
|
||||
expect(await readFile(join(transaction, 'transaction.json'), 'utf8')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { ManagedSkillDestination } from '../../shared/skill-management'
|
||||
import {
|
||||
assertManagedSkillParentTopology,
|
||||
normalizedSkillIdentityPath
|
||||
} from './skill-installation-topology'
|
||||
import { parseSkillTransactionMarker } from './skill-transaction-marker'
|
||||
import { recoverTransactionToPrior } from './skill-transaction-restore'
|
||||
|
||||
export async function recoverMarkedSkillTransactions(args: {
|
||||
reservedRoot: string
|
||||
record: ManagedSkillDestination
|
||||
}): Promise<void> {
|
||||
const entries = await readdir(args.reservedRoot, { withFileTypes: true }).catch(() => [])
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === 'locks') {
|
||||
continue
|
||||
}
|
||||
const transactionRoot = join(args.reservedRoot, entry.name)
|
||||
const marker = await readFile(join(transactionRoot, 'transaction.json'), 'utf8')
|
||||
.then(parseSkillTransactionMarker)
|
||||
.catch(() => null)
|
||||
const recordMatchesPrior = marker
|
||||
? marker.priorSnapshot.packageDigest === args.record.installedPackageDigest
|
||||
: false
|
||||
const recordMatchesCurrent = marker
|
||||
? marker.phase === 'verified' &&
|
||||
marker.currentSnapshot.packageDigest === args.record.installedPackageDigest
|
||||
: false
|
||||
if (
|
||||
!marker ||
|
||||
marker.destinationId !== args.record.id ||
|
||||
marker.hostId !== args.record.hostId ||
|
||||
marker.skillName !== args.record.skillName ||
|
||||
normalizedSkillIdentityPath(marker.destinationPath) !==
|
||||
normalizedSkillIdentityPath(args.record.resolvedPath) ||
|
||||
(!recordMatchesPrior && !recordMatchesCurrent)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
await recoverTransactionToPrior({
|
||||
transactionRoot,
|
||||
marker,
|
||||
assertParentAuthority: () => assertManagedSkillParentTopology(args.record),
|
||||
currentDisposition:
|
||||
marker.phase === 'verified' && recordMatchesCurrent ? 'committed' : 'restore'
|
||||
})
|
||||
await rm(transactionRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function processIsAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'EPERM')
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireSkillDestinationLock(
|
||||
lockRoot: string,
|
||||
beforeMutation: () => Promise<void> = async () => undefined
|
||||
): Promise<() => Promise<void>> {
|
||||
const create = async (): Promise<void> => {
|
||||
await beforeMutation()
|
||||
await mkdir(lockRoot)
|
||||
await beforeMutation()
|
||||
await writeFile(
|
||||
join(lockRoot, 'owner.json'),
|
||||
JSON.stringify({ schemaVersion: 1, pid: process.pid, createdAt: Date.now() })
|
||||
)
|
||||
}
|
||||
try {
|
||||
await create()
|
||||
} catch (error) {
|
||||
if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EEXIST') {
|
||||
throw error
|
||||
}
|
||||
const owner = await readFile(join(lockRoot, 'owner.json'), 'utf8')
|
||||
.then((value) => JSON.parse(value) as unknown)
|
||||
.catch(() => null)
|
||||
if (
|
||||
typeof owner !== 'object' ||
|
||||
owner === null ||
|
||||
!('schemaVersion' in owner) ||
|
||||
owner.schemaVersion !== 1 ||
|
||||
!('pid' in owner) ||
|
||||
typeof owner.pid !== 'number' ||
|
||||
processIsAlive(owner.pid)
|
||||
) {
|
||||
throw new Error('skill-update-busy')
|
||||
}
|
||||
// Why: only an Orca-marked lock whose owner is gone is safe to reclaim.
|
||||
await beforeMutation()
|
||||
await rm(lockRoot, { recursive: true })
|
||||
await create()
|
||||
}
|
||||
return () => rm(lockRoot, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { rename } from 'node:fs/promises'
|
||||
|
||||
export type SkillRenameRuntime = {
|
||||
renameEntry?: typeof rename
|
||||
wait?: (milliseconds: number) => Promise<void>
|
||||
windowsFallback?: boolean
|
||||
}
|
||||
|
||||
export async function retrySkillRename(
|
||||
source: string,
|
||||
destination: string,
|
||||
beforeAttempt: () => Promise<void>,
|
||||
renameEntry: typeof rename = rename,
|
||||
wait: (milliseconds: number) => Promise<void> = (milliseconds) =>
|
||||
new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
// Why: another manager can edit during a Windows sharing-violation delay.
|
||||
await beforeAttempt()
|
||||
try {
|
||||
await renameEntry(source, destination)
|
||||
return
|
||||
} catch (error) {
|
||||
const retryable =
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
(error.code === 'EPERM' || error.code === 'EBUSY')
|
||||
if (!retryable || attempt === 3) {
|
||||
throw error
|
||||
}
|
||||
await wait(25 * 2 ** attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { lstat, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { ManagedSkillDestination } from '../../shared/skill-management'
|
||||
import { skillPhysicalIdentity } from './skill-installation-topology'
|
||||
import { observeSkillPackage } from './skill-package-identity'
|
||||
import { rollbackSkillPackageSwap, type PublicationCallbacks } from './skill-package-publication'
|
||||
import {
|
||||
clearSkillPublicationTemporary,
|
||||
rollbackSkillPackageInPlace
|
||||
} from './skill-package-rollback'
|
||||
import {
|
||||
writeSkillTransactionMarker,
|
||||
type SkillTransactionMarker
|
||||
} from './skill-transaction-marker'
|
||||
import { assertTransactionOwnedState } from './skill-transaction-owned-state'
|
||||
import { assertSkillTransactionPackageBuild } from './skill-transaction-package-build'
|
||||
import {
|
||||
matchingInPlaceOperationIndex,
|
||||
type SkillPublicationState
|
||||
} from './skill-transaction-publication-state'
|
||||
import { retrySkillRename } from './skill-transaction-rename'
|
||||
|
||||
export async function recoverTransactionToPrior(args: {
|
||||
transactionRoot: string
|
||||
marker: SkillTransactionMarker
|
||||
assertParentAuthority: () => Promise<void>
|
||||
currentDisposition: 'committed' | 'restore'
|
||||
}): Promise<void> {
|
||||
if (args.marker.phase === 'staging') {
|
||||
await args.assertParentAuthority()
|
||||
for (const [rootName, evidence, snapshot] of [
|
||||
['stage', args.marker.stageBuild, args.marker.currentSnapshot],
|
||||
['backup', args.marker.backupBuild, args.marker.priorSnapshot]
|
||||
] as const) {
|
||||
const root = join(args.transactionRoot, rootName)
|
||||
if (!evidence) {
|
||||
if (await lstat(root).catch(() => null)) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
continue
|
||||
}
|
||||
await assertSkillTransactionPackageBuild({ root, evidence, snapshot }).catch(() => {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const record = { resolvedPath: args.marker.destinationPath } as ManagedSkillDestination
|
||||
let activeMarker = args.marker
|
||||
const assertOwned = () =>
|
||||
assertTransactionOwnedState({
|
||||
transactionRoot: args.transactionRoot,
|
||||
marker: activeMarker,
|
||||
assertParent: args.assertParentAuthority,
|
||||
allowAppliedOperation: true
|
||||
})
|
||||
const journal = async (publication: SkillPublicationState): Promise<void> => {
|
||||
activeMarker = { ...activeMarker, publication }
|
||||
await writeSkillTransactionMarker(
|
||||
args.transactionRoot,
|
||||
activeMarker,
|
||||
args.assertParentAuthority
|
||||
)
|
||||
}
|
||||
const assertBoundary: PublicationCallbacks['assertBoundary'] = async (
|
||||
publication,
|
||||
expectedLiveState
|
||||
) => {
|
||||
const liveState = await assertTransactionOwnedState({
|
||||
transactionRoot: args.transactionRoot,
|
||||
marker: { ...activeMarker, publication },
|
||||
assertParent: args.assertParentAuthority,
|
||||
allowAppliedOperation: true
|
||||
})
|
||||
if (expectedLiveState && liveState !== expectedLiveState) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
}
|
||||
const callbacks = { journal, assertBoundary }
|
||||
const state = await assertOwned()
|
||||
if (state === 'prior' || (state === 'current' && args.currentDisposition === 'committed')) {
|
||||
return
|
||||
}
|
||||
if (state === 'current' && activeMarker.publication?.kind === 'package-swap') {
|
||||
await rollbackSkillPackageSwap({
|
||||
record,
|
||||
transactionRoot: args.transactionRoot,
|
||||
callbacks
|
||||
})
|
||||
} else if (state === 'swap-absent') {
|
||||
const movingLive = { kind: 'package-swap', step: 'moving-live' } as const
|
||||
await retrySkillRename(
|
||||
join(args.transactionRoot, 'old-live'),
|
||||
args.marker.destinationPath,
|
||||
async () => {
|
||||
if ((await assertOwned()) !== 'swap-absent') {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
}
|
||||
)
|
||||
// Why: recovery journals both sides of the ambiguous rename before cleanup.
|
||||
await journal(movingLive)
|
||||
} else {
|
||||
if (activeMarker.publication?.kind === 'in-place') {
|
||||
await clearSkillPublicationTemporary({
|
||||
record,
|
||||
publication: activeMarker.publication,
|
||||
callbacks
|
||||
})
|
||||
}
|
||||
const observed = await observeSkillPackage(args.marker.destinationPath)
|
||||
const startingIndex = matchingInPlaceOperationIndex({
|
||||
observed,
|
||||
prior: args.marker.priorSnapshot,
|
||||
current: args.marker.currentSnapshot
|
||||
})
|
||||
if (startingIndex === null) {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
await rollbackSkillPackageInPlace({
|
||||
record,
|
||||
prior: args.marker.priorSnapshot,
|
||||
current: args.marker.currentSnapshot,
|
||||
backupRoot: join(args.transactionRoot, 'backup'),
|
||||
startingIndex,
|
||||
physicalIdentity: skillPhysicalIdentity(
|
||||
args.marker.destinationPath,
|
||||
await stat(args.marker.destinationPath)
|
||||
),
|
||||
callbacks
|
||||
})
|
||||
}
|
||||
if ((await assertOwned()) !== 'prior') {
|
||||
throw new Error('skill-transaction-recovery-required')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { posix } from 'node:path'
|
||||
import type { SkillBundleFileIdentity, SkillReleasedSnapshot } from '../../shared/skill-management'
|
||||
import { skillPackageDigest } from './skill-package-identity'
|
||||
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
||||
|
||||
export function isSkillBundleFileIdentity(value: unknown): value is SkillBundleFileIdentity {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false
|
||||
}
|
||||
const file = value as Partial<SkillBundleFileIdentity>
|
||||
return (
|
||||
typeof file.path === 'string' &&
|
||||
file.path.length > 0 &&
|
||||
!file.path.includes('\\') &&
|
||||
!posix.isAbsolute(file.path) &&
|
||||
posix.normalize(file.path) === file.path &&
|
||||
!file.path.split('/').includes('..') &&
|
||||
typeof file.size === 'number' &&
|
||||
Number.isSafeInteger(file.size) &&
|
||||
file.size >= 0 &&
|
||||
typeof file.executable === 'boolean' &&
|
||||
(file.classification === 'text' || file.classification === 'binary') &&
|
||||
typeof file.exactSha256 === 'string' &&
|
||||
SHA256_PATTERN.test(file.exactSha256) &&
|
||||
(file.textNormalizedSha256 === null ||
|
||||
(typeof file.textNormalizedSha256 === 'string' &&
|
||||
SHA256_PATTERN.test(file.textNormalizedSha256))) &&
|
||||
typeof file.identitySha256 === 'string' &&
|
||||
SHA256_PATTERN.test(file.identitySha256) &&
|
||||
(file.classification !== 'text' || file.textNormalizedSha256 !== null) &&
|
||||
file.identitySha256 ===
|
||||
(file.classification === 'text' && !file.executable
|
||||
? file.textNormalizedSha256
|
||||
: file.exactSha256)
|
||||
)
|
||||
}
|
||||
|
||||
export function isSkillReleasedSnapshot(value: unknown): value is SkillReleasedSnapshot {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false
|
||||
}
|
||||
const snapshot = value as Partial<SkillReleasedSnapshot>
|
||||
const structurallyValid =
|
||||
typeof snapshot.releaseRevision === 'number' &&
|
||||
Number.isSafeInteger(snapshot.releaseRevision) &&
|
||||
snapshot.releaseRevision >= 0 &&
|
||||
typeof snapshot.packageDigest === 'string' &&
|
||||
SHA256_PATTERN.test(snapshot.packageDigest) &&
|
||||
typeof snapshot.gitTreeSha === 'string' &&
|
||||
Array.isArray(snapshot.files) &&
|
||||
snapshot.files.every(isSkillBundleFileIdentity)
|
||||
if (!structurallyValid) {
|
||||
return false
|
||||
}
|
||||
const files = snapshot.files as SkillBundleFileIdentity[]
|
||||
const paths = files.map((file) => file.path)
|
||||
return (
|
||||
paths.every((path, index) => index === 0 || paths[index - 1]!.localeCompare(path, 'en') < 0) &&
|
||||
snapshot.packageDigest === skillPackageDigest(files)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RESERVED_SKILL_TRANSACTION_DIRECTORY } from './skill-transaction-paths'
|
||||
import { skillTransactionReservedRoot } from './skill-transaction-workspace'
|
||||
|
||||
describe('skill transaction workspace selection', () => {
|
||||
it('uses the adjacent parent only when it shares the destination mount', () => {
|
||||
const skillsRoot = join('root', 'skills')
|
||||
const parentRoot = join('root')
|
||||
expect(
|
||||
skillTransactionReservedRoot({
|
||||
skillsRoot,
|
||||
skillsRootDevice: 1,
|
||||
parentRoot,
|
||||
parentRootDevice: 1
|
||||
})
|
||||
).toBe(join(parentRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY))
|
||||
})
|
||||
|
||||
it('keeps the workspace inside the skills root across a mount boundary', () => {
|
||||
const skillsRoot = join('mounted', 'skills')
|
||||
const parentRoot = join('mounted')
|
||||
expect(
|
||||
skillTransactionReservedRoot({
|
||||
skillsRoot,
|
||||
skillsRootDevice: 2,
|
||||
parentRoot,
|
||||
parentRootDevice: 1
|
||||
})
|
||||
).toBe(join(skillsRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { lstat, realpath } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { ManagedSkillDestination } from '../../shared/skill-management'
|
||||
import {
|
||||
assertManagedSkillParentTopology,
|
||||
normalizedSkillIdentityPath,
|
||||
skillPhysicalIdentity
|
||||
} from './skill-installation-topology'
|
||||
import { RESERVED_SKILL_TRANSACTION_DIRECTORY } from './skill-transaction-paths'
|
||||
|
||||
export type SkillTransactionWorkspace = {
|
||||
skillsRoot: string
|
||||
skillsRootIdentity: string
|
||||
skillsRootDevice: number
|
||||
parentRoot: string
|
||||
parentRootIdentity: string
|
||||
parentRootDevice: number
|
||||
reservedRoot: string
|
||||
}
|
||||
|
||||
export function skillTransactionReservedRoot(args: {
|
||||
skillsRoot: string
|
||||
skillsRootDevice: number
|
||||
parentRoot: string
|
||||
parentRootDevice: number
|
||||
}): string {
|
||||
// Why: an adjacent workspace is atomic only on the destination mount;
|
||||
// a mount boundary requires the reserved child of the skills root.
|
||||
return args.skillsRootDevice === args.parentRootDevice
|
||||
? join(args.parentRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY)
|
||||
: join(args.skillsRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY)
|
||||
}
|
||||
|
||||
async function inspectWorkspaceRoots(record: ManagedSkillDestination) {
|
||||
await assertManagedSkillParentTopology(record)
|
||||
const skillsRoot = dirname(record.resolvedPath)
|
||||
const parentRoot = dirname(skillsRoot)
|
||||
const [skillsRootEntry, parentRootEntry, resolvedSkillsRoot, resolvedParentRoot] =
|
||||
await Promise.all([
|
||||
lstat(skillsRoot),
|
||||
lstat(parentRoot),
|
||||
realpath(skillsRoot),
|
||||
realpath(parentRoot)
|
||||
])
|
||||
if (
|
||||
!skillsRootEntry.isDirectory() ||
|
||||
skillsRootEntry.isSymbolicLink() ||
|
||||
!parentRootEntry.isDirectory() ||
|
||||
parentRootEntry.isSymbolicLink() ||
|
||||
normalizedSkillIdentityPath(resolvedSkillsRoot) !== normalizedSkillIdentityPath(skillsRoot) ||
|
||||
normalizedSkillIdentityPath(resolvedParentRoot) !== normalizedSkillIdentityPath(parentRoot)
|
||||
) {
|
||||
throw new Error('skill-topology-changed')
|
||||
}
|
||||
return {
|
||||
skillsRoot,
|
||||
skillsRootIdentity: skillPhysicalIdentity(skillsRoot, skillsRootEntry),
|
||||
skillsRootDevice: skillsRootEntry.dev,
|
||||
parentRoot,
|
||||
parentRootIdentity: skillPhysicalIdentity(parentRoot, parentRootEntry),
|
||||
parentRootDevice: parentRootEntry.dev
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSkillTransactionWorkspace(
|
||||
record: ManagedSkillDestination
|
||||
): Promise<SkillTransactionWorkspace> {
|
||||
const roots = await inspectWorkspaceRoots(record)
|
||||
return {
|
||||
...roots,
|
||||
reservedRoot: skillTransactionReservedRoot(roots)
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertSkillTransactionWorkspace(
|
||||
record: ManagedSkillDestination,
|
||||
expected: SkillTransactionWorkspace
|
||||
): Promise<void> {
|
||||
const actual = await inspectWorkspaceRoots(record)
|
||||
if (
|
||||
actual.skillsRoot !== expected.skillsRoot ||
|
||||
actual.skillsRootIdentity !== expected.skillsRootIdentity ||
|
||||
actual.skillsRootDevice !== expected.skillsRootDevice ||
|
||||
actual.parentRoot !== expected.parentRoot ||
|
||||
actual.parentRootIdentity !== expected.parentRootIdentity ||
|
||||
actual.parentRootDevice !== expected.parentRootDevice
|
||||
) {
|
||||
throw new Error('skill-topology-changed')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { readOfficialSkillsCliLockEntries, skillsCliLockPath } from './skills-cli-lock-provenance'
|
||||
|
||||
const temporaryRoots: string[] = []
|
||||
|
||||
async function createRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skills-lock-'))
|
||||
temporaryRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true })))
|
||||
})
|
||||
|
||||
describe('skills CLI lock provenance', () => {
|
||||
it('uses XDG_STATE_HOME instead of the default lock location', async () => {
|
||||
const homeDir = await createRoot()
|
||||
const xdgStateHome = join(homeDir, 'state')
|
||||
expect(skillsCliLockPath({ homeDir, xdgStateHome })).toBe(
|
||||
join(xdgStateHome, 'skills', '.skill-lock.json')
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts only official Orca source entries from supported schemas', async () => {
|
||||
const homeDir = await createRoot()
|
||||
const lockPath = skillsCliLockPath({ homeDir })
|
||||
await mkdir(dirname(lockPath), { recursive: true })
|
||||
await writeFile(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
skills: {
|
||||
'orca-cli': { source: 'stablyai/orca' },
|
||||
orchestration: { sourceUrl: 'https://github.com/stablyai/orca.git' },
|
||||
spoofed: { source: 'someone/orca' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(await readOfficialSkillsCliLockEntries({ homeDir })).toEqual(
|
||||
new Set(['orca-cli', 'orchestration'])
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed for missing, corrupt, and unsupported locks', async () => {
|
||||
const homeDir = await createRoot()
|
||||
expect(await readOfficialSkillsCliLockEntries({ homeDir })).toEqual(new Set())
|
||||
const lockPath = skillsCliLockPath({ homeDir })
|
||||
await mkdir(dirname(lockPath), { recursive: true })
|
||||
await writeFile(lockPath, '{')
|
||||
expect(await readOfficialSkillsCliLockEntries({ homeDir })).toEqual(new Set())
|
||||
await writeFile(lockPath, JSON.stringify({ version: 99, skills: { 'orca-cli': {} } }))
|
||||
expect(await readOfficialSkillsCliLockEntries({ homeDir })).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const MAX_LOCK_BYTES = 1024 * 1024
|
||||
const SUPPORTED_LOCK_VERSIONS = new Set([1, 2, 3])
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function officialOrcaSource(entry: Record<string, unknown>): boolean {
|
||||
const source = typeof entry.source === 'string' ? entry.source : ''
|
||||
const sourceUrl = typeof entry.sourceUrl === 'string' ? entry.sourceUrl : ''
|
||||
return (
|
||||
source === 'stablyai/orca' ||
|
||||
source === 'https://github.com/stablyai/orca' ||
|
||||
/^https:\/\/github\.com\/stablyai\/orca(?:\.git)?\/?$/.test(sourceUrl)
|
||||
)
|
||||
}
|
||||
|
||||
export function skillsCliLockPath(args: { homeDir: string; xdgStateHome?: string | null }): string {
|
||||
return args.xdgStateHome
|
||||
? join(args.xdgStateHome, 'skills', '.skill-lock.json')
|
||||
: join(args.homeDir, '.agents', '.skill-lock.json')
|
||||
}
|
||||
|
||||
export async function readOfficialSkillsCliLockEntries(args: {
|
||||
homeDir: string
|
||||
xdgStateHome?: string | null
|
||||
}): Promise<Set<string>> {
|
||||
const lockPath = skillsCliLockPath(args)
|
||||
try {
|
||||
if ((await stat(lockPath)).size > MAX_LOCK_BYTES) {
|
||||
return new Set()
|
||||
}
|
||||
const parsed: unknown = JSON.parse(await readFile(lockPath, 'utf8'))
|
||||
if (
|
||||
!isRecord(parsed) ||
|
||||
!SUPPORTED_LOCK_VERSIONS.has(Number(parsed.version)) ||
|
||||
!isRecord(parsed.skills)
|
||||
) {
|
||||
return new Set()
|
||||
}
|
||||
return new Set(
|
||||
Object.entries(parsed.skills)
|
||||
.filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1]))
|
||||
.filter(([, entry]) => officialOrcaSource(entry))
|
||||
.map(([name]) => name)
|
||||
)
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
@@ -314,6 +314,13 @@ 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 {
|
||||
OrcaSkillInstallResultArgs,
|
||||
SkillAdoptionDismissalArgs,
|
||||
SkillManagementActionArgs,
|
||||
SkillManagementInventory,
|
||||
SkillReplacementPreview
|
||||
} from '../shared/skill-management'
|
||||
import type {
|
||||
CrashReportBreadcrumbData,
|
||||
CrashReportCopyDiagnosticsArgs,
|
||||
@@ -2201,6 +2208,13 @@ export type PreloadApi = {
|
||||
}
|
||||
skills: {
|
||||
discover: (target?: SkillDiscoveryTarget) => Promise<SkillDiscoveryResult>
|
||||
managementInventory: (target?: SkillDiscoveryTarget) => Promise<SkillManagementInventory>
|
||||
adopt: (args: SkillManagementActionArgs) => Promise<SkillManagementInventory>
|
||||
dismissAdoption: (args: SkillAdoptionDismissalArgs) => Promise<SkillManagementInventory>
|
||||
previewReplacement: (args: SkillManagementActionArgs) => Promise<SkillReplacementPreview>
|
||||
replace: (args: SkillManagementActionArgs) => Promise<SkillManagementInventory>
|
||||
updateManaged: (args: SkillManagementActionArgs) => Promise<SkillManagementInventory>
|
||||
recordOrcaInstall: (args: OrcaSkillInstallResultArgs) => Promise<SkillManagementInventory>
|
||||
}
|
||||
pet: {
|
||||
import: () => Promise<CustomPet | null>
|
||||
|
||||
+22
-1
@@ -69,6 +69,13 @@ 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 {
|
||||
OrcaSkillInstallResultArgs,
|
||||
SkillAdoptionDismissalArgs,
|
||||
SkillManagementActionArgs,
|
||||
SkillManagementInventory,
|
||||
SkillReplacementPreview
|
||||
} from '../shared/skill-management'
|
||||
import type {
|
||||
RuntimeBrowserDriverState,
|
||||
RuntimeMobileSessionTabMove,
|
||||
@@ -2166,7 +2173,21 @@ const api = {
|
||||
|
||||
skills: {
|
||||
discover: (target?: SkillDiscoveryTarget): Promise<SkillDiscoveryResult> =>
|
||||
ipcRenderer.invoke('skills:discover', target)
|
||||
ipcRenderer.invoke('skills:discover', target),
|
||||
managementInventory: (target?: SkillDiscoveryTarget): Promise<SkillManagementInventory> =>
|
||||
ipcRenderer.invoke('skills:managementInventory', target),
|
||||
adopt: (args: SkillManagementActionArgs): Promise<SkillManagementInventory> =>
|
||||
ipcRenderer.invoke('skills:adopt', args),
|
||||
dismissAdoption: (args: SkillAdoptionDismissalArgs): Promise<SkillManagementInventory> =>
|
||||
ipcRenderer.invoke('skills:dismissAdoption', args),
|
||||
previewReplacement: (args: SkillManagementActionArgs): Promise<SkillReplacementPreview> =>
|
||||
ipcRenderer.invoke('skills:previewReplacement', args),
|
||||
replace: (args: SkillManagementActionArgs): Promise<SkillManagementInventory> =>
|
||||
ipcRenderer.invoke('skills:replace', args),
|
||||
updateManaged: (args: SkillManagementActionArgs): Promise<SkillManagementInventory> =>
|
||||
ipcRenderer.invoke('skills:updateManaged', args),
|
||||
recordOrcaInstall: (args: OrcaSkillInstallResultArgs): Promise<SkillManagementInventory> =>
|
||||
ipcRenderer.invoke('skills:recordOrcaInstall', args)
|
||||
},
|
||||
|
||||
pet: {
|
||||
|
||||
@@ -59,6 +59,7 @@ 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 { ManagedSkillAdoptionNudge } from './components/managed-skills/ManagedSkillAdoptionNudge'
|
||||
import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface'
|
||||
import { ZoomOverlay } from './components/ZoomOverlay'
|
||||
import { onOnboardingReopened } from './components/onboarding/show-onboarding-event'
|
||||
@@ -2827,6 +2828,7 @@ function App(): React.JSX.Element {
|
||||
</ConfirmationDialogProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
|
||||
<ManagedSkillAdoptionNudge />
|
||||
<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
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/store'
|
||||
import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
|
||||
import {
|
||||
ORCA_CLI_SKILL_INSTALL_COMMAND,
|
||||
ORCA_CLI_SKILL_NAME
|
||||
} from '@/lib/agent-feature-install-commands'
|
||||
import {
|
||||
AGENT_SKILL_CLI_PREREQUISITE_NOTICE,
|
||||
ensureOrcaCliAvailableForAgentSkillTerminal
|
||||
@@ -138,6 +141,7 @@ export function MobileEmulatorAgentSetupGuideSteps({
|
||||
)}
|
||||
</p>
|
||||
<AgentSkillSetupPanel
|
||||
managedSkillNames={[ORCA_CLI_SKILL_NAME]}
|
||||
variant="inline"
|
||||
hideHeader
|
||||
className="min-w-0"
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { Copy } from 'lucide-react'
|
||||
import { useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { OnboardingInlineCommandTerminal } from '@/components/onboarding/OnboardingInlineCommandTerminal'
|
||||
import { ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
|
||||
import {
|
||||
ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND,
|
||||
ORCA_CLI_SKILL_NAME,
|
||||
ORCHESTRATION_SKILL_NAME
|
||||
} from '@/lib/agent-feature-install-commands'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
|
||||
import { isSuccessfulSkillInstallExit } from '@/components/settings/skill-install-terminal-exit'
|
||||
|
||||
export function CliSkillSetupTerminal(): React.JSX.Element {
|
||||
const installAttemptStartedAtRef = useRef<number | null>(null)
|
||||
const handleCopySkillCommand = async (): Promise<void> => {
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND)
|
||||
@@ -76,6 +84,23 @@ export function CliSkillSetupTerminal(): React.JSX.Element {
|
||||
descriptionPaddingClassName="px-4 py-2"
|
||||
autoScrollIntoView={false}
|
||||
worktreeId="feature-tip-cli-skills-terminal"
|
||||
onOpened={() => {
|
||||
installAttemptStartedAtRef.current ??= Date.now()
|
||||
}}
|
||||
onTerminalExit={(result) => {
|
||||
notifyInstalledAgentSkillsChanged()
|
||||
const startedAt = installAttemptStartedAtRef.current
|
||||
if (!isSuccessfulSkillInstallExit(result, startedAt)) {
|
||||
return
|
||||
}
|
||||
void window.api.skills
|
||||
.recordOrcaInstall({
|
||||
skillNames: [ORCA_CLI_SKILL_NAME, ORCHESTRATION_SKILL_NAME],
|
||||
startedAt
|
||||
})
|
||||
.then(notifyInstalledAgentSkillsChanged)
|
||||
.catch(() => undefined)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JSX } from 'react'
|
||||
import {
|
||||
ORCA_CLI_SKILL_INSTALL_COMMAND,
|
||||
ORCA_CLI_SKILL_NAME,
|
||||
ORCA_CLI_SKILL_UPDATE_COMMAND
|
||||
} from '@/lib/agent-feature-install-commands'
|
||||
import {
|
||||
@@ -43,6 +44,8 @@ export function BrowserUseSkillSetupCard(props: {
|
||||
|
||||
const setupPanel = (
|
||||
<AgentSkillSetupPanel
|
||||
managedSkillNames={[ORCA_CLI_SKILL_NAME]}
|
||||
managementTarget={activeSkillRuntime.discoveryTarget}
|
||||
className={compact ? 'w-full max-w-[520px]' : undefined}
|
||||
title={translate(
|
||||
'auto.components.feature.wall.BrowserUseSkillSetupCard.d5bb1cd4ba',
|
||||
|
||||
@@ -118,6 +118,8 @@ export function FloatingTerminalOrchestrationDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<AgentSkillSetupPanel
|
||||
managedSkillNames={[ORCHESTRATION_SKILL_NAME]}
|
||||
managementTarget={activeSkillRuntime.discoveryTarget}
|
||||
title={translate(
|
||||
'auto.components.floating.terminal.FloatingTerminalOrchestrationDialog.1cd3f8af64',
|
||||
'Orchestration skill'
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ManagedSkillAdoptionNudge } from './ManagedSkillAdoptionNudge'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dismiss: vi.fn(async () => {}),
|
||||
info: vi.fn(),
|
||||
openSettingsPage: vi.fn(),
|
||||
openSettingsTarget: vi.fn(),
|
||||
inventory: {
|
||||
schemaVersion: 1,
|
||||
hostId: 'local',
|
||||
installations: [
|
||||
{
|
||||
id: 'candidate-a',
|
||||
hostId: 'local',
|
||||
physicalIdentity: '1:1',
|
||||
name: 'orca-cli',
|
||||
installedPackageDigest: 'a'.repeat(64),
|
||||
adoptionPromptEligible: true
|
||||
},
|
||||
{
|
||||
id: 'candidate-b',
|
||||
hostId: 'local',
|
||||
physicalIdentity: '1:2',
|
||||
name: 'orchestration',
|
||||
installedPackageDigest: 'b'.repeat(64),
|
||||
adoptionPromptEligible: false
|
||||
}
|
||||
],
|
||||
adoptionCandidateCount: 1,
|
||||
scannedAt: 1
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: mocks.info } }))
|
||||
vi.mock('@/hooks/useManagedAgentSkills', () => ({
|
||||
useManagedAgentSkills: () => ({ inventory: mocks.inventory, dismiss: mocks.dismiss })
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
openSettingsPage: mocks.openSettingsPage,
|
||||
openSettingsTarget: mocks.openSettingsTarget
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ManagedSkillAdoptionNudge', () => {
|
||||
beforeEach(() => {
|
||||
mocks.dismiss.mockClear()
|
||||
mocks.info.mockClear()
|
||||
mocks.openSettingsPage.mockClear()
|
||||
mocks.openSettingsTarget.mockClear()
|
||||
mocks.inventory.installations[0]!.installedPackageDigest = 'a'.repeat(64)
|
||||
mocks.inventory.installations[0]!.adoptionPromptEligible = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('persists only displayed tuples once across review and close lifecycles', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
await act(async () => root.render(<ManagedSkillAdoptionNudge />))
|
||||
|
||||
expect(mocks.info).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.dismiss).not.toHaveBeenCalled()
|
||||
|
||||
const options = mocks.info.mock.calls[0]?.[1] as {
|
||||
action: { onClick: () => void }
|
||||
onDismiss: () => void
|
||||
onAutoClose: () => void
|
||||
}
|
||||
await act(async () => options.action.onClick())
|
||||
await act(async () => options.onDismiss())
|
||||
await act(async () => options.onAutoClose())
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.dismiss).toHaveBeenCalledWith(mocks.inventory.installations[0])
|
||||
)
|
||||
expect(mocks.dismiss).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.dismiss).not.toHaveBeenCalledWith(mocks.inventory.installations[1])
|
||||
root.unmount()
|
||||
})
|
||||
|
||||
it('stays suppressed after remount but prompts a different snapshot tuple', async () => {
|
||||
const mount = async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
await act(async () => root.render(<ManagedSkillAdoptionNudge />))
|
||||
return root
|
||||
}
|
||||
let root = await mount()
|
||||
const options = mocks.info.mock.calls[0]?.[1] as { onAutoClose: () => void }
|
||||
await act(async () => options.onAutoClose())
|
||||
await vi.waitFor(() => expect(mocks.dismiss).toHaveBeenCalledTimes(1))
|
||||
root.unmount()
|
||||
|
||||
mocks.info.mockClear()
|
||||
mocks.inventory.installations[0]!.adoptionPromptEligible = false
|
||||
root = await mount()
|
||||
expect(mocks.info).not.toHaveBeenCalled()
|
||||
root.unmount()
|
||||
|
||||
mocks.inventory.installations[0]!.installedPackageDigest = 'c'.repeat(64)
|
||||
mocks.inventory.installations[0]!.adoptionPromptEligible = true
|
||||
root = await mount()
|
||||
expect(mocks.info).toHaveBeenCalledTimes(1)
|
||||
root.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useManagedAgentSkills } from '@/hooks/useManagedAgentSkills'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
export function ManagedSkillAdoptionNudge(): null {
|
||||
const state = useManagedAgentSkills()
|
||||
const shownKeys = useRef(new Set<string>())
|
||||
const persistedKeys = useRef(new Set<string>())
|
||||
const openSettingsPage = useAppStore((store) => store.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((store) => store.openSettingsTarget)
|
||||
|
||||
useEffect(() => {
|
||||
const candidates =
|
||||
state.inventory?.installations.filter(
|
||||
(installation) => installation.adoptionPromptEligible
|
||||
) ?? []
|
||||
const candidateKey = (candidate: (typeof candidates)[number]): string =>
|
||||
[
|
||||
candidate.hostId,
|
||||
candidate.physicalIdentity,
|
||||
candidate.name,
|
||||
candidate.installedPackageDigest
|
||||
].join('\0')
|
||||
const unseen = candidates.filter((candidate) => !shownKeys.current.has(candidateKey(candidate)))
|
||||
if (unseen.length === 0) {
|
||||
return
|
||||
}
|
||||
for (const candidate of unseen) {
|
||||
shownKeys.current.add(candidateKey(candidate))
|
||||
}
|
||||
const persistDismissal = (): void => {
|
||||
const pending = unseen.filter((candidate) => {
|
||||
const key = candidateKey(candidate)
|
||||
if (persistedKeys.current.has(key)) {
|
||||
return false
|
||||
}
|
||||
persistedKeys.current.add(key)
|
||||
return true
|
||||
})
|
||||
// Why: Sonner may report action, dismiss, and auto-close for one toast;
|
||||
// each exact tuple must reach the durable ledger only once.
|
||||
void pending.reduce(
|
||||
(settled, candidate) => settled.then(() => state.dismiss(candidate)),
|
||||
Promise.resolve()
|
||||
)
|
||||
}
|
||||
const count = unseen.length
|
||||
toast.info(
|
||||
translate(
|
||||
'auto.components.managedSkills.adoptionNudge.title',
|
||||
'{{value0}} installed Orca skills can be kept up to date',
|
||||
{ value0: count }
|
||||
),
|
||||
{
|
||||
description: translate(
|
||||
'auto.components.managedSkills.adoptionNudge.description',
|
||||
'Review exact official copies before Orca manages them.'
|
||||
),
|
||||
duration: 12_000,
|
||||
onDismiss: persistDismissal,
|
||||
onAutoClose: persistDismissal,
|
||||
action: {
|
||||
label: translate('auto.components.managedSkills.adoptionNudge.review', 'Review'),
|
||||
onClick: () => {
|
||||
openSettingsTarget({ pane: 'agents', repoId: null })
|
||||
openSettingsPage()
|
||||
persistDismissal()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}, [openSettingsPage, openSettingsTarget, state])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkil
|
||||
import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal'
|
||||
import {
|
||||
onboardingFeatureSetupTelemetrySelection,
|
||||
selectedOnboardingSkillNames,
|
||||
type OnboardingFeatureSetupSelection
|
||||
} from './onboarding-feature-setup'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isSuccessfulSkillInstallExit } from '../settings/skill-install-terminal-exit'
|
||||
|
||||
type FeatureSetupInlineTerminalProps = {
|
||||
command: string
|
||||
@@ -18,6 +20,7 @@ export function FeatureSetupInlineTerminal({
|
||||
selection
|
||||
}: FeatureSetupInlineTerminalProps): React.JSX.Element {
|
||||
const terminalOpenedTrackedRef = useRef(false)
|
||||
const installAttemptStartedAtRef = useRef<number | null>(null)
|
||||
const terminalInteractedTrackedRef = useRef(false)
|
||||
|
||||
const selectionTelemetry = useMemo(
|
||||
@@ -26,6 +29,7 @@ export function FeatureSetupInlineTerminal({
|
||||
)
|
||||
|
||||
const trackTerminalOpened = useCallback(() => {
|
||||
installAttemptStartedAtRef.current ??= Date.now()
|
||||
if (terminalOpenedTrackedRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -74,7 +78,20 @@ export function FeatureSetupInlineTerminal({
|
||||
autoScrollIntoView={false}
|
||||
onOpened={trackTerminalOpened}
|
||||
onInteracted={trackTerminalInteraction}
|
||||
onTerminalExit={notifyInstalledAgentSkillsChanged}
|
||||
onTerminalExit={(result) => {
|
||||
notifyInstalledAgentSkillsChanged()
|
||||
const startedAt = installAttemptStartedAtRef.current
|
||||
if (!isSuccessfulSkillInstallExit(result, startedAt)) {
|
||||
return
|
||||
}
|
||||
void window.api.skills
|
||||
.recordOrcaInstall({
|
||||
skillNames: selectedOnboardingSkillNames(selection),
|
||||
startedAt
|
||||
})
|
||||
.then(notifyInstalledAgentSkillsChanged)
|
||||
.catch(() => undefined)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ type OnboardingInlineCommandTerminalProps = {
|
||||
shellOverride?: string
|
||||
onOpened?: () => void
|
||||
onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent<HTMLElement>) => void
|
||||
onTerminalExit?: () => void
|
||||
onTerminalExit?: (result: { code: number | null }) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,8 +294,8 @@ export function OnboardingInlineCommandTerminal({
|
||||
cwd={cwd}
|
||||
isActive
|
||||
isVisible
|
||||
onPtyExit={() => {
|
||||
onTerminalExit?.()
|
||||
onPtyExit={(_ptyId, code) => {
|
||||
onTerminalExit?.({ code: code ?? null })
|
||||
closeTab(tabId, { recordInteraction: false })
|
||||
}}
|
||||
onCloseTab={() => closeTab(tabId, { recordInteraction: false })}
|
||||
|
||||
@@ -55,6 +55,10 @@ const FEATURE_SKILL_NAMES: Record<OnboardingFeatureSetupId, string> = {
|
||||
linearTickets: ORCA_LINEAR_SKILL_NAME
|
||||
}
|
||||
|
||||
export function selectedOnboardingSkillNames(selection: OnboardingFeatureSetupSelection): string[] {
|
||||
return selectedOnboardingFeatureSetupIds(selection).map((id) => FEATURE_SKILL_NAMES[id])
|
||||
}
|
||||
|
||||
const FEATURE_TELEMETRY_IDS: Record<
|
||||
OnboardingFeatureSetupId,
|
||||
EventProps<'onboarding_feature_setup_toggled'>['feature']
|
||||
@@ -112,9 +116,7 @@ export function buildOnboardingFeatureSetupClipboardText(
|
||||
export function buildOnboardingFeatureSetupSkillCommand(
|
||||
selection: OnboardingFeatureSetupSelection
|
||||
): string | null {
|
||||
const skillNames = selectedOnboardingFeatureSetupIds(selection).map(
|
||||
(id) => FEATURE_SKILL_NAMES[id]
|
||||
)
|
||||
const skillNames = selectedOnboardingSkillNames(selection)
|
||||
if (skillNames.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -140,6 +140,10 @@ function operationOwnerFromHostId(hostId: ExecutionHostId): FileExplorerOperatio
|
||||
return { kind: 'ssh', connectionId: parsed.targetId }
|
||||
case 'runtime':
|
||||
return { kind: 'runtime', environmentId: parsed.environmentId }
|
||||
case 'wsl':
|
||||
// Why: WSL skill state needs a durable host key before general file
|
||||
// operations gain an in-distro routing contract; destructive work fails closed.
|
||||
return { kind: 'unresolved' }
|
||||
case undefined:
|
||||
return { kind: 'unresolved' }
|
||||
}
|
||||
|
||||
@@ -157,6 +157,18 @@ describe('AgentSkillSetupPanel', () => {
|
||||
expect(buttonLabels(html)).toContain('Re-check')
|
||||
})
|
||||
|
||||
it('suppresses the raw updater while managed status is still loading', () => {
|
||||
const html = renderPanel({
|
||||
installed: true,
|
||||
installedCommand: UPDATE_COMMAND,
|
||||
managedSkillNames: ['orca-cli']
|
||||
})
|
||||
|
||||
expect(buttonLabels(html)).toContain('Checking…')
|
||||
expect(buttonLabels(html)).not.toContain('Update')
|
||||
expect(html).not.toContain(UPDATE_COMMAND)
|
||||
})
|
||||
|
||||
it('hides only re-check when installed re-checks are disabled', () => {
|
||||
const html = renderPanel({ installed: true, showRecheckWhenInstalled: false })
|
||||
|
||||
|
||||
@@ -5,11 +5,14 @@ import { IntegrationStatusPill } from '../integration-status-pill'
|
||||
import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal'
|
||||
import { Button } from '../ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
|
||||
import { useAgentSkillManagementActions } from './use-agent-skill-management-actions'
|
||||
|
||||
const EMPTY_MANAGED_SKILL_NAMES: readonly string[] = []
|
||||
|
||||
type AgentSkillSetupPanelVariant = 'card' | 'inline'
|
||||
type SkillPrerequisiteStatus = Awaited<ReturnType<typeof window.api.cli.getInstallStatus>>
|
||||
@@ -46,6 +49,8 @@ type AgentSkillSetupPanelProps = {
|
||||
actionHint?: ReactNode
|
||||
openingHint?: ReactNode
|
||||
footer?: ReactNode
|
||||
managedSkillNames?: readonly string[]
|
||||
managementTarget?: SkillDiscoveryTarget
|
||||
onRecheck: () => void | Promise<unknown>
|
||||
}
|
||||
|
||||
@@ -79,6 +84,8 @@ export function AgentSkillSetupPanel({
|
||||
actionHint,
|
||||
openingHint,
|
||||
footer,
|
||||
managedSkillNames = EMPTY_MANAGED_SKILL_NAMES,
|
||||
managementTarget,
|
||||
onRecheck
|
||||
}: AgentSkillSetupPanelProps): React.JSX.Element {
|
||||
const [terminalOpen, setTerminalOpen] = useState(false)
|
||||
@@ -88,6 +95,11 @@ export function AgentSkillSetupPanel({
|
||||
Boolean(preInstallNotice && !installed)
|
||||
)
|
||||
const mountedRef = useMountedRef()
|
||||
const skillManagement = useAgentSkillManagementActions({
|
||||
installed,
|
||||
skillNames: managedSkillNames,
|
||||
target: managementTarget
|
||||
})
|
||||
const readPrerequisiteStatus = useCallback(
|
||||
() => (getPrerequisiteStatus ?? window.api.cli.getInstallStatus)(),
|
||||
[getPrerequisiteStatus]
|
||||
@@ -161,7 +173,25 @@ export function AgentSkillSetupPanel({
|
||||
|
||||
const actionRow = (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{!installed || showInstallWhenInstalled ? (
|
||||
{skillManagement.applicable ? (
|
||||
skillManagement.actionLabel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={skillManagement.busy}
|
||||
onClick={() => void skillManagement.run()}
|
||||
>
|
||||
{skillManagement.actionBusy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
{skillManagement.actionBusy && !skillManagement.installation
|
||||
? translate(
|
||||
'auto.components.settings.AgentSkillSetupPanel.checkingManagement',
|
||||
'Checking…'
|
||||
)
|
||||
: skillManagement.actionLabel}
|
||||
</Button>
|
||||
) : null
|
||||
) : !installed || showInstallWhenInstalled ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -184,6 +214,7 @@ export function AgentSkillSetupPanel({
|
||||
if (mountedRef.current) {
|
||||
setTerminalOpening(false)
|
||||
if (shouldOpenTerminal) {
|
||||
skillManagement.beginInstallAttempt()
|
||||
setTerminalCommand(nextCommand)
|
||||
setTerminalOpen(true)
|
||||
}
|
||||
@@ -263,6 +294,12 @@ export function AgentSkillSetupPanel({
|
||||
'Checking...'
|
||||
)}
|
||||
</IntegrationStatusPill>
|
||||
) : installed && skillManagement.installation ? (
|
||||
<IntegrationStatusPill
|
||||
tone={skillManagement.installation.managed ? 'connected' : 'neutral'}
|
||||
>
|
||||
{skillManagement.statusLabel}
|
||||
</IntegrationStatusPill>
|
||||
) : installed ? (
|
||||
<IntegrationStatusPill tone="connected">
|
||||
{translate(
|
||||
@@ -350,7 +387,7 @@ export function AgentSkillSetupPanel({
|
||||
terminalTopMarginPx={8}
|
||||
descriptionPaddingClassName="px-4 py-2"
|
||||
autoScrollIntoView={false}
|
||||
onTerminalExit={notifyInstalledAgentSkillsChanged}
|
||||
onTerminalExit={skillManagement.recordTerminalInstall}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { getSettingOwnershipSummary } from './setting-ownership'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { parseAgentDefaultEnvDraft, stringifyAgentDefaultEnvDraft } from './agent-default-env-draft'
|
||||
import { ManagedOrcaSkillsSection } from './ManagedOrcaSkillsSection'
|
||||
|
||||
export { getAgentsPaneSearchEntries } from './agents-search'
|
||||
|
||||
@@ -829,6 +830,8 @@ export function AgentsPane({
|
||||
wslCapabilitiesLoading={wslCapabilitiesLoading}
|
||||
/>
|
||||
|
||||
<ManagedOrcaSkillsSection />
|
||||
|
||||
<AgentStatusHooksSetting settings={settings} updateSettings={updateSettings} />
|
||||
|
||||
<AgentGeneratedTabTitlesSetting settings={settings} updateSettings={updateSettings} />
|
||||
|
||||
@@ -274,6 +274,7 @@ export function BrowserUseSetup({
|
||||
)}
|
||||
>
|
||||
<BrowserUseSkillStep
|
||||
managementTarget={activeSkillRuntime.discoveryTarget}
|
||||
command={browserUseInstallCommand}
|
||||
installedCommand={browserUseUpdateCommand}
|
||||
skillDetected={skillDetected}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { ReactNode } from 'react'
|
||||
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
|
||||
import { StepBadge } from './BrowserUseStepBadge'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { ORCA_CLI_SKILL_NAME } from '@/lib/agent-feature-install-commands'
|
||||
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
|
||||
|
||||
type Props = {
|
||||
command: string
|
||||
@@ -14,6 +16,7 @@ type Props = {
|
||||
preInstallNotice?: ReactNode
|
||||
getPrerequisiteStatus?: () => Promise<Awaited<ReturnType<typeof window.api.cli.getInstallStatus>>>
|
||||
onBeforeOpenTerminal?: () => void | Promise<void>
|
||||
managementTarget?: SkillDiscoveryTarget
|
||||
onRecheck: () => void | Promise<unknown>
|
||||
}
|
||||
|
||||
@@ -28,10 +31,13 @@ export function BrowserUseSkillStep({
|
||||
preInstallNotice,
|
||||
getPrerequisiteStatus,
|
||||
onBeforeOpenTerminal,
|
||||
managementTarget,
|
||||
onRecheck
|
||||
}: Props): React.JSX.Element {
|
||||
return (
|
||||
<AgentSkillSetupPanel
|
||||
managedSkillNames={[ORCA_CLI_SKILL_NAME]}
|
||||
managementTarget={managementTarget}
|
||||
variant="inline"
|
||||
title={translate(
|
||||
'auto.components.settings.BrowserUseSkillStep.459e24eebc',
|
||||
|
||||
@@ -363,6 +363,8 @@ export function CliSection({
|
||||
</div>
|
||||
|
||||
<AgentSkillSetupPanel
|
||||
managedSkillNames={[ORCA_CLI_SKILL_NAME]}
|
||||
managementTarget={cliSkillDiscoveryTarget}
|
||||
className="mt-3"
|
||||
variant="inline"
|
||||
title={translate('auto.components.settings.CliSection.6053cf736c', 'CLI skill')}
|
||||
|
||||
@@ -48,6 +48,8 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<AgentSkillSetupPanel
|
||||
managedSkillNames={[COMPUTER_USE_SKILL_NAME]}
|
||||
managementTarget={activeSkillRuntime.discoveryTarget}
|
||||
title={translate('auto.components.settings.ComputerUsePane.93255aaf18', 'Computer Use skill')}
|
||||
description={translate(
|
||||
'auto.components.settings.ComputerUsePane.1735461723',
|
||||
|
||||
@@ -130,6 +130,8 @@ export function EphemeralVmsPane(): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-6" data-settings-section="ephemeral-vms">
|
||||
<AgentSkillSetupPanel
|
||||
managedSkillNames={[EPHEMERAL_VMS_SKILL_NAME]}
|
||||
managementTarget={activeSkillRuntime.discoveryTarget}
|
||||
title={translate(
|
||||
'auto.components.settings.EphemeralVmsPane.skillTitle',
|
||||
'Per-Workspace Environments skill'
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type {
|
||||
SkillManagementInstallation,
|
||||
SkillManagementStatus,
|
||||
SkillReplacementPreview
|
||||
} from '../../../../shared/skill-management'
|
||||
import { useManagedAgentSkills } from '@/hooks/useManagedAgentSkills'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
import { SettingsBadge, SettingsSubsectionHeader } from './SettingsFormControls'
|
||||
import { managedSkillStatusCopy } from './managed-skill-status-copy'
|
||||
|
||||
function statusTone(
|
||||
status: SkillManagementStatus
|
||||
): React.ComponentProps<typeof SettingsBadge>['tone'] {
|
||||
switch (status) {
|
||||
case 'managed-current':
|
||||
return 'accent'
|
||||
case 'known-current':
|
||||
case 'known-update-available':
|
||||
case 'managed-update-available':
|
||||
return 'muted'
|
||||
case 'newer-known':
|
||||
case 'modified':
|
||||
case 'unknown':
|
||||
case 'externally-managed':
|
||||
case 'inaccessible':
|
||||
case 'update-failed':
|
||||
return 'muted'
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticCopy(installation: SkillManagementInstallation): string {
|
||||
const revision = installation.installedReleaseRevision
|
||||
? `r${installation.installedReleaseRevision}`
|
||||
: translate('auto.components.settings.ManagedOrcaSkills.unverified', 'unverified')
|
||||
const digest = installation.installedPackageDigest?.slice(0, 8) ?? '—'
|
||||
return `${revision} · Orca ${installation.installedAppVersion ?? installation.currentAppVersion} · ${digest}`
|
||||
}
|
||||
|
||||
function missingVisibilityCopy(installation: SkillManagementInstallation): string | null {
|
||||
if (!installation.managed) {
|
||||
return null
|
||||
}
|
||||
const missing = [
|
||||
!installation.providers.includes('codex') ? 'Codex' : null,
|
||||
!installation.providers.includes('claude') ? 'Claude' : null
|
||||
].filter((provider): provider is string => provider !== null)
|
||||
return missing.length > 0
|
||||
? translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.notVisible',
|
||||
'Managed but not visible in {{value0}}.',
|
||||
{ value0: missing.join(', ') }
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
function ReplacementDialog({
|
||||
preview,
|
||||
busy,
|
||||
onClose,
|
||||
onConfirm
|
||||
}: {
|
||||
preview: SkillReplacementPreview
|
||||
busy: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.replaceTitle',
|
||||
'Replace {{value0}}?',
|
||||
{ value0: preview.skillName }
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.replaceDescription',
|
||||
'This discards the local changes shown below. Orca keeps a rollback copy until the bundled skill is verified.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="scrollbar-sleek max-h-[55vh] space-y-3 overflow-y-auto rounded-md border border-border bg-muted/20 p-3">
|
||||
{preview.files.map((file) => (
|
||||
<div key={file.path} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-mono text-xs text-foreground">{file.path}</code>
|
||||
<SettingsBadge tone="muted">{file.change}</SettingsBadge>
|
||||
</div>
|
||||
{file.beforeText !== null || file.afterText !== null ? (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<pre className="scrollbar-sleek max-h-48 overflow-auto rounded-md border border-border bg-background p-2 font-mono text-[11px] whitespace-pre-wrap text-muted-foreground">
|
||||
{file.beforeText ?? '—'}
|
||||
</pre>
|
||||
<pre className="scrollbar-sleek max-h-48 overflow-auto rounded-md border border-border bg-background p-2 font-mono text-[11px] whitespace-pre-wrap text-muted-foreground">
|
||||
{file.afterText ?? '—'}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.binaryChange',
|
||||
'Binary file contents differ.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" disabled={busy}>
|
||||
{translate('auto.components.settings.ManagedOrcaSkills.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" disabled={busy} onClick={onConfirm}>
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}
|
||||
{translate('auto.components.settings.ManagedOrcaSkills.replace', 'Replace skill')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function ManagedOrcaSkillsSection(): React.JSX.Element {
|
||||
const state = useManagedAgentSkills()
|
||||
const [preview, setPreview] = useState<SkillReplacementPreview | null>(null)
|
||||
|
||||
const reportFailure = (error: unknown): void => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.actionFailed',
|
||||
'Skill action failed.'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const reviewReplacement = async (installationId: string): Promise<void> => {
|
||||
try {
|
||||
setPreview(await state.previewReplacement(installationId))
|
||||
} catch (error) {
|
||||
reportFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
const run = async (action: () => Promise<void>, success: string): Promise<boolean> => {
|
||||
try {
|
||||
await action()
|
||||
toast.success(success)
|
||||
return true
|
||||
} catch (error) {
|
||||
reportFailure(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<SettingsSubsectionHeader
|
||||
title={translate('auto.components.settings.ManagedOrcaSkills.title', 'Orca agent skills')}
|
||||
description={translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.description',
|
||||
'Orca updates only exact official copies you explicitly choose to manage.'
|
||||
)}
|
||||
action={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={state.loading}
|
||||
onClick={() => void state.refresh()}
|
||||
>
|
||||
<RefreshCw className={state.loading ? 'animate-spin' : undefined} />
|
||||
{translate('auto.components.settings.ManagedOrcaSkills.refresh', 'Refresh')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{state.error ? <p className="text-xs text-destructive">{state.error}</p> : null}
|
||||
{state.inventory?.installations.length ? (
|
||||
<div className="divide-y divide-border/40">
|
||||
{state.inventory.installations.map((installation) => {
|
||||
const busy = state.busyInstallationId === installation.id
|
||||
const visibility = missingVisibilityCopy(installation)
|
||||
const replaceable =
|
||||
installation.status === 'modified' || installation.status === 'unknown'
|
||||
return (
|
||||
<div key={installation.id} className="flex items-start gap-4 py-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{installation.name}</span>
|
||||
<SettingsBadge tone={statusTone(installation.status)}>
|
||||
{managedSkillStatusCopy(installation.status)}
|
||||
</SettingsBadge>
|
||||
</div>
|
||||
<p className="font-mono text-[11px] text-muted-foreground">
|
||||
{diagnosticCopy(installation)}
|
||||
</p>
|
||||
{installation.description ? (
|
||||
<p className="text-xs text-muted-foreground">{installation.description}</p>
|
||||
) : null}
|
||||
<p className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
{installation.unresolvedPath}
|
||||
</p>
|
||||
{visibility ? (
|
||||
<p className="text-[11px] text-muted-foreground">{visibility}</p>
|
||||
) : null}
|
||||
{!installation.actionsSupported ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.hostPending',
|
||||
'Detection only on this host; management actions are not available yet.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{!installation.actionsSupported ? null : installation.status === 'known-current' ||
|
||||
installation.status === 'known-update-available' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => state.manage(installation.id),
|
||||
translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.managedToast',
|
||||
'Skill is now managed and current.'
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}
|
||||
{installation.status === 'known-update-available'
|
||||
? translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.manageUpdateAction',
|
||||
'Manage and update'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.manageAction',
|
||||
'Manage'
|
||||
)}
|
||||
</Button>
|
||||
) : installation.status === 'managed-update-available' ||
|
||||
installation.status === 'update-failed' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => state.update(installation.id),
|
||||
translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.updatedToast',
|
||||
'Skill updated.'
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
{busy ? <Loader2 className="animate-spin" /> : null}
|
||||
{installation.status === 'update-failed'
|
||||
? translate('auto.components.settings.ManagedOrcaSkills.retryAction', 'Retry')
|
||||
: translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.updateAction',
|
||||
'Update'
|
||||
)}
|
||||
</Button>
|
||||
) : replaceable ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => void reviewReplacement(installation.id)}
|
||||
>
|
||||
{translate('auto.components.settings.ManagedOrcaSkills.reviewAction', 'Review')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : state.loading ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.checking',
|
||||
'Checking installed skills…'
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.none',
|
||||
'No installed Orca skills found in global agent homes.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{preview ? (
|
||||
<ReplacementDialog
|
||||
preview={preview}
|
||||
busy={state.busyInstallationId === preview.installationId}
|
||||
onClose={() => setPreview(null)}
|
||||
onConfirm={() =>
|
||||
void run(
|
||||
() => state.replace(preview.installationId),
|
||||
translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.replacedToast',
|
||||
'Skill replaced with the bundled version.'
|
||||
)
|
||||
).then((succeeded) => succeeded && setPreview(null))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Import, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
ORCA_CLI_SKILL_INSTALL_COMMAND,
|
||||
ORCA_CLI_SKILL_NAME,
|
||||
ORCA_CLI_SKILL_UPDATE_COMMAND
|
||||
} from '@/lib/agent-feature-install-commands'
|
||||
import {
|
||||
@@ -143,6 +144,7 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element {
|
||||
|
||||
<div className={cn('py-3', setup.step2Blocked && 'opacity-60')}>
|
||||
<AgentSkillSetupPanel
|
||||
managedSkillNames={[ORCA_CLI_SKILL_NAME]}
|
||||
variant="inline"
|
||||
title={translate(
|
||||
'auto.components.settings.MobileEmulatorAgentControlRow.67e19ee03c',
|
||||
|
||||
@@ -86,6 +86,8 @@ export function OrchestrationPane(): React.JSX.Element {
|
||||
className="space-y-5 py-2"
|
||||
>
|
||||
<AgentSkillSetupPanel
|
||||
managedSkillNames={[ORCHESTRATION_SKILL_NAME]}
|
||||
managementTarget={activeSkillRuntime.discoveryTarget}
|
||||
title={translate(
|
||||
'auto.components.settings.OrchestrationPane.07641b9768',
|
||||
'Orchestration skill'
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from './CliSkillRuntimeSetup'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
|
||||
|
||||
export function OrchestrationSetupCard(props: {
|
||||
compact?: boolean
|
||||
@@ -40,6 +41,8 @@ export function OrchestrationSetupCard(props: {
|
||||
|
||||
const setupPanel = (
|
||||
<AgentSkillSetupPanel
|
||||
managedSkillNames={[ORCHESTRATION_SKILL_NAME]}
|
||||
managementTarget={activeSkillRuntime.discoveryTarget}
|
||||
className={compact ? 'w-full max-w-[520px]' : undefined}
|
||||
title={translate(
|
||||
'auto.components.settings.OrchestrationSetupCard.2777ff0fdc',
|
||||
|
||||
@@ -16,6 +16,8 @@ const mocks = vi.hoisted(() => ({
|
||||
refresh: vi.fn(async () => true)
|
||||
},
|
||||
useInstalledAgentSkillNames: vi.fn(),
|
||||
managementRun: vi.fn(async () => {}),
|
||||
useAgentSkillManagementActions: vi.fn(),
|
||||
clipboardWrite: vi.fn(async () => {}),
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn()
|
||||
@@ -30,6 +32,10 @@ vi.mock('./CliSkillRuntimeSetup', () => ({
|
||||
buildSkillCommandForRuntime: (command: string) => command
|
||||
}))
|
||||
|
||||
vi.mock('./use-agent-skill-management-actions', () => ({
|
||||
useAgentSkillManagementActions: mocks.useAgentSkillManagementActions
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: mocks.toastSuccess, error: mocks.toastError }
|
||||
}))
|
||||
@@ -86,6 +92,15 @@ describe('LinearAgentSkillInstallCta', () => {
|
||||
mocks.skillState.refresh.mockClear()
|
||||
mocks.useInstalledAgentSkillNames.mockReset()
|
||||
mocks.useInstalledAgentSkillNames.mockReturnValue(mocks.skillState)
|
||||
mocks.managementRun.mockClear()
|
||||
mocks.useAgentSkillManagementActions.mockReset()
|
||||
mocks.useAgentSkillManagementActions.mockReturnValue({
|
||||
installation: null,
|
||||
actionLabel: null,
|
||||
statusLabel: null,
|
||||
busy: false,
|
||||
run: mocks.managementRun
|
||||
})
|
||||
mocks.clipboardWrite.mockClear()
|
||||
mocks.toastSuccess.mockClear()
|
||||
mocks.toastError.mockClear()
|
||||
@@ -134,19 +149,33 @@ describe('LinearAgentSkillInstallCta', () => {
|
||||
expect(mocks.toastSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a subtle confirmation and the update command when installed', async () => {
|
||||
it('shows the ledger-backed status and action without a raw update command', async () => {
|
||||
mocks.skillState.installed = true
|
||||
mocks.skillState.skills = [discoveredSkill({ name: 'orca-linear' })]
|
||||
mocks.useAgentSkillManagementActions.mockReturnValue({
|
||||
installation: { managed: true },
|
||||
actionLabel: 'Update',
|
||||
statusLabel: 'Update available',
|
||||
busy: false,
|
||||
run: mocks.managementRun
|
||||
})
|
||||
|
||||
const rendered = await renderCta()
|
||||
|
||||
expect(rendered.textContent).toContain('Installed')
|
||||
expect(rendered.textContent).toContain('Agent skill installed. To update it, run:')
|
||||
expect(rendered.textContent).toContain('npx skills update orca-linear --global')
|
||||
expect(rendered.textContent).toContain('Update available')
|
||||
expect(rendered.textContent).toContain('Update')
|
||||
expect(rendered.textContent).not.toContain('npx skills update')
|
||||
expect(rendered.textContent).not.toContain('Not installed')
|
||||
|
||||
await act(async () => {
|
||||
Array.from(rendered.querySelectorAll('button'))
|
||||
.find((button) => button.textContent === 'Update')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
expect(mocks.managementRun).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates through the legacy skill name when only linear-tickets is installed', async () => {
|
||||
it('does not expose a legacy raw updater when only linear-tickets is installed', async () => {
|
||||
mocks.skillState.installed = true
|
||||
mocks.skillState.skills = [
|
||||
discoveredSkill({
|
||||
@@ -158,7 +187,8 @@ describe('LinearAgentSkillInstallCta', () => {
|
||||
|
||||
const rendered = await renderCta()
|
||||
|
||||
expect(rendered.textContent).toContain('npx skills update linear-tickets --global')
|
||||
expect(rendered.textContent).toContain('Installed')
|
||||
expect(rendered.textContent).not.toContain('npx skills update')
|
||||
})
|
||||
|
||||
it('notes that remote agent environments need their own setup', async () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
ORCA_LINEAR_SKILL_INSTALL_COMMAND,
|
||||
ORCA_LINEAR_SKILL_NAME
|
||||
} from '@/lib/agent-feature-install-commands'
|
||||
import { getLinearAgentSkillUpdateCommand } from '@/lib/linear-agent-skill-update-command'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getLinearAgentSkillSetupInlineRuntimeCopy } from '../sidebar/linear-agent-skill-setup-copy'
|
||||
import {
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
useIntegrationSubordinateRowClass
|
||||
} from './integration-card-presentation'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAgentSkillManagementActions } from './use-agent-skill-management-actions'
|
||||
|
||||
type LinearAgentSkillInstallCtaProps = {
|
||||
settings: LinearAgentSkillPromptSettings | null | undefined
|
||||
@@ -55,15 +55,14 @@ export function LinearAgentSkillInstallCta({
|
||||
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
|
||||
})
|
||||
const command = useMemo(
|
||||
() =>
|
||||
buildSkillCommandForRuntime(
|
||||
skill.installed
|
||||
? getLinearAgentSkillUpdateCommand(skill.skills, skill.installed)
|
||||
: ORCA_LINEAR_SKILL_INSTALL_COMMAND,
|
||||
agentRuntime
|
||||
),
|
||||
[agentRuntime, skill.installed, skill.skills]
|
||||
() => buildSkillCommandForRuntime(ORCA_LINEAR_SKILL_INSTALL_COMMAND, agentRuntime),
|
||||
[agentRuntime]
|
||||
)
|
||||
const management = useAgentSkillManagementActions({
|
||||
installed: skill.installed,
|
||||
skillNames: LINEAR_AGENT_SKILL_NAMES,
|
||||
target: skillDiscoveryTarget
|
||||
})
|
||||
const subordinateRowClass = useIntegrationSubordinateRowClass('space-y-1.5')
|
||||
const commandRowClass = useIntegrationCommandRowClass()
|
||||
|
||||
@@ -106,11 +105,12 @@ export function LinearAgentSkillInstallCta({
|
||||
)}
|
||||
</IntegrationStatusPill>
|
||||
) : skill.installed ? (
|
||||
<IntegrationStatusPill tone="connected">
|
||||
{translate(
|
||||
'auto.components.settings.linear.agent.skill.install.cta.installed',
|
||||
'Installed'
|
||||
)}
|
||||
<IntegrationStatusPill tone={management.installation?.managed ? 'connected' : 'neutral'}>
|
||||
{management.statusLabel ??
|
||||
translate(
|
||||
'auto.components.settings.linear.agent.skill.install.cta.installed',
|
||||
'Installed'
|
||||
)}
|
||||
</IntegrationStatusPill>
|
||||
) : (
|
||||
<IntegrationStatusPill tone="attention">
|
||||
@@ -136,44 +136,53 @@ export function LinearAgentSkillInstallCta({
|
||||
{!skill.loading && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{skill.installed
|
||||
? translate(
|
||||
'auto.components.settings.linear.agent.skill.install.cta.installedDescription',
|
||||
'Agent skill installed. To update it, run:'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.linear.agent.skill.install.cta.description',
|
||||
'Let your agents read and edit Linear tasks.'
|
||||
)}
|
||||
{translate(
|
||||
'auto.components.settings.linear.agent.skill.install.cta.description',
|
||||
'Let your agents read and edit Linear tasks.'
|
||||
)}
|
||||
</p>
|
||||
<div className={commandRowClass}>
|
||||
<code className="scrollbar-sleek min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
|
||||
{command}
|
||||
</code>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0"
|
||||
aria-label={translate(
|
||||
{skill.installed ? (
|
||||
management.actionLabel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={management.busy}
|
||||
onClick={() => void management.run()}
|
||||
>
|
||||
{management.actionLabel}
|
||||
</Button>
|
||||
) : null
|
||||
) : (
|
||||
<div className={commandRowClass}>
|
||||
<code className="scrollbar-sleek min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
|
||||
{command}
|
||||
</code>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0"
|
||||
aria-label={translate(
|
||||
'auto.components.settings.linear.agent.skill.install.cta.copyCommand',
|
||||
'Copy command'
|
||||
)}
|
||||
onClick={() => void copyCommand()}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.settings.linear.agent.skill.install.cta.copyCommand',
|
||||
'Copy command'
|
||||
)}
|
||||
onClick={() => void copyCommand()}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.settings.linear.agent.skill.install.cta.copyCommand',
|
||||
'Copy command'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{remote || agentRuntime.runtime === 'wsl' ? (
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
{getLinearAgentSkillSetupInlineRuntimeCopy(remote, agentRuntime)}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { SkillManagementStatus } from '../../../../shared/skill-management'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function managedSkillStatusCopy(status: SkillManagementStatus): string {
|
||||
switch (status) {
|
||||
case 'managed-current':
|
||||
return translate('auto.components.settings.ManagedOrcaSkills.managedCurrent', 'Managed')
|
||||
case 'managed-update-available':
|
||||
return translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.managedUpdate',
|
||||
'Update available'
|
||||
)
|
||||
case 'known-current':
|
||||
return translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.availableToManage',
|
||||
'Available to manage'
|
||||
)
|
||||
case 'known-update-available':
|
||||
return translate(
|
||||
'auto.components.settings.ManagedOrcaSkills.manageAndUpdate',
|
||||
'Manage and update'
|
||||
)
|
||||
case 'newer-known':
|
||||
return translate('auto.components.settings.ManagedOrcaSkills.newerKnown', 'Newer release')
|
||||
case 'modified':
|
||||
return translate('auto.components.settings.ManagedOrcaSkills.modified', 'Modified')
|
||||
case 'unknown':
|
||||
return translate('auto.components.settings.ManagedOrcaSkills.unknown', 'Unknown copy')
|
||||
case 'externally-managed':
|
||||
return translate('auto.components.settings.ManagedOrcaSkills.external', 'Externally managed')
|
||||
case 'inaccessible':
|
||||
return translate('auto.components.settings.ManagedOrcaSkills.inaccessible', 'Inaccessible')
|
||||
case 'update-failed':
|
||||
return translate('auto.components.settings.ManagedOrcaSkills.failed', 'Update failed')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isSuccessfulSkillInstallExit } from './skill-install-terminal-exit'
|
||||
|
||||
describe('skill install terminal exit', () => {
|
||||
it('accepts only a successful process exit after the install window began', () => {
|
||||
expect(isSuccessfulSkillInstallExit({ code: 0 }, 1)).toBe(true)
|
||||
expect(isSuccessfulSkillInstallExit({ code: 1 }, 1)).toBe(false)
|
||||
expect(isSuccessfulSkillInstallExit({ code: null }, 1)).toBe(false)
|
||||
expect(isSuccessfulSkillInstallExit({ code: 0 }, null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
export type SkillInstallTerminalExit = { code: number | null }
|
||||
|
||||
export function isSuccessfulSkillInstallExit(
|
||||
result: SkillInstallTerminalExit,
|
||||
startedAt: number | null
|
||||
): startedAt is number {
|
||||
return result.code === 0 && startedAt !== null
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
|
||||
import { useManagedAgentSkills } from '@/hooks/useManagedAgentSkills'
|
||||
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import { managedSkillStatusCopy } from './managed-skill-status-copy'
|
||||
import { isSuccessfulSkillInstallExit } from './skill-install-terminal-exit'
|
||||
|
||||
export function useAgentSkillManagementActions(args: {
|
||||
installed: boolean
|
||||
skillNames: readonly string[]
|
||||
target?: SkillDiscoveryTarget
|
||||
}) {
|
||||
const state = useManagedAgentSkills(args.target)
|
||||
const installAttemptStartedAtRef = useRef<number | null>(null)
|
||||
const openSettingsPage = useAppStore((store) => store.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((store) => store.openSettingsTarget)
|
||||
const skillNamesKey = args.skillNames.join('\0')
|
||||
const installation = useMemo(() => {
|
||||
const names = new Set(skillNamesKey.split('\0').filter(Boolean))
|
||||
return state.inventory?.installations.find((entry) => names.has(entry.name))
|
||||
}, [skillNamesKey, state.inventory])
|
||||
|
||||
const openSettings = (): void => {
|
||||
openSettingsTarget({ pane: 'agents', repoId: null })
|
||||
openSettingsPage()
|
||||
}
|
||||
const actionLabel = (() => {
|
||||
if (installation && !installation.actionsSupported) {
|
||||
return translate('auto.components.settings.AgentSkillSetupPanel.review', 'Review')
|
||||
}
|
||||
switch (installation?.status) {
|
||||
case 'known-current':
|
||||
return translate('auto.components.settings.AgentSkillSetupPanel.manage', 'Manage')
|
||||
case 'known-update-available':
|
||||
return translate(
|
||||
'auto.components.settings.AgentSkillSetupPanel.manageUpdate',
|
||||
'Manage and update'
|
||||
)
|
||||
case 'managed-update-available':
|
||||
return translate('auto.components.settings.AgentSkillSetupPanel.update', 'Update')
|
||||
case 'update-failed':
|
||||
return translate(
|
||||
'auto.components.settings.AgentSkillSetupPanel.retryUpdate',
|
||||
'Retry update'
|
||||
)
|
||||
case 'managed-current':
|
||||
return null
|
||||
case 'newer-known':
|
||||
case 'modified':
|
||||
case 'unknown':
|
||||
case 'externally-managed':
|
||||
case 'inaccessible':
|
||||
case undefined:
|
||||
return translate('auto.components.settings.AgentSkillSetupPanel.review', 'Review')
|
||||
}
|
||||
})()
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
if (!installation || !installation.actionsSupported) {
|
||||
openSettings()
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (
|
||||
installation.status === 'known-current' ||
|
||||
installation.status === 'known-update-available'
|
||||
) {
|
||||
await state.manage(installation.id)
|
||||
} else if (
|
||||
installation.status === 'managed-update-available' ||
|
||||
installation.status === 'update-failed'
|
||||
) {
|
||||
await state.update(installation.id)
|
||||
} else {
|
||||
openSettings()
|
||||
return
|
||||
}
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.settings.AgentSkillSetupPanel.managedCurrent',
|
||||
'Skill is managed and current.'
|
||||
)
|
||||
)
|
||||
notifyInstalledAgentSkillsChanged()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: translate(
|
||||
'auto.components.settings.AgentSkillSetupPanel.actionFailed',
|
||||
'Skill action failed.'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const recordTerminalInstall = (result: { code: number | null }): void => {
|
||||
notifyInstalledAgentSkillsChanged()
|
||||
const startedAt = installAttemptStartedAtRef.current
|
||||
if (
|
||||
!isSuccessfulSkillInstallExit(result, startedAt) ||
|
||||
args.installed ||
|
||||
args.skillNames.length === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
void window.api.skills
|
||||
.recordOrcaInstall({
|
||||
skillNames: [...args.skillNames],
|
||||
startedAt,
|
||||
target: args.target
|
||||
})
|
||||
.then(notifyInstalledAgentSkillsChanged)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
applicable: args.installed && args.skillNames.length > 0,
|
||||
actionLabel,
|
||||
busy: state.loading || state.busyInstallationId !== null,
|
||||
actionBusy: state.loading || state.busyInstallationId === installation?.id,
|
||||
installation,
|
||||
statusLabel: installation ? managedSkillStatusCopy(installation.status) : null,
|
||||
run,
|
||||
recordTerminalInstall,
|
||||
beginInstallAttempt: () => {
|
||||
installAttemptStartedAtRef.current = Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
isOrcaCliAvailableOnPath
|
||||
} from '@/lib/agent-skill-cli-prerequisite'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { LINEAR_AGENT_SKILL_NAMES } from '@/lib/agent-feature-install-commands'
|
||||
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
|
||||
|
||||
type AgentSkillSetupPanelProps = ComponentProps<typeof AgentSkillSetupPanel>
|
||||
|
||||
@@ -33,6 +35,7 @@ type LinearAgentSkillSetupDialogProps = {
|
||||
getPrerequisiteStatus?: AgentSkillSetupPanelProps['getPrerequisiteStatus']
|
||||
onBeforeOpenTerminal: AgentSkillSetupPanelProps['onBeforeOpenTerminal']
|
||||
onRecheck: AgentSkillSetupPanelProps['onRecheck']
|
||||
managementTarget?: SkillDiscoveryTarget
|
||||
onOpenChange: (open: boolean) => void
|
||||
onDismissPermanently: () => void
|
||||
onSnoozeForSession: () => void
|
||||
@@ -53,6 +56,7 @@ export function LinearAgentSkillSetupDialog({
|
||||
getPrerequisiteStatus,
|
||||
onBeforeOpenTerminal,
|
||||
onRecheck,
|
||||
managementTarget,
|
||||
onOpenChange,
|
||||
onDismissPermanently,
|
||||
onSnoozeForSession,
|
||||
@@ -117,6 +121,8 @@ export function LinearAgentSkillSetupDialog({
|
||||
</div>
|
||||
</div>
|
||||
<AgentSkillSetupPanel
|
||||
managedSkillNames={LINEAR_AGENT_SKILL_NAMES}
|
||||
managementTarget={managementTarget}
|
||||
className="px-6 pt-4 pb-3"
|
||||
variant="inline"
|
||||
hideHeader
|
||||
|
||||
@@ -277,6 +277,7 @@ export function LinearAgentSkillSetupPrompt({
|
||||
|
||||
const setupDialog = (
|
||||
<LinearAgentSkillSetupDialog
|
||||
managementTarget={skillDiscoveryTarget}
|
||||
open={setupDialogOpen}
|
||||
showSuccess={showSuccessModal}
|
||||
successDescription={successDescription}
|
||||
|
||||
@@ -52,6 +52,7 @@ export function buildHostHeaderMenuModel(input: HostHeaderMenuInput): HostHeader
|
||||
actions.push('runtime-check-connection')
|
||||
break
|
||||
case 'local':
|
||||
case 'wsl':
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { GlobalSettings, Repo, WorkspaceHostScope } from '../../../../share
|
||||
import {
|
||||
ALL_EXECUTION_HOSTS_SCOPE,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
type ExecutionHostId
|
||||
type ExecutionHostId,
|
||||
type ExecutionHostKind
|
||||
} from '../../../../shared/execution-host'
|
||||
import {
|
||||
buildExecutionHostRegistry,
|
||||
@@ -18,7 +19,7 @@ export type SidebarHostOption = {
|
||||
id: ExecutionHostId
|
||||
label: string
|
||||
detail: string
|
||||
kind: 'local' | 'ssh' | 'runtime'
|
||||
kind: ExecutionHostKind
|
||||
health: ExecutionHostHealth
|
||||
presence: 'local' | 'configured' | 'project' | 'active'
|
||||
// Why: surfaced to the sidebar host-header menu so it can warn on version skew.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user