From 2801486608a7b1b8ab8716339a6fd9330a322c3b Mon Sep 17 00:00:00 2001 From: Duy /zuey/ Date: Wed, 27 May 2026 18:04:06 +0700 Subject: [PATCH 1/3] feat(cli): add P6 backend-unblocked CLI surfaces (issue #16) (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 : POST /v1/providers/{id}/reconnect PR #44 surfaces: - sessions branch --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 [--cursor N --limit N]: one-shot cursor-based history poll, NOT a watch loop. --cursor=0 preserved in raw query. - channels writers test --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 --- CHANGELOG.md | 11 +- README.md | 33 +++ cmd/activity_aggregate.go | 158 ++++++++++++ cmd/activity_aggregate_test.go | 186 +++++++++++++++ cmd/channels_writers.go | 55 ++++- cmd/channels_writers_test_test.go | 160 +++++++++++++ cmd/logs_aggregate.go | 98 ++++++++ cmd/logs_aggregate_test.go | 224 ++++++++++++++++++ cmd/providers_reconnect.go | 51 ++++ cmd/providers_reconnect_test.go | 131 ++++++++++ cmd/sessions_branch.go | 85 +++++++ cmd/sessions_branch_test.go | 197 +++++++++++++++ cmd/sessions_follow.go | 83 +++++++ cmd/sessions_follow_test.go | 182 ++++++++++++++ cmd/traces_follow.go | 102 ++++++++ cmd/traces_follow_test.go | 187 +++++++++++++++ docs/codebase-summary.md | 4 +- .../phase-01-scope-lock.md | 60 +++++ ...2-traces-follow-and-providers-reconnect.md | 114 +++++++++ .../phase-03-sessions-branch-and-follow.md | 120 ++++++++++ .../phase-04-channels-writers-test.md | 76 ++++++ .../phase-05-activity-and-logs-aggregate.md | 120 ++++++++++ .../phase-06-tests-and-docs.md | 65 +++++ .../phase-07-ship-readiness.md | 68 ++++++ .../plan.md | 178 ++++++++++++++ .../reports/code-review-260527-p6.md | 76 ++++++ ...assumption-destroyer-plan-review-report.md | 181 ++++++++++++++ ...failure-mode-analyst-plan-review-report.md | 135 +++++++++++ ...pe-complexity-critic-plan-review-report.md | 155 ++++++++++++ ...m-security-adversary-plan-review-report.md | 144 +++++++++++ .../reports/scope-lock-260527-p6.md | 54 +++++ .../reports/sweep-red-team-diff-260527-p6.md | 55 +++++ 32 files changed, 3544 insertions(+), 4 deletions(-) create mode 100644 cmd/activity_aggregate.go create mode 100644 cmd/activity_aggregate_test.go create mode 100644 cmd/channels_writers_test_test.go create mode 100644 cmd/logs_aggregate.go create mode 100644 cmd/logs_aggregate_test.go create mode 100644 cmd/providers_reconnect.go create mode 100644 cmd/providers_reconnect_test.go create mode 100644 cmd/sessions_branch.go create mode 100644 cmd/sessions_branch_test.go create mode 100644 cmd/sessions_follow.go create mode 100644 cmd/sessions_follow_test.go create mode 100644 cmd/traces_follow.go create mode 100644 cmd/traces_follow_test.go create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-01-scope-lock.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-02-traces-follow-and-providers-reconnect.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-03-sessions-branch-and-follow.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-04-channels-writers-test.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-05-activity-and-logs-aggregate.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-06-tests-and-docs.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-07-ship-readiness.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/plan.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/reports/code-review-260527-p6.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/reports/from-code-reviewer-to-planner-red-team-assumption-destroyer-plan-review-report.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/reports/from-code-reviewer-to-planner-red-team-failure-mode-analyst-plan-review-report.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/reports/from-code-reviewer-to-planner-red-team-scope-complexity-critic-plan-review-report.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/reports/from-code-reviewer-to-planner-red-team-security-adversary-plan-review-report.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/reports/scope-lock-260527-p6.md create mode 100644 plans/260527-1412-domain-coverage-p6-backend-unblocked/reports/sweep-red-team-diff-260527-p6.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b26ed..b137d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- -## [Unreleased] — Domain Coverage Expansion (P0–P5) +## [Unreleased] — Domain Coverage Expansion (P0–P6) ### Added @@ -46,6 +46,15 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `goclaw agents evolution skill apply [--skill-draft @file]` — explicit wrapper for approving `skill_add` suggestions through the server evolution approval route. - `goclaw agents evolution update` now maps `--action=accept|reject` to the server-compatible `status=approved|rejected` payload. +**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 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. +- `goclaw channels writers test --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. +- `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. +- `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. + ### Notes - All new commands honor the AI-first ergonomics contract: `--output=json` envelope, central error handler, `--yes` for destructive ops, `--quiet` for CI. - P4/P5 backlog was re-swept against the current CLI surface; already-covered items were removed from residual scope before implementation. diff --git a/README.md b/README.md index db99c34..d4e306d 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,39 @@ echo "Analyze this log" | goclaw chat myagent | `restore` | System/tenant restore from backup archive | | `vault` | Knowledge Vault — documents, links, search, graph, enrichment | +### Backend-Unblocked Surfaces (P6) + +Seven one-shot subcommands wired to backend PRs `#37` and `#44`: + +```bash +# Incremental trace polling (one shot; rerun with returned cursor) +goclaw traces follow --session-key [--since ] [--limit ] +goclaw traces follow --agent [--since ] [--limit ] + +# Provider hot-reconnect (bumps registry without recreating credentials) +goclaw providers reconnect + +# Branch a chat session at a message index +goclaw sessions branch --up-to-index [--new-session-key ] \ + [--label ] [--metadata k=v ...] + +# One-shot session-history poll (cursor-based; not a stream) +goclaw sessions follow [--cursor ] [--limit ] + +# Probe a (group, user) pair against a channel's writer policy +goclaw channels writers test --group-id --user-id + +# Aggregate audit-log activity by dimension +goclaw activity aggregate --group-by \ + [--from ] [--to ] [--limit ] \ + [--actor-type ] [--actor-id ] [--action ] [--entity-type ] [--entity-id ] + +# Summarize the runtime log ring buffer (NOT a stream — see 'logs tail' for that) +goclaw logs aggregate [--group-by ] [--level ] [--source ] [--from ] +``` + +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). + ## Backup & Restore ### System Backup diff --git a/cmd/activity_aggregate.go b/cmd/activity_aggregate.go new file mode 100644 index 0000000..15405a8 --- /dev/null +++ b/cmd/activity_aggregate.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "fmt" + "net/url" + "time" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +// validActivityGroupBy enumerates allowed --group-by values for the activity +// aggregate endpoint. Server enforces admin-only for actor_id; the CLI does +// not pre-check that — it only validates the enum. +var validActivityGroupBy = map[string]bool{ + "action": true, + "actor_type": true, + "entity_type": true, + "actor_id": true, +} + +// formatLastSeen renders an aggregate bucket's last_seen field as RFC3339. +// +// The activity aggregate endpoint returns last_seen as an RFC3339 string, +// but the logs runtime aggregate endpoint returns last_seen as epoch millis +// (a number). `unmarshalMap` decodes JSON numbers as float64, and the shared +// `str()` helper renders large float64 as scientific notation (e.g. +// "1.76e+12"). This helper type-switches so both endpoints render +// consistently as RFC3339 strings in the table view. +func formatLastSeen(v any) string { + switch t := v.(type) { + case nil: + return "-" + case string: + if t == "" { + return "-" + } + return t + case float64: + if t == 0 { + return "-" + } + return time.UnixMilli(int64(t)).UTC().Format(time.RFC3339) + case int64: + if t == 0 { + return "-" + } + return time.UnixMilli(t).UTC().Format(time.RFC3339) + case int: + if t == 0 { + return "-" + } + return time.UnixMilli(int64(t)).UTC().Format(time.RFC3339) + default: + return fmt.Sprintf("%v", v) + } +} + +// activityAggregateCmd groups audit-log activity by a dimension and returns +// bucket counts. Attached as a subcommand of the existing activityCmd +// (declared in cmd/admin.go) so the top-level command surface is unchanged. +// +// Backend route: GET /v1/activity/aggregate +var activityAggregateCmd = &cobra.Command{ + Use: "aggregate", + Short: "Aggregate audit-log activity by a grouping dimension", + Long: `Group activity log entries by a dimension (action, actor_type, entity_type, +or actor_id) and return bucket counts with last_seen timestamps. + +Optional filters narrow the result set: --from/--to (RFC3339 window), +--actor-type, --actor-id, --action, --entity-type, --entity-id, --limit. + +Backend route: GET /v1/activity/aggregate +Note: --group-by=actor_id requires admin privileges (enforced server-side).`, + RunE: func(cmd *cobra.Command, args []string) error { + groupBy, _ := cmd.Flags().GetString("group-by") + if groupBy == "" { + return fmt.Errorf("--group-by is required (one of action, actor_type, entity_type, actor_id)") + } + if !validActivityGroupBy[groupBy] { + return fmt.Errorf("--group-by must be one of action, actor_type, entity_type, actor_id (got %q)", groupBy) + } + from, _ := cmd.Flags().GetString("from") + if from != "" { + if _, err := time.Parse(time.RFC3339, from); err != nil { + return fmt.Errorf("--from must be RFC3339: %w", err) + } + } + to, _ := cmd.Flags().GetString("to") + if to != "" { + if _, err := time.Parse(time.RFC3339, to); err != nil { + return fmt.Errorf("--to must be RFC3339: %w", err) + } + } + + q := url.Values{} + q.Set("group_by", groupBy) + if from != "" { + q.Set("from", from) + } + if to != "" { + q.Set("to", to) + } + if v, _ := cmd.Flags().GetInt("limit"); v > 0 { + q.Set("limit", fmt.Sprintf("%d", v)) + } + for flagName, queryKey := range map[string]string{ + "actor-type": "actor_type", + "actor-id": "actor_id", + "action": "action", + "entity-type": "entity_type", + "entity-id": "entity_id", + } { + if v, _ := cmd.Flags().GetString(flagName); v != "" { + q.Set(queryKey, v) + } + } + + c, err := newHTTP() + if err != nil { + return err + } + data, err := c.Get("/v1/activity/aggregate?" + q.Encode()) + if err != nil { + return err + } + m := unmarshalMap(data) + if cfg.OutputFormat != "table" { + printer.Print(m) + return nil + } + buckets, _ := m["buckets"].([]any) + tbl := output.NewTable("KEY", "COUNT", "LAST_SEEN") + for _, raw := range buckets { + row, ok := raw.(map[string]any) + if !ok { + continue + } + tbl.AddRow(str(row, "key"), str(row, "count"), formatLastSeen(row["last_seen"])) + } + printer.Print(tbl) + return nil + }, +} + +func init() { + activityAggregateCmd.Flags().String("group-by", "", "Grouping dimension: action | actor_type | entity_type | actor_id (required)") + activityAggregateCmd.Flags().String("from", "", "RFC3339 start of time window") + activityAggregateCmd.Flags().String("to", "", "RFC3339 end of time window") + activityAggregateCmd.Flags().Int("limit", 0, "Maximum buckets to return (server default applied if 0)") + activityAggregateCmd.Flags().String("actor-type", "", "Filter by actor type") + activityAggregateCmd.Flags().String("actor-id", "", "Filter by actor id") + activityAggregateCmd.Flags().String("action", "", "Filter by action") + activityAggregateCmd.Flags().String("entity-type", "", "Filter by entity type") + activityAggregateCmd.Flags().String("entity-id", "", "Filter by entity id") + _ = activityAggregateCmd.MarkFlagRequired("group-by") + activityCmd.AddCommand(activityAggregateCmd) +} diff --git a/cmd/activity_aggregate_test.go b/cmd/activity_aggregate_test.go new file mode 100644 index 0000000..b2add55 --- /dev/null +++ b/cmd/activity_aggregate_test.go @@ -0,0 +1,186 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func resetActivityAggregateFlags(t *testing.T) { + t.Helper() + for _, name := range []string{"group-by", "from", "to", "actor-type", "actor-id", "action", "entity-type", "entity-id"} { + resetTestFlag(activityAggregateCmd, name, "") + } + resetTestFlag(activityAggregateCmd, "limit", "0") +} + +func TestActivityAggregate_MissingGroupBy(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "activity", "aggregate") + if err == nil { + t.Fatal("expected error for missing --group-by") + } +} + +func TestActivityAggregate_InvalidGroupBy(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "activity", "aggregate", "--group-by=foo") + if err == nil { + t.Fatal("expected error for invalid --group-by=foo") + } +} + +func TestActivityAggregate_ValidGroupByValues(t *testing.T) { + for _, gb := range []string{"action", "actor_type", "entity_type", "actor_id"} { + gb := gb + t.Run(gb, func(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{"source": "activity", "group_by": gb, "total": 0, "buckets": []any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + if err := runCmd(t, "activity", "aggregate", "--group-by="+gb); err != nil { + t.Fatalf("group-by=%s: %v", gb, err) + } + }) + } +} + +func TestActivityAggregate_InvalidFromRFC3339(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "activity", "aggregate", "--group-by=action", "--from=not-a-date") + if err == nil { + t.Fatal("expected error for non-RFC3339 --from") + } +} + +func TestActivityAggregate_InvalidToRFC3339(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "activity", "aggregate", "--group-by=action", "--to=tomorrow") + if err == nil { + t.Fatal("expected error for non-RFC3339 --to") + } +} + +func TestActivityAggregate_QueryStringHasFilters(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + + var rawQuery, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + rawQuery = r.URL.RawQuery + okJSON(t, w, map[string]any{"source": "activity", "group_by": "action", "total": 0, "buckets": []any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "activity", "aggregate", + "--group-by=action", + "--from=2026-05-01T00:00:00Z", + "--to=2026-05-27T00:00:00Z", + "--limit=25", + "--actor-type=user", + "--actor-id=u1", + "--action=session.branch", + "--entity-type=session", + "--entity-id=sess-1", + ); err != nil { + t.Fatalf("activity aggregate: %v", err) + } + if path != "/v1/activity/aggregate" { + t.Fatalf("path = %q", path) + } + q, err := url.ParseQuery(rawQuery) + if err != nil { + t.Fatalf("parse query: %v", err) + } + want := map[string]string{ + "group_by": "action", + "from": "2026-05-01T00:00:00Z", + "to": "2026-05-27T00:00:00Z", + "limit": "25", + "actor_type": "user", + "actor_id": "u1", + "action": "session.branch", + "entity_type": "session", + "entity_id": "sess-1", + } + for k, v := range want { + if got := q.Get(k); got != v { + t.Errorf("query[%s] = %q, want %q (full raw: %s)", k, got, v, rawQuery) + } + } +} + +func TestActivityAggregate_JSONPreservesFields(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "source": "activity", + "group_by": "action", + "total": 10, + "buckets": []map[string]any{ + {"key": "session.branch", "count": 7, "last_seen": "2026-05-27T11:00:00Z"}, + }, + }) + })) + 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, "activity", "aggregate", "--group-by=action") + }) + if err != nil { + t.Fatalf("activity aggregate: %v", err) + } + for _, want := range []string{"source", "group_by", "total", "buckets"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q in: %s", want, out) + } + } +} + +func TestActivityAggregate_TableHeaders(t *testing.T) { + t.Cleanup(func() { resetActivityAggregateFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "source": "activity", + "group_by": "action", + "total": 1, + "buckets": []map[string]any{ + {"key": "session.branch", "count": 1, "last_seen": "2026-05-27T11:00:00Z"}, + }, + }) + })) + 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, "activity", "aggregate", "--group-by=action") + }) + if err != nil { + t.Fatalf("activity aggregate: %v", err) + } + for _, want := range []string{"KEY", "COUNT", "LAST_SEEN", "session.branch"} { + if !strings.Contains(out, want) { + t.Errorf("table missing %q in:\n%s", want, out) + } + } +} diff --git a/cmd/channels_writers.go b/cmd/channels_writers.go index f789ad9..04b1228 100644 --- a/cmd/channels_writers.go +++ b/cmd/channels_writers.go @@ -1,6 +1,9 @@ package cmd import ( + "net/url" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" "github.com/spf13/cobra" ) @@ -79,11 +82,61 @@ var channelsWritersRemoveCmd = &cobra.Command{ }, } +// channelsWritersTestCmd probes whether a (group, user) pair is permitted to +// write into a channel instance. Body is POSTed with exactly two keys +// (group_id, user_id) — no extra fields, so the server's contract stays tight. +// +// Backend route: POST /v1/channels/instances/{id}/writers/test +var channelsWritersTestCmd = &cobra.Command{ + Use: "test ", + Short: "Test whether a (group, user) pair is allowed to write", + Long: `Probe a channel instance's writer policy for a specific group/user pair +without mutating state. + +Backend route: POST /v1/channels/instances/{id}/writers/test`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + groupID, _ := cmd.Flags().GetString("group-id") + userID, _ := cmd.Flags().GetString("user-id") + // Body has exactly two keys — construct directly so no other flags + // (e.g. accidental future additions) leak into the request. + body := map[string]any{ + "group_id": groupID, + "user_id": userID, + } + c, err := newHTTP() + if err != nil { + return err + } + path := "/v1/channels/instances/" + url.PathEscape(args[0]) + "/writers/test" + data, err := c.Post(path, body) + if err != nil { + return err + } + m := unmarshalMap(data) + if cfg.OutputFormat != "table" { + printer.Print(m) + return nil + } + tbl := output.NewTable("ALLOWED", "REASON", "WRITER_COUNT", "GROUP_ID", "USER_ID") + tbl.AddRow(str(m, "allowed"), str(m, "reason"), str(m, "writer_count"), + str(m, "group_id"), str(m, "user_id")) + printer.Print(tbl) + return nil + }, +} + func init() { channelsWritersAddCmd.Flags().String("user", "", "User ID") channelsWritersAddCmd.Flags().String("display-name", "", "Display name") _ = channelsWritersAddCmd.MarkFlagRequired("user") channelsWritersRemoveCmd.Flags().String("user", "", "User ID") _ = channelsWritersRemoveCmd.MarkFlagRequired("user") - channelsWritersCmd.AddCommand(channelsWritersListCmd, channelsWritersGroupsCmd, channelsWritersAddCmd, channelsWritersRemoveCmd) + + channelsWritersTestCmd.Flags().String("group-id", "", "Group identifier (required)") + channelsWritersTestCmd.Flags().String("user-id", "", "User identifier (required)") + _ = channelsWritersTestCmd.MarkFlagRequired("group-id") + _ = channelsWritersTestCmd.MarkFlagRequired("user-id") + + channelsWritersCmd.AddCommand(channelsWritersListCmd, channelsWritersGroupsCmd, channelsWritersAddCmd, channelsWritersRemoveCmd, channelsWritersTestCmd) } diff --git a/cmd/channels_writers_test_test.go b/cmd/channels_writers_test_test.go new file mode 100644 index 0000000..fe788ea --- /dev/null +++ b/cmd/channels_writers_test_test.go @@ -0,0 +1,160 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func resetChannelsWritersTestFlags(t *testing.T) { + t.Helper() + for _, name := range []string{"group-id", "user-id"} { + resetTestFlag(channelsWritersTestCmd, name, "") + } +} + +func TestChannelsWritersTest_BodyShape(t *testing.T) { + t.Cleanup(func() { resetChannelsWritersTestFlags(t) }) + + var gotPath, gotMethod string + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + okJSON(t, w, map[string]any{ + "allowed": true, + "reason": "writer", + "instance_id": "inst-1", + "agent_id": "agent-1", + "group_id": "group:telegram:-100123", + "user_id": "386246614", + "writer_count": 3, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "channels", "writers", "test", "inst-1", + "--group-id=group:telegram:-100123", + "--user-id=386246614"); err != nil { + t.Fatalf("channels writers test: %v", err) + } + if gotMethod != http.MethodPost { + t.Fatalf("method = %q", gotMethod) + } + if gotPath != "/v1/channels/instances/inst-1/writers/test" { + t.Fatalf("path = %q", gotPath) + } + if body["group_id"] != "group:telegram:-100123" || body["user_id"] != "386246614" { + t.Fatalf("body fields wrong: %#v", body) + } + // Body must contain ONLY these two keys. + if len(body) != 2 { + t.Fatalf("body has extra keys: %#v", body) + } +} + +func TestChannelsWritersTest_MissingGroupID(t *testing.T) { + t.Cleanup(func() { resetChannelsWritersTestFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "channels", "writers", "test", "inst-1", "--user-id=u1") + if err == nil { + t.Fatal("expected error for missing --group-id") + } +} + +func TestChannelsWritersTest_MissingUserID(t *testing.T) { + t.Cleanup(func() { resetChannelsWritersTestFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "channels", "writers", "test", "inst-1", "--group-id=g1") + if err == nil { + t.Fatal("expected error for missing --user-id") + } +} + +func TestChannelsWritersTest_PathEscape(t *testing.T) { + t.Cleanup(func() { resetChannelsWritersTestFlags(t) }) + + var rawPath, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawPath = r.URL.RawPath + path = r.URL.Path + okJSON(t, w, map[string]any{"allowed": true, "reason": "writer", "writer_count": 1}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "channels", "writers", "test", "weird/id:1", "--group-id=g1", "--user-id=u1"); err != nil { + t.Fatalf("channels writers test: %v", err) + } + if !strings.Contains(rawPath, "weird%2Fid%3A1") && !strings.Contains(path, "weird/id:1") { + t.Fatalf("path not escaped — RawPath=%q Path=%q", rawPath, path) + } +} + +func TestChannelsWritersTest_JSONOutput(t *testing.T) { + t.Cleanup(func() { resetChannelsWritersTestFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "allowed": false, + "reason": "not_writer", + "writer_count": 2, + }) + })) + 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, "channels", "writers", "test", "inst-1", "--group-id=g1", "--user-id=u1") + }) + if err != nil { + t.Fatalf("channels writers test: %v", err) + } + for _, want := range []string{"allowed", "reason", "writer_count"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q in: %s", want, out) + } + } +} + +func TestChannelsWritersTest_TableHeaders(t *testing.T) { + t.Cleanup(func() { resetChannelsWritersTestFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "allowed": true, + "reason": "writer", + "writer_count": 3, + "group_id": "g1", + "user_id": "u1", + }) + })) + 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, "channels", "writers", "test", "inst-1", "--group-id=g1", "--user-id=u1") + }) + if err != nil { + t.Fatalf("channels writers test: %v", err) + } + for _, want := range []string{"ALLOWED", "REASON", "WRITER_COUNT", "GROUP_ID", "USER_ID"} { + if !strings.Contains(out, want) { + t.Errorf("table missing header %q in:\n%s", want, out) + } + } +} diff --git a/cmd/logs_aggregate.go b/cmd/logs_aggregate.go new file mode 100644 index 0000000..f994b90 --- /dev/null +++ b/cmd/logs_aggregate.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "fmt" + "net/url" + "time" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +// validLogsGroupBy enumerates allowed --group-by values for the runtime logs +// aggregate endpoint. +var validLogsGroupBy = map[string]bool{ + "level": true, + "source": true, +} + +// logsAggregateCmd queries the runtime log ring-buffer aggregate endpoint. +// Distinct from `logs tail` (WS streaming): this is a one-shot HTTP GET that +// summarizes the in-memory ring buffer by level or source. +// +// Backend route: GET /v1/logs/runtime/aggregate (admin-only on server side). +var logsAggregateCmd = &cobra.Command{ + Use: "aggregate", + Short: "Summarize runtime logs (ring buffer) by level or source", + Long: `Aggregate the in-memory runtime log ring buffer by --group-by (level or +source). This is a one-shot HTTP query — not a stream. Use 'logs tail' for +real-time streaming. + +Backend route: GET /v1/logs/runtime/aggregate (admin-only, server-enforced).`, + RunE: func(cmd *cobra.Command, args []string) error { + groupBy, _ := cmd.Flags().GetString("group-by") + if groupBy == "" { + groupBy = "level" + } + if !validLogsGroupBy[groupBy] { + return fmt.Errorf("--group-by must be one of level, source (got %q)", groupBy) + } + from, _ := cmd.Flags().GetString("from") + if from != "" { + if _, err := time.Parse(time.RFC3339, from); err != nil { + return fmt.Errorf("--from must be RFC3339: %w", err) + } + } + + q := url.Values{} + q.Set("group_by", groupBy) + for flagName, queryKey := range map[string]string{ + "level": "level", + "source": "source", + "from": "from", + } { + if v, _ := cmd.Flags().GetString(flagName); v != "" { + q.Set(queryKey, v) + } + } + + c, err := newHTTP() + if err != nil { + return err + } + data, err := c.Get("/v1/logs/runtime/aggregate?" + q.Encode()) + if err != nil { + return err + } + m := unmarshalMap(data) + if cfg.OutputFormat != "table" { + printer.Print(m) + return nil + } + // Summary row (source, retention, capacity, sample_size). + summary := output.NewTable("SOURCE", "RETENTION", "CAPACITY", "SAMPLE_SIZE") + summary.AddRow(str(m, "source"), str(m, "retention"), + str(m, "capacity"), str(m, "sample_size")) + printer.Print(summary) + // Bucket rows. + buckets, _ := m["buckets"].([]any) + tbl := output.NewTable("KEY", "COUNT", "LAST_SEEN") + for _, raw := range buckets { + row, ok := raw.(map[string]any) + if !ok { + continue + } + tbl.AddRow(str(row, "key"), str(row, "count"), formatLastSeen(row["last_seen"])) + } + printer.Print(tbl) + return nil + }, +} + +func init() { + logsAggregateCmd.Flags().String("group-by", "level", "Grouping dimension: level | source (default level)") + logsAggregateCmd.Flags().String("level", "", "Filter by level: debug | info | warn | error") + logsAggregateCmd.Flags().String("source", "", "Filter by source") + logsAggregateCmd.Flags().String("from", "", "RFC3339 start of time window") + logsCmd.AddCommand(logsAggregateCmd) +} diff --git a/cmd/logs_aggregate_test.go b/cmd/logs_aggregate_test.go new file mode 100644 index 0000000..7709c75 --- /dev/null +++ b/cmd/logs_aggregate_test.go @@ -0,0 +1,224 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" +) + +func resetLogsAggregateFlags(t *testing.T) { + t.Helper() + resetTestFlag(logsAggregateCmd, "group-by", "level") + for _, name := range []string{"level", "source", "from"} { + resetTestFlag(logsAggregateCmd, name, "") + } +} + +func TestLogsAggregate_DefaultGroupBy(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + + var path, rawQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + rawQuery = r.URL.RawQuery + okJSON(t, w, map[string]any{ + "source": "runtime", + "retention": "ring_buffer", + "capacity": 100, + "sample_size": 0, + "group_by": "level", + "buckets": []any{}, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "logs", "aggregate"); err != nil { + t.Fatalf("logs aggregate: %v", err) + } + if path != "/v1/logs/runtime/aggregate" { + t.Fatalf("path = %q", path) + } + q, _ := url.ParseQuery(rawQuery) + // Default group_by=level should appear in query. + if q.Get("group_by") != "level" { + t.Errorf("expected group_by=level in raw query, got: %q", rawQuery) + } +} + +func TestLogsAggregate_InvalidGroupBy(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "logs", "aggregate", "--group-by=foo") + if err == nil { + t.Fatal("expected error for invalid --group-by=foo") + } +} + +func TestLogsAggregate_QueryStringFilters(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + + var rawQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawQuery = r.URL.RawQuery + okJSON(t, w, map[string]any{ + "source": "runtime", "retention": "ring_buffer", "capacity": 100, + "sample_size": 0, "group_by": "source", "buckets": []any{}, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "logs", "aggregate", + "--group-by=source", + "--level=warn", + "--source=router", + "--from=2026-05-01T00:00:00Z", + ); err != nil { + t.Fatalf("logs aggregate: %v", err) + } + q, _ := url.ParseQuery(rawQuery) + want := map[string]string{ + "group_by": "source", + "level": "warn", + "source": "router", + "from": "2026-05-01T00:00:00Z", + } + for k, v := range want { + if got := q.Get(k); got != v { + t.Errorf("query[%s] = %q, want %q (raw: %s)", k, got, v, rawQuery) + } + } +} + +func TestLogsAggregate_InvalidFromRFC3339(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "logs", "aggregate", "--from=not-a-date") + if err == nil { + t.Fatal("expected error for non-RFC3339 --from") + } +} + +func TestLogsAggregate_JSONPreservesFields(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "source": "runtime", + "retention": "ring_buffer", + "capacity": 100, + "sample_size": 25, + "group_by": "level", + "buckets": []map[string]any{ + {"key": "warn", "count": 3, "last_seen": 1760000000000}, + }, + }) + })) + 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, "logs", "aggregate") + }) + if err != nil { + t.Fatalf("logs aggregate: %v", err) + } + for _, want := range []string{"retention", "capacity", "sample_size"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q in: %s", want, out) + } + } +} + +// Numeric-last_seen rendering regression: the runtime logs endpoint returns +// last_seen as epoch millis (a JSON number), which json.Unmarshal decodes as +// float64. Naively rendering with fmt.Sprintf("%v", ...) produces scientific +// notation (e.g. "1.76e+12") for large numbers — useless in a table. The +// formatLastSeen helper must type-switch and emit RFC3339. +func TestLogsAggregate_LastSeenRendersRFC3339(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "source": "runtime", + "retention": "ring_buffer", + "capacity": 100, + "sample_size": 1, + "group_by": "level", + "buckets": []map[string]any{ + {"key": "warn", "count": 3, "last_seen": 1760000000000}, + }, + }) + })) + 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, "logs", "aggregate") + }) + if err != nil { + t.Fatalf("logs aggregate: %v", err) + } + if strings.Contains(out, "e+12") || strings.Contains(out, "e+11") { + t.Errorf("LAST_SEEN must not render in scientific notation:\n%s", out) + } + rfc3339 := regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`) + if !rfc3339.MatchString(out) { + t.Errorf("expected RFC3339 timestamp in LAST_SEEN cell:\n%s", out) + } +} + +func TestLogsAggregate_TableHeaders(t *testing.T) { + t.Cleanup(func() { resetLogsAggregateFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "source": "runtime", + "retention": "ring_buffer", + "capacity": 100, + "sample_size": 1, + "group_by": "level", + "buckets": []map[string]any{ + {"key": "warn", "count": 3, "last_seen": 1760000000000}, + }, + }) + })) + 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, "logs", "aggregate") + }) + if err != nil { + t.Fatalf("logs aggregate: %v", err) + } + for _, want := range []string{"KEY", "COUNT", "LAST_SEEN"} { + if !strings.Contains(out, want) { + t.Errorf("table missing header %q in:\n%s", want, out) + } + } +} + +func TestLogsAggregate_DistinctFromTail(t *testing.T) { + if logsAggregateCmd == nil { + t.Fatal("logsAggregateCmd not declared") + } + if !strings.HasPrefix(logsAggregateCmd.Use, "aggregate") { + t.Fatalf("Use = %q", logsAggregateCmd.Use) + } + // Sanity: aggregate is NOT a watch loop — has no --follow flag. + if f := logsAggregateCmd.Flags().Lookup("follow"); f != nil { + t.Errorf("logs aggregate should not have --follow flag (that's logs tail)") + } +} diff --git a/cmd/providers_reconnect.go b/cmd/providers_reconnect.go new file mode 100644 index 0000000..be43753 --- /dev/null +++ b/cmd/providers_reconnect.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "net/url" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +// providersReconnectCmd POSTs an empty body to /v1/providers/{id}/reconnect. +// Backend verifies reconnect server-side — do NOT add a --verify flag. +var providersReconnectCmd = &cobra.Command{ + Use: "reconnect ", + Short: "Force-reconnect a registered provider (admin-only)", + Long: `Force-reconnect a registered provider. Admin-only on the server. + +The server handles reconnect verification internally; no client-side --verify +flag is exposed. (Note: ` + "`providers verify-embedding`" + ` is a different +command targeting a different endpoint.)`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newHTTP() + if err != nil { + return err + } + data, err := c.Post("/v1/providers/"+url.PathEscape(args[0])+"/reconnect", nil) + if err != nil { + return err + } + m := unmarshalMap(data) + if cfg.OutputFormat != "table" { + printer.Print(m) + return nil + } + tbl := output.NewTable("STATUS", "REGISTRY_UPDATED", "CACHE_INVALIDATED", "PROVIDER") + var providerLabel string + if p, ok := m["provider"].(map[string]any); ok { + providerLabel = str(p, "name") + if providerLabel == "" { + providerLabel = str(p, "id") + } + } + tbl.AddRow(str(m, "status"), str(m, "registry_updated"), str(m, "cache_invalidated"), providerLabel) + printer.Print(tbl) + return nil + }, +} + +func init() { + providersCmd.AddCommand(providersReconnectCmd) +} diff --git a/cmd/providers_reconnect_test.go b/cmd/providers_reconnect_test.go new file mode 100644 index 0000000..a0fd304 --- /dev/null +++ b/cmd/providers_reconnect_test.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestProvidersReconnect_PathAndMethod(t *testing.T) { + var calls int64 + var gotPath, gotMethod string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&calls, 1) + gotPath = r.URL.Path + gotMethod = r.Method + gotBody, _ = io.ReadAll(r.Body) + okJSON(t, w, map[string]any{ + "status": "reconnected", + "provider": map[string]any{"id": "prov-1", "name": "openai"}, + "registry_updated": true, + "cache_invalidated": true, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "providers", "reconnect", "prov-1"); err != nil { + t.Fatalf("providers reconnect: %v", err) + } + if atomic.LoadInt64(&calls) != 1 { + t.Fatalf("expected exactly 1 request, got %d", atomic.LoadInt64(&calls)) + } + if gotMethod != http.MethodPost { + t.Fatalf("method = %q, want POST", gotMethod) + } + if gotPath != "/v1/providers/prov-1/reconnect" { + t.Fatalf("path = %q", gotPath) + } + body := strings.TrimSpace(string(gotBody)) + if body != "" && body != "null" && body != "{}" { + // Must not include a "verify" key (or any payload). + if strings.Contains(body, "verify") { + t.Fatalf("body contains 'verify': %q", body) + } + } +} + +func TestProvidersReconnect_PathEscape(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + okJSON(t, w, map[string]any{"status": "reconnected"}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + // Provider id with characters that need percent-encoding. + if err := runCmd(t, "providers", "reconnect", "weird/id:1"); err != nil { + t.Fatalf("providers reconnect: %v", err) + } + // httptest decodes path when populating r.URL.Path, so check escaped form via RawPath. + if !strings.Contains(gotPath, "weird/id:1") { + // Path semantics: PathEscape encodes "/" as %2F; net/http decodes back. Accept either form. + if !strings.Contains(gotPath, "weird") || !strings.Contains(gotPath, "id:1") { + t.Fatalf("path = %q (expected to contain escaped provider id)", gotPath) + } + } +} + +func TestProvidersReconnect_JSONOutputPreservesFields(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "status": "disabled", + "registry_updated": false, + "cache_invalidated": true, + }) + })) + 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, "providers", "reconnect", "prov-1") + }) + if err != nil { + t.Fatalf("providers reconnect: %v", err) + } + if !strings.Contains(out, "registry_updated") || !strings.Contains(out, "cache_invalidated") { + t.Fatalf("stdout missing fields: %s", out) + } +} + +func TestProvidersReconnect_TableOutput(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "status": "reconnected", + "registry_updated": true, + "cache_invalidated": true, + }) + })) + 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, "providers", "reconnect", "prov-1") + }) + if err != nil { + t.Fatalf("providers reconnect: %v", err) + } + if !strings.Contains(out, "STATUS") || !strings.Contains(out, "REGISTRY_UPDATED") || !strings.Contains(out, "CACHE_INVALIDATED") { + t.Fatalf("table headers missing in:\n%s", out) + } +} + +func TestProvidersReconnect_MissingArg(t *testing.T) { + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "providers", "reconnect") + if err == nil { + t.Fatal("expected error for missing provider id") + } +} diff --git a/cmd/sessions_branch.go b/cmd/sessions_branch.go new file mode 100644 index 0000000..c0f2a72 --- /dev/null +++ b/cmd/sessions_branch.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "fmt" + "net/url" + "strings" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +// sessionsBranchCmd posts to /v1/chat/sessions/{key}/branch. +// +// Backend route: POST /v1/chat/sessions/{key}/branch (chat domain). +// (Sibling commands under `sessions` parent target /v1/sessions/...; this +// command intentionally targets the chat-sessions tree where branching lives.) +// +// Body is constructed directly (NOT via buildBody) because up_to_index=0 is a +// valid required value that buildBody's int-zero skip would silently drop. +var sessionsBranchCmd = &cobra.Command{ + Use: "branch ", + Short: "Branch a chat session at a message index", + Long: `Branch a chat session into a new session by copying messages up to a 1-based +index. The source session is unchanged. + +Backend route: POST /v1/chat/sessions/{key}/branch (chat domain).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + upTo, _ := cmd.Flags().GetInt("up-to-index") + if upTo < 0 { + return fmt.Errorf("--up-to-index must be >= 0 (got %d)", upTo) + } + + // Validate metadata up front, BEFORE HTTP call. + metaPairs, _ := cmd.Flags().GetStringArray("metadata") + metadata := make(map[string]any) + for _, kv := range metaPairs { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 || parts[0] == "" { + return fmt.Errorf("--metadata must be key=value (got %q)", kv) + } + metadata[parts[0]] = parts[1] + } + + // Build body directly so up_to_index=0 is preserved on the wire. + body := map[string]any{"up_to_index": upTo} + if v, _ := cmd.Flags().GetString("new-session-key"); v != "" { + body["new_session_key"] = v + } + if v, _ := cmd.Flags().GetString("label"); v != "" { + body["label"] = v + } + if len(metadata) > 0 { + body["metadata"] = metadata + } + + c, err := newHTTP() + if err != nil { + return err + } + data, err := c.Post("/v1/chat/sessions/"+url.PathEscape(args[0])+"/branch", body) + if err != nil { + return err + } + m := unmarshalMap(data) + if cfg.OutputFormat != "table" { + printer.Print(m) + return nil + } + tbl := output.NewTable("SOURCE", "NEW_KEY", "COPIED", "TOTAL", "LABEL") + tbl.AddRow(str(m, "source_key"), str(m, "session_key"), + str(m, "copied_messages"), str(m, "total_messages"), str(m, "label")) + printer.Print(tbl) + return nil + }, +} + +func init() { + sessionsBranchCmd.Flags().Int("up-to-index", -1, "Copy messages 1..N into the new session (required, >=0)") + sessionsBranchCmd.Flags().String("new-session-key", "", "Override generated session key") + sessionsBranchCmd.Flags().String("label", "", "Label for the new session") + sessionsBranchCmd.Flags().StringArray("metadata", nil, "Repeatable key=value metadata pair") + _ = sessionsBranchCmd.MarkFlagRequired("up-to-index") + sessionsCmd.AddCommand(sessionsBranchCmd) +} diff --git a/cmd/sessions_branch_test.go b/cmd/sessions_branch_test.go new file mode 100644 index 0000000..0641042 --- /dev/null +++ b/cmd/sessions_branch_test.go @@ -0,0 +1,197 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/pflag" +) + +func resetSessionsBranchFlags(t *testing.T) { + t.Helper() + for _, name := range []string{"new-session-key", "label"} { + resetTestFlag(sessionsBranchCmd, name, "") + } + resetTestFlag(sessionsBranchCmd, "up-to-index", "-1") + // metadata is a StringArray; reset via SliceValue.Replace. + if f := sessionsBranchCmd.Flags().Lookup("metadata"); f != nil { + if sv, ok := f.Value.(pflag.SliceValue); ok { + _ = sv.Replace(nil) + } + f.Changed = false + } +} + +func TestSessionsBranch_BodyShapeWithMetadata(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + + var gotPath, gotMethod string + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + data, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(data, &body) + okJSON(t, w, map[string]any{ + "ok": true, + "source_key": "sess-1", + "session_key": "sess-1-branch", + "copied_messages": 12, + "total_messages": 24, + "label": "demo", + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "branch", "sess-1", + "--up-to-index=12", + "--new-session-key=sess-1-branch", + "--label=demo", + "--metadata=foo=bar", + "--metadata=baz=qux"); err != nil { + t.Fatalf("sessions branch: %v", err) + } + if gotMethod != http.MethodPost { + t.Fatalf("method = %q", gotMethod) + } + if gotPath != "/v1/chat/sessions/sess-1/branch" { + t.Fatalf("path = %q", gotPath) + } + // up_to_index must be present as a numeric value + if v, ok := body["up_to_index"]; !ok { + t.Fatalf("body missing up_to_index: %#v", body) + } else if n, ok := v.(float64); !ok || int(n) != 12 { + t.Fatalf("up_to_index = %#v", v) + } + if body["new_session_key"] != "sess-1-branch" { + t.Errorf("new_session_key = %#v", body["new_session_key"]) + } + if body["label"] != "demo" { + t.Errorf("label = %#v", body["label"]) + } + meta, ok := body["metadata"].(map[string]any) + if !ok { + t.Fatalf("metadata is not object: %#v", body["metadata"]) + } + if meta["foo"] != "bar" || meta["baz"] != "qux" { + t.Fatalf("metadata = %#v", meta) + } +} + +// Zero-boundary regression: --up-to-index 0 means "branch with zero copied +// messages" (an empty branch from the session start) and must appear as +// "up_to_index":0 in the wire body. Any helper that drops numeric zeros would +// silently turn this into a server-side "missing required field" error. +func TestSessionsBranch_UpToIndexZero(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(data, &body) + okJSON(t, w, map[string]any{"ok": true, "source_key": "sess-1", "session_key": "sess-1-branch", "copied_messages": 0, "total_messages": 5}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "branch", "sess-1", "--up-to-index=0", "--new-session-key=sess-1-branch"); err != nil { + t.Fatalf("sessions branch: %v", err) + } + v, ok := body["up_to_index"] + if !ok { + t.Fatalf("body missing up_to_index when --up-to-index=0: %#v", body) + } + n, isNum := v.(float64) + if !isNum || n != 0 { + t.Fatalf("up_to_index = %#v, expected 0", v) + } +} + +func TestSessionsBranch_MissingUpToIndex(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "sessions", "branch", "sess-1") + if err == nil { + t.Fatal("expected error for missing --up-to-index") + } +} + +func TestSessionsBranch_NegativeUpToIndex(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "sessions", "branch", "sess-1", "--up-to-index=-1") + if err == nil { + t.Fatal("expected error for negative --up-to-index") + } +} + +func TestSessionsBranch_MalformedMetadata(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "sessions", "branch", "sess-1", "--up-to-index=0", "--metadata=foobar") + if err == nil { + t.Fatal("expected error for malformed --metadata (no '=')") + } +} + +func TestSessionsBranch_PathEscapesSessionKey(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + + var gotRawPath, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRawPath = r.URL.RawPath + gotPath = r.URL.Path + okJSON(t, w, map[string]any{"ok": true}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "branch", "weird:key/with-slash", "--up-to-index=0"); err != nil { + t.Fatalf("sessions branch: %v", err) + } + // RawPath holds the percent-encoded form; either RawPath has the escape, or decoded Path has the colon/slash. + // What matters: client did NOT inject literal "/" into path segment. + if !strings.Contains(gotRawPath, "weird%3Akey%2Fwith-slash") && !strings.Contains(gotPath, "weird:key/with-slash") { + t.Fatalf("path not escaped — RawPath=%q Path=%q", gotRawPath, gotPath) + } +} + +func TestSessionsBranch_JSONPreservesCopiedAndTotal(t *testing.T) { + t.Cleanup(func() { resetSessionsBranchFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "ok": true, + "copied_messages": 12, + "total_messages": 24, + "session_key": "sess-new", + }) + })) + 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, "sessions", "branch", "sess-1", "--up-to-index=12") + }) + if err != nil { + t.Fatalf("sessions branch: %v", err) + } + if !strings.Contains(out, "copied_messages") || !strings.Contains(out, "total_messages") { + t.Fatalf("stdout missing fields: %s", out) + } +} diff --git a/cmd/sessions_follow.go b/cmd/sessions_follow.go new file mode 100644 index 0000000..c06c43e --- /dev/null +++ b/cmd/sessions_follow.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "fmt" + "net/url" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +// sessionsFollowCmd issues one HTTP GET to /v1/chat/sessions/{key}/history/follow. +// NOT a watch loop and NOT a WS stream — operators wanting continuous follow +// rerun with the returned `next_cursor`. +// +// Backend route: GET /v1/chat/sessions/{key}/history/follow (chat domain). +// +// Query string is built directly via url.Values so cursor=0 is preserved +// (buildBody would drop int v == 0). +var sessionsFollowCmd = &cobra.Command{ + Use: "follow ", + Short: "Poll cursor-based session history (one shot)", + Long: `Poll the next batch of session-history messages from a cursor. One-shot +polling — no watch loop, no SSE, no WebSocket stream. Re-invoke with the +returned ` + "`next_cursor`" + ` to advance. + +Backend route: GET /v1/chat/sessions/{key}/history/follow (chat domain).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cursor, _ := cmd.Flags().GetInt("cursor") + limit, _ := cmd.Flags().GetInt("limit") + if cursor < 0 { + return fmt.Errorf("--cursor must be >= 0 (got %d)", cursor) + } + if limit <= 0 { + return fmt.Errorf("--limit must be > 0 (got %d)", limit) + } + + q := url.Values{} + // Build query directly so cursor=0 appears literally. + q.Set("cursor", fmt.Sprintf("%d", cursor)) + q.Set("limit", fmt.Sprintf("%d", limit)) + + c, err := newHTTP() + if err != nil { + return err + } + path := "/v1/chat/sessions/" + url.PathEscape(args[0]) + "/history/follow?" + q.Encode() + data, err := c.Get(path) + if err != nil { + return err + } + m := unmarshalMap(data) + if cfg.OutputFormat != "table" { + printer.Print(m) + return nil + } + // Summary row first. + summary := output.NewTable("SESSION", "CURSOR", "NEXT_CURSOR", "TOTAL", "RESET", "UPDATED") + summary.AddRow(str(m, "session_key"), str(m, "cursor"), + str(m, "next_cursor"), str(m, "total"), str(m, "reset"), str(m, "updated")) + printer.Print(summary) + // Compact message rows. + msgs, _ := m["messages"].([]any) + if len(msgs) > 0 { + tbl := output.NewTable("INDEX", "ROLE", "CONTENT") + for _, raw := range msgs { + row, ok := raw.(map[string]any) + if !ok { + continue + } + tbl.AddRow(str(row, "index"), str(row, "role"), str(row, "content")) + } + printer.Print(tbl) + } + return nil + }, +} + +func init() { + sessionsFollowCmd.Flags().Int("cursor", 0, "Starting cursor (>=0, default 0)") + sessionsFollowCmd.Flags().Int("limit", 50, "Max messages per call (default 50, server max 200)") + sessionsCmd.AddCommand(sessionsFollowCmd) +} diff --git a/cmd/sessions_follow_test.go b/cmd/sessions_follow_test.go new file mode 100644 index 0000000..24c8c47 --- /dev/null +++ b/cmd/sessions_follow_test.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func resetSessionsFollowFlags(t *testing.T) { + t.Helper() + // Reset to declared defaults (cursor=0, limit=50). + resetTestFlag(sessionsFollowCmd, "cursor", "0") + resetTestFlag(sessionsFollowCmd, "limit", "50") +} + +func TestSessionsFollow_DefaultsAndQuery(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + + var calls int64 + var rawQuery, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&calls, 1) + rawQuery = r.URL.RawQuery + path = r.URL.Path + okJSON(t, w, map[string]any{ + "session_key": "sess-1", + "cursor": 0, + "next_cursor": 18, + "total": 18, + "messages": []map[string]any{}, + "reset": false, + }) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "follow", "sess-1"); err != nil { + t.Fatalf("sessions follow: %v", err) + } + if atomic.LoadInt64(&calls) != 1 { + t.Fatalf("expected exactly 1 request, got %d (no watch loop)", atomic.LoadInt64(&calls)) + } + if path != "/v1/chat/sessions/sess-1/history/follow" { + t.Fatalf("path = %q", path) + } + // Defaults must be present in the raw query string. + if !strings.Contains(rawQuery, "cursor=0") { + t.Errorf("expected cursor=0 in raw query, got: %q", rawQuery) + } + if !strings.Contains(rawQuery, "limit=50") { + t.Errorf("expected limit=50 in raw query, got: %q", rawQuery) + } +} + +// Zero-boundary regression: --cursor 0 is a valid pagination origin and must +// appear literally as "cursor=0" in the raw query. Any helper that drops +// numeric zeros (e.g. omit-empty builders) would silently break "start from +// the beginning" semantics. +func TestSessionsFollow_CursorZero(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + + var rawQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawQuery = r.URL.RawQuery + okJSON(t, w, map[string]any{"session_key": "sess-1", "cursor": 0, "next_cursor": 0, "total": 0, "messages": []any{}, "reset": false}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "follow", "sess-1", "--cursor=0"); err != nil { + t.Fatalf("sessions follow: %v", err) + } + if !strings.Contains(rawQuery, "cursor=0") { + t.Fatalf("--cursor=0 must appear as cursor=0 in query, got: %q", rawQuery) + } +} + +func TestSessionsFollow_CustomCursorAndLimit(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + + var rawQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawQuery = r.URL.RawQuery + okJSON(t, w, map[string]any{"session_key": "sess-1", "cursor": 12, "next_cursor": 17, "total": 17, "messages": []any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "follow", "sess-1", "--cursor=12", "--limit=25"); err != nil { + t.Fatalf("sessions follow: %v", err) + } + if !strings.Contains(rawQuery, "cursor=12") || !strings.Contains(rawQuery, "limit=25") { + t.Fatalf("rawQuery = %q", rawQuery) + } +} + +func TestSessionsFollow_NegativeCursor(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "sessions", "follow", "sess-1", "--cursor=-1") + if err == nil { + t.Fatal("expected error for negative --cursor") + } +} + +func TestSessionsFollow_NonPositiveLimit(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + err := runCmd(t, "sessions", "follow", "sess-1", "--limit=0", "--cursor=0") + if err == nil { + t.Fatal("expected error for --limit=0") + } +} + +func TestSessionsFollow_PathEscape(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + var rawPath, path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawPath = r.URL.RawPath + path = r.URL.Path + okJSON(t, w, map[string]any{"session_key": "weird:key/x", "cursor": 0, "next_cursor": 0, "total": 0, "messages": []any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "sessions", "follow", "weird:key/x"); err != nil { + t.Fatalf("sessions follow: %v", err) + } + if !strings.Contains(rawPath, "weird%3Akey%2Fx") && !strings.Contains(path, "weird:key/x") { + t.Fatalf("path not escaped — RawPath=%q Path=%q", rawPath, path) + } +} + +func TestSessionsFollow_JSONPreservesFields(t *testing.T) { + t.Cleanup(func() { resetSessionsFollowFlags(t) }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + okJSON(t, w, map[string]any{ + "session_key": "sess-1", + "cursor": 0, + "next_cursor": 5, + "total": 5, + "reset": true, + "messages": []map[string]any{{"index": 0, "role": "user", "content": "hi"}}, + "updated": "2026-05-27T12:00:00Z", + }) + })) + 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, "sessions", "follow", "sess-1") + }) + if err != nil { + t.Fatalf("sessions follow: %v", err) + } + for _, want := range []string{"reset", "next_cursor", "messages"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q in: %s", want, out) + } + } +} + +func TestSessionsFollow_NotAWatchLoop(t *testing.T) { + // Smoke: sessionsFollowCmd must exist and not be a long-running stream + // (covered by atomic-counter assertion above; this is a structural check). + if sessionsFollowCmd == nil { + t.Fatal("sessionsFollowCmd not declared") + } + if !strings.HasPrefix(sessionsFollowCmd.Use, "follow") { + t.Fatalf("Use = %q", sessionsFollowCmd.Use) + } +} diff --git a/cmd/traces_follow.go b/cmd/traces_follow.go new file mode 100644 index 0000000..d07ef1c --- /dev/null +++ b/cmd/traces_follow.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "fmt" + "net/url" + "time" + + "github.com/nextlevelbuilder/goclaw-cli/internal/output" + "github.com/spf13/cobra" +) + +// tracesFollowCmd issues a single polling GET to /v1/traces/follow. NOT a watch loop. +// Operators wanting continuous follow rerun the command with the returned `next_since`. +var tracesFollowCmd = &cobra.Command{ + Use: "follow", + Short: "Poll incremental trace activity (one shot)", + Long: `Poll incremental trace activity for a session or agent. + +Exactly one of --session-key or --agent must be provided. This is a one-shot +polling request — no watch loop. Use the returned ` + "`next_since`" + ` to re-poll.`, + RunE: func(cmd *cobra.Command, args []string) error { + sessionKey, _ := cmd.Flags().GetString("session-key") + agent, _ := cmd.Flags().GetString("agent") + if sessionKey == "" && agent == "" { + return fmt.Errorf("exactly one of --session-key or --agent is required") + } + if sessionKey != "" && agent != "" { + return fmt.Errorf("--session-key and --agent are mutually exclusive") + } + + since, _ := cmd.Flags().GetString("since") + if since != "" { + if _, err := time.Parse(time.RFC3339, since); err != nil { + return fmt.Errorf("--since must be RFC3339: %w", err) + } + } + + q := url.Values{} + if sessionKey != "" { + q.Set("session_key", sessionKey) + } + if agent != "" { + q.Set("agent_id", agent) + } + if since != "" { + q.Set("since", since) + } + if v, _ := cmd.Flags().GetInt("limit"); v > 0 { + q.Set("limit", fmt.Sprintf("%d", v)) + } + if v, _ := cmd.Flags().GetString("status"); v != "" { + q.Set("status", v) + } + if v, _ := cmd.Flags().GetString("channel"); v != "" { + q.Set("channel", v) + } + if v, _ := cmd.Flags().GetBool("include-spans"); v { + q.Set("include_spans", "true") + } + + c, err := newHTTP() + if err != nil { + return err + } + path := "/v1/traces/follow" + if len(q) > 0 { + path += "?" + q.Encode() + } + data, err := c.Get(path) + if err != nil { + return err + } + envelope := unmarshalMap(data) + if cfg.OutputFormat != "table" { + printer.Print(envelope) + 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) + return nil + }, +} + +func init() { + tracesFollowCmd.Flags().String("session-key", "", "Session key to follow") + tracesFollowCmd.Flags().String("agent", "", "Agent id or key to follow") + tracesFollowCmd.Flags().String("since", "", "RFC3339 timestamp; only traces after this are returned") + tracesFollowCmd.Flags().Int("limit", 0, "Max traces (server default 50, max 200)") + tracesFollowCmd.Flags().String("status", "", "Filter by status") + tracesFollowCmd.Flags().String("channel", "", "Filter by channel") + tracesFollowCmd.Flags().Bool("include-spans", false, "Include spans_by_trace_id in response") + tracesCmd.AddCommand(tracesFollowCmd) +} diff --git a/cmd/traces_follow_test.go b/cmd/traces_follow_test.go new file mode 100644 index 0000000..43f9091 --- /dev/null +++ b/cmd/traces_follow_test.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "sync/atomic" + "testing" +) + +// resetTracesFollowFlags returns flags to default state between subtests. +func resetTracesFollowFlags(t *testing.T) { + t.Helper() + for _, name := range []string{"session-key", "agent", "since", "status", "channel", "include-spans"} { + resetTestFlag(tracesFollowCmd, name, "") + } + resetTestFlag(tracesFollowCmd, "limit", "0") +} + +func TestTracesFollow_SessionKeyBuildsQuery(t *testing.T) { + t.Cleanup(func() { resetTracesFollowFlags(t) }) + + var calls int64 + var query url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&calls, 1) + if r.URL.Path != "/v1/traces/follow" { + w.WriteHeader(http.StatusNotFound) + return + } + query = r.URL.Query() + okJSON(t, w, map[string]any{"traces": []map[string]any{}, "spans_by_trace_id": map[string]any{}, "next_since": "2026-05-27T12:00:00Z", "limit": 50}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "traces", "follow", "--session-key=sess-1", "--since=2026-05-27T00:00:00Z", "--limit=25", "--status=success", "--channel=telegram"); err != nil { + t.Fatalf("traces follow: %v", err) + } + if atomic.LoadInt64(&calls) != 1 { + t.Fatalf("expected exactly 1 request, got %d (no watch loop allowed)", atomic.LoadInt64(&calls)) + } + if query.Get("session_key") != "sess-1" { + t.Errorf("session_key = %q", query.Get("session_key")) + } + if query.Get("since") != "2026-05-27T00:00:00Z" { + t.Errorf("since = %q", query.Get("since")) + } + if query.Get("limit") != "25" { + t.Errorf("limit = %q", query.Get("limit")) + } + if query.Get("status") != "success" { + t.Errorf("status = %q", query.Get("status")) + } + if query.Get("channel") != "telegram" { + t.Errorf("channel = %q", query.Get("channel")) + } +} + +func TestTracesFollow_AgentTargetBuildsQuery(t *testing.T) { + t.Cleanup(func() { resetTracesFollowFlags(t) }) + + var query url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + okJSON(t, w, map[string]any{"traces": []map[string]any{}}) + })) + defer srv.Close() + t.Setenv("GOCLAW_SERVER", srv.URL) + t.Setenv("GOCLAW_TOKEN", "test-token") + + if err := runCmd(t, "traces", "follow", "--agent=agent-1", "--include-spans"); err != nil { + t.Fatalf("traces follow: %v", err) + } + if query.Get("agent_id") != "agent-1" { + t.Errorf("agent_id = %q", query.Get("agent_id")) + } + if query.Get("include_spans") != "true" { + t.Errorf("include_spans = %q", query.Get("include_spans")) + } + if query.Has("session_key") { + t.Errorf("session_key should not be set: %#v", query) + } +} + +func TestTracesFollow_RejectMissingTarget(t *testing.T) { + t.Cleanup(func() { resetTracesFollowFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "traces", "follow") + if err == nil { + t.Fatal("expected validation error for missing target") + } + if !strings.Contains(err.Error(), "session-key") && !strings.Contains(err.Error(), "agent") { + t.Errorf("error should mention target flags: %v", err) + } +} + +func TestTracesFollow_RejectBothTargets(t *testing.T) { + t.Cleanup(func() { resetTracesFollowFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "traces", "follow", "--session-key=sess-1", "--agent=agent-1") + if err == nil { + t.Fatal("expected validation error when both target flags set") + } +} + +func TestTracesFollow_RejectInvalidSince(t *testing.T) { + t.Cleanup(func() { resetTracesFollowFlags(t) }) + t.Setenv("GOCLAW_SERVER", "http://localhost:9") + t.Setenv("GOCLAW_TOKEN", "test-token") + + err := runCmd(t, "traces", "follow", "--session-key=sess-1", "--since=not-a-timestamp") + if err == nil { + t.Fatal("expected RFC3339 validation error") + } +} + +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"}}, + "spans_by_trace_id": map[string]any{"t1": []any{}}, + "next_since": "2026-05-27T13:00:00Z", + "server_time": "2026-05-27T12:30:00Z", + "limit": 50, + }) + })) + 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", "follow", "--session-key=sess-1") + }) + if err != nil { + t.Fatalf("traces follow: %v", err) + } + if !strings.Contains(out, "next_since") || !strings.Contains(out, "spans_by_trace_id") { + t.Fatalf("stdout missing fields: %s", out) + } +} + +func TestTracesFollow_TableHeaders(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", "agent_id": "agent-1", "status": "success", "duration_ms": 120, "input_tokens": 50, "output_tokens": 30, "cost": "0.001"}, + }, + }) + })) + 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", "follow", "--session-key=sess-1") + }) + if err != nil { + t.Fatalf("traces follow: %v", err) + } + headerRE := regexp.MustCompile(`TRACE_ID.*AGENT.*STATUS.*DURATION_MS.*INPUT_TOKENS.*OUTPUT_TOKENS.*COST`) + if !headerRE.MatchString(out) { + t.Fatalf("table headers missing in:\n%s", out) + } +} + +func TestTracesFollow_DoesNotImportFollowStream(t *testing.T) { + // Static assertion: tracesFollowCmd uses one HTTP GET via httpClient, not FollowStream. + // Covered indirectly by the atomic-counter test above; this test is a smoke for command existence. + if tracesFollowCmd == nil { + t.Fatal("tracesFollowCmd not declared") + } + if tracesFollowCmd.Use == "" || !strings.HasPrefix(tracesFollowCmd.Use, "follow") { + t.Fatalf("tracesFollowCmd.Use = %q, expected to start with 'follow'", tracesFollowCmd.Use) + } +} diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index a5fc48d..297a1d4 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,7 +1,7 @@ # GoClaw CLI - Codebase Summary **Generated from:** `repomix-output.xml` (2026-04-15), updated manually 2026-05-20 -**Phase Status:** P0-P4 Complete (AI-First Expansion); Super Admin API Parity Complete; Domain Coverage P5 Implemented +**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+ **Total Size:** 220+ KB @@ -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. +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). **Key Metrics:** - **70+ command files** in `cmd/` (modularized for maintainability) diff --git a/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-01-scope-lock.md b/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-01-scope-lock.md new file mode 100644 index 0000000..dcec545 --- /dev/null +++ b/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-01-scope-lock.md @@ -0,0 +1,60 @@ +--- +phase: 1 +title: "Scope Lock" +status: pending +priority: P2 +effort: "1h" +dependencies: [] +--- + +# Phase 1: Scope Lock + +## Overview + +Lock the 7-surface scope, re-verify backend contracts against `digitopvn/goclaw` `dev` tip, and produce the contract evidence file that subsequent phases reference. No CLI code changes. + +## Requirements + +- Re-fetch and read each backend handler to confirm path, method, query params, body shape, status enum, and error semantics. +- Confirm beta tag `v3.12.0-beta.20` is the minimum that contains PR #44 commit `43049d3b` (already verified during plan creation; phase re-asserts). +- Confirm no command-name collisions in current `cmd/` tree. +- Produce `reports/scope-lock-260527-p6.md` summarizing contracts and any drift discovered. + +## Implementation Steps + +1. From the `digitopvn/goclaw` checkout (or via `gh api`), read: + - `internal/http/traces.go` — locate the `traces/follow` handler. + - `internal/http/providers.go` — locate the `providers/{id}/reconnect` handler. + - `internal/http/sessions.go` — locate the branch and history/follow handlers. + - `internal/http/channel_instances.go` — locate the writers/test handler. + - `internal/http/activity.go` — locate the aggregate handler. + - `internal/http/logs.go` — locate the runtime/aggregate handler. + - `internal/http/openapi_spec.json` for any documented schema differences. +2. For each handler record: route, method, required vs optional inputs, response shape, status enum, error codes, admin-only flag. +3. Compare against `plans/reports/codex-prompt-260522-p6-pr44-backend-unblocked-cli.md` (lives on `feat/claude-skill-v0.1` worktree) and the plan overview. Flag any drift. +4. Re-verify beta tag: `gh api repos/digitopvn/goclaw/compare/v3.12.0-beta.20...43049d3b --jq '.status'` must return `identical`. +5. Re-verify no naming collisions: `grep -in 'Use:.*"follow\|reconnect\|branch\|aggregate\|test"' cmd/*.go`. +6. Write `reports/scope-lock-260527-p6.md` with the table of confirmed contracts. + +## Todo List + +- [ ] Backend contract re-read for all 7 endpoints. +- [ ] Drift table populated (or zero-drift confirmed). +- [ ] Beta tag re-confirmed. +- [ ] Naming collision sweep run with output captured. +- [ ] `reports/scope-lock-260527-p6.md` written. +- [ ] Phase status flipped to Complete. + +## Success Criteria + +- Zero unresolved contract questions before phase 2 starts. +- Scope file under `reports/` exists and is referenced from each implementation phase's "Evidence" section. + +## Out of Scope + +- Any CLI code change. +- Any test scaffolding (phase 2+). + +## Next Steps + +Proceed to Phase 2 (Traces Follow + Providers Reconnect). diff --git a/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-02-traces-follow-and-providers-reconnect.md b/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-02-traces-follow-and-providers-reconnect.md new file mode 100644 index 0000000..6a226c7 --- /dev/null +++ b/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-02-traces-follow-and-providers-reconnect.md @@ -0,0 +1,114 @@ +--- +phase: 2 +title: "Traces Follow + Providers Reconnect (PR #37 surfaces)" +status: pending +priority: P2 +effort: "3h" +dependencies: [1] +--- + +# Phase 2: Traces Follow + Providers Reconnect + +## Overview + +Implement two CLI surfaces from backend PR #37 (already in beta `v3.12.0-beta.16`+): polling-friendly trace follow and provider reconnect. Strict TDD — failing tests land before any Cobra command code. + +## Surfaces + +### 2.1 `goclaw traces follow` + +```bash +goclaw traces follow --session-key [--since ] [--limit N] [--include-spans] [--status ] [--channel ] [-o json|yaml|table] +goclaw traces follow --agent [same flags] +``` + +- Endpoint: `GET /v1/traces/follow` +- Require exactly one of `--session-key` or `--agent` (validate before HTTP call). +- Optional: `status`, `channel`, `since`, `limit`, `include_spans`. `since` must be RFC3339. +- Server default `limit=50`, max `200`. Don't enforce max client-side; let server respond. +- Response shape: + ```json + {"traces":[],"spans_by_trace_id":{},"server_time":"...","next_since":"...","limit":50} + ``` +- JSON/YAML: print full envelope. +- Table: rows like `traces list` — `TRACE_ID`, `AGENT`, `STATUS`, `DURATION_MS`, `INPUT_TOKENS`, `OUTPUT_TOKENS`, `COST`. +- **One request only. No watch loop.** + +### 2.2 `goclaw providers reconnect` + +```bash +goclaw providers reconnect [-o json|yaml|table] +``` + +- Endpoint: `POST /v1/providers/{id}/reconnect` +- Admin-only on backend; client sends no body. Do NOT send `{"verify":true}`. +- Do NOT add `--verify` flag. **The only verify-shaped command is `goclaw providers verify-embedding ` (`cmd/providers_verify.go:11`)**, which targets a different backend endpoint — do NOT recommend it as a fallback in Long-help or PR body. Backend handles reconnect verification server-side. +- Path-escape `` via `url.PathEscape` (see escaped-path pattern in `cmd/api_keys_rotate.go`). +- Response: + ```json + {"status":"reconnected","provider":{},"registry_updated":true,"cache_invalidated":true} + ``` +- Status enum: `reconnected`, `disabled`, `not_registered`. +- Table: `STATUS`, `REGISTRY_UPDATED`, `CACHE_INVALIDATED`, plus provider name/id if non-empty. + +## Files + +- Modify: `cmd/traces.go` — append `tracesFollowCmd` + register on `tracesCmd`. +- Modify: `cmd/providers.go` or new `cmd/providers_reconnect.go` (preferred — keep file < 200 lines per repo rule) — declare `providersReconnectCmd` + register on `providersCmd`. +- New: `cmd/traces_follow_test.go` +- New: `cmd/providers_reconnect_test.go` + +## TDD Sequence + +1. Write `cmd/traces_follow_test.go` with the failing tests below; run `go test ./cmd -run TracesFollow` and confirm red. +2. Implement `tracesFollowCmd` minimally until tests pass. +3. Write `cmd/providers_reconnect_test.go`; confirm red. +4. Implement `providersReconnectCmd`; confirm green. +5. `go vet ./... && go build ./...` clean. + +## Tests + +### `cmd/traces_follow_test.go` + +- Session-key target builds path `/v1/traces/follow?session_key=...&...`. +- Agent target builds path `/v1/traces/follow?agent_id=...&...`. +- Missing both target flags returns validation error before HTTP call. +- Setting both target flags returns validation error before HTTP call. +- Non-RFC3339 `--since` returns validation error before HTTP call. +- JSON output preserves `next_since` and `spans_by_trace_id`. +- Table output includes the seven required columns. +- **Atomic-counter test (Red Team F7):** wrap `httptest.NewServer` handler with `atomic.AddInt64(&calls, 1)`; assert `calls == 1` after `RunE`. Must NOT use `client.FollowStream` (`internal/client/follow.go`) — that would reconnect. +- **502-once test (Red Team F7):** server returns 502 first call, 200 second call; assert command fails fast on 502, does NOT retry. + +### `cmd/providers_reconnect_test.go` + +- POST path is `/v1/providers/{escaped-id}/reconnect`. +- Request body is empty (server receives `Content-Length: 0` or empty JSON; assert no `verify` key). +- JSON output preserves `registry_updated` and `cache_invalidated`. +- Table output renders status + boolean columns. +- Provider ID with `/` or `:` is path-escaped (regression test for RT-02). +- **Atomic-counter test (Red Team F7):** assert exactly one POST request issued. No retry/reconnect path. + +## Todo List + +- [ ] Red tests for traces follow. +- [ ] `tracesFollowCmd` implemented + green. +- [ ] Red tests for providers reconnect. +- [ ] `providersReconnectCmd` implemented + green. +- [ ] `go vet` + `go build` clean. +- [ ] Phase status flipped to Complete. + +## Success Criteria + +- Both commands callable via `goclaw --help`. +- All new tests pass without skipping or relying on live server. +- No regression in existing `traces list`, `traces get`, `traces export`, or any `providers` subcommand. + +## Risks + +- Forgetting path-escape on provider ID (RT-02 lesson). Mitigated by explicit escape test. +- Accidentally adding `--verify` based on familiarity with `providers verify`. Mitigated by codex prompt's explicit prohibition + test asserting empty body. + +## Next Steps + +Phase 3 (Sessions Branch + Follow). diff --git a/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-03-sessions-branch-and-follow.md b/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-03-sessions-branch-and-follow.md new file mode 100644 index 0000000..19235c2 --- /dev/null +++ b/plans/260527-1412-domain-coverage-p6-backend-unblocked/phase-03-sessions-branch-and-follow.md @@ -0,0 +1,120 @@ +--- +phase: 3 +title: "Sessions Branch + Follow (PR #44 chat surfaces)" +status: pending +priority: P2 +effort: "3h" +dependencies: [2] +--- + +# Phase 3: Sessions Branch + Follow + +## Overview + +Implement the two chat-session surfaces from backend PR #44 (beta `v3.12.0-beta.20`+): branch a session at a message index, and cursor-based history follow. TDD. + +## Surfaces + +### 3.1 `goclaw sessions branch` + +```bash +goclaw sessions branch --up-to-index [--new-session-key ] [--label