refactor: merge internal/sync into internal/garmin, regroup api files and routes

Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes
garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes
internal/log; the test mock moves into the garmin package as MockClient
(breaking the test-only import cycle the merge created); stale test
URLs and type names updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 16:04:18 +02:00
parent 19c9aecdeb
commit e2b2bf9611
61 changed files with 2007 additions and 982 deletions

View File

@@ -23,7 +23,7 @@ This replaces the MCP transport with a small custom Python subprocess wrapper ar
- No change to what data geniusrun actually syncs/stores/classifies today — only the transport between Go and Python changes. `internal/sync`, `internal/classify`, `internal/store`, and the frontend are untouched.
- No security sandboxing of the generic dispatcher (see below) — the subprocess is spoken to only by geniusrun's own Go process over a private pipe it owns, not exposed to any other caller, so an open dispatch surface is an accepted risk, not a gap.
- No auto-restart-on-crash logic for the subprocess. Matches today's behavior: a dead subprocess surfaces as a Go error on next use; only `UpdateCredentials` explicitly tears down and respawns.
- No change to the per-user token store layout (`{GarminTokenStoreRoot}/{userID}`) or the multi-tenant `garminFor` caching in `api.Server`.
- No change to the per-user token store layout (`{GarminTokenStoreRoot}/{userID}`) or the multi-tenant `clientFor` caching in `api.Server`.
## Wire protocol
@@ -99,5 +99,5 @@ Go's line reader uses a raised buffer size (well above the default 64KB `bufio.S
## Rollout notes
- No data migration involved — this only changes how the Go backend talks to Garmin, not what's stored.
- Existing per-user token stores under `GarminTokenStoreRoot` are unaffected (same `GARMIN_TOKENSTORE` env var name passed to the subprocess, same directory layout) — no re-login required for existing users after cutover.
- Existing per-user token stores under `TokenStoreRoot` are unaffected (same `GARMIN_TOKENSTORE` env var name passed to the subprocess, same directory layout) — no re-login required for existing users after cutover.
- Deploy order: ship the new wrapper + Go client together as one change (interface unchanged, so this is an internal swap, not a rolling/compat-sensitive migration).

View File

@@ -85,7 +85,7 @@ A new middleware step runs immediately after the existing `RequireSession` (role
`internal/garmin.Client` and `internal/sync.Service` already take their config/dependencies as constructor params with no hidden global state, so per-user instantiation is mechanical:
- `api.Server`'s current single fixed `Garmin`/`Sync` fields and flat Garmin-auth-status fields (today: plain fields on `Server`, single-user assumption) become two mutex-guarded maps: `map[int64]*garmin.Client` and `map[int64]*sync.Service`, keyed by `user_id`, lazily constructed on first access for a given user.
- A per-user `garmin.Client` is built from that user's own `profile` row (`GarminEmail`/`GarminPassword`) and a per-user token store path: `filepath.Join(cfg.GarminTokenStoreRoot, strconv.FormatInt(userID, 10))`. `GarminTokenStore` in `internal/config` is renamed/repurposed to `GarminTokenStoreRoot` (a directory, not a single tokenstore path) to reflect this.
- A per-user `garmin.Client` is built from that user's own `profile` row (`GarminEmail`/`GarminPassword`) and a per-user token store path: `filepath.Join(cfg.GarminTokenStoreRoot, strconv.FormatInt(userID, 10))`. `GarminTokenStore` in `internal/config` is renamed/repurposed to `TokenStoreRoot` (a directory, not a single tokenstore path) to reflect this.
- `sync.Service.Progress()` becomes naturally per-user once each user has their own `Service` instance — no separate change needed there.
- `store.DB` stays a single shared connection against one SQLite file — this design keeps the single-shared-DB-with-`user_id`-columns approach, so no per-user DB file/connection is needed.
@@ -125,5 +125,5 @@ Every existing handler in `internal/api` that reads/writes `profile`, `activitie
## Rollout notes
- Before deploying: log into the current single-tenant build once, note your `sub` from `GET /api/session/me`, and set `GENIUSRUN_LEGACY_OWNER_OIDC_SUB` to it for the first startup after upgrading.
- `GARMIN_TOKENSTORE` env var / `GarminTokenStore` config field is repurposed as a root directory (`GarminTokenStoreRoot`) rather than a single tokenstore path — existing deployments need to point it at a directory rather than mcp-garmin's old single-file/dir location, and any already-cached Garmin session under the old path is effectively invalidated (that one user will need to log into Garmin again post-migration, once, under their new per-user subdirectory).
- `GARMIN_TOKENSTORE` env var / `GarminTokenStore` config field is repurposed as a root directory (`TokenStoreRoot`) rather than a single tokenstore path — existing deployments need to point it at a directory rather than mcp-garmin's old single-file/dir location, and any already-cached Garmin session under the old path is effectively invalidated (that one user will need to log into Garmin again post-migration, once, under their new per-user subdirectory).
- No frontend routing changes beyond the new setup screen — everything else (Dashboard, Review Queue, Plan stub, Profile) is unchanged in shape, just now reflecting whichever user is logged in.

View File

@@ -69,7 +69,7 @@ After the schema edit, regenerate `docs/DATABASE.md` via `go run
### Backend API
- **`handleAuthLogin`/`handleAuthMFA`** (`internal/api/auth.go`): right after
- **`handleGarminAuthLogin`/`handleGarminAuthMFA`** (`internal/api/auth.go`): right after
the existing `s.recordAuthResult(userID, res)` call, if `res.Status ==
garmin.AuthSuccess`, call `s.DB.MarkGarminConnected(r.Context(), userID)`.
A failure here is logged and does *not* fail the HTTP response -- the

View File

@@ -22,7 +22,7 @@ only production caller of the core logic) calls `backfillCore`/`incrementalSyncC
Only `internal/sync/service_test.go` still calls `Backfill`/`IncrementalSync`, which is the only
reason they're not already flagged as unused by the compiler.
Since this plan already restructures `service.go`'s progress model, remove these two dead
Since this plan already restructures `sync.go`'s progress model, remove these two dead
exported methods (and their now-inaccurate doc comments) as an early task, rewriting the tests
that called them to exercise the same behavior through `FullSync` or the `*Core` functions
directly (same package, so unexported functions are still directly testable) -- before any of
@@ -120,7 +120,7 @@ able to report it; the frontend type was just never updated to match).
## Frontend design
`GarminConnection.tsx` keeps its connect/MFA/Reset-all logic and buttons untouched, but loses
its `syncStatus` polling, `syncProgressLabel` helper, and all inline sync-progress/last-sync
its `garminSyncStatus` polling, `syncProgressLabel` helper, and all inline sync-progress/last-sync
JSX.
A new `SyncModal.tsx`:

View File

@@ -31,7 +31,7 @@ commit); it just no longer gates anything on its own.
### The core problem: Garmin auth needs an account today
`garminFor` builds a per-user `garmin.Client` keyed by a DB user id and
`clientFor` builds a per-user `garmin.Client` keyed by a DB user id and
reads Garmin credentials from that user's `profile` row. To authenticate
with Garmin *before* an account exists, onboarding needs a **temporary,
not-yet-persisted** Garmin session -- keyed by the OIDC subject (already
@@ -45,7 +45,7 @@ finish onboarding.
### Backend: ephemeral pre-account Garmin sessions
New type and `Server` field (`server.go`, alongside the existing
`userGarmin`/`userSync`/etc. per-user maps):
`userClient`/`userSync`/etc. per-user maps):
```go
// setupSession is a temporary, not-yet-persisted Garmin authentication
@@ -81,7 +81,7 @@ Helper methods on `Server` (`server.go`):
spawn new" semantics as `garmin.Client.UpdateCredentials`), builds a
fresh ephemeral client whose `TokenStorePath` is namespaced under
`{root}/setup/{sub}` (distinct from the permanent `{root}/{userID}`
namespacing `garminFor` uses, so two people onboarding concurrently never
namespacing `clientFor` uses, so two people onboarding concurrently never
collide, and so the ephemeral session's persisted Garmin tokens have
*some* home even though no user id exists yet).
- `recordSetupAuthResult(sub string, res garmin.AuthResult)` -- updates a
@@ -140,7 +140,7 @@ r.Route("/setup", func(r chi.Router) {
resumes the already-established Garmin session instead of requiring a
fresh login. Logged, not fatal, on error.
`internal/api/auth.go`'s existing `handleAuthLogin`/`handleAuthMFA`/`handleAuthStatus`
`internal/api/auth.go`'s existing `handleGarminAuthLogin`/`handleGarminAuthMFA`/`handleGarminAuthStatus`
(used by the Profile page's `GarminConnection.tsx` for reconnecting an
already-provisioned user) are untouched.
@@ -204,7 +204,7 @@ simpler than threading a separate `"mfa"` step through, and matches how
`/api/setup/complete` after a successful session creates the user with
`GarminEmail`/`GarminPassword`/`GarminConnectedAt` all set, and promotes
the *same* `*mock.Client` instance into `s.userGarmin[userID]` (asserted
via `garminFor` returning that exact pointer, and `ClosedCalled` still
via `clientFor` returning that exact pointer, and `ClosedCalled` still
false -- proving no redundant re-authentication happened);
`/api/setup/complete` with no prior login attempt → 409;
already-provisioned subject hitting either endpoint → 409; two different

View File

@@ -74,7 +74,7 @@ the `requireProvisionedUser` group (`server.go`). `userID` comes from
`client.UpdateCredentials` outside `s.mu`).
- Best-effort `os.RemoveAll` on `filepath.Join(s.GarminBase.TokenStorePath,
strconv.FormatInt(userID, 10))` when `TokenStorePath` is configured --
the same path `garminFor` computes when building a client. Log on
the same path `clientFor` computes when building a client. Log on
error; this is cleanup of an already-orphaned directory, not something
that should fail the request that already deleted the DB row.
4. Respond `204 No Content`.

View File

@@ -75,7 +75,7 @@ which falls back to this default.
### HTTP access log (`internal/api`)
A new `requestLoggingMiddleware`, registered as the **first** `r.Use(...)`
A new `loggingMiddleware`, registered as the **first** `r.Use(...)`
in `Router()` (ahead of `corsMiddleware`), so it wraps every request
including unauthenticated ones (login redirect, health check) and OPTIONS
preflights:

View File

@@ -128,7 +128,7 @@ Every current inline-error site, and what changes:
| `pages/Analysis.tsx` | Local `error` state, inline paragraph. | Same. |
| `components/TrainingTypesCard.tsx` | Local `error` state, inline paragraph. | Same. |
| `LoginGate.tsx` | Reads `?auth_error=` from the URL synchronously during render, shows `<p className="login-gate-error">` with a mapped message. | A mount effect reads `?auth_error=` once and calls `showError(mappedMessage)` (same `AUTH_ERROR_MESSAGES` mapping as today); the inline paragraph and its CSS class are removed. |
| `OnboardingWizard.tsx` | Single `error` state used for both field validation ("Please enter a display name.", "Please enter your Garmin email and password.") **and** real failures (Garmin login rejected, MFA rejected, `complete()` failing after a successful Garmin auth) — all rendered via the same `onboarding-wizard-error` paragraph, in 4 places. | **Split.** Field-validation messages stay exactly as they are today (local state, inline paragraph, tied to a specific input the user needs to fix — a transient top-of-page banner is the wrong medium for "you left a required field blank"). Real failures (`submitGarmin`'s catch, `submitMFA`'s catch, `complete()`'s catch) call `showError(...)` instead of `setError(...)`. The `complete()`-failure Retry button, which today is conditionally rendered on `error &&`, switches to a new local boolean `completeFailed` (set `true` in that catch block) — the button's visibility no longer depends on the error text still being present, since that text now lives only in the (transient, auto-dismissing) banner. |
| `OnboardingWizard.tsx` | Single `error` state used for both field validation ("Please enter a display name.", "Please enter your Garmin email and password.") **and** real failures (Garmin login rejected, MFA rejected, `complete()` failing after a successful Garmin auth) — all rendered via the same `onboarding-wizard-error` paragraph, in 4 places. | **Split.** Field-validation messages stay exactly as they are today (local state, inline paragraph, tied to a specific input the user needs to fix — a transient top-of-page banner is the wrong medium for "you left a required field blank"). Real failures (`submitGarmin`'s catch, `garminMFA`'s catch, `complete()`'s catch) call `showError(...)` instead of `setError(...)`. The `complete()`-failure Retry button, which today is conditionally rendered on `error &&`, switches to a new local boolean `completeFailed` (set `true` in that catch block) — the button's visibility no longer depends on the error text still being present, since that text now lives only in the (transient, auto-dismissing) banner. |
**Dead CSS removal:** once no `.tsx` file references them, delete the
`.error`, `.onboarding-wizard-error`, and `.login-gate-error` rules from