f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
1270 lines
39 KiB
Go
1270 lines
39 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.kenn.io/agentsview/internal/export"
|
|
)
|
|
|
|
const sessionExportOrder = "last_activity_at DESC, id ASC"
|
|
|
|
type sessionExportQuerier interface {
|
|
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
|
QueryRowContext(context.Context, string, ...any) *sql.Row
|
|
}
|
|
|
|
var (
|
|
ErrSessionExportCursorReset = errors.New("session export cursor reset required")
|
|
ErrSessionExportCursorConflict = errors.New("session export cursor conflict")
|
|
)
|
|
|
|
type SessionExportOptions struct {
|
|
Filter SessionFilter
|
|
Cursor string
|
|
// UseCursorFilter resumes with the cursor's embedded filter instead of
|
|
// requiring Filter to repeat it. CLI callers set this after flag validation.
|
|
UseCursorFilter bool
|
|
Limit int
|
|
Format string
|
|
}
|
|
|
|
type SessionExportResult struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
Rows []SessionSummaryRow `json:"rows"`
|
|
NextCursor string `json:"next_cursor,omitempty"`
|
|
Pricing *export.PricingBlock `json:"pricing,omitempty"`
|
|
Projects map[string]export.ProjectMapEntry `json:"projects"`
|
|
}
|
|
|
|
type SessionSummaryRow struct {
|
|
ID string `json:"id"`
|
|
Project string `json:"-"`
|
|
ProjectReference export.ProjectReference `json:"project"`
|
|
Machine string `json:"-"`
|
|
Agent string `json:"agent"`
|
|
Cwd string `json:"-"`
|
|
GitBranch string `json:"-"`
|
|
StartedAt *string `json:"started_at"`
|
|
EndedAt *string `json:"ended_at"`
|
|
LastActivityAt string `json:"last_activity_at"`
|
|
DurationSeconds *int64 `json:"duration_seconds"`
|
|
MessageCount int `json:"message_count"`
|
|
UserMessageCount int `json:"user_message_count"`
|
|
AssistantMessageCount int `json:"assistant_message_count"`
|
|
TurnCount int `json:"turn_count"`
|
|
Classification export.SessionClassification `json:"classification"`
|
|
IsAutomated bool `json:"is_automated"`
|
|
ModelUsage *SessionModelUsage `json:"model_usage"`
|
|
ParentSessionID *string `json:"parent_session_id"`
|
|
RelationshipType *string `json:"relationship_type"`
|
|
Worktree *SessionExportWorktree `json:"-"`
|
|
TotalOutputTokens int `json:"total_output_tokens"`
|
|
PeakContextTokens int `json:"peak_context_tokens"`
|
|
HasTotalOutputTokens bool `json:"has_total_output_tokens"`
|
|
HasPeakContextTokens bool `json:"has_peak_context_tokens"`
|
|
lastActivitySort float64
|
|
}
|
|
|
|
type SessionModelUsage struct {
|
|
Models []string `json:"models"`
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
|
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
|
ReasoningTokens int `json:"reasoning_tokens"`
|
|
CostUSD float64 `json:"cost_usd"`
|
|
HasCost bool `json:"has_cost"`
|
|
ByModel map[string]SessionModelUsageBreakdown `json:"by_model"`
|
|
}
|
|
|
|
type SessionModelUsageBreakdown struct {
|
|
Model string `json:"model"`
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
|
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
|
ReasoningTokens int `json:"reasoning_tokens"`
|
|
CostUSD float64 `json:"cost_usd"`
|
|
HasCost bool `json:"has_cost"`
|
|
CostSource export.CostSource `json:"cost_source"`
|
|
}
|
|
|
|
type SessionExportWorktree struct {
|
|
Name *string `json:"name"`
|
|
RootPath *string `json:"root_path"`
|
|
}
|
|
|
|
type sessionExportCursorPayload struct {
|
|
DatabaseID string `json:"database_id"`
|
|
Filters sessionExportCursorFilters `json:"filters"`
|
|
Order string `json:"order"`
|
|
Watermark string `json:"watermark"`
|
|
WatermarkSort float64 `json:"watermark_sort,omitempty"`
|
|
LastActivityAt string `json:"last_activity_at"`
|
|
LastActivitySort float64 `json:"last_activity_sort,omitempty"`
|
|
LastID string `json:"last_id"`
|
|
Limit int `json:"limit"`
|
|
SnapshotCount int `json:"snapshot_count,omitempty"`
|
|
SnapshotDigest string `json:"snapshot_digest,omitempty"`
|
|
PrefixCount int `json:"prefix_count,omitempty"`
|
|
PrefixDigest string `json:"prefix_digest,omitempty"`
|
|
}
|
|
|
|
type sessionExportCursorFilters struct {
|
|
Project string `json:"project,omitempty"`
|
|
ExcludeProject string `json:"exclude_project,omitempty"`
|
|
Machine string `json:"machine,omitempty"`
|
|
GitBranch string `json:"git_branch,omitempty"`
|
|
Agent string `json:"agent,omitempty"`
|
|
Date string `json:"date,omitempty"`
|
|
DateFrom string `json:"date_from,omitempty"`
|
|
DateTo string `json:"date_to,omitempty"`
|
|
ActiveSince string `json:"active_since,omitempty"`
|
|
MinMessages int `json:"min_messages,omitempty"`
|
|
MaxMessages int `json:"max_messages,omitempty"`
|
|
MinUserMessages int `json:"min_user_messages,omitempty"`
|
|
ExcludeOneShot bool `json:"exclude_one_shot,omitempty"`
|
|
ExcludeAutomated bool `json:"exclude_automated,omitempty"`
|
|
AutomatedScope string `json:"automated_scope,omitempty"`
|
|
IncludeChildren bool `json:"include_children,omitempty"`
|
|
IncludeOrphans bool `json:"include_orphans,omitempty"`
|
|
Outcome []string `json:"outcome,omitempty"`
|
|
HealthGrade []string `json:"health_grade,omitempty"`
|
|
MinToolFailures *int `json:"min_tool_failures,omitempty"`
|
|
HasSecret bool `json:"has_secret,omitempty"`
|
|
Starred bool `json:"starred,omitempty"`
|
|
SecretsRulesVersions []string `json:"secrets_rules_versions,omitempty"`
|
|
Termination string `json:"termination,omitempty"`
|
|
}
|
|
|
|
type sessionExportUsageAccum struct {
|
|
models map[string]struct{}
|
|
byModel map[string]*sessionExportModelUsageAccum
|
|
inputTokens int
|
|
outputTokens int
|
|
cacheCreationInputTokens int
|
|
cacheReadInputTokens int
|
|
reasoningTokens int
|
|
costUSD float64
|
|
contributing bool
|
|
allPriced bool
|
|
seen map[usageDedupToken]struct{}
|
|
}
|
|
|
|
type sessionExportModelUsageAccum struct {
|
|
inputTokens int
|
|
outputTokens int
|
|
cacheCreationInputTokens int
|
|
cacheReadInputTokens int
|
|
reasoningTokens int
|
|
costUSD float64
|
|
contributing bool
|
|
allPriced bool
|
|
computed bool
|
|
reported bool
|
|
}
|
|
|
|
// ExportSessionSummaries returns content-free session summary rows from the
|
|
// local SQLite archive. It intentionally does not live on Store because export
|
|
// cursors are archive-bound and include the SQLite database identity.
|
|
func (db *DB) ExportSessionSummaries(
|
|
ctx context.Context, opts SessionExportOptions,
|
|
) (SessionExportResult, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
tx, err := db.getReader().BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
|
if err != nil {
|
|
return SessionExportResult{}, fmt.Errorf(
|
|
"starting session export snapshot: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
result, err := db.exportSessionSummariesTx(ctx, tx, opts, true)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return SessionExportResult{}, fmt.Errorf(
|
|
"committing session export snapshot: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ExportAllSessionSummaries follows every page inside one read transaction so
|
|
// a combined JSON or NDJSON artifact has one pricing and identity snapshot.
|
|
func (db *DB) ExportAllSessionSummaries(
|
|
ctx context.Context, opts SessionExportOptions,
|
|
) ([]SessionExportResult, error) {
|
|
return db.exportAllSessionSummaries(ctx, opts, nil)
|
|
}
|
|
|
|
func (db *DB) exportAllSessionSummaries(
|
|
ctx context.Context,
|
|
opts SessionExportOptions,
|
|
afterPage func(int) error,
|
|
) ([]SessionExportResult, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
tx, err := db.getReader().BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("starting complete session export snapshot: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
pages := []SessionExportResult{}
|
|
for {
|
|
result, err := db.exportSessionSummariesTx(ctx, tx, opts, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pages = append(pages, result)
|
|
if afterPage != nil {
|
|
if err := afterPage(len(pages)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if result.NextCursor == "" {
|
|
break
|
|
}
|
|
opts.Cursor = result.NextCursor
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("committing complete session export snapshot: %w", err)
|
|
}
|
|
return pages, nil
|
|
}
|
|
|
|
func (db *DB) exportSessionSummariesTx(
|
|
ctx context.Context, tx *sql.Tx, opts SessionExportOptions,
|
|
cursorIntegrity bool,
|
|
) (SessionExportResult, error) {
|
|
if opts.Limit <= 0 || opts.Limit > MaxSessionLimit {
|
|
opts.Limit = MaxSessionLimit
|
|
}
|
|
if opts.Format == "" {
|
|
opts.Format = "json"
|
|
}
|
|
opts.Filter = canonicalSessionExportFilter(opts.Filter)
|
|
|
|
filters := sessionExportFilters(opts.Filter)
|
|
|
|
var cursor sessionExportCursorPayload
|
|
var err error
|
|
if opts.Cursor != "" {
|
|
cursor, err = db.decodeSessionExportCursor(opts.Cursor)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
if cursor.Order != sessionExportOrder {
|
|
return SessionExportResult{}, fmt.Errorf(
|
|
"%w: order changed", ErrSessionExportCursorConflict)
|
|
}
|
|
if opts.UseCursorFilter {
|
|
opts.Filter = sessionExportFilterFromCursor(cursor.Filters)
|
|
filters = cursor.Filters
|
|
} else if !sessionExportFiltersEqual(cursor.Filters, filters) {
|
|
return SessionExportResult{}, fmt.Errorf(
|
|
"%w: filters changed", ErrSessionExportCursorConflict)
|
|
}
|
|
}
|
|
|
|
where, args := buildSessionExportFilter(opts.Filter)
|
|
databaseID, err := sessionExportMetadataValue(
|
|
ctx, tx, archiveMetadataDatabaseIDKey, ErrDatabaseIDMissing,
|
|
"database id",
|
|
)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
if opts.Cursor != "" && cursor.DatabaseID != databaseID {
|
|
return SessionExportResult{}, fmt.Errorf(
|
|
"%w: cursor database %q does not match %q",
|
|
ErrSessionExportCursorReset, cursor.DatabaseID, databaseID,
|
|
)
|
|
}
|
|
|
|
watermark := cursor.Watermark
|
|
watermarkSort := cursor.WatermarkSort
|
|
if watermark == "" {
|
|
watermark, watermarkSort, err = db.sessionExportWatermark(ctx, tx, where, args)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
}
|
|
if watermark == "" {
|
|
return SessionExportResult{
|
|
SchemaVersion: export.SessionSummarySchemaVersion,
|
|
Rows: []SessionSummaryRow{},
|
|
Projects: map[string]export.ProjectMapEntry{},
|
|
}, nil
|
|
}
|
|
if cursorIntegrity && (cursor.SnapshotDigest != "" || cursor.SnapshotCount != 0) {
|
|
count, digest, err := db.sessionExportSnapshotFingerprint(
|
|
ctx, tx, where, args, watermarkSort)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
if count != cursor.SnapshotCount || digest != cursor.SnapshotDigest {
|
|
return SessionExportResult{}, fmt.Errorf(
|
|
"%w: session export snapshot changed",
|
|
ErrSessionExportCursorReset,
|
|
)
|
|
}
|
|
}
|
|
if cursorIntegrity && (cursor.PrefixDigest != "" || cursor.PrefixCount != 0) {
|
|
count, digest, err := db.sessionExportPrefixFingerprint(
|
|
ctx, tx, where, args, watermarkSort,
|
|
cursor.LastActivitySort, cursor.LastID)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
if count != cursor.PrefixCount || digest != cursor.PrefixDigest {
|
|
return SessionExportResult{}, fmt.Errorf(
|
|
"%w: session export snapshot changed",
|
|
ErrSessionExportCursorReset,
|
|
)
|
|
}
|
|
}
|
|
|
|
rows, err := db.querySessionExportRows(
|
|
ctx, tx, where, args, watermarkSort, cursor, opts.Limit)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
|
|
resultRows := rows
|
|
var next string
|
|
if len(resultRows) > opts.Limit {
|
|
resultRows = resultRows[:opts.Limit]
|
|
last := resultRows[len(resultRows)-1]
|
|
var snapshotCount, prefixCount int
|
|
var snapshotDigest, prefixDigest string
|
|
if cursorIntegrity {
|
|
snapshotCount, snapshotDigest, err = db.sessionExportSnapshotFingerprint(
|
|
ctx, tx, where, args, watermarkSort)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
prefixCount, prefixDigest, err = db.sessionExportPrefixFingerprint(
|
|
ctx, tx, where, args, watermarkSort, last.lastActivitySort, last.ID)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
}
|
|
next = db.encodeSessionExportCursor(sessionExportCursorPayload{
|
|
DatabaseID: databaseID,
|
|
Filters: filters,
|
|
Order: sessionExportOrder,
|
|
Watermark: watermark,
|
|
WatermarkSort: watermarkSort,
|
|
LastActivityAt: last.LastActivityAt,
|
|
LastActivitySort: last.lastActivitySort,
|
|
LastID: last.ID,
|
|
Limit: opts.Limit,
|
|
SnapshotCount: snapshotCount,
|
|
SnapshotDigest: snapshotDigest,
|
|
PrefixCount: prefixCount,
|
|
PrefixDigest: prefixDigest,
|
|
})
|
|
}
|
|
pricing, err := db.attachSessionExportUsage(ctx, tx, resultRows)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
if err := db.attachSessionExportWorktrees(ctx, tx, resultRows); err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
sessionIDs := make([]string, len(resultRows))
|
|
for i := range resultRows {
|
|
sessionIDs[i] = resultRows[i].ID
|
|
}
|
|
snapshots, err := db.listSessionProjectIdentitySnapshotsFrom(
|
|
ctx, tx, sessionIDs)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
archiveID, err := sessionExportMetadataValue(
|
|
ctx, tx, archiveMetadataArchiveIDKey, ErrArchiveIDMissing,
|
|
"archive id",
|
|
)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
archiveSalt, err := sessionExportMetadataValue(
|
|
ctx, tx, archiveMetadataArchiveSaltKey, ErrArchiveSaltMissing,
|
|
"archive salt",
|
|
)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
archiveSalt, err = validateArchiveSalt(archiveSalt)
|
|
if err != nil {
|
|
return SessionExportResult{}, err
|
|
}
|
|
archiveScope := export.IdentityScope{
|
|
ArchiveID: archiveID, ArchiveSalt: archiveSalt,
|
|
}
|
|
projects := make(map[string]export.ProjectMapEntry, len(resultRows))
|
|
for i := range resultRows {
|
|
obs, ok := snapshots[resultRows[i].ID]
|
|
if !ok {
|
|
obs = export.ProjectIdentityObservation{
|
|
Project: resultRows[i].Project,
|
|
Machine: resultRows[i].Machine,
|
|
}
|
|
}
|
|
resultRows[i].ProjectReference =
|
|
export.ResolveProjectReferenceFromObservation(obs, archiveScope)
|
|
reference := resultRows[i].ProjectReference
|
|
next := export.ProjectMapEntry{
|
|
DisplayLabel: reference.DisplayLabel,
|
|
ProjectKey: reference.ProjectKey,
|
|
Resolution: reference.Resolution,
|
|
Identity: export.ProjectCatalogIdentity(reference.Identity),
|
|
}
|
|
if existing, ok := projects[reference.ProjectKey]; ok {
|
|
projects[reference.ProjectKey] = MergeSessionProjectCatalogEntry(
|
|
existing, next,
|
|
)
|
|
} else {
|
|
projects[reference.ProjectKey] = next
|
|
}
|
|
}
|
|
return SessionExportResult{
|
|
SchemaVersion: export.SessionSummarySchemaVersion,
|
|
Rows: resultRows,
|
|
NextCursor: next,
|
|
Pricing: pricing,
|
|
Projects: projects,
|
|
}, nil
|
|
}
|
|
|
|
func MergeSessionProjectCatalogEntry(
|
|
existing, next export.ProjectMapEntry,
|
|
) export.ProjectMapEntry {
|
|
if existing.Resolution == export.ProjectResolutionAmbiguous ||
|
|
next.Resolution == export.ProjectResolutionAmbiguous ||
|
|
(existing.Identity != nil && next.Identity != nil &&
|
|
existing.Identity.Key != next.Identity.Key) {
|
|
return export.ProjectMapEntry{
|
|
DisplayLabel: existing.DisplayLabel,
|
|
ProjectKey: existing.ProjectKey,
|
|
Resolution: export.ProjectResolutionAmbiguous,
|
|
}
|
|
}
|
|
if existing.Identity == nil && next.Identity != nil {
|
|
return next
|
|
}
|
|
return existing
|
|
}
|
|
|
|
func sessionExportMetadataValue(
|
|
ctx context.Context,
|
|
q sessionExportQuerier,
|
|
key string,
|
|
missing error,
|
|
label string,
|
|
) (string, error) {
|
|
var value string
|
|
err := q.QueryRowContext(ctx,
|
|
`SELECT value FROM archive_metadata WHERE key = ?`, key,
|
|
).Scan(&value)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", missing
|
|
}
|
|
return "", fmt.Errorf("reading %s: %w", label, err)
|
|
}
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "", missing
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func (db *DB) sessionExportWatermark(
|
|
ctx context.Context, q sessionExportQuerier, where string, args []any,
|
|
) (string, float64, error) {
|
|
activityExpr := sessionExportLastActivityExpr()
|
|
activitySortExpr := sessionExportLastActivitySortExpr()
|
|
query := `SELECT ` + activityExpr + `, ` + activitySortExpr + `
|
|
FROM sessions WHERE ` + where + `
|
|
ORDER BY ` + activitySortExpr + ` DESC, id ASC
|
|
LIMIT 1`
|
|
var watermark string
|
|
var watermarkSort sql.NullFloat64
|
|
if err := q.QueryRowContext(
|
|
ctx, query, args...,
|
|
).Scan(&watermark, &watermarkSort); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", 0, nil
|
|
}
|
|
return "", 0, fmt.Errorf("querying session export watermark: %w", err)
|
|
}
|
|
if !watermarkSort.Valid {
|
|
return "", 0, nil
|
|
}
|
|
return watermark, watermarkSort.Float64, nil
|
|
}
|
|
|
|
func (db *DB) sessionExportPrefixFingerprint(
|
|
ctx context.Context,
|
|
q sessionExportQuerier,
|
|
where string,
|
|
args []any,
|
|
watermarkSort float64,
|
|
lastActivitySort float64,
|
|
lastID string,
|
|
) (int, string, error) {
|
|
if lastActivitySort == 0 || lastID == "" {
|
|
return 0, "", nil
|
|
}
|
|
activityExpr := sessionExportLastActivityExpr()
|
|
activitySortExpr := sessionExportLastActivitySortExpr()
|
|
queryArgs := append([]any{}, args...)
|
|
queryArgs = append(queryArgs,
|
|
watermarkSort, lastActivitySort, lastActivitySort, lastID)
|
|
query := `
|
|
SELECT id,
|
|
` + activityExpr + ` AS last_activity_at,
|
|
` + activitySortExpr + ` AS last_activity_sort
|
|
FROM sessions
|
|
WHERE ` + where + `
|
|
AND ` + activitySortExpr + ` <= ?
|
|
AND (` + activitySortExpr + ` > ?
|
|
OR (` + activitySortExpr + ` = ? AND id <= ?))
|
|
ORDER BY last_activity_sort DESC, id ASC`
|
|
|
|
return sessionExportFingerprintRows(
|
|
ctx, q, "session export cursor prefix", query, queryArgs)
|
|
}
|
|
|
|
func (db *DB) sessionExportSnapshotFingerprint(
|
|
ctx context.Context,
|
|
q sessionExportQuerier,
|
|
where string,
|
|
args []any,
|
|
watermarkSort float64,
|
|
) (int, string, error) {
|
|
if watermarkSort == 0 {
|
|
return 0, "", nil
|
|
}
|
|
activityExpr := sessionExportLastActivityExpr()
|
|
activitySortExpr := sessionExportLastActivitySortExpr()
|
|
queryArgs := append([]any{}, args...)
|
|
queryArgs = append(queryArgs, watermarkSort)
|
|
query := `
|
|
SELECT id,
|
|
` + activityExpr + ` AS last_activity_at,
|
|
` + activitySortExpr + ` AS last_activity_sort
|
|
FROM sessions
|
|
WHERE ` + where + `
|
|
AND ` + activitySortExpr + ` <= ?
|
|
ORDER BY last_activity_sort DESC, id ASC`
|
|
count, digest, err := sessionExportFingerprintRows(
|
|
ctx, q, "session export cursor snapshot", query, queryArgs)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
return count, digest, nil
|
|
}
|
|
|
|
func sessionExportFingerprintRows(
|
|
ctx context.Context,
|
|
q sessionExportQuerier,
|
|
label string,
|
|
query string,
|
|
args []any,
|
|
) (int, string, error) {
|
|
rows, err := q.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return 0, "", fmt.Errorf("querying %s: %w", label, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
hash := sha256.New()
|
|
var count int
|
|
for rows.Next() {
|
|
var id, activity string
|
|
var activitySort float64
|
|
if err := rows.Scan(&id, &activity, &activitySort); err != nil {
|
|
return 0, "", fmt.Errorf("scanning %s: %w", label, err)
|
|
}
|
|
count++
|
|
hash.Write(fmt.Appendf(nil, "%.17f", activitySort))
|
|
hash.Write([]byte{0})
|
|
hash.Write([]byte(activity))
|
|
hash.Write([]byte{0})
|
|
hash.Write([]byte(id))
|
|
hash.Write([]byte{0})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return 0, "", fmt.Errorf("iterating %s: %w", label, err)
|
|
}
|
|
return count, hex.EncodeToString(hash.Sum(nil)), nil
|
|
}
|
|
|
|
func (db *DB) querySessionExportRows(
|
|
ctx context.Context,
|
|
q sessionExportQuerier,
|
|
where string,
|
|
args []any,
|
|
watermarkSort float64,
|
|
cursor sessionExportCursorPayload,
|
|
limit int,
|
|
) ([]SessionSummaryRow, error) {
|
|
activityExpr := sessionExportLastActivityExpr()
|
|
activitySortExpr := sessionExportLastActivitySortExpr()
|
|
queryArgs := append([]any{}, args...)
|
|
cursorWhere := where + " AND " + activitySortExpr + " <= ?"
|
|
queryArgs = append(queryArgs, watermarkSort)
|
|
if cursor.LastActivityAt != "" || cursor.LastID != "" {
|
|
cursorWhere += " AND ((" + activitySortExpr + " < ?) OR (" +
|
|
activitySortExpr + " = ? AND id > ?))"
|
|
queryArgs = append(queryArgs,
|
|
cursor.LastActivitySort, cursor.LastActivitySort, cursor.LastID)
|
|
}
|
|
queryArgs = append(queryArgs, limit+1)
|
|
|
|
query := `
|
|
SELECT
|
|
id,
|
|
project,
|
|
machine,
|
|
agent,
|
|
cwd,
|
|
git_branch,
|
|
started_at,
|
|
ended_at,
|
|
` + activityExpr + ` AS last_activity_at,
|
|
` + activitySortExpr + ` AS last_activity_sort,
|
|
message_count,
|
|
user_message_count,
|
|
(SELECT COUNT(*)
|
|
FROM messages m
|
|
WHERE m.session_id = sessions.id
|
|
AND m.role = 'assistant'
|
|
AND COALESCE(m.is_system, 0) = 0) AS assistant_message_count,
|
|
COALESCE(is_automated, 0) AS is_automated,
|
|
parent_session_id,
|
|
relationship_type,
|
|
total_output_tokens,
|
|
peak_context_tokens,
|
|
has_total_output_tokens,
|
|
has_peak_context_tokens
|
|
FROM sessions
|
|
WHERE ` + cursorWhere + `
|
|
ORDER BY last_activity_sort DESC, id ASC
|
|
LIMIT ?`
|
|
|
|
sqlRows, err := q.QueryContext(ctx, query, queryArgs...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying session summary export: %w", err)
|
|
}
|
|
defer sqlRows.Close()
|
|
|
|
out := []SessionSummaryRow{}
|
|
for sqlRows.Next() {
|
|
var row SessionSummaryRow
|
|
var startedAt, endedAt sql.NullString
|
|
var parentID, relationship sql.NullString
|
|
var automated bool
|
|
if err := sqlRows.Scan(
|
|
&row.ID,
|
|
&row.Project,
|
|
&row.Machine,
|
|
&row.Agent,
|
|
&row.Cwd,
|
|
&row.GitBranch,
|
|
&startedAt,
|
|
&endedAt,
|
|
&row.LastActivityAt,
|
|
&row.lastActivitySort,
|
|
&row.MessageCount,
|
|
&row.UserMessageCount,
|
|
&row.AssistantMessageCount,
|
|
&automated,
|
|
&parentID,
|
|
&relationship,
|
|
&row.TotalOutputTokens,
|
|
&row.PeakContextTokens,
|
|
&row.HasTotalOutputTokens,
|
|
&row.HasPeakContextTokens,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scanning session summary export: %w", err)
|
|
}
|
|
row.StartedAt = nullStringPtr(startedAt)
|
|
row.EndedAt = nullStringPtr(endedAt)
|
|
row.DurationSeconds = sessionExportDurationSeconds(
|
|
row.StartedAt, row.EndedAt, row.LastActivityAt)
|
|
row.TurnCount = row.UserMessageCount
|
|
row.ParentSessionID = nullStringPtr(parentID)
|
|
row.RelationshipType = nonEmptyNullStringPtr(relationship)
|
|
row.Classification = export.SessionClassificationInteractive
|
|
if automated {
|
|
row.Classification = export.SessionClassificationAutomated
|
|
}
|
|
row.IsAutomated = row.Classification ==
|
|
export.SessionClassificationAutomated
|
|
row.ModelUsage = emptySessionModelUsage()
|
|
out = append(out, row)
|
|
}
|
|
if err := sqlRows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating session summary export: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (db *DB) attachSessionExportUsage(
|
|
ctx context.Context, q sessionExportQuerier, rows []SessionSummaryRow,
|
|
) (*export.PricingBlock, error) {
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
pricingRows, err := db.loadPricingMapFrom(ctx, q)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading pricing: %w", err)
|
|
}
|
|
resolver := export.NewPricingResolver(pricingRows)
|
|
accum := make(map[string]*sessionExportUsageAccum, len(rows))
|
|
for i := range rows {
|
|
accum[rows[i].ID] = newSessionExportUsageAccum()
|
|
}
|
|
|
|
query, args := sessionExportUsageQuery(sessionExportIDs(rows))
|
|
sqlRows, err := q.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying session export usage: %w", err)
|
|
}
|
|
defer sqlRows.Close()
|
|
|
|
for sqlRows.Next() {
|
|
r, err := scanUsageRow(sqlRows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning session export usage: %w", err)
|
|
}
|
|
a := accum[r.sessionID]
|
|
if a == nil {
|
|
continue
|
|
}
|
|
if key, ok := usageDedupTokenForRow(
|
|
r.usageSource, r.agent, r.claudeMessageID,
|
|
r.claudeRequestID, r.sourceUUID, r.usageDedupKey,
|
|
); ok {
|
|
if _, dup := a.seen[key]; dup {
|
|
continue
|
|
}
|
|
a.seen[key] = struct{}{}
|
|
}
|
|
inputTok, outputTok, cacheCrTok, cacheRdTok, reasoningTok :=
|
|
sessionExportUsageTokens(r)
|
|
cost, priced, contributes := sessionRowCost(r, resolver)
|
|
if !contributes {
|
|
continue
|
|
}
|
|
a.contributing = true
|
|
a.models[r.model] = struct{}{}
|
|
a.inputTokens += inputTok
|
|
a.outputTokens += outputTok
|
|
a.cacheCreationInputTokens += cacheCrTok
|
|
a.cacheReadInputTokens += cacheRdTok
|
|
a.reasoningTokens += reasoningTok
|
|
if priced {
|
|
a.costUSD += cost
|
|
} else {
|
|
a.allPriced = false
|
|
}
|
|
ma := a.byModel[r.model]
|
|
if ma == nil {
|
|
ma = &sessionExportModelUsageAccum{allPriced: true}
|
|
a.byModel[r.model] = ma
|
|
}
|
|
ma.contributing = true
|
|
ma.inputTokens += inputTok
|
|
ma.outputTokens += outputTok
|
|
ma.cacheCreationInputTokens += cacheCrTok
|
|
ma.cacheReadInputTokens += cacheRdTok
|
|
ma.reasoningTokens += reasoningTok
|
|
if r.costUSD.Valid {
|
|
ma.reported = true
|
|
} else {
|
|
ma.computed = true
|
|
}
|
|
if priced {
|
|
ma.costUSD += cost
|
|
} else {
|
|
ma.allPriced = false
|
|
}
|
|
}
|
|
if err := sqlRows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating session export usage: %w", err)
|
|
}
|
|
|
|
for i := range rows {
|
|
a := accum[rows[i].ID]
|
|
if a == nil {
|
|
continue
|
|
}
|
|
rows[i].ModelUsage = &SessionModelUsage{
|
|
Models: sortedSetKeys(a.models),
|
|
InputTokens: a.inputTokens,
|
|
OutputTokens: a.outputTokens,
|
|
CacheCreationInputTokens: a.cacheCreationInputTokens,
|
|
CacheReadInputTokens: a.cacheReadInputTokens,
|
|
ReasoningTokens: a.reasoningTokens,
|
|
CostUSD: a.costUSD,
|
|
HasCost: a.contributing && a.allPriced,
|
|
ByModel: sessionExportModelUsageBreakdowns(a.byModel),
|
|
}
|
|
}
|
|
block, err := resolver.BuildBlock()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("building pricing block: %w", err)
|
|
}
|
|
return &block, nil
|
|
}
|
|
|
|
func sessionExportUsageQuery(sessionIDs []string) (string, []any) {
|
|
placeholders := make([]string, len(sessionIDs))
|
|
args := make([]any, len(sessionIDs))
|
|
for i, id := range sessionIDs {
|
|
placeholders[i] = "?"
|
|
args[i] = id
|
|
}
|
|
query := usageRowSelect() + `
|
|
AND u.session_id IN (` + strings.Join(placeholders, ",") + `)
|
|
ORDER BY u.session_id ASC, u.ts ASC, COALESCE(u.message_ordinal, -1) ASC`
|
|
return query, args
|
|
}
|
|
|
|
func sessionExportUsageTokens(
|
|
r usageScanRow,
|
|
) (inputTok, outputTok, cacheCrTok, cacheRdTok, reasoningTok int) {
|
|
if r.usageSource == "message" {
|
|
return clampedUsageTokenCountersWithReasoning(r.tokenJSON)
|
|
}
|
|
inputTok, outputTok, cacheCrTok, cacheRdTok = usageEventRowTokens(
|
|
r.usageSource,
|
|
r.inputTokens,
|
|
r.outputTokens,
|
|
r.cacheCreationInputTokens,
|
|
r.cacheReadInputTokens,
|
|
)
|
|
return inputTok, outputTok, cacheCrTok, cacheRdTok, r.reasoningTokens
|
|
}
|
|
|
|
func (db *DB) attachSessionExportWorktrees(
|
|
ctx context.Context, q sessionExportQuerier, rows []SessionSummaryRow,
|
|
) error {
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
labels := sortedSetKeys(sessionExportProjectLabels(rows))
|
|
observations, err := db.listProjectIdentityObservationsFrom(ctx, q, labels)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
byProjectMachine := make(map[string][]export.ProjectIdentityObservation)
|
|
for _, obs := range observations {
|
|
if obs.WorktreeName == "" && obs.WorktreeRootPath == "" {
|
|
continue
|
|
}
|
|
key := obs.Project + "\x00" + obs.Machine
|
|
byProjectMachine[key] = append(byProjectMachine[key], obs)
|
|
}
|
|
for i := range rows {
|
|
if wt, ok := sessionExportWorktreeForRow(
|
|
rows[i], byProjectMachine[rows[i].Project+"\x00"+rows[i].Machine],
|
|
); ok {
|
|
w := wt
|
|
rows[i].Worktree = &w
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) encodeSessionExportCursor(c sessionExportCursorPayload) string {
|
|
data, _ := json.Marshal(c)
|
|
|
|
db.cursorMu.RLock()
|
|
mac := hmac.New(sha256.New, db.cursorSecret)
|
|
db.cursorMu.RUnlock()
|
|
|
|
mac.Write(data)
|
|
sig := mac.Sum(nil)
|
|
return base64.RawURLEncoding.EncodeToString(data) + "." +
|
|
base64.RawURLEncoding.EncodeToString(sig)
|
|
}
|
|
|
|
func (db *DB) decodeSessionExportCursor(
|
|
s string,
|
|
) (sessionExportCursorPayload, error) {
|
|
parts := strings.Split(s, ".")
|
|
if len(parts) != 2 {
|
|
return sessionExportCursorPayload{},
|
|
fmt.Errorf("%w: invalid format", ErrInvalidCursor)
|
|
}
|
|
data, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
if err != nil {
|
|
return sessionExportCursorPayload{},
|
|
fmt.Errorf("%w: invalid payload: %v", ErrInvalidCursor, err)
|
|
}
|
|
var payload sessionExportCursorPayload
|
|
if err := json.Unmarshal(data, &payload); err != nil {
|
|
return sessionExportCursorPayload{},
|
|
fmt.Errorf("%w: invalid json: %v", ErrInvalidCursor, err)
|
|
}
|
|
|
|
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return sessionExportCursorPayload{},
|
|
fmt.Errorf("%w: invalid signature encoding: %v", ErrInvalidCursor, err)
|
|
}
|
|
db.cursorMu.RLock()
|
|
mac := hmac.New(sha256.New, db.cursorSecret)
|
|
db.cursorMu.RUnlock()
|
|
|
|
mac.Write(data)
|
|
if !hmac.Equal(sig, mac.Sum(nil)) {
|
|
return sessionExportCursorPayload{},
|
|
fmt.Errorf("%w: signature mismatch", ErrInvalidCursor)
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func sessionExportFilters(f SessionFilter) sessionExportCursorFilters {
|
|
f = canonicalSessionExportFilter(f)
|
|
return sessionExportCursorFilters{
|
|
Project: f.Project,
|
|
ExcludeProject: f.ExcludeProject,
|
|
Machine: f.Machine,
|
|
GitBranch: f.GitBranch,
|
|
Agent: f.Agent,
|
|
Date: f.Date,
|
|
DateFrom: f.DateFrom,
|
|
DateTo: f.DateTo,
|
|
ActiveSince: f.ActiveSince,
|
|
MinMessages: f.MinMessages,
|
|
MaxMessages: f.MaxMessages,
|
|
MinUserMessages: f.MinUserMessages,
|
|
ExcludeOneShot: f.ExcludeOneShot,
|
|
ExcludeAutomated: f.ExcludeAutomated,
|
|
AutomatedScope: f.AutomatedScope,
|
|
IncludeChildren: f.IncludeChildren,
|
|
IncludeOrphans: f.IncludeOrphans,
|
|
Outcome: append([]string(nil), f.Outcome...),
|
|
HealthGrade: append([]string(nil), f.HealthGrade...),
|
|
MinToolFailures: cloneIntPtr(f.MinToolFailures),
|
|
HasSecret: f.HasSecret,
|
|
Starred: f.Starred,
|
|
SecretsRulesVersions: append([]string(nil), f.SecretsRulesVersions...),
|
|
Termination: f.Termination,
|
|
}
|
|
}
|
|
|
|
func sessionExportFilterFromCursor(f sessionExportCursorFilters) SessionFilter {
|
|
return canonicalSessionExportFilter(SessionFilter{
|
|
Project: f.Project,
|
|
ExcludeProject: f.ExcludeProject,
|
|
Machine: f.Machine,
|
|
GitBranch: f.GitBranch,
|
|
Agent: f.Agent,
|
|
Date: f.Date,
|
|
DateFrom: f.DateFrom,
|
|
DateTo: f.DateTo,
|
|
ActiveSince: f.ActiveSince,
|
|
MinMessages: f.MinMessages,
|
|
MaxMessages: f.MaxMessages,
|
|
MinUserMessages: f.MinUserMessages,
|
|
ExcludeOneShot: f.ExcludeOneShot,
|
|
AutomatedScope: f.AutomatedScope,
|
|
IncludeChildren: f.IncludeChildren,
|
|
IncludeOrphans: f.IncludeOrphans,
|
|
Outcome: append([]string(nil), f.Outcome...),
|
|
HealthGrade: append([]string(nil), f.HealthGrade...),
|
|
MinToolFailures: cloneIntPtr(f.MinToolFailures),
|
|
HasSecret: f.HasSecret,
|
|
Starred: f.Starred,
|
|
SecretsRulesVersions: append([]string(nil), f.SecretsRulesVersions...),
|
|
Termination: f.Termination,
|
|
})
|
|
}
|
|
|
|
func canonicalSessionExportFilter(f SessionFilter) SessionFilter {
|
|
f.Project = strings.TrimSpace(f.Project)
|
|
f.ExcludeProject = strings.TrimSpace(f.ExcludeProject)
|
|
f.Machine = canonicalCSVFilter(f.Machine)
|
|
f.GitBranch = canonicalBranchListFilter(f.GitBranch)
|
|
f.Agent = canonicalCSVFilter(f.Agent)
|
|
f.Date = strings.TrimSpace(f.Date)
|
|
f.DateFrom = strings.TrimSpace(f.DateFrom)
|
|
f.DateTo = strings.TrimSpace(f.DateTo)
|
|
f.ActiveSince = strings.TrimSpace(f.ActiveSince)
|
|
f.AutomatedScope = normalizeAutomatedScope(f.AutomatedScope, f.ExcludeAutomated)
|
|
f.ExcludeAutomated = false
|
|
f.Outcome = canonicalStringSliceFilter(f.Outcome)
|
|
f.HealthGrade = canonicalStringSliceFilter(f.HealthGrade)
|
|
f.SecretsRulesVersions = canonicalStringSliceFilter(f.SecretsRulesVersions)
|
|
f.Termination = strings.TrimSpace(f.Termination)
|
|
if f.Termination == "all" {
|
|
f.Termination = ""
|
|
}
|
|
f.OrderBy = ""
|
|
return f
|
|
}
|
|
|
|
func canonicalCSVFilter(s string) string {
|
|
parts := splitCSV(s)
|
|
sort.Strings(parts)
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
func canonicalBranchListFilter(s string) string {
|
|
parts := strings.Split(s, branchListSep)
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return strings.Join(out, branchListSep)
|
|
}
|
|
|
|
func canonicalStringSliceFilter(values []string) []string {
|
|
out := make([]string, 0, len(values))
|
|
for _, v := range values {
|
|
if v = strings.TrimSpace(v); v != "" {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func sessionExportFiltersEqual(
|
|
a, b sessionExportCursorFilters,
|
|
) bool {
|
|
aj, _ := json.Marshal(a)
|
|
bj, _ := json.Marshal(b)
|
|
return string(aj) == string(bj)
|
|
}
|
|
|
|
func buildSessionExportFilter(f SessionFilter) (string, []any) {
|
|
dialect := SQLiteQueryDialect()
|
|
if f.IncludeChildren {
|
|
dialect.activityExpr = sessionExportLastActivitySortExprFor("root_session")
|
|
} else {
|
|
dialect.activityExpr = sessionExportLastActivitySortExpr()
|
|
}
|
|
dialect.activityParam = func(ph string) string { return "julianday(" + ph + ")" }
|
|
return BuildSessionFilterSQL(f, dialect)
|
|
}
|
|
|
|
func sessionExportLastActivityExpr() string {
|
|
return sessionExportLastActivityExprFor("sessions")
|
|
}
|
|
|
|
func sessionExportLastActivityExprFor(sessionAlias string) string {
|
|
messageActivity := "(SELECT NULLIF(m.timestamp, '') " +
|
|
"FROM messages m WHERE m.session_id = " + sessionAlias + ".id " +
|
|
"AND m.timestamp != '' " +
|
|
"ORDER BY julianday(m.timestamp) DESC, m.timestamp DESC LIMIT 1)"
|
|
return "COALESCE(NULLIF(" + sessionAlias + ".ended_at, ''), " +
|
|
messageActivity + ", NULLIF(" + sessionAlias + ".started_at, ''), " +
|
|
sessionAlias + ".created_at)"
|
|
}
|
|
|
|
func sessionExportLastActivitySortExpr() string {
|
|
return sessionExportLastActivitySortExprFor("sessions")
|
|
}
|
|
|
|
func sessionExportLastActivitySortExprFor(sessionAlias string) string {
|
|
messageActivity := "(SELECT MAX(julianday(NULLIF(m.timestamp, ''))) " +
|
|
"FROM messages m WHERE m.session_id = " + sessionAlias + ".id)"
|
|
return "COALESCE(julianday(NULLIF(" + sessionAlias + ".ended_at, '')), " +
|
|
messageActivity + ", julianday(NULLIF(" + sessionAlias + ".started_at, '')), " +
|
|
"julianday(" + sessionAlias + ".created_at))"
|
|
}
|
|
|
|
func sessionExportProjectLabels(rows []SessionSummaryRow) map[string]struct{} {
|
|
labels := make(map[string]struct{})
|
|
for _, row := range rows {
|
|
labels[row.Project] = struct{}{}
|
|
}
|
|
return labels
|
|
}
|
|
|
|
func sessionExportIDs(rows []SessionSummaryRow) []string {
|
|
ids := make([]string, 0, len(rows))
|
|
for _, row := range rows {
|
|
ids = append(ids, row.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func newSessionExportUsageAccum() *sessionExportUsageAccum {
|
|
return &sessionExportUsageAccum{
|
|
models: make(map[string]struct{}),
|
|
byModel: make(map[string]*sessionExportModelUsageAccum),
|
|
allPriced: true,
|
|
seen: make(map[usageDedupToken]struct{}),
|
|
}
|
|
}
|
|
|
|
func emptySessionModelUsage() *SessionModelUsage {
|
|
return &SessionModelUsage{
|
|
Models: []string{},
|
|
HasCost: false,
|
|
ByModel: map[string]SessionModelUsageBreakdown{},
|
|
}
|
|
}
|
|
|
|
func nullStringPtr(v sql.NullString) *string {
|
|
if !v.Valid {
|
|
return nil
|
|
}
|
|
return &v.String
|
|
}
|
|
|
|
func nonEmptyNullStringPtr(v sql.NullString) *string {
|
|
if !v.Valid || v.String == "" {
|
|
return nil
|
|
}
|
|
return &v.String
|
|
}
|
|
|
|
func optionalStringPtr(v string) *string {
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
return &v
|
|
}
|
|
|
|
func cloneIntPtr(v *int) *int {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
c := *v
|
|
return &c
|
|
}
|
|
|
|
func sessionExportModelUsageBreakdowns(
|
|
byModel map[string]*sessionExportModelUsageAccum,
|
|
) map[string]SessionModelUsageBreakdown {
|
|
out := make(map[string]SessionModelUsageBreakdown, len(byModel))
|
|
for model, a := range byModel {
|
|
if a == nil {
|
|
continue
|
|
}
|
|
out[model] = SessionModelUsageBreakdown{
|
|
Model: model,
|
|
InputTokens: a.inputTokens,
|
|
OutputTokens: a.outputTokens,
|
|
CacheCreationInputTokens: a.cacheCreationInputTokens,
|
|
CacheReadInputTokens: a.cacheReadInputTokens,
|
|
ReasoningTokens: a.reasoningTokens,
|
|
CostUSD: a.costUSD,
|
|
HasCost: a.contributing && a.allPriced,
|
|
CostSource: sessionExportCostSource(a.computed, a.reported),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sessionExportCostSource(computed, reported bool) export.CostSource {
|
|
switch {
|
|
case computed && reported:
|
|
return export.CostSourceMixed
|
|
case reported:
|
|
return export.CostSourceReported
|
|
default:
|
|
return export.CostSourceComputed
|
|
}
|
|
}
|
|
|
|
func sessionExportWorktreeForRow(
|
|
row SessionSummaryRow,
|
|
observations []export.ProjectIdentityObservation,
|
|
) (SessionExportWorktree, bool) {
|
|
if len(observations) == 0 {
|
|
return SessionExportWorktree{}, false
|
|
}
|
|
cwd := filepath.Clean(row.Cwd)
|
|
var best *export.ProjectIdentityObservation
|
|
bestLen := -1
|
|
for i := range observations {
|
|
obs := &observations[i]
|
|
for _, root := range []string{obs.WorktreeRootPath, obs.RootPath} {
|
|
if root == "" || !pathHasPrefix(cwd, root) {
|
|
continue
|
|
}
|
|
cleanRoot := filepath.Clean(root)
|
|
if len(cleanRoot) > bestLen {
|
|
best = obs
|
|
bestLen = len(cleanRoot)
|
|
}
|
|
}
|
|
}
|
|
if best == nil {
|
|
return SessionExportWorktree{}, false
|
|
}
|
|
return SessionExportWorktree{
|
|
Name: optionalStringPtr(best.WorktreeName),
|
|
RootPath: optionalStringPtr(best.WorktreeRootPath),
|
|
}, true
|
|
}
|
|
|
|
func pathHasPrefix(path, prefix string) bool {
|
|
if path == "." || prefix == "" {
|
|
return false
|
|
}
|
|
cleanPath := filepath.Clean(path)
|
|
cleanPrefix := filepath.Clean(prefix)
|
|
if cleanPath == cleanPrefix {
|
|
return true
|
|
}
|
|
rel, err := filepath.Rel(cleanPrefix, cleanPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
func sessionExportDurationSeconds(
|
|
startedAt, endedAt *string, lastActivityAt string,
|
|
) *int64 {
|
|
if startedAt == nil || *startedAt == "" {
|
|
return nil
|
|
}
|
|
endAt := lastActivityAt
|
|
if endedAt != nil && *endedAt != "" {
|
|
endAt = *endedAt
|
|
}
|
|
if endAt == "" {
|
|
return nil
|
|
}
|
|
start, err := time.Parse(time.RFC3339Nano, *startedAt)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
end, err := time.Parse(time.RFC3339Nano, endAt)
|
|
if err != nil || end.Before(start) {
|
|
return nil
|
|
}
|
|
seconds := int64(end.Sub(start).Seconds())
|
|
return &seconds
|
|
}
|