Version skills too, numbered by the server rather than by their author
Some checks failed
CI / test (push) Has been cancelled
CI / fixture (push) Has been cancelled

repo.KindSkill existed with nothing writing it. Migration 000010 says "agents
and skills version identically", the table has always accepted kind='skill',
and no path on either side ever recorded one — so an edit to a skill left no
record of what it used to say. Agents name their skills and the runtime refuses
to load one whose skill is missing, so a skill changing under a pinned agent is
the same class of problem the last two commits fixed, one layer down.

Skills are numbered differently, and not by preference. An agent's frontmatter
carries `version:`, so its author decides when a change is a new version and can
be refused for rewriting an old one. definition.Skill has no such field, the
skill_definitions table has no such column, and the vocabulary is active |
inactive rather than draft | published. Giving skills an authored version would
mean a migration, a parser change on BOTH sides of the conformance test in
internal/definition — which replays a capture of the real frontend module graph
— and an edit to all 23 shipped skills. That is a feature, not this fix.

So the server assigns it: one after whatever was last published. This is not an
invention. repo.VersionsRepo.LatestVersion was written for exactly this and
says so — "the next published version has to follow what was actually published
rather than what somebody wrote in the frontmatter" — and had no callers
outside its own test.

Because the author never names a version, there is nothing to refuse: an edit
is always a new version. What needs care instead is the opposite — a save that
changed nothing must NOT be one, or every deploy would add a version to all 23
skills and the number would stop meaning anything. Each publish is compared
against the last recorded copy first. Inactive skills are not recorded at all;
inactive is this vocabulary's draft.

Verified against a live stack:

  - first import over 23 unversioned skills: 23 skill version(s) recorded
  - second import, files unchanged: 0 recorded, total still 23
  - one skill edited: 1 recorded, that skill at v1, v2; v1 still holds the
    original text and v2 the edit

The test fails without the change — "after create: 0 version(s), want 1" — and
covers the three behaviours that matter: an edit versions, an identical save
does not, and an inactive skill is not recorded.

Both races noted on the agent path apply here as well: two simultaneous edits
can compute the same next number, and the loser's snapshot is dropped rather
than failing the author's save.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
Suriyakumarvijayanayagam
2026-08-28 19:34:06 +05:30
parent 6b3dda8e5a
commit 0cda877cd6
3 changed files with 275 additions and 23 deletions

View File

@@ -164,17 +164,31 @@ func run(dir, skillDir, orgSlug string, dryRun bool, timeout time.Duration) erro
// briefly unloadable, and inside one transaction that is invisible — but
// ordering them correctly costs nothing and means a future non-transactional
// path is not silently broken.
skillsWritten := 0
// Versions are recorded through the same transaction, so the history and
// the definition it describes cannot disagree: either both land or neither
// does.
versions := repo.NewVersionsRepo(tx)
ident := authctx.Identity{OrgID: orgID, UserID: author}
skillsWritten, skillVersions := 0, 0
for _, sk := range skills {
if err := upsertSkill(ctx, tx, orgID, author, sk); err != nil {
return fmt.Errorf("%s: %w", sk.name, err)
}
skillsWritten++
// Skills are numbered by the server rather than by their author — they
// have no `version:` to read. See snapshotSkill in internal/service,
// which does the same for the authoring path.
recorded, err := snapshotSkill(ctx, versions, ident, sk)
if err != nil {
return fmt.Errorf("%s: record version: %w", sk.name, err)
}
if recorded {
skillVersions++
}
}
// Versions are recorded through the same transaction, so the history and
// the definition it describes cannot disagree: either both land or neither
// does.
//
// Snapshot is what refuses a spec that changed without raising its
// `version:`. Every such spec is collected rather than the first one
@@ -183,9 +197,6 @@ func run(dir, skillDir, orgSlug string, dryRun bool, timeout time.Duration) erro
// because that refusal comes from comparing a row this code read, not from
// a failed statement: the INSERT is ON CONFLICT DO NOTHING, so the
// transaction is still healthy and the remaining specs can be checked.
versions := repo.NewVersionsRepo(tx)
ident := authctx.Identity{OrgID: orgID, UserID: author}
inserted, updated, versioned := 0, 0, 0
var rewrites []string
for _, s := range specs {
@@ -231,9 +242,9 @@ func run(dir, skillDir, orgSlug string, dryRun bool, timeout time.Duration) erro
return fmt.Errorf("commit: %w", err)
}
fmt.Printf("\n%d agent(s) published, %d updated, %d version(s) recorded, "+
"%d skill(s) written, into %s\n",
inserted, updated, versioned, skillsWritten, orgSlug)
fmt.Printf("\n%d agent(s) published, %d updated, %d agent version(s) recorded, "+
"%d skill(s) written, %d skill version(s) recorded, into %s\n",
inserted, updated, versioned, skillsWritten, skillVersions, orgSlug)
return nil
}
@@ -436,3 +447,40 @@ func upsertSkill(ctx context.Context, tx pgx.Tx, orgID, author string, sk skillS
sk.parsed.Status, sk.parsed.Name, sk.parsed.Description, sk.parsed.Pages)
return err
}
// snapshotSkill records a skill version, numbered by the server.
//
// Skills carry no `version:` in their frontmatter, so unlike an agent there is
// no author-supplied number to honour or to refuse. The number is one after
// whatever was last published, and a skill whose text has not changed since
// then is not published again — otherwise every deploy would add a version to
// all 23 of them.
//
// Reports whether it wrote one, so the run can say how many changed.
func snapshotSkill(ctx context.Context, versions *repo.VersionsRepo,
ident authctx.Identity, sk skillSpec) (bool, error) {
latest, err := versions.LatestVersion(ctx, ident, repo.KindSkill, sk.parsed.ID)
if err != nil {
return false, err
}
if latest > 0 {
stored, err := versions.Load(ctx, ident, repo.KindSkill, sk.parsed.ID, latest)
if err == nil && stored != nil && stored.Markdown == sk.raw {
return false, nil // unchanged since the last publish
}
}
if err := versions.Snapshot(ctx, ident, repo.SnapshotInput{
Kind: repo.KindSkill,
DefinitionID: sk.parsed.ID,
Version: latest + 1,
Markdown: sk.raw,
Name: sk.parsed.Name,
Description: sk.parsed.Description,
Pages: sk.parsed.Pages,
}); err != nil {
return false, err
}
return true, nil
}