catence 0.2.0-beta.16 → 0.2.0-beta.18

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.
@@ -271,6 +271,24 @@ export class CatenceDatabase {
271
271
  const rows = await this.rows('SELECT remote_activity_id, summary_hash FROM activity_sync_state WHERE provider = $provider', { provider });
272
272
  return Object.fromEntries(rows.map((row) => [row.remote_activity_id, row.summary_hash]));
273
273
  }
274
+ async hasGarminLactateThresholdHistory() {
275
+ const rows = await this.rows("SELECT count(*) AS n FROM training_metric_observations WHERE observation_id LIKE 'garmin:lactate_threshold:daily:%'");
276
+ return (rows[0]?.n ?? 0) > 0;
277
+ }
278
+ async earliestActivityStartDate(provider) {
279
+ const rows = await this.rows(`SELECT min(cast(started_at_utc AS DATE)) AS earliest FROM activities activity
280
+ JOIN activity_sources source USING (activity_id) WHERE source.provider = $provider`, { provider });
281
+ return toIsoDate(rows[0]?.earliest ?? null);
282
+ }
283
+ async garminSwimActivitySources() {
284
+ const sports = ['lap_swimming', 'swimming', 'open_water_swimming', 'openwaterswimming'];
285
+ return this.rows(`SELECT source.remote_activity_id, cast(activity.started_at_utc AS VARCHAR) AS started_on
286
+ FROM activity_sources AS source
287
+ JOIN activities AS activity USING (activity_id)
288
+ WHERE source.provider = 'garmin'
289
+ AND lower(coalesce(activity.sport, '')) IN (${sports.map((sport) => `'${sport}'`).join(', ')})
290
+ ORDER BY activity.started_at_utc`);
291
+ }
274
292
  async latestSourceDate(provider, cursorName) {
275
293
  const observations = cursorName === 'daily'
276
294
  ? `SELECT max(metric_date) AS observed_date FROM daily_metrics WHERE provider = $provider
@@ -1,11 +1,17 @@
1
1
  import { type Server } from 'node:http';
2
- import { type CatalogPaths, type CatencePaths } from '../../runtime/index.js';
2
+ import { type DetachedSyncSpawner, mergeOpenCodeGoConsoleProfiles, startDetachedSync, type CatalogPaths, type CatencePaths } from '../../runtime/index.js';
3
3
  export type CatenceHttpServerOptions = {
4
4
  /** A shared catalog for public servers. All data APIs then require athleteId. */
5
5
  catalogPaths?: CatalogPaths;
6
6
  /** Internal compatibility hook for tests and embedded single-store callers. */
7
7
  paths?: CatencePaths;
8
8
  allowedOrigins?: readonly string[];
9
+ /** Internal compatibility hook for tests: override the detached sync spawner. */
10
+ startSync?: typeof startDetachedSync;
11
+ /** Internal compatibility hook for tests: passed into the default startDetachedSync. */
12
+ syncSpawnProcess?: DetachedSyncSpawner;
13
+ /** Internal compatibility hook for tests: override OpenCode Go model discovery. */
14
+ mergeModels?: typeof mergeOpenCodeGoConsoleProfiles;
9
15
  };
10
16
  /**
11
17
  * Create Catence's local Streamable HTTP server.
@@ -1,6 +1,6 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
- import { catenceRuntimeHealth, DashboardSnapshotService, jsonSafe, loadCatalog, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths } from '../../runtime/index.js';
3
+ import { catenceRuntimeHealth, DashboardSnapshotService, dataStatus, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, } from '../../runtime/index.js';
4
4
  import { createCatenceMcpServer } from '../mcp/server.js';
5
5
  const MAX_REQUEST_BYTES = 1_000_000;
6
6
  function json(response, statusCode, value) {
@@ -85,6 +85,65 @@ async function dashboardPaths(catalogPaths, staticPaths, url) {
85
85
  return staticPaths;
86
86
  throw new Error('No Catence data store is configured.');
87
87
  }
88
+ function parseSyncBody(body) {
89
+ if (body === undefined)
90
+ return {};
91
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
92
+ throw new Error('Request body must be a JSON object.');
93
+ const raw = body;
94
+ for (const key of Object.keys(raw)) {
95
+ if (!['athleteId', 'provider', 'from', 'refresh', 'refreshModels'].includes(key)) {
96
+ throw new Error(`Unknown sync field: ${key}.`);
97
+ }
98
+ }
99
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
100
+ throw new Error('athleteId must be a string.');
101
+ if (raw.provider !== undefined && (typeof raw.provider !== 'string' || !DETACHED_SYNC_PROVIDERS.includes(raw.provider))) {
102
+ throw new Error('provider must be intervals, garmin, strava, or all.');
103
+ }
104
+ if (raw.from !== undefined && (typeof raw.from !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.from))) {
105
+ throw new Error('from must use YYYY-MM-DD.');
106
+ }
107
+ for (const flag of ['refresh', 'refreshModels']) {
108
+ if (raw[flag] !== undefined && typeof raw[flag] !== 'boolean')
109
+ throw new Error(`${flag} must be a boolean.`);
110
+ }
111
+ return raw;
112
+ }
113
+ async function resolveSyncPaths(catalogPaths, staticPaths, body) {
114
+ if (catalogPaths) {
115
+ if (!body.athleteId)
116
+ throw new Error('athleteId is required. Call /api/v1/athletes to inspect the configured catalog.');
117
+ return (await resolveAthlete(catalogPaths, body.athleteId)).paths;
118
+ }
119
+ if (staticPaths)
120
+ return staticPaths;
121
+ throw new Error('No Catence data store is configured.');
122
+ }
123
+ /** Best-effort last-success timestamps; null while a run holds the write lock. */
124
+ async function lastSyncSummary(paths) {
125
+ try {
126
+ const status = await dataStatus(paths);
127
+ const runs = Array.isArray(status.syncRuns) ? status.syncRuns : [];
128
+ const completed = runs.filter((run) => typeof run.completed_at === 'string' && String(run.status).startsWith('completed'));
129
+ const providers = {};
130
+ let lastCompletedAt = null;
131
+ for (const run of completed) {
132
+ const completedAt = String(run.completed_at);
133
+ const provider = String(run.provider ?? 'unknown');
134
+ if (!providers[provider] || providers[provider] < completedAt)
135
+ providers[provider] = completedAt;
136
+ if (!lastCompletedAt || lastCompletedAt < completedAt)
137
+ lastCompletedAt = completedAt;
138
+ }
139
+ return { lastCompletedAt, providers, recentRuns: runs.slice(0, 5) };
140
+ }
141
+ catch (error) {
142
+ if (error instanceof ReadOnlyDatabaseError)
143
+ return null;
144
+ throw error;
145
+ }
146
+ }
88
147
  async function handleMcpRequest(request, response, paths) {
89
148
  if (request.method !== 'POST') {
90
149
  methodNotAllowed(response);
@@ -143,6 +202,8 @@ export function createCatenceHttpServer(options = {}) {
143
202
  const catalogPaths = options.catalogPaths ?? (options.paths ? null : resolveCatalogPaths());
144
203
  const staticPaths = options.paths ?? null;
145
204
  const allowedOrigins = options.allowedOrigins ?? [];
205
+ const startSync = options.startSync ?? startDetachedSync;
206
+ const mergeModels = options.mergeModels ?? mergeOpenCodeGoConsoleProfiles;
146
207
  return createServer((request, response) => {
147
208
  void (async () => {
148
209
  if (!addCorsHeaders(request, response, allowedOrigins)) {
@@ -182,6 +243,48 @@ export function createCatenceHttpServer(options = {}) {
182
243
  }
183
244
  return;
184
245
  }
246
+ if (pathname === '/api/v1/sync' && request.method === 'POST') {
247
+ let body;
248
+ try {
249
+ body = parseSyncBody(await readJsonBody(request));
250
+ const paths = await resolveSyncPaths(catalogPaths, staticPaths, body);
251
+ // Optional model self-discovery runs before the data sync so new
252
+ // OpenCode Go models appear without a manual script run. Failure is
253
+ // non-fatal: it must never block or fail a data sync.
254
+ let discoveryWarning = null;
255
+ if (body.refreshModels) {
256
+ try {
257
+ await mergeModels({ configPath: paths.config, setDefault: false });
258
+ }
259
+ catch (error) {
260
+ discoveryWarning = `OpenCode Go model discovery failed; profiles were left unchanged (${error instanceof Error ? error.message : String(error)}).`;
261
+ }
262
+ }
263
+ const handle = await startSync({ paths, athleteId: body.athleteId ?? 'local', provider: body.provider, from: body.from, refresh: body.refresh }, { spawnProcess: options.syncSpawnProcess });
264
+ json(response, 202, { ...handle, ...(discoveryWarning ? { warning: discoveryWarning } : {}), progressEndpoint: '/api/v1/sync/status' });
265
+ }
266
+ catch (error) {
267
+ json(response, error instanceof DetachedSyncBusyError ? 409 : 400, {
268
+ error: {
269
+ code: error instanceof DetachedSyncBusyError ? 'sync_in_progress' : 'sync_start_failed',
270
+ message: error instanceof Error ? error.message : String(error),
271
+ },
272
+ });
273
+ }
274
+ return;
275
+ }
276
+ if (pathname === '/api/v1/sync/status' && request.method === 'GET') {
277
+ try {
278
+ const url = new URL(request.url ?? '/', 'http://localhost');
279
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url);
280
+ const athleteId = catalogPaths ? (await resolveAthlete(catalogPaths, url.searchParams.get('athleteId') ?? '')).athlete.id : 'local';
281
+ json(response, 200, { athleteId, progress: await syncProgress(paths), lastSync: await lastSyncSummary(paths) });
282
+ }
283
+ catch (error) {
284
+ json(response, 400, { error: { code: 'sync_status_unavailable', message: error instanceof Error ? error.message : String(error) } });
285
+ }
286
+ return;
287
+ }
185
288
  if (pathname === '/mcp') {
186
289
  await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths());
187
290
  return;
@@ -1,8 +1,9 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { type CatalogPaths, hydrateStravaActivity, type CatencePaths } from '../../runtime/index.js';
2
+ import { type CatalogPaths, hydrateStravaActivity, startDetachedSync, type CatencePaths } from '../../runtime/index.js';
3
3
  type StravaActivityHydrator = typeof hydrateStravaActivity;
4
4
  type McpDependencies = {
5
5
  hydrateStravaActivity?: StravaActivityHydrator;
6
+ startDetachedSync?: typeof startDetachedSync;
6
7
  };
7
8
  export declare function createCatenceMcpServer(paths?: CatencePaths | CatalogPaths, dependencies?: McpDependencies): McpServer;
8
9
  export {};
@@ -1,7 +1,7 @@
1
1
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { AsyncLocalStorage } from 'node:async_hooks';
3
3
  import { z } from 'zod';
4
- import { ActivityDiscoveryService, AnalyticsService, loadCatalog, configuredMcpRateLimit, DataWriteBusyError, DATASET_CATALOG, demoStoreMetadata, FitnessService, getDataset, hydrateStravaActivity, hydrateStravaSegmentHistory, jsonSafe, loadCatenceConfig, openReadOnlyRepository, queryReadOnlyData, QueryValidationError, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, searchContext, SlidingWindowLimiter, StravaEnrichmentError, StravaRateLimitError, SwimmingService, WELLNESS_METRICS, WellnessService, } from '../../runtime/index.js';
4
+ import { ActivityDiscoveryService, AnalyticsService, loadCatalog, configuredMcpRateLimit, DataWriteBusyError, DATASET_CATALOG, demoStoreMetadata, DetachedSyncBusyError, FitnessService, getDataset, hydrateStravaActivity, hydrateStravaSegmentHistory, jsonSafe, loadCatenceConfig, openReadOnlyRepository, queryReadOnlyData, QueryValidationError, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, searchContext, SlidingWindowLimiter, startDetachedSync, StravaEnrichmentError, StravaRateLimitError, SwimmingService, WELLNESS_METRICS, WellnessService, } from '../../runtime/index.js';
5
5
  const filterSchema = z.object({
6
6
  column: z.string().min(1),
7
7
  op: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'between', 'contains']),
@@ -32,17 +32,19 @@ function textResult(value) {
32
32
  return { content: [{ type: 'text', text: JSON.stringify(jsonSafe(value), null, 2) }] };
33
33
  }
34
34
  function errorResult(error) {
35
- const classified = error instanceof DataWriteBusyError
36
- ? { code: 'data_sync_in_progress', message: error.message, retryable: true }
37
- : error instanceof StravaRateLimitError
38
- ? { code: 'rate_limited', message: error.message, retryable: true, retryAfterSeconds: error.retryAfterSeconds ?? null }
39
- : error instanceof StravaEnrichmentError
40
- ? { code: 'strava_connection_error', message: error.message }
41
- : error instanceof ReadOnlyDatabaseError
42
- ? { code: error.code, message: error.message }
43
- : error instanceof QueryValidationError
44
- ? { code: 'invalid_request', message: error.message }
45
- : { code: 'data_unavailable', message: error instanceof Error ? error.message : String(error) };
35
+ const classified = error instanceof DetachedSyncBusyError
36
+ ? { code: 'sync_in_progress', message: error.message, retryable: true, runId: error.runId }
37
+ : error instanceof DataWriteBusyError
38
+ ? { code: 'data_sync_in_progress', message: error.message, retryable: true }
39
+ : error instanceof StravaRateLimitError
40
+ ? { code: 'rate_limited', message: error.message, retryable: true, retryAfterSeconds: error.retryAfterSeconds ?? null }
41
+ : error instanceof StravaEnrichmentError
42
+ ? { code: 'strava_connection_error', message: error.message }
43
+ : error instanceof ReadOnlyDatabaseError
44
+ ? { code: error.code, message: error.message }
45
+ : error instanceof QueryValidationError
46
+ ? { code: 'invalid_request', message: error.message }
47
+ : { code: 'data_unavailable', message: error instanceof Error ? error.message : String(error) };
46
48
  return { ...textResult({ data: null, provenance: {}, query: {}, caveats: [classified.message], error: classified }), isError: true };
47
49
  }
48
50
  function hydrationFailure(error) {
@@ -123,6 +125,7 @@ async function activitySegments(repository, activityId, hydratedStravaActivityId
123
125
  export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependencies = {}) {
124
126
  const server = new McpServer({ name: 'catence', version: '0.1.0' }, { instructions: MCP_INSTRUCTIONS });
125
127
  const hydrateActivity = dependencies.hydrateStravaActivity ?? hydrateStravaActivity;
128
+ const startSync = dependencies.startDetachedSync ?? startDetachedSync;
126
129
  const catalogPaths = isCatalogPaths(paths) ? paths : null;
127
130
  const staticPaths = isCatalogPaths(paths) ? null : paths;
128
131
  const scope = new AsyncLocalStorage();
@@ -176,7 +179,12 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
176
179
  ...(demoStore ? { demoStore: { generated: true, seed: demoStore.seed, days: demoStore.days, startDate: demoStore.startDate, endDate: demoStore.endDate }, caveats: [...caveats, 'This response uses Catence generated demo data, not personal measurements.'] } : {}),
177
180
  });
178
181
  };
179
- return selected ? scope.run(selected, run) : run();
182
+ // `return await` matters here: a bare `return run()` would let a
183
+ // rejected promise escape the try block, bypassing the classified
184
+ // errorResult payload entirely.
185
+ if (selected)
186
+ return await scope.run(selected, run);
187
+ return await run();
180
188
  }
181
189
  catch (error) {
182
190
  return errorResult(error);
@@ -238,6 +246,28 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
238
246
  data: await repository.progress(),
239
247
  provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
240
248
  }))));
249
+ server.registerTool('start_detached_sync', {
250
+ title: 'Start a detached data sync',
251
+ description: 'Write-only sync trigger, named like the other explicit lock-guarded write tools. Spawns one detached catence-data sync process for this athlete and returns immediately with the run handle and log file; it refuses while another sync run is active. Track the run with catence_sync_progress; this tool never waits for completion.',
252
+ inputSchema: {
253
+ provider: z.enum(['intervals', 'garmin', 'strava', 'all']).optional(),
254
+ from: z.string().date().optional(),
255
+ refresh: z.boolean().optional(),
256
+ },
257
+ }, tool('start_detached_sync', async (input) => {
258
+ const paths = activePaths();
259
+ const athleteId = activeAthlete()?.id ?? 'local';
260
+ const handle = await startSync({ paths, athleteId, provider: input.provider, from: input.from, refresh: input.refresh });
261
+ return {
262
+ data: { ...handle, progressTool: 'catence_sync_progress' },
263
+ provenance: { operation: 'detached_sync_spawn' },
264
+ query: { ...input },
265
+ caveats: [
266
+ 'The sync runs in its own process and keeps running if this session ends; its log file records the full worker output.',
267
+ 'Only one sync run may be active per athlete store.',
268
+ ],
269
+ };
270
+ }));
241
271
  server.registerTool('review_daily_recovery_load', {
242
272
  title: 'Review daily recovery and training load',
243
273
  description: 'Return source-cited daily health, training, and nutrition facts for a recovery/load review. It does not prescribe a training plan.',
@@ -30,6 +30,10 @@ export { queryReadOnlyData } from '../core/query/sql-guard.js';
30
30
  export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
31
31
  export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
32
32
  export type { ProviderChoice } from '../elt/application/management.js';
33
+ export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
34
+ export type { DetachedSyncHandle, DetachedSyncProvider, DetachedSyncRequest, DetachedSyncSpawner } from '../elt/application/detached-sync.js';
35
+ export { buildOpenCodeGoConsoleProfiles, classifyOpenCodeGoModel, fetchOpenCodeGoModelIds, mergeOpenCodeGoConsoleProfiles, OPENCODE_GO_API_BASE_ENV, OPENCODE_GO_API_KEY_ENV, OPENCODE_GO_DEFAULT_BASE_URL, OPENCODE_GO_MESSAGES_API_BASE_ENV, } from '../core/runtime/opencode-go.js';
36
+ export type { OpenCodeGoConsoleProfiles, OpenCodeGoMergeResult, OpenCodeGoRoute } from '../core/runtime/opencode-go.js';
33
37
  export type { InterruptedRuns, SyncProgressSnapshot, SyncProgressState } from '../contracts/progress.js';
34
38
  export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
35
39
  export { DataWriteBusyError } from '../elt/storage/write-lock.js';
@@ -16,6 +16,8 @@ export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
16
16
  export { queryReadOnlyData } from '../core/query/sql-guard.js';
17
17
  export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
18
18
  export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
19
+ export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
20
+ export { buildOpenCodeGoConsoleProfiles, classifyOpenCodeGoModel, fetchOpenCodeGoModelIds, mergeOpenCodeGoConsoleProfiles, OPENCODE_GO_API_BASE_ENV, OPENCODE_GO_API_KEY_ENV, OPENCODE_GO_DEFAULT_BASE_URL, OPENCODE_GO_MESSAGES_API_BASE_ENV, } from '../core/runtime/opencode-go.js';
19
21
  export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
20
22
  export { DataWriteBusyError } from '../elt/storage/write-lock.js';
21
23
  export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
@@ -160,6 +160,12 @@ Precedence for reasoning effort: per-chat dropdown selection > model
160
160
  `reasoningEffort` > profile `defaultReasoningEffort` > provider default. See
161
161
  [`llm-providers.md`](llm-providers.md#reasoning-effort) for the full behavior.
162
162
 
163
+ The Console's **Models** page edits this section in place (enable/disable,
164
+ add custom variants, remove, set defaults). Disabled choices are stored in the
165
+ Console database rather than here; every write preserves all non-console
166
+ sections and is re-validated with the same strict parser before the file is
167
+ replaced.
168
+
163
169
  ### The Console wizard
164
170
 
165
171
  On the first authenticated chat, the Console wizard (`write_provider_setup`)
@@ -185,6 +191,22 @@ into the provider worker environment, and inherited `GARMIN_*`/`INTERVALS_*`/
185
191
  `STRAVA_*` variables are stripped first so an athlete without a provider cannot
186
192
  inherit its credentials.
187
193
 
194
+ For local and Docker development you can also keep credentials in env without
195
+ `secret set` — see the two fallback modes below. File values always win over
196
+ env, so a `providers.json` entry overrides any env fallback for that field.
197
+
198
+ ### Provider credentials via env (dev fallback)
199
+
200
+ Two opt-in env fallbacks are checked **only when a `providers.json` field is
201
+ missing** — file values always win. See `src/core/runtime/secrets.ts:51`.
202
+
203
+ | Mode | Variables | When allowed | Example for athlete `alex` |
204
+ |------|-----------|--------------|----------------------------|
205
+ | **Per-athlete scoped** (recommended, multi-athlete safe) | `CATENCE_ATHLETE_<ID>_<PROVIDER_VAR>` — prefix is `CATENCE_ATHLETE_` + upper-cased athlete id with `-` → `_` + `_` | **Always** — checked even when the generic flag is off. Only maps to that one athlete. | `CATENCE_ATHLETE_ALEX_GARMIN_EMAIL=alex@example.com` `CATENCE_ATHLETE_ALEX_INTERVALS_API_KEY=…` |
206
+ | **Generic** (single-athlete dev, opt-in) | `GARMIN_EMAIL`, `GARMIN_PASSWORD`, `INTERVALS_API_KEY`, `INTERVALS_ATHLETE_ID`, `STRAVA_CLIENT_ID`, `STRAVA_CLIENT_SECRET` | Only when `CATENCE_ALLOW_ENV_SECRETS` (or alias `CATENCE_SECRETS_FROM_ENV`) is `1`/`true`/`yes`/`on` in the worker's env. Without the flag, bare provider env vars are stripped. | `CATENCE_ALLOW_ENV_SECRETS=1` + `GARMIN_EMAIL=…` |
207
+
208
+ Resolution order per field: `providers.json` → `CATENCE_ATHLETE_<ID>_<VAR>` → (if flag set) `VAR`. Set `CATENCE_ALLOW_ENV_SECRETS=1` in `.env.dev` / `docker-compose.dev.yml` for single-athlete Docker dev, or prefer per-athlete vars for a shared catalog.
209
+
188
210
  ### Console auth and serving
189
211
 
190
212
  | Variable | Purpose |
@@ -119,6 +119,30 @@ Preflight with:
119
119
  catence-console doctor
120
120
  ```
121
121
 
122
+ ### Manage models in the Console
123
+
124
+ The **Models** page (header button) manages the model list without editing
125
+ `config.json` by hand:
126
+
127
+ - Enable/disable toggles hide models from the chat's Model dropdown. Disabled
128
+ choices are stored per machine in the Console database
129
+ (`console/chat-history.sqlite3`), never in `config.json`, and the Console
130
+ refuses to disable the only enabled model.
131
+ - **Add a custom model** appends one deployment to an existing profile — id,
132
+ display label, LiteLLM reference (`openai/…`, `anthropic/…`,
133
+ `openai/responses/…`), and optional fixed reasoning effort or a custom
134
+ variants map.
135
+ - **Remove / Make default** edit `config.json`'s console section directly;
136
+ every other section is preserved, the result is re-validated with the same
137
+ strict parser used at startup before it replaces the file, and secrets stay
138
+ forbidden (only environment-variable names are ever stored).
139
+
140
+ The dashboard header has a **Sync data** button that starts the same detached
141
+ sync as `catence-data sync --provider all` through the authenticated Console
142
+ origin, shows live progress while the run is active, and displays the last
143
+ completed sync afterwards. Each manual sync also refreshes OpenCode Go model
144
+ profiles first; a discovery failure never blocks the data sync.
145
+
122
146
  ## Console serve options
123
147
 
124
148
  `catence-console serve` accepts:
@@ -100,6 +100,11 @@ athletes never leak into each other.
100
100
  - Rate limits are tracked **per athlete**: each athlete has its own sliding
101
101
  window for server, tool, and resource limits.
102
102
  - MCP responses carry an `athlete` field identifying which store served them.
103
+ - `start_detached_sync` is the one explicitly named write tool besides the
104
+ Strava hydration tools: it spawns a detached `catence-data sync` child for
105
+ the selected athlete and returns immediately with the run handle and log
106
+ file. It refuses to start while another sync run is active (`sync_in_progress`);
107
+ follow the run with `catence_sync_progress`.
103
108
 
104
109
  ### Adding another athlete
105
110
 
@@ -298,6 +303,17 @@ override the host and port when the flags are omitted.
298
303
  - `GET /api/v1/dashboard` requires `athleteId`, for example
299
304
  `http://127.0.0.1:8787/api/v1/dashboard?athleteId=alex&days=28` (`endDate`
300
305
  is an optional `YYYY-MM-DD`; `days` ranges 1-90 and defaults to 28).
306
+ - `POST /api/v1/sync` starts a detached data sync for one athlete:
307
+ `{"athleteId": "alex", "provider": "all", "from": "2026-01-01", "refresh": false}`.
308
+ The spawned child outlives the request, logs to `<home>/logs/`, and the call
309
+ returns `202` with the run handle immediately; an already-active run is
310
+ refused with `409 sync_in_progress`. Pass `"refreshModels": true` to also
311
+ refresh OpenCode Go model profiles first (failure is a non-fatal warning in
312
+ the response).
313
+ - `GET /api/v1/sync/status?athleteId=alex` reports live progress heartbeats,
314
+ recent runs, and best-effort last-completion timestamps per provider. It
315
+ keeps working while a sync holds the write lock (`lastSync` becomes `null`
316
+ then).
301
317
  - Browser origins must be listed with `--allow-origin` (repeatable). The
302
318
  packaged Console instead proxies the dashboard through its authenticated
303
319
  same-origin route.