waitfor: block until a shell condition holds
--cmd exits 0 (+ optional --matches regex) or --timeout; exit 0/3/1, last output always printed, --then hook composes with notifyr. Injectable runner for tests + real-shell integration test. Spec: doc/tool-parity.md 3.2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
This commit is contained in:
94
waitfor/wait/wait.go
Normal file
94
waitfor/wait/wait.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Package wait blocks until a shell condition holds: run a command
|
||||
// every interval until it exits 0 (and, optionally, its output matches
|
||||
// a regex) or a timeout expires. One blocking call instead of an
|
||||
// agent burning turns on poll loops.
|
||||
package wait
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Cmd string // shell command to run each attempt (required)
|
||||
Matches string // optional regex the output must match
|
||||
Interval time.Duration // between attempts
|
||||
Timeout time.Duration // total budget
|
||||
Verbose bool // progress line per attempt to the log func
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Met bool
|
||||
Attempts int
|
||||
Elapsed time.Duration
|
||||
LastOut string
|
||||
LastExit int
|
||||
}
|
||||
|
||||
// Runner executes a shell command, returning combined output and exit
|
||||
// code. Separated out so tests can fake it.
|
||||
type Runner func(cmd string) (string, int)
|
||||
|
||||
// ShellRunner runs via sh -c with combined stdout+stderr.
|
||||
func ShellRunner(cmd string) (string, int) {
|
||||
c := exec.Command("sh", "-c", cmd)
|
||||
out, err := c.CombinedOutput()
|
||||
code := 0
|
||||
if err != nil {
|
||||
code = 1
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
code = ee.ExitCode()
|
||||
}
|
||||
}
|
||||
return string(out), code
|
||||
}
|
||||
|
||||
// Wait polls until the condition holds or the timeout expires. The
|
||||
// first attempt runs immediately. log receives progress lines when
|
||||
// Verbose is set (pass nil otherwise).
|
||||
func Wait(opts Options, run Runner, log func(string)) (Result, error) {
|
||||
if opts.Cmd == "" {
|
||||
return Result{}, fmt.Errorf("no command given")
|
||||
}
|
||||
var re *regexp.Regexp
|
||||
if opts.Matches != "" {
|
||||
var err error
|
||||
re, err = regexp.Compile(opts.Matches)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("bad --matches regex: %v", err)
|
||||
}
|
||||
}
|
||||
if opts.Interval <= 0 {
|
||||
opts.Interval = 10 * time.Second
|
||||
}
|
||||
if opts.Timeout <= 0 {
|
||||
opts.Timeout = 10 * time.Minute
|
||||
}
|
||||
start := time.Now()
|
||||
res := Result{}
|
||||
for {
|
||||
res.Attempts++
|
||||
out, code := run(opts.Cmd)
|
||||
res.LastOut, res.LastExit = out, code
|
||||
met := code == 0 && (re == nil || re.MatchString(out))
|
||||
if opts.Verbose && log != nil {
|
||||
state := "not yet"
|
||||
if met {
|
||||
state = "met"
|
||||
}
|
||||
log(fmt.Sprintf("attempt %d: exit %d, condition %s", res.Attempts, code, state))
|
||||
}
|
||||
if met {
|
||||
res.Met = true
|
||||
res.Elapsed = time.Since(start)
|
||||
return res, nil
|
||||
}
|
||||
if time.Since(start)+opts.Interval > opts.Timeout {
|
||||
res.Elapsed = time.Since(start)
|
||||
return res, nil
|
||||
}
|
||||
time.Sleep(opts.Interval)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user