Skip to content

Commit e5409e9

Browse files
authored
feat: add versioned activity and usage exports (#1304)
Add commands for exporting closed-hour activity and usage data, daily snapshots, and date-range digests from the local SQLite archive. Exports use deterministic JSON and content hashes so integrations can detect changes and validate payloads. The change includes tests, synthetic fixtures, and user documentation for UTC period handling and empty intervals. Co-authored-by: Matthew Jacobs <mjacobs@users.noreply.github.com>
1 parent 277f126 commit e5409e9

23 files changed

Lines changed: 4738 additions & 119 deletions

cmd/agentsview/export.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ type exportSessionsCursorResetError struct {
9090
}
9191

9292
func newExportCommand() *cobra.Command {
93+
return newExportCommandWithDeps(defaultExportReportingDeps())
94+
}
95+
96+
func newExportCommandWithDeps(deps exportReportingDeps) *cobra.Command {
9397
cmd := &cobra.Command{
9498
Use: "export",
9599
Short: "Export local archive data",
@@ -102,6 +106,9 @@ func newExportCommand() *cobra.Command {
102106
}
103107
cmd.AddCommand(newExportSessionsCommand())
104108
cmd.AddCommand(newExportStatusCommand())
109+
cmd.AddCommand(newExportHourCommand(deps))
110+
cmd.AddCommand(newExportDayCommand(deps))
111+
cmd.AddCommand(newExportDigestCommand(deps))
105112
return cmd
106113
}
107114

cmd/agentsview/export_reporting.go

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/spf13/cobra"
8+
9+
"go.kenn.io/agentsview/internal/config"
10+
"go.kenn.io/agentsview/internal/db"
11+
"go.kenn.io/agentsview/internal/export"
12+
)
13+
14+
const maxReportingDigestDays = 31
15+
16+
type exportReportingDeps struct {
17+
now func() time.Time
18+
openDatabase func(*cobra.Command) (*db.DB, func(), error)
19+
}
20+
21+
func defaultExportReportingDeps() exportReportingDeps {
22+
return exportReportingDeps{
23+
now: time.Now,
24+
openDatabase: openReportingExportDB,
25+
}
26+
}
27+
28+
func newExportHourCommand(deps exportReportingDeps) *cobra.Command {
29+
var schemaVersion *int
30+
command := &cobra.Command{
31+
Use: "hour YYYY-MM-DD-HH",
32+
Short: "Export one closed UTC reporting hour",
33+
Args: cobra.ExactArgs(1),
34+
SilenceUsage: true,
35+
RunE: func(cmd *cobra.Command, args []string) error {
36+
if err := validateReportingSchemaVersion(*schemaVersion); err != nil {
37+
return err
38+
}
39+
now := deps.now()
40+
hourStart, err := export.ParseReportingHourKey(args[0], now)
41+
if err != nil {
42+
return err
43+
}
44+
database, cleanup, err := deps.openDatabase(cmd)
45+
if err != nil {
46+
return err
47+
}
48+
defer cleanup()
49+
day, err := database.ExportReportingDay(
50+
cmd.Context(),
51+
db.ReportingExportOptions{
52+
Date: hourStart.Truncate(24 * time.Hour),
53+
Now: now,
54+
},
55+
)
56+
if err != nil {
57+
return err
58+
}
59+
index := hourStart.Hour()
60+
if index >= len(day.Hours) ||
61+
day.Hours[index].Period != args[0] {
62+
return fmt.Errorf("reporting hour %q is unavailable", args[0])
63+
}
64+
return writeCanonicalReportingDocument(
65+
cmd, day.Hours[index],
66+
)
67+
},
68+
}
69+
schemaVersion = bindReportingSchemaVersion(command)
70+
return command
71+
}
72+
73+
func newExportDayCommand(deps exportReportingDeps) *cobra.Command {
74+
var schemaVersion *int
75+
command := &cobra.Command{
76+
Use: "day YYYY-MM-DD",
77+
Short: "Export all closed UTC reporting hours for a date",
78+
Args: cobra.ExactArgs(1),
79+
SilenceUsage: true,
80+
RunE: func(cmd *cobra.Command, args []string) error {
81+
if err := validateReportingSchemaVersion(*schemaVersion); err != nil {
82+
return err
83+
}
84+
date, err := export.ParseReportingDate(args[0])
85+
if err != nil {
86+
return err
87+
}
88+
database, cleanup, err := deps.openDatabase(cmd)
89+
if err != nil {
90+
return err
91+
}
92+
defer cleanup()
93+
day, err := database.ExportReportingDay(
94+
cmd.Context(),
95+
db.ReportingExportOptions{Date: date, Now: deps.now()},
96+
)
97+
if err != nil {
98+
return err
99+
}
100+
return writeCanonicalReportingDocument(cmd, day)
101+
},
102+
}
103+
schemaVersion = bindReportingSchemaVersion(command)
104+
return command
105+
}
106+
107+
func newExportDigestCommand(deps exportReportingDeps) *cobra.Command {
108+
var fromValue string
109+
var toValue string
110+
var schemaVersion *int
111+
command := &cobra.Command{
112+
Use: "digest --from YYYY-MM-DD --to YYYY-MM-DD",
113+
Short: "Export reporting digests for a UTC date range",
114+
Args: cobra.NoArgs,
115+
SilenceUsage: true,
116+
RunE: func(cmd *cobra.Command, _ []string) error {
117+
if err := validateReportingSchemaVersion(*schemaVersion); err != nil {
118+
return err
119+
}
120+
if fromValue == "" || toValue == "" {
121+
return fmt.Errorf("--from and --to are required")
122+
}
123+
from, err := export.ParseReportingDate(fromValue)
124+
if err != nil {
125+
return fmt.Errorf("invalid --from: %w", err)
126+
}
127+
to, err := export.ParseReportingDate(toValue)
128+
if err != nil {
129+
return fmt.Errorf("invalid --to: %w", err)
130+
}
131+
if from.After(to) {
132+
return fmt.Errorf("--from must not be after --to")
133+
}
134+
dayCount := int(to.Sub(from)/(24*time.Hour)) + 1
135+
if dayCount > maxReportingDigestDays {
136+
return fmt.Errorf(
137+
"digest range contains %d dates; maximum is %d",
138+
dayCount,
139+
maxReportingDigestDays,
140+
)
141+
}
142+
143+
database, cleanup, err := deps.openDatabase(cmd)
144+
if err != nil {
145+
return err
146+
}
147+
defer cleanup()
148+
now := deps.now()
149+
days := make([]export.ReportingDigestDay, 0, dayCount)
150+
for date := from; !date.After(to); date = date.Add(24 * time.Hour) {
151+
day, err := database.ExportReportingDay(
152+
cmd.Context(),
153+
db.ReportingExportOptions{Date: date, Now: now},
154+
)
155+
if err != nil {
156+
return err
157+
}
158+
hourDigests := make([]string, len(day.Hours))
159+
for i := range day.Hours {
160+
hourDigests[i] = day.Hours[i].Digest
161+
}
162+
days = append(days, export.ReportingDigestDay{
163+
Date: day.Date,
164+
Complete: day.Complete,
165+
HasData: day.HasData,
166+
DayDigest: day.Digest,
167+
HourDigests: hourDigests,
168+
})
169+
}
170+
return writeCanonicalReportingDocument(cmd, export.ReportingDigest{
171+
SchemaVersion: export.ReportingSchemaVersion,
172+
From: fromValue,
173+
To: toValue,
174+
Days: days,
175+
})
176+
},
177+
}
178+
schemaVersion = bindReportingSchemaVersion(command)
179+
command.Flags().StringVar(
180+
&fromValue, "from", "", "First UTC date (YYYY-MM-DD)",
181+
)
182+
command.Flags().StringVar(
183+
&toValue, "to", "", "Last UTC date (YYYY-MM-DD)",
184+
)
185+
return command
186+
}
187+
188+
func bindReportingSchemaVersion(command *cobra.Command) *int {
189+
version := new(int)
190+
command.Flags().IntVar(
191+
version,
192+
"schema-version",
193+
export.ReportingSchemaVersion,
194+
"Reporting export schema version",
195+
)
196+
return version
197+
}
198+
199+
func validateReportingSchemaVersion(version int) error {
200+
if version != export.ReportingSchemaVersion {
201+
return fmt.Errorf("unsupported reporting schema version %d", version)
202+
}
203+
return nil
204+
}
205+
206+
func openReportingExportDB(
207+
cmd *cobra.Command,
208+
) (*db.DB, func(), error) {
209+
appConfig, err := config.LoadPFlags(cmd.Flags())
210+
if err != nil {
211+
return nil, func() {}, fmt.Errorf("loading config: %w", err)
212+
}
213+
database, err := openExportReadOnlyDB(appConfig)
214+
if err != nil {
215+
return nil, func() {}, err
216+
}
217+
applyEmptyCatalogPricing(database, appConfig.CustomModelPricing)
218+
return database, func() {
219+
_ = database.Close()
220+
}, nil
221+
}
222+
223+
func writeCanonicalReportingDocument(
224+
cmd *cobra.Command, document any,
225+
) error {
226+
canonical, err := export.MarshalCanonical(document)
227+
if err != nil {
228+
return fmt.Errorf("marshal reporting export: %w", err)
229+
}
230+
canonical = append(canonical, '\n')
231+
if _, err := cmd.OutOrStdout().Write(canonical); err != nil {
232+
return fmt.Errorf("write reporting export: %w", err)
233+
}
234+
return nil
235+
}

0 commit comments

Comments
 (0)