From 2db427c71cc10bdb68c78ed4796471959b2f56d9 Mon Sep 17 00:00:00 2001 From: reid Date: Fri, 26 Jun 2026 20:33:18 -0500 Subject: [PATCH 01/29] fix stuck toggles --- goseg/handler/startram.go | 29 ++++++++-- goseg/startram/startram.go | 58 +++++++++++++++---- goseg/startram/startram_test.go | 56 ++++++++++++++++++ ui/src/routes/(home)/StartramToggle.svelte | 3 +- .../routes/[patp]/Section/RemoteAccess.svelte | 3 +- 5 files changed, 132 insertions(+), 17 deletions(-) create mode 100644 goseg/startram/startram_test.go diff --git a/goseg/handler/startram.go b/goseg/handler/startram.go index fa5d13bb..fbb557f3 100644 --- a/goseg/handler/startram.go +++ b/goseg/handler/startram.go @@ -72,6 +72,16 @@ func handleStartramRegions() { func handleStartramRestart() { zap.L().Info("Restarting StarTram") startram.EventBus <- structs.Event{Type: "restart", Data: "startram"} + showDone := false + defer func() { + if r := recover(); r != nil { + zap.L().Error(fmt.Sprintf("Panic restarting StarTram: %v", r)) + } + if showDone { + time.Sleep(3 * time.Second) + } + startram.EventBus <- structs.Event{Type: "restart", Data: ""} + }() conf := config.Conf() // only restart if startram is on if conf.WgOn { @@ -130,13 +140,18 @@ func handleStartramRestart() { zap.L().Error(fmt.Sprintf("Failed to load RustFS containers: %v", err)) } startram.EventBus <- structs.Event{Type: "restart", Data: "done"} - time.Sleep(3 * time.Second) - startram.EventBus <- structs.Event{Type: "restart", Data: ""} + showDone = true } } func handleStartramToggle() { startram.EventBus <- structs.Event{Type: "toggle", Data: "loading"} + defer func() { + if r := recover(); r != nil { + zap.L().Error(fmt.Sprintf("Panic toggling StarTram: %v", r)) + } + startram.EventBus <- structs.Event{Type: "toggle", Data: nil} + }() conf := config.Conf() if conf.WgOn { if containerState, exists := config.GetContainerState()["wireguard"]; exists { @@ -199,7 +214,13 @@ func handleStartramToggle() { if err := docker.LoadObjectStores(); err != nil { zap.L().Error(fmt.Sprintf("Failed to load RustFS containers: %v", err)) } - startram.EventBus <- structs.Event{Type: "toggle", Data: nil} +} + +func loadFreshWireguard() error { + if err := docker.DeleteContainer("wireguard"); err != nil { + zap.L().Debug(fmt.Sprintf("Wireguard container was not removed before reload: %v", err)) + } + return docker.LoadWireguard() } func handleStartramRegister(regCode, region string) { @@ -231,7 +252,7 @@ func handleStartramRegister(regCode, region string) { } // Start Wireguard startram.EventBus <- structs.Event{Type: "register", Data: "starting"} - if err := docker.LoadWireguard(); err != nil { + if err := loadFreshWireguard(); err != nil { handleError(fmt.Sprintf("Unable to start Wireguard: %v", err)) return } diff --git a/goseg/startram/startram.go b/goseg/startram/startram.go index 978fabbe..37d8810e 100644 --- a/goseg/startram/startram.go +++ b/goseg/startram/startram.go @@ -324,8 +324,30 @@ func AliasDelete(subdomain string, alias string) error { return nil } -// call registration endpoint for 5 minutes or until all services are "ok" -func backoffRetrieve() error { +func pendingSubdomains(subdomains []structs.Subdomain, expectedURLs []string) []string { + if len(expectedURLs) == 0 { + return nil + } + byURL := make(map[string]structs.Subdomain, len(subdomains)) + for _, subdomain := range subdomains { + byURL[subdomain.URL] = subdomain + } + pending := []string{} + for _, expectedURL := range expectedURLs { + remote, ok := byURL[expectedURL] + if !ok { + pending = append(pending, fmt.Sprintf("%s missing", expectedURL)) + continue + } + if remote.Status != "ok" { + pending = append(pending, fmt.Sprintf("%s %s", remote.URL, remote.Status)) + } + } + return pending +} + +// call registration endpoint for 5 minutes or until expected services are "ok" +func backoffRetrieve(expectedURLs []string) error { startTime := time.Now() duration := 5 * time.Second for { @@ -333,15 +355,13 @@ func backoffRetrieve() error { if err != nil { return err } - // return if all services are registered - for _, remote := range res.Subdomains { - if remote.Status != "ok" { - zap.L().Warn(fmt.Sprintf("backoff: %v %v", remote.URL, remote.Status)) - break - } - // all "ok" + pending := pendingSubdomains(res.Subdomains, expectedURLs) + if len(pending) == 0 { return nil } + for _, pendingSubdomain := range pending { + zap.L().Warn(fmt.Sprintf("backoff: %s", pendingSubdomain)) + } // timeout after 5min if time.Since(startTime) > 5*time.Minute { return fmt.Errorf("Registration retrieval timed out") @@ -361,22 +381,30 @@ func backoffRetrieve() error { func RegisterExistingShips() error { conf := config.Conf() if conf.WgRegistered { + expectedURLs := []string{} for _, ship := range conf.Piers { if err := SvcCreate(ship, "urbit"); err != nil { zap.L().Error(fmt.Sprintf("Couldn't register pier: %v: %v", ship, err)) continue } + expectedURLs = append(expectedURLs, ship) if err := SvcCreate("s3."+ship, "minio"); err != nil { zap.L().Error(fmt.Sprintf("Couldn't register S3: %v: %v", ship, err)) + } else { + expectedURLs = append(expectedURLs, "s3."+ship) } if err := SvcCreate("console.s3."+ship, "minio-console"); err != nil { zap.L().Warn(fmt.Sprintf("Couldn't register RustFS console: %v: %v", ship, err)) + } else { + expectedURLs = append(expectedURLs, "console.s3."+ship) } if err := SvcCreate("bucket.s3."+ship, "minio-bucket"); err != nil { zap.L().Warn(fmt.Sprintf("Couldn't register RustFS bucket endpoint: %v: %v", ship, err)) + } else { + expectedURLs = append(expectedURLs, "bucket.s3."+ship) } } - if err := backoffRetrieve(); err != nil { + if err := backoffRetrieve(expectedURLs); err != nil { return err } } else { @@ -388,19 +416,27 @@ func RegisterExistingShips() error { func RegisterNewShip(ship string) error { zap.L().Info(fmt.Sprintf("Registering service for new ship: %s", ship)) + expectedURLs := []string{} if err := SvcCreate(ship, "urbit"); err != nil { return fmt.Errorf("Couldn't register pier: %v: %v", ship, err) } + expectedURLs = append(expectedURLs, ship) if err := SvcCreate("s3."+ship, "minio"); err != nil { zap.L().Error(fmt.Sprintf("Couldn't register S3: %v: %v", ship, err)) + } else { + expectedURLs = append(expectedURLs, "s3."+ship) } if err := SvcCreate("console.s3."+ship, "minio-console"); err != nil { zap.L().Warn(fmt.Sprintf("Couldn't register RustFS console: %v: %v", ship, err)) + } else { + expectedURLs = append(expectedURLs, "console.s3."+ship) } if err := SvcCreate("bucket.s3."+ship, "minio-bucket"); err != nil { zap.L().Warn(fmt.Sprintf("Couldn't register RustFS bucket endpoint: %v: %v", ship, err)) + } else { + expectedURLs = append(expectedURLs, "bucket.s3."+ship) } - if err := backoffRetrieve(); err != nil { + if err := backoffRetrieve(expectedURLs); err != nil { return err } return nil diff --git a/goseg/startram/startram_test.go b/goseg/startram/startram_test.go new file mode 100644 index 00000000..0ab52035 --- /dev/null +++ b/goseg/startram/startram_test.go @@ -0,0 +1,56 @@ +package startram + +import ( + "reflect" + "testing" + + "groundseg/structs" +) + +func TestPendingSubdomains(t *testing.T) { + tests := []struct { + name string + subdomains []structs.Subdomain + expectedURLs []string + want []string + }{ + { + name: "all expected services are ready", + subdomains: []structs.Subdomain{ + {URL: "sampel-palnet", Status: "ok"}, + {URL: "s3.sampel-palnet", Status: "ok"}, + }, + expectedURLs: []string{"sampel-palnet", "s3.sampel-palnet"}, + want: []string{}, + }, + { + name: "later service is still creating", + subdomains: []structs.Subdomain{ + {URL: "sampel-palnet", Status: "ok"}, + {URL: "s3.sampel-palnet", Status: "creating"}, + }, + expectedURLs: []string{"sampel-palnet", "s3.sampel-palnet"}, + want: []string{"s3.sampel-palnet creating"}, + }, + { + name: "expected service missing", + subdomains: []structs.Subdomain{ + {URL: "sampel-palnet", Status: "ok"}, + }, + expectedURLs: []string{"sampel-palnet", "s3.sampel-palnet"}, + want: []string{"s3.sampel-palnet missing"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := pendingSubdomains(tt.subdomains, tt.expectedURLs) + if got == nil { + got = []string{} + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("pendingSubdomains() = %#v, want %#v", got, tt.want) + } + }) + } +} diff --git a/ui/src/routes/(home)/StartramToggle.svelte b/ui/src/routes/(home)/StartramToggle.svelte index aeafbec3..3cdab020 100644 --- a/ui/src/routes/(home)/StartramToggle.svelte +++ b/ui/src/routes/(home)/StartramToggle.svelte @@ -3,10 +3,11 @@ const dispatch = createEventDispatcher() export let on export let remoteReady + $: disabled = !remoteReady && !on
StarTram
-
dispatch('click')} class="outer" class:disabled={!remoteReady} > +
!disabled && dispatch('click')} class="outer" class:disabled>
diff --git a/ui/src/routes/[patp]/Section/RemoteAccess.svelte b/ui/src/routes/[patp]/Section/RemoteAccess.svelte index 4d9d8b7c..e37cff1e 100644 --- a/ui/src/routes/[patp]/Section/RemoteAccess.svelte +++ b/ui/src/routes/[patp]/Section/RemoteAccess.svelte @@ -25,6 +25,7 @@ export let ownShip const dispatch = createEventDispatcher() + $: disabled = (!wgRunning || !remoteReady) && !remote function handleClick() { if ($URBIT_MODE) { @@ -35,7 +36,7 @@ } -
+
Remote Access
Access your ship via a StarTram connection
From c8728d4c4fb5baad1ba43db0fbb14445920c2c56 Mon Sep 17 00:00:00 2001 From: reid Date: Fri, 26 Jun 2026 20:33:34 -0500 Subject: [PATCH 02/29] precommit hook: --- .githooks/pre-commit | 68 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100755 .githooks/pre-commit diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..3b3623e1 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,68 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(git rev-parse --show-toplevel)" +goseg_dir="$repo_root/goseg" + +if [ ! -d "$goseg_dir" ]; then + echo "pre-commit: missing goseg directory at $goseg_dir" >&2 + exit 1 +fi + +if ! command -v go >/dev/null 2>&1; then + echo "pre-commit: go is not available on PATH" >&2 + exit 1 +fi + +run_goimports() { + go_files="$(git ls-files -- '*.go')" + if [ -z "$go_files" ]; then + return 0 + fi + + if command -v goimports >/dev/null 2>&1; then + printf '%s\n' "$go_files" | xargs goimports -w + else + printf '%s\n' "$go_files" | xargs go run golang.org/x/tools/cmd/goimports@v0.39.0 -w + fi +} + +staged_go_files="$(git diff --cached --name-only --diff-filter=ACMR -- goseg | sed -n '/\.go$/p')" + +if [ -z "$staged_go_files" ]; then + exit 0 +fi + +unstaged_go_files="$(git diff --name-only -- goseg | sed -n '/\.go$/p')" +untracked_go_files="$(git ls-files --others --exclude-standard -- goseg | sed -n '/\.go$/p')" + +if [ -n "$unstaged_go_files" ] || [ -n "$untracked_go_files" ]; then + echo "pre-commit: go fix/fmt rewrites package files, but goseg has unstaged or untracked Go changes." >&2 + echo "pre-commit: stage or stash these files before committing:" >&2 + { + printf '%s\n' "$unstaged_go_files" + printf '%s\n' "$untracked_go_files" + } | sed '/^$/d' | sed 's/^/ /' >&2 + exit 1 +fi + +echo "pre-commit: running go fix ./... in goseg" +(cd "$goseg_dir" && go fix ./...) + +echo "pre-commit: cleaning Go imports in goseg" +(cd "$goseg_dir" && run_goimports) + +echo "pre-commit: running go fmt ./... in goseg" +(cd "$goseg_dir" && go fmt ./...) + +printf '%s\n' "$staged_go_files" | while IFS= read -r file; do + [ -n "$file" ] && git add -- "$file" +done + +remaining_unstaged_go_files="$(git diff --name-only -- goseg | sed -n '/\.go$/p')" + +if [ -n "$remaining_unstaged_go_files" ]; then + echo "pre-commit: go fix/fmt changed additional Go files. Review and stage them before committing:" >&2 + printf '%s\n' "$remaining_unstaged_go_files" | sed 's/^/ /' >&2 + exit 1 +fi From 8e7adf91280d7e13e3e3addf953c0a2c18b5e456 Mon Sep 17 00:00:00 2001 From: reid Date: Sat, 27 Jun 2026 11:42:04 -0500 Subject: [PATCH 03/29] add config editors --- goseg/config/config.go | 24 +++- goseg/config/urbit.go | 67 ++++++++++ goseg/config/urbit_test.go | 35 ++++++ goseg/handler/config_files.go | 194 +++++++++++++++++++++++++++++ goseg/handler/config_files_test.go | 72 +++++++++++ goseg/handler/startram.go | 15 ++- goseg/main.go | 1 + ui/src/routes/system/+page.svelte | 2 + 8 files changed, 401 insertions(+), 9 deletions(-) create mode 100644 goseg/handler/config_files.go create mode 100644 goseg/handler/config_files_test.go diff --git a/goseg/config/config.go b/goseg/config/config.go index 471b78d1..877c1696 100644 --- a/goseg/config/config.go +++ b/goseg/config/config.go @@ -298,6 +298,26 @@ func UpdateConf(values map[string]any) error { return nil } +func ReplaceConfJSON(raw []byte) ([]byte, error) { + confMutex.Lock() + defer confMutex.Unlock() + var configMap map[string]any + if err := json.Unmarshal(raw, &configMap); err != nil { + return nil, fmt.Errorf("invalid system config JSON: %v", err) + } + if len(configMap) == 0 { + return nil, fmt.Errorf("refusing to persist empty system configuration") + } + formatted, err := json.MarshalIndent(configMap, "", " ") + if err != nil { + return nil, fmt.Errorf("error encoding system config: %v", err) + } + if err := persistConf(configMap); err != nil { + return nil, fmt.Errorf("unable to persist system config: %v", err) + } + return formatted, nil +} + func persistConf(configMap map[string]any) error { BasePath := getBasePath() confPath := filepath.Join(BasePath, "settings", "system.json") @@ -326,9 +346,11 @@ func persistConf(configMap map[string]any) error { if fi.Size() == 0 { return fmt.Errorf("refusing to persist empty configuration file") } - if err := json.Unmarshal(updatedJSON, &globalConfig); err != nil { + var nextConfig structs.SysConfig + if err := json.Unmarshal(updatedJSON, &nextConfig); err != nil { return fmt.Errorf("error updating global config: %v", err) } + globalConfig = nextConfig if err := os.Rename(tmpPath, confPath); err != nil { return fmt.Errorf("error moving temp file: %v", err) } diff --git a/goseg/config/urbit.go b/goseg/config/urbit.go index 075c206a..28f8aa96 100644 --- a/goseg/config/urbit.go +++ b/goseg/config/urbit.go @@ -129,6 +129,73 @@ func UpdateUrbitConfig(inputConfig map[string]structs.UrbitDocker) error { return nil } +func ReplaceUrbitConfigJSON(pier string, raw []byte) ([]byte, error) { + var configMap map[string]any + if err := json.Unmarshal(raw, &configMap); err != nil { + return nil, fmt.Errorf("invalid %s config JSON: %v", pier, err) + } + if len(configMap) == 0 { + return nil, fmt.Errorf("refusing to persist empty configuration for pier %s", pier) + } + formatted, err := json.MarshalIndent(configMap, "", " ") + if err != nil { + return nil, fmt.Errorf("error encoding %s config: %v", pier, err) + } + var targetStruct structs.UrbitDocker + if err := unmarshalUrbitDockerSafe(formatted, &targetStruct); err != nil { + return nil, err + } + if targetStruct.PierName != "" && targetStruct.PierName != pier { + return nil, fmt.Errorf("pier_name %q does not match %q", targetStruct.PierName, pier) + } + if targetStruct.StartramReminder == nil { + targetStruct.StartramReminder = defaults.UrbitConfig.StartramReminder + } + if targetStruct.SnapTime == 0 { + targetStruct.SnapTime = 60 + } + structs.SyncCustomS3Domains(&targetStruct) + + urbitMutex.Lock() + defer urbitMutex.Unlock() + path := filepath.Join(BasePath, "settings", "pier", pier+".json") + if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + return nil, err + } + tmpFile, err := os.CreateTemp(filepath.Dir(path), pier+".json.*") + if err != nil { + return nil, fmt.Errorf("error creating temp file: %v", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + if _, err := tmpFile.Write(formatted); err != nil { + tmpFile.Close() + return nil, fmt.Errorf("error writing temp file: %v", err) + } + if err := tmpFile.Close(); err != nil { + return nil, fmt.Errorf("error closing temp file: %v", err) + } + if fi, err := os.Stat(tmpPath); err != nil { + return nil, fmt.Errorf("error checking temp file: %v", err) + } else if fi.Size() == 0 { + return nil, fmt.Errorf("refusing to persist empty configuration for pier %s", pier) + } + UrbitsConfig[pier] = targetStruct + if err := os.Rename(tmpPath, path); err != nil { + return nil, fmt.Errorf("error moving temp file: %v", err) + } + return formatted, nil +} + +func unmarshalUrbitDockerSafe(data []byte, target *structs.UrbitDocker) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("invalid urbit config: %v", r) + } + }() + return json.Unmarshal(data, target) +} + func UpdateUrbitConfigForPier(pier string, mutate func(*structs.UrbitDocker)) error { if err := LoadUrbitConfig(pier); err != nil { return err diff --git a/goseg/config/urbit_test.go b/goseg/config/urbit_test.go index d56b487c..ea8aa4ac 100644 --- a/goseg/config/urbit_test.go +++ b/goseg/config/urbit_test.go @@ -78,3 +78,38 @@ func TestLoadAndUpdateUrbitConfigKeepsLegacyCustomS3Field(t *testing.T) { t.Fatalf("expected remote field to persist, got %q", saved.CustomS3WebRemote) } } + +func TestReplaceUrbitConfigJSONValidatesAndUpdatesMemory(t *testing.T) { + oldBasePath := BasePath + oldConfigs := UrbitsConfig + t.Cleanup(func() { + BasePath = oldBasePath + UrbitsConfig = oldConfigs + }) + + BasePath = t.TempDir() + UrbitsConfig = make(map[string]structs.UrbitDocker) + pier := "sampel-palnet" + + if _, err := ReplaceUrbitConfigJSON(pier, []byte(`{"pier_name":"wrong-palnet"}`)); err == nil { + t.Fatalf("expected mismatched pier_name to fail") + } + if _, err := ReplaceUrbitConfigJSON(pier, []byte(`{"minio_linked":"not-a-bool"}`)); err == nil { + t.Fatalf("expected invalid field type to fail") + } + + formatted, err := ReplaceUrbitConfigJSON(pier, []byte(`{"pier_name":"sampel-palnet","http_port":8080,"ames_port":34343}`)) + if err != nil { + t.Fatalf("failed to replace urbit config: %v", err) + } + if !json.Valid(formatted) { + t.Fatalf("formatted config is not valid JSON: %s", string(formatted)) + } + conf := UrbitConf(pier) + if conf.PierName != pier { + t.Fatalf("in-memory pier_name = %q, want %q", conf.PierName, pier) + } + if conf.HTTPPort != 8080 { + t.Fatalf("in-memory http_port = %d, want 8080", conf.HTTPPort) + } +} diff --git a/goseg/handler/config_files.go b/goseg/handler/config_files.go new file mode 100644 index 00000000..4b7b75c8 --- /dev/null +++ b/goseg/handler/config_files.go @@ -0,0 +1,194 @@ +package handler + +import ( + "encoding/json" + "fmt" + "groundseg/auth" + "groundseg/config" + "groundseg/structs" + "net/http" + "os" + "path" + "path/filepath" + "slices" + "strings" + + "go.uber.org/zap" +) + +type configFileRequest struct { + Token structs.WsTokenStruct `json:"token"` + Action string `json:"action"` + File string `json:"file"` + Content string `json:"content"` +} + +type configFileSummary struct { + File string `json:"file"` + Label string `json:"label"` + Kind string `json:"kind"` + Pier string `json:"pier,omitempty"` +} + +type configFileResponse struct { + OK bool `json:"ok"` + Files []configFileSummary `json:"files,omitempty"` + File string `json:"file,omitempty"` + Content string `json:"content,omitempty"` + Error string `json:"error,omitempty"` +} + +type configFileTarget struct { + file string + kind string + pier string + path string +} + +func ConfigFilesHandler(w http.ResponseWriter, r *http.Request) { + setConfigFilesCORS(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodPost { + writeConfigFilesError(w, http.StatusMethodNotAllowed, fmt.Errorf("method not allowed")) + return + } + defer r.Body.Close() + var req configFileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeConfigFilesError(w, http.StatusBadRequest, fmt.Errorf("invalid request body: %v", err)) + return + } + if !authorizeConfigFilesRequest(w, r, req.Token) { + return + } + switch req.Action { + case "list": + writeConfigFilesJSON(w, http.StatusOK, configFileResponse{OK: true, Files: listConfigFiles()}) + case "read": + target, err := resolveConfigFileTarget(req.File) + if err != nil { + writeConfigFilesError(w, http.StatusBadRequest, err) + return + } + content, err := os.ReadFile(target.path) + if err != nil { + writeConfigFilesError(w, http.StatusInternalServerError, fmt.Errorf("unable to read %s: %v", target.file, err)) + return + } + writeConfigFilesJSON(w, http.StatusOK, configFileResponse{OK: true, File: target.file, Content: string(content)}) + case "save": + target, err := resolveConfigFileTarget(req.File) + if err != nil { + writeConfigFilesError(w, http.StatusBadRequest, err) + return + } + if strings.TrimSpace(req.Content) == "" { + writeConfigFilesError(w, http.StatusBadRequest, fmt.Errorf("refusing to save empty config")) + return + } + var formatted []byte + switch target.kind { + case "system": + formatted, err = config.ReplaceConfJSON([]byte(req.Content)) + case "pier": + formatted, err = config.ReplaceUrbitConfigJSON(target.pier, []byte(req.Content)) + default: + err = fmt.Errorf("unsupported config kind %q", target.kind) + } + if err != nil { + writeConfigFilesError(w, http.StatusBadRequest, err) + return + } + writeConfigFilesJSON(w, http.StatusOK, configFileResponse{OK: true, File: target.file, Content: string(formatted)}) + default: + writeConfigFilesError(w, http.StatusBadRequest, fmt.Errorf("unrecognized config file action: %s", req.Action)) + } +} + +func listConfigFiles() []configFileSummary { + conf := config.Conf() + files := []configFileSummary{{ + File: "system.json", + Label: "System settings", + Kind: "system", + }} + for _, pier := range conf.Piers { + files = append(files, configFileSummary{ + File: fmt.Sprintf("pier/%s.json", pier), + Label: fmt.Sprintf("%s pier config", pier), + Kind: "pier", + Pier: pier, + }) + } + return files +} + +func resolveConfigFileTarget(file string) (configFileTarget, error) { + trimmed := strings.TrimSpace(file) + if trimmed == "" { + return configFileTarget{}, fmt.Errorf("config file is required") + } + cleaned := path.Clean(trimmed) + if cleaned != trimmed || strings.Contains(cleaned, "..") { + return configFileTarget{}, fmt.Errorf("invalid config file path") + } + switch cleaned { + case "system.json", "settings.json": + return configFileTarget{ + file: "system.json", + kind: "system", + path: filepath.Join(config.BasePath, "settings", "system.json"), + }, nil + } + if path.Dir(cleaned) != "pier" || path.Ext(cleaned) != ".json" { + return configFileTarget{}, fmt.Errorf("unsupported config file: %s", file) + } + pier := strings.TrimSuffix(path.Base(cleaned), ".json") + if pier == "" || strings.ContainsAny(pier, `/\`) { + return configFileTarget{}, fmt.Errorf("invalid pier config name") + } + if !configuredPier(pier) { + return configFileTarget{}, fmt.Errorf("pier %s is not configured", pier) + } + return configFileTarget{ + file: fmt.Sprintf("pier/%s.json", pier), + kind: "pier", + pier: pier, + path: filepath.Join(config.BasePath, "settings", "pier", pier+".json"), + }, nil +} + +func configuredPier(pier string) bool { + return slices.Contains(config.Conf().Piers, pier) +} + +func authorizeConfigFilesRequest(w http.ResponseWriter, r *http.Request, token structs.WsTokenStruct) bool { + _, valid, authed := auth.CheckStreamToken(token, r) + if !valid || !authed { + writeConfigFilesError(w, http.StatusUnauthorized, fmt.Errorf("unauthorized")) + return false + } + return true +} + +func setConfigFilesCORS(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") +} + +func writeConfigFilesJSON(w http.ResponseWriter, status int, payload configFileResponse) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(payload); err != nil { + zap.L().Error(fmt.Sprintf("failed to write config files response: %v", err)) + } +} + +func writeConfigFilesError(w http.ResponseWriter, status int, err error) { + zap.L().Warn(fmt.Sprintf("config files request failed: %v", err)) + writeConfigFilesJSON(w, status, configFileResponse{OK: false, Error: err.Error()}) +} diff --git a/goseg/handler/config_files_test.go b/goseg/handler/config_files_test.go new file mode 100644 index 00000000..3277e597 --- /dev/null +++ b/goseg/handler/config_files_test.go @@ -0,0 +1,72 @@ +package handler + +import ( + "encoding/json" + "groundseg/config" + "os" + "path/filepath" + "testing" +) + +func TestResolveConfigFileTarget(t *testing.T) { + base := t.TempDir() + t.Setenv("GS_BASE_PATH", base) + oldBasePath := config.BasePath + t.Cleanup(func() { + config.BasePath = oldBasePath + }) + config.BasePath = base + if err := os.MkdirAll(filepath.Join(base, "settings"), 0o755); err != nil { + t.Fatalf("failed to create settings dir: %v", err) + } + raw, err := json.Marshal(map[string]any{ + "piers": []string{"sampel-palnet"}, + "sessions": map[string]any{ + "authorized": map[string]any{}, + "unauthorized": map[string]any{}, + }, + }) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if _, err := config.ReplaceConfJSON(raw); err != nil { + t.Fatalf("failed to seed config: %v", err) + } + + tests := []struct { + name string + file string + wantErr bool + kind string + pier string + }{ + {name: "system file", file: "system.json", kind: "system"}, + {name: "settings alias", file: "settings.json", kind: "system"}, + {name: "configured pier", file: "pier/sampel-palnet.json", kind: "pier", pier: "sampel-palnet"}, + {name: "traversal", file: "../system.json", wantErr: true}, + {name: "nested traversal", file: "pier/../system.json", wantErr: true}, + {name: "nested pier path", file: "pier/sampel-palnet/extra.json", wantErr: true}, + {name: "unconfigured pier", file: "pier/zod.json", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveConfigFileTarget(tt.file) + if tt.wantErr { + if err == nil { + t.Fatalf("resolveConfigFileTarget(%q) returned nil error", tt.file) + } + return + } + if err != nil { + t.Fatalf("resolveConfigFileTarget(%q) returned error: %v", tt.file, err) + } + if got.kind != tt.kind { + t.Fatalf("kind = %q, want %q", got.kind, tt.kind) + } + if got.pier != tt.pier { + t.Fatalf("pier = %q, want %q", got.pier, tt.pier) + } + }) + } +} diff --git a/goseg/handler/startram.go b/goseg/handler/startram.go index fbb557f3..1640d374 100644 --- a/goseg/handler/startram.go +++ b/goseg/handler/startram.go @@ -110,14 +110,8 @@ func handleStartramRestart() { if err := click.BarExit(patp); err != nil { zap.L().Error(fmt.Sprintf("Failed to stop %s with |exit for startram restart: %v", patp, err)) } else { - for { - exited, err := shipExited(patp) - if err == nil { - if !exited { - continue - } - } - break + if _, err := shipExited(patp); err != nil { + zap.L().Warn(fmt.Sprintf("Timed out waiting for %s to exit cleanly: %v", patp, err)) } } } @@ -447,6 +441,7 @@ func handleNotImplement(action string) { } func shipExited(patp string) (bool, error) { + deadline := time.Now().Add(90 * time.Second) for { statuses, err := docker.GetShipStatus([]string{patp}) if err != nil { @@ -457,6 +452,10 @@ func shipExited(patp string) (bool, error) { return false, fmt.Errorf("%s status doesn't exist", patp) } if strings.Contains(status, "Up") { + if time.Now().After(deadline) { + return false, fmt.Errorf("%s is still %s", patp, status) + } + time.Sleep(500 * time.Millisecond) continue } return true, nil diff --git a/goseg/main.go b/goseg/main.go index d4664386..af783712 100644 --- a/goseg/main.go +++ b/goseg/main.go @@ -164,6 +164,7 @@ func startServer() { // *http.Server { w.HandleFunc("/keys/operation", handler.KeysOperationHandler) w.HandleFunc("/keys/wallet/prepare", handler.KeysPrepareWalletHandler) w.HandleFunc("/keys/wallet/submit", handler.KeysSubmitWalletHandler) + w.HandleFunc("/config/files", handler.ConfigFilesHandler) wsServer := &http.Server{ Addr: ":3000", Handler: w, diff --git a/ui/src/routes/system/+page.svelte b/ui/src/routes/system/+page.svelte index 3a8eb9bb..22b8ebb5 100644 --- a/ui/src/routes/system/+page.svelte +++ b/ui/src/routes/system/+page.svelte @@ -12,6 +12,7 @@ import Logs from './Logs.svelte' import Penpai from './Penpai.svelte' import Support from './Support.svelte' + import ConfigEditor from './ConfigEditor.svelte' $: state = ($structure?.system?.updates?.linux?.state) || "updated" @@ -28,6 +29,7 @@ {/if} +
From 28033a72739c880f2f39290993b413b7559c1a06 Mon Sep 17 00:00:00 2001 From: reid Date: Sat, 27 Jun 2026 13:27:19 -0500 Subject: [PATCH 05/29] v2.8.0 hermes --- .github/workflows/hermes-image.yaml | 100 +++++ containers/hermes/Dockerfile | 95 +++++ containers/hermes/entrypoint.sh | 208 +++++++++++ gallseg-reference/app/groundseg.hoon | 2 +- goseg/broadcast/broadcast.go | 62 ++-- goseg/broadcast/loop.go | 1 + goseg/click/desk.go | 4 +- goseg/config/config.go | 97 +++-- goseg/config/hermes.go | 119 ++++++ goseg/config/urbit.go | 25 +- goseg/defaults/defaults.go | 60 +-- goseg/defaults/scripts.go | 64 ---- goseg/docker/docker.go | 28 +- goseg/docker/hermes.go | 314 ++++++++++++++++ goseg/docker/llama.go | 140 ------- goseg/handler/hermes.go | 337 +++++++++++++++++ goseg/handler/leak.go | 4 - goseg/handler/newship.go | 13 - goseg/handler/penpai.go | 98 ----- goseg/handler/startram.go | 3 + goseg/handler/support.go | 27 +- goseg/handler/system.go | 26 -- goseg/handler/urbit.go | 90 +---- goseg/main.go | 6 +- goseg/rectify/rectify.go | 32 +- goseg/routines/docker.go | 14 + goseg/structs/broadcast.go | 41 ++- goseg/structs/configs.go | 26 +- goseg/structs/ws.go | 33 +- goseg/ws/ws.go | 10 +- ui/src/lib/ToggleButton.svelte | 13 +- ui/src/lib/stores/websocket.js | 96 ++--- ui/src/routes/apps/Penpai.svelte | 401 --------------------- ui/src/routes/profile/+page.svelte | 3 + ui/src/routes/profile/Hermes.svelte | 320 ++++++++++++++++ ui/src/routes/system/+page.svelte | 2 - ui/src/routes/system/BugReportModal.svelte | 16 +- ui/src/routes/system/Penpai.svelte | 44 --- 38 files changed, 1813 insertions(+), 1161 deletions(-) create mode 100644 .github/workflows/hermes-image.yaml create mode 100644 containers/hermes/Dockerfile create mode 100644 containers/hermes/entrypoint.sh create mode 100644 goseg/config/hermes.go create mode 100644 goseg/docker/hermes.go delete mode 100644 goseg/docker/llama.go create mode 100644 goseg/handler/hermes.go delete mode 100644 goseg/handler/penpai.go delete mode 100644 ui/src/routes/apps/Penpai.svelte create mode 100644 ui/src/routes/profile/Hermes.svelte delete mode 100644 ui/src/routes/system/Penpai.svelte diff --git a/.github/workflows/hermes-image.yaml b/.github/workflows/hermes-image.yaml new file mode 100644 index 00000000..5032ad78 --- /dev/null +++ b/.github/workflows/hermes-image.yaml @@ -0,0 +1,100 @@ +name: Hermes image + +on: + workflow_dispatch: + inputs: + image: + description: Docker image name to push + required: false + default: nativeplanet/hermes-tlon + hermes_agent_ref: + description: Hermes Agent git ref + required: false + default: 2ffa1c97c09317c1d066aa5708b8ad961a4ca589 + tlon_apps_ref: + description: tlon-apps git ref + required: false + default: b9180da6491d29933a98f6e4f1b1458ce61ca576 + push: + description: Push image to Docker Hub + required: false + type: boolean + default: true + push: + branches: + - main + - master + paths: + - containers/hermes/** + - .github/workflows/hermes-image.yaml + pull_request: + paths: + - containers/hermes/** + - .github/workflows/hermes-image.yaml + +permissions: + contents: read + +env: + DEFAULT_IMAGE: nativeplanet/hermes-tlon + HERMES_IMAGE_TAG: 0.14.0-0.13.0 + HERMES_AGENT_REF: 2ffa1c97c09317c1d066aa5708b8ad961a4ca589 + TLON_APPS_REF: b9180da6491d29933a98f6e4f1b1458ce61ca576 + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - id: args + name: Resolve build arguments + run: | + set -euo pipefail + image="${{ github.event.inputs.image }}" + hermes_ref="${{ github.event.inputs.hermes_agent_ref }}" + tlon_ref="${{ github.event.inputs.tlon_apps_ref }}" + push_image="true" + + if [ -z "$image" ]; then image="$DEFAULT_IMAGE"; fi + if [ -z "$hermes_ref" ]; then hermes_ref="$HERMES_AGENT_REF"; fi + if [ -z "$tlon_ref" ]; then tlon_ref="$TLON_APPS_REF"; fi + if [ "${{ github.event_name }}" = "pull_request" ]; then + push_image="false" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ github.event.inputs.push }}" != "true" ]; then + push_image="false" + fi + + echo "image=$image" >> "$GITHUB_OUTPUT" + echo "hermes-ref=$hermes_ref" >> "$GITHUB_OUTPUT" + echo "tlon-ref=$tlon_ref" >> "$GITHUB_OUTPUT" + echo "push=$push_image" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: steps.args.outputs.push == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build Hermes image + uses: docker/build-push-action@v6 + with: + context: containers/hermes + file: containers/hermes/Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ steps.args.outputs.push == 'true' }} + build-args: | + HERMES_AGENT_REF=${{ steps.args.outputs.hermes-ref }} + TLON_APPS_REF=${{ steps.args.outputs.tlon-ref }} + tags: | + ${{ steps.args.outputs.image }}:${{ env.HERMES_IMAGE_TAG }} + ${{ steps.args.outputs.image }}:${{ env.HERMES_IMAGE_TAG }}-${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/containers/hermes/Dockerfile b/containers/hermes/Dockerfile new file mode 100644 index 00000000..8f25c532 --- /dev/null +++ b/containers/hermes/Dockerfile @@ -0,0 +1,95 @@ +ARG NODE_VERSION=22.22.2 +FROM node:${NODE_VERSION}-bookworm-slim + +ARG HERMES_AGENT_REPO=https://github.com/NousResearch/hermes-agent.git +ARG HERMES_AGENT_REF=2ffa1c97c09317c1d066aa5708b8ad961a4ca589 +ARG TLON_APPS_REPO=https://github.com/tloncorp/tlon-apps.git +ARG TLON_APPS_REF=b9180da6491d29933a98f6e4f1b1458ce61ca576 +ARG PNPM_VERSION=9.15.9 + +LABEL org.opencontainers.image.title="NativePlanet Hermes Tlon" +LABEL org.opencontainers.image.description="Hermes Agent with the Tlon platform adapter and tlon CLI" +LABEL org.opencontainers.image.source="https://github.com/Native-Planet/GroundSeg" +LABEL org.nativeplanet.hermes.agent-ref="${HERMES_AGENT_REF}" +LABEL org.nativeplanet.hermes.tlon-apps-ref="${TLON_APPS_REF}" + +ENV HERMES_AGENT_DIR=/opt/hermes-agent +ENV HERMES_VENV=/opt/hermes-venv +ENV TLON_APPS_DIR=/opt/tlon-apps +ENV TLON_ADAPTER_DIR=/opt/tlon-apps/packages/hermes-tlon-adapter +ENV TLON_SKILL_DIR=/opt/tlon-apps/packages/tlon-skill +ENV TLON_CLI=/usr/local/bin/tlon +ENV TLON_SKILL_PATH=/opt/tlon-apps/packages/tlon-skill/SKILL.md +ENV HERMES_HOME=/opt/data +ENV HERMES_WEB_DIST=/opt/hermes-agent/hermes_cli/web_dist +ENV HERMES_DASHBOARD=1 +ENV HERMES_DASHBOARD_HOST=0.0.0.0 +ENV HERMES_DASHBOARD_PORT=9119 +ENV HERMES_MODEL_PROVIDER=openrouter +ENV HERMES_MODEL=deepseek/deepseek-v4-flash +ENV TLON_TELEMETRY=false +ENV TERMINAL_CWD=/opt/data +ENV PATH="/opt/hermes-venv/bin:/root/.bun/bin:${PATH}" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + build-essential \ + ca-certificates \ + curl \ + ffmpeg \ + git \ + libffi-dev \ + openssh-client \ + procps \ + python3 \ + python3-dev \ + python3-pip \ + python3-venv \ + ripgrep \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g "pnpm@${PNPM_VERSION}" \ + && curl -fsSL https://bun.sh/install | bash + +RUN git clone --filter=blob:none "${HERMES_AGENT_REPO}" "${HERMES_AGENT_DIR}" \ + && cd "${HERMES_AGENT_DIR}" \ + && git checkout "${HERMES_AGENT_REF}" + +RUN cd "${HERMES_AGENT_DIR}" \ + && npm install --prefer-offline --no-audit \ + && cd web \ + && npm install --prefer-offline --no-audit \ + && npm run build \ + && cd ../ui-tui \ + && npm install --prefer-offline --no-audit \ + && npm run build \ + && npm cache clean --force + +RUN python3 -m venv "${HERMES_VENV}" \ + && "${HERMES_VENV}/bin/python" -m pip install --upgrade pip setuptools wheel \ + && "${HERMES_VENV}/bin/python" -m pip install -e "${HERMES_AGENT_DIR}[cron,cli,pty,mcp,acp,web]" \ + && "${HERMES_VENV}/bin/python" -m pip install "aiohttp>=3.9" + +RUN git clone --filter=blob:none "${TLON_APPS_REPO}" "${TLON_APPS_DIR}" \ + && cd "${TLON_APPS_DIR}" \ + && git checkout "${TLON_APPS_REF}" + +RUN cd "${TLON_APPS_DIR}" \ + && pnpm install --filter @tloncorp/tlon-skill... --frozen-lockfile --ignore-scripts \ + && pnpm --filter @tloncorp/api build \ + && pnpm --filter @tloncorp/tlon-skill build \ + && cp "${TLON_SKILL_DIR}/dist/tlon-run" "${TLON_CLI}" \ + && chmod +x "${TLON_CLI}" \ + && "${TLON_CLI}" --version \ + && pnpm store prune + +RUN mkdir -p "${HERMES_HOME}" +VOLUME [ "/opt/data" ] + +COPY entrypoint.sh /usr/local/bin/hermes-tlon-entrypoint +RUN chmod +x /usr/local/bin/hermes-tlon-entrypoint + +ENTRYPOINT ["/usr/local/bin/hermes-tlon-entrypoint"] +CMD ["gateway", "run", "--replace", "--accept-hooks"] diff --git a/containers/hermes/entrypoint.sh b/containers/hermes/entrypoint.sh new file mode 100644 index 00000000..a9df3440 --- /dev/null +++ b/containers/hermes/entrypoint.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERMES_HOME="${HERMES_HOME:-/opt/data}" +HERMES_AGENT_DIR="${HERMES_AGENT_DIR:-/opt/hermes-agent}" +TLON_ADAPTER_DIR="${TLON_ADAPTER_DIR:-/opt/tlon-apps/packages/hermes-tlon-adapter}" +TLON_SKILL_DIR="${TLON_SKILL_DIR:-/opt/tlon-apps/packages/tlon-skill}" +TLON_CLI="${TLON_CLI:-/usr/local/bin/tlon}" +TERMINAL_CWD="${TERMINAL_CWD:-$HERMES_HOME}" + +export HERMES_HOME HERMES_AGENT_DIR TLON_ADAPTER_DIR TLON_SKILL_DIR TLON_CLI TERMINAL_CWD + +if [ -z "${BRAVE_SEARCH_API_KEY:-}" ] && [ -n "${BRAVE_API_KEY:-}" ]; then + export BRAVE_SEARCH_API_KEY="$BRAVE_API_KEY" +fi + +if [ -z "${TLON_HOME_CHANNEL:-}" ] && [ -n "${TLON_OWNER_SHIP:-}" ]; then + export TLON_HOME_CHANNEL="$TLON_OWNER_SHIP" +fi + +if [ ! -f "$TLON_ADAPTER_DIR/plugin.yaml" ]; then + echo "ERROR: Tlon Hermes adapter is missing at $TLON_ADAPTER_DIR" >&2 + exit 1 +fi + +if [ ! -x "$TLON_CLI" ]; then + echo "ERROR: tlon CLI is missing or not executable at $TLON_CLI" >&2 + exit 1 +fi + +mkdir -p "$HERMES_HOME/plugins/platforms" "$HERMES_HOME/logs" "$HERMES_HOME/memories" +ln -sfn "$TLON_ADAPTER_DIR" "$HERMES_HOME/plugins/platforms/tlon" + +python3 - <<'PY' +import os +import re +from pathlib import Path + +import yaml + +home = Path(os.environ["HERMES_HOME"]) +adapter_dir = Path(os.environ["TLON_ADAPTER_DIR"]) +prompts_dir = adapter_dir / "prompts" +prompts_root = prompts_dir.resolve() +include_re = re.compile(r"(?m)^\{\{include:([^}]+)\}\}\s*$") + + +def env_any(names, default): + for name in names: + value = (os.environ.get(name) or "").strip() + if value: + return value + return default + + +values = { + "TLON_NODE_ID": env_any(["TLON_NODE_ID", "TLON_SHIP", "URBIT_SHIP"], "the configured bot node"), + "TLON_OWNER_SHIP": env_any(["TLON_OWNER_SHIP"], "the configured owner ship"), + "TLON_NODE_URL": env_any(["TLON_NODE_URL", "TLON_SHIP_URL", "TLON_URL", "URBIT_URL"], "the configured Tlon node URL"), +} + + +def checked_prompt_path(rel): + path = (prompts_dir / rel).resolve() + if path != prompts_root and prompts_root not in path.parents: + raise ValueError(f"Prompt include escapes prompts directory: {rel}") + return path + + +def render_prompt(rel, stack=()): + if rel in stack: + raise ValueError(f"Prompt include cycle: {' -> '.join((*stack, rel))}") + path = checked_prompt_path(rel) + text = path.read_text(encoding="utf-8") + + def include(match): + return render_prompt(match.group(1).strip(), (*stack, rel)).rstrip() + + text = include_re.sub(include, text) + for key, value in values.items(): + text = text.replace("{{" + key + "}}", value) + return text.strip() + "\n" + + +def upsert_managed_block(target, rel, *, replace_default_soul=False, memory_file=False): + rendered = render_prompt(rel).rstrip() + start = f"" + end = f"" + block = f"{start}\n{rendered}\n{end}\n" + target.parent.mkdir(parents=True, exist_ok=True) + current = target.read_text(encoding="utf-8") if target.exists() else "" + default_soul = "You are Hermes Agent, an intelligent AI assistant created by Nous Research." + + if start in current and end in current: + pattern = re.compile(re.escape(start) + r".*?" + re.escape(end) + r"\n?", re.S) + updated = pattern.sub(block, current) + elif replace_default_soul and current.strip().startswith(default_soul): + updated = block + elif current.strip(): + separator = "\n---\n" if memory_file else "\n\n" + updated = current.rstrip() + separator + block + else: + updated = block + target.write_text(updated, encoding="utf-8") + + +upsert_managed_block(home / "SOUL.md", "hermes/SOUL.md", replace_default_soul=True) +upsert_managed_block(home / ".hermes.md", "hermes/.hermes.md") +upsert_managed_block(home / "memories" / "USER.md", "hermes/USER.md", memory_file=True) + +config_path = home / "config.yaml" +config = yaml.safe_load(config_path.read_text()) if config_path.exists() else {} +if not isinstance(config, dict): + config = {} + +plugins = config.setdefault("plugins", {}) +enabled = plugins.setdefault("enabled", []) +if not isinstance(enabled, list): + enabled = [] + plugins["enabled"] = enabled +if "platforms/tlon" not in enabled: + enabled.append("platforms/tlon") + +gateway = config.setdefault("gateway", {}) +gateway_platforms = gateway.setdefault("platforms", {}) +gateway_tlon = gateway_platforms.setdefault("tlon", {}) +gateway_tlon["enabled"] = True + +platforms = config.setdefault("platforms", {}) +tlon = platforms.setdefault("tlon", {}) +tlon["enabled"] = True + +terminal = config.get("terminal") +if not isinstance(terminal, dict): + terminal = {} +terminal["cwd"] = os.environ.get("TERMINAL_CWD") or str(home) +config["terminal"] = terminal + +home_channel = ( + os.environ.get("TLON_HOME_CHANNEL") + or os.environ.get("TLON_OWNER_SHIP") + or os.environ.get("TLON_GATEWAY_STATUS_OWNER") + or "" +).strip() +if home_channel: + home_channel_config = { + "platform": "tlon", + "chat_id": home_channel, + "name": home_channel, + } + tlon["home_channel"] = home_channel_config + gateway_tlon["home_channel"] = home_channel_config + +provider = (os.environ.get("HERMES_MODEL_PROVIDER") or os.environ.get("HERMES_PROVIDER") or "").strip() +model = (os.environ.get("HERMES_MODEL") or os.environ.get("MODEL") or "").strip() +if provider or model: + model_config = config.get("model") + if not isinstance(model_config, dict): + model_config = {} + if provider: + model_config["provider"] = provider + if model: + model_config["default"] = model + config["model"] = model_config + +web_backend = (os.environ.get("HERMES_WEB_BACKEND") or "").strip() +web_search_backend = (os.environ.get("HERMES_WEB_SEARCH_BACKEND") or web_backend).strip() +if not web_search_backend and (os.environ.get("BRAVE_SEARCH_API_KEY") or "").strip(): + web_search_backend = "brave-free" + +web_extract_backend = (os.environ.get("HERMES_WEB_EXTRACT_BACKEND") or "").strip() +if web_search_backend or web_extract_backend: + web_config = config.get("web") + if not isinstance(web_config, dict): + web_config = {} + if web_search_backend: + web_config["search_backend"] = web_search_backend + if web_extract_backend: + web_config["extract_backend"] = web_extract_backend + config["web"] = web_config + +config_path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8") +PY + +if [ -d "$HERMES_AGENT_DIR/skills" ] && [ -f "$HERMES_AGENT_DIR/tools/skills_sync.py" ]; then + python3 "$HERMES_AGENT_DIR/tools/skills_sync.py" || true +fi + +case "${HERMES_DASHBOARD:-}" in + 1|true|TRUE|True|yes|YES|Yes) + dash_host="${HERMES_DASHBOARD_HOST:-0.0.0.0}" + dash_port="${HERMES_DASHBOARD_PORT:-9119}" + dash_args=(--host "$dash_host" --port "$dash_port" --no-open) + if [ "$dash_host" != "127.0.0.1" ] && [ "$dash_host" != "localhost" ]; then + dash_args+=(--insecure) + fi + ( + stdbuf -oL -eL hermes dashboard "${dash_args[@]}" 2>&1 \ + | sed -u 's/^/[dashboard] /' + ) & + ;; +esac + +if [ $# -gt 0 ] && command -v "$1" >/dev/null 2>&1; then + exec "$@" +fi + +exec hermes "$@" diff --git a/gallseg-reference/app/groundseg.hoon b/gallseg-reference/app/groundseg.hoon index ff67d88b..262b86ec 100644 --- a/gallseg-reference/app/groundseg.hoon +++ b/gallseg-reference/app/groundseg.hoon @@ -37,7 +37,7 @@ |= [=mark =vase] ?> =(our.bowl src.bowl) ^- (quip card _this) - ?> ?=(%penpai-do mark) + ?> ?=(%groundseg-do mark) ?> =(our.bol src.bol) =+ !<(=do vase) ?- -.do diff --git a/goseg/broadcast/broadcast.go b/goseg/broadcast/broadcast.go index 2699e58c..66053bdf 100644 --- a/goseg/broadcast/broadcast.go +++ b/goseg/broadcast/broadcast.go @@ -15,7 +15,6 @@ import ( "path" "path/filepath" "regexp" - "runtime" "slices" "strconv" "strings" @@ -285,16 +284,6 @@ func ConstructPierInfo() (map[string]structs.Urbit, error) { minioLinked := config.GetMinIOLinkedStatus(pier) - var penpaiCompanionInstalled bool - if strings.Contains(pierStatus[pier], "Up") { - deskStatus, err := click.GetDesk(pier, "penpai", false) - if err != nil { - penpaiCompanionInstalled = false - zap.L().Debug(fmt.Sprintf("Broadcast failed to get penpai desk info for %v: %v", pier, err)) - } - penpaiCompanionInstalled = deskStatus == "running" - } - var gallsegInstalled bool if strings.Contains(pierStatus[pier], "Up") { deskStatus, err := click.GetDesk(pier, "groundseg", false) @@ -364,7 +353,6 @@ func ConstructPierInfo() (map[string]structs.Urbit, error) { urbit.Info.NextPack = strconv.FormatInt(GetScheduledPack(pier).Unix(), 10) urbit.Info.PackIntervalType = dockerConfig.MeldScheduleType urbit.Info.PackIntervalValue = dockerConfig.MeldFrequency - urbit.Info.PenpaiCompanion = penpaiCompanionInstalled urbit.Info.Gallseg = gallsegInstalled urbit.Info.StartramReminder = startramReminder urbit.Info.ChopOnUpgrade = chopOnUpgrade @@ -399,26 +387,13 @@ func ConstructPierInfo() (map[string]structs.Urbit, error) { func constructAppsInfo() structs.Apps { var apps structs.Apps - conf := config.Conf() - - // penpai - var modelTitles []string - // Iterate through penpais to extract modelTitle - for _, penpaiInfo := range conf.PenpaiModels { - modelTitles = append(modelTitles, penpaiInfo.ModelTitle) - } - apps.Penpai.Info.Models = modelTitles - apps.Penpai.Info.Allowed = conf.PenpaiAllow - apps.Penpai.Info.ActiveModel = conf.PenpaiActive - apps.Penpai.Info.Running = conf.PenpaiRunning - apps.Penpai.Info.MaxCores = runtime.NumCPU() - 1 - apps.Penpai.Info.ActiveCores = conf.PenpaiCores return apps } func constructProfileInfo() structs.Profile { // Build startram struct var startramInfo structs.Startram + var hermesInfo structs.Hermes // Information from config conf := config.Conf() startramInfo.Info.Registered = conf.WgRegistered @@ -469,9 +444,44 @@ func constructProfileInfo() structs.Profile { // Get Regions startramInfo.Info.Regions = broadcastState.Profile.Startram.Info.Regions + + if err := config.LoadHermesConfig(); err != nil { + zap.L().Warn(fmt.Sprintf("Unable to load Hermes profile config: %v", err)) + } + hermesConf := config.HermesConf() + hermesRunning := false + if hermesContainer, err := docker.FindContainer(docker.HermesContainerName); err == nil && hermesContainer != nil { + hermesRunning = hermesContainer.State == "running" + } + hermesURL := "#" + hostName := system.LocalUrl + if hostName == "" { + hostName = "nativeplanet.local" + } + if hermesConf.Port > 0 { + hermesURL = fmt.Sprintf("http://%s:%d", hostName, hermesConf.Port) + } + hermesInfo.Info.Enabled = hermesConf.Enabled + hermesInfo.Info.Running = hermesRunning + hermesInfo.Info.URL = hermesURL + hermesInfo.Info.Ship = docker.NormalizeHermesShip(hermesConf.Ship) + hermesInfo.Info.Owner = docker.NormalizeHermesShip(hermesConf.Owner) + hermesInfo.Info.Port = hermesConf.Port + hermesInfo.Info.Image = docker.HermesImageOrDefault(hermesConf.Image) + hermesInfo.Info.HermesVersion = docker.HermesVersionOrDefault(hermesConf.HermesVersion) + hermesInfo.Info.HermesAgentRef = docker.HermesAgentRefOrDefault(hermesConf.HermesAgentRef) + hermesInfo.Info.TlonAdapterVersion = docker.HermesTlonAdapterVersionOrDefault(hermesConf.TlonAdapterVersion) + hermesInfo.Info.TlonAdapterRef = docker.HermesTlonAdapterRefOrDefault(hermesConf.TlonAdapterRef) + hermesInfo.Info.ModelProvider = docker.HermesModelProviderOrDefault(hermesConf.ModelProvider) + hermesInfo.Info.Model = docker.HermesModelOrDefault(hermesConf.Model) + for _, pier := range conf.Piers { + hermesInfo.Info.Ships = append(hermesInfo.Info.Ships, docker.NormalizeHermesShip(pier)) + } + // Build profile struct var profile structs.Profile profile.Startram = startramInfo + profile.Hermes = hermesInfo return profile } diff --git a/goseg/broadcast/loop.go b/goseg/broadcast/loop.go index 04adf609..f8d9063e 100644 --- a/goseg/broadcast/loop.go +++ b/goseg/broadcast/loop.go @@ -59,6 +59,7 @@ func BroadcastLoop() { func PreserveProfileTransitions(oldState structs.AuthBroadcast, newProfile structs.Profile) structs.Profile { newProfile.Startram.Transition = oldState.Profile.Startram.Transition + newProfile.Hermes.Transition = oldState.Profile.Hermes.Transition return newProfile } diff --git a/goseg/click/desk.go b/goseg/click/desk.go index 7125340e..f9e53496 100644 --- a/goseg/click/desk.go +++ b/goseg/click/desk.go @@ -115,7 +115,7 @@ func getDesk(patp, desk string, bypass bool) (string, error) { vats, _, err := filterResponse("desk", response) if err != nil { storeDeskError(patp, desk) - return "", fmt.Errorf("Click penpai desk info failed to get exec: %v", err) + return "", fmt.Errorf("Click get desk %%%v failed to parse response: %v", desk, err) } storeDesk(patp, desk, vats) return vats, nil @@ -217,7 +217,7 @@ func fetchDeskFromMemory(patp, desk string) (string, error) { } func storeDeskError(patp, desk string) { - zap.L().Debug(fmt.Sprintf("Recording penpai desk info failure for %s", patp)) + zap.L().Debug(fmt.Sprintf("Recording %%%v desk info failure for %s", desk, patp)) desksMutex.Lock() defer desksMutex.Unlock() deskInfo, exists := shipDesks[patp] diff --git a/goseg/config/config.go b/goseg/config/config.go index 877c1696..0072eb17 100644 --- a/goseg/config/config.go +++ b/goseg/config/config.go @@ -44,9 +44,16 @@ var ( // representation of desired/actual container states GSContainers = make(map[string]structs.ContainerState) // channel for log stream requests - DockerDir = defaults.DockerData("volumes") + "/" - confPath = filepath.Join(BasePath, "settings", "system.json") - keyPath = filepath.Join(BasePath, "settings", "session.key") + DockerDir = defaults.DockerData("volumes") + "/" + confPath = filepath.Join(BasePath, "settings", "system.json") + keyPath = filepath.Join(BasePath, "settings", "session.key") + removedSysConfigKeys = []string{ + "penpaiAllow", + "penpaiRunning", + "penpaiCores", + "penpaiModels", + "penpaiActive", + } isEMMCMachine bool confMutex sync.Mutex contMutex sync.Mutex @@ -111,6 +118,9 @@ func init() { } // add mising fields globalConfig = mergeConfigs(defaults.SysConfig(BasePath), globalConfig) + if err := pruneRemovedSysConfigKeysOnDisk(); err != nil { + zap.L().Warn(fmt.Sprintf("Unable to prune removed config keys: %v", err)) + } // wipe the sessions on each startup //globalConfig.Sessions.Authorized = make(map[string]structs.SessionInfo) globalConfig.Sessions.Unauthorized = make(map[string]structs.SessionInfo) @@ -292,6 +302,7 @@ func UpdateConf(values map[string]any) error { } // update our unmarshaled struct maps.Copy(configMap, values) + pruneRemovedSysConfigKeys(configMap) if err = persistConf(configMap); err != nil { return fmt.Errorf("Unable to persist config update: %v", err) } @@ -308,6 +319,7 @@ func ReplaceConfJSON(raw []byte) ([]byte, error) { if len(configMap) == 0 { return nil, fmt.Errorf("refusing to persist empty system configuration") } + pruneRemovedSysConfigKeys(configMap) formatted, err := json.MarshalIndent(configMap, "", " ") if err != nil { return nil, fmt.Errorf("error encoding system config: %v", err) @@ -319,6 +331,7 @@ func ReplaceConfJSON(raw []byte) ([]byte, error) { } func persistConf(configMap map[string]any) error { + pruneRemovedSysConfigKeys(configMap) BasePath := getBasePath() confPath := filepath.Join(BasePath, "settings", "system.json") tmpFile, err := os.CreateTemp(filepath.Dir(confPath), "system.json.*") @@ -357,6 +370,54 @@ func persistConf(configMap map[string]any) error { return nil } +func pruneRemovedSysConfigKeys(configMap map[string]any) { + for _, key := range removedSysConfigKeys { + delete(configMap, key) + } +} + +func pruneRemovedSysConfigKeysOnDisk() error { + file, err := os.ReadFile(confPath) + if err != nil { + return err + } + var configMap map[string]any + if err := json.Unmarshal(file, &configMap); err != nil { + return err + } + changed := false + for _, key := range removedSysConfigKeys { + if _, ok := configMap[key]; ok { + delete(configMap, key) + changed = true + } + } + if !changed { + return nil + } + tmpFile, err := os.CreateTemp(filepath.Dir(confPath), "system.json.*") + if err != nil { + return fmt.Errorf("error creating temp file: %v", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + encoder := json.NewEncoder(tmpFile) + encoder.SetIndent("", " ") + if err := encoder.Encode(configMap); err != nil { + tmpFile.Close() + return fmt.Errorf("error encoding config: %v", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("error closing temp file: %v", err) + } + if fi, err := os.Stat(tmpPath); err != nil { + return fmt.Errorf("error checking temp file: %v", err) + } else if fi.Size() == 0 { + return fmt.Errorf("refusing to persist empty configuration file") + } + return os.Rename(tmpPath, confPath) +} + // we keep map[string]structs.ContainerState in memory to keep track of the containers // eg if they're running and whether they should be @@ -669,36 +730,6 @@ func mergeConfigs(defaultConfig, customConfig structs.SysConfig) structs.SysConf mergedConfig.Salt = customConfig.Salt } - // PenpaiAllow - mergedConfig.PenpaiAllow = customConfig.PenpaiAllow || defaultConfig.PenpaiAllow - - // PenpaiCores - if customConfig.PenpaiCores != 0 { - mergedConfig.PenpaiCores = customConfig.PenpaiCores - } else { - mergedConfig.PenpaiCores = defaultConfig.PenpaiCores - } - - // PenpaiModels - // always use defaults as newest - mergedConfig.PenpaiModels = defaultConfig.PenpaiModels - - // PenpaiRunning - mergedConfig.PenpaiRunning = customConfig.PenpaiRunning - - // PenpaiActive - validModel := false - for _, model := range defaultConfig.PenpaiModels { - if strings.EqualFold(model.ModelName, customConfig.PenpaiActive) { - validModel = true - } - } - if customConfig.PenpaiActive != "" && validModel { - mergedConfig.PenpaiActive = customConfig.PenpaiActive - } else { - mergedConfig.PenpaiActive = defaultConfig.PenpaiActive - } - // 502 checker if customConfig.Disable502 { mergedConfig.Disable502 = customConfig.Disable502 diff --git a/goseg/config/hermes.go b/goseg/config/hermes.go new file mode 100644 index 00000000..404ebf55 --- /dev/null +++ b/goseg/config/hermes.go @@ -0,0 +1,119 @@ +package config + +import ( + "encoding/json" + "fmt" + "groundseg/defaults" + "groundseg/structs" + "os" + "path/filepath" + "sync" +) + +var ( + hermesConfig structs.HermesConfig + hermesMutex sync.RWMutex +) + +func HermesConf() structs.HermesConfig { + hermesMutex.RLock() + defer hermesMutex.RUnlock() + return hermesConfig +} + +func LoadHermesConfig() error { + path := filepath.Join(BasePath, "settings", "hermes.json") + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := CreateDefaultHermesConf(); err != nil { + return err + } + } + file, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("unable to load Hermes config: %w", err) + } + var target structs.HermesConfig + if err := json.Unmarshal(file, &target); err != nil { + return fmt.Errorf("error decoding Hermes config: %w", err) + } + applyHermesDefaults(&target) + hermesMutex.Lock() + hermesConfig = target + hermesMutex.Unlock() + return nil +} + +func CreateDefaultHermesConf() error { + defaultConfig := defaults.HermesConfig + path := filepath.Join(BasePath, "settings", "hermes.json") + if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + return err + } + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + return encoder.Encode(&defaultConfig) +} + +func UpdateHermesConfig(input structs.HermesConfig) error { + applyHermesDefaults(&input) + hermesMutex.Lock() + defer hermesMutex.Unlock() + hermesConfig = input + path := filepath.Join(BasePath, "settings", "hermes.json") + if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + return err + } + tmpFile, err := os.CreateTemp(filepath.Dir(path), "hermes.json.*") + if err != nil { + return fmt.Errorf("error creating temp Hermes config: %v", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + encoder := json.NewEncoder(tmpFile) + encoder.SetIndent("", " ") + if err := encoder.Encode(&input); err != nil { + tmpFile.Close() + return fmt.Errorf("error encoding Hermes config: %v", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("error closing Hermes config: %v", err) + } + if fi, err := os.Stat(tmpPath); err != nil { + return fmt.Errorf("error checking Hermes config: %v", err) + } else if fi.Size() == 0 { + return fmt.Errorf("refusing to persist empty Hermes config") + } + return os.Rename(tmpPath, path) +} + +func applyHermesDefaults(target *structs.HermesConfig) { + if target.Port == 0 { + target.Port = defaults.HermesConfig.Port + } + if target.Image == "" { + target.Image = defaults.HermesConfig.Image + } + if target.HermesVersion == "" { + target.HermesVersion = defaults.HermesConfig.HermesVersion + } + if target.HermesAgentRef == "" { + target.HermesAgentRef = defaults.HermesConfig.HermesAgentRef + } + if target.TlonAdapterVersion == "" { + target.TlonAdapterVersion = defaults.HermesConfig.TlonAdapterVersion + } + if target.TlonAdapterRef == "" { + target.TlonAdapterRef = defaults.HermesConfig.TlonAdapterRef + } + if target.ModelProvider == "" { + target.ModelProvider = defaults.HermesConfig.ModelProvider + } + if target.Model == "" { + target.Model = defaults.HermesConfig.Model + } +} diff --git a/goseg/config/urbit.go b/goseg/config/urbit.go index 28f8aa96..ecae212c 100644 --- a/goseg/config/urbit.go +++ b/goseg/config/urbit.go @@ -60,13 +60,7 @@ func LoadUrbitConfig(pier string) error { if err := json.Unmarshal(file, &targetStruct); err != nil { return fmt.Errorf("Error decoding %s JSON: %w", pier, err) } - // set startram reminder - if targetStruct.StartramReminder == nil { - targetStruct.StartramReminder = defaults.UrbitConfig.StartramReminder - } - if targetStruct.SnapTime == 0 { - targetStruct.SnapTime = 60 - } + applyUrbitDefaults(&targetStruct) structs.SyncCustomS3Domains(&targetStruct) // Store in var UrbitsConfig[pier] = targetStruct @@ -90,6 +84,7 @@ func UpdateUrbitConfig(inputConfig map[string]structs.UrbitDocker) error { defer urbitMutex.Unlock() // update UrbitsConfig with the values from inputConfig for pier, config := range inputConfig { + applyUrbitDefaults(&config) structs.SyncCustomS3Domains(&config) ver, err := getImageTagByContainerName(pier) if err == nil { @@ -148,12 +143,7 @@ func ReplaceUrbitConfigJSON(pier string, raw []byte) ([]byte, error) { if targetStruct.PierName != "" && targetStruct.PierName != pier { return nil, fmt.Errorf("pier_name %q does not match %q", targetStruct.PierName, pier) } - if targetStruct.StartramReminder == nil { - targetStruct.StartramReminder = defaults.UrbitConfig.StartramReminder - } - if targetStruct.SnapTime == 0 { - targetStruct.SnapTime = 60 - } + applyUrbitDefaults(&targetStruct) structs.SyncCustomS3Domains(&targetStruct) urbitMutex.Lock() @@ -196,6 +186,15 @@ func unmarshalUrbitDockerSafe(data []byte, target *structs.UrbitDocker) (err err return json.Unmarshal(data, target) } +func applyUrbitDefaults(target *structs.UrbitDocker) { + if target.StartramReminder == nil { + target.StartramReminder = defaults.UrbitConfig.StartramReminder + } + if target.SnapTime == 0 { + target.SnapTime = defaults.UrbitConfig.SnapTime + } +} + func UpdateUrbitConfigForPier(pier string, mutate func(*structs.UrbitDocker)) error { if err := LoadUrbitConfig(pier); err != nil { return err diff --git a/goseg/defaults/defaults.go b/goseg/defaults/defaults.go index 5211775a..fe86e1ee 100644 --- a/goseg/defaults/defaults.go +++ b/goseg/defaults/defaults.go @@ -84,6 +84,21 @@ var ( Arm64Sha256: "6825aecd2f123c9d4408e660aba8a72f9e547a3774350b8f4d2d9b674e99e424", } + HermesConfig = structs.HermesConfig{ + Enabled: false, + Ship: "", + Owner: "", + Port: 19119, + Image: "registry.hub.docker.com/nativeplanet/hermes-tlon:0.14.0-0.13.0", + HermesVersion: "0.14.0", + HermesAgentRef: "2ffa1c97c09317c1d066aa5708b8ad961a4ca589", + TlonAdapterVersion: "0.13.0", + TlonAdapterRef: "b9180da6491d29933a98f6e4f1b1458ce61ca576", + ModelProvider: "openrouter", + Model: "deepseek/deepseek-v4-flash", + AccessCode: "", + } + WgConfig = structs.WgConfig{ WireguardName: "wireguard", WireguardVersion: "latest", @@ -162,52 +177,7 @@ func SysConfig(basePath string) structs.SysConfig { Pubkey: "", Privkey: "", Salt: "", - PenpaiRunning: false, - PenpaiCores: 1, SnapTime: 60, - PenpaiActive: "TinyLlama-1.1B", - PenpaiModels: []structs.Penpai{ - { - ModelTitle: "TinyLlama 1.1B", - ModelName: "TinyLlama-1.1B", - ModelUrl: "https://huggingface.co/jartine/TinyLlama-1.1B-Chat-v1.0-GGUF/resolve/main/TinyLlama-1.1B-Chat-v1.0.Q5_K_M.llamafile?download=true", - }, - { - ModelTitle: "Mistral 7B Instruct", - ModelName: "Mistral 7B Instruct", - ModelUrl: "https://huggingface.co/jartine/Mistral-7B-Instruct-v0.2-llamafile/resolve/main/mistral-7b-instruct-v0.2.Q5_K_M.llamafile?download=true", - }, - { - ModelTitle: "Mixtral 8x7B Instruct", - ModelName: "Mixtral-8x7B-Instruct", - ModelUrl: "https://huggingface.co/jartine/Mixtral-8x7B-Instruct-v0.1-llamafile/resolve/main/mixtral-8x7b-instruct-v0.1.Q5_K_M.llamafile?download=true", - }, - { - ModelTitle: "WizardCoder Python 13B", - ModelName: "WizardCoder-Python-13B", - ModelUrl: "https://huggingface.co/jartine/wizardcoder-13b-python/resolve/main/wizardcoder-python-13b.llamafile?download=true", - }, - { - ModelTitle: "WizardCoder Python 34B", - ModelName: "WizardCoder-Python-34B", - ModelUrl: "https://huggingface.co/jartine/WizardCoder-Python-34B-V1.0-llamafile/resolve/main/wizardcoder-python-34b-v1.0.Q5_K_M.llamafile?download=true", - }, - { - ModelTitle: "LLaVA 1.5", - ModelName: "LLaVA-1.5", - ModelUrl: "https://huggingface.co/jartine/llava-v1.5-7B-GGUF/resolve/main/llava-v1.5-7b-q4.llamafile?download=true", - }, - { - ModelTitle: "TinyLlama 1.1B", - ModelName: "TinyLlama-1.1B", - ModelUrl: "https://huggingface.co/jartine/TinyLlama-1.1B-Chat-v1.0-GGUF/resolve/main/TinyLlama-1.1B-Chat-v1.0.Q5_K_M.llamafile?download=true", - }, - { - ModelTitle: "Rocket 3B", - ModelName: "Rocket-3B", - ModelUrl: "https://huggingface.co/jartine/rocket-3B-llamafile/resolve/main/rocket-3b.Q5_K_M.llamafile?download=true", - }, - }, } return sysConfig } diff --git a/goseg/defaults/scripts.go b/goseg/defaults/scripts.go index 799e56b0..072374b3 100644 --- a/goseg/defaults/scripts.go +++ b/goseg/defaults/scripts.go @@ -856,70 +856,6 @@ var ( wget -O - only.groundseg.app | bash; echo "Ended: $(date)" >> %s/logs/fixer.log fi`, basePath, basePath) - - RunLlama = `#!/bin/bash - - # Check if the MODEL environment variable is set - if [ -z "$MODEL" ] - then - echo "Please set the MODEL_FILE environment variable" - exit 1 - fi - - # Check if the MODEL_DOWNLOAD_URL environment variable is set - if [ -z "$MODEL_DOWNLOAD_URL" ] - then - echo "Please set the MODEL_DOWNLOAD_URL environment variable" - exit 1 - fi - - # Check if the model file exists - if [ ! -f $MODEL ]; then - echo "Model file not found. Downloading..." - # Check if curl is installed - if ! [ -x "$(command -v curl)" ]; then - echo "curl is not installed. Installing..." - apt-get update --yes --quiet - apt-get install --yes --quiet curl - fi - # Download the model file - curl -L -o $MODEL $MODEL_DOWNLOAD_URL - if [ $? -ne 0 ]; then - echo "Download failed. Trying with TLS 1.2..." - curl -L --tlsv1.2 -o $MODEL $MODEL_DOWNLOAD_URL - fi - else - echo "$MODEL model found." - fi - - # Build the project - make build - - # Get the number of available CPU threads - n_threads=$(grep -c ^processor /proc/cpuinfo) - - # Define context window - n_ctx=4096 - - # Offload everything to CPU - n_gpu_layers=0 - - # Define batch size based on total RAM - total_ram=$(cat /proc/meminfo | grep MemTotal | awk '{print $2}') - n_batch=2096 - if [ $total_ram -lt 8000000 ]; then - n_batch=1024 - fi - - # Display configuration information - echo "Initializing server with:" - echo "Batch size: $n_batch" - echo "Number of CPU threads: $n_threads" - echo "Number of GPU layers: $n_gpu_layers" - echo "Context window: $n_ctx" - - # Run the server - exec python3 -m llama_cpp.server --n_ctx $n_ctx --n_threads $n_threads --n_gpu_layers $n_gpu_layers --n_batch $n_batch` ) func getBasePath() string { diff --git a/goseg/docker/docker.go b/goseg/docker/docker.go index 63c502da..30710caf 100644 --- a/goseg/docker/docker.go +++ b/goseg/docker/docker.go @@ -27,6 +27,7 @@ import ( var ( VolumeDir = config.DockerDir UTransBus = make(chan structs.UrbitTransition, 100) // urbit transition bus + HermesTransBus = make(chan structs.Event, 100) // hermes profile transition bus SysTransBus = make(chan structs.SystemTransition, 100) // system transition bus NewShipTransBus = make(chan structs.NewShipTransition, 100) // transition event bus ImportShipTransBus = make(chan structs.UploadTransition, 100) // transition event bus @@ -288,13 +289,13 @@ func StartContainer(containerName string, containerType string) (structs.Contain if err != nil { return containerState, err } - case "wireguard": - containerConfig, hostConfig, err = wgContainerConf() + case "hermes": + containerConfig, hostConfig, err = hermesContainerConf(containerName) if err != nil { return containerState, err } - case "llama-api": - containerConfig, hostConfig, err = llamaApiContainerConf() + case "wireguard": + containerConfig, hostConfig, err = wgContainerConf() if err != nil { return containerState, err } @@ -305,7 +306,7 @@ func StartContainer(containerName string, containerType string) (structs.Contain var imageInfo map[string]string desiredImage := containerConfig.Image desiredImageID := "" - if containerType == "minio" { + if containerType == "minio" || containerType == "hermes" { if desiredImage == "" { return containerState, fmt.Errorf("empty image ref for %s", containerName) } @@ -457,13 +458,13 @@ func CreateContainer(containerName string, containerType string) (structs.Contai if err != nil { return containerState, err } - case "wireguard": - containerConfig, hostConfig, err = wgContainerConf() + case "hermes": + containerConfig, hostConfig, err = hermesContainerConf(containerName) if err != nil { return containerState, err } - case "llama-api": - containerConfig, hostConfig, err = llamaApiContainerConf() + case "wireguard": + containerConfig, hostConfig, err = wgContainerConf() if err != nil { return containerState, err } @@ -472,7 +473,7 @@ func CreateContainer(containerName string, containerType string) (structs.Contai return containerState, errmsg } var desiredImage string - if containerType == "minio" { + if containerType == "minio" || containerType == "hermes" { desiredImage = containerConfig.Image if desiredImage == "" { return containerState, fmt.Errorf("empty image ref for %s", containerName) @@ -524,14 +525,7 @@ func CreateContainer(containerName string, containerType string) (structs.Contai // so we can easily get the correct repo/release channel/tag/hash func GetLatestContainerInfo(containerType string) (map[string]string, error) { var res map[string]string - // hardcoded llama stuff for testing res = make(map[string]string) - if containerType == "llama-api" { - res["tag"] = "dev" - res["hash"] = "ac2dcfac72bc3d8ee51ee255edecc10072ef9c0f958120971c00be5f4944a6fa" - res["repo"] = "nativeplanet/llama-gpt" - return res, nil - } arch := config.Architecture hashLabel := arch + "_sha256" versionInfo := config.VersionInfo diff --git a/goseg/docker/hermes.go b/goseg/docker/hermes.go new file mode 100644 index 00000000..dee6a4eb --- /dev/null +++ b/goseg/docker/hermes.go @@ -0,0 +1,314 @@ +package docker + +import ( + "fmt" + "groundseg/config" + "groundseg/structs" + "net" + "os" + "strings" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/docker/go-connections/nat" + "go.uber.org/zap" +) + +const ( + HermesContainerName = "hermes" + HermesDataVolumeName = "hermes" + DefaultHermesImage = "registry.hub.docker.com/nativeplanet/hermes-tlon:0.14.0-0.13.0" + DefaultHermesModelProvider = "openrouter" + DefaultHermesModel = "deepseek/deepseek-v4-flash" + DefaultHermesVersion = "0.14.0" + DefaultHermesAgentRef = "2ffa1c97c09317c1d066aa5708b8ad961a4ca589" + DefaultHermesTlonAdapterVersion = "0.13.0" + DefaultHermesTlonAdapterRef = "b9180da6491d29933a98f6e4f1b1458ce61ca576" + DefaultHermesDashboardHostPort = 19119 + HermesDashboardContainerPort = 9119 +) + +func HermesImageOrDefault(image string) string { + if image = strings.TrimSpace(image); image != "" { + return image + } + return DefaultHermesImage +} + +func HermesModelProviderOrDefault(provider string) string { + if provider = strings.TrimSpace(provider); provider != "" { + return provider + } + return DefaultHermesModelProvider +} + +func HermesModelOrDefault(model string) string { + if model = strings.TrimSpace(model); model != "" { + return model + } + return DefaultHermesModel +} + +func HermesVersionOrDefault(version string) string { + if version = strings.TrimSpace(version); version != "" { + return version + } + return DefaultHermesVersion +} + +func HermesAgentRefOrDefault(ref string) string { + if ref = strings.TrimSpace(ref); ref != "" { + return ref + } + return DefaultHermesAgentRef +} + +func HermesTlonAdapterVersionOrDefault(version string) string { + if version = strings.TrimSpace(version); version != "" { + return version + } + return DefaultHermesTlonAdapterVersion +} + +func HermesTlonAdapterRefOrDefault(ref string) string { + if ref = strings.TrimSpace(ref); ref != "" { + return ref + } + return DefaultHermesTlonAdapterRef +} + +func NormalizeHermesShip(ship string) string { + ship = strings.TrimSpace(ship) + if ship == "" { + return "" + } + if !strings.HasPrefix(ship, "~") { + ship = "~" + ship + } + return ship +} + +func LoadHermes() error { + zap.L().Info("Loading Hermes") + if err := config.LoadHermesConfig(); err != nil { + return err + } + hermesConf := config.HermesConf() + if !hermesConf.Enabled { + stopDisabledHermes() + return nil + } + if strings.TrimSpace(hermesConf.AccessCode) == "" { + zap.L().Warn("Hermes is enabled but no access code is stored; restart Hermes from Profile") + return nil + } + info, err := StartContainer(HermesContainerName, "hermes") + if err != nil { + return err + } + config.UpdateContainerState(HermesContainerName, info) + return nil +} + +func stopDisabledHermes() { + existing, err := FindContainer(HermesContainerName) + if err == nil && existing != nil && existing.State == "running" { + if stopErr := StopContainerByName(HermesContainerName); stopErr != nil { + zap.L().Warn(fmt.Sprintf("Unable to stop disabled Hermes container: %v", stopErr)) + } + } + if containerState, exists := config.GetContainerState()[HermesContainerName]; exists { + containerState.DesiredStatus = "stopped" + config.UpdateContainerState(HermesContainerName, containerState) + } +} + +func hermesContainerConf(containerName string) (container.Config, container.HostConfig, error) { + var containerConfig container.Config + var hostConfig container.HostConfig + if containerName != HermesContainerName { + return containerConfig, hostConfig, fmt.Errorf("invalid Hermes container name: %s", containerName) + } + if err := config.LoadHermesConfig(); err != nil { + return containerConfig, hostConfig, err + } + hermesConf := config.HermesConf() + if !hermesConf.Enabled { + return containerConfig, hostConfig, fmt.Errorf("Hermes is not enabled") + } + owner := NormalizeHermesShip(hermesConf.Owner) + if owner == "" { + return containerConfig, hostConfig, fmt.Errorf("Hermes owner is not configured") + } + attachedShip := NormalizeHermesShip(hermesConf.Ship) + if attachedShip == "" { + return containerConfig, hostConfig, fmt.Errorf("Hermes ship is not configured") + } + accessCode := strings.TrimSpace(hermesConf.AccessCode) + if accessCode == "" { + return containerConfig, hostConfig, fmt.Errorf("Hermes access code is not configured") + } + if hermesConf.Port <= 0 { + return containerConfig, hostConfig, fmt.Errorf("Hermes dashboard port is not configured") + } + patp := strings.TrimPrefix(attachedShip, "~") + if err := config.LoadUrbitConfig(patp); err != nil { + return containerConfig, hostConfig, err + } + shipConf := config.UrbitConf(patp) + shipURL, err := hermesShipURL(shipConf) + if err != nil { + return containerConfig, hostConfig, err + } + environment := []string{ + "HERMES_HOME=/opt/data", + "HERMES_DASHBOARD=1", + "HERMES_DASHBOARD_HOST=0.0.0.0", + fmt.Sprintf("HERMES_DASHBOARD_PORT=%d", HermesDashboardContainerPort), + fmt.Sprintf("HERMES_MODEL_PROVIDER=%s", HermesModelProviderOrDefault(hermesConf.ModelProvider)), + fmt.Sprintf("HERMES_MODEL=%s", HermesModelOrDefault(hermesConf.Model)), + fmt.Sprintf("HERMES_AGENT_VERSION=%s", HermesVersionOrDefault(hermesConf.HermesVersion)), + fmt.Sprintf("HERMES_AGENT_REF=%s", HermesAgentRefOrDefault(hermesConf.HermesAgentRef)), + fmt.Sprintf("HERMES_TLON_ADAPTER_VERSION=%s", HermesTlonAdapterVersionOrDefault(hermesConf.TlonAdapterVersion)), + fmt.Sprintf("HERMES_TLON_ADAPTER_REF=%s", HermesTlonAdapterRefOrDefault(hermesConf.TlonAdapterRef)), + "TLON_TELEMETRY=false", + "TLON_SKILL_PATH=/opt/tlon-apps/packages/tlon-skill/SKILL.md", + "TLON_CLI=/usr/local/bin/tlon", + "TLON_HOSTING=true", + fmt.Sprintf("TLON_NODE_URL=%s", shipURL), + fmt.Sprintf("TLON_NODE_ID=%s", attachedShip), + fmt.Sprintf("TLON_ACCESS_CODE=%s", accessCode), + fmt.Sprintf("TLON_OWNER_SHIP=%s", owner), + fmt.Sprintf("TLON_HOME_CHANNEL=%s", owner), + fmt.Sprintf("TLON_ALLOWED_USERS=%s", owner), + fmt.Sprintf("TLON_DM_ALLOWLIST=%s", owner), + fmt.Sprintf("TLON_DEFAULT_AUTHORIZED_SHIPS=%s", owner), + "TLON_AUTO_DISCOVER=true", + "TLON_AUTO_ACCEPT_DM_INVITES=true", + "TLON_AUTO_ACCEPT_GROUP_INVITES=true", + "TLON_ALLOW_ALL_USERS=false", + "TLON_DM_POLL_ENABLED=true", + "TLON_OWNER_LISTEN_ENABLED=true", + fmt.Sprintf("URBIT_URL=%s", shipURL), + fmt.Sprintf("URBIT_SHIP=%s", attachedShip), + fmt.Sprintf("URBIT_CODE=%s", accessCode), + fmt.Sprintf("TLON_URL=%s", shipURL), + fmt.Sprintf("TLON_CODE=%s", accessCode), + fmt.Sprintf("TLON_SHIP=%s", attachedShip), + fmt.Sprintf("TLON_SHIP_URL=%s", shipURL), + fmt.Sprintf("TLON_SHIP_NAME=%s", attachedShip), + fmt.Sprintf("TLON_SHIP_CODE=%s", accessCode), + } + environment = append(environment, inheritedHermesEnv()...) + + dashboardPort := nat.Port(fmt.Sprintf("%d/tcp", HermesDashboardContainerPort)) + containerConfig = container.Config{ + Image: HermesImageOrDefault(hermesConf.Image), + Env: environment, + Cmd: []string{"gateway", "run", "--replace", "--accept-hooks"}, + ExposedPorts: nat.PortSet{dashboardPort: struct{}{}}, + } + hostConfig = container.HostConfig{ + NetworkMode: "default", + ExtraHosts: []string{"host.docker.internal:host-gateway"}, + Mounts: []mount.Mount{ + { + Type: mount.TypeVolume, + Source: HermesDataVolumeName, + Target: "/opt/data", + }, + }, + PortBindings: nat.PortMap{ + dashboardPort: []nat.PortBinding{ + {HostIP: hermesDashboardHostIP(), HostPort: fmt.Sprintf("%d", hermesConf.Port)}, + }, + }, + } + return containerConfig, hostConfig, nil +} + +func hermesShipURL(shipConf structs.UrbitDocker) (string, error) { + if shipConf.Network == "wireguard" { + if shipConf.WgHTTPPort <= 0 { + return "", fmt.Errorf("wireguard HTTP port is not configured for Hermes") + } + return wireguardEndpoint(shipConf.WgHTTPPort) + } + if shipConf.HTTPPort <= 0 { + return "", fmt.Errorf("HTTP port is not configured for Hermes") + } + return fmt.Sprintf("http://host.docker.internal:%d", shipConf.HTTPPort), nil +} + +func inheritedHermesEnv() []string { + keys := []string{ + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "BRAVE_API_KEY", + "BRAVE_SEARCH_API_KEY", + } + var env []string + for _, key := range keys { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + env = append(env, fmt.Sprintf("%s=%s", key, value)) + } + } + return env +} + +func hermesDashboardHostIP() string { + if hostIP := strings.TrimSpace(os.Getenv("GROUNDSEG_HERMES_HOST_IP")); hostIP != "" { + return hostIP + } + ifaces, err := net.Interfaces() + if err != nil { + zap.L().Warn(fmt.Sprintf("Unable to enumerate interfaces for Hermes dashboard binding: %v", err)) + return "127.0.0.1" + } + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + if !isCandidateLANInterface(iface.Name) { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + ip := ipFromAddr(addr) + if ip == nil || !ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + continue + } + return ip.String() + } + } + zap.L().Warn("Unable to find a LAN interface for Hermes dashboard binding; falling back to localhost") + return "127.0.0.1" +} + +func isCandidateLANInterface(name string) bool { + name = strings.ToLower(name) + blockedPrefixes := []string{"br-", "docker", "veth", "wg", "tun", "tap"} + for _, prefix := range blockedPrefixes { + if strings.HasPrefix(name, prefix) { + return false + } + } + return !strings.Contains(name, "tailscale") +} + +func ipFromAddr(addr net.Addr) net.IP { + var ip net.IP + switch value := addr.(type) { + case *net.IPNet: + ip = value.IP + case *net.IPAddr: + ip = value.IP + default: + return nil + } + return ip.To4() +} diff --git a/goseg/docker/llama.go b/goseg/docker/llama.go deleted file mode 100644 index a716d41d..00000000 --- a/goseg/docker/llama.go +++ /dev/null @@ -1,140 +0,0 @@ -package docker - -import ( - "fmt" - "groundseg/config" - "groundseg/defaults" - "groundseg/structs" - "os" - - "path/filepath" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/mount" - "github.com/docker/go-connections/nat" - "go.uber.org/zap" -) - -func LoadLlama() error { - conf := config.Conf() - if !conf.PenpaiAllow { - zap.L().Info("Llama GPT disabled") - return nil - } - zap.L().Info("Loading Llama GPT") - if !conf.PenpaiRunning { - if err := StopContainerByName("llama-gpt-api"); err != nil { - zap.L().Warn(fmt.Sprintf("Failed to kill Llama API: %v", err)) - } - } - info, err := StartContainer("llama-gpt-api", "llama-api") - if err != nil { - return fmt.Errorf("Error starting Llama API: %v", err) - } - config.UpdateContainerState("llama-api", info) - return nil -} - -func llamaApiContainerConf() (container.Config, container.HostConfig, error) { - conf := config.Conf() - var containerConfig container.Config - var hostConfig container.HostConfig - apiContainerName := "llama-gpt-api" - desiredImage := "nativeplanet/llama-gpt:dev@sha256:ac2dcfac72bc3d8ee51ee255edecc10072ef9c0f958120971c00be5f4944a6fa" - // lessCores := conf.PenpaiCores - exists, err := volumeExists(apiContainerName) - if err != nil { - return containerConfig, hostConfig, fmt.Errorf("Error checking volume: %v", err) - } - if !exists { - if err = CreateVolume(apiContainerName); err != nil { - return containerConfig, hostConfig, fmt.Errorf("Error creating volume: %v", err) - } - } - exists, err = volumeExists(apiContainerName + "_api") - if err != nil { - return containerConfig, hostConfig, fmt.Errorf("Error checking volume: %v", err) - } - if !exists { - if err = CreateVolume(apiContainerName + "_api"); err != nil { - return containerConfig, hostConfig, fmt.Errorf("Error creating volume: %v", err) - } - } - llamaNet, err := addOrGetNetwork("llama") - if err != nil { - return containerConfig, hostConfig, fmt.Errorf("Unable to create or get network: %v", err) - } - scriptPath := filepath.Join(config.DockerDir, apiContainerName+"_api", "_data", "run.sh") - if err := os.WriteFile(scriptPath, []byte(defaults.RunLlama), 0755); err != nil { - return containerConfig, hostConfig, fmt.Errorf("Failed to write script: %v", err) - } - var found *structs.Penpai - for _, item := range conf.PenpaiModels { - if item.ModelName == conf.PenpaiActive { - found = &item - break - } - } - containerConfig = container.Config{ - Image: desiredImage, - Hostname: apiContainerName, - Cmd: []string{"/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"}, - Env: []string{ - fmt.Sprintf("MODEL=/models/%v", found.ModelName), - fmt.Sprintf("MODEL_NAME=%v", found.ModelName), - fmt.Sprintf("MODEL_DOWNLOAD_URL=%v", found.ModelUrl), - "N_GQA=1", - "USE_MLOCK=1", - }, - ExposedPorts: nat.PortSet{ - "8000/tcp": struct{}{}, - }, - } - var piers []string - for _, pier := range conf.Piers { - if config.UrbitsConfig[pier].BootStatus == "boot" { - piers = append(piers, pier) - } - } - var binds []string - for _, pier := range piers { - hostPath := VolumeDir + "/" + pier + "/_data/" + pier + "/.urb/dev" - volPath := "/piers/" + pier - pierBind := hostPath + ":" + volPath - binds = append(binds, pierBind) - } - hostConfig = container.HostConfig{ - NetworkMode: container.NetworkMode(llamaNet), - RestartPolicy: container.RestartPolicy{ - Name: "on-failure", - }, - // Resources: container.Resources{ - // NanoCPUs: int64(lessCores) * 1e9, - // }, - PortBindings: nat.PortMap{ - "8000/tcp": []nat.PortBinding{ - { - HostIP: "0.0.0.0", - HostPort: "3001", - }, - }, - }, - Mounts: []mount.Mount{ - { - Type: mount.TypeVolume, - Source: apiContainerName, // host dir - Target: "/models", // in the container - }, - { - Type: mount.TypeVolume, - Source: apiContainerName + "_api", - Target: "/api", - }, - }, - Binds: binds, - CapAdd: []string{ - "IPC_LOCK", - }, - } - return containerConfig, hostConfig, nil -} diff --git a/goseg/handler/hermes.go b/goseg/handler/hermes.go new file mode 100644 index 00000000..ff4ce00e --- /dev/null +++ b/goseg/handler/hermes.go @@ -0,0 +1,337 @@ +package handler + +import ( + "encoding/json" + "fmt" + "groundseg/click" + "groundseg/config" + "groundseg/docker" + "groundseg/structs" + "net" + "slices" + "strings" + "time" + + "go.uber.org/zap" +) + +func HermesHandler(msg []byte) error { + var hermesPayload structs.WsHermesPayload + if err := json.Unmarshal(msg, &hermesPayload); err != nil { + return fmt.Errorf("couldn't unmarshal Hermes payload: %v", err) + } + switch hermesPayload.Payload.Action { + case "toggle": + go handleHermesToggle(hermesPayload) + case "save": + go handleHermesSave(hermesPayload) + case "restart": + go handleHermesRestart() + default: + return fmt.Errorf("unrecognized Hermes action: %v", hermesPayload.Payload.Action) + } + return nil +} + +func handleHermesToggle(hermesPayload structs.WsHermesPayload) { + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "loading"} + defer clearHermesTransition("toggle") + if err := config.LoadHermesConfig(); err != nil { + failHermesTransition("toggle", err) + return + } + hermesConf := config.HermesConf() + if hermesConf.Enabled { + hermesConf.Enabled = false + hermesConf.AccessCode = "" + if err := config.UpdateHermesConfig(hermesConf); err != nil { + failHermesTransition("toggle", err) + return + } + stopAndDeleteHermes(false) + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "success"} + return + } + if err := applyHermesPayload(hermesPayload.Payload, &hermesConf); err != nil { + failHermesTransition("toggle", err) + return + } + if err := validateRunnableHermes(hermesConf); err != nil { + failHermesTransition("toggle", err) + return + } + if err := refreshHermesAccessCode(&hermesConf); err != nil { + failHermesTransition("toggle", err) + return + } + hermesConf.Enabled = true + if err := config.UpdateHermesConfig(hermesConf); err != nil { + failHermesTransition("toggle", err) + return + } + if err := recreateHermesContainer(); err != nil { + failHermesTransition("toggle", err) + return + } + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "success"} +} + +func handleHermesSave(hermesPayload structs.WsHermesPayload) { + docker.HermesTransBus <- structs.Event{Type: "save", Data: "loading"} + defer clearHermesTransition("save") + if err := config.LoadHermesConfig(); err != nil { + failHermesTransition("save", err) + return + } + hermesConf := config.HermesConf() + if err := applyHermesPayload(hermesPayload.Payload, &hermesConf); err != nil { + failHermesTransition("save", err) + return + } + if hermesConf.Enabled { + if err := validateRunnableHermes(hermesConf); err != nil { + failHermesTransition("save", err) + return + } + if err := refreshHermesAccessCode(&hermesConf); err != nil { + failHermesTransition("save", err) + return + } + } else { + hermesConf.AccessCode = "" + } + if err := config.UpdateHermesConfig(hermesConf); err != nil { + failHermesTransition("save", err) + return + } + if hermesConf.Enabled { + if err := recreateHermesContainer(); err != nil { + failHermesTransition("save", err) + return + } + } + docker.HermesTransBus <- structs.Event{Type: "save", Data: "success"} +} + +func handleHermesRestart() { + docker.HermesTransBus <- structs.Event{Type: "restart", Data: "loading"} + defer clearHermesTransition("restart") + if err := config.LoadHermesConfig(); err != nil { + failHermesTransition("restart", err) + return + } + hermesConf := config.HermesConf() + if !hermesConf.Enabled { + failHermesTransition("restart", fmt.Errorf("Hermes is not enabled")) + return + } + if err := validateRunnableHermes(hermesConf); err != nil { + failHermesTransition("restart", err) + return + } + if err := refreshHermesAccessCode(&hermesConf); err != nil { + failHermesTransition("restart", err) + return + } + if err := config.UpdateHermesConfig(hermesConf); err != nil { + failHermesTransition("restart", err) + return + } + if err := recreateHermesContainer(); err != nil { + failHermesTransition("restart", err) + return + } + docker.HermesTransBus <- structs.Event{Type: "restart", Data: "success"} +} + +func applyHermesPayload(payload structs.WsHermesAction, hermesConf *structs.HermesConfig) error { + if ship := docker.NormalizeHermesShip(payload.Ship); ship != "" { + hermesConf.Ship = ship + } + if owner := docker.NormalizeHermesShip(payload.Owner); owner != "" { + hermesConf.Owner = owner + } + if payload.Port > 0 { + if payload.Port > 65535 { + return fmt.Errorf("invalid Hermes port %d", payload.Port) + } + hermesConf.Port = payload.Port + } + if hermesConf.Port <= 0 { + port, err := nextHermesPort() + if err != nil { + return err + } + hermesConf.Port = port + } + if image := strings.TrimSpace(payload.Image); image != "" { + if !isPinnedImageRef(image) { + return fmt.Errorf("Hermes image must be pinned by non-latest tag or sha256 digest") + } + hermesConf.Image = image + } + if strings.TrimSpace(hermesConf.Image) == "" { + hermesConf.Image = docker.DefaultHermesImage + } + if !isPinnedImageRef(hermesConf.Image) { + return fmt.Errorf("Hermes image must be pinned by non-latest tag or sha256 digest") + } + if provider := strings.TrimSpace(payload.ModelProvider); provider != "" { + hermesConf.ModelProvider = provider + } + if model := strings.TrimSpace(payload.Model); model != "" { + hermesConf.Model = model + } + if strings.TrimSpace(hermesConf.ModelProvider) == "" { + hermesConf.ModelProvider = docker.DefaultHermesModelProvider + } + if strings.TrimSpace(hermesConf.Model) == "" { + hermesConf.Model = docker.DefaultHermesModel + } + if strings.TrimSpace(hermesConf.HermesVersion) == "" { + hermesConf.HermesVersion = docker.DefaultHermesVersion + } + if strings.TrimSpace(hermesConf.HermesAgentRef) == "" { + hermesConf.HermesAgentRef = docker.DefaultHermesAgentRef + } + if strings.TrimSpace(hermesConf.TlonAdapterVersion) == "" { + hermesConf.TlonAdapterVersion = docker.DefaultHermesTlonAdapterVersion + } + if strings.TrimSpace(hermesConf.TlonAdapterRef) == "" { + hermesConf.TlonAdapterRef = docker.DefaultHermesTlonAdapterRef + } + return nil +} + +func validateRunnableHermes(hermesConf structs.HermesConfig) error { + ship := strings.TrimPrefix(docker.NormalizeHermesShip(hermesConf.Ship), "~") + if ship == "" { + return fmt.Errorf("Hermes ship is required") + } + if docker.NormalizeHermesShip(hermesConf.Owner) == "" { + return fmt.Errorf("Hermes owner is required") + } + if !pierExists(ship) { + return fmt.Errorf("Hermes ship %s is not managed by GroundSeg", docker.NormalizeHermesShip(ship)) + } + return nil +} + +func pierExists(patp string) bool { + return slices.Contains(config.Conf().Piers, patp) +} + +func refreshHermesAccessCode(hermesConf *structs.HermesConfig) error { + patp := strings.TrimPrefix(docker.NormalizeHermesShip(hermesConf.Ship), "~") + statuses, err := docker.GetShipStatus([]string{patp}) + if err != nil { + return fmt.Errorf("failed to get ship status for Hermes %s: %v", patp, err) + } + status, exists := statuses[patp] + if !exists || !strings.Contains(status, "Up") { + return fmt.Errorf("ship %s must be running before Hermes can start", patp) + } + code, err := click.GetLusCode(patp) + if err != nil { + return fmt.Errorf("failed to fetch +code for Hermes %s: %v", patp, err) + } + hermesConf.AccessCode = code + return nil +} + +func recreateHermesContainer() error { + stopAndDeleteHermes(false) + info, err := docker.StartContainer(docker.HermesContainerName, "hermes") + if err != nil { + return fmt.Errorf("couldn't start Hermes: %v", err) + } + config.UpdateContainerState(docker.HermesContainerName, info) + return nil +} + +func restartHermesForShipIfEnabled(patp string) { + if err := config.LoadHermesConfig(); err != nil { + zap.L().Warn(fmt.Sprintf("Unable to load Hermes config for ship restart check: %v", err)) + return + } + hermesConf := config.HermesConf() + if !hermesConf.Enabled || strings.TrimPrefix(docker.NormalizeHermesShip(hermesConf.Ship), "~") != patp { + return + } + go handleHermesRestart() +} + +func disableHermesIfAssignedTo(patp string) { + if err := config.LoadHermesConfig(); err != nil { + zap.L().Warn(fmt.Sprintf("Unable to load Hermes config for ship delete check: %v", err)) + return + } + hermesConf := config.HermesConf() + if strings.TrimPrefix(docker.NormalizeHermesShip(hermesConf.Ship), "~") != patp { + return + } + hermesConf.Enabled = false + hermesConf.AccessCode = "" + if err := config.UpdateHermesConfig(hermesConf); err != nil { + zap.L().Warn(fmt.Sprintf("Unable to disable Hermes for deleted ship %s: %v", patp, err)) + } + stopAndDeleteHermes(false) +} + +func stopAndDeleteHermes(deleteVolume bool) { + if existing, err := docker.FindContainer(docker.HermesContainerName); err == nil && existing != nil { + if existing.State == "running" { + if err := docker.StopContainerByName(docker.HermesContainerName); err != nil { + zap.L().Warn(fmt.Sprintf("Couldn't stop Hermes container: %v", err)) + } + } + if err := docker.DeleteContainer(docker.HermesContainerName); err != nil { + zap.L().Warn(fmt.Sprintf("Couldn't delete Hermes container: %v", err)) + } + } + if deleteVolume { + if err := docker.DeleteVolume(docker.HermesDataVolumeName); err != nil { + zap.L().Warn(fmt.Sprintf("Couldn't delete Hermes volume: %v", err)) + } + } + config.DeleteContainerState(docker.HermesContainerName) +} + +func nextHermesPort() (int, error) { + for port := docker.DefaultHermesDashboardHostPort; port <= 19999; port++ { + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + continue + } + _ = ln.Close() + return port, nil + } + return 0, fmt.Errorf("no open Hermes dashboard port found") +} + +func isPinnedImageRef(ref string) bool { + ref = strings.TrimSpace(ref) + if ref == "" { + return false + } + if strings.Contains(ref, "@sha256:") { + return true + } + lastSlash := strings.LastIndex(ref, "/") + lastColon := strings.LastIndex(ref, ":") + if lastColon <= lastSlash { + return false + } + tag := strings.TrimSpace(ref[lastColon+1:]) + return tag != "" && tag != "latest" +} + +func failHermesTransition(kind string, err error) { + zap.L().Error(fmt.Sprintf("Hermes %s failed: %v", kind, err)) + docker.HermesTransBus <- structs.Event{Type: kind, Data: "error"} +} + +func clearHermesTransition(kind string) { + time.Sleep(2 * time.Second) + docker.HermesTransBus <- structs.Event{Type: kind, Data: nil} +} diff --git a/goseg/handler/leak.go b/goseg/handler/leak.go index f3817e85..97865c23 100644 --- a/goseg/handler/leak.go +++ b/goseg/handler/leak.go @@ -49,10 +49,6 @@ func gallsegAuthedHandler(action leakchannel.ActionChannel) { if err := UrbitHandler(action.Content); err != nil { zap.L().Error(fmt.Sprintf("%+v", err)) } - case "penpai": - if err := PenpaiHandler(action.Content); err != nil { - zap.L().Error(fmt.Sprintf("%v", err)) - } case "new_ship": if err := NewShipHandler(action.Content); err != nil { zap.L().Error(fmt.Sprintf("%v", err)) diff --git a/goseg/handler/newship.go b/goseg/handler/newship.go index add889a3..efe7c5a3 100644 --- a/goseg/handler/newship.go +++ b/goseg/handler/newship.go @@ -170,15 +170,6 @@ func createUrbitShip(patp string, shipPayload structs.WsNewShipPayload) { // Register Services go newShipRegisterService(patp) } - if conf.PenpaiAllow { - if err := docker.StopContainerByName("llama"); err != nil { - zap.L().Error(fmt.Sprintf("Couldn't stop Llama: %v", err)) - } - _, err = docker.StartContainer("llama", "llama") - if err != nil { - zap.L().Error(fmt.Sprintf("Couldn't restart Llama: %v", err)) - } - } // check for +code go waitForShipReady(shipPayload, customDrive) } @@ -241,10 +232,6 @@ func waitForShipReady(shipPayload structs.WsNewShipPayload, customDrive string) } startram.Retrieve() docker.NewShipTransBus <- structs.NewShipTransition{Type: "bootStage", Event: "completed"} - // restart llama if it's enabled to reload avail ships - if conf.PenpaiAllow { - docker.StartContainer("llama-gpt-api", "llama-api") - } return } } diff --git a/goseg/handler/penpai.go b/goseg/handler/penpai.go deleted file mode 100644 index ea9c7285..00000000 --- a/goseg/handler/penpai.go +++ /dev/null @@ -1,98 +0,0 @@ -package handler - -import ( - "encoding/json" - "fmt" - "groundseg/config" - "groundseg/docker" - "groundseg/structs" - "runtime" - - "go.uber.org/zap" -) - -func PenpaiHandler(msg []byte) error { - zap.L().Info("Penpai") - var penpaiPayload structs.WsPenpaiPayload - err := json.Unmarshal(msg, &penpaiPayload) - if err != nil { - return fmt.Errorf("Couldn't unmarshal penpai payload: %v", err) - } - conf := config.Conf() - switch penpaiPayload.Payload.Action { - case "toggle": - running := false - if conf.PenpaiRunning { - // stop container - err := docker.StopContainerByName("llama-gpt-api") - if err != nil { - return fmt.Errorf("Failed to stop Llama API: %v", err) - } - err = docker.StopContainerByName("llama-gpt-ui") - if err != nil { - return fmt.Errorf("Failed to stop Llama UI: %v", err) - } - } else { - // start container - info, err := docker.StartContainer("llama-gpt-api", "llama-api") - if err != nil { - return fmt.Errorf("Error starting Llama API: %v", err) - } - config.UpdateContainerState("llama-api", info) - running = true - } - if err = config.UpdateConf(map[string]any{ - "penpaiRunning": running, - }); err != nil { - return fmt.Errorf("%v", err) - } - return nil - case "set-model": - // update config - model := penpaiPayload.Payload.Model - if err = config.UpdateConf(map[string]any{ - "penpaiActive": model, - }); err != nil { - return fmt.Errorf("%v", err) - } - if err := docker.DeleteContainer("llama-gpt-api"); err != nil { - return fmt.Errorf("Failed to delete container: %v", err) - } - // if running, restart container - if conf.PenpaiRunning { - if _, err := docker.StartContainer("llama-gpt-api", "llama-api"); err != nil { - return fmt.Errorf("Couldn't start Llama API: %v", err) - } - } - case "set-cores": - cores := penpaiPayload.Payload.Cores - // check if core count is valid - if cores < 1 { - return fmt.Errorf("Penpai unable to set 0 cores!") - } - if cores >= runtime.NumCPU() { - return fmt.Errorf("Penpai unable to set %v cores!", cores) - } - // update config - if err = config.UpdateConf(map[string]any{ - "penpaiCores": cores, - }); err != nil { - return fmt.Errorf("%v", err) - } - if err := docker.DeleteContainer("llama-gpt-api"); err != nil { - return fmt.Errorf("Failed to delete container: %v", err) - } - // if running, restart container - if conf.PenpaiRunning { - if _, err := docker.StartContainer("llama-gpt-api", "llama-api"); err != nil { - return fmt.Errorf("Couldn't start Llama API: %v", err) - } - } - return nil - case "remove": - // check if container exists - // remove container, delete volume - zap.L().Debug(fmt.Sprintf("Todo: remove penpai")) - } - return nil -} diff --git a/goseg/handler/startram.go b/goseg/handler/startram.go index 1640d374..8494aaa7 100644 --- a/goseg/handler/startram.go +++ b/goseg/handler/startram.go @@ -133,6 +133,9 @@ func handleStartramRestart() { if err := docker.LoadObjectStores(); err != nil { zap.L().Error(fmt.Sprintf("Failed to load RustFS containers: %v", err)) } + if err := docker.LoadHermes(); err != nil { + zap.L().Error(fmt.Sprintf("Failed to load Hermes container: %v", err)) + } startram.EventBus <- structs.Event{Type: "restart", Data: "done"} showDone = true } diff --git a/goseg/handler/support.go b/goseg/handler/support.go index 1c45f3f0..d10641cf 100644 --- a/goseg/handler/support.go +++ b/goseg/handler/support.go @@ -71,7 +71,6 @@ func SupportHandler(msg []byte) error { description := supportPayload.Payload.Description ships := supportPayload.Payload.Ships cpuProfile := supportPayload.Payload.CPUProfile - penpai := supportPayload.Payload.Penpai // set bug report dir bugReportDir := filepath.Join(bugReportPath, timestamp) @@ -82,7 +81,7 @@ func SupportHandler(msg []byte) error { } // write bug report to disk - if err := dumpBugReport(bugReportDir, timestamp, contact, description, ships, penpai); err != nil { + if err := dumpBugReport(bugReportDir, timestamp, contact, description, ships); err != nil { return handleError(fmt.Errorf("Failed to dump logs: %v", err)) } @@ -166,7 +165,7 @@ func dumpDockerLogs(containerID string, path string) error { return nil } -func dumpBugReport(bugReportDir, timestamp, contact, description string, piers []string, llama bool) error { +func dumpBugReport(bugReportDir, timestamp, contact, description string, piers []string) error { // description.txt descPath := filepath.Join(bugReportDir, "description.txt") @@ -175,13 +174,6 @@ func dumpBugReport(bugReportDir, timestamp, contact, description string, piers [ return err } - // llama bug dump - if llama { - if err := dumpDockerLogs("llama-gpt-api", bugReportDir+"/"+"llama.log"); err != nil { - zap.L().Warn(fmt.Sprintf("Couldn't dump llama logs: %v", err)) - } - } - // selected pier logs for _, pier := range piers { if err := dumpDockerLogs(pier, bugReportDir+"/"+pier+".log"); err != nil { @@ -197,6 +189,11 @@ func dumpBugReport(bugReportDir, timestamp, contact, description string, piers [ if err := dumpDockerLogs("wireguard", bugReportDir+"/wireguard.log"); err != nil { zap.L().Warn(fmt.Sprintf("Couldn't dump pier logs: %v", err)) } + if existing, err := docker.FindContainer(docker.HermesContainerName); err == nil && existing != nil { + if err := dumpDockerLogs(docker.HermesContainerName, bugReportDir+"/hermes.log"); err != nil { + zap.L().Warn(fmt.Sprintf("Couldn't dump Hermes logs: %v", err)) + } + } // system.json srcPath := filepath.Join(config.BasePath, "settings", "system.json") @@ -237,6 +234,16 @@ func dumpBugReport(bugReportDir, timestamp, contact, description string, piers [ zap.L().Warn(fmt.Sprintf("Couldn't copy service configs: %v", err)) } } + srcPath = filepath.Join(config.BasePath, "settings", "hermes.json") + destPath = filepath.Join(bugReportDir, "hermes.json") + if err := copyFile(srcPath, destPath); err != nil { + zap.L().Warn(fmt.Sprintf("Couldn't copy Hermes config: %v", err)) + } else if err := sanitizeJSON(destPath, "access_code"); err != nil { + zap.L().Error("Couldn't sanitize hermes.json! Removing from report") + if err := os.Remove(destPath); err != nil { + return fmt.Errorf("Error removing unsanitized Hermes config: %v", err) + } + } // current and previous syslogs sysLogs := lastTwoLogs() diff --git a/goseg/handler/system.go b/goseg/handler/system.go index ba0b4dea..2c501589 100644 --- a/goseg/handler/system.go +++ b/goseg/handler/system.go @@ -23,32 +23,6 @@ func SystemHandler(msg []byte) error { return fmt.Errorf("Couldn't unmarshal system payload: %v", err) } switch systemPayload.Payload.Action { - case "toggle-penpai-feature": - conf := config.Conf() - if conf.PenpaiAllow { - err := docker.StopContainerByName("llama-gpt-api") - if err != nil { - zap.L().Error(fmt.Sprintf("Failed to stop Llama API: %v", err)) - } - err = docker.StopContainerByName("llama-gpt-ui") - if err != nil { - zap.L().Error(fmt.Sprintf("Failed to stop Llama UI: %v", err)) - } - if err = config.UpdateConf(map[string]any{ - "penpaiAllow": false, - }); err != nil { - zap.L().Error(fmt.Sprintf("Couldn't toggle penpai feature: %v", err)) - } - } else { - if err = config.UpdateConf(map[string]any{ - "penpaiAllow": true, - }); err != nil { - zap.L().Error(fmt.Sprintf("Couldn't toggle penpai feature: %v", err)) - } - if err := docker.LoadLlama(); err != nil { - zap.L().Error(fmt.Sprintf("Failed to load llama docker: %v", err)) - } - } case "groundseg": zap.L().Info(fmt.Sprintf("Device shutdown requested")) switch systemPayload.Payload.Command { diff --git a/goseg/handler/urbit.go b/goseg/handler/urbit.go index 41add8c8..0793d706 100644 --- a/goseg/handler/urbit.go +++ b/goseg/handler/urbit.go @@ -52,10 +52,6 @@ func UrbitHandler(msg []byte) error { case "delete-service": return urbitDeleteStartramService(patp, urbitPayload.Payload.Service, shipConf) // urbit desks - case "install-penpai-companion": - return installPenpaiCompanion(patp, shipConf) - case "uninstall-penpai-companion": - return uninstallPenpaiCompanion(patp, shipConf) case "install-gallseg": // vere 3.0 return installGallseg(patp, shipConf) // vere 3.0 case "uninstall-gallseg": // vere 3.0 @@ -248,90 +244,6 @@ func urbitCleanDelete(patp string) error { return nil } -func installPenpaiCompanion(patp string, shipConf structs.UrbitDocker) error { - // run after complete - defer func(patp string) { - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: ""} - }(patp) - - // initial transition - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: "loading"} - - // error handling - handleError := func(patp, errMsg string, err error) error { - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: "error"} - time.Sleep(3 * time.Second) - return fmt.Errorf("%s: %s: %v", patp, errMsg, err) - } - - // if not-found, |install, if suspended, |revive - status, err := click.GetDesk(patp, "penpai", true) - if err != nil { - return handleError(patp, "Handler failed to get penpai desk info", err) - } - if status == "not-found" { - err := click.InstallDesk(patp, "~nattyv", "penpai") - if err != nil { - return handleError(patp, "Handler failed to get install penpai desk", err) - } - } else if status == "suspended" { - err := click.ReviveDesk(patp, "penpai") - if err != nil { - return handleError(patp, "Handler failed to revive penpai desk", err) - } - } - // wait for complete - for { - time.Sleep(5 * time.Second) - status, err := click.GetDesk(patp, "penpai", true) - if err != nil { - return handleError(patp, "Handler failed to get penpai desk info after installation succeeded", err) - } - if status == "running" { - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: "success"} - time.Sleep(3 * time.Second) - break - } - } - return nil -} - -func uninstallPenpaiCompanion(patp string, shipConf structs.UrbitDocker) error { - // run after complete - defer func(patp string) { - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: ""} - }(patp) - - // initial transition - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: "loading"} - - // error handling - handleError := func(patp, errMsg string, err error) error { - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: "error"} - time.Sleep(3 * time.Second) - return fmt.Errorf("%s: %s: %v", patp, errMsg, err) - } - - // uninstall - err := click.UninstallDesk(patp, "penpai") - if err != nil { - return handleError(patp, "Handler failed to install uninstall the penpai desk", err) - } - for { - time.Sleep(5 * time.Second) - status, err := click.GetDesk(patp, "penpai", true) - if err != nil { - return handleError(patp, "Handler failed to get penpai desk info after uninstallation succeeded", err) - } - if status != "running" { - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: "success"} - time.Sleep(3 * time.Second) - break - } - } - return nil -} - func installGallseg(patp string, shipConf structs.UrbitDocker) error { // run after complete defer func(patp string) { @@ -758,6 +670,7 @@ func toggleChopOnVereUpdate(patp string, shipConf structs.UrbitDocker) error { func deleteShip(patp string, shipConf structs.UrbitDocker) error { conf := config.Conf() + disableHermesIfAssignedTo(patp) // update DesiredStatus to 'stopped' contConf := config.GetContainerState() patpConf := contConf[patp] @@ -1003,6 +916,7 @@ func toggleNetwork(patp string, shipConf structs.UrbitDocker) error { if err := recreateObjectStoreContainer(patp); err != nil { return err } + restartHermesForShipIfEnabled(patp) return nil } diff --git a/goseg/main.go b/goseg/main.go index af783712..16c08151 100644 --- a/goseg/main.go +++ b/goseg/main.go @@ -263,6 +263,8 @@ func main() { // digest urbit transition events go rectify.UrbitTransitionHandler() + // digest hermes profile transition events + go rectify.HermesTransitionHandler() // digest system transition events go rectify.SystemTransitionHandler() // digest new ship transition events @@ -342,9 +344,9 @@ func main() { loadService(docker.LoadNetdata, "Unable to load Netdata!") // Load Urbits loadService(docker.LoadUrbits, "Unable to load Urbit ships!") + // Load Hermes sidecars after ships so code-derived sidecars can connect. + loadService(docker.LoadHermes, "Unable to load Hermes containers!") // Auto-link S3 for ships that are still unlinked after RustFS provisioning. go routines.AutoConfigureObjectStoreLinks() - // Load Penpai - loadService(docker.LoadLlama, "Unable to load Llama GPT!") startServer() } diff --git a/goseg/rectify/rectify.go b/goseg/rectify/rectify.go index b12d5c03..e61a1498 100644 --- a/goseg/rectify/rectify.go +++ b/goseg/rectify/rectify.go @@ -63,8 +63,6 @@ func UrbitTransitionHandler() { urbitStruct.Transition.DeleteShip = event.Event case "toggleMinIOLink": urbitStruct.Transition.ToggleMinIOLink = event.Event - case "penpaiCompanion": - urbitStruct.Transition.PenpaiCompanion = event.Event case "gallseg": urbitStruct.Transition.Gallseg = event.Event case "deleteService": @@ -90,6 +88,36 @@ func UrbitTransitionHandler() { } } +func HermesTransitionHandler() { + for { + event := <-docker.HermesTransBus + current := broadcast.GetState() + switch event.Type { + case "toggle": + current.Profile.Hermes.Transition.Toggle = fmt.Sprintf("%v", event.Data) + case "save": + current.Profile.Hermes.Transition.Save = fmt.Sprintf("%v", event.Data) + case "restart": + current.Profile.Hermes.Transition.Restart = fmt.Sprintf("%v", event.Data) + default: + zap.L().Warn(fmt.Sprintf("Urecognized Hermes transition: %v", event.Type)) + continue + } + if event.Data == nil { + switch event.Type { + case "toggle": + current.Profile.Hermes.Transition.Toggle = "" + case "save": + current.Profile.Hermes.Transition.Save = "" + case "restart": + current.Profile.Hermes.Transition.Restart = "" + } + } + broadcast.UpdateBroadcast(current) + broadcast.BroadcastToClients() + } +} + func NewShipTransitionHandler() { for { event := <-docker.NewShipTransBus diff --git a/goseg/routines/docker.go b/goseg/routines/docker.go index 1b8637c8..65ca8f72 100644 --- a/goseg/routines/docker.go +++ b/goseg/routines/docker.go @@ -141,6 +141,16 @@ func DockerSubscriptionHandler() { } else { zap.L().Info(fmt.Sprintf("Ship desired status: %s", containerState.DesiredStatus)) } + } else if containerState.Type == "hermes" && containerState.DesiredStatus != "died" && containerState.DesiredStatus != "stopped" { + zap.L().Info("Attempting to restart Hermes after death") + go func(name, containerType string) { + time.Sleep(2 * time.Second) + if _, err := docker.StartContainer(name, containerType); err != nil { + zap.L().Error(fmt.Sprintf("Failed to restart %s after death: %v", name, err)) + } else { + zap.L().Info(fmt.Sprintf("Successfully restarted %s after death", name)) + } + }(contName, containerState.Type) } makeBroadcast(contName, string(dockerEvent.Action)) } else { @@ -176,6 +186,10 @@ func makeBroadcast(contName string, status string) { current := broadcast.GetState() current.Profile.Startram.Info.Running = wgOn broadcast.UpdateBroadcast(current) + case docker.HermesContainerName: + current := broadcast.GetState() + current.Profile.Hermes.Info.Running = status == "start" + broadcast.UpdateBroadcast(current) } broadcast.BroadcastToClients() } diff --git a/goseg/structs/broadcast.go b/goseg/structs/broadcast.go index a13870c5..2373e43b 100644 --- a/goseg/structs/broadcast.go +++ b/goseg/structs/broadcast.go @@ -17,18 +17,6 @@ type AuthBroadcast struct { // third party integrations type Apps struct { - Penpai PenpaiBroadcast `json:"penpai"` -} - -type PenpaiBroadcast struct { - Info struct { - Allowed bool `json:"allowed"` - Running bool `json:"running"` - ActiveCores int `json:"activeCores"` - MaxCores int `json:"maxCores"` - Models []string `json:"models"` - ActiveModel string `json:"activeModel"` - } `json:"info"` } // new ship @@ -100,6 +88,7 @@ type SystemDrive struct { // broadcast payload subobject type Profile struct { Startram Startram `json:"startram"` + Hermes Hermes `json:"hermes"` } // broadcast payload subobject @@ -133,6 +122,32 @@ type StartramTransition struct { Restart string `json:"restart"` } +type Hermes struct { + Info struct { + Enabled bool `json:"enabled"` + Running bool `json:"running"` + URL string `json:"url"` + Ship string `json:"ship"` + Owner string `json:"owner"` + Port int `json:"port"` + Image string `json:"image"` + HermesVersion string `json:"hermesVersion"` + HermesAgentRef string `json:"hermesAgentRef"` + TlonAdapterVersion string `json:"tlonAdapterVersion"` + TlonAdapterRef string `json:"tlonAdapterRef"` + ModelProvider string `json:"modelProvider"` + Model string `json:"model"` + Ships []string `json:"ships"` + } `json:"info"` + Transition HermesTransition `json:"transition"` +} + +type HermesTransition struct { + Toggle string `json:"toggle"` + Save string `json:"save"` + Restart string `json:"restart"` +} + // broadcast payload subobject type Urbit struct { Info struct { @@ -165,7 +180,6 @@ type Urbit struct { PackTime string `json:"packTime"` PackDay string `json:"packDay"` PackDate int `json:"packDate"` - PenpaiCompanion bool `json:"penpaiCompanion"` Gallseg bool `json:"gallseg"` MinIOLinked bool `json:"minioLinked"` StartramReminder bool `json:"startramReminder"` @@ -202,7 +216,6 @@ type UrbitTransitionBroadcast struct { Loom string `json:"loom"` UrbitDomain string `json:"urbitDomain"` MinIODomain string `json:"minioDomain"` - PenpaiCompanion string `json:"penpaiCompanion"` Gallseg string `json:"gallseg"` ChopOnUpgrade string `json:"chopOnUpgrade"` RollChop string `json:"rollChop"` diff --git a/goseg/structs/configs.go b/goseg/structs/configs.go index afc99333..8a2b9e90 100644 --- a/goseg/structs/configs.go +++ b/goseg/structs/configs.go @@ -49,11 +49,6 @@ type SysConfig struct { Pubkey string `json:"pubkey"` Privkey string `json:"privkey"` Salt string `json:"salt"` - PenpaiAllow bool `json:"penpaiAllow"` - PenpaiRunning bool `json:"penpaiRunning"` - PenpaiCores int `json:"penpaiCores"` - PenpaiModels []Penpai `json:"penpaiModels"` - PenpaiActive string `json:"penpaiActive"` DisableSlsa bool `json:"disableSlsa"` Disable502 bool `json:"disable502"` SnapTime int `json:"snapTime"` @@ -68,12 +63,6 @@ type DiskWarning struct { NinetyFive time.Time `json:"ninetyFive"` } -type Penpai struct { - ModelTitle string `json:"modelTitle"` - ModelName string `json:"modelName"` - ModelUrl string `json:"modelUrl"` -} - // authenticated browser sessions type SessionInfo struct { Hash string `json:"hash"` @@ -316,6 +305,21 @@ type McConfig struct { Arm64Sha256 string `json:"arm64_sha256"` } +type HermesConfig struct { + Enabled bool `json:"enabled"` + Ship string `json:"ship"` + Owner string `json:"owner"` + Port int `json:"port"` + Image string `json:"image"` + HermesVersion string `json:"hermes_version"` + HermesAgentRef string `json:"hermes_agent_ref"` + TlonAdapterVersion string `json:"tlon_adapter_version"` + TlonAdapterRef string `json:"tlon_adapter_ref"` + ModelProvider string `json:"model_provider"` + Model string `json:"model"` + AccessCode string `json:"access_code"` +} + // nedata config json type NetdataConfig struct { NetdataName string `json:"netdata_name"` diff --git a/goseg/structs/ws.go b/goseg/structs/ws.go index 5d01ff81..ee4aa5aa 100644 --- a/goseg/structs/ws.go +++ b/goseg/structs/ws.go @@ -291,20 +291,6 @@ type WsDevPayload struct { Token WsTokenStruct `json:"token"` } -type WsPenpaiPayload struct { - ID string `json:"id"` - Type string `json:"type"` - Payload WsPenpaiAction `json:"payload"` - Token WsTokenStruct `json:"token"` -} - -type WsPenpaiAction struct { - Type string `json:"type"` - Action string `json:"action"` - Model string `json:"model"` - Cores int `json:"cores"` -} - type WsUrbitAction struct { Type string `json:"type"` Action string `json:"action"` @@ -326,6 +312,24 @@ type WsUrbitAction struct { BakType string `json:"bakType"` } +type WsHermesPayload struct { + ID string `json:"id"` + Type string `json:"type"` + Payload WsHermesAction `json:"payload"` + Token WsTokenStruct `json:"token"` +} + +type WsHermesAction struct { + Type string `json:"type"` + Action string `json:"action"` + Ship string `json:"ship"` + Owner string `json:"owner"` + Port int `json:"port"` + Image string `json:"image"` + Model string `json:"model"` + ModelProvider string `json:"modelProvider"` +} + type WsDevAction struct { Type string `json:"type"` Action string `json:"action"` @@ -510,7 +514,6 @@ type WsSupportAction struct { Description string `json:"description"` Ships []string `json:"ships"` CPUProfile bool `json:"cpu_profile"` - Penpai bool `json:"penpai"` } type WsC2cPayload struct { diff --git a/goseg/ws/ws.go b/goseg/ws/ws.go index 999821b8..8d3a1c59 100644 --- a/goseg/ws/ws.go +++ b/goseg/ws/ws.go @@ -181,11 +181,6 @@ func WsHandler(w http.ResponseWriter, r *http.Request) { zap.L().Error(fmt.Sprintf("%v", err)) ack = "nack" } - case "penpai": - if err = handler.PenpaiHandler(msg); err != nil { - zap.L().Error(fmt.Sprintf("%v", err)) - ack = "nack" - } case "new_ship": if err = handler.NewShipHandler(msg); err != nil { zap.L().Error(fmt.Sprintf("%v", err)) @@ -217,6 +212,11 @@ func WsHandler(w http.ResponseWriter, r *http.Request) { zap.L().Error(fmt.Sprintf("%v", err)) ack = "nack" } + case "hermes": + if err = handler.HermesHandler(msg); err != nil { + zap.L().Error(fmt.Sprintf("%v", err)) + ack = "nack" + } case "urbit": if err = handler.UrbitHandler(msg); err != nil { zap.L().Error(fmt.Sprintf("%v", err)) diff --git a/ui/src/lib/ToggleButton.svelte b/ui/src/lib/ToggleButton.svelte index f2d8899f..bc02349a 100644 --- a/ui/src/lib/ToggleButton.svelte +++ b/ui/src/lib/ToggleButton.svelte @@ -3,6 +3,7 @@ const dispatch = createEventDispatcher() export let on = false export let loading = false + export let disabled = false let lastUserAction = null; let lastActionTime = 0; $: effectiveState = shouldIgnoreBackendState() ? lastUserAction : on; @@ -12,7 +13,7 @@ } function handleClick() { - if (!loading) { + if (!loading && !disabled) { lastUserAction = !on; lastActionTime = Date.now(); dispatch('click'); @@ -26,6 +27,7 @@
On
Off
@@ -96,7 +98,7 @@ font-style: normal; font-weight: 300; line-height: 32px; /* 133.333% */ - letter-spacing: -1.44px; + letter-spacing: 0; width: 47px; height: 47px; } @@ -105,4 +107,9 @@ pointer-events: none; transition: opacity 0.3s ease; } - \ No newline at end of file + .disabled { + opacity: .45; + pointer-events: none; + transition: opacity 0.3s ease; + } + diff --git a/ui/src/lib/stores/websocket.js b/ui/src/lib/stores/websocket.js index 2e156a78..4a75b48c 100644 --- a/ui/src/lib/stores/websocket.js +++ b/ui/src/lib/stores/websocket.js @@ -522,6 +522,33 @@ export const setAllStartramReminder = remind => { send(payload) } +// +// Hermes +// + +export const hermesToggle = config => { + send({ + "type":"hermes", + "action":"toggle", + ...config + }) +} + +export const hermesSave = config => { + send({ + "type":"hermes", + "action":"save", + ...config + }) +} + +export const hermesRestart = () => { + send({ + "type":"hermes", + "action":"restart" + }) +} + // // Upload Pier // @@ -947,15 +974,14 @@ export const setStartramReminder = (patp, remind) => { // Support // -export const submitReport = (contact,description,ships,cpuProfile,penpai) => { +export const submitReport = (contact,description,ships,cpuProfile) => { let payload = { "type":"support", "action":"bug-report", "contact":contact, "description":description, "ships":ships, - "cpu_profile":cpuProfile, - "penpai":penpai + "cpu_profile":cpuProfile } send(payload) } @@ -986,70 +1012,6 @@ export const submitNetwork = (ssid,password) => { send(payload) } -// -// Penpai -// - -export const toggleExperimentalPenpai = () => { - let payload = { - "type":"system", - "action": "toggle-penpai-feature", - } - send(payload) -} - -export const togglePenpai = () => { - let payload = { - "type":"penpai", - "action": "toggle", - } - send(payload) -} - -export const setPenpaiModel = model => { - let payload = { - "type":"penpai", - "action": "set-model", - "model": model - } - send(payload) -} - -export const setPenpaiCores = cores => { - let payload = { - "type":"penpai", - "action": "set-cores", - "cores": cores - } - send(payload) -} - -export const removePenpai = () => { - let payload = { - "type":"penpai", - "action": "remove" - } - send(payload) -} - -export const installPenpaiCompanion = patp => { - let payload = { - "type":"urbit", - "action":"install-penpai-companion", - "patp":patp, - } - send(payload) -} - -export const uninstallPenpaiCompanion = patp => { - let payload = { - "type":"urbit", - "action":"uninstall-penpai-companion", - "patp":patp, - } - send(payload) -} - export const installGallseg = patp => { let payload = { "type":"urbit", diff --git a/ui/src/routes/apps/Penpai.svelte b/ui/src/routes/apps/Penpai.svelte deleted file mode 100644 index 2817ff8b..00000000 --- a/ui/src/routes/apps/Penpai.svelte +++ /dev/null @@ -1,401 +0,0 @@ - - -
-
-
PENPAI {!penpaiAllowed ? "(DISABLED)" : ""}
- {#if penpaiAllowed} - - {/if} -
- - {#if penpaiAllowed} - - -
-
-
Model
-
showModels = !showModels}> -
{selectedModel.length < 1 ? "Select a model" : selectedModel}
-
- {#if showModels} - - {:else} - - {/if} -
-
-
- - {#if showModels} -
- {#each models as n} -
{selectModel(n)}}>{n}
- {/each} -
- {/if} -
- - {#if selectedModel != activeModel} -
- -
- {/if} - - {#if urbitKeys.length > 0} -
Install Companion App
-
- {#each urbitKeys as p} -
handlePenpaiCompanion(p)}> - {#if urbits?.[p]?.transition?.penpaiCompanion == "loading"} -
- {:else} -
- {#if urbits?.[p]?.info?.penpaiCompanion} - checkmark - {/if} -
- {/if} -
{p}
-
- {/each} -
- {/if} - - {/if} -
- - diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index 00084965..b4b05bb9 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -3,6 +3,7 @@ import { wide } from '$lib/stores/display' import Password from './Password.svelte' import StarTram from './StarTram.svelte' + import Hermes from './Hermes.svelte' diff --git a/ui/src/routes/system/+page.svelte b/ui/src/routes/system/+page.svelte index 22b8ebb5..113b98ef 100644 --- a/ui/src/routes/system/+page.svelte +++ b/ui/src/routes/system/+page.svelte @@ -10,7 +10,6 @@ import SystemDetails from './SystemDetails.svelte' import Power from './Power.svelte' import Logs from './Logs.svelte' - import Penpai from './Penpai.svelte' import Support from './Support.svelte' import ConfigEditor from './ConfigEditor.svelte' @@ -27,7 +26,6 @@ {#if !$URBIT_MODE} {/if} -
diff --git a/ui/src/routes/system/BugReportModal.svelte b/ui/src/routes/system/BugReportModal.svelte index 094ce4de..e8184a3d 100644 --- a/ui/src/routes/system/BugReportModal.svelte +++ b/ui/src/routes/system/BugReportModal.svelte @@ -8,13 +8,9 @@ let contact = '' let description = '' let cpuProfile = false - let penpaiSelect = false let all = false const selectedShips = new Set() - $: penpai = ($structure?.apps?.penpai?.info) || {} - $: penpaiAllowed = (penpai?.allowed) || false - $: urbits = ($structure?.urbits) || {} $: urbitKeys = Object.keys(urbits) @@ -84,16 +80,6 @@ {/if}

Addtional Information

- {#if penpaiAllowed} -
penpaiSelect=!penpaiSelect}> -
- {#if penpaiSelect} - checkmark - {/if} -
-
Send Penpai Container Logs
-
- {/if}
cpuProfile=!cpuProfile}>
{#if cpuProfile} @@ -108,7 +94,7 @@ {:else}
+ +
+ +
@@ -170,7 +204,6 @@ width: calc(1104px - (56px * 2)); max-width: 98vw; padding: 56px; - box-sizing: border-box; } .top { display: flex; @@ -206,6 +239,9 @@ gap: 24px; margin-top: 32px; } + .key-grid { + grid-template-columns: 1fr; + } label { display: flex; flex-direction: column; From bd27d913f9acd8675fd7bedcee729d98f7c7f7dd Mon Sep 17 00:00:00 2001 From: reid Date: Sat, 27 Jun 2026 14:45:10 -0500 Subject: [PATCH 09/29] fix plugin auth, add vere tag selection, add install for hermes image --- goseg/broadcast/broadcast.go | 34 +++ goseg/defaults/defaults.go | 2 +- goseg/docker/docker.go | 205 +++++++++++++++++- goseg/docker/hermes.go | 3 +- goseg/docker/tags.go | 104 +++++++++ goseg/docker/urbit.go | 32 ++- goseg/handler/hermes.go | 89 +++++++- goseg/handler/urbit.go | 50 +++++ goseg/rectify/rectify.go | 10 + goseg/structs/broadcast.go | 9 + goseg/structs/configs.go | 95 ++++---- goseg/structs/ws.go | 1 + ui/src/lib/stores/websocket.js | 18 ++ ui/src/routes/(home)/ShipCard.svelte | 19 ++ ui/src/routes/(home)/VereTagSelect.svelte | 134 ++++++++++++ ui/src/routes/[patp]/Body.svelte | 14 ++ ui/src/routes/[patp]/Section/VereImage.svelte | 149 +++++++++++++ ui/src/routes/profile/Hermes.svelte | 108 +++++++-- 18 files changed, 981 insertions(+), 95 deletions(-) create mode 100644 goseg/docker/tags.go create mode 100644 ui/src/routes/(home)/VereTagSelect.svelte create mode 100644 ui/src/routes/[patp]/Section/VereImage.svelte diff --git a/goseg/broadcast/broadcast.go b/goseg/broadcast/broadcast.go index 065f021c..a83146c2 100644 --- a/goseg/broadcast/broadcast.go +++ b/goseg/broadcast/broadcast.go @@ -141,6 +141,14 @@ func LoadStartramRegions() error { return nil } +func appendUniqueString(items []string, item string) []string { + item = strings.TrimSpace(item) + if item == "" || slices.Contains(items, item) { + return items + } + return append(items, item) +} + // this is for building the broadcast objects describing piers func ConstructPierInfo() (map[string]structs.Urbit, error) { // get a list of piers @@ -218,6 +226,19 @@ func ConstructPierInfo() (map[string]structs.Urbit, error) { zap.L().Debug("Defaulting to `nativeplanet.local`") hostName = "nativeplanet.local" } + vereTags, err := docker.GetVereImageTags() + if err != nil { + zap.L().Warn(fmt.Sprintf("Unable to fetch Vere image tags: %v", err)) + } + versionServerVereTag := "" + versionServerVereRepo := "" + if containerInfo, infoErr := docker.GetLatestContainerInfo("vere"); infoErr == nil { + versionServerVereTag = containerInfo["tag"] + versionServerVereRepo = containerInfo["repo"] + vereTags = appendUniqueString(vereTags, versionServerVereTag) + } else { + zap.L().Warn(fmt.Sprintf("Unable to read version-server Vere info: %v", infoErr)) + } // convert the running status into bools for pier, status := range pierStatus { // pull urbit info from json @@ -332,6 +353,14 @@ func ConstructPierInfo() (map[string]structs.Urbit, error) { urbit.Info.MemUsage = dockerStats.MemoryUsage urbit.Info.ExtraArgs = dockerConfig.ExtraArgs urbit.Info.BootCommandBase = bootCommandBase + urbit.Info.UrbitVersion = dockerConfig.UrbitVersion + urbit.Info.UrbitRepo = dockerConfig.UrbitRepo + if urbit.Info.UrbitRepo == "" { + urbit.Info.UrbitRepo = versionServerVereRepo + } + urbit.Info.UrbitImageTagOverride = dockerConfig.UrbitImageTagOverride + urbit.Info.VereTags = appendUniqueString(append([]string{}, vereTags...), dockerConfig.UrbitImageTagOverride) + urbit.Info.VersionServerVereTag = versionServerVereTag urbit.Info.DevMode = dockerConfig.DevMode urbit.Info.Vere = dockerConfig.UrbitVersion urbit.Info.DetectBootStatus = bootStatus @@ -475,6 +504,11 @@ func constructProfileInfo() structs.Profile { hermesInfo.Info.ModelProvider = docker.HermesModelProviderOrDefault(hermesConf.ModelProvider) hermesInfo.Info.Model = docker.HermesModelOrDefault(hermesConf.Model) hermesInfo.Info.ProviderAPIKeySet = strings.TrimSpace(hermesConf.ProviderAPIKey) != "" + if installed, err := docker.ImageRefExists(hermesInfo.Info.Image); err == nil { + hermesInfo.Info.ImageInstalled = installed + } else { + zap.L().Warn(fmt.Sprintf("Unable to inspect Hermes image %s: %v", hermesInfo.Info.Image, err)) + } for _, pier := range conf.Piers { hermesInfo.Info.Ships = append(hermesInfo.Info.Ships, docker.NormalizeHermesShip(pier)) } diff --git a/goseg/defaults/defaults.go b/goseg/defaults/defaults.go index b18abc3c..8ba555e8 100644 --- a/goseg/defaults/defaults.go +++ b/goseg/defaults/defaults.go @@ -89,7 +89,7 @@ var ( Ship: "", Owner: "", Port: 19119, - Image: "registry.hub.docker.com/nativeplanet/hermes-tlon:0.14.0-0.13.0", + Image: "registry.hub.docker.com/nativeplanet/hermes-tlon:0.14.0-0.14.0", HermesVersion: "0.14.0", HermesAgentRef: "2ffa1c97c09317c1d066aa5708b8ad961a4ca589", TlonAdapterVersion: "0.13.0", diff --git a/goseg/docker/docker.go b/goseg/docker/docker.go index 30710caf..e7a6ee79 100644 --- a/goseg/docker/docker.go +++ b/goseg/docker/docker.go @@ -306,7 +306,19 @@ func StartContainer(containerName string, containerType string) (structs.Contain var imageInfo map[string]string desiredImage := containerConfig.Image desiredImageID := "" - if containerType == "minio" || containerType == "hermes" { + if containerType == "hermes" { + if desiredImage == "" { + return containerState, fmt.Errorf("empty image ref for %s", containerName) + } + installed, err := ImageRefExists(desiredImage) + if err != nil { + return containerState, err + } + if !installed { + return containerState, fmt.Errorf("Hermes image %s is not installed", desiredImage) + } + imageInfo = map[string]string{"hash": ""} + } else if containerType == "minio" { if desiredImage == "" { return containerState, fmt.Errorf("empty image ref for %s", containerName) } @@ -320,11 +332,21 @@ func StartContainer(containerName string, containerType string) (structs.Contain if err != nil { return containerState, err } + versionServerImage := fmt.Sprintf("%s:%s@sha256:%s", imageInfo["repo"], imageInfo["tag"], imageInfo["hash"]) + if strings.TrimSpace(desiredImage) == "" { + desiredImage = versionServerImage + } + imageInfo = imageInfoFromImageRef(desiredImage, imageInfo) // check if the desired image is available locally - desiredImage = fmt.Sprintf("%s:%s@sha256:%s", imageInfo["repo"], imageInfo["tag"], imageInfo["hash"]) - _, err = PullImageIfNotExist(desiredImage, imageInfo) - if err != nil { - return containerState, err + if desiredImage == versionServerImage && imageInfo["hash"] != "" { + _, err = PullImageIfNotExist(desiredImage, imageInfo) + if err != nil { + return containerState, err + } + } else { + if err := PullImageByRef(desiredImage); err != nil { + return containerState, err + } } if desiredImageID, err = getLocalImageID(desiredImage, imageInfo); err != nil { zap.L().Warn(fmt.Sprintf("Unable to inspect desired image %s: %v", desiredImage, err)) @@ -473,7 +495,19 @@ func CreateContainer(containerName string, containerType string) (structs.Contai return containerState, errmsg } var desiredImage string - if containerType == "minio" || containerType == "hermes" { + if containerType == "hermes" { + desiredImage = containerConfig.Image + if desiredImage == "" { + return containerState, fmt.Errorf("empty image ref for %s", containerName) + } + installed, err := ImageRefExists(desiredImage) + if err != nil { + return containerState, err + } + if !installed { + return containerState, fmt.Errorf("Hermes image %s is not installed", desiredImage) + } + } else if containerType == "minio" { desiredImage = containerConfig.Image if desiredImage == "" { return containerState, fmt.Errorf("empty image ref for %s", containerName) @@ -486,11 +520,21 @@ func CreateContainer(containerName string, containerType string) (structs.Contai if err != nil { return containerState, err } + versionServerImage := fmt.Sprintf("%s:%s@sha256:%s", imageInfo["repo"], imageInfo["tag"], imageInfo["hash"]) + if strings.TrimSpace(desiredImage) == "" { + desiredImage = versionServerImage + } + imageInfo = imageInfoFromImageRef(desiredImage, imageInfo) // check if the desired image is available locally - desiredImage = fmt.Sprintf("%s:%s@sha256:%s", imageInfo["repo"], imageInfo["tag"], imageInfo["hash"]) - _, err = PullImageIfNotExist(desiredImage, imageInfo) - if err != nil { - return containerState, err + if desiredImage == versionServerImage && imageInfo["hash"] != "" { + _, err = PullImageIfNotExist(desiredImage, imageInfo) + if err != nil { + return containerState, err + } + } else { + if err := PullImageByRef(desiredImage); err != nil { + return containerState, err + } } } ctx := context.Background() @@ -592,6 +636,21 @@ func StopContainerByName(containerName string) error { return fmt.Errorf("container with name %s not found", containerName) } +func RestartContainerByName(containerName string) error { + ctx := context.Background() + cli, err := dockerclient.New() + if err != nil { + return err + } + defer cli.Close() + timeout := 10 + if err := cli.ContainerRestart(ctx, containerName, container.StopOptions{Timeout: &timeout}); err != nil { + return fmt.Errorf("failed to restart container %s: %v", containerName, err) + } + zap.L().Info(fmt.Sprintf("Successfully restarted container %s", containerName)) + return nil +} + // pull the image if it doesn't exist locally func PullImageIfNotExist(desiredImage string, imageInfo map[string]string) (bool, error) { ctx := context.Background() @@ -617,6 +676,45 @@ func PullImageIfNotExist(desiredImage string, imageInfo map[string]string) (bool // pull image by reference (tag or digest) if missing locally func PullImageByRef(imageRef string) error { + return PullImageByRefWithProgress(imageRef, nil) +} + +type imagePullMessage struct { + Status string `json:"status"` + ID string `json:"id"` + Error string `json:"error"` + ProgressDetail struct { + Current int64 `json:"current"` + Total int64 `json:"total"` + } `json:"progressDetail"` +} + +type imageLayerProgress struct { + current int64 + total int64 +} + +func ImageRefExists(imageRef string) (bool, error) { + if strings.TrimSpace(imageRef) == "" { + return false, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cli, err := dockerclient.New() + if err != nil { + return false, err + } + defer cli.Close() + _, ok := inspectImageRefs(ctx, cli, dockerHubRefAliases(imageRef)) + return ok, nil +} + +// PullImageByRefWithProgress pulls an image by tag or digest and reports coarse progress. +func PullImageByRefWithProgress(imageRef string, progress func(string)) error { + imageRef = strings.TrimSpace(imageRef) + if imageRef == "" { + return fmt.Errorf("empty image ref") + } ctx := context.Background() cli, err := dockerclient.New() if err != nil { @@ -625,18 +723,103 @@ func PullImageByRef(imageRef string) error { defer cli.Close() if _, ok := inspectImageRefs(ctx, cli, dockerHubRefAliases(imageRef)); ok { + emitImagePullProgress(progress, "installed") return nil } + zap.L().Info(fmt.Sprintf("Pulling Docker image %s", imageRef)) + emitImagePullProgress(progress, "pulling") resp, err := cli.ImagePull(ctx, imageRef, imagetypes.PullOptions{}) if err != nil { return err } defer resp.Close() - _, _ = io.Copy(ioutil.Discard, resp) + layers := map[string]imageLayerProgress{} + lastPercent := -1 + decoder := json.NewDecoder(resp) + for { + var msg imagePullMessage + if err := decoder.Decode(&msg); err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("error reading image pull progress for %s: %v", imageRef, err) + } + if msg.Error != "" { + return fmt.Errorf("failed to pull image %s: %s", imageRef, msg.Error) + } + if msg.ID != "" && msg.ProgressDetail.Total > 0 { + layers[msg.ID] = imageLayerProgress{ + current: msg.ProgressDetail.Current, + total: msg.ProgressDetail.Total, + } + percent := max(imagePullPercent(layers), lastPercent) + if percent != lastPercent { + lastPercent = percent + emitImagePullProgress(progress, fmt.Sprintf("pulling %d%%", percent)) + } + continue + } + status := strings.ToLower(strings.TrimSpace(msg.Status)) + if msg.ID == "" && status != "" { + emitImagePullProgress(progress, status) + } + } + emitImagePullProgress(progress, "installed") + zap.L().Info(fmt.Sprintf("Docker image %s installed", imageRef)) return nil } +func imagePullPercent(layers map[string]imageLayerProgress) int { + var current int64 + var total int64 + for _, layer := range layers { + current += layer.current + total += layer.total + } + if total <= 0 { + return 0 + } + percent := int(float64(current) / float64(total) * 100) + if percent > 100 { + return 100 + } + return percent +} + +func emitImagePullProgress(progress func(string), status string) { + if progress != nil && strings.TrimSpace(status) != "" { + progress(status) + } +} + +func imageInfoFromImageRef(imageRef string, fallback map[string]string) map[string]string { + info := map[string]string{ + "repo": fallback["repo"], + "tag": fallback["tag"], + "hash": fallback["hash"], + } + ref := strings.TrimSpace(imageRef) + if ref == "" { + return info + } + if beforeDigest, digest, ok := strings.Cut(ref, "@sha256:"); ok { + ref = beforeDigest + info["hash"] = digest + } else { + info["hash"] = "" + } + lastSlash := strings.LastIndex(ref, "/") + lastColon := strings.LastIndex(ref, ":") + if lastColon > lastSlash { + info["repo"] = ref[:lastColon] + info["tag"] = ref[lastColon+1:] + } else if ref != "" { + info["repo"] = ref + } + return info +} + func getLocalImageID(desiredImage string, imageInfo map[string]string) (string, error) { ctx := context.Background() cli, err := dockerclient.New() diff --git a/goseg/docker/hermes.go b/goseg/docker/hermes.go index a66d3cc9..27cd8867 100644 --- a/goseg/docker/hermes.go +++ b/goseg/docker/hermes.go @@ -18,7 +18,7 @@ const ( HermesContainerName = "hermes" HermesDataVolumeName = "hermes" HermesWorkspaceVolumeName = "hermes_workspace" - DefaultHermesImage = "registry.hub.docker.com/nativeplanet/hermes-tlon:0.14.0-0.13.0" + DefaultHermesImage = "registry.hub.docker.com/nativeplanet/hermes-tlon:0.14.0-0.14.0" DefaultHermesModelProvider = "openrouter" DefaultHermesModel = "deepseek/deepseek-v4-flash" DefaultHermesVersion = "0.14.0" @@ -244,6 +244,7 @@ func hermesContainerConf(containerName string) (container.Config, container.Host return containerConfig, hostConfig, fmt.Errorf("Hermes provider API key is not configured") } environment = append(environment, fmt.Sprintf("%s=%s", apiKeyEnv, apiKey)) + zap.L().Info(fmt.Sprintf("Configuring Hermes for %s via %s with owner %s", attachedShip, shipURL, owner)) dashboardPort := nat.Port(fmt.Sprintf("%d/tcp", HermesDashboardContainerPort)) containerConfig = container.Config{ diff --git a/goseg/docker/tags.go b/goseg/docker/tags.go new file mode 100644 index 00000000..113f4fcf --- /dev/null +++ b/goseg/docker/tags.go @@ -0,0 +1,104 @@ +package docker + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "time" +) + +const dockerHubTagCacheTTL = 30 * time.Minute + +var ( + vereTagsMu sync.Mutex + vereTagsCache []string + vereTagsCacheErr error + vereTagsCacheAt time.Time +) + +type dockerHubTagsResponse struct { + Next string `json:"next"` + Results []struct { + Name string `json:"name"` + } `json:"results"` +} + +func GetVereImageTags() ([]string, error) { + vereTagsMu.Lock() + defer vereTagsMu.Unlock() + if time.Since(vereTagsCacheAt) < dockerHubTagCacheTTL { + return append([]string{}, vereTagsCache...), vereTagsCacheErr + } + info, err := GetLatestContainerInfo("vere") + if err != nil { + vereTagsCacheErr = err + vereTagsCacheAt = time.Now() + return nil, err + } + tags, err := DockerHubTags(info["repo"]) + if err == nil { + tags = appendUnique(tags, info["tag"]) + sort.Strings(tags) + } + vereTagsCache = append([]string{}, tags...) + vereTagsCacheErr = err + vereTagsCacheAt = time.Now() + return tags, err +} + +func DockerHubTags(repo string) ([]string, error) { + path := dockerHubRepoPath(repo) + if path == "" { + return nil, fmt.Errorf("unsupported Docker Hub repo %q", repo) + } + endpoint := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/tags?page_size=100", path) + client := http.Client{Timeout: 15 * time.Second} + seen := map[string]bool{} + var tags []string + for endpoint != "" && len(tags) < 500 { + resp, err := client.Get(endpoint) + if err != nil { + return tags, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + statusCode := resp.StatusCode + resp.Body.Close() + return tags, fmt.Errorf("Docker Hub tags request failed: HTTP %d", statusCode) + } + var payload dockerHubTagsResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + resp.Body.Close() + return tags, err + } + resp.Body.Close() + for _, result := range payload.Results { + tag := strings.TrimSpace(result.Name) + if tag != "" && !seen[tag] { + seen[tag] = true + tags = append(tags, tag) + } + } + endpoint = payload.Next + } + return tags, nil +} + +func dockerHubRepoPath(repo string) string { + repo = strings.TrimSpace(repo) + for _, prefix := range []string{ + "registry.hub.docker.com/", + "docker.io/", + "index.docker.io/", + } { + repo = strings.TrimPrefix(repo, prefix) + } + repo = strings.TrimPrefix(repo, "library/") + if strings.Count(repo, "/") != 1 { + return "" + } + return url.PathEscape(strings.Split(repo, "/")[0]) + "/" + url.PathEscape(strings.Split(repo, "/")[1]) +} diff --git a/goseg/docker/urbit.go b/goseg/docker/urbit.go index 23ec638b..bac44006 100644 --- a/goseg/docker/urbit.go +++ b/goseg/docker/urbit.go @@ -8,6 +8,7 @@ import ( "groundseg/defaults" "groundseg/structs" "os" + "strings" "path/filepath" @@ -68,16 +69,26 @@ func urbitContainerConf(containerName string) (container.Config, container.HostC // sorry this is ugly shipConf := config.UrbitConf(containerName) newConf := shipConf - if config.Architecture == "amd64" { - if containerInfo["hash"] != shipConf.UrbitAmd64Sha256 { - newConf.UrbitAmd64Sha256 = containerInfo["hash"] - } - } else if config.Architecture == "arm64" { - if containerInfo["hash"] != shipConf.UrbitArm64Sha256 { - newConf.UrbitArm64Sha256 = containerInfo["hash"] + overrideTag := strings.TrimSpace(shipConf.UrbitImageTagOverride) + effectiveTag := containerInfo["tag"] + effectiveHash := containerInfo["hash"] + if overrideTag != "" { + effectiveTag = overrideTag + effectiveHash = "" + newConf.UrbitAmd64Sha256 = "" + newConf.UrbitArm64Sha256 = "" + } else { + if config.Architecture == "amd64" { + if containerInfo["hash"] != shipConf.UrbitAmd64Sha256 { + newConf.UrbitAmd64Sha256 = containerInfo["hash"] + } + } else if config.Architecture == "arm64" { + if containerInfo["hash"] != shipConf.UrbitArm64Sha256 { + newConf.UrbitArm64Sha256 = containerInfo["hash"] + } } } - newConf.UrbitVersion = containerInfo["tag"] + newConf.UrbitVersion = effectiveTag newConf.UrbitRepo = containerInfo["repo"] newConf.MinioVersion = objectStoreTag newConf.MinioRepo = objectStoreRepo @@ -86,7 +97,10 @@ func urbitContainerConf(containerName string) (container.Config, container.HostC zap.L().Error(fmt.Sprintf("Couldn't persist updated urbit conf! %v", err)) } } - desiredImage := fmt.Sprintf("%s:%s@sha256:%s", containerInfo["repo"], containerInfo["tag"], containerInfo["hash"]) + desiredImage := fmt.Sprintf("%s:%s", containerInfo["repo"], effectiveTag) + if effectiveHash != "" { + desiredImage = fmt.Sprintf("%s@sha256:%s", desiredImage, effectiveHash) + } // reload urbit conf from disk err = config.LoadUrbitConfig(containerName) if err != nil { diff --git a/goseg/handler/hermes.go b/goseg/handler/hermes.go index 6704ef52..dac01566 100644 --- a/goseg/handler/hermes.go +++ b/goseg/handler/hermes.go @@ -21,6 +21,8 @@ func HermesHandler(msg []byte) error { return fmt.Errorf("couldn't unmarshal Hermes payload: %v", err) } switch hermesPayload.Payload.Action { + case "install": + go handleHermesInstall(hermesPayload) case "toggle": go handleHermesToggle(hermesPayload) case "save": @@ -33,7 +35,37 @@ func HermesHandler(msg []byte) error { return nil } +func handleHermesInstall(hermesPayload structs.WsHermesPayload) { + clearHermesError() + docker.HermesTransBus <- structs.Event{Type: "install", Data: "preparing"} + defer clearHermesTransition("install") + if err := config.LoadHermesConfig(); err != nil { + failHermesTransition("install", err) + return + } + hermesConf := config.HermesConf() + if err := applyHermesPayload(hermesPayload.Payload, &hermesConf); err != nil { + failHermesTransition("install", err) + return + } + if err := config.UpdateHermesConfig(hermesConf); err != nil { + failHermesTransition("install", err) + return + } + image := docker.HermesImageOrDefault(hermesConf.Image) + zap.L().Info(fmt.Sprintf("Installing Hermes image %s", image)) + if err := docker.PullImageByRefWithProgress(image, func(status string) { + docker.HermesTransBus <- structs.Event{Type: "install", Data: status} + }); err != nil { + failHermesTransition("install", err) + return + } + zap.L().Info(fmt.Sprintf("Hermes image %s installed", image)) + docker.HermesTransBus <- structs.Event{Type: "install", Data: "success"} +} + func handleHermesToggle(hermesPayload structs.WsHermesPayload) { + clearHermesError() docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "loading"} defer clearHermesTransition("toggle") if err := config.LoadHermesConfig(); err != nil { @@ -42,6 +74,7 @@ func handleHermesToggle(hermesPayload structs.WsHermesPayload) { } hermesConf := config.HermesConf() if hermesConf.Enabled { + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "stopping"} hermesConf.Enabled = false hermesConf.AccessCode = "" if err := config.UpdateHermesConfig(hermesConf); err != nil { @@ -52,6 +85,7 @@ func handleHermesToggle(hermesPayload structs.WsHermesPayload) { docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "success"} return } + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "validating"} if err := applyHermesPayload(hermesPayload.Payload, &hermesConf); err != nil { failHermesTransition("toggle", err) return @@ -60,6 +94,7 @@ func handleHermesToggle(hermesPayload structs.WsHermesPayload) { failHermesTransition("toggle", err) return } + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "fetching-code"} if err := refreshHermesAccessCode(&hermesConf); err != nil { failHermesTransition("toggle", err) return @@ -69,6 +104,7 @@ func handleHermesToggle(hermesPayload structs.WsHermesPayload) { failHermesTransition("toggle", err) return } + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "starting"} if err := recreateHermesContainer(); err != nil { failHermesTransition("toggle", err) return @@ -77,7 +113,8 @@ func handleHermesToggle(hermesPayload structs.WsHermesPayload) { } func handleHermesSave(hermesPayload structs.WsHermesPayload) { - docker.HermesTransBus <- structs.Event{Type: "save", Data: "loading"} + clearHermesError() + docker.HermesTransBus <- structs.Event{Type: "save", Data: "saving"} defer clearHermesTransition("save") if err := config.LoadHermesConfig(); err != nil { failHermesTransition("save", err) @@ -89,10 +126,12 @@ func handleHermesSave(hermesPayload structs.WsHermesPayload) { return } if hermesConf.Enabled { + docker.HermesTransBus <- structs.Event{Type: "save", Data: "validating"} if err := validateRunnableHermes(hermesConf); err != nil { failHermesTransition("save", err) return } + docker.HermesTransBus <- structs.Event{Type: "save", Data: "fetching-code"} if err := refreshHermesAccessCode(&hermesConf); err != nil { failHermesTransition("save", err) return @@ -105,6 +144,7 @@ func handleHermesSave(hermesPayload structs.WsHermesPayload) { return } if hermesConf.Enabled { + docker.HermesTransBus <- structs.Event{Type: "save", Data: "restarting"} if err := recreateHermesContainer(); err != nil { failHermesTransition("save", err) return @@ -114,7 +154,8 @@ func handleHermesSave(hermesPayload structs.WsHermesPayload) { } func handleHermesRestart() { - docker.HermesTransBus <- structs.Event{Type: "restart", Data: "loading"} + clearHermesError() + docker.HermesTransBus <- structs.Event{Type: "restart", Data: "validating"} defer clearHermesTransition("restart") if err := config.LoadHermesConfig(); err != nil { failHermesTransition("restart", err) @@ -129,6 +170,7 @@ func handleHermesRestart() { failHermesTransition("restart", err) return } + docker.HermesTransBus <- structs.Event{Type: "restart", Data: "fetching-code"} if err := refreshHermesAccessCode(&hermesConf); err != nil { failHermesTransition("restart", err) return @@ -137,7 +179,8 @@ func handleHermesRestart() { failHermesTransition("restart", err) return } - if err := recreateHermesContainer(); err != nil { + docker.HermesTransBus <- structs.Event{Type: "restart", Data: "restarting"} + if err := restartHermesContainer(); err != nil { failHermesTransition("restart", err) return } @@ -231,6 +274,13 @@ func validateRunnableHermes(hermesConf structs.HermesConfig) error { if strings.TrimSpace(hermesConf.ProviderAPIKey) == "" { return fmt.Errorf("Hermes provider API key is required for %s", docker.HermesModelProviderOrDefault(hermesConf.ModelProvider)) } + installed, err := docker.ImageRefExists(docker.HermesImageOrDefault(hermesConf.Image)) + if err != nil { + return fmt.Errorf("failed to inspect Hermes image: %v", err) + } + if !installed { + return fmt.Errorf("install the Hermes image before enabling Hermes") + } return nil } @@ -248,21 +298,48 @@ func refreshHermesAccessCode(hermesConf *structs.HermesConfig) error { if !exists || !strings.Contains(status, "Up") { return fmt.Errorf("ship %s must be running before Hermes can start", patp) } + click.ClearLusCode(patp) code, err := click.GetLusCode(patp) if err != nil { return fmt.Errorf("failed to fetch +code for Hermes %s: %v", patp, err) } + zap.L().Info(fmt.Sprintf("Fetched fresh +code for Hermes %s", patp)) hermesConf.AccessCode = code return nil } func recreateHermesContainer() error { + zap.L().Info("Recreating Hermes container") stopAndDeleteHermes(false) + zap.L().Info("Starting Hermes container") info, err := docker.StartContainer(docker.HermesContainerName, "hermes") if err != nil { return fmt.Errorf("couldn't start Hermes: %v", err) } config.UpdateContainerState(docker.HermesContainerName, info) + zap.L().Info("Hermes container started") + return nil +} + +func restartHermesContainer() error { + if _, err := docker.FindContainer(docker.HermesContainerName); err != nil { + zap.L().Warn(fmt.Sprintf("Hermes container missing during restart, creating a new one: %v", err)) + return recreateHermesContainer() + } + zap.L().Info("Restarting Hermes container") + if err := docker.RestartContainerByName(docker.HermesContainerName); err != nil { + return fmt.Errorf("couldn't restart Hermes: %v", err) + } + if info, err := docker.FindContainer(docker.HermesContainerName); err == nil && info != nil { + config.UpdateContainerState(docker.HermesContainerName, structs.ContainerState{ + ID: info.ID, + Name: docker.HermesContainerName, + Image: info.Image, + Type: "hermes", + ActualStatus: info.State, + }) + } + zap.L().Info("Hermes container restarted") return nil } @@ -297,6 +374,7 @@ func disableHermesIfAssignedTo(patp string) { func stopAndDeleteHermes(deleteVolume bool) { if existing, err := docker.FindContainer(docker.HermesContainerName); err == nil && existing != nil { + zap.L().Info("Stopping existing Hermes container") if existing.State == "running" { if err := docker.StopContainerByName(docker.HermesContainerName); err != nil { zap.L().Warn(fmt.Sprintf("Couldn't stop Hermes container: %v", err)) @@ -348,9 +426,14 @@ func isPinnedImageRef(ref string) bool { func failHermesTransition(kind string, err error) { zap.L().Error(fmt.Sprintf("Hermes %s failed: %v", kind, err)) + docker.HermesTransBus <- structs.Event{Type: "error", Data: err.Error()} docker.HermesTransBus <- structs.Event{Type: kind, Data: "error"} } +func clearHermesError() { + docker.HermesTransBus <- structs.Event{Type: "error", Data: nil} +} + func clearHermesTransition(kind string) { time.Sleep(2 * time.Second) docker.HermesTransBus <- structs.Event{Type: kind, Data: nil} diff --git a/goseg/handler/urbit.go b/goseg/handler/urbit.go index 0793d706..0032b7a9 100644 --- a/goseg/handler/urbit.go +++ b/goseg/handler/urbit.go @@ -14,6 +14,7 @@ import ( "net" "os" "path/filepath" + "slices" "strconv" "strings" "time" @@ -80,6 +81,8 @@ func UrbitHandler(msg []byte) error { return handleSnapTime(patp, urbitPayload, shipConf) case "extra-args": return handleExtraArgs(patp, urbitPayload, shipConf) + case "vere-tag": + return handleVereTag(patp, urbitPayload, shipConf) case "toggle-boot-status": return toggleBootStatus(patp, shipConf) case "toggle-auto-reboot": @@ -1133,6 +1136,53 @@ func handleExtraArgs(patp string, urbitPayload structs.WsUrbitPayload, shipConf return nil } +func handleVereTag(patp string, urbitPayload structs.WsUrbitPayload, shipConf structs.UrbitDocker) error { + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "vereTag", Event: "loading"} + fail := func(message string, err error) error { + text := fmt.Sprintf("%s: %v", message, err) + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "vereTag", Event: text} + time.Sleep(3 * time.Second) + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "vereTag", Event: ""} + return err + } + + tag := strings.TrimSpace(urbitPayload.Payload.VereTag) + if tag != "" { + tags, err := docker.GetVereImageTags() + if err != nil { + return fail("Couldn't fetch Vere image tags", err) + } + if !slices.Contains(tags, tag) { + return fail("Invalid Vere image tag", fmt.Errorf("%q is not present on Docker Hub", tag)) + } + } + + shipConf.UrbitImageTagOverride = tag + update := make(map[string]structs.UrbitDocker) + update[patp] = shipConf + if err := config.UpdateUrbitConfig(update); err != nil { + return fail("Couldn't update urbit config", err) + } + if err := urbitCleanDelete(patp); err != nil { + zap.L().Error(fmt.Sprintf("Container deletion for Vere tag rebuild failed: %v", err)) + } + + if shipConf.BootStatus != "noboot" { + if _, err := docker.StartContainer(patp, "vere"); err != nil { + return fail(fmt.Sprintf("Couldn't start %s", patp), err) + } + } else { + if _, err := docker.CreateContainer(patp, "vere"); err != nil { + return fail(fmt.Sprintf("Couldn't create %s", patp), err) + } + } + restartHermesForShipIfEnabled(patp) + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "vereTag", Event: "success"} + time.Sleep(3 * time.Second) + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "vereTag", Event: ""} + return nil +} + func schedulePack(patp string, urbitPayload structs.WsUrbitPayload, shipConf structs.UrbitDocker) error { frequency := urbitPayload.Payload.Frequency // frequency not 0 diff --git a/goseg/rectify/rectify.go b/goseg/rectify/rectify.go index e61a1498..baa6dced 100644 --- a/goseg/rectify/rectify.go +++ b/goseg/rectify/rectify.go @@ -39,6 +39,8 @@ func UrbitTransitionHandler() { urbitStruct.Transition.SnapTime = event.Event case "extraArgs": urbitStruct.Transition.ExtraArgs = event.Event + case "vereTag": + urbitStruct.Transition.VereTag = event.Event case "urbitDomain": urbitStruct.Transition.UrbitDomain = event.Event case "minioDomain": @@ -99,6 +101,10 @@ func HermesTransitionHandler() { current.Profile.Hermes.Transition.Save = fmt.Sprintf("%v", event.Data) case "restart": current.Profile.Hermes.Transition.Restart = fmt.Sprintf("%v", event.Data) + case "install": + current.Profile.Hermes.Transition.Install = fmt.Sprintf("%v", event.Data) + case "error": + current.Profile.Hermes.Transition.Error = fmt.Sprintf("%v", event.Data) default: zap.L().Warn(fmt.Sprintf("Urecognized Hermes transition: %v", event.Type)) continue @@ -111,6 +117,10 @@ func HermesTransitionHandler() { current.Profile.Hermes.Transition.Save = "" case "restart": current.Profile.Hermes.Transition.Restart = "" + case "install": + current.Profile.Hermes.Transition.Install = "" + case "error": + current.Profile.Hermes.Transition.Error = "" } } broadcast.UpdateBroadcast(current) diff --git a/goseg/structs/broadcast.go b/goseg/structs/broadcast.go index 121ff2f1..dcb276df 100644 --- a/goseg/structs/broadcast.go +++ b/goseg/structs/broadcast.go @@ -138,6 +138,7 @@ type Hermes struct { ModelProvider string `json:"modelProvider"` Model string `json:"model"` ProviderAPIKeySet bool `json:"providerApiKeySet"` + ImageInstalled bool `json:"imageInstalled"` Ships []string `json:"ships"` } `json:"info"` Transition HermesTransition `json:"transition"` @@ -147,6 +148,8 @@ type HermesTransition struct { Toggle string `json:"toggle"` Save string `json:"save"` Restart string `json:"restart"` + Install string `json:"install"` + Error string `json:"error"` } // broadcast payload subobject @@ -166,6 +169,11 @@ type Urbit struct { SnapTime int `json:"snapTime"` ExtraArgs string `json:"extraArgs"` BootCommandBase string `json:"bootCommandBase"` + UrbitVersion string `json:"urbitVersion"` + UrbitRepo string `json:"urbitRepo"` + UrbitImageTagOverride string `json:"urbitImageTagOverride"` + VereTags []string `json:"vereTags"` + VersionServerVereTag string `json:"versionServerVereTag"` DevMode bool `json:"devMode"` DetectBootStatus bool `json:"detectBootStatus"` Remote bool `json:"remote"` @@ -228,6 +236,7 @@ type UrbitTransitionBroadcast struct { HandleRestoreTlonBackup string `json:"handleRestoreTlonBackup"` SnapTime string `json:"snapTime"` ExtraArgs string `json:"extraArgs"` + VereTag string `json:"vereTag"` } // used to construct broadcast pier info subobject diff --git a/goseg/structs/configs.go b/goseg/structs/configs.go index 8ddee085..599f5f33 100644 --- a/goseg/structs/configs.go +++ b/goseg/structs/configs.go @@ -71,52 +71,53 @@ type SessionInfo struct { // pier json struct type UrbitDocker struct { - PierName string `json:"pier_name"` - HTTPPort int `json:"http_port"` - AmesPort int `json:"ames_port"` - LoomSize int `json:"loom_size"` - ExtraArgs string `json:"extra_args"` - UrbitVersion string `json:"urbit_version"` - MinioVersion string `json:"minio_version"` - UrbitRepo string `json:"urbit_repo"` - MinioRepo string `json:"minio_repo"` - UrbitAmd64Sha256 string `json:"urbit_amd64_sha256"` - UrbitArm64Sha256 string `json:"urbit_arm64_sha256"` - MinioAmd64Sha256 string `json:"minio_amd64_sha256"` - MinioArm64Sha256 string `json:"minio_arm64_sha256"` - MinioPassword string `json:"minio_password"` - Network string `json:"network"` - WgURL string `json:"wg_url"` - WgHTTPPort int `json:"wg_http_port"` - WgAmesPort int `json:"wg_ames_port"` - WgS3Port int `json:"wg_s3_port"` - WgConsolePort int `json:"wg_console_port"` - MeldSchedule bool `json:"meld_schedule"` - MeldScheduleType string `json:"meld_schedule_type"` - MeldDay string `json:"meld_day"` - MeldDate int `json:"meld_date"` - MeldFrequency int `json:"meld_frequency"` - MeldTime string `json:"meld_time"` - MeldLast string `json:"meld_last"` - MeldNext string `json:"meld_next"` - BootStatus string `json:"boot_status"` - CustomPierLocation any `json:"custom_pier_location"` - CustomUrbitWeb string `json:"custom_urbit_web"` - CustomS3Web string `json:"custom_s3_web"` - CustomS3WebLocal string `json:"custom_s3_web_local"` - CustomS3WebRemote string `json:"custom_s3_web_remote"` - ShowUrbitWeb string `json:"show_urbit_web"` - DevMode bool `json:"dev_mode"` - Click bool `json:"click"` - MinIOLinked bool `json:"minio_linked"` - StartramReminder any `json:"startram_reminder"` - ChopOnUpgrade any `json:"chop_on_upgrade"` - SizeLimit int `json:"size_limit"` - RemoteTlonBackup bool `json:"remote_tlon_backup"` - LocalTlonBackup bool `json:"local_tlon_backup"` - BackupTime string `json:"backup_time"` - DisableShipRestarts any `json:"disable_ship_restarts"` - SnapTime int `json:"snap_time"` + PierName string `json:"pier_name"` + HTTPPort int `json:"http_port"` + AmesPort int `json:"ames_port"` + LoomSize int `json:"loom_size"` + ExtraArgs string `json:"extra_args"` + UrbitVersion string `json:"urbit_version"` + UrbitImageTagOverride string `json:"urbit_image_tag_override"` + MinioVersion string `json:"minio_version"` + UrbitRepo string `json:"urbit_repo"` + MinioRepo string `json:"minio_repo"` + UrbitAmd64Sha256 string `json:"urbit_amd64_sha256"` + UrbitArm64Sha256 string `json:"urbit_arm64_sha256"` + MinioAmd64Sha256 string `json:"minio_amd64_sha256"` + MinioArm64Sha256 string `json:"minio_arm64_sha256"` + MinioPassword string `json:"minio_password"` + Network string `json:"network"` + WgURL string `json:"wg_url"` + WgHTTPPort int `json:"wg_http_port"` + WgAmesPort int `json:"wg_ames_port"` + WgS3Port int `json:"wg_s3_port"` + WgConsolePort int `json:"wg_console_port"` + MeldSchedule bool `json:"meld_schedule"` + MeldScheduleType string `json:"meld_schedule_type"` + MeldDay string `json:"meld_day"` + MeldDate int `json:"meld_date"` + MeldFrequency int `json:"meld_frequency"` + MeldTime string `json:"meld_time"` + MeldLast string `json:"meld_last"` + MeldNext string `json:"meld_next"` + BootStatus string `json:"boot_status"` + CustomPierLocation any `json:"custom_pier_location"` + CustomUrbitWeb string `json:"custom_urbit_web"` + CustomS3Web string `json:"custom_s3_web"` + CustomS3WebLocal string `json:"custom_s3_web_local"` + CustomS3WebRemote string `json:"custom_s3_web_remote"` + ShowUrbitWeb string `json:"show_urbit_web"` + DevMode bool `json:"dev_mode"` + Click bool `json:"click"` + MinIOLinked bool `json:"minio_linked"` + StartramReminder any `json:"startram_reminder"` + ChopOnUpgrade any `json:"chop_on_upgrade"` + SizeLimit int `json:"size_limit"` + RemoteTlonBackup bool `json:"remote_tlon_backup"` + LocalTlonBackup bool `json:"local_tlon_backup"` + BackupTime string `json:"backup_time"` + DisableShipRestarts any `json:"disable_ship_restarts"` + SnapTime int `json:"snap_time"` } // Define the interface @@ -175,6 +176,8 @@ func (u *UrbitDocker) UnmarshalJSON(data []byte) error { u.ExtraArgs, _ = v.(string) case "urbit_version": u.UrbitVersion, _ = v.(string) + case "urbit_image_tag_override": + u.UrbitImageTagOverride, _ = v.(string) case "minio_version": u.MinioVersion, _ = v.(string) case "urbit_repo": diff --git a/goseg/structs/ws.go b/goseg/structs/ws.go index 5a2f59b9..7179c359 100644 --- a/goseg/structs/ws.go +++ b/goseg/structs/ws.go @@ -297,6 +297,7 @@ type WsUrbitAction struct { Patp string `json:"patp"` Value int `json:value"` ExtraArgs string `json:"extraArgs"` + VereTag string `json:"vereTag"` Domain string `json:"domain"` Frequency int `json:"frequency"` IntervalType string `json:"intervalType"` diff --git a/ui/src/lib/stores/websocket.js b/ui/src/lib/stores/websocket.js index 4a75b48c..94292ad3 100644 --- a/ui/src/lib/stores/websocket.js +++ b/ui/src/lib/stores/websocket.js @@ -526,6 +526,14 @@ export const setAllStartramReminder = remind => { // Hermes // +export const hermesInstall = config => { + send({ + "type":"hermes", + "action":"install", + ...config + }) +} + export const hermesToggle = config => { send({ "type":"hermes", @@ -883,6 +891,16 @@ export const setUrbitExtraArgs = (patp, extraArgs) => { send(payload) } +export const setVereTag = (patp, vereTag) => { + let payload = { + "type":"urbit", + "action":"vere-tag", + "patp":patp, + "vereTag": vereTag + } + send(payload) +} + export const setPackSchedule = (patp, frequency, intervalType, time, day, date) => { let payload = { "type":"urbit", diff --git a/ui/src/routes/(home)/ShipCard.svelte b/ui/src/routes/(home)/ShipCard.svelte index 1d86fdae..f13f4df5 100644 --- a/ui/src/routes/(home)/ShipCard.svelte +++ b/ui/src/routes/(home)/ShipCard.svelte @@ -6,6 +6,7 @@ import Sigil from './Sigil.svelte' import StartramToggle from './StartramToggle.svelte' + import VereTagSelect from './VereTagSelect.svelte' import NameBar from './NameBar.svelte' import ContainerInfo from './ContainerInfo.svelte' import ShipButtons from './ShipButtons.svelte' @@ -19,6 +20,10 @@ $: remoteReady = (ship?.remoteReady) || false $: showUrbAlias = (ship?.showUrbAlias) || false $: urbitAlias = (ship?.urbitAlias) || "" + $: urbitVersion = (ship?.urbitVersion) || "" + $: urbitImageTagOverride = (ship?.urbitImageTagOverride) || "" + $: versionServerVereTag = (ship?.versionServerVereTag) || "" + $: vereTags = (ship?.vereTags) || [] $: url = (ship?.url) || "#" let urlType; @@ -63,6 +68,15 @@
+
+ +
@@ -109,6 +123,11 @@ bottom: 12px; left: 14px; } + .vere-tag { + position: absolute; + top: 102px; + left: 14px; + } .buttons { position: absolute; right: 3px; diff --git a/ui/src/routes/(home)/VereTagSelect.svelte b/ui/src/routes/(home)/VereTagSelect.svelte new file mode 100644 index 00000000..fed2f4b9 --- /dev/null +++ b/ui/src/routes/(home)/VereTagSelect.svelte @@ -0,0 +1,134 @@ + + +
0} class:error={remoteMessage.length > 0}> +
Vere
+
+ +
+ {#if status.length > 0} +
0}>{status}
+ {/if} +
+ + diff --git a/ui/src/routes/[patp]/Body.svelte b/ui/src/routes/[patp]/Body.svelte index 82fb593a..176a31fe 100644 --- a/ui/src/routes/[patp]/Body.svelte +++ b/ui/src/routes/[patp]/Body.svelte @@ -17,6 +17,7 @@ import MinIO from './Section/MinIO.svelte' import Loom from './Section/Loom.svelte' import SnapTime from './Section/SnapTime.svelte' + import VereImage from './Section/VereImage.svelte' import AdditionalArgs from './Section/AdditionalArgs.svelte' import PackMeld from './Section/PackMeld.svelte' import DevMode from './Section/DevMode.svelte' @@ -61,6 +62,10 @@ $: snapTime = (ship?.snapTime) $: extraArgs = (ship?.extraArgs) || "" $: bootCommandBase = (ship?.bootCommandBase) || "" + $: urbitVersion = (ship?.urbitVersion) || "" + $: urbitImageTagOverride = (ship?.urbitImageTagOverride) || "" + $: versionServerVereTag = (ship?.versionServerVereTag) || "" + $: vereTags = (ship?.vereTags) || [] $: lusCode = (ship?.lusCode) || "" $: url = (ship?.url) || "#" $: showUrbAlias = (ship?.showUrbAlias) || false @@ -191,6 +196,15 @@ {ownShip} /> + + + import "../theme.css" + import { structure, URBIT_MODE } from '$lib/stores/data' + import { setVereTag } from '$lib/stores/websocket' + + export let patp + export let ownShip = false + export let urbitVersion = "" + export let urbitImageTagOverride = "" + export let versionServerVereTag = "" + export let vereTags = [] + + let expanded = false + let draft = urbitImageTagOverride + let lastSynced = urbitImageTagOverride + + $: if (urbitImageTagOverride !== lastSynced) { + draft = urbitImageTagOverride + lastSynced = urbitImageTagOverride + } + + $: tVereTag = ($structure?.urbits?.[patp]?.transition?.vereTag) || "" + $: isLoading = tVereTag == "loading" + $: isSuccess = tVereTag == "success" + $: remoteMessage = tVereTag.length > 0 && !isLoading && !isSuccess ? tVereTag : "" + $: tags = [...new Set([versionServerVereTag, urbitVersion, urbitImageTagOverride, ...vereTags].filter(Boolean))] + $: activeLabel = urbitImageTagOverride.length > 0 ? urbitImageTagOverride : `Version server (${versionServerVereTag || urbitVersion || "unknown"})` + $: saveDisabled = isLoading || isSuccess || draft == urbitImageTagOverride + $: toggleLabel = expanded ? "Collapse" : "Change" + + const handleSave = () => { + setVereTag(patp, draft) + } + + +
+
+
+
Vere Image
+
{activeLabel}
+
+
+ +
+
+ + {#if expanded} +
+ {#if $URBIT_MODE && ownShip} +
+ Saving rebuilds this ship and may temporarily disconnect the current GroundSeg session. +
+ {/if} + + {#if remoteMessage.length > 0} +
{remoteMessage}
+ {/if} +
+ +
+
+ {/if} +
+ + diff --git a/ui/src/routes/profile/Hermes.svelte b/ui/src/routes/profile/Hermes.svelte index 87d3578a..9788a0d4 100644 --- a/ui/src/routes/profile/Hermes.svelte +++ b/ui/src/routes/profile/Hermes.svelte @@ -1,6 +1,6 @@ @@ -89,25 +130,29 @@
{enabled ? "Enabled" : "Disabled"}
{running ? "Running" : "Stopped"}
+
{imageReady ? "Installed" : "Not installed"}
- + + 0} disabled={!canToggle} on={enabled} /> {#if dashboardReady} Open {/if}
+ {#if tError.length > 0 || activityText.length > 0} +
0 || activity == "error"}> + {tError || activityText} +
+ {/if} +
- - diff --git a/ui/src/routes/profile/Hermes.svelte b/ui/src/routes/profile/Hermes.svelte index 9788a0d4..ab6053f3 100644 --- a/ui/src/routes/profile/Hermes.svelte +++ b/ui/src/routes/profile/Hermes.svelte @@ -279,6 +279,7 @@ gap: 16px; flex-wrap: wrap; justify-content: flex-end; + min-height: 65px; } .grid { display: grid; @@ -345,6 +346,7 @@ display: flex; justify-content: flex-end; margin-top: 32px; + min-height: 65px; } .save, .restart, .install, .dashboard { border-radius: 16px; @@ -357,6 +359,25 @@ height: 65px; padding: 0 48px; cursor: pointer; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + } + .install { + width: 230px; + } + .restart { + width: 190px; + } + .dashboard { + width: 120px; + justify-content: center; + padding: 0; + } + .save { + width: 180px; + padding: 0; } .dashboard { display: flex; @@ -410,7 +431,6 @@ } .save, .restart, .install, .dashboard { font-size: 20px; - padding: 0 24px; } } From 35eaa07c4850b0f90baa3b2ef474ea0effe2ba9a Mon Sep 17 00:00:00 2001 From: reid Date: Sat, 27 Jun 2026 15:31:46 -0500 Subject: [PATCH 11/29] fix hermes entrypoint and vere image selector --- goseg/config/version.go | 27 +++++- goseg/docker/docker.go | 84 +++++++++++------- goseg/docker/hermes.go | 2 +- goseg/main.go | 10 ++- goseg/routines/version.go | 5 +- ui/src/routes/[patp]/Header.svelte | 135 +++++++++++++++++++++++------ 6 files changed, 201 insertions(+), 62 deletions(-) diff --git a/goseg/config/version.go b/goseg/config/version.go index 96316a29..5a91ffaf 100644 --- a/goseg/config/version.go +++ b/goseg/config/version.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "go.uber.org/zap" @@ -74,7 +75,11 @@ func CheckVersion() (structs.Channel, bool) { return VersionInfo, false } } - VersionInfo = fetchedVersion.Groundseg[releaseChannel] + targetChannel, selectedChannel, exactChannel := SelectVersionChannel(fetchedVersion, releaseChannel) + if !exactChannel { + zap.L().Warn(fmt.Sprintf("Version channel %q not found; using %q", releaseChannel, selectedChannel)) + } + VersionInfo = targetChannel // debug: re-marshal and write the entire fetched version to disk confPath := filepath.Join(BasePath, "settings", "version_info.json") file, err := os.Create(confPath) @@ -98,6 +103,26 @@ func CheckVersion() (structs.Channel, bool) { return VersionInfo, false } +func SelectVersionChannel(versionStruct structs.Version, releaseChannel string) (structs.Channel, string, bool) { + releaseChannel = strings.TrimSpace(releaseChannel) + if releaseChannel == "" { + releaseChannel = "latest" + } + if versionStruct.Groundseg == nil { + return structs.Channel{}, releaseChannel, false + } + if channel, ok := versionStruct.Groundseg[releaseChannel]; ok { + return channel, releaseChannel, true + } + if channel, ok := versionStruct.Groundseg["latest"]; ok { + return channel, "latest", false + } + for channelName, channel := range versionStruct.Groundseg { + return channel, channelName, false + } + return structs.Channel{}, releaseChannel, false +} + // write the defaults.VersionInfo value to disk func CreateDefaultVersion() error { var versionInfo structs.Version diff --git a/goseg/docker/docker.go b/goseg/docker/docker.go index e7a6ee79..b3d34710 100644 --- a/goseg/docker/docker.go +++ b/goseg/docker/docker.go @@ -568,43 +568,67 @@ func CreateContainer(containerName string, containerType string) (structs.Contai // convert the version info back into json then a map lol // so we can easily get the correct repo/release channel/tag/hash func GetLatestContainerInfo(containerType string) (map[string]string, error) { - var res map[string]string - res = make(map[string]string) arch := config.Architecture hashLabel := arch + "_sha256" - versionInfo := config.VersionInfo - jsonData, err := json.Marshal(versionInfo) - if err != nil { - return res, err - } - // Convert JSON to map - var m map[string]any - err = json.Unmarshal(jsonData, &m) - if err != nil { - return res, err + detail, ok := containerVersionDetails(config.VersionInfo, containerType) + if !ok || strings.TrimSpace(detail.Tag) == "" || strings.TrimSpace(detail.Repo) == "" { + localVersion := config.LocalVersion() + releaseChannel := config.Conf().UpdateBranch + channel, selectedChannel, exactChannel := config.SelectVersionChannel(localVersion, releaseChannel) + if !exactChannel { + zap.L().Warn(fmt.Sprintf("Version channel %q not found locally; using %q", releaseChannel, selectedChannel)) + } + detail, ok = containerVersionDetails(channel, containerType) } - containerData, ok := m[containerType].(map[string]any) if !ok { - return nil, fmt.Errorf("%s data is not a map", containerType) + return nil, fmt.Errorf("%s data is not configured", containerType) } - tag, ok := containerData["tag"].(string) - if !ok { - return nil, fmt.Errorf("'tag' is not a string") + tag := strings.TrimSpace(detail.Tag) + if tag == "" { + return nil, fmt.Errorf("%s tag is empty", containerType) } - hashValue, ok := containerData[hashLabel].(string) - if !ok { - return nil, fmt.Errorf("'%s' is not a string", hashLabel) + repo := strings.TrimSpace(detail.Repo) + if repo == "" { + return nil, fmt.Errorf("%s repo is empty", containerType) + } + hashValue := strings.TrimSpace(detail.Amd64Sha256) + if arch != "amd64" { + hashValue = strings.TrimSpace(detail.Arm64Sha256) + } + if hashValue == "" { + return nil, fmt.Errorf("%s %s is empty", containerType, hashLabel) + } + return map[string]string{ + "tag": tag, + "hash": hashValue, + "repo": repo, + "type": containerType, + }, nil +} + +func containerVersionDetails(channel structs.Channel, containerType string) (structs.VersionDetails, bool) { + switch strings.ToLower(strings.TrimSpace(containerType)) { + case "groundseg": + return channel.Groundseg, true + case "manual": + return channel.Manual, true + case "rustfs": + return channel.Rustfs, true + case "minio": + return channel.Minio, true + case "miniomc", "mc": + return channel.Miniomc, true + case "netdata": + return channel.Netdata, true + case "vere": + return channel.Vere, true + case "webui": + return channel.Webui, true + case "wireguard": + return channel.Wireguard, true + default: + return structs.VersionDetails{}, false } - repo, ok := containerData["repo"].(string) - if !ok { - return nil, fmt.Errorf("'repo' is not a string") - } - res = make(map[string]string) - res["tag"] = tag - res["hash"] = hashValue - res["repo"] = repo - res["type"] = containerType - return res, nil } // stop a container with the name diff --git a/goseg/docker/hermes.go b/goseg/docker/hermes.go index 419fba16..f54540c9 100644 --- a/goseg/docker/hermes.go +++ b/goseg/docker/hermes.go @@ -299,7 +299,7 @@ func hermesContainerConf(containerName string) (container.Config, container.Host func hermesGatewayCommand(hermesConf structs.HermesConfig) string { return fmt.Sprintf( - "cat > /opt/data/config.yaml <<'EOF'\n%s\nEOF\nexec gateway run --replace --accept-hooks", + "cat > /opt/data/config.yaml <<'EOF'\n%s\nEOF\nexec hermes gateway run --replace --accept-hooks", hermesConfigYAML(hermesConf), ) } diff --git a/goseg/main.go b/goseg/main.go index 16c08151..55e22939 100644 --- a/goseg/main.go +++ b/goseg/main.go @@ -251,7 +251,10 @@ func main() { } else { versionStruct := config.LocalVersion() releaseChannel := conf.UpdateBranch - targetChan := versionStruct.Groundseg[releaseChannel] + targetChan, selectedChannel, exactChannel := config.SelectVersionChannel(versionStruct, releaseChannel) + if !exactChannel { + zap.L().Warn(fmt.Sprintf("Version channel %q not found locally; using %q", releaseChannel, selectedChannel)) + } config.VersionInfo = targetChan } // routines/version.go @@ -316,7 +319,10 @@ func main() { zap.L().Warn("Could not retrieve version info after 10 seconds!") versionStruct := config.LocalVersion() releaseChannel := conf.UpdateBranch - targetChan := versionStruct.Groundseg[releaseChannel] + targetChan, selectedChannel, exactChannel := config.SelectVersionChannel(versionStruct, releaseChannel) + if !exactChannel { + zap.L().Warn(fmt.Sprintf("Version channel %q not found locally; using %q", releaseChannel, selectedChannel)) + } config.VersionInfo = targetChan } } diff --git a/goseg/routines/version.go b/goseg/routines/version.go index 8ebc9a05..1176164c 100644 --- a/goseg/routines/version.go +++ b/goseg/routines/version.go @@ -43,7 +43,10 @@ func CheckVersionLoop() { } func callUpdater(releaseChannel string) { - currentChannelVersion := config.LocalVersion().Groundseg[releaseChannel] + currentChannelVersion, selectedChannel, exactChannel := config.SelectVersionChannel(config.LocalVersion(), releaseChannel) + if !exactChannel { + zap.L().Warn(fmt.Sprintf("Version channel %q not found locally; using %q", releaseChannel, selectedChannel)) + } // Get latest information latestVersion, _ := config.CheckVersion() latestChannelVersion := latestVersion diff --git a/ui/src/routes/[patp]/Header.svelte b/ui/src/routes/[patp]/Header.svelte index 06fbcc16..0a0a947a 100644 --- a/ui/src/routes/[patp]/Header.svelte +++ b/ui/src/routes/[patp]/Header.svelte @@ -20,8 +20,10 @@ $: isSavingVere = tVereTag == "loading" $: vereError = tVereTag.length > 0 && tVereTag != "loading" && tVereTag != "success" ? tVereTag : "" $: defaultVereTag = versionServerVereTag || urbitVersion || vere - $: selectableVereTags = [...new Set([urbitImageTagOverride, ...vereTags].filter(Boolean))] - .filter(tag => tag != defaultVereTag) + $: displayedVereTag = urbitImageTagOverride || defaultVereTag || "current" + $: versionOptions = [...new Set([defaultVereTag, urbitImageTagOverride, ...vereTags].filter(Boolean))] + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: "base" })) + .map(tag => ({ tag, value: tag == defaultVereTag ? "" : tag })) $: versionTitle = vereError || (urbitImageTagOverride ? `Vere image tag override: ${urbitImageTagOverride}` : `Vere image tag: ${defaultVereTag || "current"}`) @@ -33,15 +35,25 @@ let copied = false let draftVereTag = urbitImageTagOverride let lastVereTag = urbitImageTagOverride + let versionMenu + let versionMenuOpen = false $: if (urbitImageTagOverride !== lastVereTag) { draftVereTag = urbitImageTagOverride lastVereTag = urbitImageTagOverride } - const changeVereTag = () => { - if (draftVereTag !== urbitImageTagOverride) { - setVereTag(patp, draftVereTag) + const toggleVersionMenu = () => { + if (!isSavingVere && versionOptions.length > 0) { + versionMenuOpen = !versionMenuOpen + } + } + + const selectVereTag = value => { + versionMenuOpen = false + draftVereTag = value + if (value !== urbitImageTagOverride) { + setVereTag(patp, value) } } @@ -51,6 +63,14 @@ copied = true; setTimeout(()=> copied = false, 1000) }) + + const closeVersionMenu = event => { + if (versionMenu && !versionMenu.contains(event.target)) { + versionMenuOpen = false + } + } + document.addEventListener('click', closeVersionMenu) + return () => document.removeEventListener('click', closeVersionMenu) }) @@ -58,18 +78,34 @@
{shipClass} - 0} class:error={vereError.length > 0}> - + aria-expanded={versionMenuOpen} + on:click|stopPropagation={toggleVersionMenu}> + {displayedVereTag.toUpperCase()} + + {#if versionMenuOpen} +
+ {#each versionOptions as option} + + {/each} +
+ {/if}
{#if isSavingVere} SAVING @@ -136,12 +172,13 @@ position: relative; vertical-align: super; margin-left: 5px; + z-index: 10; } .version-control::after { content: ""; position: absolute; - right: 5px; - top: 9px; + right: 7px; + top: 12px; width: 0; height: 0; border-left: 3px solid transparent; @@ -149,37 +186,81 @@ border-top: 4px solid var(--text-card-color); pointer-events: none; } - .version-control select { - appearance: none; + .version-control.open::after { + transform: rotate(180deg); + } + .version-control > button { background: transparent; border: 1px solid var(--Gray-400, #5C7060); border-radius: 4px; color: var(--text-card-color); cursor: pointer; font-family: var(--title-font); - font-size: 11px; + font-size: 18px; font-weight: 700; - height: 22px; + height: 30px; letter-spacing: 0; - max-width: 134px; - min-width: 58px; + line-height: 28px; + max-width: 150px; + min-width: 74px; overflow: hidden; - padding: 0 15px 0 5px; + padding: 0 20px 0 7px; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } - .version-control.override select { + .version-control.override > button { background: var(--Gray-400, #5C7060); } - .version-control.error select { + .version-control.error > button { border-color: #d45151; color: #ffd4d4; } - .version-control select:disabled { + .version-control > button:disabled { cursor: default; opacity: .6; } + .version-menu { + position: absolute; + top: 34px; + left: 0; + width: 162px; + max-height: 300px; + overflow-y: auto; + border: 1px solid var(--Gray-400, #5C7060); + border-radius: 6px; + background: var(--bg-modal, #F5F1E8); + box-shadow: 0 10px 24px rgba(0, 0, 0, .22); + padding: 4px; + } + .version-menu button { + display: block; + width: 100%; + height: 29px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--NP_Black, #313933); + cursor: pointer; + font-family: var(--title-font); + font-size: 18px; + font-weight: 700; + letter-spacing: 0; + overflow: hidden; + padding: 0 8px; + text-align: left; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + } + .version-menu button:hover, + .version-menu button.active { + background: var(--Gray-400, #5C7060); + color: #fff; + } + .version-menu button.default { + border-bottom: 1px solid rgba(49, 57, 51, .18); + } .version-status { margin-left: 5px; color: var(--Gray-300, #8FA393); From 906a4656db13fd2f61753ae5142a1a3730636080 Mon Sep 17 00:00:00 2001 From: reid Date: Sat, 27 Jun 2026 15:49:00 -0500 Subject: [PATCH 12/29] fix hermes config and tlon cli --- goseg/docker/hermes.go | 153 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/goseg/docker/hermes.go b/goseg/docker/hermes.go index f54540c9..cc58e3bb 100644 --- a/goseg/docker/hermes.go +++ b/goseg/docker/hermes.go @@ -20,6 +20,7 @@ const ( HermesDataVolumeName = "hermes" HermesWorkspaceVolumeName = "hermes_workspace" HermesUrbitHostName = "groundseg-urbit.local" + HermesTlonSkillDir = "/opt/data/tlon-skill" DefaultHermesImage = "registry.hub.docker.com/nativeplanet/hermes-tlon:0.14.0-0.14.0" DefaultHermesModelProvider = "openrouter" DefaultHermesModel = "deepseek/deepseek-v4-flash" @@ -201,9 +202,12 @@ func hermesContainerConf(containerName string) (container.Config, container.Host "HERMES_WORKSPACE=/workspace", "HERMES_WORKSPACE_DIR=/workspace", "HERMES_CONTAINER_HOME=/workspace/home", + "HERMES_OPENROUTER_CACHE=false", + "HERMES_TLON_ADAPTER_DIR=/opt/tlon-apps/packages/hermes-tlon-adapter", "HERMES_INTERACTIVE=true", "HERMES_GATEWAY_SESSION=true", "HERMES_EXEC_ASK=true", + "LCM_DATABASE_PATH=/opt/data/lcm.db", "HOME=/workspace/home", "HERMES_DASHBOARD=1", "HERMES_DASHBOARD_HOST=0.0.0.0", @@ -216,6 +220,7 @@ func hermesContainerConf(containerName string) (container.Config, container.Host fmt.Sprintf("HERMES_TLON_ADAPTER_VERSION=%s", HermesTlonAdapterVersionOrDefault(hermesConf.TlonAdapterVersion)), fmt.Sprintf("HERMES_TLON_ADAPTER_REF=%s", HermesTlonAdapterRefOrDefault(hermesConf.TlonAdapterRef)), "TLON_TELEMETRY=false", + "HERMES_TLON_TOOLSET=tlon", "HERMES_TLON_TOOLSETS=tlon,file,cronjob", "TERMINAL_ENV=local", "TERMINAL_CWD=/workspace", @@ -223,7 +228,9 @@ func hermesContainerConf(containerName string) (container.Config, container.Host "TERMINAL_TIMEOUT=180", "TERMINAL_MAX_FOREGROUND_TIMEOUT=900", "TLON_SKILL_PATH=/opt/tlon-apps/packages/tlon-skill/SKILL.md", + fmt.Sprintf("TLON_SKILL_DIR=%s", HermesTlonSkillDir), "TLON_CLI=/usr/local/bin/tlon", + fmt.Sprintf("TLON_CONFIG_FILE=%s", hermesTlonShipConfigPath(attachedShipBare)), fmt.Sprintf("TLON_NODE_URL=%s", shipURL), fmt.Sprintf("TLON_NODE_ID=%s", attachedShip), fmt.Sprintf("TLON_ACCESS_CODE=%s", accessCode), @@ -235,6 +242,9 @@ func hermesContainerConf(containerName string) (container.Config, container.Host fmt.Sprintf("TLON_DM_ALLOWLIST=%s", owner), fmt.Sprintf("TLON_DEFAULT_AUTHORIZED_SHIPS=%s", owner), fmt.Sprintf("TLON_GROUP_INVITE_ALLOWLIST=%s", owner), + "TLON_BOT_ALIASES=", + "TLON_BOT_MENTIONS=", + "TLON_CHANNELS=", "TLON_CHANNEL_RULES={}", "TLON_AUTO_DISCOVER=true", "TLON_AUTO_ACCEPT_DM_INVITES=true", @@ -299,11 +309,152 @@ func hermesContainerConf(containerName string) (container.Config, container.Host func hermesGatewayCommand(hermesConf structs.HermesConfig) string { return fmt.Sprintf( - "cat > /opt/data/config.yaml <<'EOF'\n%s\nEOF\nexec hermes gateway run --replace --accept-hooks", + `cat > /opt/data/config.yaml <<'EOF' +%s +EOF +python3 <<'PY' +import json +import os +from pathlib import Path + +home = Path(os.environ.get("HERMES_HOME") or "/opt/data") +skill_dir = Path(os.environ.get("TLON_SKILL_DIR") or home / "tlon-skill") +ship = ( + os.environ.get("TLON_NODE_ID") + or os.environ.get("TLON_SHIP_NAME") + or os.environ.get("URBIT_SHIP") + or os.environ.get("TLON_SHIP") + or "" +).strip() +if ship and not ship.startswith("~"): + ship = "~" + ship +bare_ship = ship.lstrip("~") or "ship" +config_file = Path(os.environ.get("TLON_CONFIG_FILE") or skill_dir / "ships" / f"{bare_ship}.json") +config_file.parent.mkdir(parents=True, exist_ok=True) + +ship_config = { + "url": ( + os.environ.get("TLON_NODE_URL") + or os.environ.get("TLON_SHIP_URL") + or os.environ.get("TLON_URL") + or os.environ.get("URBIT_URL") + or "" + ), + "ship": ship, + "code": ( + os.environ.get("TLON_ACCESS_CODE") + or os.environ.get("TLON_SHIP_CODE") + or os.environ.get("TLON_CODE") + or os.environ.get("URBIT_CODE") + or "" + ), +} +config_file.write_text(json.dumps(ship_config, indent=2) + "\n", encoding="utf-8") +config_file.chmod(0o600) + +env_names = [ + "ANTHROPIC_API_KEY", + "BRAVE_API_KEY", + "BRAVE_SEARCH_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "HERMES_CONTAINER_HOME", + "HERMES_DASHBOARD", + "HERMES_DASHBOARD_HOST", + "HERMES_DASHBOARD_PORT", + "HERMES_EXEC_ASK", + "HERMES_GATEWAY_SESSION", + "HERMES_HOME", + "HERMES_INFERENCE_PROVIDER", + "HERMES_INTERACTIVE", + "HERMES_MODEL", + "HERMES_MODEL_PROVIDER", + "HERMES_OPENROUTER_CACHE", + "HERMES_TLON_ADAPTER_DIR", + "HERMES_TLON_TOOLSET", + "HERMES_TLON_TOOLSETS", + "HERMES_WORKSPACE", + "HERMES_WORKSPACE_DIR", + "HOME", + "LCM_DATABASE_PATH", + "MISTRAL_API_KEY", + "NOUS_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "TERMINAL_CWD", + "TERMINAL_ENV", + "TERMINAL_LOCAL_PERSISTENT", + "TERMINAL_MAX_FOREGROUND_TIMEOUT", + "TERMINAL_TIMEOUT", + "TLON_ACCESS_CODE", + "TLON_ALLOWED_USERS", + "TLON_ALLOW_ALL_USERS", + "TLON_AUTO_ACCEPT_DM_INVITES", + "TLON_AUTO_ACCEPT_GROUP_INVITES", + "TLON_AUTO_DISCOVER", + "TLON_BOT_ALIASES", + "TLON_BOT_MENTIONS", + "TLON_CHANNELS", + "TLON_CHANNEL_RULES", + "TLON_CLI", + "TLON_CODE", + "TLON_CONFIG_FILE", + "TLON_DEFAULT_AUTHORIZED_SHIPS", + "TLON_DM_ALLOWLIST", + "TLON_DM_POLL_ENABLED", + "TLON_GROUP_INVITE_ALLOWLIST", + "TLON_HOME_CHANNEL", + "TLON_MAX_CONSECUTIVE_BOT_RESPONSES", + "TLON_NODE_ID", + "TLON_NODE_URL", + "TLON_OWNER", + "TLON_OWNER_LISTEN", + "TLON_OWNER_LISTEN_ENABLED", + "TLON_OWNER_SHIP", + "TLON_OWNER_URL", + "TLON_REQUIRE_MENTION", + "TLON_SHIP", + "TLON_SHIP_CODE", + "TLON_SHIP_NAME", + "TLON_SHIP_URL", + "TLON_SKILL_DIR", + "TLON_SKILL_PATH", + "TLON_TELEMETRY", + "TLON_URL", + "URBIT_CODE", + "URBIT_SHIP", + "URBIT_URL", + "XAI_API_KEY", +] +values = {} +for name in env_names: + value = os.environ.get(name) + if value is not None: + values[name] = value +values["TLON_CONFIG_FILE"] = str(config_file) +values["TLON_SKILL_DIR"] = str(skill_dir) + +env_file = home / ".env" +env_file.parent.mkdir(parents=True, exist_ok=True) +env_text = "".join(f"{key}={str(value).replace(chr(10), r'\n')}\n" for key, value in sorted(values.items())) +env_file.write_text(env_text, encoding="utf-8") +env_file.chmod(0o600) +workspace_env = Path(os.environ.get("HERMES_WORKSPACE") or "/workspace") / ".env" +workspace_env.write_text(env_text, encoding="utf-8") +workspace_env.chmod(0o600) +print(f"Hermes Tlon runtime files: env={env_file} workspace_env={workspace_env} config={config_file}") +PY +echo "Hermes Tlon CLI: ${TLON_CLI:-tlon} ($(command -v "${TLON_CLI:-tlon}" || true))" +"${TLON_CLI:-tlon}" --version || true +exec hermes gateway run --replace --accept-hooks`, hermesConfigYAML(hermesConf), ) } +func hermesTlonShipConfigPath(attachedShipBare string) string { + return fmt.Sprintf("%s/ships/%s.json", HermesTlonSkillDir, strings.TrimPrefix(attachedShipBare, "~")) +} + func hermesConfigYAML(hermesConf structs.HermesConfig) string { provider := HermesModelProviderOrDefault(hermesConf.ModelProvider) model := HermesModelOrDefault(hermesConf.Model) From de25c9bd59e9e5da93566f5efb766e2fe1e2a367 Mon Sep 17 00:00:00 2001 From: reid Date: Sat, 27 Jun 2026 16:02:45 -0500 Subject: [PATCH 13/29] fix .env writer and get correct arch tlon bin --- containers/hermes/Dockerfile | 18 +++- goseg/docker/hermes.go | 182 ++++++++++------------------------- 2 files changed, 65 insertions(+), 135 deletions(-) diff --git a/containers/hermes/Dockerfile b/containers/hermes/Dockerfile index 1395befd..dd59eb0c 100644 --- a/containers/hermes/Dockerfile +++ b/containers/hermes/Dockerfile @@ -5,7 +5,9 @@ ARG HERMES_AGENT_REPO=https://github.com/NousResearch/hermes-agent.git ARG HERMES_AGENT_REF=2ffa1c97c09317c1d066aa5708b8ad961a4ca589 ARG TLON_APPS_REPO=https://github.com/tloncorp/tlon-apps.git ARG TLON_APPS_REF=b9180da6491d29933a98f6e4f1b1458ce61ca576 +ARG TLON_SKILL_CLI_VERSION=0.4.0 ARG PNPM_VERSION=9.15.9 +ARG TARGETARCH LABEL org.opencontainers.image.title="NativePlanet Hermes Tlon" LABEL org.opencontainers.image.description="Hermes Agent with the Tlon platform adapter and tlon CLI" @@ -89,11 +91,21 @@ RUN cd "${TLON_APPS_DIR}" \ && pnpm install --filter @tloncorp/tlon-skill... --frozen-lockfile --ignore-scripts \ && pnpm --filter @tloncorp/api build \ && pnpm --filter @tloncorp/tlon-skill build \ - && cp "${TLON_SKILL_DIR}/dist/tlon-run" "${TLON_CLI}" \ - && chmod +x "${TLON_CLI}" \ - && "${TLON_CLI}" --version \ && pnpm store prune +RUN set -eux; \ + arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ + case "$arch" in \ + amd64|x86_64) tlon_pkg_arch="linux-x64" ;; \ + arm64|aarch64) tlon_pkg_arch="linux-arm64" ;; \ + *) echo "Unsupported tlon CLI architecture: $arch" >&2; exit 1 ;; \ + esac; \ + tmp="$(mktemp -d)"; \ + curl -fsSL "https://registry.npmjs.org/@tloncorp/tlon-skill-${tlon_pkg_arch}/-/tlon-skill-${tlon_pkg_arch}-${TLON_SKILL_CLI_VERSION}.tgz" \ + | tar -xz -C "$tmp"; \ + install -m 0755 "$tmp/package/tlon" "${TLON_CLI}"; \ + rm -rf "$tmp" + RUN mkdir -p "${HERMES_HOME}" "${HERMES_WORKSPACE}" "${HERMES_CONTAINER_HOME}" \ && ln -sf /root/.bun/bin/bun /usr/local/bin/bun \ && ln -sf /root/.bun/bin/bun /usr/local/bin/bunx \ diff --git a/goseg/docker/hermes.go b/goseg/docker/hermes.go index cc58e3bb..daa5a3e3 100644 --- a/goseg/docker/hermes.go +++ b/goseg/docker/hermes.go @@ -312,140 +312,58 @@ func hermesGatewayCommand(hermesConf structs.HermesConfig) string { `cat > /opt/data/config.yaml <<'EOF' %s EOF -python3 <<'PY' -import json -import os -from pathlib import Path - -home = Path(os.environ.get("HERMES_HOME") or "/opt/data") -skill_dir = Path(os.environ.get("TLON_SKILL_DIR") or home / "tlon-skill") -ship = ( - os.environ.get("TLON_NODE_ID") - or os.environ.get("TLON_SHIP_NAME") - or os.environ.get("URBIT_SHIP") - or os.environ.get("TLON_SHIP") - or "" -).strip() -if ship and not ship.startswith("~"): - ship = "~" + ship -bare_ship = ship.lstrip("~") or "ship" -config_file = Path(os.environ.get("TLON_CONFIG_FILE") or skill_dir / "ships" / f"{bare_ship}.json") -config_file.parent.mkdir(parents=True, exist_ok=True) - -ship_config = { - "url": ( - os.environ.get("TLON_NODE_URL") - or os.environ.get("TLON_SHIP_URL") - or os.environ.get("TLON_URL") - or os.environ.get("URBIT_URL") - or "" - ), - "ship": ship, - "code": ( - os.environ.get("TLON_ACCESS_CODE") - or os.environ.get("TLON_SHIP_CODE") - or os.environ.get("TLON_CODE") - or os.environ.get("URBIT_CODE") - or "" - ), +skill_dir="${TLON_SKILL_DIR:-/opt/data/tlon-skill}" +ship="${TLON_NODE_ID:-${TLON_SHIP_NAME:-${URBIT_SHIP:-${TLON_SHIP:-}}}}" +case "$ship" in + "~"*) ;; + "") ship="~ship" ;; + *) ship="~$ship" ;; +esac +bare_ship="${ship#~}" +config_file="${TLON_CONFIG_FILE:-$skill_dir/ships/$bare_ship.json}" +mkdir -p "$(dirname "$config_file")" /opt/data /workspace +url="${TLON_NODE_URL:-${TLON_SHIP_URL:-${TLON_URL:-${URBIT_URL:-}}}}" +code="${TLON_ACCESS_CODE:-${TLON_SHIP_CODE:-${TLON_CODE:-${URBIT_CODE:-}}}}" +cat > "$config_file" < /opt/data/.env +chmod 600 /opt/data/.env +cp /opt/data/.env /workspace/.env +chmod 600 /workspace/.env +echo "Hermes Tlon runtime files: env=/opt/data/.env workspace_env=/workspace/.env config=$config_file" echo "Hermes Tlon CLI: ${TLON_CLI:-tlon} ($(command -v "${TLON_CLI:-tlon}" || true))" -"${TLON_CLI:-tlon}" --version || true exec hermes gateway run --replace --accept-hooks`, hermesConfigYAML(hermesConf), ) From acfdff471011f4ebe84893c26dcabc5f0550346d Mon Sep 17 00:00:00 2001 From: reid Date: Sat, 27 Jun 2026 16:13:44 -0500 Subject: [PATCH 14/29] fix width of hermes control buttons --- ui/src/routes/profile/Hermes.svelte | 70 +++++++++++++++++------------ 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/ui/src/routes/profile/Hermes.svelte b/ui/src/routes/profile/Hermes.svelte index ab6053f3..cf1cfa36 100644 --- a/ui/src/routes/profile/Hermes.svelte +++ b/ui/src/routes/profile/Hermes.svelte @@ -143,9 +143,15 @@ {installLabel} 0} disabled={!canToggle} on={enabled} /> - {#if dashboardReady} - Open - {/if} + + Open + @@ -237,6 +362,43 @@
{/if} + + + {#if showConfig} +
+
+ + +
+ + {#if configLoading} +
Loading config.yaml...
+ {/if} + {#if configError} +
{configError}
+ {:else if configValidationError} +
{configValidationError}
+ {:else if configStatus} +
{configStatus}
+ {:else if configDirty} +
Unsaved config.yaml edits
+ {/if} + +