fix(skills): decouple bundled skill artifacts from the release train (#9119)
The current manifest stamped package.json's version into itself (9 lines), so every RC/stable version bump made the committed artifact stale on every open branch: lint failed until authors committed content-free regeneration diffs, which also dragged the resources/skills-filtered update-roundtrip matrix onto unrelated PRs. Cutting a release tag whose skills tree changed had the same effect through release-mapping.json. - current-manifest.json is now schema 2 and content-only; the generator no longer reads package.json. Registry and mapping stay schema 1 so the append-only released-history guard keeps its schema gate. - The running build's version enters at the IPC boundary (skills:freshnessInventory passes app.getVersion()) and threads through the inventory to placement observation; current-revision placements are labeled with it while historical revisions keep resolving through the release mapping. The artifact loader and its cache stay content-only. - verify tolerates a committed release mapping that is a byte-exact prefix of the derived one when every missing trailing row's revisions equal the current manifest (a just-cut tag over unchanged-since bytes); such rows are provably redundant until the next real regeneration adds them. Artifacts now change only when skills/ content changes.
This commit is contained in:
parent
5ee90c8d59
commit
cc1ad064d7
|
|
@ -472,10 +472,7 @@ 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
|
||||
# Why: package version is embedded in generated skill provenance; full tag
|
||||
# history preserves released snapshots while the current version is updated.
|
||||
node config/scripts/generate-skill-bundle-manifest.mjs --write
|
||||
git add package.json resources/skills
|
||||
git add package.json
|
||||
commit_message="release: v$VERSION"
|
||||
if [[ "$EVENT_NAME" == "schedule" ]]; then
|
||||
commit_message="$commit_message [rc-slot:$SLOT]"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import path from 'node:path'
|
|||
import process from 'node:process'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
|
||||
const SCHEMA_VERSION = 1
|
||||
// Why: the three artifacts version independently — bumping one shape must not
|
||||
// rewrite the others or bypass the registry's schema-gated append-only guard.
|
||||
const CURRENT_MANIFEST_SCHEMA_VERSION = 2
|
||||
const SNAPSHOT_REGISTRY_SCHEMA_VERSION = 1
|
||||
const RELEASE_MAPPING_SCHEMA_VERSION = 1
|
||||
const SCRIPT_DIR = import.meta.dirname
|
||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
|
||||
const SKILLS_ROOT = path.join(REPO_ROOT, 'skills')
|
||||
|
|
@ -313,8 +317,8 @@ function skillsTreeShasAtRefs(refs) {
|
|||
}
|
||||
|
||||
function buildReleasedHistory() {
|
||||
const registry = { schemaVersion: SCHEMA_VERSION, skills: {} }
|
||||
const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] }
|
||||
const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} }
|
||||
const mapping = { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] }
|
||||
const tags = releaseTags()
|
||||
const treeShas = skillsTreeShasAtRefs(tags)
|
||||
const distinctTreeShas = [...new Set(treeShas.filter(Boolean))]
|
||||
|
|
@ -366,7 +370,10 @@ function buildReleasedHistory() {
|
|||
return { registry, mapping }
|
||||
}
|
||||
|
||||
async function buildArtifacts(appVersion) {
|
||||
// Why: the artifacts must be pure functions of skills/ bytes and release-tag
|
||||
// history. Stamping the app version made every release cut invalidate the
|
||||
// committed output on all open branches and drag skill CI onto unrelated PRs.
|
||||
async function buildArtifacts() {
|
||||
const { registry, mapping } = buildReleasedHistory()
|
||||
const releasedSnapshotCounts = Object.fromEntries(
|
||||
Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length])
|
||||
|
|
@ -399,14 +406,12 @@ async function buildArtifacts(appVersion) {
|
|||
currentSkills.push({
|
||||
name,
|
||||
sourcePath: `skills/${name}`,
|
||||
appVersion,
|
||||
...snapshot
|
||||
})
|
||||
}
|
||||
return {
|
||||
currentManifest: {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
appVersion,
|
||||
schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION,
|
||||
skills: currentSkills
|
||||
},
|
||||
snapshotRegistry: registry,
|
||||
|
|
@ -419,7 +424,7 @@ async function buildArtifacts(appVersion) {
|
|||
// so a generation-logic change must not rewrite them silently. Only the one
|
||||
// unreleased working-tree append per skill may change between runs.
|
||||
function assertReleasedHistoryPreserved(committedRegistry, artifacts) {
|
||||
if (!committedRegistry || committedRegistry.schemaVersion !== SCHEMA_VERSION) {
|
||||
if (!committedRegistry || committedRegistry.schemaVersion !== SNAPSHOT_REGISTRY_SCHEMA_VERSION) {
|
||||
return
|
||||
}
|
||||
for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) {
|
||||
|
|
@ -466,17 +471,50 @@ async function writeArtifacts(artifacts) {
|
|||
])
|
||||
}
|
||||
|
||||
// Why: cutting a release tag adds a trailing mapping row on every checkout at
|
||||
// once, before any branch can regenerate. A trailing row whose revisions all
|
||||
// equal the current manifest is provably redundant until the next real
|
||||
// regeneration (installs of those bytes classify as current and are labeled by
|
||||
// the running build), so verify must not fail branches over it.
|
||||
function isToleratedReleaseMappingPrefix(committedText, artifacts) {
|
||||
let committed
|
||||
try {
|
||||
committed = JSON.parse(committedText)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const derived = artifacts.releaseMapping
|
||||
const committedCount = Array.isArray(committed?.releases) ? committed.releases.length : -1
|
||||
if (committedCount < 0 || committedCount >= derived.releases.length) {
|
||||
return false
|
||||
}
|
||||
const prefix = {
|
||||
schemaVersion: derived.schemaVersion,
|
||||
releases: derived.releases.slice(0, committedCount)
|
||||
}
|
||||
if (committedText !== serialized(prefix)) {
|
||||
return false
|
||||
}
|
||||
const currentRevisions = Object.fromEntries(
|
||||
artifacts.currentManifest.skills.map((skill) => [skill.name, skill.releaseRevision])
|
||||
)
|
||||
return derived.releases
|
||||
.slice(committedCount)
|
||||
.every((release) => isDeepStrictEqual(release.skills, currentRevisions))
|
||||
}
|
||||
|
||||
async function verifyArtifacts(artifacts) {
|
||||
const expected = [
|
||||
[CURRENT_MANIFEST_PATH, artifacts.currentManifest],
|
||||
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry],
|
||||
[RELEASE_MAPPING_PATH, artifacts.releaseMapping]
|
||||
[CURRENT_MANIFEST_PATH, artifacts.currentManifest, null],
|
||||
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry, null],
|
||||
[RELEASE_MAPPING_PATH, artifacts.releaseMapping, isToleratedReleaseMappingPrefix]
|
||||
]
|
||||
const stale = []
|
||||
for (const [filePath, value] of expected) {
|
||||
for (const [filePath, value, tolerated] of expected) {
|
||||
try {
|
||||
await access(filePath, constants.R_OK)
|
||||
if ((await readFile(filePath, 'utf8')) !== serialized(value)) {
|
||||
const committedText = await readFile(filePath, 'utf8')
|
||||
if (committedText !== serialized(value) && !tolerated?.(committedText, artifacts)) {
|
||||
stale.push(filePath)
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -493,8 +531,7 @@ async function verifyArtifacts(artifacts) {
|
|||
}
|
||||
|
||||
async function main() {
|
||||
const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8'))
|
||||
const artifacts = await buildArtifacts(packageJson.version)
|
||||
const artifacts = await buildArtifacts()
|
||||
assertReleasedHistoryPreserved(await readCommittedRegistry(), artifacts)
|
||||
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
|
||||
}
|
||||
|
|
@ -514,6 +551,7 @@ export {
|
|||
collectPackageFiles,
|
||||
describeFile,
|
||||
gitTreeSha,
|
||||
isToleratedReleaseMappingPrefix,
|
||||
normalizeText,
|
||||
packageDigest,
|
||||
sortManifestFiles,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
collectPackageFiles,
|
||||
describeFile,
|
||||
gitTreeSha,
|
||||
isToleratedReleaseMappingPrefix,
|
||||
normalizeText,
|
||||
packageDigest,
|
||||
sortManifestFiles
|
||||
|
|
@ -136,6 +137,56 @@ describe('skill bundle manifest generator', () => {
|
|||
expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow()
|
||||
})
|
||||
|
||||
it('tolerates only redundant trailing release-mapping rows', () => {
|
||||
const serialized = (value) => `${JSON.stringify(value, null, 2)}\n`
|
||||
const rows = [
|
||||
{ appVersion: '1.0.0', skills: { 'orca-cli': 1 } },
|
||||
{ appVersion: '1.1.0', skills: { 'orca-cli': 2 } }
|
||||
]
|
||||
const artifacts = {
|
||||
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 2 }] },
|
||||
releaseMapping: { schemaVersion: 1, releases: rows }
|
||||
}
|
||||
const committedPrefix = serialized({ schemaVersion: 1, releases: [rows[0]] })
|
||||
|
||||
// A just-cut tag whose bytes equal the working tree may lag in the mapping.
|
||||
expect(isToleratedReleaseMappingPrefix(committedPrefix, artifacts)).toBe(true)
|
||||
// The committed file matching the derived mapping is byte-equality's job, not tolerance.
|
||||
expect(isToleratedReleaseMappingPrefix(serialized(artifacts.releaseMapping), artifacts)).toBe(
|
||||
false
|
||||
)
|
||||
// A trailing row for bytes the committed artifacts do not describe is a real gap.
|
||||
expect(
|
||||
isToleratedReleaseMappingPrefix(committedPrefix, {
|
||||
...artifacts,
|
||||
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 3 }] }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isToleratedReleaseMappingPrefix(committedPrefix, {
|
||||
...artifacts,
|
||||
currentManifest: {
|
||||
skills: [
|
||||
{ name: 'orca-cli', releaseRevision: 2 },
|
||||
{ name: 'orca-linear', releaseRevision: 1 }
|
||||
]
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
// Rewritten earlier rows never pass, with or without trailing rows.
|
||||
expect(
|
||||
isToleratedReleaseMappingPrefix(
|
||||
serialized({
|
||||
schemaVersion: 1,
|
||||
releases: [{ appVersion: '0.9.0', skills: { 'orca-cli': 1 } }]
|
||||
}),
|
||||
artifacts
|
||||
)
|
||||
).toBe(false)
|
||||
expect(isToleratedReleaseMappingPrefix('not json', artifacts)).toBe(false)
|
||||
expect(isToleratedReleaseMappingPrefix(serialized({ schemaVersion: 1 }), artifacts)).toBe(false)
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'rejects executable files in shipped skill packages',
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -374,7 +374,7 @@ describe('Electron runtime package contract', () => {
|
|||
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
|
||||
})
|
||||
|
||||
it('keeps release-cut version commits self-healing and taggable on retries', () => {
|
||||
it('keeps release-cut version commits skill-independent and taggable on retries', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
'utf8'
|
||||
|
|
@ -388,14 +388,13 @@ describe('Electron runtime package contract', () => {
|
|||
const bumpIndex = bumpStep.run.indexOf(
|
||||
'npm version "$VERSION" --no-git-tag-version --allow-same-version'
|
||||
)
|
||||
const generateIndex = bumpStep.run.indexOf(
|
||||
'node config/scripts/generate-skill-bundle-manifest.mjs --write'
|
||||
)
|
||||
const stageIndex = bumpStep.run.indexOf('git add package.json resources/skills')
|
||||
const stageIndex = bumpStep.run.indexOf('git add package.json')
|
||||
expect(checkoutStep.with['fetch-depth']).toBe(0)
|
||||
expect(bumpIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(generateIndex).toBeGreaterThan(bumpIndex)
|
||||
expect(stageIndex).toBeGreaterThan(generateIndex)
|
||||
expect(stageIndex).toBeGreaterThan(bumpIndex)
|
||||
// Why: version-only cuts must not mutate content-addressed skill artifacts.
|
||||
expect(bumpStep.run).not.toContain('generate-skill-bundle-manifest')
|
||||
expect(bumpStep.run).not.toContain('resources/skills')
|
||||
expect(bumpStep.run).toContain('git diff --cached --quiet')
|
||||
expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"schemaVersion": 2,
|
||||
"skills": [
|
||||
{
|
||||
"name": "computer-use",
|
||||
"sourcePath": "skills/computer-use",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 5,
|
||||
"packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68",
|
||||
"gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344",
|
||||
|
|
@ -24,7 +22,6 @@
|
|||
{
|
||||
"name": "linear-tickets",
|
||||
"sourcePath": "skills/linear-tickets",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b",
|
||||
"gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176",
|
||||
|
|
@ -43,7 +40,6 @@
|
|||
{
|
||||
"name": "orca-cli",
|
||||
"sourcePath": "skills/orca-cli",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 32,
|
||||
"packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f",
|
||||
"gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19",
|
||||
|
|
@ -62,7 +58,6 @@
|
|||
{
|
||||
"name": "orca-emulator",
|
||||
"sourcePath": "skills/orca-emulator",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 4,
|
||||
"packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b",
|
||||
"gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656",
|
||||
|
|
@ -81,7 +76,6 @@
|
|||
{
|
||||
"name": "orca-emulator-android",
|
||||
"sourcePath": "skills/orca-emulator-android",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 2,
|
||||
"packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5",
|
||||
"gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83",
|
||||
|
|
@ -100,7 +94,6 @@
|
|||
{
|
||||
"name": "orca-linear",
|
||||
"sourcePath": "skills/orca-linear",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 2,
|
||||
"packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f",
|
||||
"gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8",
|
||||
|
|
@ -119,7 +112,6 @@
|
|||
{
|
||||
"name": "orca-per-workspace-env",
|
||||
"sourcePath": "skills/orca-per-workspace-env",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 2,
|
||||
"packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
|
||||
"gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
|
||||
|
|
@ -138,7 +130,6 @@
|
|||
{
|
||||
"name": "orchestration",
|
||||
"sourcePath": "skills/orchestration",
|
||||
"appVersion": "1.4.144-rc.4",
|
||||
"releaseRevision": 24,
|
||||
"packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9",
|
||||
"gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ const {
|
|||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getVersion: () => '9.9.9-test'
|
||||
},
|
||||
ipcMain: {
|
||||
handle: handleMock
|
||||
}
|
||||
|
|
@ -168,7 +171,10 @@ describe('registerSkillsHandlers', () => {
|
|||
|
||||
await handler(null)
|
||||
|
||||
expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ repos })
|
||||
expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({
|
||||
currentAppVersion: '9.9.9-test',
|
||||
repos
|
||||
})
|
||||
expect(getWslHomeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ipcMain } from 'electron'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import { discoverSkills } from '../skills/discovery'
|
||||
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills'
|
||||
|
|
@ -61,6 +61,9 @@ export function registerSkillsHandlers(store: Store): void {
|
|||
ipcMain.handle('skills:freshnessInventory', async (): Promise<SkillFreshnessInventory> => {
|
||||
// Why: the update command targets this machine's global homes. WSL and SSH
|
||||
// inventories stay out until their installer rail has an equivalent proof.
|
||||
return inventorySkillFreshness({ repos: store.getRepos() })
|
||||
return inventorySkillFreshness({
|
||||
currentAppVersion: app.getVersion(),
|
||||
repos: store.getRepos()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,48 @@ afterEach(async () => {
|
|||
})
|
||||
|
||||
describe('skill bundle artifacts', () => {
|
||||
it('loads the committed schema-2 artifacts and rejects a stamped schema-1 manifest', async () => {
|
||||
const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-'))
|
||||
temporaryDirectories.push(resourceRoot)
|
||||
const target = join(resourceRoot, 'skills')
|
||||
const source = resolve('resources', 'skills')
|
||||
await mkdir(target, { recursive: true })
|
||||
const [manifest, registry, releaseMapping] = await Promise.all(
|
||||
['current-manifest.json', 'snapshot-registry.json', 'release-mapping.json'].map((name) =>
|
||||
readFile(join(source, name), 'utf8')
|
||||
)
|
||||
)
|
||||
await Promise.all([
|
||||
writeFile(join(target, 'current-manifest.json'), manifest),
|
||||
writeFile(join(target, 'snapshot-registry.json'), registry),
|
||||
writeFile(join(target, 'release-mapping.json'), releaseMapping)
|
||||
])
|
||||
|
||||
const artifacts = await loadSkillBundleArtifacts(resourceRoot)
|
||||
expect(artifacts.manifest.schemaVersion).toBe(2)
|
||||
expect(artifacts.manifest.skills.length).toBeGreaterThan(0)
|
||||
|
||||
const legacyRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-'))
|
||||
temporaryDirectories.push(legacyRoot)
|
||||
const legacyTarget = join(legacyRoot, 'skills')
|
||||
await mkdir(legacyTarget, { recursive: true })
|
||||
const legacyManifest = JSON.parse(manifest)
|
||||
legacyManifest.schemaVersion = 1
|
||||
legacyManifest.appVersion = '1.0.0'
|
||||
for (const skill of legacyManifest.skills) {
|
||||
skill.appVersion = '1.0.0'
|
||||
}
|
||||
await Promise.all([
|
||||
writeFile(join(legacyTarget, 'current-manifest.json'), JSON.stringify(legacyManifest)),
|
||||
writeFile(join(legacyTarget, 'snapshot-registry.json'), registry),
|
||||
writeFile(join(legacyTarget, 'release-mapping.json'), releaseMapping)
|
||||
])
|
||||
|
||||
await expect(loadSkillBundleArtifacts(legacyRoot)).rejects.toThrow(
|
||||
'Invalid skill bundle manifest'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed nested release entries before building provenance', async () => {
|
||||
const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-'))
|
||||
temporaryDirectories.push(resourceRoot)
|
||||
|
|
|
|||
|
|
@ -41,14 +41,12 @@ const snapshotShape = {
|
|||
const knownSnapshotSchema = z.object(snapshotShape).strict()
|
||||
const manifestSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
appVersion: z.string().min(1),
|
||||
schemaVersion: z.literal(2),
|
||||
skills: z.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/),
|
||||
sourcePath: z.string().min(1),
|
||||
appVersion: z.string().min(1),
|
||||
...snapshotShape
|
||||
})
|
||||
.strict()
|
||||
|
|
@ -126,7 +124,6 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise<SkillBund
|
|||
)
|
||||
for (const current of manifest.skills) {
|
||||
if (
|
||||
current.appVersion !== manifest.appVersion ||
|
||||
!registry.skills[current.name]?.some(
|
||||
(snapshot) =>
|
||||
snapshot.releaseRevision === current.releaseRevision &&
|
||||
|
|
@ -137,6 +134,8 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise<SkillBund
|
|||
}
|
||||
}
|
||||
|
||||
// Why: historical provenance only — the current revision's label is the
|
||||
// running build's version, supplied at the inventory boundary, not stored here.
|
||||
const releasedAppVersions: Record<string, Record<number, string>> = {}
|
||||
for (const release of releaseMapping.releases) {
|
||||
for (const [name, revision] of Object.entries(release.skills)) {
|
||||
|
|
@ -147,10 +146,6 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise<SkillBund
|
|||
releasedAppVersions[name][revision] ??= release.appVersion
|
||||
}
|
||||
}
|
||||
for (const current of manifest.skills) {
|
||||
releasedAppVersions[current.name] ??= {}
|
||||
releasedAppVersions[current.name][current.releaseRevision] = current.appVersion
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
|
|
|
|||
|
|
@ -55,13 +55,12 @@ async function fixture() {
|
|||
const current: SkillCurrentBundleEntry = {
|
||||
name: 'orca-cli',
|
||||
sourcePath: 'skills/orca-cli',
|
||||
appVersion: '2.0.0',
|
||||
...snapshots[1]
|
||||
}
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(skillResourceRoot, 'current-manifest.json'),
|
||||
`${JSON.stringify({ schemaVersion: 1, appVersion: '2.0.0', skills: [current] }, null, 2)}\n`
|
||||
`${JSON.stringify({ schemaVersion: 2, skills: [current] }, null, 2)}\n`
|
||||
),
|
||||
writeFile(
|
||||
join(skillResourceRoot, 'snapshot-registry.json'),
|
||||
|
|
@ -111,6 +110,7 @@ describe('read-only skill freshness inventory', () => {
|
|||
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
|
|
@ -130,6 +130,7 @@ describe('read-only skill freshness inventory', () => {
|
|||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
|
|
@ -151,6 +152,7 @@ describe('read-only skill freshness inventory', () => {
|
|||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
|
|
@ -176,6 +178,7 @@ describe('read-only skill freshness inventory', () => {
|
|||
await symlink(canonical, join(claudeRoot, 'orca-cli'))
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
|
|
@ -205,6 +208,7 @@ describe('read-only skill freshness inventory', () => {
|
|||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos,
|
||||
resourceRoot: test.resourceRoot
|
||||
|
|
@ -223,6 +227,7 @@ describe('read-only skill freshness inventory', () => {
|
|||
const inaccessiblePath = join(test.homeDir, '.codex', 'skills', 'orca-cli')
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot,
|
||||
|
|
@ -248,6 +253,7 @@ describe('read-only skill freshness inventory', () => {
|
|||
const inaccessiblePath = join(repoPath, '.agents', 'skills', 'orca-cli')
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [{ id: 'repo', path: repoPath }] as unknown as Repo[],
|
||||
resourceRoot: test.resourceRoot,
|
||||
|
|
@ -292,6 +298,7 @@ describe('read-only skill freshness inventory', () => {
|
|||
}
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos,
|
||||
resourceRoot: test.resourceRoot
|
||||
|
|
@ -310,6 +317,7 @@ describe('read-only skill freshness inventory', () => {
|
|||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
|
|
@ -326,7 +334,10 @@ describe('read-only skill freshness inventory', () => {
|
|||
await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`)
|
||||
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown)
|
||||
|
||||
// Why: the injected version deliberately differs from every mapping entry
|
||||
// to prove current placements are labeled by the running build, not history.
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.1.0-unreleased',
|
||||
homeDir: test.homeDir,
|
||||
repos: [],
|
||||
resourceRoot: test.resourceRoot
|
||||
|
|
@ -335,7 +346,8 @@ describe('read-only skill freshness inventory', () => {
|
|||
expect(inventory.installations[0]).toMatchObject({
|
||||
status: 'current',
|
||||
installedReleaseRevision: 2,
|
||||
installedAppVersion: '2.0.0'
|
||||
installedAppVersion: '2.1.0-unreleased',
|
||||
currentAppVersion: '2.1.0-unreleased'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -348,6 +360,7 @@ describe('read-only skill freshness inventory', () => {
|
|||
)
|
||||
|
||||
const inventory = await inventorySkillFreshness({
|
||||
currentAppVersion: '2.0.0',
|
||||
homeDir: test.homeDir,
|
||||
repos,
|
||||
resourceRoot: test.resourceRoot
|
||||
|
|
|
|||
|
|
@ -30,15 +30,16 @@ export function boundRepositorySkillRoots(roots: readonly SkillScanRoot[]): {
|
|||
}
|
||||
}
|
||||
|
||||
export async function inventorySkillFreshness(
|
||||
args: {
|
||||
homeDir?: string
|
||||
cwd?: string
|
||||
repos?: Repo[]
|
||||
resourceRoot?: string
|
||||
candidateLstat?: CandidateLstat
|
||||
} = {}
|
||||
): Promise<SkillFreshnessInventory> {
|
||||
export async function inventorySkillFreshness(args: {
|
||||
// Why: the bundled artifacts are content-only; the running build supplies
|
||||
// its own version here so current placements can be labeled honestly.
|
||||
currentAppVersion: string
|
||||
homeDir?: string
|
||||
cwd?: string
|
||||
repos?: Repo[]
|
||||
resourceRoot?: string
|
||||
candidateLstat?: CandidateLstat
|
||||
}): Promise<SkillFreshnessInventory> {
|
||||
const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
|
||||
const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill]))
|
||||
const discoveryArgs = {
|
||||
|
|
@ -65,6 +66,7 @@ export async function inventorySkillFreshness(
|
|||
classifyHomeSkillCandidate({
|
||||
root,
|
||||
current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts,
|
||||
canonicalRootPath,
|
||||
candidateLstat: args.candidateLstat ?? ((path) => lstat(path))
|
||||
|
|
@ -84,6 +86,7 @@ export async function inventorySkillFreshness(
|
|||
classifyUnsupportedSkillCandidate({
|
||||
root,
|
||||
current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts,
|
||||
unresolvedPath: join(root.path, current.name),
|
||||
candidateLstat
|
||||
|
|
@ -99,6 +102,7 @@ export async function inventorySkillFreshness(
|
|||
(current) => () =>
|
||||
observeSkillFreshnessInstallation({
|
||||
current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts,
|
||||
rootId: 'repo-scan-limit',
|
||||
providers: [...new Set(omittedRepoRoots.flatMap((root) => root.providers))],
|
||||
|
|
@ -128,6 +132,7 @@ export async function inventorySkillFreshness(
|
|||
classifyUnsupportedSkillCandidate({
|
||||
root,
|
||||
current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts,
|
||||
unresolvedPath: candidate.path,
|
||||
candidateLstat
|
||||
|
|
@ -142,6 +147,7 @@ export async function inventorySkillFreshness(
|
|||
(current) => () =>
|
||||
observeSkillFreshnessInstallation({
|
||||
current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts,
|
||||
rootId: root.id,
|
||||
providers: root.providers,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ function knownSnapshots(
|
|||
|
||||
export async function observeSkillFreshnessInstallation(args: {
|
||||
current: SkillCurrentBundleEntry
|
||||
currentAppVersion: string
|
||||
artifacts: SkillBundleArtifacts
|
||||
rootId: string
|
||||
providers: SkillFreshnessInstallation['providers']
|
||||
|
|
@ -78,7 +79,7 @@ export async function observeSkillFreshnessInstallation(args: {
|
|||
topology: args.topology.topology,
|
||||
currentReleaseRevision: args.current.releaseRevision,
|
||||
currentPackageDigest: args.current.packageDigest,
|
||||
currentAppVersion: args.current.appVersion,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
errorCategory: args.topology.errorCategory
|
||||
}
|
||||
if (!args.topology.resolvedPath || !args.topology.identity) {
|
||||
|
|
@ -105,9 +106,14 @@ export async function observeSkillFreshnessInstallation(args: {
|
|||
...base,
|
||||
status: freshnessStatus(snapshot, args.current),
|
||||
installedReleaseRevision: snapshot?.releaseRevision ?? null,
|
||||
// Why: the current revision may be unreleased (or trail the newest tag),
|
||||
// so its label is the running build's version; only historical revisions
|
||||
// resolve through the release mapping.
|
||||
installedAppVersion: snapshot
|
||||
? (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ??
|
||||
null)
|
||||
? snapshot.releaseRevision === args.current.releaseRevision
|
||||
? args.currentAppVersion
|
||||
: (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ??
|
||||
null)
|
||||
: null,
|
||||
observedPackageDigest: observed.observedDigest
|
||||
}
|
||||
|
|
@ -126,6 +132,7 @@ export async function observeSkillFreshnessInstallation(args: {
|
|||
export async function classifyHomeSkillCandidate(args: {
|
||||
root: SkillScanRoot
|
||||
current: SkillCurrentBundleEntry
|
||||
currentAppVersion: string
|
||||
artifacts: SkillBundleArtifacts
|
||||
canonicalRootPath: string
|
||||
candidateLstat: CandidateLstat
|
||||
|
|
@ -139,6 +146,7 @@ export async function classifyHomeSkillCandidate(args: {
|
|||
}
|
||||
return observeSkillFreshnessInstallation({
|
||||
current: args.current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts: args.artifacts,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
|
|
@ -167,6 +175,7 @@ export async function classifyHomeSkillCandidate(args: {
|
|||
}
|
||||
return observeSkillFreshnessInstallation({
|
||||
current: args.current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts: args.artifacts,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
|
|
@ -180,6 +189,7 @@ export async function classifyHomeSkillCandidate(args: {
|
|||
export async function classifyUnsupportedSkillCandidate(args: {
|
||||
root: SkillScanRoot
|
||||
current: SkillCurrentBundleEntry
|
||||
currentAppVersion: string
|
||||
artifacts: SkillBundleArtifacts
|
||||
unresolvedPath: string
|
||||
candidateLstat: CandidateLstat
|
||||
|
|
@ -192,6 +202,7 @@ export async function classifyUnsupportedSkillCandidate(args: {
|
|||
}
|
||||
return observeSkillFreshnessInstallation({
|
||||
current: args.current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts: args.artifacts,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
|
|
@ -208,6 +219,7 @@ export async function classifyUnsupportedSkillCandidate(args: {
|
|||
}
|
||||
return observeSkillFreshnessInstallation({
|
||||
current: args.current,
|
||||
currentAppVersion: args.currentAppVersion,
|
||||
artifacts: args.artifacts,
|
||||
rootId: args.root.id,
|
||||
providers: args.root.providers,
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ export type SkillKnownSnapshot = {
|
|||
export type SkillCurrentBundleEntry = SkillKnownSnapshot & {
|
||||
name: string
|
||||
sourcePath: string
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
// Why: schema 2 removed the stamped app version so the committed artifact is a
|
||||
// pure function of skills/ content; the running build supplies its own version.
|
||||
export type SkillBundleManifest = {
|
||||
schemaVersion: 1
|
||||
appVersion: string
|
||||
schemaVersion: 2
|
||||
skills: SkillCurrentBundleEntry[]
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue