Skip to content
Closed
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
25 changes: 25 additions & 0 deletions cmd/onwatch/menubar_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -232,6 +233,7 @@ func runMenubarCommand() error {

mbCfg := settings.ToConfig(cfg.Port, httpSnapshotProvider(cfg.Port))
mbCfg.TestMode = cfg.TestMode
mbCfg.MistralRetry = httpMistralRetry(cfg.Port, cfg.BasePath)

pidPath := menubarPIDPath(cfg.TestMode)
if err := writeRuntimePID(pidPath); err != nil {
Expand Down Expand Up @@ -283,6 +285,29 @@ func httpSnapshotProvider(port int) menubar.SnapshotProvider {
}
}

func httpMistralRetry(port int, basePath string) func() error {
url := fmt.Sprintf("http://localhost:%d%s/api/menubar/mistral/retry", port, basePath)
client := &http.Client{Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
return func() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
return err
}
req.Header.Set("X-Requested-With", "onWatch")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("Mistral retry request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
return fmt.Errorf("Mistral retry returned HTTP %d", resp.StatusCode)
}
return nil
}
}

// portFilePath is the discovery file thin clients (GNOME extension, VS Code
// extension, tray companion) read to find the dashboard port.
func portFilePath() string {
Expand Down
30 changes: 30 additions & 0 deletions cmd/onwatch/mistral_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package main

import (
"net"
"net/http"
"net/http/httptest"
"strconv"
"testing"
)

func TestMistralNativeRetryRequest(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/watch/api/menubar/mistral/retry" || r.Header.Get("X-Requested-With") == "" {
t.Errorf("bad retry request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(202)
}))
defer s.Close()
_, p, err := net.SplitHostPort(s.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
port, err := strconv.Atoi(p)
if err != nil {
t.Fatal(err)
}
if err := httpMistralRetry(port, "/watch")(); err != nil {
t.Fatal(err)
}
}
18 changes: 15 additions & 3 deletions docs/MISTRAL_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ Settings > Providers > Mistral > Manual, then paste your Mistral Cookie header.

## macOS permissions

Automatic import needs two one-time permissions on macOS:
Automatic import needs two permissions on macOS:

1. **Browser folder access.** macOS blocks background apps from reading another app's data folder. Right-click the onWatch tray icon and choose **Grant Browser Access...**, then pick your browser in the folder panel. A locally rebuilt binary will need re-granting.
1. **Browser folder access.** macOS blocks background apps from reading another app's data folder. In the native macOS menubar card, click **Grant Browser Access** above **Retry connection**. Alternatively, right-click the onWatch tray icon and choose **Grant Browser Access...**. Confirm the browser folder in the system panel (selected by default). Once access is verified, onWatch automatically requests a Mistral retry; cancelling does not retry. A rebuilt or updated binary may need a new grant. Dashboard and browser fallback views retain the right-click guidance.
2. **Keychain access.** You'll see a prompt that `security` wants to use **Chrome Safe Storage** - this decrypts the cookie file, it's not asking for your Mistral password. Enter your Mac login password and choose **Allow** (or **Allow Once** if you'd rather be asked again next time).

Prefer to skip both prompts? Sign into Mistral in Firefox instead and select Firefox as your browser, or use manual cookie mode.
Expand Down Expand Up @@ -57,7 +57,11 @@ Settings saved in onWatch override these environment variables. The cookie field

## Refresh behaviour

onWatch polls every 120 seconds by default. If your session is rejected, it retries the import once immediately; after that it pauses polling until a fresh login is detected, without switching to a different account on its own. Usage history is kept for 90 days by default - change this with `MISTRAL_RETENTION` (a Go duration like `720h`, or `0` to keep everything).
onWatch polls every 120 seconds by default. If your session is rejected, it retries the import once immediately; after that it pauses polling until a fresh login is detected, without switching to a different account on its own. Repeated import failures increase the automatic retry delay from 20 minutes to at most 5 hours 20 minutes, to avoid repeatedly prompting for your password.

Connection messages distinguish browser folder permissions, Keychain/keyring access, unreadable cookie storage, missing logins, and rejected sessions. After addressing the message, use **Retry connection** on the Mistral dashboard or menubar card. It retries the selected source immediately without restarting the daemon, even if the cookie has not changed. Repeated clicks are coalesced, with a 30-second cooldown after a request; Mistral's own rate-limit deadlines still apply. A queued retry may need you to answer a credential-store prompt. The ordinary refresh icon only reloads saved usage.

The last successful values retain their original timestamps until a successful poll. Usage history is kept for 90 days by default - change this with `MISTRAL_RETENTION` (a Go duration like `720h`, or `0` to keep everything).

## Testing

Expand All @@ -68,3 +72,11 @@ ONWATCH_MISTRAL_LIVE=1 GOFLAGS='-run=TestMistralLive -v' ./app.sh --test
```

The default test suite uses synthetic data only. A synthetic dashboard preview is also available with `ONWATCH_MISTRAL_PREVIEW=1 GOFLAGS='-run=TestMistralPreview -v' ./app.sh --test`.

On macOS, run the native grant callback and UI regressions with race detection:

```sh
GOFLAGS='-tags=menubar,desktop,production,granttest -run=TestBrowserGrant|TestMistralRecoveryUI' ./app.sh --test
```

The `granttest` harness exercises the real C-to-Go callback and Cocoa completion queue with simulated permission results, including host destruction during a request. It does not grant browser permissions and is excluded from production builds. The actual system permission picker still requires an in-situ check.
24 changes: 16 additions & 8 deletions internal/agent/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,23 @@ type RunnerFactory func() (AgentRunner, error)
type AgentManager struct {
mu sync.RWMutex
factories map[string]RunnerFactory
running map[string]context.CancelFunc
running map[string]*runningAgent
logger *slog.Logger
}

type runningAgent struct {
cancel context.CancelFunc
runner AgentRunner
}

// NewAgentManager creates a new manager.
func NewAgentManager(logger *slog.Logger) *AgentManager {
if logger == nil {
logger = slog.Default()
}
return &AgentManager{
factories: make(map[string]RunnerFactory),
running: make(map[string]context.CancelFunc),
running: make(map[string]*runningAgent),
logger: logger,
}
}
Expand Down Expand Up @@ -85,7 +90,8 @@ func (m *AgentManager) Start(key string) error {
cancel()
return nil
}
m.running[key] = cancel
entry := &runningAgent{cancel: cancel, runner: runner}
m.running[key] = entry
m.mu.Unlock()

go func() {
Expand All @@ -94,7 +100,9 @@ func (m *AgentManager) Start(key string) error {
m.logger.Error("Agent error", "provider", key, "error", err)
}
m.mu.Lock()
delete(m.running, key)
if m.running[key] == entry {
delete(m.running, key)
}
m.mu.Unlock()
}()

Expand All @@ -104,13 +112,13 @@ func (m *AgentManager) Start(key string) error {
// Stop cancels the running provider agent, if present.
func (m *AgentManager) Stop(key string) {
m.mu.Lock()
cancel, running := m.running[key]
entry, running := m.running[key]
if running {
delete(m.running, key)
}
m.mu.Unlock()
if running {
cancel()
entry.cancel()
m.logger.Info("Stopped agent", "provider", key)
}
}
Expand All @@ -119,10 +127,10 @@ func (m *AgentManager) Stop(key string) {
func (m *AgentManager) StopAll() {
m.mu.Lock()
cancels := make([]context.CancelFunc, 0, len(m.running))
for key, cancel := range m.running {
for key, entry := range m.running {
delete(m.running, key)
m.logger.Info("Stopped agent", "provider", key)
cancels = append(cancels, cancel)
cancels = append(cancels, entry.cancel)
}
m.mu.Unlock()

Expand Down
69 changes: 69 additions & 0 deletions internal/agent/manager_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package agent

import (
"context"
"errors"
"sync/atomic"
"testing"
"time"

"github.com/onllm-dev/onwatch/v2/internal/api"
)

type managedRetryRunner struct {
started, release, exited chan struct{}
retries atomic.Int32
}

func (r *managedRetryRunner) Run(ctx context.Context) error {
close(r.started)
<-ctx.Done()
<-r.release
close(r.exited)
return nil
}
func (r *managedRetryRunner) RequestRetry() error { r.retries.Add(1); return nil }
func (r *managedRetryRunner) ConnectionState() api.MistralConnection {
return api.MistralConnection{CanRetry: true}
}

func TestAgentManagerRetryUsesCurrentInstance(t *testing.T) {
m := NewAgentManager(nil)
makeRunner := func() *managedRetryRunner {
return &managedRetryRunner{started: make(chan struct{}), release: make(chan struct{}), exited: make(chan struct{})}
}
old, current := makeRunner(), makeRunner()
m.RegisterFactory("mistral", func() (AgentRunner, error) { return old, nil })
if err := m.RequestRetry("mistral"); !errors.Is(err, ErrRetryUnavailable) {
t.Fatal(err)
}
if err := m.Start("mistral"); err != nil {
t.Fatal(err)
}
<-old.started
if err := m.RequestRetry("mistral"); err != nil {
t.Fatal(err)
}
m.Stop("mistral")
m.RegisterFactory("mistral", func() (AgentRunner, error) { return current, nil })
if err := m.Start("mistral"); err != nil {
t.Fatal(err)
}
<-current.started
defer func() { m.StopAll(); close(current.release); <-current.exited }()
close(old.release)
<-old.exited
// Exercise retries throughout the old goroutine's deferred cleanup.
for deadline := time.Now().Add(30 * time.Millisecond); time.Now().Before(deadline); {
if err := m.RequestRetry("mistral"); err != nil {
t.Fatalf("old exit removed replacement: %v", err)
}
time.Sleep(time.Millisecond)
}
if old.retries.Load() != 1 || current.retries.Load() == 0 {
t.Fatal("retry routed to stale instance")
}
if c, ok := m.ConnectionState("mistral"); !ok || !c.CanRetry {
t.Fatal("missing running connection state")
}
}
Loading
Loading