Co-authored-by: Harry Bayliss <harry@hjb.dev> Co-committed-by: Harry Bayliss <harry@hjb.dev>
163 lines
3.7 KiB
Go
163 lines
3.7 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// debugCapture implements ChildEventListener and writes structured
|
|
// debug artefacts under a single directory:
|
|
//
|
|
// - patterm.log — the existing logf() stream
|
|
// - events.jsonl — one JSON object per lifecycle event
|
|
// - <id>.raw — raw PTY bytes for each child, by id+name
|
|
//
|
|
// The capture is installed only when --debug=<dir> is set, so default
|
|
// runs pay nothing.
|
|
type debugCapture struct {
|
|
dir string
|
|
logPath string
|
|
|
|
mu sync.Mutex
|
|
events *os.File
|
|
rawByID map[string]*os.File
|
|
}
|
|
|
|
func openDebugCapture(dir string) (*debugCapture, error) {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
logPath := filepath.Join(dir, "patterm.log")
|
|
// Truncate-style fresh log per run is friendlier for grep'ing one
|
|
// session. The existing logf opens O_APPEND though, so concurrent
|
|
// runs against the same dir would interleave — that's on the user.
|
|
if f, err := os.Create(logPath); err != nil {
|
|
return nil, err
|
|
} else {
|
|
_ = f.Close()
|
|
}
|
|
ev, err := os.Create(filepath.Join(dir, "events.jsonl"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dc := &debugCapture{
|
|
dir: dir,
|
|
logPath: logPath,
|
|
events: ev,
|
|
rawByID: make(map[string]*os.File),
|
|
}
|
|
dc.writeEvent("session_start", map[string]any{
|
|
"time": time.Now().Format(time.RFC3339Nano),
|
|
"pid": os.Getpid(),
|
|
})
|
|
return dc, nil
|
|
}
|
|
|
|
func (d *debugCapture) LogPath() string { return d.logPath }
|
|
|
|
func (d *debugCapture) Close() error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.writeEventLocked("session_end", map[string]any{
|
|
"time": time.Now().Format(time.RFC3339Nano),
|
|
})
|
|
for _, f := range d.rawByID {
|
|
_ = f.Close()
|
|
}
|
|
d.rawByID = nil
|
|
if d.events != nil {
|
|
_ = d.events.Close()
|
|
d.events = nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *debugCapture) OnChildSpawned(c *Child) {
|
|
d.writeEvent("child_spawned", map[string]any{
|
|
"time": time.Now().Format(time.RFC3339Nano),
|
|
"id": c.ID,
|
|
"name": c.Name,
|
|
"kind": string(c.Kind),
|
|
"parent_id": c.ParentID,
|
|
"preset": c.PresetRef,
|
|
"argv": c.Argv,
|
|
})
|
|
}
|
|
|
|
func (d *debugCapture) OnChildExited(c *Child) {
|
|
d.writeEvent("child_exited", map[string]any{
|
|
"time": time.Now().Format(time.RFC3339Nano),
|
|
"id": c.ID,
|
|
"name": c.Name,
|
|
"exit_code": c.ExitCode(),
|
|
})
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if f, ok := d.rawByID[c.ID]; ok {
|
|
_ = f.Close()
|
|
delete(d.rawByID, c.ID)
|
|
}
|
|
}
|
|
|
|
func (d *debugCapture) OnChildStateChanged(id string, state IdleState) {
|
|
d.writeEvent("child_state", map[string]any{
|
|
"time": time.Now().Format(time.RFC3339Nano),
|
|
"id": id,
|
|
"state": string(state),
|
|
})
|
|
}
|
|
|
|
func (d *debugCapture) OnChildClosed(id string) {
|
|
d.writeEvent("child_closed", map[string]any{
|
|
"time": time.Now().Format(time.RFC3339Nano),
|
|
"id": id,
|
|
})
|
|
}
|
|
|
|
func (d *debugCapture) OnPTYOut(childID string, chunk []byte) {
|
|
if len(chunk) == 0 {
|
|
return
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
f, ok := d.rawByID[childID]
|
|
if !ok {
|
|
path := filepath.Join(d.dir, childID+".raw")
|
|
nf, err := os.Create(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
f = nf
|
|
d.rawByID[childID] = nf
|
|
}
|
|
// Listener contract: don't retain chunk past return. Writing now
|
|
// is fine; the slice's backing buffer is reused for the next read
|
|
// only after this listener chain completes.
|
|
_, _ = f.Write(chunk)
|
|
}
|
|
|
|
func (d *debugCapture) writeEvent(kind string, fields map[string]any) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.writeEventLocked(kind, fields)
|
|
}
|
|
|
|
func (d *debugCapture) writeEventLocked(kind string, fields map[string]any) {
|
|
if d.events == nil {
|
|
return
|
|
}
|
|
if fields == nil {
|
|
fields = map[string]any{}
|
|
}
|
|
fields["event"] = kind
|
|
enc, err := json.Marshal(fields)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, _ = fmt.Fprintln(d.events, string(enc))
|
|
}
|