enforcer

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Apr 9, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package enforcer implements the two sides of budget-breach enforcement for budgetclaw:

  1. A filesystem-backed LockStore that persists "this (project, branch) is in breach" across process restarts, so a user who accidentally re-launches claude cannot sidestep a cap they already hit.

  2. A Killer interface (with a gopsutil-backed RealKiller) that finds Claude Code processes whose working directory matches the breached project and sends SIGTERM to them.

The two concerns live in one package because the watcher always wires them together: on a kill breach, write the lock AND kill; on every subsequent event for a locked (project, branch), re-kill. Splitting them into separate packages would just force callers to hold two things together at every call site.

The package has a single external dependency (gopsutil) and does not import any other budgetclaw package except `paths` for XDG resolution. That keeps test dependencies minimal and lets watcher code own the wiring.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Enforcer

type Enforcer struct {
	Locks  *LockStore
	Killer Killer
}

Enforcer bundles a LockStore and a Killer so the watcher only has to hold one thing. Construct via NewEnforcer or, for tests, by setting the fields directly with a fake Killer and a temp-dir LockStore.

func NewEnforcer

func NewEnforcer() (*Enforcer, error)

NewEnforcer constructs an Enforcer using the default XDG lock directory and a real gopsutil-backed killer.

func (*Enforcer) CheckLocked

func (e *Enforcer) CheckLocked(ctx context.Context, project, branch, cwd string, now time.Time) (*Lock, []int, error)

CheckLocked is the "every event" path: before the watcher runs full budget evaluation, it calls this to see if (project, branch) is already locked. Returns:

(nil,  nil, nil)            not locked, proceed normally
(lock, killed, nil)          locked and active, processes killed (may be empty list)
(nil,  nil, nil)            locked but expired — auto-released, proceed normally

The auto-release happens transparently: if Lock.Expired(now) is true, CheckLocked calls Release and reports "not locked".

func (*Enforcer) HandleBreach

func (e *Enforcer) HandleBreach(ctx context.Context, lock Lock, cwd string) ([]int, error)

HandleBreach writes a lock for (lock.Project, lock.Branch) and immediately SIGTERMs any matching Claude process found in cwd. Returns the list of killed PIDs (possibly empty) and any error from the lock write or kill phase.

This is the "fresh breach" path: the watcher sees an event, evaluates rules, finds a kill-action breach, and calls this.

type Killer

type Killer interface {
	// FindByCWD returns the PIDs of every Claude process whose
	// working directory equals or is under `cwd`. Returns an empty
	// slice (not an error) when no matches are found.
	FindByCWD(ctx context.Context, cwd string) ([]int, error)

	// Kill sends SIGTERM to the given PIDs. Returns the list of
	// PIDs successfully signaled and an error describing any
	// failures. Individual failures do not abort iteration: killing
	// two of three matching processes is better than killing none.
	Kill(ctx context.Context, pids []int) ([]int, error)
}

Killer finds and terminates Claude Code processes by working directory. The interface exists so tests can inject a fake implementation instead of enumerating real processes and sending real signals.

type Lock

type Lock struct {
	Project    string    `json:"project"`
	Branch     string    `json:"branch"`
	Period     string    `json:"period"` // "daily" | "weekly" | "monthly"
	Reason     string    `json:"reason"`
	CapUSD     float64   `json:"cap_usd"`
	CurrentUSD float64   `json:"current_usd"`
	LockedAt   time.Time `json:"locked_at"`
	ExpiresAt  time.Time `json:"expires_at"` // auto-unlock at this time
}

Lock is one active budget-breach lock. It is both written to disk (as JSON in the locks dir) and returned in-memory by the query methods.

func (Lock) Expired

func (l Lock) Expired(now time.Time) bool

Expired reports whether the lock's period has rolled over and the lock should be treated as released. Callers are responsible for actually deleting the file (see LockStore.Prune).

A zero ExpiresAt means "no auto-expire" — the lock only clears on explicit Release.

type LockStore

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

LockStore is a filesystem-backed set of active locks, one file per (project, branch) under $XDG_DATA_HOME/budgetclaw/locks/.

The store is safe to use from a single process. Multi-process coordination is handled by the filesystem (atomic rename on write, ENOENT on read of a released lock). The budgetclaw watcher is single-process by design; two watchers racing on the same lock directory is not a supported configuration.

func NewLockStore

func NewLockStore() (*LockStore, error)

NewLockStore creates a store at the default XDG location and ensures the directory exists.

func NewLockStoreAt

func NewLockStoreAt(dir string) (*LockStore, error)

NewLockStoreAt creates a store at an explicit directory. Used by tests that want an isolated temp dir.

func (*LockStore) Acquire

func (s *LockStore) Acquire(l Lock) error

Acquire writes a lock file for (Project, Branch). If one already exists, it is overwritten — the latest breach metadata is what matters. If LockedAt is zero, it is set to time.Now().UTC().

The write is atomic via the classic temp-file-plus-rename dance so a crash mid-write cannot leave a half-written JSON file on disk (a half-written file would cause IsLocked to return a corrupted-lock error).

func (*LockStore) Dir

func (s *LockStore) Dir() string

Dir returns the on-disk directory. Used by `budgetclaw locks path` and similar diagnostic commands.

func (*LockStore) IsLocked

func (s *LockStore) IsLocked(project, branch string) (*Lock, error)

IsLocked returns the active Lock for (project, branch), or (nil, nil) if no lock exists. A corrupt lock file (invalid JSON) produces an error — the caller should probably delete the file and treat it as unlocked, but we surface the error so debugging is possible.

func (*LockStore) List

func (s *LockStore) List() ([]Lock, error)

List returns every active lock file. Unreadable or corrupt entries are silently skipped — a single bad file should not break `budgetclaw locks list`. Returned locks are in filesystem order (no sort guarantee).

func (*LockStore) Prune

func (s *LockStore) Prune(now time.Time) (int, error)

Prune removes every lock whose ExpiresAt has passed relative to `now`. Returns the number of locks removed. Useful as a lightweight cleanup run by the watcher on startup or on a timer.

func (*LockStore) Release

func (s *LockStore) Release(project, branch string) error

Release removes the lock for (project, branch). Releasing a non-existent lock is not an error — the desired end state (no lock) is achieved either way.

type RealKiller

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

RealKiller uses a processSource to enumerate processes and syscall.Kill to signal them. The source is swappable so tests can run without a real process table.

func NewRealKiller

func NewRealKiller() *RealKiller

NewRealKiller returns a production Killer backed by gopsutil.

func (*RealKiller) FindByCWD

func (k *RealKiller) FindByCWD(ctx context.Context, cwd string) ([]int, error)

FindByCWD walks the process list, filters to recognized Claude executable names, and returns PIDs whose cwd equals or is under the requested cwd. An empty cwd on a process is treated as "unknown" and skipped.

func (*RealKiller) Kill

func (k *RealKiller) Kill(_ context.Context, pids []int) ([]int, error)

Kill sends SIGTERM to every PID in the list. We never send SIGKILL: SIGTERM lets Claude Code flush its session log and exit cleanly. Users who really need a hard kill can `kill -9` the PID from the lockfile reason by hand.

Individual signal failures (process already exited, permission denied) are collected into a single aggregated error, but successfully-signaled PIDs are still returned in the first slice.

Jump to

Keyboard shortcuts

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