Documentation
¶
Overview ¶
Package enforcer implements the two sides of budget-breach enforcement for budgetclaw:
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.
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 ¶
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 ¶
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 ¶
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.
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 ¶
NewLockStore creates a store at the default XDG location and ensures the directory exists.
func NewLockStoreAt ¶
NewLockStoreAt creates a store at an explicit directory. Used by tests that want an isolated temp dir.
func (*LockStore) Acquire ¶
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 ¶
Dir returns the on-disk directory. Used by `budgetclaw locks path` and similar diagnostic commands.
func (*LockStore) IsLocked ¶
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 ¶
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).
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 ¶
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 ¶
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.