A draft built on a ready-made image whose files all live outside /server (nodes keep only /server and /data) went round three times and ended as WIP, although research had also verified the game's Steam app, which SteamCMD installs straight into /server. The builder never tried it. - research reads each Steam app's OS list. When it ships a Linux server, cm2network/steamcmd goes first in the variant's images and that variant is recommended. Windows-only servers keep images with Proton or Wine first. The research prompt says the same. - build tries the variant's images in order: when no draft passes with one, the next image gets a fresh start. The first draft that boots wins; if none does, the PR gets the draft that got furthest, and its summary lists each image tried and how it went. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
517 lines
19 KiB
Go
517 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// build turns one researched variant into a template that has really booted: first boot of the image
|
|
// untouched (facts), a draft by the model, then the draft deployed with the node-agent's own code, exactly
|
|
// as CI's boot test does. What goes wrong in that boot (a crash, an error line, saves outside the volume)
|
|
// goes back to the model, a few rounds at most. The result is a games/<id>/ folder ready for a pull request.
|
|
|
|
type buildResult struct {
|
|
GameID string `json:"gameId"`
|
|
Dir string `json:"dir"`
|
|
Image string `json:"image"`
|
|
Passed bool `json:"passed"`
|
|
Rounds int `json:"rounds"`
|
|
Verified []string `json:"verified"`
|
|
Problems []string `json:"problems"`
|
|
Summary string `json:"summary"`
|
|
Tried []string `json:"tried,omitempty"` // each image tried, and how it went
|
|
}
|
|
|
|
const defaultAgentImage = "www.gitea.gbreed.com/gbreed-public/gbr-node-agent:latest"
|
|
|
|
func runBuild(args []string) error {
|
|
fs := flag.NewFlagSet("build", flag.ExitOnError)
|
|
gamesDir := fs.String("games", "games", "directory holding one folder per game")
|
|
schemaDir := fs.String("schemas", "schema", "directory holding the JSON Schemas")
|
|
gameName := fs.String("name", "", "the game's name")
|
|
id := fs.String("id", "", "folder/id for the new game (default: from -name)")
|
|
variantArg := fs.String("variant", "", "the chosen variant: a research result variant as JSON, or @file")
|
|
notes := fs.String("notes", "", "what the admin asked for")
|
|
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")
|
|
out := fs.String("out", "", "write the result JSON here")
|
|
fs.Parse(args)
|
|
if *gameName == "" || *variantArg == "" {
|
|
return errors.New("build needs -name and -variant")
|
|
}
|
|
raw := []byte(*variantArg)
|
|
if strings.HasPrefix(*variantArg, "@") {
|
|
var err error
|
|
if raw, err = os.ReadFile((*variantArg)[1:]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var v variant
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return fmt.Errorf("-variant: %w", err)
|
|
}
|
|
if *id == "" {
|
|
*id = gameID(*gameName)
|
|
}
|
|
agentImage := os.Getenv("AGENT_IMAGE")
|
|
if agentImage == "" {
|
|
agentImage = defaultAgentImage
|
|
}
|
|
|
|
res, err := build(buildOptions{gamesDir: *gamesDir, schemaDir: *schemaDir, name: *gameName, id: *id, variant: v,
|
|
notes: *notes, provider: *provider, model: *modelName, attempts: *attempts, bootRounds: *bootRounds, agentImage: agentImage})
|
|
if res != nil && *out != "" {
|
|
raw, _ := json.MarshalIndent(res, "", " ")
|
|
if werr := os.WriteFile(*out, raw, 0o644); werr != nil && err == nil {
|
|
err = werr
|
|
}
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !res.Passed {
|
|
return fmt.Errorf("the draft in %s still has problems:\n- %s", res.Dir, strings.Join(res.Problems, "\n- "))
|
|
}
|
|
fmt.Printf("[build] %s booted with the node-agent and is ready for a pull request\n", res.Dir)
|
|
return nil
|
|
}
|
|
|
|
type buildOptions struct {
|
|
gamesDir, schemaDir, name, id string
|
|
variant variant
|
|
notes, provider, model string
|
|
attempts, bootRounds int
|
|
agentImage string
|
|
}
|
|
|
|
// build tries the variant's images in order (research puts SteamCMD first when the game ships a Linux
|
|
// server) and keeps the first draft that boots. A draft that can't be made to work with one image is set
|
|
// aside and the next image gets a fresh start; if none works, the draft that got furthest is returned.
|
|
func build(o buildOptions) (*buildResult, error) {
|
|
var candidates []string
|
|
for _, image := range o.variant.Images {
|
|
if isSteamCMDImage(image) && o.variant.SteamAppID == "" {
|
|
continue // the SteamCMD image alone runs nothing
|
|
}
|
|
candidates = append(candidates, image)
|
|
}
|
|
if len(candidates) == 0 {
|
|
return nil, errors.New("the variant has no image to build from")
|
|
}
|
|
dir := filepath.Join(o.gamesDir, o.id)
|
|
setAside := filepath.Join("firstboot-out", o.id+"-best-draft")
|
|
var best *buildResult
|
|
var tried []string
|
|
for i, image := range candidates {
|
|
if i > 0 {
|
|
fmt.Printf("[build] trying the next image: %s\n", image)
|
|
}
|
|
res, err := buildWithImage(o, image)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
tried = append(tried, attemptNote(res))
|
|
if res.Passed {
|
|
res.Tried = tried
|
|
res.Summary = buildSummary(o, res)
|
|
return res, nil
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "game.yaml")); err == nil {
|
|
// This one got a draft written: keep it in case no later image does better
|
|
os.RemoveAll(setAside)
|
|
if err := os.Rename(dir, setAside); err != nil {
|
|
return res, err
|
|
}
|
|
best = res
|
|
}
|
|
}
|
|
if best == nil {
|
|
return &buildResult{GameID: o.id, Dir: dir, Tried: tried, Problems: []string{"no image of this variant could be drafted"}},
|
|
errors.New("no image of this variant could be drafted: " + strings.Join(tried, "; "))
|
|
}
|
|
if err := os.Rename(setAside, dir); err != nil {
|
|
return best, err
|
|
}
|
|
best.Tried = tried
|
|
best.Summary = buildSummary(o, best)
|
|
return best, nil
|
|
}
|
|
|
|
func attemptNote(res *buildResult) string {
|
|
switch {
|
|
case res.Passed:
|
|
return res.Image + ": booted"
|
|
case len(res.Problems) > 0:
|
|
return res.Image + ": " + firstLine(res.Problems[0])
|
|
}
|
|
return res.Image + ": did not boot"
|
|
}
|
|
|
|
// buildWithImage drafts the template for one image: first boot, draft, then boots of the draft with
|
|
// failures going back to the model.
|
|
func buildWithImage(o buildOptions, image string) (*buildResult, error) {
|
|
res := &buildResult{GameID: o.id, Dir: filepath.Join(o.gamesDir, o.id), Image: image}
|
|
res.Verified = append(res.Verified, o.variant.Verified...)
|
|
steam := isSteamCMDImage(image)
|
|
|
|
// 1. First boot of the image untouched: its log, files and settings are the draft's facts
|
|
var report *bootReport
|
|
if steam && o.variant.StartupHint == "" {
|
|
fmt.Printf("[build] no startup command known for Steam app %s; drafting without a first boot\n", o.variant.SteamAppID)
|
|
} else {
|
|
fmt.Printf("[build] first boot of %s\n", image)
|
|
r, err := firstBoot(o.id+"-probe", image, o.variant.SteamAppID, steam, o.variant.StartupHint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if why := imageUnusable(r); why != "" {
|
|
fmt.Printf("[build] %s can't be used: %s\n", image, why)
|
|
res.Problems = []string{why}
|
|
return res, nil
|
|
}
|
|
report = r
|
|
res.Verified = append(res.Verified, fmt.Sprintf("first boot of %s: %s", image, r.Outcome))
|
|
}
|
|
|
|
// 2. Draft until validation and the image checks pass
|
|
steamApp := ""
|
|
if isSteamCMDImage(res.Image) {
|
|
steamApp = o.variant.SteamAppID
|
|
}
|
|
d, err := newDrafter(draftJob{GamesDir: o.gamesDir, SchemaDir: o.schemaDir, Name: o.name, ID: o.id, Image: res.Image,
|
|
SteamApp: steamApp, Notes: variantNotes(o.variant, o.notes), Report: report, Provider: o.provider, Model: o.model, NoPull: true,
|
|
Docs: sourceText(o.variant.Sources)})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
problems, err := d.refine(o.attempts, nil)
|
|
res.Rounds = d.rounds
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
if len(problems) > 0 {
|
|
res.Problems = problems
|
|
res.Summary = buildSummary(o, res)
|
|
return res, nil
|
|
}
|
|
|
|
// 3. Boot the draft the way nodes run it; failures go back to the model
|
|
wantOutside := len(outsideDirs(report)) > 0
|
|
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
|
|
}
|
|
if len(bootProblems) == 0 {
|
|
res.Passed = true
|
|
res.Verified = append(res.Verified, notes...)
|
|
break
|
|
}
|
|
fmt.Printf("[build] boot %d failed:\n", boot)
|
|
for _, p := range bootProblems {
|
|
fmt.Println(" - " + firstLine(p))
|
|
}
|
|
res.Problems = bootProblems
|
|
if boot == o.bootRounds {
|
|
break
|
|
}
|
|
problems, err := d.refine(o.attempts, bootProblems)
|
|
res.Rounds = d.rounds
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
if len(problems) > 0 {
|
|
res.Problems = problems
|
|
break
|
|
}
|
|
}
|
|
if res.Passed {
|
|
res.Problems = nil
|
|
}
|
|
res.Summary = buildSummary(o, res)
|
|
return res, nil
|
|
}
|
|
|
|
func isSteamCMDImage(image string) bool {
|
|
return image == "cm2network/steamcmd" || strings.HasPrefix(image, "cm2network/steamcmd:")
|
|
}
|
|
|
|
func variantNotes(v variant, admin string) string {
|
|
var parts []string
|
|
if v.Title != "" {
|
|
parts = append(parts, "variant: "+v.Title+" ("+v.Description+")")
|
|
}
|
|
if v.StartupHint != "" {
|
|
parts = append(parts, "documented startup: "+v.StartupHint)
|
|
}
|
|
if len(v.Ports) > 0 {
|
|
parts = append(parts, "documented ports: "+strings.Join(v.Ports, ", "))
|
|
}
|
|
if v.Notes != "" {
|
|
parts = append(parts, v.Notes)
|
|
}
|
|
if admin != "" {
|
|
parts = append(parts, "the admin asked for: "+admin)
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
// firstBoot runs first-boot and reads back its report.
|
|
func firstBoot(name, image, steamApp string, steam bool, startup string) (*bootReport, error) {
|
|
outDir := filepath.Join("firstboot-out", name)
|
|
args := []string{"-image", image, "-out", outDir}
|
|
if steam {
|
|
args = append(args, "-steam-app", steamApp, "-startup", startup)
|
|
}
|
|
if err := runFirstBoot(args); err != nil {
|
|
return nil, err
|
|
}
|
|
return readBootReport(filepath.Join(outDir, "report.json"))
|
|
}
|
|
|
|
// bootCheck deploys the template with the node-agent's BootTest, the same check CI runs, and when the
|
|
// game wrote files outside its volume on the first boot, boots it once more to see that it no longer does.
|
|
func bootCheck(gamesDir, id, agentImage string, wantOutside bool) (problems, verified []string, err error) {
|
|
if out, err := runCmd("docker", "pull", "-q", agentImage); err != nil {
|
|
return nil, nil, fmt.Errorf("docker pull %s: %v: %s", agentImage, err, out)
|
|
}
|
|
output, passed, problems, err := agentBoot(gamesDir, id, agentImage, false)
|
|
if err != nil || problems != nil {
|
|
return problems, nil, err
|
|
}
|
|
if !passed {
|
|
// The server's own output rarely says it stopped because a setting was empty. Booting once more
|
|
// with the blank ones filled tells for certain whether they are the cause.
|
|
if blank := blankRequiredLooking(gamesDir, id); len(blank) > 0 {
|
|
fmt.Printf("[build] it failed with the defaults; booting again with %s filled to see whether empty settings are the cause\n",
|
|
strings.Join(blank, ", "))
|
|
if _, filledPassed, _, err := agentBoot(gamesDir, id, agentImage, true); err == nil && filledPassed {
|
|
return []string{emptySettingsProblem(blank, bootVerdict(output))}, nil, nil
|
|
}
|
|
}
|
|
return []string{bootFailure(output)}, nil, nil
|
|
}
|
|
verified = append(verified, "boot test with the node-agent: "+bootVerdict(output))
|
|
|
|
if wantOutside {
|
|
fmt.Println("[build] first boot wrote saves outside the volume; booting the draft to see that it no longer does")
|
|
r, err := firstBootGame(gamesDir, id)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if dirs := outsideDirs(r); len(dirs) > 0 {
|
|
return []string{fmt.Sprintf("booting your template, the server still wrote its own files outside the server volume, "+
|
|
"in %s, so your startup command or settings did not move them. Check the exact launch option name and "+
|
|
"syntax this server documents for its save/data folder (a wrong option is usually ignored silently) "+
|
|
"and point it at /server", strings.Join(dirs, ", "))}, nil, nil
|
|
}
|
|
verified = append(verified, "the server keeps its files inside /server")
|
|
}
|
|
return nil, verified, nil
|
|
}
|
|
|
|
// agentBoot runs one boot test with the node-agent's BootTest, with the settings a new server gets, like
|
|
// CI. fillBlank also fills blank password-like settings, only to find out whether they are why a draft
|
|
// fails. problems is set when the template can't be boot-tested at all.
|
|
func agentBoot(gamesDir, id, agentImage string, fillBlank bool) (output string, passed bool, problems []string, err error) {
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
return "", false, nil, err
|
|
}
|
|
// A boot test that was killed (worker restarted mid-build) leaves its container and volume behind,
|
|
// and the next deploy under the same name would trip over them
|
|
runCmd("docker", "rm", "-f", "boottest-"+id)
|
|
runCmd("docker", "volume", "rm", "-f", "boottest-"+id+"-data")
|
|
args := []string{"boottest-payload", "-games", gamesDir, id}
|
|
if fillBlank {
|
|
args = []string{"boottest-payload", "-fill-blank-passwords", "-games", gamesDir, id}
|
|
}
|
|
payloadCmd := exec.Command(self, args...)
|
|
var stderr bytes.Buffer
|
|
payloadCmd.Stderr = &stderr
|
|
payload, err := payloadCmd.Output()
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == exitMissingSecret {
|
|
// The builder has no CI secrets: that's the template's problem to fix, not a reason to stop
|
|
return "", false, []string{"the boot test can't run: " + strings.TrimSpace(stderr.String()) + ". Remove 'secrets' from " +
|
|
"test.yaml; boot tests run with exactly what a new server gets"}, nil
|
|
}
|
|
return "", false, nil, fmt.Errorf("boottest-payload: %v: %s", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
cmd := exec.Command("docker", "run", "--rm", "-i", "-v", "/var/run/docker.sock:/var/run/docker.sock",
|
|
"--entrypoint", "java", agentImage, "-Dloader.main=com.gbreed.gbr.nodeagent.BootTest", "-cp", "app.jar",
|
|
"org.springframework.boot.loader.launch.PropertiesLauncher")
|
|
cmd.Stdin = bytes.NewReader(payload)
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &teeWriter{&out}
|
|
cmd.Stderr = cmd.Stdout
|
|
if runErr := cmd.Run(); runErr != nil {
|
|
var exitErr *exec.ExitError
|
|
if !errors.As(runErr, &exitErr) {
|
|
return "", false, nil, runErr
|
|
}
|
|
return out.String(), false, nil, nil
|
|
}
|
|
return out.String(), true, nil, nil
|
|
}
|
|
|
|
// blankRequiredLooking lists the settings a new server gets empty that look like a server might need a
|
|
// value: password-like keys with an empty default and no x-generate.
|
|
func blankRequiredLooking(gamesDir, id string) []string {
|
|
g, err := loadMap(filepath.Join(gamesDir, id, "game.yaml"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defaults, _ := g["defaultConfig"].(map[string]any)
|
|
var keys []string
|
|
for key, p := range schemaProperties(g) {
|
|
prop, _ := p.(map[string]any)
|
|
if prop["x-generate"] != nil || !passwordKey.MatchString(key) {
|
|
continue
|
|
}
|
|
if v, ok := defaults[key]; !ok || v == nil || v == "" {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func emptySettingsProblem(keys []string, verdict string) string {
|
|
return fmt.Sprintf("the server stops when %s is empty, as it is for every new server: booted with your defaults it "+
|
|
"failed (%s), and booted again with a value in %s it ran. A new server must start without the owner typing "+
|
|
"anything: give %s \"x-generate\": {\"type\": \"password\"} in configSchema (the panel then makes a password for "+
|
|
"each server; keep the default \"\"), or a default value that works. Keep the setting so owners can change it",
|
|
strings.Join(keys, ", "), verdict, strings.Join(keys, ", "), strings.Join(keys, ", "))
|
|
}
|
|
|
|
func firstBootGame(gamesDir, id string) (*bootReport, error) {
|
|
outDir := filepath.Join("firstboot-out", id+"-draft")
|
|
if err := runFirstBoot([]string{"-games", gamesDir, "-game", id, "-out", outDir}); err != nil {
|
|
return nil, err
|
|
}
|
|
return readBootReport(filepath.Join(outDir, "report.json"))
|
|
}
|
|
|
|
// bootFailure turns BootTest's output into feedback: its verdict and the server's last lines, which hold
|
|
// the actual error (an unknown option, a missing file, a port in use).
|
|
func bootFailure(output string) string {
|
|
verdict, tail := bootVerdict(output), output
|
|
if i := strings.Index(output, "---- last "); i >= 0 {
|
|
tail = output[i:]
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(tail), "\n")
|
|
if len(lines) > 60 {
|
|
lines = append(lines[:1], lines[len(lines)-59:]...)
|
|
}
|
|
return "booting your template with the node-agent (the real deploy code) failed: " + verdict +
|
|
". It was started with your defaults exactly as a new server gets them: settings left empty stay empty " +
|
|
"(only x-generate fields are filled), so a server that needs a value must get it from a default or " +
|
|
"x-generate. Find the cause in the server's output below and fix the template (startup command, settings, " +
|
|
"ready pattern, memory):\n" + strings.Join(lines, "\n")
|
|
}
|
|
|
|
func bootVerdict(output string) string {
|
|
for _, l := range strings.Split(output, "\n") {
|
|
for _, k := range []string{"[boot-test] FAILED: ", "[boot-test] PASSED: "} {
|
|
if i := strings.Index(l, k); i >= 0 {
|
|
return strings.TrimSpace(l[i+len("[boot-test] "):])
|
|
}
|
|
}
|
|
}
|
|
return "no verdict"
|
|
}
|
|
|
|
func firstLine(s string) string {
|
|
line, _, _ := strings.Cut(s, "\n")
|
|
return line
|
|
}
|
|
|
|
// teeWriter shows the boot as it happens (the worker streams it to the panel) while keeping it for the verdict.
|
|
type teeWriter struct{ buf *bytes.Buffer }
|
|
|
|
func (t *teeWriter) Write(p []byte) (int, error) {
|
|
os.Stdout.Write(p)
|
|
return t.buf.Write(p)
|
|
}
|
|
|
|
// buildSummary is the pull request's description: where the facts came from and what was really checked.
|
|
func buildSummary(o buildOptions, res *buildResult) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Drafted by the GBR AI builder. **Review before merging**: check the settings, "+
|
|
"ports and descriptions against the game's documentation.\n\n")
|
|
fmt.Fprintf(&b, "- Game: **%s**", o.name)
|
|
if o.variant.Title != "" {
|
|
fmt.Fprintf(&b, " — %s", o.variant.Title)
|
|
}
|
|
fmt.Fprintf(&b, "\n- Image: `%s`", res.Image)
|
|
if o.variant.SteamAppID != "" && isSteamCMDImage(res.Image) {
|
|
fmt.Fprintf(&b, ", Steam app %s", o.variant.SteamAppID)
|
|
}
|
|
fmt.Fprintf(&b, "\n- Model rounds: %d\n", res.Rounds)
|
|
if len(res.Tried) > 1 {
|
|
b.WriteString("\n**Images tried, in order**\n")
|
|
for _, t := range res.Tried {
|
|
fmt.Fprintf(&b, "- %s\n", firstLine(t))
|
|
}
|
|
}
|
|
if len(res.Verified) > 0 {
|
|
b.WriteString("\n**Checked for real**\n")
|
|
for _, v := range res.Verified {
|
|
fmt.Fprintf(&b, "- %s\n", v)
|
|
}
|
|
}
|
|
if len(res.Problems) > 0 {
|
|
b.WriteString("\n**Still open** (the builder could not fix these)\n")
|
|
for _, p := range res.Problems {
|
|
fmt.Fprintf(&b, "- %s\n", firstLine(p))
|
|
}
|
|
}
|
|
if o.variant.Notes != "" {
|
|
fmt.Fprintf(&b, "\n**Notes from research:** %s\n", o.variant.Notes)
|
|
}
|
|
if len(o.variant.Sources) > 0 {
|
|
b.WriteString("\n**Sources**\n")
|
|
for _, s := range o.variant.Sources {
|
|
fmt.Fprintf(&b, "- %s\n", s)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// sourceText reads the pages research based the variant on, in full, so the drafter can check that every
|
|
// launch option the model uses is documented there. Empty (check skipped) without a reader.
|
|
func sourceText(sources []string) string {
|
|
reader := os.Getenv("READER_URL")
|
|
if reader == "" || len(sources) == 0 {
|
|
fmt.Println("[build] no research sources to check launch options against (READER_URL or sources missing)")
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
read := 0
|
|
for _, s := range sources {
|
|
text, err := readPageLimit(reader, s, 200_000)
|
|
if err != nil {
|
|
fmt.Printf("[build] could not read %s: %v\n", s, err)
|
|
continue
|
|
}
|
|
read++
|
|
b.WriteString(text)
|
|
b.WriteString("\n")
|
|
}
|
|
fmt.Printf("[build] read %d of %d research sources to check launch options against\n", read, len(sources))
|
|
return b.String()
|
|
}
|