Skip to content

Commit 44efdb9

Browse files
committed
add SDK documentation
1 parent 04cf860 commit 44efdb9

1 file changed

Lines changed: 356 additions & 0 deletions

File tree

docs/sdk.md

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
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

Comments
 (0)