From 487dfbdf681766c7cc76761f3e59dd3a058c0c35 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 25 Oct 2026 23:15:33 -0400 Subject: [PATCH] feat(builder): diagnose a failed boot before changing the template A failed boot went straight back to the model as "fix it", so it guessed: one draft raised its memory three rounds in a row while the real cause, an empty setting the server can't run without, stayed untouched. Now a failed boot is investigated first: - Evidence: the server's distinct error lines (timestamps and repeats removed), and a web search on the most telling one, with the top answer read through the reader. - Diagnosis: the model states the cause, the evidence for it, its confidence and the exact fix. If unsure, it asks for up to 3 checks: a search, a page, the files the server writes (a first boot of the draft), or a boot test with settings or the startup command changed. Results go back to it, two rounds at most. - Fix: the model applies exactly the diagnosed fix and nothing unrelated. - Attempt log: each cause, fix and outcome. A fix that changed nothing rules its cause out; if the diagnosis returns to a ruled-out cause, the build stops instead of guessing, and the PR lists what the builder found. Boot rounds go up to 5, since rounds now end on a ruled-out cause rather than a fixed count. Co-Authored-By: Claude Opus 5.5 --- cmd/gbrgames/build.go | 32 ++- cmd/gbrgames/diagnose.go | 385 ++++++++++++++++++++++++++++++++++ cmd/gbrgames/diagnose_test.go | 128 +++++++++++ 3 files changed, 542 insertions(+), 3 deletions(-) create mode 100644 cmd/gbrgames/diagnose.go create mode 100644 cmd/gbrgames/diagnose_test.go diff --git a/cmd/gbrgames/build.go b/cmd/gbrgames/build.go index bb94efb..b6267f6 100644 --- a/cmd/gbrgames/build.go +++ b/cmd/gbrgames/build.go @@ -27,6 +27,8 @@ type buildResult struct { Verified []string `json:"verified"` Problems []string `json:"problems"` Summary string `json:"summary"` + // What the builder diagnosed after each failed boot, the fix it made, and how that went + Diagnoses []attempt `json:"diagnoses,omitempty"` } const defaultAgentImage = "www.gitea.gbreed.com/gbreed-public/gbr-node-agent:latest" @@ -42,7 +44,7 @@ func runBuild(args []string) error { provider := fs.String("provider", "ollama", "ollama or gemini") modelName := fs.String("model", "", "model name (default as for draft)") attempts := fs.Int("attempts", 3, "fix rounds for validation problems, each time") - bootRounds := fs.Int("boot-rounds", 3, "boots of the draft before giving up") + bootRounds := fs.Int("boot-rounds", 5, "boots of the draft at most (it stops earlier when a ruled-out cause comes back)") out := fs.String("out", "", "write the result JSON here") fs.Parse(args) if *gameName == "" || *variantArg == "" { @@ -158,14 +160,20 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) { return res, nil } - // 3. Boot the draft the way nodes run it; failures go back to the model + // 3. Boot the draft the way nodes run it. A failure is investigated and diagnosed before the model + // changes anything; a fix that changes nothing rules its cause out wantOutside := len(outsideDirs(report)) > 0 + iv := newInvestigator(d.m, o.name, o.gamesDir, o.id, o.agentImage) + lastFailure := "" for boot := 1; boot <= o.bootRounds; boot++ { fmt.Printf("[build] boot %d of the draft with the node-agent\n", boot) bootProblems, notes, err := bootCheck(o.gamesDir, o.id, o.agentImage, wantOutside) if err != nil { return res, err } + failure := strings.Join(bootProblems, "\n") + iv.outcome(len(bootProblems) == 0, lastFailure, failure) + lastFailure = failure if len(bootProblems) == 0 { res.Passed = true res.Verified = append(res.Verified, notes...) @@ -179,7 +187,18 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) { if boot == o.bootRounds { break } - problems, err := d.refine(o.attempts, bootProblems) + diag, err := iv.diagnose(failure) + if err != nil { + fmt.Printf("[build] diagnosis failed (%v); sending the failure as it is\n", err) + diag = &diagnosis{Cause: "unknown", Fix: "find the cause in the server's output", Evidence: failure} + } + if iv.ruledOut(diag) { + res.Problems = append(res.Problems, "the diagnosis came back to a cause an earlier fix ruled out ("+firstLine(diag.Cause)+ + "); the builder stopped rather than guess. See what the builder found") + break + } + iv.record(diag) + problems, err := d.refine(o.attempts, fixRequest(failure, diag)) res.Rounds = d.rounds if err != nil { return res, err @@ -189,6 +208,7 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) { break } } + res.Diagnoses = iv.log if res.Passed { res.Problems = nil } @@ -426,6 +446,12 @@ func buildSummary(o buildOptions, res *buildResult) string { fmt.Fprintf(&b, "- %s\n", firstLine(p)) } } + if len(res.Diagnoses) > 0 { + b.WriteString("\n**What the builder found** (after each failed boot)\n") + for i, a := range res.Diagnoses { + fmt.Fprintf(&b, "%d. Cause: %s\n Fix: %s\n Outcome: %s\n", i+1, firstLine(a.Cause), firstLine(a.Fix), a.Outcome) + } + } if o.variant.Notes != "" { fmt.Fprintf(&b, "\n**Notes from research:** %s\n", o.variant.Notes) } diff --git a/cmd/gbrgames/diagnose.go b/cmd/gbrgames/diagnose.go new file mode 100644 index 0000000..6d82750 --- /dev/null +++ b/cmd/gbrgames/diagnose.go @@ -0,0 +1,385 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// When a draft fails its boot, the builder investigates before anything is changed, the way a person +// would: it collects evidence (the server's errors, what others found about that error on the web), has +// the model state the cause and the evidence for it, lets the model ask for more checks (a search, a +// page, the files the server wrote, a boot with one thing changed), and only then asks for exactly that +// fix. Every attempt is logged, so a fix that changed nothing rules its cause out instead of being tried +// again, and the build stops when a ruled-out cause comes back. + +type attempt struct { + Cause string `json:"cause"` + Fix string `json:"fix"` + Outcome string `json:"outcome"` +} + +type checkRequest struct { + Type string `json:"type"` // search, read, files, boot_with + Query string `json:"query,omitempty"` + URL string `json:"url,omitempty"` + Config map[string]string `json:"config,omitempty"` + Startup string `json:"startup,omitempty"` +} + +type diagnosis struct { + Cause string `json:"cause"` + Evidence string `json:"evidence"` + Confidence string `json:"confidence"` + Fix string `json:"fix"` + Checks []checkRequest `json:"checks"` +} + +var diagnosisSchema = map[string]any{ + "type": "object", + "required": []string{"cause", "evidence", "confidence", "fix", "checks"}, + "properties": map[string]any{ + "cause": map[string]any{"type": "string"}, + "evidence": map[string]any{"type": "string"}, + "confidence": map[string]any{"enum": []string{"high", "medium", "low"}}, + "fix": map[string]any{"type": "string"}, + "checks": map[string]any{ + "type": "array", + "maxItems": 3, + "items": map[string]any{ + "type": "object", + "required": []string{"type"}, + "properties": map[string]any{ + "type": map[string]any{"enum": []string{"search", "read", "files", "boot_with"}}, + "query": map[string]any{"type": "string"}, + "url": map[string]any{"type": "string"}, + "config": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}}, + "startup": map[string]any{"type": "string"}, + }, + }, + }, + }, +} + +const diagnosePrompt = "You debug why a game server template fails on GBR Hosting nodes. Work like an engineer: find the " + + "cause from the evidence, never by guessing or by changing things to see what happens. The house rules below " + + "describe how servers really run; the boot test starts the server with exactly the settings a new server gets " + + "(settings left empty stay empty; only x-generate fields are filled).\n\n" + + "Answer with JSON: cause (what is wrong, specifically), evidence (which lines, files or results show it), " + + "confidence, fix (the exact change to the template), and checks. If the evidence doesn't settle the cause, ask " + + "for up to 3 checks instead of guessing:\n" + + "- {\"type\":\"search\",\"query\":\"...\"}: a web search, e.g. the exact error message with the game's name\n" + + "- {\"type\":\"read\",\"url\":\"...\"}: read a page\n" + + "- {\"type\":\"files\"}: boot the template once more and list the files and config files the server writes, " + + "where it writes them, and its error lines\n" + + "- {\"type\":\"boot_with\",\"config\":{\"Key\":\"value\"},\"startup\":\"...\"}: boot test with those settings " + + "and/or that startup command changed, to see whether that change makes the difference\n" + + "Leave checks empty once you are sure. Never propose a cause the attempt log has ruled out.\n\n" + +type investigator struct { + m model + gameName, gamesDir, id, agentImage string + searx, reader, rules string + log []attempt +} + +func newInvestigator(m model, gameName, gamesDir, id, agentImage string) *investigator { + rules, _ := os.ReadFile("AUTHORING.md") + return &investigator{m: m, gameName: gameName, gamesDir: gamesDir, id: id, agentImage: agentImage, + searx: os.Getenv("SEARXNG_URL"), reader: os.Getenv("READER_URL"), rules: string(rules)} +} + +// diagnose investigates one failure and returns the model's diagnosis (checks answered). +func (iv *investigator) diagnose(failure string) (*diagnosis, error) { + template, _ := os.ReadFile(filepath.Join(iv.gamesDir, iv.id, "game.yaml")) + var b strings.Builder + fmt.Fprintf(&b, "Game: %s\n\n## The template\n```yaml\n%s```\n\n## What failed\n%s\n", iv.gameName, template, failure) + if ev := iv.webEvidence(failure); ev != "" { + b.WriteString("\n## What the web says about its errors\n" + ev) + } + b.WriteString("\n## Attempt log\n" + iv.logText()) + messages := []chatMessage{ + {Role: "system", Content: diagnosePrompt + iv.rules}, + {Role: "user", Content: b.String()}, + } + var d diagnosis + for round := 0; ; round++ { + fmt.Printf("[diagnose] round %d: asking %s what is wrong\n", round+1, iv.m.name()) + answer, err := iv.m.chat(messages, diagnosisSchema) + if err != nil { + return nil, err + } + d = diagnosis{} + if err := json.Unmarshal([]byte(trimToJSON(answer)), &d); err != nil { + return nil, fmt.Errorf("unreadable diagnosis: %w", err) + } + fmt.Printf("[diagnose] cause (%s confidence): %s\n", d.Confidence, firstLine(d.Cause)) + if len(d.Checks) == 0 || round == 2 { + return &d, nil + } + messages = append(messages, chatMessage{Role: "assistant", Content: answer}) + messages = append(messages, chatMessage{Role: "user", Content: "Results of your checks:\n\n" + iv.runChecks(d.Checks) + + "\nNow give your diagnosis. Ask for more checks only if these still don't settle the cause."}) + } +} + +// webEvidence searches for the server's most telling error line and reads the best answer. +func (iv *investigator) webEvidence(failure string) string { + errs := distinctErrors(failure, 3) + if len(errs) == 0 || iv.searx == "" { + return "" + } + return iv.search(iv.gameName + " dedicated server " + errs[0]) +} + +func (iv *investigator) search(query string) string { + if iv.searx == "" { + return "(no search configured)\n" + } + fmt.Printf("[diagnose] searching: %s\n", query) + hits, err := searxSearch(iv.searx, query) + if err != nil || len(hits) == 0 { + return fmt.Sprintf("search %q: no results\n", query) + } + var b strings.Builder + fmt.Fprintf(&b, "Search %q:\n", query) + for i, h := range hits { + if i == 5 { + break + } + fmt.Fprintf(&b, "- %s — %s: %.300s\n", h.URL, h.Title, h.Content) + } + if iv.reader != "" { + if text, err := readPageLimit(iv.reader, hits[0].URL, 6000); err == nil { + fmt.Fprintf(&b, "\nTop result (%s):\n%s\n", hits[0].URL, text) + } + } + return b.String() +} + +func (iv *investigator) runChecks(checks []checkRequest) string { + var b strings.Builder + boots := 0 + for _, c := range checks { + switch c.Type { + case "search": + b.WriteString(iv.search(c.Query)) + case "read": + fmt.Printf("[diagnose] reading %s\n", c.URL) + if text, err := readPageLimit(iv.reader, c.URL, 8000); err != nil { + fmt.Fprintf(&b, "read %s: %v\n", c.URL, err) + } else { + fmt.Fprintf(&b, "Page %s:\n%s\n", c.URL, text) + } + case "files", "boot_with": + // Boots take minutes: at most two per round + if boots++; boots > 2 { + fmt.Fprintf(&b, "%s: skipped (at most two boots per round)\n", c.Type) + continue + } + if c.Type == "files" { + b.WriteString(iv.filesCheck()) + } else { + b.WriteString(iv.bootWith(c)) + } + } + b.WriteString("\n") + } + return b.String() +} + +// filesCheck boots the template and reports what the server wrote and where. +func (iv *investigator) filesCheck() string { + fmt.Println("[diagnose] booting once more to see what the server writes") + r, err := firstBootGame(iv.gamesDir, iv.id) + if err != nil { + return "files: the boot could not run: " + err.Error() + "\n" + } + var b strings.Builder + fmt.Fprintf(&b, "Files check: %s\n", r.Outcome) + for i, f := range r.FilesCreated { + if i == 60 { + fmt.Fprintf(&b, " ... and %d more files\n", len(r.FilesCreated)-60) + break + } + fmt.Fprintf(&b, " file %s (%d bytes)\n", f.Path, f.Size) + } + for i, s := range r.Settings { + if i == 80 { + break + } + fmt.Fprintf(&b, " setting %s | %s | %s = %s\n", s.File, s.Section, s.Key, s.Value) + } + if dirs := outsideDirs(r); len(dirs) > 0 { + fmt.Fprintf(&b, " written outside /server and /data (lost on every restart): %s\n", strings.Join(dirs, ", ")) + } + for i, l := range r.ErrorLines { + if i == 10 { + break + } + fmt.Fprintf(&b, " error line: %s\n", l.Text) + } + return b.String() +} + +// bootWith boot-tests a copy of the template with settings and/or the startup command changed. +func (iv *investigator) bootWith(c checkRequest) string { + desc := describeChange(c) + fmt.Printf("[diagnose] boot test with %s\n", desc) + games, err := os.MkdirTemp("", "gbr-diagnose-") + if err != nil { + return "boot_with: " + err.Error() + "\n" + } + defer os.RemoveAll(games) + if err := copyTemplateWith(filepath.Join(iv.gamesDir, iv.id), filepath.Join(games, iv.id), c); err != nil { + return "boot_with: " + err.Error() + "\n" + } + output, passed, problems, err := agentBoot(games, iv.id, iv.agentImage, false) + switch { + case err != nil: + return "boot_with: the boot could not run: " + err.Error() + "\n" + case problems != nil: + return "boot_with: " + strings.Join(problems, "; ") + "\n" + case passed: + return fmt.Sprintf("Boot with %s: PASSED (%s)\n", desc, bootVerdict(output)) + } + return fmt.Sprintf("Boot with %s: %s\n", desc, strings.TrimPrefix(bootFailure(output), "booting your template with the node-agent (the real deploy code) failed: ")) +} + +func describeChange(c checkRequest) string { + var parts []string + for _, k := range sortedKeysString(c.Config) { + parts = append(parts, fmt.Sprintf("%s=%q", k, c.Config[k])) + } + if c.Startup != "" { + parts = append(parts, fmt.Sprintf("startup %q", c.Startup)) + } + if len(parts) == 0 { + return "no change" + } + return strings.Join(parts, ", ") +} + +// copyTemplateWith copies games// to dst with the requested settings and startup command applied. +func copyTemplateWith(src, dst string, c checkRequest) error { + g, err := loadMap(filepath.Join(src, "game.yaml")) + if err != nil { + return err + } + if len(c.Config) > 0 { + defaults, _ := g["defaultConfig"].(map[string]any) + if defaults == nil { + defaults = map[string]any{} + } + for k, v := range c.Config { + defaults[k] = v + } + g["defaultConfig"] = defaults + } + if c.Startup != "" { + g["defaultStartupCommand"] = c.Startup + } + if err := os.MkdirAll(dst, 0o755); err != nil { + return err + } + if err := writeGame(filepath.Join(dst, "game.yaml"), g); err != nil { + return err + } + if raw, err := os.ReadFile(filepath.Join(src, "test.yaml")); err == nil { + return os.WriteFile(filepath.Join(dst, "test.yaml"), raw, 0o644) + } + return nil +} + +var ( + digits = regexp.MustCompile(`\d+`) + // Timestamps the shared linePrefix doesn't cover, like 10/26/2026 01:12:02: + slashDate = regexp.MustCompile(`^\s*\d{1,2}/\d{1,2}/\d{2,4}[ T][0-9:.,]+:?\s*`) +) + +// distinctErrors picks the server's error-looking lines, without timestamps and with repeats removed. +// The boot test's own verdict lines are not the server's errors. +func distinctErrors(output string, n int) []string { + seen := map[string]bool{} + var out []string + for _, raw := range strings.Split(output, "\n") { + raw = strings.TrimSpace(ansi.ReplaceAllString(raw, "")) + if strings.HasPrefix(raw, "[boot-test]") || strings.HasPrefix(raw, "booting your template") { + continue + } + line := strings.TrimSpace(slashDate.ReplaceAllString(linePrefix.ReplaceAllString(raw, ""), "")) + if line == "" || !errorWords.MatchString(line) { + continue + } + key := digits.ReplaceAllString(strings.ToLower(line), "N") + if seen[key] { + continue + } + seen[key] = true + if len(line) > 160 { + line = line[:160] + } + out = append(out, line) + if len(out) == n { + break + } + } + return out +} + +func (iv *investigator) logText() string { + if len(iv.log) == 0 { + return "(first attempt)\n" + } + var b strings.Builder + for i, a := range iv.log { + fmt.Fprintf(&b, "%d. cause: %s\n fix: %s\n outcome: %s\n", i+1, a.Cause, a.Fix, a.Outcome) + } + b.WriteString("A fix whose outcome is unchanged ruled its cause out: look for another cause.\n") + return b.String() +} + +// ruledOut reports whether the diagnosis names a cause an earlier fix already disproved. +func (iv *investigator) ruledOut(d *diagnosis) bool { + key := causeKey(d.Cause) + for _, a := range iv.log { + if causeKey(a.Cause) == key && !strings.HasPrefix(a.Outcome, "fixed") { + return true + } + } + return false +} + +var nonWord = regexp.MustCompile(`[^a-z0-9]+`) + +func causeKey(s string) string { + return strings.Trim(nonWord.ReplaceAllString(strings.ToLower(s), " "), " ") +} + +func (iv *investigator) record(d *diagnosis) { + iv.log = append(iv.log, attempt{Cause: d.Cause, Fix: d.Fix, Outcome: "(not booted yet)"}) +} + +// outcome fills in how the last fix went: fixed, the same failure, or a new one. +func (iv *investigator) outcome(passed bool, before, after string) { + if len(iv.log) == 0 { + return + } + a := &iv.log[len(iv.log)-1] + switch { + case passed: + a.Outcome = "fixed: the boot test passed" + case causeKey(firstLine(before)) == causeKey(firstLine(after)): + a.Outcome = "unchanged: " + firstLine(after) + default: + a.Outcome = "a different failure: " + firstLine(after) + } +} + +func fixRequest(failure string, d *diagnosis) []string { + return []string{fmt.Sprintf("%s\n\nDiagnosis:\n- cause: %s\n- evidence: %s\n- fix: %s\n\nApply exactly this fix and change "+ + "nothing unrelated to it.", firstLine(failure), d.Cause, d.Evidence, d.Fix)} +} diff --git a/cmd/gbrgames/diagnose_test.go b/cmd/gbrgames/diagnose_test.go new file mode 100644 index 0000000..d4abb8a --- /dev/null +++ b/cmd/gbrgames/diagnose_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeModel answers with the given replies in order and keeps what it was asked. +type fakeModel struct { + replies []string + asked [][]chatMessage +} + +func (f *fakeModel) name() string { return "fake" } +func (f *fakeModel) chat(m []chatMessage, _ map[string]any) (string, error) { + f.asked = append(f.asked, append([]chatMessage{}, m...)) + r := f.replies[0] + f.replies = f.replies[1:] + return r, nil +} + +func TestDiagnoseRunsRequestedChecksBeforeAnswering(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/search") { + w.Write([]byte(`{"results":[{"url":"https://example.com/fix","title":"Server stops at start","content":"set a password"}]}`)) + return + } + w.Write([]byte("The server refuses to run without a join password.")) + })) + defer srv.Close() + games := t.TempDir() + os.MkdirAll(filepath.Join(games, "example"), 0o755) + os.WriteFile(filepath.Join(games, "example", "game.yaml"), []byte("id: example\nname: Example\n"), 0o644) + + m := &fakeModel{replies: []string{ + `{"cause":"unsure","evidence":"no error line","confidence":"low","fix":"","checks":[{"type":"search","query":"example server stops at start"}]}`, + `{"cause":"the server needs a join password","evidence":"the search result says so","confidence":"high","fix":"give JoinPassword x-generate","checks":[]}`, + }} + iv := &investigator{m: m, gameName: "Example", gamesDir: games, id: "example", searx: srv.URL, reader: srv.URL} + d, err := iv.diagnose("FAILED: server crashed and was restarted 1 time(s)\nError: could not start") + if err != nil { + t.Fatal(err) + } + if d.Confidence != "high" || !strings.Contains(d.Fix, "x-generate") { + t.Errorf("diagnosis: %+v", d) + } + second := m.asked[1] + last := second[len(second)-1].Content + if !strings.Contains(last, "Results of your checks") || !strings.Contains(last, "https://example.com/fix") || + !strings.Contains(last, "refuses to run without a join password") { + t.Errorf("check results not sent back:\n%s", last) + } + // The first question already carried the web evidence for the error line + if first := m.asked[0][1].Content; !strings.Contains(first, "What the web says") { + t.Errorf("no web evidence in:\n%s", first) + } +} + +func TestAttemptLogRulesOutCausesThatChangedNothing(t *testing.T) { + iv := &investigator{} + iv.record(&diagnosis{Cause: "Not enough memory.", Fix: "requiredMemoryMb 8192"}) + iv.outcome(false, "FAILED: server crashed", "FAILED: server crashed") + if !strings.HasPrefix(iv.log[0].Outcome, "unchanged") { + t.Errorf("outcome %q", iv.log[0].Outcome) + } + if !iv.ruledOut(&diagnosis{Cause: "not enough memory"}) { + t.Error("the same cause again should be ruled out") + } + if iv.ruledOut(&diagnosis{Cause: "the join password is empty"}) { + t.Error("a new cause is not ruled out") + } + if !strings.Contains(iv.logText(), "ruled its cause out") { + t.Error("log text should tell the model") + } + iv.record(&diagnosis{Cause: "empty join password", Fix: "x-generate"}) + iv.outcome(true, "FAILED: server crashed", "") + if !strings.HasPrefix(iv.log[1].Outcome, "fixed") || iv.ruledOut(&diagnosis{Cause: "empty join password"}) { + t.Errorf("fixed cause: %+v", iv.log[1]) + } +} + +func TestDistinctErrorsDropsTimestampsAndRepeats(t *testing.T) { + out := "[boot-test] FAILED: container exited\n" + + "10/26/2026 01:12:02: Error: failed to bind port 30000\n" + + "10/26/2026 01:12:03: Error: failed to bind port 30001\n" + + "all fine here\n" + + "[12:00:01] Fatal: missing config file" + got := distinctErrors(out, 5) + if len(got) != 2 || got[0] != "Error: failed to bind port 30000" || got[1] != "Fatal: missing config file" { + t.Errorf("got %q", got) + } +} + +func TestCopyTemplateWithAppliesTheChange(t *testing.T) { + src, dst := filepath.Join(t.TempDir(), "example"), filepath.Join(t.TempDir(), "example") + os.MkdirAll(src, 0o755) + os.WriteFile(filepath.Join(src, "game.yaml"), []byte("id: example\nname: Example\ndefaultStartupCommand: ./server\ndefaultConfig:\n JoinPassword: \"\"\n"), 0o644) + os.WriteFile(filepath.Join(src, "test.yaml"), []byte("timeoutSec: 600\n"), 0o644) + if err := copyTemplateWith(src, dst, checkRequest{Config: map[string]string{"JoinPassword": "secret1"}, Startup: "./server -verbose"}); err != nil { + t.Fatal(err) + } + g, err := loadMap(filepath.Join(dst, "game.yaml")) + if err != nil { + t.Fatal(err) + } + if g["defaultStartupCommand"] != "./server -verbose" || g["defaultConfig"].(map[string]any)["JoinPassword"] != "secret1" { + t.Errorf("got %v", g) + } + if _, err := os.Stat(filepath.Join(dst, "test.yaml")); err != nil { + t.Error("test.yaml not copied") + } + if got := describeChange(checkRequest{Config: map[string]string{"JoinPassword": "secret1"}}); got != `JoinPassword="secret1"` { + t.Errorf("describe: %s", got) + } +} + +func TestFixRequestCarriesTheDiagnosis(t *testing.T) { + got := strings.Join(fixRequest("FAILED: server crashed\nlog...", &diagnosis{Cause: "c", Evidence: "e", Fix: "f"}), "") + for _, want := range []string{"FAILED: server crashed", "- cause: c", "- fix: f", "Apply exactly this fix"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } +} -- 2.30.2