11
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user