catence 0.2.1-beta.2 → 0.2.1-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +4 -4
  2. package/config.example.json +1 -0
  3. package/dist/contracts/release.d.ts +1 -1
  4. package/dist/contracts/release.js +1 -1
  5. package/dist/contracts/runtime.d.ts +4 -0
  6. package/dist/core/query/catalog.d.ts +1 -1
  7. package/dist/core/query/catalog.js +16 -10
  8. package/dist/core/query/repository.d.ts +1 -1
  9. package/dist/core/query/repository.js +31 -2
  10. package/dist/core/retrieval/index.js +23 -5
  11. package/dist/core/runtime/athlete-file.d.ts +49 -0
  12. package/dist/core/runtime/athlete-file.js +177 -0
  13. package/dist/core/runtime/configuration.js +2 -0
  14. package/dist/core/runtime/console-accounts.d.ts +52 -0
  15. package/dist/core/runtime/console-accounts.js +103 -0
  16. package/dist/core/runtime/mcp-keys.d.ts +65 -0
  17. package/dist/core/runtime/mcp-keys.js +171 -0
  18. package/dist/core/runtime/oauth/model.d.ts +44 -0
  19. package/dist/core/runtime/oauth/model.js +345 -0
  20. package/dist/core/runtime/oauth/public-client.d.ts +28 -0
  21. package/dist/core/runtime/oauth/public-client.js +35 -0
  22. package/dist/core/runtime/oauth/settings.d.ts +33 -0
  23. package/dist/core/runtime/oauth/settings.js +96 -0
  24. package/dist/core/runtime/secrets.d.ts +15 -0
  25. package/dist/core/runtime/secrets.js +48 -7
  26. package/dist/elt/application/autosync.d.ts +71 -0
  27. package/dist/elt/application/autosync.js +173 -0
  28. package/dist/elt/application/detached-sync.d.ts +7 -2
  29. package/dist/elt/application/detached-sync.js +10 -4
  30. package/dist/elt/application/management.d.ts +7 -0
  31. package/dist/elt/application/management.js +103 -7
  32. package/dist/elt/ingestion/importer.d.ts +11 -1
  33. package/dist/elt/ingestion/importer.js +63 -14
  34. package/dist/elt/ingestion/providers/intervals.d.ts +7 -0
  35. package/dist/elt/ingestion/providers/intervals.js +21 -2
  36. package/dist/elt/normalization/normalizers.js +122 -0
  37. package/dist/elt/storage/database.d.ts +6 -1
  38. package/dist/elt/storage/database.js +7 -1
  39. package/dist/elt/storage/migrations.js +45 -1
  40. package/dist/elt/storage/progress-sidecar.d.ts +9 -0
  41. package/dist/elt/storage/progress-sidecar.js +31 -16
  42. package/dist/interfaces/cli/commands.js +117 -2
  43. package/dist/interfaces/http/cli-options.d.ts +64 -1
  44. package/dist/interfaces/http/cli-options.js +214 -2
  45. package/dist/interfaces/http/oauth.d.ts +16 -0
  46. package/dist/interfaces/http/oauth.js +228 -0
  47. package/dist/interfaces/http/server.d.ts +60 -1
  48. package/dist/interfaces/http/server.js +810 -16
  49. package/dist/interfaces/mcp/main.js +35 -3
  50. package/dist/interfaces/mcp/server.d.ts +21 -1
  51. package/dist/interfaces/mcp/server.js +107 -21
  52. package/dist/runtime/index.d.ts +15 -3
  53. package/dist/runtime/index.js +9 -2
  54. package/docs/architecture.md +1 -1
  55. package/docs/configuration.md +76 -6
  56. package/docs/deployment/docker.md +173 -15
  57. package/docs/deployment/local-console.md +132 -21
  58. package/docs/deployment/local-mcp.md +190 -11
  59. package/docs/development.md +5 -3
  60. package/docs/llm-providers.md +4 -4
  61. package/package.json +11 -4
  62. package/pyproject.toml +1 -1
  63. package/python/catence/providers/garmin/cli.py +55 -7
  64. package/python/catence/providers/garmin/progress.py +71 -35
  65. package/python/catence/providers/garmin/staging.py +11 -4
  66. package/python/catence/providers/garmin/worker.py +420 -206
  67. package/uv.lock +3 -3
@@ -81,14 +81,19 @@ catence
81
81
  ```
82
82
 
83
83
  An agent first calls `list_athletes`, then includes `athleteId` with every
84
- data tool. This is intentional: a shared agent may access the stores you
85
- configured, but no tool implicitly aggregates or crosses between athletes.
84
+ data tool. Tools act only on the named athlete store and never merge athletes
85
+ implicitly; the MCP server does not authenticate callers — it trusts the
86
+ `athleteId` each call names.
86
87
 
87
88
  ## Multi-athlete catalog
88
89
 
89
- Catence is designed to serve **several isolated athlete stores** from one
90
- shared server. Each store has its own DuckDB, raw data, and tokens, so
91
- athletes never leak into each other.
90
+ Catence serves several athlete stores from one server. Each store has its own
91
+ DuckDB, raw data, and tokens; a call acts on the single store it names and
92
+ never merges athletes implicitly. That is a tool contract, not an access
93
+ boundary: the stdio server does not authenticate callers and trusts the
94
+ `athleteId` they pass — over Streamable HTTP the bearer gate, per-account
95
+ keys, or OAuth authenticate and scope callers; see
96
+ [MCP clients and HTTP](#streamable-http-server).
92
97
 
93
98
  ### MCP behavior
94
99
 
@@ -104,7 +109,9 @@ athletes never leak into each other.
104
109
  Strava hydration tools: it spawns a detached `catence-data sync` child for
105
110
  the selected athlete and returns immediately with the run handle and log
106
111
  file. It refuses to start while another sync run is active (`sync_in_progress`);
107
- follow the run with `catence_sync_progress`.
112
+ follow the run with `catence_sync_progress`. Pass `backfill: true` with a
113
+ `from` date to spawn `catence-data backfill` instead — history is re-imported
114
+ backwards from that date without advancing the incremental sync cursors.
108
115
 
109
116
  ### Adding another athlete
110
117
 
@@ -299,7 +306,60 @@ catence serve --host 127.0.0.1 --port 8787
299
306
  By default it is loopback-only. `CATENCE_HTTP_HOST` and `CATENCE_HTTP_PORT`
300
307
  override the host and port when the flags are omitted.
301
308
 
309
+ #### Bearer-token authentication
310
+
311
+ The server has no authentication of its own; when it must be reachable beyond
312
+ loopback (for example through a Cloudflare Tunnel to an MCP client such as
313
+ Mistral Work), switch on the bearer-token gate. The gate is armed at a token
314
+ file — `<catalog-home>/mcp-token` by default, or the path given by
315
+ `--auth-token-file` (environment equivalent: `CATENCE_MCP_TOKEN_FILE`) — and
316
+ the **file's presence is the switch**: create it to enable, remove it to
317
+ disable, no restart needed. The gate is also armed when the per-account key
318
+ registry holds at least one active key or when OAuth is enabled (see the next
319
+ sections); only with no token file, no keys, and no OAuth configured is the
320
+ server open.
321
+
322
+ Catence generates the token itself; never hand-craft one:
323
+
324
+ ```sh
325
+ catence-data mcp status # enabled, token file, endpoint URLs
326
+ catence-data mcp enable # generate the token file (mode 0600) if missing
327
+ catence-data mcp token # print the token for the client credential flow
328
+ catence-data mcp disable # remove the file; remote access is refused again
329
+ ```
330
+
331
+ - Every endpoint except `/health` and `/api/v1/health` (kept open for readiness
332
+ probes) answers `401` without an exact `Authorization: Bearer <token>`
333
+ header while the gate is on. A request with no token gets no
334
+ `WWW-Authenticate` at all, so MCP clients with auth auto-detection (Mistral
335
+ Work) classify the server as bearer-auth rather than OAuth; with OAuth
336
+ enabled a token-less `401` instead gains a
337
+ `WWW-Authenticate: Bearer resource_metadata="<public-url>/.well-known/oauth-protected-resource/mcp"`
338
+ challenge — see [OAuth 2.1 (ChatGPT/Codex)](#oauth-21-chatgptcodex). A request
339
+ that presents a wrong or expired token gets
340
+ `WWW-Authenticate: Bearer error="invalid_token", error_description="The access token is invalid or expired."`
341
+ with no `resource_metadata`: the client already holds a credential, so it is
342
+ told the token is bad instead of being steered into the OAuth flow.
343
+ - CORS preflights (`OPTIONS`) pass through without a token.
344
+ - An existing but unreadable or empty token file fails closed: the gate
345
+ rejects everything except the health probes until the file is fixed or
346
+ removed.
347
+ - `GET /api/v1/mcp-exposure` reports the same status the Console Settings page
348
+ shows (including the token); `POST /api/v1/mcp-exposure` with
349
+ `{"enabled": true|false}` toggles the gate. The Console exposes both to
350
+ admins only.
351
+ - Rotation: `catence-data mcp disable && catence-data mcp enable` (or rewrite
352
+ the file), then re-authenticate the MCP client. Keep the token out of
353
+ `.env`, `config.json`, shell history, and repositories.
354
+ - The Console attaches the same header automatically to its runtime calls and
355
+ reads the token file lazily, so enabling the gate from the Console or CLI
356
+ needs no Console restart either.
357
+
302
358
  - `GET /api/v1/athletes` returns IDs and labels only.
359
+ - `POST /api/v1/athletes` creates a catalog athlete from
360
+ `{"id": "sam", "label": "Sam", "setDefault": false}` and answers with the
361
+ updated roster; duplicate ids are refused with `409 athlete_exists`, and
362
+ single-store runtimes without a catalog with `400 catalog_required`.
303
363
  - `GET /api/v1/dashboard` requires `athleteId`, for example
304
364
  `http://127.0.0.1:8787/api/v1/dashboard?athleteId=alex&days=28` (`endDate`
305
365
  is an optional `YYYY-MM-DD`; `days` ranges 1-90 and defaults to 28).
@@ -309,7 +369,11 @@ override the host and port when the flags are omitted.
309
369
  returns `202` with the run handle immediately; an already-active run is
310
370
  refused with `409 sync_in_progress`. Pass `"refreshModels": true` to also
311
371
  refresh OpenCode Go model profiles first (failure is a non-fatal warning in
312
- the response).
372
+ the response). Pass `"backfill": true` with a required `"from"` date
373
+ (`YYYY-MM-DD`) to run `catence-data backfill` instead: history is re-imported
374
+ backwards from that date without advancing the incremental cursors. The
375
+ Console's Status tab exposes the same control (date picker + "Backfill
376
+ history").
313
377
  - `GET /api/v1/sync/status?athleteId=alex` reports live progress heartbeats,
314
378
  recent runs, and best-effort last-completion timestamps per provider. It
315
379
  keeps working while a sync holds the write lock (`lastSync` becomes `null`
@@ -323,7 +387,122 @@ override the host and port when the flags are omitted.
323
387
  packaged Console instead proxies the dashboard through its authenticated
324
388
  same-origin route.
325
389
 
326
- Connect HTTP-based clients to `http://127.0.0.1:8787/mcp`. There is **no
327
- authentication on the MCP server itself** — it trusts the caller's `athleteId`
328
- parameter — so keep it on loopback or restrict it at the network layer. For
329
- remote access over SSH: `ssh -L 8787:127.0.0.1:8787 server`.
390
+ #### Per-account API keys
391
+
392
+ The shared bearer token is a single admin credential. For per-account access,
393
+ create API keys bound to Console accounts (`console/accounts.json`). A key acts
394
+ as its account: an admin account's key sees every athlete, a member account's
395
+ key sees only that account's granted athletes.
396
+
397
+ The key registry lives at `<catalog-home>/mcp-keys.json` — override with
398
+ `--mcp-keys-file` or `CATENCE_MCP_KEYS_FILE`. In the Docker deployment the
399
+ default is `/data/mcp-keys.json` inside the data volume: runtime-managed, no
400
+ host mount needed. The file is written atomically with mode 0600 and stores
401
+ only SHA-256 token hashes. Any active key in the registry arms the gate,
402
+ exactly like the token file's presence does.
403
+
404
+ ```sh
405
+ catence-data mcp status # gate, token, key counts, OAuth state
406
+ catence-data mcp key add --account coach --label "vibe" # prints the raw token exactly once
407
+ catence-data mcp key list # ids, labels, accounts — never hashes
408
+ catence-data mcp key roll <id> # new raw token, printed exactly once
409
+ catence-data mcp key revoke <id> # stops working immediately
410
+ ```
411
+
412
+ All four accept `--file <path>` for a non-default registry. Every Console
413
+ account manages its own keys in Settings → API keys (create with a label — the
414
+ raw token is shown once — plus roll and revoke); admins additionally see the
415
+ whole registry in Settings → MCP Exposure → API keys with an account picker.
416
+ The HTTP API `GET`/`POST`/`DELETE /api/v1/mcp-keys` and `POST
417
+ /api/v1/mcp-keys/roll` enforce the same boundary for direct callers and for
418
+ key- or OAuth-authenticated sessions: an admin principal may manage any
419
+ account's keys, any other principal only its own (`403 admin_required`
420
+ otherwise), and a non-admin's list shows only its own account's keys.
421
+
422
+ Scope semantics:
423
+
424
+ - A key is bound to a Console account; its athlete scope is the account's
425
+ grant list, resolved live from `console/accounts.json` on every request.
426
+ - Scope is enforced in the runtime for both MCP tools and the HTTP APIs: an
427
+ out-of-scope `athleteId` is refused with `403 athlete_forbidden` over HTTP
428
+ (`athlete_forbidden` tool error over MCP), and `list_athletes`,
429
+ `catence://athletes`, and `GET /api/v1/athletes` are filtered to the scope.
430
+ - Admin-only APIs (`POST /api/v1/athletes`, `/api/v1/mcp-exposure`,
431
+ `/api/v1/models/discover`) require an admin principal: the legacy token is
432
+ always admin, an account key is admin only when its account is. The key
433
+ registry APIs are account-scoped instead of admin-only — admins manage every
434
+ account's keys, any other principal only its own (see above).
435
+ - Fail closed: a revoked key, a deleted account, or an unreadable registry or
436
+ accounts file rejects the request (`401`) — never an anonymous fallback.
437
+ - The raw token is shown exactly once (on `add` and `roll`); only its hash is
438
+ stored. Keep it in a password manager, out of `.env`, `config.json`, shell
439
+ history, and repositories.
440
+ - `mcp disable` only closes the shared-token path: while keys or OAuth are
441
+ configured, exposure stays armed through them.
442
+
443
+ #### OAuth 2.1 (ChatGPT/Codex)
444
+
445
+ `catence serve --oauth --public-url <url>` runs an OAuth 2.1 authorization
446
+ server inside the runtime so ChatGPT and Codex can connect with their native
447
+ OAuth flow. The environment equivalents are `CATENCE_OAUTH_ENABLED=1|true` and
448
+ `CATENCE_PUBLIC_URL=<url>`.
449
+
450
+ From the Console, open Settings → MCP exposure → **OAuth 2.1 sign-in**: set the
451
+ public URL and flip the switch. The Console writes
452
+ `<catalog-home>/oauth/settings.json`, which the runtime reads on every request —
453
+ the change applies immediately, without a restart. Once a valid settings file
454
+ exists it alone decides whether OAuth is enabled and overrides the
455
+ `CATENCE_OAUTH_ENABLED`/`CATENCE_PUBLIC_URL` (or `--oauth`/`--public-url`)
456
+ startup values; a malformed file fails closed to disabled until the Console
457
+ saves a valid one. `catence-data mcp status` reports the effective state and
458
+ its `source` (`console`, `environment`, or `disabled`).
459
+
460
+ - The issuer is the public URL: it must be an absolute `https` URL with no
461
+ query string or fragment (typically the tunnel URL), and it is required when
462
+ OAuth is enabled — the server refuses to start without it (startup values)
463
+ and the Console write rejects it (settings values).
464
+ - Clients discover the server through RFC 8414/RFC 9728 metadata served at
465
+ `/.well-known/oauth-authorization-server` and
466
+ `/.well-known/oauth-protected-resource[/mcp]`. Dynamic client registration
467
+ (`POST /oauth/register`, RFC 7591) and client ID metadata documents (CIMD)
468
+ are enabled; PKCE with S256 is enforced.
469
+ - The browser consent page lives at `<public-url>/oauth/consent`: it logs in
470
+ with a Console account (username + password, verified against
471
+ `console/accounts.json`) and asks to approve or deny the client. The access
472
+ token carries the account username, and the athlete scope is that account's
473
+ live grants — scoped exactly like an account key.
474
+ - While OAuth is enabled, the `401` for a request with no bearer token gains
475
+ an RFC 9728 challenge —
476
+ `WWW-Authenticate: Bearer resource_metadata="<public-url>/.well-known/oauth-protected-resource/mcp"`
477
+ — so OAuth clients can discover the authorization server. A presented but
478
+ invalid token gets `invalid_token` without the challenge instead. Without
479
+ `--oauth` the `401` stays plain on purpose (Mistral connector
480
+ auto-detection).
481
+ - Clients that register (DCR) or publish a client ID metadata document with
482
+ `token_endpoint_auth_method: "private_key_jwt"` — OpenAI's ChatGPT/Codex
483
+ connector does — are accepted as public clients: the declaration is
484
+ downgraded to `"none"`, no client secret is issued, and PKCE with S256
485
+ still protects the flow. Catence cannot verify client JWT assertions, so
486
+ the method is never honored as such.
487
+ - Authorization-server state (registered clients, authorization codes, tokens)
488
+ lives in `<catalog-home>/oauth/` (directory mode 0700, files 0600).
489
+
490
+ Note: the `resource_metadata` challenge on token-less `401`s can flip a client
491
+ with auth auto-detection (Mistral Work) from bearer to OAuth — smoke-test the
492
+ Mistral connector after enabling OAuth. A client that already sends a key
493
+ never sees the challenge; it only gets `invalid_token` when the key is wrong.
494
+
495
+ #### Client matrix
496
+
497
+ | Client | Setup | Credential |
498
+ | --- | --- | --- |
499
+ | Mistral Vibe (Work custom connector) | Connectors → Add Connector → Custom MCP Connector, URL `https://<host>/mcp` | Bearer credential (the shared token or a per-account API key) |
500
+ | Claude (custom connector) | Authentication: "No sign in"; Request headers: `Authorization: Bearer <key>` | Per-account API key |
501
+ | Claude Code (CLI) | `claude mcp add --transport http --header "Authorization: Bearer <key>" <url>` | Per-account API key |
502
+ | ChatGPT / Codex | Connector OAuth connect flow | OAuth 2.1 (Console: MCP exposure → OAuth 2.1 sign-in, or `--oauth`) |
503
+
504
+ Connect HTTP-based clients to `http://127.0.0.1:8787/mcp`. Without the bearer
505
+ gate, per-account keys, or OAuth there is **no authentication on the MCP
506
+ server itself** — it trusts the caller's `athleteId` parameter — so keep it on
507
+ loopback or restrict it at the network layer. For remote access over SSH:
508
+ `ssh -L 8787:127.0.0.1:8787 server`.
@@ -321,9 +321,11 @@ Recommended dev defaults:
321
321
  - The deployed scaffold's `sync.sh` / `hash-password.sh` / `doctor.sh` helpers are generated by `scripts/deploy-console.sh`; the dev container does not generate them — use the `npm run` / `uv run` equivalents above, or run the same `catence-data --home /data` commands via `docker compose exec`.
322
322
  - If the Console settings panel shows no Model dropdown, the data home's `config.json` has no `console.profiles` — re-copy `config.example.json` or re-run the discovery script, then restart the Console.
323
323
 
324
- ### Status vs Dashboard
324
+ ### Settings, Status, and Dashboard
325
325
 
326
- The **Dashboard** (`/dashboard`) is now a pure analytics view — it renders charts and recent activities from `GET /api/v1/dashboard` and, when a detached sync holds the DuckDB write lock, shows a non-blocking amber banner (“Data temporarily unavailable — a sync is running. View live progress on the Status page.”) instead of a full-page `dashboard_unavailable` error. The **Status** page (`/status`) is the lock-safe control plane: it reads `GET /api/v1/health` (runtime `catenceRuntimeHealth()`), `GET /api/v1/sync/status` (sidecar heartbeats + `lastSync`), and `GET /api/v1/athletes` (roster), so it never needs a successful `dashboardSnapshot` and never 400s under lock. The interactive **Sync** button (with instant grey-out via `startingRef`) lives only on Status; Dashboard keeps a read-only “Syncing…” pill and a “Sync moved to Status →” banner.
326
+ Top-level navigation is **Chat**, **Dashboard**, and **Settings**; the chat-history sidebar only renders on Chat routes, so the other pages use the full width. **Settings** (`/settings`) is a single page whose tabs — Profile, Status, and Models for every account, plus Athletes and Accounts for admins — are deep-linkable as `/settings?tab=<tab>`; asking for a tab the account cannot see falls back to the first visible one.
327
+
328
+ The **Dashboard** (`/dashboard`) is a pure analytics view — it renders charts and recent activities from `GET /api/v1/dashboard` and, when a detached sync holds the DuckDB write lock, shows a non-blocking amber banner (“Data temporarily unavailable — a sync is running. View live progress in Settings → Status.”) instead of a full-page `dashboard_unavailable` error. **Settings → Status** (`/settings?tab=status`) is the lock-safe control plane: it reads `GET /api/v1/health` (runtime `catenceRuntimeHealth()`), `GET /api/v1/sync/status` (sidecar heartbeats + `lastSync`), and `GET /api/v1/athletes` (roster), so it never needs a successful `dashboardSnapshot` and never 400s under lock. The interactive **Sync** button (with instant grey-out via `startingRef`) lives only there; Dashboard keeps a read-only “Syncing…” pill and a “Sync moved to Settings → Status” banner.
327
329
 
328
330
  ## Troubleshooting
329
331
 
@@ -334,4 +336,4 @@ The **Dashboard** (`/dashboard`) is now a pure analytics view — it renders cha
334
336
  1. Runtime status API: `curl -s "http://127.0.0.1:8787/api/v1/sync/status?athleteId=<id>" | jq '.progress.running'` (through the Console instead: `/api/v1/sync/status` behind your login).
335
337
  2. Detached-child log files: `ls -t "$CATENCE_HOME/logs" | head`. A successful `POST /api/v1/sync` creates `logs/sync-<athlete>-<stamp>.log` before spawning, so an absent or empty `logs/` directory proves no detached sync ever started.
336
338
  3. The MCP progress tools (`catence_sync_progress`) read the same heartbeats.
337
- - **Models page clutter** — unused shipped profiles can be hidden per device from the Models page (`Hide` on the profile card); hidden profiles disappear from the chat Model dropdown but stay configured, and are restored with `Unhide`. Hiding lives in the Console database, never in `config.json`.
339
+ - **Models page clutter** — unused shipped profiles can be hidden per device from **Settings → Models** (`Hide` on the profile card); hidden profiles disappear from the chat Model dropdown but stay configured, and are restored with `Unhide`. Hiding lives in the Console database, never in `config.json`.
@@ -203,10 +203,10 @@ of the live catalog are pruned from the managed profiles, so the Console never
203
203
  offers a route the gateway no longer serves; if a pruned model was the
204
204
  profile's default, discovery selects a current one instead.
205
205
 
206
- You do not have to run the script manually: the Console dashboard's **Sync
207
- data** button (and `POST /api/v1/sync` with `"refreshModels": true`) refreshes
208
- these profiles from the live catalog before starting a data sync, and the
209
- Models page's **Discover OpenCode Go models** button (or
206
+ You do not have to run the script manually: the **Sync data** button in
207
+ **Settings → Status** (and `POST /api/v1/sync` with `"refreshModels": true`)
208
+ refreshes these profiles from the live catalog before starting a data sync, and
209
+ the **Discover OpenCode Go models** button in **Settings → Models** (or
210
210
  `POST /api/v1/models/discover`) refreshes them without syncing anything.
211
211
  Discovery failures — offline machine, catalog unreachable — are reported as a
212
212
  warning and never block or fail the sync itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.2",
3
+ "version": "0.2.1-beta.21",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -55,6 +55,7 @@
55
55
  "test:watch": "vitest",
56
56
  "catence-data": "tsx src/interfaces/cli/main.ts",
57
57
  "mcp": "tsx src/interfaces/mcp/main.ts",
58
+ "dev": "node scripts/dev.mjs",
58
59
  "console": "uv run --project console catence-console serve",
59
60
  "console:doctor": "uv run --project console catence-console doctor",
60
61
  "discover:opencode-go": "node scripts/discover-opencode-go.mjs",
@@ -70,20 +71,26 @@
70
71
  "dependencies": {
71
72
  "@duckdb/node-api": "^1.4.4-r.1",
72
73
  "@modelcontextprotocol/sdk": "^1.30.0",
74
+ "bcryptjs": "3.0.3",
73
75
  "commander": "^14.0.0",
74
76
  "dotenv": "^17.2.2",
77
+ "express": "5.3.0",
78
+ "express-rate-limit": "8.7.1",
75
79
  "intervals-icu": "^2.2.0",
80
+ "mcp-oauth-server": "1.0.1",
76
81
  "zod": "^4.1.5"
77
82
  },
78
83
  "devDependencies": {
84
+ "@types/bcryptjs": "2.4.6",
85
+ "@types/express": "5.0.6",
79
86
  "@types/node": "^24.3.0",
80
87
  "tsx": "^4.20.5",
81
88
  "typescript": "^5.9.2",
82
- "vitest": "^3.2.4"
89
+ "vitest": "^5.0.0"
83
90
  },
84
91
  "overrides": {
85
- "fast-uri": "3.1.7",
86
- "hono": "4.12.34",
92
+ "fast-uri": "3.1.8",
93
+ "hono": "4.13.7",
87
94
  "nanoid": "3.3.18",
88
95
  "qs": "6.16.0"
89
96
  }
package/pyproject.toml CHANGED
@@ -15,7 +15,7 @@ dependencies = [
15
15
 
16
16
  [tool.uv.sources]
17
17
  # fork commit
18
- garminconnect = { git = "https://github.com/rifusaki/python-garminconnect.git", rev = "cd4e3d9ef81a93681950138e08f57af226fd5735" }
18
+ garminconnect = { git = "https://github.com/rifusaki/python-garminconnect.git", rev = "38b3d5003ae348de384f679976d0527316775f94" }
19
19
 
20
20
  [tool.uv]
21
21
  package = false
@@ -8,6 +8,7 @@ import signal
8
8
  import sys
9
9
  from datetime import date
10
10
  from pathlib import Path
11
+ from typing import Any
11
12
 
12
13
  from .progress import ProgressReporter
13
14
  from .staging import StagingWriter
@@ -40,7 +41,31 @@ def parse_args() -> argparse.Namespace:
40
41
  type=date.fromisoformat,
41
42
  help="Fetch calendar events from this date instead of the default short lookback.",
42
43
  )
43
- return parser.parse_args()
44
+ parser.add_argument(
45
+ "--concurrency",
46
+ type=int,
47
+ default=5,
48
+ help="Maximum in-flight Garmin requests (1 disables request parallelization).",
49
+ )
50
+ args = parser.parse_args()
51
+ if args.concurrency < 1:
52
+ parser.error("--concurrency must be at least 1")
53
+ return args
54
+
55
+
56
+ def _print_run_summary(run_id: str, worker: GarminStagingWorker, reporter: ProgressReporter) -> None:
57
+ """Emit one stderr record with per-stage timings and capture counts.
58
+
59
+ The management layer surfaces stderr in the sync log, so quiet-day runs
60
+ carry their own performance profile without an extra instrumentation pass.
61
+ """
62
+ record: dict[str, Any] = {
63
+ "kind": "stage_timings",
64
+ "runId": run_id,
65
+ "timings": {name: round(seconds, 2) for name, seconds in reporter.stage_summary().items()},
66
+ }
67
+ record.update(worker.capture_summary())
68
+ print(json.dumps(record, separators=(",", ":")), file=sys.stderr, flush=True)
44
69
 
45
70
 
46
71
  def main() -> None:
@@ -50,12 +75,32 @@ def main() -> None:
50
75
  email = os.environ.get("GARMIN_EMAIL")
51
76
  if not email:
52
77
  raise SystemExit("GARMIN_EMAIL is required.")
53
- password = os.environ.get("GARMIN_PASSWORD") or getpass.getpass("Garmin password: ")
54
- writer = StagingWriter(args.data_dir, args.output)
55
- writer.manifest(args.run_id, args.from_date.isoformat())
56
78
  token_store = args.data_dir / "auth" / "garmin_tokens.json"
57
79
  token_store.parent.mkdir(parents=True, exist_ok=True)
58
- client = Garmin(email, password, prompt_mfa=lambda: input("Garmin MFA code: "))
80
+ if not sys.stdin.isatty():
81
+ # Unattended auto-sync must never hang on a hidden prompt: fail fast
82
+ # so the run records a visible error instead.
83
+ password = os.environ.get("GARMIN_PASSWORD")
84
+ if not password and not token_store.exists():
85
+ raise SystemExit(
86
+ "Garmin login is required (no stored token, no GARMIN_PASSWORD, "
87
+ "and stdin is not interactive). Run an interactive sync once to "
88
+ "store the session token, or set GARMIN_PASSWORD."
89
+ )
90
+ else:
91
+ password = os.environ.get("GARMIN_PASSWORD") or getpass.getpass("Garmin password: ")
92
+
93
+ def prompt_mfa() -> str:
94
+ if not sys.stdin.isatty():
95
+ raise SystemExit(
96
+ "Garmin MFA is required but stdin is not interactive; the "
97
+ "session token expired. Run an interactive sync once to renew it."
98
+ )
99
+ return input("Garmin MFA code: ")
100
+
101
+ writer = StagingWriter(args.data_dir, args.output)
102
+ writer.manifest(args.run_id, args.from_date.isoformat())
103
+ client = Garmin(email, password, prompt_mfa=prompt_mfa)
59
104
  client.login(str(token_store))
60
105
  reporter = ProgressReporter(args.run_id)
61
106
  reporter.set_stage("login")
@@ -65,7 +110,7 @@ def main() -> None:
65
110
  loaded = json.loads(args.known_activities.read_text(encoding="utf-8"))
66
111
  if isinstance(loaded, dict):
67
112
  known_hashes = {str(key): str(value) for key, value in loaded.items()}
68
- worker = GarminStagingWorker(client, writer, args.data_dir, known_hashes, progress=reporter)
113
+ worker = GarminStagingWorker(client, writer, args.data_dir, known_hashes, progress=reporter, concurrency=args.concurrency)
69
114
  interrupt_signal: int | None = None
70
115
 
71
116
  def _request_interrupt(signum: int, _frame: object) -> None:
@@ -89,7 +134,10 @@ def main() -> None:
89
134
  except WorkerInterrupted:
90
135
  reporter.finish("interrupted")
91
136
  raise SystemExit(130 if interrupt_signal == signal.SIGINT else 143)
92
- reporter.finish("completed")
137
+ else:
138
+ reporter.finish("completed")
139
+ finally:
140
+ _print_run_summary(args.run_id, worker, reporter)
93
141
 
94
142
 
95
143
  if __name__ == "__main__":
@@ -2,19 +2,26 @@
2
2
 
3
3
  Emits single-line JSON progress records (kind: 'progress') on stdout so the
4
4
  Node management layer can persist heartbeats without parsing the staging
5
- JSONL consumed by the importer.
5
+ JSONL consumed by the importer. Captures run on several threads once sync
6
+ concurrency is enabled, so every state mutation happens under a lock while
7
+ the actual emit stays outside it.
8
+
9
+ The reporter also accumulates per-stage wall-clock timings; the CLI prints
10
+ them to stderr as a single `stage_timings` record so each sync run carries
11
+ its own phase profile.
6
12
  """
7
13
 
8
14
  from __future__ import annotations
9
15
 
10
16
  import json
17
+ import threading
11
18
  import time
12
19
  from datetime import datetime, timezone
13
20
  from typing import Any, Callable
14
21
 
15
22
 
16
23
  class ProgressReporter:
17
- """Throttled progress publisher writing NDJSON records to stdout."""
24
+ """Throttled, thread-safe progress publisher writing NDJSON records to stdout."""
18
25
 
19
26
  def __init__(
20
27
  self,
@@ -27,59 +34,88 @@ class ProgressReporter:
27
34
  self.provider = provider
28
35
  self.emit = emit
29
36
  self.interval_seconds = interval_seconds
37
+ self._lock = threading.Lock()
30
38
  self._started = time.monotonic()
31
39
  self._last_emitted = 0.0
40
+ self._stage_started = self._started
32
41
  self.stage = "starting"
33
42
  self.current_step: str | None = None
34
43
  self.completed_units = 0
35
44
  self.total_units: int | None = None
45
+ self.stage_timings: dict[str, float] = {}
36
46
 
37
47
  def set_stage(self, stage: str) -> None:
38
48
  """Switch phases, resetting phase-local counters and forcing a publish."""
39
- if stage != self.stage:
49
+ with self._lock:
50
+ if stage == self.stage:
51
+ return
52
+ self._close_stage_locked()
40
53
  self.stage = stage
41
54
  self.current_step = None
42
55
  self.completed_units = 0
43
56
  self.total_units = None
44
- self.publish(force=True)
57
+ self.publish(force=True)
45
58
 
46
59
  def advance(self, completed: int | None = None, total: int | None = None, step: str | None = None) -> None:
47
- if completed is not None:
48
- self.completed_units = completed
49
- if total is not None:
50
- self.total_units = total
51
- if step is not None:
52
- self.current_step = step
60
+ with self._lock:
61
+ if completed is not None:
62
+ self.completed_units = completed
63
+ if total is not None:
64
+ self.total_units = total
65
+ if step is not None:
66
+ self.current_step = step
67
+ self.publish()
68
+
69
+ def add_completed(self, amount: int = 1) -> None:
70
+ """Increment the completed-unit counter without replacing it."""
71
+ with self._lock:
72
+ self.completed_units += amount
53
73
  self.publish()
54
74
 
55
75
  def publish(self, force: bool = False) -> None:
56
76
  now = time.monotonic()
57
- if not force and now - self._last_emitted < self.interval_seconds:
58
- return
59
- self._last_emitted = now
60
- elapsed = now - self._started
61
- percent = 0.0
62
- eta: float | None = None
63
- if self.total_units and self.total_units > 0:
64
- percent = min(100.0, max(0.0, self.completed_units / self.total_units * 100.0))
65
- if self.completed_units > 0 and self.completed_units < self.total_units:
66
- rate = elapsed / self.completed_units
67
- eta = rate * (self.total_units - self.completed_units)
68
- record = {
69
- "kind": "progress",
70
- "runId": self.run_id,
71
- "provider": self.provider,
72
- "stage": self.stage,
73
- "currentStep": self.current_step,
74
- "completedUnits": self.completed_units,
75
- "totalUnits": self.total_units,
76
- "percentComplete": round(percent, 2),
77
- "elapsedSeconds": round(elapsed, 1),
78
- "estimatedRemainingSeconds": round(eta, 1) if eta is not None else None,
79
- "heartbeatAt": datetime.now(timezone.utc).isoformat(),
80
- }
77
+ with self._lock:
78
+ if not force and now - self._last_emitted < self.interval_seconds:
79
+ return
80
+ self._last_emitted = now
81
+ elapsed = now - self._started
82
+ percent = 0.0
83
+ eta: float | None = None
84
+ if self.total_units and self.total_units > 0:
85
+ percent = min(100.0, max(0.0, self.completed_units / self.total_units * 100.0))
86
+ if 0 < self.completed_units < self.total_units:
87
+ rate = elapsed / self.completed_units
88
+ eta = rate * (self.total_units - self.completed_units)
89
+ record: dict[str, Any] = {
90
+ "kind": "progress",
91
+ "runId": self.run_id,
92
+ "provider": self.provider,
93
+ "stage": self.stage,
94
+ "currentStep": self.current_step,
95
+ "completedUnits": self.completed_units,
96
+ "totalUnits": self.total_units,
97
+ "percentComplete": round(percent, 2),
98
+ "elapsedSeconds": round(elapsed, 1),
99
+ "estimatedRemainingSeconds": round(eta, 1) if eta is not None else None,
100
+ "heartbeatAt": datetime.now(timezone.utc).isoformat(),
101
+ }
102
+ # Emitting outside the lock keeps a slow pipe from stalling captures.
81
103
  self.emit(json.dumps(record, separators=(",", ":")))
82
104
 
105
+ def stage_summary(self) -> dict[str, float]:
106
+ """Per-stage elapsed seconds, including the still-open stage."""
107
+ with self._lock:
108
+ summary = dict(self.stage_timings)
109
+ summary[self.stage] = summary.get(self.stage, 0.0) + (time.monotonic() - self._stage_started)
110
+ return summary
111
+
83
112
  def finish(self, stage: str = "completed") -> None:
84
- self.stage = stage
113
+ with self._lock:
114
+ self._close_stage_locked()
115
+ self.stage = stage
116
+ self._stage_started = time.monotonic()
85
117
  self.publish(force=True)
118
+
119
+ def _close_stage_locked(self) -> None:
120
+ self.stage_timings[self.stage] = self.stage_timings.get(self.stage, 0.0) + (time.monotonic() - self._stage_started)
121
+ self._stage_started = time.monotonic()
@@ -3,10 +3,12 @@ from __future__ import annotations
3
3
  import hashlib
4
4
  import json
5
5
  import os
6
+ import threading
6
7
  from dataclasses import dataclass
7
8
  from datetime import datetime, timezone
8
9
  from pathlib import Path
9
10
  from typing import Any
11
+ from uuid import uuid4
10
12
 
11
13
  from . import STAGING_SCHEMA_VERSION
12
14
  from .contracts import validate_record
@@ -28,12 +30,15 @@ class StagingWriter:
28
30
  def __post_init__(self) -> None:
29
31
  self.output.parent.mkdir(parents=True, exist_ok=True)
30
32
  self.output.touch(exist_ok=True)
33
+ self._lock = threading.Lock()
31
34
 
32
35
  def emit(self, record: dict[str, Any]) -> None:
33
36
  record["schemaVersion"] = STAGING_SCHEMA_VERSION
34
- with self.output.open("a", encoding="utf-8") as file:
35
- file.write(json.dumps(validate_record(record), separators=(",", ":"), default=str))
36
- file.write("\n")
37
+ line = json.dumps(validate_record(record), separators=(",", ":"), default=str) + "\n"
38
+ # Sync runs fan capture out across threads; serialize whole lines so the
39
+ # JSONL never interleaves, even though each write still opens the file.
40
+ with self._lock, self.output.open("a", encoding="utf-8") as file:
41
+ file.write(line)
37
42
 
38
43
  def manifest(self, run_id: str, from_date: str) -> None:
39
44
  self.emit({
@@ -54,7 +59,9 @@ class StagingWriter:
54
59
  destination = self.data_dir / relative
55
60
  destination.parent.mkdir(parents=True, exist_ok=True)
56
61
  if not destination.exists():
57
- temporary = destination.with_suffix(f".{extension}.tmp")
62
+ # Unique same-directory temp name: concurrent writers can race on
63
+ # the same content hash, and a shared temp path would clobber.
64
+ temporary = destination.with_name(f".{destination.name}.{os.getpid()}.{uuid4().hex}.tmp")
58
65
  temporary.write_bytes(contents)
59
66
  os.replace(temporary, destination)
60
67
  self.emit({