feat(skillpack): SKILL.md-subscription battery
New additive, nil-safe battery for subscribing to skill packages in the Anthropic agent-skills format (SKILL.md manifest + bundled files): - Manifest/ParseManifest: SKILL.md frontmatter+body parse & validation (name/description required, allowed-tools passthrough, kebab/length limits) - Tree/Pack/LoadPack: self-contained file set, order-independent content digest (the pin identity + change signal), bundled-file listing, traversal- safe staging - Source (DirSource, GitSource): Fetch returns tree + resolved ref; git clones to temp, reads subpath into memory, cleans up (self-contained tree) - Subscription + Store + content-addressed PackCache, with Memory defaults - Syncer: Subscribe pins; Check records a PENDING update but never moves the pin; Apply is the only re-pin (supply-chain guard — upstream can't silently change what an agent runs) - Activate: resolved packs -> majordomo agent.Skill (catalog instructions + one skill_use tool) for progressive disclosure; Stage materializes files Third distinct 'skill' concept, deliberately separate from executus/skill (saved-agent noun) and majordomo/skill (eager capability bundle). Mort-side wiring (convars, .skillpack commands, Agent.SkillPacks, allowed-tools shim) is a later, separate step. Full unit + hermetic local-git tests; gofmt/vet clean; race-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
package skillpack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeSource returns a caller-controlled tree, so sync behavior is tested with
|
||||
// no filesystem or git.
|
||||
type fakeSource struct {
|
||||
tree Tree
|
||||
ref string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSource) Fetch(context.Context, string) (Tree, string, error) {
|
||||
return f.tree, f.ref, f.err
|
||||
}
|
||||
func (f *fakeSource) Kind() string { return "fake" }
|
||||
func (f *fakeSource) String() string { return "fake://pack" }
|
||||
|
||||
func packTree(name, body string) Tree {
|
||||
return Tree{ManifestName: []byte("---\nname: " + name + "\ndescription: does " + name + "\n---\n" + body + "\n")}
|
||||
}
|
||||
|
||||
func newTestSyncer(src *fakeSource) *Syncer {
|
||||
n := 0
|
||||
return &Syncer{
|
||||
Store: NewMemoryPackCache(),
|
||||
Subs: NewMemory(),
|
||||
Now: func() time.Time { return time.Unix(1000, 0) },
|
||||
NewID: func() string { n++; return "id-1" },
|
||||
SourceFor: func(*Subscription) (Source, error) { return src, nil },
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeAndPin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSource{tree: packTree("alpha", "v1"), ref: "sha-v1"}
|
||||
y := newTestSyncer(src)
|
||||
|
||||
sub, err := y.Subscribe(ctx, src, "main", "steve")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sub.Name != "alpha" || sub.PinnedSourceRef != "sha-v1" || sub.PinnedBy != "steve" {
|
||||
t.Fatalf("bad pin: %+v", sub)
|
||||
}
|
||||
if sub.HasPending() {
|
||||
t.Fatal("fresh subscription should have no pending update")
|
||||
}
|
||||
// pinned tree is cached under its digest
|
||||
if _, err := y.Store.Get(ctx, sub.PinnedDigest); err != nil {
|
||||
t.Fatalf("pinned tree not cached: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribe_DuplicateName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSource{tree: packTree("alpha", "v1"), ref: "r"}
|
||||
y := newTestSyncer(src)
|
||||
if _, err := y.Subscribe(ctx, src, "", "s"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := y.Subscribe(ctx, src, "", "s"); err == nil {
|
||||
t.Fatal("expected duplicate-name error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_RecordsPendingButDoesNotMovePin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSource{tree: packTree("alpha", "v1"), ref: "sha-v1"}
|
||||
y := newTestSyncer(src)
|
||||
sub, _ := y.Subscribe(ctx, src, "main", "s")
|
||||
pinnedBefore := sub.PinnedDigest
|
||||
|
||||
// upstream changes
|
||||
src.tree = packTree("alpha", "v2-new-instructions")
|
||||
src.ref = "sha-v2"
|
||||
|
||||
updated, err := y.Check(ctx, sub.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !updated.HasPending() {
|
||||
t.Fatal("expected a pending update after upstream change")
|
||||
}
|
||||
if updated.PinnedDigest != pinnedBefore {
|
||||
t.Fatal("Check must NOT move the pin — that is the supply-chain guard")
|
||||
}
|
||||
if updated.PendingSourceRef != "sha-v2" {
|
||||
t.Errorf("pending ref = %q", updated.PendingSourceRef)
|
||||
}
|
||||
// the pending tree is cached, ready for Apply
|
||||
if _, err := y.Store.Get(ctx, updated.PendingDigest); err != nil {
|
||||
t.Fatalf("pending tree not cached: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_ClearsStalePendingWhenUpstreamMatches(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSource{tree: packTree("alpha", "v1"), ref: "r1"}
|
||||
y := newTestSyncer(src)
|
||||
sub, _ := y.Subscribe(ctx, src, "main", "s")
|
||||
|
||||
src.tree = packTree("alpha", "v2")
|
||||
src.ref = "r2"
|
||||
sub, _ = y.Check(ctx, sub.ID) // records pending
|
||||
if !sub.HasPending() {
|
||||
t.Fatal("precondition: pending expected")
|
||||
}
|
||||
// upstream reverts to the pinned content
|
||||
src.tree = packTree("alpha", "v1")
|
||||
src.ref = "r1"
|
||||
sub, _ = y.Check(ctx, sub.ID)
|
||||
if sub.HasPending() {
|
||||
t.Fatal("pending should be cleared once upstream matches the pin again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_MovesPinAndClearsPending(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSource{tree: packTree("alpha", "v1"), ref: "sha-v1"}
|
||||
y := newTestSyncer(src)
|
||||
sub, _ := y.Subscribe(ctx, src, "main", "s")
|
||||
|
||||
src.tree = packTree("alpha", "v2")
|
||||
src.ref = "sha-v2"
|
||||
sub, _ = y.Check(ctx, sub.ID)
|
||||
pendingDigest := sub.PendingDigest
|
||||
|
||||
applied, err := y.Apply(ctx, sub.ID, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied.PinnedDigest != pendingDigest {
|
||||
t.Fatal("Apply must move the pin to the pending digest")
|
||||
}
|
||||
if applied.PinnedSourceRef != "sha-v2" || applied.PinnedBy != "admin" {
|
||||
t.Errorf("bad post-apply pin: %+v", applied)
|
||||
}
|
||||
if applied.HasPending() {
|
||||
t.Fatal("Apply must clear the pending update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_NoPending(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSource{tree: packTree("alpha", "v1"), ref: "r"}
|
||||
y := newTestSyncer(src)
|
||||
sub, _ := y.Subscribe(ctx, src, "", "s")
|
||||
if _, err := y.Apply(ctx, sub.ID, "admin"); err == nil {
|
||||
t.Fatal("expected error applying with no pending update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAll(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSource{tree: packTree("alpha", "v1"), ref: "r1"}
|
||||
y := newTestSyncer(src)
|
||||
sub, _ := y.Subscribe(ctx, src, "main", "s")
|
||||
|
||||
if pend, errs := y.CheckAll(ctx); len(pend) != 0 || len(errs) != 0 {
|
||||
t.Fatalf("no change: pend=%v errs=%v", pend, errs)
|
||||
}
|
||||
src.tree = packTree("alpha", "v2")
|
||||
src.ref = "r2"
|
||||
pend, errs := y.CheckAll(ctx)
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("errs: %v", errs)
|
||||
}
|
||||
if len(pend) != 1 || pend[0].ID != sub.ID {
|
||||
t.Fatalf("expected 1 pending, got %v", pend)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user