Version skills too, numbered by the server rather than by their author
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user