Skip to content

Commit 6b120f6

Browse files
authored
fix(registry): preserve root Skill snapshots (#573)
* fix(registry): preserve root Skill snapshots * fix(registry): refresh every legacy Skill snapshot * fix(registry): make legacy snapshots due for refresh
1 parent 523be8e commit 6b120f6

32 files changed

Lines changed: 400 additions & 184 deletions

‎COPY.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ The Skill behaviors panel shows a behavior reading under each SKILL.md match tha
157157
Name the model plainly, as a language model. Never call a reading a review, a scan, a safety call, or a false positive.
158158
The strings live in `layers/registry/app/components/_SkillBehaviors.vue`.
159159

160+
- If the rules fail: `Skill behaviors are unavailable. Run skilld run to check every file.`
161+
160162
- Each match: `SKILL.md:42 · Quoted example. {reason}`. `behaviorVerdictLabel` in `shared/behavior-readings.ts` owns the five labels: `Instruction`, `Quoted example`, `Prohibition`, `Documentation`, `Unclear`.
161163
- Panel note, shown when a match has a reading: `A language model read each match that needs approval in its context. Its reading is no guarantee and changes no approval.`
162164
- The skilld CLI approval message adds `(model reading: quoted example. {reason})` to each match, and ends its list with `A language model on skilld.dev wrote each model reading. A reading is no guarantee and changes no approval.` The CLI owns that string, so change it there first.

‎layers/registry/app/components/SkillDetail.vue‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ interface SourceFacts {
122122
capabilityScopes: ('read' | 'write' | 'exec' | 'net')[]
123123
mcpServers: string[]
124124
}
125-
behaviors: SkillBehavior[]
125+
behaviors: SkillBehavior[] | null
126126
}
127127
128128
interface NeighborSkill {
@@ -191,6 +191,7 @@ const skillFetch = useFetch(
191191
githubUrl: string
192192
description: string | null
193193
license: string | null
194+
licenseSource: 'skill' | 'repository' | null
194195
stars: number
195196
forks: number
196197
pushedAt: string | null
@@ -446,8 +447,7 @@ const { data: skillFiles } = useFetch(
446447
total: number
447448
} | null>>
448449
449-
// Prefer the live ungh-walked file list when available (catches markdown
450-
// siblings the sync job hasn't registered yet); fall back to data.assets.
450+
// Prefer the stored file inventory endpoint; fall back to detail metadata.
451451
const treeAssets = computed(() => {
452452
if (data.value?.sourceGone)
453453
return []
@@ -1655,7 +1655,7 @@ useHead(computed(() => ({
16551655
v-if="licenseFile"
16561656
type="button"
16571657
class="skill-chip skill-chip-link"
1658-
:title="`License: ${data.license}. Opens ${licenseFile}.`"
1658+
:title="`${data.licenseSource === 'repository' ? 'Repository license' : 'License'}: ${data.license}. Opens ${licenseFile}.`"
16591659
@click="() => { void resolveAndOpen(licenseFile!) }"
16601660
>
16611661
<UIcon name="i-lucide-scale" class="size-3.5" aria-hidden="true" />
@@ -1664,7 +1664,7 @@ useHead(computed(() => ({
16641664
<span
16651665
v-else
16661666
class="skill-chip"
1667-
:title="`License: ${data.license}`"
1667+
:title="`${data.licenseSource === 'repository' ? 'Repository license' : 'License'}: ${data.license}`"
16681668
>
16691669
<UIcon name="i-lucide-scale" class="size-3.5" aria-hidden="true" />
16701670
{{ licenseLabel }}
@@ -2335,7 +2335,7 @@ useHead(computed(() => ({
23352335
>
23362336
<SkillBehaviors
23372337
v-if="data.raw || behaviors.length"
2338-
:behaviors="behaviors"
2338+
:behaviors="data.sourceFacts.behaviors"
23392339
:readings="behaviorReadings"
23402340
/>
23412341
<SkillThirdPartyChecks

‎layers/registry/app/components/SkillFileTree.vue‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const emit = defineEmits<{
3030
3131
const nodes = computed(() => buildSkillFileTree(props.assets, props.skillMdSize ?? 0))
3232
33-
const skillDir = computed(() => props.skillPath?.replace(/\/SKILL\.md$/, '') ?? '')
33+
const skillDir = computed(() => props.skillPath?.replace(/(?:^|\/)SKILL\.md$/i, '') ?? '')
3434
3535
/**
3636
* The skill page renders this tree twice, once floating and once inline, so a

‎layers/registry/app/components/SkillFileTreeNode.vue‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ const isActive = computed(() => {
4343
const githubUrl = computed(() => {
4444
if (props.node.kind !== 'file')
4545
return ''
46-
return `https://github.com/${props.owner}/${props.repo}/blob/${props.branch}/${props.skillDir}/${props.node.path}`
46+
const path = props.skillDir ? `${props.skillDir}/${props.node.path}` : props.node.path
47+
return `https://github.com/${props.owner}/${props.repo}/blob/${props.branch}/${path}`
4748
})
4849
4950
// A plain click opens the file in the viewer. Modified clicks keep the

‎layers/registry/app/components/_SkillBehaviors.vue‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export interface SkillBehavior {
2525
}
2626
2727
const { behaviors, readings = [] } = defineProps<{
28-
behaviors: SkillBehavior[]
28+
behaviors: SkillBehavior[] | null
2929
readings?: BehaviorReading[]
3030
}>()
3131
@@ -45,7 +45,7 @@ function locationRows(behavior: SkillBehavior): Array<{ location: SkillBehaviorL
4545
return behavior.locations.map(location => ({ location, reading: readingFor(behavior, location) }))
4646
}
4747
48-
const anyReading = computed(() => behaviors.some(hasReadings))
48+
const anyReading = computed(() => behaviors?.some(hasReadings) ?? false)
4949
5050
function locationLabel(location: SkillBehaviorLocation): string {
5151
return location.line === null ? location.path : `${location.path}:${location.line}`
@@ -67,7 +67,7 @@ function locationLabel(location: SkillBehaviorLocation): string {
6767

6868
<div class="rounded-lg border border-default">
6969
<ul
70-
v-if="behaviors.length"
70+
v-if="behaviors?.length"
7171
role="list"
7272
class="divide-y divide-default"
7373
>
@@ -153,7 +153,7 @@ function locationLabel(location: SkillBehaviorLocation): string {
153153
v-else
154154
class="px-4 py-3 font-mono text-xs text-muted"
155155
>
156-
No rule matched.
156+
{{ behaviors === null ? 'Skill behaviors are unavailable. Run skilld run to check every file.' : 'No rule matched.' }}
157157
</p>
158158
<div class="space-y-1 border-t border-default bg-muted/20 px-4 py-3 text-xs text-muted leading-snug">
159159
<p>skilld matched fixed text patterns in SKILL.md and file names. Patterns miss obfuscated code.</p>

‎layers/registry/server/api/skill-asset/[...slug].get.ts‎

Lines changed: 15 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { normalizeSkillAssetFilePath } from '#shared/skill-asset-path'
44
import { resolveRepoSourceIdentityFromRow } from '../../utils/repo-source-identity'
55
import { skillImagePolicyForEvent } from '../../utils/skill-image-policy'
66
import { parseSkillMd } from '../../utils/skill-md-render'
7+
import { loadStoredSkillRow, parseStoredAssets, resolveReferencedFileTarget } from '../../utils/skill-stored-source'
78
import { findSkill } from '../../utils/skills-registry'
89
import { fetchUpstreamText } from '../../utils/upstream-text'
9-
import { fetchUpstreamTree } from '../../utils/upstream-tree'
1010

1111
const ASSET_CACHE_TTL = 60 * 60 * 24 * 7
1212
const ASSET_MISSING_TTL = 60 * 60
@@ -22,14 +22,6 @@ interface AssetCache {
2222
skillPath: string | null
2323
}
2424

25-
interface SkillAssetRow {
26-
default_branch: string | null
27-
assets: string | null
28-
source_owner: string | null
29-
source_repo: string | null
30-
source_resolved: number | null
31-
}
32-
3325
interface RepoSkillNameRow {
3426
name: string
3527
}
@@ -79,12 +71,7 @@ export default defineApiHandler({
7971
throw createError({ statusCode: 404, message: 'Skill not found' })
8072

8173
const [row, repoSkillRows] = await Promise.all([
82-
platform.db
83-
.prepare(`SELECT r.default_branch, r.source_owner, r.source_repo, s.assets, s.source_resolved
84-
FROM skills s JOIN repos r ON r.owner = s.owner AND r.repo = s.repo
85-
WHERE s.owner = ? AND s.repo = ? AND s.name = ?`)
86-
.bind(skill.owner, skill.repo, skill.name)
87-
.first<SkillAssetRow>(),
74+
loadStoredSkillRow(platform.db, skill),
8875
platform.db
8976
.prepare(`SELECT name FROM skills WHERE owner = ? AND repo = ? ORDER BY name`)
9077
.bind(skill.owner, skill.repo)
@@ -101,21 +88,7 @@ export default defineApiHandler({
10188
throw createError({ statusCode: 410, message: 'Skill source is gone upstream' })
10289

10390
const source = resolveRepoSourceIdentityFromRow(skill, row)
104-
let registered: RegisteredAsset[] = []
105-
if (row.assets) {
106-
try {
107-
const parsed = JSON.parse(row.assets) as unknown
108-
if (Array.isArray(parsed)) {
109-
registered = parsed.filter((a): a is RegisteredAsset =>
110-
Boolean(a) && typeof a === 'object' && typeof (a as { path: unknown }).path === 'string',
111-
)
112-
}
113-
}
114-
catch {
115-
// Treat as empty list.
116-
}
117-
}
118-
91+
const registered = parseStoredAssets(row.assets)
11992
// Any file inside the resolved skillDir is fair game — the path is
12093
// already constrained below so this stays scoped to the skill folder.
12194
// Registration is used only to pick up the recorded size/type when
@@ -124,56 +97,25 @@ export default defineApiHandler({
12497
const asset: RegisteredAsset = registeredAsset
12598
?? { path: filePath, size: 0, type: classifyAsset(filePath) }
12699

127-
const branch = row.default_branch || 'main'
128-
const cacheKey = `skills:asset:v4:${source.owner}/${source.repo}/${skill.name}:${filePath}:${branch}`
100+
const target = resolveReferencedFileTarget(skill, row, filePath)
101+
if (target._tag === 'missing')
102+
throw createError({ statusCode: 404, message: 'Skill source not found' })
103+
if (target._tag === 'unavailable') {
104+
setHeader(event, 'retry-after', ASSET_RETRY_AFTER)
105+
throw createError({ statusCode: 503, message: 'Skill source commit is unavailable. Try again after the next sync.' })
106+
}
107+
const branch = row.rendered_commit_sha!
108+
const skillMdPath = row.rendered_skill_path!
109+
const skillDir = skillMdPath.replace(/(?:^|\/)SKILL\.md$/i, '')
110+
const cacheKey = `skills:asset:v5:${source.owner}/${source.repo}/${skill.name}:${filePath}:${branch}`
129111
const cached = await readCache<AssetCache>(useStorage('edge-cache'), cacheKey)
130112
if (cached) {
131113
if (cached.status === 'missing')
132114
throw createError({ statusCode: 404, message: 'Asset content unavailable' })
133115
return cached
134116
}
135117

136-
// Resolve the skill directory by re-finding the SKILL.md path.
137-
const treeResult = await fetchUpstreamTree(source, branch, { operation: 'skill-asset-tree-fetch' })
138-
139-
if (treeResult._tag === 'gone') {
140-
// The registry has not recorded this deletion yet. The next sync flips
141-
// `source_resolved` and short-circuits earlier.
142-
throw createError({ statusCode: 410, message: 'Skill source is gone upstream' })
143-
}
144-
145-
const treeUnavailable = treeResult._tag === 'unavailable'
146-
const treeFiles = treeResult._tag === 'available' ? treeResult.files : []
147-
const slugifiedName = skill.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
148-
const skillMdPath = treeFiles.find(f =>
149-
f.path.toLowerCase().endsWith(`/${slugifiedName}/skill.md`)
150-
|| f.path.toLowerCase() === `${slugifiedName}/skill.md`
151-
|| f.path.toLowerCase().endsWith(`/${skill.name.toLowerCase()}/skill.md`),
152-
)?.path
153-
154-
if (!skillMdPath) {
155-
// A tree-listing outage must not leave a "missing" marker behind, or the
156-
// asset reads as deleted for the rest of the cache window.
157-
if (treeUnavailable) {
158-
setHeader(event, 'retry-after', ASSET_RETRY_AFTER)
159-
throw createError({ statusCode: 503, message: 'Skill source is unavailable upstream' })
160-
}
161-
await writeCache(useStorage('edge-cache'), cacheKey, {
162-
status: 'missing',
163-
raw: null,
164-
html: null,
165-
size: asset.size,
166-
type: asset.type,
167-
branch,
168-
skillPath: null,
169-
} satisfies AssetCache, { ttl: ASSET_MISSING_TTL })
170-
throw createError({ statusCode: 404, message: 'Skill source not found' })
171-
}
172-
173-
const skillDir = skillMdPath.replace(/\/SKILL\.md$/, '')
174-
const fullPath = `${skillDir}/${filePath}`
175-
const rawUrl = `https://raw.githubusercontent.com/${source.owner}/${source.repo}/${branch}/${fullPath}`
176-
const upstream = await fetchUpstreamText(rawUrl)
118+
const upstream = await fetchUpstreamText(target.url)
177119

178120
if (upstream._tag === 'missing') {
179121
emitOperationalEvent(createWideEvent({ 'operation': 'skill-asset-fetch', 'outcome': 'missing', 'upstream.status': upstream.status }))

‎layers/registry/server/api/skills-raw/[...slug].get.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ export default defineApiHandler({
7676
// A file beside SKILL.md (e.g. `references/foo.md`) is not stored, so it
7777
// is the one read that still reaches GitHub. Its directory comes from D1.
7878
const target = resolveReferencedFileTarget(skill, row, filePath)
79+
if (target._tag === 'unavailable') {
80+
setHeader(event, 'retry-after', RAW_RETRY_AFTER)
81+
throw createError({ statusCode: 503, message: 'Skill source commit is unavailable. Try again after the next sync.' })
82+
}
7983
if (target._tag === 'missing')
8084
throw createError({ statusCode: 404, message: 'Referenced file not found in repository' })
8185

0 commit comments

Comments
 (0)