Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -46,6 +46,15 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `goclaw agents evolution skill apply <agent-id> <suggestion-id> [--skill-draft @file]` — explicit wrapper for approving `skill_add` suggestions through the server evolution approval route.
- `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 <provider-id>` — hot-reconnect a provider, bumping the registry without touching credentials (`POST /v1/providers/{id}/reconnect`).
- `goclaw sessions branch <session-key> --up-to-index N [--new-session-key K] [--label L] [--metadata k=v ...]` — branch a chat session at a 1-based message index into a new session (`POST /v1/chat/sessions/{key}/branch`). `--up-to-index=0` is preserved on the wire.
- `goclaw sessions follow <session-key> [--cursor N] [--limit N]` — one-shot cursor-based history poll (`GET /v1/chat/sessions/{key}/history/follow`). Not a stream; `--cursor=0` is preserved literally in the query string.
- `goclaw channels writers test <instance-id> --group-id G --user-id U` — probe a (group, user) pair against a channel's writer policy without mutating state (`POST /v1/channels/instances/{id}/writers/test`). Request body has exactly two keys.
- `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.
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key> [--since <RFC3339>] [--limit <n>]
goclaw traces follow --agent <id> [--since <RFC3339>] [--limit <n>]

# Provider hot-reconnect (bumps registry without recreating credentials)
goclaw providers reconnect <provider-id>

# Branch a chat session at a message index
goclaw sessions branch <session-key> --up-to-index <N> [--new-session-key <k>] \
[--label <l>] [--metadata k=v ...]

# One-shot session-history poll (cursor-based; not a stream)
goclaw sessions follow <session-key> [--cursor <n>] [--limit <n>]

# Probe a (group, user) pair against a channel's writer policy
goclaw channels writers test <instance-id> --group-id <g> --user-id <u>

# Aggregate audit-log activity by dimension
goclaw activity aggregate --group-by <action|actor_type|entity_type|actor_id> \
[--from <RFC3339>] [--to <RFC3339>] [--limit <n>] \
[--actor-type <v>] [--actor-id <v>] [--action <v>] [--entity-type <v>] [--entity-id <v>]

# Summarize the runtime log ring buffer (NOT a stream — see 'logs tail' for that)
goclaw logs aggregate [--group-by <level|source>] [--level <l>] [--source <s>] [--from <RFC3339>]
```

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
Expand Down
158 changes: 158 additions & 0 deletions cmd/activity_aggregate.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading