feat: add session log package (append/read JSONL)
Introduces internal/session with Entry and Attempt types, Append (O_APPEND JSONL writer) and Read (line scanner, nil on missing file). Raw material for retrospective and trainer workers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
83
internal/session/session.go
Normal file
83
internal/session/session.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// internal/session/session.go
|
||||
package session
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Entry is one skill invocation record, appended to the session JSONL log.
|
||||
type Entry struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Skill string `json:"skill"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
ProjectRoot string `json:"project_root,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
Attempts []Attempt `json:"attempts,omitempty"`
|
||||
FinalStatus string `json:"final_status"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
ModelUsed string `json:"model_used,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
}
|
||||
|
||||
// Attempt represents one subprocess invocation within a skill call.
|
||||
type Attempt struct {
|
||||
Attempt int `json:"attempt"`
|
||||
Model string `json:"model"`
|
||||
OutputSummary string `json:"output_summary,omitempty"`
|
||||
RunnerOutput string `json:"runner_output,omitempty"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// Append writes entry as a single JSON line to sessionsDir/{sessionID}.jsonl.
|
||||
func Append(sessionsDir, sessionID string, entry Entry) error {
|
||||
if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create sessions dir: %w", err)
|
||||
}
|
||||
path := filepath.Join(sessionsDir, sessionID+".jsonl")
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open session log: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
line, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal entry: %w", err)
|
||||
}
|
||||
_, err = fmt.Fprintf(f, "%s\n", line)
|
||||
return err
|
||||
}
|
||||
|
||||
// Read returns all entries for sessionID. Returns empty slice if no log exists.
|
||||
func Read(sessionsDir, sessionID string) ([]Entry, error) {
|
||||
path := filepath.Join(sessionsDir, sessionID+".jsonl")
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open session log: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var entries []Entry
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var e Entry
|
||||
if err := json.Unmarshal(line, &e); err != nil {
|
||||
return nil, fmt.Errorf("parse entry: %w", err)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, scanner.Err()
|
||||
}
|
||||
63
internal/session/session_test.go
Normal file
63
internal/session/session_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// internal/session/session_test.go
|
||||
package session_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/session"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAppend_WritesJSONLEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
entry := session.Entry{
|
||||
SessionID: "test-session-1",
|
||||
Timestamp: time.Now().UTC(),
|
||||
Skill: "tdd_green",
|
||||
Phase: "green",
|
||||
ProjectRoot: "/tmp/myproject",
|
||||
FinalStatus: "pass",
|
||||
ModelUsed: "ollama/qwen3",
|
||||
DurationMs: 5000,
|
||||
}
|
||||
|
||||
require.NoError(t, session.Append(dir, "test-session-1", entry))
|
||||
|
||||
path := filepath.Join(dir, "test-session-1.jsonl")
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
var got session.Entry
|
||||
require.NoError(t, json.Unmarshal(data, &got))
|
||||
assert.Equal(t, "test-session-1", got.SessionID)
|
||||
assert.Equal(t, "tdd_green", got.Skill)
|
||||
assert.Equal(t, "pass", got.FinalStatus)
|
||||
}
|
||||
|
||||
func TestAppend_AppendsMultipleEntries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for i := 0; i < 3; i++ {
|
||||
require.NoError(t, session.Append(dir, "s1", session.Entry{
|
||||
SessionID: "s1",
|
||||
Timestamp: time.Now().UTC(),
|
||||
Skill: "tdd_red",
|
||||
FinalStatus: "pass",
|
||||
}))
|
||||
}
|
||||
|
||||
entries, err := session.Read(dir, "s1")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 3)
|
||||
}
|
||||
|
||||
func TestRead_EmptyWhenNoFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
entries, err := session.Read(dir, "missing")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
}
|
||||
Reference in New Issue
Block a user