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 ¶
const LastRunFile = "last-run.json"
LastRunFile is the basename of the per-machine run record inside .state/.
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 ¶
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.
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 SaveLastRun ¶ added in v0.11.0
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
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
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
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
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
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
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
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
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
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
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.
type Marketplace ¶
type PluginEntry ¶
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.