Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions cleanup-services.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Aggressive Service Cleanup Script
# Run this from an ELEVATED (Administrator) PowerShell session.

Write-Host "Please ensure the 'Services' (services.msc) window and 'Task Manager' are CLOSED." -ForegroundColor Yellow

$NSSM = "C:\Users\Paul\AppData\Local\Microsoft\WinGet\Packages\NSSM.NSSM_Microsoft.WinGet.Source_8wekyb3d8bbwe\nssm-2.24-101-g897c7ad\win64\nssm.exe"
$Services = @("onWatch", "onWatchApp", "onWatchService")

foreach ($svc in $Services) {
Write-Host "`nCleaning up: $svc" -ForegroundColor Cyan

# 1. Stop the service
& $NSSM stop $svc 2>$null
Start-Sleep -Seconds 1

# 2. Try removing via NSSM
& $NSSM remove $svc confirm 2>$null
Comment on lines +4 to +17

# 3. Try removing via SC
sc.exe delete $svc 2>$null

# 4. Force delete from the Windows Registry to clear "marked for deletion" lock
$regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$svc"
if (Test-Path $regPath) {
Write-Host "Forcefully removing registry keys for $svc..." -ForegroundColor Yellow
Remove-Item -Path $regPath -Recurse -Force -ErrorAction SilentlyContinue
} else {
Comment on lines +22 to +27
Write-Host "Registry keys already gone for $svc." -ForegroundColor DarkGray
}
}

Write-Host "`nCleanup complete! To fully flush the Windows cache of these services, you must RESTART YOUR COMPUTER." -ForegroundColor Green
50 changes: 35 additions & 15 deletions docs/WINDOWS_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,28 +271,48 @@ To run in debug mode (shows console window):

## Running as a Windows Service (Advanced)

For production deployments, you can run onWatch as a Windows Service using [NSSM](https://nssm.cc/) (Non-Sucking Service Manager):
For production deployments, you can run onWatch as a background Windows Service using [NSSM](https://nssm.cc/) (Non-Sucking Service Manager). We provide a robust setup script to handle common service pitfalls (like path resolution and lifecycle management).

1. Download NSSM from https://nssm.cc/download
2. Extract and open Command Prompt as Administrator
3. Install the service:
### Automatic Setup (Recommended)

1. Ensure NSSM is installed via winget:
```powershell
winget install nssm --accept-package-agreements --accept-source-agreements --silent
```
2. Open an **Elevated (Administrator) PowerShell** session.
3. Run the provided setup script:
```powershell
# If you cloned the repository:
.\scripts\setup-windows-service.ps1

# Or download and review the script before running:
Invoke-WebRequest -Uri https://raw.githubusercontent.com/onllm-dev/onwatch/main/scripts/setup-windows-service.ps1 -OutFile setup-windows-service.ps1
.\setup-windows-service.ps1
```
Comment thread
PavelA85 marked this conversation as resolved.
Comment on lines +284 to +291

### Manual Setup (For Custom Environments)

If you prefer to configure NSSM manually, open an Administrator prompt and use the following commands. **Note:** Using `--debugstdout` keeps the process attached to NSSM so it can monitor the application correctly, and setting `AppEnvironmentExtra` ensures the `LocalSystem` account can find your auto-detected AI API keys.

```cmd
nssm install onwatch "%USERPROFILE%\.onwatch\bin\onwatch.exe"
nssm set onwatch AppDirectory "%USERPROFILE%\.onwatch"
nssm set onwatch AppParameters "--debug"
nssm set onwatch DisplayName "onWatch API Quota Tracker"
nssm set onwatch Description "Tracks AI API quota usage"
nssm set onwatch Start SERVICE_AUTO_START
nssm start onwatch
nssm install onWatchService "%USERPROFILE%\.onwatch\bin\onwatch.exe"
nssm set onWatchService AppParameters "--debugstdout"
nssm set onWatchService AppDirectory "%USERPROFILE%\.onwatch"
nssm set onWatchService AppStdout "%USERPROFILE%\.onwatch\service.log"
nssm set onWatchService AppStderr "%USERPROFILE%\.onwatch\service.log"
nssm set onWatchService DisplayName "onWatch API Quota Tracker"
nssm set onWatchService Description "Tracks AI API quota usage"
nssm set onWatchService AppEnvironmentExtra "USERPROFILE=%USERPROFILE%\0HOME=%USERPROFILE%\0"
nssm set onWatchService Start SERVICE_AUTO_START
nssm start onWatchService
```

Manage the service:
```cmd
nssm status onwatch
nssm stop onwatch
nssm restart onwatch
nssm remove onwatch confirm
nssm status onWatchService
nssm stop onWatchService
nssm restart onWatchService
nssm remove onWatchService confirm
```

---
Expand Down
9 changes: 9 additions & 0 deletions internal/api/anthropic_token_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ func SetTestMode(enabled bool) {
testMode = enabled
}

// getCredentialsFilePath returns the path to the Claude credentials file on Windows.
func getCredentialsFilePath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".claude", ".credentials.json")
}

// detectAnthropicCredentialsPlatform tries to detect full OAuth credentials on Windows.
func detectAnthropicCredentialsPlatform(logger *slog.Logger) *AnthropicCredentials {
if logger == nil {
Expand Down
22 changes: 16 additions & 6 deletions internal/api/anthropic_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ type AnthropicQuotaResponse map[string]*AnthropicQuotaEntry

// AnthropicQuota represents a single normalized quota for storage.
type AnthropicQuota struct {
Name string
Utilization float64
ResetsAt *time.Time
Name string
Utilization float64
UsedCredits float64
MonthlyLimit float64
ResetsAt *time.Time
}

// AnthropicSnapshot represents a point-in-time capture of all Anthropic quotas.
Expand All @@ -40,6 +42,8 @@ var anthropicDisplayNames = map[string]string{
"five_hour": "5-Hour Limit",
"seven_day": "Weekly All-Model",
"seven_day_sonnet": "Weekly Sonnet",
"seven_day_opus": "Weekly Opus",
"seven_day_design": "Claude Design",
"monthly_limit": "Monthly Limit",
"extra_usage": "Extra Usage",
}
Expand Down Expand Up @@ -98,6 +102,12 @@ func (r AnthropicQuotaResponse) ToSnapshot(capturedAt time.Time) *AnthropicSnaps
Name: name,
Utilization: *entry.Utilization,
}
if entry.UsedCredits != nil {
q.UsedCredits = *entry.UsedCredits
}
if entry.MonthlyLimit != nil {
q.MonthlyLimit = *entry.MonthlyLimit
}
if entry.ResetsAt != nil && *entry.ResetsAt != "" {
if t, err := time.Parse(time.RFC3339, *entry.ResetsAt); err == nil {
q.ResetsAt = &t
Expand All @@ -106,9 +116,9 @@ func (r AnthropicQuotaResponse) ToSnapshot(capturedAt time.Time) *AnthropicSnaps
snapshot.Quotas = append(snapshot.Quotas, q)
}

// Store raw JSON for debugging/auditing
if raw, err := json.Marshal(r); err == nil {
snapshot.RawJSON = string(raw)
// Capture RawJSON for debugging and potential detail extraction
if data, err := json.Marshal(r); err == nil {
snapshot.RawJSON = string(data)
}

return snapshot
Expand Down
36 changes: 27 additions & 9 deletions internal/api/anthropic_types_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ func TestAnthropicDisplayName_KnownKeys(t *testing.T) {
{"five_hour", "5-Hour Limit"},
{"seven_day", "Weekly All-Model"},
{"seven_day_sonnet", "Weekly Sonnet"},
{"seven_day_opus", "Weekly Opus"},
{"seven_day_design", "Claude Design"},
{"monthly_limit", "Monthly Limit"},
{"extra_usage", "Extra Usage"},
{"unknown_key", "unknown_key"},
Expand All @@ -35,6 +37,9 @@ func TestAnthropicQuotaResponse_ToSnapshot(t *testing.T) {

fiveHour := 45.2
sevenDay := 12.8
extraUsage := 15.0
extraUsed := 11.25
extraLimit := 75.0
boolTrue := true
fiveHourResetStr := fiveHourReset.Format(time.RFC3339)
sevenDayResetStr := sevenDayReset.Format(time.RFC3339)
Expand All @@ -50,28 +55,41 @@ func TestAnthropicQuotaResponse_ToSnapshot(t *testing.T) {
ResetsAt: &sevenDayResetStr,
IsEnabled: &boolTrue,
},
"extra_usage": &AnthropicQuotaEntry{
Utilization: &extraUsage,
UsedCredits: &extraUsed,
MonthlyLimit: &extraLimit,
IsEnabled: &boolTrue,
},
}

snapshot := resp.ToSnapshot(now)
if snapshot.CapturedAt != now {
t.Errorf("CapturedAt = %v, want %v", snapshot.CapturedAt, now)
}
if len(snapshot.Quotas) != 2 {
t.Fatalf("expected 2 quotas, got %d", len(snapshot.Quotas))
if len(snapshot.Quotas) != 3 {
t.Fatalf("expected 3 quotas, got %d", len(snapshot.Quotas))
}
if snapshot.RawJSON == "" {
t.Error("RawJSON should not be empty")
}

// Quotas should be sorted by name
if snapshot.Quotas[0].Name != "five_hour" {
t.Errorf("first quota = %q, want five_hour", snapshot.Quotas[0].Name)
// Quotas should be sorted by name: extra_usage, five_hour, seven_day
if snapshot.Quotas[0].Name != "extra_usage" {
t.Errorf("first quota = %q, want extra_usage", snapshot.Quotas[0].Name)
}
if snapshot.Quotas[0].UsedCredits != 11.25 {
t.Errorf("extra_usage usedCredits = %f, want 11.25", snapshot.Quotas[0].UsedCredits)
}
if snapshot.Quotas[0].Utilization != 45.2 {
t.Errorf("five_hour utilization = %f, want 45.2", snapshot.Quotas[0].Utilization)
if snapshot.Quotas[0].MonthlyLimit != 75.0 {
t.Errorf("extra_usage monthlyLimit = %f, want 75.0", snapshot.Quotas[0].MonthlyLimit)
}

if snapshot.Quotas[1].Name != "five_hour" {
t.Errorf("second quota = %q, want five_hour", snapshot.Quotas[1].Name)
}
if snapshot.Quotas[0].ResetsAt == nil {
t.Error("five_hour ResetsAt should not be nil")
if snapshot.Quotas[1].Utilization != 45.2 {
t.Errorf("five_hour utilization = %f, want 45.2", snapshot.Quotas[1].Utilization)
}
}

Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,10 @@ func OpenRotatingLogFile(path string) (*os.File, error) {
return nil, fmt.Errorf("failed to stat log file %s: %w", path, err)
}

if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, fmt.Errorf("failed to create log directory %s: %w", filepath.Dir(path), err)
}

file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
Comment thread
PavelA85 marked this conversation as resolved.
if err != nil {
return nil, err
Expand Down
15 changes: 15 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1530,3 +1530,18 @@ func TestConfig_LogFormat_AliasesAndCaseInsensitive(t *testing.T) {
})
}
}

func TestOpenRotatingLogFile_CreatesDirectories(t *testing.T) {
tmpDir := t.TempDir()
logPath := filepath.Join(tmpDir, "nested", "dirs", "test.log")

file, err := OpenRotatingLogFile(logPath)
if err != nil {
t.Fatalf("OpenRotatingLogFile() failed: %v", err)
}
defer file.Close()

if _, err := os.Stat(filepath.Dir(logPath)); os.IsNotExist(err) {
t.Errorf("Directory was not created: %v", err)
}
}
28 changes: 16 additions & 12 deletions internal/store/anthropic_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ func (s *Store) InsertAnthropicSnapshot(snapshot *api.AnthropicSnapshot) (int64,
resetsAt = q.ResetsAt.Format(time.RFC3339Nano)
}
_, err := tx.Exec(
`INSERT INTO anthropic_quota_values (snapshot_id, quota_name, utilization, resets_at) VALUES (?, ?, ?, ?)`,
snapshotID, q.Name, q.Utilization, resetsAt,
`INSERT INTO anthropic_quota_values (snapshot_id, quota_name, utilization, resets_at, used_credits, monthly_limit) VALUES (?, ?, ?, ?, ?, ?)`,
snapshotID, q.Name, q.Utilization, resetsAt, q.UsedCredits, q.MonthlyLimit,
)
if err != nil {
return 0, fmt.Errorf("failed to insert quota value %s: %w", q.Name, err)
Expand Down Expand Up @@ -511,18 +511,20 @@ func (s *Store) QueryAnthropicCycleOverview(groupBy string, limit int) ([]CycleO

// AnthropicLatestQuota holds the most recent value for a single quota across all snapshots.
type AnthropicLatestQuota struct {
Name string
Utilization float64
ResetsAt *time.Time
CapturedAt time.Time
Source string // "statusline" or "api"
Name string
Utilization float64
UsedCredits float64
MonthlyLimit float64
ResetsAt *time.Time
CapturedAt time.Time
Source string // "statusline" or "api"
}

// QueryAnthropicLatestPerQuota returns the most recent value for each distinct quota name.
// Scans only the last 50 snapshots (bounded) and merges in Go - fast even on large DBs.
func (s *Store) QueryAnthropicLatestPerQuota() ([]AnthropicLatestQuota, error) {
rows, err := s.db.Query(`
SELECT qv.quota_name, qv.utilization, qv.resets_at, s.captured_at, s.raw_json
SELECT qv.quota_name, qv.utilization, qv.resets_at, s.captured_at, s.raw_json, qv.used_credits, qv.monthly_limit
FROM anthropic_quota_values qv
JOIN anthropic_snapshots s ON s.id = qv.snapshot_id
WHERE s.id >= (SELECT MAX(id) - 50 FROM anthropic_snapshots)
Expand All @@ -537,10 +539,10 @@ func (s *Store) QueryAnthropicLatestPerQuota() ([]AnthropicLatestQuota, error) {
var results []AnthropicLatestQuota
for rows.Next() {
var name string
var utilization float64
var utilization, usedCredits, monthlyLimit float64
var resetsAt sql.NullString
var capturedAt, rawJSON string
if err := rows.Scan(&name, &utilization, &resetsAt, &capturedAt, &rawJSON); err != nil {
if err := rows.Scan(&name, &utilization, &resetsAt, &capturedAt, &rawJSON, &usedCredits, &monthlyLimit); err != nil {
return nil, fmt.Errorf("failed to scan latest quota: %w", err)
}
// Hide historical rows for experimental/unknown quota keys (pre-whitelist data).
Expand All @@ -553,8 +555,10 @@ func (s *Store) QueryAnthropicLatestPerQuota() ([]AnthropicLatestQuota, error) {
seen[name] = true

q := AnthropicLatestQuota{
Name: name,
Utilization: utilization,
Name: name,
Utilization: utilization,
UsedCredits: usedCredits,
MonthlyLimit: monthlyLimit,
}
q.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt)
if resetsAt.Valid && resetsAt.String != "" {
Expand Down
13 changes: 13 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,19 @@ func (s *Store) migrateSchema() error {
}
}

// Add used_credits and monthly_limit to anthropic_quota_values
for _, col := range []string{
"used_credits REAL NOT NULL DEFAULT 0",
"monthly_limit REAL NOT NULL DEFAULT 0",
} {
if _, err := s.db.Exec(`ALTER TABLE anthropic_quota_values ADD COLUMN ` + col); err != nil {
if !strings.Contains(err.Error(), "duplicate column name") &&
!strings.Contains(err.Error(), "no such table") {
return fmt.Errorf("failed to add anthropic column: %w", err)
}
}
}

return nil
}

Expand Down
2 changes: 2 additions & 0 deletions internal/web/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5017,6 +5017,8 @@ func (h *Handler) buildAnthropicCurrent() map[string]interface{} {
"name": q.Name,
"displayName": api.AnthropicDisplayName(q.Name),
"utilization": q.Utilization,
"usedCredits": q.UsedCredits,
"monthlyLimit": q.MonthlyLimit,
"status": anthropicUtilStatus(q.Utilization),
"source": q.Source,
"lastUpdatedAt": q.CapturedAt.Format(time.RFC3339),
Expand Down
Loading