From 09bdae78ca778eedb8a95e8146b007e02c9e1477 Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Sat, 27 Jun 2026 14:05:52 -0300 Subject: [PATCH] fix(cli): stabilize timeline json schema --- docs/usage.md | 2 +- internal/cli/cli_e2e_test.go | 6 +- internal/cli/nu.go | 115 ++++++++++++++++++++++++++++++----- internal/cli/nu_test.go | 63 ++++++++++++++++++- internal/cli/show.go | 8 +++ 5 files changed, 174 insertions(+), 20 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 9ded051c..19cea819 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -249,7 +249,7 @@ prosa search "sqlite" --remote --last 90d prosa --project mz-iac --agent codex # pipe to jq -prosa --last 30d --json | jq '[.[] | {agent, project, started_at}]' +prosa --last 30d --json | jq -s '[.[] | {agent, project, started_at}]' # show the raw of the most recent session prosa --last 1d --json \ diff --git a/internal/cli/cli_e2e_test.go b/internal/cli/cli_e2e_test.go index 0a5d6070..60f60443 100644 --- a/internal/cli/cli_e2e_test.go +++ b/internal/cli/cli_e2e_test.go @@ -59,9 +59,9 @@ func TestCLIEndToEnd(t *testing.T) { var row map[string]any require.NoError(t, json.Unmarshal([]byte(lines[0]), &row)) - require.Equal(t, e2eSessionID, row["ID"]) - require.Equal(t, "codex", row["Agent"]) - require.Equal(t, "explain entanglement", row["FirstPrompt"]) + require.Equal(t, e2eSessionID, row["id"]) + require.Equal(t, "codex", row["agent"]) + require.Equal(t, "explain entanglement", row["first_prompt"]) } type cliEnv struct { diff --git a/internal/cli/nu.go b/internal/cli/nu.go index e367d362..fa77e6b0 100644 --- a/internal/cli/nu.go +++ b/internal/cli/nu.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "time" @@ -88,13 +89,7 @@ func runNu(cmd *cobra.Command, _ []string) error { } if g.JSON { - enc := json.NewEncoder(os.Stdout) - for i := range sessions { - if err := enc.Encode(sessions[i]); err != nil { - return err - } - } - return nil + return emitTimelineJSON(os.Stdout, sessions) } if len(sessions) == 0 { @@ -185,13 +180,7 @@ func runNuRemote(ctx context.Context, w Window, now time.Time) error { sessions = append(sessions, remoteSessionToLocal(in)) } if g.JSON { - enc := json.NewEncoder(os.Stdout) - for i := range sessions { - if err := enc.Encode(sessions[i]); err != nil { - return err - } - } - return nil + return emitTimelineJSON(os.Stdout, sessions) } if len(sessions) == 0 { if interactive { @@ -224,6 +213,104 @@ func runNuRemote(ctx context.Context, w Window, now time.Time) error { }) } +type timelineSessionJSON struct { + ID string `json:"id"` + Agent string `json:"agent"` + DeviceID string `json:"device_id"` + Project string `json:"project,omitempty"` + ProjectPath string `json:"project_path,omitempty"` + ProjectRemote string `json:"project_remote,omitempty"` + ProjectMarker string `json:"project_marker,omitempty"` + StartedAt time.Time `json:"started_at"` + LastActivityAt time.Time `json:"last_activity_at"` + FirstPrompt string `json:"first_prompt,omitempty"` + Model string `json:"model,omitempty"` + RawPath string `json:"raw_path,omitempty"` + RawHash string `json:"raw_hash,omitempty"` + RawSize int64 `json:"raw_size,omitempty"` + Usage *timelineUsageJSON `json:"usage,omitempty"` + ParentID string `json:"parent_session_id,omitempty"` + Profile string `json:"profile"` + Kinds []string `json:"kinds,omitempty"` +} + +type timelineUsageJSON struct { + TotalTokens int64 `json:"total_tokens"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CachedTokens int64 `json:"cached_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheCreationTokens int64 `json:"cache_creation_tokens"` +} + +func emitTimelineJSON(w io.Writer, sessions []session.Session) error { + enc := json.NewEncoder(w) + for i := range sessions { + if err := enc.Encode(timelineSessionPayload(sessions[i])); err != nil { + return err + } + } + return nil +} + +func timelineSessionPayload(s session.Session) timelineSessionJSON { + out := timelineSessionJSON{ + ID: s.ID, + Agent: s.Agent, + DeviceID: s.DeviceID, + ProjectPath: ptrText(s.ProjectPath), + ProjectRemote: ptrText(s.ProjectRemote), + ProjectMarker: ptrText(s.ProjectMarker), + StartedAt: s.StartedAt, + LastActivityAt: s.LastActivityAt, + FirstPrompt: ptrText(s.FirstPrompt), + Model: ptrText(s.Model), + RawPath: s.RawPath, + RawHash: s.RawHash, + RawSize: s.RawSize, + Usage: timelineUsagePayload(s.Usage), + ParentID: ptrText(s.ParentSessionID), + Profile: session.ProfileOrDefault(s.Profile), + } + out.Project = timelineProject(out.ProjectPath, out.ProjectRemote, out.ProjectMarker) + if len(s.Kinds) > 0 { + out.Kinds = append([]string(nil), s.Kinds...) + } + return out +} + +func timelineProject(path, remote, marker string) string { + switch { + case marker != "": + return marker + case remote != "": + return remote + default: + return path + } +} + +func timelineUsagePayload(u *session.TokenUsage) *timelineUsageJSON { + if u == nil { + return nil + } + return &timelineUsageJSON{ + TotalTokens: u.TotalTokens, + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + CachedTokens: u.CachedTokens, + CacheReadTokens: u.CacheReadTokens, + CacheCreationTokens: u.CacheCreationTokens, + } +} + +func ptrText(v *string) string { + if v == nil { + return "" + } + return *v +} + func remoteDeviceLabels(ctx context.Context, auth rpc.AuthFile) map[string]string { client := rpc.Devices(auth.Server, auth.Token) resp, err := client.List(ctx, connect.NewRequest(&prosav1.DevicesServiceListRequest{})) diff --git a/internal/cli/nu_test.go b/internal/cli/nu_test.go index 3b10eaf6..f0313543 100644 --- a/internal/cli/nu_test.go +++ b/internal/cli/nu_test.go @@ -18,6 +18,7 @@ import ( prosav1 "github.com/c3-oss/prosa/gen/go/prosa/v1" "github.com/c3-oss/prosa/gen/go/prosa/v1/prosav1connect" "github.com/c3-oss/prosa/internal/cli/rpc" + "github.com/c3-oss/prosa/pkg/session" ) type fakeNuSessions struct { @@ -38,6 +39,8 @@ func (f *fakeNuSessions) List(_ context.Context, req *connect.Request[prosav1.Li FirstPrompt: "remote timeline work", RawHash: "hash", RawSize: 4, + Profile: "work", + Kinds: []string{"goal"}, }}, TotalCount: 1, }), nil @@ -113,6 +116,62 @@ func TestRunNuRemoteListsWithoutLocalStore(t *testing.T) { var row map[string]any require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &row)) - require.Equal(t, "remote-session", row["ID"]) - require.Equal(t, "device-b", row["DeviceID"]) + require.Equal(t, "remote-session", row["id"]) + require.Equal(t, "device-b", row["device_id"]) + require.Equal(t, "work", row["profile"]) + require.NotContains(t, row, "ID") + require.NotContains(t, row, "DeviceID") +} + +func TestTimelineSessionPayloadUsesStableJSONSchema(t *testing.T) { + projectPath := "/Users/cai/Projects/prosa" + projectRemote := "git@github.com:c3-oss/prosa.git" + projectMarker := "prosa" + firstPrompt := "fix timeline json" + model := "gpt-5-codex" + parent := "parent-session" + started := time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) + payload := timelineSessionPayload(session.Session{ + ID: "s1", + Agent: "codex", + DeviceID: "device-a", + ProjectPath: &projectPath, + ProjectRemote: &projectRemote, + ProjectMarker: &projectMarker, + StartedAt: started, + LastActivityAt: started.Add(time.Minute), + FirstPrompt: &firstPrompt, + Model: &model, + RawPath: "/tmp/s1.jsonl", + RawHash: "hash", + RawSize: 42, + Usage: &session.TokenUsage{TotalTokens: 10, InputTokens: 7, OutputTokens: 3}, + ParentSessionID: &parent, + Profile: "work", + Kinds: []string{"goal"}, + }) + + body, err := json.Marshal(payload) + require.NoError(t, err) + var row map[string]any + require.NoError(t, json.Unmarshal(body, &row)) + require.Equal(t, "s1", row["id"]) + require.Equal(t, "codex", row["agent"]) + require.Equal(t, "device-a", row["device_id"]) + require.Equal(t, "prosa", row["project"]) + require.Equal(t, projectPath, row["project_path"]) + require.Equal(t, projectRemote, row["project_remote"]) + require.Equal(t, projectMarker, row["project_marker"]) + require.Equal(t, "2026-06-22T12:00:00Z", row["started_at"]) + require.Equal(t, "fix timeline json", row["first_prompt"]) + require.Equal(t, "parent-session", row["parent_session_id"]) + require.Equal(t, "work", row["profile"]) + require.Equal(t, []any{"goal"}, row["kinds"]) + usage := row["usage"].(map[string]any) + require.Equal(t, float64(10), usage["total_tokens"]) + require.Equal(t, float64(7), usage["input_tokens"]) + require.Equal(t, float64(3), usage["output_tokens"]) + require.NotContains(t, row, "ID") + require.NotContains(t, row, "StartedAt") + require.NotContains(t, row, "DeviceID") } diff --git a/internal/cli/show.go b/internal/cli/show.go index e7857003..174b515a 100644 --- a/internal/cli/show.go +++ b/internal/cli/show.go @@ -237,6 +237,14 @@ func remoteSessionToLocal(in *prosav1.Session) session.Session { v := in.Model out.Model = &v } + if in.ParentSessionId != "" { + v := in.ParentSessionId + out.ParentSessionID = &v + } + out.Profile = session.ProfileOrDefault(in.Profile) + if len(in.Kinds) > 0 { + out.Kinds = append([]string(nil), in.Kinds...) + } if in.Usage != nil { out.Usage = &session.TokenUsage{ TotalTokens: in.Usage.TotalTokens,