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 // - .raw — raw PTY bytes for each child, by id+name // // The capture is installed only when --debug= 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)) }