Skip to content

Commit 0a835e1

Browse files
committed
feat(maintenance): storage maintenance package + operator-only config
New internal/maintenance package: Sweep (one pass) and Start (periodic janitor goroutine) covering session retention (reuses Store.Cleanup), audit-log retention (previously zero retention), telegram/schedule log rotation at 50MB, telegram plans retention, media sweep including per-chat subdirs (the per-turn sweep skipped them), and skill skip-list GC. New 'maintenance' config section (enabled by default, 60m interval, 30d sessions / 14d audit / 50MB logs / 30d plans / 90d skips) with ODEK_MAINTENANCE_* env plumbing; rejected from project-level odek.json like other deletion-relevant settings.
1 parent af875d8 commit 0a835e1

6 files changed

Lines changed: 1691 additions & 25 deletions

File tree

internal/config/loader.go

Lines changed: 161 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/BackendStack21/odek/internal/danger"
2727
"github.com/BackendStack21/odek/internal/embedding"
2828
"github.com/BackendStack21/odek/internal/guard"
29+
"github.com/BackendStack21/odek/internal/maintenance"
2930
"github.com/BackendStack21/odek/internal/mcpclient"
3031
"github.com/BackendStack21/odek/internal/memory"
3132
"github.com/BackendStack21/odek/internal/memory/extended"
@@ -101,11 +102,11 @@ type CLIFlags struct {
101102
MemoryExtendedFollowUpAnticipationEnabled *bool // nil = not set
102103

103104
// Guard subsystem CLI overrides.
104-
GuardProvider string // "" = not set
105-
GuardURL string // "" = not set
106-
GuardBatchURL string // "" = not set
107-
GuardLongURL string // "" = not set
108-
GuardSocketPath string // "" = not set
105+
GuardProvider string // "" = not set
106+
GuardURL string // "" = not set
107+
GuardBatchURL string // "" = not set
108+
GuardLongURL string // "" = not set
109+
GuardSocketPath string // "" = not set
109110
GuardThreshold float64 // 0 = not set
110111
GuardTimeoutSeconds int // 0 = not set
111112
GuardFallbackToLocal *bool // nil = not set
@@ -202,6 +203,21 @@ type ToolConfig struct {
202203
Disabled []string `json:"disabled,omitempty"`
203204
}
204205

206+
// MaintenanceConfig is the file-level "maintenance" section. Pointer fields
207+
// distinguish "not set" (inherit the default) from an explicit 0, which is
208+
// meaningful for the retention knobs (0 = keep forever / disable).
209+
// Operator-controlled: rejected from project-level ./odek.json because it
210+
// governs DELETION of user data.
211+
type MaintenanceConfig struct {
212+
Enabled *bool `json:"enabled,omitempty"`
213+
IntervalMinutes *int `json:"interval_minutes,omitempty"`
214+
SessionsMaxAgeDays *int `json:"sessions_max_age_days,omitempty"`
215+
AuditMaxAgeDays *int `json:"audit_max_age_days,omitempty"`
216+
LogMaxMB *int64 `json:"log_max_mb,omitempty"`
217+
PlansMaxAgeDays *int `json:"plans_max_age_days,omitempty"`
218+
SkillsSkipMaxAgeDays *int `json:"skills_skip_max_age_days,omitempty"`
219+
}
220+
205221
// ToolsConfig is the "tools" section of odek.json. It is intentionally a
206222
// pointer in FileConfig so "not set" can be distinguished from an explicit
207223
// empty list.
@@ -303,6 +319,11 @@ type FileConfig struct {
303319
// Schedules configures the native in-process task scheduler.
304320
Schedules *SchedulesConfig `json:"schedules,omitempty"`
305321

322+
// Maintenance configures the storage-maintenance janitor (retention and
323+
// deletion of sessions, audit records, plans, logs, skip-list entries).
324+
// Operator-controlled: rejected from project-level ./odek.json.
325+
Maintenance *MaintenanceConfig `json:"maintenance,omitempty"`
326+
306327
// Tools controls which tools are exposed to the LLM.
307328
// Project-level ./odek.json may only disable tools, not enable them.
308329
Tools *ToolsConfig `json:"tools,omitempty"`
@@ -464,6 +485,11 @@ type ResolvedConfig struct {
464485
// Default: enabled=true, max_concurrent=2, timezone="UTC", catchup=false.
465486
Schedules ScheduleConfig
466487

488+
// Maintenance is the resolved storage-maintenance config.
489+
// Default: maintenance.DefaultConfig() (enabled, 60min tick, sessions 30d,
490+
// audit 14d, logs 50MB, plans 30d, skip-list 90d).
491+
Maintenance maintenance.Config
492+
467493
// Tools is the resolved tool-list configuration.
468494
// Empty Enabled/Disabled means "no restriction" for that direction.
469495
Tools ToolConfig
@@ -666,6 +692,35 @@ func envInt(key string) int {
666692
return n
667693
}
668694

695+
// envIntPtr parses a ODEK_* env var as an integer. Returns nil if unset or
696+
// unparseable, so an explicit 0 (meaningful for retention knobs) stays
697+
// distinguishable from "not set".
698+
func envIntPtr(key string) *int {
699+
v := os.Getenv("ODEK_" + key)
700+
if v == "" {
701+
return nil
702+
}
703+
n, err := strconv.Atoi(v)
704+
if err != nil {
705+
return nil
706+
}
707+
return &n
708+
}
709+
710+
// envInt64Ptr parses a ODEK_* env var as an int64. Returns nil if unset or
711+
// unparseable, like envIntPtr.
712+
func envInt64Ptr(key string) *int64 {
713+
v := os.Getenv("ODEK_" + key)
714+
if v == "" {
715+
return nil
716+
}
717+
n, err := strconv.ParseInt(v, 10, 64)
718+
if err != nil {
719+
return nil
720+
}
721+
return &n
722+
}
723+
669724
// envFloat parses a ODEK_* env var as a float64. Returns 0 if unset/unparseable.
670725
func envFloat(key string) float64 {
671726
v := os.Getenv("ODEK_" + key)
@@ -737,6 +792,46 @@ func ensureGuard(cfg *guard.Config) *guard.Config {
737792
return cfg
738793
}
739794

795+
// ensureMaintenance returns a non-nil *MaintenanceConfig, allocating one if needed.
796+
func ensureMaintenance(cfg *MaintenanceConfig) *MaintenanceConfig {
797+
if cfg == nil {
798+
return &MaintenanceConfig{}
799+
}
800+
return cfg
801+
}
802+
803+
// resolveMaintenance merges the file-level maintenance section over the
804+
// package defaults. Unset (nil) fields inherit the default; explicit 0 keeps
805+
// its meaning (keep forever / disable).
806+
func resolveMaintenance(cfg *MaintenanceConfig) maintenance.Config {
807+
def := maintenance.DefaultConfig()
808+
if cfg == nil {
809+
return def
810+
}
811+
if cfg.Enabled != nil {
812+
def.Enabled = *cfg.Enabled
813+
}
814+
if cfg.IntervalMinutes != nil {
815+
def.IntervalMinutes = *cfg.IntervalMinutes
816+
}
817+
if cfg.SessionsMaxAgeDays != nil {
818+
def.SessionsMaxAgeDays = *cfg.SessionsMaxAgeDays
819+
}
820+
if cfg.AuditMaxAgeDays != nil {
821+
def.AuditMaxAgeDays = *cfg.AuditMaxAgeDays
822+
}
823+
if cfg.LogMaxMB != nil {
824+
def.LogMaxMB = *cfg.LogMaxMB
825+
}
826+
if cfg.PlansMaxAgeDays != nil {
827+
def.PlansMaxAgeDays = *cfg.PlansMaxAgeDays
828+
}
829+
if cfg.SkillsSkipMaxAgeDays != nil {
830+
def.SkillsSkipMaxAgeDays = *cfg.SkillsSkipMaxAgeDays
831+
}
832+
return def
833+
}
834+
740835
// envScheduleDangerousConfig parses ODEK_SCHEDULES_DANGEROUS_* env vars into a
741836
// DangerousConfig. Returns nil if none are set.
742837
func envScheduleDangerousConfig(prefix string) *danger.DangerousConfig {
@@ -857,6 +952,12 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
857952
fmt.Fprintf(os.Stderr, "odek: WARNING: ignoring memory from project config (%s); set it via ~/.odek/config.json\n", ProjectConfigPath())
858953
project.Memory = nil
859954
}
955+
// The maintenance section governs DELETION of user data (sessions, audit
956+
// records, plans, logs). A malicious repo must not be able to set it.
957+
if project.Maintenance != nil {
958+
fmt.Fprintf(os.Stderr, "odek: WARNING: ignoring maintenance from project config (%s); set it via ~/.odek/config.json or ODEK_MAINTENANCE_*\n", ProjectConfigPath())
959+
project.Maintenance = nil
960+
}
860961
if project.Guard != nil {
861962
fmt.Fprintf(os.Stderr, "odek: WARNING: ignoring guard from project config (%s); set it via ~/.odek/config.json, ODEK_GUARD_*, or the CLI\n", ProjectConfigPath())
862963
project.Guard = nil
@@ -1245,6 +1346,38 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
12451346
}
12461347
}
12471348

1349+
// Maintenance env overrides (ODEK_MAINTENANCE_*). Explicit 0 is meaningful
1350+
// for the retention knobs (0 = keep forever / disable), so they parse via
1351+
// the pointer helpers rather than envInt.
1352+
if v := envBool("MAINTENANCE_ENABLED"); v != nil {
1353+
cfg.Maintenance = ensureMaintenance(cfg.Maintenance)
1354+
cfg.Maintenance.Enabled = v
1355+
}
1356+
if v := envIntPtr("MAINTENANCE_INTERVAL_MINUTES"); v != nil {
1357+
cfg.Maintenance = ensureMaintenance(cfg.Maintenance)
1358+
cfg.Maintenance.IntervalMinutes = v
1359+
}
1360+
if v := envIntPtr("MAINTENANCE_SESSIONS_MAX_AGE_DAYS"); v != nil {
1361+
cfg.Maintenance = ensureMaintenance(cfg.Maintenance)
1362+
cfg.Maintenance.SessionsMaxAgeDays = v
1363+
}
1364+
if v := envIntPtr("MAINTENANCE_AUDIT_MAX_AGE_DAYS"); v != nil {
1365+
cfg.Maintenance = ensureMaintenance(cfg.Maintenance)
1366+
cfg.Maintenance.AuditMaxAgeDays = v
1367+
}
1368+
if v := envInt64Ptr("MAINTENANCE_LOG_MAX_MB"); v != nil {
1369+
cfg.Maintenance = ensureMaintenance(cfg.Maintenance)
1370+
cfg.Maintenance.LogMaxMB = v
1371+
}
1372+
if v := envIntPtr("MAINTENANCE_PLANS_MAX_AGE_DAYS"); v != nil {
1373+
cfg.Maintenance = ensureMaintenance(cfg.Maintenance)
1374+
cfg.Maintenance.PlansMaxAgeDays = v
1375+
}
1376+
if v := envIntPtr("MAINTENANCE_SKILLS_SKIP_MAX_AGE_DAYS"); v != nil {
1377+
cfg.Maintenance = ensureMaintenance(cfg.Maintenance)
1378+
cfg.Maintenance.SkillsSkipMaxAgeDays = v
1379+
}
1380+
12481381
// Telegram env overrides: merge env vars on top of file config.
12491382
baseTelegram := telegram.DefaultConfig()
12501383
if cfg.Telegram != nil {
@@ -1489,29 +1622,30 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
14891622
MaxIter: cfg.MaxIter,
14901623
System: cfg.System,
14911624

1492-
SandboxImage: cfg.SandboxImage, // empty = resolve at call site (Dockerfile.odek or alpine:latest)
1493-
SandboxNetwork: ifZero(cfg.SandboxNetwork, DefaultSandboxNetwork),
1494-
SandboxMemory: cfg.SandboxMemory,
1495-
SandboxCPUs: cfg.SandboxCPUs,
1496-
SandboxUser: cfg.SandboxUser,
1497-
SandboxEnv: cfg.SandboxEnv,
1498-
SandboxVolumes: cfg.SandboxVolumes,
1499-
Skills: resolveSkills(cfg.Skills),
1500-
Dangerous: resolveDangerous(cfg.Dangerous, true),
1501-
Memory: resolveMemory(cfg.Memory),
1502-
Guard: resolveGuard(cfg.Guard),
1503-
Embedding: cfg.Embedding,
1625+
SandboxImage: cfg.SandboxImage, // empty = resolve at call site (Dockerfile.odek or alpine:latest)
1626+
SandboxNetwork: ifZero(cfg.SandboxNetwork, DefaultSandboxNetwork),
1627+
SandboxMemory: cfg.SandboxMemory,
1628+
SandboxCPUs: cfg.SandboxCPUs,
1629+
SandboxUser: cfg.SandboxUser,
1630+
SandboxEnv: cfg.SandboxEnv,
1631+
SandboxVolumes: cfg.SandboxVolumes,
1632+
Skills: resolveSkills(cfg.Skills),
1633+
Dangerous: resolveDangerous(cfg.Dangerous, true),
1634+
Memory: resolveMemory(cfg.Memory),
1635+
Guard: resolveGuard(cfg.Guard),
1636+
Embedding: cfg.Embedding,
15041637
MCPServers: cfg.MCPServers,
15051638
ProjectMCPServerNames: projectMCPNames,
15061639
ProjectSandboxOverride: projectSandboxOverride,
15071640
Telegram: resolveTelegram(cfg.Telegram),
1508-
Transcription: resolveTranscription(cfg.Transcription),
1509-
Vision: resolveVision(cfg.Vision),
1510-
WebSearch: resolveWebSearch(cfg.WebSearch),
1511-
Schedules: resolveSchedules(cfg.Schedules),
1512-
Tools: resolveTools(cfg.Tools),
1513-
InteractionMode: ifZero(cfg.InteractionMode, "engaging"),
1514-
ToolProgress: ifZero(cfg.ToolProgress, "all"),
1641+
Transcription: resolveTranscription(cfg.Transcription),
1642+
Vision: resolveVision(cfg.Vision),
1643+
WebSearch: resolveWebSearch(cfg.WebSearch),
1644+
Schedules: resolveSchedules(cfg.Schedules),
1645+
Maintenance: resolveMaintenance(cfg.Maintenance),
1646+
Tools: resolveTools(cfg.Tools),
1647+
InteractionMode: ifZero(cfg.InteractionMode, "engaging"),
1648+
ToolProgress: ifZero(cfg.ToolProgress, "all"),
15151649
}
15161650

15171651
// Every subsystem inherits the shared top-level embedding default unless it
@@ -2114,6 +2248,9 @@ func overlayFile(base, override FileConfig) FileConfig {
21142248
if override.Memory != nil {
21152249
base.Memory = override.Memory
21162250
}
2251+
if override.Maintenance != nil {
2252+
base.Maintenance = override.Maintenance
2253+
}
21172254
if override.Guard != nil {
21182255
base.Guard = override.Guard
21192256
}

0 commit comments

Comments
 (0)