Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/adr/0017-follow-renamed-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# ADR-0017: Follow renamed Skills

**Status:** Accepted
**Date:** 2026-10-13

Extends [ADR-0015](0015-follow-moved-repositories.md) from Repositories to the Skills inside one.

## Context

The registry names a Skill after its folder. When an author renames the folder,
sync saw one Skill disappear and another appear. The old page became a 410
tombstone, its likes and collection entries stayed behind, and old run commands
failed. The new name started with no history.

On 2026-10-13 `harlan-zw/brundlefly` renamed `im-not-a-fly` to `im-part-human`
and `im-a-fly` to `im-not-a-human`. The old pages answered 410 and the new
names answered 404.

## Decision

Sync detects a rename and follows it.

- Detection runs in a sync's final sweep, once the tree's full name set is
known. A candidate pair is a name the tree no longer holds and a Skill first
seen in this sync. The removal and the addition can come from different
invocations of one sync, so both copies are read from D1.
- The pair must share at least 60% of the distinct body lines of their
`SKILL.md`, frontmatter excluded, and each must be the other's single best
match. Brundlefly's renamed Skills scored 0.97 and 0.99 against their old
copies and 0.00 against everything else. An ambiguous pair stays a removal
and an addition.
- A name the sweep already marked gone still pairs. A scheduled sync can sweep
the old name before an index request admits the new one.
- The old name's rows move to the new name in the sync's final batch, through
the same Skill table list a Repository move uses. A clash keeps the row under
the new name. The new name keeps the first-seen date of the old one.
- The old name stays as a `skill_aliases` row. `/gh/<owner>/<repo>/<old-name>`{lang="html"},
every path under it, and its `.md` copy answer 301 to the same path under the
new name, in one hop. A later rename rewrites older aliases. A rename back
deletes the alias.
- `skilld run <owner>/<repo>/<old-name>`{lang="html"} keeps working. The registry
answers the old name with the new identity.

`layers/registry/server/utils/skill-rename.ts` holds detection and the rename.
`layers/registry/server/middleware/gh-renamed-skill-redirect.ts` answers old URLs.

## Consequences

A rename adds no indexable page and keeps the Skill's likes, collection
entries, revisions, and history. The new name still needs admission like any
new Skill. For a Repository that only a submission admits, the owner's index
request brings the new name in, and that sync records the rename.

Every renamed Skill is queued in `skill_dirty` with reason `skill_renamed`. The
`embedding` marker stays under the old name, as in a Repository move.

Only a folder rename inside one Repository is detected. A rename that also
rewrites more than about 40% of the body reads as a removal and an addition. A
move of a Skill between Repositories reads the same way.

Cull path: there is none while old URLs receive traffic. Dropping
`skill_aliases` ends every redirect.

## Rejected alternatives

Redirecting a removed Skill to its Repository page would point an old URL at a
page that is not its equivalent. Search engines treat that as a soft 404, and
the site was suppressed once for scaled low-value pages. A removal without a
detected rename keeps its 410.

Rename detection from GitHub's compare API needs a stored base commit and one
more request for each sync. The cached `SKILL.md` already answers the question.

An author-declared previous name, for example in frontmatter `metadata`, is
exact, but no Agent Skills convention defines it today.
25 changes: 25 additions & 0 deletions layers/registry/server/middleware/gh-renamed-skill-redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { repositoryPathSegments } from '../utils/repository-aliases'
import { indexSkillAliases, loadSkillAliases, resolveRenamedSkillRoute } from '../utils/skill-aliases'

/**
* Answer every URL under a renamed Skill with a 301 to its new name, so the
* old URLs pass their link equity on (ADR-0017). Paths without a Skill
* segment skip the cache read.
*/
export default defineEventHandler(async (event) => {
const url = getRequestURL(event)
if (!repositoryPathSegments(url.pathname)?.rest.length)
return

const db = event.context.platform?.db
if (!db)
return

const aliases = await loadSkillAliases(useStorage('cache'), db)
if (!aliases?.length)
return

const decision = resolveRenamedSkillRoute(url.pathname, url.search, indexSkillAliases(aliases))
if (decision._tag === 'redirect')
return sendRedirect(event, decision.location, 301)
})
16 changes: 12 additions & 4 deletions layers/registry/server/utils/repository-move.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const ROOT_SKILL = `(SELECT name FROM skills WHERE owner = :from_owner AND repo
const movedSkillName = (column: string) => `CASE WHEN ${column} = ${ROOT_SKILL} THEN :root_name ELSE ${column} END`
const movedRowName = (row = '') => `CASE WHEN ${row}rendered_skill_path = 'SKILL.md' THEN :root_name ELSE ${row}name END`

interface KeyedTable {
export interface KeyedTable {
table: string
repo?: string
/** The Skill name column, for a table that holds Skills. */
Expand All @@ -121,7 +121,7 @@ const skillTable = (table: string, options: Omit<KeyedTable, 'table'> = {}): Key
* nightly prune finds through that marker, and the missing marker under the
* new name queues a fresh embedding.
*/
const SKILL_TABLES: KeyedTable[] = [
export const SKILL_TABLES: readonly KeyedTable[] = [
skillTable('skill_likes'),
skillTable('skill_revisions'),
skillTable('skill_description_history'),
Expand Down Expand Up @@ -270,8 +270,16 @@ const TOKEN = /:([a-z_]+)/g
* uses. D1 rejects a statement bound with more values than it names.
*/
export function repositoryMoveStatements(move: RepositoryMove): MoveStatement[] {
const values = moveValues(move)
return MOVE_SQL.map((template) => {
return namedStatements(MOVE_SQL, moveValues(move))
}

/**
* Bind `:name` tokens to positional `?N` parameters, giving each statement
* exactly the values it uses. D1 rejects a statement bound with more values
* than it names.
*/
export function namedStatements(templates: readonly string[], values: Record<string, MoveSqlValue>): MoveStatement[] {
return templates.map((template) => {
const order: string[] = []
const sql = template.replace(TOKEN, (_, token: string) => {
if (!order.includes(token))
Expand Down
128 changes: 128 additions & 0 deletions layers/registry/server/utils/skill-aliases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/// <reference types="@cloudflare/workers-types" />

import type { ReadThroughCache } from '#shared/server/cache'
import { readCache, writeCache } from '#shared/server/cache'
import { repositoryPathSegments } from './repository-aliases'

/** An old Skill name and the name the Skill now lives under (ADR-0017). */
export interface SkillAlias {
owner: string
repo: string
name: string
targetName: string
}

interface SkillAliasRow {
owner: string
repo: string
name: string
target_name: string
}

export type RenamedSkillRoute
= | { _tag: 'pass' }
| { _tag: 'redirect', location: string }

export const SKILL_ALIASES_CACHE_KEY = 'skills:aliases:v1'
/** Seconds. A new alias waits at most this long before its URLs redirect. */
export const SKILL_ALIASES_CACHE_TTL = 15 * 60

// An admitted row under the old name means the author brought the name back.
const ALIAS_COLUMNS = `a.owner, a.repo, a.name, a.target_name`
const LIVE_NAME = `EXISTS (
SELECT 1 FROM skills s
WHERE s.owner = a.owner AND s.repo = a.repo AND s.name = a.name AND s.source_resolved = 1)`

export const SKILL_ALIASES_SQL = `
SELECT ${ALIAS_COLUMNS}
FROM skill_aliases a
WHERE NOT ${LIVE_NAME}
`

function toAlias(row: SkillAliasRow): SkillAlias {
return { owner: row.owner, repo: row.repo, name: row.name, targetName: row.target_name }
}

/** The alias an old Skill name answers with, matched without case, or null. */
export async function findSkillAlias(
db: D1Database,
skill: { owner: string, repo: string, name: string },
): Promise<SkillAlias | null> {
const row = await db
.prepare(`SELECT ${ALIAS_COLUMNS} FROM skill_aliases a WHERE a.owner = ? AND a.repo = ? AND a.name = ? AND NOT ${LIVE_NAME}`)
.bind(skill.owner, skill.repo, skill.name)
.first<SkillAliasRow>()
return row ? toAlias(row) : null
}

export async function selectSkillAliases(db: D1Database): Promise<SkillAlias[]> {
const rows = await db.prepare(SKILL_ALIASES_SQL).bind().all<SkillAliasRow>()
return (rows.results ?? []).map(toAlias)
}

/**
* Every live alias for this request, or null when neither store answers.
*
* This runs on every `/gh` Skill request, so the set comes from the cache. A
* failure costs one request the redirect: the old URL renders what it did
* before the rename. The wide event carries the reason.
*/
export async function loadSkillAliases(
cache: ReadThroughCache,
db: D1Database,
): Promise<SkillAlias[] | null> {
const cached = await readCache<SkillAlias[]>(cache, SKILL_ALIASES_CACHE_KEY)
if (Array.isArray(cached))
return cached

try {
const aliases = await selectSkillAliases(db)
await writeCache(cache, SKILL_ALIASES_CACHE_KEY, aliases, { ttl: SKILL_ALIASES_CACHE_TTL })
return aliases
}
catch (error) {
emitOperationalEvent(createWideEvent({
operation: 'skill-aliases',
outcome: 'failed',
reason: error instanceof Error ? error.message : String(error),
}))
return null
}
}

const aliasKey = (owner: string, repo: string, name: string) => `${owner}/${repo}/${name}`.toLowerCase()

export function indexSkillAliases(aliases: readonly SkillAlias[]): ReadonlyMap<string, SkillAlias> {
return new Map(aliases.map(alias => [aliasKey(alias.owner, alias.repo, alias.name), alias]))
}

/**
* Send a URL under a renamed Skill to the same place under its new name, in
* one 301. The Skill page, its file deep links, and its agent `.md` copy all
* follow. The owner and Repository segments and the query string stay.
*/
export function resolveRenamedSkillRoute(
pathname: string,
search: string,
aliases: ReadonlyMap<string, SkillAlias>,
): RenamedSkillRoute {
const path = repositoryPathSegments(pathname)
const [segment, ...below] = path?.rest ?? []
if (!path || !segment)
return { _tag: 'pass' }

const exact = aliases.get(aliasKey(path.owner, path.repo, segment))
// `/gh/<owner>/<repo>/<name>.md` is the agent copy of the Skill page.
const markdown = !exact && below.length === 0 && segment.toLowerCase().endsWith('.md')
? aliases.get(aliasKey(path.owner, path.repo, segment.slice(0, -'.md'.length)))
: undefined
const alias = exact ?? markdown
if (!alias)
return { _tag: 'pass' }

const name = markdown ? `${alias.targetName}.md` : alias.targetName
return {
_tag: 'redirect',
location: `/gh/${[path.owner, path.repo, name, ...below].join('/')}${search}`,
}
}
Loading
Loading