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.
- package/README.md +4 -4
- package/config.example.json +1 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/contracts/runtime.d.ts +4 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +16 -10
- package/dist/core/query/repository.d.ts +1 -1
- package/dist/core/query/repository.js +31 -2
- package/dist/core/retrieval/index.js +23 -5
- package/dist/core/runtime/athlete-file.d.ts +49 -0
- package/dist/core/runtime/athlete-file.js +177 -0
- package/dist/core/runtime/configuration.js +2 -0
- package/dist/core/runtime/console-accounts.d.ts +52 -0
- package/dist/core/runtime/console-accounts.js +103 -0
- package/dist/core/runtime/mcp-keys.d.ts +65 -0
- package/dist/core/runtime/mcp-keys.js +171 -0
- package/dist/core/runtime/oauth/model.d.ts +44 -0
- package/dist/core/runtime/oauth/model.js +345 -0
- package/dist/core/runtime/oauth/public-client.d.ts +28 -0
- package/dist/core/runtime/oauth/public-client.js +35 -0
- package/dist/core/runtime/oauth/settings.d.ts +33 -0
- package/dist/core/runtime/oauth/settings.js +96 -0
- package/dist/core/runtime/secrets.d.ts +15 -0
- package/dist/core/runtime/secrets.js +48 -7
- package/dist/elt/application/autosync.d.ts +71 -0
- package/dist/elt/application/autosync.js +173 -0
- package/dist/elt/application/detached-sync.d.ts +7 -2
- package/dist/elt/application/detached-sync.js +10 -4
- package/dist/elt/application/management.d.ts +7 -0
- package/dist/elt/application/management.js +103 -7
- package/dist/elt/ingestion/importer.d.ts +11 -1
- package/dist/elt/ingestion/importer.js +63 -14
- package/dist/elt/ingestion/providers/intervals.d.ts +7 -0
- package/dist/elt/ingestion/providers/intervals.js +21 -2
- package/dist/elt/normalization/normalizers.js +122 -0
- package/dist/elt/storage/database.d.ts +6 -1
- package/dist/elt/storage/database.js +7 -1
- package/dist/elt/storage/migrations.js +45 -1
- package/dist/elt/storage/progress-sidecar.d.ts +9 -0
- package/dist/elt/storage/progress-sidecar.js +31 -16
- package/dist/interfaces/cli/commands.js +117 -2
- package/dist/interfaces/http/cli-options.d.ts +64 -1
- package/dist/interfaces/http/cli-options.js +214 -2
- package/dist/interfaces/http/oauth.d.ts +16 -0
- package/dist/interfaces/http/oauth.js +228 -0
- package/dist/interfaces/http/server.d.ts +60 -1
- package/dist/interfaces/http/server.js +810 -16
- package/dist/interfaces/mcp/main.js +35 -3
- package/dist/interfaces/mcp/server.d.ts +21 -1
- package/dist/interfaces/mcp/server.js +107 -21
- package/dist/runtime/index.d.ts +15 -3
- package/dist/runtime/index.js +9 -2
- package/docs/architecture.md +1 -1
- package/docs/configuration.md +76 -6
- package/docs/deployment/docker.md +173 -15
- package/docs/deployment/local-console.md +132 -21
- package/docs/deployment/local-mcp.md +190 -11
- package/docs/development.md +5 -3
- package/docs/llm-providers.md +4 -4
- package/package.json +11 -4
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +55 -7
- package/python/catence/providers/garmin/progress.py +71 -35
- package/python/catence/providers/garmin/staging.py +11 -4
- package/python/catence/providers/garmin/worker.py +420 -206
- 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.
|
|
85
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
athletes
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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`.
|
package/docs/development.md
CHANGED
|
@@ -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
|
|
324
|
+
### Settings, Status, and Dashboard
|
|
325
325
|
|
|
326
|
-
|
|
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
|
|
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`.
|
package/docs/llm-providers.md
CHANGED
|
@@ -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
|
|
207
|
-
|
|
208
|
-
these profiles from the live catalog before starting a data sync, and
|
|
209
|
-
|
|
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.
|
|
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": "^
|
|
89
|
+
"vitest": "^5.0.0"
|
|
83
90
|
},
|
|
84
91
|
"overrides": {
|
|
85
|
-
"fast-uri": "3.1.
|
|
86
|
-
"hono": "4.
|
|
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 = "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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({
|