Skip to content

Commit 725a3d0

Browse files
authored
Add export v1 contracts and session summary export (kenn-io#991)
This PR turns report/export JSON into explicit v1 contracts for programmatic consumers. It adds shared schema, pricing provenance, and project identity metadata to usage daily and activity report outputs, and introduces a daemonless `agentsview export sessions` summary export for headless analytics. The session export is content-free, supports JSON/NDJSON, and includes per-session usage, model, cost, project, worktree, branch, machine, timestamp, and classification metadata without transcript content. Pricing provenance is centralized under `internal/export`: reports use a resolver-derived block with source/table metadata, RFC 8785-style digest, fallback indicators, `cost_source`, and a bounded per-model effective rates map. Source-reported costs are marked so consumers know when token-times-rate recomputation is not expected, and reasoning tokens are handled as output-rate billing breakdowns. Project identity now persists raw observations at sync/import time and recomputes stable identities at export time. Remote-backed identities use normalized network remotes with `sha256:` keys; path-backed fallbacks remain explicit and machine-local. The identity store is preserved through resync and mirrored through PostgreSQL/DuckDB so CLI and HTTP exports stay aligned across backends. The new session-summary export adds stable watermark/keyset pagination, cursor-reset signaling, `--all`, NDJSON meta rows, root/child and automation filtering, and shared pricing/project metadata. Existing usage/activity payloads stay additive: metadata lands as sibling blocks, and daily breakdown arrays are pinned as arrays rather than omitted. Docs now describe the v1 contract rules, pricing digest input, project identity derivation, cursor behavior, session-export limits, and default exclusion caveats. Golden fixtures pin usage daily, usage daily with breakdowns, activity report, and session export JSON/NDJSON shapes. Stale `docs/superpowers` design notes were removed, and the shared contract package was renamed from `internal/exportcontracts` to `internal/export`. Reviewers should focus on: - shared DTO/resolver code in `internal/export` - project identity capture, fallback, resync preservation, and mirror-backend persistence - session summary export query/cursor behavior in `internal/db/session_export.go` and `cmd/agentsview/export.go` - pricing provenance coupling across SQLite, PostgreSQL, and DuckDB usage/activity paths The main tradeoff is landing the related export-contract issues together so field names and semantics stay shared across surfaces. This intentionally does not add redaction flags or per-row pricing provenance: raw project paths/remotes are emitted by default, and pricing provenance remains report-level with a bounded per-model map. Co-authored-by: Wes McKinney <wesm@users.noreply.github.com>
1 parent f356e52 commit 725a3d0

94 files changed

Lines changed: 13249 additions & 2873 deletions

File tree

Some content is hidden

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

cmd/agentsview/activity.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"go.kenn.io/agentsview/internal/activity"
1616
"go.kenn.io/agentsview/internal/config"
1717
"go.kenn.io/agentsview/internal/db"
18+
"go.kenn.io/agentsview/internal/export"
1819
)
1920

2021
// ActivityReportConfig holds the flags for `agentsview activity report`.
@@ -33,6 +34,8 @@ type ActivityReportConfig struct {
3334
Offline bool
3435
}
3536

37+
var activityReportNow = time.Now
38+
3639
// runActivityReport syncs, resolves the range, runs the report, and prints it.
3740
func runActivityReport(cfg ActivityReportConfig) {
3841
ctx := context.Background()
@@ -115,6 +118,9 @@ func fetchHTTPActivityReport(
115118
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
116119
return activity.Report{}, err
117120
}
121+
if r.Projects == nil {
122+
r.Projects = map[string]export.ProjectMapEntry{}
123+
}
118124
return r, nil
119125
}
120126

@@ -154,7 +160,7 @@ func resolveActivityReport(
154160
Timezone: tz,
155161
BucketOverride: cfg.Bucket,
156162
}
157-
q, err := activity.ResolveQuery(input, time.Now())
163+
q, err := activity.ResolveQuery(input, activityReportNow())
158164
if err != nil {
159165
return activity.Report{}, err
160166
}
@@ -177,7 +183,7 @@ func todayIn(tz string) string {
177183
if err != nil {
178184
loc = time.Local
179185
}
180-
return time.Now().In(loc).Format("2006-01-02")
186+
return activityReportNow().In(loc).Format("2006-01-02")
181187
}
182188

183189
// printActivityReport renders the human-readable report: a header, totals,

cmd/agentsview/activity_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"encoding/json"
77
"net/http"
8+
"net/http/httptest"
89
"net/url"
910
"path/filepath"
1011
"testing"
@@ -14,8 +15,14 @@ import (
1415
"github.com/stretchr/testify/require"
1516

1617
"go.kenn.io/agentsview/internal/activity"
18+
"go.kenn.io/agentsview/internal/config"
1719
"go.kenn.io/agentsview/internal/db"
20+
"go.kenn.io/agentsview/internal/dbtest"
21+
"go.kenn.io/agentsview/internal/export"
22+
"go.kenn.io/agentsview/internal/parser"
1823
"go.kenn.io/agentsview/internal/pricing"
24+
"go.kenn.io/agentsview/internal/server"
25+
"go.kenn.io/agentsview/internal/sync"
1926
)
2027

2128
func TestNewActivityCommand_RegistersReport(t *testing.T) {
@@ -225,6 +232,66 @@ func TestResolveActivityReport_PricesFreshDBUsage(t *testing.T) {
225232
"resolveActivityReportPriced must seed fallback pricing for fresh-DB usage")
226233
}
227234

235+
func TestActivityReportJSONMatchesHTTPExportMetadata(t *testing.T) {
236+
dataDir := testDataDir(t)
237+
dbPath := filepath.Join(dataDir, "sessions.db")
238+
database := dbtest.OpenTestDBAt(t, dbPath)
239+
fallbackModel := fallbackPricedModel(t)
240+
require.NoError(t, seedFallbackPricing(database))
241+
seedUsageDailyExportMetadataFixture(t, database, fallbackModel)
242+
243+
cliOut := captureStdout(t, func() {
244+
runActivityReport(ActivityReportConfig{
245+
JSON: true, Preset: "day", Date: "2026-06-01",
246+
Timezone: "UTC", Offline: true, NoSync: true,
247+
})
248+
})
249+
var cliReport activity.Report
250+
require.NoError(t, json.Unmarshal([]byte(cliOut), &cliReport))
251+
252+
srv := server.New(config.Config{
253+
Host: "127.0.0.1", Port: 0, DataDir: dataDir, DBPath: dbPath,
254+
WriteTimeout: 30 * time.Second,
255+
}, database, sync.NewEngine(database, sync.EngineConfig{
256+
AgentDirs: map[parser.AgentType][]string{
257+
parser.AgentClaude: {dataDir},
258+
},
259+
Machine: "test",
260+
}))
261+
req := httptest.NewRequest(http.MethodGet,
262+
"http://127.0.0.1:0/api/v1/activity/report?"+
263+
url.Values{
264+
"preset": {"day"},
265+
"date": {"2026-06-01"},
266+
"timezone": {"UTC"},
267+
}.Encode(), nil)
268+
req.RemoteAddr = "127.0.0.1:1234"
269+
w := httptest.NewRecorder()
270+
srv.Handler().ServeHTTP(w, req)
271+
require.Equal(t, http.StatusOK, w.Code)
272+
var httpReport activity.Report
273+
require.NoError(t, json.NewDecoder(w.Body).Decode(&httpReport))
274+
275+
assert.Equal(t, export.ActivityReportSchemaVersion,
276+
cliReport.SchemaVersion)
277+
assert.Equal(t, cliReport.SchemaVersion, httpReport.SchemaVersion)
278+
require.NotNil(t, cliReport.Pricing)
279+
require.NotNil(t, httpReport.Pricing)
280+
assert.Contains(t, cliReport.Pricing.Models, "gpt-5.1")
281+
assert.Contains(t, cliReport.Pricing.Models, fallbackModel)
282+
assert.Equal(t, cliReport.Pricing.Models, httpReport.Pricing.Models)
283+
require.Contains(t, cliReport.Projects, "shared-project")
284+
require.Contains(t, httpReport.Projects, "shared-project")
285+
assert.Equal(t, cliReport.Projects, httpReport.Projects)
286+
assert.Equal(t, "UTC", cliReport.Timezone)
287+
assert.Equal(t, cliReport.Timezone, httpReport.Timezone)
288+
assert.Equal(t, cliReport.Totals.Sessions, httpReport.Totals.Sessions)
289+
assert.Equal(t, cliReport.Totals.OutputTokens,
290+
httpReport.Totals.OutputTokens)
291+
assert.NotEmpty(t, cliReport.Buckets)
292+
assert.Equal(t, len(cliReport.BySession), len(httpReport.BySession))
293+
}
294+
228295
func TestRunActivityReportOfflineUsesReadOnlyDBWhenWriteLockHeld(t *testing.T) {
229296
dataDir := setupGoldenStatsDataDir(t)
230297

@@ -244,3 +311,30 @@ func TestRunActivityReportOfflineUsesReadOnlyDBWhenWriteLockHeld(t *testing.T) {
244311
assert.Contains(t, out, "Activity 2026-04-04 to 2026-04-05")
245312
assert.Contains(t, out, "Sessions")
246313
}
314+
315+
func TestActivityReportGolden(t *testing.T) {
316+
setupExportGoldenDataDir(t)
317+
oldNow := activityReportNow
318+
activityReportNow = func() time.Time { return goldenFixtureNow }
319+
t.Cleanup(func() { activityReportNow = oldNow })
320+
321+
cmd := newRootCommand()
322+
cmd.SetArgs([]string{
323+
"activity", "report",
324+
"--json",
325+
"--preset", "custom",
326+
"--from", "2026-07-03T10:00:00Z",
327+
"--to", "2026-07-03T13:00:00Z",
328+
"--timezone", "UTC",
329+
"--bucket", "1h",
330+
"--offline",
331+
"--no-sync",
332+
})
333+
var err error
334+
stdout := captureStdout(t, func() {
335+
_, err = cmd.ExecuteC()
336+
})
337+
require.NoError(t, err, "activity report json golden command")
338+
339+
assertGoldenBytes(t, "activity_report_v1.json", []byte(stdout))
340+
}

cmd/agentsview/cli.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ const (
2525
const dataVersionTooNewExitCode = 3
2626

2727
type cliExitError struct {
28-
code int
29-
err error
28+
code int
29+
err error
30+
silent bool
3031
}
3132

3233
func (e *cliExitError) Error() string {
@@ -44,6 +45,13 @@ func withExitCode(err error, code int) error {
4445
return &cliExitError{code: code, err: err}
4546
}
4647

48+
func withSilentExitCode(err error, code int) error {
49+
if err == nil {
50+
return nil
51+
}
52+
return &cliExitError{code: code, err: err, silent: true}
53+
}
54+
4755
func exitCodeFromError(err error) int {
4856
var exitErr *cliExitError
4957
if errors.As(err, &exitErr) {
@@ -52,6 +60,14 @@ func exitCodeFromError(err error) int {
5260
return 1
5361
}
5462

63+
func isSilentExitError(err error) bool {
64+
var exitErr *cliExitError
65+
if !errors.As(err, &exitErr) || exitErr == nil {
66+
return false
67+
}
68+
return exitErr.silent
69+
}
70+
5571
func newRootCommand() *cobra.Command {
5672
var showVersion bool
5773

@@ -92,6 +108,7 @@ func newRootCommand() *cobra.Command {
92108
root.AddCommand(newUpdateCommand())
93109
root.AddCommand(newTokenUseCommand())
94110
root.AddCommand(newImportCommand())
111+
root.AddCommand(newExportCommand())
95112
root.AddCommand(newProjectsCommand())
96113
root.AddCommand(newHealthCommand())
97114
root.AddCommand(newUsageCommand())

0 commit comments

Comments
 (0)