Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions API_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ The daemon exposes a **JSON-over-HTTP** interface on `localhost`.
* **Local-First**: Bound strictly to `127.0.0.1` (or a Unix Domain Socket in future strict modes).

### 1.2 Configuration
* **Default Port**: `8090` (configurable via env `RATELORD_PORT`)
* **Bind Address**: `127.0.0.1`
* **Default Address**: `127.0.0.1:8090` (configurable via env `RATELORD_ADDR` or CLI `--addr`)
* **Port Override**: `RATELORD_PORT` (legacy; used when `RATELORD_ADDR` is unset)
* **Content-Type**: `application/json` required for all POST bodies.

---
Expand Down
9 changes: 5 additions & 4 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ This guide covers deployment via Systemd, Docker, and Kubernetes.
- **Configuration**:
- `policy.json`: Defines pools, windows, and burst limits.
- Environment Variables: Store sensitive tokens (e.g., `GITHUB_TOKEN`, `OPENAI_API_KEY`).
- **Network**: Exposes an HTTP API on port `8090`.
- Daemon settings: `RATELORD_DB_PATH`, `RATELORD_POLICY_PATH` (or `RATELORD_CONFIG_PATH`), `RATELORD_ADDR`, `RATELORD_POLL_INTERVAL`, `RATELORD_WEB_ASSETS_MODE`, `RATELORD_WEB_DIR`.
- **Network**: Exposes an HTTP API on port `8090` (default bind `127.0.0.1:8090`).
- **Signals**:
- `SIGINT` / `SIGTERM`: Graceful shutdown (checkpoints state).
- `SIGHUP`: Hot-reload `policy.json`.
Expand Down Expand Up @@ -57,7 +58,7 @@ Type=simple
User=ratelord
Group=ratelord
# Adjust path to binary
ExecStart=/usr/local/bin/ratelord-d --config /etc/ratelord/policy.json --db /var/lib/ratelord/ratelord.db --addr 127.0.0.1:8090
ExecStart=/usr/local/bin/ratelord-d --policy /etc/ratelord/policy.json --db /var/lib/ratelord/ratelord.db --addr 127.0.0.1:8090
ExecReload=/bin/kill -HUP $MAINPID
KillMode=process
Restart=on-failure
Expand Down Expand Up @@ -107,7 +108,7 @@ COPY policy.json /etc/ratelord/policy.json
# State volume
VOLUME /data
ENV RATELORD_DB_PATH=/data/ratelord.db
ENV RATELORD_CONFIG_PATH=/etc/ratelord/policy.json
ENV RATELORD_POLICY_PATH=/etc/ratelord/policy.json
ENV RATELORD_ADDR=0.0.0.0:8090

CMD ["ratelord-d"]
Expand Down Expand Up @@ -168,7 +169,7 @@ spec:
args:
- "--addr=127.0.0.1:8090"
- "--db=/data/ratelord.db"
- "--config=/etc/config/policy.json"
- "--policy=/etc/config/policy.json"
volumeMounts:
- name: ratelord-data
mountPath: /data
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. **Spec-to-implementation alignment**: Updating deployment and API docs before code changes kept configuration expectations consistent with actual runtime behavior.
2. **Explicit defaults**: Centralizing defaults for DB path, policy path, and poll interval simplified daemon startup and made overrides easy to reason about.

### Challenges Encountered
1. **Legacy configuration naming**: Existing docs referenced `RATELORD_CONFIG_PATH`, requiring backward-compatible support while introducing a clearer `RATELORD_POLICY_PATH`.

### Improvements for Implementation Phase
1. **Config validation surface**: Add a dedicated validation pass (with better error messaging) as more config knobs accumulate.
14 changes: 4 additions & 10 deletions NEXT_STEPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,13 @@

## Current Context
- **v1.0.0 Released**: The system has been tagged and released.
- **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.
- **Core Features**: Daemon authority, Event sourcing, Policy Engine, Forecasting, TUI, Web UI, and real providers are live.
- **Configuration Implemented**: Daemon config now supports env/flag overrides for DB path, policy path, poll interval, listen address, and web assets mode.
- **Documentation**: Deployment guide (`DEPLOYMENT.md`) and client SDK spec (`CLIENT_SDK_SPEC.md`) are available.

## 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. **Define the next milestone**: All tasks in `TASKS.md` are complete; queue the next phase or open issues for post-1.0 improvements.

## Reference

Expand Down
3 changes: 3 additions & 0 deletions PHASE_LEDGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@
- **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.

## 2026-01-27
- [x] **M1.4: Configuration** (Epic 1) - Added daemon config loader with env/flag overrides for DB path, policy path, poll interval, listen address, and web assets mode. Updated deployment and API docs plus tracking files.
4 changes: 3 additions & 1 deletion PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 All @@ -55,6 +56,7 @@
| M11.2: Implement client-side Wait/Modify logic | COMPLETED | Feature | 2026-01-25 |
| M12.1: Persist Provider State | COMPLETED | Fix | 2026-01-26 |
| M12.2: TUI Verification | COMPLETED | Terminal | 2026-01-26 |
| M1.4: Configuration | COMPLETED | Agent | 2026-01-27 |
| Epic 14: GitHub Provider | COMPLETED | Agent | 2026-01-26 |
| Epic 15: OpenAI Provider | COMPLETED | Agent | 2026-01-26 |
| pkg/client/types.go | CREATED | Implement | 2026-01-26 |
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
154 changes: 154 additions & 0 deletions cmd/ratelord-d/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package main

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

const (
defaultAddr = "127.0.0.1:8090"
defaultPollInterval = 10 * time.Second
defaultWebAssetsMode = "embedded"
)

type Config struct {
DBPath string
PolicyPath string
Addr string
PollInterval time.Duration
WebAssetsMode string
WebDir string
}

func LoadConfig(args []string) (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")

dbPath := envOrDefault("RATELORD_DB_PATH", defaultDBPath)
policyPath := envOrDefaultWithFallback([]string{"RATELORD_POLICY_PATH", "RATELORD_CONFIG_PATH"}, defaultPolicyPath)
addr := addrFromEnv(defaultAddr)
pollInterval := defaultPollInterval
if pollIntervalEnv := os.Getenv("RATELORD_POLL_INTERVAL"); pollIntervalEnv != "" {
parsed, err := time.ParseDuration(pollIntervalEnv)
if err != nil {
return Config{}, fmt.Errorf("invalid RATELORD_POLL_INTERVAL: %w", err)
}
pollInterval = parsed
}
webAssetsMode := envOrDefault("RATELORD_WEB_ASSETS_MODE", defaultWebAssetsMode)
webDir := os.Getenv("RATELORD_WEB_DIR")

flagSet := flag.NewFlagSet("ratelord-d", flag.ContinueOnError)
flagSet.SetOutput(io.Discard)
flagDB := flagSet.String("db", dbPath, "path to SQLite database")
flagPolicy := flagSet.String("policy", policyPath, "path to policy JSON")
flagAddr := flagSet.String("addr", addr, "HTTP listen address")
flagPollInterval := flagSet.String("poll-interval", pollInterval.String(), "provider poll interval")
flagWebAssets := flagSet.String("web-assets", webAssetsMode, "web assets mode: embedded|fs|off")
flagWebDir := flagSet.String("web-dir", webDir, "web assets directory when web-assets=fs")

if err := flagSet.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
flagSet.SetOutput(os.Stdout)
flagSet.PrintDefaults()
return Config{}, err
}
return Config{}, err
}

pollIntervalParsed, err := time.ParseDuration(*flagPollInterval)
if err != nil {
return Config{}, fmt.Errorf("invalid poll interval: %w", err)
}
Comment on lines +70 to +73
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 poll interval should be validated to ensure it's positive. A negative or zero poll interval would cause time.NewTicker to panic when the Poller starts. Add validation to check that pollIntervalParsed is greater than zero before returning the config.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback


resolvedDBPath := resolvePath(*flagDB, cwd)
resolvedPolicyPath := resolvePath(*flagPolicy, cwd)
mode := normalizeWebAssetsMode(*flagWebAssets)

config := Config{
DBPath: resolvedDBPath,
PolicyPath: resolvedPolicyPath,
Addr: strings.TrimSpace(*flagAddr),
PollInterval: pollIntervalParsed,
WebAssetsMode: mode,
WebDir: strings.TrimSpace(*flagWebDir),
}

if config.Addr == "" {
return Config{}, errors.New("addr cannot be empty")
}

if config.WebAssetsMode == "fs" {
if config.WebDir == "" {
return Config{}, errors.New("web-assets=fs requires web-dir")
}
config.WebDir = resolvePath(config.WebDir, cwd)
}

if config.WebAssetsMode != "embedded" && config.WebAssetsMode != "fs" && config.WebAssetsMode != "off" {
return Config{}, fmt.Errorf("unsupported web-assets mode: %s", config.WebAssetsMode)
}

return config, nil
}

func envOrDefault(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}

func envOrDefaultWithFallback(keys []string, fallback string) string {
for _, key := range keys {
if value := os.Getenv(key); value != "" {
return value
}
}
return fallback
}

func addrFromEnv(fallback string) string {
if value := os.Getenv("RATELORD_ADDR"); value != "" {
return value
}
if port := os.Getenv("RATELORD_PORT"); port != "" {
return fmt.Sprintf("127.0.0.1:%s", port)
}
return fallback
}

func resolvePath(path string, cwd string) string {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
return trimmed
}
if filepath.IsAbs(trimmed) {
return trimmed
}
return filepath.Join(cwd, trimmed)
}

func normalizeWebAssetsMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "", "embedded":
return "embedded"
case "fs", "dir", "directory":
return "fs"
case "off", "disabled", "none":
return "off"
default:
return strings.ToLower(strings.TrimSpace(mode))
}
}
Comment on lines +1 to +154
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 config.go file lacks test coverage. Given that other packages in the codebase (store, engine, provider, client) have comprehensive test coverage, this configuration loader should also have tests to verify env/flag precedence, validation logic, path resolution, and error cases. For example, tests should cover: invalid poll intervals, empty addresses, missing web-dir when mode is 'fs', invalid web-assets modes, and precedence of flags over environment variables.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback

55 changes: 33 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,20 @@ 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(os.Args[1:])
if err != nil {
panic(fmt.Sprintf("failed to get cwd: %v", err))
fmt.Printf(`{"level":"fatal","msg":"failed_to_load_config","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","addr":"%s","poll_interval":"%s","web_assets_mode":"%s"}`+"\n", cfg.DBPath, cfg.PolicyPath, cfg.Addr, cfg.PollInterval, cfg.WebAssetsMode)

// 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 +92,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 loaded, err := engine.LoadPolicyConfig(cfg.PolicyPath); err == nil {
policyCfg = loaded
policyEngine.UpdatePolicies(loaded)
fmt.Printf(`{"level":"info","msg":"policy_loaded","path":"%s","policies_count":%d}`+"\n", cfg.PolicyPath, len(loaded.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 +152,27 @@ 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.Addr)

// 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.WebAssetsMode {
case "embedded":
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","mode":"embedded"}`)
}
case "fs":
if _, err := os.Stat(cfg.WebDir); err != nil {
fmt.Printf(`{"level":"error","msg":"web_assets_dir_unavailable","path":"%s","error":"%v"}`+"\n", cfg.WebDir, err)
} else {
srv.SetStaticFS(os.DirFS(cfg.WebDir))
fmt.Printf(`{"level":"info","msg":"web_assets_loaded","mode":"fs","path":"%s"}`+"\n", cfg.WebDir)
}
case "off":
fmt.Println(`{"level":"info","msg":"web_assets_disabled"}`)
}

go func() {
Expand All @@ -181,9 +192,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 loaded, err := engine.LoadPolicyConfig(cfg.PolicyPath); err == nil {
policyEngine.UpdatePolicies(loaded)
fmt.Printf(`{"level":"info","msg":"policy_reloaded","policies_count":%d}`+"\n", len(loaded.Policies))
} else {
fmt.Printf(`{"level":"error","msg":"failed_to_reload_policy","error":"%v"}`+"\n", err)
}
Expand Down
5 changes: 5 additions & 0 deletions pkg/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ func (s *Server) SetStaticFS(fs fs.FS) {
s.staticFS = fs
}

// SetAddr sets the HTTP listen address.
func (s *Server) SetAddr(addr string) {
s.server.Addr = addr
}

// Start runs the HTTP server (blocking)
func (s *Server) Start() error {
fmt.Printf(`{"level":"info","msg":"server_starting","addr":"%s"}`+"\n", s.server.Addr)
Expand Down
Loading