◐ Off-By-One · answer catalog

go-api-empty-state-ux

1 answer(s)godocker

go-api-empty-state-ux

📦 Source in repository (JSON)

Answer

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

Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog