From cd892dff510b974fd146f7134dd61e0b785a6087 Mon Sep 17 00:00:00 2001 From: Goon Date: Thu, 11 Jun 2026 23:31:12 +0700 Subject: [PATCH 1/2] feat(traces): complete CLI trace contract --- CHANGELOG.md | 8 +- README.md | 14 +- cmd/p3_commands_test.go | 25 ++- cmd/testdata/trace_detail_get.json | 69 ++++---- cmd/traces.go | 149 ++++++------------ cmd/traces_contract_test.go | 34 ++++ cmd/traces_export_test.go | 57 +++++++ cmd/traces_follow.go | 12 +- cmd/traces_follow_test.go | 9 +- cmd/traces_get_test.go | 42 +++-- cmd/traces_list_test.go | 135 ++++++++++++++++ cmd/traces_render_helpers.go | 144 +++++++++++++++++ cmd/traces_timeline.go | 85 ++++++++++ cmd/traces_timeline_test.go | 147 +++++++++++++++++ docs/codebase-summary.md | 10 +- docs/project-overview-pdr.md | 6 +- docs/project-roadmap.md | 5 +- docs/system-architecture.md | 2 +- .../plan.md | 6 +- ...se-01-contract-lock-and-fixture-refresh.md | 76 +++++++++ ...e-02-list-and-follow-contract-alignment.md | 80 ++++++++++ ...se-03-trace-detail-and-export-hardening.md | 82 ++++++++++ .../phase-04-run-timeline-command.md | 81 ++++++++++ ...e-05-docs-validation-and-ship-readiness.md | 91 +++++++++++ .../plan.md | 104 ++++++++++++ .../reports/contract-lock.md | 40 +++++ .../reports/final-validation.md | 44 ++++++ 27 files changed, 1366 insertions(+), 191 deletions(-) create mode 100644 cmd/traces_contract_test.go create mode 100644 cmd/traces_export_test.go create mode 100644 cmd/traces_list_test.go create mode 100644 cmd/traces_render_helpers.go create mode 100644 cmd/traces_timeline.go create mode 100644 cmd/traces_timeline_test.go create mode 100644 plans/260611-2303-complete-traces-cli-contract/phase-01-contract-lock-and-fixture-refresh.md create mode 100644 plans/260611-2303-complete-traces-cli-contract/phase-02-list-and-follow-contract-alignment.md create mode 100644 plans/260611-2303-complete-traces-cli-contract/phase-03-trace-detail-and-export-hardening.md create mode 100644 plans/260611-2303-complete-traces-cli-contract/phase-04-run-timeline-command.md create mode 100644 plans/260611-2303-complete-traces-cli-contract/phase-05-docs-validation-and-ship-readiness.md create mode 100644 plans/260611-2303-complete-traces-cli-contract/plan.md create mode 100644 plans/260611-2303-complete-traces-cli-contract/reports/contract-lock.md create mode 100644 plans/260611-2303-complete-traces-cli-contract/reports/final-validation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b969e1a..dcda86c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `GOCLAW_PROFILE` — per-command profile selection precedence between `--profile` and active config. - `goclaw sessions compact ` — invokes WS RPC `sessions.compact` behind destructive confirmation. - `goclaw health` — uses WS RPC `health` when authenticated, retaining unauthenticated HTTP `/health` fallback. -- `goclaw traces list --since --agent --status --root-only --limit` — expanded filters for automation-friendly trace search. +- `goclaw traces list --agent --user --session-key --status --channel --limit --offset` — server-aligned filters for paginated trace listing. **P4 — UX polish** - `goclaw codex-pool activity --agent=|--provider=` — unified Codex pool activity lookup; legacy agent/provider commands remain as deprecated aliases. @@ -48,6 +48,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). **P6 — Backend-unblocked surfaces (gateway `v3.12.0-beta.20`+)** - `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. +- `goclaw traces timeline [--session-key K] [--limit N] [--offset N]` — read archived run timeline items (`GET /v1/runs/{runID}/timeline`) without replaying or mutating a run. - `goclaw providers reconnect ` — hot-reconnect a provider, bumping the registry without touching credentials (`POST /v1/providers/{id}/reconnect`). - `goclaw sessions branch --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. - `goclaw sessions follow [--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. @@ -57,7 +58,10 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed -- `goclaw traces get ` — 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. +- `goclaw traces list` now decodes the current server payload `{traces,total,limit,offset}`. JSON/YAML mode preserves that envelope; table mode renders rows from `traces` using `id`, `total_input_tokens`, `total_output_tokens`, and `total_cost`. +- `goclaw traces get ` — TTY mode now renders a human-readable summary (header card + span tree) 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. +- `goclaw traces get ` now handles the current server detail payload `{trace,spans}` while preserving the server envelope in JSON/YAML mode. +- `goclaw traces export ` now validates trace IDs and path-escapes the export route before making the HTTP request. ### Notes - All new commands honor the AI-first ergonomics contract: `--output=json` envelope, central error handler, `--yes` for destructive ops, `--quiet` for CI. diff --git a/README.md b/README.md index 43a1a4d..7d9592b 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,17 @@ echo "Analyze this log" | goclaw chat myagent Seven one-shot subcommands wired to backend PRs `#37` and `#44`: ```bash +# Paginated trace listing with server-supported filters +goclaw traces list [--agent ] [--user ] [--session-key ] \ + [--status ] [--channel ] [--limit ] [--offset ] + # Incremental trace polling (one shot; rerun with returned cursor) goclaw traces follow --session-key [--since ] [--limit ] goclaw traces follow --agent [--since ] [--limit ] +# Archived run timeline (read-only) +goclaw traces timeline [--session-key ] [--limit ] [--offset ] + # Provider hot-reconnect (bumps registry without recreating credentials) goclaw providers reconnect @@ -127,11 +134,14 @@ All are one-shot HTTP — no watch loops or WS streams. `logs aggregate` is admi ### Reading a Trace by ID ```bash -# Human-readable: header + span tree + events +# Human-readable: header + span tree goclaw traces get # Machine-readable JSON (also auto-selected when stdout is piped) goclaw traces get -o json + +# Export gzipped trace tree +goclaw traces export --output trace.json.gz ``` 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. @@ -475,7 +485,7 @@ One-shot profile override: ```bash goclaw --profile staging agents list -GOCLAW_PROFILE=staging goclaw traces list --since=1h --root-only -o json +GOCLAW_PROFILE=staging goclaw traces list --session-key=session-1 --channel=telegram -o json ``` ## Claude Code Skill diff --git a/cmd/p3_commands_test.go b/cmd/p3_commands_test.go index b157cc9..a5623d9 100644 --- a/cmd/p3_commands_test.go +++ b/cmd/p3_commands_test.go @@ -86,26 +86,37 @@ func TestTracesListAddsP3Filters(t *testing.T) { return } q := r.URL.Query() - if q.Get("agent_id") != "agent-1" || q.Get("status") != "error" || - q.Get("since") != "1h" || q.Get("root_only") != "true" || q.Get("limit") != "5" { + if q.Get("agent_id") != "agent-1" || q.Get("user_id") != "user-1" || + q.Get("session_key") != "session-1" || q.Get("status") != "error" || + q.Get("channel") != "telegram" || q.Get("limit") != "5" || + q.Get("offset") != "10" { t.Fatalf("unexpected query: %s", r.URL.RawQuery) } - okJSON(t, w, []map[string]any{{"trace_id": "trace-1"}}) + rawJSON(t, w, map[string]any{ + "traces": []map[string]any{{"id": "trace-1"}}, + "total": 1, + "limit": 5, + "offset": 10, + }) })) defer srv.Close() setupP3CommandTest(srv.URL) _ = tracesListCmd.Flags().Set("agent", "agent-1") + _ = tracesListCmd.Flags().Set("user", "user-1") + _ = tracesListCmd.Flags().Set("session-key", "session-1") _ = tracesListCmd.Flags().Set("status", "error") - _ = tracesListCmd.Flags().Set("since", "1h") - _ = tracesListCmd.Flags().Set("root-only", "true") + _ = tracesListCmd.Flags().Set("channel", "telegram") _ = tracesListCmd.Flags().Set("limit", "5") + _ = tracesListCmd.Flags().Set("offset", "10") t.Cleanup(func() { _ = tracesListCmd.Flags().Set("agent", "") + _ = tracesListCmd.Flags().Set("user", "") + _ = tracesListCmd.Flags().Set("session-key", "") _ = tracesListCmd.Flags().Set("status", "") - _ = tracesListCmd.Flags().Set("since", "") - _ = tracesListCmd.Flags().Set("root-only", "false") + _ = tracesListCmd.Flags().Set("channel", "") _ = tracesListCmd.Flags().Set("limit", "20") + _ = tracesListCmd.Flags().Set("offset", "0") }) if err := tracesListCmd.RunE(tracesListCmd, nil); err != nil { diff --git a/cmd/testdata/trace_detail_get.json b/cmd/testdata/trace_detail_get.json index 6ecad9c..63b7e8a 100644 --- a/cmd/testdata/trace_detail_get.json +++ b/cmd/testdata/trace_detail_get.json @@ -1,54 +1,57 @@ { - "_TODO_refresh": "stub fixture derived from traces follow payload shape; refresh against goclaw.zuey.me before merge per phase-03 reviewer gate", - "trace_id": "trace_FIXTURE_001", - "agent_id": "agent_FIXTURE_001", - "session_key": "session_FIXTURE_001", - "user_id": "user_REDACTED", - "tenant_id": "tenant_REDACTED", - "status": "success", - "started_at": "2026-05-28T10:00:00Z", - "ended_at": "2026-05-28T10:00:02Z", - "duration_ms": 2000, - "input_tokens": 120, - "output_tokens": 80, - "cost": "0.0042", + "trace": { + "id": "trace_FIXTURE_001", + "agent_id": "agent_FIXTURE_001", + "session_key": "session_FIXTURE_001", + "run_id": "run_FIXTURE_001", + "user_id": "user_REDACTED", + "status": "completed", + "start_time": "2026-05-28T10:00:00Z", + "end_time": "2026-05-28T10:00:02Z", + "duration_ms": 2000, + "total_input_tokens": 120, + "total_output_tokens": 80, + "total_cost": 0.0042, + "span_count": 3, + "llm_call_count": 1, + "tool_call_count": 1 + }, "spans": [ { - "span_id": "span_001", + "id": "span_001", + "trace_id": "trace_FIXTURE_001", "parent_span_id": null, + "span_type": "agent", "name": "agent.run", - "kind": "agent", - "started_at": "2026-05-28T10:00:00Z", - "ended_at": "2026-05-28T10:00:02Z", + "start_time": "2026-05-28T10:00:00Z", + "end_time": "2026-05-28T10:00:02Z", "duration_ms": 2000, - "status": "success" + "status": "completed" }, { - "span_id": "span_002", + "id": "span_002", + "trace_id": "trace_FIXTURE_001", "parent_span_id": "span_001", + "span_type": "llm", "name": "llm.call", - "kind": "llm", - "started_at": "2026-05-28T10:00:00Z", - "ended_at": "2026-05-28T10:00:01Z", + "start_time": "2026-05-28T10:00:00Z", + "end_time": "2026-05-28T10:00:01Z", "duration_ms": 1500, - "status": "success", + "status": "completed", "input_tokens": 120, "output_tokens": 80 }, { - "span_id": "span_003", + "id": "span_003", + "trace_id": "trace_FIXTURE_001", "parent_span_id": "span_001", + "span_type": "tool", "name": "tool.call", - "kind": "tool", - "started_at": "2026-05-28T10:00:01Z", - "ended_at": "2026-05-28T10:00:02Z", + "tool_name": "web_fetch", + "start_time": "2026-05-28T10:00:01Z", + "end_time": "2026-05-28T10:00:02Z", "duration_ms": 400, - "status": "success" + "status": "completed" } - ], - "events": [ - {"event_id": "ev_001", "span_id": "span_002", "type": "llm.prompt", "timestamp": "2026-05-28T10:00:00Z"}, - {"event_id": "ev_002", "span_id": "span_002", "type": "llm.completion", "timestamp": "2026-05-28T10:00:01Z"}, - {"event_id": "ev_003", "span_id": "span_003", "type": "tool.invoke", "timestamp": "2026-05-28T10:00:01Z"} ] } diff --git a/cmd/traces.go b/cmd/traces.go index 6803495..7b7b5e5 100644 --- a/cmd/traces.go +++ b/cmd/traces.go @@ -11,7 +11,6 @@ import ( "time" "github.com/nextlevelbuilder/goclaw-cli/internal/client" - "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/spf13/cobra" ) @@ -28,18 +27,30 @@ var tracesListCmd = &cobra.Command{ if v, _ := cmd.Flags().GetString("agent"); v != "" { q.Set("agent_id", v) } + if v, _ := cmd.Flags().GetString("user"); v != "" { + q.Set("user_id", v) + } + if v, _ := cmd.Flags().GetString("session-key"); v != "" { + q.Set("session_key", v) + } if v, _ := cmd.Flags().GetString("status"); v != "" { q.Set("status", v) } - if v, _ := cmd.Flags().GetString("since"); v != "" { - q.Set("since", v) - } - if v, _ := cmd.Flags().GetBool("root-only"); v { - q.Set("root_only", "true") + if v, _ := cmd.Flags().GetString("channel"); v != "" { + q.Set("channel", v) } if v, _ := cmd.Flags().GetInt("limit"); v > 0 { q.Set("limit", fmt.Sprintf("%d", v)) } + if v, _ := cmd.Flags().GetInt("offset"); v > 0 { + q.Set("offset", fmt.Sprintf("%d", v)) + } + if cmd.Flags().Changed("since") { + return &client.APIError{Code: "INVALID_REQUEST", Message: "traces list no longer supports --since; use traces follow --since for incremental polling"} + } + if cmd.Flags().Changed("root-only") { + return &client.APIError{Code: "INVALID_REQUEST", Message: "traces list no longer supports --root-only; the server trace list has no root-only filter"} + } path := "/v1/traces" if len(q) > 0 { path += "?" + q.Encode() @@ -48,16 +59,15 @@ var tracesListCmd = &cobra.Command{ if err != nil { return err } + envelope, rows, err := decodeTraceListPayload(data) + if err != nil { + return err + } if cfg.OutputFormat != "table" { - printer.Print(unmarshalList(data)) + printer.Print(envelope) return nil } - tbl := output.NewTable("TRACE_ID", "AGENT", "STATUS", "DURATION_MS", "INPUT_TOKENS", "OUTPUT_TOKENS", "COST") - for _, t := range unmarshalList(data) { - tbl.AddRow(str(t, "trace_id"), str(t, "agent_id"), str(t, "status"), - str(t, "duration_ms"), str(t, "input_tokens"), str(t, "output_tokens"), str(t, "cost")) - } - printer.Print(tbl) + printTraceRowsTable(rows) return nil }, } @@ -77,15 +87,15 @@ var tracesGetCmd = &cobra.Command{ if err != nil { return err } - var trace map[string]any - if err := json.Unmarshal(data, &trace); err != nil { + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { return fmt.Errorf("decode trace payload: %w", err) } if cfg.OutputFormat != "table" { - printer.Print(trace) + printer.Print(payload) return nil } - renderTraceTable(trace, os.Stdout) + renderTraceTable(payload, os.Stdout) return nil }, } @@ -105,105 +115,28 @@ func validateTraceID(id string) error { return nil } -// renderTraceTable prints a human-readable summary: header card, span tree, events. -func renderTraceTable(t map[string]any, w io.Writer) { - for _, row := range [][2]string{ - {"TRACE_ID", str(t, "trace_id")}, {"AGENT_ID", str(t, "agent_id")}, - {"SESSION_KEY", str(t, "session_key")}, {"STATUS", str(t, "status")}, - {"DURATION_MS", str(t, "duration_ms")}, - } { - if row[1] != "" { - fmt.Fprintf(w, "%-12s %s\n", row[0]+":", row[1]) - } - } - if in, out, cost := str(t, "input_tokens"), str(t, "output_tokens"), str(t, "cost"); in+out+cost != "" { - fmt.Fprintf(w, "%-12s in=%s out=%s cost=%s\n", "TOKENS:", in, out, cost) - } - spans, _ := t["spans"].([]any) - if len(spans) == 0 { - fmt.Fprintln(w, "\nSPANS: (none)") - } else { - fmt.Fprintln(w, "\nSPANS:") - output.PrintTreeRoot(buildSpanTree(spans), w) - } - events, _ := t["events"].([]any) - fmt.Fprintf(w, "\nEVENTS (n=%d):\n", len(events)) - for _, e := range events { - if m, ok := e.(map[string]any); ok { - fmt.Fprintf(w, " - %s\n", str(m, "type")) - } - } -} - -// buildSpanTree links spans via parent_span_id; spans whose parent isn't in this -// trace attach to a virtual root. Children are kept in insertion order. -func buildSpanTree(spans []any) output.TreeNode { - order := make([]string, 0, len(spans)) - labels := make(map[string]string, len(spans)) - children := make(map[string][]string, len(spans)) - parentOf := make(map[string]string, len(spans)) - for _, s := range spans { - m, ok := s.(map[string]any) - if !ok { - continue - } - id := str(m, "span_id") - if id == "" { - continue - } - label := id - if name := str(m, "name"); name != "" { - label = name + " [" + id + "]" - } - if kind := str(m, "kind"); kind != "" { - label += " kind=" + kind - } - if dur := str(m, "duration_ms"); dur != "" { - label += " " + dur + "ms" - } - labels[id] = label - order = append(order, id) - parentOf[id], _ = m["parent_span_id"].(string) - } - for _, id := range order { - if p := parentOf[id]; p != "" { - if _, ok := labels[p]; ok { - children[p] = append(children[p], id) - continue - } - } - children[""] = append(children[""], id) - } - var build func(id string) output.TreeNode - build = func(id string) output.TreeNode { - n := output.TreeNode{Name: labels[id]} - for _, c := range children[id] { - n.Children = append(n.Children, build(c)) - } - return n - } - root := output.TreeNode{Name: "trace"} - for _, id := range children[""] { - root.Children = append(root.Children, build(id)) - } - return root -} - var tracesExportCmd = &cobra.Command{ Use: "export ", Short: "Export trace to file", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + id := strings.TrimSpace(args[0]) + if err := validateTraceID(id); err != nil { + return err + } c, err := newHTTP() if err != nil { return err } outFile, _ := cmd.Flags().GetString("output") if outFile == "" { - outFile = args[0] + ".json.gz" + outFile = id + ".json.gz" } - resp, err := c.GetRaw("/v1/traces/" + args[0] + "/export") + resp, err := c.GetRaw("/v1/traces/" + url.PathEscape(id) + "/export") if err != nil { return err } + if resp.StatusCode >= 400 { + return rawResponseError(resp) + } defer resp.Body.Close() f, err := os.Create(outFile) if err != nil { @@ -371,10 +304,16 @@ func normalizeUsageTimestamp(v string) string { func init() { tracesListCmd.Flags().String("agent", "", "Filter by agent ID") + tracesListCmd.Flags().String("user", "", "Filter by user ID") + tracesListCmd.Flags().String("session-key", "", "Filter by session key") tracesListCmd.Flags().String("status", "", "Filter: running, success, error") - tracesListCmd.Flags().String("since", "", "Filter by relative or ISO timestamp, e.g. 1h or 2026-05-19T00:00:00Z") - tracesListCmd.Flags().Bool("root-only", false, "Only show root traces") + tracesListCmd.Flags().String("channel", "", "Filter by channel") tracesListCmd.Flags().Int("limit", 20, "Max results") + tracesListCmd.Flags().Int("offset", 0, "Pagination offset") + tracesListCmd.Flags().String("since", "", "Deprecated: use traces follow --since") + tracesListCmd.Flags().Bool("root-only", false, "Deprecated: unsupported by server trace list") + _ = tracesListCmd.Flags().MarkHidden("since") + _ = tracesListCmd.Flags().MarkHidden("root-only") tracesExportCmd.Flags().StringP("output", "f", "", "Output file (default: .json.gz)") usageSummaryCmd.Flags().String("from", "", "Start date (YYYY-MM-DD)") diff --git a/cmd/traces_contract_test.go b/cmd/traces_contract_test.go new file mode 100644 index 0000000..44b88bd --- /dev/null +++ b/cmd/traces_contract_test.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "testing" +) + +func rawJSON(t *testing.T, w http.ResponseWriter, payload any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("write raw json: %v", err) + } +} + +func resetTracesListFlags(t *testing.T) { + t.Helper() + for _, name := range []string{"agent", "user", "session-key", "status", "channel", "since"} { + resetTestFlag(tracesListCmd, name, "") + } + resetTestFlag(tracesListCmd, "root-only", "false") + resetTestFlag(tracesListCmd, "limit", "20") + resetTestFlag(tracesListCmd, "offset", "0") +} + +func assertNoTracesReplayCommand(t *testing.T) { + t.Helper() + for _, c := range tracesCmd.Commands() { + if c.Name() == "replay" { + t.Fatal("traces replay command must not exist until the server exposes POST /v1/traces/{id}/replay") + } + } +} diff --git a/cmd/traces_export_test.go b/cmd/traces_export_test.go new file mode 100644 index 0000000..d593331 --- /dev/null +++ b/cmd/traces_export_test.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func resetTracesExportFlags(t *testing.T) { + t.Helper() + resetTestFlag(tracesExportCmd, "output", "") +} + +func TestTracesExport_RejectsMalformedIDBeforeHTTP(t *testing.T) { + t.Cleanup(func() { resetTracesExportFlags(t) }) + var calls int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&calls, 1) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "traces", "export", "../bad") + if err == nil { + t.Fatal("expected validation error") + } + if atomic.LoadInt64(&calls) != 0 { + t.Fatalf("malformed trace id made %d HTTP calls", calls) + } + if !strings.Contains(err.Error(), "trace id") { + t.Fatalf("error should mention trace id: %v", err) + } +} + +func TestTracesExport_UsesSafePathAndOutputFile(t *testing.T) { + t.Cleanup(func() { resetTracesExportFlags(t) }) + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.EscapedPath() + w.Header().Set("Content-Type", "application/gzip") + _, _ = w.Write([]byte("gzip-bytes")) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + outFile := t.TempDir() + "/trace.json.gz" + if err := runCmd(t, "traces", "export", "trace_FIXTURE.001", "--output", outFile); err != nil { + t.Fatalf("traces export: %v", err) + } + if gotPath != "/v1/traces/trace_FIXTURE.001/export" { + t.Fatalf("path = %q", gotPath) + } +} diff --git a/cmd/traces_follow.go b/cmd/traces_follow.go index d07ef1c..dc3bb7f 100644 --- a/cmd/traces_follow.go +++ b/cmd/traces_follow.go @@ -5,7 +5,6 @@ import ( "net/url" "time" - "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/spf13/cobra" ) @@ -76,16 +75,7 @@ polling request — no watch loop. Use the returned ` + "`next_since`" + ` to re return nil } traces, _ := envelope["traces"].([]any) - tbl := output.NewTable("TRACE_ID", "AGENT", "STATUS", "DURATION_MS", "INPUT_TOKENS", "OUTPUT_TOKENS", "COST") - for _, raw := range traces { - t, ok := raw.(map[string]any) - if !ok { - continue - } - tbl.AddRow(str(t, "trace_id"), str(t, "agent_id"), str(t, "status"), - str(t, "duration_ms"), str(t, "input_tokens"), str(t, "output_tokens"), str(t, "cost")) - } - printer.Print(tbl) + printTraceRowsTable(traces) return nil }, } diff --git a/cmd/traces_follow_test.go b/cmd/traces_follow_test.go index 43f9091..ca013de 100644 --- a/cmd/traces_follow_test.go +++ b/cmd/traces_follow_test.go @@ -126,7 +126,7 @@ func TestTracesFollow_JSONPreservesEnvelope(t *testing.T) { t.Cleanup(func() { resetTracesFollowFlags(t) }) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { okJSON(t, w, map[string]any{ - "traces": []map[string]any{{"trace_id": "t1"}}, + "traces": []map[string]any{{"id": "t1"}}, "spans_by_trace_id": map[string]any{"t1": []any{}}, "next_since": "2026-05-27T13:00:00Z", "server_time": "2026-05-27T12:30:00Z", @@ -154,7 +154,7 @@ func TestTracesFollow_TableHeaders(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { okJSON(t, w, map[string]any{ "traces": []map[string]any{ - {"trace_id": "t1", "agent_id": "agent-1", "status": "success", "duration_ms": 120, "input_tokens": 50, "output_tokens": 30, "cost": "0.001"}, + {"id": "t1", "agent_id": "agent-1", "status": "completed", "duration_ms": 120, "total_input_tokens": 50, "total_output_tokens": 30, "total_cost": "0.001"}, }, }) })) @@ -169,10 +169,13 @@ func TestTracesFollow_TableHeaders(t *testing.T) { if err != nil { t.Fatalf("traces follow: %v", err) } - headerRE := regexp.MustCompile(`TRACE_ID.*AGENT.*STATUS.*DURATION_MS.*INPUT_TOKENS.*OUTPUT_TOKENS.*COST`) + headerRE := regexp.MustCompile(`ID.*AGENT.*STATUS.*DURATION_MS.*TOTAL_INPUT_TOKENS.*TOTAL_OUTPUT_TOKENS.*TOTAL_COST`) if !headerRE.MatchString(out) { t.Fatalf("table headers missing in:\n%s", out) } + if !strings.Contains(out, "t1") || !strings.Contains(out, "completed") || !strings.Contains(out, "0.001") { + t.Fatalf("table row missing server-shaped fields:\n%s", out) + } } func TestTracesFollow_DoesNotImportFollowStream(t *testing.T) { diff --git a/cmd/traces_get_test.go b/cmd/traces_get_test.go index fd14ce2..feb7994 100644 --- a/cmd/traces_get_test.go +++ b/cmd/traces_get_test.go @@ -12,8 +12,8 @@ import ( "github.com/nextlevelbuilder/goclaw-cli/internal/output" ) -// loadTraceDetailFixture reads the captured trace detail envelope from testdata. -// The fixture is a single trace map (not wrapped). Tests wrap it via okJSON. +// loadTraceDetailFixture reads the server-shaped trace detail payload from testdata. +// The fixture matches GET /v1/traces/{traceID}: {"trace": {...}, "spans": [...]}. func loadTraceDetailFixture(t *testing.T) map[string]any { t.Helper() data, err := os.ReadFile("testdata/trace_detail_get.json") @@ -47,7 +47,7 @@ func TestTracesGet_PathAndMethod(t *testing.T) { atomic.AddInt64(&calls, 1) gotPath = r.URL.Path gotMethod = r.Method - okJSON(t, w, loadTraceDetailFixture(t)) + rawJSON(t, w, loadTraceDetailFixture(t)) })) defer srv.Close() t.Setenv("GOCLAW_SERVER", srv.URL) @@ -71,7 +71,7 @@ func TestTracesGet_PathAndMethod(t *testing.T) { func TestTracesGet_HappyPath_JSON_LocksFixture(t *testing.T) { fixture := loadTraceDetailFixture(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - okJSON(t, w, fixture) + rawJSON(t, w, fixture) })) defer srv.Close() t.Setenv("GOCLAW_SERVER", srv.URL) @@ -87,14 +87,18 @@ func TestTracesGet_HappyPath_JSON_LocksFixture(t *testing.T) { if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatalf("stdout is not JSON: %v\nstdout: %q", err, out) } - if got["trace_id"] != "trace_FIXTURE_001" { - t.Errorf("trace_id = %v", got["trace_id"]) + trace, ok := got["trace"].(map[string]any) + if !ok { + t.Fatalf("trace object missing: %#v", got) } - if got["agent_id"] != "agent_FIXTURE_001" { - t.Errorf("agent_id = %v", got["agent_id"]) + if trace["id"] != "trace_FIXTURE_001" { + t.Errorf("trace.id = %v", trace["id"]) } - if got["status"] != "success" { - t.Errorf("status = %v", got["status"]) + if trace["agent_id"] != "agent_FIXTURE_001" { + t.Errorf("trace.agent_id = %v", trace["agent_id"]) + } + if trace["status"] != "completed" { + t.Errorf("trace.status = %v", trace["status"]) } spans, ok := got["spans"].([]any) if !ok || len(spans) != 3 { @@ -106,7 +110,7 @@ func TestTracesGet_HappyPath_JSON_LocksFixture(t *testing.T) { func TestTracesGet_TableMode_HumanReadable_RED(t *testing.T) { fixture := loadTraceDetailFixture(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - okJSON(t, w, fixture) + rawJSON(t, w, fixture) })) defer srv.Close() t.Setenv("GOCLAW_SERVER", srv.URL) @@ -122,7 +126,7 @@ func TestTracesGet_TableMode_HumanReadable_RED(t *testing.T) { if strings.HasPrefix(trimmed, "{") { t.Fatalf("table mode rendered raw JSON (starts with '{'): %q", out) } - wantAny := []string{"TRACE", "SPAN", "EVENT", "trace_id", "agent_id"} + wantAny := []string{"TRACE", "SPAN", "trace_FIXTURE_001", "agent_FIXTURE_001"} hit := false for _, m := range wantAny { if strings.Contains(out, m) { @@ -139,7 +143,7 @@ func TestTracesGet_TableMode_HumanReadable_RED(t *testing.T) { func TestTracesGet_TableMode_HasHeaderAndSpanMarkers(t *testing.T) { fixture := loadTraceDetailFixture(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - okJSON(t, w, fixture) + rawJSON(t, w, fixture) })) defer srv.Close() t.Setenv("GOCLAW_SERVER", srv.URL) @@ -157,12 +161,18 @@ func TestTracesGet_TableMode_HasHeaderAndSpanMarkers(t *testing.T) { if !strings.Contains(out, "TRACE_ID") { t.Errorf("missing TRACE_ID header in: %q", out) } + if !strings.Contains(out, "RUN_ID") || !strings.Contains(out, "run_FIXTURE_001") { + t.Errorf("missing RUN_ID header/value in: %q", out) + } + if !strings.Contains(out, "TOTAL_INPUT_TOKENS") || !strings.Contains(out, "TOTAL_OUTPUT_TOKENS") { + t.Errorf("missing total token fields in: %q", out) + } // At least one tree connector must appear (├─ or └─). if !strings.Contains(out, "├") && !strings.Contains(out, "└") { t.Errorf("missing span tree connectors (├ / └) in: %q", out) } - if !strings.Contains(out, "EVENTS") { - t.Errorf("missing EVENTS section in: %q", out) + if !strings.Contains(out, "span_002") || !strings.Contains(out, "span_type=llm") { + t.Errorf("span tree should use server id/span_type fields in: %q", out) } } @@ -170,7 +180,7 @@ func TestTracesGet_TableMode_HasHeaderAndSpanMarkers(t *testing.T) { func TestTracesGet_JSONMode_PreservesStructure(t *testing.T) { fixture := loadTraceDetailFixture(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - okJSON(t, w, fixture) + rawJSON(t, w, fixture) })) defer srv.Close() t.Setenv("GOCLAW_SERVER", srv.URL) diff --git a/cmd/traces_list_test.go b/cmd/traces_list_test.go new file mode 100644 index 0000000..5aaa056 --- /dev/null +++ b/cmd/traces_list_test.go @@ -0,0 +1,135 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" +) + +func TestTracesList_ServerEnvelope_TableRows(t *testing.T) { + t.Cleanup(func() { resetTracesListFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/traces" { + w.WriteHeader(http.StatusNotFound) + return + } + rawJSON(t, w, traceListFixture()) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "table") + + out, err := captureStdout(t, func() error { + return runCmd(t, "traces", "list", "--output", "table") + }) + if err != nil { + t.Fatalf("traces list: %v", err) + } + headerRE := regexp.MustCompile(`ID.*AGENT.*STATUS.*DURATION_MS.*TOTAL_INPUT_TOKENS.*TOTAL_OUTPUT_TOKENS.*TOTAL_COST`) + if !headerRE.MatchString(out) { + t.Fatalf("table headers missing in:\n%s", out) + } + for _, want := range []string{"trace-1", "agent-1", "completed", "0.01"} { + if !strings.Contains(out, want) { + t.Fatalf("table output missing %q:\n%s", want, out) + } + } +} + +func TestTracesList_JSONPreservesEnvelope(t *testing.T) { + t.Cleanup(func() { resetTracesListFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawJSON(t, w, traceListFixture()) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + out, err := captureStdout(t, func() error { + return runCmd(t, "traces", "list", "--output", "json") + }) + if err != nil { + t.Fatalf("traces list: %v", err) + } + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, out) + } + if got["total"] != float64(1) || got["limit"] != float64(20) || got["offset"] != float64(0) { + t.Fatalf("envelope pagination fields not preserved: %#v", got) + } + if _, ok := got["traces"].([]any); !ok { + t.Fatalf("traces array not preserved: %#v", got["traces"]) + } +} + +func TestTracesList_ServerFilters(t *testing.T) { + t.Cleanup(func() { resetTracesListFlags(t) }) + var query url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + rawJSON(t, w, traceListFixture()) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + err := runCmd(t, "traces", "list", + "--agent=agent-1", + "--user=user-1", + "--session-key=session-1", + "--status=failed", + "--channel=telegram", + "--limit=5", + "--offset=10", + ) + if err != nil { + t.Fatalf("traces list: %v", err) + } + want := map[string]string{ + "agent_id": "agent-1", + "user_id": "user-1", + "session_key": "session-1", + "status": "failed", + "channel": "telegram", + "limit": "5", + "offset": "10", + } + for key, val := range want { + if got := query.Get(key); got != val { + t.Fatalf("%s = %q, want %q; query=%s", key, got, val, query.Encode()) + } + } + if query.Has("since") || query.Has("root_only") { + t.Fatalf("unsupported list filters must not be sent: %s", query.Encode()) + } +} + +func TestTracesReplayCommandAbsent(t *testing.T) { + assertNoTracesReplayCommand(t) +} + +func traceListFixture() map[string]any { + return map[string]any{ + "traces": []map[string]any{{ + "id": "trace-1", + "agent_id": "agent-1", + "session_key": "session-1", + "status": "completed", + "duration_ms": 2500, + "total_input_tokens": 10, + "total_output_tokens": 5, + "total_cost": 0.01, + }}, + "total": 1, + "limit": 20, + "offset": 0, + } +} diff --git a/cmd/traces_render_helpers.go b/cmd/traces_render_helpers.go new file mode 100644 index 0000000..df3c83b --- /dev/null +++ b/cmd/traces_render_helpers.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" +) + +func decodeTraceListPayload(data json.RawMessage) (map[string]any, []any, error) { + var envelope map[string]any + if err := json.Unmarshal(data, &envelope); err == nil { + rows, _ := envelope["traces"].([]any) + if rows == nil { + rows = []any{} + } + return envelope, rows, nil + } + + var legacy []any + if err := json.Unmarshal(data, &legacy); err != nil { + return nil, nil, fmt.Errorf("decode traces payload: %w", err) + } + return map[string]any{ + "traces": legacy, + "total": len(legacy), + "limit": len(legacy), + "offset": 0, + }, legacy, nil +} + +func printTraceRowsTable(rows []any) { + tbl := output.NewTable("ID", "AGENT", "STATUS", "DURATION_MS", "TOTAL_INPUT_TOKENS", "TOTAL_OUTPUT_TOKENS", "TOTAL_COST") + for _, raw := range rows { + t, ok := raw.(map[string]any) + if !ok { + continue + } + tbl.AddRow(traceField(t, "id", "trace_id"), str(t, "agent_id"), str(t, "status"), + str(t, "duration_ms"), traceField(t, "total_input_tokens", "input_tokens"), + traceField(t, "total_output_tokens", "output_tokens"), traceField(t, "total_cost", "cost")) + } + printer.Print(tbl) +} + +// renderTraceTable prints a human-readable summary and span tree. +func renderTraceTable(payload map[string]any, w io.Writer) { + trace := payload + if nested, ok := payload["trace"].(map[string]any); ok { + trace = nested + } + for _, row := range [][2]string{ + {"TRACE_ID", traceField(trace, "id", "trace_id")}, + {"AGENT_ID", str(trace, "agent_id")}, + {"SESSION_KEY", str(trace, "session_key")}, + {"RUN_ID", str(trace, "run_id")}, + {"STATUS", str(trace, "status")}, + {"DURATION_MS", str(trace, "duration_ms")}, + {"TOTAL_INPUT_TOKENS", traceField(trace, "total_input_tokens", "input_tokens")}, + {"TOTAL_OUTPUT_TOKENS", traceField(trace, "total_output_tokens", "output_tokens")}, + {"TOTAL_COST", traceField(trace, "total_cost", "cost")}, + } { + if row[1] != "" { + fmt.Fprintf(w, "%-20s %s\n", row[0]+":", row[1]) + } + } + spans, _ := payload["spans"].([]any) + if len(spans) == 0 { + fmt.Fprintln(w, "\nSPANS: (none)") + return + } + fmt.Fprintln(w, "\nSPANS:") + output.PrintTreeRoot(buildSpanTree(spans), w) +} + +// buildSpanTree links spans via parent_span_id. Children are kept in insertion order. +func buildSpanTree(spans []any) output.TreeNode { + order := make([]string, 0, len(spans)) + labels := make(map[string]string, len(spans)) + children := make(map[string][]string, len(spans)) + parentOf := make(map[string]string, len(spans)) + for _, s := range spans { + m, ok := s.(map[string]any) + if !ok { + continue + } + id := traceField(m, "id", "span_id") + if id == "" { + continue + } + label := id + if name := str(m, "name"); name != "" { + label = name + " [" + id + "]" + } + if kind := traceField(m, "span_type", "kind"); kind != "" { + label += " span_type=" + kind + } + if dur := str(m, "duration_ms"); dur != "" { + label += " " + dur + "ms" + } + if status := str(m, "status"); status != "" { + label += " status=" + status + } + labels[id] = label + order = append(order, id) + parentOf[id], _ = m["parent_span_id"].(string) + } + for _, id := range order { + if p := parentOf[id]; p != "" { + if _, ok := labels[p]; ok { + children[p] = append(children[p], id) + continue + } + } + children[""] = append(children[""], id) + } + var build func(id string) output.TreeNode + build = func(id string) output.TreeNode { + n := output.TreeNode{Name: labels[id]} + for _, c := range children[id] { + n.Children = append(n.Children, build(c)) + } + return n + } + root := output.TreeNode{Name: "trace"} + for _, id := range children[""] { + root.Children = append(root.Children, build(id)) + } + return root +} + +func traceField(m map[string]any, primary, fallback string) string { + if v := str(m, primary); v != "" && v != "" { + return v + } + if fallback == "" { + return "" + } + if v := str(m, fallback); v != "" && v != "" { + return v + } + return "" +} diff --git a/cmd/traces_timeline.go b/cmd/traces_timeline.go new file mode 100644 index 0000000..597ff94 --- /dev/null +++ b/cmd/traces_timeline.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/nextlevelbuilder/goclaw-cli/internal/client" + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +var tracesTimelineCmd = &cobra.Command{ + Use: "timeline ", + Short: "Read archived run timeline items", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + runID := strings.TrimSpace(args[0]) + if err := validateRunID(runID); err != nil { + return err + } + q := url.Values{} + if v, _ := cmd.Flags().GetString("session-key"); v != "" { + q.Set("session_key", v) + } + if v, _ := cmd.Flags().GetInt("limit"); v > 0 { + q.Set("limit", fmt.Sprintf("%d", v)) + } + if v, _ := cmd.Flags().GetInt("offset"); v > 0 { + q.Set("offset", fmt.Sprintf("%d", v)) + } + + c, err := newHTTP() + if err != nil { + return err + } + path := "/v1/runs/" + url.PathEscape(runID) + "/timeline" + if len(q) > 0 { + path += "?" + q.Encode() + } + data, err := c.Get(path) + if err != nil { + return err + } + var envelope map[string]any + if err := json.Unmarshal(data, &envelope); err != nil { + return fmt.Errorf("decode run timeline payload: %w", err) + } + if cfg.OutputFormat != "table" { + printer.Print(envelope) + return nil + } + items, _ := envelope["items"].([]any) + tbl := output.NewTable("SEQ", "TYPE", "STATUS", "TITLE", "TOOL", "TRACE_ID", "SPAN_ID", "CREATED_AT") + for _, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + continue + } + tbl.AddRow(str(item, "seq"), str(item, "item_type"), str(item, "status"), + str(item, "title"), str(item, "tool_name"), str(item, "trace_id"), + str(item, "span_id"), str(item, "created_at")) + } + printer.Print(tbl) + return nil + }, +} + +func validateRunID(id string) error { + if id == "" || id == "." || id == ".." { + return &client.APIError{Code: "INVALID_REQUEST", Message: "run id is empty or reserved"} + } + if !traceIDPattern.MatchString(id) { + return &client.APIError{Code: "INVALID_REQUEST", Message: "run id contains invalid characters (allowed: A-Z a-z 0-9 . _ -)"} + } + return nil +} + +func init() { + tracesTimelineCmd.Flags().String("session-key", "", "Optional session key scope") + tracesTimelineCmd.Flags().Int("limit", 0, "Max timeline items (server default 200, max 500)") + tracesTimelineCmd.Flags().Int("offset", 0, "Pagination offset") + tracesCmd.AddCommand(tracesTimelineCmd) +} diff --git a/cmd/traces_timeline_test.go b/cmd/traces_timeline_test.go new file mode 100644 index 0000000..6d5c883 --- /dev/null +++ b/cmd/traces_timeline_test.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" +) + +func resetTracesTimelineFlags(t *testing.T) { + t.Helper() + for _, name := range []string{"session-key"} { + resetTestFlag(tracesTimelineCmd, name, "") + } + resetTestFlag(tracesTimelineCmd, "limit", "0") + resetTestFlag(tracesTimelineCmd, "offset", "0") +} + +func TestTracesTimeline_BuildsPathAndQuery(t *testing.T) { + t.Cleanup(func() { resetTracesTimelineFlags(t) }) + var gotPath string + var query url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.EscapedPath() + query = r.URL.Query() + rawJSON(t, w, timelineFixture()) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + if err := runCmd(t, "traces", "timeline", "run_FIXTURE.001", "--session-key=session-1", "--limit=25", "--offset=5"); err != nil { + t.Fatalf("traces timeline: %v", err) + } + if gotPath != "/v1/runs/run_FIXTURE.001/timeline" { + t.Fatalf("path = %q", gotPath) + } + if query.Get("session_key") != "session-1" || query.Get("limit") != "25" || query.Get("offset") != "5" { + t.Fatalf("query = %s", query.Encode()) + } +} + +func TestTracesTimeline_JSONPreservesEnvelope(t *testing.T) { + t.Cleanup(func() { resetTracesTimelineFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawJSON(t, w, timelineFixture()) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "json") + + out, err := captureStdout(t, func() error { + return runCmd(t, "traces", "timeline", "run_FIXTURE.001", "--output", "json") + }) + if err != nil { + t.Fatalf("traces timeline: %v", err) + } + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, out) + } + if got["run_id"] != "run_FIXTURE.001" || got["limit"] != float64(25) { + t.Fatalf("timeline envelope not preserved: %#v", got) + } + if _, ok := got["items"].([]any); !ok { + t.Fatalf("items array not preserved: %#v", got["items"]) + } +} + +func TestTracesTimeline_TableRendersItems(t *testing.T) { + t.Cleanup(func() { resetTracesTimelineFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawJSON(t, w, timelineFixture()) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + t.Setenv("GOCLAW_OUTPUT", "table") + + out, err := captureStdout(t, func() error { + return runCmd(t, "traces", "timeline", "run_FIXTURE.001", "--output", "table") + }) + if err != nil { + t.Fatalf("traces timeline: %v", err) + } + headerRE := regexp.MustCompile(`SEQ.*TYPE.*STATUS.*TITLE.*TOOL.*TRACE_ID.*SPAN_ID.*CREATED_AT`) + if !headerRE.MatchString(out) { + t.Fatalf("table headers missing in:\n%s", out) + } + for _, want := range []string{"2", "tool.call", "running", "web_fetch", "trace-1", "span-1"} { + if !strings.Contains(out, want) { + t.Fatalf("timeline table missing %q:\n%s", want, out) + } + } +} + +func TestTracesTimeline_RejectsMalformedRunIDBeforeHTTP(t *testing.T) { + t.Cleanup(func() { resetTracesTimelineFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("malformed run id should not make an HTTP request") + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "traces", "timeline", "../run") + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestTracesTimeline_CommandRegistered(t *testing.T) { + for _, c := range tracesCmd.Commands() { + if c.Name() == "timeline" { + return + } + } + t.Fatal("traces timeline command is not registered") +} + +func timelineFixture() map[string]any { + return map[string]any{ + "run_id": "run_FIXTURE.001", + "session_key": "session-1", + "items": []map[string]any{{ + "id": "item-1", + "run_id": "run_FIXTURE.001", + "session_key": "session-1", + "seq": 2, + "item_type": "tool.call", + "status": "running", + "title": "web_fetch", + "tool_name": "web_fetch", + "tool_call_id": "call-1", + "trace_id": "trace-1", + "span_id": "span-1", + "created_at": "2026-05-29T10:00:00Z", + }}, + "limit": 25, + "offset": 0, + } +} diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 199e13c..18ac127 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,6 +1,6 @@ # GoClaw CLI - Codebase Summary -**Generated from:** `repomix-output.xml` (2026-04-15), updated manually 2026-05-20 +**Generated from:** `repomix-output.xml` (2026-04-15), updated manually 2026-06-11 **Phase Status:** P0-P4 Complete (AI-First Expansion); Super Admin API Parity Complete; Domain Coverage P5 + P6 (Backend-Unblocked) Implemented **Total Files:** 80+ **Estimated Tokens:** 80,000+ @@ -10,7 +10,7 @@ ## Overview -GoClaw CLI is a production-ready Go application providing comprehensive command-line management for GoClaw AI agent gateway servers. Built with Cobra framework, it supports 30+ command groups across modular command files with dual modes: interactive (human) and automation (CI/agent). Phases 0-4 (AI-first expansion) add AI ergonomics, admin/ops, migration, vault, and advanced agent/team/memory support. The 2026-05-18 super-admin parity work adds gateway upgrade, package updates, workstations, webhooks, MCP user credentials, secure env reveal, media/TTS/storage/channel fillers, and focused route-contract tests. The 2026-05-19 P3/P4 filler pass adds first-class profile commands, `GOCLAW_PROFILE`, `sessions compact`, WS health, trace filter polish, `codex-pool`, `api-keys rotate`, `config defaults`, chat session convenience wrappers, and `tools invoke --args`. The 2026-05-20 P5 filler pass adds team attachment download, skill-specific evolution suggestion apply, and fixes evolution update payload compatibility. The 2026-05-27 P6 backend-unblocked pass adds seven new surfaces wired to backend PRs `#37` and `#44`: `traces follow`, `providers reconnect`, `sessions branch`, `sessions follow`, `channels writers test`, `activity aggregate`, and `logs aggregate` — all one-shot HTTP commands (no new watch loops; reuse the existing `client.FollowStream` only for true streaming surfaces). +GoClaw CLI is a production-ready Go application providing comprehensive command-line management for GoClaw AI agent gateway servers. Built with Cobra framework, it supports 30+ command groups across modular command files with dual modes: interactive (human) and automation (CI/agent). Phases 0-4 (AI-first expansion) add AI ergonomics, admin/ops, migration, vault, and advanced agent/team/memory support. The 2026-05-18 super-admin parity work adds gateway upgrade, package updates, workstations, webhooks, MCP user credentials, secure env reveal, media/TTS/storage/channel fillers, and focused route-contract tests. The 2026-05-19 P3/P4 filler pass adds first-class profile commands, `GOCLAW_PROFILE`, `sessions compact`, WS health, trace filter polish, `codex-pool`, `api-keys rotate`, `config defaults`, chat session convenience wrappers, and `tools invoke --args`. The 2026-05-20 P5 filler pass adds team attachment download, skill-specific evolution suggestion apply, and fixes evolution update payload compatibility. The 2026-05-27 P6 backend-unblocked pass adds seven new surfaces wired to backend PRs `#37` and `#44`: `traces follow`, `providers reconnect`, `sessions branch`, `sessions follow`, `channels writers test`, `activity aggregate`, and `logs aggregate` — all one-shot HTTP commands (no new watch loops; reuse the existing `client.FollowStream` only for true streaming surfaces). The 2026-06-11 traces contract pass aligns `traces list/get/follow/export` with server `dev` envelopes and adds `traces timeline`. **Key Metrics:** - **70+ command files** in `cmd/` (modularized for maintainability) @@ -54,7 +54,7 @@ All files follow Cobra pattern: root command + subcommands. | `cron.go` | `cron` (list/create/delete/trigger) | 220+ | Scheduled job management | | `teams.go` | `teams` (list/create/members) | 270+ | Team management (largest file) | | `channels.go` | `channels` (list/contacts) | 200+ | Channel management | -| `traces.go` | `traces` (list/export + filters) | 180+ | LLM trace viewing | +| `traces.go`, `traces_follow.go`, `traces_timeline.go` | `traces` (list/get/export/follow/timeline) | modular | LLM trace and run timeline viewing | | `memory.go` | `memory` (list/search/upsert) | 180+ | Memory document management | | `config_cmd.go` | `config` (get/apply/patch/permissions) | 230+ | Server config + permissions | | `logs.go` | `logs` | 120+ | Real-time log streaming | @@ -316,7 +316,7 @@ goclaw (root) │ ├── workspace (list, read, delete, upload, move) │ └── attachments download --output ├── channels (list, contacts, pending-messages) -├── traces (list, get, export, follow) # `get` validates id allowlist, renders header+span-tree+events for TTY, JSON for piped/`-o json` +├── traces (list, get, export, follow, timeline) # server-shaped envelopes, validated IDs, table renderers for traces/spans/timeline ├── memory (list, search, upsert) ├── knowledge-graph (entities, links, query) ├── usage (summary, detail, costs, timeseries, breakdown) @@ -472,7 +472,7 @@ Each level overrides the previous. - Multiple profiles in `~/.goclaw/config.yaml` - Set active via `goclaw profile use ` or legacy `goclaw auth use-context ` - Override per-command: `goclaw --profile staging agents list` -- Env override: `GOCLAW_PROFILE=staging goclaw traces list --since=1h` +- Env override: `GOCLAW_PROFILE=staging goclaw traces list --session-key=session-1 --channel=telegram` ### Automation Mode - Flags: `--yes` (skip prompts), `--output json` (machine output), `--verbose` (debug) diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index e852e45..0b10311 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -97,7 +97,7 @@ goclaw sessions label --label "name" ``` goclaw logs [-f] # Real-time log tailing goclaw chat [interactive] # Streaming chat -goclaw traces [--stream] # Trace streaming +goclaw traces follow --session-key # One-shot trace polling ``` ### Configuration Management @@ -296,8 +296,8 @@ goclaw agents list # Uses custom server/token/output ### Channels (3 commands) `channels` (list, contacts, pending-messages) -### LLM Traces (2 commands) -`traces` (list, export) +### LLM Traces (5 commands) +`traces` (list, get, export, follow, timeline) ### Memory Documents (3 commands) `memory` (list, search, upsert) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 4cb6b20..94a22fe 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -48,7 +48,7 @@ - [x] Added legacy single-profile config migration that removes token from config.yaml. - [x] Added `sessions compact ` via WS RPC `sessions.compact`. - [x] Updated `health` to use WS RPC `health` when authenticated, with HTTP fallback. -- [x] Added `traces list --since --root-only` on top of existing agent/status/limit filters. +- [x] Added `traces list --agent --user --session-key --status --channel --limit --offset` aligned with the server trace list contract. - [x] Added focused tests for profile, migration, session compact, health, and traces filters. **Validation:** `go test ./...`. @@ -250,6 +250,9 @@ **Deliverables:** - [x] `goclaw traces list` (LLM traces) - [x] `goclaw traces export` (export traces) +- [x] `goclaw traces get` (trace detail with span tree) +- [x] `goclaw traces follow` (one-shot incremental polling) +- [x] `goclaw traces timeline` (read archived run timeline) - [x] `goclaw memory list` (memory documents) - [x] `goclaw memory search` (semantic search) - [x] `goclaw memory upsert` (create/update) diff --git a/docs/system-architecture.md b/docs/system-architecture.md index ef5e561..bb70027 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -174,7 +174,7 @@ WebSocket Stream (Bidirectional) **Commands Using WebSocket:** - `goclaw chat` (interactive mode) - `goclaw logs` (real-time tailing with -f) -- `goclaw traces` (live trace streaming) +- Trace commands use HTTP one-shot reads/polling; `traces follow` is not a WebSocket stream. #### Authentication (auth.go) diff --git a/plans/260528-1357-fix-trace-details-by-id/plan.md b/plans/260528-1357-fix-trace-details-by-id/plan.md index 9258c3d..db1daab 100644 --- a/plans/260528-1357-fix-trace-details-by-id/plan.md +++ b/plans/260528-1357-fix-trace-details-by-id/plan.md @@ -11,7 +11,8 @@ tags: - traces - ai-ergonomics - tdd -blockedBy: [] +blockedBy: + - 260611-2303-complete-traces-cli-contract blocks: [] created: '2026-05-28T06:58:02.755Z' createdBy: 'ck:plan' @@ -76,7 +77,8 @@ Each phase opens with red tests describing the desired behavior, then implementa ## Dependencies -None. P6 (HEAD = `2801486`) provides all required prerequisite surfaces. +- Superseded by `../260611-2303-complete-traces-cli-contract/plan.md` for implementation. The older issue #17 plan used/allowed a flat trace fixture; the newer plan locks the current server `dev` contract `{trace,spans}` plus trace list/timeline gaps. +- P6 (HEAD = `2801486`) provided the original prerequisite surfaces, but current contract truth is `digitopvn/goclaw` `dev` as of 2026-06-11. ## Risk and rollback diff --git a/plans/260611-2303-complete-traces-cli-contract/phase-01-contract-lock-and-fixture-refresh.md b/plans/260611-2303-complete-traces-cli-contract/phase-01-contract-lock-and-fixture-refresh.md new file mode 100644 index 0000000..d26472b --- /dev/null +++ b/plans/260611-2303-complete-traces-cli-contract/phase-01-contract-lock-and-fixture-refresh.md @@ -0,0 +1,76 @@ +--- +phase: 1 +title: "Contract Lock and Fixture Refresh" +status: complete +priority: P1 +effort: "3h" +dependencies: [] +--- + +# Phase 1: Contract Lock and Fixture Refresh + +## Overview + +Lock current trace API contracts before touching code. Replace stale assumptions from prior trace plans with server-shaped fixtures and failing tests. + +## Requirements + +- Functional: capture server `dev` shapes for `list`, `get`, `follow`, `export`, and run timeline. +- Functional: document unsupported trace replay as out of scope. +- Non-functional: no committed secrets or live trace content. + +## Architecture + +The CLI uses `internal/client.HTTPClient`, which supports both `{ok,payload}` envelopes and raw JSON. Server `writeJSON` returns raw JSON for trace handlers, so test fixtures must cover raw JSON directly. + +## Related Code Files + +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/traces_get_test.go` +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/traces_follow_test.go` +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/p3_commands_test.go` +- Create: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/traces_timeline_test.go` +- Modify/Create: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/testdata/trace_*.json` +- Read-only source: `/Volumes/GOON/www/digitop/goclaw/internal/http/traces.go` +- Read-only source: `/Volumes/GOON/www/digitop/goclaw/docs/18-http-api.md` + +## Tests Before + +1. Add red test: `TestTracesList_ServerEnvelope_TableRows` with raw response: + `{"traces":[{"id":"...","agent_id":"...","status":"completed","total_input_tokens":10,"total_output_tokens":5,"total_cost":0.01}],"total":1,"limit":20,"offset":0}`. +2. Add red test: `TestTracesGet_ServerEnvelope_TableRendersTraceAndSpans` with raw response: + `{"trace":{...},"spans":[{"id":"span-root","parent_span_id":null,"span_type":"agent","name":"agent.run"}]}`. +3. Add red test: `TestTracesTimeline_CommandMissing_RED` expecting the final command shape to exist and call `/v1/runs/{runID}/timeline`. +4. Add regression test that no command named `traces replay` exists. + +## Refactor + +- None in this phase except test fixture setup. + +## Tests After + +- Existing tests may fail; that is expected until later phases. +- Run focused tests and record red failures in a short report. + +## Implementation Steps + +1. Re-read current server route registration in `internal/http/traces.go`. +2. Update trace fixtures to use real JSON names: `id`, `total_input_tokens`, `total_output_tokens`, `total_cost`, `span_type`, `parent_span_id`. +3. Preserve old flat fixture only if needed for backward-compat test; mark it legacy. +4. Write failing tests before implementation. +5. Create `plans/260611-2303-complete-traces-cli-contract/reports/contract-lock.md` with exact route/field inventory. + +## Success Criteria + +- [x] Test fixtures match server `dev` response structures. +- [x] At least 3 red tests prove current CLI gaps. +- [x] Report lists unsupported `POST /v1/traces/{id}/replay` as absent on server. +- [x] No fixture contains bearer tokens, prompts, API keys, or real user IDs. + +## Risk Assessment + +Risk: accidental fake envelope via `okJSON` hides real raw server behavior. +Mitigation: write at least one raw JSON `httptest` response per trace command. + +## Regression Gate + +- `go test -count=1 ./cmd -run 'TestTraces(List|Get|Timeline|Replay)'` diff --git a/plans/260611-2303-complete-traces-cli-contract/phase-02-list-and-follow-contract-alignment.md b/plans/260611-2303-complete-traces-cli-contract/phase-02-list-and-follow-contract-alignment.md new file mode 100644 index 0000000..01f407d --- /dev/null +++ b/plans/260611-2303-complete-traces-cli-contract/phase-02-list-and-follow-contract-alignment.md @@ -0,0 +1,80 @@ +--- +phase: 2 +title: "List and Follow Contract Alignment" +status: complete +priority: P1 +effort: "4h" +dependencies: [1] +--- + +# Phase 2: List and Follow Contract Alignment + +## Overview + +Fix trace list and follow output against server `dev` contracts. Keep `follow` one-shot; do not introduce streaming or loops. + +## Requirements + +- Functional: `traces list` parses server object envelope and supports current server filters. +- Functional: `traces follow` preserves existing one-shot behavior and uses current field names in table output. +- Non-functional: JSON/YAML modes preserve raw payload shape for automation. + +## Architecture + +`traces list` should decode an object with `traces`, `total`, `limit`, and `offset`. Table mode renders `traces`; JSON/YAML prints the full object. `traces follow` already decodes an object; only field mapping and tests need hardening. + +## Related Code Files + +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/traces.go` +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/traces_follow.go` +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/p3_commands_test.go` +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/traces_follow_test.go` + +## Tests Before + +1. Update `TestTracesListAddsP3Filters` so response is raw server object, not top-level array. +2. Add tests for new list filters: + - `--session-key` -> `session_key` + - `--user` -> `user_id` + - `--channel` -> `channel` + - `--offset` -> `offset` +3. Add/keep follow one-request test with atomic call count. +4. Add table tests expecting `TOTAL_INPUT_TOKENS`, `TOTAL_OUTPUT_TOKENS`, `TOTAL_COST` or agreed concise aliases sourced from server fields. + +## Refactor + +- Add a small local helper only if needed: + `traceRowsFromEnvelope(data json.RawMessage) (map[string]any, []any, error)`. +- Remove or deprecate unsupported `--root-only`; either no-op with hidden deprecation or delete if tests/docs allow. +- Do not pass `since` to `traces list` unless server supports it; keep `since` only on `traces follow`. + +## Tests After + +- JSON mode for `traces list` includes `total`, `limit`, and `offset`. +- Table mode for `traces list` and `traces follow` renders rows from `traces`. + +## Implementation Steps + +1. Decode list response into map and read `traces`. +2. Adjust table columns to server field names: + `ID`, `AGENT`, `STATUS`, `DURATION_MS`, `TOTAL_INPUT_TOKENS`, `TOTAL_OUTPUT_TOKENS`, `TOTAL_COST`. +3. Add list flags for server-supported filters. +4. Revisit README examples that mention `--root-only` or `--since` on list. +5. Ensure follow still validates exactly one target and RFC3339 `since`. + +## Success Criteria + +- [x] `traces list` works with raw server object response. +- [x] `traces list -o json` prints the object, not just the array. +- [x] Current server filters are available. +- [x] `traces follow` still makes one HTTP request and no watch loop. +- [x] Unsupported list flags removed from docs/help or explicitly hidden/deprecated. + +## Risk Assessment + +Risk: removing `--root-only` breaks scripts. +Mitigation: prefer hidden/deprecated flag that returns validation error explaining server no longer supports it, unless maintainer chooses deletion. + +## Regression Gate + +- `go test -count=1 ./cmd -run 'TestTracesList|TestTracesFollow'` diff --git a/plans/260611-2303-complete-traces-cli-contract/phase-03-trace-detail-and-export-hardening.md b/plans/260611-2303-complete-traces-cli-contract/phase-03-trace-detail-and-export-hardening.md new file mode 100644 index 0000000..3517db4 --- /dev/null +++ b/plans/260611-2303-complete-traces-cli-contract/phase-03-trace-detail-and-export-hardening.md @@ -0,0 +1,82 @@ +--- +phase: 3 +title: "Trace Detail and Export Hardening" +status: complete +priority: P1 +effort: "5h" +dependencies: [1, 2] +--- + +# Phase 3: Trace Detail and Export Hardening + +## Overview + +Fix `traces get` for the real `{trace,spans}` response and harden `traces export` path handling. + +## Requirements + +- Functional: `traces get ` prints the full server payload in JSON/YAML and a useful header + span tree in table mode. +- Functional: `traces export ` validates IDs before HTTP and path-escapes route params. +- Non-functional: keep error routing through central error handler. + +## Architecture + +Table rendering should unwrap `trace` and `spans` only for display. JSON/YAML should preserve `{trace,spans}` exactly. Span tree should use server fields: `id`, `parent_span_id`, `span_type`, `name`, `duration_ms`, `status`. + +## Related Code Files + +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/traces.go` +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/traces_get_test.go` +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/testdata/trace_detail_get.json` +- Possibly create: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/traces_render_helpers.go` only if `cmd/traces.go` becomes too large. + +## Tests Before + +1. Replace flat trace detail fixture with raw `{trace,spans}` fixture. +2. Add test that table mode includes trace `id`, `run_id`, `session_key`, `status`, `total_*` values. +3. Add test that span tree uses `id`/`parent_span_id`, not `span_id`. +4. Add export validation tests: + - bad IDs do not call HTTP + - valid ID path is escaped +5. Keep existing exit-code tests for 404/403/400/5xx. + +## Refactor + +- Change `renderTraceTable` to accept full response map, unwrap `trace` and `spans`. +- Update `buildSpanTree` to use `id` and `span_type`. +- Reuse `validateTraceID` for `export`. +- Use `url.PathEscape(id)` in export path. + +## Tests After + +- `traces get -o json` preserves top-level `trace` and `spans`. +- `traces get -o table` no longer prints empty headers for server-shaped payload. +- `traces export ../bad` returns validation error before HTTP. + +## Implementation Steps + +1. Decode `traces get` response with checked `json.Unmarshal`. +2. If response contains `trace` object, render that; if legacy flat response appears, optionally keep backward-compatible fallback. +3. Update span tree mapping: + - key: `id` + - parent: `parent_span_id` + - type: `span_type` +4. Harden export ID before file path default is computed. +5. Ensure output filename for export remains safe: use validated ID, not raw arg. + +## Success Criteria + +- [x] `traces get` works against server-shaped `{trace,spans}`. +- [x] JSON/YAML mode round-trips server structure. +- [x] Table mode shows useful trace header and span tree. +- [x] `traces export` cannot send malformed/path-like IDs. +- [x] Existing exit-code contract remains unchanged. + +## Risk Assessment + +Risk: table render may expose untrusted LLM/tool text. +Mitigation: render compact fields only (`name`, `tool_name`, previews if explicitly chosen); avoid raw content dumps. + +## Regression Gate + +- `go test -count=1 ./cmd -run 'TestTracesGet|TestTracesExport'` diff --git a/plans/260611-2303-complete-traces-cli-contract/phase-04-run-timeline-command.md b/plans/260611-2303-complete-traces-cli-contract/phase-04-run-timeline-command.md new file mode 100644 index 0000000..c30c33d --- /dev/null +++ b/plans/260611-2303-complete-traces-cli-contract/phase-04-run-timeline-command.md @@ -0,0 +1,81 @@ +--- +phase: 4 +title: "Run Timeline Command" +status: complete +priority: P1 +effort: "4h" +dependencies: [1] +--- + +# Phase 4: Run Timeline Command + +## Overview + +Add CLI coverage for `GET /v1/runs/{runID}/timeline`, the trace-adjacent run archive endpoint present in server `dev`. + +## Requirements + +- Functional: command calls `/v1/runs/{runID}/timeline`. +- Functional: supports `--session-key`, `--limit`, and `--offset`. +- Functional: JSON/YAML mode preserves full response; table mode renders timeline items. +- Non-functional: no server mutation; read-only command. + +## Architecture + +Preferred command shape: `goclaw traces timeline `. It keeps trace-adjacent observability under `traces` without creating a new top-level `runs` group. If implementation finds an existing `runs`/`sessions` convention that fits better, document before changing. + +## Related Code Files + +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/traces.go` +- Create optional split: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/traces_timeline.go` +- Create: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/traces_timeline_test.go` +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/cmd/cmd_test.go` + +## Tests Before + +1. `TestTracesTimeline_BuildsPathAndQuery`: + - run ID path-escaped + - `session_key`, `limit`, `offset` query params set. +2. `TestTracesTimeline_JSONPreservesEnvelope`. +3. `TestTracesTimeline_TableRendersItems` with columns: + `SEQ`, `TYPE`, `STATUS`, `TITLE`, `TOOL`, `TRACE_ID`, `SPAN_ID`, `CREATED_AT`. +4. Validation test for empty/path-like run IDs if `validateRunID` is added. + +## Refactor + +- Add `tracesTimelineCmd`. +- Add small table renderer for `items`. +- Reuse existing URL/query patterns from `sessions_follow.go` and `traces_follow.go`. + +## Tests After + +- New command appears under `traces --help`. +- Focused timeline tests pass. + +## Implementation Steps + +1. Add Cobra command with `Use: "timeline "`. +2. Add flags: + - `--session-key` + - `--limit` + - `--offset` +3. Build path `/v1/runs/{url.PathEscape(runID)}/timeline`. +4. Decode response map. +5. Table mode renders `items`; non-table prints full map. +6. Register command in `tracesCmd.AddCommand(...)`. + +## Success Criteria + +- [x] CLI covers server run timeline endpoint. +- [x] Query params match server docs. +- [x] Table and JSON modes covered by tests. +- [x] Command docs make clear it is read-only archive view, not replay. + +## Risk Assessment + +Risk: command belongs under future `runs` group, not `traces`. +Mitigation: use `traces timeline` now because endpoint is registered by `TracesHandler` and returns trace/span IDs; do not create a broad `runs` group for one read command. + +## Regression Gate + +- `go test -count=1 ./cmd -run 'TestTracesTimeline|TestAllCommandsRegistered|TestCommandUseFields'` diff --git a/plans/260611-2303-complete-traces-cli-contract/phase-05-docs-validation-and-ship-readiness.md b/plans/260611-2303-complete-traces-cli-contract/phase-05-docs-validation-and-ship-readiness.md new file mode 100644 index 0000000..068598e --- /dev/null +++ b/plans/260611-2303-complete-traces-cli-contract/phase-05-docs-validation-and-ship-readiness.md @@ -0,0 +1,91 @@ +--- +phase: 5 +title: "Docs Validation and Ship Readiness" +status: complete +priority: P1 +effort: "4h" +dependencies: [2, 3, 4] +--- + +# Phase 5: Docs Validation and Ship Readiness + +## Overview + +Update docs and run validation gates after all trace command gaps are implemented. + +## Requirements + +- Functional: README and changelog describe actual trace commands and filters. +- Functional: docs mention unsupported replay only as out of scope, not as command. +- Non-functional: compile/test/vet gates pass before handoff. + +## Architecture + +No architecture changes. This phase reconciles docs with final CLI behavior and performs a whole-plan consistency sweep. + +## Related Code Files + +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/README.md` +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/CHANGELOG.md` +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/docs/project-roadmap.md` +- Modify: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/docs/codebase-summary.md` +- Read: `/Users/duynguyen/.codex/worktrees/202b/goclaw-cli/plans/260611-2303-complete-traces-cli-contract/*.md` + +## Tests Before + +- None; docs-only after implementation. Re-run all focused command tests first to protect behavior. + +## Refactor + +- Remove stale README examples: + - `traces list --since` + - `traces list --root-only` + unless implementation intentionally preserves/deprecates them. +- Add examples: + - `traces list --session-key ... --channel ... --offset ...` + - `traces get ` + - `traces export --output trace.json.gz` + - `traces follow --session-key ... --since ...` + - `traces timeline --session-key ...` + +## Tests After + +Run: +1. `go test -count=1 ./cmd -run 'TestTraces(List|Get|Follow|Timeline|Export)'` +2. `go test -count=1 ./cmd ./internal/client ./internal/output` +3. `go test -count=1 ./...` +4. `go vet ./...` +5. `go build ./...` + +If local PATH lacks Go, record exact missing command and run with absolute Go path or stop before claiming done. + +## Implementation Steps + +1. Update README command table/details. +2. Add CHANGELOG bullet for trace contract alignment. +3. Update docs status so future agents do not trust old flat fixture plan. +4. Search all docs/plans for stale phrases: + - `trace_id` as top-level field for `get` + - `input_tokens`/`cost` trace table fields + - `root-only` + - `trace replay` +5. Run validation gates. +6. Capture final report in `plans/260611-2303-complete-traces-cli-contract/reports/final-validation.md`. + +## Success Criteria + +- [x] Docs match implemented flags and response behavior. +- [x] Unsupported replay is not presented as CLI-supported. +- [x] Full validation gates pass or exact environment blocker recorded. +- [x] Whole-plan consistency sweep finds no stale contract contradictions. + +## Risk Assessment + +Risk: broad doc scan touches unrelated historical plans. +Mitigation: update active docs and this plan; leave old reports immutable except adding supersession note if needed. + +## Regression Gate + +- `go test -count=1 ./...` +- `go vet ./...` +- `go build ./...` diff --git a/plans/260611-2303-complete-traces-cli-contract/plan.md b/plans/260611-2303-complete-traces-cli-contract/plan.md new file mode 100644 index 0000000..c0be0f2 --- /dev/null +++ b/plans/260611-2303-complete-traces-cli-contract/plan.md @@ -0,0 +1,104 @@ +--- +title: "Complete Traces CLI Contract" +description: "Bring goclaw-cli traces commands back in sync with digitopvn/goclaw dev: real list/get/follow/export contracts plus run timeline support." +status: completed +priority: P1 +effort: 2d +branch: "detached-head" +tags: [bugfix, traces, cli, api, tdd] +blockedBy: [] +blocks: [260528-1357-fix-trace-details-by-id] +created: "2026-06-11T16:03:22.437Z" +createdBy: "ck:plan" +source: skill +--- + +# Complete Traces CLI Contract + +## Overview + +`digitopvn/goclaw` `dev` now exposes a trace API surface wider and slightly different from what `goclaw-cli` currently assumes. The CLI has `traces list/get/export/follow`, but static contract scout found: + +- `GET /v1/traces` returns `{traces,total,limit,offset}`, while CLI still parses a top-level array. +- `GET /v1/traces/{traceID}` returns `{trace,spans}`, while CLI renders fields as if they are top-level. +- `GET /v1/runs/{runID}/timeline` exists and is documented, but CLI has no command. +- `traces export` does not reuse trace ID validation/path escaping. +- `traces list` exposes stale flags (`--since`, `--root-only`) and misses server-supported filters (`--session-key`, `--user`, `--channel`, `--offset`). + +This plan is TDD-first: each implementation phase begins by replacing stale mocks with server-shaped `httptest` fixtures, then lands the smallest CLI changes needed. + +## Scope + +In scope: +- Align `traces list`, `traces get`, `traces export`, and `traces follow` with server `dev` trace contracts. +- Add run timeline read command for `GET /v1/runs/{runID}/timeline`. +- Update tests, README/help text, changelog, and docs status. + +Out of scope: +- `POST /v1/traces/{id}/replay` because server `dev` does not expose it. +- New watch loops, SSE, or WebSocket trace streaming. +- Server-side changes in `/Volumes/GOON/www/digitop/goclaw`. + +## Contract Sources + +- Server handlers: `/Volumes/GOON/www/digitop/goclaw/internal/http/traces.go` +- Server docs: `/Volumes/GOON/www/digitop/goclaw/docs/18-http-api.md` +- Server response structs: `/Volumes/GOON/www/digitop/goclaw/internal/store/tracing_store.go`, `/Volumes/GOON/www/digitop/goclaw/internal/store/run_timeline_store.go` +- Existing CLI: `cmd/traces.go`, `cmd/traces_follow.go`, `cmd/traces_get_test.go`, `cmd/traces_follow_test.go` +- Existing output/client patterns: `cmd/helpers.go`, `internal/client/http.go`, `internal/output/tree.go` + +## Decisions + +- Keep trace work in existing `cmd/traces.go` unless file growth forces one small `cmd/traces_timeline.go` split for the new timeline command. +- Do not add a new generic response framework. Use tiny local helpers only where repeated across trace list/get/follow. +- JSON/YAML mode should preserve server payload shape; table mode may flatten for human scan. +- Reuse `validateTraceID` for `get` and `export`; add a separate `validateRunID` only if needed for path safety. +- Treat existing plan `260528-1357-fix-trace-details-by-id` as superseded for implementation details; its security findings remain useful. + +## Phases + +| Phase | Name | Status | +|-------|------|--------| +| 1 | [Contract Lock and Fixture Refresh](./phase-01-contract-lock-and-fixture-refresh.md) | Complete | +| 2 | [List and Follow Contract Alignment](./phase-02-list-and-follow-contract-alignment.md) | Complete | +| 3 | [Trace Detail and Export Hardening](./phase-03-trace-detail-and-export-hardening.md) | Complete | +| 4 | [Run Timeline Command](./phase-04-run-timeline-command.md) | Complete | +| 5 | [Docs Validation and Ship Readiness](./phase-05-docs-validation-and-ship-readiness.md) | Complete | + +## Dependencies + +| Relationship | Plan | Reason | +|--------------|------|--------| +| Blocks / supersedes | `260528-1357-fix-trace-details-by-id` | Older issue #17 plan used a flat fixture; this plan replaces it with current server `dev` envelope `{trace,spans}`. | +| Related | `260527-1412-domain-coverage-p6-backend-unblocked` | P6 added `traces follow`; this plan keeps that command but revalidates current contract drift. | + +## Validation Gates + +- Focused red/green: `go test -count=1 ./cmd -run 'TestTraces(List|Get|Follow|Timeline|Export)'` +- Package compile: `go test -count=1 ./cmd ./internal/client ./internal/output` +- Full repo: `go test -count=1 ./...` +- Static check: `go vet ./...` +- Compile: `go build ./...` +- Optional live smoke when credentials exist: list one trace, get it, export it, and query timeline when `run_id` is present. + +Local note: current shell did not have `go` in PATH during scout. Implementer must either fix PATH or use the installed absolute Go binary if available. + +## Success Criteria + +- [x] `traces list` reads `{traces,total,limit,offset}` and renders non-empty table rows for server-shaped payloads. +- [x] `traces get` reads `{trace,spans}` and renders header + span tree from `span_type`, `id`, `parent_span_id`. +- [x] `traces export` validates and path-escapes trace ID before HTTP. +- [x] `traces follow` remains one-shot, preserves envelope in JSON/YAML, and table mode uses real server field names. +- [x] New `traces timeline ` command reads `/v1/runs/{runID}/timeline`. +- [x] Tests use server-shaped fixtures, not the legacy flat trace fixture. +- [x] README/CHANGELOG/docs reflect actual commands and omit unsupported replay. + +## Risk Assessment + +| Risk | Mitigation | +|------|------------| +| CLI test helpers wrap responses in `ok/payload`, while server `writeJSON` often returns raw JSON | Add tests that exercise raw server JSON and envelope JSON where existing `HTTPClient` supports both. | +| Old docs mention fields like `input_tokens`/`cost`, but server trace fields are `total_input_tokens`/`total_cost` | Lock field mapping in table tests before implementation. | +| `cmd/traces.go` grows past maintainability target | Prefer one small split file for timeline only; avoid broad refactor. | +| Local Go unavailable in PATH | Capture this as environment setup; do not mark implementation complete until compile/test commands run somewhere valid. | +| Live fixture may contain sensitive prompt/user data | Use synthetic server-shaped fixtures for committed tests; live smoke output stays local/scrubbed. | diff --git a/plans/260611-2303-complete-traces-cli-contract/reports/contract-lock.md b/plans/260611-2303-complete-traces-cli-contract/reports/contract-lock.md new file mode 100644 index 0000000..c474d95 --- /dev/null +++ b/plans/260611-2303-complete-traces-cli-contract/reports/contract-lock.md @@ -0,0 +1,40 @@ +# Contract Lock Report + +## Source Inventory + +- Server trace list: `GET /v1/traces` +- Server trace follow: `GET /v1/traces/follow` +- Server trace detail: `GET /v1/traces/{traceID}` +- Server trace export: `GET /v1/traces/{traceID}/export` +- Server run timeline: `GET /v1/runs/{runID}/timeline` + +## Locked Response Shapes + +- Trace list returns `{traces,total,limit,offset}`. +- Trace detail returns `{trace,spans}`. +- Trace follow returns `{traces,spans_by_trace_id,server_time,next_since,limit}`. +- Run timeline returns `{run_id,session_key,items,limit,offset}`. + +## Locked Field Names + +- Trace rows use `id`, `agent_id`, `session_key`, `status`, `duration_ms`, `total_input_tokens`, `total_output_tokens`, `total_cost`. +- Span rows use `id`, `parent_span_id`, `span_type`, `name`, `duration_ms`, `status`. +- Timeline items use `seq`, `item_type`, `status`, `title`, `tool_name`, `trace_id`, `span_id`, `created_at`. + +## Unsupported + +- `POST /v1/traces/{id}/replay` is absent on server `dev`; no CLI replay command added. + +## Fixture Safety + +- Fixtures are synthetic. +- No bearer tokens, prompts, API keys, live user IDs, or live tenant IDs. + +## Red Gate Captured + +- Focused test gate initially failed at compile because `tracesTimelineCmd` was missing. +- Existing list/get tests also encoded stale array/flat payload assumptions before update. + +## Unresolved Questions + +None. diff --git a/plans/260611-2303-complete-traces-cli-contract/reports/final-validation.md b/plans/260611-2303-complete-traces-cli-contract/reports/final-validation.md new file mode 100644 index 0000000..6d7c1fe --- /dev/null +++ b/plans/260611-2303-complete-traces-cli-contract/reports/final-validation.md @@ -0,0 +1,44 @@ +# Final Validation Report + +## Implementation Summary + +- `traces list` now reads `{traces,total,limit,offset}` and preserves the envelope in JSON/YAML. +- `traces list` supports server filters: `agent`, `user`, `session-key`, `status`, `channel`, `limit`, `offset`. +- `traces get` now reads `{trace,spans}` and renders a header plus span tree from `id`, `parent_span_id`, and `span_type`. +- `traces export` validates trace IDs before HTTP and path-escapes route params. +- `traces follow` remains one-shot and uses the shared server-field trace table renderer. +- `traces timeline ` now reads `/v1/runs/{runID}/timeline`. + +## Validation Gates + +- PASS: `/usr/local/go/bin/go test -count=1 ./cmd -run 'TestTraces(List|Get|Follow|Timeline|Export)'` +- PASS: `/usr/local/go/bin/go test -count=1 ./cmd ./internal/client ./internal/output` +- PASS: `/usr/local/go/bin/go test -count=1 ./...` +- PASS: `/usr/local/go/bin/go vet ./...` +- PASS: `/usr/local/go/bin/go build ./...` + +## Environment Note + +- `go` was not on PATH in this shell. +- Used `/usr/local/go/bin/go` (`go1.25.3 darwin/arm64`) for all validation gates. + +## Docs Impact + +- Updated `README.md`. +- Updated `CHANGELOG.md`. +- Updated `docs/project-roadmap.md`. +- Updated `docs/codebase-summary.md`. +- Updated `docs/project-overview-pdr.md`. +- Updated `docs/system-architecture.md`. +- Updated active plan and phase statuses. + +## Stale Contract Sweep + +- Removed current-doc examples for `traces list --since` and `--root-only`. +- Removed current-doc references to trace WebSocket streaming. +- Kept plan text that describes historical stale assumptions because those are part of the original problem statement. +- No CLI `traces replay` command added because server `dev` does not expose replay. + +## Unresolved Questions + +None. From 8e863adff612633dd7f28557679a2e894d560ac0 Mon Sep 17 00:00:00 2001 From: Goon Date: Thu, 11 Jun 2026 23:32:29 +0700 Subject: [PATCH 2/2] fix(docs): align trace timeline wording --- README.md | 3 ++- docs/project-overview-pdr.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7d9592b..5704609 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,8 @@ echo "Analyze this log" | goclaw chat myagent ### Backend-Unblocked Surfaces (P6) -Seven one-shot subcommands wired to backend PRs `#37` and `#44`: +Backend-unblocked one-shot subcommands wired to backend PRs `#37` and `#44` +plus the run timeline archive endpoint: ```bash # Paginated trace listing with server-supported filters diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 0b10311..00ef4ba 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -92,7 +92,7 @@ goclaw sessions reset [-y] goclaw sessions label --label "name" ``` -### Streaming Operations +### Streaming and Polling Operations ``` goclaw logs [-f] # Real-time log tailing