The start-up check read the token's account with GET /user, which needs the read:user scope. A token with only repository scope, which is all the worker needs, got 403 and the worker exited in a restart loop. On 403 it now names the commit author from BOT_NAME (default gbr-bot) and still checks push access on the games repo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
418 lines
13 KiB
Go
418 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// worker runs the AI builder. It asks the panel for queued jobs (it only connects out,
|
|
// like node-agents do), runs research or build in a fresh clone of this repo, streams the output to the
|
|
// panel as the job's log, and finishes a build with a pull request opened as the bot account. CI then
|
|
// validates and boot-tests that pull request like any other.
|
|
//
|
|
// Panel API (headers X-Builder-Token and X-Builder-Worker):
|
|
//
|
|
// POST /api/v1/builder/jobs/claim {worker} -> 200 builderJob | 204 nothing queued
|
|
// POST /api/v1/builder/jobs/{id}/progress {stage, lines} -> 200 {cancelled}
|
|
// POST /api/v1/builder/jobs/{id}/result {research | build+prUrl | error}
|
|
|
|
type builderJob struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"` // "research" or "build"
|
|
GameName string `json:"gameName"`
|
|
Notes string `json:"notes"`
|
|
Variant json.RawMessage `json:"variant"`
|
|
}
|
|
|
|
type jobResult struct {
|
|
Research *researchResult `json:"research,omitempty"`
|
|
Build *buildResult `json:"build,omitempty"`
|
|
PRURL string `json:"prUrl,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type workerConfig struct {
|
|
panel, token string
|
|
gitea, repo, botToken string
|
|
workDir, name string
|
|
poll time.Duration
|
|
provider, model string
|
|
botLogin, botEmail string // the token's account, used as the commit author
|
|
}
|
|
|
|
// checkBot reads which account GITEA_TOKEN belongs to and makes sure it can push to the games repo, so a
|
|
// wrong token or missing permission shows when the worker starts, not at the end of a 30-minute build.
|
|
func (c *workerConfig) checkBot() error {
|
|
var user struct {
|
|
Login string `json:"login"`
|
|
Email string `json:"email"`
|
|
}
|
|
if err := c.giteaGet("/user", &user); err != nil {
|
|
if !strings.HasPrefix(err.Error(), "HTTP 403") {
|
|
return fmt.Errorf("GITEA_TOKEN: %w", err)
|
|
}
|
|
// A repository-only token (all the worker needs) may not read its own account: name the author from
|
|
// BOT_NAME instead. Whether it can push is still checked below.
|
|
user.Login = envOr("BOT_NAME", "gbr-bot")
|
|
}
|
|
var repo struct {
|
|
Permissions struct {
|
|
Push bool `json:"push"`
|
|
} `json:"permissions"`
|
|
}
|
|
if err := c.giteaGet("/repos/"+c.repo, &repo); err != nil {
|
|
return fmt.Errorf("%s: %w", c.repo, err)
|
|
}
|
|
if !repo.Permissions.Push {
|
|
return fmt.Errorf("the account %s can't push to %s: give it Write access there", user.Login, c.repo)
|
|
}
|
|
c.botLogin, c.botEmail = user.Login, user.Email
|
|
if c.botEmail == "" {
|
|
c.botEmail = user.Login + "@users.noreply"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *workerConfig) giteaGet(p string, out any) error {
|
|
req, err := http.NewRequest(http.MethodGet, c.gitea+"/api/v1"+p, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "token "+c.botToken)
|
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("HTTP %d: %.200s", resp.StatusCode, raw)
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
func runWorker(args []string) error {
|
|
fs := flag.NewFlagSet("worker", flag.ExitOnError)
|
|
once := fs.Bool("once", false, "handle at most one job, then exit")
|
|
fs.Parse(args)
|
|
host, _ := os.Hostname()
|
|
c := workerConfig{
|
|
panel: strings.TrimRight(os.Getenv("PANEL_URL"), "/"), token: os.Getenv("BUILDER_TOKEN"),
|
|
gitea: strings.TrimRight(envOr("GITEA_URL", "https://www.gitea.gbreed.com"), "/"), repo: envOr("GAMES_REPO", "gbreed-public/gbr-games"),
|
|
botToken: os.Getenv("GITEA_TOKEN"), workDir: envOr("WORK_DIR", "/work"), name: envOr("WORKER_NAME", host),
|
|
provider: envOr("MODEL_PROVIDER", "ollama"), model: os.Getenv("MODEL"),
|
|
}
|
|
var err error
|
|
if c.poll, err = time.ParseDuration(envOr("POLL_INTERVAL", "20s")); err != nil {
|
|
return err
|
|
}
|
|
for _, v := range []struct{ name, value string }{{"PANEL_URL", c.panel}, {"BUILDER_TOKEN", c.token}, {"GITEA_TOKEN", c.botToken}} {
|
|
if v.value == "" {
|
|
return fmt.Errorf("set %s", v.name)
|
|
}
|
|
}
|
|
if err := c.checkBot(); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("[worker] %s polling %s every %s; pull requests as %s\n", c.name, c.panel, c.poll, c.botLogin)
|
|
for {
|
|
job, err := c.claim()
|
|
if err != nil {
|
|
fmt.Printf("[worker] claim: %v\n", err)
|
|
}
|
|
if job != nil {
|
|
fmt.Printf("[worker] job %s: %s %q\n", job.ID, job.Kind, job.GameName)
|
|
res := c.handle(job)
|
|
if err := c.post("/jobs/"+job.ID+"/result", res, nil); err != nil {
|
|
fmt.Printf("[worker] posting the result of %s: %v\n", job.ID, err)
|
|
}
|
|
if *once {
|
|
return nil
|
|
}
|
|
continue
|
|
}
|
|
if *once {
|
|
return nil
|
|
}
|
|
time.Sleep(c.poll)
|
|
}
|
|
}
|
|
|
|
func envOr(name, def string) string {
|
|
if v := os.Getenv(name); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func (c *workerConfig) claim() (*builderJob, error) {
|
|
var job builderJob
|
|
status, err := c.request("/jobs/claim", map[string]string{"worker": c.name}, &job)
|
|
if err != nil || status == http.StatusNoContent {
|
|
return nil, err
|
|
}
|
|
return &job, nil
|
|
}
|
|
|
|
func (c *workerConfig) post(p string, body, out any) error {
|
|
_, err := c.request(p, body, out)
|
|
return err
|
|
}
|
|
|
|
func (c *workerConfig) request(p string, body, out any) (int, error) {
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, c.panel+"/api/v1/builder"+p, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Builder-Token", c.token)
|
|
req.Header.Set("X-Builder-Worker", c.name)
|
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 300 {
|
|
return resp.StatusCode, fmt.Errorf("HTTP %d: %.200s", resp.StatusCode, raw)
|
|
}
|
|
if out != nil && len(bytes.TrimSpace(raw)) > 0 {
|
|
if err := json.Unmarshal(raw, out); err != nil {
|
|
return resp.StatusCode, err
|
|
}
|
|
}
|
|
return resp.StatusCode, nil
|
|
}
|
|
|
|
// handle runs one job in its own clone and returns what to report; it never panics the worker.
|
|
func (c *workerConfig) handle(job *builderJob) *jobResult {
|
|
dir := filepath.Join(c.workDir, "job-"+job.ID)
|
|
os.RemoveAll(dir)
|
|
defer os.RemoveAll(dir)
|
|
if out, err := runCmd("git", "clone", "-q", "--depth", "1", c.gitea+"/"+c.repo+".git", dir); err != nil {
|
|
return &jobResult{Error: "git clone: " + out}
|
|
}
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
return &jobResult{Error: err.Error()}
|
|
}
|
|
resultFile := filepath.Join(dir, "job-result.json")
|
|
modelArgs := []string{"-provider", c.provider}
|
|
if c.model != "" {
|
|
modelArgs = append(modelArgs, "-model", c.model)
|
|
}
|
|
|
|
switch job.Kind {
|
|
case "research":
|
|
args := append([]string{"research", "-name", job.GameName, "-notes", job.Notes, "-out", resultFile}, modelArgs...)
|
|
if err := c.runStreamed(job.ID, dir, self, args, "RESEARCH"); err != nil {
|
|
return &jobResult{Error: err.Error()}
|
|
}
|
|
res := &researchResult{}
|
|
if err := readJSON(resultFile, res); err != nil {
|
|
return &jobResult{Error: err.Error()}
|
|
}
|
|
return &jobResult{Research: res}
|
|
|
|
case "build":
|
|
variantFile := filepath.Join(dir, "job-variant.json")
|
|
if err := os.WriteFile(variantFile, job.Variant, 0o644); err != nil {
|
|
return &jobResult{Error: err.Error()}
|
|
}
|
|
args := append([]string{"build", "-name", job.GameName, "-notes", job.Notes, "-variant", "@" + variantFile, "-out", resultFile}, modelArgs...)
|
|
runErr := c.runStreamed(job.ID, dir, self, args, "FIRST_BOOT")
|
|
res := &buildResult{}
|
|
if err := readJSON(resultFile, res); err != nil {
|
|
msg := "the build produced no result"
|
|
if runErr != nil {
|
|
msg = runErr.Error()
|
|
}
|
|
return &jobResult{Error: msg}
|
|
}
|
|
// A draft that validates but didn't boot still goes up as a draft PR: a person can finish it, and
|
|
// CI shows the same boot failure
|
|
if _, err := os.Stat(filepath.Join(dir, res.Dir, "game.yaml")); err != nil {
|
|
return &jobResult{Build: res, Error: "no template was written: " + strings.Join(res.Problems, "; ")}
|
|
}
|
|
c.progress(job.ID, "OPENING_PR", []string{"[worker] opening the pull request"})
|
|
pr, err := c.openPR(dir, job, res)
|
|
if err != nil {
|
|
return &jobResult{Build: res, Error: "opening the pull request: " + err.Error()}
|
|
}
|
|
return &jobResult{Build: res, PRURL: pr}
|
|
}
|
|
return &jobResult{Error: "unknown job kind " + job.Kind}
|
|
}
|
|
|
|
// Output prefixes that mark a new stage of a build, shown in the panel's progress bar
|
|
var stagePrefixes = []struct{ prefix, stage string }{
|
|
{"[build] first boot", "FIRST_BOOT"},
|
|
{"[first-boot]", ""},
|
|
{"[draft]", "DRAFTING"},
|
|
{"[build] boot ", "BOOT_TEST"},
|
|
}
|
|
|
|
// runStreamed runs a gbrgames command and sends its output to the panel every few seconds (which also
|
|
// tells the panel the worker is alive). The panel can answer "cancelled" to stop the job.
|
|
func (c *workerConfig) runStreamed(jobID, dir, self string, args []string, stage string) error {
|
|
cmd := exec.Command(self, args...)
|
|
cmd.Dir = dir
|
|
pr, pw := io.Pipe()
|
|
cmd.Stdout, cmd.Stderr = pw, pw
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
var mu sync.Mutex
|
|
var pending []string
|
|
var lastErr string
|
|
done := make(chan struct{})
|
|
go func() {
|
|
sc := bufio.NewScanner(pr)
|
|
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
|
for sc.Scan() {
|
|
line := strings.TrimRight(ansi.ReplaceAllString(sc.Text(), ""), "\r ")
|
|
fmt.Println(line)
|
|
mu.Lock()
|
|
pending = append(pending, line)
|
|
for _, s := range stagePrefixes {
|
|
if s.stage != "" && strings.HasPrefix(line, s.prefix) {
|
|
stage = s.stage
|
|
}
|
|
}
|
|
if strings.HasPrefix(line, "error: ") {
|
|
lastErr = strings.TrimPrefix(line, "error: ")
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
close(done)
|
|
}()
|
|
wait := make(chan error, 1)
|
|
go func() {
|
|
err := cmd.Wait()
|
|
pw.Close()
|
|
<-done
|
|
wait <- err
|
|
}()
|
|
|
|
tick := time.NewTicker(5 * time.Second)
|
|
defer tick.Stop()
|
|
flush := func() bool {
|
|
mu.Lock()
|
|
lines, st := pending, stage
|
|
pending = nil
|
|
mu.Unlock()
|
|
return c.progress(jobID, st, lines)
|
|
}
|
|
for {
|
|
select {
|
|
case err := <-wait:
|
|
flush()
|
|
if err != nil {
|
|
if lastErr != "" {
|
|
return errors.New(lastErr)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
case <-tick.C:
|
|
if cancelled := flush(); cancelled {
|
|
cmd.Process.Kill()
|
|
<-wait
|
|
return errors.New("cancelled from the panel")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// progress sends log lines (possibly none, as a heartbeat) and reports whether the job was cancelled.
|
|
func (c *workerConfig) progress(jobID, stage string, lines []string) bool {
|
|
var resp struct {
|
|
Cancelled bool `json:"cancelled"`
|
|
}
|
|
if lines == nil {
|
|
lines = []string{}
|
|
}
|
|
if err := c.post("/jobs/"+jobID+"/progress", map[string]any{"stage": stage, "lines": lines}, &resp); err != nil {
|
|
fmt.Printf("[worker] progress: %v\n", err)
|
|
}
|
|
return resp.Cancelled
|
|
}
|
|
|
|
// openPR commits the new game folder to its own branch as the bot and opens a pull request to main.
|
|
func (c *workerConfig) openPR(dir string, job *builderJob, res *buildResult) (string, error) {
|
|
short := job.ID
|
|
if len(short) > 8 {
|
|
short = short[:8]
|
|
}
|
|
branch := "draft/" + res.GameID + "-" + short
|
|
auth := "http.extraHeader=Authorization: token " + c.botToken
|
|
steps := []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{"checkout", []string{"checkout", "-q", "-b", branch}},
|
|
{"add", []string{"add", filepath.ToSlash(res.Dir)}},
|
|
{"commit", []string{"-c", "user.name=" + c.botLogin, "-c", "user.email=" + c.botEmail, "commit", "-q", "-m",
|
|
fmt.Sprintf("feat(games): draft %s\n\nDrafted and boot-tested by the GBR builder (job %s).", job.GameName, job.ID)}},
|
|
{"push", []string{"-c", auth, "push", "-q", "origin", branch}},
|
|
}
|
|
for _, s := range steps {
|
|
cmd := exec.Command("git", s.args...)
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
// The push command holds the token: name the step only, and mask it in git's output
|
|
return "", fmt.Errorf("git %s: %s", s.name, strings.ReplaceAll(string(out), c.botToken, "***"))
|
|
}
|
|
}
|
|
title := "feat(games): add " + job.GameName
|
|
if !res.Passed {
|
|
title = "WIP: " + title + " (did not boot yet)"
|
|
}
|
|
body := map[string]any{"title": title, "head": branch, "base": "main", "body": res.Summary}
|
|
payload, _ := json.Marshal(body)
|
|
req, err := http.NewRequest(http.MethodPost, c.gitea+"/api/v1/repos/"+c.repo+"/pulls", bytes.NewReader(payload))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "token "+c.botToken)
|
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
var pr struct {
|
|
HTMLURL string `json:"html_url"`
|
|
Message string `json:"message"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&pr)
|
|
if resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, pr.Message)
|
|
}
|
|
return pr.HTMLURL, nil
|
|
}
|
|
|
|
func readJSON(p string, out any) error {
|
|
raw, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(raw, out)
|
|
}
|