catence 0.2.0-beta.9 → 0.2.0

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 (71) hide show
  1. package/README.md +38 -180
  2. package/dist/contracts/index.d.ts +1 -0
  3. package/dist/contracts/index.js +1 -0
  4. package/dist/contracts/progress.d.ts +23 -0
  5. package/dist/contracts/progress.js +49 -0
  6. package/dist/contracts/release.d.ts +1 -1
  7. package/dist/contracts/release.js +1 -1
  8. package/dist/core/query/activity-discovery.d.ts +2 -0
  9. package/dist/core/query/activity-discovery.js +1 -1
  10. package/dist/core/query/analytics.d.ts +10 -0
  11. package/dist/core/query/analytics.js +494 -0
  12. package/dist/core/query/catalog.d.ts +1 -1
  13. package/dist/core/query/catalog.js +8 -4
  14. package/dist/core/query/events.d.ts +34 -0
  15. package/dist/core/query/events.js +121 -0
  16. package/dist/core/query/fitness.d.ts +4 -0
  17. package/dist/core/query/fitness.js +60 -0
  18. package/dist/core/query/repository.d.ts +2 -0
  19. package/dist/core/query/repository.js +48 -0
  20. package/dist/core/query/swimming.js +2 -2
  21. package/dist/core/runtime/configuration.d.ts +3 -1
  22. package/dist/core/runtime/configuration.js +5 -1
  23. package/dist/core/runtime/opencode-go.d.ts +58 -0
  24. package/dist/core/runtime/opencode-go.js +246 -0
  25. package/dist/core/runtime/secrets.js +51 -13
  26. package/dist/elt/application/detached-sync.d.ts +50 -0
  27. package/dist/elt/application/detached-sync.js +99 -0
  28. package/dist/elt/application/management.d.ts +15 -0
  29. package/dist/elt/application/management.js +420 -18
  30. package/dist/elt/ingestion/importer.js +1 -1
  31. package/dist/elt/ingestion/providers/intervals.d.ts +9 -0
  32. package/dist/elt/ingestion/providers/intervals.js +33 -1
  33. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  34. package/dist/elt/ingestion/providers/registry.js +19 -1
  35. package/dist/elt/normalization/normalizers.js +207 -15
  36. package/dist/elt/storage/database.d.ts +21 -2
  37. package/dist/elt/storage/database.js +182 -6
  38. package/dist/elt/storage/migrations.js +145 -1
  39. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  40. package/dist/elt/storage/progress-sidecar.js +200 -0
  41. package/dist/interfaces/cli/commands.js +94 -1
  42. package/dist/interfaces/http/generation-status.d.ts +19 -0
  43. package/dist/interfaces/http/generation-status.js +55 -0
  44. package/dist/interfaces/http/server.d.ts +7 -1
  45. package/dist/interfaces/http/server.js +184 -1
  46. package/dist/interfaces/mcp/main.js +33 -1
  47. package/dist/interfaces/mcp/server.d.ts +2 -1
  48. package/dist/interfaces/mcp/server.js +168 -45
  49. package/dist/runtime/index.d.ts +9 -2
  50. package/dist/runtime/index.js +6 -2
  51. package/docs/architecture.md +13 -2
  52. package/docs/assets/image.png +0 -0
  53. package/docs/configuration.md +243 -0
  54. package/docs/deployment/docker.md +528 -0
  55. package/docs/deployment/local-console.md +263 -0
  56. package/docs/deployment/local-mcp.md +329 -0
  57. package/docs/development.md +337 -0
  58. package/docs/distribution.md +45 -0
  59. package/docs/llm-providers.md +24 -2
  60. package/docs/mcp-activity-retrieval.md +90 -210
  61. package/package.json +1 -1
  62. package/pyproject.toml +1 -1
  63. package/python/catence/providers/garmin/cli.py +43 -9
  64. package/python/catence/providers/garmin/progress.py +85 -0
  65. package/python/catence/providers/garmin/registry.py +2 -0
  66. package/python/catence/providers/garmin/streams.py +38 -0
  67. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  68. package/python/catence/providers/garmin/worker.py +415 -12
  69. package/scripts/discover-opencode-go.mjs +36 -144
  70. package/uv.lock +3 -3
  71. package/docs/deployment.md +0 -338
@@ -1,7 +1,9 @@
1
1
  import { createServer } from 'node:http';
2
+ import path from 'node:path';
2
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
- import { catenceRuntimeHealth, DashboardSnapshotService, jsonSafe, loadCatalog, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths } from '../../runtime/index.js';
4
+ import { catenceRuntimeHealth, DashboardSnapshotService, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, } from '../../runtime/index.js';
4
5
  import { createCatenceMcpServer } from '../mcp/server.js';
6
+ import { generationStatus } from './generation-status.js';
5
7
  const MAX_REQUEST_BYTES = 1_000_000;
6
8
  function json(response, statusCode, value) {
7
9
  const body = JSON.stringify(jsonSafe(value));
@@ -85,6 +87,84 @@ async function dashboardPaths(catalogPaths, staticPaths, url) {
85
87
  return staticPaths;
86
88
  throw new Error('No Catence data store is configured.');
87
89
  }
90
+ function parseSyncBody(body) {
91
+ if (body === undefined)
92
+ return {};
93
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
94
+ throw new Error('Request body must be a JSON object.');
95
+ const raw = body;
96
+ for (const key of Object.keys(raw)) {
97
+ if (!['athleteId', 'provider', 'from', 'refresh', 'refreshModels'].includes(key)) {
98
+ throw new Error(`Unknown sync field: ${key}.`);
99
+ }
100
+ }
101
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
102
+ throw new Error('athleteId must be a string.');
103
+ if (raw.provider !== undefined && (typeof raw.provider !== 'string' || !DETACHED_SYNC_PROVIDERS.includes(raw.provider))) {
104
+ throw new Error('provider must be intervals, garmin, strava, or all.');
105
+ }
106
+ if (raw.from !== undefined && (typeof raw.from !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.from))) {
107
+ throw new Error('from must use YYYY-MM-DD.');
108
+ }
109
+ for (const flag of ['refresh', 'refreshModels']) {
110
+ if (raw[flag] !== undefined && typeof raw[flag] !== 'boolean')
111
+ throw new Error(`${flag} must be a boolean.`);
112
+ }
113
+ return raw;
114
+ }
115
+ async function resolveSyncPaths(catalogPaths, staticPaths, body) {
116
+ if (catalogPaths) {
117
+ if (!body.athleteId)
118
+ throw new Error('athleteId is required. Call /api/v1/athletes to inspect the configured catalog.');
119
+ return (await resolveAthlete(catalogPaths, body.athleteId)).paths;
120
+ }
121
+ if (staticPaths)
122
+ return staticPaths;
123
+ throw new Error('No Catence data store is configured.');
124
+ }
125
+ /** Best-effort last-success timestamps; null while a run holds the write lock. */
126
+ async function lastSyncSummary(paths) {
127
+ const completedAtIso = (value) => {
128
+ if (value instanceof Date)
129
+ return Number.isNaN(value.getTime()) ? null : value.toISOString();
130
+ if (typeof value === 'string' && value.trim())
131
+ return value;
132
+ return null;
133
+ };
134
+ try {
135
+ // Read-only repository on purpose: it exposes per-run history (`syncRuns`)
136
+ // and degrades through the typed lock-conflict error while a detached
137
+ // sync holds DuckDB's single-writer lock. The write-capable dataStatus()
138
+ // would both block here and has no run history at all.
139
+ const repository = await openReadOnlyRepository(paths);
140
+ try {
141
+ const status = await repository.status();
142
+ const runs = Array.isArray(status.syncRuns) ? status.syncRuns : [];
143
+ const completed = runs.filter((run) => String(run.status ?? '').startsWith('completed') && completedAtIso(run.completed_at));
144
+ const providers = {};
145
+ let lastCompletedAt = null;
146
+ for (const run of completed) {
147
+ const completedAt = completedAtIso(run.completed_at);
148
+ const provider = String(run.provider ?? 'unknown');
149
+ if (!providers[provider] || providers[provider] < completedAt)
150
+ providers[provider] = completedAt;
151
+ if (!lastCompletedAt || lastCompletedAt < completedAt)
152
+ lastCompletedAt = completedAt;
153
+ }
154
+ return { lastCompletedAt, providers, recentRuns: runs.slice(0, 5) };
155
+ }
156
+ finally {
157
+ await repository.close();
158
+ }
159
+ }
160
+ catch {
161
+ // Best-effort by contract: while a detached sync holds the single-writer
162
+ // DuckDB lock, even opening a snapshot fails cross-process. Progress (from
163
+ // sidecar heartbeats) must still reach the dashboard, so any failure here
164
+ // degrades to "no last-sync summary" instead of failing /sync/status.
165
+ return null;
166
+ }
167
+ }
88
168
  async function handleMcpRequest(request, response, paths) {
89
169
  if (request.method !== 'POST') {
90
170
  methodNotAllowed(response);
@@ -143,6 +223,8 @@ export function createCatenceHttpServer(options = {}) {
143
223
  const catalogPaths = options.catalogPaths ?? (options.paths ? null : resolveCatalogPaths());
144
224
  const staticPaths = options.paths ?? null;
145
225
  const allowedOrigins = options.allowedOrigins ?? [];
226
+ const startSync = options.startSync ?? startDetachedSync;
227
+ const mergeModels = options.mergeModels ?? mergeOpenCodeGoConsoleProfiles;
146
228
  return createServer((request, response) => {
147
229
  void (async () => {
148
230
  if (!addCorsHeaders(request, response, allowedOrigins)) {
@@ -158,6 +240,10 @@ export function createCatenceHttpServer(options = {}) {
158
240
  json(response, 200, catenceRuntimeHealth());
159
241
  return;
160
242
  }
243
+ if (pathname === '/api/v1/health' && request.method === 'GET') {
244
+ json(response, 200, catenceRuntimeHealth());
245
+ return;
246
+ }
161
247
  if (pathname === '/api/v1/athletes' && request.method === 'GET') {
162
248
  if (!catalogPaths) {
163
249
  json(response, 200, { defaultAthleteId: 'local', athletes: [{ id: 'local', label: 'Local athlete' }] });
@@ -182,6 +268,103 @@ export function createCatenceHttpServer(options = {}) {
182
268
  }
183
269
  return;
184
270
  }
271
+ if (pathname === '/api/v1/sync' && request.method === 'POST') {
272
+ let body;
273
+ try {
274
+ body = parseSyncBody(await readJsonBody(request));
275
+ const paths = await resolveSyncPaths(catalogPaths, staticPaths, body);
276
+ // Optional model self-discovery runs before the data sync so new
277
+ // OpenCode Go models appear without a manual script run. Failure is
278
+ // non-fatal: it must never block or fail a data sync. The Console
279
+ // reads <CATENCE_HOME>/config.json, so catalog deployments merge
280
+ // into the catalog root's config instead of the athlete store.
281
+ const discoveryConfigPath = catalogPaths ? path.join(catalogPaths.root, 'config.json') : paths.config;
282
+ let discoveryWarning = null;
283
+ if (body.refreshModels) {
284
+ try {
285
+ await mergeModels({ configPath: discoveryConfigPath, setDefault: false });
286
+ }
287
+ catch (error) {
288
+ discoveryWarning = `OpenCode Go model discovery failed; profiles were left unchanged (${error instanceof Error ? error.message : String(error)}).`;
289
+ }
290
+ }
291
+ const handle = await startSync({
292
+ paths,
293
+ athleteId: body.athleteId ?? 'local',
294
+ provider: body.provider,
295
+ from: body.from,
296
+ refresh: body.refresh,
297
+ // The child resolves the athlete within the catalog, not within
298
+ // the athlete store this route resolved for locks and logs.
299
+ cliHome: catalogPaths ? catalogPaths.root : undefined,
300
+ }, { spawnProcess: options.syncSpawnProcess });
301
+ json(response, 202, { ...handle, ...(discoveryWarning ? { warning: discoveryWarning } : {}), progressEndpoint: '/api/v1/sync/status' });
302
+ }
303
+ catch (error) {
304
+ json(response, error instanceof DetachedSyncBusyError ? 409 : 400, {
305
+ error: {
306
+ code: error instanceof DetachedSyncBusyError ? 'sync_in_progress' : 'sync_start_failed',
307
+ message: error instanceof Error ? error.message : String(error),
308
+ },
309
+ });
310
+ }
311
+ return;
312
+ }
313
+ if (pathname === '/api/v1/models/discover' && request.method === 'POST') {
314
+ // Refresh the OpenCode Go profiles without starting a data sync. The
315
+ // Console's Models page calls this behind its authenticated proxy.
316
+ try {
317
+ const body = await readJsonBody(request).catch(() => ({}));
318
+ const raw = (body ?? {});
319
+ if (raw.setDefault !== undefined && typeof raw.setDefault !== 'boolean')
320
+ throw new Error('setDefault must be a boolean.');
321
+ const configPath = catalogPaths ? path.join(catalogPaths.root, 'config.json') : staticPaths?.config;
322
+ if (!configPath)
323
+ throw new Error('No Catence data store is configured.');
324
+ json(response, 200, jsonSafe(await mergeModels({ configPath, setDefault: raw.setDefault === true })));
325
+ }
326
+ catch (error) {
327
+ json(response, 400, { error: { code: 'model_discovery_failed', message: error instanceof Error ? error.message : String(error) } });
328
+ }
329
+ return;
330
+ }
331
+ if (pathname === '/api/v1/sync/status' && request.method === 'GET') {
332
+ try {
333
+ const url = new URL(request.url ?? '/', 'http://localhost');
334
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url);
335
+ const athleteId = catalogPaths ? (await resolveAthlete(catalogPaths, url.searchParams.get('athleteId') ?? '')).athlete.id : 'local';
336
+ json(response, 200, { athleteId, progress: await syncProgress(paths), lastSync: await lastSyncSummary(paths) });
337
+ }
338
+ catch (error) {
339
+ json(response, 400, { error: { code: 'sync_status_unavailable', message: error instanceof Error ? error.message : String(error) } });
340
+ }
341
+ return;
342
+ }
343
+ if (pathname.startsWith('/api/v1/threads/') &&
344
+ pathname.endsWith('/generation') &&
345
+ request.method === 'GET') {
346
+ try {
347
+ const segments = pathname.split('/').filter(Boolean);
348
+ const threadId = segments[segments.length - 2];
349
+ if (!threadId)
350
+ throw new Error('Missing threadId in request path.');
351
+ const base = (catalogPaths ?? staticPaths ?? resolveCatalogPaths());
352
+ const root = base?.root;
353
+ if (!root || typeof root !== 'string') {
354
+ throw new Error('No Catence data store is configured.');
355
+ }
356
+ json(response, 200, await generationStatus(root, threadId));
357
+ }
358
+ catch (error) {
359
+ json(response, 400, {
360
+ error: {
361
+ code: 'generation_status_unavailable',
362
+ message: error instanceof Error ? error.message : String(error)
363
+ }
364
+ });
365
+ }
366
+ return;
367
+ }
185
368
  if (pathname === '/mcp') {
186
369
  await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths());
187
370
  return;
@@ -1,11 +1,42 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { existsSync } from 'node:fs';
4
- import { addAthlete, athleteStorePaths, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
4
+ import { addAthlete, athleteStorePaths, CatenceDatabase, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
5
5
  import { parseServeCliOptions, SERVE_USAGE } from '../http/cli-options.js';
6
6
  import { createCatenceHttpServer } from '../http/server.js';
7
7
  import { MCP_USAGE, parseMcpCliOptions } from './cli-options.js';
8
8
  import { createCatenceMcpServer } from './server.js';
9
+ async function migrateCatalogStores(home) {
10
+ // On Docker image bumps the DuckDB schema version increases. The
11
+ // read-only MCP health probe would otherwise report "version X required"
12
+ // until the next manual sync. Proactively opening each athlete store in
13
+ // writable mode runs the idempotent migrations (database.ts:migrate)
14
+ // before the HTTP server starts listening, so a plain `docker compose up`
15
+ // after `deploy-console.sh beta` heals existing installs without an extra
16
+ // manual `catence-data migrate --all` step. Failures are best-effort:
17
+ // a missing catalog or a store still locked by another process is retried
18
+ // on the next sync or health check.
19
+ try {
20
+ const catalogPaths = resolveCatalogPaths(home);
21
+ const catalog = await loadCatalog(catalogPaths).catch(() => null);
22
+ if (!catalog || catalog.athletes.length === 0)
23
+ return;
24
+ for (const athlete of catalog.athletes) {
25
+ const paths = athleteStorePaths(catalogPaths, athlete.id);
26
+ try {
27
+ const database = await CatenceDatabase.open(paths);
28
+ await database.close();
29
+ }
30
+ catch {
31
+ // Best-effort: a lock or a corrupted store must not prevent the
32
+ // server from starting; the next sync will retry the migration.
33
+ }
34
+ }
35
+ }
36
+ catch {
37
+ // Catalog read failures are non-fatal for startup.
38
+ }
39
+ }
9
40
  async function run() {
10
41
  const arguments_ = process.argv.slice(2);
11
42
  if (arguments_[0] === 'serve') {
@@ -14,6 +45,7 @@ async function run() {
14
45
  process.stderr.write(`${SERVE_USAGE}\n`);
15
46
  return;
16
47
  }
48
+ await migrateCatalogStores(options.home);
17
49
  const httpServer = createCatenceHttpServer({
18
50
  catalogPaths: resolveCatalogPaths(options.home),
19
51
  allowedOrigins: options.allowedOrigins,
@@ -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 {};