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
73 changes: 67 additions & 6 deletions cmd/robodev/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/unitaryai/robodev/internal/diagnosis"
"github.com/unitaryai/robodev/internal/estimator"
"github.com/unitaryai/robodev/internal/jobbuilder"
"github.com/unitaryai/robodev/internal/localui"
"github.com/unitaryai/robodev/internal/memory"
"github.com/unitaryai/robodev/internal/prm"
"github.com/unitaryai/robodev/internal/reviewpoller"
Expand All @@ -46,6 +47,7 @@ import (
// Ticketing backends.
ghticket "github.com/unitaryai/robodev/pkg/plugin/ticketing/github"
linearticket "github.com/unitaryai/robodev/pkg/plugin/ticketing/linear"
localticket "github.com/unitaryai/robodev/pkg/plugin/ticketing/local"
noopticket "github.com/unitaryai/robodev/pkg/plugin/ticketing/noop"
scticket "github.com/unitaryai/robodev/pkg/plugin/ticketing/shortcut"

Expand Down Expand Up @@ -81,6 +83,7 @@ import (
func main() {
var (
configPath = flag.String("config", "/etc/robodev/config.yaml", "path to the RoboDev configuration file")
localUIAddr = flag.String("local-ui-addr", "127.0.0.1:8082", "address for the local ticketing UI when ticketing.backend=local")
metricsAddr = flag.String("metrics-addr", ":8080", "address for the Prometheus metrics and health endpoints")
pollInterval = flag.Duration("poll-interval", 30*time.Second, "interval between ticketing backend polls")
namespace = flag.String("namespace", "robodev", "kubernetes namespace for job creation")
Expand All @@ -94,6 +97,7 @@ func main() {

logger.Info("starting robodev controller",
"config", *configPath,
"local_ui_addr", *localUIAddr,
"metrics_addr", *metricsAddr,
"poll_interval", *pollInterval,
"namespace", *namespace,
Expand Down Expand Up @@ -126,6 +130,7 @@ func main() {
}

// --- Ticketing backend ---
var localBackend *localticket.Backend
var scBackend *scticket.ShortcutBackend
if cfg.Ticketing.Backend == "github" {
ghBackend, ghErr := initGitHubBackend(cfg, k8sClient, *namespace, logger)
Expand Down Expand Up @@ -155,17 +160,25 @@ func main() {
"workflow_state_id", scBackend.WorkflowStateID(),
"in_progress_state_id", scBackend.InProgressStateID(),
)
} else if cfg.Ticketing.Backend == "local" {
var localErr error
localBackend, localErr = initLocalBackend(cfg, logger)
if localErr != nil {
logger.Error("failed to initialise local ticketing backend", "error", localErr)
os.Exit(1)
}
opts = append(opts, controller.WithTicketing(localBackend))
logger.Info("local ticketing backend initialised")
Comment on lines +163 to +171
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Close the SQLite backend during shutdown.

initLocalBackend opens a long-lived SQLite store, but the shutdown path only stops the HTTP servers. Add localBackend.Close() alongside the existing cleanup so file handles and pending DB state are flushed before exit.

💡 Suggested fix
 	if localUISrv != nil {
 		if err := localUISrv.Shutdown(shutdownCtx); err != nil {
 			logger.Error("local ticketing UI server shutdown error", "error", err)
 		}
 	}
+	if localBackend != nil {
+		if err := localBackend.Close(); err != nil {
+			logger.Error("local ticketing backend close error", "error", err)
+		}
+	}
 	if webhookSrv != nil {
 		if err := webhookSrv.Shutdown(shutdownCtx); err != nil {
 			logger.Error("webhook server shutdown error", "error", err)
 		}
 	}

Also applies to: 816-820

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/robodev/main.go` around lines 163 - 171, The local SQLite backend created
by initLocalBackend is not closed on shutdown; update the shutdown/cleanup path
that currently stops HTTP servers to also call localBackend.Close() (guarded by
non-nil), logging any error and ensuring Close() is invoked before os.Exit so
file handles and DB state are flushed; modify the branch where
cfg.Ticketing.Backend == "local" (which sets localBackend and
controller.WithTicketing(localBackend)) to register this Close in the same
cleanup sequence (or defer it where shutdown is orchestrated).

} else if cfg.Ticketing.Backend != "" {
logger.Error("unsupported ticketing backend", "backend", cfg.Ticketing.Backend)
os.Exit(1)
} else {
// Check for a task_file in the ticketing config (file-watcher mode).
if taskFile, _, err := configStringOptional(cfg.Ticketing.Config, "task_file"); err != nil {
if taskFile, ok, err := configStringOptional(cfg.Ticketing.Config, "task_file"); err != nil {
logger.Error("invalid task_file config", "error", err)
os.Exit(1)
} else if taskFile != "" {
opts = append(opts, controller.WithTicketing(noopticket.NewWithTaskFile(logger, taskFile)))
logger.Info("noop ticketing with file-watcher enabled", "task_file", taskFile)
} else if ok && taskFile != "" {
logger.Error("ticketing.config.task_file is no longer supported; use ticketing.backend=local with ticketing.config.store_path and optional ticketing.config.seed_file")
os.Exit(1)
} else {
opts = append(opts, controller.WithTicketing(noopticket.New()))
logger.Info("no ticketing backend configured, using noop fallback")
Expand Down Expand Up @@ -692,13 +705,27 @@ func main() {
_, _ = w.Write([]byte("not ready"))
}
})

srv := &http.Server{
Addr: *metricsAddr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}

var localUISrv *http.Server
if localBackend != nil {
localUIHandler, uiErr := localui.NewHandler(logger.With("component", "local-ui"), localBackend)
if uiErr != nil {
logger.Error("failed to initialise local ticketing UI", "error", uiErr)
os.Exit(1)
}
localUISrv = &http.Server{
Addr: *localUIAddr,
Handler: localUIHandler,
ReadHeaderTimeout: 5 * time.Second,
}
logger.Info("local ticketing UI enabled", "addr", *localUIAddr, "url", fmt.Sprintf("http://%s/", *localUIAddr))
}

// Start the HTTP server in a goroutine.
go func() {
logger.Info("starting metrics and health server", "addr", *metricsAddr)
Expand All @@ -707,6 +734,15 @@ func main() {
os.Exit(1)
}
}()
if localUISrv != nil {
go func() {
logger.Info("starting local ticketing UI server", "addr", localUISrv.Addr)
if err := localUISrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("local ticketing UI server failed", "error", err)
os.Exit(1)
}
}()
}

// Create the reconciler with all backends wired up.
reconciler := controller.NewReconciler(cfg, logger, opts...)
Expand Down Expand Up @@ -777,6 +813,11 @@ func main() {
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("http server shutdown error", "error", err)
}
if localUISrv != nil {
if err := localUISrv.Shutdown(shutdownCtx); err != nil {
logger.Error("local ticketing UI server shutdown error", "error", err)
}
}
if webhookSrv != nil {
if err := webhookSrv.Shutdown(shutdownCtx); err != nil {
logger.Error("webhook server shutdown error", "error", err)
Expand Down Expand Up @@ -1030,6 +1071,26 @@ func initShortcutBackend(cfg *config.Config, k8sClient kubernetes.Interface, nam
return backend, nil
}

// initLocalBackend creates and returns a local SQLite-backed ticketing backend
// from the controller configuration.
func initLocalBackend(cfg *config.Config, logger *slog.Logger) (*localticket.Backend, error) {
m := cfg.Ticketing.Config

storePath, err := configString(m, "store_path")
if err != nil {
return nil, err
}
seedFile, _, err := configStringOptional(m, "seed_file")
if err != nil {
return nil, err
}

return localticket.New(localticket.Config{
StorePath: storePath,
SeedFile: seedFile,
}, logger)
}

// webhookAdapter wraps the controller's Reconciler to satisfy the
// webhook.EventHandler interface, bridging webhook events into the
// controller's ticket processing pipeline.
Expand Down
19 changes: 19 additions & 0 deletions docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,25 @@ ticketing:
| `labels` | No | Issues must carry at least one of these labels |
| `exclude_labels` | No | Issues carrying any of these labels are skipped. Defaults to `["in-progress", "robodev-failed"]` |

### Local

```yaml
ticketing:
backend: local
config:
store_path: "/data/local-ticketing.db" # required
seed_file: "/data/tasks.yaml" # optional one-time import
```

| Field | Required | Description |
|---|---|---|
| `store_path` | Yes | SQLite database path for the local ticket store |
| `seed_file` | No | YAML file imported once at startup; existing ticket IDs are left unchanged |

When `ticketing.backend` is `local`, the controller exposes an embedded frontend on a dedicated local UI listener. By default it binds to `http://127.0.0.1:8082/`; override this with the `-local-ui-addr` flag if needed. That UI lists tickets, shows comment history, creates new local tickets, adds operator comments, and requeues terminal tickets back to `ready`.

The legacy `ticketing.config.task_file` key is no longer supported. Replace it with `ticketing.backend: local`, set `ticketing.config.store_path` to the SQLite database path, and optionally use `ticketing.config.seed_file` to import tickets from YAML once at startup.

## Engines

```yaml
Expand Down
41 changes: 41 additions & 0 deletions docs/plugins/ticketing.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,47 @@ The Linear API key needs **read and write access** to issues and comments in the

---

## Built-in: Local

The local backend (`pkg/plugin/ticketing/local/`) stores tickets in a local SQLite database and is intended for local development, demos, and evaluation runs where you want durable ticket lifecycle state without depending on GitHub, Linear, or Shortcut.

### Configuration

```yaml
config:
ticketing:
backend: local
config:
store_path: "/data/local-ticketing.db"
seed_file: "/data/tasks.yaml" # optional one-time import
```

### Behaviour

| Method | Local Action |
|---|---|
| `PollReadyTickets` | Reads `ready` tickets from SQLite, ordered by creation time |
| `MarkInProgress` | Moves the ticket to `in_progress` and records an audit event |
| `MarkComplete` | Persists the full task result, adds a system comment, and marks the ticket `completed` |
| `MarkFailed` | Persists the failure reason, adds a system comment, and marks the ticket `failed` |
| `AddComment` | Persists a durable comment on the ticket |

### Local Admin Surface

When the local backend is enabled, RoboDev serves an embedded frontend on a dedicated local UI listener. By default it binds to `http://127.0.0.1:8082/`; override this with the `-local-ui-addr` flag if needed. The UI can:

- list local tickets and inspect their state
- show the persisted comment stream
- create new local tickets
- add operator comments
- requeue `completed` or `failed` tickets back to `ready`

The optional `seed_file` is bootstrap input only. It is imported once when the backend starts and does not remain the source of truth after import.

The legacy `ticketing.config.task_file` key is no longer supported. Use `ticketing.backend: local`, set `ticketing.config.store_path` to the SQLite database path, and optionally set `ticketing.config.seed_file` when you want to bootstrap local tickets from YAML.

---

## Writing a Custom Ticketing Backend

See the [Writing a Plugin](writing-a-plugin.md) guide for complete examples in Go, Python, and TypeScript. Key design considerations:
Expand Down
143 changes: 143 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,29 @@ plugin_health:
}
}

func TestLoad_RejectsNonStringLocalSeedFile(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "robodev-config.yaml")
err := os.WriteFile(tmp, []byte(`
ticketing:
backend: local
config:
store_path: /tmp/local-ticketing.db
seed_file: 123
secrets:
backend: env
engines:
default: claude-code
guardrails:
max_cost_per_job: 5.0
max_concurrent_jobs: 10
max_job_duration_minutes: 60
`), 0o600)
require.NoError(t, err)

_, err = Load(tmp)
require.ErrorContains(t, err, "ticketing.config.seed_file must be a string")
}

func TestLoad_TaskProfiles(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -608,6 +631,126 @@ guardrails:
}
}

func TestLoad_LocalTicketingConfig(t *testing.T) {
tests := []struct {
name string
yaml string
wantErr string
}{
{
name: "valid local ticketing config",
yaml: `
ticketing:
backend: local
config:
store_path: /var/lib/robodev/local-ticketing.db
seed_file: /var/lib/robodev/tasks.yaml
secrets:
backend: env
engines:
default: claude-code
guardrails:
max_cost_per_job: 5.0
max_concurrent_jobs: 10
max_job_duration_minutes: 60
`,
},
{
name: "local ticketing requires store path",
yaml: `
ticketing:
backend: local
config:
seed_file: /var/lib/robodev/tasks.yaml
secrets:
backend: env
engines:
default: claude-code
guardrails:
max_cost_per_job: 5.0
max_concurrent_jobs: 10
max_job_duration_minutes: 60
`,
wantErr: "ticketing.config.store_path is required",
},
{
name: "local ticketing rejects traversal in store path",
yaml: `
ticketing:
backend: local
config:
store_path: ../local-ticketing.db
secrets:
backend: env
engines:
default: claude-code
guardrails:
max_cost_per_job: 5.0
max_concurrent_jobs: 10
max_job_duration_minutes: 60
`,
wantErr: "ticketing.config.store_path contains directory traversal component",
},
{
name: "local ticketing rejects traversal in seed file",
yaml: `
ticketing:
backend: local
config:
store_path: /var/lib/robodev/local-ticketing.db
seed_file: ../tasks.yaml
secrets:
backend: env
engines:
default: claude-code
guardrails:
max_cost_per_job: 5.0
max_concurrent_jobs: 10
max_job_duration_minutes: 60
`,
wantErr: "ticketing.config.seed_file contains directory traversal component",
},
{
name: "legacy task file is rejected",
yaml: `
ticketing:
config:
task_file: /var/lib/robodev/tasks.yaml
secrets:
backend: env
engines:
default: claude-code
guardrails:
max_cost_per_job: 5.0
max_concurrent_jobs: 10
max_job_duration_minutes: 60
`,
wantErr: "ticketing.config.task_file is no longer supported",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "robodev-config.yaml")
err := os.WriteFile(tmp, []byte(tt.yaml), 0o600)
require.NoError(t, err)

got, err := Load(tmp)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}

require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "local", got.Ticketing.Backend)
assert.Equal(t, "/var/lib/robodev/local-ticketing.db", got.Ticketing.Config["store_path"])
assert.Equal(t, "/var/lib/robodev/tasks.yaml", got.Ticketing.Config["seed_file"])
})
}
}

func TestLoad_FileNotFound(t *testing.T) {
_, err := Load("/nonexistent/path/robodev-config.yaml")
require.Error(t, err)
Expand Down
Loading
Loading