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
22 changes: 14 additions & 8 deletions docs/ADRs/0038-universal-harness-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
206 changes: 206 additions & 0 deletions internal/lock/lock.go
Original file line number Diff line number Diff line change
@@ -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 <harness>`
// 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.*")
Comment thread
ggallen marked this conversation as resolved.
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)
}
Comment thread
ggallen marked this conversation as resolved.
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)
Comment thread
ggallen marked this conversation as resolved.
}

// 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
}
Loading
Loading