diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md new file mode 100644 index 0000000..f25209b --- /dev/null +++ b/ATTRIBUTIONS.md @@ -0,0 +1,63 @@ +# Attributions + +I am building sum for myself, to the highest standard I can achieve. I am not trying to grow a user base for it, and I would rather you use the projects below than adopt sum. They shaped this project in concrete ways and deserve the credit. Please explore them, use the ones that fit your work, and support their maintainers. + +This page distinguishes conceptual inspiration, direct dependency, adapted code, historical lineage, and integrations still being evaluated. Replacing a dependency later does not erase the credit recorded here. It supplements, and never replaces, the license and source notices carried alongside any copied material. + +## Where sum comes from + +**[Firstmate](https://github.com/kunchenguid/firstmate)** is the origin of **the agent-distro concept that inspired sum**: an ordinary coding harness, portable instructions/skills/helpers, and one liaison coordinating workers. That shape is sum's lineage, not a claim that Firstmate invented agent orchestration generally. If the idea of one coordinator delegating to disposable workers appeals to you, try Firstmate itself first. + +**[Consigliere](https://github.com/douglasjarquin/consigliere)** was the author's own earlier, Firstmate-derived experiment: delegation conventions and the light mafia identity sum still carries came from there. It is respectful lineage, not a story about something that didn't work. + +## Terminal and session runtime + +**[Herdr](https://github.com/herdrdev/herdr)** ([site](https://herdr.dev/)) is the terminal runtime sum is native to: pane, session, and worktree operations are Herdr's own mechanics, and sum deliberately lets an existing runtime own them instead of reimplementing process/session management. + +**[Herdr Mesh](https://github.com/runchr-works/herdr-mesh)** supplied the early, concrete MCP bridge and the small shared pane/agent tool surface that sum's own Mesh builtin now implements natively. That credit stands even after internalizing ownership of the implementation. + +**[Unpeel](https://unpeel.com/)** is a conceptual inspiration for **MCP pane/session management** and cross-harness coordination — not a claim that any implementation here was copied from it. Worth exploring if you want a more general take on coordinating multiple harnesses through MCP. + +**[Solo](https://soloterm.com/)** ([meta-harness explanation](https://soloterm.com/blog/the-agentic-metaharness)) is where the **meta-harness framing** sum uses comes from, along with wakeups, presets, handoffs, selective context, environment awareness, and visibility as ideas worth having regardless of which terminal you run them in. If you want a polished desktop application built around this idea rather than a small CLI helper, Solo is that product. + +**[Delta](https://delta.dev/)** shaped how sum thinks about durable, thread-centered work: keeping the conversation and code context attached to a unit of work, and carrying that through handoff and review. + +**[Oh My Pi](https://github.com/can1357/oh-my-pi#09--unapologetically-native-even-on-windows)** is the inspiration behind sum's preference for **builtins and reducing avoidable process boundaries** — running logic in-process instead of shelling out where it is practical to do so. This is inspiration, not a claim of an identical architecture or of matching benchmark results. + +## Provider quota and evidence + +**[quota-axi](https://github.com/kunchenguid/quota-axi)** shaped how sum thinks about provider evidence: freshness and uncertainty made explicit, compact output, and separating collection from policy. It is also the original dependency and inspiration for the author's independent Go sister project, **[Remainder](https://github.com/douglasjarquin/remainder)**. Sum and Pinchos each consume Remainder independently; neither treats it as a sum builtin. Credit to quota-axi stands on its own, without an unmeasured performance comparison. + +## Verification and evidence + +**[Atlas verification example](https://github.com/poteto/verification-skill-example/blob/main/.cursor/skills/verify-atlas/SKILL.md)** demonstrates feature-driven, real-user-path verification backed by observable evidence — the pattern sum's own verification skills follow. It is one worked example, not a supplied universal recorder or driver. + +**[Cursor pstack skills](https://github.com/cursor/plugins/tree/main/pstack/skills)**, in particular [create-verification-skill](https://github.com/cursor/plugins/blob/main/pstack/skills/create-verification-skill/SKILL.md) and [maintain-verification-skill](https://github.com/cursor/plugins/blob/main/pstack/skills/maintain-verification-skill/SKILL.md), shaped how sum creates and maintains project verification contracts and feature maps, and how it selects useful upstream skills at all. Individual imported-source attribution is added here as specific skill imports actually land. + +**[before-and-after](https://github.com/vercel-labs/before-and-after)** shaped how sum presents and publishes before/after media in a pull request — the presentation and publication step, distinct from the capture itself. Any adapted material keeps its original notices. + +## Code exploration + +**[codegraph](https://github.com/colbymchenry/codegraph)** provides the structural code context and worktree-local graph exploration every checkout sum creates gets its own index of. Credit reflects the current pinned-binary, per-checkout-index relationship, not earlier planning language. + +## Verification companions + +**[MADE](https://github.com/douglasjarquin/made)** is the author's existing, independent candidate-bound verification and review companion. It is a separate project with its own relationship to No Mistakes; sum does not conflate the two. + +## Bot deployment + +**[Grok Ship](https://github.com/kunchenguid/grok-ship)** and the [native Firstmate/Bot template](https://x.ai/bot/__4FfrkUdvpdMk6-LKg5r) it distributes shaped sum's single user-facing Bot / project-Bot deployment model. Where a successor replaces one of these, the historical reference stays. + +**[Grok Ship Steward](https://github.com/douglasjarquin/grok-ship-steward)** inspired square, sum's scoped backup/recovery stewardship companion. Any upstream credit Grok Ship Steward itself carries is retained where its material is adapted. + +## Tooling and supporting ecosystem + +**[mise](https://mise.jdx.dev/)** underlies sum's tool/version/task setup and its portable verification foundations. + +**[herdr-mirror](https://github.com/nikok6/herdr-mirror)** was evaluated as a related remote-visibility reference during remote planning. It is labeled evaluated, not installed: sum does not depend on it. + +Beyond the named projects above, sum also rests on the broader Agent Skills convention, Git, and the coding harnesses it launches — none of them sum's own work, all of them worth understanding on their own terms. + +## The four you'll see most + +If you read nothing else on this page: **Firstmate** shaped the agent-distro concept, **Oh My Pi** shaped the preference for builtins, **Solo** shaped the meta-harness framing, and **Unpeel** shaped MCP pane/session management. Go look at all four. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0651db..2482b13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ Use an isolated development checkout and keep the installation serving live work - [ ] Run `MISE_ENABLE_TOOLS=go,python,node python3 .agents/skills/verify/scripts/verify_run.py --base ` once for your role and retain its candidate-bound record. - [ ] Run `mise run test-live` separately only when a real Herdr smoke test applies and is available; otherwise leave that manual scenario accurately not-run. - [ ] Describe acceptance results and evidence paths in the handoff, including failures or manual scenarios not run. -- [ ] Include contributor attribution when applicable and state deployment or release impact when relevant. +- [ ] Include contributor attribution when applicable, adding an entry to `ATTRIBUTIONS.md` when new material or inspiration enters, and state deployment or release impact when relevant. - [ ] Leave branch-protection and merge actions to their owner; a human reviews and merges changes. The recommended order is scope and prerequisites, implementation, canonical verification, applicable live verification, review, then human merge. diff --git a/README.md b/README.md index 49086ab..3d2da94 100644 --- a/README.md +++ b/README.md @@ -395,4 +395,4 @@ The optional Git bundle preserves the bootstrap commit. To use it instead: `git ## License and inspiration -MIT. Inspired by Firstmate and Consigliere. Uses [Herdr](https://github.com/herdrdev/herdr), [Herdr Mesh](https://github.com/runchr-works/herdr-mesh), and [quota-axi](https://github.com/kunchenguid/quota-axi) rather than replacing them. +MIT. sum stands on projects it did not write — Firstmate, Herdr, Oh My Pi, Solo, Unpeel, and others. See [ATTRIBUTIONS.md](ATTRIBUTIONS.md) for the complete credit and what each one shaped. diff --git a/go/cmd/sumctl-go/error_format_test.go b/go/cmd/sumctl-go/error_format_test.go new file mode 100644 index 0000000..cb29224 --- /dev/null +++ b/go/cmd/sumctl-go/error_format_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestCompiledEntrypoint_errorJSONMatchesPythonSeparatorsAndEscaping(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + + dir := t.TempDir() + binary := filepath.Join(dir, "sumctl-go") + build := exec.Command("go", "build", "-trimpath", "-buildvcs=false", "-o", binary, ".") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("go build failed: %v\n%s", err, output) + } + + home := t.TempDir() + args := []string{"--home", home, "preset", "show", "nope"} + + pythonCombined, _ := exec.Command(reference, args...).CombinedOutput() + wantStderr := extractLast(string(pythonCombined)) + + goCmd := exec.Command(binary, args...) + goCombined, _ := goCmd.CombinedOutput() + gotStderr := extractLast(string(goCombined)) + + if gotStderr != wantStderr { + t.Fatalf("go stderr = %q, want (python reference) %q", gotStderr, wantStderr) + } + if !strings.HasPrefix(gotStderr, `{"error": `) { + t.Fatalf("go stderr does not use Python's default json.dumps separators: %q", gotStderr) + } +} + +func extractLast(s string) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + return lines[len(lines)-1] +} diff --git a/go/cmd/sumctl-go/main.go b/go/cmd/sumctl-go/main.go index 3b83729..ce5c9ae 100644 --- a/go/cmd/sumctl-go/main.go +++ b/go/cmd/sumctl-go/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "os" @@ -11,6 +10,7 @@ import ( "syscall" "github.com/douglasjarquin/sum/go/internal/cli" + "github.com/douglasjarquin/sum/go/internal/ordjson" ) func main() { @@ -23,9 +23,11 @@ func main() { if errors.As(err, &exitErr) { os.Exit(exitErr.Code) } - payload, marshalErr := json.Marshal(map[string]string{"error": err.Error()}) + errorValue := ordjson.NewObject() + errorValue.Set("error", err.Error()) + payload, marshalErr := ordjson.MarshalCompact(errorValue) if marshalErr != nil { - fmt.Fprintln(os.Stderr, `{"error":"sumctl-go failed"}`) + fmt.Fprintln(os.Stderr, `{"error": "sumctl-go failed"}`) } else { fmt.Fprintln(os.Stderr, string(payload)) } diff --git a/go/cmd/sumctl-go/main_test.go b/go/cmd/sumctl-go/main_test.go index 88a36d6..e4fb7ea 100644 --- a/go/cmd/sumctl-go/main_test.go +++ b/go/cmd/sumctl-go/main_test.go @@ -53,7 +53,7 @@ func TestCompiledEntrypointCancellationExitsOnce(t *testing.T) { if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 { t.Fatalf("compiled exit = %v, want status 1", err) } - if stdout.Len() != 0 || !strings.Contains(stderr.String(), `"error":"context canceled"`) { + if stdout.Len() != 0 || !strings.Contains(stderr.String(), `"error": "context canceled"`) { t.Fatalf("compiled cancellation output stdout=%q stderr=%q", stdout.String(), stderr.String()) } } diff --git a/go/internal/cli/doctor_test.go b/go/internal/cli/doctor_test.go new file mode 100644 index 0000000..6dab99b --- /dev/null +++ b/go/internal/cli/doctor_test.go @@ -0,0 +1,89 @@ +package cli + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// Safe against the live installation: doctor is documented "never binds" and +// this port only reads (tool lookups, a herdr pane-get, file existence +// checks) — the same observation `./bin/sumctl doctor` already performs here. +func TestDoctor_matchesThePythonReferenceInThisDevCheckout(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + if os.Getenv("HERDR_ENV") != "1" || os.Getenv("HERDR_PANE_ID") == "" { + t.Skip("not running inside a live Herdr pane") + } + + home := filepath.Join(repoRoot, ".sum") + args := []string{"--home", home, "doctor"} + + pythonCmd := exec.Command(reference, args...) + want, pythonErr := pythonCmd.Output() + pythonExit := 0 + if pythonErr != nil { + if exitErr, ok := pythonErr.(*exec.ExitError); ok { + pythonExit = exitErr.ExitCode() + } else { + t.Fatalf("python reference failed: %v", pythonErr) + } + } + + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs(args) + goExit := 0 + if err := root.ExecuteContext(context.Background()); err != nil { + if exitErr, ok := err.(*ExitError); ok { + goExit = exitErr.Code + } else { + t.Fatalf("go command failed: %v (stderr=%s)", err, stderr.String()) + } + } + + if stdout.String() != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", stdout.String(), want) + } + if goExit != pythonExit { + t.Fatalf("go exit = %d, want (python reference) %d", goExit, pythonExit) + } +} + +func TestDoctor_fallsBackToReferenceWithExtraArgs(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + reference := filepath.Join(dir, "reference.sh") + if err := os.WriteFile(reference, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$SUM_GO_ARGS_FILE\"\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("SUM_GO_ARGS_FILE", argsFile) + + home := filepath.Join(dir, "state") + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs([]string{"--home", home, "doctor", "--unexpected"}) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("execute: %v (stderr=%s)", err, stderr.String()) + } + got, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + want := "--home\n" + home + "\ndoctor\n--unexpected\n" + if string(got) != want { + t.Fatalf("reference argv = %q, want %q", got, want) + } +} diff --git a/go/internal/cli/graph_config_test.go b/go/internal/cli/graph_config_test.go new file mode 100644 index 0000000..c9f6890 --- /dev/null +++ b/go/internal/cli/graph_config_test.go @@ -0,0 +1,135 @@ +package cli + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestGraphConfig_matchesThePythonReferenceAcrossScenarios(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + + installationReleases := filepath.Join(repoRoot, "..", "..", "..", ".local", "releases") + pinned := findPinnedCodegraph(t, installationReleases) + if pinned == "" { + t.Skip("no pinned codegraph release found to exercise the available-tool path") + } + + cases := []struct { + name string + env []string + args []string + expect string + }{ + {name: "claude snippet", env: []string{"SUM_CODEGRAPH_BIN=" + pinned}, args: []string{"graph", "config", "--harness", "claude"}}, + {name: "codex snippet", env: []string{"SUM_CODEGRAPH_BIN=" + pinned}, args: []string{"graph", "config", "--harness", "codex"}}, + {name: "cursor snippet", env: []string{"SUM_CODEGRAPH_BIN=" + pinned}, args: []string{"graph", "config", "--harness", "cursor"}}, + {name: "opencode snippet", env: []string{"SUM_CODEGRAPH_BIN=" + pinned}, args: []string{"graph", "config", "--harness", "opencode"}}, + {name: "raw snippet", env: []string{"SUM_CODEGRAPH_BIN=" + pinned}, args: []string{"graph", "config", "--harness", "claude", "--raw"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + fullArgs := append([]string{"--home", home}, tc.args...) + + pythonCmd := exec.Command(reference, fullArgs...) + pythonCmd.Env = append(os.Environ(), tc.env...) + want, err := pythonCmd.Output() + if err != nil { + t.Fatalf("python reference failed: %v", err) + } + + for _, kv := range tc.env { + parts := splitEnv(kv) + t.Setenv(parts[0], parts[1]) + } + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs(fullArgs) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("go command failed: %v (stderr=%s)", err, stderr.String()) + } + + if stdout.String() != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", stdout.String(), want) + } + }) + } +} + +func TestGraphConfig_missingToolErrorMatchesPythonReference(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + + home := t.TempDir() + args := []string{"--home", home, "graph", "config", "--harness", "claude"} + + pythonCombined, _ := exec.Command(reference, args...).CombinedOutput() + + dir := t.TempDir() + binary := filepath.Join(dir, "sumctl-go") + build := exec.Command("go", "build", "-trimpath", "-buildvcs=false", "-o", binary, "../../cmd/sumctl-go") + build.Dir = "." + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("go build failed: %v\n%s", err, output) + } + goCmd := exec.Command(binary, args...) + goCmd.Env = append(os.Environ(), "SUM_PYTHON_HELPER="+reference) + goCombined, _ := goCmd.CombinedOutput() + + if string(goCombined) != string(pythonCombined) { + t.Fatalf("go combined output = %q, want (python reference) %q", goCombined, pythonCombined) + } +} + +func findPinnedCodegraph(t *testing.T, releasesGlobRoot string) string { + t.Helper() + matches, err := filepath.Glob(filepath.Join(releasesGlobRoot, "*", ".local", "bin", "codegraph")) + if err != nil || len(matches) == 0 { + return "" + } + for _, m := range matches { + if info, statErr := os.Lstat(m); statErr == nil && info.Mode()&os.ModeSymlink != 0 { + if _, resolveErr := filepath.EvalSymlinks(m); resolveErr == nil { + return m + } + continue + } + if _, statErr := os.Stat(m); statErr == nil { + return m + } + } + return "" +} + +func splitEnv(kv string) [2]string { + for i := 0; i < len(kv); i++ { + if kv[i] == '=' { + return [2]string{kv[:i], kv[i+1:]} + } + } + return [2]string{kv, ""} +} diff --git a/go/internal/cli/init_test.go b/go/internal/cli/init_test.go new file mode 100644 index 0000000..39dc4de --- /dev/null +++ b/go/internal/cli/init_test.go @@ -0,0 +1,75 @@ +package cli + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// Safe against the live installation: the non-designated branch of `init` +// never writes (no store.lock, no atomic_json) — same read-only observation +// `./bin/sumctl init` already performs on every ordinary invocation here. +func TestInit_matchesThePythonReferenceInThisDevCheckout(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + if os.Getenv("HERDR_ENV") != "1" || os.Getenv("HERDR_PANE_ID") == "" { + t.Skip("not running inside a live Herdr pane") + } + + home := filepath.Join(repoRoot, ".sum") + args := []string{"--home", home, "init"} + + want, err := exec.Command(reference, args...).Output() + if err != nil { + t.Fatalf("python reference failed: %v", err) + } + + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs(args) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("go command failed: %v (stderr=%s)", err, stderr.String()) + } + + if stdout.String() != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", stdout.String(), want) + } +} + +func TestInit_fallsBackToReferenceWhenRequestedRoleUnrecognized(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + reference := filepath.Join(dir, "reference.sh") + if err := os.WriteFile(reference, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$SUM_GO_ARGS_FILE\"\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("SUM_GO_ARGS_FILE", argsFile) + + home := filepath.Join(dir, "state") + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs([]string{"--home", home, "init", "--role", "bogus"}) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("execute: %v (stderr=%s)", err, stderr.String()) + } + got, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + want := "--home\n" + home + "\ninit\n--role\nbogus\n" + if string(got) != want { + t.Fatalf("reference argv = %q, want %q", got, want) + } +} diff --git a/go/internal/cli/metadata_snippet_test.go b/go/internal/cli/metadata_snippet_test.go new file mode 100644 index 0000000..13e6d16 --- /dev/null +++ b/go/internal/cli/metadata_snippet_test.go @@ -0,0 +1,63 @@ +package cli + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestMetadataSnippet_matchesThePythonReferenceAcrossScenarios(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + + cases := []struct { + name string + home func(t *testing.T) string + args []string + }{ + {name: "plain", home: func(t *testing.T) string { return t.TempDir() }, args: []string{"metadata", "snippet"}}, + {name: "raw", home: func(t *testing.T) string { return t.TempDir() }, args: []string{"metadata", "snippet", "--raw"}}, + {name: "home path contains spaces (exercises shlex quoting)", home: func(t *testing.T) string { + dir := filepath.Join(t.TempDir(), "dir with spaces") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + return dir + }, args: []string{"metadata", "snippet"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := tc.home(t) + fullArgs := append([]string{"--home", home}, tc.args...) + + want, err := exec.Command(reference, fullArgs...).Output() + if err != nil { + t.Fatalf("python reference failed: %v", err) + } + + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs(fullArgs) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("go command failed: %v (stderr=%s)", err, stderr.String()) + } + + if stdout.String() != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", stdout.String(), want) + } + }) + } +} diff --git a/go/internal/cli/preset_show_test.go b/go/internal/cli/preset_show_test.go new file mode 100644 index 0000000..082057c --- /dev/null +++ b/go/internal/cli/preset_show_test.go @@ -0,0 +1,65 @@ +package cli + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestPresetListAndShow_matchThePythonReferenceStdout(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + + settingsJSON := `{"schema": 1, "worker": {"preset": "fast"}, "presets": {"fast": {"harness": "codex", "model": "gpt-5", "reasoning": "high", "args": ["--flag"], "revision": 3}, "slow": {"harness": "claude", "revision": 1}}}` + + cases := []struct { + name string + writeSettings bool + args []string + }{ + {name: "list on empty store", args: []string{"preset", "list"}}, + {name: "list with presets", writeSettings: true, args: []string{"preset", "list"}}, + {name: "show a preset with model/reasoning/args", writeSettings: true, args: []string{"preset", "show", "fast"}}, + {name: "show a bare preset", writeSettings: true, args: []string{"preset", "show", "slow"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + if tc.writeSettings { + if err := os.WriteFile(filepath.Join(home, "settings.json"), []byte(settingsJSON), 0o600); err != nil { + t.Fatal(err) + } + } + fullArgs := append([]string{"--home", home}, tc.args...) + + want, err := exec.Command(reference, fullArgs...).Output() + if err != nil { + t.Fatalf("python reference failed: %v", err) + } + + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs(fullArgs) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("go command failed: %v (stderr=%s)", err, stderr.String()) + } + + if stdout.String() != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", stdout.String(), want) + } + }) + } +} diff --git a/go/internal/cli/release_contract_test.go b/go/internal/cli/release_contract_test.go new file mode 100644 index 0000000..c260ab7 --- /dev/null +++ b/go/internal/cli/release_contract_test.go @@ -0,0 +1,37 @@ +package cli + +import ( + "bytes" + "context" + "os/exec" + "path/filepath" + "testing" +) + +func TestReleaseContract_matchesThePythonReferenceByteForByte(t *testing.T) { + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := exec.LookPath("python3"); statErr != nil { + t.Skip("python3 not on PATH") + } + + home := t.TempDir() + want, err := exec.Command(reference, "--home", home, "release-contract").Output() + if err != nil { + t.Fatalf("python reference failed: %v", err) + } + + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs([]string{"--home", home, "release-contract"}) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("go release-contract failed: %v (stderr=%s)", err, stderr.String()) + } + + if stdout.String() != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", stdout.String(), want) + } +} diff --git a/go/internal/cli/root.go b/go/internal/cli/root.go index b77241f..1db83a8 100644 --- a/go/internal/cli/root.go +++ b/go/internal/cli/root.go @@ -2,11 +2,22 @@ package cli import ( "context" + "encoding/json" "fmt" "io" "os" "os/exec" + "path/filepath" + "strings" + "github.com/douglasjarquin/sum/go/internal/contract" + "github.com/douglasjarquin/sum/go/internal/doctor" + "github.com/douglasjarquin/sum/go/internal/graph" + "github.com/douglasjarquin/sum/go/internal/metadata" + "github.com/douglasjarquin/sum/go/internal/ordjson" + "github.com/douglasjarquin/sum/go/internal/roleinit" + "github.com/douglasjarquin/sum/go/internal/settings" + "github.com/douglasjarquin/sum/go/internal/store" "github.com/spf13/cobra" ) @@ -71,6 +82,168 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { _, _ = io.WriteString(cmd.OutOrStdout(), cmd.UsageString()) }) + root.AddCommand(&cobra.Command{ + Use: "release-contract", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return emitJSON(cmd.OutOrStdout(), contract.BuildRelease()) + }, + }) + + root.AddCommand(&cobra.Command{ + Use: "settings", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 && args[0] == "show" && opts.homeSet { + st, err := store.Open(opts.home) + if err != nil { + return err + } + view, err := settings.CapacityView(st) + if err != nil { + return err + } + return emitOrdjson(cmd.OutOrStdout(), view) + } + return opts.compat(cmd.Context(), append([]string{"settings"}, args...)) + }, + }) + + root.AddCommand(&cobra.Command{ + Use: "preset", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.homeSet { + st, err := store.Open(opts.home) + if err != nil { + return err + } + switch { + case len(args) == 1 && args[0] == "list": + view, err := settings.PresetList(st) + if err != nil { + return err + } + return emitOrdjson(cmd.OutOrStdout(), view) + case len(args) == 2 && args[0] == "show": + view, err := settings.PresetShow(st, args[1]) + if err != nil { + return err + } + return emitOrdjson(cmd.OutOrStdout(), view) + } + } + return opts.compat(cmd.Context(), append([]string{"preset"}, args...)) + }, + }) + + root.AddCommand(&cobra.Command{ + Use: "graph", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.homeSet && len(args) >= 1 && args[0] == "config" { + if harness, raw, ok := parseGraphConfigArgs(args[1:]); ok && graph.IsValidHarness(harness) { + if runtimeRoot := runtimeRootFromReference(opts.reference); runtimeRoot != "" { + if _, err := store.Open(opts.home); err != nil { + return err + } + view, err := graph.Config(runtimeRoot, harness) + if err != nil { + return err + } + if raw { + snippetValue, _ := view.Get("snippet") + snippet, _ := snippetValue.(string) + if !strings.HasSuffix(snippet, "\n") { + snippet += "\n" + } + _, writeErr := io.WriteString(cmd.OutOrStdout(), snippet) + return writeErr + } + return emitOrdjson(cmd.OutOrStdout(), view) + } + } + } + return opts.compat(cmd.Context(), append([]string{"graph"}, args...)) + }, + }) + + root.AddCommand(&cobra.Command{ + Use: "metadata", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.homeSet && opts.reference != "" { + raw := len(args) == 2 && args[0] == "snippet" && args[1] == "--raw" + plain := len(args) == 1 && args[0] == "snippet" + if raw || plain { + if _, err := store.Open(opts.home); err != nil { + return err + } + view := metadata.Snippet(opts.reference, opts.home) + if raw { + tomlValue, _ := view.Get("toml") + toml, _ := tomlValue.(string) + _, writeErr := io.WriteString(cmd.OutOrStdout(), toml) + return writeErr + } + return emitOrdjson(cmd.OutOrStdout(), view) + } + } + return opts.compat(cmd.Context(), append([]string{"metadata"}, args...)) + }, + }) + + root.AddCommand(&cobra.Command{ + Use: "doctor", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.homeSet && opts.reference != "" && len(args) == 0 { + if st, err := store.Open(opts.home); err == nil { + view := doctor.Doctor(runtimeRootFromReference(opts.reference), st) + if emitErr := emitOrdjson(cmd.OutOrStdout(), view); emitErr != nil { + return emitErr + } + if okValue, _ := view.Get("ok"); okValue != true { + return &ExitError{Code: 1} + } + return nil + } + } + return opts.compat(cmd.Context(), append([]string{"doctor"}, args...)) + }, + }) + + root.AddCommand(&cobra.Command{ + Use: "init", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.homeSet && opts.reference != "" { + if role, task, ok := parseInitArgs(args); ok { + st, err := store.Open(opts.home) + if err == nil && !st.Designated() { + root := runtimeRootFromReference(opts.reference) + ctx, ctxErr := store.Context(root) + if ctxErr != nil { + return ctxErr + } + view, initErr := roleinit.Init(root, st, ctx, role, task) + if initErr != nil { + return initErr + } + return emitOrdjson(cmd.OutOrStdout(), view) + } + } + } + return opts.compat(cmd.Context(), append([]string{"init"}, args...)) + }, + }) + for _, name := range compatibilityCommands { command := &cobra.Command{ Use: name, @@ -86,7 +259,87 @@ func NewRoot(reference string, out, errOut io.Writer) *cobra.Command { } var compatibilityCommands = []string{ - "doctor", "init", "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "metadata", "attention", "bind", "backup", "settings", "preset", "project", "herdr", "graph", "dev", "brief", "refresh", "release", "update", + "status", "inbox", "prepare", "dispatch", "start", "help", "context", "notes", "env", "show", "notice", "archive", "ask", "answer", "report", "resolve", "review", "verify", "pr", "cleanup", "pump", "hook", "attention", "bind", "backup", "project", "herdr", "dev", "brief", "refresh", "release", "update", +} + +func parseInitArgs(tokens []string) (role, task string, ok bool) { + roles := map[string]bool{"coordinator": true, "worker": true, "developer": true} + reclaimSeen := false + for i := 0; i < len(tokens); i++ { + token := tokens[i] + switch { + case token == "--role": + if role != "" || i+1 >= len(tokens) { + return "", "", false + } + i++ + role = tokens[i] + case strings.HasPrefix(token, "--role="): + if role != "" { + return "", "", false + } + role = strings.TrimPrefix(token, "--role=") + case token == "--task": + if task != "" || i+1 >= len(tokens) { + return "", "", false + } + i++ + task = tokens[i] + case strings.HasPrefix(token, "--task="): + if task != "" { + return "", "", false + } + task = strings.TrimPrefix(token, "--task=") + case token == "--reclaim": + if reclaimSeen { + return "", "", false + } + reclaimSeen = true + default: + return "", "", false + } + } + if role != "" && !roles[role] { + return "", "", false + } + return role, task, true +} + +func parseGraphConfigArgs(tokens []string) (harness string, raw bool, ok bool) { + for i := 0; i < len(tokens); i++ { + token := tokens[i] + switch { + case token == "--harness": + if harness != "" || i+1 >= len(tokens) { + return "", false, false + } + i++ + harness = tokens[i] + case strings.HasPrefix(token, "--harness="): + if harness != "" { + return "", false, false + } + harness = strings.TrimPrefix(token, "--harness=") + case token == "--raw": + if raw { + return "", false, false + } + raw = true + default: + return "", false, false + } + } + if harness == "" { + return "", false, false + } + return harness, raw, true +} + +func runtimeRootFromReference(reference string) string { + if reference == "" { + return "" + } + return filepath.Dir(filepath.Dir(reference)) } func (o *rootOptions) compat(ctx context.Context, args []string) error { @@ -111,6 +364,24 @@ func (o *rootOptions) compat(ctx context.Context, args []string) error { return nil } +func emitJSON(out io.Writer, value any) error { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(out, string(encoded)) + return err +} + +func emitOrdjson(out io.Writer, value any) error { + encoded, err := ordjson.MarshalIndent(value) + if err != nil { + return err + } + _, err = fmt.Fprintln(out, string(encoded)) + return err +} + func normalizeHome(args []string, home string, homeSet bool) []string { var prefix []string if homeSet { diff --git a/go/internal/cli/settings_fallback_test.go b/go/internal/cli/settings_fallback_test.go new file mode 100644 index 0000000..7cbceb7 --- /dev/null +++ b/go/internal/cli/settings_fallback_test.go @@ -0,0 +1,46 @@ +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" +) + +func TestSettings_fallsBackToReferenceWhenNotShowOrHomeUnset(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "args") + reference := filepath.Join(dir, "reference.sh") + if err := os.WriteFile(reference, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$SUM_GO_ARGS_FILE\"\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("SUM_GO_ARGS_FILE", argsFile) + + cases := []struct { + name string + args []string + want string + }{ + {name: "show without --home", args: []string{"settings", "show"}, want: "settings\nshow\n"}, + {name: "set with --home", args: []string{"--home", filepath.Join(dir, "state"), "settings", "set", "--global", "3"}, want: "--home\n" + filepath.Join(dir, "state") + "\nsettings\nset\n--global\n3\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs(tc.args) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("execute: %v (stderr=%s)", err, stderr.String()) + } + got, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + if string(got) != tc.want { + t.Fatalf("reference argv = %q, want %q", got, tc.want) + } + os.Remove(argsFile) + }) + } +} diff --git a/go/internal/cli/settings_show_test.go b/go/internal/cli/settings_show_test.go new file mode 100644 index 0000000..f926bc0 --- /dev/null +++ b/go/internal/cli/settings_show_test.go @@ -0,0 +1,108 @@ +package cli + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func runPythonSettingsShow(t *testing.T, reference, home string) []byte { + t.Helper() + out, err := exec.Command(reference, "--home", home, "settings", "show").Output() + if err != nil { + t.Fatalf("python reference failed: %v", err) + } + return out +} + +func runGoSettingsShow(t *testing.T, reference, home string) (string, string) { + t.Helper() + var stdout, stderr bytes.Buffer + root := NewRoot(reference, &stdout, &stderr) + root.SetArgs([]string{"--home", home, "settings", "show"}) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("go settings show failed: %v (stderr=%s)", err, stderr.String()) + } + return stdout.String(), stderr.String() +} + +func TestSettingsShow_matchesThePythonReferenceAcrossScenarios(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + reference := filepath.Join(repoRoot, "bin", "sumctl") + if _, statErr := os.Stat(reference); statErr != nil { + t.Skipf("reference bin/sumctl not found: %v", statErr) + } + + cases := []struct { + name string + files map[string]string + }{ + {name: "fresh store, no settings.json"}, + { + name: "populated settings", + files: map[string]string{ + "settings.json": `{"schema": 1, "capacity": {"global": 3}, "worker": {"harness": "claude", "model": "sonnet"}, "presets": {"fast": {"harness": "codex", "revision": 2}}, "reviewer": {"preset": "fast"}}`, + }, + }, + { + name: "invalid capacity value", + files: map[string]string{ + "settings.json": `{"schema": 1, "capacity": {"global": 0}}`, + }, + }, + { + name: "unknown preset reference", + files: map[string]string{ + "settings.json": `{"schema": 1, "worker": {"preset": "missing"}}`, + }, + }, + { + name: "unknown capacity key", + files: map[string]string{ + "settings.json": `{"schema": 1, "capacity": {"global": 5, "bogus": 1}}`, + }, + }, + { + name: "preset arg conflicts with resolved model", + files: map[string]string{ + "settings.json": `{"schema": 1, "presets": {"fast": {"harness": "claude", "model": "sonnet", "args": ["--model", "haiku"]}}}`, + }, + }, + { + name: "occupancy across active and archived tasks", + files: map[string]string{ + "tasks/t-aaaaaaaaaaaa/task.json": `{"schema": 1, "id": "t-aaaaaaaaaaaa", "status": "running", "repository": "owner/repo"}`, + "tasks/t-bbbbbbbbbbbb/task.json": `{"schema": 1, "id": "t-bbbbbbbbbbbb", "status": "archived", "repository": "owner/repo"}`, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + for relPath, content := range tc.files { + full := filepath.Join(home, relPath) + if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + want := runPythonSettingsShow(t, reference, home) + got, _ := runGoSettingsShow(t, reference, home) + if got != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", got, want) + } + }) + } +} diff --git a/go/internal/contract/contract.go b/go/internal/contract/contract.go new file mode 100644 index 0000000..3f0df05 --- /dev/null +++ b/go/internal/contract/contract.go @@ -0,0 +1,40 @@ +package contract + +const ( + SumVersion = "0.1.0" + StateSchema = 1 + BriefSchema = 1 + HerdrCLI = "0.9.0" +) + +var MCP = MCPContract{Server: "herdr-mesh-sum", Version: SumVersion, Tools: 10} + +type MCPContract struct { + Server string `json:"server"` + Version string `json:"version"` + Tools int `json:"tools"` +} + +type Contracts struct { + HerdrCLI string `json:"herdr_cli"` + MCP MCPContract `json:"mcp"` +} + +type Supports struct { + StateSchema []int `json:"state_schema"` + BriefSchema []int `json:"brief_schema"` +} + +type Release struct { + SumVersion string `json:"sum_version"` + Contracts Contracts `json:"contracts"` + Supports Supports `json:"supports"` +} + +func BuildRelease() Release { + return Release{ + SumVersion: SumVersion, + Contracts: Contracts{HerdrCLI: HerdrCLI, MCP: MCP}, + Supports: Supports{StateSchema: []int{StateSchema}, BriefSchema: []int{BriefSchema}}, + } +} diff --git a/go/internal/contract/contract_test.go b/go/internal/contract/contract_test.go new file mode 100644 index 0000000..8bb0e27 --- /dev/null +++ b/go/internal/contract/contract_test.go @@ -0,0 +1,36 @@ +package contract + +import ( + "encoding/json" + "testing" +) + +func TestBuildRelease_matchesThePythonReferenceShape(t *testing.T) { + release := BuildRelease() + encoded, err := json.MarshalIndent(release, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{ + "sum_version": "0.1.0", + "contracts": { + "herdr_cli": "0.9.0", + "mcp": { + "server": "herdr-mesh-sum", + "version": "0.1.0", + "tools": 10 + } + }, + "supports": { + "state_schema": [ + 1 + ], + "brief_schema": [ + 1 + ] + } +}` + if string(encoded) != want { + t.Fatalf("release contract JSON =\n%s\nwant\n%s", encoded, want) + } +} diff --git a/go/internal/doctor/doctor.go b/go/internal/doctor/doctor.go new file mode 100644 index 0000000..42b6264 --- /dev/null +++ b/go/internal/doctor/doctor.go @@ -0,0 +1,227 @@ +package doctor + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/douglasjarquin/sum/go/internal/contract" + "github.com/douglasjarquin/sum/go/internal/graph" + "github.com/douglasjarquin/sum/go/internal/herdrclient" + "github.com/douglasjarquin/sum/go/internal/ordjson" + "github.com/douglasjarquin/sum/go/internal/store" + "github.com/douglasjarquin/sum/go/internal/toolpath" +) + +const herdrVersionPin = "0.9.0" + +var toolNames = []string{"python3", "node", "git", "gh", "herdr", "quota-axi", "lsof"} + +var harnessExecutables = []struct { + Kind string + Exe string +}{ + {"codex", "codex"}, + {"claude", "claude"}, + {"grok", "grok"}, + {"cursor", "cursor-agent"}, + {"pi", "pi"}, + {"opencode", "opencode"}, + {"gemini", "gemini"}, + {"omp", "omp"}, + {"copilot", "copilot"}, +} + +func Doctor(runtimeRoot string, s *store.Store) *ordjson.Object { + rows := make([]any, 0, 12) + + for _, name := range toolNames { + row := ordjson.NewObject() + row.Set("tool", name) + if path, err := toolpath.Find(runtimeRoot, name); err == nil { + row.Set("path", path) + row.Set("ok", true) + } else { + row.Set("ok", false) + row.Set("detail", err.Error()) + } + rows = append(rows, row) + } + + herdrVersionRow := ordjson.NewObject() + herdrVersionRow.Set("tool", "herdr-version") + herdrPath, herdrPathErr := toolpath.Find(runtimeRoot, "herdr") + if herdrPathErr == nil { + if found, err := herdrclient.EnsureVersion(herdrPath, herdrVersionPin); err == nil { + herdrVersionRow.Set("ok", true) + herdrVersionRow.Set("detail", found) + } else { + herdrVersionRow.Set("ok", false) + herdrVersionRow.Set("detail", err.Error()) + } + } else { + herdrVersionRow.Set("ok", false) + herdrVersionRow.Set("detail", herdrPathErr.Error()) + } + rows = append(rows, herdrVersionRow) + + rows = append(rows, ghAttachCheck(runtimeRoot)) + + role := ordjson.NewObject() + role.Set("tool", "role") + role.Set("ok", true) + designated := s.Designated() + role.Set("installation", designated) + if designated { + owner, _ := s.Owner() + if owner != nil { + role.Set("coordinator", owner) + } else { + role.Set("coordinator", nil) + } + } else { + role.Set("coordinator", nil) + } + + herdrContextRow := ordjson.NewObject() + herdrContextRow.Set("tool", "herdr-context") + ctx, ctxErr := store.Context(runtimeRoot) + if ctxErr == nil { + if herdrPathErr == nil { + pane, _ := ctx.Get("pane") + session, _ := ctx.Get("session") + paneStr, _ := pane.(string) + sessionStr, _ := session.(string) + _, callErr := herdrclient.Call(herdrPath, sessionStr, 10*time.Second, "pane", "get", paneStr) + if callErr == nil { + herdrContextRow.Set("ok", true) + herdrContextRow.Set("detail", ctx) + var registration *ordjson.Object + if designated { + registration, _ = s.Registration(store.EndpointFromContext(ctx)) + } + if registration != nil { + roleValue, _ := registration.Get("role") + roleStr, _ := roleValue.(string) + role.Set("registered", roleStr) + role.Set("detail", "Registered as "+roleStr+".") + } else { + role.Set("registered", nil) + role.Set("detail", "This pane is not registered. Run ./bin/sumctl init to register explicitly; doctor never binds.") + } + } else { + herdrContextRow.Set("ok", false) + herdrContextRow.Set("detail", callErr.Error()) + } + } else { + herdrContextRow.Set("ok", false) + herdrContextRow.Set("detail", herdrPathErr.Error()) + } + } else { + herdrContextRow.Set("ok", false) + herdrContextRow.Set("detail", ctxErr.Error()) + } + rows = append(rows, herdrContextRow) + rows = append(rows, role) + + installed := ordjson.NewObject() + anyInstalled := false + for _, h := range harnessExecutables { + if path := findExecutable(runtimeRoot, h.Exe); path != "" { + installed.Set(h.Kind, path) + anyInstalled = true + } + } + harnessRow := ordjson.NewObject() + harnessRow.Set("tool", "harness") + harnessRow.Set("ok", anyInstalled) + harnessRow.Set("installed", installed) + rows = append(rows, harnessRow) + + meshRow := ordjson.NewObject() + meshRow.Set("tool", "mesh") + meshRow.Set("ok", isFile(filepath.Join(runtimeRoot, ".deps", "herdr-mesh", ".sum-patched"))) + rows = append(rows, meshRow) + + graphTool := graph.Tool(runtimeRoot) + available, _ := graphTool.Get("available") + pinned, _ := graphTool.Get("pinned") + version, _ := graphTool.Get("version") + path, _ := graphTool.Get("path") + reason, _ := graphTool.Get("reason") + codegraphRow := ordjson.NewObject() + codegraphRow.Set("tool", "codegraph") + codegraphRow.Set("ok", true) + codegraphRow.Set("available", available) + codegraphRow.Set("pinned", pinned) + codegraphRow.Set("version", version) + codegraphRow.Set("path", path) + if available == true { + codegraphRow.Set("detail", "pinned codegraph available; new checkouts get a local index") + } else { + reasonStr, _ := reason.(string) + codegraphRow.Set("detail", "graph optional and unavailable: "+reasonStr) + } + rows = append(rows, codegraphRow) + + allOK := true + for _, r := range rows { + obj := r.(*ordjson.Object) + if ok, _ := obj.Get("ok"); ok != true { + allOK = false + break + } + } + + result := ordjson.NewObject() + result.Set("version", contract.SumVersion) + result.Set("home", s.Home) + result.Set("runtime", runtimeRoot) + result.Set("installation", runtimeRoot) + result.Set("checks", rows) + result.Set("ok", allOK) + result.Set("note", "Observation only: nothing was bound or written. No auth changes or permission bypasses. Authenticate the chosen harness and gh separately.") + return result +} + +func ghAttachCheck(runtimeRoot string) *ordjson.Object { + row := ordjson.NewObject() + row.Set("tool", "gh-attach") + ghPath, err := toolpath.Find(runtimeRoot, "gh") + if err != nil { + row.Set("ok", true) + row.Set("supported", false) + row.Set("detail", err.Error()) + return row + } + out, _ := exec.Command(ghPath, "pr", "edit", "--help").Output() + attach := strings.Contains(string(out), "--attach") + row.Set("ok", true) + row.Set("supported", attach) + if attach { + row.Set("detail", "gh pr edit --attach available; `pr evidence` can publish") + } else { + row.Set("detail", "this runtime's gh has no --attach (GitHub CLI 2.99+); evidence publication defers until a release with the current pin is active") + } + return row +} + +func findExecutable(runtimeRoot, exe string) string { + if path, err := exec.LookPath(exe); err == nil { + return path + } + local := filepath.Join(runtimeRoot, ".local", "bin", exe) + if isFile(local) { + return local + } + return "" +} + +func isFile(path string) bool { + if info, err := os.Stat(path); err == nil { + return !info.IsDir() + } + return false +} diff --git a/go/internal/graph/graph.go b/go/internal/graph/graph.go new file mode 100644 index 0000000..ff2ec02 --- /dev/null +++ b/go/internal/graph/graph.go @@ -0,0 +1,164 @@ +package graph + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/douglasjarquin/sum/go/internal/ordjson" +) + +const ( + CodegraphVersion = "1.5.0" + CodegraphPackage = "@colbymchenry/codegraph" +) + +var HarnessChoices = []string{"claude", "codex", "cursor", "opencode"} + +func IsValidHarness(harness string) bool { + for _, h := range HarnessChoices { + if h == harness { + return true + } + } + return false +} + +func binPath(runtimeRoot string) string { + if override := os.Getenv("SUM_CODEGRAPH_BIN"); override != "" { + return override + } + return filepath.Join(runtimeRoot, ".local", "bin", "codegraph") +} + +func Tool(runtimeRoot string) *ordjson.Object { + path := binPath(runtimeRoot) + row := ordjson.NewObject() + row.Set("pinned", CodegraphVersion) + row.Set("path", path) + row.Set("available", false) + row.Set("version", nil) + row.Set("reason", nil) + + info, statErr := os.Stat(path) + if statErr != nil || info.IsDir() { + row.Set("reason", fmt.Sprintf("codegraph is not installed in this runtime (%s). `mise run setup` or a staged release links the pinned %s@%s; a global or floating installation is never used.", path, CodegraphPackage, CodegraphVersion)) + return row + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, path, "--version") + cmd.Env = append(os.Environ(), "CODEGRAPH_NO_DAEMON=1", "CODEGRAPH_NO_DOWNLOAD=1", "NO_COLOR=1") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + runErr := cmd.Run() + + var lines []string + for _, line := range strings.Split(stdout.String(), "\n") { + if trimmed := strings.TrimSpace(line); trimmed != "" { + lines = append(lines, trimmed) + } + } + var version string + if len(lines) > 0 { + version = lines[len(lines)-1] + row.Set("version", version) + } + + exitCode := 0 + if runErr != nil { + if exitErr, ok := runErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + row.Set("reason", fmt.Sprintf("codegraph did not answer `--version`: %s", runErr)) + return row + } + } + if exitCode != 0 || version == "" { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = strings.TrimSpace(stdout.String()) + } + if len(detail) > 300 { + detail = detail[len(detail)-300:] + } + row.Set("reason", fmt.Sprintf("codegraph at %s exited %d without a version: %s", path, exitCode, detail)) + return row + } + if version != CodegraphVersion { + row.Set("reason", fmt.Sprintf("codegraph %s at %s is not the tested pin %s; only the pinned release is used, nothing is upgraded or downgraded", version, path, CodegraphVersion)) + return row + } + row.Set("available", true) + return row +} + +func Config(runtimeRoot, harness string) (*ordjson.Object, error) { + tool := Tool(runtimeRoot) + available, _ := tool.Get("available") + if available != true { + reasonValue, _ := tool.Get("reason") + reason, _ := reasonValue.(string) + return nil, fmt.Errorf("No snippet: %s", reason) + } + commandValue, _ := tool.Get("path") + command, _ := commandValue.(string) + + var target, format, snippet string + switch harness { + case "claude": + target, format = ".mcp.json in the checkout (project scope)", "json" + snippet = mcpServersSnippet(command, []any{"serve", "--mcp"}) + case "codex": + target, format = ".codex/config.toml in the checkout", "toml" + snippet = fmt.Sprintf("[mcp_servers.codegraph]\ncommand = %s\nargs = [\"serve\", \"--mcp\"]\n", ordjson.QuoteString(command)) + case "cursor": + target, format = ".cursor/mcp.json in the checkout", "json" + snippet = mcpServersSnippet(command, []any{"serve", "--mcp", "--path", "${workspaceFolder}"}) + default: + target, format = "opencode.json in the checkout", "json" + snippet = opencodeSnippet(command) + } + + result := ordjson.NewObject() + result.Set("harness", harness) + result.Set("format", format) + result.Set("target", target) + result.Set("snippet", snippet) + result.Set("tool", tool) + result.Set("note", "Printed only; sum wrote no file, changed no permission list, and did not run `codegraph install`. The server this starts watches only the project it is started in; it is that harness session's process, and cleanup reports it as an occupant of the checkout until the session exits.") + return result, nil +} + +func mcpServersSnippet(command string, args []any) string { + inner := ordjson.NewObject() + inner.Set("type", "stdio") + inner.Set("command", command) + inner.Set("args", args) + servers := ordjson.NewObject() + servers.Set("codegraph", inner) + root := ordjson.NewObject() + root.Set("mcpServers", servers) + encoded, _ := ordjson.MarshalIndent(root) + return string(encoded) +} + +func opencodeSnippet(command string) string { + inner := ordjson.NewObject() + inner.Set("type", "local") + inner.Set("command", []any{command, "serve", "--mcp"}) + inner.Set("enabled", true) + mcp := ordjson.NewObject() + mcp.Set("codegraph", inner) + root := ordjson.NewObject() + root.Set("mcp", mcp) + encoded, _ := ordjson.MarshalIndent(root) + return string(encoded) +} diff --git a/go/internal/herdrclient/herdrclient.go b/go/internal/herdrclient/herdrclient.go new file mode 100644 index 0000000..4100cb9 --- /dev/null +++ b/go/internal/herdrclient/herdrclient.go @@ -0,0 +1,112 @@ +package herdrclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/douglasjarquin/sum/go/internal/ordjson" +) + +var sessionNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + +func run(herdrPath string, timeout time.Duration, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, herdrPath, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + name := filepath.Base(herdrPath) + if ctxErr := ctx.Err(); ctxErr == context.DeadlineExceeded { + return "", fmt.Errorf("%s: timed out after %s; its effect is unknown", name, timeout) + } + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = strings.TrimSpace(stdout.String()) + } + if len(detail) > 4000 { + detail = detail[len(detail)-4000:] + } + return "", fmt.Errorf("%s exited %d: %s", name, exitErr.ExitCode(), detail) + } + return "", fmt.Errorf("%s: %s", name, err) + } + return stdout.String(), nil +} + +func Call(herdrPath, session string, timeout time.Duration, args ...string) (any, error) { + if !sessionNamePattern.MatchString(session) { + return nil, fmt.Errorf("invalid session name") + } + options := args + for i, a := range args { + if a == "--" { + options = args[:i] + break + } + } + for _, a := range options { + if a == "--session" || strings.HasPrefix(a, "--session=") { + return nil, fmt.Errorf("do not override sum's explicit Herdr session inside command arguments") + } + } + fullArgs := append([]string{"--session", session}, args...) + stdout, err := run(herdrPath, timeout, fullArgs...) + if err != nil { + return nil, err + } + value, err := ordjson.Decode([]byte(stdout)) + if err != nil { + preview := stdout + if len(preview) > 300 { + preview = preview[:300] + } + return nil, fmt.Errorf("Herdr did not return JSON: %s", preview) + } + if obj, ok := value.(*ordjson.Object); ok { + if errValue, has := obj.Get("error"); has && errValue != nil && errValue != false { + encoded, marshalErr := json.Marshal(errValue) + if marshalErr == nil { + return nil, fmt.Errorf("Herdr: %s", encoded) + } + } + if resultValue, has := obj.Get("result"); has { + return resultValue, nil + } + } + return value, nil +} + +func Version(herdrPath string) (string, string, error) { + stdout, err := run(herdrPath, 20*time.Second, "--version") + if err != nil { + return "", "", err + } + found := strings.TrimSpace(stdout) + matched := regexp.MustCompile(`(?i)^herdr[ \t]+(\d+\.\d+\.\d+)$`).FindStringSubmatch(found) + if matched == nil { + return "", "", fmt.Errorf("Herdr did not report one exact stable semantic version; found %q. Run mise run setup; do not silently mix CLI contracts.", found) + } + return matched[1], found, nil +} + +func EnsureVersion(herdrPath, pinned string) (string, error) { + version, found, err := Version(herdrPath) + if err != nil { + return "", err + } + if version != pinned { + return "", fmt.Errorf("This MVP is pinned to Herdr %s; found %q. Run mise run setup; do not silently mix CLI contracts.", pinned, found) + } + return found, nil +} diff --git a/go/internal/metadata/metadata.go b/go/internal/metadata/metadata.go new file mode 100644 index 0000000..7c417cd --- /dev/null +++ b/go/internal/metadata/metadata.go @@ -0,0 +1,57 @@ +package metadata + +import ( + "strings" + + "github.com/douglasjarquin/sum/go/internal/ordjson" + "github.com/douglasjarquin/sum/go/internal/shquote" +) + +var TaskTokens = []string{"sum_state", "sum_task", "sum_repo", "sum_rev", "sum_pr"} + +var RootTokens = []string{"sum_inbox", "sum_tasks"} + +var SumStates = []string{ + "needs-attention", "needs-decision", "merged-cleanup-pending", "review-ready", "attention-blocked", "attention-exited", + "attention-closed", "attention-idle", "instruction-refresh-pending", "answer-pending", "pr-open", "verified", "preparing", "running", +} + +func snippetTOML() string { + return strings.Join([]string{ + "# sum: optional sidebar rows that render sum's task tokens. Merge into ~/.config/herdr/config.toml, then run", + "# `herdr server reload-config`. `rows` replaces the whole layout, so keep the built-in tokens you already use.", + "[ui.sidebar.agents]", + `rows = [["state_icon", "workspace", "tab"], ["agent", "$sum_state"], ["$sum_task", "$sum_inbox"]]`, + "", + "[ui.sidebar.spaces]", + `rows = [["state_icon", "workspace"], ["branch", "git_status"], ["$sum_state", "$sum_task"]]`, + "", + "# Optional: pop-up notifications for sum transitions need a toast delivery; sum sends them only after `metadata enable --notify`.", + "# [ui.toast]", + `# delivery = "herdr"`, + "", + }, "\n") +} + +func Snippet(sumctlPath, home string) *ordjson.Object { + tokens := ordjson.NewObject() + tokens.Set("task", toAny(TaskTokens)) + tokens.Set("coordinator", toAny(RootTokens)) + + result := ordjson.NewObject() + result.Set("toml", snippetTOML()) + result.Set("tokens", tokens) + result.Set("states", toAny(SumStates)) + result.Set("enable", shquote.CommandFor(sumctlPath, home, "metadata", "enable")) + result.Set("inbox", shquote.CommandFor(sumctlPath, home, "metadata", "inbox")) + result.Set("note", "Nothing here is written by sum: the snippet is text for the user to merge. Without these rows the tokens exist but stay out of sight; every other setting (theme, keybindings, labels, toast delivery) is the user's.") + return result +} + +func toAny(values []string) []any { + out := make([]any, len(values)) + for i, v := range values { + out[i] = v + } + return out +} diff --git a/go/internal/ordjson/differential_test.go b/go/internal/ordjson/differential_test.go new file mode 100644 index 0000000..a97eb09 --- /dev/null +++ b/go/internal/ordjson/differential_test.go @@ -0,0 +1,61 @@ +package ordjson + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestWriteFile_matchesPythonAtomicJSONByteForByte(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not on PATH") + } + repoRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + libPath := filepath.Join(repoRoot, "lib", "sumctl.py") + if _, err := os.Stat(libPath); err != nil { + t.Skipf("reference lib/sumctl.py not found: %v", err) + } + + eAcute := string(rune(0x00e9)) + musicalSymbol := string(rune(0x1d11e)) + stringValue := strings.Join([]string{ + "caf" + eAcute, + `\n`, + `\"`, + `\\`, + musicalSymbol, + }, " ") + input := `{"b": 1, "a": {"nested": true, "list": [1, 2, "x"]}, "s": "` + stringValue + `"}` + + pythonOut := filepath.Join(t.TempDir(), "python.json") + cmd := exec.Command("python3", "testdata/atomic_json_ref.py", libPath, pythonOut, input) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("python reference failed: %v\n%s", err, out) + } + want, err := os.ReadFile(pythonOut) + if err != nil { + t.Fatal(err) + } + + value, err := Decode([]byte(input)) + if err != nil { + t.Fatalf("decode: %v", err) + } + goOut := filepath.Join(t.TempDir(), "go.json") + if err := WriteFile(goOut, value); err != nil { + t.Fatalf("write: %v", err) + } + got, err := os.ReadFile(goOut) + if err != nil { + t.Fatal(err) + } + + if string(got) != string(want) { + t.Fatalf("go output =\n%s\nwant (python reference)\n%s", got, want) + } +} diff --git a/go/internal/ordjson/io.go b/go/internal/ordjson/io.go new file mode 100644 index 0000000..0664df6 --- /dev/null +++ b/go/internal/ordjson/io.go @@ -0,0 +1,61 @@ +package ordjson + +import ( + "fmt" + "os" + "path/filepath" +) + +func ReadFile(path string) (any, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("Cannot read %s: %w", path, err) + } + value, err := Decode(data) + if err != nil { + return nil, fmt.Errorf("Cannot read %s: %w", path, err) + } + return value, nil +} + +func WriteFile(path string, value any) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + encoded, err := MarshalIndent(value) + if err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".write-") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if _, err := tmp.Write(encoded); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write([]byte("\n")); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + directory, err := os.Open(dir) + if err != nil { + return err + } + defer directory.Close() + return directory.Sync() +} diff --git a/go/internal/ordjson/ordjson.go b/go/internal/ordjson/ordjson.go new file mode 100644 index 0000000..e6d2ae9 --- /dev/null +++ b/go/internal/ordjson/ordjson.go @@ -0,0 +1,297 @@ +package ordjson + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" +) + +type Object struct { + keys []string + values map[string]any +} + +func NewObject() *Object { + return &Object{values: map[string]any{}} +} + +func (o *Object) Set(key string, value any) { + if o.values == nil { + o.values = map[string]any{} + } + if _, exists := o.values[key]; !exists { + o.keys = append(o.keys, key) + } + o.values[key] = value +} + +func (o *Object) Get(key string) (any, bool) { + v, ok := o.values[key] + return v, ok +} + +func (o *Object) Delete(key string) { + if _, exists := o.values[key]; !exists { + return + } + delete(o.values, key) + for i, k := range o.keys { + if k == key { + o.keys = append(o.keys[:i], o.keys[i+1:]...) + break + } + } +} + +func (o *Object) Keys() []string { + return append([]string(nil), o.keys...) +} + +func (o *Object) Len() int { + return len(o.keys) +} + +func QuoteString(s string) string { + var buf bytes.Buffer + encodeString(&buf, s) + return buf.String() +} + +func Decode(data []byte) (any, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + value, err := decodeValue(dec) + if err != nil { + return nil, err + } + if dec.More() { + return nil, fmt.Errorf("trailing data after JSON value") + } + return value, nil +} + +func decodeValue(dec *json.Decoder) (any, error) { + tok, err := dec.Token() + if err != nil { + return nil, err + } + return decodeToken(dec, tok) +} + +func decodeToken(dec *json.Decoder, tok json.Token) (any, error) { + delim, ok := tok.(json.Delim) + if !ok { + return tok, nil + } + switch delim { + case '{': + obj := NewObject() + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, err + } + key, ok := keyTok.(string) + if !ok { + return nil, fmt.Errorf("expected string object key") + } + val, err := decodeValue(dec) + if err != nil { + return nil, err + } + obj.Set(key, val) + } + if _, err := dec.Token(); err != nil { + return nil, err + } + return obj, nil + case '[': + arr := []any{} + for dec.More() { + val, err := decodeValue(dec) + if err != nil { + return nil, err + } + arr = append(arr, val) + } + if _, err := dec.Token(); err != nil { + return nil, err + } + return arr, nil + default: + return nil, fmt.Errorf("unexpected delimiter %v", delim) + } +} + +const indentUnit = " " + +func MarshalIndent(value any) ([]byte, error) { + var buf bytes.Buffer + if err := encodeIndent(&buf, value, 0); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func MarshalCompact(value any) ([]byte, error) { + var buf bytes.Buffer + if err := encodeCompact(&buf, value); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func encodeCompact(buf *bytes.Buffer, value any) error { + switch v := value.(type) { + case *Object: + buf.WriteByte('{') + for i, k := range v.keys { + if i > 0 { + buf.WriteString(", ") + } + encodeString(buf, k) + buf.WriteString(": ") + if err := encodeCompact(buf, v.values[k]); err != nil { + return err + } + } + buf.WriteByte('}') + case []any: + buf.WriteByte('[') + for i, item := range v { + if i > 0 { + buf.WriteString(", ") + } + if err := encodeCompact(buf, item); err != nil { + return err + } + } + buf.WriteByte(']') + case string: + encodeString(buf, v) + case json.Number: + buf.WriteString(string(v)) + case bool: + if v { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case nil: + buf.WriteString("null") + case int: + buf.WriteString(strconv.Itoa(v)) + case int64: + buf.WriteString(strconv.FormatInt(v, 10)) + case float64: + buf.WriteString(strconv.FormatFloat(v, 'g', -1, 64)) + default: + return fmt.Errorf("ordjson: unsupported type %T", value) + } + return nil +} + +func writeIndent(buf *bytes.Buffer, level int) { + for range level { + buf.WriteString(indentUnit) + } +} + +func encodeIndent(buf *bytes.Buffer, value any, level int) error { + switch v := value.(type) { + case *Object: + if v.Len() == 0 { + buf.WriteString("{}") + return nil + } + buf.WriteString("{\n") + for i, k := range v.keys { + writeIndent(buf, level+1) + encodeString(buf, k) + buf.WriteString(": ") + if err := encodeIndent(buf, v.values[k], level+1); err != nil { + return err + } + if i < len(v.keys)-1 { + buf.WriteByte(',') + } + buf.WriteByte('\n') + } + writeIndent(buf, level) + buf.WriteByte('}') + case []any: + if len(v) == 0 { + buf.WriteString("[]") + return nil + } + buf.WriteString("[\n") + for i, item := range v { + writeIndent(buf, level+1) + if err := encodeIndent(buf, item, level+1); err != nil { + return err + } + if i < len(v)-1 { + buf.WriteByte(',') + } + buf.WriteByte('\n') + } + writeIndent(buf, level) + buf.WriteByte(']') + case string: + encodeString(buf, v) + case json.Number: + buf.WriteString(string(v)) + case bool: + if v { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case nil: + buf.WriteString("null") + case int: + buf.WriteString(strconv.Itoa(v)) + case int64: + buf.WriteString(strconv.FormatInt(v, 10)) + case float64: + buf.WriteString(strconv.FormatFloat(v, 'g', -1, 64)) + default: + return fmt.Errorf("ordjson: unsupported type %T", value) + } + return nil +} + +func encodeString(buf *bytes.Buffer, s string) { + buf.WriteByte('"') + for _, r := range s { + switch { + case r == '\\': + buf.WriteString(`\\`) + case r == '"': + buf.WriteString(`\"`) + case r == '\b': + buf.WriteString(`\b`) + case r == '\f': + buf.WriteString(`\f`) + case r == '\n': + buf.WriteString(`\n`) + case r == '\r': + buf.WriteString(`\r`) + case r == '\t': + buf.WriteString(`\t`) + case r < 0x20: + fmt.Fprintf(buf, `\u%04x`, r) + case r >= 0x20 && r <= 0x7e: + buf.WriteRune(r) + case r < 0x10000: + fmt.Fprintf(buf, `\u%04x`, r) + default: + r2 := r - 0x10000 + hi := 0xd800 | ((r2 >> 10) & 0x3ff) + lo := 0xdc00 | (r2 & 0x3ff) + fmt.Fprintf(buf, `\u%04x\u%04x`, hi, lo) + } + } + buf.WriteByte('"') +} diff --git a/go/internal/ordjson/ordjson_test.go b/go/internal/ordjson/ordjson_test.go new file mode 100644 index 0000000..4abc39b --- /dev/null +++ b/go/internal/ordjson/ordjson_test.go @@ -0,0 +1,80 @@ +package ordjson + +import "testing" + +func TestDecodeThenMarshalIndent_preservesInsertionOrder(t *testing.T) { + input := `{"b": 1, "a": 2, "c": {"z": 1, "y": 2}}` + value, err := Decode([]byte(input)) + if err != nil { + t.Fatalf("decode: %v", err) + } + obj, ok := value.(*Object) + if !ok { + t.Fatalf("value = %T, want *Object", value) + } + if got, want := obj.Keys(), []string{"b", "a", "c"}; !equalStrings(got, want) { + t.Fatalf("keys = %v, want %v", got, want) + } + encoded, err := MarshalIndent(obj) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "{\n \"b\": 1,\n \"a\": 2,\n \"c\": {\n \"z\": 1,\n \"y\": 2\n }\n}" + if string(encoded) != want { + t.Fatalf("encoded =\n%s\nwant\n%s", encoded, want) + } +} + +func TestMarshalIndent_emptyContainersStayInline(t *testing.T) { + obj := NewObject() + obj.Set("list", []any{}) + obj.Set("obj", NewObject()) + encoded, err := MarshalIndent(obj) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "{\n \"list\": [],\n \"obj\": {}\n}" + if string(encoded) != want { + t.Fatalf("encoded =\n%s\nwant\n%s", encoded, want) + } +} + +func TestMarshalIndent_escapesNonASCIILikePythonEnsureASCII(t *testing.T) { + obj := NewObject() + input := "caf" + string(rune(0xe9)) + " " + string(rune(0x01)) + " \n \" \\ " + string(rune(0x1d11e)) + obj.Set("s", input) + encoded, err := MarshalIndent(obj) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "{\n \"s\": \"caf\\u00e9 \\u0001 \\n \\\" \\\\ \\ud834\\udd1e\"\n}" + if string(encoded) != want { + t.Fatalf("encoded =\n%s\nwant\n%s", encoded, want) + } +} + +func TestSet_updatesInPlaceWithoutReordering(t *testing.T) { + obj := NewObject() + obj.Set("a", 1) + obj.Set("b", 2) + obj.Set("a", 3) + if got, want := obj.Keys(), []string{"a", "b"}; !equalStrings(got, want) { + t.Fatalf("keys = %v, want %v", got, want) + } + value, _ := obj.Get("a") + if value != 3 { + t.Fatalf("a = %v, want 3", value) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/go/internal/ordjson/testdata/atomic_json_ref.py b/go/internal/ordjson/testdata/atomic_json_ref.py new file mode 100644 index 0000000..2006f5c --- /dev/null +++ b/go/internal/ordjson/testdata/atomic_json_ref.py @@ -0,0 +1,11 @@ +import importlib.util +import json +import sys + +spec = importlib.util.spec_from_file_location("sumctl_ref", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +output_path = sys.argv[2] +value = json.loads(sys.argv[3]) +module.atomic_json(output_path, value) diff --git a/go/internal/pyrepr/pyrepr.go b/go/internal/pyrepr/pyrepr.go new file mode 100644 index 0000000..9fe67aa --- /dev/null +++ b/go/internal/pyrepr/pyrepr.go @@ -0,0 +1,78 @@ +package pyrepr + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/douglasjarquin/sum/go/internal/ordjson" +) + +func Repr(value any) string { + switch v := value.(type) { + case nil: + return "None" + case bool: + if v { + return "True" + } + return "False" + case json.Number: + return string(v) + case string: + return reprString(v) + case []any: + parts := make([]string, len(v)) + for i, item := range v { + parts[i] = Repr(item) + } + return "[" + strings.Join(parts, ", ") + "]" + case *ordjson.Object: + parts := make([]string, 0, v.Len()) + for _, k := range v.Keys() { + val, _ := v.Get(k) + parts = append(parts, reprString(k)+": "+Repr(val)) + } + return "{" + strings.Join(parts, ", ") + "}" + default: + return fmt.Sprintf("%v", v) + } +} + +func StrList(items []string) string { + parts := make([]string, len(items)) + for i, s := range items { + parts[i] = reprString(s) + } + return "[" + strings.Join(parts, ", ") + "]" +} + +func reprString(s string) string { + quote := byte('\'') + if strings.Contains(s, "'") && !strings.Contains(s, `"`) { + quote = '"' + } + var b strings.Builder + b.WriteByte(quote) + for _, r := range s { + switch { + case byte(r) == quote && r < 128: + b.WriteByte('\\') + b.WriteRune(r) + case r == '\\': + b.WriteString(`\\`) + case r == '\n': + b.WriteString(`\n`) + case r == '\r': + b.WriteString(`\r`) + case r == '\t': + b.WriteString(`\t`) + case r < 0x20 || r == 0x7f: + fmt.Fprintf(&b, `\x%02x`, r) + default: + b.WriteRune(r) + } + } + b.WriteByte(quote) + return b.String() +} diff --git a/go/internal/pyrepr/pyrepr_test.go b/go/internal/pyrepr/pyrepr_test.go new file mode 100644 index 0000000..e525382 --- /dev/null +++ b/go/internal/pyrepr/pyrepr_test.go @@ -0,0 +1,36 @@ +package pyrepr + +import ( + "encoding/json" + "testing" +) + +func TestRepr_matchesPythonReprForCommonTypes(t *testing.T) { + cases := []struct { + value any + want string + }{ + {nil, "None"}, + {true, "True"}, + {false, "False"}, + {json.Number("0"), "0"}, + {json.Number("1.5"), "1.5"}, + {"plain", "'plain'"}, + {"has'quote", `"has'quote"`}, + {[]any{json.Number("1"), "two"}, "[1, 'two']"}, + } + for _, tc := range cases { + if got := Repr(tc.value); got != tc.want { + t.Errorf("Repr(%#v) = %s, want %s", tc.value, got, tc.want) + } + } +} + +func TestStrList_matchesPythonListReprOfStrings(t *testing.T) { + if got, want := StrList([]string{"a", "b"}), "['a', 'b']"; got != want { + t.Fatalf("StrList = %s, want %s", got, want) + } + if got, want := StrList(nil), "[]"; got != want { + t.Fatalf("StrList(nil) = %s, want %s", got, want) + } +} diff --git a/go/internal/roleinit/roleinit.go b/go/internal/roleinit/roleinit.go new file mode 100644 index 0000000..265e2f3 --- /dev/null +++ b/go/internal/roleinit/roleinit.go @@ -0,0 +1,172 @@ +package roleinit + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/douglasjarquin/sum/go/internal/ordjson" + "github.com/douglasjarquin/sum/go/internal/store" +) + +var ErrDesignated = fmt.Errorf("designated installation: init must run through the Python reference") + +func Init(root string, s *store.Store, ctx *ordjson.Object, requestedRole, requestedTask string) (*ordjson.Object, error) { + if requestedRole == "worker" && requestedTask == "" { + return nil, fmt.Errorf("--role worker needs --task TASK_ID") + } + if s.Designated() { + return nil, ErrDesignated + } + + endpoint := store.EndpointFromContext(ctx) + hint, err := installationHint(root) + if err != nil { + return nil, err + } + + var task *ordjson.Object + if hint != "" { + if hintStore, openErr := store.Open(hint); openErr == nil { + task, err = matchingTask(hintStore, endpoint) + if err != nil { + return nil, err + } + } + } + + role := "developer" + var taskID any + if task != nil { + role = "worker" + taskID, _ = task.Get("id") + } + if requestedRole == "coordinator" { + return nil, fmt.Errorf("%s is not a sum installation (no state.json from setup). A checkout alone grants no coordinator authority; run mise run setup in the designated installation", s.Home) + } + + marker, err := developmentMarker(root) + if err != nil { + return nil, err + } + + result := ordjson.NewObject() + result.Set("role", role) + result.Set("home", s.Home) + result.Set("installation", false) + result.Set("task", taskID) + if hint != "" { + result.Set("installation_home", hint) + } else { + result.Set("installation_home", nil) + } + result.Set("registered", false) + result.Set("endpoint", ctx) + if marker != nil { + result.Set("development", marker) + } else { + result.Set("development", nil) + } + result.Set("note", note(task, marker)) + return result, nil +} + +func note(task, marker *ordjson.Object) string { + if task != nil { + return "Dispatched worker checkout: follow your brief; do not initialize a coordinator." + } + base := "Development checkout: modify and test sum here only. No coordinator initialization, dispatch, production setup, or instance-wide updates." + if marker == nil { + return base + } + installationValue, _ := marker.Get("installation") + installation, _ := installationValue.(string) + return base + " Tests use temporary --home state and a named lab Herdr session; the installed helper at " + + filepath.Join(installation, "bin", "sumctl") + " owns any parent-task callbacks." +} + +func installationHint(root string) (string, error) { + out, err := exec.Command("git", "-C", root, "rev-parse", "--path-format=absolute", "--git-common-dir").Output() + if err != nil { + return "", nil + } + common := strings.TrimSpace(string(out)) + if filepath.Base(common) != ".git" { + return "", nil + } + parent := filepath.Dir(common) + if resolveOrSelf(parent) == resolveOrSelf(root) { + return "", nil + } + home := filepath.Join(parent, ".sum") + if info, statErr := os.Stat(filepath.Join(home, "state.json")); statErr != nil || info.IsDir() { + return "", nil + } + return home, nil +} + +func resolveOrSelf(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + return path + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return resolved + } + return abs +} + +func matchingTask(s *store.Store, endpoint store.Endpoint) (*ordjson.Object, error) { + tasks, err := s.AllTasks() + if err != nil { + return nil, err + } + for _, task := range tasks { + paneValue, hasPane := task.Get("pane") + pane, _ := paneValue.(string) + if !hasPane || pane == "" { + continue + } + status, _ := task.Get("status") + if status == "archived" { + continue + } + machineValue, _ := task.Get("machine") + sessionValue, _ := task.Get("session") + if machineValue == endpoint.Machine && sessionValue == endpoint.Session && pane == endpoint.Pane { + return task, nil + } + } + return nil, nil +} + +func developmentMarker(root string) (*ordjson.Object, error) { + path := filepath.Join(root, ".sum", "dev.json") + if info, statErr := os.Stat(path); statErr != nil || info.IsDir() { + return nil, nil + } + value, err := ordjson.ReadFile(path) + if err != nil { + return nil, err + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("%s is not a JSON object", path) + } + schemaOK := false + if schema, has := obj.Get("schema"); has { + if number, isNum := schema.(json.Number); isNum { + if n, convErr := number.Int64(); convErr == nil && n == store.Schema { + schemaOK = true + } + } + } + kind, _ := obj.Get("kind") + if !schemaOK || kind != "development" { + return nil, fmt.Errorf("unrecognized development marker %s; inspect it before continuing", path) + } + return obj, nil +} diff --git a/go/internal/roleinit/roleinit_test.go b/go/internal/roleinit/roleinit_test.go new file mode 100644 index 0000000..e1f0a53 --- /dev/null +++ b/go/internal/roleinit/roleinit_test.go @@ -0,0 +1,244 @@ +package roleinit + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "github.com/douglasjarquin/sum/go/internal/ordjson" + "github.com/douglasjarquin/sum/go/internal/store" +) + +func jsonInt(n int) json.Number { + return json.Number(strconv.Itoa(n)) +} + +func fakeContext(machine, session, pane, cwd string) *ordjson.Object { + ctx := ordjson.NewObject() + ctx.Set("session", session) + ctx.Set("pane", pane) + ctx.Set("machine", machine) + ctx.Set("cwd", cwd) + ctx.Set("at", "2026-09-10T00:00:00+00:00") + return ctx +} + +func TestInit_requiresTaskForRoleWorker(t *testing.T) { + root := t.TempDir() + s, err := store.Open(root) + if err != nil { + t.Fatalf("open: %v", err) + } + _, err = Init(root, s, fakeContext("m", "s", "p", root), "worker", "") + if err == nil || err.Error() != "--role worker needs --task TASK_ID" { + t.Fatalf("err = %v, want the worker/task requirement message", err) + } +} + +func TestInit_refusesCoordinatorWhenNotDesignated(t *testing.T) { + root := t.TempDir() + s, err := store.Open(root) + if err != nil { + t.Fatalf("open: %v", err) + } + _, err = Init(root, s, fakeContext("m", "s", "p", root), "coordinator", "") + if err == nil { + t.Fatal("expected an error refusing coordinator role") + } +} + +func TestInit_returnsErrDesignated(t *testing.T) { + root := t.TempDir() + s, err := store.Open(root) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("store init: %v", err) + } + _, err = Init(root, s, fakeContext("m", "s", "p", root), "", "") + if err != ErrDesignated { + t.Fatalf("err = %v, want ErrDesignated", err) + } +} + +func TestInit_plainDeveloperWithNoHintOrMarker(t *testing.T) { + root := t.TempDir() + s, err := store.Open(root) + if err != nil { + t.Fatalf("open: %v", err) + } + view, err := Init(root, s, fakeContext("m", "s", "p", root), "", "") + if err != nil { + t.Fatalf("init: %v", err) + } + role, _ := view.Get("role") + if role != "developer" { + t.Fatalf("role = %v, want developer", role) + } + noteValue, _ := view.Get("note") + want := "Development checkout: modify and test sum here only. No coordinator initialization, dispatch, production setup, or instance-wide updates." + if noteValue != want { + t.Fatalf("note = %q, want %q", noteValue, want) + } + installationHome, _ := view.Get("installation_home") + if installationHome != nil { + t.Fatalf("installation_home = %v, want nil (no linked worktree here)", installationHome) + } +} + +func TestInit_developmentMarkerAppendsCallbackSentence(t *testing.T) { + root := t.TempDir() + s, err := store.Open(root) + if err != nil { + t.Fatalf("open: %v", err) + } + marker := ordjson.NewObject() + marker.Set("schema", jsonInt(store.Schema)) + marker.Set("kind", "development") + marker.Set("installation", "/installations/sum") + if err := ordjson.WriteFile(filepath.Join(root, ".sum", "dev.json"), marker); err != nil { + t.Fatal(err) + } + + view, err := Init(root, s, fakeContext("m", "s", "p", root), "", "") + if err != nil { + t.Fatalf("init: %v", err) + } + noteValue, _ := view.Get("note") + want := "Development checkout: modify and test sum here only. No coordinator initialization, dispatch, production setup, or instance-wide updates. " + + "Tests use temporary --home state and a named lab Herdr session; the installed helper at /installations/sum/bin/sumctl owns any parent-task callbacks." + if noteValue != want { + t.Fatalf("note = %q, want %q", noteValue, want) + } +} + +func TestMatchingTask_findsAPaneMatchingNonArchivedTask(t *testing.T) { + hintHome := t.TempDir() + hintStore, err := store.Open(hintHome) + if err != nil { + t.Fatalf("open hint store: %v", err) + } + if err := hintStore.Init(); err != nil { + t.Fatalf("hint store init: %v", err) + } + + endpoint := store.Endpoint{Machine: "m1", Session: "s1", Pane: "p1"} + + archived := ordjson.NewObject() + archived.Set("schema", jsonInt(store.Schema)) + archived.Set("id", "t-aaaaaaaaaaaa") + archived.Set("status", "archived") + archived.Set("pane", "p1") + archived.Set("session", "s1") + archived.Set("machine", "m1") + if err := hintStore.SaveTask(archived); err != nil { + t.Fatalf("save archived task: %v", err) + } + + running := ordjson.NewObject() + running.Set("schema", jsonInt(store.Schema)) + running.Set("id", "t-bbbbbbbbbbbb") + running.Set("status", "running") + running.Set("pane", "p1") + running.Set("session", "s1") + running.Set("machine", "m1") + if err := hintStore.SaveTask(running); err != nil { + t.Fatalf("save running task: %v", err) + } + + task, err := matchingTask(hintStore, endpoint) + if err != nil { + t.Fatalf("matchingTask: %v", err) + } + if task == nil { + t.Fatal("expected a matching task, got nil") + } + id, _ := task.Get("id") + if id != "t-bbbbbbbbbbbb" { + t.Fatalf("matched task id = %v, want t-bbbbbbbbbbbb (the archived one must be skipped)", id) + } +} + +func TestMatchingTask_nilWhenNoIdentityMatches(t *testing.T) { + hintHome := t.TempDir() + hintStore, err := store.Open(hintHome) + if err != nil { + t.Fatalf("open hint store: %v", err) + } + if err := hintStore.Init(); err != nil { + t.Fatalf("hint store init: %v", err) + } + task, err := matchingTask(hintStore, store.Endpoint{Machine: "m1", Session: "s1", Pane: "p1"}) + if err != nil { + t.Fatalf("matchingTask: %v", err) + } + if task != nil { + t.Fatalf("task = %v, want nil", task) + } +} + +func TestInstallationHint_findsTheLinkedWorktreesInstallation(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + base := t.TempDir() + installation := filepath.Join(base, "installation") + if err := os.MkdirAll(installation, 0o700); err != nil { + t.Fatal(err) + } + runGit(t, installation, "init", "-q") + runGit(t, installation, "config", "user.email", "test@example.com") + runGit(t, installation, "config", "user.name", "test") + runGit(t, installation, "commit", "--allow-empty", "-q", "-m", "root") + if err := os.MkdirAll(filepath.Join(installation, ".sum"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(installation, ".sum", "state.json"), []byte(`{"schema":1}`), 0o600); err != nil { + t.Fatal(err) + } + + worktree := filepath.Join(base, "worktree") + runGit(t, installation, "worktree", "add", worktree, "-b", "wt") + + hint, err := installationHint(worktree) + if err != nil { + t.Fatalf("installationHint: %v", err) + } + wantSuffix := filepath.Join(installation, ".sum") + if hint == "" { + t.Fatal("hint is empty, want the installation's .sum") + } + resolvedHint, _ := filepath.EvalSymlinks(hint) + resolvedWant, _ := filepath.EvalSymlinks(wantSuffix) + if resolvedHint != resolvedWant { + t.Fatalf("hint = %s, want %s", hint, wantSuffix) + } +} + +func TestInstallationHint_emptyForAnOrdinaryRepoRoot(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + root := t.TempDir() + runGit(t, root, "init", "-q") + hint, err := installationHint(root) + if err != nil { + t.Fatalf("installationHint: %v", err) + } + if hint != "" { + t.Fatalf("hint = %q, want empty for a plain (non-worktree) repo root", hint) + } +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} diff --git a/go/internal/settings/settings.go b/go/internal/settings/settings.go new file mode 100644 index 0000000..951cf28 --- /dev/null +++ b/go/internal/settings/settings.go @@ -0,0 +1,633 @@ +package settings + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/douglasjarquin/sum/go/internal/ordjson" + "github.com/douglasjarquin/sum/go/internal/pyrepr" + "github.com/douglasjarquin/sum/go/internal/store" +) + +const ( + File = "settings.json" + Schema = 1 + CapacityMax = 64 + presetMax = 32 + defaultGlobal = 2 + defaultPerRepository = 1 +) + +var ( + presetNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`) + harnessKindPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`) + launchValuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/@,=\[\]-]{0,127}$`) + settingsKeys = map[string]bool{"schema": true, "capacity": true, "worker": true, "presets": true, "reviewer": true} + defaultCapacity = map[string]int{"global": defaultGlobal, "per_repository": defaultPerRepository} +) + +type adapter struct { + Model []string + Reasoning []string +} + +var adapters = map[string]adapter{ + "codex": {Model: []string{"-m"}, Reasoning: []string{"-c", "model_reasoning_effort="}}, + "claude": {Model: []string{"--model"}, Reasoning: []string{"--effort"}}, + "grok": {Model: []string{"-m"}, Reasoning: []string{"--reasoning-effort"}}, + "copilot": {Model: []string{"--model"}, Reasoning: []string{"--effort"}}, + "cursor": {Model: []string{"--model"}}, + "pi": {Model: []string{"--model"}}, + "omp": {Model: []string{"--model="}}, +} + +func adapterPrefix(harness, field string) ([]string, bool) { + a, ok := adapters[harness] + if !ok { + return nil, false + } + switch field { + case "model": + if a.Model == nil { + return nil, false + } + return a.Model, true + case "reasoning": + if a.Reasoning == nil { + return nil, false + } + return a.Reasoning, true + } + return nil, false +} + +func knownHarnessesForField(field string) []string { + var names []string + for name := range adapters { + if _, ok := adapterPrefix(name, field); ok { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + +func argvConflicts(harness, field string, extra []string) bool { + prefix, ok := adapterPrefix(harness, field) + if !ok || len(prefix) == 0 { + return false + } + head := prefix[0] + flag := strings.TrimSuffix(head, "=") + if len(prefix) == 2 && strings.HasSuffix(prefix[1], "=") { + key := prefix[1] + for _, a := range extra { + if strings.HasPrefix(a, key) || a == flag+"="+key || strings.HasPrefix(a, flag+"="+key) { + return true + } + } + return false + } + for _, a := range extra { + if a == flag || strings.HasPrefix(a, flag+"=") { + return true + } + } + return false +} + +type Settings struct { + Capacity *ordjson.Object + Worker *ordjson.Object + Presets map[string]*ordjson.Object + Reviewer *ordjson.Object + Source string + Path string +} + +func LoadSettings(s *store.Store) (*Settings, error) { + path := filepath.Join(s.Home, File) + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("%s must not be a symlink", path) + } + if info, err := os.Stat(path); err != nil || info.IsDir() { + return &Settings{Presets: map[string]*ordjson.Object{}, Source: "unlimited", Path: path}, nil + } + settings, err := loadSettingsFile(path) + if err != nil { + return nil, fmt.Errorf("Invalid %s: %s. Fix or remove the file; nothing was admitted or changed, and existing tasks keep running. Only an explicit capacity block configures admission.", path, err) + } + return settings, nil +} + +func loadSettingsFile(path string) (*Settings, error) { + raw, err := ordjson.ReadFile(path) + if err != nil { + return nil, err + } + obj, ok := raw.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("top level must be an object") + } + schemaValue, _ := obj.Get("schema") + if !isSchema(schemaValue, Schema) { + return nil, fmt.Errorf("schema must be %d", Schema) + } + var unknown []string + for _, k := range obj.Keys() { + if !settingsKeys[k] { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + allowed := []string{"capacity", "presets", "reviewer", "schema", "worker"} + return nil, fmt.Errorf("unknown keys %s; allowed: %s", pyrepr.StrList(unknown), pyrepr.StrList(allowed)) + } + var capacity *ordjson.Object + if capacityValue, has := obj.Get("capacity"); has { + validated, err := validateCapacity(capacityValue) + if err != nil { + return nil, err + } + capacity = validated + } + presetsValue, _ := obj.Get("presets") + presets, err := validatePresets(presetsValue) + if err != nil { + return nil, err + } + workerValue, _ := obj.Get("worker") + worker, err := validateWorker(workerValue, presets) + if err != nil { + return nil, err + } + reviewerValue, _ := obj.Get("reviewer") + reviewer, err := validateReviewer(reviewerValue, presets) + if err != nil { + return nil, err + } + return &Settings{Capacity: capacity, Worker: worker, Presets: presets, Reviewer: reviewer, Source: "settings.json", Path: path}, nil +} + +func isSchema(value any, want int) bool { + number, ok := value.(json.Number) + if !ok { + return false + } + n, err := number.Int64() + return err == nil && n == int64(want) +} + +func asInt(value any) (int, bool) { + number, ok := value.(json.Number) + if !ok { + return 0, false + } + n, err := number.Int64() + if err != nil { + return 0, false + } + return int(n), true +} + +func validateCapacity(value any) (*ordjson.Object, error) { + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("capacity must be an object") + } + var unknown []string + for _, k := range obj.Keys() { + if _, isDefault := defaultCapacity[k]; !isDefault { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return nil, fmt.Errorf("unknown capacity keys %s; allowed: %s", pyrepr.StrList(unknown), pyrepr.StrList([]string{"global", "per_repository"})) + } + result := map[string]int{"global": defaultCapacity["global"], "per_repository": defaultCapacity["per_repository"]} + for _, key := range obj.Keys() { + raw, _ := obj.Get(key) + n, ok := asInt(raw) + if !ok || n < 1 || n > CapacityMax { + return nil, fmt.Errorf("capacity.%s must be an integer between 1 and %d, got %s", key, CapacityMax, pyrepr.Repr(raw)) + } + result[key] = n + } + if result["per_repository"] > result["global"] { + return nil, fmt.Errorf("capacity.per_repository (%d) exceeds capacity.global (%d)", result["per_repository"], result["global"]) + } + canonical := ordjson.NewObject() + canonical.Set("global", json.Number(fmt.Sprint(result["global"]))) + canonical.Set("per_repository", json.Number(fmt.Sprint(result["per_repository"]))) + return canonical, nil +} + +func validateLaunchValue(harness, field string, value any) (string, error) { + str, ok := value.(string) + if !ok || !launchValuePattern.MatchString(str) { + return "", fmt.Errorf("%s must be one plain CLI value (letters, digits, . _ : / @ , = [ ] -), got %s", field, pyrepr.Repr(value)) + } + if _, hasAdapter := adapterPrefix(harness, field); !hasAdapter { + known := knownHarnessesForField(field) + return "", fmt.Errorf("No verified %s flag for harness %s; sum passes only mappings confirmed from an installed CLI's help (%s). Pass the native argument yourself with --arg, or choose a supported harness.", field, pyrepr.Repr(harness), strings.Join(known, ", ")) + } + return str, nil +} + +func validatePresetReference(field string, name any, presets map[string]*ordjson.Object) error { + str, ok := name.(string) + if !ok || !presetNamePattern.MatchString(str) { + return fmt.Errorf("%s must name a preset (lowercase letters, digits, _ -), got %s", field, pyrepr.Repr(name)) + } + if presets != nil { + if _, exists := presets[str]; !exists { + keys := make([]string, 0, len(presets)) + for k := range presets { + keys = append(keys, k) + } + sort.Strings(keys) + list := "none" + if len(keys) > 0 { + list = pyrepr.StrList(keys) + } + return fmt.Errorf("%s names unknown preset %s; saved presets: %s. Create it with `preset set %s --harness ...` or point the default elsewhere.", field, pyrepr.Repr(str), list, str) + } + } + return nil +} + +func validateWorker(value any, presets map[string]*ordjson.Object) (*ordjson.Object, error) { + if value == nil { + return nil, nil + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("worker must be an object") + } + if presetValue, hasPreset := obj.Get("preset"); hasPreset { + if obj.Len() != 1 { + return nil, fmt.Errorf("worker is either {'preset': NAME} or a harness/model/reasoning block, not both") + } + if err := validatePresetReference("worker.preset", presetValue, presets); err != nil { + return nil, err + } + result := ordjson.NewObject() + result.Set("preset", presetValue) + return result, nil + } + allowed := map[string]bool{"harness": true, "model": true, "reasoning": true} + var unknown []string + for _, k := range obj.Keys() { + if !allowed[k] { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return nil, fmt.Errorf("unknown worker keys %s; allowed: ['harness', 'model', 'reasoning'] or ['preset']", pyrepr.StrList(unknown)) + } + harnessValue, _ := obj.Get("harness") + harness, ok := harnessValue.(string) + if !ok || !harnessKindPattern.MatchString(harness) { + return nil, fmt.Errorf("worker.harness must be a Herdr integration kind such as codex or claude") + } + result := ordjson.NewObject() + result.Set("harness", harness) + for _, field := range []string{"model", "reasoning"} { + if fieldValue, has := obj.Get(field); has && fieldValue != nil { + validated, err := validateLaunchValue(harness, field, fieldValue) + if err != nil { + return nil, err + } + result.Set(field, validated) + } + } + return result, nil +} + +func validateReviewer(value any, presets map[string]*ordjson.Object) (*ordjson.Object, error) { + if value == nil { + return nil, nil + } + obj, ok := value.(*ordjson.Object) + if !ok || obj.Len() != 1 { + return nil, fmt.Errorf("reviewer must be {'preset': NAME}") + } + presetValue, hasPreset := obj.Get("preset") + if !hasPreset { + return nil, fmt.Errorf("reviewer must be {'preset': NAME}") + } + if err := validatePresetReference("reviewer.preset", presetValue, presets); err != nil { + return nil, err + } + result := ordjson.NewObject() + result.Set("preset", presetValue) + return result, nil +} + +func validatePreset(name string, value any) (*ordjson.Object, error) { + if !presetNamePattern.MatchString(name) { + return nil, fmt.Errorf("preset name must be lowercase letters, digits, _ or - (up to 32 characters), got %s", pyrepr.Repr(name)) + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("presets.%s must be an object", name) + } + allowed := map[string]bool{"harness": true, "model": true, "reasoning": true, "args": true, "revision": true} + var unknown []string + for _, k := range obj.Keys() { + if !allowed[k] { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return nil, fmt.Errorf("unknown keys %s in presets.%s; allowed: %s", pyrepr.StrList(unknown), name, pyrepr.StrList([]string{"harness", "model", "reasoning", "args", "revision"})) + } + harnessValue, _ := obj.Get("harness") + harness, ok := harnessValue.(string) + if !ok || !harnessKindPattern.MatchString(harness) { + return nil, fmt.Errorf("presets.%s.harness must be a Herdr integration kind such as codex or claude", name) + } + result := ordjson.NewObject() + result.Set("harness", harness) + for _, field := range []string{"model", "reasoning"} { + if fieldValue, has := obj.Get(field); has && fieldValue != nil { + validated, err := validateLaunchValue(harness, field, fieldValue) + if err != nil { + return nil, err + } + result.Set(field, validated) + } + } + var args []string + if rawArgs, has := obj.Get("args"); has && rawArgs != nil { + list, isList := rawArgs.([]any) + if !isList { + return nil, fmt.Errorf("presets.%s.args must be a list of plain non-empty strings", name) + } + for _, item := range list { + s, ok := item.(string) + if !ok || s == "" || strings.Contains(s, "\x00") { + return nil, fmt.Errorf("presets.%s.args must be a list of plain non-empty strings", name) + } + args = append(args, s) + } + } + for _, field := range []string{"model", "reasoning"} { + if fieldValue, has := result.Get(field); has { + if s, isStr := fieldValue.(string); isStr && argvConflicts(harness, field, args) { + return nil, fmt.Errorf("presets.%s: %s %s and an entry of args both set the %s %s flag. Give one.", name, field, pyrepr.Repr(s), harness, field) + } + } + } + if len(args) > 0 { + argsAny := make([]any, len(args)) + for i, a := range args { + argsAny[i] = a + } + result.Set("args", argsAny) + } + revision := 1 + if revisionValue, has := obj.Get("revision"); has { + n, ok := asInt(revisionValue) + if !ok || n < 1 { + return nil, fmt.Errorf("presets.%s.revision must be a positive integer", name) + } + revision = n + } + result.Set("revision", json.Number(fmt.Sprint(revision))) + return result, nil +} + +func validatePresets(value any) (map[string]*ordjson.Object, error) { + if value == nil { + return map[string]*ordjson.Object{}, nil + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("presets must be an object keyed by preset name") + } + if obj.Len() > presetMax { + return nil, fmt.Errorf("at most %d presets are supported", presetMax) + } + result := map[string]*ordjson.Object{} + for _, name := range obj.Keys() { + spec, _ := obj.Get(name) + validated, err := validatePreset(name, spec) + if err != nil { + return nil, err + } + result[name] = validated + } + return result, nil +} + +func presetSummary(presets map[string]*ordjson.Object) *ordjson.Object { + names := make([]string, 0, len(presets)) + for name := range presets { + names = append(names, name) + } + sort.Strings(names) + result := ordjson.NewObject() + for _, name := range names { + spec := presets[name] + entry := ordjson.NewObject() + harness, _ := spec.Get("harness") + entry.Set("harness", harness) + model, hasModel := spec.Get("model") + if !hasModel { + model = nil + } + entry.Set("model", model) + reasoning, hasReasoning := spec.Get("reasoning") + if !hasReasoning { + reasoning = nil + } + entry.Set("reasoning", reasoning) + args, hasArgs := spec.Get("args") + if !hasArgs { + args = []any{} + } + entry.Set("args", args) + revision, _ := spec.Get("revision") + entry.Set("revision", revision) + result.Set(name, entry) + } + return result +} + +func occupancy(tasks []*ordjson.Object) *ordjson.Object { + byRepo := ordjson.NewObject() + holderCount := 0 + for _, t := range tasks { + status, _ := t.Get("status") + if status == "archived" { + continue + } + holderCount++ + repoValue, _ := t.Get("repository") + repo, _ := repoValue.(string) + idValue, _ := t.Get("id") + var list []any + if existing, has := byRepo.Get(repo); has { + list, _ = existing.([]any) + } + list = append(list, idValue) + byRepo.Set(repo, list) + } + result := ordjson.NewObject() + result.Set("global", json.Number(fmt.Sprint(holderCount))) + result.Set("by_repository", byRepo) + return result +} + +func orNil(obj *ordjson.Object) any { + if obj == nil { + return nil + } + return obj +} + +func CapacityView(s *store.Store) (*ordjson.Object, error) { + tasks, err := s.AllTasks() + if err != nil { + return nil, err + } + loaded, err := LoadSettings(s) + if err != nil { + result := ordjson.NewObject() + result.Set("limits", nil) + result.Set("worker", nil) + result.Set("source", "invalid") + result.Set("error", err.Error()) + result.Set("occupied", occupancy(tasks)) + result.Set("note", "Admission is refused until settings.json is fixed; every recorded task keeps its slot and callbacks.") + return result, nil + } + result := ordjson.NewObject() + result.Set("limits", orNil(loaded.Capacity)) + result.Set("worker", orNil(loaded.Worker)) + result.Set("source", loaded.Source) + result.Set("occupied", occupancy(tasks)) + result.Set("presets", presetSummary(loaded.Presets)) + result.Set("reviewer", orNil(loaded.Reviewer)) + result.Set("worker_note", "Saved worker defaults apply to future dispatches only; absent means the worker runs the coordinator's harness. A task prompt overrides them without changing them.") + result.Set("note", "A slot is held by every non-archived task and released only by `archive --acknowledge`; idle, reported, or unobservable workers keep theirs.") + return result, nil +} + +func adapterArgv(harness, field string, value string) []string { + prefix, _ := adapterPrefix(harness, field) + if len(prefix) == 0 { + return nil + } + last := prefix[len(prefix)-1] + if strings.HasSuffix(last, "=") { + argv := append([]string{}, prefix[:len(prefix)-1]...) + return append(argv, last+value) + } + return append(append([]string{}, prefix...), value) +} + +func presetLaunch(spec *ordjson.Object) *ordjson.Object { + harnessValue, _ := spec.Get("harness") + harness, _ := harnessValue.(string) + var argv []string + for _, field := range []string{"model", "reasoning"} { + if value, has := spec.Get(field); has { + if s, ok := value.(string); ok { + argv = append(argv, adapterArgv(harness, field, s)...) + } + } + } + if rawArgs, has := spec.Get("args"); has { + if list, ok := rawArgs.([]any); ok { + for _, a := range list { + if s, ok := a.(string); ok { + argv = append(argv, s) + } + } + } + } + result := ordjson.NewObject() + result.Set("harness", harness) + model, hasModel := spec.Get("model") + if !hasModel { + model = nil + } + result.Set("model", model) + reasoning, hasReasoning := spec.Get("reasoning") + if !hasReasoning { + reasoning = nil + } + result.Set("reasoning", reasoning) + argvAny := make([]any, len(argv)) + for i, a := range argv { + argvAny[i] = a + } + result.Set("argv", argvAny) + return result +} + +func presetReferences(worker, reviewer *ordjson.Object, name string) []string { + var refs []string + if worker != nil { + if presetValue, has := worker.Get("preset"); has && presetValue == name { + refs = append(refs, "worker default") + } + } + if reviewer != nil { + if presetValue, has := reviewer.Get("preset"); has && presetValue == name { + refs = append(refs, "reviewer default") + } + } + return refs +} + +func PresetList(s *store.Store) (*ordjson.Object, error) { + loaded, err := LoadSettings(s) + if err != nil { + return nil, err + } + result := ordjson.NewObject() + result.Set("presets", presetSummary(loaded.Presets)) + result.Set("worker", orNil(loaded.Worker)) + result.Set("reviewer", orNil(loaded.Reviewer)) + result.Set("source", loaded.Source) + result.Set("path", loaded.Path) + result.Set("note", "Presets are dispatch shortcuts expanded at prepare; each task keeps the specification it was prepared with. Nothing here is a running agent, a role, or a default until you say so.") + return result, nil +} + +func PresetShow(s *store.Store, name string) (*ordjson.Object, error) { + loaded, err := LoadSettings(s) + if err != nil { + return nil, err + } + if err := validatePresetReference("preset", name, loaded.Presets); err != nil { + return nil, err + } + spec := loaded.Presets[name] + revision, _ := spec.Get("revision") + usedBy := presetReferences(loaded.Worker, loaded.Reviewer, name) + usedByAny := make([]any, len(usedBy)) + for i, u := range usedBy { + usedByAny[i] = u + } + result := ordjson.NewObject() + result.Set("name", name) + result.Set("revision", revision) + result.Set("preset", spec) + result.Set("launch", presetLaunch(spec)) + result.Set("used_by", usedByAny) + result.Set("note", "`launch.argv` is exactly what `dispatch --preset` appends after the harness executable; a model here is CLI-requested, never runtime-verified.") + return result, nil +} diff --git a/go/internal/shquote/shquote.go b/go/internal/shquote/shquote.go new file mode 100644 index 0000000..b676d8b --- /dev/null +++ b/go/internal/shquote/shquote.go @@ -0,0 +1,30 @@ +package shquote + +import ( + "regexp" + "strings" +) + +var unsafe = regexp.MustCompile(`[^\w@%+=:,./-]`) + +func Quote(s string) string { + if s == "" { + return "''" + } + if !unsafe.MatchString(s) { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" +} + +func Join(parts []string) string { + quoted := make([]string, len(parts)) + for i, p := range parts { + quoted[i] = Quote(p) + } + return strings.Join(quoted, " ") +} + +func CommandFor(sumctlPath, home string, args ...string) string { + return Join(append([]string{sumctlPath, "--home", home}, args...)) +} diff --git a/go/internal/shquote/shquote_test.go b/go/internal/shquote/shquote_test.go new file mode 100644 index 0000000..630fb9a --- /dev/null +++ b/go/internal/shquote/shquote_test.go @@ -0,0 +1,29 @@ +package shquote + +import "testing" + +func TestQuote_matchesPythonShlexQuote(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", "''"}, + {"plain", "plain"}, + {"a/b-c.d_e:f,g@h%i+j=k", "a/b-c.d_e:f,g@h%i+j=k"}, + {"has space", "'has space'"}, + {"has'quote", `'has'"'"'quote'`}, + } + for _, tc := range cases { + if got := Quote(tc.in); got != tc.want { + t.Errorf("Quote(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestCommandFor_joinsWithHomeFlag(t *testing.T) { + got := CommandFor("/bin/sumctl", "/tmp/dir with spaces", "metadata", "enable") + want := "/bin/sumctl --home '/tmp/dir with spaces' metadata enable" + if got != want { + t.Fatalf("CommandFor = %q, want %q", got, want) + } +} diff --git a/go/internal/store/context.go b/go/internal/store/context.go new file mode 100644 index 0000000..57ed359 --- /dev/null +++ b/go/internal/store/context.go @@ -0,0 +1,66 @@ +package store + +import ( + "fmt" + "os" + "regexp" + + "github.com/douglasjarquin/sum/go/internal/ordjson" +) + +var ( + sessionNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + socketSessionPattern = regexp.MustCompile(`/sessions/([^/]+)/herdr\.sock$`) +) + +func SessionFromEnv() (string, error) { + value := os.Getenv("SUM_SESSION") + if value == "" { + value = os.Getenv("HERDR_SESSION") + } + if value == "" { + if m := socketSessionPattern.FindStringSubmatch(os.Getenv("HERDR_SOCKET_PATH")); m != nil { + value = m[1] + } + } + if value == "" { + value = "default" + } + if !sessionNamePattern.MatchString(value) { + return "", fmt.Errorf("cannot identify the Herdr session. Set SUM_SESSION to its explicit name") + } + return value, nil +} + +func Context(root string) (*ordjson.Object, error) { + if os.Getenv("HERDR_ENV") != "1" || os.Getenv("HERDR_PANE_ID") == "" { + return nil, fmt.Errorf("run this command inside a Herdr pane (HERDR_ENV=1 and HERDR_PANE_ID are required)") + } + session, err := SessionFromEnv() + if err != nil { + return nil, err + } + hostname, err := os.Hostname() + if err != nil { + return nil, err + } + ctx := ordjson.NewObject() + ctx.Set("session", session) + ctx.Set("pane", os.Getenv("HERDR_PANE_ID")) + ctx.Set("machine", hostname) + ctx.Set("cwd", root) + ctx.Set("at", Now()) + return ctx, nil +} + +func EndpointFromContext(ctx *ordjson.Object) Endpoint { + machine, _ := ctx.Get("machine") + session, _ := ctx.Get("session") + pane, _ := ctx.Get("pane") + cwd, _ := ctx.Get("cwd") + m, _ := machine.(string) + s, _ := session.(string) + p, _ := pane.(string) + c, _ := cwd.(string) + return Endpoint{Machine: m, Session: s, Pane: p, Cwd: c} +} diff --git a/go/internal/store/registration_test.go b/go/internal/store/registration_test.go new file mode 100644 index 0000000..9b0b02b --- /dev/null +++ b/go/internal/store/registration_test.go @@ -0,0 +1,139 @@ +package store + +import ( + "os" + "testing" +) + +func TestRegistrationKey_matchesThePythonReferenceHash(t *testing.T) { + got := registrationKey(Endpoint{Machine: "m1", Session: "s1", Pane: "p1"}) + want := "293c980b4e6c2341" + if got != want { + t.Fatalf("registrationKey = %q, want %q", got, want) + } +} + +func TestDesignated_falseUntilInitializedAndFalseForADevCheckout(t *testing.T) { + home := t.TempDir() + s, err := Open(home) + if err != nil { + t.Fatalf("open: %v", err) + } + if s.Designated() { + t.Fatal("empty store must not be designated") + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + if !s.Designated() { + t.Fatal("initialized store must be designated") + } + if err := os.WriteFile(home+"/dev.json", []byte(`{"schema":1}`), 0o600); err != nil { + t.Fatal(err) + } + if s.Designated() { + t.Fatal("a store with dev.json must never be designated") + } +} + +func TestOwner_nilWhenAbsent(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + owner, err := s.Owner() + if err != nil { + t.Fatalf("owner: %v", err) + } + if owner != nil { + t.Fatalf("owner = %v, want nil", owner) + } +} + +func TestRegisterThenRegistration_roundTripsAndPreservesRegisteredAt(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + endpoint := Endpoint{Machine: "m1", Session: "s1", Pane: "p1", Cwd: "/tmp/x"} + + first, err := s.Register(endpoint, "developer", nil) + if err != nil { + t.Fatalf("register: %v", err) + } + role, _ := first.Get("role") + if role != "developer" { + t.Fatalf("role = %v, want developer", role) + } + firstRegisteredAt, _ := first.Get("registered_at") + + read, err := s.Registration(endpoint) + if err != nil { + t.Fatalf("registration: %v", err) + } + if read == nil { + t.Fatal("registration = nil, want the record just written") + } + + second, err := s.Register(endpoint, "worker", "t-0123456789ab") + if err != nil { + t.Fatalf("re-register: %v", err) + } + secondRegisteredAt, _ := second.Get("registered_at") + if secondRegisteredAt != firstRegisteredAt { + t.Fatalf("registered_at changed on re-registration: %v -> %v", firstRegisteredAt, secondRegisteredAt) + } + role, _ = second.Get("role") + if role != "worker" { + t.Fatalf("role after re-register = %v, want worker", role) + } +} + +func TestRegistration_rejectsIdentityMismatch(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + endpoint := Endpoint{Machine: "m1", Session: "s1", Pane: "p1"} + if _, err := s.Register(endpoint, "developer", nil); err != nil { + t.Fatalf("register: %v", err) + } + + path := s.Sessions + "/" + registrationKey(endpoint) + ".json" + corrupted := `{"schema":1,"key":"x","role":"developer","task":null,"machine":"other","session":"s1","pane":"p1"}` + if err := os.WriteFile(path, []byte(corrupted), 0o600); err != nil { + t.Fatal(err) + } + if _, err := s.Registration(endpoint); err == nil { + t.Fatal("expected an identity mismatch error") + } +} + +func TestRegistrations_returnsAllSortedByFilename(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + if _, err := s.Register(Endpoint{Machine: "m1", Session: "s1", Pane: "p1"}, "developer", nil); err != nil { + t.Fatalf("register 1: %v", err) + } + if _, err := s.Register(Endpoint{Machine: "m2", Session: "s2", Pane: "p2"}, "worker", nil); err != nil { + t.Fatalf("register 2: %v", err) + } + all, err := s.Registrations() + if err != nil { + t.Fatalf("registrations: %v", err) + } + if len(all) != 2 { + t.Fatalf("len(registrations) = %d, want 2", len(all)) + } +} diff --git a/go/internal/store/store.go b/go/internal/store/store.go new file mode 100644 index 0000000..4dd85d0 --- /dev/null +++ b/go/internal/store/store.go @@ -0,0 +1,354 @@ +package store + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "syscall" + "time" + + "github.com/douglasjarquin/sum/go/internal/contract" + "github.com/douglasjarquin/sum/go/internal/ordjson" +) + +const Schema = 1 + +const SumVersion = "0.1.0" + +var taskIDPattern = regexp.MustCompile(`^t-[a-f0-9]{12}$`) + +type Store struct { + Home string + Tasks string + Sessions string +} + +func Open(home string) (*Store, error) { + resolved, err := resolveHome(home) + if err != nil { + return nil, err + } + s := &Store{Home: resolved, Tasks: filepath.Join(resolved, "tasks"), Sessions: filepath.Join(resolved, "sessions")} + statePath := filepath.Join(resolved, "state.json") + if info, statErr := os.Stat(statePath); statErr == nil && !info.IsDir() { + value, readErr := ordjson.ReadFile(statePath) + if readErr != nil { + return nil, readErr + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("state.json is not a JSON object") + } + if !schemaMatches(obj) { + return nil, fmt.Errorf("unsupported state schema. Preserve the original; use the matching sum release. No in-place migration") + } + } + return s, nil +} + +func schemaMatches(state *ordjson.Object) bool { + value, ok := state.Get("schema") + if !ok { + return false + } + number, ok := value.(json.Number) + if !ok { + return false + } + n, err := number.Int64() + return err == nil && n == Schema +} + +func resolveHome(home string) (string, error) { + expanded, err := expandUser(home) + if err != nil { + return "", err + } + absolute, err := filepath.Abs(expanded) + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(absolute); err == nil { + return resolved, nil + } + return absolute, nil +} + +func expandUser(path string) (string, error) { + if path != "~" && !hasHomePrefix(path) { + return path, nil + } + dir, err := os.UserHomeDir() + if err != nil { + return "", err + } + if path == "~" { + return dir, nil + } + return filepath.Join(dir, path[2:]), nil +} + +func hasHomePrefix(path string) bool { + return len(path) >= 2 && path[0] == '~' && path[1] == filepath.Separator +} + +func (s *Store) Init() error { + if err := os.MkdirAll(s.Home, 0o700); err != nil { + return err + } + if err := os.MkdirAll(s.Tasks, 0o700); err != nil { + return err + } + statePath := filepath.Join(s.Home, "state.json") + if _, err := os.Stat(statePath); os.IsNotExist(err) { + state := ordjson.NewObject() + state.Set("schema", json.Number(fmt.Sprint(Schema))) + state.Set("sum_version", SumVersion) + state.Set("created_at", Now()) + return ordjson.WriteFile(statePath, state) + } + return nil +} + +func (s *Store) Lock() (func() error, error) { + if err := s.Init(); err != nil { + return nil, err + } + handle, err := os.OpenFile(filepath.Join(s.Home, ".lock"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(handle.Fd()), syscall.LOCK_EX); err != nil { + handle.Close() + return nil, err + } + return func() error { + unlockErr := syscall.Flock(int(handle.Fd()), syscall.LOCK_UN) + closeErr := handle.Close() + if unlockErr != nil { + return unlockErr + } + return closeErr + }, nil +} + +func (s *Store) TaskPath(taskID string) (string, error) { + if !taskIDPattern.MatchString(taskID) { + return "", fmt.Errorf("invalid task ID") + } + path := filepath.Join(s.Tasks, taskID) + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("task directories must not be symlinks") + } + return path, nil +} + +func (s *Store) ReadTask(taskID string) (*ordjson.Object, error) { + path, err := s.TaskPath(taskID) + if err != nil { + return nil, err + } + value, err := ordjson.ReadFile(filepath.Join(path, "task.json")) + if err != nil { + return nil, err + } + task, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("task.json is not a JSON object") + } + schema, _ := task.Get("schema") + id, _ := task.Get("id") + number, isNumber := schema.(json.Number) + schemaOK := isNumber + if isNumber { + n, convErr := number.Int64() + schemaOK = convErr == nil && n == Schema + } + if !schemaOK || id != taskID { + return nil, fmt.Errorf("task identity/schema mismatch") + } + return task, nil +} + +func (s *Store) SaveTask(task *ordjson.Object) error { + id, ok := task.Get("id") + idString, isString := id.(string) + if !ok || !isString { + return fmt.Errorf("task has no string id") + } + path, err := s.TaskPath(idString) + if err != nil { + return err + } + task.Set("updated_at", Now()) + return ordjson.WriteFile(filepath.Join(path, "task.json"), task) +} + +func (s *Store) AllTasks() ([]*ordjson.Object, error) { + entries, err := filepath.Glob(filepath.Join(s.Tasks, "t-*", "task.json")) + if err != nil { + return nil, err + } + sort.Strings(entries) + tasks := make([]*ordjson.Object, 0, len(entries)) + for _, entry := range entries { + id := filepath.Base(filepath.Dir(entry)) + task, err := s.ReadTask(id) + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + return tasks, nil +} + +func Now() string { + return time.Now().UTC().Format("2006-01-02T15:04:05+00:00") +} + +type Endpoint struct { + Machine string + Session string + Pane string + Cwd string +} + +func registrationKey(e Endpoint) string { + sum := sha256.Sum256([]byte(strings.Join([]string{e.Machine, e.Session, e.Pane}, "\n"))) + return hex.EncodeToString(sum[:])[:16] +} + +func identityMatches(value *ordjson.Object, e Endpoint) bool { + machine, _ := value.Get("machine") + session, _ := value.Get("session") + pane, _ := value.Get("pane") + return machine == e.Machine && session == e.Session && pane == e.Pane +} + +func (s *Store) Designated() bool { + if info, err := os.Stat(filepath.Join(s.Home, "state.json")); err != nil || info.IsDir() { + return false + } + if info, err := os.Stat(filepath.Join(s.Home, "dev.json")); err == nil && !info.IsDir() { + return false + } + return true +} + +func (s *Store) Owner() (*ordjson.Object, error) { + path := filepath.Join(s.Home, "context.json") + if info, err := os.Stat(path); err != nil || info.IsDir() { + return nil, nil + } + value, err := ordjson.ReadFile(path) + if err != nil { + return nil, err + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("context.json is not a JSON object") + } + return obj, nil +} + +func (s *Store) Registration(endpoint Endpoint) (*ordjson.Object, error) { + path := filepath.Join(s.Sessions, registrationKey(endpoint)+".json") + if info, err := os.Stat(path); err != nil || info.IsDir() { + return nil, nil + } + value, err := ordjson.ReadFile(path) + if err != nil { + return nil, err + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("session registration is not a JSON object") + } + if !identityMatches(obj, endpoint) { + return nil, fmt.Errorf("session registration identity mismatch; inspect the sessions directory") + } + return obj, nil +} + +func (s *Store) Register(endpoint Endpoint, role string, task any) (*ordjson.Object, error) { + stateValue, err := ordjson.ReadFile(filepath.Join(s.Home, "state.json")) + if err != nil { + return nil, err + } + state, ok := stateValue.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("state.json is not a JSON object") + } + previous, err := s.Registration(endpoint) + if err != nil { + return nil, err + } + instance, _ := state.Get("instance") + registeredAt := Now() + if previous != nil { + if v, ok := previous.Get("registered_at"); ok { + if s, isString := v.(string); isString { + registeredAt = s + } + } + } + + key := registrationKey(endpoint) + value := ordjson.NewObject() + value.Set("schema", json.Number(fmt.Sprint(Schema))) + value.Set("key", key) + value.Set("role", role) + value.Set("task", task) + value.Set("machine", endpoint.Machine) + value.Set("session", endpoint.Session) + value.Set("pane", endpoint.Pane) + value.Set("cwd", endpointCwd(endpoint)) + value.Set("instance", instance) + value.Set("sum_version", contract.SumVersion) + mcp := ordjson.NewObject() + mcp.Set("server", contract.MCP.Server) + mcp.Set("version", contract.MCP.Version) + mcp.Set("tools", json.Number(fmt.Sprint(contract.MCP.Tools))) + value.Set("mcp", mcp) + value.Set("registered_at", registeredAt) + value.Set("updated_at", Now()) + + if err := ordjson.WriteFile(filepath.Join(s.Sessions, key+".json"), value); err != nil { + return nil, err + } + return value, nil +} + +func endpointCwd(e Endpoint) any { + if e.Cwd == "" { + return nil + } + return e.Cwd +} + +func (s *Store) Registrations() ([]*ordjson.Object, error) { + entries, err := filepath.Glob(filepath.Join(s.Sessions, "*.json")) + if err != nil { + return nil, err + } + sort.Strings(entries) + registrations := make([]*ordjson.Object, 0, len(entries)) + for _, entry := range entries { + value, err := ordjson.ReadFile(entry) + if err != nil { + return nil, err + } + obj, ok := value.(*ordjson.Object) + if !ok { + return nil, fmt.Errorf("%s is not a JSON object", entry) + } + registrations = append(registrations, obj) + } + return registrations, nil +} diff --git a/go/internal/store/store_test.go b/go/internal/store/store_test.go new file mode 100644 index 0000000..363f55e --- /dev/null +++ b/go/internal/store/store_test.go @@ -0,0 +1,147 @@ +package store + +import ( + "encoding/json" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/douglasjarquin/sum/go/internal/ordjson" +) + +func jsonInt(n int) json.Number { + return json.Number(strconv.Itoa(n)) +} + +func TestOpen_initializesEmptyStoreWithoutWriting(t *testing.T) { + home := filepath.Join(t.TempDir(), "state") + s, err := Open(home) + if err != nil { + t.Fatalf("open: %v", err) + } + if s.Home != home { + t.Fatalf("home = %q, want %q", s.Home, home) + } + if _, statErr := ordjson.ReadFile(filepath.Join(home, "state.json")); statErr == nil { + t.Fatal("Open must not create state.json") + } +} + +func TestInitThenSaveThenReadTask_roundTripsAndBumpsUpdatedAt(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + taskID := "t-0123456789ab" + task := ordjson.NewObject() + task.Set("schema", jsonInt(Schema)) + task.Set("id", taskID) + task.Set("status", "prepared") + if err := s.SaveTask(task); err != nil { + t.Fatalf("save: %v", err) + } + if _, ok := task.Get("updated_at"); !ok { + t.Fatal("save did not set updated_at") + } + + read, err := s.ReadTask(taskID) + if err != nil { + t.Fatalf("read: %v", err) + } + status, _ := read.Get("status") + if status != "prepared" { + t.Fatalf("status = %v, want prepared", status) + } +} + +func TestTaskPath_rejectsInvalidTaskID(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + if _, err := s.TaskPath("not-an-id"); err == nil { + t.Fatal("expected invalid task ID to be rejected") + } +} + +func TestAllTasks_returnsTasksSortedByID(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := s.Init(); err != nil { + t.Fatalf("init: %v", err) + } + for _, id := range []string{"t-bbbbbbbbbbbb", "t-aaaaaaaaaaaa"} { + task := ordjson.NewObject() + task.Set("schema", jsonInt(Schema)) + task.Set("id", id) + if err := s.SaveTask(task); err != nil { + t.Fatalf("save %s: %v", id, err) + } + } + tasks, err := s.AllTasks() + if err != nil { + t.Fatalf("all: %v", err) + } + if len(tasks) != 2 { + t.Fatalf("len(tasks) = %d, want 2", len(tasks)) + } + first, _ := tasks[0].Get("id") + if first != "t-aaaaaaaaaaaa" { + t.Fatalf("first task id = %v, want t-aaaaaaaaaaaa", first) + } +} + +func TestLock_excludesASecondLockerUntilReleased(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("open: %v", err) + } + unlock, err := s.Lock() + if err != nil { + t.Fatalf("lock: %v", err) + } + + acquired := make(chan error, 1) + go func() { + second, err := Open(s.Home) + if err != nil { + acquired <- err + return + } + secondUnlock, err := second.Lock() + if err != nil { + acquired <- err + return + } + defer secondUnlock() + acquired <- nil + }() + + select { + case err := <-acquired: + t.Fatalf("second locker acquired the lock while the first still held it (err=%v)", err) + case <-time.After(150 * time.Millisecond): + } + + if err := unlock(); err != nil { + t.Fatalf("unlock: %v", err) + } + + select { + case err := <-acquired: + if err != nil { + t.Fatalf("second locker failed after release: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("second locker never acquired the lock after release") + } +} diff --git a/go/internal/toolpath/toolpath.go b/go/internal/toolpath/toolpath.go new file mode 100644 index 0000000..120a500 --- /dev/null +++ b/go/internal/toolpath/toolpath.go @@ -0,0 +1,24 @@ +package toolpath + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func Find(runtimeRoot, name string) (string, error) { + envName := "SUM_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_")) + "_BIN" + if override := os.Getenv(envName); override != "" { + return override, nil + } + local := filepath.Join(runtimeRoot, ".local", "bin", name) + if info, err := os.Stat(local); err == nil && !info.IsDir() { + return local, nil + } + if found, err := exec.LookPath(name); err == nil { + return found, nil + } + return "", fmt.Errorf("Missing %s. Run mise run setup.", name) +} diff --git a/go/internal/toolpath/toolpath_test.go b/go/internal/toolpath/toolpath_test.go new file mode 100644 index 0000000..f5af369 --- /dev/null +++ b/go/internal/toolpath/toolpath_test.go @@ -0,0 +1,58 @@ +package toolpath + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFind_honorsEnvOverrideFirst(t *testing.T) { + t.Setenv("SUM_HERDR_BIN", "/somewhere/herdr") + got, err := Find(t.TempDir(), "herdr") + if err != nil { + t.Fatalf("find: %v", err) + } + if got != "/somewhere/herdr" { + t.Fatalf("got %q, want the env override", got) + } +} + +func TestFind_honorsHyphenatedNameEnvVar(t *testing.T) { + t.Setenv("SUM_QUOTA_AXI_BIN", "/somewhere/quota-axi") + got, err := Find(t.TempDir(), "quota-axi") + if err != nil { + t.Fatalf("find: %v", err) + } + if got != "/somewhere/quota-axi" { + t.Fatalf("got %q, want the env override", got) + } +} + +func TestFind_prefersPinnedLocalOverPath(t *testing.T) { + root := t.TempDir() + local := filepath.Join(root, ".local", "bin", "widget") + if err := os.MkdirAll(filepath.Dir(local), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(local, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + got, err := Find(root, "widget") + if err != nil { + t.Fatalf("find: %v", err) + } + if got != local { + t.Fatalf("got %q, want the pinned local %q", got, local) + } +} + +func TestFind_errorsWhenNowhereToBeFound(t *testing.T) { + _, err := Find(t.TempDir(), "definitely-not-a-real-tool-xyz") + if err == nil { + t.Fatal("expected an error for a missing tool") + } + want := "Missing definitely-not-a-real-tool-xyz. Run mise run setup." + if err.Error() != want { + t.Fatalf("err = %q, want %q", err.Error(), want) + } +}