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
500 lines
18 KiB
Go
500 lines
18 KiB
Go
package duckdb
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.kenn.io/agentsview/internal/activity"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/export"
|
|
)
|
|
|
|
// activityReportRangeBoundsUTC returns the exact [start, end) UTC bounds
|
|
// of the resolved range `q` as RFC3339 strings. It mirrors the SQLite and
|
|
// PostgreSQL backends so the candidate-session predicate selects exactly
|
|
// the sessions whose window intersects the range, with no padding slop.
|
|
// DuckDB compares parsed instants (the bounds are cast to TIMESTAMP), so
|
|
// it keeps the zone suffix, unlike SQLite's zone-less TEXT comparison.
|
|
func activityReportRangeBoundsUTC(q activity.Query) (string, string) {
|
|
return q.RangeStart.UTC().Format(time.RFC3339),
|
|
q.RangeEnd.UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
// GetActivityReport assembles a concurrency- and usage-oriented report
|
|
// for the resolved range `q`, reading from the DuckDB store. It mirrors
|
|
// the SQLite (*DB).GetActivityReport and PostgreSQL
|
|
// (*Store).GetActivityReport: three fetches scoped to the SAME candidate
|
|
// session-ID set so the concurrency timeline, sessions table, and usage
|
|
// totals stay mutually consistent (no orphan usage rows), then the
|
|
// in-memory streams are handed to activity.Aggregate.
|
|
//
|
|
// The filter `f` is honored as-is: callers that want one-shot or
|
|
// automated sessions included must pass them through with the
|
|
// corresponding exclusions disabled. Subagent and fork sessions are
|
|
// always counted so the cost totals match GetDailyUsage, which never
|
|
// filters by relationship_type. Fork sessions hold only their own
|
|
// rewound-branch messages (the parsers partition entries across
|
|
// branches), so counting them adds no duplicate activity; any usage
|
|
// rows that do recur across sessions collapse in the aggregator's
|
|
// dedup, the same guarantee GetDailyUsage relies on.
|
|
func (s *Store) GetActivityReport(
|
|
ctx context.Context, f db.AnalyticsFilter, q activity.Query,
|
|
) (activity.Report, error) {
|
|
f.IncludeSubagents = true
|
|
f.IncludeForks = true
|
|
rangeStartUTC, rangeEndUTC := activityReportRangeBoundsUTC(q)
|
|
lowerBound := duckUsagePaddedUTCBound(q.RangeStart.UTC().Format(time.RFC3339), -14)
|
|
upperBound := duckUsagePaddedUTCBound(q.RangeEnd.UTC().Format(time.RFC3339), 14)
|
|
|
|
sessions, ids, err := s.activityReportSessions(
|
|
ctx, f, rangeStartUTC, rangeEndUTC)
|
|
if err != nil {
|
|
return activity.Report{}, err
|
|
}
|
|
|
|
acts, err := s.activityReportActivity(ctx, ids)
|
|
if err != nil {
|
|
return activity.Report{}, err
|
|
}
|
|
|
|
usage, pricing, err := s.activityReportUsage(ctx, ids, lowerBound, upperBound, q)
|
|
if err != nil {
|
|
return activity.Report{}, err
|
|
}
|
|
|
|
report := activity.Aggregate(activity.Params{
|
|
RangeStart: q.RangeStart,
|
|
RangeEnd: q.RangeEnd,
|
|
Loc: q.Loc,
|
|
EffectiveEnd: q.EffectiveEnd,
|
|
Partial: q.Partial,
|
|
GapCapSeconds: q.GapCapSeconds,
|
|
Bucket: q.Bucket,
|
|
}, sessions, acts, usage)
|
|
report.SchemaVersion = export.ActivityReportSchemaVersion
|
|
report.Pricing = pricing
|
|
projects, err := s.BuildProjectIdentityMap(ctx,
|
|
activityReportProjectLabels(sessions))
|
|
if err != nil {
|
|
return activity.Report{}, err
|
|
}
|
|
activity.SanitizeProjectLabels(&report, projects)
|
|
report.Projects = export.ProjectMapForWire(projects)
|
|
return report, nil
|
|
}
|
|
|
|
func activityReportProjectLabels(sessions []activity.SessionMeta) []string {
|
|
set := make(map[string]bool, len(sessions))
|
|
for _, session := range sessions {
|
|
set[session.Project] = true
|
|
}
|
|
return sortedBoolKeys(set)
|
|
}
|
|
|
|
// activityReportSessions returns the candidate sessions whose window
|
|
// overlaps the exact range [rangeStartUTC, rangeEndUTC), plus their
|
|
// IDs. The ID set defines the scope for the activity and usage fetches.
|
|
// DuckDB stores native timestamps, so the timestamp fallbacks need no
|
|
// NULLIF guard. The Title expression mirrors SQLite and PostgreSQL while
|
|
// intentionally excluding first_message because activity reports cross the
|
|
// summary export boundary. Empty display_name/session_name/project values do
|
|
// not win the fallback.
|
|
//
|
|
// The effective-end fallback for a session with no ended_at uses its
|
|
// latest message timestamp before started_at, so a still-open session
|
|
// that began before the range but has messages inside it is not dropped,
|
|
// matching SQLite and PostgreSQL. COALESCE short-circuits, so the
|
|
// correlated MAX subquery runs only for the rare sessions missing an
|
|
// ended_at.
|
|
func (s *Store) activityReportSessions(
|
|
ctx context.Context, f db.AnalyticsFilter, rangeStartUTC, rangeEndUTC string,
|
|
) ([]activity.SessionMeta, []string, error) {
|
|
where, args := duckBuildAnalyticsWhere(
|
|
f, "COALESCE(s.started_at, s.created_at)", "s.", false, false)
|
|
args = append(args, rangeStartUTC, rangeEndUTC)
|
|
|
|
query := `SELECT
|
|
s.id,
|
|
COALESCE(NULLIF(s.display_name, ''), NULLIF(s.session_name, ''), NULLIF(s.project, ''), s.id) AS display_name,
|
|
s.project,
|
|
s.agent,
|
|
s.machine,
|
|
s.started_at,
|
|
s.ended_at,
|
|
COALESCE(s.is_automated, false) AS is_automated
|
|
FROM sessions s
|
|
WHERE ` + where + `
|
|
AND COALESCE(s.ended_at,
|
|
(SELECT MAX(m.timestamp) FROM messages m
|
|
WHERE m.session_id = s.id AND m.timestamp IS NOT NULL),
|
|
s.started_at, s.created_at) >= CAST(? AS TIMESTAMP)
|
|
AND COALESCE(s.started_at, s.created_at) < CAST(? AS TIMESTAMP)`
|
|
|
|
rows, err := s.queryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"querying duckdb activity report sessions: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var sessions []activity.SessionMeta
|
|
var ids []string
|
|
for rows.Next() {
|
|
var m activity.SessionMeta
|
|
var startedAt, endedAt any
|
|
if err := rows.Scan(
|
|
&m.SessionID, &m.Title, &m.Project, &m.Agent,
|
|
&m.Machine, &startedAt, &endedAt, &m.IsAutomated,
|
|
); err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"scanning duckdb activity report session: %w", err)
|
|
}
|
|
m.StartedAt = formatDBTime(startedAt)
|
|
m.EndedAt = formatDBTime(endedAt)
|
|
sessions = append(sessions, m)
|
|
ids = append(ids, m.SessionID)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"iterating duckdb activity report sessions: %w", err)
|
|
}
|
|
return sessions, ids, nil
|
|
}
|
|
|
|
// activityReportActivity returns every timestamped message for the
|
|
// candidate sessions, ordered for the aggregator's per-session interval
|
|
// walk. It is not time-bounded so cross-boundary successor messages are
|
|
// present.
|
|
func (s *Store) activityReportActivity(
|
|
ctx context.Context, ids []string,
|
|
) ([]activity.ActivityEvent, error) {
|
|
var out []activity.ActivityEvent
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
args, placeholders := stringInArgs(ids)
|
|
query := `SELECT session_id, ordinal, role, timestamp, model
|
|
FROM messages
|
|
WHERE session_id IN (` + strings.Join(placeholders, ",") + `)
|
|
AND timestamp IS NOT NULL
|
|
ORDER BY session_id, ordinal`
|
|
|
|
rows, err := s.queryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"querying duckdb activity report activity: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var e activity.ActivityEvent
|
|
var ts any
|
|
if err := rows.Scan(
|
|
&e.SessionID, &e.Ordinal, &e.Role, &ts, &e.Model,
|
|
); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"scanning duckdb activity report activity: %w", err)
|
|
}
|
|
e.Timestamp = formatDBTime(ts)
|
|
if e.Timestamp == "" {
|
|
continue
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"iterating duckdb activity report activity: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// duckActivityReportUsageRow is one scanned usage-union row before mapping
|
|
// into an activity.UsageRow, carrying the normalized token amounts and
|
|
// dedup keys the aggregator and per-row cost need.
|
|
type duckActivityReportUsageRow struct {
|
|
sessionID string
|
|
source string
|
|
model string
|
|
ts string
|
|
messageOrdinal any
|
|
agent string
|
|
claudeMessageID string
|
|
claudeRequestID string
|
|
sourceUUID string
|
|
usageDedupKey string
|
|
inputTok int
|
|
outputTok int
|
|
cacheCr int
|
|
cacheRd int
|
|
reasoningTok int
|
|
costUSD *float64
|
|
}
|
|
|
|
// activityReportUsage returns the usage rows for the candidate sessions
|
|
// within the padded range bounds, with per-row cost computed up front
|
|
// (mirroring GetDailyUsage's cost logic) so cost stays in the backend.
|
|
// Rows are delivered as one globally ordered stream by
|
|
// (ts, session_id, message_ordinal) as the aggregator's first-seen-wins
|
|
// dedup requires. The ordering is computed in Go on the parsed time
|
|
// value, not the formatted string, to avoid fractional-second lexical
|
|
// issues.
|
|
func (s *Store) activityReportUsage(
|
|
ctx context.Context, ids []string, lowerBound, upperBound string, q activity.Query,
|
|
) ([]activity.UsageRow, *export.PricingBlock, error) {
|
|
out := []activity.UsageRow{}
|
|
|
|
pricing, err := s.loadPricing(ctx)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("loading duckdb pricing: %w", err)
|
|
}
|
|
rateResolver := export.NewPricingResolver(duckPricingRows(pricing))
|
|
if len(ids) == 0 {
|
|
block, err := rateResolver.BuildBlock()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("building pricing block: %w", err)
|
|
}
|
|
return out, &block, nil
|
|
}
|
|
|
|
idArgs, placeholders := stringInArgs(ids)
|
|
inClause := strings.Join(placeholders, ",")
|
|
query := duckActivityReportUsageQuery(inClause)
|
|
args := make([]any, 0, len(idArgs)*2+2)
|
|
args = append(args, idArgs...) // message-source IN
|
|
args = append(args, idArgs...) // event-source IN
|
|
args = append(args, lowerBound, upperBound)
|
|
|
|
rows, err := s.queryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("querying duckdb activity report usage: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Accumulate the parsed ts and dedup ordinal alongside each mapped row
|
|
// so a single global (ts, session_id, ordinal) order can be imposed
|
|
// before the aggregator's first-seen dedup.
|
|
type ordered struct {
|
|
row activity.UsageRow
|
|
scan duckActivityReportUsageRow
|
|
ts time.Time
|
|
ordinal int64
|
|
}
|
|
var rowsAcc []ordered
|
|
|
|
for rows.Next() {
|
|
var r duckActivityReportUsageRow
|
|
if err := rows.Scan(
|
|
&r.sessionID, &r.messageOrdinal, &r.ts, &r.source, &r.model,
|
|
&r.agent, &r.claudeMessageID, &r.claudeRequestID, &r.sourceUUID,
|
|
&r.usageDedupKey,
|
|
&r.inputTok, &r.outputTok, &r.cacheCr, &r.cacheRd,
|
|
&r.reasoningTok, &r.costUSD,
|
|
); err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"scanning duckdb activity report usage: %w", err)
|
|
}
|
|
tsStr := formatDBTime(r.ts)
|
|
ord := int64(-1)
|
|
if o, ok := duckUsageOrdinal(r.messageOrdinal); ok {
|
|
ord = o
|
|
}
|
|
parsedTS, _ := parseTimestamp(tsStr)
|
|
rowsAcc = append(rowsAcc, ordered{
|
|
ts: parsedTS,
|
|
ordinal: ord,
|
|
scan: r,
|
|
row: activity.UsageRow{
|
|
SessionID: r.sessionID,
|
|
Model: r.model,
|
|
Timestamp: tsStr,
|
|
OutputTokens: r.outputTok,
|
|
Agent: r.agent,
|
|
ClaudeMessageID: r.claudeMessageID,
|
|
ClaudeRequestID: r.claudeRequestID,
|
|
SourceUUID: r.sourceUUID,
|
|
UsageDedupKey: r.usageDedupKey,
|
|
},
|
|
})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"iterating duckdb activity report usage: %w", err)
|
|
}
|
|
|
|
sort.SliceStable(rowsAcc, func(i, j int) bool {
|
|
a, b := rowsAcc[i], rowsAcc[j]
|
|
if !a.ts.Equal(b.ts) {
|
|
return a.ts.Before(b.ts)
|
|
}
|
|
if a.row.SessionID != b.row.SessionID {
|
|
return a.row.SessionID < b.row.SessionID
|
|
}
|
|
return a.ordinal < b.ordinal
|
|
})
|
|
baseRows := make([]activity.UsageRow, len(rowsAcc))
|
|
for i, o := range rowsAcc {
|
|
baseRows[i] = o.row
|
|
}
|
|
mask := activity.UsageSurvivorMask(q.RangeStart, q.RangeEnd, q.EffectiveEnd, baseRows)
|
|
out = make([]activity.UsageRow, 0, len(rowsAcc))
|
|
for i, o := range rowsAcc {
|
|
if !mask[i] {
|
|
continue
|
|
}
|
|
_, cost := duckActivityReportRowCost(o.scan, rateResolver)
|
|
row := o.row
|
|
row.Cost = cost
|
|
out = append(out, row)
|
|
}
|
|
block, err := rateResolver.BuildBlock()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("building pricing block: %w", err)
|
|
}
|
|
return out, &block, nil
|
|
}
|
|
|
|
// duckActivityReportUsageQuery builds the per-row usage-union SQL scoped to
|
|
// the candidate sessions. It applies the same message and usage-event
|
|
// eligibility predicates as GetDailyUsage (empty token_usage, empty, and
|
|
// synthetic models excluded) so the daily totals match the Usage
|
|
// dashboard, normalizes the per-source token columns in SQL, and bounds
|
|
// rows to the padded range window. inClause is the comma-joined "?"
|
|
// placeholder list; it is interpolated twice (message source, event
|
|
// source). Cost is computed per row in Go.
|
|
func duckActivityReportUsageQuery(inClause string) string {
|
|
return fmt.Sprintf(`
|
|
WITH usage_raw AS (
|
|
SELECT m.session_id AS session_id, m.ordinal AS message_ordinal,
|
|
'message' AS source, COALESCE(m.timestamp, s.started_at) AS ts,
|
|
m.model AS model, m.token_usage AS token_json,
|
|
s.agent AS agent,
|
|
m.claude_message_id AS claude_message_id,
|
|
m.claude_request_id AS claude_request_id,
|
|
m.source_uuid AS source_uuid,
|
|
'' AS usage_dedup_key,
|
|
0 AS input_tokens, 0 AS output_tokens,
|
|
0 AS cache_create, 0 AS cache_read,
|
|
COALESCE(TRY_CAST(json_extract_string(m.token_usage, '$.reasoning_tokens') AS BIGINT), 0) AS reasoning_tokens,
|
|
NULL AS cost_usd
|
|
FROM messages m
|
|
JOIN sessions s ON s.id = m.session_id
|
|
WHERE m.token_usage != ''
|
|
AND m.model != ''
|
|
AND m.model != '<synthetic>'
|
|
AND s.deleted_at IS NULL
|
|
AND m.session_id IN (%[1]s)
|
|
UNION ALL
|
|
SELECT ue.session_id AS session_id, ue.message_ordinal AS message_ordinal,
|
|
ue.source AS source, COALESCE(ue.occurred_at, s.started_at) AS ts,
|
|
ue.model AS model, '' AS token_json,
|
|
s.agent AS agent,
|
|
'' AS claude_message_id, '' AS claude_request_id,
|
|
'' AS source_uuid,
|
|
CASE
|
|
WHEN ue.dedup_key != '' THEN ue.session_id || ':' || ue.source || ':' || ue.dedup_key
|
|
ELSE ue.session_id || ':' || ue.source || ':id:' || CAST(ue.id AS VARCHAR)
|
|
END AS usage_dedup_key,
|
|
ue.input_tokens AS input_tokens, ue.output_tokens AS output_tokens,
|
|
ue.cache_creation_input_tokens AS cache_create,
|
|
ue.cache_read_input_tokens AS cache_read,
|
|
ue.reasoning_tokens AS reasoning_tokens,
|
|
ue.cost_usd AS cost_usd
|
|
FROM usage_events ue
|
|
JOIN sessions s ON s.id = ue.session_id
|
|
WHERE ue.model != ''
|
|
AND s.deleted_at IS NULL
|
|
AND ue.session_id IN (%[1]s)
|
|
),
|
|
usage_normalized AS (
|
|
SELECT session_id, message_ordinal, ts, source, model, agent,
|
|
claude_message_id, claude_request_id, source_uuid, usage_dedup_key,
|
|
CASE
|
|
WHEN source = 'message' THEN LEAST(GREATEST(COALESCE(TRY_CAST(json_extract_string(token_json, '$.input_tokens') AS BIGINT), 0), 0), %[2]d)
|
|
WHEN source = 'session' THEN GREATEST(input_tokens, 0)
|
|
ELSE LEAST(GREATEST(input_tokens, 0), %[2]d)
|
|
END AS input_tokens_norm,
|
|
CASE
|
|
WHEN source = 'message' THEN LEAST(GREATEST(COALESCE(TRY_CAST(json_extract_string(token_json, '$.output_tokens') AS BIGINT), 0), 0), %[2]d)
|
|
WHEN source = 'session' THEN GREATEST(output_tokens, 0)
|
|
ELSE LEAST(GREATEST(output_tokens, 0), %[2]d)
|
|
END AS output_tokens_norm,
|
|
CASE
|
|
WHEN source = 'message' THEN LEAST(GREATEST(COALESCE(TRY_CAST(json_extract_string(token_json, '$.cache_creation_input_tokens') AS BIGINT), 0), 0), %[2]d)
|
|
WHEN source = 'session' THEN GREATEST(cache_create, 0)
|
|
ELSE LEAST(GREATEST(cache_create, 0), %[2]d)
|
|
END AS cache_create_norm,
|
|
CASE
|
|
WHEN source = 'message' THEN LEAST(GREATEST(COALESCE(TRY_CAST(json_extract_string(token_json, '$.cache_read_input_tokens') AS BIGINT), 0), 0), %[2]d)
|
|
WHEN source = 'session' THEN GREATEST(cache_read, 0)
|
|
ELSE LEAST(GREATEST(cache_read, 0), %[2]d)
|
|
END AS cache_read_norm,
|
|
CASE
|
|
WHEN source = 'message' THEN LEAST(GREATEST(COALESCE(TRY_CAST(json_extract_string(token_json, '$.reasoning_tokens') AS BIGINT), 0), 0), %[2]d)
|
|
WHEN source = 'session' THEN GREATEST(reasoning_tokens, 0)
|
|
ELSE LEAST(GREATEST(reasoning_tokens, 0), %[2]d)
|
|
END AS reasoning_tokens_norm,
|
|
cost_usd
|
|
FROM usage_raw
|
|
)
|
|
SELECT session_id, message_ordinal, ts, source, model, agent,
|
|
claude_message_id, claude_request_id, source_uuid, usage_dedup_key,
|
|
input_tokens_norm, output_tokens_norm,
|
|
cache_create_norm, cache_read_norm, reasoning_tokens_norm, cost_usd
|
|
FROM usage_normalized
|
|
WHERE ts >= CAST(? AS TIMESTAMP)
|
|
AND ts <= CAST(? AS TIMESTAMP)`, inClause, db.MaxPlausibleTokens)
|
|
}
|
|
|
|
// duckActivityReportRowCost computes one usage row's cost the same way
|
|
// GetDailyUsage does: an explicit cost_usd wins, otherwise the per-model
|
|
// rates price the normalized token amounts. Billable amounts equal the
|
|
// normalized amounts when there is no explicit cost (mirroring the
|
|
// billable_* SQL in dailyUsageAggregateRows). It returns the cache
|
|
// savings delta and the cost.
|
|
func duckActivityReportRowCost(
|
|
r duckActivityReportUsageRow, pricing *export.PricingResolver,
|
|
) (savings, cost float64) {
|
|
var explicitCost float64
|
|
var billableInput, billableOutput, billableReasoning, billableCacheCr, billableCacheRd int
|
|
if r.costUSD != nil {
|
|
explicitCost = *r.costUSD
|
|
} else {
|
|
billableInput = r.inputTok
|
|
billableOutput = r.outputTok
|
|
billableReasoning = r.reasoningTok
|
|
billableCacheCr = r.cacheCr
|
|
billableCacheRd = r.cacheRd
|
|
}
|
|
cost, savings, _, _ = duckUsageAggregateCost(
|
|
r.model,
|
|
r.inputTok, r.outputTok, r.cacheCr, r.cacheRd,
|
|
billableInput, billableOutput, billableReasoning,
|
|
billableCacheCr, billableCacheRd,
|
|
explicitCost,
|
|
r.costUSD != nil,
|
|
pricing,
|
|
)
|
|
return savings, cost
|
|
}
|
|
|
|
// duckUsageOrdinal extracts a non-negative message ordinal from a
|
|
// scanned value (DuckDB returns NULL message_ordinal for some usage
|
|
// events). ok is false when the value is NULL or not an integer.
|
|
func duckUsageOrdinal(v any) (int64, bool) {
|
|
switch n := v.(type) {
|
|
case nil:
|
|
return 0, false
|
|
case int64:
|
|
return n, true
|
|
case int32:
|
|
return int64(n), true
|
|
case int:
|
|
return int64(n), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|