|
| 1 | +# Embedding refresh: the process SDK |
| 2 | + |
| 3 | +`refresh` is usable as a library, not just a CLI. An upstream application can |
| 4 | +embed the engine, **tap each process's stdout/stderr separately**, observe |
| 5 | +process lifecycle, and poll live process state — everything needed to drive a |
| 6 | +per-process TUI (think turbo's TUI mode) on top of the runner. |
| 7 | + |
| 8 | +This document covers that SDK surface. For watching/config basics see the main |
| 9 | +`README.md`. |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## The three taps |
| 14 | + |
| 15 | +There are three integration points, all opt-in via `engine.Config`. When you set |
| 16 | +none of them, behavior is unchanged: process output goes straight to the |
| 17 | +terminal. |
| 18 | + |
| 19 | +| Tap | Config field | Direction | Use it for | |
| 20 | +|-----|--------------|-----------|------------| |
| 21 | +| **Output** | `Output OutputFunc` | push (bytes) | per-process log panes | |
| 22 | +| **Events** | `OnProcessEvent EventFunc` | push (state) | status changes, restart notifications | |
| 23 | +| **Snapshot** | `engine.Processes()` | pull (state) | rendering a status table on a tick | |
| 24 | + |
| 25 | +The split is deliberate: stream **output is pushed** (you can't poll bytes), while |
| 26 | +**status is pulled** on your render tick to avoid backpressure on the engine's |
| 27 | +supervisor goroutine. Events exist for when you need to react to a transition the |
| 28 | +moment it happens (e.g. flash a pane red on crash) rather than wait for the next |
| 29 | +tick. |
| 30 | + |
| 31 | +--- |
| 32 | + |
| 33 | +## 1. Capturing per-process output |
| 34 | + |
| 35 | +Set `Config.Output`. It is called once per stream (`"stdout"` and `"stderr"`) |
| 36 | +when a process starts, and returns the `io.Writer` that stream is wired to. |
| 37 | + |
| 38 | +```go |
| 39 | +import ( |
| 40 | + "io" |
| 41 | + |
| 42 | + "github.com/atterpac/refresh/engine" |
| 43 | +) |
| 44 | + |
| 45 | +// One buffer per process name — these are what your TUI renders as panes. |
| 46 | +panes := map[string]io.Writer{ |
| 47 | + "build": newPaneBuffer(), |
| 48 | + "server": newPaneBuffer(), |
| 49 | +} |
| 50 | + |
| 51 | +cfg := engine.Config{ |
| 52 | + RootPath: "./", |
| 53 | + ExecStruct: []engine.Execute{ |
| 54 | + {Name: "build", Cmd: "go build -o ./app", Type: engine.Blocking}, |
| 55 | + {Name: "server", Cmd: "./app", Type: engine.Primary}, |
| 56 | + }, |
| 57 | + Output: func(info engine.ProcessInfo, stream string) io.Writer { |
| 58 | + // Route both streams of a process into its pane. Return a distinct |
| 59 | + // writer per stream if you want to style stderr differently. |
| 60 | + return panes[info.Name] |
| 61 | + }, |
| 62 | +} |
| 63 | +``` |
| 64 | + |
| 65 | +Key points: |
| 66 | + |
| 67 | +- **`info.Name` is the routing key.** Set `Name` on each `Execute`. If you leave |
| 68 | + it empty it defaults to the command string, but a stable explicit name is what |
| 69 | + you want as a pane key. |
| 70 | +- **Returning `nil` keeps the default** (the process's own `os.Stdout` / |
| 71 | + `os.Stderr`). So you can capture some processes and let others print normally: |
| 72 | + |
| 73 | + ```go |
| 74 | + Output: func(info engine.ProcessInfo, stream string) io.Writer { |
| 75 | + if p, ok := panes[info.Name]; ok { |
| 76 | + return p |
| 77 | + } |
| 78 | + return nil // not a tracked process — leave it on the terminal |
| 79 | + } |
| 80 | + ``` |
| 81 | +- **You fully own the writer.** Unlike a tee, refresh does *not* also copy to the |
| 82 | + terminal when you return a writer — important for a TUI that owns the screen. |
| 83 | + If you *want* a tee, return `io.MultiWriter(os.Stdout, yourBuffer)` yourself. |
| 84 | +- **Your writer is called from the process's own goroutine.** Make it |
| 85 | + goroutine-safe (guard a `bytes.Buffer` with a mutex, or write to a channel). |
| 86 | + |
| 87 | +--- |
| 88 | + |
| 89 | +## 2. Observing lifecycle (events) |
| 90 | + |
| 91 | +Set `Config.OnProcessEvent` to receive a `ProcessEvent` on every state change. |
| 92 | + |
| 93 | +```go |
| 94 | +cfg.OnProcessEvent = func(ev engine.ProcessEvent) { |
| 95 | + // ev.Info is a snapshot; ev.Time is when it changed; ev.Err is set on failure. |
| 96 | + log.Printf("%s -> %s (pid %d)", ev.Info.Name, ev.Info.State, ev.Info.PID) |
| 97 | + if ev.Info.State == engine.StateFailed { |
| 98 | + tui.FlashRed(ev.Info.Name, ev.Err) |
| 99 | + } |
| 100 | +} |
| 101 | +``` |
| 102 | + |
| 103 | +States (`engine.ProcessState`): |
| 104 | + |
| 105 | +| State | Meaning | |
| 106 | +|-------|---------| |
| 107 | +| `StatePending` | configured, not yet started in this run | |
| 108 | +| `StateRunning` | started; `PID` is live | |
| 109 | +| `StateExited` | finished on its own, exit code 0 | |
| 110 | +| `StateFailed` | finished on its own, non-zero exit (`ExitCode` + `Err` set) | |
| 111 | +| `StateKilled` | terminated by refresh (a reload restarting the primary, or shutdown) | |
| 112 | + |
| 113 | +Typical sequences: |
| 114 | + |
| 115 | +- **blocking/once step:** `Running → Exited` (or `Failed`) |
| 116 | +- **primary:** `Running`, then on each reload `Killed → Running`, and `Killed` at shutdown |
| 117 | +- **background:** `Running` once, `Killed` at shutdown |
| 118 | + |
| 119 | +> `OnProcessEvent` is called **synchronously from the engine's goroutine — do not |
| 120 | +> block in it.** If you need to do real work, hand the event to your own channel |
| 121 | +> and return immediately. |
| 122 | +
|
| 123 | +--- |
| 124 | + |
| 125 | +## 3. Polling state (snapshot) |
| 126 | + |
| 127 | +`engine.Processes()` returns a `[]ProcessInfo` snapshot of every configured |
| 128 | +process and its current state. Safe to call from any goroutine, including before |
| 129 | +the engine starts (everything reports `StatePending`). This is the natural fit |
| 130 | +for a TUI that redraws on a tick: |
| 131 | + |
| 132 | +```go |
| 133 | +for range ticker.C { |
| 134 | + for _, p := range eng.Processes() { |
| 135 | + tui.Row(p.Name, p.State, p.PID, time.Since(p.StartedAt)) |
| 136 | + } |
| 137 | +} |
| 138 | +``` |
| 139 | + |
| 140 | +`ProcessInfo` is a value copy with no live handles, so you can hold and diff |
| 141 | +snapshots freely. |
| 142 | + |
| 143 | +```go |
| 144 | +type ProcessInfo struct { |
| 145 | + Name string // stable id / pane key (defaults to Exec) |
| 146 | + Exec string // the command string |
| 147 | + Type ExecuteType // background | once | blocking | primary |
| 148 | + State ProcessState |
| 149 | + PID int // 0 when not running |
| 150 | + StartedAt time.Time // zero if never started |
| 151 | + ExitCode int // last completed run; -1 if killed / never exited |
| 152 | +} |
| 153 | +``` |
| 154 | + |
| 155 | +--- |
| 156 | + |
| 157 | +## Driving the engine: `Run(ctx)` vs `Start()` |
| 158 | + |
| 159 | +A TUI owns the terminal and the keyboard, so it must **not** let the engine grab |
| 160 | +OS signals. Use `Run(ctx)` instead of `Start()`: |
| 161 | + |
| 162 | +| | `Start()` | `Run(ctx)` | |
| 163 | +|--|-----------|------------| |
| 164 | +| Blocks until | SIGINT/SIGTERM | `ctx` cancelled (or `Stop()`) | |
| 165 | +| Traps SIGINT/SIGTERM | yes | **no** | |
| 166 | +| Traps Ctrl+Z pause (if `EnablePause`) | yes | **no** — you drive pause via your own input | |
| 167 | +| Intended caller | the CLI | an embedding app / TUI | |
| 168 | + |
| 169 | +```go |
| 170 | +ctx, cancel := context.WithCancel(context.Background()) |
| 171 | +defer cancel() |
| 172 | + |
| 173 | +eng, err := engine.NewEngineFromConfig(cfg) |
| 174 | +if err != nil { |
| 175 | + return err |
| 176 | +} |
| 177 | + |
| 178 | +go func() { |
| 179 | + if err := eng.Run(ctx); err != nil { |
| 180 | + log.Printf("engine: %v", err) |
| 181 | + } |
| 182 | +}() |
| 183 | + |
| 184 | +// ... run your TUI event loop; on quit: |
| 185 | +cancel() // engine tears down all processes cleanly, Run returns nil |
| 186 | +``` |
| 187 | + |
| 188 | +`Run` blocks, so call it from its own goroutine. Cancelling `ctx` (or calling |
| 189 | +`eng.Stop()`) triggers a clean shutdown: every process is killed, you get the |
| 190 | +matching `StateKilled` events, and `Run` returns `nil`. |
| 191 | + |
| 192 | +Because `Run` installs no signal traps, **your TUI owns pause/resume** — drive it |
| 193 | +through the control API below. |
| 194 | + |
| 195 | +--- |
| 196 | + |
| 197 | +## Controlling the engine: Reload / Pause / Resume |
| 198 | + |
| 199 | +Under `Run(ctx)` the engine installs no Ctrl+Z handler, so the embedding app |
| 200 | +drives the supervisor through four methods. All are safe to call from any |
| 201 | +goroutine (your input handler, a button, a keybinding) and are non-blocking. |
| 202 | + |
| 203 | +| Method | Effect | |
| 204 | +|--------|--------| |
| 205 | +| `Reload()` | Trigger a reload cycle (re-run blocking steps, restart the primary), exactly as a file change would. Deferred if paused. | |
| 206 | +| `Pause()` | Suspend reload handling. File changes and `Reload` calls made while paused are remembered, not dropped. Idempotent. | |
| 207 | +| `Resume()` | Re-enable reloads and apply any single change that arrived while paused. Idempotent. | |
| 208 | +| `Paused() bool` | Report the current pause state (e.g. to render a "PAUSED" badge). | |
| 209 | + |
| 210 | +```go |
| 211 | +// Wire a TUI keymap to the control API. |
| 212 | +switch key { |
| 213 | +case 'r': |
| 214 | + eng.Reload() // force a rebuild/restart on demand |
| 215 | +case ' ': |
| 216 | + if eng.Paused() { |
| 217 | + eng.Resume() |
| 218 | + } else { |
| 219 | + eng.Pause() |
| 220 | + } |
| 221 | +} |
| 222 | +``` |
| 223 | + |
| 224 | +Semantics worth knowing: |
| 225 | + |
| 226 | +- **Pause defers, it does not drop.** A reload that arrives while paused (whether |
| 227 | + from a file change or a `Reload()` call) is coalesced into a single pending |
| 228 | + reload and applied the moment you `Resume()`. Multiple changes while paused |
| 229 | + still result in exactly one reload on resume. |
| 230 | +- **A no-op resume does nothing.** `Resume()` with no change pending will not |
| 231 | + restart the primary. |
| 232 | +- **These work the same with `Start()`.** The CLI's Ctrl+Z toggle (when |
| 233 | + `EnablePause` is set) is just a wrapper around `Pause`/`Resume`; calling them |
| 234 | + programmatically and pressing Ctrl+Z share one pause flag. With `Run(ctx)` the |
| 235 | + Ctrl+Z trap is off, but the methods still work. |
| 236 | +- **`Reload()` honors pause**: a forced reload while paused is itself deferred to |
| 237 | + resume, so a paused engine truly holds still. |
| 238 | + |
| 239 | +--- |
| 240 | + |
| 241 | +## Full example |
| 242 | + |
| 243 | +```go |
| 244 | +package main |
| 245 | + |
| 246 | +import ( |
| 247 | + "bytes" |
| 248 | + "context" |
| 249 | + "io" |
| 250 | + "log" |
| 251 | + "sync" |
| 252 | + "time" |
| 253 | + |
| 254 | + "github.com/atterpac/refresh/engine" |
| 255 | +) |
| 256 | + |
| 257 | +// pane is a goroutine-safe buffer standing in for a TUI log pane. |
| 258 | +type pane struct { |
| 259 | + mu sync.Mutex |
| 260 | + buf bytes.Buffer |
| 261 | +} |
| 262 | + |
| 263 | +func (p *pane) Write(b []byte) (int, error) { |
| 264 | + p.mu.Lock() |
| 265 | + defer p.mu.Unlock() |
| 266 | + return p.buf.Write(b) |
| 267 | +} |
| 268 | +func (p *pane) String() string { |
| 269 | + p.mu.Lock() |
| 270 | + defer p.mu.Unlock() |
| 271 | + return p.buf.String() |
| 272 | +} |
| 273 | + |
| 274 | +func main() { |
| 275 | + panes := map[string]*pane{"build": {}, "server": {}} |
| 276 | + |
| 277 | + cfg := engine.Config{ |
| 278 | + RootPath: "./", |
| 279 | + LogLevel: "mute", // silence engine chatter; you render your own UI |
| 280 | + ExecStruct: []engine.Execute{ |
| 281 | + {Name: "build", Cmd: "go build -o ./app", Type: engine.Blocking}, |
| 282 | + {Name: "server", Cmd: "./app", Type: engine.Primary}, |
| 283 | + }, |
| 284 | + Output: func(info engine.ProcessInfo, _ string) io.Writer { |
| 285 | + if p, ok := panes[info.Name]; ok { |
| 286 | + return p |
| 287 | + } |
| 288 | + return nil |
| 289 | + }, |
| 290 | + OnProcessEvent: func(ev engine.ProcessEvent) { |
| 291 | + log.Printf("event: %s -> %s", ev.Info.Name, ev.Info.State) |
| 292 | + }, |
| 293 | + } |
| 294 | + |
| 295 | + eng, err := engine.NewEngineFromConfig(cfg) |
| 296 | + if err != nil { |
| 297 | + log.Fatal(err) |
| 298 | + } |
| 299 | + |
| 300 | + ctx, cancel := context.WithCancel(context.Background()) |
| 301 | + defer cancel() |
| 302 | + go func() { _ = eng.Run(ctx) }() |
| 303 | + |
| 304 | + // Stand-in render loop: print status + pane contents every second. |
| 305 | + for range time.Tick(time.Second) { |
| 306 | + for _, p := range eng.Processes() { |
| 307 | + log.Printf("[%s] %s pid=%d", p.Name, p.State, p.PID) |
| 308 | + } |
| 309 | + log.Printf("server log:\n%s", panes["server"].String()) |
| 310 | + } |
| 311 | +} |
| 312 | +``` |
| 313 | + |
| 314 | +--- |
| 315 | + |
| 316 | +## Threading & safety notes |
| 317 | + |
| 318 | +- `Output` and `OnProcessEvent` callbacks run on engine-owned goroutines. Keep |
| 319 | + them non-blocking and make any shared state goroutine-safe. |
| 320 | +- `Processes()` is `RWMutex`-guarded and copies everything — call it as often as |
| 321 | + your render loop needs. |
| 322 | +- An event handler may safely call `Processes()`; events are dispatched outside |
| 323 | + the engine's internal lock specifically to allow this. |
| 324 | +- All of this is additive. With no hooks set, `refresh` behaves exactly as the |
| 325 | + CLI does today. |
| 326 | + |
| 327 | +--- |
| 328 | + |
| 329 | +## API reference (engine package) |
| 330 | + |
| 331 | +```go |
| 332 | +// Config fields |
| 333 | +Output engine.OutputFunc // func(ProcessInfo, stream string) io.Writer |
| 334 | +OnProcessEvent engine.EventFunc // func(ProcessEvent) |
| 335 | + |
| 336 | +// Engine methods |
| 337 | +func (e *Engine) Run(ctx context.Context) error // supervise until ctx cancelled; no signal traps |
| 338 | +func (e *Engine) Start() error // CLI entry; traps OS signals, blocks |
| 339 | +func (e *Engine) Stop() // request graceful shutdown |
| 340 | +func (e *Engine) Processes() []engine.ProcessInfo // live snapshot, any goroutine |
| 341 | +func (e *Engine) Reload() // force a reload cycle (deferred if paused) |
| 342 | +func (e *Engine) Pause() // suspend reloads; remembers a deferred change |
| 343 | +func (e *Engine) Resume() // re-enable reloads; applies a deferred change |
| 344 | +func (e *Engine) Paused() bool // current pause state |
| 345 | + |
| 346 | +// Types (re-exported from the process package) |
| 347 | +engine.ProcessInfo |
| 348 | +engine.ProcessEvent |
| 349 | +engine.ProcessState |
| 350 | +engine.OutputFunc |
| 351 | +engine.EventFunc |
| 352 | + |
| 353 | +// State constants |
| 354 | +engine.StatePending engine.StateRunning engine.StateExited |
| 355 | +engine.StateFailed engine.StateKilled |
| 356 | +``` |
0 commit comments