Gitea's push-mirror cannot push to a non-existent remote — it just runs 'git push' against whatever URL it's given. So a project_create flow that only configures the mirror leaves the GitHub side as an unfulfillable URL. New internal/githubclient package: single-purpose client that POSTs /user/repos to create an empty private repo (auto_init=false so the first mirror push doesn't conflict with a generated README). Treats 422 'name already exists' as idempotent success via ErrAlreadyExists. 401/403 are surfaced as 'PAT missing repo scope or invalid' so the operator sees the real cause instead of a vague upstream error. Skill wiring: - New stepCreateGitHub between stepCreateRepo and stepMirror in the orchestrator. - Skipped entirely when Config.GitHub is nil (degraded mode — the routing pod runs without GITHUB_PAT, mirror config still lands, but the actual sync to github fails until the repo exists). - cmd/routing/main.go constructs githubclient.New(GitHubPAT) only when the PAT is set; the skill receives nil otherwise. Tests: - happy path: fake github 201 + assertions that the 'reached' array is [create_repo, create_github_repo, mirror, infra_commit, issue]. - github 422 already-exists: idempotent, all gitea steps still run. - github 401: returns failed_step=create_github_repo, no mirror or later steps. - degraded mode (Config.GitHub nil): reached omits create_github_repo, rest of the flow runs unchanged. Updated existing tests to read [skill, gh] from newSkill instead of just skill, and adjusted reached-array expectations to include the new step. Tracks #10.
166 lines
5.6 KiB
Go
166 lines
5.6 KiB
Go
package main
|
|
|
|
// The internal/skills/{debug,retrospective,review,trainer} packages imported
|
|
// below are also imported by cmd/supervisor. Plan 7 (supervisor retirement)
|
|
// MUST NOT delete these four packages — the routing pod is their second
|
|
// consumer. Plan 7 deletes only internal/skills/{tdd,spec,tier} (the skills
|
|
// that don't route to local), the supervisor binary, and supervisor manifests.
|
|
// See docs/superpowers/specs/2026-05-04-mode-2-routing-pod-design.md (Constraints).
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/mathiasbq/supervisor/internal/auth"
|
|
"github.com/mathiasbq/supervisor/internal/config"
|
|
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
|
"github.com/mathiasbq/supervisor/internal/githubclient"
|
|
"github.com/mathiasbq/supervisor/internal/mcp"
|
|
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
|
"github.com/mathiasbq/supervisor/internal/registry"
|
|
"github.com/mathiasbq/supervisor/internal/routing"
|
|
"github.com/mathiasbq/supervisor/internal/skills/debug"
|
|
"github.com/mathiasbq/supervisor/internal/skills/project"
|
|
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
|
|
"github.com/mathiasbq/supervisor/internal/skills/review"
|
|
"github.com/mathiasbq/supervisor/internal/skills/trainer"
|
|
)
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
slog.SetDefault(logger)
|
|
|
|
cfg, err := config.LoadRouting()
|
|
if err != nil {
|
|
logger.Error("config load failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
configDir := envOr("SUPERVISOR_CONFIG_DIR", "/app/config/supervisor")
|
|
mustRead := func(path string) string {
|
|
b, err := os.ReadFile(configDir + "/" + path)
|
|
if err != nil {
|
|
logger.Error("read prompt failed", "path", path, "err", err)
|
|
os.Exit(1)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
llm := iexec.NewLiteLLM(cfg.LiteLLMBaseURL, cfg.LiteLLMAPIKey, 0)
|
|
|
|
router := &routing.Router{
|
|
Fetcher: routing.NewFetcher(cfg.BrainURL, "7d", time.Duration(cfg.PassRateTTLSeconds)*time.Second),
|
|
Logger: routing.NewLogger(cfg.BrainURL),
|
|
Policy: routing.Policy{Floor: cfg.RouteLocalFloor, Ceil: cfg.RouteLocalCeil},
|
|
FastModel: cfg.FastModel,
|
|
ThinkingModel: cfg.ThinkingModel,
|
|
Complete: llm.Complete,
|
|
}
|
|
|
|
// Skill packages call CompleteFunc(ctx, model, system, user) — no session_id
|
|
// or project_root in the signature. Rather than modifying every skill's API
|
|
// (and inflating Plan 6's blast radius), the routing pod logs every decision
|
|
// under a fixed session_id "_routing". Operators query
|
|
// `GET /pass-rate?skill=_routing&window=...` to inspect routing health.
|
|
const routingSessionID = "_routing"
|
|
wrap := func(skillName string) routing.CompleteFunc {
|
|
return func(ctx context.Context, _, system, user string) (string, int64, error) {
|
|
// The model param is ignored: the router picks the model based on policy.
|
|
return router.Run(ctx, routing.RunInput{
|
|
Skill: skillName,
|
|
System: system,
|
|
User: user,
|
|
SessionID: routingSessionID,
|
|
ProjectRoot: "",
|
|
})
|
|
}
|
|
}
|
|
|
|
reg := registry.New()
|
|
reg.Register(review.New(review.Config{
|
|
SkillPrompt: mustRead("review.md"),
|
|
DefaultModel: cfg.FastModel,
|
|
CompleteFunc: review.CompleteFunc(wrap("review")),
|
|
}))
|
|
reg.Register(debug.New(debug.Config{
|
|
SkillPrompt: mustRead("debug.md"),
|
|
DefaultModel: cfg.FastModel,
|
|
CompleteFunc: debug.CompleteFunc(wrap("debug")),
|
|
}))
|
|
reg.Register(retrospective.New(retrospective.Config{
|
|
SkillPrompt: mustRead("retrospective.md"),
|
|
DefaultModel: cfg.FastModel,
|
|
CompleteFunc: retrospective.CompleteFunc(wrap("retrospective")),
|
|
}))
|
|
reg.Register(trainer.New(trainer.Config{
|
|
ReaderPrompt: mustRead("trainer-reader.md"),
|
|
WriterPrompt: mustRead("trainer-writer.md"),
|
|
DefaultModel: cfg.FastModel,
|
|
CompleteFunc: trainer.CompleteFunc(wrap("trainer")),
|
|
}))
|
|
|
|
if cfg.GiteaMCPURL != "" {
|
|
var ghClient *githubclient.Client
|
|
if cfg.GitHubPAT != "" {
|
|
ghClient = githubclient.New(cfg.GitHubPAT)
|
|
}
|
|
reg.Register(project.New(project.Config{
|
|
Client: mcpclient.New(cfg.GiteaMCPURL, cfg.GiteaMCPToken),
|
|
GitHub: ghClient,
|
|
GiteaOwner: cfg.GiteaOwner,
|
|
GitHubOwner: cfg.GitHubOwner,
|
|
GitHubPAT: cfg.GitHubPAT,
|
|
InfraRepo: cfg.InfraRepo,
|
|
}))
|
|
logger.Info("project_create registered", "gitea_mcp_url", cfg.GiteaMCPURL,
|
|
"gitea_owner", cfg.GiteaOwner, "github_owner", cfg.GitHubOwner,
|
|
"infra_repo", cfg.InfraRepo, "github_pat_set", cfg.GitHubPAT != "")
|
|
} else {
|
|
logger.Info("project_create skipped — GITEA_MCP_URL not set")
|
|
}
|
|
|
|
var validator *auth.Validator
|
|
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
|
audience := os.Getenv("MCP_AUDIENCE")
|
|
v, err := auth.NewValidator(dexURL, audience)
|
|
if err != nil {
|
|
logger.Error("build jwt validator", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
validator = v
|
|
logger.Info("jwt auth enabled", "issuer", dexURL)
|
|
}
|
|
|
|
srv := mcp.NewServer(reg, cfg.MCPAuthToken, validator)
|
|
mux := http.NewServeMux()
|
|
mux.Handle("/mcp", srv)
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
|
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
|
auth.ProtectedResourceHandler(resourceURL, dexURL))
|
|
}
|
|
|
|
addr := ":" + cfg.Port
|
|
logger.Info("routing pod starting", "addr", addr,
|
|
"fast", cfg.FastModel, "thinking", cfg.ThinkingModel,
|
|
"floor", cfg.RouteLocalFloor, "ceil", cfg.RouteLocalCeil)
|
|
if err := http.ListenAndServe(addr, mux); err != nil { //nolint:gosec
|
|
logger.Error("server stopped", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func envOr(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|