cache

package
v0.4.1 Latest Latest
Warning

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

Go to latest
Published: Sep 3, 2026 License: GPL-3.0 Imports: 47 Imported by: 0

Documentation

Overview

Package cache implements magus's content-addressed build cache. Layout: cas/ (blobs) + manifests/ + logs/. The store is local; an optional pluggable RemoteBackend (see remote.go) lets a miss restore from, and a build publish to, a shared store.

Index

Examples

Constants

View Source
const (
	HintKeyRerun  = "rerun"
	HintKeyFocus  = "focus"
	HintKeyCopy   = "copy"
	HintKeyOutput = "output"
	HintKeyDone   = "done"
)

The failure prompt's instruction row: the text, and the click keys that name each action.

It lives beside the band rather than in the command that dispatches it: the failure list and the row saying what you can do with one are a single presentation, drawn into adjacent leases of the same zone. The command owns the VERBS; this owns what a reader is told and what a click resolves to.

Keeping them together also keeps the docs honest - a renderer that cannot import an unexported const hand-copies the strings, and the drift gate then compares that copy against itself while the terminal says something else.

View Source
const (
	// MinRunsForYield is how many executions a target needs before a zero hit rate is
	// evidence rather than noise.
	MinRunsForYield = 8
	// MinAvgMsForYield is the average execution time below which a never-cached target
	// is not worth reporting.
	MinAvgMsForYield = 2_000
)

A target that never replays is the most expensive kind of misconfiguration, because nothing about it looks wrong: every run passes, the cache reports no error, and the only symptom is that the build is slower than it should be. It is invisible per-run and obvious only in aggregate - which is why it is read back out of the invocation journals rather than detected live.

The thresholds keep this quiet until acting on it is worthwhile. A handful of runs proves nothing (a target legitimately misses while you edit its inputs), and a target that misses in milliseconds costs nothing to rerun.

View Source
const DefaultMaxRunBytes int64 = 2 << 30

DefaultMaxRunBytes bounds the runs dir on disk, which the count above cannot: one journal holds every output line of its run, so keeping N of them keeps N unbounded files.

2 GiB because it has to be some number and this one is big enough that an ordinary week of runs never reaches it, while a runaway log-emitting build cannot quietly eat a disk. RunsStat reports the current footprint, so the honest way to retune it is to look rather than to guess again.

View Source
const DefaultMaxRuns = 500

DefaultMaxRuns bounds how many invocation journals the runs dir retains, so a long-lived daemon's run-log dir stays bounded. Coarser than the per-key output cap (defaultOutputKeepLast): one file per invocation, kept newest-first by modtime. The RotateLogs job trims to this.

View Source
const ExitCodeMachineBusy = 75

ExitCodeMachineBusy is the process status a machine-budget refusal asks for: 75, EX_TEMPFAIL. It lives here rather than beside the CLI's other exit codes because the error is built here and has to state its own code - the daemon runs an adopted step in its own process and reads the code off the error, having lost the Go type.

The workspace lock's own contention error picks the same number for the same reason (lockContendedExit). Two independent decisions that agree, not one shared setting: coupling them would make a change to either silently move the other.

View Source
const KeyVersion = 7

KeyVersion is bumped when the set of hashed fields changes, forcing a full rebuild. It is the ONLY version in this system: the output descriptor carries no schema of its own, because a descriptor is stored under a key that already contains this number, and magus reads no descriptor it did not write.

7 drops that descriptor schema and evicts every entry predating the change, so no descriptor missing the reproduce fields (spell, args, vcs) survives to be read as though those were empty rather than unrecorded.

6 added the spell: line for explicit spell::op runs - emitted only when the filter is set, but a pre-6 store could already hold entries an op-form run recorded under the plain target's key, and only a bump evicts that poison class.

View Source
const RefPrefix = "out"

RefPrefix begins every target-output reference id ("out1a2b3c"). It is the provenance tag in the shared ref namespace - "out" for a target OUTPUT, alongside "mcp" for an MCP call payload (internal/trail) - so a ref names where it came from at a glance. No delimiter, matching the other prefixes. LooksLikeRef validates the shape of the argument to `magus query output <ref>`; it is not a router (retrieval is an explicit subcommand), so a shape-collision with a search term is impossible.

View Source
const RunsDir = "runs"

RunsDir is the cache subdir holding one union event log per invocation (<cacheDir>/runs/<inv>.jsonl). Shared by the writer (magus.BeginInvocation) and the reader (InvocationByID) so the two never drift on the path.

Variables

View Source
var ErrArtifactMissing = errors.New("cache: artifact content not in store")

ErrArtifactMissing reports that a version's bytes are not in the store.

Named for the observation, not the cause: eviction is the usual reason, but a hand-cleared store or an entry that never stored its blob reach here too. Callers must not treat it as "no differences" - an empty diff reads as "unchanged", which is the most misleading answer available.

Functions

func ClampConcurrency added in v0.4.0

func ClampConcurrency(requested int) (n int, clamped bool)

ClampConcurrency caps a configured concurrency at what the machine can actually run, reporting whether it had to.

A configured value was previously taken at face value, so `concurrency: 32` in a magus.yaml written on a big machine ran 32 parallel steps on a laptop with 10 cores. That does not fail - it thrashes, and every target simply takes longer, which is the failure mode nothing ever gets attributed to. The number also outlives the machine it was chosen on: it travels in the repo, and the person it hurts is whoever has the smallest box.

The caller announces the clamp rather than applying it silently. A run that is quietly narrower than requested is the same invisible-cause problem in the other direction.

func ContextWithLimiter

func ContextWithLimiter(ctx context.Context, lim *Limiter) context.Context

ContextWithLimiter stores lim in ctx for nested callers (e.g. magus.needs) to yield their slot.

func ContextWithTracer

func ContextWithTracer(ctx context.Context, t Tracer) context.Context

ContextWithTracer returns a copy of ctx carrying t. A nil t is stored as a no-op so callers can wire it through unconditionally.

func DefaultConcurrency

func DefaultConcurrency() int

DefaultConcurrency returns the concurrency cap: MAGUS_CONCURRENCY env var, then 4 on GitHub-hosted runners (RUNNER_ENVIRONMENT != self-hosted), then min(NumCPU, 8). A hosted runner reports its host's CPU count while giving the job a small slice of it, so NumCPU over-subscribes badly there.

This is the one place magus names a CI provider outside a spell, and it is startup ordering that forces it: the limiter is built before the magusfile is evaluated (see cmd/magus/main.go), so the CI provider spell that would otherwise answer this is not loaded yet. Everything else provider-specific lives in a spell; see internal/ci/annotate.

func DepKey

func DepKey(project, target string) string

DepKey returns the scheduling identity of a (project, target) node. Empty target → bare project path (backward-compatible with DependsOn-as-path callers).

func DigestEnvValues added in v0.4.0

func DigestEnvValues(inputs []string) []string

DigestEnvValues returns lines with every env value replaced by a short digest ("env:NAME=abc" -> "env:NAME=sha256:<12hex>"). Env values are the one key-input class that routinely carries material a user would not publish (tokens ride env vars whether or not a secret provider registered them), so the raw value never leaves hashStep: the store persists DIGESTED lines, and every comparison surface digests its live lines the same way - which also keeps the two sides byte-comparable (a registry-based redaction would fire on one machine and not the other, turning every secret-bearing env line into a false diff). The digest still changes when the value changes, so the diff names the exact variable without exposing it.

func ExpandSources added in v0.4.0

func ExpandSources(globs []string, root string, outputGlobs, ignoreDirs []string) ([]string, error)

ExpandSources is expandSources exported for a caller needing the SAME source walk the cache key is built from - today a spell op's Sources placeholder. Reusing this walk rather than a second one is what makes that op inherit root's declared ignore dirs instead of drifting from the set the key was built from.

Returns root-relative paths only, sorted: a caller building argv for a subprocess that runs IN root wants paths relative to it.

func FailureHint added in v0.4.0

func FailureHint() []tty.Line

FailureHint composes the instruction row.

The way out is aligned RIGHT so it is the last thing clipped rather than the first: as one string this row was 86 columns, and an 80-column terminal cut it to exactly "[esc] do".

func FailureHintPlain added in v0.4.0

func FailureHintPlain() string

FailureHintPlain is the same instruction as one line, for a terminal with no room to pin it. Printed rather than dropped: every other thing the prompt draws is a view, but this is the only statement of how to leave.

func FormatMB added in v0.4.0

func FormatMB(mb int) string

FormatMB renders a declared memory figure. Base-1024 with binary suffixes and a space, matching fmtBytesLog rather than inventing a second spelling of the same quantity in one binary. Exported so a refusal, a wait notice, and `magus status` all say the same figure the same way.

func IsMintedRef added in v0.2.0

func IsMintedRef(s string) bool

IsMintedRef reports whether s is a fully-minted reference id: the "out" prefix followed by exactly refHexLen hex digits (a portable step ref) or attemptHexLen digits (an attempt id, and every pre-portable ref). Unlike LooksLikeRef, which accepts any-length hex prefix so `magus query output` can take a git-style short ref, this rejects prefixes. Use it when scanning free text for a chainable ref, so short English words whose tail is coincidentally hex ("outed", "outface") are not mistaken for a ref.

func LooksLikeInvocationID added in v0.4.0

func LooksLikeInvocationID(s string) bool

LooksLikeInvocationID reports whether s is shaped like an invocation id. The counterpart to LooksLikeRef, and deliberately only a recognizer: retrieval stays an explicit subcommand.

func LooksLikeRef added in v0.2.0

func LooksLikeRef(s string) bool

LooksLikeRef reports whether s is shaped like a target-output reference id (or a hex prefix of one). It is the `magus query` router discriminator: a match routes to output retrieval, while a real free-text query like "refactor" (non-hex tail) falls through to the graph grammar.

func MachineCeiling added in v0.4.0

func MachineCeiling() int

MachineCeiling is the most concurrent build steps this machine should ever run: one per CPU. It is a CEILING, not a default - DefaultConcurrency picks a smaller, gentler number when nothing is configured, and this only ever caps a number someone asked for.

func NewContext added in v0.4.0

func NewContext(ctx context.Context, c *Cache) context.Context

NewContext stores c in ctx for magusfile bindings (e.g. magus.bust_cache).

func PoolGauge added in v0.4.0

func PoolGauge(running, capacity int) string

PoolGauge renders the pool as filled and empty slots, or "" when there are too many slots to draw honestly. PoolGauge is exported for the documentation renderer, which draws the same gauge a run draws rather than a copy of what it looks like.

func PortableRef added in v0.4.0

func PortableRef(cacheKey string) string

PortableRef derives the user-facing reference id from the cache key alone: RefPrefix + the key's first refHexLen hex digits. Same inputs -> same key -> same ref on every machine, so an inspect line from CI or a teammate's terminal resolves locally, and two machines printing DIFFERENT refs for one target proves their inputs differ. The ref names the STEP (the outputs/<cacheKey>/ directory); execution-level identity lives a level down in attempt ids. Exported so the CLI can predict the ref a computed-but-not-yet-run key would print.

func RedactKeyInputs added in v0.4.0

func RedactKeyInputs(ctx context.Context, lines []string) []string

RedactKeyInputs replaces every value the run's secret resolver has registered with its mask, one line at a time. It is the second net behind DigestEnvValues: env values are digested by construction, but a registered credential can ride a non-env class too - an `arg:` line carrying `--token=<value>`, say - and nothing else strips it.

BOTH sides of a comparison must pass through it. The store redacts at write, so a live line that skipped this differs from its redacted stored twin on every run: a phantom diff no edit can settle.

Per line rather than over a joined blob, because a key input may itself contain a newline; splitting a redacted join back apart would resegment the set. Always returns a fresh slice of the same length, holding the lines verbatim when nothing is registered.

func RegisterRemoteBackendOpener

func RegisterRemoteBackendOpener(fn func(ctx context.Context, selector string) (RemoteBackend, error))

RegisterRemoteBackendOpener installs the opener that OpenRemoteBackend delegates to. It is meant to be called once, from a backend package's init; a second call panics rather than silently shadowing the first.

func ResolveConcurrency added in v0.4.0

func ResolveConcurrency(configured int) int

ResolveConcurrency returns the width a run actually gets from a configured value: the default when nothing is configured, clamped to what the machine can run.

It is the resolution the run path applies - cmd/magus/main.go when it builds the bootstrap limiter, Magus.limiter per workspace - so a reporter can answer "how many slots does this box give a build" without re-deriving it. Those two sites keep their own copy because they announce the clamp as it takes effect; this one only reports.

func SlotHeld

func SlotHeld(ctx context.Context) bool

SlotHeld reports whether ctx is marked as holding at least one limiter slot.

func SlotsHeld

func SlotsHeld(ctx context.Context) int

SlotsHeld reports how many limiter slots ctx is marked as holding (0 if none).

func SlowExecutions added in v0.4.0

func SlowExecutions(journalPath string, minMs int64) map[string]bool

SlowExecutions returns the "project\x00target" keys that actually EXECUTED in one invocation journal and took at least minMs. Cached replays are excluded: a replay proves the cache works for that target, which is the opposite of the finding.

This is the gate that makes a per-run cache-yield check affordable. Scanning the workspace's whole journal history on every run would be absurd; scanning it after a target just spent a minute executing is free by comparison, and a fast run reads only its own journal and stops.

func WithRemoteStats added in v0.4.0

func WithRemoteStats(ctx context.Context) context.Context

WithRemoteStats installs run-scoped remote-cache counters on ctx.

func WithSlotHeld

func WithSlotHeld(ctx context.Context) context.Context

WithSlotHeld marks ctx as holding a single limiter slot.

func WithSlotsHeld

func WithSlotsHeld(ctx context.Context, n int) context.Context

WithSlotsHeld marks ctx as holding n limiter slots. A hand-back site (Yield, os.with_slots, archive.*) must release exactly n so it gives back its whole hold, not one slot: a weighted step holds more than one, and releasing only one would leave it pinning slots it then blocks trying to re-reserve.

func WithoutSlotHeld

func WithoutSlotHeld(ctx context.Context) context.Context

WithoutSlotHeld clears the slot-held marker for child work dispatched without a slot.

Types

type AmbiguousRefError added in v0.2.0

type AmbiguousRefError struct {
	Prefix     string
	Candidates []string
}

AmbiguousRefError is returned by output lookup when a ref (or prefix) matches more than one stored identity. Candidates are pasteable-back refs, sorted: a step candidate renders as its portable ref (lengthened past refHexLen if two keys collide), an attempt candidate as its full file stem - so the CLI can list them for the user to disambiguate (git-style).

func (*AmbiguousRefError) Error added in v0.2.0

func (e *AmbiguousRefError) Error() string

type ArtifactVersion added in v0.4.0

type ArtifactVersion struct {
	Output    OutputRecord
	Target    string    // the target whose run produced it
	CreatedAt time.Time // when that entry was written
	EntryHash string    // the cache key; OutputStore.StepRef maps it to an output ref
}

ArtifactVersion is one cached version of a declared output: the record a run snapshotted, plus which run it was.

Output is EMBEDDED rather than restated. An earlier version copied four of its five fields and dropped Symlink, which was a live bug: a symlink record carries no Blob, so every symlink version hashed alike, collapsed to one row, and resolved to the cas/ directory itself.

func (ArtifactVersion) ShortBlob added in v0.4.0

func (v ArtifactVersion) ShortBlob() string

ShortBlob abbreviates the content hash for display. Empty for a symlink record, which has no content of its own.

type CQE

type CQE struct {
	UserData uint64 // tag attached at submission
	Result   int32  // bytes read on success, negated errno on failure
}

CQE is a copy-by-value io_uring completion event.

type Cache

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

Cache is an on-disk content-addressed build cache handle.

func FromContext added in v0.4.0

func FromContext(ctx context.Context) *Cache

FromContext retrieves the Cache stored by NewContext, or nil.

func Open

func Open(ctx context.Context, dir string, opts ...Option) (*Cache, error)

Open returns a Cache rooted at dir (created on demand). MAGUS_CACHE_WRITE_ENABLED=false opens read-only (replays hits, never writes). Logger respects MAGUS_LOG_FORMAT/LEVEL.

func (*Cache) Collapsing

func (c *Cache) Collapsing() bool

Collapsing reports whether the cache is withholding per-project subprocess output until failure (collapse-on-success). Callers use it to decide whether to attach a stage observer that prints progress lines for the otherwise-hidden work.

func (*Cache) Delete added in v0.4.0

func (c *Cache) Delete(ctx context.Context, projectPaths ...string) error

Delete removes cached manifests for the given project paths (all if none given). Orphaned blobs are collected after manifests are deleted.

func (*Cache) Dir added in v0.2.0

func (c *Cache) Dir() string

Dir returns the cache's root directory. Subsystems that write sibling artifacts next to the cache (the symbol index a `scip` op produces) resolve their paths under it, so those artifacts stay out of the working tree.

func (*Cache) DiskBytes added in v0.2.0

func (c *Cache) DiskBytes() int64

DiskBytes returns the approximate on-disk size of the cache: the summed file sizes of cas/ (the blob store) and manifests/ (the index), stat-only (no file reads, unlike scanManifests). The result is memoized for diskSizeTTL so it is cheap to poll for a live dashboard. Returns 0 for a dirless cache.

func (*Cache) Evict added in v0.4.0

func (c *Cache) Evict(ctx context.Context) error

Evict removes LRU entries down to the size cap, then collects unreferenced CAS blobs.

func (*Cache) Export

func (c *Cache) Export(ctx context.Context, w io.Writer) error

Export writes the cache as a gzip-compressed tar archive (paths relative to the cache root, so Import can extract into any target directory).

func (*Cache) GetArtifact added in v0.4.0

func (c *Cache) GetArtifact(ctx context.Context, v ArtifactVersion, dst string) error

GetArtifact writes v to dst, creating parent directories.

Content is cloned from the store rather than copied where the filesystem supports reflink (APFS, btrfs, XFS), so comparing a large artifact costs almost nothing.

func (*Cache) Import

func (c *Cache) Import(ctx context.Context, r io.Reader) error

Import extracts a gzip-compressed tar archive produced by Export into the cache directory. Existing files are overwritten; entries older than what is on disk are skipped.

func (*Cache) IsCached added in v0.4.0

func (c *Cache) IsCached(ctx context.Context, s Step) (bool, error)

IsCached reports whether step s would replay from cache rather than run: its inputs hash to a manifest already present locally. It is Run's hash-and-lookup without the execution or the remote fetch - a read-only "is this up to date?" probe (e.g. status reporting whether a project's symbol index reflects current sources). A missing manifest is "not fresh", not an error; only a hashing failure returns one.

func (*Cache) LastEntry

func (c *Cache) LastEntry(projectPath string) (*Manifest, string, error)

LastEntry returns the manifest and log-file path of the most recently created cache entry for projectPath. Returns an error wrapping fs.ErrNotExist when no entries exist for the project.

func (*Cache) LastEntryForTarget

func (c *Cache) LastEntryForTarget(projectPath, target string) (*Manifest, string, error)

LastEntryForTarget returns the manifest and log-file path of the most recently created cache entry for projectPath with the given target. Returns an error wrapping fs.ErrNotExist when no matching entries exist.

func (*Cache) LastRecordedRun added in v0.4.0

func (c *Cache) LastRecordedRun(projectPath, target string) (RecordedRun, error)

LastRecordedRun returns the most recent entry recorded for target in projectPath, the comparison peer for "why would a run here MISS": its key inputs are what FirstKeyInputChange pairs the live ones against. Charms are deliberately not part of the lookup - an entry recorded under different charms is still the last thing this target stored here, and the charm lines then show up as the difference that explains the miss.

Wraps fs.ErrNotExist when nothing is recorded for that target.

func (*Cache) ListArtifacts added in v0.4.0

func (c *Cache) ListArtifacts(ctx context.Context, projectPath, wsPath string) ([]ArtifactVersion, error)

ListArtifacts returns every cached version of wsPath, newest first.

wsPath is WORKSPACE-relative, matching how snapshot recorded it and what TargetArtifact.Path carries; it is not relative to projectPath.

Consecutive versions with identical content collapse to one, keeping the earliest of each run: a target that ran twenty times producing the same bytes changed the artifact once, and the question is when content first appeared.

func (*Cache) LogBase added in v0.4.0

func (c *Cache) LogBase(ctx context.Context, base, vcs string)

LogBase emits what a run's affected set was compared against, beside the projects and charms headers, because it is the third input that decides what runs: the same command against a different base is a different build.

base already reads "git diff vs origin/main" - the VCS name in front of the ref, rather than a git:// URI, because no such scheme is standard across git, Mercurial and jj and inventing one would put a magus-only string where a reader expects a ref they can paste straight back into their own VCS. vcs is accepted for a caller that has the two apart.

func (*Cache) LogCache added in v0.4.0

func (c *Cache) LogCache(ctx context.Context)

LogCache emits the cache header: which tiers this run can reach and whether it may write to them.

Always printed, including the boring case, because the value is in never having to wonder. "Why was nothing cached" and "am I even talking to the remote" were previously answerable only by reading config and env, and the answer differs per event - a pull request reads the shared cache but must not publish to it, which is invisible unless something says so.

It names the backend rather than probing it. Active() is a spell op on the real implementation - arbitrary Buzz, with the whole host surface - and calling it here put that on the path before the first line of output, where a slow probe stalls the run with nothing on screen to explain the pause. Presence and name are known without asking; whether the backend engages is the run's business, not the header's.

func (*Cache) LogCharms added in v0.2.0

func (c *Cache) LogCharms(ctx context.Context, charms string)

LogCharms emits the active-charm header (the charms mixed into this run, e.g. the magus.yaml default_charms like `rw`) so the reader sees up front what state the run executes under - and can tell at a glance whether a default charm actually took effect. Routed through the cache logger like LogScope so every format receives it.

func (*Cache) LogDry

func (c *Cache) LogDry(ctx context.Context, project, label, target string)

LogDry emits a per-target dry-run line through the cache logger, in place of the executed pass/fail line. project is the workspace-relative path, carried so the line can print the same repro command an executed one does.

func (*Cache) LogDryBanner

func (c *Cache) LogDryBanner(ctx context.Context)

LogDryBanner emits the one-time dry-run banner through the cache logger.

func (*Cache) LogDrySummary added in v0.4.0

func (c *Cache) LogDrySummary(ctx context.Context, planned int, elapsed time.Duration)

LogDrySummary emits the end-of-run footer for a dry run: the same cache.summary event a real run ends with, marked dry and carrying what WOULD have run.

It is the same event on purpose. A dry run previously just stopped after its last [dry] line, so the one shape a reader had learned to look for at the bottom of a run - the summary - was missing exactly when they were checking a plan. Reusing cache.summary also means every output format (json, jsonl, template) keeps reporting a footer rather than only the text renderer growing one.

The cache's own counters are not consulted: nothing executed, so they are all zero and would report "0 ran" for a plan that intends to run plenty.

func (*Cache) LogMemoryPressure added in v0.4.0

func (c *Cache) LogMemoryPressure(ctx context.Context, p MemoryPressure)

LogMemoryPressure warns that the host is running out of memory, routed through the cache logger like every other header.

Warn rather than Info because a killed runner never lets magus reach its summary. Only what magus already streamed survives, and this is the line that explains an otherwise unattributable shutdown signal.

func (*Cache) LogRemoteSummary added in v0.4.0

func (c *Cache) LogRemoteSummary(ctx context.Context)

LogRemoteSummary accounts for what the remote cache did this run, reading the run-scoped counters off ctx. It is the only place the ZERO case gets stated: a run that never touched a configured remote says so rather than saying nothing, and silence is what made that indistinguishable from working.

func (*Cache) LogScope

func (c *Cache) LogScope(ctx context.Context, label, source string)

LogScope emits the projects header through the cache logger so all output formats (pretty/text/JSON) receive the same event.

func (*Cache) LogStage

func (c *Cache) LogStage(ctx context.Context, label, target string, elapsed time.Duration, runErr error)

LogStage emits a per-stage progress event for one magus.needs sub-target that ran under a project, routed through the cache logger like the other events. In collapse mode (where a project's subprocess output is withheld) these lines give the reader a checklist of what ran and whether it passed; runErr is nil on success.

func (*Cache) LogSummary

func (c *Cache) LogSummary(ctx context.Context, elapsed time.Duration)

LogSummary emits an end-of-run [summary] footer through the cache logger, drawn from the cache's own hit/miss/error counters. Like LogScope it routes through the logger so every output format receives the same event.

func (*Cache) OutputByRef added in v0.4.0

func (c *Cache) OutputByRef(ctx context.Context, ref string) ([]byte, OutputDescriptor, error)

OutputByRef resolves a ref to its captured bytes and descriptor, consulting the LOCAL store first and then, only if the ref is unknown here, the remote bundle namespace. That order matters: local is authoritative and free, and a remote lookup is a network call no one asked for until the answer is not here. A miss reports which stores were consulted, so "not found" never hides where magus looked.

func (*Cache) Prune

func (c *Cache) Prune(ctx context.Context, cutoff time.Time, dryRun bool) (n int, freed int64, err error)

Prune removes cache entries whose CreatedAt is before cutoff and then GCs orphaned blobs. Returns the count of entries removed and total bytes freed. When dryRun is true no files are deleted; counts are still returned.

func (*Cache) PruneRemote

func (c *Cache) PruneRemote(ctx context.Context, policy RetentionPolicy) error

PruneRemote evicts remote cache artifacts per policy. It errors when no remote backend is configured, when the backend does not implement RemotePruner, or when the backend is inactive in this environment (the same gate fetch/push use, so a misconfigured prune fails loudly instead of silently no-opping).

func (*Cache) PublishOutput added in v0.4.0

func (c *Cache) PublishOutput(ctx context.Context, ref string) (string, error)

PublishOutput uploads the run behind ref as a signed output bundle, so another machine can resolve that exact ref. Passing runs already travel with the cache artifact; this is what makes a FAILING run shareable, and it is always explicit. It errors when no remote backend is configured, when the backend is inactive, or when the cache holds no signing key (an unsigned bundle no consumer would accept).

func (*Cache) Remote added in v0.2.0

func (c *Cache) Remote() RemoteBackend

Remote returns the configured remote backend, or nil when local-only. Exposed so subsystems with their own artifacts (the knowledge-graph shard store) can ride the same backend as build artifacts, under the same signing/verification.

func (*Cache) Run

func (c *Cache) Run(ctx context.Context, s Step, fn func(context.Context) error, opts ...RunOption) (Result, error)

Run executes fn under the cache. On a hash match it replays recorded outputs; otherwise fn runs and its outputs are snapshotted. Per-hash locking prevents manifest races when multiple RunAll goroutines share the same key.

Example

ExampleCache_Run shows the minimal round-trip: a miss on the first call runs the work; a second call for the same step replays it as a hit without running.

The cache's own progress logging is raised to Error level so only the example's own prints reach stdout, keeping the output deterministic (the real CLI logs "cache miss/hit (<duration>)" lines, whose durations vary run to run).

// A fresh, private store so the first run is always a cold miss regardless of
// any earlier run or example. t.TempDir is unavailable in an Example, so this
// manages its own temp dir.
dir, err := os.MkdirTemp("", "magus-cache-example-run")
if err != nil {
	fmt.Println("setup:", err)
	return
}
defer os.RemoveAll(dir)

c, err := Open(context.Background(), dir, WithLog("text", slog.LevelError))
if err != nil {
	fmt.Println("open:", err)
	return
}

step := Step{
	ProjectPath:   "api",
	WorkspaceRoot: ".",
	Target:        "build",
}

ran := 0
fn := func(_ context.Context) error {
	ran++
	return nil
}

// First run: cold miss → fn is called.
r1, err := c.Run(context.Background(), step, fn)
if err != nil {
	fmt.Println("run1:", err)
	return
}
fmt.Println("run1 hit:", r1.Hit)

// Second run on the same store: hit → fn is skipped.
r2, err := c.Run(context.Background(), step, fn)
if err != nil {
	fmt.Println("run2:", err)
	return
}
fmt.Println("run2 hit:", r2.Hit)
fmt.Println("fn ran:", ran, "time(s)")
Output:
run1 hit: false
run2 hit: true
fn ran: 1 time(s)

func (*Cache) RunAll

func (c *Cache) RunAll(ctx context.Context, steps []Step, fn func(context.Context, Step) error, opts ...RunOption) ([]Result, error)

RunAll schedules steps concurrently (bounded by WithLimiter, or DefaultConcurrency). Step.DependsOn imposes scheduling order for in-scope steps only; out-of-scope deps are ignored. A cyclic DependsOn graph is rejected before any goroutine launches. Upstream cache keys fold into dependent Step.Deps transitively (happens-before: markDone writes the key before waitForDeps returns in dependents). Every goroutine launches immediately and blocks on deps without holding a slot, so the pool never deadlocks and g.Wait() always drains cleanly.

Example

ExampleCache_RunAll shows fan-out across multiple steps with bounded concurrency and per-result callbacks. RunAll schedules its steps concurrently, so the order callbacks fire in is not deterministic; the example collects the outcomes and sorts them before printing.

dir, err := os.MkdirTemp("", "magus-cache-example-runall")
if err != nil {
	fmt.Println("setup:", err)
	return
}
defer os.RemoveAll(dir)

c, err := Open(context.Background(), dir, WithLog("text", slog.LevelError))
if err != nil {
	fmt.Println("open:", err)
	return
}

steps := []Step{
	{ProjectPath: "api", WorkspaceRoot: ".", Target: "test"},
	{ProjectPath: "web", WorkspaceRoot: ".", Target: "test"},
}

var mu sync.Mutex
var missed []string
results, err := c.RunAll(
	context.Background(), steps,
	func(_ context.Context, _ Step) error { return nil },
	WithLimiter(NewLimiter(4)),
	OnMiss(func(r *Result) {
		mu.Lock()
		missed = append(missed, r.ProjectPath)
		mu.Unlock()
	}),
)
if err != nil {
	fmt.Println("runall:", err)
	return
}

sort.Strings(missed)
fmt.Println("results:", len(results))
fmt.Println("missed:", missed)
Output:
results: 2
missed: [api web]

func (*Cache) Stats

func (c *Cache) Stats() Stats

Stats returns a snapshot of the per-cache counters.

func (*Cache) StepKey added in v0.4.0

func (c *Cache) StepKey(ctx context.Context, s *Step) (key string, lines []string, err error)

StepKey computes s's cache key plus the pre-hash key inputs behind it, without executing or storing anything. It is the SDK seam for the live half of the works-on-my-machine diff: `describe target --cache` keys the step exactly as a run would, then compares these lines against the set stored behind a ref. The returned key is what portable refs truncate, so callers can also predict the ref a run of this step would print.

func (*Cache) StepKeyMemo added in v0.4.0

func (c *Cache) StepKeyMemo(ctx context.Context, s *Step, memo *SourceMemo) (key string, lines []string, err error)

StepKeyMemo is StepKey with an optional SourceMemo. It exists for a caller that keys MANY steps sharing one WorkspaceRoot in one pass - IdentifyRef's sweep, via ComputeTargetKey - so distinct targets whose buildStep gives them the SAME Sources baseline expand and hash that source set once instead of once per target/charm combination. Pass nil for ordinary StepKey behavior; see SourceMemo's doc for the safety condition on passing a real one.

computeTargetKey (run.go), the implementation behind the `describe target --cache` seam StepKey's own doc describes, calls StepKeyMemo directly rather than through StepKey; only tests exercise StepKey itself.

type ClassDigest added in v0.4.0

type ClassDigest struct {
	Class  string `json:"class"`
	Digest string `json:"digest"` // sha256 over the class's lines, truncated to 12 hex
	Count  int    `json:"count"`  // how many key inputs the class contributes
}

ClassDigest summarizes one component class of a cache key: every key input shares a label prefix ("src", "env", "tool", ...), and the class digest hashes the class's lines in key order. Two machines comparing digests learn WHICH CLASS disagrees without shipping the full lines - small enough for a URL fragment - while the full lines (CLI side) name the exact file or variable.

func ClassDigests added in v0.4.0

func ClassDigests(inputs []string) []ClassDigest

ClassDigests folds key inputs into one digest per component class, preserving first- appearance order (the hash order of the key itself, so output is stable).

type FSRemoteBackend

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

FSRemoteBackend is a local-filesystem RemoteBackend: artifacts are stored as gzip-tarballs under <dir>/<flat-project>/<hash>.tar.gz. Useful for testing and for sharing a cache between local workspaces on the same machine.

func NewFSRemoteBackend

func NewFSRemoteBackend(dir string) (*FSRemoteBackend, error)

NewFSRemoteBackend returns an FSRemoteBackend rooted at dir (created on demand).

func (*FSRemoteBackend) Active

func (r *FSRemoteBackend) Active(context.Context) bool

Active reports true: a filesystem backend is usable wherever its dir is.

func (*FSRemoteBackend) GetArtifact

func (r *FSRemoteBackend) GetArtifact(_ context.Context, projectPath, hash string) (io.ReadCloser, error)

GetArtifact opens the artifact file. Returns (nil, nil) when not found.

func (*FSRemoteBackend) Name added in v0.4.0

func (r *FSRemoteBackend) Name() string

Name identifies this backend in the run header. "fs" rather than the directory: the header names the KIND of tier, and the path is already config the reader can look up.

func (*FSRemoteBackend) PutArtifact

func (r *FSRemoteBackend) PutArtifact(_ context.Context, projectPath, hash string, data io.Reader) error

PutArtifact writes the artifact to the filesystem atomically.

type Failure added in v0.4.0

type Failure struct {
	Project string
	Target  string
	// OutputRef is the reference for the captured log, the same one the
	// scrolling `inspect:` line names. Deterministic, so it stays valid for as
	// long as the entry is in the cache.
	OutputRef string
	// LogPath is where the captured output was written. It is what makes the
	// ref linkable: a file:// URL resolves with nothing running.
	LogPath string
	// Dur is how long the target ran before it failed, kept as a FIELD rather
	// than only baked into Heading.
	//
	// A single pre-formatted string cannot be laid out. Durations are the one
	// column a reader scans down - "which of these was slow" - and that only
	// works if they share a right edge, which needs them separable from the
	// text they follow. Heading stays for the plain-output path, where there
	// is no column to align to.
	Dur time.Duration
}

Failure is one failed target, as the pinned band holds it.

It carries what an ACTION needs - which project, which target, which captured output - alongside the line the reader sees. The band used to hold only the rendered heading, which meant the failures were on screen and unreachable: everything needed to rerun one was known at the moment it was formatted and thrown away immediately afterwards.

type KeyInfo

type KeyInfo struct {
	PubB64 string
	KeyID  string
}

KeyInfo is the public identity of a key: its base64 public key and derived keyid.

func SigningKeyInfo

func SigningKeyInfo(seedB64 string) (KeyInfo, error)

SigningKeyInfo derives the public key + keyid of a base64 seed without echoing the seed — for `magus config cache key id` reading MAGUS_CACHE_SIGNING_KEY.

func TrustedKeyInfo

func TrustedKeyInfo(pubB64 string) (KeyInfo, error)

TrustedKeyInfo validates a base64 Ed25519 public key and returns it normalized with its derived keyid — for `magus config cache key id <pubkey>`.

type KeyInputChange added in v0.4.0

type KeyInputChange struct {
	Class string `json:"class"` // the component class: "src", "env", "tool", ...
	Input string `json:"input"` // the input's identity: the file, variable, or tool
	// Recorded and Live are the values the two keys hashed under Input. The *Absent
	// flags separate "that key had no such input" from "it hashed an empty value":
	// classes like dep and charm carry no value slot, so appearing and disappearing is
	// the only difference they can express.
	Recorded       string `json:"recorded,omitempty"`
	RecordedAbsent bool   `json:"recorded_absent,omitempty"`
	Live           string `json:"live,omitempty"`
	LiveAbsent     bool   `json:"live_absent,omitempty"`
}

KeyInputChange is one key input that does not agree between a recorded run's key and the live one.

func FirstKeyInputChange added in v0.4.0

func FirstKeyInputChange(recorded, live []string) []KeyInputChange

FirstKeyInputChange reports which inputs disagree between a recorded run's key inputs and the live ones, in LIVE key order (inputs only the recorded key had trail behind, in recorded order). Live order is the order hashStepInputs writes, so the slice LEADS with the earliest component class - the target's own definition before its sources, sources before env, env before tools - which is the order a reader wants to be told about: a changed target definition explains a moved source hash, never the reverse. Empty exactly when the two sides agree.

This is the one pairing rule for both comparison surfaces; DiffKeyInputs projects the same result onto whole lines for `--against`.

Both sides must already carry digested env values (DigestEnvValues); the store persists digested lines and a raw live line would read as a difference on every env var. Identity, not the whole line, is what pairs the two sides, so a source file whose hash moved counts once. Multiplicity collapses: a line repeated within one side is compared once.

type KeyInputDiff added in v0.4.0

type KeyInputDiff struct {
	Class      string   `json:"class"`
	StoredOnly []string `json:"stored_only,omitempty"`
	LiveOnly   []string `json:"live_only,omitempty"`
}

KeyInputDiff is one component class's stored-vs-live disagreement: lines only the stored key has and lines only the live key has. A class absent from the slice matched exactly.

func DiffKeyInputs added in v0.4.0

func DiffKeyInputs(stored, live []string) []KeyInputDiff

DiffKeyInputs projects FirstKeyInputChange onto whole lines: the same identity pairing decides what moved, and each moved input contributes its stored line to StoredOnly and its live line to LiveOnly, grouped by component class. A source file whose hash changed appears once on each side, which is exactly the shape a reader needs to see what drifted.

Classes come back in stored-key order, then any class only the live key has, in live order - the order `--against` has always rendered, and a shape scripts read. The pairing's own live-first ordering is the right lead for a SINGLE first difference and the wrong one for a full listing, where the reader is scanning classes rather than being handed a culprit.

Lines are emitted verbatim rather than rebuilt from a change's Input and value, because that join is not invertible: env keys its value behind "=" but its unset marker behind ":", and the classes with no value slot at all would gain a trailing separator.

type KeyMaterial

type KeyMaterial struct {
	SeedB64 string
	PubB64  string
	KeyID   string
}

KeyMaterial is a freshly minted signing keypair, base64-encoded. SeedB64 is the secret (MAGUS_CACHE_SIGNING_KEY); PubB64 goes in trusted_keys.

func GenerateSigningKey

func GenerateSigningKey() (KeyMaterial, error)

GenerateSigningKey mints a fresh Ed25519 keypair. Lives here, beside the verifier, so the keyid derivation never drifts.

type Limiter

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

Limiter is a FIFO-fair weighted semaphore that bounds concurrent work and exposes live occupancy metrics for the pool inspector.

func LimiterFromContext

func LimiterFromContext(ctx context.Context) *Limiter

LimiterFromContext retrieves the Limiter stored by ContextWithLimiter, or nil.

func NewLimiter

func NewLimiter(n int) *Limiter

NewLimiter returns a Limiter with capacity n. n <= 0 means unlimited (Acquire and AcquireN always succeed immediately).

func (*Limiter) Acquire

func (l *Limiter) Acquire(ctx context.Context) error

Acquire blocks until 1 slot is available or ctx is cancelled. Returns ctx.Err() on cancellation.

func (*Limiter) AcquireN

func (l *Limiter) AcquireN(ctx context.Context, n int) error

AcquireN acquires n slots under FIFO fairness. n < 1 is floored to 1 (a defensive guard; every real caller already passes >= 1). A request above capacity can never be satisfied by any wait, so it fails immediately rather than blocking forever; callers decide whether to clamp first (RunAll, where a slot count is a coarse throttle) or surface the error (os.with_slots/archive, where reserving more slots than exist would desync the reservation from the tool's own worker count). Returns ctx.Err() on cancellation.

func (*Limiter) Capacity

func (l *Limiter) Capacity() int

Capacity returns the limiter's slot capacity. 0 means unlimited.

func (*Limiter) Release

func (l *Limiter) Release()

Release frees 1 previously acquired slot.

func (*Limiter) ReleaseN

func (l *Limiter) ReleaseN(n int)

ReleaseN frees n previously acquired slots.

func (*Limiter) SetHooks

func (l *Limiter) SetHooks(onAcquire func(waitNs int64, n int), onRelease func(n int), onWait func(delta int))

SetHooks installs optional callbacks fired on every Acquire/Release. Must not block. Stored atomically so a SetHooks racing with concurrent Acquire/Release is safe.

onWait mirrors the internal queued counter exactly: it fires with +n the instant a caller begins waiting for n slots (before the blocking Acquire) and with -n once that Acquire returns, whether it acquired or the context was cancelled. Net inflight of onWait tracks Limiter.Snapshot's Queued.

func (*Limiter) Snapshot

func (l *Limiter) Snapshot() LimiterStats

Snapshot returns a point-in-time view of the limiter.

func (*Limiter) Yield

func (l *Limiter) Yield(ctx context.Context, fn func() error) error

Yield releases the caller's slots for the duration of fn, then re-acquires them before returning. It releases every slot the caller holds (SlotsHeld(ctx), at least 1): a weighted step holds more than one, and releasing only one would leave it pinning slots that fn's own AcquireN then blocks on forever. The caller MUST hold a slot; a slotless caller would over-release the semaphore. Re-acquire uses a non-cancellable context so the caller always returns with its slots held (RunAll releases unconditionally; a slotless return would panic). The re-acquire re-enters the FIFO queue, so a yielding goroutine goes to the back.

Trade-off: the non-cancellable re-acquire can block a returning yield on a saturated limiter even after ctx is cancelled, slowing shutdown until peers free the slots.

type LimiterStats

type LimiterStats struct {
	Capacity int // total slots; 0 = unlimited
	Running  int // currently acquired slots
	Queued   int // slots currently blocked in Acquire/AcquireN
}

LimiterStats is a point-in-time view of the concurrency pool.

type LocalAdmitter added in v0.4.0

type LocalAdmitter struct{ Budget *MachineBudget }

LocalAdmitter reaches a budget held in THIS process. It is what the daemon's own workspaces use: dialing its own socket from inside a request it is serving would have it wait on itself.

func (LocalAdmitter) Drop added in v0.4.0

func (l LocalAdmitter) Drop(_ context.Context, waiter string)

func (LocalAdmitter) Release added in v0.4.0

func (l LocalAdmitter) Release(_ context.Context, id string)

func (LocalAdmitter) Request added in v0.4.0

type MachineAdmitter added in v0.4.0

type MachineAdmitter interface {
	Request(ctx context.Context, waiter string, c types.MachineClaim) (types.MachineVerdict, error)
	Release(ctx context.Context, id string)
	Drop(ctx context.Context, waiter string)
}

MachineAdmitter is the budget as a client reaches it: the daemon over the proc socket, or a MachineBudget directly when this process IS the daemon.

type MachineBudget added in v0.4.0

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

MachineBudget is admission across every magus on the host: one budget of concurrency slots and declared memory, arbitrated in one place.

The Limiter cannot answer this. It is per-process, so N worktrees each admit up to their own capacity and nothing sums them. Both read the same folded figure: the limiter throttles peers within a process, this decides whether the machine can seat the step at all.

It lives in the user's daemon, which is what makes a queue possible. An earlier attempt kept it in a flocked lease directory and had to refuse rather than wait, because a passive directory cannot tell a waiter when its turn came. A process can.

DECLARED, not observed, so the same command on the same host reaches the same verdict whatever a browser is doing. Observed pressure stays advisory in sys/mem.

func NewMachineBudget added in v0.4.0

func NewMachineBudget(budgetMB, budgetSlots int) *MachineBudget

NewMachineBudget returns a budget of budgetMB megabytes and budgetSlots concurrency slots. A non-positive figure leaves that half unlimited, which is what a host magus cannot measure falls back to.

func (*MachineBudget) Drop added in v0.4.0

func (b *MachineBudget) Drop(waiter string)

Drop retires a waiter that gave up, so the queue behind it moves now rather than after machineWaiterStaleAfter.

func (*MachineBudget) Release added in v0.4.0

func (b *MachineBudget) Release(id string)

Release returns a granted claim. An unknown id is ignored: a client that lost its daemon mid-run releases into a budget that never recorded it.

func (*MachineBudget) Request added in v0.4.0

Request is one poll for admission on behalf of waiter, an id stable across this step's polls and unique machine-wide. It grants, refuses, or queues, and never blocks: the wait belongs to the client, which is the process that can report it.

func (*MachineBudget) Snapshot added in v0.4.0

func (b *MachineBudget) Snapshot() types.MachineSnapshot

Snapshot reports the whole budget. Read-only: it retires nothing, so a status command can ask what the machine is doing without moving a queue. Entries whose process is gone are FILTERED rather than deleted, so the report never shows a corpse the next Request would retire anyway.

type Manifest

type Manifest struct {
	ProjectPath string         `json:"projectPath"`
	Hash        string         `json:"hash"`
	Target      string         `json:"target,omitempty"`
	Outputs     []OutputRecord `json:"outputs"`
	CreatedAt   time.Time      `json:"createdAt"`
	// Platform is runtime.GOOS+"/"+runtime.GOARCH at the time this entry was
	// produced (e.g. "darwin/arm64"). It is NOT part of the cache key - the key
	// must stay platform-free so an output ref (a truncated key) is identical on
	// every machine - so it lives here instead, as a replay-time gate. src: lines
	// are content hashes, so darwin and linux compute the SAME digest for the
	// same commit; without this field a Linux CI pass could replay on a darwin
	// laptop as a pass for code darwin never compiled (or vice versa), and worse
	// for a platform-conditional file like hash_iouring_linux.go, which darwin
	// never even builds. Empty means "written before this field existed"; see the
	// mismatch check in readManifest for how that is treated.
	Platform string `json:"platform,omitempty"`
	// DurationMs is how long the run that produced this entry took. A cache HIT replays that run's
	// result, so this is exactly the work the hit avoided - a measured figure for this target on
	// this machine, not an average over targets that never ran. Cache.Stats sums it across hits.
	//
	// Absent (zero) on every manifest written before this field, and on an entry whose run was not
	// timed. Those hits count toward Hit and contribute nothing to Saved, so the total understates
	// rather than invents - which is why the console labels it as saved THIS SESSION rather than
	// implying it covers the cache's whole history.
	DurationMs int64 `json:"durationMs,omitempty"`
	// Return is what the target returned (str or [str]), stored so a cache HIT can
	// replay it. A hit never invokes the target, so without this a target would
	// print its result on the first run and nothing on the second. Absent for the
	// `> void` targets that are the overwhelming majority, and absent from every
	// manifest written before returns existed - which read back as no value, the
	// same as a void target, so old entries stay valid.
	Return any `json:"return,omitempty"`
}

Manifest is the on-disk record of a single cache entry.

type MemoryPressure added in v0.4.0

type MemoryPressure struct {
	AvailableBytes int64
	TotalBytes     int64
	// SwapUsedBytes and SwapGrowthBytes are the machine's swap and how much of it
	// this run added. Growth is the attributable half: a machine up for weeks
	// carries swap that predates the run, and reporting the level alone would
	// blame this run for it. Both 0 where the platform cannot report swap.
	SwapUsedBytes   int64
	SwapGrowthBytes int64
	// SwapTriggered reports that swap growth is why the watchdog spoke, as opposed to
	// falling headroom. See mem.Reading.
	SwapTriggered bool
	// BuzzObjects and BuzzPeak are the script VM's heap counts. Its heap never
	// frees, so a magusfile can consume the machine with no subprocess looking
	// guilty; 0 means the caller did not measure.
	BuzzObjects int
	BuzzPeak    int
	// BuzzHotSite is the source position responsible for the most heap growth,
	// as "source:line", or "" when nothing was sampled.
	BuzzHotSite string
}

MemoryPressure is what the run-time watchdog observed. Data, not a sentence: every other Log* method on Cache takes the facts and builds the record here, and a warning assembled across two packages has no single owner for its wording.

type Option

type Option func(*Cache)

Option configures a Cache at open time.

func WithCollapse

func WithCollapse(collapse bool) Option

WithCollapse enables collapse-on-success output: a project's subprocess output is captured. Failures show an excerpt; the output ref keeps the full log.

func WithInsecureRemote

func WithInsecureRemote() Option

WithInsecureRemote allows a remote backend to run with no trust set, importing unsigned artifacts without authentication. Open otherwise refuses that combination. Only for a fully trusted store (e.g. a local cross-workspace cache); never for a shared cache that an untrusted party could write.

func WithLog

func WithLog(format string, level slog.Level) Option

WithLog sets the log format ("pretty", "text", "json") and minimum level.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger replaces the default logger.

func WithMachineAdmission added in v0.4.0

func WithMachineAdmission(admitter MachineAdmitter, noWait bool) Option

WithMachineAdmission routes every step through machine-wide admission: it takes its concurrency slots and declared memory_mb from a budget shared by every magus on the host, queueing behind the peers already holding it. noWait fails fast (MGS3009) instead of queueing.

admitter must reach the ONE arbiter for this machine, which is the user's daemon. Omitting the option leaves admission per-process, which is what a library caller with no host to arbitrate wants.

Applied after WithLogger, so a lost arbiter is reported through the caller's logger rather than the default one.

func WithMaxImportBytes

func WithMaxImportBytes(n int64) Option

WithMaxImportBytes sets the per-entry byte cap used by Import (default 10 GiB).

func WithMutable

func WithMutable(mutable bool) Option

WithMutable controls whether the cache writes new entries on a miss (default true).

func WithRemoteBackend

func WithRemoteBackend(t RemoteBackend) Option

WithRemoteBackend configures a remote backend that is consulted on local miss.

func WithSigningKey

func WithSigningKey(seed []byte) Option

WithSigningKey sets the Ed25519 seed (32 bytes) used to sign artifacts on push. Set only in trusted CI; without it the cache cannot publish trusted artifacts.

func WithSilent

func WithSilent(silent bool) Option

WithSilent enables silent output mode: on top of quiet's suppression, a failing project's dump is bounded to its tail (with a pointer to the retained full log) and only target-marked important lines are bubbled up. See captureRun.

func WithSizeMB

func WithSizeMB(n int) Option

WithSizeMB caps cache disk usage to n MiB. 0 means unlimited.

func WithTrustedKeys

func WithTrustedKeys(pubkeys [][]byte) Option

WithTrustedKeys sets the raw Ed25519 public keys (32 bytes each) that remote artifacts must be signed by. A non-empty set makes verification mandatory.

type OutputBundle added in v0.4.0

type OutputBundle struct {
	Schema     int              `json:"schema"`
	Descriptor OutputDescriptor `json:"descriptor"`
	KeyInputs  []string         `json:"key_inputs,omitempty"`
}

OutputBundle is the metadata half of a published output: the run's descriptor plus the key inputs behind it. The captured bytes travel beside it as a separate member.

type OutputDescriptor added in v0.2.0

type OutputDescriptor struct {
	Ref         string `json:"ref"`
	Project     string `json:"project"`
	Target      string `json:"target,omitempty"`
	Inv         string `json:"inv,omitempty"` // invocation id of the run that produced this output
	Failed      bool   `json:"failed"`
	ErrMsg      string `json:"error,omitempty"` // failure message; empty on success
	TimestampMs int64  `json:"timestamp_ms"`    // unix milliseconds, matching DurationMs' unit
	DurationMs  int64  `json:"duration_ms"`

	// Ref is the key-derived portable id shared by every attempt of the step; the
	// fields below carry the identity it derives from.
	Key          string `json:"key,omitempty"`         // full cache key hash (64 hex)
	KeyVersion   int    `json:"key_version,omitempty"` // hashStep KeyVersion that produced Key
	Attempt      string `json:"attempt,omitempty"`     // execution-unique id; the file stem
	MagusVersion string `json:"magus_version,omitempty"`

	// The cache key pins a TREE STATE (via its source content hashes)
	// without naming it - it deliberately contains no commit, branch, or base. Revision
	// and Dirty close that gap: they record the VCS state the run's inputs were read
	// at, so a ref fetched from a foreign machine (CI, a teammate) can say not just
	// WHICH target produced it but which commit reproduces it. A v2 descriptor (or
	// earlier) carries neither field, which reads as "unknown, no VCS, or predates
	// this field" - never an error, since resolving it is best-effort by construction
	// (a workspace with no VCS is a supported, silent no-op).
	Revision string `json:"revision,omitempty"` // full VCS revision hash inputs were read at; "" when unknown
	Dirty    bool   `json:"dirty,omitempty"`    // working tree had uncommitted changes; Revision alone then is necessary but not sufficient to reproduce

	// The last key inputs a reader cannot recover from the fields above. Charms are
	// folded into Target by reproTarget and sources are pinned by Revision, but these
	// key the cache while appearing nowhere else, so `magus x <ref>` would otherwise
	// rebuild a DIFFERENT invocation and report success. Spell is the sharpest:
	// `go::go-build` and `go-build` are the same Target with different bodies.
	Spell     string   `json:"spell,omitempty"`      // spell::op filter that selected the definition
	ExtraArgs []string `json:"extra_args,omitempty"` // trailing args forwarded after --
	VCSName   string   `json:"vcs,omitempty"`        // provider Revision came from: git, hg, sl, jj
	// Platform is GOOS/GOARCH, the same string readManifest and importArtifact
	// refuse a mismatch on. Recorded unconditionally, NOT gated on the
	// cache.include.os/arch settings: those govern what keys a build, while this
	// explains a refusal the guards perform regardless of them.
	Platform string `json:"platform,omitempty"`
}

OutputDescriptor is a stored execution's identity and outcome, written beside its verbatim output blob. It gives `magus query output <ref> -o json`, the MCP tool, and the viewer header the run's project/target/status/timing without anyone parsing the output bytes.

type OutputRecord

type OutputRecord struct {
	Path    string `json:"path"`              // repo-relative
	Blob    string `json:"blob"`              // sha256 hex of contents
	Mode    uint32 `json:"mode"`              // file mode bits & 0o777
	Symlink string `json:"symlink,omitempty"` // if non-empty, restore as symlink to this target
	Size    int64  `json:"size"`              // bytes (for sanity-check on replay)
}

OutputRecord captures one declared output file.

type OutputStore added in v0.2.0

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

OutputStore is the cache's output-retrieval repository: it persists each execution's captured output VERBATIM under <cacheDir>/outputs and resolves refs back to bytes/metadata. The user-facing ref is PORTABLE: a truncation of the step's cache key, so equal inputs mint the same ref on every machine. One execution is two sibling files named by an execution-unique attempt id:

outputs/<cacheKey>/<attempt>.out    the exact bytes the process wrote
outputs/<cacheKey>/<attempt>.json   its OutputDescriptor (identity + outcome)

The .out blob is the source of truth: ByRef reads it straight through, byte-for-byte what ran. Per-line structured events live once in the invocation journal, so output is never stored twice. Grouping by cache key keeps a nondeterministic target's runs together, making keep-last-K a per-directory prune and a ref lookup a shallow scan. Safe for concurrent Persist calls (each mints a distinct attempt).

func NewOutputStore added in v0.2.0

func NewOutputStore(cacheDir string) *OutputStore

NewOutputStore builds a store rooted at the cache ROOT dir (not the outputs subdir). It joins "outputs" and RunsDir itself, so ByRef works on an Inspect workspace with no live cache.

func (*OutputStore) AdoptImported added in v0.4.0

func (s *OutputStore) AdoptImported(cacheKey string, output []byte) (string, bool)

AdoptImported completes an execution record that arrived from a remote artifact. An import ships the producer's DESCRIPTOR but not its output blob (the bytes travel once, as the build log), so the attempt is a descriptor with no sibling .out and stays unresolvable. Given the log bytes a cache hit just replayed, this writes that missing blob and returns the step's portable ref - so a fresh machine answers under the SAME ref the producer printed instead of minting a local one. Reports false when the key has no orphan descriptor to complete (nothing imported, or already completed), leaving the caller to persist its own attempt.

func (*OutputStore) Attempts added in v0.4.0

func (s *OutputStore) Attempts(ref string) ([]OutputDescriptor, error)

Attempts lists every stored execution of the step ref names, newest first - the keep-last-K history behind one portable ref (`magus query output <ref> --attempts`). ref may be the step ref, a unique prefix, or any attempt id within the step; the whole directory answers either way. Pre-portable descriptors carry no Attempt field; their file stem (which was the v1 ref) fills it so every row is addressable. A blob whose descriptor is missing or unreadable (a Persist that died between its two writes) still rows up - minimally, from the blob itself - because a listing that silently omits a retrievable execution reads as "it does not exist".

func (*OutputStore) ByRef added in v0.2.0

func (s *OutputStore) ByRef(ref string) ([]byte, OutputDescriptor, error)

ByRef resolves a ref (or unique prefix) to the target's VERBATIM captured output bytes plus its metadata. The bytes are read straight from the <ref>.out blob - exactly what the process wrote, no reconstruction. This is the retrieval entry point for `magus query output <ref>` (print path).

func (*OutputStore) DescriptorByRef added in v0.4.0

func (s *OutputStore) DescriptorByRef(ref string) (OutputDescriptor, error)

DescriptorByRef resolves a ref (or unique prefix) to its stored descriptor alone, without reading the output blob - the identity views (`--identity`, `--against`) want the metadata, and a captured log can be large. A resolvable ref whose descriptor is missing or unreadable yields the error, unlike ByRef, which still has bytes to return and so degrades to a zero descriptor.

func (*OutputStore) InvocationByID added in v0.2.0

func (s *OutputStore) InvocationByID(inv string) (journal.Invocation, error)

InvocationByID reads the union run log (<cacheDir>/runs/<inv>.jsonl) for one invocation id and rebuilds its header: the command lineage (subcommand/args/trigger), timing, and outcome. It is how a stored output (OutputDescriptor.Inv) is traced back to the run that produced it - `magus query output <ref> --identity` and the viewer surface this lineage. Reads off the cache ROOT (RunsDir), not outputsDir. Returns fs.ErrNotExist when the run log has aged out.

func (*OutputStore) InvocationEventsByID added in v0.4.0

func (s *OutputStore) InvocationEventsByID(inv string) (journal.Invocation, []journal.Event, error)

InvocationEventsByID reads one invocation's run log and returns its header alongside the EVENTS it was reconstructed from. OutputStore.InvocationByID keeps only the header, which is all a `--identity` line needs; this is for a caller that wants the stream itself.

It exists because the events were being read and discarded: journal.KindSecret records every credential a run reached for, and the docs offer that as the answer to "what did this run touch", but nothing could read it back. Anything answering a question FROM the journal starts here.

inv must be a FULL invocation id - unlike an output ref there is no prefix resolution, because a run log is addressed by exact filename. An id failing LooksLikeInvocationID is refused before it is joined onto the runs dir: this is reachable from the daemon's Connect API, where an unvalidated id reads any .jsonl on the machine. Returns fs.ErrNotExist when the log has aged out under the RotateLogs cap.

func (*OutputStore) InvocationEventsFrom added in v0.4.0

func (s *OutputStore) InvocationEventsFrom(inv string, from int64) ([]journal.Event, int64, error)

InvocationEventsFrom reads one invocation's journal starting at byte offset from and returns the events on COMPLETE lines plus the offset just past the last one. It is the tailing counterpart to OutputStore.InvocationEventsByID, which reads the file whole.

A journal is appended while its run is still going (journal.FileHandler flushes every kind but output), so a follower resumes from where it stopped instead of re-reading megabytes it has already delivered - which is what keeps watching a long build proportional to what arrived. Stopping at the last newline is what makes a concurrent writer safe to read: a half-written line is left for the next call rather than parsed as corruption.

A file shorter than from means the id was reused after a cache clean, so the read restarts at zero rather than seeking past the end and reporting nothing forever. Returns fs.ErrNotExist when the log never existed, has aged out, or inv is not shaped like an invocation id.

func (*OutputStore) KeyInputsByKey added in v0.4.0

func (s *OutputStore) KeyInputsByKey(cacheKey string) ([]string, error)

KeyInputsByKey returns the stored pre-hash key inputs for an EXACT cache key, the shape a manifest records. It does not scan the store the way KeyInputsByRef must: a prefix search would be a slower route to the same directory, and it fails outright once the key's attempt blobs have been pruned away while this sidecar remains.

func (*OutputStore) KeyInputsByRef added in v0.4.0

func (s *OutputStore) KeyInputsByRef(ref string) ([]string, error)

KeyInputsByRef returns the stored pre-hash key inputs behind ref (step ref, unique prefix, or any attempt id within the step). fs.ErrNotExist when the step resolves but predates key input persistence.

func (*OutputStore) LatestRefsByTarget added in v0.2.0

func (s *OutputStore) LatestRefsByTarget() []OutputDescriptor

LatestRefsByTarget returns the newest stored execution per (project, target): one OutputDescriptor each, the most recent by TimestampMs (ties broken by attempt id, then ref, so the choice is stable regardless of directory iteration order). It scans every cache-key directory's descriptor sidecars. This is what folds each target's last output ref onto its knowledge-graph node without the graph builder parsing the store's on-disk layout.

Descriptors store the REPRO target (bare name plus charm suffix, see reproTarget); this collapses that back to the bare declared target so the newest run is picked across charm variants and Target matches a knowledge-graph node. Descriptors without a target are skipped, as is anything unreadable - fewer entries, never an error. Sorted by project then bare target for deterministic assembly.

func (*OutputStore) ListDescriptors added in v0.2.0

func (s *OutputStore) ListDescriptors() []OutputDescriptor

ListDescriptors returns every stored execution's descriptor, newest run first, across all cache keys - the feed for the console's run browser (the log-viewer tree groups them project -> target -> run so a reader can browse recent runs and open any one's captured output). Unlike LatestRefsByTarget, which collapses to the single newest run per target, this keeps every retained execution (the store holds keep-last-K per cache key), so a target's recent history is browsable. The REPRO target is preserved verbatim (with any charm suffix) so a run's exact invocation stays visible; the caller collapses to the bare name for grouping if it wants. Best-effort: an absent or unreadable store, or an undecodable descriptor, yields fewer entries, never an error.

func (*OutputStore) ListRunLogs added in v0.4.0

func (s *OutputStore) ListRunLogs(limit int) []RunLog

ListRunLogs returns the newest retained invocation journals, newest first by modtime, capped at limit (limit <= 0 returns every retained one). It is the run browser's feed and the invocation-addressed counterpart to OutputStore.ListDescriptors, which lists stored OUTPUTS.

Cost is bounded per journal, not per event: a head read for the started event and a tail read for the finished one. A run killed before it finished has no finished event, so its Status is empty and FinishedMs falls back to the last event the tail window holds - the same honest degradation journal.InvocationFromEvents makes.

Best-effort throughout: an unreadable dir, an unparsable line, or a journal whose head is not a started event yields fewer rows, never an error.

func (*OutputStore) Persist added in v0.2.0

func (s *OutputStore) Persist(ctx context.Context, cacheKey string, output []byte, d OutputDescriptor) (OutputDescriptor, error)

Persist writes the execution's captured output VERBATIM as outputs/<cacheKey>/<attempt>.out (byte-for-byte what the process wrote, so `magus query output <ref>` is a straight read - never a reconstruction) plus an <attempt>.json descriptor, then prunes the cache key's directory to keep-last-K. Per-line structured events are NOT stored here - they live in the invocation journal, so no output is stored twice. Returns the descriptor as stamped and stored: Ref is the step's portable ref (shared by every attempt of this key), Attempt the execution-unique id that names the files just written. Best-effort at the call site: on error the caller keeps the run's own outcome.

func (*OutputStore) PersistKeyInputs added in v0.4.0

func (s *OutputStore) PersistKeyInputs(ctx context.Context, cacheKey string, inputs []string) error

PersistKeyInputs writes the step's pre-hash key inputs beside its attempts, env values digested (DigestEnvValues) and the result secret-redacted line-by-line as a second net for non-env classes. One file per cache key: the lines are a property of the KEY, so later attempts of the same step overwrite with identical content. Best-effort at the call site: an error just means a later --identity/--against has no lines to explain with.

func (*OutputStore) RotateRuns added in v0.2.0

func (s *OutputStore) RotateRuns(keepLast int, keepBytes int64) (removed int, bytesFreed int64)

RotateRuns keeps the newest invocation journals (runs/<inv>.jsonl, by modtime) and removes the rest, returning how many it deleted and the bytes that freed. The runs dir is flat (one file per invocation, not keyed like outputs/), so this is a single keep-last over the whole directory - the run-log analogue of pruneKey, and the worker behind the rotate-logs job.

TWO caps, and the tighter one wins. keepLast bounds the COUNT; keepBytes bounds the total on disk. Count alone does not bound anything: a journal holds every output line of its run, so its size is whatever the subprocess printed, and 500 of them is 500 times an unbounded number. A run that emits a gigabyte earns a gigabyte.

Best-effort: an unreadable dir or a failed remove is skipped, never fatal. keepLast <= 0 is a no-op (never wipe the whole dir by accident), and keepBytes <= 0 means no size cap.

func (*OutputStore) RunsStat added in v0.2.0

func (s *OutputStore) RunsStat() (bytes int64, count int64)

RunsStat reports the invocation-journal directory's current footprint: total bytes across every runs/<inv>.jsonl and the number of such files. Best-effort and read-only; a missing dir is (0, 0). It is what the RotateLogs job reports as its target size.

func (*OutputStore) StepRef added in v0.4.0

func (s *OutputStore) StepRef(cacheKey string) string

StepRef returns the step's portable ref when at least one execution is stored for cacheKey, or "" if none. A cache HIT reuses it instead of re-persisting identical output under a fresh attempt - so hits point at the existing events, not bloat the store. Pre-portable directories qualify too: the ref derives from the key, not from what any stored descriptor says.

type PaneFocus added in v0.4.0

type PaneFocus int
const (
	FocusTree PaneFocus = iota
	FocusPreview
)

type PrettyHandler added in v0.4.0

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

PrettyHandler renders both cache events (known cache.* messages) and general diagnostics in a compact, scannable style: colored ASCII status glyphs on a TTY, bracketed prefixes on plain streams. It carries no timestamps or level=/key= boilerplate; that noise is what makes raw slog output hard to read interactively.

On a TTY, errors are written into a sticky region at the bottom of the terminal so they never scroll off, under a live pool status line (scroll margins are set on the first cache.pool or cache.error, and reset on cache.summary or Close). Non-TTY writers fall through to plain output; the region is harmless when disabled.

func NewPrettyHandler

func NewPrettyHandler(w io.Writer, level slog.Level) *PrettyHandler

NewPrettyHandler builds the unified pretty handler. Color is driven by the terminal-ness of w's file descriptor (any writer exposing Fd(), not just an *os.File); a writer without one -- a bytes.Buffer in tests, a pipe -- renders plain. TTY detection runs per-Handle so late redirects are noticed.

On a TTY writer, a sticky region is reserved at the bottom of the terminal: a live pool status line on top, then [fail] lines that do not scroll off. The reservation is lazy (the region opens on the first cache.pool or cache.error) and reset on cache.summary or Close; a pipe / bytes.Buffer / non-TTY writer disables it.

func NewPrettyHandlerFor added in v0.4.0

func NewPrettyHandlerFor(w io.Writer, level slog.Level, p tty.Probe, now func() time.Time) *PrettyHandler

NewPrettyHandlerFor returns a handler that draws into w, measured through p.

Exported for a caller that is deliberately NOT the process terminal: the documentation renderer, which drives a real handler against an in-memory screen so the pictures it publishes are drawn by the code a reader will actually meet. NewPrettyHandler is the production entry point and keys on standard error to keep one handler per terminal; this one makes no such claim, so two callers get two independent handlers and neither takes the process band. A nil now defaults to time.Now; pass a fixed one to render a picture whose bytes do not depend on when it was rendered.

func StderrHandler added in v0.4.0

func StderrHandler() *PrettyHandler

StderrHandler returns the process's display handler if one was built, or nil. It is how a caller that did not construct the handler - the CLI's exit path, offering an interactive prompt over the run's pinned failures - reaches the one holding them.

func (*PrettyHandler) Close added in v0.4.0

func (h *PrettyHandler) Close() error

Close releases the sticky error region if one was reserved. Idempotent and safe to defer; required so a panic or interrupted run does not leave the terminal with the scroll margins still set. The next non-magus command the user runs inherits a clean terminal state.

func (*PrettyHandler) Enabled added in v0.4.0

func (h *PrettyHandler) Enabled(_ context.Context, lvl slog.Level) bool

Enabled reports whether a level is worth handling. slog calls it from every logging goroutine, and setLevel writes h.level under the mutex, so this reads it under the mutex too.

func (*PrettyHandler) Failures added in v0.4.0

func (h *PrettyHandler) Failures() []Failure

Failures returns the failures currently pinned in the band, in the order they are DRAWN, skipping empty slots. It is the keyboard counterpart to HitFailure: a caller offering "select with the arrow keys" needs the list, not a row.

Drawn order, not arrival order. The ring keeps a failure in the row it was painted into, so once it has wrapped the oldest entry sits in the middle - and the list has to match the screen, because a reader picks by position.

func (*PrettyHandler) Handle added in v0.4.0

func (h *PrettyHandler) Handle(ctx context.Context, r slog.Record) error

Handle renders one record. It deliberately does NOT skip on ctx.Err(): a handler must not treat cancellation as permission to drop output. The check that used to live here was inert for as long as it existed, because every call site reached slog through Logger.Info/Warn/..., which passes context.Background() - Err() was never non-nil. Once the run path started passing its REAL context (so records could reach the secret resolver), it woke up and began eating exactly the lines that matter most: in a concurrent run, the first failure cancels the errgroup, and every [pass]/[fail] that finished afterwards, plus the [summary] footer and the Ctrl-C service-release warning, vanished from the default output while -o json still showed them.

func (*PrettyHandler) HitFailure added in v0.4.0

func (h *PrettyHandler) HitFailure(row int) (Failure, bool)

HitFailure maps an absolute terminal row to the failure drawn on it.

The handler answers this rather than exposing its band layout, because the layout is its own business: the status line owns the first row and the ring owns the rest, and a caller that had to know that would be a second copy of the arrangement waiting to drift. Callers pass the Row from a mouse event straight through.

Reports false for the status row, for an empty ring slot, and for any row outside this handler's band - including a click on another consumer's rows.

func (*PrettyHandler) ReleaseBand added in v0.4.0

func (h *PrettyHandler) ReleaseBand() error

ReleaseBand hands this handler's rows back, for a caller that held them past the end of a run to offer an interactive prompt over the pinned failures.

func (*PrettyHandler) RendersBand added in v0.4.0

func (h *PrettyHandler) RendersBand() bool

RendersBand reports whether this handler has a live band to paint into. The cache asks before emitting pool samples, so a piped, JSON, or CI run pays nothing for a feature it cannot show, and the CLI asks before offering an interactive prompt over failures it may not have drawn.

Not named Enabled: that one belongs to slog.Handler and answers an entirely different question (whether a level is worth handling).

func (*PrettyHandler) SetFocus added in v0.4.0

func (h *PrettyHandler) SetFocus(f PaneFocus)

SetFocus moves focus between the two views, which resizes them: the focused one takes the golden ratio's major share.

Resizing on focus rather than offering a drag handle is deliberate - there is no pointer contract to invent, and the pane you are working in is the one that should be big.

func (*PrettyHandler) SetPreview added in v0.4.0

func (h *PrettyHandler) SetPreview(lines []string)

SetPreview gives the band a right-hand column: the captured output of whatever is selected. Nil or empty returns it to a single column.

This is the "two views, one run" surface. It is deliberately not two PANES - nothing here manages a terminal, and a caller cannot put arbitrary content in it. Both columns are things this handler already owns, which is the line between showing a reader their run and becoming a multiplexer.

func (*PrettyHandler) SetSelection added in v0.4.0

func (h *PrettyHandler) SetSelection(n int)

SetSelection highlights the nth pinned failure, counting only the occupied slots that PrettyHandler.Failures returns. A negative n clears it.

Selection lives here rather than in the prompt for the reason the band does: this repaints on a timer, so a highlight painted from outside would be erased by the next status tick.

func (*PrettyHandler) ToggleFocus added in v0.4.0

func (h *PrettyHandler) ToggleFocus() PaneFocus

ToggleFocus swaps which view has the major share, and reports the new focus.

func (*PrettyHandler) WithAttrs added in v0.4.0

func (h *PrettyHandler) WithAttrs(_ []slog.Attr) slog.Handler

func (*PrettyHandler) WithGroup added in v0.4.0

func (h *PrettyHandler) WithGroup(_ string) slog.Handler

func (*PrettyHandler) Zone added in v0.4.0

func (h *PrettyHandler) Zone() *tty.Zone

Zone returns the terminal owner this handler paints its band into.

Exposed so a second consumer can lease rows from the SAME owner: the failure prompt puts its instruction row directly beneath the band, and two zones over one terminal each compute their margins from their own row arithmetic and overwrite each other - the exact failure tty.Zone exists to prevent. In production both sides reach the same singleton through standard error and the sharing is invisible; for any other writer it has to be asked for.

type RecordedRun added in v0.4.0

type RecordedRun struct {
	Key       string
	CreatedAt time.Time
	KeyInputs []string
	// contains filtered or unexported fields
}

RecordedRun identifies the most recent cache entry recorded for one target in one project: the key it was stored under, when it was stored, and the pre-hash key inputs behind that key. KeyInputs is nil when the entry predates key-input persistence, which leaves the key comparable but nothing line-level to name.

RecordedRun.WouldReplay answers the separate question of whether some entry - not necessarily this one - would replay for a given key.

func (RecordedRun) WouldReplay added in v0.4.0

func (r RecordedRun) WouldReplay(key string) bool

WouldReplay reports whether a run whose cache key is key would replay a stored entry here instead of executing: a manifest sits at that exact key and passes the gates a hit applies (key, project, platform).

The entry it finds need not be this one. RecordedRun.Key names only the NEWEST entry, so an edit followed by a revert leaves Key pointing at the edited run while the key a run now mints belongs to an older entry that still hits - and that is the case a verdict read off the newest entry alone gets wrong.

False on a zero RecordedRun and on an empty key.

type RefNotFoundError added in v0.4.0

type RefNotFoundError struct {
	Ref    string
	Stores []string
}

RefNotFoundError reports a ref that resolved in none of the stores consulted, and names them. "Not found" is only actionable if the reader knows where magus looked - a foreign ref that was never published looks identical to a mistyped one otherwise.

func (*RefNotFoundError) Error added in v0.4.0

func (e *RefNotFoundError) Error() string

func (*RefNotFoundError) Is added in v0.4.0

func (e *RefNotFoundError) Is(target error) bool

Is reports RefNotFoundError as fs.ErrNotExist, so existing not-found handling (the CLI's MGS8001 path) keeps working while the message gains the store list.

type RemoteBackend

type RemoteBackend interface {
	// Name identifies the backend to a human: the spell that provides it ("s3", "gha").
	// It must not probe or dial - the run header calls it before any work, precisely so
	// the header cannot be what makes a run hang.
	Name() string
	// Active reports whether the backend is usable in the current environment.
	// The cache skips both fetch and push when it returns false, so a backend
	// gated on its environment (e.g. one that only runs under a specific CI
	// provider) costs nothing per build elsewhere. Implementations should make it
	// cheap — the cache may call it once per build — and cache any probe.
	Active(ctx context.Context) bool
	// GetArtifact streams the stored artifact for (projectPath, hash). Returns (nil, nil)
	// when no artifact is present.
	GetArtifact(ctx context.Context, projectPath, hash string) (io.ReadCloser, error)
	// PutArtifact stores the artifact bytes for (projectPath, hash) from r.
	PutArtifact(ctx context.Context, projectPath, hash string, r io.Reader) error
}

RemoteBackend is a pluggable remote backend for cache artifacts, keyed by (projectPath, hash). The local cache consults it on a local miss (before building) and populates it after a successful build. The artifact payload is an opaque byte stream — its format is the cache's concern, not the store's — so an implementation is effectively a content-addressed blob store. Implementations must be safe for concurrent use.

func OpenRemoteBackend

func OpenRemoteBackend(ctx context.Context, selector string) (RemoteBackend, error)

OpenRemoteBackend opens the registered remote backend for selector, or errors when no opener has been registered (no backend was linked into this binary).

type RemotePruner

type RemotePruner interface {
	PruneArtifacts(ctx context.Context, policy RetentionPolicy) error
}

RemotePruner is an optional capability a RemoteBackend may implement to support retention-based eviction (`magus config cache prune --remote`). A backend that does not implement it cannot be pruned — the cache's built-in eviction governs only the local store. PruneArtifacts enumerates the remote store and evicts artifacts matching policy; it runs out of band (a maintenance command), never on the build hot path.

type Result

type Result struct {
	ProjectPath string
	Hash        string
	Hit         bool
	Duration    time.Duration
	Outputs     []string // absolute paths written or replayed
	Ref         string   // per-execution output reference id (see recordOutput); "" when the output store is absent or persistence failed
	// Saved is the per-hit half of [Stats.SavedMs]: the duration the entry recorded when it
	// was written, which this hit replayed instead of running. Zero on a miss, and zero for
	// an entry written before the manifest carried a duration - the same understatement
	// SavedMs carries, for the same reason.
	Saved time.Duration
}

Result is the outcome of a Cache.Run call.

type RetentionPolicy

type RetentionPolicy struct {
	OlderThan time.Duration // evict artifacts older than this; 0 disables the age bound
	KeepLast  int           // keep only the newest N artifacts; 0 disables the count bound
	DryRun    bool          // report intended deletions without performing them
}

RetentionPolicy describes which remote cache artifacts a prune should evict. The two bounds are independent and additive: an artifact is evicted if it is older than OlderThan OR falls outside the newest KeepLast. A zero field disables that bound.

type Ring

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

Ring is an io_uring instance with its three mmaps.

type RunLog added in v0.4.0

type RunLog struct {
	Inv          string   `json:"inv"`
	Arguments    []string `json:"arguments,omitempty"` // full argv, subcommand included
	Trigger      string   `json:"trigger,omitempty"`   // one of journal's Trigger* constants
	StartedMs    int64    `json:"started_ms"`
	FinishedMs   int64    `json:"finished_ms,omitempty"`
	Status       string   `json:"status,omitempty"` // pass|fail; empty when the run was interrupted
	MagusVersion string   `json:"magus_version,omitempty"`
	SizeBytes    int64    `json:"size_bytes,omitempty"`
}

RunLog summarizes one retained invocation journal - the row the console's run browser lists so a reader can find a past run by the COMMAND that produced it rather than by a ref id somebody printed on a terminal.

It is deliberately not journal.Invocation: rebuilding one of those means reading a whole stream, and a journal holds every output line the run captured, so listing 500 of them would read hundreds of megabytes to show a list. Every field here comes off the two lifecycle events that bracket the file - the first line and the last - which OutputStore.ListRunLogs reads with bounded seeks.

type RunOption

type RunOption func(*runCtx)

RunOption configures a single Cache.Run (or RunAll) invocation.

func OnError

func OnError(fn func(error)) RunOption

OnError fires when fn returns an error.

func OnHit

func OnHit(fn func(*Result)) RunOption

OnHit fires after a cache hit replay.

func OnMiss

func OnMiss(fn func(*Result)) RunOption

OnMiss fires after a successful cache miss (fn returned no error).

func OnResult

func OnResult(fn func(*Step, *Result, error)) RunOption

OnResult fires after every Cache.Run regardless of outcome (after OnHit/OnMiss/OnError). Multiple OnResult options accumulate; all fire in registration order.

func WithLimiter

func WithLimiter(l *Limiter) RunOption

WithLimiter shares an external Limiter with RunAll instead of creating a private one, so in-process tasks and nested calls compete for the same concurrency budget.

func WithMaxFailures added in v0.4.0

func WithMaxFailures(n int) RunOption

WithMaxFailures bounds how many steps may fail before RunAll stops admitting more, as a budget rather than a boolean: 1 is fail-fast, 3 tolerates three, and 0 (the default) is unlimited. A step that fails only because a dependency failed does not count - it is a consequence, not an independent finding - so a budget of 1 stops at the first REAL failure rather than at whichever cascade victim reports first.

type SourceMemo added in v0.4.0

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

SourceMemo memoizes the expandSources+hashFiles result for one sweep of steps sharing a WorkspaceRoot, keyed by the (Sources, Outputs, IgnoreDirs) tuple that determines it.

ONLY for prediction: profiling IdentifyRef's sweep showed expandSources' WalkDir re-walking the workspace root as the dominant cost - 57% of a 355ms sweep - with dozens of walks over an IDENTICAL Sources set, because buildStep gives most targets in a project the same baseline.

It must NEVER reach a path that executes a target: the memo assumes nothing on disk changes between calls, which holds for a read-only sweep and nothing else. A real Run/RunAll handed one could replay a source hash observed before that step's build wrote to its own inputs. Construct one per sweep with NewSourceMemo, thread it explicitly, and let it fall out of scope.

Threaded as an explicit parameter rather than through ctx like gopherbuzz's TargetMemo, deliberately: an explicit parameter is what makes "prediction only" provable by grep.

func NewSourceMemo added in v0.4.0

func NewSourceMemo() *SourceMemo

NewSourceMemo returns an empty memo. See SourceMemo's doc for its one intended caller shape: created and discarded around a single prediction sweep.

type Stalled added in v0.4.0

type Stalled struct {
	// Project is the human display form ("(root)" for the workspace root).
	Project string
	// ProjectPath is the raw workspace-relative key the journal recorded ("." for the
	// root). Carried alongside the display form so a caller can look the project up in
	// the workspace - doctor matches these against per-target policy to tell a target
	// that CANNOT replay by design from one that merely does not.
	ProjectPath string
	Target      string
	Runs        int
	TotalMs     int64
}

Stalled is one (project, target) pair that executed repeatedly and never replayed from cache. TotalMs is the wall clock spent doing so.

func StalledTargets added in v0.4.0

func StalledTargets(cacheDir string, only map[string]bool) []Stalled

StalledTargets reports targets that execute repeatedly and never replay from cache, worst wall-clock first.

It reads the invocation journals magus already writes (<cacheDir>/runs/*.jsonl), so it costs no new bookkeeping: every target result is recorded there with a status of "cached" (replayed) or "pass"/"fail" (executed). A pair with many executions and no replays at all is not a cold cache, it is a cache that cannot work - almost always because the target's footprint includes inputs it does not read, so ordinary edits keep busting a key that had no reason to change.

When only is non-empty, only those "project\x00target" keys are considered. The run path passes the targets that just executed, so a normal run never pays to assess the whole workspace.

func (Stalled) AvgMs added in v0.4.0

func (s Stalled) AvgMs() int64

AvgMs is the mean execution time across Runs.

type Stats

type Stats struct {
	Hit   int
	Miss  int
	Error int
	// SavedMs is the summed recorded duration of the runs those hits replayed. Measured, not
	// modeled - each figure is how long that exact target took on this machine when it last ran.
	// Understates when entries predate Manifest.DurationMs, and never overstates.
	SavedMs int64
}

Stats holds per-Cache counters reported in the end-of-run summary.

type Step

type Step struct {
	ProjectPath string   // repo-relative project directory
	Sources     []string // doublestar globs (relative to WorkspaceRoot) for the cache key
	// IgnoreDirs are the non-source dir names this project's resolved spells generate
	// (vendor, node_modules, ...); pruned from the source walk so they are never hashed.
	// The field itself is not written into the key - only the resulting file set is, so
	// two ignore sets that yield the same files hash identically.
	IgnoreDirs []string
	EnvAllow   []string // env var names whose values contribute to the key
	// ExecOverrides are per-op ctx.withEnv / ctx.withCwd execution overrides ("env:K=V", "cwd:V"), extracted
	// statically. Unlike EnvAllow, which names env vars whose PROCESS value is read, a
	// derived override's value lives in the magusfile, so it is hashed directly.
	ExecOverrides []string
	// Observations are per-target ctx.observes declarations ("key=value"): facts OUTSIDE
	// the tree that the target's answer depends on and no other key input can see - a
	// vulnerability feed's id, a remote schema's revision. Hashed directly like
	// ExecOverrides, since both halves are written in the magusfile; unlike
	// ExecOverrides they change nothing about how the target runs, so they get their own
	// line class rather than reusing exec:. The value is opaque here - magus compares it
	// and never interprets it.
	Observations []string
	Outputs      []string // globs snapshotted into cache and replayed on hit
	// RequiredOutputs is the subset of Outputs that must each match at least one file,
	// rather than the whole set merely matching something. It carries the globs another
	// project's build order depends on (a cross-project output), where producing nothing
	// is a build failure rather than an empty result: the manifest would omit the file
	// and later cache hits would replay a partial output set into a tree this target does
	// not own. Ordinary outputs stay lenient - a glob that legitimately matches nothing
	// is common, and only a total miss is suspicious.
	RequiredOutputs []string

	// OutputsDeclared reports that Outputs came from the TARGET (ctx.writesFiles) rather than
	// being inherited from the project or a bound spell. Only then does producing nothing mean
	// the target broke its promise.
	//
	// Inherited globs routinely match nothing: binding the typescript spell contributes
	// `dist/**` to every target on the project, so a check-only target like a test - which
	// produces no files at all - would otherwise fail its snapshot for a glob it never claimed.
	// That failure hid for a long time because snapshot only runs on a cache MISS, and those
	// targets always replayed.
	OutputsDeclared bool

	// Updates and OwnedOutputs are unhashed: both are already covered by Sources and
	// Outputs, and hashing either would change every existing key. They exist so
	// checkSourceMutation can tell a declared write from an undeclared one (MGS4007).
	Updates []string // ctx.modifiesExistingFiles globs
	// OwnedOutputs spans EVERY target in EVERY project, not the running one, because
	// ctx.needs puts a chained target's writes inside this step's window and a workspace
	// target does the same across projects.
	OwnedOutputs []string

	Deps          []string // upstream project hashes folded into the key
	DependsOn     []string // upstream project paths for scheduling (not hashed)
	WorkspaceRoot string
	Target        string   // mixed into key to distinguish targets on the same sources
	Charms        []string // active charm names (sorted), mixed into key so charm-variant runs differ
	// ExtraArgs are the args after `--`, forwarded to the target. They change what
	// the target does, so like Charms they MUST key the cache: without them a run
	// with different args replays the previous run's result. Order is significant
	// (`-run X` is not `X -run`), so unlike Charms they are never sorted.
	ExtraArgs []string
	// Spell is the explicit `spell::op` filter of the invocation, empty on a plain
	// target run. It keys the cache because the filter selects WHICH DEFINITION
	// runs: an explicit op bypasses the magusfile export shadowing the same name,
	// so a compile-only go::go-build must not satisfy (or be satisfied by) the
	// go-build target's entry.
	Spell           string
	SpellDefVersion string   // binary fingerprint; forces miss on magus upgrade
	ToolVersions    []string // "spell:tool:token" strings (run.go builds them); forces miss on toolchain upgrade
	// PlatformIndependent drops the host-platform line from the key, so one entry
	// serves every platform. Resolved from the target's declaration or its spells';
	// false (the default) keys the platform. See types.PlatformSensitivity.
	// IncludeOS and IncludeArch select which host facts key this step. Separate
	// because they move independently; see config.CacheInclude.
	IncludeOS   bool
	IncludeArch bool
	NoCache     bool // when true, always run fn; never replay or snapshot (long-running targets)
	SkipReplay  bool // when true, never replay a hit (always run fn), but still snapshot on success - a forced rebuild that refreshes the entry, unlike NoCache which never snapshots either (magus run --no-cache)
	Exclusive   bool // RunAll only: when true, runs alone; no other batch step runs concurrently (ignored by Run, which has no batch)
	Slots       int  // RunAll only: concurrency slots held while running (0 or 1 = one slot); clamped to the limiter's capacity. Never hashed.
	// MemoryMB is RunAll only: the declared peak memory this step will reach,
	// including every target it composes, carried alongside the slot count Slots
	// derives from the same figure. Slots throttle peers inside THIS process; the
	// figure is what machine-wide admission arbitrates, and a slot count cannot be
	// converted back into it (the conversion divides by a per-process budget). 0 means
	// undeclared: no claim, no refusal. Never hashed.
	MemoryMB int
	// MemoryDeclaredBy is RunAll only: the target whose policy MemoryMB came from,
	// which is not this step when the figure was inherited from a target it composes
	// with ctx.needs. A refusal names it so the reader is sent to the magusfile line
	// to change rather than to the target they typed. Never hashed.
	MemoryDeclaredBy string
	Label            string // display-only project name for logs (root reads as e.g. "magus", not "."); never hashed
	// Revision and Dirty are the VCS state the run's inputs were read at, resolved ONCE
	// per invocation by the caller (a per-target probe would spawn a VCS subprocess per
	// step) and copied onto every step. Display-only provenance for the output
	// descriptor (recordOutput) - never hashed, so a run before vs. after a commit still
	// shares a cache entry when the tree content is unchanged.
	// VCSName is the provider the two above came from ("git", "hg", "sl", "jj"). Recorded
	// because a bare hash does not identify its own kind: a git SHA and an hg node id
	// are both 40 hex, and a colocated jj repo can yield either a git commit or a jj
	// commit_id. Comparing two revisions without it is a confident answer to the
	// wrong question.
	VCSName  string
	Revision string
	Dirty    bool
}

Step is the hashable description of a cached build step.

type Tracer

type Tracer interface {
	StartSpan(ctx context.Context, name string) (context.Context, func(err error))
}

Tracer opens a child span for an internal cache phase — hashing inputs, replaying a hit, or snapshotting outputs. It is the cache's only window onto a tracing backend: the observability layer implements it and installs it on the run context with ContextWithTracer, so this package keeps no OpenTelemetry dependency of its own. StartSpan returns a context carrying the new span and a func that ends it, recording err as the span's status.

Directories

Path Synopsis
Package reflink copies regular files using the most efficient mechanism the host platform and filesystem provide, transparently falling back to a plain userspace copy where no acceleration is available.
Package reflink copies regular files using the most efficient mechanism the host platform and filesystem provide, transparently falling back to a plain userspace copy where no acceleration is available.

Jump to

Keyboard shortcuts

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