2026-02-05 10:36:21 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"log"
|
|
|
|
|
"os"
|
|
|
|
|
"os/signal"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"runtime"
|
|
|
|
|
"strings"
|
|
|
|
|
"syscall"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/host-uk/core/pkg/cli"
|
2026-02-08 23:15:41 +00:00
|
|
|
"github.com/host-uk/core/pkg/forge"
|
2026-02-05 10:36:21 +00:00
|
|
|
"github.com/host-uk/core/pkg/jobrunner"
|
2026-02-08 23:15:41 +00:00
|
|
|
forgejosource "github.com/host-uk/core/pkg/jobrunner/forgejo"
|
2026-02-05 10:36:21 +00:00
|
|
|
"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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 23:15:41 +00:00
|
|
|
// Forge client
|
2026-02-09 10:10:08 +00:00
|
|
|
forgeURL, forgeToken, _ := forge.ResolveConfig("", "")
|
|
|
|
|
forgeClient, err := forge.New(forgeURL, forgeToken)
|
2026-02-08 23:15:41 +00:00
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Failed to create forge client: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Forgejo source — repos from CORE_REPOS env var or default
|
2026-02-05 10:36:21 +00:00
|
|
|
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"}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 23:15:41 +00:00
|
|
|
source := forgejosource.New(forgejosource.Config{
|
2026-02-05 10:36:21 +00:00
|
|
|
Repos: repos,
|
2026-02-08 23:15:41 +00:00
|
|
|
}, forgeClient)
|
2026-02-05 10:36:21 +00:00
|
|
|
|
|
|
|
|
// Handlers (order matters — first match wins)
|
2026-02-08 23:15:41 +00:00
|
|
|
publishDraft := handlers.NewPublishDraftHandler(forgeClient)
|
|
|
|
|
sendFix := handlers.NewSendFixCommandHandler(forgeClient)
|
|
|
|
|
dismissReviews := handlers.NewDismissReviewsHandler(forgeClient)
|
|
|
|
|
enableAutoMerge := handlers.NewEnableAutoMergeHandler(forgeClient)
|
|
|
|
|
tickParent := handlers.NewTickParentHandler(forgeClient)
|
2026-02-05 10:36:21 +00:00
|
|
|
|
2026-02-09 10:10:08 +00:00
|
|
|
// 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)
|
|
|
|
|
|
2026-02-05 10:36:21 +00:00
|
|
|
// Build poller
|
|
|
|
|
poller := jobrunner.NewPoller(jobrunner.PollerConfig{
|
2026-02-08 23:15:41 +00:00
|
|
|
Sources: []jobrunner.JobSource{source},
|
2026-02-05 10:36:21 +00:00
|
|
|
Handlers: []jobrunner.JobHandler{
|
|
|
|
|
publishDraft,
|
|
|
|
|
sendFix,
|
2026-02-08 23:15:41 +00:00
|
|
|
dismissReviews,
|
2026-02-05 10:36:21 +00:00
|
|
|
enableAutoMerge,
|
|
|
|
|
tickParent,
|
2026-02-09 10:10:08 +00:00
|
|
|
dispatch, // Last — only matches NeedsCoding signals
|
2026-02-05 10:36:21 +00:00
|
|
|
},
|
|
|
|
|
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
|
|
|
|
|
}
|