From 6e62d4a20cbb1227c15e30a6f98c31deaedef02e Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 23 Apr 2026 13:12:11 +0100 Subject: [PATCH] feat(lifecycle): add AppMode type + DetectMode() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New pkg/lifecycle/mode.go introduces AppMode (Manager | Worker) and DetectMode() that returns: - Manager by default (explicit or empty env) - Worker when CORE_APP_MODE=worker, or when empty env + CI=true signals a headless/compute context Prerequisite for the BugSETI compute worker pattern (core/gui RFC.md §9.1) — manager GUI runs the full app UI, workers run headless compute with only an RPC surface. Tests follow the core/go {Good,Bad,Ugly} naming convention; go test ./pkg/lifecycle/... passes. Co-authored-by: Codex Closes tasks.lthn.sh/view.php?id=32 --- pkg/lifecycle/mode.go | 52 ++++++++++++++++++++++++++++++++++ pkg/lifecycle/mode_test.go | 57 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 pkg/lifecycle/mode.go create mode 100644 pkg/lifecycle/mode_test.go diff --git a/pkg/lifecycle/mode.go b/pkg/lifecycle/mode.go new file mode 100644 index 00000000..65986061 --- /dev/null +++ b/pkg/lifecycle/mode.go @@ -0,0 +1,52 @@ +package lifecycle + +import ( + "os" + "strings" +) + +type AppMode string + +const ( + ModeManager AppMode = "manager" + ModeWorker AppMode = "worker" +) + +const ( + appModeEnv = "CORE_APP_MODE" + ciEnv = "CI" +) + +// DetectMode returns ModeWorker when CORE_APP_MODE=worker. +// +// mode := DetectMode() +func DetectMode() AppMode { + if value, ok := os.LookupEnv(appModeEnv); ok { + if mode, valid := parseAppMode(value); valid { + return mode + } + } + + if value, ok := os.LookupEnv(ciEnv); ok && isTrue(value) { + return ModeWorker + } + + return ModeManager +} + +func parseAppMode(value string) (AppMode, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case string(ModeManager): + return ModeManager, true + case string(ModeWorker): + return ModeWorker, true + case "": + return "", false + default: + return ModeManager, true + } +} + +func isTrue(value string) bool { + return strings.EqualFold(strings.TrimSpace(value), "true") +} diff --git a/pkg/lifecycle/mode_test.go b/pkg/lifecycle/mode_test.go new file mode 100644 index 00000000..32ddc74d --- /dev/null +++ b/pkg/lifecycle/mode_test.go @@ -0,0 +1,57 @@ +package lifecycle + +import ( + "os" + "testing" +) + +func TestMode_DetectMode_Good(t *testing.T) { + unsetEnv(t, appModeEnv) + t.Setenv(ciEnv, "") + + mode := DetectMode() + if mode != ModeManager { + t.Fatalf("expected manager mode, got %q", mode) + } +} + +func TestMode_DetectMode_Bad(t *testing.T) { + t.Setenv(appModeEnv, "bogus") + t.Setenv(ciEnv, "") + + mode := DetectMode() + if mode != ModeManager { + t.Fatalf("expected manager mode after invalid env value, got %q", mode) + } +} + +func TestMode_DetectMode_Ugly(t *testing.T) { + t.Setenv(appModeEnv, "") + t.Setenv(ciEnv, "true") + + mode := DetectMode() + if mode != ModeWorker { + t.Fatalf("expected worker mode in CI headless context, got %q", mode) + } +} + +func unsetEnv(t *testing.T, key string) { + t.Helper() + + value, ok := os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + + t.Cleanup(func() { + if ok { + if err := os.Setenv(key, value); err != nil { + t.Fatalf("restore %s: %v", key, err) + } + return + } + if err := os.Unsetenv(key); err != nil { + t.Fatalf("restore unset %s: %v", key, err) + } + }) +}