project

package
v0.1.0-rc.1 Latest Latest
Warning

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

Go to latest
Published: Aug 10, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package project is the generic projection engine behind pkg/skillsync, pkg/contexts, pkg/agentsync, and pkg/wiring: "project canonical content into per-client locations with lockfile-tracked ownership." The engine owns the unified lockfile (schema, two-tier versioning, migration from the legacy lockfiles, cross-process locking) and the shared vocabulary (states, dry-run actions, hash-scheme prefix, atomic writes, backup pruning).

A kind yields a set of (source, target) projection keys: the contexts adapter records one source ("global") fanned to N clients (plus one source per fragment in fragments mode); the skills adapter records one source per projected skill. Everything the kinds do differently on purpose stays in the kind packages: target tables, channel/strategy resolution, hashing (tree vs content), backup placement, status enumeration mode, rendering, and remediation text. The engine deliberately does not force a uniform Target or a shared sync loop; the frozen CLI contracts are per-kind and the characterization tests in cmd/gridctl arbitrate.

Index

Constants

View Source
const (
	StateInSync        = "in-sync"
	StateStale         = "stale"
	StateDrifted       = "drifted"
	StateTargetMissing = "target-missing"
)

Projection states shared by every kind. Kinds may extend the vocabulary (contexts adds "unsupported" and "never-synced").

View Source
const (
	ActionUpdated            = "updated"
	ActionUnchanged          = "unchanged"
	ActionError              = "error"
	ActionSkippedDrift       = "skipped-drift"
	ActionSkippedUnavailable = "skipped-unavailable"
	ActionWouldUpdate        = "would-update"
)

Actions shared by every kind's sync results. Kind-specific actions (linked, copied, created, ...) stay in the kind packages.

View Source
const ChannelReasonModelPolicy = "model-policy"

ChannelReasonModelPolicy is the Entry.ChannelReason value marking a projection whose installed bytes carry a stack model preference rewrite. Defined once in the engine so the skill and agent kinds can never drift apart on the string.

View Source
const HashScheme = "sha256:"

HashScheme prefixes every stored hash so a future scheme change never presents as false drift (the pkg/pins lesson).

View Source
const LockVersion = 1

LockVersion is the breaking-change tier of the lockfile version: readers reject a newer version with ErrNewerLockVersion instead of silently clobbering state written by a newer gridctl.

View Source
const MaxBackups = 3

MaxBackups is the shared keep-newest retention for projection backups, whatever their per-kind placement policy.

Variables

View Source
var ErrNewerLockVersion = errors.New("project lockfile was written by a newer gridctl version")

ErrNewerLockVersion signals a lockfile written by a newer gridctl. Callers must never paper over it: acting on state a newer version wrote risks silent data loss.

View Source
var ErrPathConflict = errors.New("destination path is already owned by another projection")

ErrPathConflict signals two projections claiming the same destination path. The unified lockfile exists to enforce the opposite invariant: one destination has exactly one owner.

Functions

func AtomicWriteFile

func AtomicWriteFile(path string, data []byte) error

AtomicWriteFile writes data via a uniquely named temp file + rename in the target dir. Unique names keep concurrent writers from clobbering each other's in-flight temp file. Absorbed from the byte-identical copies in pkg/contexts and pkg/skillsync.

func StaleBackups

func StaleBackups(backups []string, keep int) []string

StaleBackups returns the entries of a timestamped backup set beyond the newest keep, oldest first, for the caller to delete. Lexicographic order is chronological because backup names lead with a zero-padded "20060102-150405" timestamp. Placement and removal policy stay with the kind: contexts removes sibling files, skillsync removes out-of-tree directories, and both treat deletion as best-effort.

Types

type Entry

type Entry struct {
	Kind   Kind   `yaml:"kind"`
	Client string `yaml:"client"`
	// Source names what is projected: the skill name for KindSkill, the
	// scope ("global") for KindContext.
	Source string `yaml:"source"`
	// Path is the absolute destination path gridctl wrote or created.
	Path string `yaml:"path"`

	// KindSkill attributes.
	Channel          string `yaml:"channel,omitempty"`
	CreatedByGridctl bool   `yaml:"created_by_gridctl,omitempty"`
	TreeHash         string `yaml:"tree_hash,omitempty"`
	// ChannelReason records why the CHANNEL diverges from what the user
	// or target table chose: ChannelReasonModelPolicy marks a skill
	// projection forced off symlink because its bytes carry a policy
	// rewrite. Empty when the channel is the user's own choice (a --copy
	// the policy merely rewrote keeps its empty reason, so the copy
	// stays sticky when the policy goes away). Absent in pre-existing
	// lockfiles, which migrate-on-read as empty.
	ChannelReason string `yaml:"channel_reason,omitempty"`
	// ModelValue records the model preference a policy rewrite wrote
	// into the projected bytes; non-empty is the "bytes are rewritten"
	// marker the preserve rule and adopt key on, and the value itself
	// lets adopt distinguish the policy's write from a deliberate user
	// edit of the same key. Empty for pass-through projections. Skill
	// and agent kinds both use it.
	ModelValue string `yaml:"model_value,omitempty"`

	// KindContext attributes.
	Strategy      string `yaml:"strategy,omitempty"`
	InstalledHash string `yaml:"installed_hash,omitempty"`
	CanonicalHash string `yaml:"canonical_hash,omitempty"`
	CreatedFile   bool   `yaml:"created_file,omitempty"`
	// InputHashes records, for a compiled context target, each input
	// fragment's canonical hash at sync time (fragment name -> hash), so
	// staleness can name which fragment moved instead of only "the
	// composite changed". Absent outside fragments mode.
	InputHashes map[string]string `yaml:"input_hashes,omitempty"`

	// KindWiring attributes. Path is the composite "<config path>#<entry
	// name>" (one config file legitimately holds several owned entries, and
	// the one-owner invariant keys on the full Path); ConfigPath is the real
	// file path so no consumer parses the composite. Hashes is the short
	// history of canonical value hashes gridctl wrote, newest last, so a
	// shape change by a newer gridctl never reads as user drift.
	ConfigPath string   `yaml:"config_path,omitempty"`
	Group      string   `yaml:"group,omitempty"`
	ClientID   string   `yaml:"client_id,omitempty"`
	Hashes     []string `yaml:"hashes,omitempty"`

	// Pack tags the projection with the pack that applied it (empty =
	// not pack-managed). Any kind may carry it; `gridctl pack` uses it
	// for scoped status and cascade removal.
	Pack string `yaml:"pack,omitempty"`

	SyncedAt time.Time `yaml:"synced_at"`

	Extra map[string]any `yaml:",inline"`
}

Entry is one recorded (kind, source, client) projection. The primary key is (client, path): one destination path has exactly one owner. The kind-specific attribute fields form a union across the two kinds; the engine stores them but never interprets them, and each kind's adapter reads only its own. Extra preserves fields this binary does not understand, so a revision bump by a newer gridctl survives a rewrite by this one (Article XVII).

type Kind

type Kind string

Kind identifies a projection tenant.

const (
	// KindSkill projects registry skill directories into client skill
	// dirs (pkg/skillsync).
	KindSkill Kind = "skill"
	// KindContext projects the canonical global context file into client
	// context locations (pkg/contexts).
	KindContext Kind = "context"
	// KindAgent projects imported agent definitions into client agent
	// directories as single files (pkg/agentsync).
	KindAgent Kind = "agent"
	// KindWiring records ownership of gateway entries merged into client
	// MCP configs (pkg/wiring): key-level ownership inside files gridctl
	// does not otherwise own, per Article XVI.
	KindWiring Kind = "wiring"
	// KindContextFragment projects one context rule fragment as its own
	// file into a client rules directory (pkg/contexts fragments mode).
	// A separate kind from KindContext on purpose: contexts flushes its
	// per-client entries with ReplaceKind, and an older gridctl that only
	// knows KindContext must never be able to drop or clobber fragment
	// records it cannot represent.
	KindContextFragment Kind = "context-fragment"
)

type Lock

type Lock struct {
	// contains filtered or unexported fields
}

Lock is an in-memory view of the lockfile, handed to Load and Mutate callers. Save is only valid inside Mutate.

func (*Lock) Entries

func (l *Lock) Entries(kind Kind) []*Entry

Entries returns the entries of one kind in deterministic (source, client) order.

func (*Lock) Get

func (l *Lock) Get(kind Kind, client, source string) *Entry

Get returns the entry for (kind, client, source), or nil.

func (*Lock) Remove

func (l *Lock) Remove(kind Kind, client, source string)

Remove deletes the record for (kind, client, source). Removal is an explicit engine-driven delete under the cross-process lock, never an inference from absence.

func (*Lock) ReplaceKind

func (l *Lock) ReplaceKind(kind Kind, entries []*Entry) error

ReplaceKind makes entries the complete recorded set for one kind: each entry is Set, and records of that kind absent from entries are removed as explicit engine-driven deletes. Entries of other kinds and unknown file-level fields ride along untouched. This is how a kind view flushes back into the lock.

func (*Lock) Save

func (l *Lock) Save() error

Save persists the lock atomically. Kind managers call it after every recorded mutation (skillsync's persistIfRecorded crash-safety property: a crash mid-pass must never leave artifacts on disk the lockfile does not own).

func (*Lock) Set

func (l *Lock) Set(e *Entry) error

Set records an entry, replacing any previous record for the same (kind, client, source) and enforcing the one-owner invariant: a path already owned by a different projection is refused, never stolen. When e carries no Extra, the previous record's unknown fields are carried forward, so re-recording an entry by an older binary never strips fields a newer revision wrote (Article XVII); pass an empty non-nil map to deliberately clear them.

type LockFile

type LockFile struct {
	Version     int      `yaml:"version"`
	Revision    int      `yaml:"revision"`
	Projections []*Entry `yaml:"projections"`

	Extra map[string]any `yaml:",inline"`
}

LockFile is the on-disk shape of the unified projection lockfile at ~/.gridctl/project.lock.yaml. Absence of an entry means "unknown, do not touch," never "remove": removal of a projection happens only as an explicit engine-driven delete under the cross-process lock (Article XVI).

func ReadLockFile

func ReadLockFile(path string) (*LockFile, error)

ReadLockFile loads the unified lockfile from path. A missing file is the normal nothing-projected state and yields an empty lock.

type LockfileState

type LockfileState struct {
	// UnifiedPath is where the unified lockfile lives (or will live).
	UnifiedPath string
	// Unified reports whether the unified lockfile exists.
	Unified bool
	// LegacySkill and LegacyContext report live (version 1) legacy
	// lockfiles awaiting migration.
	LegacySkill   bool
	LegacyContext bool
	// Tombstones lists legacy paths already tombstoned by a migration.
	Tombstones []string
	// BackupRoot is where migration backups are kept.
	BackupRoot string
}

LockfileState describes which projection lockfiles exist on disk, for surfaces like `gridctl doctor` that report migration state without loading entries.

func InspectLockfiles

func InspectLockfiles(home string) LockfileState

InspectLockfiles reports the on-disk projection lockfile state under home. Read-only; unreadable or malformed legacy files are reported as live so the caller surfaces them rather than ignoring them.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store owns the unified projection lockfile under <home>/.gridctl and the cross-process lock guarding it. All paths resolve against home, so tests point it at a temp dir. Two Store instances over the same home (or two processes) serialize on the flock; in-process higher-level serialization stays with the kind managers' mutexes.

func NewStore

func NewStore(home string) *Store

NewStore builds a Store rooted at an explicit home directory.

func (*Store) Load

func (s *Store) Load(ctx context.Context) (*Lock, error)

Load returns a read-only view of the projection state without taking the cross-process lock (the lockfile is written atomically, so lock-free reads see a consistent file). Before migration, the two legacy lockfiles are merged in memory; nothing is written.

func (*Store) Mutate

func (s *Store) Mutate(ctx context.Context, dryRun bool, fn func(l *Lock) error) error

Mutate runs fn with a writable view while holding the cross-process lock. On the first mutating operation after an upgrade the legacy lockfiles migrate to the unified file (backups, then tombstones). Dry-run passes take no lock at all: they read the same merged view (the lockfile is written atomically, so lock-free reads are consistent), skip the on-disk migration, refuse Save, and therefore can neither write anything nor fail on lock contention.

func (*Store) Path

func (s *Store) Path() string

Path returns the unified lockfile path (<home>/.gridctl/project.lock.yaml). Its ".flock" sibling and the migration backups live next to it, outside the watched registry tree and outside every client-scanned directory.

Jump to

Keyboard shortcuts

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