Skip to content

Commit 7757628

Browse files
authored
release: promote dev to main (P6 backend-unblocked + trace-by-id fix) (#21)
* feat(cli): add P6 backend-unblocked CLI surfaces (issue #16) (#19) * plan(p6): add domain coverage P6 backend-unblocked CLI plan Mirrors issue #16 scope (7 command surfaces) as 4 grouped implementation phases with TDD per the user's request. Backend evidence: digitopvn/goclaw PR #37 + PR #44. Verified beta tag containing PR #44 is v3.12.0-beta.20. Phases: 1. Scope Lock — contract re-verification, drift sweep 2. Traces Follow + Providers Reconnect (PR #37) 3. Sessions Branch + Follow (PR #44 chat) 4. Channels Writers Test (PR #44 channels) 5. Activity + Logs Runtime Aggregate (PR #44 aggregation) 6. Tests + Docs sweep with out-of-scope red-team 7. Ship Readiness — single PR to dev Out-of-scope list retained verbatim from issue #16: traces replay, generic logs aggregate, WS/SSE chat history, watch loops. * feat(cli): add P6 backend-unblocked CLI surfaces (issue #16) Seven one-shot HTTP commands wired to backend PRs #37 and #44 (gateway tag v3.12.0-beta.20+). TDD: failing tests landed before each implementation slice; all suites green under -count=1. PR #37 surfaces: - traces follow --session-key|--agent [--since --limit --include-spans --status --channel]: GET /v1/traces/follow - providers reconnect <id>: POST /v1/providers/{id}/reconnect PR #44 surfaces: - sessions branch <key> --up-to-index N [--new-session-key --label --metadata k=v]: POST /v1/chat/sessions/{key}/branch. --up-to-index=0 preserved literally on the wire (bypasses buildBody int-zero skip). - sessions follow <key> [--cursor N --limit N]: one-shot cursor-based history poll, NOT a watch loop. --cursor=0 preserved in raw query. - channels writers test <id> --group-id G --user-id U: POST writers/test with body containing exactly two keys. - activity aggregate --group-by {action|actor_type|entity_type|actor_id} [--from --to --limit + scope filters]: subcommand of existing activityCmd. - logs aggregate [--group-by {level|source} --level --source --from]: ring-buffer summary, distinct from logs tail. Shared formatLastSeen helper type-switches RFC3339-string vs epoch-millis last_seen so neither aggregate command renders scientific notation in table view. All path params url.PathEscape'd; all flag validation runs before HTTP call; central error handler honored (no direct error printing). Docs: README + docs/codebase-summary + CHANGELOG updated. Closes #16 * fix(cli): render trace details by id with validation + exit-code mapping (issue #17) (#20) Closes #17. Bug surface: `goclaw traces get <id>` was unreadable in TTY mode (dumped raw JSON), silently swallowed unmarshal errors (empty `{}`), accepted any raw id including path-traversal sequences (`..`, `/`, control chars), and collapsed every server failure to exit code 1 regardless of HTTP status. Fixes: - `cmd/traces.go` - `tracesGetCmd.RunE`: validate id allowlist before HTTP, decode payload with error surfacing, render header card + span tree + events list for TTY, pass-through JSON for `-o json` / piped stdout. - `validateTraceID`: regex allowlist `^[A-Za-z0-9._-]+$` + reserved-token reject (`.`, `..`, empty/whitespace). Returns typed `*client.APIError{Code: INVALID_REQUEST}` so the central error handler maps it to exit code 4. - `renderTraceTable` + `buildSpanTree`: insertion-ordered span tree, orphan spans attach to virtual root. No relink walk, cycle-safe (cycles silently drop). - `internal/client/http.go` - Retry loop bug: previously closed `resp.Body` on every iteration including the final attempt, then `io.ReadAll` after the loop failed with "read on closed response body". This collapsed typed `APIError` into an opaque wrapped error and lost the exit-code mapping for 5xx/429. Fixed by skipping the per-iteration close on the final attempt; outer `defer` handles cleanup. Exit-code contract for `traces get`: - 0 success / 2 permission denied / 3 not-found / 4 malformed id (pre-HTTP) / 5 server failure / 6 rate-limit. Tests: 10 new tests in `cmd/traces_get_test.go` lock the wire contract, table+JSON rendering, exit-code mapping per HTTP status, and the "malformed id makes zero HTTP calls" invariant (parameterized table). Fixture `cmd/testdata/trace_detail_get.json` is a scrubbed stub with `_TODO_refresh` marker; refresh against live gateway before merging to default. Docs: CHANGELOG `[Unreleased]` Fixed entry, README `Reading a Trace by ID` section with exit-code table, `docs/codebase-summary.md` traces bullet updated. * docs: add planning artifacts for P6 backend-unblocked CLI and skill scaffold
1 parent 445c939 commit 7757628

57 files changed

Lines changed: 8065 additions & 10 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# GoClaw CLI
2+
3+
Go CLI for managing GoClaw AI agent gateway servers.
4+
5+
## Tech Stack
6+
7+
- **Language:** Go 1.25
8+
- **CLI:** Cobra (commands) + Viper-style config
9+
- **Transport:** HTTP REST + WebSocket RPC (gorilla/websocket)
10+
- **Config:** `~/.goclaw/config.yaml` + env vars + flags
11+
12+
## Build & Test
13+
14+
```bash
15+
go build ./... # Compile check
16+
go vet ./... # Static analysis
17+
go test ./... # Run all tests
18+
go test -count=1 ./... # Skip test cache
19+
make build # Build binary with ldflags
20+
make install # Install to GOPATH/bin
21+
```
22+
23+
## Project Structure
24+
25+
```
26+
cmd/ # Cobra command files (1 per resource group)
27+
internal/
28+
├── client/ # HTTP + WebSocket + auth clients
29+
├── config/ # Config loader (~/.goclaw/)
30+
├── output/ # Table/JSON/YAML formatters
31+
└── tui/ # Interactive prompts
32+
```
33+
34+
## Conventions
35+
36+
- Go snake_case file naming
37+
- Cobra command pattern: register in `init()`, implement as `RunE`
38+
- Config precedence: flags > env vars > config file
39+
- Token stored in credential store (not config.yaml)
40+
- All destructive ops require `--yes` or interactive confirmation
41+
- Dual mode: interactive (table output) + automation (JSON/YAML)
42+
43+
## Key Patterns
44+
45+
- `newHTTP()` / `newWS()` — create authenticated clients from global config
46+
- `buildBody()` — construct request body from flag values, skip empty
47+
- `readContent()` — read from `@filepath` or literal string
48+
- `unmarshalMap()` / `unmarshalList()` — parse JSON responses
49+
- `printer.Print()` — output in configured format
50+
51+
## Testing
52+
53+
- Unit tests in `*_test.go` alongside source
54+
- Use `httptest.NewServer` for HTTP client tests
55+
- Use gorilla/websocket upgrader for WS tests
56+
- No CGO race detector on Windows (use Linux CI)

CHANGELOG.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
55

66
---
77

8-
## [Unreleased] — Domain Coverage Expansion (P0–P5)
8+
## [Unreleased] — Domain Coverage Expansion (P0–P6)
99

1010
### Added
1111

@@ -46,6 +46,19 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
4646
- `goclaw agents evolution skill apply <agent-id> <suggestion-id> [--skill-draft @file]` — explicit wrapper for approving `skill_add` suggestions through the server evolution approval route.
4747
- `goclaw agents evolution update` now maps `--action=accept|reject` to the server-compatible `status=approved|rejected` payload.
4848

49+
**P6 — Backend-unblocked surfaces (gateway `v3.12.0-beta.20`+)**
50+
- `goclaw traces follow --session-key|--agent [--since RFC3339] [--limit N]` — one-shot incremental trace polling (`GET /v1/traces/follow`). Re-invoke with returned cursor to advance; no WS stream, no watch loop.
51+
- `goclaw providers reconnect <provider-id>` — hot-reconnect a provider, bumping the registry without touching credentials (`POST /v1/providers/{id}/reconnect`).
52+
- `goclaw sessions branch <session-key> --up-to-index N [--new-session-key K] [--label L] [--metadata k=v ...]` — branch a chat session at a 1-based message index into a new session (`POST /v1/chat/sessions/{key}/branch`). `--up-to-index=0` is preserved on the wire.
53+
- `goclaw sessions follow <session-key> [--cursor N] [--limit N]` — one-shot cursor-based history poll (`GET /v1/chat/sessions/{key}/history/follow`). Not a stream; `--cursor=0` is preserved literally in the query string.
54+
- `goclaw channels writers test <instance-id> --group-id G --user-id U` — probe a (group, user) pair against a channel's writer policy without mutating state (`POST /v1/channels/instances/{id}/writers/test`). Request body has exactly two keys.
55+
- `goclaw activity aggregate --group-by {action|actor_type|entity_type|actor_id} [--from --to --limit --actor-type --actor-id --action --entity-type --entity-id]` — group audit-log activity by dimension with bucket counts (`GET /v1/activity/aggregate`). Attached as subcommand of existing `activity` parent.
56+
- `goclaw logs aggregate [--group-by {level|source}] [--level --source --from]` — summarize the runtime log ring buffer (`GET /v1/logs/runtime/aggregate`, admin-only). Distinct from `logs tail`. Epoch-millis `last_seen` rendered as RFC3339, never scientific notation.
57+
58+
### Fixed
59+
60+
- `goclaw traces get <id>` — TTY mode now renders a human-readable summary (header card + span tree + events list) instead of dumping raw JSON. JSON-mode payload unchanged. Decode failures surface as wrapped errors instead of an empty `{}`. Trace ids are validated against `^[A-Za-z0-9._-]+$` and reserved tokens (`.`, `..`) are rejected before any HTTP call. Distinct exit codes per failure: not-found → 3, permission-denied → 2, malformed-id → 4, server-failure → 5. Latent retry-body bug in `internal/client/http.go` fixed: the final 5xx/429 response body is now preserved so the typed `APIError` reaches the caller (previously collapsed to exit 1). Closes #17.
61+
4962
### Notes
5063
- All new commands honor the AI-first ergonomics contract: `--output=json` envelope, central error handler, `--yes` for destructive ops, `--quiet` for CI.
5164
- P4/P5 backlog was re-swept against the current CLI surface; already-covered items were removed from residual scope before implementation.

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,51 @@ echo "Analyze this log" | goclaw chat myagent
9191
| `restore` | System/tenant restore from backup archive |
9292
| `vault` | Knowledge Vault — documents, links, search, graph, enrichment |
9393

94+
### Backend-Unblocked Surfaces (P6)
95+
96+
Seven one-shot subcommands wired to backend PRs `#37` and `#44`:
97+
98+
```bash
99+
# Incremental trace polling (one shot; rerun with returned cursor)
100+
goclaw traces follow --session-key <key> [--since <RFC3339>] [--limit <n>]
101+
goclaw traces follow --agent <id> [--since <RFC3339>] [--limit <n>]
102+
103+
# Provider hot-reconnect (bumps registry without recreating credentials)
104+
goclaw providers reconnect <provider-id>
105+
106+
# Branch a chat session at a message index
107+
goclaw sessions branch <session-key> --up-to-index <N> [--new-session-key <k>] \
108+
[--label <l>] [--metadata k=v ...]
109+
110+
# One-shot session-history poll (cursor-based; not a stream)
111+
goclaw sessions follow <session-key> [--cursor <n>] [--limit <n>]
112+
113+
# Probe a (group, user) pair against a channel's writer policy
114+
goclaw channels writers test <instance-id> --group-id <g> --user-id <u>
115+
116+
# Aggregate audit-log activity by dimension
117+
goclaw activity aggregate --group-by <action|actor_type|entity_type|actor_id> \
118+
[--from <RFC3339>] [--to <RFC3339>] [--limit <n>] \
119+
[--actor-type <v>] [--actor-id <v>] [--action <v>] [--entity-type <v>] [--entity-id <v>]
120+
121+
# Summarize the runtime log ring buffer (NOT a stream — see 'logs tail' for that)
122+
goclaw logs aggregate [--group-by <level|source>] [--level <l>] [--source <s>] [--from <RFC3339>]
123+
```
124+
125+
All are one-shot HTTP — no watch loops or WS streams. `logs aggregate` is admin-only on the server; `activity aggregate --group-by actor_id` is also admin-only (server-enforced).
126+
127+
### Reading a Trace by ID
128+
129+
```bash
130+
# Human-readable: header + span tree + events
131+
goclaw traces get <trace-id>
132+
133+
# Machine-readable JSON (also auto-selected when stdout is piped)
134+
goclaw traces get <trace-id> -o json
135+
```
136+
137+
Exit codes for `traces get`: `0` on success, `2` on permission denied, `3` on not-found, `4` on malformed id (rejected before any HTTP call — allowlist `^[A-Za-z0-9._-]+$`), `5` on upstream server failure, `6` on rate-limit / network-resource exhaustion.
138+
94139
## Backup & Restore
95140

96141
### System Backup

cmd/activity_aggregate.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
"time"
7+
8+
"github.com/nextlevelbuilder/goclaw-cli/internal/output"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
// validActivityGroupBy enumerates allowed --group-by values for the activity
13+
// aggregate endpoint. Server enforces admin-only for actor_id; the CLI does
14+
// not pre-check that — it only validates the enum.
15+
var validActivityGroupBy = map[string]bool{
16+
"action": true,
17+
"actor_type": true,
18+
"entity_type": true,
19+
"actor_id": true,
20+
}
21+
22+
// formatLastSeen renders an aggregate bucket's last_seen field as RFC3339.
23+
//
24+
// The activity aggregate endpoint returns last_seen as an RFC3339 string,
25+
// but the logs runtime aggregate endpoint returns last_seen as epoch millis
26+
// (a number). `unmarshalMap` decodes JSON numbers as float64, and the shared
27+
// `str()` helper renders large float64 as scientific notation (e.g.
28+
// "1.76e+12"). This helper type-switches so both endpoints render
29+
// consistently as RFC3339 strings in the table view.
30+
func formatLastSeen(v any) string {
31+
switch t := v.(type) {
32+
case nil:
33+
return "-"
34+
case string:
35+
if t == "" {
36+
return "-"
37+
}
38+
return t
39+
case float64:
40+
if t == 0 {
41+
return "-"
42+
}
43+
return time.UnixMilli(int64(t)).UTC().Format(time.RFC3339)
44+
case int64:
45+
if t == 0 {
46+
return "-"
47+
}
48+
return time.UnixMilli(t).UTC().Format(time.RFC3339)
49+
case int:
50+
if t == 0 {
51+
return "-"
52+
}
53+
return time.UnixMilli(int64(t)).UTC().Format(time.RFC3339)
54+
default:
55+
return fmt.Sprintf("%v", v)
56+
}
57+
}
58+
59+
// activityAggregateCmd groups audit-log activity by a dimension and returns
60+
// bucket counts. Attached as a subcommand of the existing activityCmd
61+
// (declared in cmd/admin.go) so the top-level command surface is unchanged.
62+
//
63+
// Backend route: GET /v1/activity/aggregate
64+
var activityAggregateCmd = &cobra.Command{
65+
Use: "aggregate",
66+
Short: "Aggregate audit-log activity by a grouping dimension",
67+
Long: `Group activity log entries by a dimension (action, actor_type, entity_type,
68+
or actor_id) and return bucket counts with last_seen timestamps.
69+
70+
Optional filters narrow the result set: --from/--to (RFC3339 window),
71+
--actor-type, --actor-id, --action, --entity-type, --entity-id, --limit.
72+
73+
Backend route: GET /v1/activity/aggregate
74+
Note: --group-by=actor_id requires admin privileges (enforced server-side).`,
75+
RunE: func(cmd *cobra.Command, args []string) error {
76+
groupBy, _ := cmd.Flags().GetString("group-by")
77+
if groupBy == "" {
78+
return fmt.Errorf("--group-by is required (one of action, actor_type, entity_type, actor_id)")
79+
}
80+
if !validActivityGroupBy[groupBy] {
81+
return fmt.Errorf("--group-by must be one of action, actor_type, entity_type, actor_id (got %q)", groupBy)
82+
}
83+
from, _ := cmd.Flags().GetString("from")
84+
if from != "" {
85+
if _, err := time.Parse(time.RFC3339, from); err != nil {
86+
return fmt.Errorf("--from must be RFC3339: %w", err)
87+
}
88+
}
89+
to, _ := cmd.Flags().GetString("to")
90+
if to != "" {
91+
if _, err := time.Parse(time.RFC3339, to); err != nil {
92+
return fmt.Errorf("--to must be RFC3339: %w", err)
93+
}
94+
}
95+
96+
q := url.Values{}
97+
q.Set("group_by", groupBy)
98+
if from != "" {
99+
q.Set("from", from)
100+
}
101+
if to != "" {
102+
q.Set("to", to)
103+
}
104+
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
105+
q.Set("limit", fmt.Sprintf("%d", v))
106+
}
107+
for flagName, queryKey := range map[string]string{
108+
"actor-type": "actor_type",
109+
"actor-id": "actor_id",
110+
"action": "action",
111+
"entity-type": "entity_type",
112+
"entity-id": "entity_id",
113+
} {
114+
if v, _ := cmd.Flags().GetString(flagName); v != "" {
115+
q.Set(queryKey, v)
116+
}
117+
}
118+
119+
c, err := newHTTP()
120+
if err != nil {
121+
return err
122+
}
123+
data, err := c.Get("/v1/activity/aggregate?" + q.Encode())
124+
if err != nil {
125+
return err
126+
}
127+
m := unmarshalMap(data)
128+
if cfg.OutputFormat != "table" {
129+
printer.Print(m)
130+
return nil
131+
}
132+
buckets, _ := m["buckets"].([]any)
133+
tbl := output.NewTable("KEY", "COUNT", "LAST_SEEN")
134+
for _, raw := range buckets {
135+
row, ok := raw.(map[string]any)
136+
if !ok {
137+
continue
138+
}
139+
tbl.AddRow(str(row, "key"), str(row, "count"), formatLastSeen(row["last_seen"]))
140+
}
141+
printer.Print(tbl)
142+
return nil
143+
},
144+
}
145+
146+
func init() {
147+
activityAggregateCmd.Flags().String("group-by", "", "Grouping dimension: action | actor_type | entity_type | actor_id (required)")
148+
activityAggregateCmd.Flags().String("from", "", "RFC3339 start of time window")
149+
activityAggregateCmd.Flags().String("to", "", "RFC3339 end of time window")
150+
activityAggregateCmd.Flags().Int("limit", 0, "Maximum buckets to return (server default applied if 0)")
151+
activityAggregateCmd.Flags().String("actor-type", "", "Filter by actor type")
152+
activityAggregateCmd.Flags().String("actor-id", "", "Filter by actor id")
153+
activityAggregateCmd.Flags().String("action", "", "Filter by action")
154+
activityAggregateCmd.Flags().String("entity-type", "", "Filter by entity type")
155+
activityAggregateCmd.Flags().String("entity-id", "", "Filter by entity id")
156+
_ = activityAggregateCmd.MarkFlagRequired("group-by")
157+
activityCmd.AddCommand(activityAggregateCmd)
158+
}

0 commit comments

Comments
 (0)