Skip to content
Open
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
8 changes: 7 additions & 1 deletion API_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ The daemon exposes a **JSON-over-HTTP** interface on `localhost`.

### 1.2 Configuration
* **Default Port**: `8090` (configurable via env `RATELORD_PORT`)
* **Bind Address**: `127.0.0.1`
* **Bind Address**: `127.0.0.1` (override via `RATELORD_LISTEN_ADDR`)
* **Database Path**: `./ratelord.db` (override via `RATELORD_DB_PATH`)
* **Policy Path**: `./policy.json` (override via `RATELORD_POLICY_PATH`)
* **Poll Interval**: `10s` (override via `RATELORD_POLL_INTERVAL`)
* **Web Assets**:
* `RATELORD_WEB_MODE=embedded|dir|off` (default: `embedded`)
* `RATELORD_WEB_DIR` (required when `web_mode=dir`)
* **Content-Type**: `application/json` required for all POST bodies.

---
Expand Down
9 changes: 9 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,15 @@ If you run multiple replicas of `ratelord` (e.g., 3 sidecars for 3 app replicas)

## 6. Configuration & Secrets Management

### Runtime Configuration
- `RATELORD_DB_PATH`: SQLite database path (default `./ratelord.db`).
- `RATELORD_POLICY_PATH`: Policy file path (default `./policy.json`).
- `RATELORD_PORT`: Override daemon port (default `8090`, sets bind to `127.0.0.1`).
- `RATELORD_LISTEN_ADDR`: Full bind address (default `127.0.0.1:8090`).
- `RATELORD_POLL_INTERVAL`: Provider poll interval (default `10s`).
- `RATELORD_WEB_MODE`: `embedded`, `dir`, or `off` (default `embedded`).
- `RATELORD_WEB_DIR`: Directory for web assets when `RATELORD_WEB_MODE=dir`.

### Policy (`policy.json`)
- Treat as code. Version control it.
- **Updates**:
Expand Down
12 changes: 12 additions & 0 deletions LEARNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,15 @@ The "Required Document Set" is complete with 0 implementation code written, pres

### Challenges Encountered
1. **Context Continuity**: Deciding between `--continue` (stateful) and fresh runs (stateless). Fresh runs are safer for disk-sourced truth, but `--continue` can reduce repeated "orientation" overhead. We opted for NO `--continue` in the improved loop to allow the orchestrator to build on its internal reasoning.

## 2026-01-27: Configuration Implementation

### What Worked Well
1. **Env-first defaults**: Deriving flag defaults from environment variables kept runtime config flexible while preserving sane defaults.
2. **Centralized normalization**: A single validation/normalization pass reduced drift between CLI, env, and runtime usage.

### Challenges Encountered
1. **Toolchain availability**: Go tooling was unavailable in the environment, so formatting and test runs required careful reporting.

### Improvements for Implementation Phase
1. **Bootstrap check**: Add a quick bootstrap check for required toolchains before implementing code changes to reduce rework.
11 changes: 4 additions & 7 deletions NEXT_STEPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,13 @@
- **Core Features**: Daemon authority, Event sourcing, Policy Engine, Forecasting, TUI, and initial Mock Provider are live.
- **Phase 7 Complete**: Real providers (GitHub, OpenAI) implemented. Dogfooding environment validated forecast accuracy.
- **Documentation**: Deployment guide (`DEPLOYMENT.md`) is now available. `CLIENT_SDK_SPEC.md` has been drafted.
- **M1.4 Complete**: Daemon configuration loader now supports env/flag overrides for storage, policy, polling, bind address, and web assets.

## Immediate Actions

1. **Phase 8: Operations & Expansion**:
- **Epic 18: Web UI**:
- [x] **M18.1-M18.4: Web UI V1**: Dashboard, Scaffolding, and Embedding are live.
- [x] **M18.5: History View**: Deep dive into event log history with filters.
- [x] **M18.6: Identity Explorer**: Detailed view of registered identities and their stats.

*Note: Epic 18 (Web UI) is now complete.*
1. **Re-assess Backlog**:
- Confirm if any new milestones are required beyond M1.4.
- Capture new work in `TASKS.md` before starting the next iteration.

## Reference

Expand Down
1 change: 1 addition & 0 deletions PHASE_LEDGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@
- **Action**: Implemented Web UI Dashboard View: Created API client (`web/src/lib/api.ts`), AppShell layout (`web/src/layouts/AppShell.tsx`), Dashboard page (`web/src/pages/Dashboard.tsx`), and updated App.tsx for routing. Built successfully with TypeScript and Vite.
- [x] **M18.4: Build Integration** (Epic 18) - Integrated Web UI into daemon using `//go:embed`. Updated Makefile to build web assets and embed them into `cmd/ratelord-d`. Added `--web-dir` flag to serve dev builds or embedded assets by default.
- [x] **Epic 18: Web UI Implementation** - Status: Completed. Implemented Dashboard, History, and Identity Explorer. React + Vite + Tailwind stack embedded in Go binary.
- [x] **M1.4: Configuration** (Epic 1) - Added daemon configuration loader with env/flag overrides for storage, policy, polling, listen address, and web assets.
5 changes: 3 additions & 2 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
| pkg/store/types.go | COMMITTED | Agent | 2026-01-25 |
| pkg/store/sqlite.go | COMMITTED | Agent | 2026-01-25 |
| pkg/store/store_test.go | COMMITTED | Agent | 2026-01-25 |
| pkg/api/server.go | COMMITTED | Agent | 2026-01-25 |
| pkg/api/server.go | UPDATED | Agent | 2026-01-27 |
| pkg/engine/projection.go | COMMITTED | Agent | 2026-01-25 |
| pkg/engine/usage.go | COMMITTED | Agent | 2026-01-25 |
| pkg/engine/policy.go | UPDATED | Agent | 2026-01-25 |
Expand All @@ -41,7 +41,8 @@
| pkg/engine/poller.go | UPDATED | Agent | 2026-01-25 |
| pkg/engine/config.go | COMMITTED | Agent | 2026-01-25 |
| pkg/engine/loader.go | COMMITTED | Agent | 2026-01-25 |
| cmd/ratelord-d/main.go | UPDATED | Agent | 2026-01-25 |
| cmd/ratelord-d/main.go | UPDATED | Agent | 2026-01-27 |
| cmd/ratelord-d/config.go | CREATED | Agent | 2026-01-27 |
| cmd/ratelord-tui/main.go | CREATED | Agent | 2026-01-25 |
| cmd/ratelord-sim/main.go | CREATED | Agent | 2026-01-25 |
| Epic 7: Forecasting | COMPLETED | Agent | 2026-01-25 |
Expand Down
2 changes: 1 addition & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Focus: Getting the process to run, manage its lifecycle, and handle signals corr
- [x] **M1.3: Logging & Observability**
- Setup structured logging (stdout/stderr).
- Emit `system_started` log on boot.
- [ ] **M1.4: Configuration**
- [x] **M1.4: Configuration**
- Implement configuration loader (env vars, defaults).
- *Note*: Split from M1.1 to ensure atomic commits.

Expand Down
142 changes: 142 additions & 0 deletions cmd/ratelord-d/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package main

import (
"flag"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"time"
)

const (
defaultPollInterval = 10 * time.Second
defaultListenAddr = "127.0.0.1:8090"
defaultWebMode = "embedded"
)

type Config struct {
DBPath string
PolicyPath string
ListenAddr string
PollInterval time.Duration
WebMode string
WebDir string
}

func LoadConfig() (Config, error) {
cwd, err := os.Getwd()
if err != nil {
return Config{}, fmt.Errorf("failed to get cwd: %w", err)
}

defaultDBPath := filepath.Join(cwd, "ratelord.db")
defaultPolicyPath := filepath.Join(cwd, "policy.json")

dbDefault := envOrDefault("RATELORD_DB_PATH", defaultDBPath)
policyDefault := envOrDefault("RATELORD_POLICY_PATH", defaultPolicyPath)
listenDefault := defaultListenAddr
if portOverride := envOrDefault("RATELORD_PORT", ""); portOverride != "" {
listenDefault = net.JoinHostPort("127.0.0.1", portOverride)
}
listenDefault = envOrDefault("RATELORD_LISTEN_ADDR", listenDefault)
webModeDefault := envOrDefault("RATELORD_WEB_MODE", defaultWebMode)
webDirDefault := envOrDefault("RATELORD_WEB_DIR", "")

pollDefault, err := envDuration("RATELORD_POLL_INTERVAL", defaultPollInterval)
if err != nil {
return Config{}, err
}

dbPath := flag.String("db-path", dbDefault, "Path to the SQLite database")
policyPath := flag.String("policy-path", policyDefault, "Path to the policy JSON file")
listenAddr := flag.String("listen-addr", listenDefault, "HTTP listen address")
pollInterval := flag.Duration("poll-interval", pollDefault, "Provider poll interval (e.g. 10s)")
webMode := flag.String("web-mode", webModeDefault, "Web assets mode: embedded, dir, off")
webDir := flag.String("web-dir", webDirDefault, "Web assets directory when web-mode=dir")

flag.Parse()
Comment on lines +52 to +59
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling flag.Parse() in LoadConfig() means flags can only be parsed once per process lifetime. If LoadConfig is called multiple times (e.g., in tests or future hot-reload scenarios), subsequent calls will fail or behave unexpectedly. Consider checking if flags have already been parsed using flag.Parsed(), or restructuring to separate flag definition from parsing.

Copilot uses AI. Check for mistakes.

cfg := Config{
DBPath: strings.TrimSpace(*dbPath),
PolicyPath: strings.TrimSpace(*policyPath),
ListenAddr: strings.TrimSpace(*listenAddr),
PollInterval: *pollInterval,
WebMode: strings.TrimSpace(*webMode),
WebDir: strings.TrimSpace(*webDir),
}

return normalizeConfig(cfg)
}
Comment on lines +28 to +71
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new LoadConfig function and its helper functions (normalizeConfig, envOrDefault, envDuration) lack test coverage. The cmd/ratelord-d directory has test files (hot_reload_test.go), indicating that testing is expected in this package. Consider adding tests for LoadConfig to cover scenarios such as: environment variable parsing, flag parsing, validation errors (empty paths, invalid poll intervals, invalid web modes), path normalization, and the interaction between RATELORD_PORT and RATELORD_LISTEN_ADDR.

Copilot uses AI. Check for mistakes.

func normalizeConfig(cfg Config) (Config, error) {
var err error
if cfg.DBPath == "" {
return Config{}, fmt.Errorf("db path is required")
}
if cfg.PolicyPath == "" {
return Config{}, fmt.Errorf("policy path is required")
}
if cfg.ListenAddr == "" {
return Config{}, fmt.Errorf("listen address is required")
}
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ListenAddr validation only checks if the address is non-empty, but doesn't validate the format. Consider validating that the listen address is a valid host:port combination using net.SplitHostPort or similar. Invalid addresses will only fail at runtime when the server attempts to start, making misconfiguration harder to debug.

Suggested change
}
}
if _, _, err := net.SplitHostPort(cfg.ListenAddr); err != nil {
return Config{}, fmt.Errorf("invalid listen address %q: %w", cfg.ListenAddr, err)
}

Copilot uses AI. Check for mistakes.
if cfg.PollInterval <= 0 {
return Config{}, fmt.Errorf("poll interval must be positive")
}

cfg.DBPath, err = filepath.Abs(cfg.DBPath)
if err != nil {
return Config{}, fmt.Errorf("resolve db path: %w", err)
}
cfg.PolicyPath, err = filepath.Abs(cfg.PolicyPath)
if err != nil {
return Config{}, fmt.Errorf("resolve policy path: %w", err)
}

cfg.WebMode = strings.ToLower(cfg.WebMode)
switch cfg.WebMode {
case "embedded", "dir", "off":
default:
return Config{}, fmt.Errorf("invalid web mode %q (expected embedded, dir, or off)", cfg.WebMode)
}

if cfg.WebMode == "dir" {
if cfg.WebDir == "" {
return Config{}, fmt.Errorf("web dir is required when web mode is dir")
}
cfg.WebDir, err = filepath.Abs(cfg.WebDir)
if err != nil {
return Config{}, fmt.Errorf("resolve web dir: %w", err)
}
if info, err := os.Stat(cfg.WebDir); err != nil {
return Config{}, fmt.Errorf("stat web dir: %w", err)
} else if !info.IsDir() {
return Config{}, fmt.Errorf("web dir is not a directory: %s", cfg.WebDir)
}
Comment on lines +112 to +116
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When web_mode is "dir", the config validates that the directory exists and is indeed a directory (lines 112-116). However, this validation happens at startup before the daemon needs to serve assets. If the directory is deleted or becomes inaccessible after the daemon starts, the static file handler will fail at runtime with potentially unclear errors. Consider whether it's preferable to validate directory existence at startup (current behavior) or defer validation until assets are actually served (more resilient to temporary filesystem issues).

Suggested change
if info, err := os.Stat(cfg.WebDir); err != nil {
return Config{}, fmt.Errorf("stat web dir: %w", err)
} else if !info.IsDir() {
return Config{}, fmt.Errorf("web dir is not a directory: %s", cfg.WebDir)
}

Copilot uses AI. Check for mistakes.
}

return cfg, nil
}

func envOrDefault(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return strings.TrimSpace(value)
}
return fallback
}

func envDuration(key string, fallback time.Duration) (time.Duration, error) {
if value, ok := os.LookupEnv(key); ok {
value = strings.TrimSpace(value)
if value == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("invalid %s duration: %w", key, err)
}
return parsed, nil
}
return fallback, nil
}
58 changes: 36 additions & 22 deletions cmd/ratelord-d/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"

Expand All @@ -29,21 +28,27 @@ func main() {
// M1.3: Emit system_started log on boot (structured)
fmt.Println(`{"level":"info","msg":"system_started","component":"ratelord-d"}`)

// Configuration (M1.4 placeholder: hardcoded DB path for now)
cwd, err := os.Getwd()
cfg, err := LoadConfig()
if err != nil {
panic(fmt.Sprintf("failed to get cwd: %v", err))
fmt.Printf(`{"level":"fatal","msg":"config_load_failed","error":"%v"}`+"\n", err)
os.Exit(1)
}
dbPath := filepath.Join(cwd, "ratelord.db")
policyPath := filepath.Join(cwd, "policy.json")
fmt.Printf(
`{"level":"info","msg":"config_loaded","db_path":"%s","policy_path":"%s","listen_addr":"%s","poll_interval":"%s","web_mode":"%s"}`+"\n",
cfg.DBPath,
cfg.PolicyPath,
cfg.ListenAddr,
cfg.PollInterval,
cfg.WebMode,
)

// M2.1: Initialize SQLite Store
st, err := store.NewStore(dbPath)
st, err := store.NewStore(cfg.DBPath)
if err != nil {
fmt.Printf(`{"level":"fatal","msg":"failed_to_init_store","error":"%v"}`+"\n", err)
os.Exit(1)
}
fmt.Printf(`{"level":"info","msg":"store_initialized","path":"%s"}`+"\n", dbPath)
fmt.Printf(`{"level":"info","msg":"store_initialized","path":"%s"}`+"\n", cfg.DBPath)

// M4.2: Initialize Identity Projection
identityProj := engine.NewIdentityProjection()
Expand Down Expand Up @@ -94,18 +99,18 @@ func main() {

// M9.3: Initial Policy Load
var policyCfg *engine.PolicyConfig
if cfg, err := engine.LoadPolicyConfig(policyPath); err == nil {
policyCfg = cfg
policyEngine.UpdatePolicies(cfg)
fmt.Printf(`{"level":"info","msg":"policy_loaded","path":"%s","policies_count":%d}`+"\n", policyPath, len(cfg.Policies))
if policyConfig, err := engine.LoadPolicyConfig(cfg.PolicyPath); err == nil {
policyCfg = policyConfig
policyEngine.UpdatePolicies(policyConfig)
fmt.Printf(`{"level":"info","msg":"policy_loaded","path":"%s","policies_count":%d}`+"\n", cfg.PolicyPath, len(policyConfig.Policies))
} else if !os.IsNotExist(err) {
// Log error if file exists but failed to load; ignore if missing (default mode)
fmt.Printf(`{"level":"error","msg":"failed_to_load_policy","error":"%v"}`+"\n", err)
}

// M6.3: Initialize Polling Orchestrator
// Use the new Poller to drive the provider loop
poller := engine.NewPoller(st, 10*time.Second, forecaster) // Poll every 10s for demo
poller := engine.NewPoller(st, cfg.PollInterval, forecaster)
// Register the mock provider (M6.2)
// IMPORTANT: For the demo, we assume the mock provider is available in the 'pkg/provider' package via a factory or similar,
// but currently it resides in 'pkg/provider/mock.go' which is in package 'provider'.
Expand Down Expand Up @@ -154,14 +159,23 @@ func main() {
// M3.1: Start HTTP Server (in background)
// Use NewServerWithPoller to enable debug endpoints
srv := api.NewServerWithPoller(st, identityProj, usageProj, policyEngine, poller)
srv.SetAddr(cfg.ListenAddr)

// Load and set web assets
webAssets, err := web.Assets()
if err != nil {
fmt.Printf(`{"level":"error","msg":"failed_to_load_web_assets","error":"%v"}`+"\n", err)
} else {
srv.SetStaticFS(webAssets)
fmt.Println(`{"level":"info","msg":"web_assets_loaded"}`)
switch cfg.WebMode {
case "off":
fmt.Println(`{"level":"info","msg":"web_assets_disabled"}`)
case "dir":
srv.SetStaticFS(os.DirFS(cfg.WebDir))
fmt.Printf(`{"level":"info","msg":"web_assets_loaded","mode":"dir","path":"%s"}`+"\n", cfg.WebDir)
default:
webAssets, err := web.Assets()
if err != nil {
fmt.Printf(`{"level":"error","msg":"failed_to_load_web_assets","error":"%v"}`+"\n", err)
} else {
srv.SetStaticFS(webAssets)
fmt.Printf(`{"level":"info","msg":"web_assets_loaded","mode":"embedded"}` + "\n")
}
}

go func() {
Expand All @@ -181,9 +195,9 @@ func main() {
sig := <-sigs
if sig == syscall.SIGHUP {
fmt.Println(`{"level":"info","msg":"reload_signal_received"}`)
if cfg, err := engine.LoadPolicyConfig(policyPath); err == nil {
policyEngine.UpdatePolicies(cfg)
fmt.Printf(`{"level":"info","msg":"policy_reloaded","policies_count":%d}`+"\n", len(cfg.Policies))
if policyConfig, err := engine.LoadPolicyConfig(cfg.PolicyPath); err == nil {
policyEngine.UpdatePolicies(policyConfig)
fmt.Printf(`{"level":"info","msg":"policy_reloaded","policies_count":%d}`+"\n", len(policyConfig.Policies))
} else {
fmt.Printf(`{"level":"error","msg":"failed_to_reload_policy","error":"%v"}`+"\n", err)
}
Expand Down
Loading
Loading