Help us shape the future of Go! Take the 2026 Go Developer Survey

state

package
v0.17.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 13, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package state persists agentsync's own bookkeeping under ~/.agentsync/.state/: the last-applied hashes and marketplace/plugin pinning in targets.json, and the per-machine run record in last-run.json (see lastrun.go).

Index

Constants

View Source
const LastRunFile = "last-run.json"

LastRunFile is the basename of the per-machine run record inside .state/.

View Source
const SchemaVersion = 2

SchemaVersion is the on-disk format version of targets.json.

1 — files/keys were string maps keyed "agent:scope:project:path[:pointer]",
    a ':'-joined encoding that is NOT injective: ':' is legal in a POSIX
    path, so a project root containing one produced keys that string-prefixed
    a sibling project's (issue #227).
2 — files/keys are keyed by the typed, length-prefixed state.Key (key.go).
    Load migrates 0/1 -> 2 in place; the next Save commits the upgrade.

Variables

View Source
var ErrCorruptLastRun = errors.New("corrupt last-run record")

ErrCorruptLastRun reports a run record that exists but cannot be parsed.

It is distinguished from an I/O error on purpose. A record is a UX marker with no authority: an unparseable one carries no information, so the honest reading is "this machine has shown nothing", NOT "say nothing forever". Treating a parse failure like an I/O failure meant a single truncated file — the classic crash-mid-write artifact, or an empty file after a full disk — suppressed the notice permanently, silently contradicting the documented contract that a corrupt record only means the notice shows again later.

LoadLastRun therefore returns a USABLE zero record alongside this error, so a caller that wants the forgiving behavior can take it and overwrite the file, while one that wants to bail can still check the error.

View Source
var ErrNonUTF8Key = errors.New("state key field is not valid UTF-8")

ErrNonUTF8Key marks a state key that cannot be persisted because one of its fields is not valid UTF-8.

The key ENCODING is byte-safe — ParseKey(k.String()) == k for any bytes at all — but the container is narrower: targets.json is JSON, and encoding/json rewrites every invalid byte in a string to U+FFFD. A key holding a raw 0xFF (Linux paths are byte strings, so $HOME, a project root or a filename may legally contain one) would be written with a length prefix that no longer matches its payload, and the next Load would refuse the WHOLE file — a state that does not converge, because the next apply rebuilds and re-writes the same mangled key. Save therefore fails closed at the moment the key is created, naming the destination path so the user can rename it.

It fires from Save, which every caller reaches AFTER doing its work (apply has already written every destination), so the message also says so: the files landed, only the ownership record did not. Validating at plan time instead was considered and declined — it would reshape the apply pipeline for a failure mode that already self-heals on the next run.

Functions

func Save

func Save(path string, t *Targets) error

Save serializes t to path atomically (iox.AtomicWrite).

func SaveLastRun added in v0.11.0

func SaveLastRun(path string, l *LastRun) error

SaveLastRun writes the run record atomically. Callers MUST treat a failure as non-fatal — a read-only home or a full disk must not fail the user's command over a UX marker.

Types

type FileEntry

type FileEntry struct {
	SHA256    string    `json:"sha256"`
	Mode      uint32    `json:"mode"`
	AppliedAt time.Time `json:"applied_at"`
	SourceID  string    `json:"source_id"` // canonical file that produced this dest
}

FileEntry tracks one fully-managed destination file, under a Key whose Pointer is empty.

type Key added in v0.15.0

type Key struct {
	Agent   string
	Scope   string
	Project string
	Path    string
	Pointer string
}

Key identifies one managed destination in targets.json: a whole file (Pointer == "", stored in Targets.Files) or one JSON-pointer-addressable key inside a shared file (Pointer != "", stored in Targets.Keys).

Project and Path are ${HOME}-relative (paths.HomeRelative) so targets.json is portable across machines whose $HOME differs. Scope is a plain string — adapter.Scope.String(), i.e. "user" or "project" — because internal/state sits below internal/adapter in the package layering and must not import it.

Key is the ONLY way to name a state entry: Targets.Files and Targets.Keys are keyed by it, so building a key with fmt.Sprintf is a compile error rather than a convention someone has to remember. See String for why the encoding is what it is.

func NewFileKey added in v0.15.0

func NewFileKey(userHome, agent, scope, project, dest string) Key

NewFileKey builds the whole-file key for a destination. project and dest are ABSOLUTE paths; both are portabilized against userHome — the user's $HOME (paths.HomeDir), NOT the agentsync home, because destination files live under $HOME. An empty project (user scope) portabilizes to "".

func NewPointerKey added in v0.15.0

func NewPointerKey(userHome, agent, scope, project, dest, pointer string) Key

NewPointerKey builds the key for one JSON pointer inside a shared destination file. Arguments match NewFileKey; pointer is a JSON pointer ("/mcpServers/github") and is stored verbatim.

func ParseKey added in v0.15.0

func ParseKey(s string) (Key, error)

ParseKey decodes a key produced by String. It is strict: a legacy (v1) key, a non-canonical length ("06"), a truncated payload, or trailing bytes are all errors, never a best-effort reading. Load relies on that — a key it cannot decode exactly means the state file was hand-edited or corrupted, and agentsync refuses rather than silently disowning a destination.

func (Key) AbsPath added in v0.15.0

func (k Key) AbsPath(userHome string) string

AbsPath expands k.Path back to an absolute filesystem path. Callers that turn a state entry into a real file operation MUST route through this so they never operate on the literal "${HOME}/..." string.

func (Key) InTree added in v0.15.0

func (k Key) InTree(agent, scope, project string) bool

InTree reports whether k was recorded by agent at scope for the given project tree. project is the PORTABLE (${HOME}-relative) root — "" at user scope — matching what NewFileKey stores, so callers hoist paths.HomeRelative out of their loop. This replaces the v1 "does the key string start with this prefix" test, which could not tell a sibling project apart when a root contained ':'.

func (Key) MarshalText added in v0.15.0

func (k Key) MarshalText() ([]byte, error)

MarshalText lets Key be a JSON object key: encoding/json renders a map[Key]V as an ordinary string-keyed object, sorted by the marshalled text, so targets.json keeps the shape it has always had and stays byte-deterministic.

The ENCODING is byte-safe, but the JSON container is narrower than the encoding: encoding/json rewrites an invalid UTF-8 byte in a string to U+FFFD, which would break the length prefix. Save refuses such a key before it ever reaches this boundary — see ErrNonUTF8Key.

func (Key) String added in v0.15.0

func (k Key) String() string

String encodes k as the targets.json map key.

Fields are LENGTH-PREFIXED rather than joined by a separator — the same technique, for the same reason, as the hook-handler join key in internal/cli. The v1 format "agent:scope:project:path[:pointer]" assumed ':' could not occur inside a field, but ':' is a legal byte in a POSIX path: a project root containing one produced keys that string-prefixed a SIBLING project's keys, and a prefix-scoped prune then deleted the sibling's ownership (issue #227).

Length prefixes make the encoding injective for ANY byte content: decoding reads a decimal length, a ':', then exactly that many bytes, never interpreting the bytes themselves. ParseKey is a total left inverse of String, which is what makes String injective.

func (*Key) UnmarshalText added in v0.15.0

func (k *Key) UnmarshalText(b []byte) error

UnmarshalText is the inverse of MarshalText, so a map[Key]V round-trips through encoding/json (TestKey_IsAJSONObjectKey); a key that does not decode fails the whole unmarshal.

It exists to complete the encoding.TextMarshaler/TextUnmarshaler pair, not to guard Load: nothing in production unmarshals into a map[Key]V. Load decodes targets.json into rawTargets — string-keyed, because a v1 document's keys could not unmarshal into map[Key]V at all — and migrate re-keys it through ParseKey/parseCurrentKey, which is where the fail-closed role check lives.

type KeyEntry

type KeyEntry struct {
	SHA256    string    `json:"sha256"`
	AppliedAt time.Time `json:"applied_at"`
	SourceID  string    `json:"source_id"`
}

KeyEntry tracks one managed JSON-pointer-addressable key inside a shared destination file, under a Key whose Pointer is that JSON pointer.

type LastRun added in v0.11.0

type LastRun struct {
	// Version is the agentsync version that last ran on this machine. Empty on
	// a record written by a build with no version stamped.
	Version string `json:"version"`
	// NoticesSeen holds the ids of the upgrade notices already shown, so a
	// version jump that skips releases still shows each notice exactly once and
	// a re-install never re-shows one.
	NoticesSeen []string `json:"notices_seen,omitempty"`
}

LastRun records what the last agentsync invocation on this machine was, so a release can tell a user once — and only once — what changed under them.

It is deliberately a SEPARATE file from targets.json rather than a field on Targets, for three reasons:

  • targets.json is drift state guarded by SchemaVersion; a UX marker has no business gating on (or bumping) that version.
  • targets.json is written only by the MUTATING commands. A read-only `status` must be able to record that it showed the notice, or the notice repeats forever for a user whose daily loop never applies.
  • a corrupt or unwritable run record must never be able to take the drift state down with it. Callers treat every error here as "skip the notice".

It lives under .state/, which `init` gitignores, so a committed dotfiles repo never carries one machine's run record to another.

func LoadLastRun added in v0.11.0

func LoadLastRun(path string) (*LastRun, error)

LoadLastRun reads the run record at path.

It always returns a non-nil record, so callers never nil-check before calling Seen/MarkSeen. A MISSING file yields the zero record with a nil error — absence is not an error condition, and "has this machine seen notice X?" is correctly "no" for a machine with no record at all. (Distinguishing a fresh install from an upgrade is NOT this function's job: that is the existence of the agentsync home, checked by the caller.)

A record that exists but does not parse yields the zero record wrapped with ErrCorruptLastRun. Any other failure (a real I/O error — .state is a regular file, permissions, a bad mount) returns the zero record and that error; callers skip the notice rather than fail the command.

func (*LastRun) MarkSeen added in v0.11.0

func (l *LastRun) MarkSeen(id string)

MarkSeen adds id to the seen set (idempotent, kept sorted for a stable file).

func (*LastRun) Seen added in v0.11.0

func (l *LastRun) Seen(id string) bool

Seen reports whether the notice id has already been shown on this machine.

type Marketplace

type Marketplace struct {
	URL       string    `json:"url"`
	Ref       string    `json:"ref"`
	HeadSHA   string    `json:"head_sha"`
	FetchedAt time.Time `json:"fetched_at"`
}

type PluginEntry

type PluginEntry struct {
	Version     string `json:"version"`
	ManifestSHA string `json:"manifest_sha"`
	Enabled     bool   `json:"enabled"`
}

type Targets

type Targets struct {
	SchemaVersion int                    `json:"schema_version"`
	Files         map[Key]FileEntry      `json:"files,omitempty"`
	Keys          map[Key]KeyEntry       `json:"keys,omitempty"`
	Marketplaces  map[string]Marketplace `json:"marketplaces,omitempty"`
	Plugins       map[string]PluginEntry `json:"plugins,omitempty"`
}

Targets is the root state document.

Files and Keys are keyed by the typed Key, so a hand-rolled fmt.Sprintf key is a compile error rather than a convention. That is the guarantee — NOT that NewFileKey/NewPointerKey are the only way in: a Key{…} composite literal compiles anywhere (it just skips their paths.HomeRelative, which is how tests plant a key in a specific portable spelling), and ParseKey is exported. Marketplaces and Plugins keep string keys: those are marketplace names and "owner/plugin" ids, a different namespace with no ambiguity problem.

func Load

func Load(path string) (*Targets, error)

Load reads targets.json from path. If the file is missing, returns a fresh state at the current SchemaVersion. Keys are upgraded to the current encoding on the way in (see migrate).

func New

func New() *Targets

New returns a fresh empty Targets at SchemaVersion.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL