package acp import ( "context" "errors" "fmt" "math/big" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" acpsqlc "github.com/yan1h/agent-coding-workflow/internal/acp/sqlc" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) func isNoRows(err error) bool { return errors.Is(err, pgx.ErrNoRows) } // ===== NUMERIC <-> float64 转换 helper(复制 chat/repository.go 的实现)===== func numericToFloat64(n pgtype.Numeric) float64 { if !n.Valid { return 0 } f8, err := n.Float64Value() if err != nil { return 0 } return f8.Float64 } func numericToFloat64Ptr(n pgtype.Numeric) *float64 { if !n.Valid { return nil } v := numericToFloat64(n) return &v } func float64ToNumeric(f float64) pgtype.Numeric { var n pgtype.Numeric if err := n.ScanScientific(fmt.Sprintf("%.10e", f)); err != nil { bi := new(big.Int) bi.SetInt64(int64(f)) return pgtype.Numeric{Int: bi, Exp: 0, Valid: true} } return n } func float64PtrToNumeric(f *float64) pgtype.Numeric { if f == nil { return pgtype.Numeric{} } return float64ToNumeric(*f) } // ===== 成本核算 / 预算 / reaper 仓储方法 ===== func (r *pgRepo) InsertSessionUsage(ctx context.Context, rec *SessionUsageRecord) (bool, error) { var srcID *int64 if rec.SourceEventID != nil { v := *rec.SourceEventID srcID = &v } id, err := r.q.InsertSessionUsage(ctx, acpsqlc.InsertSessionUsageParams{ SessionID: toPgUUID(rec.SessionID), UserID: toPgUUID(rec.UserID), ProjectID: toPgUUID(rec.ProjectID), AgentKindID: toPgUUID(rec.AgentKindID), ModelID: toPgUUIDPtr(rec.ModelID), PromptTokens: rec.PromptTokens, CompletionTokens: rec.CompletionTokens, ThinkingTokens: rec.ThinkingTokens, CostUsd: float64ToNumeric(rec.CostUSD), SourceEventID: srcID, }) if err != nil { // ON CONFLICT DO NOTHING + no row -> not inserted (idempotent de-dup). if isNoRows(err) { return false, nil } return false, errs.Wrap(err, errs.CodeInternal, "insert session usage") } rec.ID = id return true, nil } func (r *pgRepo) AddSessionUsageTotals(ctx context.Context, sid uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error) { row, err := r.q.AddSessionUsageTotals(ctx, acpsqlc.AddSessionUsageTotalsParams{ ID: toPgUUID(sid), PromptTokens: dp, CompletionTokens: dc, ThinkingTokens: dt, TotalCostUsd: float64ToNumeric(dCost), }) if err != nil { return 0, 0, 0, 0, errs.Wrap(err, errs.CodeInternal, "add session usage totals") } return row.PromptTokens, row.CompletionTokens, row.ThinkingTokens, numericToFloat64(row.TotalCostUsd), nil } func (r *pgRepo) SumProjectCostUSD(ctx context.Context, projectID uuid.UUID, since time.Time) (float64, error) { n, err := r.q.SumProjectCostUSD(ctx, acpsqlc.SumProjectCostUSDParams{ ProjectID: toPgUUID(projectID), CreatedAt: pgtype.Timestamptz{Time: since, Valid: true}, }) if err != nil { return 0, errs.Wrap(err, errs.CodeInternal, "sum project cost") } return numericToFloat64(n), nil } func (r *pgRepo) MarkSessionTerminatedReason(ctx context.Context, sid uuid.UUID, reason string) error { if err := r.q.MarkSessionTerminatedReason(ctx, acpsqlc.MarkSessionTerminatedReasonParams{ ID: toPgUUID(sid), TerminatedReason: &reason, }); err != nil { return errs.Wrap(err, errs.CodeInternal, "mark session terminated reason") } return nil } func (r *pgRepo) ListSessionsForReaper(ctx context.Context, wallClockStartedBefore, idleSince time.Time) ([]ReaperSession, error) { var wallTS pgtype.Timestamptz if !wallClockStartedBefore.IsZero() { wallTS = pgtype.Timestamptz{Time: wallClockStartedBefore, Valid: true} } rows, err := r.q.ListSessionsForReaper(ctx, acpsqlc.ListSessionsForReaperParams{ Column1: wallTS, Column2: pgtype.Timestamptz{Time: idleSince, Valid: true}, }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list sessions for reaper") } out := make([]ReaperSession, 0, len(rows)) for _, row := range rows { out = append(out, ReaperSession{ ID: fromPgUUID(row.ID), UserID: fromPgUUID(row.UserID), StartedAt: row.StartedAt.Time, LastActivityAt: row.LastActivityAt.Time, MaxWallClockSeconds: row.BudgetMaxWallClockSeconds, }) } return out, nil } func (r *pgRepo) ListSessionUsage(ctx context.Context, sessionID uuid.UUID, limit int32) ([]*SessionUsageRecord, error) { if limit <= 0 { limit = 50 } rows, err := r.q.ListSessionUsageBySession(ctx, acpsqlc.ListSessionUsageBySessionParams{ SessionID: toPgUUID(sessionID), Limit: limit, }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "list session usage") } out := make([]*SessionUsageRecord, 0, len(rows)) for _, row := range rows { out = append(out, rowToSessionUsage(row)) } return out, nil } func rowToSessionUsage(row acpsqlc.AcpSessionUsage) *SessionUsageRecord { var srcID *int64 if row.SourceEventID != nil { v := *row.SourceEventID srcID = &v } return &SessionUsageRecord{ ID: row.ID, SessionID: fromPgUUID(row.SessionID), UserID: fromPgUUID(row.UserID), ProjectID: fromPgUUID(row.ProjectID), AgentKindID: fromPgUUID(row.AgentKindID), ModelID: fromPgUUIDPtr(row.ModelID), PromptTokens: row.PromptTokens, CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: numericToFloat64(row.CostUsd), SourceEventID: srcID, CreatedAt: row.CreatedAt.Time, } } func (r *pgRepo) SummarizeAcpUsageByUser(ctx context.Context, from, to time.Time) ([]AcpUsageUserRow, error) { rows, err := r.q.SummarizeAcpUsageByUser(ctx, acpsqlc.SummarizeAcpUsageByUserParams{ CreatedAt: pgtype.Timestamptz{Time: from, Valid: true}, CreatedAt_2: pgtype.Timestamptz{Time: to, Valid: true}, }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "summarize acp usage by user") } out := make([]AcpUsageUserRow, 0, len(rows)) for _, row := range rows { out = append(out, AcpUsageUserRow{ UserID: fromPgUUID(row.UserID), PromptTokens: row.PromptTokens, CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: numericToFloat64(row.CostUsd), }) } return out, nil } func (r *pgRepo) SummarizeAcpUsageByModel(ctx context.Context, from, to time.Time) ([]AcpUsageModelRow, error) { rows, err := r.q.SummarizeAcpUsageByModel(ctx, acpsqlc.SummarizeAcpUsageByModelParams{ CreatedAt: pgtype.Timestamptz{Time: from, Valid: true}, CreatedAt_2: pgtype.Timestamptz{Time: to, Valid: true}, }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "summarize acp usage by model") } out := make([]AcpUsageModelRow, 0, len(rows)) for _, row := range rows { out = append(out, AcpUsageModelRow{ ModelID: fromPgUUID(row.ModelID), PromptTokens: row.PromptTokens, CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: numericToFloat64(row.CostUsd), }) } return out, nil } func (r *pgRepo) SummarizeAcpUsageByDay(ctx context.Context, from, to time.Time) ([]AcpUsageDayRow, error) { rows, err := r.q.SummarizeAcpUsageByDay(ctx, acpsqlc.SummarizeAcpUsageByDayParams{ CreatedAt: pgtype.Timestamptz{Time: from, Valid: true}, CreatedAt_2: pgtype.Timestamptz{Time: to, Valid: true}, }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "summarize acp usage by day") } out := make([]AcpUsageDayRow, 0, len(rows)) for _, row := range rows { out = append(out, AcpUsageDayRow{ Day: row.Day.Time, PromptTokens: row.PromptTokens, CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: numericToFloat64(row.CostUsd), }) } return out, nil } // ===== run-history dashboard 聚合 ===== func (r *pgRepo) SessionRollup(ctx context.Context, f DashboardFilter) (SessionRollup, error) { row, err := r.q.SessionRollup(ctx, acpsqlc.SessionRollupParams{ Column1: toPgUUIDPtr(f.UserID), Column2: toPgUUIDPtr(f.ProjectID), Column3: toPgUUIDPtr(f.RequirementID), StartedAt: pgtype.Timestamptz{Time: f.From, Valid: true}, StartedAt_2: pgtype.Timestamptz{Time: f.To, Valid: true}, }) if err != nil { return SessionRollup{}, errs.Wrap(err, errs.CodeInternal, "session rollup") } return SessionRollup{ Total: row.Total, Succeeded: row.Succeeded, Crashed: row.Crashed, Active: row.Active, TotalCostUSD: numericToFloat64(row.TotalCostUsd), TotalTokens: row.TotalTokens, AvgDurationSeconds: row.AvgDurationSeconds, }, nil } func (r *pgRepo) SessionsByDay(ctx context.Context, f DashboardFilter) ([]SessionDayRow, error) { rows, err := r.q.SessionsByDay(ctx, acpsqlc.SessionsByDayParams{ Column1: toPgUUIDPtr(f.UserID), Column2: toPgUUIDPtr(f.ProjectID), Column3: toPgUUIDPtr(f.RequirementID), StartedAt: pgtype.Timestamptz{Time: f.From, Valid: true}, StartedAt_2: pgtype.Timestamptz{Time: f.To, Valid: true}, }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "sessions by day") } out := make([]SessionDayRow, 0, len(rows)) for _, row := range rows { out = append(out, SessionDayRow{ Day: row.Day.Time, Total: row.Total, Succeeded: row.Succeeded, Crashed: row.Crashed, TotalCostUSD: numericToFloat64(row.TotalCostUsd), }) } return out, nil } func (r *pgRepo) SessionTimeline(ctx context.Context, sessionID uuid.UUID) ([]SessionTimelineBucket, error) { rows, err := r.q.SessionTimeline(ctx, toPgUUID(sessionID)) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "session timeline") } out := make([]SessionTimelineBucket, 0, len(rows)) for _, row := range rows { out = append(out, SessionTimelineBucket{ Direction: row.Direction, RPCKind: row.RpcKind, Method: row.Method, EventCount: row.EventCount, FirstAt: row.FirstAt.Time, LastAt: row.LastAt.Time, }) } return out, nil }