Files
superwork/tui/internal/cc/codex_session_watcher.go
2026-06-23 05:02:15 +08:00

167 lines
4.3 KiB
Go

package cc
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/fsnotify/fsnotify"
)
// rolloutUUIDRegex matches the UUID at the tail of rollout-<ISO>-<UUID>.jsonl.
// Matches v7 UUIDs (still 8-4-4-4-12 hex groups).
var rolloutUUIDRegex = regexp.MustCompile(`-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$`)
// extractIDFromRolloutFilename tries to extract the thread_id UUID from a
// rollout filename. Returns "" when the name doesn't match the expected pattern.
func extractIDFromRolloutFilename(filename string) string {
m := rolloutUUIDRegex.FindStringSubmatch(strings.ToLower(filename))
if m == nil {
return ""
}
return m[1]
}
// CodexSessionsDir returns the codex sessions directory for a given date:
// <homeDir>/.codex/sessions/YYYY/MM/DD. The time.Time parameter makes this
// testable without calling time.Now() inside the library.
func CodexSessionsDir(homeDir string, t time.Time) string {
return filepath.Join(homeDir, ".codex", "sessions",
fmt.Sprintf("%04d", t.Year()),
fmt.Sprintf("%02d", t.Month()),
fmt.Sprintf("%02d", t.Day()),
)
}
// CodexSessionWatchOpts configures WatchForNewCodexSession.
type CodexSessionWatchOpts struct {
SessionsDir string
Timeout time.Duration
}
// WatchForNewCodexSession watches SessionsDir for the next new rollout-*.jsonl
// file. It prefers the UUID embedded in the filename; falls back to parsing
// payload.id from the first JSON line. Returns ("", nil) on timeout or context
// cancellation. MkdirAll ensures the leaf dir exists so the first-of-day
// session (when codex creates the YYYY/MM/DD dir from scratch) is captured.
//
// Known limitation: a session file landing in a different day-dir than the one
// watched (watcher started just before midnight) will not be caught.
func WatchForNewCodexSession(ctx context.Context, opts CodexSessionWatchOpts) (string, error) {
// Ensure the target dir exists so fsnotify can watch it even before codex
// creates it for the first time today.
if err := os.MkdirAll(opts.SessionsDir, 0o755); err != nil {
return "", nil
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return "", nil
}
defer watcher.Close()
if err := watcher.Add(opts.SessionsDir); err != nil {
return "", nil
}
// Deduplicate: fsnotify may fire multiple events for the same file.
seen := make(map[string]bool)
timer := time.NewTimer(opts.Timeout)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return "", nil
case <-timer.C:
return "", nil
case event, ok := <-watcher.Events:
if !ok {
return "", nil
}
if event.Op&(fsnotify.Create|fsnotify.Rename) == 0 {
continue
}
name := filepath.Base(event.Name)
if !strings.HasPrefix(name, "rollout-") || !strings.HasSuffix(name, ".jsonl") {
continue
}
if seen[name] {
continue
}
seen[name] = true
// Confirm the file exists (rename fires for deletes too).
if _, err := os.Stat(event.Name); err != nil {
continue
}
// Prefer UUID from filename; fall back to first-line JSON payload.id.
if id := extractIDFromRolloutFilename(name); id != "" {
return id, nil
}
if id := extractIDFromFirstLine(event.Name); id != "" {
return id, nil
}
// Could not extract an id — keep watching.
case _, ok := <-watcher.Errors:
if !ok {
return "", nil
}
}
}
}
// sessionMetaLine is the shape of the first JSON line in a codex rollout file.
type sessionMetaLine struct {
Payload *struct {
ID string `json:"id"`
} `json:"payload"`
ThreadID string `json:"thread_id"`
SessionID string `json:"session_id"`
ID string `json:"id"`
}
// extractIDFromFirstLine reads the first JSON line of filePath and returns the
// thread id found in payload.id, thread_id, session_id, or id (in that order).
func extractIDFromFirstLine(filePath string) string {
f, err := os.Open(filePath)
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
if !scanner.Scan() {
return ""
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
return ""
}
var meta sessionMetaLine
if err := json.Unmarshal([]byte(line), &meta); err != nil {
return ""
}
if meta.Payload != nil && meta.Payload.ID != "" {
return meta.Payload.ID
}
if meta.ThreadID != "" {
return meta.ThreadID
}
if meta.SessionID != "" {
return meta.SessionID
}
return meta.ID
}