feat(builder): research, build and a worker for the AI builder
- research: searches SearXNG, reads the best pages through the reader, has the model list the server's variants, then checks every Docker image and Steam app it named for real before an admin sees it. - build: first boot of the variant's images (skipping ones that need root), a draft by the model, then the draft deployed with the node-agent's BootTest like CI does; crashes, error lines and saves outside /server go back to the model, three boots at most. - worker: polls the panel's builder queue (outbound only), runs research or build in a fresh clone, streams the output as the job log, and opens the result as a pull request from the bot account. - Dockerfile.builder, a workflow publishing gbr-builder:latest, and BUILDER.md. Everything about where it runs comes from environment variables; deployment files live outside this public repo. draft now keeps its conversation in a drafter, so build can send boot failures back to the same model session. The boot-test runner comment no longer names its VM. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
05bfc9b061
commit
c75190d8c4
12 files changed
+1495
-95
No files matched your search
+157
-92
@@ -16,6 +16,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
// draft writes a first template for a new game with an AI model: it gives the model AUTHORING.md as its
|
||||
@@ -52,102 +54,165 @@ func runDraft(args []string) error {
|
||||
if *gameName == "" || *image == "" {
|
||||
return errors.New("draft needs -name and -image")
|
||||
}
|
||||
if *id == "" {
|
||||
*id = strings.Trim(regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(strings.ToLower(*gameName), "-"), "-")
|
||||
var report *bootReport
|
||||
if *reportPath != "" {
|
||||
var err error
|
||||
if report, err = readBootReport(*reportPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
dir := filepath.Join(*gamesDir, *id)
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
return fmt.Errorf("%s already exists; drafts only create new games", dir)
|
||||
}
|
||||
|
||||
m, err := newModel(*provider, *modelName)
|
||||
d, err := newDrafter(draftJob{GamesDir: *gamesDir, SchemaDir: *schemaDir, Name: *gameName, ID: *id, Image: *image,
|
||||
SteamApp: *steamApp, Notes: *notes, Report: report, Provider: *provider, Model: *modelName, NoPull: *noPull})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if problems, err := d.refine(*attempts, nil); err != nil {
|
||||
return err
|
||||
} else if len(problems) > 0 {
|
||||
return fmt.Errorf("draft still has problems after %d fix rounds; it is in %s for a person to finish", *attempts, d.dir)
|
||||
}
|
||||
fmt.Println("[draft] next: review it, then boot-test it (build / CI) before opening a PR")
|
||||
return nil
|
||||
}
|
||||
|
||||
type draftJob struct {
|
||||
GamesDir, SchemaDir string
|
||||
Name, ID, Image string
|
||||
SteamApp, Notes string
|
||||
Report *bootReport
|
||||
Provider, Model string
|
||||
NoPull bool
|
||||
}
|
||||
|
||||
// drafter holds one conversation with the model about one game, so later problems (a failed boot) can
|
||||
// be sent back to it with everything it was told before.
|
||||
type drafter struct {
|
||||
job draftJob
|
||||
dir, id string
|
||||
m model
|
||||
facts *imageFacts
|
||||
gameSchema, testSchema *jsonschema.Schema
|
||||
answerSchema map[string]any
|
||||
messages []chatMessage
|
||||
rounds int
|
||||
}
|
||||
|
||||
func newDrafter(j draftJob) (*drafter, error) {
|
||||
id := j.ID
|
||||
if id == "" {
|
||||
id = gameID(j.Name)
|
||||
}
|
||||
d := &drafter{job: j, id: id, dir: filepath.Join(j.GamesDir, id)}
|
||||
if _, err := os.Stat(d.dir); err == nil {
|
||||
return nil, fmt.Errorf("%s already exists; drafts only create new games", d.dir)
|
||||
}
|
||||
var err error
|
||||
if d.m, err = newModel(j.Provider, j.Model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules, err := os.ReadFile("AUTHORING.md")
|
||||
if err != nil {
|
||||
return fmt.Errorf("AUTHORING.md (the model's rules): %w", err)
|
||||
return nil, fmt.Errorf("AUTHORING.md (the model's rules): %w", err)
|
||||
}
|
||||
gameSchema, err := compileSchema(filepath.Join(*schemaDir, "game.schema.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
if d.gameSchema, err = compileSchema(filepath.Join(j.SchemaDir, "game.schema.json")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
testSchema, err := compileSchema(filepath.Join(*schemaDir, "test.schema.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
if d.testSchema, err = compileSchema(filepath.Join(j.SchemaDir, "test.schema.json")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answerSchema, err := draftAnswerSchema(*schemaDir)
|
||||
if err != nil {
|
||||
return err
|
||||
if d.answerSchema, err = draftAnswerSchema(j.SchemaDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !*noPull {
|
||||
fmt.Printf("[draft] pulling %s\n", *image)
|
||||
if out, err := runCmd("docker", "pull", "-q", *image); err != nil {
|
||||
return fmt.Errorf("docker pull: %v: %s", err, out)
|
||||
if !j.NoPull {
|
||||
fmt.Printf("[draft] pulling %s\n", j.Image)
|
||||
if out, err := runCmd("docker", "pull", "-q", j.Image); err != nil {
|
||||
return nil, fmt.Errorf("docker pull: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
facts, err := inspectImage(*image, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
if d.facts, err = inspectImage(j.Image, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var report *bootReport
|
||||
if *reportPath != "" {
|
||||
raw, err := os.ReadFile(*reportPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
report = &bootReport{}
|
||||
if err := json.Unmarshal(raw, report); err != nil {
|
||||
return fmt.Errorf("%s: %w", *reportPath, err)
|
||||
}
|
||||
if why := imageUnusable(report); why != "" {
|
||||
return errors.New(why)
|
||||
}
|
||||
if why := imageUnusable(j.Report); why != "" {
|
||||
return nil, errors.New(why)
|
||||
}
|
||||
|
||||
messages := []chatMessage{
|
||||
d.messages = []chatMessage{
|
||||
{Role: "system", Content: draftSystemPrompt(string(rules))},
|
||||
{Role: "user", Content: draftRequest(*gameName, *id, *image, *steamApp, *notes, facts, report, exampleTemplates(*gamesDir, 2))},
|
||||
{Role: "user", Content: draftRequest(j.Name, id, j.Image, j.SteamApp, j.Notes, d.facts, j.Report, exampleTemplates(j.GamesDir, 2))},
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
for round := 0; round <= *attempts; round++ {
|
||||
fmt.Printf("[draft] round %d: asking %s\n", round+1, m.name())
|
||||
func gameID(name string) string {
|
||||
return strings.Trim(regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(strings.ToLower(name), "-"), "-")
|
||||
}
|
||||
|
||||
// refine asks for a (corrected) template until it passes validation and the image checks, at most
|
||||
// attempts+1 answers. feedback, when given, is what went wrong since the last answer (e.g. its boot).
|
||||
// It returns the problems still left, nil when the draft passes.
|
||||
func (d *drafter) refine(attempts int, feedback []string) ([]string, error) {
|
||||
if len(feedback) > 0 {
|
||||
d.ask(feedback)
|
||||
}
|
||||
var problems []string
|
||||
for round := 0; round <= attempts; round++ {
|
||||
d.rounds++
|
||||
fmt.Printf("[draft] round %d: asking %s\n", d.rounds, d.m.name())
|
||||
start := time.Now()
|
||||
answer, err := m.chat(messages, answerSchema)
|
||||
answer, err := d.m.chat(d.messages, d.answerSchema)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
fmt.Printf("[draft] answer in %.0fs\n", time.Since(start).Seconds())
|
||||
messages = append(messages, chatMessage{Role: "assistant", Content: answer})
|
||||
d.messages = append(d.messages, chatMessage{Role: "assistant", Content: answer})
|
||||
|
||||
problems := writeDraft(dir, *id, answer, report)
|
||||
if len(problems) == 0 {
|
||||
problems = validateGame(dir, *id, gameSchema, testSchema)
|
||||
}
|
||||
if len(problems) == 0 {
|
||||
if g, err := loadMap(filepath.Join(dir, "game.yaml")); err == nil {
|
||||
imageProblems, _ := checkTemplate(g, facts)
|
||||
problems = append(problems, imageProblems...)
|
||||
t, _ := loadMap(filepath.Join(dir, "test.yaml"))
|
||||
problems = append(problems, draftCompleteness(g, t)...)
|
||||
problems = append(problems, unreadSettings(g, facts)...)
|
||||
problems = append(problems, savesOutsideVolumes(g, report)...)
|
||||
}
|
||||
}
|
||||
if len(problems) == 0 {
|
||||
fmt.Printf("[draft] %s passes validation and the image checks\n", dir)
|
||||
fmt.Println("[draft] next: review it, then boot-test it (first-boot / CI) before opening a PR")
|
||||
return nil
|
||||
if problems = d.check(answer); len(problems) == 0 {
|
||||
fmt.Printf("[draft] %s passes validation and the image checks\n", d.dir)
|
||||
return nil, nil
|
||||
}
|
||||
fmt.Printf("[draft] %d problem(s):\n", len(problems))
|
||||
for _, p := range problems {
|
||||
fmt.Println(" - " + p)
|
||||
}
|
||||
messages = append(messages, chatMessage{Role: "user", Content: "Your template has these problems. Fix all of them and " +
|
||||
"answer with the complete corrected JSON object (both \"game\" and \"test\"):\n- " + strings.Join(problems, "\n- ")})
|
||||
if round < attempts {
|
||||
d.ask(problems)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("draft still has problems after %d fix rounds; it is in %s for a person to finish", *attempts, dir)
|
||||
return problems, nil
|
||||
}
|
||||
|
||||
func (d *drafter) ask(problems []string) {
|
||||
d.messages = append(d.messages, chatMessage{Role: "user", Content: "Your template has these problems. Fix all of them and " +
|
||||
"answer with the complete corrected JSON object (both \"game\" and \"test\"):\n- " + strings.Join(problems, "\n- ")})
|
||||
}
|
||||
|
||||
func (d *drafter) check(answer string) []string {
|
||||
problems := writeDraft(d.dir, d.id, answer, d.job.Report)
|
||||
if len(problems) == 0 {
|
||||
problems = validateGame(d.dir, d.id, d.gameSchema, d.testSchema)
|
||||
}
|
||||
if len(problems) == 0 {
|
||||
if g, err := loadMap(filepath.Join(d.dir, "game.yaml")); err == nil {
|
||||
imageProblems, _ := checkTemplate(g, d.facts)
|
||||
problems = append(problems, imageProblems...)
|
||||
t, _ := loadMap(filepath.Join(d.dir, "test.yaml"))
|
||||
problems = append(problems, draftCompleteness(g, t)...)
|
||||
problems = append(problems, unreadSettings(g, d.facts)...)
|
||||
problems = append(problems, savesOutsideVolumes(g, d.job.Report)...)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func readBootReport(p string) (*bootReport, error) {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &bootReport{}
|
||||
if err := json.Unmarshal(raw, r); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", p, err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func draftSystemPrompt(rules string) string {
|
||||
@@ -269,22 +334,7 @@ var outsideNoise = []string{"/Steam/logs/", "/.steam/", "/steamcmd/", "/.cache/"
|
||||
// node recreates the container. A template passes once its startup command or settings point the game at
|
||||
// /server or /data; a boot test then shows whether that really worked.
|
||||
func savesOutsideVolumes(game map[string]any, report *bootReport) []string {
|
||||
if report == nil {
|
||||
return nil
|
||||
}
|
||||
dirs := map[string]bool{}
|
||||
for _, c := range report.ContainerChanges {
|
||||
_, p, _ := strings.Cut(c, " ")
|
||||
noise := false
|
||||
for _, n := range outsideNoise {
|
||||
if strings.Contains(p, n) {
|
||||
noise = true
|
||||
}
|
||||
}
|
||||
if !noise && p != "" {
|
||||
dirs[path.Dir(p)] = true
|
||||
}
|
||||
}
|
||||
dirs := outsideDirs(report)
|
||||
if len(dirs) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -302,7 +352,28 @@ func savesOutsideVolumes(game map[string]any, report *bootReport) []string {
|
||||
return []string{fmt.Sprintf("first boot: the server wrote its own files outside the server volume, in %s. Those are lost "+
|
||||
"whenever the node recreates the container (every start). Make the game keep them under /server, e.g. with "+
|
||||
"its save-directory launch option or by pointing that folder at /server, and include it in backupPaths",
|
||||
strings.Join(sortedKeysBool(dirs), ", "))}
|
||||
strings.Join(dirs, ", "))}
|
||||
}
|
||||
|
||||
// outsideDirs lists the folders outside the volumes where the game itself wrote files during a boot.
|
||||
func outsideDirs(report *bootReport) []string {
|
||||
if report == nil {
|
||||
return nil
|
||||
}
|
||||
dirs := map[string]bool{}
|
||||
for _, c := range report.ContainerChanges {
|
||||
_, p, _ := strings.Cut(c, " ")
|
||||
noise := false
|
||||
for _, n := range outsideNoise {
|
||||
if strings.Contains(p, n) {
|
||||
noise = true
|
||||
}
|
||||
}
|
||||
if !noise && p != "" {
|
||||
dirs[path.Dir(p)] = true
|
||||
}
|
||||
}
|
||||
return sortedKeysBool(dirs)
|
||||
}
|
||||
|
||||
// imageUnusable recognises a first boot where the image died within seconds because it needs root (or
|
||||
@@ -400,13 +471,7 @@ func exampleTemplates(gamesDir string, n int) string {
|
||||
// writeDraft turns the model's JSON answer into games/<id>/game.yaml and test.yaml, using the importer's
|
||||
// writer so drafts look like every other template.
|
||||
func writeDraft(dir, id, answer string, report *bootReport) []string {
|
||||
answer = strings.TrimSpace(answer)
|
||||
if i := strings.Index(answer, "{"); i > 0 {
|
||||
answer = answer[i:] // some models still wrap JSON in prose or code fences
|
||||
}
|
||||
if i := strings.LastIndex(answer, "}"); i >= 0 {
|
||||
answer = answer[:i+1]
|
||||
}
|
||||
answer = trimToJSON(answer)
|
||||
var parsed struct {
|
||||
Game map[string]any `json:"game"`
|
||||
Test map[string]any `json:"test"`
|
||||
|
||||
Reference in new issue
Block a user