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
17 changes: 15 additions & 2 deletions cmd/odek/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/BackendStack21/odek/internal/config"
"github.com/BackendStack21/odek/internal/maintenance"
"github.com/BackendStack21/odek/internal/runtimelog"
"github.com/BackendStack21/odek/internal/session"
)

Expand Down Expand Up @@ -82,11 +83,12 @@ func startStorageMaintenance(ctx context.Context, resolved config.ResolvedConfig
// success line when there was nothing to do.
func printCleanupReport(r maintenance.Report) {
if r.SessionsRemoved == 0 && r.AuditRemoved == 0 && r.PlansRemoved == 0 &&
r.ArtifactsRemoved == 0 && r.MediaFreedBytes == 0 && len(r.LogsRotated) == 0 {
r.RuntimeLogRecordsRemoved == 0 && r.ArtifactsRemoved == 0 && r.MediaFreedBytes == 0 && len(r.LogsRotated) == 0 {
fmt.Println("Storage is clean — nothing to remove.")
return
}
fmt.Println("Cleanup complete:")
fmt.Printf(" runtime records removed: %d\n", r.RuntimeLogRecordsRemoved)
fmt.Printf(" sessions removed: %d\n", r.SessionsRemoved)
fmt.Printf(" audit records removed: %d\n", r.AuditRemoved)
fmt.Printf(" plans removed: %d\n", r.PlansRemoved)
Expand Down Expand Up @@ -220,12 +222,23 @@ func filesOlderThan(dir string, cutoff time.Time, recursive bool) []string {

// printCleanupDryRun reports the candidate list without removing anything.
func printCleanupDryRun(home string, cfg maintenance.Config) {
expired := 0
if cfg.RuntimeLogMaxAgeHours > 0 {
n, err := runtimelog.Prune(context.Background(), filepath.Join(home, "runtime.log"), time.Now().Add(-time.Duration(maintenance.ClampRetentionHours(cfg.RuntimeLogMaxAgeHours))*time.Hour), true)
if err != nil {
fmt.Fprintf(os.Stderr, "runtime log preview failed: %v\n", err)
} else {
expired = n
}
}

c := collectCleanupCandidates(home, cfg)
if len(c.sessions) == 0 && len(c.audit) == 0 && len(c.plans) == 0 && len(c.logs) == 0 && len(c.artifacts) == 0 {
if expired == 0 && len(c.sessions) == 0 && len(c.audit) == 0 && len(c.plans) == 0 && len(c.logs) == 0 && len(c.artifacts) == 0 {
fmt.Println("Dry run: storage is clean — nothing would be removed.")
return
}
fmt.Println("Dry run — nothing removed. Would remove:")
fmt.Printf(" runtime records expired: %d\n", expired)
fmt.Printf(" sessions: %d\n", len(c.sessions))
fmt.Printf(" audit records: %d\n", len(c.audit))
fmt.Printf(" plans: %d\n", len(c.plans))
Expand Down
31 changes: 31 additions & 0 deletions cmd/odek/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import (
"fmt"
"os"
"runtime"
"slices"

"github.com/BackendStack21/odek/internal/budget"
"github.com/BackendStack21/odek/internal/diagnostics"
"github.com/BackendStack21/odek/internal/events"
)

// dispatch routes a top-level CLI invocation to its handler. It takes the
Expand All @@ -27,6 +30,19 @@ func dispatch(args []string) int {

cmd := args[0]
rest := args[1:]
// Protocol children relay diagnostics through their parent once initialized.
// Keep version queries independent of configuration and filesystem writes.
preview := cmd == "cleanup" && slices.Contains(rest, "--dry-run")
if !preview && (cmd != "subagent" || subagentDepth() == 0) && cmd != "version" && cmd != "--version" && cmd != "-v" {
closeLog := startOperationalLogging(commandSurface(cmd))
defer closeLog()
defer func() {
if value := recover(); value != nil {
diagnostics.Emit(events.Event{Type: "panic_recovered", Data: map[string]any{"component": "cli", "operation": "dispatch", "error_class": "panic"}})
panic(value) // preserve the original crash behavior after flushing
}
}()
}

switch cmd {
case "run":
Expand Down Expand Up @@ -66,6 +82,7 @@ func dispatch(args []string) int {
case "upgrade":
return cliExit(upgradeCmd(rest))
default:
diagnostics.Warning("cli", "unknown_command", nil)
fmt.Fprintf(os.Stderr, "odek: unknown command %q\n", cmd)
printUsage()
return 1
Expand All @@ -78,6 +95,7 @@ func cliExit(err error) int {
if err == nil {
return 0
}
logCommandFailure(err)
fmt.Fprintf(os.Stderr, "odek: %v\n", err)
return 1
}
Expand All @@ -90,6 +108,7 @@ func runExit(err error) int {
if err == nil {
return 0
}
logCommandFailure(err)
fmt.Fprintf(os.Stderr, "odek: %v\n", err)
if _, ok := budget.As(err); ok {
return 4
Expand Down Expand Up @@ -123,6 +142,7 @@ func subagentExit(err error) int {
// Pre-run budget stop (share-mode exhaustion): the typed budget
// error arrived before any run started. Same wire contract as a
// mid-run exhaustion — budget_exhausted envelope, exit code 4.
logCommandFailure(err)
fmt.Fprintf(os.Stderr, "odek: %v\n", err)
_ = json.NewEncoder(os.Stdout).Encode(subagentResult{
Status: "budget_exhausted",
Expand All @@ -131,6 +151,7 @@ func subagentExit(err error) int {
})
return 4
}
logCommandFailure(err)
fmt.Fprintf(os.Stderr, "odek: %v\n", err)
_ = json.NewEncoder(os.Stdout).Encode(subagentResult{
Status: "error",
Expand All @@ -150,3 +171,13 @@ func printVersion() {
fmt.Printf(" built: %s\n", date)
}
}

// commandSurface never copies an arbitrary CLI argument into metadata.
func commandSurface(cmd string) string {
switch cmd {
case "run", "subagent", "continue", "init", "session", "audit", "repl", "skill", "serve", "mcp", "telegram", "schedule", "memory", "cleanup", "upgrade":
return cmd
default:
return "cli"
}
}
12 changes: 7 additions & 5 deletions cmd/odek/introspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,14 @@ func buildConfigView(resolved config.ResolvedConfig) map[string]any {
"enabled": resolved.Tools.Enabled,
"disabled": resolved.Tools.Disabled,
},
"logging": map[string]any{"enabled": resolved.Logging.Enabled},
"maintenance": map[string]any{
"enabled": resolved.Maintenance.Enabled,
"interval_minutes": resolved.Maintenance.IntervalMinutes,
"sessions_max_age_days": resolved.Maintenance.SessionsMaxAgeDays,
"audit_max_age_days": resolved.Maintenance.AuditMaxAgeDays,
"plans_max_age_days": resolved.Maintenance.PlansMaxAgeDays,
"runtime_log_max_age_hours": resolved.Maintenance.RuntimeLogMaxAgeHours,
"enabled": resolved.Maintenance.Enabled,
"interval_minutes": resolved.Maintenance.IntervalMinutes,
"sessions_max_age_days": resolved.Maintenance.SessionsMaxAgeDays,
"audit_max_age_days": resolved.Maintenance.AuditMaxAgeDays,
"plans_max_age_days": resolved.Maintenance.PlansMaxAgeDays,
},
"dangerous_default_action": resolved.Dangerous.DefaultAction,
"guard_scan": guardScanView(resolved.Guard.Scan),
Expand Down
16 changes: 16 additions & 0 deletions cmd/odek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/BackendStack21/odek/internal/budget"
"github.com/BackendStack21/odek/internal/config"
"github.com/BackendStack21/odek/internal/danger"
"github.com/BackendStack21/odek/internal/diagnostics"
"github.com/BackendStack21/odek/internal/events"
"github.com/BackendStack21/odek/internal/guard"
"github.com/BackendStack21/odek/internal/llmclient"
Expand Down Expand Up @@ -1528,12 +1529,14 @@ const globalConfigTemplate = `{
"timezone": "UTC",
"catchup": false
},
"logging": {"enabled": false},
"maintenance": {
"enabled": true,
"interval_minutes": 60,
"sessions_max_age_days": 30,
"audit_max_age_days": 14,
"log_max_mb": 50,
"runtime_log_max_age_hours": 168,
"plans_max_age_days": 30,
"artifacts_max_age_hours": 24
},
Expand Down Expand Up @@ -1963,6 +1966,7 @@ func run(args []string) error {
Limits: resolved.Limits,
}
applyResolvedProvider(&runCfg, resolved)
runCfg.RuntimeLogSurface = "run"
agent, err := odek.New(runCfg)
if err != nil {
return err
Expand Down Expand Up @@ -2055,6 +2059,7 @@ func run(args []string) error {
if sessionID == "" {
sessionID = session.GenerateID()
}
agent.SetToolSessionID(sessionID)
ctx = withReadLedger(ctx, sessionID)
if auditStore == nil {
store, err := session.NewStore()
Expand Down Expand Up @@ -2319,6 +2324,7 @@ func ensureSandbox(resolved config.ResolvedConfig, tools []odek.Tool, cfg sandbo
}

func setupSandbox(tools []odek.Tool, cfg sandboxConfig) (containerName string, cleanup func() error, err error) {
defer func() { diagnostics.Report("sandbox", "setup", "", err) }()
// An implicit Dockerfile.odek build executes repo-controlled code on the
// host; refuse to proceed unless it was approved (startup prompt, trusted
// project, or ODEK_APPROVE_PROJECT_SANDBOX=1). Skipped when an explicit
Expand Down Expand Up @@ -2507,6 +2513,14 @@ type toolConfig struct {
// applyResolvedProvider copies the v2 LLM identity (provider registry +
// timeout/window) onto an odek.Config built from a ResolvedConfig.
func applyResolvedProvider(cfg *odek.Config, resolved config.ResolvedConfig) {
if resolved.Logging.Enabled {
cfg.RuntimeLogPath = expandHome("~/.odek/runtime.log")
cfg.RuntimeLogMaxMB = resolved.Maintenance.LogMaxMB
// Prices support estimates on every surface without adding budget caps.
cfg.Limits.InputCostPerMillionUSD = resolved.Limits.InputCostPerMillionUSD
cfg.Limits.OutputCostPerMillionUSD = resolved.Limits.OutputCostPerMillionUSD
cfg.Limits.ModelPrices = resolved.Limits.ModelPrices
}
cfg.Provider = resolved.Provider
cfg.Providers = resolved.ProviderOverrides()
if resolved.LLM.RequestTimeoutSeconds > 0 {
Expand Down Expand Up @@ -3345,7 +3359,9 @@ func continueCmd(args []string) error {
Guard: injectionGuard,
GuardConfig: resolved.Guard,
}
contCfg.EventContext.SessionID = sess.ID
applyResolvedProvider(&contCfg, resolved)
contCfg.RuntimeLogSurface = "continue"
agent, err := odek.New(contCfg)
if err != nil {
return err
Expand Down
72 changes: 72 additions & 0 deletions cmd/odek/operational_logging.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package main

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

"github.com/BackendStack21/odek/internal/config"
"github.com/BackendStack21/odek/internal/diagnostics"
"github.com/BackendStack21/odek/internal/events"
"github.com/BackendStack21/odek/internal/runtimelog"
)

// startOperationalLogging covers failures before an Agent exists and between
// turns. Agent loggers use the same process identity and cooperating file lock.
func startOperationalLogging(surface string) func() {
var mu sync.Mutex
var pending []events.Event
var lost int
var logger *runtimelog.Logger
booting := true
restore := diagnostics.Install(func(ev events.Event) {
mu.Lock()
defer mu.Unlock()
if logger != nil {
logger.Emit(ev)
} else if booting {
if len(pending) < 128 {
pending = append(pending, ev)
} else {
lost++
}
}
})
enabled, maxMB := config.LoadLoggingSettings()
mu.Lock()
booting = false
if enabled {
var err error
logger, err = runtimelog.Open(filepath.Join(expandHome("~/.odek"), "runtime.log"), surface, maxMB)
if err != nil {
fmt.Fprintf(os.Stderr, "odek: operational logging unavailable: %v\n", err)
} else {
for _, ev := range pending {
logger.Emit(ev)
}
if lost > 0 {
logger.Emit(events.Event{Type: "logging_dropped", Data: map[string]any{"dropped": lost}})
}
}
}
pending = nil
mu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
restore()
mu.Lock()
l := logger
logger = nil
mu.Unlock()
if l != nil {
l.Close()
}
})
}
}

func logCommandFailure(err error) {
diagnostics.Report("cli", "command", "", err)
}
Loading
Loading