From c904b1cfa19b600cae1a1539af11a25ae5bcd5a2 Mon Sep 17 00:00:00 2001 From: Aaron Florey Date: Mon, 4 May 2026 08:19:44 +0000 Subject: [PATCH 1/3] fix(dashboard): render OpenCode Go chart in both view --- internal/web/static/app.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 9e9c755..a6e07dc 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -995,6 +995,22 @@ const cursorChartColorFallback = [ { border: '#ef4444', bg: 'rgba(239, 68, 68, 0.08)' }, ]; +const opencodegoDisplayNames = { + rolling: 'Rolling', + weekly: 'Weekly', + monthly: 'Monthly', +}; + +const opencodegoChartColorMap = { + rolling: { border: '#0EA5E9', bg: 'rgba(14, 165, 233, 0.08)' }, + weekly: { border: '#22C55E', bg: 'rgba(34, 197, 94, 0.08)' }, + monthly: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.08)' }, +}; +const opencodegoChartColorFallback = [ + { border: '#8B5CF6', bg: 'rgba(139, 92, 246, 0.08)' }, + { border: '#EC4899', bg: 'rgba(236, 72, 153, 0.08)' }, +]; + const geminiChartColorFallback = [ { border: '#4285F4', bg: 'rgba(66, 133, 244, 0.08)' }, { border: '#34A853', bg: 'rgba(52, 168, 83, 0.08)' }, @@ -4690,6 +4706,7 @@ const bothProviderNames = { minimax: 'MiniMax', gemini: 'Gemini', cursor: 'Cursor', + opencodego: 'OpenCode Go', 'api-integrations': 'API Integrations', }; @@ -5551,6 +5568,9 @@ function buildProviderCardDatasets(provider, rows, range) { const orFallback = [{ border: '#8B5CF6', bg: 'rgba(139, 92, 246, 0.06)' }]; return buildDynamicDatasetsForRows(rows, range, orDisplayNames, orColors, orFallback, 'openrouter'); } + if (provider === 'opencodego') { + return buildDynamicDatasetsForRows(rows, range, opencodegoDisplayNames, opencodegoChartColorMap, opencodegoChartColorFallback, 'opencodego'); + } return []; } @@ -5706,6 +5726,9 @@ function updateBothCharts(data, range = '6h') { if (activeProviders.has('cursor') && Array.isArray(data.cursor) && data.cursor.length > 0) { slots.push({ id: 'cursor', label: 'Cursor', provider: 'cursor', rows: data.cursor }); } + if (activeProviders.has('opencodego') && Array.isArray(data.opencodego) && data.opencodego.length > 0) { + slots.push({ id: 'opencodego', label: 'OpenCode Go', provider: 'opencodego', rows: data.opencodego }); + } if (activeProviders.has('codex')) { if (Array.isArray(data.codexAccounts) && data.codexAccounts.length > 0) { data.codexAccounts.forEach((account, idx) => { @@ -5836,6 +5859,8 @@ function updateBothCharts(data, range = '6h') { const orCM = { usage: { border: '#0D9488', bg: 'rgba(13, 148, 136, 0.06)' }, usageDaily: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.06)' }, percent: { border: '#3B82F6', bg: 'rgba(59, 130, 246, 0.06)' } }; const orFB = [{ border: '#8B5CF6', bg: 'rgba(139, 92, 246, 0.06)' }]; datasets = createDynamicDatasets(slot.rows, orDN, orCM, orFB, 'openrouter'); + } else if (slot.provider === 'opencodego') { + datasets = createDynamicDatasets(slot.rows, opencodegoDisplayNames, opencodegoChartColorMap, opencodegoChartColorFallback, 'opencodego'); } if (datasets.length === 0) return; From 654a43540f5e89103f0cfe7e7767db4ca9ded800 Mon Sep 17 00:00:00 2001 From: Aaron Florey Date: Mon, 4 May 2026 08:23:26 +0000 Subject: [PATCH 2/3] feat(opencodego): add OpenCode Go provider tracking --- internal/agent/opencodego_agent.go | 183 +++++++ internal/api/opencodego_client.go | 385 ++++++++++++++ internal/api/opencodego_types.go | 693 +++++++++++++++++++++++++ internal/api/opencodego_types_test.go | 213 ++++++++ internal/config/config.go | 22 + internal/store/opencodego_store.go | 321 ++++++++++++ internal/store/store.go | 66 +++ internal/tracker/opencodego_tracker.go | 230 ++++++++ internal/web/handlers.go | 560 ++++++++++++++++++++ main.go | 42 +- 10 files changed, 2714 insertions(+), 1 deletion(-) create mode 100644 internal/agent/opencodego_agent.go create mode 100644 internal/api/opencodego_client.go create mode 100644 internal/api/opencodego_types.go create mode 100644 internal/api/opencodego_types_test.go create mode 100644 internal/store/opencodego_store.go create mode 100644 internal/tracker/opencodego_tracker.go diff --git a/internal/agent/opencodego_agent.go b/internal/agent/opencodego_agent.go new file mode 100644 index 0000000..c935661 --- /dev/null +++ b/internal/agent/opencodego_agent.go @@ -0,0 +1,183 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/notify" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +// maxOpenCodeGoAuthFailures is the number of consecutive auth failures before pausing polling. +const maxOpenCodeGoAuthFailures = 5 + +// isOpenCodeGoAuthError returns true if the error is an authentication/authorization error. +func isOpenCodeGoAuthError(err error) bool { + return errors.Is(err, api.ErrOpenCodeGoUnauthorized) || errors.Is(err, api.ErrOpenCodeGoNotSignedIn) +} + +// OpenCodeGoAgent manages the background polling loop for OpenCode Go quota tracking. +type OpenCodeGoAgent struct { + client *api.OpenCodeGoClient + store *store.Store + tracker *tracker.OpenCodeGoTracker + interval time.Duration + logger *slog.Logger + sm *SessionManager + notifier *notify.NotificationEngine + pollingCheck func() bool + + // Auth failure rate limiting + authFailCount int + authPaused bool + lastFailedCookie string +} + +// NewOpenCodeGoAgent creates a new OpenCodeGoAgent with the given dependencies. +func NewOpenCodeGoAgent(client *api.OpenCodeGoClient, st *store.Store, track *tracker.OpenCodeGoTracker, interval time.Duration, logger *slog.Logger, sm *SessionManager) *OpenCodeGoAgent { + if logger == nil { + logger = slog.Default() + } + return &OpenCodeGoAgent{ + client: client, + store: st, + tracker: track, + interval: interval, + logger: logger, + sm: sm, + } +} + +// SetPollingCheck sets a function called before each poll. +func (a *OpenCodeGoAgent) SetPollingCheck(fn func() bool) { + a.pollingCheck = fn +} + +// SetNotifier sets notification engine for sending alerts. +func (a *OpenCodeGoAgent) SetNotifier(n *notify.NotificationEngine) { + a.notifier = n +} + +// sendAuthErrorNotification sends an auth error notification via the notifier. +func (a *OpenCodeGoAgent) sendAuthErrorNotification(title, message string, isRecoverable bool) { + if a.notifier == nil { + return + } + a.notifier.SendAuthErrorNotification(notify.AuthErrorAlert{ + Provider: "opencodego", + Title: title, + Message: message, + IsRecovable: isRecoverable, + }) +} + +// Run starts the agent polling loop. +func (a *OpenCodeGoAgent) Run(ctx context.Context) error { + a.logger.Info("OpenCode Go agent started", "interval", a.interval) + + defer func() { + if a.sm != nil { + a.sm.Close() + } + a.logger.Info("OpenCode Go agent stopped") + }() + + a.poll(ctx) + + ticker := time.NewTicker(a.interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + a.poll(ctx) + case <-ctx.Done(): + return nil + } + } +} + +func (a *OpenCodeGoAgent) poll(ctx context.Context) { + if a.pollingCheck != nil && !a.pollingCheck() { + return + } + + if a.authPaused { + return + } + + snapshot, err := a.client.FetchQuotas(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + + if isOpenCodeGoAuthError(err) { + currentCookie := a.client.GetCookie() + a.authFailCount++ + a.logger.Error("OpenCode Go auth error", + "error", err, + "failure_count", a.authFailCount, + "max_failures", maxOpenCodeGoAuthFailures) + + if a.authFailCount >= maxOpenCodeGoAuthFailures { + a.authPaused = true + a.lastFailedCookie = currentCookie + a.logger.Error("OpenCode Go polling PAUSED due to repeated auth failures") + a.sendAuthErrorNotification( + "Authentication Failed", + "OpenCode Go polling has been paused due to repeated authentication failures. Please update your OPENCODEGO_COOKIE to resume.", + false, + ) + } + } else { + a.logger.Error("Failed to fetch OpenCode Go quotas", "error", err) + } + return + } + + // Success - reset auth failure count + a.authFailCount = 0 + + if _, err := a.store.InsertOpenCodeGoSnapshot(snapshot); err != nil { + a.logger.Error("Failed to insert OpenCode Go snapshot", "error", err) + return + } + + if a.tracker != nil { + if err := a.tracker.Process(snapshot); err != nil { + a.logger.Error("OpenCode Go tracker processing failed", "error", err) + } + } + + if a.notifier != nil { + for _, w := range snapshot.Windows { + a.notifier.Check(notify.QuotaStatus{ + Provider: "opencodego", + QuotaKey: w.WindowName, + Utilization: w.UsagePercent, + Limit: 100, + }) + } + } + + if a.sm != nil { + values := make([]float64, 0, len(snapshot.Windows)) + for _, w := range snapshot.Windows { + values = append(values, w.UsagePercent) + } + a.sm.ReportPoll(values) + } + + for _, w := range snapshot.Windows { + a.logger.Info("OpenCode Go poll complete", + "window", w.WindowName, + "usage", fmt.Sprintf("%.1f%%", w.UsagePercent), + "reset_in_sec", w.ResetInSec) + } +} diff --git a/internal/api/opencodego_client.go b/internal/api/opencodego_client.go new file mode 100644 index 0000000..e2c0ef3 --- /dev/null +++ b/internal/api/opencodego_client.go @@ -0,0 +1,385 @@ +package api + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "regexp" + "strings" + "sync" + "time" +) + +var ( + ErrOpenCodeGoUnauthorized = errors.New("opencodego: unauthorized - invalid or expired cookie") + ErrOpenCodeGoServerError = errors.New("opencodego: server error") + ErrOpenCodeGoInvalidResponse = errors.New("opencodego: invalid response") +) + +const ( + OpenCodeGoBaseURL = "https://opencode.ai" + OpenCodeGoServerURL = "https://opencode.ai/_server" + OpenCodeGoWorkspaceServerID = "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f" + OpenCodeGoUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" +) + +// wrkPattern matches workspace IDs in responses. +var wrkPattern = regexp.MustCompile(`"wrk_[^"]*"`) + +// OpenCodeGoClient handles HTTP communication with the OpenCode Go dashboard. +type OpenCodeGoClient struct { + httpClient *http.Client + cookie string + cookieMu sync.RWMutex + baseURL string + workspaceID string // cached resolved workspace ID + logger *slog.Logger +} + +// OpenCodeGoOption configures an OpenCodeGoClient. +type OpenCodeGoOption func(*OpenCodeGoClient) + +// WithOpenCodeGoBaseURL sets a custom base URL for testing. +func WithOpenCodeGoBaseURL(url string) OpenCodeGoOption { + return func(c *OpenCodeGoClient) { + c.baseURL = url + } +} + +// WithOpenCodeGoTimeout sets a custom HTTP timeout. +func WithOpenCodeGoTimeout(timeout time.Duration) OpenCodeGoOption { + return func(c *OpenCodeGoClient) { + c.httpClient.Timeout = timeout + } +} + +// WithOpenCodeGoWorkspaceID sets a pre-resolved workspace ID (skips resolution). +func WithOpenCodeGoWorkspaceID(id string) OpenCodeGoOption { + return func(c *OpenCodeGoClient) { + c.workspaceID = id + } +} + +// SetWorkspaceID sets a pre-resolved workspace ID. +func (c *OpenCodeGoClient) SetWorkspaceID(id string) { + c.workspaceID = id +} + +// NewOpenCodeGoClient creates a new OpenCode Go client. +func NewOpenCodeGoClient(cookie string, logger *slog.Logger, opts ...OpenCodeGoOption) *OpenCodeGoClient { + client := &OpenCodeGoClient{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 1, + MaxIdleConnsPerHost: 1, + ResponseHeaderTimeout: 30 * time.Second, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ForceAttemptHTTP2: true, + }, + }, + cookie: normalizeCookieValue(cookie), + baseURL: OpenCodeGoBaseURL, + logger: logger, + } + + for _, opt := range opts { + opt(client) + } + + return client +} + +// SetCookie updates the auth cookie. The value is normalized to extract only +// auth and __Host-auth cookies from a raw cookie header string. +func (c *OpenCodeGoClient) SetCookie(cookie string) { + c.cookieMu.Lock() + defer c.cookieMu.Unlock() + c.cookie = normalizeCookieValue(cookie) +} + +// normalizeCookieValue extracts only auth and __Host-auth cookies from a raw +// cookie header string (matching CodexBar's OpenCodeWebCookieSupport behavior). +func normalizeCookieValue(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + parts := strings.Split(raw, ";") + var authParts []string + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if strings.HasPrefix(trimmed, "auth=") || strings.HasPrefix(trimmed, "__Host-auth=") { + authParts = append(authParts, trimmed) + } + } + if len(authParts) > 0 { + return strings.Join(authParts, "; ") + } + + // If no auth cookies found, return as-is (user may have passed just the value) + return raw +} + +func (c *OpenCodeGoClient) getCookie() string { + c.cookieMu.RLock() + defer c.cookieMu.RUnlock() + return c.cookie +} + +// GetCookie returns the current auth cookie. +func (c *OpenCodeGoClient) GetCookie() string { + return c.getCookie() +} + +// FetchQuotas fetches the latest OpenCode Go usage data. +func (c *OpenCodeGoClient) FetchQuotas(ctx context.Context) (*OpenCodeGoSnapshot, error) { + cookie := c.getCookie() + if cookie == "" { + return nil, fmt.Errorf("%w: no cookie configured", ErrOpenCodeGoUnauthorized) + } + + // Step 1: Resolve workspace ID + workspaceID, err := c.resolveWorkspaceID(ctx) + if err != nil { + return nil, fmt.Errorf("opencodego: resolve workspace: %w", err) + } + + c.logger.Debug("opencodego: resolved workspace", "workspace_id", workspaceID) + + // Step 2: Fetch the usage page + pageURL := fmt.Sprintf("%s/workspace/%s/go", c.baseURL, workspaceID) + body, err := c.doGet(ctx, pageURL) + if err != nil { + return nil, err + } + + // Step 3: Parse the response + _, diag := DebugParse(body) + c.logger.Debug("opencodego: parse diagnostics", "diag", diag) + resp, err := ParseOpenCodeGoUsageResponse(body) + if err != nil { + if errors.Is(err, ErrOpenCodeGoNotSignedIn) { + return nil, fmt.Errorf("%w: %v", ErrOpenCodeGoUnauthorized, err) + } + // Log raw response prefix for debugging + bodyPreview := string(body) + if len(bodyPreview) > 500 { + bodyPreview = bodyPreview[:500] + } + c.logger.Error("opencodego: parse failed, raw response prefix", "body_preview", bodyPreview) + return nil, fmt.Errorf("opencodego: parse usage: %w", err) + } + + now := time.Now().UTC() + snapshot := resp.ToSnapshot(now) + + c.logger.Debug("opencodego: quotas fetched", + "window_count", len(snapshot.Windows), + ) + + return snapshot, nil +} + +// resolveWorkspaceID resolves the workspace ID from the OpenCode server endpoint. +func (c *OpenCodeGoClient) resolveWorkspaceID(ctx context.Context) (string, error) { + if c.workspaceID != "" { + return c.workspaceID, nil + } + + rpcURL := OpenCodeGoServerURL + "?id=" + OpenCodeGoWorkspaceServerID + body, err := c.doServerGet(ctx, rpcURL) + if err != nil { + id := extractWorkspaceID(body) + if id != "" { + c.workspaceID = id + return id, nil + } + // Try fallback: POST with X-Server-Id header + return c.resolveWorkspaceIDFallback(ctx) + } + + id := extractWorkspaceID(body) + if id != "" { + c.workspaceID = id + return id, nil + } + + // Fallback via POST + id, err = c.resolveWorkspaceIDFallback(ctx) + if err == nil { + c.workspaceID = id + } + return id, err +} + +// doServerGet performs a GET request to the _server RPC endpoint with full headers. +func (c *OpenCodeGoClient) doServerGet(ctx context.Context, url string) ([]byte, error) { + reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + c.setCommonHeaders(req) + req.Header.Set("X-Server-Id", OpenCodeGoWorkspaceServerID) + req.Header.Set("X-Server-Instance", "server-fn:"+newUUID()) + req.Header.Set("Origin", OpenCodeGoBaseURL) + req.Header.Set("Referer", OpenCodeGoBaseURL) + req.Header.Set("Accept", "text/javascript, application/json;q=0.9, */*;q=0.8") + + resp, err := c.httpClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("%w: %v", ErrOpenCodeGoNetworkError, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return body, fmt.Errorf("server GET returned HTTP %d: %s", resp.StatusCode, string(body)) + } + + return body, nil +} + +// resolveWorkspaceIDFallback tries the POST fallback for workspace resolution. +func (c *OpenCodeGoClient) resolveWorkspaceIDFallback(ctx context.Context) (string, error) { + reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, OpenCodeGoServerURL, bytes.NewReader([]byte("[]"))) + if err != nil { + return "", fmt.Errorf("create workspace request: %w", err) + } + + c.setCommonHeaders(req) + req.Header.Set("X-Server-Id", OpenCodeGoWorkspaceServerID) + req.Header.Set("X-Server-Instance", "server-fn:"+newUUID()) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", OpenCodeGoBaseURL) + req.Header.Set("Referer", OpenCodeGoBaseURL) + req.Header.Set("Accept", "text/javascript, application/json;q=0.9, */*;q=0.8") + + resp, err := c.httpClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return "", ctx.Err() + } + return "", fmt.Errorf("%w: %v", ErrOpenCodeGoNetworkError, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return "", fmt.Errorf("read workspace response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("workspace resolution returned HTTP %d: %s", resp.StatusCode, string(body)) + } + + id := extractWorkspaceID(body) + if id == "" { + return "", fmt.Errorf("no workspace ID found in response") + } + + return id, nil +} + +// doGet performs a GET request with common headers and cookie auth. +func (c *OpenCodeGoClient) doGet(ctx context.Context, url string) ([]byte, error) { + reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + c.setCommonHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("%w: %v", ErrOpenCodeGoNetworkError, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + switch { + case resp.StatusCode == http.StatusOK: + return body, nil + case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: + return nil, fmt.Errorf("%w: HTTP %d", ErrOpenCodeGoUnauthorized, resp.StatusCode) + case resp.StatusCode >= 500: + return nil, fmt.Errorf("%w: HTTP %d", ErrOpenCodeGoServerError, resp.StatusCode) + default: + return nil, fmt.Errorf("unexpected status %d for %s", resp.StatusCode, url) + } +} + +// setCommonHeaders sets the common request headers including cookie auth. +func (c *OpenCodeGoClient) setCommonHeaders(req *http.Request) { + cookie := c.getCookie() + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + req.Header.Set("User-Agent", OpenCodeGoUserAgent) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,text/javascript,application/json;q=0.9,*/*;q=0.8") +} + +// extractWorkspaceID extracts a workspace ID (wrk_ prefix) from response body. +func extractWorkspaceID(body []byte) string { + matches := wrkPattern.FindAll(body, -1) + for _, match := range matches { + id := string(match) + // Strip surrounding quotes + id = trimQuotes(id) + if len(id) > 4 { + return id + } + } + return "" +} + +func trimQuotes(s string) string { + for len(s) > 0 && (s[0] == '"' || s[0] == '\'') { + s = s[1:] + } + for len(s) > 0 && (s[len(s)-1] == '"' || s[len(s)-1] == '\'') { + s = s[:len(s)-1] + } + return s +} + +func newUUID() string { + // Simple UUID v4 generation - sufficient for request correlation + b := make([]byte, 16) + for i := range b { + b[i] = byte(time.Now().UnixNano()>>((15-i)*4)) % 16 + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) +} diff --git a/internal/api/opencodego_types.go b/internal/api/opencodego_types.go new file mode 100644 index 0000000..3554e13 --- /dev/null +++ b/internal/api/opencodego_types.go @@ -0,0 +1,693 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "regexp" + "strconv" + "strings" + "time" +) + +// OpenCodeGoUsageWindow represents a single usage window (rolling, weekly, monthly). +type OpenCodeGoUsageWindow struct { + Name string // "rolling", "weekly", "monthly" + UsagePercent float64 // 0-100 + ResetInSec int // seconds until reset + Status string // "normal" or "rate-limited" +} + +// OpenCodeGoUsageResponse holds parsed usage data from the dashboard page. +type OpenCodeGoUsageResponse struct { + RollingUsage *OpenCodeGoUsageWindow + WeeklyUsage *OpenCodeGoUsageWindow + MonthlyUsage *OpenCodeGoUsageWindow + UseBalance bool +} + +// OpenCodeGoSnapshot is a point-in-time capture of OpenCode Go usage. +type OpenCodeGoSnapshot struct { + ID int64 + CapturedAt time.Time + RawJSON string + Windows []OpenCodeGoWindowValue +} + +// OpenCodeGoWindowValue holds a single window's value at a snapshot. +type OpenCodeGoWindowValue struct { + WindowName string // "rolling", "weekly", "monthly" + UsagePercent float64 // 0-100 + ResetInSec int // seconds until reset + Status string // "normal" or "rate-limited" +} + +// OpenCodeGoUsagePoint represents a datapoint for time-series queries. +type OpenCodeGoUsagePoint struct { + CapturedAt time.Time + UsagePercent float64 +} + +// OpenCodeGoResetCycle represents a reset cycle record. +type OpenCodeGoResetCycle struct { + ID int64 + WindowName string + CycleStart time.Time + CycleEnd *time.Time + ResetsAt time.Time + PeakUsage float64 + TotalDelta float64 +} + +// OpenCodeGoCycleOverviewRow is a cycle overview with cross-quota data. +type OpenCodeGoCycleOverviewRow struct { + CycleID int64 + WindowName string + CycleStart time.Time + CycleEnd *time.Time + PeakValue float64 + TotalDelta float64 + PeakTime time.Time + CrossQuotas []OpenCodeGoCrossQuota +} + +// OpenCodeGoCrossQuota holds a single window's value at a point in time. +type OpenCodeGoCrossQuota struct { + Name string + Value float64 + Limit float64 + Percent float64 + StartPercent float64 + Delta float64 +} + +// OpenCodeGoSummary contains computed usage statistics for a window. +type OpenCodeGoSummary struct { + WindowName string + UsagePercent float64 + ResetInSec int + TimeUntilReset time.Duration + CurrentRate float64 // percent per hour + ProjectedUsage float64 // projected percent at reset + CompletedCycles int + PeakCycle float64 + TotalTracked float64 + TrackingSince time.Time +} + +var ( + ErrOpenCodeGoNotSignedIn = errors.New("opencodego: not signed in") + ErrOpenCodeGoParseError = errors.New("opencodego: failed to parse usage data") + ErrOpenCodeGoNetworkError = errors.New("opencodego: network error") +) + +// serovalRegex matches Seroval usage window patterns like: +// rollingUsage:$R[28]={status:"ok",resetInSec:17591,usagePercent:0} +var serovalRegex = regexp.MustCompile(`(rollingUsage|weeklyUsage|monthlyUsage)\s*:\s*\$R\s*\[\d+\]\s*=\s*\{([^}]+)\}`) + +// serovalFieldRegex extracts named fields from a Seroval object. +var serovalFieldRegex = regexp.MustCompile(`(\w+)\s*:\s*([\d.]+|"[^"]*")`) + +// simpleJSONUsageRegex is a fallback regex for parsing inline JS objects like: +// rollingUsage={status:"ok",resetInSec:17591,usagePercent:0} +var simpleJSONUsageRegex = regexp.MustCompile(`(rollingUsage|weeklyUsage|monthlyUsage)\s*[:=]\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}`) + +// ParseOpenCodeGoUsageResponse parses the HTML response from the dashboard page. +// It tries multiple parsing strategies: +// 1. Direct JSON parse (look for standard JSON structure) +// 2. Seroval format extraction from script tags +// 3. Fallback regex extraction from raw text +func ParseOpenCodeGoUsageResponse(data []byte) (*OpenCodeGoUsageResponse, error) { + if data == nil || len(data) == 0 { + return nil, fmt.Errorf("%w: empty response", ErrOpenCodeGoParseError) + } + + text := string(data) + + // Check for sign-out indicators + if strings.Contains(text, "login") && + (strings.Contains(text, "sign in") || strings.Contains(text, "auth/authorize") || + strings.Contains(text, "not associated with an account")) { + return nil, ErrOpenCodeGoNotSignedIn + } + + // Strategy 1: Direct JSON parse + resp, err := tryDirectJSONParse(text) + if err == nil && resp != nil && len(resp) > 0 { + return buildFromWindows(resp), nil + } + + // Strategy 2: Seroval format extraction + resp, err = trySerovalParse(text) + if err == nil && resp != nil && len(resp) > 0 { + return buildFromWindows(resp), nil + } + + // Strategy 3: Simple JS object fallback + resp, err = trySimpleJSParse(text) + if err == nil && resp != nil && len(resp) > 0 { + return buildFromWindows(resp), nil + } + + // Strategy 4: Nested JSON scan (depth up to 3) + resp, err = tryNestedJSONScan(text) + if err == nil && resp != nil && len(resp) > 0 { + return buildFromWindows(resp), nil + } + + return nil, fmt.Errorf("%w: no usage data found in response", ErrOpenCodeGoParseError) +} + +// DebugParse attempts to parse and returns diagnostic info about what was found. +func DebugParse(data []byte) (resp *OpenCodeGoUsageResponse, diag string) { + if data == nil || len(data) == 0 { + return nil, "empty response" + } + + text := string(data) + + diag += fmt.Sprintf("len=%d", len(data)) + + // Sign-out check + if strings.Contains(text, "login") && + (strings.Contains(text, "sign in") || strings.Contains(text, "auth/authorize") || + strings.Contains(text, "not associated with an account")) { + return nil, diag + " signout_detected" + } + + // Check for seroval patterns + sm := serovalRegex.FindAllString(text, -1) + diag += fmt.Sprintf(" seroval_matches=%d", len(sm)) + + // Check for simple JS objects + jm := simpleJSONUsageRegex.FindAllString(text, -1) + diag += fmt.Sprintf(" simplejs_matches=%d", len(jm)) + + // Check for JSON + braceCount := strings.Count(text, "{") + diag += fmt.Sprintf(" braces=%d", braceCount) + + // Try actual parse + parseResp, parseErr := ParseOpenCodeGoUsageResponse(data) + resp = parseResp + if parseErr != nil { + diag += " parse_error=" + parseErr.Error() + } else if resp != nil { + c := 0 + if resp.RollingUsage != nil { + c++ + } + if resp.WeeklyUsage != nil { + c++ + } + if resp.MonthlyUsage != nil { + c++ + } + diag += fmt.Sprintf(" windows_found=%d", c) + } + + return resp, diag +} + +// tryDirectJSONParse attempts to parse the response as JSON. +func tryDirectJSONParse(text string) (map[string]*OpenCodeGoUsageWindow, error) { + // Try to find JSON in the text + idx := 0 + for { + start := strings.Index(text[idx:], "{") + if start == -1 { + return nil, fmt.Errorf("no JSON object found") + } + start += idx + + end := findMatchingBrace(text, start) + if end == -1 { + idx = start + 1 + continue + } + + jsonStr := text[start : end+1] + + // Try parsing directly + windows := extractWindowsFromJSON(jsonStr) + if windows != nil && len(windows) > 0 { + return windows, nil + } + + // Try parsing as nested object and look in common keys + var root map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &root); err != nil { + idx = end + 1 + continue + } + + for _, key := range []string{"data", "result", "usage", "billing", "payload", "props", "pageProps"} { + if sub, ok := root[key]; ok { + if subMap, ok := sub.(map[string]interface{}); ok { + windows := extractWindowsFromMap(subMap) + if windows != nil && len(windows) > 0 { + return windows, nil + } + } + } + } + + idx = end + 1 + } +} + +// trySerovalParse extracts usage data from Seroval format. +func trySerovalParse(text string) (map[string]*OpenCodeGoUsageWindow, error) { + matches := serovalRegex.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + // Try the simple js object regex as fallback within the seroval path + matches = simpleJSONUsageRegex.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + return nil, fmt.Errorf("no seroval patterns found") + } + } + + windows := make(map[string]*OpenCodeGoUsageWindow) + for _, match := range matches { + name := classifyWindowName(match[1]) + fields := match[2] + + fieldMatches := serovalFieldRegex.FindAllStringSubmatch(fields, -1) + fieldsMap := make(map[string]string) + for _, fm := range fieldMatches { + val := fm[2] + val = strings.Trim(val, `"`) + fieldsMap[fm[1]] = val + } + + usagePercentStr := fieldsMap["usagePercent"] + usagePercent, _ := strconv.ParseFloat(usagePercentStr, 64) + // Handle 0-1 fractional values (e.g., 0.02 -> 2%) without + // affecting integer values like 1 meaning 1%. + if usagePercent > 0 && usagePercent < 1 && strings.Contains(usagePercentStr, ".") { + usagePercent *= 100 + } + resetInSec, _ := strconv.Atoi(fieldsMap["resetInSec"]) + status := fieldsMap["status"] + if status == "" { + status = "normal" + } + + windows[name] = &OpenCodeGoUsageWindow{ + Name: name, + UsagePercent: math.Round(usagePercent*10) / 10, + ResetInSec: resetInSec, + Status: status, + } + } + + return windows, nil +} + +// trySimpleJSParse extracts usage data from simple JS object notation. +func trySimpleJSParse(text string) (map[string]*OpenCodeGoUsageWindow, error) { + matches := simpleJSONUsageRegex.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + return nil, fmt.Errorf("no simple JS objects found") + } + + windows := make(map[string]*OpenCodeGoUsageWindow) + for _, match := range matches { + name := classifyWindowName(match[1]) + fields := match[2] + + fieldMatches := serovalFieldRegex.FindAllStringSubmatch(fields, -1) + fieldsMap := make(map[string]string) + for _, fm := range fieldMatches { + val := fm[2] + val = strings.Trim(val, `"`) + fieldsMap[fm[1]] = val + } + + usagePercentStr := fieldsMap["usagePercent"] + usagePercent, _ := strconv.ParseFloat(usagePercentStr, 64) + if usagePercent > 0 && usagePercent < 1 && strings.Contains(usagePercentStr, ".") { + usagePercent *= 100 + } + resetInSec, _ := strconv.Atoi(fieldsMap["resetInSec"]) + status := fieldsMap["status"] + if status == "" { + status = "normal" + } + + windows[name] = &OpenCodeGoUsageWindow{ + Name: name, + UsagePercent: math.Round(usagePercent*10) / 10, + ResetInSec: resetInSec, + Status: status, + } + } + + return windows, nil +} + +// tryNestedJSONScan scans nested JSON for usage window data. +func tryNestedJSONScan(text string) (map[string]*OpenCodeGoUsageWindow, error) { + idx := 0 + for { + start := strings.Index(text[idx:], "{") + if start == -1 { + return nil, fmt.Errorf("no JSON objects found") + } + start += idx + + end := findMatchingBrace(text, start) + if end == -1 { + idx = start + 1 + continue + } + + jsonStr := text[start : end+1] + var root map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &root); err != nil { + idx = end + 1 + continue + } + + // Scan all sub-objects at depth <= 3 + windows := scanForWindows(root, 3) + if windows != nil && len(windows) > 0 { + return windows, nil + } + + idx = end + 1 + } +} + +// scanForWindows recursively scans a map for usage window data. +func scanForWindows(m map[string]interface{}, maxDepth int) map[string]*OpenCodeGoUsageWindow { + if maxDepth <= 0 { + return nil + } + + windows := make(map[string]*OpenCodeGoUsageWindow) + + // Check if this map itself is a window + for key, val := range m { + lowerKey := strings.ToLower(key) + if window, ok := tryExtractWindow(val); ok { + switch { + case strings.Contains(lowerKey, "rolling") || strings.Contains(lowerKey, "5h") || strings.Contains(lowerKey, "5-hour"): + window.Name = "rolling" + windows["rolling"] = window + case strings.Contains(lowerKey, "weekly") || strings.Contains(lowerKey, "week"): + window.Name = "weekly" + windows["weekly"] = window + case strings.Contains(lowerKey, "monthly") || strings.Contains(lowerKey, "month"): + window.Name = "monthly" + windows["monthly"] = window + } + } + + if subMap, ok := val.(map[string]interface{}); ok { + subWindows := scanForWindows(subMap, maxDepth-1) + for k, v := range subWindows { + if _, exists := windows[k]; !exists { + windows[k] = v + } + } + } + } + + if len(windows) == 0 { + return nil + } + return windows +} + +// tryExtractWindow attempts to extract a usage window from an interface{}. +func tryExtractWindow(val interface{}) (*OpenCodeGoUsageWindow, bool) { + m, ok := val.(map[string]interface{}) + if !ok { + return nil, false + } + + var usagePercent float64 + var resetInSec int + var status string + hasData := false + + for _, key := range []string{"usagePercent", "usedPercent", "percentUsed", "percent", "usage_percent", "used_percent", "utilization", "utilizationPercent", "utilization_percent", "usage"} { + if v, ok := m[key]; ok { + usagePercent = toFloat64(v) + hasData = true + break + } + } + + // Try computing from used/limit + if !hasData { + used, hasUsed := m["used"].(float64) + limit, hasLimit := m["limit"].(float64) + if hasUsed && hasLimit && limit > 0 { + usagePercent = (used / limit) * 100 + hasData = true + } + } + + for _, key := range []string{"resetInSec", "resetInSeconds", "resetSeconds", "reset_sec", "reset_in_sec", "resetsInSec", "resetsInSeconds", "resetIn", "resetSec"} { + if v, ok := m[key]; ok { + resetInSec = int(toFloat64(v)) + break + } + } + + if s, ok := m["status"].(string); ok { + status = s + } + if status == "" { + status = "normal" + } + + if !hasData { + return nil, false + } + + if usagePercent > 0 && usagePercent <= 1 { + usagePercent *= 100 + } + + return &OpenCodeGoUsageWindow{ + UsagePercent: math.Round(usagePercent*10) / 10, + ResetInSec: resetInSec, + Status: status, + }, true +} + +// toFloat64 converts various numeric types to float64. +func toFloat64(v interface{}) float64 { + switch val := v.(type) { + case float64: + return val + case float32: + return float64(val) + case int: + return float64(val) + case int64: + return float64(val) + case string: + f, _ := strconv.ParseFloat(val, 64) + return f + case json.Number: + f, _ := val.Float64() + return f + default: + return 0 + } +} + +// findMatchingBrace finds the matching closing brace for a JSON object. +func findMatchingBrace(s string, start int) int { + if start >= len(s) || s[start] != '{' { + return -1 + } + depth := 0 + inString := false + escapeNext := false + for i := start; i < len(s); i++ { + if escapeNext { + escapeNext = false + continue + } + if s[i] == '\\' { + escapeNext = true + continue + } + if s[i] == '"' || s[i] == '\'' { + inString = !inString + continue + } + if inString { + continue + } + switch s[i] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +// extractWindowsFromJSON tries to extract usage windows from a JSON string. +func extractWindowsFromJSON(jsonStr string) map[string]*OpenCodeGoUsageWindow { + var data map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + return nil + } + return extractWindowsFromMap(data) +} + +// extractWindowsFromMap extracts usage windows from a parsed JSON map. +func extractWindowsFromMap(data map[string]interface{}) map[string]*OpenCodeGoUsageWindow { + for _, key := range []string{"rollingUsage", "weeklyUsage", "monthlyUsage", "rolling", "weekly", "monthly"} { + val, ok := data[key] + if !ok { + continue + } + if window, ok := tryExtractWindow(val); ok { + window.Name = classifyWindowName(key) + result := make(map[string]*OpenCodeGoUsageWindow) + result[window.Name] = window + // Check for sibling windows + for _, otherKey := range []string{"rollingUsage", "weeklyUsage", "monthlyUsage", "rolling", "weekly", "monthly"} { + if otherKey == key { + continue + } + if otherVal, ok := data[otherKey]; ok { + if otherWindow, ok := tryExtractWindow(otherVal); ok { + otherWindow.Name = classifyWindowName(otherKey) + result[otherWindow.Name] = otherWindow + } + } + } + return result + } + } + + // Check nested structures: usage.rollingUsage, windows.primaryWindow, etc. + for _, containerKey := range []string{"usage", "windows", "data", "result"} { + if container, ok := data[containerKey]; ok { + if containerMap, ok := container.(map[string]interface{}); ok { + mapped := make(map[string]*OpenCodeGoUsageWindow) + for _, windowKey := range []string{"rollingUsage", "weeklyUsage", "monthlyUsage", "rolling", "weekly", "monthly", "primaryWindow", "weeklyQuota", "monthlyBucket"} { + if val, ok := containerMap[windowKey]; ok { + if window, ok := tryExtractWindow(val); ok { + name := classifyWindowName(windowKey) + window.Name = name + mapped[name] = window + } + } + } + if len(mapped) > 0 { + return mapped + } + } + } + } + + return nil +} + +// classifyWindowName maps a raw key to a standard window name. +func classifyWindowName(key string) string { + lower := strings.ToLower(key) + switch { + case strings.Contains(lower, "rolling") || strings.Contains(lower, "primary") || strings.Contains(lower, "5h"): + return "rolling" + case strings.Contains(lower, "weekly") || strings.Contains(lower, "week"): + return "weekly" + case strings.Contains(lower, "monthly") || strings.Contains(lower, "month"): + return "monthly" + default: + return key + } +} + +// buildFromWindows builds an OpenCodeGoUsageResponse from parsed windows. +func buildFromWindows(windows map[string]*OpenCodeGoUsageWindow) *OpenCodeGoUsageResponse { + resp := &OpenCodeGoUsageResponse{} + + if w, ok := windows["rolling"]; ok { + resp.RollingUsage = w + } + if w, ok := windows["weekly"]; ok { + resp.WeeklyUsage = w + } + if w, ok := windows["monthly"]; ok { + resp.MonthlyUsage = w + } + + return resp +} + +// ToSnapshot converts the response to a snapshot for storage. +func (r *OpenCodeGoUsageResponse) ToSnapshot(capturedAt time.Time) *OpenCodeGoSnapshot { + snapshot := &OpenCodeGoSnapshot{ + CapturedAt: capturedAt, + } + + var windows []OpenCodeGoWindowValue + + addWindow := func(w *OpenCodeGoUsageWindow) { + if w == nil { + return + } + windows = append(windows, OpenCodeGoWindowValue{ + WindowName: w.Name, + UsagePercent: w.UsagePercent, + ResetInSec: w.ResetInSec, + Status: w.Status, + }) + } + + addWindow(r.RollingUsage) + addWindow(r.WeeklyUsage) + addWindow(r.MonthlyUsage) + + snapshot.Windows = windows + + if raw, err := json.Marshal(r); err == nil { + snapshot.RawJSON = string(raw) + } + + return snapshot +} + +// HasMonthlyWindow returns true if monthly usage data is available. +func (s *OpenCodeGoSnapshot) HasMonthlyWindow() bool { + for _, w := range s.Windows { + if w.WindowName == "monthly" { + return true + } + } + return false +} + +// GetWindow returns a specific window value by name. +func (s *OpenCodeGoSnapshot) GetWindow(name string) *OpenCodeGoWindowValue { + for i := range s.Windows { + if s.Windows[i].WindowName == name { + return &s.Windows[i] + } + } + return nil +} + +// resetAt computes the time when this window resets (now + resetInSec). +func (w *OpenCodeGoWindowValue) resetAt(now time.Time) time.Time { + if w.ResetInSec <= 0 { + return now.Add(24 * time.Hour) + } + return now.Add(time.Duration(w.ResetInSec) * time.Second) +} diff --git a/internal/api/opencodego_types_test.go b/internal/api/opencodego_types_test.go new file mode 100644 index 0000000..b3a0d92 --- /dev/null +++ b/internal/api/opencodego_types_test.go @@ -0,0 +1,213 @@ +package api + +import ( + "testing" + "time" +) + +func TestParseOpenCodeGoUsageResponse_SerovalFormat(t *testing.T) { + html := `window.__INITIAL_STATE__ = { + rollingUsage:$R[30]={status:"ok",resetInSec:17562,usagePercent:2}, + weeklyUsage:$R[31]={status:"ok",resetInSec:533388,usagePercent:0}, + monthlyUsage:$R[32]={status:"ok",resetInSec:2485309,usagePercent:50} + };` + + resp, err := ParseOpenCodeGoUsageResponse([]byte(html)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.RollingUsage == nil { + t.Fatal("expected rolling") + } + if resp.RollingUsage.UsagePercent != 2 { + t.Errorf("rolling usagePercent = %v, want 2", resp.RollingUsage.UsagePercent) + } + if resp.RollingUsage.ResetInSec != 17562 { + t.Errorf("rolling resetInSec = %v, want 17562", resp.RollingUsage.ResetInSec) + } + if resp.RollingUsage.Status != "ok" { + t.Errorf("rolling status = %v, want ok", resp.RollingUsage.Status) + } + + if resp.WeeklyUsage == nil { + t.Fatal("expected weekly") + } + if resp.WeeklyUsage.UsagePercent != 0 { + t.Errorf("weekly usagePercent = %v, want 0", resp.WeeklyUsage.UsagePercent) + } + + if resp.MonthlyUsage == nil { + t.Fatal("expected monthly") + } + if resp.MonthlyUsage.UsagePercent != 50 { + t.Errorf("monthly usagePercent = %v, want 50", resp.MonthlyUsage.UsagePercent) + } +} + +func TestParseOpenCodeGoUsageResponse_SerovalWithSpaces(t *testing.T) { + html := `window.__INITIAL_STATE__ = { + rollingUsage : $R[30] = { status : "ok" , resetInSec : 17562 , usagePercent : 2 } + };` + + resp, err := ParseOpenCodeGoUsageResponse([]byte(html)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.RollingUsage == nil { + t.Fatal("expected rolling") + } + if resp.RollingUsage.UsagePercent != 2 { + t.Errorf("rolling usagePercent = %v, want 2", resp.RollingUsage.UsagePercent) + } + if resp.RollingUsage.ResetInSec != 17562 { + t.Errorf("rolling resetInSec = %v, want 17562", resp.RollingUsage.ResetInSec) + } + if resp.RollingUsage.Status != "ok" { + t.Errorf("rolling status = %v, want ok", resp.RollingUsage.Status) + } +} + +func TestParseOpenCodeGoUsageResponse_IntegerPercentNotMultiplied(t *testing.T) { + // Value of 1 should mean 1%, not 100% + html := `rollingUsage:$R[30]={status:"ok",resetInSec:17562,usagePercent:1}` + + resp, err := ParseOpenCodeGoUsageResponse([]byte(html)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.RollingUsage == nil { + t.Fatal("expected rolling") + } + if resp.RollingUsage.UsagePercent != 1 { + t.Errorf("rolling usagePercent = %v, want 1", resp.RollingUsage.UsagePercent) + } +} + +func TestParseOpenCodeGoUsageResponse_FractionalPercent(t *testing.T) { + // Value of 0.02 should be converted to 2% + html := `rollingUsage:$R[30]={status:"ok",resetInSec:17562,usagePercent:0.02}` + + resp, err := ParseOpenCodeGoUsageResponse([]byte(html)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.RollingUsage == nil { + t.Fatal("expected rolling") + } + if resp.RollingUsage.UsagePercent != 2 { // 0.02 * 100 = 2 + t.Errorf("rolling usagePercent = %v, want 2", resp.RollingUsage.UsagePercent) + } +} + +func TestParseOpenCodeGoUsageResponse_StringWithBraces(t *testing.T) { + // findMatchingBrace should not be confused by } inside strings + html := `{"description": "Some {text} here", "rollingUsage": {"status": "ok", "resetInSec": 17562, "usagePercent": 2}}` + + resp, err := ParseOpenCodeGoUsageResponse([]byte(html)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.RollingUsage == nil { + t.Fatal("expected rolling") + } + if resp.RollingUsage.UsagePercent != 2 { + t.Errorf("rolling usagePercent = %v, want 2", resp.RollingUsage.UsagePercent) + } +} + +func TestParseOpenCodeGoUsageResponse_NotSignedIn(t *testing.T) { + html := `sign in` + + _, err := ParseOpenCodeGoUsageResponse([]byte(html)) + if err != ErrOpenCodeGoNotSignedIn { + t.Errorf("expected ErrOpenCodeGoNotSignedIn, got %v", err) + } +} + +func TestOpenCodeGoUsageResponse_ToSnapshot(t *testing.T) { + resp := &OpenCodeGoUsageResponse{ + RollingUsage: &OpenCodeGoUsageWindow{ + Name: "rolling", + UsagePercent: 2.5, + ResetInSec: 3600, + Status: "ok", + }, + WeeklyUsage: &OpenCodeGoUsageWindow{ + Name: "weekly", + UsagePercent: 0, + ResetInSec: 86400, + Status: "ok", + }, + } + + now := time.Now().UTC() + snap := resp.ToSnapshot(now) + + if snap.CapturedAt.IsZero() { + t.Error("expected capturedAt to be set") + } + if len(snap.Windows) != 2 { + t.Errorf("expected 2 windows, got %d", len(snap.Windows)) + } + + rolling := snap.GetWindow("rolling") + if rolling == nil { + t.Fatal("expected rolling window") + } + if rolling.UsagePercent != 2.5 { + t.Errorf("rolling usagePercent = %v, want 2.5", rolling.UsagePercent) + } + if rolling.ResetInSec != 3600 { + t.Errorf("rolling resetInSec = %v, want 3600", rolling.ResetInSec) + } + + monthly := snap.GetWindow("monthly") + if monthly != nil { + t.Error("expected no monthly window") + } +} + +func TestOpenCodeGoSnapshot_HasMonthlyWindow(t *testing.T) { + snap := &OpenCodeGoSnapshot{ + Windows: []OpenCodeGoWindowValue{ + {WindowName: "rolling"}, + }, + } + if snap.HasMonthlyWindow() { + t.Error("expected HasMonthlyWindow to be false") + } + + snap.Windows = append(snap.Windows, OpenCodeGoWindowValue{WindowName: "monthly"}) + if !snap.HasMonthlyWindow() { + t.Error("expected HasMonthlyWindow to be true") + } +} + +func TestFindMatchingBrace(t *testing.T) { + tests := []struct { + input string + start int + want int + }{ + {"{}", 0, 1}, + {"{{}}", 0, 3}, + {"{{}}", 1, 2}, + {"{\"a\": \"}\"}", 0, 9}, + {"{\"a\": \"{\"}", 0, 9}, + {"abc{def}ghi", 3, 7}, + {"abc", 0, -1}, + {"", 0, -1}, + } + + for _, tt := range tests { + got := findMatchingBrace(tt.input, tt.start) + if got != tt.want { + t.Errorf("findMatchingBrace(%q, %d) = %d, want %d", tt.input, tt.start, got, tt.want) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 7d8c4b6..5906c62 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,6 +66,10 @@ type Config struct { CursorToken string // CURSOR_TOKEN or auto-detected CursorAutoToken bool // true if token was auto-detected + // OpenCode Go provider configuration (cookie-based auth from opencode.ai) + OpenCodeGoCookie string // OPENCODEGO_COOKIE (auth cookie for opencode.ai) + OpenCodeGoWorkspaceID string // OPENCODEGO_WORKSPACE_ID (optional, skip resolution) + // Custom API Integrations telemetry ingestion APIIntegrationsEnabled bool // ONWATCH_API_INTEGRATIONS_ENABLED (default: true) APIIntegrationsDir string // ONWATCH_API_INTEGRATIONS_DIR (default: ~/.onwatch/api-integrations or /data/api-integrations) @@ -200,6 +204,8 @@ var onwatchEnvKeys = []string{ "MINIMAX_API_KEY", "OPENROUTER_API_KEY", "CURSOR_TOKEN", + "OPENCODEGO_COOKIE", + "OPENCODEGO_WORKSPACE_ID", "GEMINI_ENABLED", "GEMINI_REFRESH_TOKEN", "GEMINI_ACCESS_TOKEN", @@ -327,6 +333,10 @@ func loadFromEnvAndFlags(flags *flagValues) (*Config, error) { // Cursor provider (auto-detected from Cursor Desktop SQLite or keychain) cfg.CursorToken = strings.TrimSpace(os.Getenv("CURSOR_TOKEN")) + // OpenCode Go provider (cookie-based auth) + cfg.OpenCodeGoCookie = strings.TrimSpace(os.Getenv("OPENCODEGO_COOKIE")) + cfg.OpenCodeGoWorkspaceID = strings.TrimSpace(os.Getenv("OPENCODEGO_WORKSPACE_ID")) + // Custom API Integrations telemetry ingestion cfg.APIIntegrationsDir = strings.TrimSpace(os.Getenv("ONWATCH_API_INTEGRATIONS_DIR")) cfg.APIIntegrationsEnabled = true @@ -551,6 +561,9 @@ func (c *Config) AvailableProviders() []string { if c.CursorToken != "" { providers = append(providers, "cursor") } + if c.OpenCodeGoCookie != "" { + providers = append(providers, "opencodego") + } return providers } @@ -577,6 +590,8 @@ func (c *Config) HasProvider(name string) bool { return c.GeminiEnabled case "cursor": return c.CursorToken != "" + case "opencodego": + return c.OpenCodeGoCookie != "" } return false } @@ -614,6 +629,9 @@ func (c *Config) HasMultipleProviders() bool { if c.CursorToken != "" { count++ } + if c.OpenCodeGoCookie != "" { + count++ + } return count > 1 } @@ -664,6 +682,10 @@ func (c *Config) String() string { fmt.Fprintf(&sb, " CursorAutoToken: true,\n") } + // Redact OpenCodeGo cookie + opencodegoDisplay := redactAPIKey(c.OpenCodeGoCookie, "") + fmt.Fprintf(&sb, " OpenCodeGoCookie: %s,\n", opencodegoDisplay) + fmt.Fprintf(&sb, " PollInterval: %v,\n", c.PollInterval) fmt.Fprintf(&sb, " SessionIdleTimeout: %v,\n", c.SessionIdleTimeout) fmt.Fprintf(&sb, " Port: %d,\n", c.Port) diff --git a/internal/store/opencodego_store.go b/internal/store/opencodego_store.go new file mode 100644 index 0000000..ca66da2 --- /dev/null +++ b/internal/store/opencodego_store.go @@ -0,0 +1,321 @@ +package store + +import ( + "database/sql" + "fmt" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +// InsertOpenCodeGoSnapshot inserts a snapshot and its window values in a transaction. +func (s *Store) InsertOpenCodeGoSnapshot(snapshot *api.OpenCodeGoSnapshot) (int64, error) { + tx, err := s.db.Begin() + if err != nil { + return 0, fmt.Errorf("opencodego: begin tx: %w", err) + } + defer tx.Rollback() + + quotaCount := len(snapshot.Windows) + result, err := tx.Exec( + `INSERT INTO opencodego_snapshots (captured_at, raw_json, quota_count) VALUES (?, ?, ?)`, + snapshot.CapturedAt.Format(time.RFC3339Nano), + snapshot.RawJSON, + quotaCount, + ) + if err != nil { + return 0, fmt.Errorf("opencodego: insert snapshot: %w", err) + } + + snapshotID, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("opencodego: get snapshot id: %w", err) + } + snapshot.ID = snapshotID + + for _, w := range snapshot.Windows { + _, err := tx.Exec( + `INSERT INTO opencodego_usage_values (snapshot_id, window_name, usage_percent, reset_in_sec, status) + VALUES (?, ?, ?, ?, ?)`, + snapshotID, + w.WindowName, + w.UsagePercent, + w.ResetInSec, + w.Status, + ) + if err != nil { + return 0, fmt.Errorf("opencodego: insert usage value: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("opencodego: commit tx: %w", err) + } + + return snapshotID, nil +} + +// QueryLatestOpenCodeGo returns the most recent snapshot with all window values. +func (s *Store) QueryLatestOpenCodeGo() (*api.OpenCodeGoSnapshot, error) { + var snapshot api.OpenCodeGoSnapshot + var capturedAt string + + err := s.db.QueryRow( + `SELECT id, captured_at, COALESCE(raw_json, ''), quota_count + FROM opencodego_snapshots ORDER BY captured_at DESC LIMIT 1`, + ).Scan(&snapshot.ID, &capturedAt, &snapshot.RawJSON, new(int)) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("opencodego: query latest snapshot: %w", err) + } + + snapshot.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + + values, err := s.queryOpenCodeGoWindowValues(snapshot.ID) + if err != nil { + return nil, err + } + snapshot.Windows = values + + return &snapshot, nil +} + +// QueryOpenCodeGoRange returns snapshots within a time range with optional limit. +func (s *Store) QueryOpenCodeGoRange(start, end time.Time, limit ...int) ([]*api.OpenCodeGoSnapshot, error) { + maxResults := 200 + if len(limit) > 0 && limit[0] > 0 && limit[0] < maxResults { + maxResults = limit[0] + } + + rows, err := s.db.Query( + `SELECT id, captured_at, COALESCE(raw_json, ''), quota_count + FROM ( + SELECT id, captured_at, raw_json, quota_count + FROM opencodego_snapshots + WHERE captured_at BETWEEN ? AND ? + ORDER BY captured_at DESC + LIMIT ? + ) recent + ORDER BY captured_at ASC`, + start.Format(time.RFC3339Nano), + end.Format(time.RFC3339Nano), + maxResults, + ) + if err != nil { + return nil, fmt.Errorf("opencodego: query range: %w", err) + } + defer rows.Close() + + var snapshots []*api.OpenCodeGoSnapshot + for rows.Next() { + var snapshot api.OpenCodeGoSnapshot + var capturedAt string + if err := rows.Scan(&snapshot.ID, &capturedAt, &snapshot.RawJSON, new(int)); err != nil { + return nil, fmt.Errorf("opencodego: scan snapshot: %w", err) + } + snapshot.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + + values, err := s.queryOpenCodeGoWindowValues(snapshot.ID) + if err != nil { + return nil, err + } + snapshot.Windows = values + + snapshots = append(snapshots, &snapshot) + } + + return snapshots, rows.Err() +} + +func (s *Store) queryOpenCodeGoWindowValues(snapshotID int64) ([]api.OpenCodeGoWindowValue, error) { + rows, err := s.db.Query( + `SELECT window_name, usage_percent, reset_in_sec, COALESCE(status, '') + FROM opencodego_usage_values WHERE snapshot_id = ?`, + snapshotID, + ) + if err != nil { + return nil, fmt.Errorf("opencodego: query window values: %w", err) + } + defer rows.Close() + + var values []api.OpenCodeGoWindowValue + for rows.Next() { + var v api.OpenCodeGoWindowValue + if err := rows.Scan(&v.WindowName, &v.UsagePercent, &v.ResetInSec, &v.Status); err != nil { + return nil, fmt.Errorf("opencodego: scan window value: %w", err) + } + values = append(values, v) + } + + return values, rows.Err() +} + +// CreateOpenCodeGoCycle creates a new reset cycle for a window. +func (s *Store) CreateOpenCodeGoCycle(windowName string, cycleStart time.Time, resetsAt time.Time) (int64, error) { + result, err := s.db.Exec( + `INSERT INTO opencodego_reset_cycles (window_name, cycle_start, resets_at) VALUES (?, ?, ?)`, + windowName, + cycleStart.Format(time.RFC3339Nano), + resetsAt.Format(time.RFC3339Nano), + ) + if err != nil { + return 0, fmt.Errorf("opencodego: create cycle: %w", err) + } + return result.LastInsertId() +} + +// CloseOpenCodeGoCycle closes a reset cycle with final stats. +func (s *Store) CloseOpenCodeGoCycle(windowName string, cycleEnd time.Time, peak, delta float64) error { + _, err := s.db.Exec( + `UPDATE opencodego_reset_cycles SET cycle_end = ?, peak_usage = ?, total_delta = ? + WHERE window_name = ? AND cycle_end IS NULL`, + cycleEnd.Format(time.RFC3339Nano), peak, delta, windowName, + ) + return err +} + +// UpdateOpenCodeGoCycle updates the peak and delta for an active cycle. +func (s *Store) UpdateOpenCodeGoCycle(windowName string, peak, delta float64) error { + _, err := s.db.Exec( + `UPDATE opencodego_reset_cycles SET peak_usage = ?, total_delta = ? + WHERE window_name = ? AND cycle_end IS NULL`, + peak, delta, windowName, + ) + return err +} + +// QueryActiveOpenCodeGoCycle returns the active cycle for a window. +func (s *Store) QueryActiveOpenCodeGoCycle(windowName string) (*OpenCodeGoResetCycle, error) { + var cycle OpenCodeGoResetCycle + var cycleStart, resetsAt string + + err := s.db.QueryRow( + `SELECT id, window_name, cycle_start, cycle_end, resets_at, peak_usage, total_delta + FROM opencodego_reset_cycles WHERE window_name = ? AND cycle_end IS NULL`, + windowName, + ).Scan(&cycle.ID, &cycle.WindowName, &cycleStart, &cycle.CycleEnd, &resetsAt, &cycle.PeakUsage, &cycle.TotalDelta) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("opencodego: query active cycle: %w", err) + } + + cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart) + cycle.ResetsAt, _ = time.Parse(time.RFC3339Nano, resetsAt) + + return &cycle, nil +} + +// QueryOpenCodeGoCycleHistory returns completed cycles for a window. +func (s *Store) QueryOpenCodeGoCycleHistory(windowName string, limit int) ([]*OpenCodeGoResetCycle, error) { + if limit <= 0 { + limit = 50 + } + + rows, err := s.db.Query( + `SELECT id, window_name, cycle_start, cycle_end, resets_at, peak_usage, total_delta + FROM opencodego_reset_cycles WHERE window_name = ? AND cycle_end IS NOT NULL + ORDER BY cycle_start DESC LIMIT ?`, + windowName, limit, + ) + if err != nil { + return nil, fmt.Errorf("opencodego: query cycle history: %w", err) + } + defer rows.Close() + + var cycles []*OpenCodeGoResetCycle + for rows.Next() { + var cycle OpenCodeGoResetCycle + var cycleStart, cycleEnd, resetsAt string + if err := rows.Scan(&cycle.ID, &cycle.WindowName, &cycleStart, &cycleEnd, &resetsAt, &cycle.PeakUsage, &cycle.TotalDelta); err != nil { + return nil, fmt.Errorf("opencodego: scan cycle: %w", err) + } + cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart) + cycle.ResetsAt, _ = time.Parse(time.RFC3339Nano, resetsAt) + endTime, _ := time.Parse(time.RFC3339Nano, cycleEnd) + cycle.CycleEnd = &endTime + cycles = append(cycles, &cycle) + } + + return cycles, rows.Err() +} + +// OpenCodeGoResetCycle is a local type for reset cycle records. +type OpenCodeGoResetCycle struct { + ID int64 + WindowName string + CycleStart time.Time + CycleEnd *time.Time + ResetsAt time.Time + PeakUsage float64 + TotalDelta float64 +} + +// QueryOpenCodeGoUsageSeries returns usage time series for a window. +func (s *Store) QueryOpenCodeGoUsageSeries(windowName string, start, end time.Time) ([]api.OpenCodeGoUsagePoint, error) { + rows, err := s.db.Query( + `SELECT s.captured_at, v.usage_percent + FROM opencodego_snapshots s + JOIN opencodego_usage_values v ON v.snapshot_id = s.id + WHERE v.window_name = ? AND s.captured_at BETWEEN ? AND ? + ORDER BY s.captured_at ASC`, + windowName, + start.Format(time.RFC3339Nano), + end.Format(time.RFC3339Nano), + ) + if err != nil { + return nil, fmt.Errorf("opencodego: query usage series: %w", err) + } + defer rows.Close() + + var points []api.OpenCodeGoUsagePoint + for rows.Next() { + var p api.OpenCodeGoUsagePoint + var capturedAt string + if err := rows.Scan(&capturedAt, &p.UsagePercent); err != nil { + return nil, fmt.Errorf("opencodego: scan usage point: %w", err) + } + p.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + points = append(points, p) + } + + return points, rows.Err() +} + +// QueryOpenCodeGoCycleOverview returns cycle overviews with cross-quota data. +func (s *Store) QueryOpenCodeGoCycleOverview(windowName string, limit int) ([]api.OpenCodeGoCycleOverviewRow, error) { + if limit <= 0 { + limit = 50 + } + + rows, err := s.db.Query( + `SELECT id, window_name, cycle_start, cycle_end, resets_at, peak_usage, total_delta + FROM opencodego_reset_cycles WHERE window_name = ? AND cycle_end IS NOT NULL + ORDER BY cycle_start DESC LIMIT ?`, + windowName, limit, + ) + if err != nil { + return nil, fmt.Errorf("opencodego: query cycle overview: %w", err) + } + defer rows.Close() + + var overviews []api.OpenCodeGoCycleOverviewRow + for rows.Next() { + var row api.OpenCodeGoCycleOverviewRow + var cycleStart, cycleEnd, resetsAt string + if err := rows.Scan(&row.CycleID, &row.WindowName, &cycleStart, &cycleEnd, &resetsAt, &row.PeakValue, &row.TotalDelta); err != nil { + return nil, fmt.Errorf("opencodego: scan overview row: %w", err) + } + row.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart) + endTime, _ := time.Parse(time.RFC3339Nano, cycleEnd) + row.CycleEnd = &endTime + overviews = append(overviews, row) + } + + return overviews, rows.Err() +} diff --git a/internal/store/store.go b/internal/store/store.go index fabe1df..feb1611 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -665,6 +665,40 @@ func (s *Store) createTables() error { CREATE INDEX IF NOT EXISTS idx_cursor_cycles_name_start ON cursor_reset_cycles(quota_name, cycle_start); CREATE INDEX IF NOT EXISTS idx_cursor_cycles_name_active ON cursor_reset_cycles(quota_name, cycle_end) WHERE cycle_end IS NULL; + -- OpenCode Go-specific tables + CREATE TABLE IF NOT EXISTS opencodego_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + captured_at TEXT NOT NULL, + raw_json TEXT NOT NULL DEFAULT '', + quota_count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS opencodego_usage_values ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + window_name TEXT NOT NULL, + usage_percent REAL NOT NULL DEFAULT 0, + reset_in_sec INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'normal', + FOREIGN KEY (snapshot_id) REFERENCES opencodego_snapshots(id) + ); + + CREATE TABLE IF NOT EXISTS opencodego_reset_cycles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + window_name TEXT NOT NULL, + cycle_start TEXT NOT NULL, + cycle_end TEXT, + resets_at TEXT, + peak_usage REAL NOT NULL DEFAULT 0, + total_delta REAL NOT NULL DEFAULT 0 + ); + + -- OpenCode Go indexes + CREATE INDEX IF NOT EXISTS idx_opencodego_snapshots_captured ON opencodego_snapshots(captured_at); + CREATE INDEX IF NOT EXISTS idx_opencodego_usage_values_snapshot ON opencodego_usage_values(snapshot_id); + CREATE INDEX IF NOT EXISTS idx_opencodego_cycles_name_start ON opencodego_reset_cycles(window_name, cycle_start); + CREATE INDEX IF NOT EXISTS idx_opencodego_cycles_name_active ON opencodego_reset_cycles(window_name, cycle_end) WHERE cycle_end IS NULL; + -- API integrations telemetry ingestion tables CREATE TABLE IF NOT EXISTS api_integration_usage_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -976,6 +1010,38 @@ func (s *Store) migrateSchema() error { } } + // Normalize OpenCode Go window names from legacy "rollingUsage" style to short "rolling". + if _, err := s.db.Exec(`UPDATE opencodego_usage_values SET window_name = 'rolling' WHERE window_name = 'rollingUsage'`); err != nil { + if !strings.Contains(err.Error(), "no such table") { + return fmt.Errorf("failed to migrate opencodego_usage_values rollingUsage: %w", err) + } + } + if _, err := s.db.Exec(`UPDATE opencodego_usage_values SET window_name = 'weekly' WHERE window_name = 'weeklyUsage'`); err != nil { + if !strings.Contains(err.Error(), "no such table") { + return fmt.Errorf("failed to migrate opencodego_usage_values weeklyUsage: %w", err) + } + } + if _, err := s.db.Exec(`UPDATE opencodego_usage_values SET window_name = 'monthly' WHERE window_name = 'monthlyUsage'`); err != nil { + if !strings.Contains(err.Error(), "no such table") { + return fmt.Errorf("failed to migrate opencodego_usage_values monthlyUsage: %w", err) + } + } + if _, err := s.db.Exec(`UPDATE opencodego_reset_cycles SET window_name = 'rolling' WHERE window_name = 'rollingUsage'`); err != nil { + if !strings.Contains(err.Error(), "no such table") { + return fmt.Errorf("failed to migrate opencodego_reset_cycles rollingUsage: %w", err) + } + } + if _, err := s.db.Exec(`UPDATE opencodego_reset_cycles SET window_name = 'weekly' WHERE window_name = 'weeklyUsage'`); err != nil { + if !strings.Contains(err.Error(), "no such table") { + return fmt.Errorf("failed to migrate opencodego_reset_cycles weeklyUsage: %w", err) + } + } + if _, err := s.db.Exec(`UPDATE opencodego_reset_cycles SET window_name = 'monthly' WHERE window_name = 'monthlyUsage'`); err != nil { + if !strings.Contains(err.Error(), "no such table") { + return fmt.Errorf("failed to migrate opencodego_reset_cycles monthlyUsage: %w", err) + } + } + return nil } diff --git a/internal/tracker/opencodego_tracker.go b/internal/tracker/opencodego_tracker.go new file mode 100644 index 0000000..bfb63e3 --- /dev/null +++ b/internal/tracker/opencodego_tracker.go @@ -0,0 +1,230 @@ +package tracker + +import ( + "fmt" + "log/slog" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +// OpenCodeGoTracker manages reset cycle detection and usage calculation for OpenCode Go windows. +type OpenCodeGoTracker struct { + store *store.Store + logger *slog.Logger + lastPercents map[string]float64 // window_name -> last usage percent + lastResetSecs map[string]int // window_name -> last reset seconds + hasLastValues bool + + onReset func(windowName string) +} + +// NewOpenCodeGoTracker creates a new OpenCodeGoTracker. +func NewOpenCodeGoTracker(st *store.Store, logger *slog.Logger) *OpenCodeGoTracker { + if logger == nil { + logger = slog.Default() + } + return &OpenCodeGoTracker{ + store: st, + logger: logger, + lastPercents: make(map[string]float64), + lastResetSecs: make(map[string]int), + } +} + +// SetOnReset registers a callback invoked when a window reset is detected. +func (t *OpenCodeGoTracker) SetOnReset(fn func(string)) { + t.onReset = fn +} + +// Process processes a snapshot, detecting resets and updating cycles. +func (t *OpenCodeGoTracker) Process(snapshot *api.OpenCodeGoSnapshot) error { + for _, w := range snapshot.Windows { + if err := t.processWindow(w, snapshot.CapturedAt); err != nil { + return fmt.Errorf("opencodego tracker: %s: %w", w.WindowName, err) + } + } + + t.hasLastValues = true + return nil +} + +func (t *OpenCodeGoTracker) processWindow(w api.OpenCodeGoWindowValue, capturedAt time.Time) error { + windowName := w.WindowName + currentUsage := w.UsagePercent + + // Check if we have an active cycle + cycle, err := t.store.QueryActiveOpenCodeGoCycle(windowName) + if err != nil { + return fmt.Errorf("query active cycle: %w", err) + } + + if cycle == nil { + // Create new cycle + now := time.Now().UTC() + resetTime := now + if w.ResetInSec > 0 { + resetTime = now.Add(time.Duration(w.ResetInSec) * time.Second) + } else { + resetTime = now.Add(24 * time.Hour) + } + + _, err := t.store.CreateOpenCodeGoCycle(windowName, capturedAt, resetTime) + if err != nil { + return fmt.Errorf("create cycle: %w", err) + } + + if err := t.store.UpdateOpenCodeGoCycle(windowName, currentUsage, 0); err != nil { + return fmt.Errorf("set initial peak: %w", err) + } + + t.lastPercents[windowName] = currentUsage + t.lastResetSecs[windowName] = w.ResetInSec + return nil + } + + lastPercent, hasLast := t.lastPercents[windowName] + lastResetSec := t.lastResetSecs[windowName] + + if !hasLast { + // First time seeing this window with an active cycle - skip delta + t.lastPercents[windowName] = currentUsage + t.lastResetSecs[windowName] = w.ResetInSec + return nil + } + + // Detect reset: if rename_sec increased significantly (went up instead of down by more than interval) + // or if usage dropped significantly + now := time.Now().UTC() + resetDetected := false + + if w.ResetInSec > lastResetSec+300 { + // reset_in_sec jumped up by 5+ minutes - reset likely occurred + resetDetected = true + } else if currentUsage < lastPercent-50 && lastPercent > 50 { + // Usage dropped by more than 50% from a high value - reset likely occurred + resetDetected = true + } + + delta := currentUsage - lastPercent + + if resetDetected { + // Close current cycle + if err := t.store.CloseOpenCodeGoCycle(windowName, now, cycle.PeakUsage, cycle.TotalDelta+delta); err != nil { + return fmt.Errorf("close cycle: %w", err) + } + + // Create new cycle + resetTime := now + if w.ResetInSec > 0 { + resetTime = now.Add(time.Duration(w.ResetInSec) * time.Second) + } else { + resetTime = now.Add(24 * time.Hour) + } + + if _, err := t.store.CreateOpenCodeGoCycle(windowName, capturedAt, resetTime); err != nil { + return fmt.Errorf("create new cycle: %w", err) + } + + if err := t.store.UpdateOpenCodeGoCycle(windowName, currentUsage, 0); err != nil { + return fmt.Errorf("set new cycle peak: %w", err) + } + + if t.onReset != nil { + t.onReset(windowName) + } + + t.logger.Info("OpenCode Go reset detected", "window", windowName, + "prev_percent", lastPercent, "cur_percent", currentUsage) + } else { + // Update peak and delta + existingPeak := cycle.PeakUsage + existingDelta := cycle.TotalDelta + + peak := existingPeak + if currentUsage > peak { + peak = currentUsage + } + + if err := t.store.UpdateOpenCodeGoCycle(windowName, peak, existingDelta+delta); err != nil { + return fmt.Errorf("update cycle: %w", err) + } + } + + t.lastPercents[windowName] = currentUsage + t.lastResetSecs[windowName] = w.ResetInSec + + return nil +} + +// UsageSummary returns computed usage statistics for a window. +func (t *OpenCodeGoTracker) UsageSummary(windowName string) (*api.OpenCodeGoSummary, error) { + // Get active cycle + cycle, err := t.store.QueryActiveOpenCodeGoCycle(windowName) + if err != nil { + return nil, fmt.Errorf("query active cycle: %w", err) + } + + // Get latest snapshot for current values + latest, err := t.store.QueryLatestOpenCodeGo() + if err != nil { + return nil, fmt.Errorf("query latest snapshot: %w", err) + } + + summary := &api.OpenCodeGoSummary{ + WindowName: windowName, + } + + if latest != nil { + if w := latest.GetWindow(windowName); w != nil { + summary.UsagePercent = w.UsagePercent + summary.ResetInSec = w.ResetInSec + summary.TimeUntilReset = time.Duration(w.ResetInSec) * time.Second + } + } + + // Get completed cycles + completed, err := t.store.QueryOpenCodeGoCycleHistory(windowName, 0) + if err != nil { + t.logger.Debug("opencodego: failed to query cycle history", "window", windowName, "error", err) + } + + summary.CompletedCycles = len(completed) + if len(completed) > 0 { + var totalDelta float64 + for _, c := range completed { + totalDelta += c.TotalDelta + if c.PeakUsage > summary.PeakCycle { + summary.PeakCycle = c.PeakUsage + } + if summary.TrackingSince.IsZero() || c.CycleStart.Before(summary.TrackingSince) { + summary.TrackingSince = c.CycleStart + } + } + summary.TotalTracked = totalDelta + } + + // Calculate current rate + if cycle != nil { + elapsed := time.Since(cycle.CycleStart).Hours() + if elapsed > 0 && latest != nil { + if w := latest.GetWindow(windowName); w != nil { + // Current rate: accumulated delta / elapsed hours (change over time, not absolute usage) + delta := cycle.TotalDelta + if delta < 0 { + delta = 0 + } + summary.CurrentRate = delta / elapsed + + // Projected usage at reset + if summary.CurrentRate > 0 && w.ResetInSec > 0 { + remainingHours := float64(w.ResetInSec) / 3600.0 + summary.ProjectedUsage = w.UsagePercent + summary.CurrentRate*remainingHours + } + } + } + } + + return summary, nil +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index af38ea7..e13866c 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "github.com/onllm-dev/onwatch/v2/internal/agent" "github.com/onllm-dev/onwatch/v2/internal/api" "github.com/onllm-dev/onwatch/v2/internal/config" "github.com/onllm-dev/onwatch/v2/internal/menubar" @@ -92,6 +93,8 @@ type Handler struct { geminiTracker *tracker.GeminiTracker openrouterTracker *tracker.OpenRouterTracker cursorTracker *tracker.CursorTracker + opencodegoTracker *tracker.OpenCodeGoTracker + opencodegoAgent *agent.OpenCodeGoAgent updater *update.Updater notifier Notifier agentManager ProviderAgentController @@ -817,6 +820,15 @@ func (h *Handler) SetCursorTracker(t *tracker.CursorTracker) { h.cursorTracker = t } +func (h *Handler) SetOpenCodeGoTracker(t *tracker.OpenCodeGoTracker) { + h.opencodegoTracker = t +} + +// SetOpenCodeGoAgent sets the OpenCode Go agent for usage summary enrichment. +func (h *Handler) SetOpenCodeGoAgent(a *agent.OpenCodeGoAgent) { + h.opencodegoAgent = a +} + // SetAgentManager sets provider agent lifecycle controller. func (h *Handler) SetAgentManager(m ProviderAgentController) { h.agentManager = m @@ -1152,6 +1164,7 @@ func providerCatalog() []providerCatalogItem { {Key: "openrouter", Name: "OpenRouter", Description: "OpenRouter credits usage tracking"}, {Key: "gemini", Name: "Gemini", Description: "Google Gemini CLI quota tracking", AutoDetectable: true}, {Key: "cursor", Name: "Cursor", Description: "Cursor usage and quota tracking", AutoDetectable: true}, + {Key: "opencodego", Name: "OpenCode Go", Description: "OpenCode Go subscription usage", AutoDetectable: false}, } } @@ -1211,6 +1224,8 @@ func (h *Handler) isProviderConfigured(provider string) bool { return h.config.GeminiEnabled case "cursor": return strings.TrimSpace(h.config.CursorToken) != "" || strings.TrimSpace(api.DetectCursorToken(h.logger)) != "" + case "opencodego": + return h.config.OpenCodeGoCookie != "" default: return false } @@ -1411,6 +1426,9 @@ var providerEnumFields = map[string]map[string][]string{ "cursor": { "display_mode": {"usage", "available"}, }, + "opencodego": { + "display_mode": {"usage", "available"}, + }, } // sanitizeProviderSettings validates enum fields and resets invalid values @@ -1499,6 +1517,11 @@ func ApplyProviderSettingsFromDB(st *store.Store, cfg *config.Config, logger *sl cfg.AntigravityCSRFToken = token } } + if s := provSettings["opencodego"]; s != nil { + if cookie, _ := s["cookie"].(string); cookie != "" { + cfg.OpenCodeGoCookie = cookie + } + } if logger != nil { logger.Debug("Applied provider_settings from DB") @@ -1830,6 +1853,8 @@ func (h *Handler) Current(w http.ResponseWriter, r *http.Request) { h.currentGemini(w, r) case "cursor": h.currentCursor(w, r) + case "opencodego": + h.currentOpenCodeGo(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -1841,6 +1866,136 @@ func (h *Handler) currentCursor(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } +func (h *Handler) currentOpenCodeGo(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, h.buildOpenCodeGoCurrent()) +} + +func (h *Handler) buildOpenCodeGoCurrent() map[string]interface{} { + now := time.Now().UTC() + response := map[string]interface{}{ + "capturedAt": now.Format(time.RFC3339), + "windows": []interface{}{}, + } + + if h.store == nil { + return response + } + + latest, err := h.store.QueryLatestOpenCodeGo() + if err != nil || latest == nil { + return response + } + + response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) + + type windowEntry struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + UsagePercent float64 `json:"usagePercent"` + ResetInSec int `json:"resetInSec"` + ResetsAt string `json:"resetsAt"` + TimeUntilReset string `json:"timeUntilReset"` + TimeUntilResetSeconds int64 `json:"timeUntilResetSeconds"` + Status string `json:"status"` + AgeSeconds int64 `json:"ageSeconds"` + } + + windows := make([]windowEntry, 0, len(latest.Windows)) + for _, w := range latest.Windows { + displayName := opencodegoDisplayName(w.WindowName) + age := now.Sub(latest.CapturedAt) + resetsAt := latest.CapturedAt.Add(time.Duration(w.ResetInSec) * time.Second) + timeUntilReset := time.Until(resetsAt) + if timeUntilReset < 0 { + timeUntilReset = 0 + } + status := w.Status + if status == "ok" || status == "normal" { + status = "healthy" + } + entry := windowEntry{ + Name: w.WindowName, + DisplayName: displayName, + UsagePercent: w.UsagePercent, + ResetInSec: w.ResetInSec, + ResetsAt: resetsAt.Format(time.RFC3339), + TimeUntilReset: formatDuration(timeUntilReset), + TimeUntilResetSeconds: int64(timeUntilReset.Seconds()), + Status: status, + AgeSeconds: int64(age.Seconds()), + } + windows = append(windows, entry) + } + response["quotas"] = windows + + applyDisplayModeToResponse(response, h.getDisplayMode("opencodego")) + return response +} + +func (h *Handler) historyOpenCodeGo(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + + rangeParam := r.URL.Query().Get("range") + if rangeParam == "" { + rangeParam = "7d" + } + + now := time.Now().UTC() + var start time.Time + switch rangeParam { + case "1h": + start = now.Add(-1 * time.Hour) + case "6h": + start = now.Add(-6 * time.Hour) + case "24h", "1d": + start = now.Add(-24 * time.Hour) + case "3d": + start = now.Add(-3 * 24 * time.Hour) + case "30d": + start = now.Add(-30 * 24 * time.Hour) + case "7d": + start = now.Add(-7 * 24 * time.Hour) + default: + start = now.Add(-7 * 24 * time.Hour) + } + + snapshots, err := h.store.QueryOpenCodeGoRange(start, now, 200) + if err != nil { + h.logger.Error("failed to query OpenCode Go history", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query history") + return + } + + type historyEntry struct { + CapturedAt string `json:"capturedAt"` + Quotas []map[string]interface{} `json:"quotas"` + } + + result := make([]historyEntry, 0, len(snapshots)) + for _, snap := range snapshots { + entry := historyEntry{ + CapturedAt: snap.CapturedAt.Format(time.RFC3339), + } + for _, w := range snap.Windows { + wMap := map[string]interface{}{ + "name": w.WindowName, + "displayName": opencodegoDisplayName(w.WindowName), + "usagePercent": w.UsagePercent, + "utilization": w.UsagePercent, + "resetInSec": w.ResetInSec, + "status": w.Status, + } + entry.Quotas = append(entry.Quotas, wMap) + } + result = append(result, entry) + } + + respondJSON(w, http.StatusOK, result) +} + // currentBoth returns combined quota status for all configured providers. func (h *Handler) currentBoth(w http.ResponseWriter, r *http.Request) { response := map[string]interface{}{} @@ -1908,6 +2063,9 @@ func (h *Handler) currentBoth(w http.ResponseWriter, r *http.Request) { if h.config.HasProvider("cursor") && providerTelemetryEnabled(visibility, "cursor") { response["cursor"] = h.buildCursorCurrent() } + if h.config.HasProvider("opencodego") && providerTelemetryEnabled(visibility, "opencodego") { + response["opencodego"] = h.buildOpenCodeGoCurrent() + } respondJSON(w, http.StatusOK, response) } @@ -2250,6 +2408,8 @@ func (h *Handler) History(w http.ResponseWriter, r *http.Request) { h.historyGemini(w, r) case "cursor": h.historyCursor(w, r) + case "opencodego": + h.historyOpenCodeGo(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -2567,6 +2727,28 @@ func (h *Handler) historyBoth(w http.ResponseWriter, r *http.Request) { } } + if h.config.HasProvider("opencodego") && providerTelemetryEnabled(visibility, "opencodego") && h.store != nil { + snapshots, err := h.store.QueryOpenCodeGoRange(start, now) + if err == nil { + step := downsampleStep(len(snapshots), maxChartPoints) + last := len(snapshots) - 1 + ocgData := make([]map[string]interface{}, 0, min(len(snapshots), maxChartPoints)) + for i, snap := range snapshots { + if step > 1 && i != 0 && i != last && i%step != 0 { + continue + } + entry := map[string]interface{}{ + "capturedAt": snap.CapturedAt.Format(time.RFC3339), + } + for _, w := range snap.Windows { + entry[w.WindowName] = w.UsagePercent + } + ocgData = append(ocgData, entry) + } + response["opencodego"] = ocgData + } + } + respondJSON(w, http.StatusOK, response) } @@ -3165,6 +3347,8 @@ func (h *Handler) Cycles(w http.ResponseWriter, r *http.Request) { h.cyclesGemini(w, r) case "cursor": h.cyclesCursor(w, r) + case "opencodego": + h.cyclesOpenCodeGo(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -3310,6 +3494,36 @@ func (h *Handler) cyclesBoth(w http.ResponseWriter, r *http.Request) { response["gemini"] = []interface{}{} } + if h.config.HasProvider("opencodego") { + windowName := r.URL.Query().Get("type") + if windowName == "" { + windowName = "rolling" + } + switch windowName { + case "rollingUsage": + windowName = "rolling" + case "weeklyUsage": + windowName = "weekly" + case "monthlyUsage": + windowName = "monthly" + } + var ocgCycles []map[string]interface{} + if active, err := h.store.QueryActiveOpenCodeGoCycle(windowName); err == nil && active != nil { + ocgCycles = append(ocgCycles, opencodegoCycleToMap(active)) + } + if history, err := h.store.QueryOpenCodeGoCycleHistory(windowName, 50); err == nil { + for _, c := range history { + ocgCycles = append(ocgCycles, opencodegoCycleToMap(c)) + } + } + response["opencodego"] = map[string]interface{}{ + "groupBy": windowName, + "provider": "opencodego", + "quotaNames": []string{"rolling", "weekly", "monthly"}, + "cycles": ocgCycles, + } + } + respondJSON(w, http.StatusOK, response) } @@ -3484,6 +3698,8 @@ func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) { h.summaryGemini(w, r) case "cursor": h.summaryCursor(w, r) + case "opencodego": + h.summaryOpenCodeGo(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -3554,6 +3770,9 @@ func (h *Handler) summaryBoth(w http.ResponseWriter, r *http.Request) { if h.config.HasProvider("cursor") && h.cursorTracker != nil { response["cursor"] = h.buildCursorSummaryMap() } + if h.config.HasProvider("opencodego") && h.opencodegoTracker != nil { + response["opencodego"] = h.buildOpenCodeGoSummaryMap() + } respondJSON(w, http.StatusOK, response) } @@ -4280,6 +4499,8 @@ func (h *Handler) Insights(w http.ResponseWriter, r *http.Request) { h.insightsGemini(w, r, rangeDur) case "cursor": h.insightsCursor(w, r, rangeDur) + case "opencodego": + h.insightsOpenCodeGo(w, r, rangeDur) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -4361,6 +4582,9 @@ func (h *Handler) insightsBoth(w http.ResponseWriter, r *http.Request, rangeDur if h.config.HasProvider("cursor") && providerTelemetryEnabled(visibility, "cursor") { response["cursor"] = h.buildCursorInsights(hidden, rangeDur) } + if h.config.HasProvider("opencodego") && providerTelemetryEnabled(visibility, "opencodego") { + response["opencodego"] = h.buildOpenCodeGoInsights(hidden, rangeDur) + } respondJSON(w, http.StatusOK, response) } @@ -6697,6 +6921,8 @@ func (h *Handler) CycleOverview(w http.ResponseWriter, r *http.Request) { h.cycleOverviewGemini(w, r) case "cursor": h.cycleOverviewCursor(w, r) + case "opencodego": + h.cycleOverviewOpenCodeGo(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -6970,6 +7196,29 @@ func (h *Handler) cycleOverviewBoth(w http.ResponseWriter, r *http.Request) { } } + if h.config.HasProvider("opencodego") { + groupBy := r.URL.Query().Get("opencodegoGroupBy") + if groupBy == "" { + groupBy = "rolling" + } + switch groupBy { + case "rollingUsage": + groupBy = "rolling" + case "weeklyUsage": + groupBy = "weekly" + case "monthlyUsage": + groupBy = "monthly" + } + if rows, err := h.store.QueryOpenCodeGoCycleOverview(groupBy, limit); err == nil { + response["opencodego"] = map[string]interface{}{ + "groupBy": groupBy, + "provider": "opencodego", + "quotaNames": []string{"rolling", "weekly", "monthly"}, + "cycles": opencodegoCycleOverviewRowsToJSON(rows), + } + } + } + respondJSON(w, http.StatusOK, response) } @@ -10191,6 +10440,8 @@ func (h *Handler) LoggingHistory(w http.ResponseWriter, r *http.Request) { h.loggingHistoryGemini(w, r) case "cursor": h.loggingHistoryCursor(w, r) + case "opencodego": + h.loggingHistoryOpenCodeGo(w, r) default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider: %s", provider)) } @@ -10947,3 +11198,312 @@ func (h *Handler) SimulateAlert(w http.ResponseWriter, r *http.Request) { "provider": req.Provider, }) } + +// ── OpenCode Go Handlers ── + +var opencodegoDisplayNames = map[string]string{ + "rolling": "Rolling", + "weekly": "Weekly", + "monthly": "Monthly", +} + +func opencodegoDisplayName(name string) string { + if dn, ok := opencodegoDisplayNames[name]; ok { + return dn + } + return name +} + +var opencodegoWindowOrder = map[string]int{ + "rolling": 1, + "weekly": 2, + "monthly": 3, +} + +func opencodegoWindowSortOrder(name string) int { + if order, ok := opencodegoWindowOrder[name]; ok { + return order + } + return 99 +} + +func opencodegoCycleToMap(cycle *store.OpenCodeGoResetCycle) map[string]interface{} { + m := map[string]interface{}{ + "id": cycle.ID, + "windowName": cycle.WindowName, + "quotaName": cycle.WindowName, + "cycleStart": cycle.CycleStart.Format(time.RFC3339), + "peakUsage": cycle.PeakUsage, + "peakUtilization": cycle.PeakUsage, + "totalDelta": cycle.TotalDelta, + "isActive": true, + } + if cycle.ResetsAt.After(cycle.CycleStart) { + m["resetsAt"] = cycle.ResetsAt.Format(time.RFC3339) + m["timeUntilReset"] = formatDuration(time.Until(cycle.ResetsAt)) + } + m["cycleEnd"] = nil + return m +} + +func opencodegoCycleOverviewRowsToJSON(rows []api.OpenCodeGoCycleOverviewRow) []map[string]interface{} { + result := make([]map[string]interface{}, 0, len(rows)) + for _, row := range rows { + entry := map[string]interface{}{ + "cycleId": row.CycleID, + "quotaType": row.WindowName, + "cycleStart": row.CycleStart.Format(time.RFC3339), + "peakValue": row.PeakValue, + "totalDelta": row.TotalDelta, + "peakTime": row.PeakTime.Format(time.RFC3339), + } + if row.CycleEnd != nil { + entry["cycleEnd"] = row.CycleEnd.Format(time.RFC3339) + } else { + entry["cycleEnd"] = nil + } + crossQuotas := make([]map[string]interface{}, 0, len(row.CrossQuotas)) + for _, cq := range row.CrossQuotas { + crossQuotas = append(crossQuotas, map[string]interface{}{ + "name": cq.Name, + "value": cq.Value, + "limit": cq.Limit, + "percent": cq.Percent, + "startPercent": cq.StartPercent, + "delta": cq.Delta, + }) + } + entry["crossQuotas"] = crossQuotas + result = append(result, entry) + } + return result +} + +func (h *Handler) summaryOpenCodeGo(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, h.buildOpenCodeGoSummaryMap()) +} + +func (h *Handler) buildOpenCodeGoSummaryMap() map[string]interface{} { + if h.store == nil || h.opencodegoTracker == nil { + return map[string]interface{}{} + } + + result := make(map[string]interface{}) + for _, windowName := range []string{"rolling", "weekly", "monthly"} { + summary, err := h.opencodegoTracker.UsageSummary(windowName) + if err != nil || summary == nil { + continue + } + entry := map[string]interface{}{ + "displayName": opencodegoDisplayName(windowName), + "usagePercent": summary.UsagePercent, + "resetInSec": summary.ResetInSec, + "currentRate": summary.CurrentRate, + "projectedUsage": summary.ProjectedUsage, + "completedCycles": summary.CompletedCycles, + "peakCycle": summary.PeakCycle, + "totalTracked": summary.TotalTracked, + } + if summary.TimeUntilReset > 0 { + entry["timeUntilReset"] = formatDuration(summary.TimeUntilReset) + } + result[windowName] = entry + } + return result +} + +func (h *Handler) cyclesOpenCodeGo(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + + windowName := r.URL.Query().Get("type") + if windowName == "" { + windowName = "rolling" + } + // Accept legacy window names from older clients. + switch windowName { + case "rollingUsage": + windowName = "rolling" + case "weeklyUsage": + windowName = "weekly" + case "monthlyUsage": + windowName = "monthly" + } + + active, err := h.store.QueryActiveOpenCodeGoCycle(windowName) + if err != nil { + h.logger.Error("failed to query active OpenCode Go cycle", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query cycles") + return + } + + history, err := h.store.QueryOpenCodeGoCycleHistory(windowName, 50) + if err != nil { + h.logger.Error("failed to query OpenCode Go cycle history", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query cycles") + return + } + + var cycles []map[string]interface{} + if active != nil { + cycles = append(cycles, opencodegoCycleToMap(active)) + } + for _, c := range history { + m := opencodegoCycleToMap(c) + m["isActive"] = false + if c.CycleEnd != nil { + m["cycleEnd"] = c.CycleEnd.Format(time.RFC3339) + } + cycles = append(cycles, m) + } + + respondJSON(w, http.StatusOK, cycles) +} + +func (h *Handler) cycleOverviewOpenCodeGo(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + + groupBy := r.URL.Query().Get("group_by") + if groupBy == "" { + groupBy = "rolling" + } + switch groupBy { + case "rollingUsage": + groupBy = "rolling" + case "weeklyUsage": + groupBy = "weekly" + case "monthlyUsage": + groupBy = "monthly" + } + + overview, err := h.store.QueryOpenCodeGoCycleOverview(groupBy, 50) + if err != nil { + h.logger.Error("failed to query OpenCode Go cycle overview", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query cycle overview") + return + } + + respondJSON(w, http.StatusOK, overview) +} + +func (h *Handler) insightsOpenCodeGo(w http.ResponseWriter, r *http.Request, rangeDur time.Duration) { + hidden := h.getHiddenInsightKeys() + respondJSON(w, http.StatusOK, h.buildOpenCodeGoInsights(hidden, rangeDur)) +} + +func (h *Handler) buildOpenCodeGoInsights(hidden map[string]bool, _ time.Duration) insightsResponse { + resp := insightsResponse{Stats: []insightStat{}, Insights: []insightItem{}} + + if h.store == nil { + return resp + } + + latest, err := h.store.QueryLatestOpenCodeGo() + if err != nil || latest == nil || len(latest.Windows) == 0 { + return resp + } + + windows := make([]api.OpenCodeGoWindowValue, len(latest.Windows)) + copy(windows, latest.Windows) + sort.SliceStable(windows, func(i, j int) bool { + left := opencodegoWindowSortOrder(windows[i].WindowName) + right := opencodegoWindowSortOrder(windows[j].WindowName) + if left != right { + return left < right + } + return windows[i].WindowName < windows[j].WindowName + }) + + for _, w := range windows { + label := fmt.Sprintf("%s Usage", opencodegoDisplayName(w.WindowName)) + value := fmt.Sprintf("%.1f%%", w.UsagePercent) + sublabel := "resets in " + formatDuration(time.Duration(w.ResetInSec)*time.Second) + resp.Stats = append(resp.Stats, insightStat{ + Label: label, + Value: value, + Sublabel: sublabel, + }) + + insightKey := fmt.Sprintf("forecast_%s", w.WindowName) + if hidden[insightKey] { + continue + } + severity := utilStatus(w.UsagePercent) + var metric string + var desc string + if h.opencodegoTracker != nil { + summary, err := h.opencodegoTracker.UsageSummary(w.WindowName) + if err == nil && summary != nil { + if summary.CurrentRate > 0 { + metric = fmt.Sprintf("%.1f%%/hr", summary.CurrentRate) + } + if summary.ProjectedUsage > 0 { + desc = fmt.Sprintf("At %.1f%%, projected %.1f%% by reset", w.UsagePercent, summary.ProjectedUsage) + } + } + } + if desc == "" { + desc = fmt.Sprintf("Currently at %.1f%% usage", w.UsagePercent) + } + resp.Insights = append(resp.Insights, insightItem{ + Key: insightKey, + Type: "forecast", + Severity: severity, + Title: label, + Metric: metric, + Sublabel: sublabel, + Desc: desc, + }) + } + + return resp +} + +func (h *Handler) loggingHistoryOpenCodeGo(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, map[string]interface{}{"logs": []interface{}{}}) + return + } + + start, end, limit := h.loggingHistoryRangeAndLimit(r) + snapshots, err := h.store.QueryOpenCodeGoRange(start, end, limit) + if err != nil { + h.logger.Error("failed to query OpenCode Go snapshots", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query logging history") + return + } + + quotaNames := []string{"rolling", "weekly", "monthly"} + + capturedAt := make([]time.Time, 0, len(snapshots)) + ids := make([]int64, 0, len(snapshots)) + series := make([]map[string]loggingHistoryCrossQuota, 0, len(snapshots)) + + for _, snap := range snapshots { + capturedAt = append(capturedAt, snap.CapturedAt) + ids = append(ids, snap.ID) + row := make(map[string]loggingHistoryCrossQuota, len(snap.Windows)) + for _, w := range snap.Windows { + row[w.WindowName] = loggingHistoryCrossQuota{ + Name: w.WindowName, + Value: w.UsagePercent, + Limit: 100, + Percent: w.UsagePercent, + HasValue: true, + HasLimit: true, + } + } + series = append(series, row) + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "provider": "opencodego", + "quotaNames": quotaNames, + "logs": loggingHistoryRowsFromSnapshots(capturedAt, ids, quotaNames, series), + }) +} diff --git a/main.go b/main.go index c449874..2d070ce 100644 --- a/main.go +++ b/main.go @@ -828,6 +828,15 @@ func run() error { logger.Info("Cursor API client configured") } + var opencodegoClient *api.OpenCodeGoClient + if cfg.HasProvider("opencodego") { + opencodegoClient = api.NewOpenCodeGoClient(cfg.OpenCodeGoCookie, logger) + if cfg.OpenCodeGoWorkspaceID != "" { + opencodegoClient.SetWorkspaceID(cfg.OpenCodeGoWorkspaceID) + } + logger.Info("OpenCode Go API client configured") + } + // Create components tr := tracker.New(db, logger) @@ -994,6 +1003,11 @@ func run() error { cursorTr = tracker.NewCursorTracker(db, logger) } + var opencodegoTr *tracker.OpenCodeGoTracker + if cfg.HasProvider("opencodego") { + opencodegoTr = tracker.NewOpenCodeGoTracker(db, logger) + } + var antigravityAg *agent.AntigravityAgent if antigravityClient != nil { antigravitySm := agent.NewSessionManager(db, "antigravity", idleTimeout, logger) @@ -1052,6 +1066,12 @@ func run() error { }) } + var openCodeGoAg *agent.OpenCodeGoAgent + if opencodegoClient != nil { + openCodeGoSm := agent.NewSessionManager(db, "opencodego", idleTimeout, logger) + openCodeGoAg = agent.NewOpenCodeGoAgent(opencodegoClient, db, opencodegoTr, cfg.PollInterval, logger, openCodeGoSm) + } + var apiIntegrationsAg *agent.APIIntegrationsIngestAgent if cfg.APIIntegrationsEnabled { apiIntegrationsAg = agent.NewAPIIntegrationsIngestAgent(db, cfg.APIIntegrationsDir, cfg.APIIntegrationsRetention, logger) @@ -1096,6 +1116,9 @@ func run() error { if cursorAg != nil { cursorAg.SetNotifier(notifier) } + if openCodeGoAg != nil { + openCodeGoAg.SetNotifier(notifier) + } // Wire polling checks - agents skip poll when telemetry disabled isPollingEnabled := func(providerKey string) bool { @@ -1200,6 +1223,9 @@ func run() error { if cursorAg != nil { cursorAg.SetPollingCheck(func() bool { return isPollingEnabled("cursor") }) } + if openCodeGoAg != nil { + openCodeGoAg.SetPollingCheck(func() bool { return isPollingEnabled("opencodego") }) + } // Wire reset callbacks to trackers tr.SetOnReset(func(quotaName string) { @@ -1250,6 +1276,11 @@ func run() error { notifier.Check(notify.QuotaStatus{Provider: "cursor", QuotaKey: quotaName, ResetOccurred: true}) }) } + if opencodegoTr != nil { + opencodegoTr.SetOnReset(func(windowName string) { + notifier.Check(notify.QuotaStatus{Provider: "opencodego", QuotaKey: windowName, ResetOccurred: true}) + }) + } handler := web.NewHandler(db, tr, logger, nil, cfg, zaiTr) handler.SetVersion(version) @@ -1278,6 +1309,12 @@ func run() error { if cursorTr != nil { handler.SetCursorTracker(cursorTr) } + if opencodegoTr != nil { + handler.SetOpenCodeGoTracker(opencodegoTr) + } + if openCodeGoAg != nil { + handler.SetOpenCodeGoAgent(openCodeGoAg) + } agentMgr := agent.NewAgentManager(logger) if ag != nil { agentMgr.RegisterFactory("synthetic", func() (agent.AgentRunner, error) { return ag, nil }) @@ -1309,6 +1346,9 @@ func run() error { if cursorAg != nil { agentMgr.RegisterFactory("cursor", func() (agent.AgentRunner, error) { return cursorAg, nil }) } + if openCodeGoAg != nil { + agentMgr.RegisterFactory("opencodego", func() (agent.AgentRunner, error) { return openCodeGoAg, nil }) + } if apiIntegrationsAg != nil { agentMgr.RegisterFactory("api_integrations", func() (agent.AgentRunner, error) { return apiIntegrationsAg, nil }) @@ -1335,7 +1375,7 @@ func run() error { // Start configured agents through the manager. startedAny := false - for _, providerKey := range []string{"synthetic", "zai", "anthropic", "copilot", "codex", "antigravity", "minimax", "openrouter", "gemini", "cursor"} { + for _, providerKey := range []string{"synthetic", "zai", "anthropic", "copilot", "codex", "antigravity", "minimax", "openrouter", "gemini", "cursor", "opencodego"} { if !isPollingEnabled(providerKey) { continue } From 2477a35f1de5d2cd9d7eb2061ebba24eb31f253c Mon Sep 17 00:00:00 2001 From: Aaron Florey Date: Tue, 12 May 2026 22:14:50 +0000 Subject: [PATCH 3/3] fix(opencode): add more tests for codecoverage --- internal/agent/opencodego_agent_test.go | 83 +++++++++++++ internal/api/opencodego_client_test.go | 131 ++++++++++++++++++++ internal/store/opencodego_store_test.go | 116 +++++++++++++++++ internal/tracker/opencodego_tracker_test.go | 82 ++++++++++++ 4 files changed, 412 insertions(+) create mode 100644 internal/agent/opencodego_agent_test.go create mode 100644 internal/api/opencodego_client_test.go create mode 100644 internal/store/opencodego_store_test.go create mode 100644 internal/tracker/opencodego_tracker_test.go diff --git a/internal/agent/opencodego_agent_test.go b/internal/agent/opencodego_agent_test.go new file mode 100644 index 0000000..3087ffc --- /dev/null +++ b/internal/agent/opencodego_agent_test.go @@ -0,0 +1,83 @@ +package agent + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +func TestOpenCodeGoAgent_PausesAfterRepeatedAuthFailures(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + st, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer st.Close() + + logger := slog.New(slog.DiscardHandler) + client := api.NewOpenCodeGoClient("auth=bad", logger, api.WithOpenCodeGoBaseURL(server.URL), api.WithOpenCodeGoWorkspaceID("wrk_test")) + + tr := tracker.NewOpenCodeGoTracker(st, logger) + ag := NewOpenCodeGoAgent(client, st, tr, time.Hour, logger, nil) + + ctx := context.Background() + for i := 0; i < maxOpenCodeGoAuthFailures; i++ { + ag.poll(ctx) + } + + if !ag.authPaused { + t.Fatal("expected authPaused=true") + } + if ag.authFailCount < maxOpenCodeGoAuthFailures { + t.Fatalf("authFailCount=%d", ag.authFailCount) + } +} + +func TestOpenCodeGoAgent_PollSuccessInsertsSnapshot(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/workspace/wrk_test/go" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `window.__INITIAL_STATE__ = {rollingUsage:$R[30]={status:"ok",resetInSec:300,usagePercent:35}};`) + })) + defer server.Close() + + st, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + defer st.Close() + + logger := slog.New(slog.DiscardHandler) + client := api.NewOpenCodeGoClient("auth=good", logger, api.WithOpenCodeGoBaseURL(server.URL), api.WithOpenCodeGoWorkspaceID("wrk_test")) + tr := tracker.NewOpenCodeGoTracker(st, logger) + ag := NewOpenCodeGoAgent(client, st, tr, time.Hour, logger, nil) + + ag.poll(context.Background()) + + latest, err := st.QueryLatestOpenCodeGo() + if err != nil { + t.Fatalf("QueryLatestOpenCodeGo: %v", err) + } + if latest == nil || len(latest.Windows) != 1 { + t.Fatal("expected one inserted OpenCode Go snapshot") + } +} diff --git a/internal/api/opencodego_client_test.go b/internal/api/opencodego_client_test.go new file mode 100644 index 0000000..ca3b04a --- /dev/null +++ b/internal/api/opencodego_client_test.go @@ -0,0 +1,131 @@ +package api + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func testOpenCodeGoLogger() *slog.Logger { + return slog.New(slog.DiscardHandler) +} + +func TestOpenCodeGoClient_NormalizeCookieValue(t *testing.T) { + t.Parallel() + + got := normalizeCookieValue("foo=1; auth=abc; bar=2; __Host-auth=xyz") + if got != "auth=abc; __Host-auth=xyz" { + t.Fatalf("normalizeCookieValue = %q", got) + } + + passthrough := normalizeCookieValue("raw-cookie-value") + if passthrough != "raw-cookie-value" { + t.Fatalf("passthrough normalizeCookieValue = %q", passthrough) + } +} + +func TestOpenCodeGoClient_DoGet_StatusHandling(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int + want error + }{ + {name: "unauthorized", status: http.StatusUnauthorized, want: ErrOpenCodeGoUnauthorized}, + {name: "forbidden", status: http.StatusForbidden, want: ErrOpenCodeGoUnauthorized}, + {name: "server_error", status: http.StatusInternalServerError, want: ErrOpenCodeGoServerError}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.status) + _, _ = w.Write([]byte("nope")) + })) + defer server.Close() + + client := NewOpenCodeGoClient("auth=ok", testOpenCodeGoLogger(), WithOpenCodeGoBaseURL(server.URL)) + _, err := client.doGet(context.Background(), server.URL) + if !errors.Is(err, tt.want) { + t.Fatalf("doGet error = %v, want %v", err, tt.want) + } + }) + } +} + +func TestOpenCodeGoClient_ResolveWorkspaceID_UsesFallback(t *testing.T) { + t.Parallel() + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader("no workspace")), Header: make(http.Header)}, nil + } + if req.Method == http.MethodPost { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"workspace":"wrk_fallback123"}`)), Header: make(http.Header)}, nil + } + return nil, fmt.Errorf("unexpected method %s", req.Method) + }) + + client := NewOpenCodeGoClient("auth=ok", testOpenCodeGoLogger()) + client.httpClient = &http.Client{Transport: transport} + + id, err := client.resolveWorkspaceID(context.Background()) + if err != nil { + t.Fatalf("resolveWorkspaceID: %v", err) + } + if id != "wrk_fallback123" { + t.Fatalf("workspace id = %q", id) + } +} + +func TestOpenCodeGoClient_FetchQuotas_Success(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/workspace/wrk_test/go") { + w.WriteHeader(http.StatusNotFound) + return + } + if cookie := r.Header.Get("Cookie"); cookie != "auth=abc" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `window.__INITIAL_STATE__ = {rollingUsage:$R[30]={status:"ok",resetInSec:3600,usagePercent:25}};`) + })) + defer server.Close() + + client := NewOpenCodeGoClient("foo=1; auth=abc", testOpenCodeGoLogger(), WithOpenCodeGoBaseURL(server.URL), WithOpenCodeGoWorkspaceID("wrk_test"), WithOpenCodeGoTimeout(2*time.Second)) + client.httpClient = server.Client() + + snap, err := client.FetchQuotas(context.Background()) + if err != nil { + t.Fatalf("FetchQuotas: %v", err) + } + if snap == nil || len(snap.Windows) != 1 { + t.Fatalf("snapshot windows = %d", len(snap.Windows)) + } + if got := snap.Windows[0].UsagePercent; got != 25 { + t.Fatalf("usage percent = %.1f", got) + } +} + +func TestExtractWorkspaceIDAndTrimQuotes(t *testing.T) { + t.Parallel() + + if got := trimQuotes(`"'wrk_abc'"`); got != "wrk_abc" { + t.Fatalf("trimQuotes = %q", got) + } + if got := extractWorkspaceID([]byte(`prefix "wrk_one" middle "wrk_two"`)); got != "wrk_one" { + t.Fatalf("extractWorkspaceID = %q", got) + } +} diff --git a/internal/store/opencodego_store_test.go b/internal/store/opencodego_store_test.go new file mode 100644 index 0000000..31cfa1a --- /dev/null +++ b/internal/store/opencodego_store_test.go @@ -0,0 +1,116 @@ +package store + +import ( + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +func testOpenCodeGoSnapshot(ts time.Time, rolling float64) *api.OpenCodeGoSnapshot { + return &api.OpenCodeGoSnapshot{ + CapturedAt: ts, + RawJSON: `{"ok":true}`, + Windows: []api.OpenCodeGoWindowValue{ + {WindowName: "rolling", UsagePercent: rolling, ResetInSec: 3600, Status: "ok"}, + {WindowName: "weekly", UsagePercent: 10, ResetInSec: 7200, Status: "ok"}, + }, + } +} + +func TestOpenCodeGoStore_InsertAndQueryLatest(t *testing.T) { + t.Parallel() + s, err := New(":memory:") + if err != nil { + t.Fatalf("New: %v", err) + } + defer s.Close() + + now := time.Now().UTC().Truncate(time.Second) + if _, err := s.InsertOpenCodeGoSnapshot(testOpenCodeGoSnapshot(now, 12.5)); err != nil { + t.Fatalf("InsertOpenCodeGoSnapshot: %v", err) + } + + latest, err := s.QueryLatestOpenCodeGo() + if err != nil { + t.Fatalf("QueryLatestOpenCodeGo: %v", err) + } + if latest == nil { + t.Fatal("expected latest snapshot") + } + if len(latest.Windows) != 2 { + t.Fatalf("len(latest.Windows) = %d", len(latest.Windows)) + } +} + +func TestOpenCodeGoStore_QuerySeries(t *testing.T) { + t.Parallel() + s, err := New(":memory:") + if err != nil { + t.Fatalf("New: %v", err) + } + defer s.Close() + + now := time.Now().UTC().Truncate(time.Second) + for i := range 3 { + ts := now.Add(time.Duration(i) * time.Minute) + if _, err := s.InsertOpenCodeGoSnapshot(testOpenCodeGoSnapshot(ts, float64(20+i))); err != nil { + t.Fatalf("insert[%d]: %v", i, err) + } + } + + series, err := s.QueryOpenCodeGoUsageSeries("rolling", now.Add(-time.Minute), now.Add(4*time.Minute)) + if err != nil { + t.Fatalf("QueryOpenCodeGoUsageSeries: %v", err) + } + if len(series) != 3 { + t.Fatalf("len(series) = %d", len(series)) + } +} + +func TestOpenCodeGoStore_CycleLifecycleAndOverview(t *testing.T) { + t.Parallel() + s, err := New(":memory:") + if err != nil { + t.Fatalf("New: %v", err) + } + defer s.Close() + + now := time.Now().UTC().Truncate(time.Second) + reset := now.Add(2 * time.Hour) + + if _, err := s.CreateOpenCodeGoCycle("rolling", now, reset); err != nil { + t.Fatalf("CreateOpenCodeGoCycle: %v", err) + } + if err := s.UpdateOpenCodeGoCycle("rolling", 45, 12); err != nil { + t.Fatalf("UpdateOpenCodeGoCycle: %v", err) + } + + active, err := s.QueryActiveOpenCodeGoCycle("rolling") + if err != nil { + t.Fatalf("QueryActiveOpenCodeGoCycle: %v", err) + } + if active == nil || active.PeakUsage != 45 { + t.Fatalf("active cycle invalid: %+v", active) + } + + if err := s.CloseOpenCodeGoCycle("rolling", now.Add(time.Hour), 60, 20); err != nil { + t.Fatalf("CloseOpenCodeGoCycle: %v", err) + } + + history, err := s.QueryOpenCodeGoCycleHistory("rolling", 0) + if err != nil { + t.Fatalf("QueryOpenCodeGoCycleHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("len(history) = %d", len(history)) + } + + overview, err := s.QueryOpenCodeGoCycleOverview("rolling", 10) + if err != nil { + t.Fatalf("QueryOpenCodeGoCycleOverview: %v", err) + } + if len(overview) != 1 { + t.Fatalf("len(overview) = %d", len(overview)) + } +} diff --git a/internal/tracker/opencodego_tracker_test.go b/internal/tracker/opencodego_tracker_test.go new file mode 100644 index 0000000..f7221b2 --- /dev/null +++ b/internal/tracker/opencodego_tracker_test.go @@ -0,0 +1,82 @@ +package tracker + +import ( + "log/slog" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +func newOpenCodeGoTestStore(t *testing.T) *store.Store { + t.Helper() + s, err := store.New(":memory:") + if err != nil { + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestOpenCodeGoTracker_ProcessAndReset(t *testing.T) { + t.Parallel() + s := newOpenCodeGoTestStore(t) + tr := NewOpenCodeGoTracker(s, slog.Default()) + + now := time.Now().UTC() + first := &api.OpenCodeGoSnapshot{CapturedAt: now, Windows: []api.OpenCodeGoWindowValue{{WindowName: "rolling", UsagePercent: 70, ResetInSec: 300}}} + if err := tr.Process(first); err != nil { + t.Fatalf("Process first: %v", err) + } + + resetCalled := false + tr.SetOnReset(func(string) { resetCalled = true }) + second := &api.OpenCodeGoSnapshot{CapturedAt: now.Add(time.Minute), Windows: []api.OpenCodeGoWindowValue{{WindowName: "rolling", UsagePercent: 5, ResetInSec: 7200}}} + if err := tr.Process(second); err != nil { + t.Fatalf("Process second: %v", err) + } + if !resetCalled { + t.Fatal("expected reset callback") + } +} + +func TestOpenCodeGoTracker_UsageSummary(t *testing.T) { + t.Parallel() + s := newOpenCodeGoTestStore(t) + tr := NewOpenCodeGoTracker(s, slog.Default()) + + now := time.Now().UTC().Add(-5 * time.Minute) + snap := &api.OpenCodeGoSnapshot{ + CapturedAt: now, + Windows: []api.OpenCodeGoWindowValue{{WindowName: "rolling", UsagePercent: 30, ResetInSec: 3600, Status: "ok"}}, + } + if _, err := s.InsertOpenCodeGoSnapshot(snap); err != nil { + t.Fatalf("insert: %v", err) + } + if err := tr.Process(snap); err != nil { + t.Fatalf("process: %v", err) + } + + snap2 := &api.OpenCodeGoSnapshot{ + CapturedAt: now.Add(2 * time.Minute), + Windows: []api.OpenCodeGoWindowValue{{WindowName: "rolling", UsagePercent: 40, ResetInSec: 3000, Status: "ok"}}, + } + if _, err := s.InsertOpenCodeGoSnapshot(snap2); err != nil { + t.Fatalf("insert2: %v", err) + } + if err := tr.Process(snap2); err != nil { + t.Fatalf("process2: %v", err) + } + + summary, err := tr.UsageSummary("rolling") + if err != nil { + t.Fatalf("UsageSummary: %v", err) + } + if summary.UsagePercent != 40 { + t.Fatalf("UsagePercent = %.1f", summary.UsagePercent) + } + if summary.CurrentRate <= 0 { + t.Fatalf("CurrentRate = %.2f, want > 0", summary.CurrentRate) + } +}