167 lines
4.3 KiB
Go
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
|
|
}
|