Files
codegirl-games/agentbox/internal/trace/store.go
T
2026-08-18 06:03:59 +00:00

181 lines
4.5 KiB
Go

package trace
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"agentbox/internal/environment"
)
// Store writes one run's metadata and append-only event streams.
type Store struct {
runDirectory string
run Run
stepsFile *os.File
actionsFile *os.File
}
func New(projectRoot, task, application, agentName string) (*Store, error) {
runID, err := newRunID()
if err != nil {
return nil, err
}
runDirectory := filepath.Join(projectRoot, ".agentbox", "runs", runID)
screenshotDirectory := filepath.Join(runDirectory, "screenshots")
if err := os.MkdirAll(screenshotDirectory, 0o755); err != nil {
return nil, fmt.Errorf("create run directory: %w", err)
}
stepsFile, err := os.Create(filepath.Join(runDirectory, "steps.jsonl"))
if err != nil {
return nil, fmt.Errorf("create step trace: %w", err)
}
actionsFile, err := os.Create(filepath.Join(runDirectory, "actions.jsonl"))
if err != nil {
_ = stepsFile.Close()
return nil, fmt.Errorf("create action trace: %w", err)
}
store := &Store{
runDirectory: runDirectory,
stepsFile: stepsFile,
actionsFile: actionsFile,
run: Run{
SchemaVersion: SchemaVersion,
ID: runID,
Task: task,
Application: application,
Agent: agentName,
Status: "running",
StartedAt: time.Now().UTC(),
},
}
if err := store.writeRunSummary(); err != nil {
_ = stepsFile.Close()
_ = actionsFile.Close()
return nil, err
}
return store, nil
}
func (store *Store) ID() string {
return store.run.ID
}
func (store *Store) Directory() string {
return store.runDirectory
}
func (store *Store) SaveScreenshot(stepNumber int, screenshot []byte) (string, error) {
relativePath := filepath.Join(
"screenshots",
fmt.Sprintf("%04d.png", stepNumber),
)
absolutePath := filepath.Join(store.runDirectory, relativePath)
if err := os.WriteFile(absolutePath, screenshot, 0o644); err != nil {
return "", fmt.Errorf("write screenshot: %w", err)
}
// Trace paths always use slash separators so traces are portable.
return filepath.ToSlash(relativePath), nil
}
func (store *Store) Record(step StepRecord) error {
if err := appendJSONLine(store.stepsFile, step); err != nil {
return fmt.Errorf("record step: %w", err)
}
if step.Action != nil {
action := actionRecord{
Step: step.Step,
Timestamp: step.Timestamp,
Action: *step.Action,
}
if err := appendJSONLine(store.actionsFile, action); err != nil {
return fmt.Errorf("record action: %w", err)
}
}
store.run.StepCount = step.Step
return store.writeRunSummary()
}
func (store *Store) WriteLogs(logEntries []environment.LogEntry) error {
var standardOutput, standardError string
for _, entry := range logEntries {
switch entry.Stream {
case "stdout":
standardOutput = entry.Message
case "stderr":
standardError = entry.Message
}
}
if err := os.WriteFile(
filepath.Join(store.runDirectory, "stdout.log"),
[]byte(standardOutput),
0o644,
); err != nil {
return err
}
return os.WriteFile(
filepath.Join(store.runDirectory, "stderr.log"),
[]byte(standardError),
0o644,
)
}
func (store *Store) Finish(runErr error) error {
store.closeEventFiles()
store.run.FinishedAt = time.Now().UTC()
if runErr != nil {
store.run.Status = "failed"
store.run.Error = runErr.Error()
} else {
store.run.Status = "complete"
}
return store.writeRunSummary()
}
func (store *Store) closeEventFiles() {
if store.stepsFile != nil {
_ = store.stepsFile.Close()
store.stepsFile = nil
}
if store.actionsFile != nil {
_ = store.actionsFile.Close()
store.actionsFile = nil
}
}
// Sync each JSONL record so an interrupted run retains its latest complete
// decision. Performance is secondary to debuggability in this local spike.
func appendJSONLine(file *os.File, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
if _, err := file.Write(append(data, '\n')); err != nil {
return err
}
return file.Sync()
}
func (store *Store) writeRunSummary() error {
data, err := json.MarshalIndent(store.run, "", " ")
if err != nil {
return err
}
runPath := filepath.Join(store.runDirectory, "run.json")
return os.WriteFile(runPath, append(data, '\n'), 0o644)
}
func newRunID() (string, error) {
randomSuffix := make([]byte, 3)
if _, err := rand.Read(randomSuffix); err != nil {
return "", err
}
timestamp := time.Now().UTC().Format("20060102T150405")
return timestamp + "-" + hex.EncodeToString(randomSuffix), nil
}