cli/internal/core-ide/headless.go

143 lines
3.7 KiB
Go
Raw Normal View History

package main
import (
"context"
"log"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/forge"
"github.com/host-uk/core/pkg/jobrunner"
forgejosource "github.com/host-uk/core/pkg/jobrunner/forgejo"
"github.com/host-uk/core/pkg/jobrunner/handlers"
)
// hasDisplay returns true if a graphical display is available.
func hasDisplay() bool {
if runtime.GOOS == "windows" {
return true
}
return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != ""
}
// startHeadless runs the job runner in daemon mode without GUI.
func startHeadless() {
log.Println("Starting Core IDE in headless mode...")
// Signal handling
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
// Journal
journalDir := filepath.Join(os.Getenv("HOME"), ".core", "journal")
journal, err := jobrunner.NewJournal(journalDir)
if err != nil {
log.Fatalf("Failed to create journal: %v", err)
}
// Forge client
forgeURL, forgeToken, _ := forge.ResolveConfig("", "")
forgeClient, err := forge.New(forgeURL, forgeToken)
if err != nil {
log.Fatalf("Failed to create forge client: %v", err)
}
// Forgejo source — repos from CORE_REPOS env var or default
repos := parseRepoList(os.Getenv("CORE_REPOS"))
if len(repos) == 0 {
repos = []string{"host-uk/core", "host-uk/core-php", "host-uk/core-tenant", "host-uk/core-admin"}
}
source := forgejosource.New(forgejosource.Config{
Repos: repos,
}, forgeClient)
// Handlers (order matters — first match wins)
publishDraft := handlers.NewPublishDraftHandler(forgeClient)
sendFix := handlers.NewSendFixCommandHandler(forgeClient)
dismissReviews := handlers.NewDismissReviewsHandler(forgeClient)
enableAutoMerge := handlers.NewEnableAutoMergeHandler(forgeClient)
tickParent := handlers.NewTickParentHandler(forgeClient)
// Agent dispatch — maps Forgejo usernames to SSH targets.
agentTargets := map[string]handlers.AgentTarget{
"darbs-claude": {Host: "claude@192.168.0.201", QueueDir: "/home/claude/ai-work/queue"},
}
dispatch := handlers.NewDispatchHandler(forgeClient, forgeURL, forgeToken, agentTargets)
// Build poller
poller := jobrunner.NewPoller(jobrunner.PollerConfig{
Sources: []jobrunner.JobSource{source},
Handlers: []jobrunner.JobHandler{
publishDraft,
sendFix,
dismissReviews,
enableAutoMerge,
tickParent,
dispatch, // Last — only matches NeedsCoding signals
},
Journal: journal,
PollInterval: 60 * time.Second,
DryRun: isDryRun(),
})
// Daemon with PID file and health check
daemon := cli.NewDaemon(cli.DaemonOptions{
PIDFile: filepath.Join(os.Getenv("HOME"), ".core", "core-ide.pid"),
HealthAddr: "127.0.0.1:9878",
})
if err := daemon.Start(); err != nil {
log.Fatalf("Failed to start daemon: %v", err)
}
daemon.SetReady(true)
// Start MCP bridge in headless mode too (port 9877)
go startHeadlessMCP(poller)
log.Printf("Polling %d repos every %s (dry-run: %v)", len(repos), "60s", poller.DryRun())
// Run poller in goroutine, block on context
go func() {
if err := poller.Run(ctx); err != nil && err != context.Canceled {
log.Printf("Poller error: %v", err)
}
}()
// Block until signal
<-ctx.Done()
log.Println("Shutting down...")
_ = daemon.Stop()
}
// parseRepoList splits a comma-separated repo list.
func parseRepoList(s string) []string {
if s == "" {
return nil
}
var repos []string
for _, r := range strings.Split(s, ",") {
r = strings.TrimSpace(r)
if r != "" {
repos = append(repos, r)
}
}
return repos
}
// isDryRun checks if --dry-run flag was passed.
func isDryRun() bool {
for _, arg := range os.Args[1:] {
if arg == "--dry-run" {
return true
}
}
return false
}