Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ type AgentConfig struct {
type StorageConfig struct {
Worktrees string `mapstructure:"worktrees" validate:"required"`
Catalog string `mapstructure:"catalog" validate:"required"`
Logs string `mapstructure:"logs" validate:"required"`
}

// Validate checks the configuration for errors using struct tags.
Expand Down Expand Up @@ -134,6 +135,7 @@ func (l *Loader) setDefaults() {
l.v.SetDefault("default.base_image", defaultBaseImage)
l.v.SetDefault("storage.worktrees", "~/.local/share/headjack/git")
l.v.SetDefault("storage.catalog", "~/.local/share/headjack/catalog.json")
l.v.SetDefault("storage.logs", "~/.local/share/headjack/logs")
l.v.SetDefault("agents.claude.env", map[string]string{"CLAUDE_CODE_MAX_TURNS": "100"})
l.v.SetDefault("agents.gemini.env", map[string]string{})
l.v.SetDefault("agents.codex.env", map[string]string{})
Expand Down Expand Up @@ -161,6 +163,7 @@ func (l *Loader) Load() (*Config, error) {
// Expand paths
cfg.Storage.Worktrees = l.expandPath(cfg.Storage.Worktrees)
cfg.Storage.Catalog = l.expandPath(cfg.Storage.Catalog)
cfg.Storage.Logs = l.expandPath(cfg.Storage.Logs)

return &cfg, nil
}
Expand Down
14 changes: 9 additions & 5 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func TestLoader_Load_CreatesDefaultIfMissing(t *testing.T) {
assert.Equal(t, defaultBaseImage, cfg.Default.BaseImage)
assert.Contains(t, cfg.Storage.Worktrees, "headjack")
assert.Contains(t, cfg.Storage.Catalog, "catalog.json")
assert.Contains(t, cfg.Storage.Logs, "logs")

// Verify file was created
_, err = os.Stat(loader.Path())
Expand All @@ -45,6 +46,7 @@ default:
storage:
worktrees: ~/custom/worktrees
catalog: ~/custom/catalog.json
logs: ~/custom/logs
agents:
claude:
env:
Expand All @@ -66,6 +68,7 @@ agents:
assert.Equal(t, "custom:latest", cfg.Default.BaseImage)
assert.Equal(t, filepath.Join(tmpHome, "custom", "worktrees"), cfg.Storage.Worktrees)
assert.Equal(t, filepath.Join(tmpHome, "custom", "catalog.json"), cfg.Storage.Catalog)
assert.Equal(t, filepath.Join(tmpHome, "custom", "logs"), cfg.Storage.Logs)

// Test agent env via GetAgentEnv helper
// Note: viper lowercases all keys
Expand Down Expand Up @@ -163,23 +166,23 @@ func TestConfig_Validate(t *testing.T) {
cfg := &Config{
Default: DefaultConfig{Agent: "claude", BaseImage: "test:latest"},
Agents: map[string]AgentConfig{"claude": {}},
Storage: StorageConfig{Worktrees: "/tmp/worktrees", Catalog: "/tmp/catalog.json"},
Storage: StorageConfig{Worktrees: "/tmp/worktrees", Catalog: "/tmp/catalog.json", Logs: "/tmp/logs"},
}
assert.NoError(t, cfg.Validate())
})

t.Run("valid config without agent", func(t *testing.T) {
cfg := &Config{
Default: DefaultConfig{Agent: "", BaseImage: "test:latest"},
Storage: StorageConfig{Worktrees: "/tmp/worktrees", Catalog: "/tmp/catalog.json"},
Storage: StorageConfig{Worktrees: "/tmp/worktrees", Catalog: "/tmp/catalog.json", Logs: "/tmp/logs"},
}
assert.NoError(t, cfg.Validate())
})

t.Run("invalid default agent", func(t *testing.T) {
cfg := &Config{
Default: DefaultConfig{Agent: "invalid", BaseImage: "test:latest"},
Storage: StorageConfig{Worktrees: "/tmp/worktrees", Catalog: "/tmp/catalog.json"},
Storage: StorageConfig{Worktrees: "/tmp/worktrees", Catalog: "/tmp/catalog.json", Logs: "/tmp/logs"},
}
err := cfg.Validate()
assert.Error(t, err)
Expand All @@ -190,7 +193,7 @@ func TestConfig_Validate(t *testing.T) {
cfg := &Config{
Default: DefaultConfig{BaseImage: "test:latest"},
Agents: map[string]AgentConfig{"unknown": {}},
Storage: StorageConfig{Worktrees: "/tmp/worktrees", Catalog: "/tmp/catalog.json"},
Storage: StorageConfig{Worktrees: "/tmp/worktrees", Catalog: "/tmp/catalog.json", Logs: "/tmp/logs"},
}
err := cfg.Validate()
assert.Error(t, err)
Expand All @@ -199,7 +202,7 @@ func TestConfig_Validate(t *testing.T) {
t.Run("missing required base_image", func(t *testing.T) {
cfg := &Config{
Default: DefaultConfig{Agent: ""},
Storage: StorageConfig{Worktrees: "/tmp/worktrees", Catalog: "/tmp/catalog.json"},
Storage: StorageConfig{Worktrees: "/tmp/worktrees", Catalog: "/tmp/catalog.json", Logs: "/tmp/logs"},
}
err := cfg.Validate()
assert.Error(t, err)
Expand Down Expand Up @@ -237,6 +240,7 @@ func TestValidateKey(t *testing.T) {
{"default.base_image is valid", "default.base_image", nil},
{"storage.worktrees is valid", "storage.worktrees", nil},
{"storage.catalog is valid", "storage.catalog", nil},
{"storage.logs is valid", "storage.logs", nil},
{"agents is valid", "agents", nil},
{"default is valid", "default", nil},
{"storage is valid", "storage", nil},
Expand Down
104 changes: 104 additions & 0 deletions internal/logging/paths.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Package logging provides session output logging infrastructure for Headjack.
package logging

import (
"fmt"
"os"
"path/filepath"
)

// PathManager handles log file path construction and directory management.
type PathManager struct {
baseDir string
}

// NewPathManager creates a new PathManager with the given base directory.
// The base directory is typically ~/.local/share/headjack/logs.
func NewPathManager(baseDir string) *PathManager {
return &PathManager{baseDir: baseDir}
}

// BaseDir returns the base log directory.
func (p *PathManager) BaseDir() string {
return p.baseDir
}

// InstanceDir returns the log directory for a specific instance.
// Path format: <baseDir>/<instanceID>/
func (p *PathManager) InstanceDir(instanceID string) string {
return filepath.Join(p.baseDir, instanceID)
}

// SessionLogPath returns the full path for a session's log file.
// Path format: <baseDir>/<instanceID>/<sessionID>.log
func (p *PathManager) SessionLogPath(instanceID, sessionID string) string {
return filepath.Join(p.baseDir, instanceID, sessionID+".log")
}

// EnsureInstanceDir creates the instance log directory if it doesn't exist.
// Returns the instance directory path.
func (p *PathManager) EnsureInstanceDir(instanceID string) (string, error) {
dir := p.InstanceDir(instanceID)
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("create instance log directory: %w", err)
}
return dir, nil
}

// EnsureSessionLog ensures the parent directory exists for a session log file.
// Returns the full log file path.
func (p *PathManager) EnsureSessionLog(instanceID, sessionID string) (string, error) {
if _, err := p.EnsureInstanceDir(instanceID); err != nil {
return "", err
}
return p.SessionLogPath(instanceID, sessionID), nil
}

// LogExists checks if a log file exists for the given session.
func (p *PathManager) LogExists(instanceID, sessionID string) bool {
path := p.SessionLogPath(instanceID, sessionID)
_, err := os.Stat(path)
return err == nil
}

// RemoveSessionLog removes a session's log file if it exists.
func (p *PathManager) RemoveSessionLog(instanceID, sessionID string) error {
path := p.SessionLogPath(instanceID, sessionID)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove session log: %w", err)
}
return nil
}

// RemoveInstanceLogs removes all log files for an instance.
func (p *PathManager) RemoveInstanceLogs(instanceID string) error {
dir := p.InstanceDir(instanceID)
if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove instance logs: %w", err)
}
return nil
}

// ListSessionLogs returns a list of session IDs that have log files for the given instance.
func (p *PathManager) ListSessionLogs(instanceID string) ([]string, error) {
dir := p.InstanceDir(instanceID)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read instance log directory: %w", err)
}

var sessions []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if ext := filepath.Ext(name); ext == ".log" {
sessions = append(sessions, name[:len(name)-len(ext)])
}
}
return sessions, nil
}
154 changes: 154 additions & 0 deletions internal/logging/paths_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package logging

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestPathManager_BaseDir(t *testing.T) {
pm := NewPathManager("/var/log/headjack")
assert.Equal(t, "/var/log/headjack", pm.BaseDir())
}

func TestPathManager_InstanceDir(t *testing.T) {
pm := NewPathManager("/var/log/headjack")
assert.Equal(t, "/var/log/headjack/abc123", pm.InstanceDir("abc123"))
}

func TestPathManager_SessionLogPath(t *testing.T) {
pm := NewPathManager("/var/log/headjack")
path := pm.SessionLogPath("abc123", "session456")
assert.Equal(t, "/var/log/headjack/abc123/session456.log", path)
}

func TestPathManager_EnsureInstanceDir(t *testing.T) {
baseDir := t.TempDir()
pm := NewPathManager(baseDir)

dir, err := pm.EnsureInstanceDir("test-instance")
require.NoError(t, err)

assert.Equal(t, filepath.Join(baseDir, "test-instance"), dir)

info, err := os.Stat(dir)
require.NoError(t, err)
assert.True(t, info.IsDir())
}

func TestPathManager_EnsureSessionLog(t *testing.T) {
baseDir := t.TempDir()
pm := NewPathManager(baseDir)

path, err := pm.EnsureSessionLog("inst1", "sess1")
require.NoError(t, err)

assert.Equal(t, filepath.Join(baseDir, "inst1", "sess1.log"), path)

// Verify directory was created
info, err := os.Stat(filepath.Dir(path))
require.NoError(t, err)
assert.True(t, info.IsDir())
}

func TestPathManager_LogExists(t *testing.T) {
baseDir := t.TempDir()
pm := NewPathManager(baseDir)

// Log doesn't exist yet
assert.False(t, pm.LogExists("inst1", "sess1"))

// Create the log file
path, err := pm.EnsureSessionLog("inst1", "sess1")
require.NoError(t, err)

err = os.WriteFile(path, []byte("test"), 0644)
require.NoError(t, err)

// Now it should exist
assert.True(t, pm.LogExists("inst1", "sess1"))
}

func TestPathManager_RemoveSessionLog(t *testing.T) {
baseDir := t.TempDir()
pm := NewPathManager(baseDir)

// Create a log file
path, err := pm.EnsureSessionLog("inst1", "sess1")
require.NoError(t, err)

err = os.WriteFile(path, []byte("test"), 0644)
require.NoError(t, err)

assert.True(t, pm.LogExists("inst1", "sess1"))

// Remove it
err = pm.RemoveSessionLog("inst1", "sess1")
require.NoError(t, err)

assert.False(t, pm.LogExists("inst1", "sess1"))

// Removing non-existent should not error
err = pm.RemoveSessionLog("inst1", "nonexistent")
require.NoError(t, err)
}

func TestPathManager_RemoveInstanceLogs(t *testing.T) {
baseDir := t.TempDir()
pm := NewPathManager(baseDir)

// Create multiple log files for an instance
for _, sess := range []string{"sess1", "sess2", "sess3"} {
path, err := pm.EnsureSessionLog("inst1", sess)
require.NoError(t, err)
err = os.WriteFile(path, []byte("test"), 0644)
require.NoError(t, err)
}

// Verify they exist
sessions, err := pm.ListSessionLogs("inst1")
require.NoError(t, err)
assert.Len(t, sessions, 3)

// Remove all
err = pm.RemoveInstanceLogs("inst1")
require.NoError(t, err)

// Verify directory is gone
_, err = os.Stat(pm.InstanceDir("inst1"))
assert.True(t, os.IsNotExist(err))

// Removing non-existent instance should not error
err = pm.RemoveInstanceLogs("nonexistent")
require.NoError(t, err)
}

func TestPathManager_ListSessionLogs(t *testing.T) {
baseDir := t.TempDir()
pm := NewPathManager(baseDir)

// Empty directory should return nil
sessions, err := pm.ListSessionLogs("nonexistent")
require.NoError(t, err)
assert.Nil(t, sessions)

// Create some log files
for _, sess := range []string{"alpha", "beta", "gamma"} {
path, err := pm.EnsureSessionLog("inst1", sess)
require.NoError(t, err)
err = os.WriteFile(path, []byte("test"), 0644)
require.NoError(t, err)
}

// Also create a non-log file (should be ignored)
err = os.WriteFile(filepath.Join(pm.InstanceDir("inst1"), "other.txt"), []byte("not a log"), 0644)
require.NoError(t, err)

// List sessions
sessions, err = pm.ListSessionLogs("inst1")
require.NoError(t, err)
assert.ElementsMatch(t, []string{"alpha", "beta", "gamma"}, sessions)
}
Loading