Skip to content
edgesync Artifact [40e1f194ea]
Login

Artifact [40e1f194ea]

Artifact 40e1f194eab1d76c52d8960ea84e00de06a48bad05b50e6b0139dd5b0c007b20:


package agent

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"strings"

	libfossil "github.com/danmestas/go-libfossil"
)

// RevID is an opaque commit handle. Treat as opaque; do not parse.
type RevID string

// FileToCommit names a file and its content for inclusion in a commit.
type FileToCommit struct {
	Name    string
	Content []byte
}

// CommitOpts configures Agent.Commit.
type CommitOpts struct {
	Files   []FileToCommit
	Message string
	Author  string // fossil user; required

	// ParentID is the primary parent rid for the new commit. When 0,
	// Agent.Commit resolves the current trunk tip and uses that — the
	// natural "chain onto trunk" behavior matching `fossil ci`. On a
	// fresh repo with no trunk tip, the resolution falls through to 0
	// and the commit is a legitimate orphan root (the first commit).
	// Set explicitly to chain onto a non-trunk branch via Agent.Tip.
	ParentID int64

	// MergeParents lists additional parents for a merge commit. Each
	// contributes a secondary entry on the manifest's P-card.
	MergeParents []int64

	// Tags, when non-empty, are attached to the resulting checkin manifest.
	// Tags are libfossil's primitive — fossil "branches" are themselves
	// propagating "branch=<name>" + "sym-<name>" tag pairs. To land a commit
	// on branch "agent/abc123":
	//
	//   Tags: []TagSpec{
	//       {Name: "branch",            Value: "agent/abc123"},
	//       {Name: "sym-agent/abc123",  Value: "*"},
	//   }
	//
	// Empty preserves current behavior (commit to current branch, no extra tags).
	Tags []TagSpec
}

// TagSpec describes a tag attached at commit time. Mirrors libfossil's
// underlying tag primitive; pass these via CommitOpts.Tags to attach
// arbitrary tags (including the propagating branch-tag pair that creates
// or advances a fossil branch) on the resulting checkin.
type TagSpec struct {
	Name  string
	Value string
}

// SyncOpts configures Agent.SyncTo.
type SyncOpts struct {
	Push        bool
	Pull        bool
	User        string
	Password    string
	ProjectCode string // optional; auto-derived from agent's repo if empty
}

// SyncResult summarizes a sync session.
type SyncResult struct {
	Pushed   int
	Pulled   int
	Forked   bool
	BranchID string // local fork tip's UUID; empty if no fork
}

// Commit commits a set of files to the agent's repo with the given message
// and author. Returns the new manifest UUID as an opaque RevID.
//
// opts.Files reads with `fossil ci` semantics — "files this commit
// changes." Files at the parent that are not in opts.Files are carried
// forward unchanged. libfossil.Repo.Commit handles the merge against
// the parent's F-card set internally as of v0.6.2 (the fix for
// libfossil#30 / EdgeSync#152); the agent layer is a thin pass-through.
//
// When opts.ParentID is 0, the current trunk tip is resolved and used as
// the parent — matching `fossil ci`'s default chain-onto-tip behavior. On
// an empty repo (no trunk tip yet), the resolution returns 0 and the
// commit is a legitimate orphan root.
func (a *Agent) Commit(ctx context.Context, opts CommitOpts) (RevID, error) {
	if opts.Author == "" {
		return "", errors.New("agent: Commit: Author is required")
	}
	parentID, err := a.resolveCommitParent(opts.ParentID)
	if err != nil {
		return "", err
	}
	files := make([]libfossil.FileToCommit, len(opts.Files))
	for i, f := range opts.Files {
		files[i] = libfossil.FileToCommit{Name: f.Name, Content: f.Content}
	}
	var tags []libfossil.TagSpec
	if len(opts.Tags) > 0 {
		tags = make([]libfossil.TagSpec, len(opts.Tags))
		for i, t := range opts.Tags {
			tags[i] = libfossil.TagSpec{Name: t.Name, Value: t.Value}
		}
	}
	_, uuid, err := a.repo.Commit(libfossil.CommitOpts{
		Files:        files,
		Comment:      opts.Message,
		User:         opts.Author,
		ParentID:     parentID,
		MergeParents: opts.MergeParents,
		Tags:         tags,
	})
	if err != nil {
		return "", fmt.Errorf("agent: commit: %w", err)
	}
	return RevID(uuid), nil
}

// resolveCommitParent maps the caller-supplied ParentID to the rid the
// commit should chain onto. Non-zero is used verbatim. Zero auto-resolves
// trunk's tip; on an empty repo with no trunk tip yet, returns 0 (which
// libfossil treats as orphan root — correct first-commit behavior).
func (a *Agent) resolveCommitParent(supplied int64) (int64, error) {
	if supplied != 0 {
		return supplied, nil
	}
	rid, err := a.repo.BranchTip("trunk")
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) || strings.Contains(err.Error(), "no rows in result set") {
			return 0, nil
		}
		return 0, fmt.Errorf("agent: commit: resolve trunk tip: %w", err)
	}
	return rid, nil
}

// Sync runs one sync round against the agent's configured upstream/transport.
// Returns nil result if no transport is configured.
func (a *Agent) Sync(ctx context.Context) (*SyncResult, error) {
	if a.transport == nil {
		return nil, errors.New("agent: Sync: no transport configured")
	}
	res, err := a.repo.Sync(ctx, a.transport, a.buildSyncOpts())
	if err != nil {
		return nil, fmt.Errorf("agent: sync: %w", err)
	}
	return a.toSyncResult(res), nil
}

// SyncTo runs one sync round against an explicit hub URL. The transport is
// constructed internally; callers do not see libfossil.Transport.
func (a *Agent) SyncTo(ctx context.Context, hubURL string, opts SyncOpts) (*SyncResult, error) {
	if hubURL == "" {
		return nil, errors.New("agent: SyncTo: hubURL is required")
	}
	transport := libfossil.NewHTTPTransport(hubURL)
	projectCode := opts.ProjectCode
	if projectCode == "" {
		projectCode = a.projectCode
	}
	res, err := a.repo.Sync(ctx, transport, libfossil.SyncOpts{
		Push:        opts.Push,
		Pull:        opts.Pull,
		ProjectCode: projectCode,
		User:        opts.User,
		Password:    opts.Password,
	})
	if err != nil {
		return nil, fmt.Errorf("agent: syncTo %s: %w", hubURL, err)
	}
	return a.toSyncResult(res), nil
}

// Read returns the contents of path at the current trunk tip.
func (a *Agent) Read(ctx context.Context, path string) ([]byte, error) {
	return a.repo.ReadFileAt("trunk", path)
}

// Files lists the file names at the current trunk tip. An empty repo (no
// checkins on trunk) returns (nil, nil).
func (a *Agent) Files(ctx context.Context) ([]string, error) {
	rid, err := a.repo.BranchTip("trunk")
	if err != nil {
		return nil, fmt.Errorf("agent: files: %w", err)
	}
	if rid == 0 {
		return nil, nil
	}
	entries, err := a.repo.ListFiles(rid)
	if err != nil {
		return nil, fmt.Errorf("agent: files: %w", err)
	}
	names := make([]string, len(entries))
	for i, e := range entries {
		names[i] = e.Name
	}
	return names, nil
}

// Diff returns the unified diff between revA and revB, concatenated across
// all changed files. Either rev may be a branch name, tag, or RevID.
func (a *Agent) Diff(ctx context.Context, revA, revB RevID) ([]byte, error) {
	ridA, err := a.repo.ResolveVersion(string(revA))
	if err != nil {
		return nil, fmt.Errorf("agent: diff: resolve %q: %w", revA, err)
	}
	ridB, err := a.repo.ResolveVersion(string(revB))
	if err != nil {
		return nil, fmt.Errorf("agent: diff: resolve %q: %w", revB, err)
	}
	entries, err := a.repo.Diff(ridA, ridB, "")
	if err != nil {
		return nil, fmt.Errorf("agent: diff: %w", err)
	}
	var out strings.Builder
	for _, e := range entries {
		out.WriteString(e.Unified)
	}
	return []byte(out.String()), nil
}

// Tip returns the manifest UUID at the tip of the named branch. An unknown
// or empty branch (no checkins) returns "" with nil error; an error
// indicates a substrate fault, not an empty branch.
func (a *Agent) Tip(ctx context.Context, branch string) (RevID, error) {
	rid, err := a.repo.BranchTip(branch)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) || strings.Contains(err.Error(), "no rows in result set") {
			return "", nil
		}
		return "", fmt.Errorf("agent: tip %q: %w", branch, err)
	}
	if rid == 0 {
		return "", nil
	}
	uuid, err := a.repo.UUIDFromRID(rid)
	if err != nil {
		return "", fmt.Errorf("agent: tip %q: %w", branch, err)
	}
	return RevID(uuid), nil
}

// ExtractTo populates dir with the files at rev. dir must already exist.
// rev may be a branch name, tag, or RevID returned from Commit. Empty rev
// is a no-op.
func (a *Agent) ExtractTo(ctx context.Context, dir string, rev RevID) error {
	if rev == "" {
		return nil
	}
	rid, err := a.repo.ResolveVersion(string(rev))
	if err != nil {
		return fmt.Errorf("agent: extractTo %q: resolve %q: %w", dir, rev, err)
	}
	co, err := a.repo.CreateCheckout(dir, libfossil.CheckoutCreateOpts{})
	if err != nil {
		return fmt.Errorf("agent: extractTo %q: create checkout: %w", dir, err)
	}
	defer co.Close()
	if err := co.Extract(rid, libfossil.ExtractOpts{}); err != nil {
		return fmt.Errorf("agent: extractTo %q: extract: %w", dir, err)
	}
	return nil
}

// Config reads a fossil config value (e.g. "project-code").
func (a *Agent) Config(key string) (string, error) {
	return a.repo.Config(key)
}

func (a *Agent) toSyncResult(r *libfossil.SyncResult) *SyncResult {
	out := &SyncResult{}
	if r != nil {
		out.Pushed = r.FilesSent
		out.Pulled = r.FilesRecvd
	}
	if forks, err := a.repo.DetectForks(); err == nil && len(forks) > 0 {
		out.Forked = true
		if uuid, uerr := a.repo.UUIDFromRID(forks[0].LocalTip); uerr == nil {
			out.BranchID = uuid
		}
	}
	return out
}

// applySQLiteTuning applies SQLite settings the agent needs for safe
// concurrent operation against its repo. The busy_timeout PRAGMA tells
// SQLite to retry on SQLITE_BUSY for up to 30s before giving up, which
// is what production callers want under contention.
//
// We do NOT cap MaxOpenConns at 1 here, even though libfossil PRAGMAs
// are per-connection. Capping the pool to 1 deadlocks libfossil's clone
// path, which has internal goroutines that all need a DB connection
// (issue #120). If PRAGMA stickiness across pooled connections matters
// later, route it through the modernc driver's per-connection init
// hook rather than a pool cap.
func applySQLiteTuning(r *libfossil.Repo) {
	_, _ = r.DB().Exec(`PRAGMA busy_timeout = 30000`)
}