go-api-empty-state-ux
Root cause: the memory tree/index handlers did os.Stat(memoryDir) and treated any failure — including fs.ErrNotExist — as a routing failure, returning a bare 404 with zero guidance. A fresh workspace is a normal, recoverable state (the user hasn't run hivemind init), not a missing route. The CLI then had nothing to read, so it could only echo controller returned status 404.
Fix pattern (three parts, applies to any empty-state API gap):
1. API: 404 → 200 + empty result + hint,omitempty. Only fs.ErrNotExist maps to the empty state; genuine failures (permissions, corrupt paths) stay 500.
type MemoryIndexResponse struct {
Entries []MemoryEntry `json:"entries"`
Hint string `json:"hint,omitempty"` // absent on wire when empty
}
func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request, recursive bool) {
dir := h.memoryDir()
info, err := os.Stat(dir)
switch {
case errors.Is(err, fs.ErrNotExist):
// Empty state: 200 + empty result + actionable hint — NOT 404.
writeJSON(w, http.StatusOK, MemoryIndexResponse{
Entries: []MemoryEntry{}, // non-nil → JSON "[]", never "null"
Hint: fmt.Sprintf("no memory tree found at %q — this workspace was created but not initialized. "+
"Run %q to scaffold the memory tree, then retry.", dir, h.initCmd()),
})
return
case err != nil:
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
case !info.IsDir():
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "memory path is not a directory"})
return
}
writeJSON(w, http.StatusOK, MemoryIndexResponse{Entries: entries}) // no hint
}
Two subtle traps the tests caught: loadEntries must start with entries := []MemoryEntry{} (not var entries []MemoryEntry) so an initialized-but-empty tree also emits []; and the hint,omitempty tag guarantees populated payloads stay byte-identical to the pre-fix format, so old clients are unaffected.
2. CLI: decode the body, route hint → stderr, data → stdout. Never stop at the status code.
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var m memoryResponse
_ = json.Unmarshal(body, &m) // tolerate non-JSON error bodies
if m.Hint != "" {
fmt.Fprintln(cfg.stderr, "hivemind: "+m.Hint) // guidance off the data channel
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("server returned %s", resp.Status) // still non-zero for scripts
}
m.Hint = "" // stderr-only; never serialize into stdout data
json.NewEncoder(cfg.stdout).Encode(m)
This also degrades gracefully against a half-upgraded fleet: if a legacy server returns 404 with a hint, the hint still reaches stderr and the exit code stays non-zero (automation fails loudly, humans get guidance).
3. Docs: workspace create ≠ init.
```markdown
Verified in a real Go module (`go 1.26`): `go vet`, `gofmt`, and `go test ./...` all clean, 13/13 tests passing.
End-to-end wire behavior (`go run ./demo`):
```
== BEFORE (old behavior) ==
GET /memory/tree -> status 404, body: "404 page not found\n"
== AFTER (fix applied, fresh workspace) ==
GET /memory/tree -> status 200, body: {"entries":[],"hint":"no memory tree found at \"/tmp/ws…/memory\" — this workspace was created but not initialized. Run \"hivemind init\" to scaffold the memory tree, then retry."}
== AFTER init (populated) ==
GET /memory/tree -> status 200, body: {"entries":[{"path":"note.md","kind":"file"},{"path":"sub","kind":"dir"},{"path":"sub/deep.md","kind":"file"}]}
```
Edge cases tested (each its own test):
| Case | Assertion |
|---|---|
| Fresh workspace, no `memory/` | 200, `entries:[]` (not null), hint names `hivemind init`, `Content-Type: application/json` |
| Populated workspace | 200, entries listed, `hint` absent from raw JSON (`omitempty`) |
| `memory/` exists but empty (user ran init) | 200, `entries:[]`, **no hint** — init already done |
| `memory/` is a file / unreadable (mode 000) | 500, error body, **no hint** — only `fs.ErrNotExist` is empty state |
| `hint` field on the wire | empty hint omitted entirely; non-JSON/HTML/nil bodies → empty hint (tolerant decode) |
| CLI fresh workspace | hint on **stderr**, pure JSON on **stdout**, exit 0 |
| CLI populated workspace | stderr completely quiet, data on stdout |
| CLI vs legacy 404 (no hint) | exit non-zero, error mentions 404, stderr quiet |
| CLI vs 404-with-hint | hint reaches stderr *and* exit non-zero |
| Transport failure | still a hard error |
| HTTP lifecycle regression | `httptest` server: fresh → 200+hint → `mkdir memory/` → hint disappears |
Note: the first test run caught two genuine bugs in my initial fix (null `entries` on initialized-but-empty trees; hint leaking into stdout), both fixed and locked in by the tests above.
Module lives at `/tmp/hivemind-fix` (`api/`, `cli/`, `demo/`, `docs/`).
---{"model": "deepseek-v4-flash", "problem_class": "go-api-empty-state-ux", "result": "passed", "tests": 13}