diff --git a/docs/ADRs/0038-universal-harness-access.md b/docs/ADRs/0038-universal-harness-access.md index 6263723f4..f0cfbcd81 100644 --- a/docs/ADRs/0038-universal-harness-access.md +++ b/docs/ADRs/0038-universal-harness-access.md @@ -299,20 +299,26 @@ The following design questions have been resolved as part of this ADR: ```yaml # .fullsend/lock.yaml version: 1 +generated_at: "2026-05-12T10:00:00Z" harnesses: rust-linter: + source: harness/rust-linter.yaml + sha256: abc123... resolved_at: "2026-05-12T10:00:00Z" dependencies: - agent: + - field: agent url: https://raw.githubusercontent.com/fullsend-ai/library/8cd3799.../agents/rust.md - sha256: abc123... + sha256: def456... fetched_at: "2026-05-12T10:00:00Z" - skills: - - url: https://raw.githubusercontent.com/fullsend-ai/library/8cd3799.../skills/cargo-check/SKILL.md - sha256: def456... - transitive_deps: - - url: https://raw.githubusercontent.com/fullsend-ai/library/8cd3799.../policies/rust-sandbox.yaml - sha256: ghi789... + - field: skills[0] + url: https://raw.githubusercontent.com/fullsend-ai/library/8cd3799.../skills/cargo-check/SKILL.md + sha256: ghi789... + fetched_at: "2026-05-12T10:00:00Z" + transitive_deps: + - field: skills[dep0] + url: https://raw.githubusercontent.com/fullsend-ai/library/8cd3799.../policies/rust-sandbox.yaml + sha256: jkl012... + fetched_at: "2026-05-12T10:00:00Z" ``` **Rationale:** Lock files provide dependency pinning (reproducible builds), transitive closure visibility (auditability), and automated updates (tools can rewrite lock files when dependencies change). Similar to `package-lock.json` in npm. diff --git a/internal/lock/lock.go b/internal/lock/lock.go new file mode 100644 index 000000000..d79446bcc --- /dev/null +++ b/internal/lock/lock.go @@ -0,0 +1,206 @@ +// Package lock reads and writes .fullsend/lock.yaml files that pin all +// resolved remote dependencies (URLs and integrity hashes) for reproducible +// harness execution. A lock file is produced by `fullsend lock ` +// and consumed by the resolver to skip re-resolution when dependencies +// have not changed. +package lock + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "gopkg.in/yaml.v3" +) + +// LockFile is the top-level structure of .fullsend/lock.yaml. +type LockFile struct { + Version int `yaml:"version"` + GeneratedAt time.Time `yaml:"generated_at"` + Harnesses map[string]HarnessLock `yaml:"harnesses"` +} + +// HarnessLock records resolved dependencies for one harness. +type HarnessLock struct { + Source string `yaml:"source"` + SHA256 string `yaml:"sha256"` + ResolvedAt time.Time `yaml:"resolved_at"` + Dependencies []DependencyEntry `yaml:"dependencies"` +} + +// DependencyEntry records a single resolved remote resource. +type DependencyEntry struct { + Field string `yaml:"field"` + URL string `yaml:"url"` + SHA256 string `yaml:"sha256"` + FetchedAt time.Time `yaml:"fetched_at"` + TransitiveDeps []DependencyEntry `yaml:"transitive_deps,omitempty"` +} + +const currentVersion = 1 + +// Load reads a lock file from path. Returns nil (no error) if the file +// does not exist. +func Load(path string) (*LockFile, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading lock file: %w", err) + } + + var lf LockFile + if err := yaml.Unmarshal(data, &lf); err != nil { + return nil, fmt.Errorf("parsing lock file: %w", err) + } + + if lf.Version != currentVersion { + return nil, fmt.Errorf("unsupported lock file version %d (expected %d)", lf.Version, currentVersion) + } + + return &lf, nil +} + +// Save writes the lock file to path atomically (write to temp, then rename). +// Parent directories are created as needed. The file is written with 0o644 +// permissions (world-readable) because lock files are meant to be committed +// to version control, unlike cache files which use 0o600. +// +// Save mutates lf.Version to currentVersion when it is zero. +func Save(path string, lf *LockFile) error { + if lf.Version == 0 { + lf.Version = currentVersion + } + + data, err := yaml.Marshal(lf) + if err != nil { + return fmt.Errorf("marshaling lock file: %w", err) + } + + header := []byte("# Generated by fullsend lock — DO NOT EDIT\n") + content := make([]byte, 0, len(header)+len(data)) + content = append(content, header...) + content = append(content, data...) + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating lock file directory: %w", err) + } + + tmp, err := os.CreateTemp(dir, ".lock.yaml.tmp.*") + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + tmpName := tmp.Name() + + if _, err := tmp.Write(content); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("writing temp file: %w", err) + } + if err := tmp.Chmod(0o644); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("setting file permissions: %w", err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("syncing temp file: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("closing temp file: %w", err) + } + + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return fmt.Errorf("renaming temp file: %w", err) + } + return nil +} + +// Lookup returns the HarnessLock entry for the named harness, or nil if +// not found. Returns nil for a nil receiver. The returned pointer is a +// snapshot — mutations do not update the LockFile map. Use SetHarness to +// modify entries. +func (lf *LockFile) Lookup(harnessName string) *HarnessLock { + if lf == nil { + return nil + } + hl, ok := lf.Harnesses[harnessName] + if !ok { + return nil + } + return &hl +} + +// IsStale returns true if the harness source file has changed since the +// lock entry was generated. It compares the harness file's SHA256 against +// the stored hash. sourceHash is the SHA256 of the current harness file +// content. Returns true for a nil receiver. +func (hl *HarnessLock) IsStale(sourceHash string) bool { + if hl == nil { + return true + } + return hl.SHA256 != sourceHash +} + +// LookupDep searches the dependency list (including transitive deps, +// depth-first) for the given URL and returns the matching entry or nil. +// The returned pointer references the live slice — mutations update the +// underlying Dependencies. Returns nil for a nil receiver. +func (hl *HarnessLock) LookupDep(url string) *DependencyEntry { + if hl == nil { + return nil + } + return lookupInDeps(hl.Dependencies, url, 0) +} + +// Guard against excessively deep or cyclic transitive deps in lock files +// (possible via YAML anchors/aliases or manual editing). +const maxLookupDepth = 32 + +func lookupInDeps(deps []DependencyEntry, url string, depth int) *DependencyEntry { + if depth >= maxLookupDepth { + return nil + } + for i := range deps { + if deps[i].URL == url { + return &deps[i] + } + if found := lookupInDeps(deps[i].TransitiveDeps, url, depth+1); found != nil { + return found + } + } + return nil +} + +// SetHarness adds or replaces the lock entry for the named harness. +// Intended for use by the lock file generator (fullsend lock); callers +// should use Save to persist changes. No-op on a nil receiver. +func (lf *LockFile) SetHarness(name string, hl HarnessLock) { + if lf == nil { + return + } + if lf.Harnesses == nil { + lf.Harnesses = make(map[string]HarnessLock) + } + lf.Harnesses[name] = hl +} + +// HarnessNames returns the sorted list of harness names in the lock file. +func (lf *LockFile) HarnessNames() []string { + if lf == nil || len(lf.Harnesses) == 0 { + return nil + } + names := make([]string, 0, len(lf.Harnesses)) + for name := range lf.Harnesses { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/internal/lock/lock_test.go b/internal/lock/lock_test.go new file mode 100644 index 000000000..1e998beea --- /dev/null +++ b/internal/lock/lock_test.go @@ -0,0 +1,432 @@ +package lock + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testTime = time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) + +func TestLoad_ValidLockFile(t *testing.T) { + content := `version: 1 +generated_at: 2026-06-08T12:00:00Z +harnesses: + code: + source: harness/code.yaml + sha256: abc123def456abc123def456abc123def456abc123def456abc123def456abcd + resolved_at: 2026-06-08T12:00:00Z + dependencies: + - field: agent + url: https://github.com/fullsend-ai/library/agents/code.md + sha256: def456abc123def456abc123def456abc123def456abc123def456abc123defg + fetched_at: 2026-06-08T11:59:55Z +` + path := filepath.Join(t.TempDir(), "lock.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + lf, err := Load(path) + require.NoError(t, err) + require.NotNil(t, lf) + + assert.Equal(t, 1, lf.Version) + assert.Equal(t, testTime, lf.GeneratedAt) + + hl := lf.Lookup("code") + require.NotNil(t, hl) + assert.Equal(t, "harness/code.yaml", hl.Source) + require.Len(t, hl.Dependencies, 1) + assert.Equal(t, "agent", hl.Dependencies[0].Field) + assert.Equal(t, "https://github.com/fullsend-ai/library/agents/code.md", hl.Dependencies[0].URL) +} + +func TestLoad_FileNotFound(t *testing.T) { + lf, err := Load(filepath.Join(t.TempDir(), "nonexistent.yaml")) + require.NoError(t, err) + assert.Nil(t, lf) +} + +func TestLoad_MalformedYAML(t *testing.T) { + path := filepath.Join(t.TempDir(), "lock.yaml") + require.NoError(t, os.WriteFile(path, []byte("{{not yaml"), 0o644)) + + _, err := Load(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "parsing lock file") +} + +func TestLoad_UnsupportedVersion(t *testing.T) { + content := `version: 99 +generated_at: 2026-06-08T12:00:00Z +harnesses: {} +` + path := filepath.Join(t.TempDir(), "lock.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + _, err := Load(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported lock file version 99") +} + +func TestSave_RoundTrip(t *testing.T) { + lf := &LockFile{ + Version: 1, + GeneratedAt: testTime, + Harnesses: map[string]HarnessLock{ + "code": { + Source: "harness/code.yaml", + SHA256: "abc123", + ResolvedAt: testTime, + Dependencies: []DependencyEntry{ + { + Field: "agent", + URL: "https://example.com/agents/code.md", + SHA256: "def456", + FetchedAt: testTime, + }, + { + Field: "skills[0]", + URL: "https://example.com/skills/rust/SKILL.md", + SHA256: "ghi789", + FetchedAt: testTime, + TransitiveDeps: []DependencyEntry{ + { + Field: "skills[dep0]", + URL: "https://example.com/skills/common/SKILL.md", + SHA256: "jkl012", + FetchedAt: testTime, + }, + }, + }, + }, + }, + }, + } + + path := filepath.Join(t.TempDir(), ".fullsend", "lock.yaml") + require.NoError(t, Save(path, lf)) + + loaded, err := Load(path) + require.NoError(t, err) + require.NotNil(t, loaded) + + assert.Equal(t, 1, loaded.Version) + assert.Equal(t, testTime, loaded.GeneratedAt) + + hl := loaded.Lookup("code") + require.NotNil(t, hl) + assert.Equal(t, "harness/code.yaml", hl.Source) + assert.Equal(t, "abc123", hl.SHA256) + require.Len(t, hl.Dependencies, 2) + + assert.Equal(t, "agent", hl.Dependencies[0].Field) + assert.Equal(t, "https://example.com/agents/code.md", hl.Dependencies[0].URL) + + skill := hl.Dependencies[1] + assert.Equal(t, "skills[0]", skill.Field) + require.Len(t, skill.TransitiveDeps, 1) + assert.Equal(t, "https://example.com/skills/common/SKILL.md", skill.TransitiveDeps[0].URL) +} + +func TestSave_DefaultsVersion(t *testing.T) { + lf := &LockFile{ + GeneratedAt: testTime, + Harnesses: map[string]HarnessLock{}, + } + + path := filepath.Join(t.TempDir(), "lock.yaml") + require.NoError(t, Save(path, lf)) + + loaded, err := Load(path) + require.NoError(t, err) + assert.Equal(t, 1, loaded.Version) +} + +func TestSave_Header(t *testing.T) { + lf := &LockFile{ + Version: 1, + GeneratedAt: testTime, + Harnesses: map[string]HarnessLock{}, + } + + path := filepath.Join(t.TempDir(), "lock.yaml") + require.NoError(t, Save(path, lf)) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(data), "# Generated by fullsend lock — DO NOT EDIT") +} + +func TestSave_AtomicWrite(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "lock.yaml") + + lf1 := &LockFile{ + Version: 1, + GeneratedAt: testTime, + Harnesses: map[string]HarnessLock{ + "first": {Source: "harness/first.yaml", SHA256: "aaa", ResolvedAt: testTime}, + }, + } + require.NoError(t, Save(path, lf1)) + + lf2 := &LockFile{ + Version: 1, + GeneratedAt: testTime, + Harnesses: map[string]HarnessLock{ + "second": {Source: "harness/second.yaml", SHA256: "bbb", ResolvedAt: testTime}, + }, + } + require.NoError(t, Save(path, lf2)) + + loaded, err := Load(path) + require.NoError(t, err) + assert.Nil(t, loaded.Lookup("first")) + assert.NotNil(t, loaded.Lookup("second")) +} + +func TestSave_CreatesDirectories(t *testing.T) { + path := filepath.Join(t.TempDir(), "deep", "nested", "lock.yaml") + + lf := &LockFile{ + Version: 1, + GeneratedAt: testTime, + Harnesses: map[string]HarnessLock{}, + } + require.NoError(t, Save(path, lf)) + + _, err := os.Stat(path) + require.NoError(t, err) +} + +func TestLookup_NotFound(t *testing.T) { + lf := &LockFile{ + Version: 1, + Harnesses: map[string]HarnessLock{}, + } + assert.Nil(t, lf.Lookup("nonexistent")) +} + +func TestLookup_NilLockFile(t *testing.T) { + var lf *LockFile + assert.Nil(t, lf.Lookup("anything")) +} + +func TestIsStale_MatchingHash(t *testing.T) { + hl := &HarnessLock{SHA256: "abc123"} + assert.False(t, hl.IsStale("abc123")) +} + +func TestIsStale_DifferentHash(t *testing.T) { + hl := &HarnessLock{SHA256: "abc123"} + assert.True(t, hl.IsStale("def456")) +} + +func TestLookupDep_DirectMatch(t *testing.T) { + hl := &HarnessLock{ + Dependencies: []DependencyEntry{ + {URL: "https://example.com/agents/code.md", SHA256: "aaa"}, + {URL: "https://example.com/skills/rust.md", SHA256: "bbb"}, + }, + } + + dep := hl.LookupDep("https://example.com/skills/rust.md") + require.NotNil(t, dep) + assert.Equal(t, "bbb", dep.SHA256) +} + +func TestLookupDep_TransitiveMatch(t *testing.T) { + hl := &HarnessLock{ + Dependencies: []DependencyEntry{ + { + URL: "https://example.com/skills/rust.md", + SHA256: "aaa", + TransitiveDeps: []DependencyEntry{ + { + URL: "https://example.com/skills/common.md", + SHA256: "bbb", + TransitiveDeps: []DependencyEntry{ + {URL: "https://example.com/policies/sandbox.yaml", SHA256: "ccc"}, + }, + }, + }, + }, + }, + } + + dep := hl.LookupDep("https://example.com/policies/sandbox.yaml") + require.NotNil(t, dep) + assert.Equal(t, "ccc", dep.SHA256) +} + +func TestLookupDep_NotFound(t *testing.T) { + hl := &HarnessLock{ + Dependencies: []DependencyEntry{ + {URL: "https://example.com/agents/code.md", SHA256: "aaa"}, + }, + } + + assert.Nil(t, hl.LookupDep("https://example.com/nonexistent")) +} + +func TestSetHarness(t *testing.T) { + lf := &LockFile{Version: 1, GeneratedAt: testTime} + + lf.SetHarness("code", HarnessLock{ + Source: "harness/code.yaml", + SHA256: "abc", + }) + + hl := lf.Lookup("code") + require.NotNil(t, hl) + assert.Equal(t, "abc", hl.SHA256) +} + +func TestSetHarness_Replaces(t *testing.T) { + lf := &LockFile{ + Version: 1, + GeneratedAt: testTime, + Harnesses: map[string]HarnessLock{ + "code": {Source: "harness/code.yaml", SHA256: "old"}, + }, + } + + lf.SetHarness("code", HarnessLock{ + Source: "harness/code.yaml", + SHA256: "new", + }) + + hl := lf.Lookup("code") + require.NotNil(t, hl) + assert.Equal(t, "new", hl.SHA256) +} + +func TestHarnessNames(t *testing.T) { + lf := &LockFile{ + Version: 1, + Harnesses: map[string]HarnessLock{ + "zebra": {}, + "alpha": {}, + "mid": {}, + }, + } + + names := lf.HarnessNames() + assert.Equal(t, []string{"alpha", "mid", "zebra"}, names) +} + +func TestHarnessNames_Empty(t *testing.T) { + lf := &LockFile{Version: 1} + assert.Nil(t, lf.HarnessNames()) +} + +func TestHarnessNames_NilLockFile(t *testing.T) { + var lf *LockFile + assert.Nil(t, lf.HarnessNames()) +} + +func TestLoad_TransitiveDeps(t *testing.T) { + content := `version: 1 +generated_at: 2026-06-08T12:00:00Z +harnesses: + code: + source: harness/code.yaml + sha256: abc + resolved_at: 2026-06-08T12:00:00Z + dependencies: + - field: skills[0] + url: https://example.com/skills/rust/SKILL.md + sha256: def + fetched_at: 2026-06-08T12:00:00Z + transitive_deps: + - field: skills[dep0] + url: https://example.com/skills/common/SKILL.md + sha256: ghi + fetched_at: 2026-06-08T12:00:00Z +` + path := filepath.Join(t.TempDir(), "lock.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + lf, err := Load(path) + require.NoError(t, err) + + hl := lf.Lookup("code") + require.NotNil(t, hl) + require.Len(t, hl.Dependencies, 1) + require.Len(t, hl.Dependencies[0].TransitiveDeps, 1) + assert.Equal(t, "https://example.com/skills/common/SKILL.md", hl.Dependencies[0].TransitiveDeps[0].URL) +} + +func TestLoad_MultipleHarnesses(t *testing.T) { + content := `version: 1 +generated_at: 2026-06-08T12:00:00Z +harnesses: + code: + source: harness/code.yaml + sha256: aaa + resolved_at: 2026-06-08T12:00:00Z + dependencies: [] + review: + source: harness/review.yaml + sha256: bbb + resolved_at: 2026-06-08T12:00:00Z + dependencies: [] +` + path := filepath.Join(t.TempDir(), "lock.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + lf, err := Load(path) + require.NoError(t, err) + assert.NotNil(t, lf.Lookup("code")) + assert.NotNil(t, lf.Lookup("review")) + assert.Nil(t, lf.Lookup("triage")) +} + +func TestSetHarness_NilLockFile(t *testing.T) { + var lf *LockFile + lf.SetHarness("code", HarnessLock{SHA256: "abc"}) // should not panic +} + +func TestIsStale_NilHarnessLock(t *testing.T) { + var hl *HarnessLock + assert.True(t, hl.IsStale("anything")) +} + +func TestLookupDep_NilHarnessLock(t *testing.T) { + var hl *HarnessLock + assert.Nil(t, hl.LookupDep("https://example.com/anything")) +} + +func TestLookupDep_MaxDepth(t *testing.T) { + target := "https://example.com/deep-target" + leaf := DependencyEntry{URL: target, SHA256: "leaf"} + for i := 0; i < maxLookupDepth+5; i++ { + leaf = DependencyEntry{ + URL: fmt.Sprintf("https://example.com/level-%d", i), + SHA256: fmt.Sprintf("hash-%d", i), + TransitiveDeps: []DependencyEntry{leaf}, + } + } + + hl := &HarnessLock{Dependencies: []DependencyEntry{leaf}} + assert.Nil(t, hl.LookupDep(target), "should return nil for deps beyond maxLookupDepth") +} + +func TestLoad_EmptyHarnesses(t *testing.T) { + content := `version: 1 +generated_at: 2026-06-08T12:00:00Z +harnesses: {} +` + path := filepath.Join(t.TempDir(), "lock.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + lf, err := Load(path) + require.NoError(t, err) + require.NotNil(t, lf) + assert.Empty(t, lf.Harnesses) +}