Skip to content

Generic per-volume hook foundation + on-change trigger#88

Merged
mbertschler merged 5 commits into
mainfrom
claude/squirrel-generic-hooks-UkpNz
Jun 4, 2026
Merged

Generic per-volume hook foundation + on-change trigger#88
mbertschler merged 5 commits into
mainfrom
claude/squirrel-generic-hooks-UkpNz

Conversation

@mbertschler
Copy link
Copy Markdown
Owner

Adds the tool-agnostic, best-effort hook foundation plus the on-change (event) trigger — the first sub-issue of the generic-hooks tracker. The interval ("check") trigger follows in a stacked PR (#86).

What this does

Per-volume, user-configured commands the agent runs to opportunistically nudge an external tool when a volume's content settles. squirrel never learns what the command does (kopia is just one consumer): it exec's the command without a shell, passes context via environment variables, and records only the generic outcome. A hook failure or timeout never fails or blocks the triggering run.

Config

[volumes.pictures.hook]
command = ["kopia", "snapshot", "create", "."]
timeout = "30m"   # optional, defaults to 1h

A single command per volume (no rules engine). It's distinguished across triggers by SQUIRREL_TRIGGER, so this shape already anticipates the interval trigger without over-building.

The shared contract (both triggers will reuse it)

  • Context via env vars, command exec'd without a shell — the volume path is never string-concatenated into a command line. Env: SQUIRREL_VOLUME, SQUIRREL_PATH, SQUIRREL_RUN_ID, SQUIRREL_CHANGED, SQUIRREL_TRIGGER.
  • Best-effort: asynchronous fire; never affects the run's status.
  • Don't stack: a per-volume guard skips a fire while a prior invocation for that volume is still running.
  • Timeout-bounded: context timeout + cmd.WaitDelay so a hook that spawns a grandchild holding a pipe can't wedge the scheduler.

On-change trigger

Fires after every successful index run (success or partial) the agent's scheduler completes — keying off content settled, not off a sync to a remote (a volume need not have any sync_to destination). Per the issue's lean default it always fires on a completed run and passes SQUIRREL_CHANGED (additions, modifications, or disappearances) so the consumer can cheaply no-op.

Outcome recording & visibility

New hook_runs table (migration v10→v11, additive — no existing row is rewritten, preserving the content-immutability invariant). Records exit code, started/ended timestamps, the triggering run, and the changed flag. Surfaced via:

  • squirrel hooks (new CLI listing)
  • a Hooks tab in the TUI

Layout

  • config/[volumes.X.hook] parsing + validation
  • hook/ — pure exec library (env contract, timeout, bounded stderr capture, generic Outcome)
  • store/hook_runs schema + BeginHookRun/FinishHookRun/ListHookRuns
  • agent/hookRunner (don't-stack guard, spawn/reap, recording) wired into the scheduler's post-index path
  • cmd/squirrel/hooks.go, tui/hooks.go — visibility

Tests cover config resolution, the store layer, the exec library (success / non-zero exit / timeout / spawn failure / env contract / parent cancellation), and the agent runner (success, failure recording, don't-stack, and the real scheduler tick firing the change hook). go vet ./... clean; no new dependencies.

Closes #85
Part of #84


Generated by Claude Code

Introduce a tool-agnostic, best-effort hook mechanism: per-volume,
user-configured commands the agent exec's (without a shell) after a
successful index run, passing context via SQUIRREL_* env vars and
recording only the generic outcome (exit code, timestamps, triggering
run). squirrel never learns what the command does.

- config: [volumes.X.hook] with `command` (argv, no shell) and optional
  `timeout` (default 1h). The single command is distinguished across
  triggers by SQUIRREL_TRIGGER, leaving room for the interval trigger
  without an over-built rules engine.
- hook package: pure library that exec's argv, sets the SQUIRREL_*
  contract, bounds the run with a timeout + WaitDelay (so a grandchild
  holding a pipe can't wedge the agent), and reports a generic Outcome.
- store: hook_runs table (migration v10->v11, additive — no existing row
  rewritten) plus Begin/Finish/List. Records exit code, timestamps, the
  triggering run, and the SQUIRREL_CHANGED flag.
- agent: hookRunner with a per-volume don't-stack guard and tracked
  goroutines drained on shutdown; the scheduler fires the on-change hook
  after every successful index run, best-effort and asynchronous so it
  never affects the run that triggered it.
- visibility: `squirrel hooks` CLI listing and a TUI Hooks tab.

Closes #85
Part of #84

https://claude.ai/code/session_016huzsmuzXRLEWJSCTMctEa
Copilot AI review requested due to automatic review settings June 3, 2026 23:12
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a generic, tool-agnostic per-volume hook mechanism that runs best-effort external commands after index runs, records their generic outcomes in the store, and surfaces history in both CLI and TUI.

Changes:

  • Add hook configuration parsing/validation ([volumes.<name>.hook]) with default timeout behavior.
  • Add hook execution library + agent runner to fire on-change hooks asynchronously (don’t-stack, timeout-bounded) and persist outcomes in a new hook_runs table.
  • Add visibility via squirrel hooks CLI and a new Hooks tab in the TUI.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tui/tui.go Adds “Hooks” as a new top-level TUI tab and keybinding.
tui/hooks.go New TUI screen that lists recorded hook runs and status.
store/migrations.go Bumps schema to v11 and adds hook_runs table + index.
store/hookruns.go Store layer for recording and listing hook run lifecycle.
store/hookruns_test.go Store tests covering hook run insert/finish/list semantics.
README.md Documents hook configuration, behavior, and env contract.
hook/hook.go New hook execution library (no shell, env contract, timeout, bounded stderr).
hook/hook_test.go Tests for hook execution outcomes, env contract, and bounding behavior.
config/config.go Adds VolumeHook config model and TOML resolution/validation.
config/hook_test.go Tests for hook config resolution, defaults, and errors.
cmd/squirrel/root.go Registers the new hooks subcommand.
cmd/squirrel/hooks.go New CLI listing for hook run history.
agent/scheduler.go Wires on-change hook firing into successful index completion + shutdown drain.
agent/scheduler_test.go Scheduler fixture updated to include hook runner.
agent/hooks.go Implements hookRunner (don’t-stack guard, async exec, outcome recording).
agent/hooks_test.go Tests runner behavior and tick-path firing/recording.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread hook/hook.go
Comment on lines +191 to +202
func (b *boundedBuffer) Write(p []byte) (int, error) {
if room := b.limit - b.buf.Len(); room > 0 {
if len(p) <= room {
b.buf.Write(p)
} else {
b.buf.Write(p[:room])
}
}
// Report the full length as written: we are intentionally discarding
// the overflow, not failing the command's write.
return len(p), nil
}
Comment thread hook/hook.go
Comment on lines +100 to +110
func Run(ctx context.Context, spec Spec) Outcome {
out := Outcome{StartedAtNs: time.Now().UnixNano()}
if len(spec.Command) == 0 {
out.EndedAtNs = time.Now().UnixNano()
out.Err = errors.New("hook: empty command")
return out
}

runCtx, cancel := context.WithTimeout(ctx, spec.Timeout)
defer cancel()

Comment thread store/hookruns.go
Comment on lines +75 to +82
// BeginHookRun records the start of a hook invocation and returns its id.
// The trigger must be one of the HookTrigger* constants. The row begins
// in status 'running'; FinishHookRun moves it to a terminal state. A
// non-zero spec.TriggeringRunID is stored as the FK; zero stores NULL.
func (s *Store) BeginHookRun(ctx context.Context, spec HookRunSpec) (int64, error) {
if spec.Trigger != HookTriggerChange && spec.Trigger != HookTriggerInterval {
return 0, fmt.Errorf("BeginHookRun: trigger must be %q or %q, got %q", HookTriggerChange, HookTriggerInterval, spec.Trigger)
}
Comment thread agent/hooks_test.go Outdated
IndexEvery: time.Minute,
Hook: &config.VolumeHook{
// Touch a marker so we can prove the command actually ran.
Command: []string{"sh", "-c", "echo x > " + marker},
claude added 2 commits June 3, 2026 23:19
- execute: drop the unused volumeID parameter (the goroutine in fire
  already owns done(volumeID)).
- fire: keep trigger as a parameter so the foundation stays
  trigger-agnostic, suppressing unparam until the interval caller in #86
  exercises the second value.

https://claude.ai/code/session_016huzsmuzXRLEWJSCTMctEa
- hook.boundedBuffer now keeps the last `limit` bytes (the tail) instead
  of the first, so a verbose failing command's recorded stderr shows the
  context near the error, matching the field's documented intent.
- hook.Run rejects a non-positive Timeout with a clear error instead of
  letting context.WithTimeout fire instantly and surface a phantom
  "timed out" outcome.
- BeginHookRun enforces the trigger↔triggering-run coupling (change ⇒ has
  a run, interval ⇒ none), backed by a hook_runs CHECK that mirrors the
  runs table's kind↔destination idiom — a wiring bug now fails loudly
  instead of writing a misleading NULL run.
- Test fixups: the scheduler change-hook test passes the marker path as an
  argument ($1) rather than concatenating it into the sh -c script.

https://claude.ai/code/session_016huzsmuzXRLEWJSCTMctEa
mbertschler pushed a commit that referenced this pull request Jun 3, 2026
…ling

Brings #88's review fixes into the interval branch (stderr tail capture,
positive-timeout guard, the trigger↔triggering-run coupling) and updates
TestLatestHookRun so its change-hook fixture carries a real triggering run
as the new coupling now requires.

https://claude.ai/code/session_016huzsmuzXRLEWJSCTMctEa
claude added 2 commits June 3, 2026 23:28
Resolves the store/migrations.go conflict: main introduced v11 (runs_audit)
and v12 (peer_sync_state_history), so the hook_runs migration is renumbered
to v13 (migrateV12ToV13) and SchemaVersion bumped to 13.

Also reconciles a semantic conflict main shipped broken: c2fb4a4 added an
allowRewind bool to Store.UpsertPeerSyncState while 298b9b6 added an
agent/sync_test.go caller still using the old 4-arg form, so main did not
compile. Pass allowRewind=false (a forward watermark write) to match every
other caller.

https://claude.ai/code/session_016huzsmuzXRLEWJSCTMctEa
@mbertschler mbertschler merged commit 59362b3 into main Jun 4, 2026
2 checks passed
@mbertschler mbertschler deleted the claude/squirrel-generic-hooks-UkpNz branch June 4, 2026 11:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generic post-change hook (foundation + on-change trigger)

3 participants