catence 0.2.0-beta.1 → 0.2.0-beta.11

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 (45) hide show
  1. package/README.md +23 -77
  2. package/config.example.json +32 -20
  3. package/dist/contracts/index.d.ts +1 -0
  4. package/dist/contracts/index.js +1 -0
  5. package/dist/contracts/progress.d.ts +23 -0
  6. package/dist/contracts/progress.js +48 -0
  7. package/dist/contracts/release.d.ts +1 -1
  8. package/dist/contracts/release.js +1 -1
  9. package/dist/core/logging.d.ts +16 -0
  10. package/dist/core/logging.js +68 -0
  11. package/dist/core/query/repository.d.ts +2 -0
  12. package/dist/core/query/repository.js +46 -0
  13. package/dist/core/runtime/catalog.js +17 -7
  14. package/dist/core/runtime/self-update.d.ts +44 -0
  15. package/dist/core/runtime/self-update.js +138 -0
  16. package/dist/elt/application/management.d.ts +2 -0
  17. package/dist/elt/application/management.js +220 -18
  18. package/dist/elt/ingestion/importer.d.ts +3 -2
  19. package/dist/elt/ingestion/importer.js +4 -3
  20. package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
  21. package/dist/elt/ingestion/providers/intervals.js +4 -1
  22. package/dist/elt/ingestion/providers/strava/service.js +7 -1
  23. package/dist/elt/storage/database.d.ts +7 -2
  24. package/dist/elt/storage/database.js +81 -4
  25. package/dist/elt/storage/migrations.js +25 -1
  26. package/dist/elt/storage/progress-sidecar.d.ts +26 -0
  27. package/dist/elt/storage/progress-sidecar.js +87 -0
  28. package/dist/interfaces/cli/commands.js +91 -1
  29. package/dist/interfaces/mcp/server.js +7 -0
  30. package/dist/runtime/index.d.ts +4 -1
  31. package/dist/runtime/index.js +2 -1
  32. package/docs/architecture.md +11 -1
  33. package/docs/configuration.md +221 -0
  34. package/docs/deployment/docker.md +528 -0
  35. package/docs/deployment/local-console.md +230 -0
  36. package/docs/deployment/local-mcp.md +304 -0
  37. package/docs/distribution.md +58 -0
  38. package/docs/llm-providers.md +467 -0
  39. package/docs/mcp-activity-retrieval.md +37 -30
  40. package/package.json +5 -1
  41. package/python/catence/providers/garmin/cli.py +30 -9
  42. package/python/catence/providers/garmin/progress.py +85 -0
  43. package/python/catence/providers/garmin/worker.py +132 -5
  44. package/scripts/discover-opencode-go.mjs +210 -0
  45. package/scripts/discover-opencode-zen.mjs +218 -0
@@ -2,6 +2,7 @@ import { DuckDBInstance } from '@duckdb/node-api';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { ReadOnlyRepository } from '../../core/query/repository.js';
5
+ import { SYNC_STAGES, STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
5
6
  import { migrations } from './migrations.js';
6
7
  import { json } from './sql.js';
7
8
  export class CatenceDatabase {
@@ -81,6 +82,66 @@ export class CatenceDatabase {
81
82
  await this.run(`UPDATE sync_runs SET status = CASE WHEN error_count > 0 THEN 'completed_with_errors' ELSE 'completed' END, completed_at = now() WHERE run_id = $runId`, { runId });
82
83
  await this.run("UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context'");
83
84
  }
85
+ async heartbeatRun(runId, progress) {
86
+ await this.run(`INSERT INTO sync_run_progress (run_id, provider, stage, current_step, completed_units, total_units, percent, elapsed_seconds, estimated_remaining_seconds, heartbeat_at, detail_json)
87
+ VALUES ($runId, $provider, $stage, $currentStep, $completedUnits, $totalUnits, $percent, $elapsedSeconds, $estimatedRemainingSeconds, now(), '{}')
88
+ ON CONFLICT (run_id) DO UPDATE SET
89
+ provider = EXCLUDED.provider,
90
+ stage = EXCLUDED.stage,
91
+ current_step = EXCLUDED.current_step,
92
+ completed_units = EXCLUDED.completed_units,
93
+ total_units = EXCLUDED.total_units,
94
+ percent = EXCLUDED.percent,
95
+ elapsed_seconds = EXCLUDED.elapsed_seconds,
96
+ estimated_remaining_seconds = EXCLUDED.estimated_remaining_seconds,
97
+ heartbeat_at = now(),
98
+ detail_json = '{}'`, {
99
+ runId,
100
+ provider: progress.provider,
101
+ stage: progress.stage,
102
+ currentStep: progress.currentStep,
103
+ completedUnits: progress.completedUnits,
104
+ totalUnits: progress.totalUnits,
105
+ percent: progress.percentComplete,
106
+ elapsedSeconds: progress.elapsedSeconds,
107
+ estimatedRemainingSeconds: progress.estimatedRemainingSeconds,
108
+ });
109
+ }
110
+ async markRunInterrupted(runId, stage = 'interrupted') {
111
+ await this.run("UPDATE sync_runs SET status = 'interrupted', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId });
112
+ await this.run('UPDATE sync_run_progress SET stage = $stage, heartbeat_at = now() WHERE run_id = $runId', { runId, stage });
113
+ }
114
+ async interruptStaleSyncRuns(timeoutMs = STALE_RUN_TIMEOUT_MS) {
115
+ const cutoff = new Date(Date.now() - timeoutMs).toISOString();
116
+ const stale = await this.rows(`SELECT run_id FROM sync_runs WHERE status = 'running' AND started_at < $cutoff`, { cutoff });
117
+ for (const row of stale) {
118
+ await this.run("UPDATE sync_runs SET status = 'timed_out', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId: row.run_id });
119
+ await this.run("UPDATE sync_run_progress SET stage = 'timed_out', heartbeat_at = now() WHERE run_id = $runId", { runId: row.run_id });
120
+ }
121
+ return { runIds: stale.map((row) => row.run_id) };
122
+ }
123
+ async syncProgress() {
124
+ const running = await this.rows(`SELECT runs.run_id, runs.provider,
125
+ coalesce(progress.stage, 'starting') AS stage,
126
+ progress.current_step AS current_step,
127
+ coalesce(progress.completed_units, 0) AS completed_units,
128
+ progress.total_units AS total_units,
129
+ coalesce(progress.percent, 0) AS percent,
130
+ coalesce(progress.elapsed_seconds, 0) AS elapsed_seconds,
131
+ progress.estimated_remaining_seconds AS estimated_remaining_seconds,
132
+ cast(coalesce(progress.heartbeat_at, runs.started_at) AS VARCHAR) AS heartbeat_at
133
+ FROM sync_runs AS runs
134
+ LEFT JOIN sync_run_progress AS progress USING (run_id)
135
+ WHERE runs.status = 'running'
136
+ ORDER BY runs.started_at DESC`);
137
+ const recent = await this.rows(`SELECT progress.run_id, progress.provider, progress.stage, progress.current_step,
138
+ progress.completed_units, progress.total_units, progress.percent, progress.elapsed_seconds,
139
+ progress.estimated_remaining_seconds, cast(progress.heartbeat_at AS VARCHAR) AS heartbeat_at
140
+ FROM sync_run_progress AS progress
141
+ ORDER BY progress.heartbeat_at DESC
142
+ LIMIT 10`);
143
+ return { running: running.map(toSyncProgressState), recent: recent.map(toSyncProgressState) };
144
+ }
84
145
  async addError(runId, provider, endpoint, remoteId, message, retryable) {
85
146
  await this.run(`INSERT INTO normalization_errors (error_id, run_id, provider, endpoint, remote_id, message, retryable)
86
147
  VALUES ($errorId, $runId, $provider, $endpoint, $remoteId, $message, $retryable)`, { errorId: randomUUID(), runId, provider, endpoint, remoteId, message, retryable });
@@ -94,9 +155,11 @@ export class CatenceDatabase {
94
155
  const rawObjects = await this.rows('SELECT count(*)::INTEGER AS count FROM raw_objects');
95
156
  const activities = await this.rows('SELECT count(*)::INTEGER AS count FROM activities');
96
157
  const streams = await this.rows('SELECT count(*)::INTEGER AS count FROM stream_manifest');
158
+ const errorDetails = await this.rows('SELECT run_id, provider, endpoint, remote_id, message, cast(created_at AS VARCHAR) AS created_at FROM normalization_errors WHERE resolved_at IS NULL ORDER BY created_at DESC LIMIT 100');
97
159
  const cursors = await this.rows('SELECT provider, cursor_name, cast(covered_through_date AS VARCHAR) AS covered_through_date, cast(latest_source_date AS VARCHAR) AS latest_source_date, lookback_days, last_successful_run_id, cast(last_completed_at AS VARCHAR) AS last_completed_at, status FROM sync_cursors ORDER BY provider, cursor_name');
98
160
  return {
99
161
  runs, unresolvedErrors: errors[0]?.count ?? 0, rawObjects: rawObjects[0]?.count ?? 0, activities: activities[0]?.count ?? 0, streams: streams[0]?.count ?? 0,
162
+ errors: errorDetails,
100
163
  cursors: cursors.map((cursor) => ({ ...cursor, next_from_date: subtractDays(cursor.covered_through_date, Number(cursor.lookback_days)) })),
101
164
  };
102
165
  }
@@ -281,10 +344,6 @@ export class ReadOnlyCatenceDatabase {
281
344
  }
282
345
  }
283
346
  }
284
- /** Compose core read services with the DuckDB-backed, read-only adapter. */
285
- export async function openReadOnlyRepository(paths) {
286
- return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
287
- }
288
347
  export class ReadOnlyDatabaseError extends Error {
289
348
  code;
290
349
  constructor(code, message) {
@@ -293,4 +352,22 @@ export class ReadOnlyDatabaseError extends Error {
293
352
  this.name = 'ReadOnlyDatabaseError';
294
353
  }
295
354
  }
355
+ /** Compose core read services with the DuckDB-backed, read-only adapter. */
356
+ export async function openReadOnlyRepository(paths) {
357
+ return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
358
+ }
359
+ function toSyncProgressState(row) {
360
+ return {
361
+ runId: row.run_id,
362
+ provider: row.provider,
363
+ stage: SYNC_STAGES.includes(row.stage) ? row.stage : 'starting',
364
+ currentStep: row.current_step,
365
+ completedUnits: Math.max(0, Number(row.completed_units) || 0),
366
+ totalUnits: row.total_units === null || row.total_units === undefined ? null : Math.max(0, Number(row.total_units) || 0),
367
+ percentComplete: Math.min(100, Math.max(0, Number(row.percent) || 0)),
368
+ elapsedSeconds: Math.max(0, Number(row.elapsed_seconds) || 0),
369
+ estimatedRemainingSeconds: row.estimated_remaining_seconds === null || row.estimated_remaining_seconds === undefined ? null : Math.max(0, Number(row.estimated_remaining_seconds) || 0),
370
+ heartbeatAt: row.heartbeat_at,
371
+ };
372
+ }
296
373
  //# sourceMappingURL=database.js.map
@@ -828,5 +828,29 @@ const swimFactsAndQualityMigration = {
828
828
  UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
829
829
  `,
830
830
  };
831
- export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration];
831
+ const syncRunProgressMigration = {
832
+ version: 14,
833
+ name: 'sync_run_progress_heartbeats',
834
+ sql: `
835
+ -- Live progress heartbeats for in-flight sync runs. Written by the Node
836
+ -- side from progress records streamed by provider workers; read-only
837
+ -- surfaces (status, progress CLI, MCP) join against sync_runs.
838
+ CREATE TABLE IF NOT EXISTS sync_run_progress (
839
+ run_id VARCHAR PRIMARY KEY,
840
+ provider VARCHAR NOT NULL,
841
+ stage VARCHAR NOT NULL,
842
+ current_step VARCHAR,
843
+ completed_units INTEGER NOT NULL DEFAULT 0,
844
+ total_units INTEGER,
845
+ percent DOUBLE NOT NULL DEFAULT 0,
846
+ elapsed_seconds DOUBLE NOT NULL DEFAULT 0,
847
+ estimated_remaining_seconds DOUBLE,
848
+ heartbeat_at TIMESTAMPTZ NOT NULL,
849
+ detail_json JSON NOT NULL DEFAULT '{}'
850
+ );
851
+
852
+ UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
853
+ `,
854
+ };
855
+ export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration, syncRunProgressMigration];
832
856
  //# sourceMappingURL=migrations.js.map
@@ -0,0 +1,26 @@
1
+ import { type SyncProgressSnapshot, type SyncProgressState } from '../../contracts/progress.js';
2
+ import type { CatencePaths } from '../../contracts/runtime.js';
3
+ export declare const PROGRESS_SIDECAR_SUFFIX = ".progress.json";
4
+ export declare const PROGRESS_SIDECAR_STALE_MS: number;
5
+ export declare function progressSidecarPath(paths: CatencePaths, runId: string): string;
6
+ /**
7
+ * Persist one progress heartbeat as a sidecar file next to the Garmin staging
8
+ * manifest. Reading this file needs no DuckDB lock, so `progress` stays live
9
+ * while a sync run holds the single-writer database lock.
10
+ */
11
+ export declare function writeProgressSidecar(paths: CatencePaths, runId: string, state: SyncProgressState): Promise<void>;
12
+ export declare function removeProgressSidecar(paths: CatencePaths, runId: string): Promise<void>;
13
+ /**
14
+ * Scan every persisted sidecar heartbeat for an athlete store. Corrupt or
15
+ * unreadable files are skipped so a torn write can never break progress reads.
16
+ */
17
+ export declare function readProgressSidecars(paths: CatencePaths): Promise<SyncProgressState[]>;
18
+ /** Active runs from sidecars: non-terminal stage and a fresh heartbeat. */
19
+ export declare function readRunningProgress(paths: CatencePaths, staleAfterMs?: number): Promise<SyncProgressState[]>;
20
+ /**
21
+ * Merge database-backed progress with sidecar heartbeats. Sidecars win for a
22
+ * given run (they are freshest); database rows fill runs that never emitted a
23
+ * sidecar (for example Intervals runs). Recent history is deduplicated across
24
+ * both sources and sorted by heartbeat recency.
25
+ */
26
+ export declare function mergeProgress(dbRunning: SyncProgressState[], dbRecent: SyncProgressState[], sidecars: SyncProgressState[]): SyncProgressSnapshot;
@@ -0,0 +1,87 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { normalizeProgress } from '../../contracts/progress.js';
5
+ export const PROGRESS_SIDECAR_SUFFIX = '.progress.json';
6
+ export const PROGRESS_SIDECAR_STALE_MS = 5 * 60 * 1000;
7
+ const TERMINAL_STAGES = new Set(['completed', 'failed', 'interrupted', 'timed_out']);
8
+ export function progressSidecarPath(paths, runId) {
9
+ return path.join(paths.staging, 'garmin', `${runId}${PROGRESS_SIDECAR_SUFFIX}`);
10
+ }
11
+ /**
12
+ * Persist one progress heartbeat as a sidecar file next to the Garmin staging
13
+ * manifest. Reading this file needs no DuckDB lock, so `progress` stays live
14
+ * while a sync run holds the single-writer database lock.
15
+ */
16
+ export async function writeProgressSidecar(paths, runId, state) {
17
+ const target = progressSidecarPath(paths, runId);
18
+ await mkdir(path.dirname(target), { recursive: true });
19
+ const temporary = `${target}.tmp`;
20
+ await writeFile(temporary, JSON.stringify(state));
21
+ await rename(temporary, target);
22
+ }
23
+ export async function removeProgressSidecar(paths, runId) {
24
+ await rm(progressSidecarPath(paths, runId), { force: true });
25
+ }
26
+ /**
27
+ * Scan every persisted sidecar heartbeat for an athlete store. Corrupt or
28
+ * unreadable files are skipped so a torn write can never break progress reads.
29
+ */
30
+ export async function readProgressSidecars(paths) {
31
+ const directory = path.join(paths.staging, 'garmin');
32
+ if (!existsSync(directory))
33
+ return [];
34
+ const files = (await readdir(directory)).filter((file) => file.endsWith(PROGRESS_SIDECAR_SUFFIX));
35
+ const states = [];
36
+ for (const file of files) {
37
+ try {
38
+ const state = normalizeProgress(JSON.parse(await readFile(path.join(directory, file), 'utf8')));
39
+ if (state)
40
+ states.push(state);
41
+ }
42
+ catch {
43
+ // Torn or partial sidecar write; it is not authoritative.
44
+ }
45
+ }
46
+ return states;
47
+ }
48
+ /** Active runs from sidecars: non-terminal stage and a fresh heartbeat. */
49
+ export async function readRunningProgress(paths, staleAfterMs = PROGRESS_SIDECAR_STALE_MS) {
50
+ const now = Date.now();
51
+ const running = [];
52
+ for (const state of await readProgressSidecars(paths)) {
53
+ if (TERMINAL_STAGES.has(state.stage))
54
+ continue;
55
+ const ageMs = now - Date.parse(state.heartbeatAt);
56
+ if (!Number.isFinite(ageMs) || ageMs > staleAfterMs)
57
+ continue;
58
+ running.push(state);
59
+ }
60
+ return running;
61
+ }
62
+ /**
63
+ * Merge database-backed progress with sidecar heartbeats. Sidecars win for a
64
+ * given run (they are freshest); database rows fill runs that never emitted a
65
+ * sidecar (for example Intervals runs). Recent history is deduplicated across
66
+ * both sources and sorted by heartbeat recency.
67
+ */
68
+ export function mergeProgress(dbRunning, dbRecent, sidecars) {
69
+ const running = new Map();
70
+ for (const state of sidecars)
71
+ running.set(state.runId, state);
72
+ for (const state of dbRunning)
73
+ if (!running.has(state.runId))
74
+ running.set(state.runId, state);
75
+ const recent = new Map();
76
+ for (const state of dbRecent)
77
+ recent.set(state.runId, state);
78
+ for (const state of sidecars)
79
+ recent.set(state.runId, state);
80
+ return {
81
+ running: [...running.values()],
82
+ recent: [...recent.values()]
83
+ .sort((a, b) => Date.parse(b.heartbeatAt) - Date.parse(a.heartbeatAt))
84
+ .slice(0, 10),
85
+ };
86
+ }
87
+ //# sourceMappingURL=progress-sidecar.js.map
@@ -2,7 +2,7 @@
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
4
  import { Command } from 'commander';
5
- import { addAthlete, athleteStorePaths, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, initializeCatalog, linkActivity, loadCatalog, rebuildRetrievalIndex, resolveAthlete, resolveCatalogPaths, retryDataSync, setAthleteSecret, syncData, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
5
+ import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
6
6
  const program = new Command()
7
7
  .name('catence-data')
8
8
  .description('Athlete-scoped Catence data setup, extraction, and normalization.')
@@ -158,6 +158,96 @@ program.command('status').action(async () => {
158
158
  const { paths } = await currentAthlete();
159
159
  process.stdout.write(`${JSON.stringify(await dataStatus(paths), null, 2)}\n`);
160
160
  });
161
+ function renderProgress(snapshot) {
162
+ const lines = snapshot.running.map((run) => {
163
+ const pct = run.percentComplete.toFixed(1).padStart(5);
164
+ const units = run.totalUnits !== null ? `${run.completedUnits}/${run.totalUnits}` : String(run.completedUnits);
165
+ const eta = run.estimatedRemainingSeconds !== null ? `${Math.round(run.estimatedRemainingSeconds)}s` : '?';
166
+ const step = run.currentStep ?? '';
167
+ return `${pct}% ${run.stage.padEnd(12)} ${units.padEnd(11)} ${eta.padEnd(6)} ${step}`;
168
+ });
169
+ return lines.length === 0 ? 'no active sync runs' : lines.join('\n');
170
+ }
171
+ program.command('progress')
172
+ .description('Show live progress for active sync runs.')
173
+ .option('--watch', 'refresh every few seconds until no sync runs are active')
174
+ .action(async (options) => {
175
+ const { paths } = await currentAthlete();
176
+ if (!options.watch) {
177
+ process.stdout.write(`${JSON.stringify(await syncProgress(paths), null, 2)}\n`);
178
+ return;
179
+ }
180
+ while (true) {
181
+ const snapshot = await syncProgress(paths);
182
+ if (output.isTTY) {
183
+ output.write(`\x1b[2J\x1b[H${renderProgress(snapshot)}\n`);
184
+ }
185
+ else {
186
+ process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`);
187
+ }
188
+ if (snapshot.running.length === 0)
189
+ break;
190
+ await new Promise((resolve) => setTimeout(resolve, 3000));
191
+ }
192
+ });
193
+ program.command('update')
194
+ .description('Check for and apply the newest Catence release on the tracked channel (runtime and Console).')
195
+ .option('--check', 'report available releases without changing anything')
196
+ .option('--channel <stable|beta>', 'release channel to track; defaults to the installed runtime channel')
197
+ .option('--runtime', 'only check or update the Catence runtime package')
198
+ .option('--console', 'only check or update the Catence Console package')
199
+ .action(async (options) => {
200
+ if (options.channel !== undefined && options.channel !== 'stable' && options.channel !== 'beta') {
201
+ throw new Error('--channel must be stable or beta.');
202
+ }
203
+ const channel = options.channel ?? channelForVersion(CATENCE_RUNTIME_VERSION);
204
+ const runtimeOnly = options.runtime ?? false;
205
+ const consoleOnly = options.console ?? false;
206
+ const updateRuntime = runtimeOnly || !consoleOnly;
207
+ const updateConsole = consoleOnly || !runtimeOnly;
208
+ const [npmDistTags, pypiReleases, consoleInstalled] = await Promise.all([
209
+ fetchNpmDistTags('catence'),
210
+ fetchPypiReleases('catence-console'),
211
+ readInstalledConsoleVersion(),
212
+ ]);
213
+ const plan = planSelfUpdate({ runtimeVersion: CATENCE_RUNTIME_VERSION, channel, npmDistTags, pypiReleases, consoleInstalled });
214
+ const report = { channel, runtime: plan.runtime, console: plan.console };
215
+ if (options.check) {
216
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
217
+ process.exit(plan.ok ? 0 : 1);
218
+ }
219
+ const applied = [];
220
+ if (plan.runtime.updateAvailable && updateRuntime) {
221
+ const runtimeTarget = plan.runtime.target;
222
+ if (runtimeTarget === undefined)
223
+ throw new Error('No runtime update target is available.');
224
+ const npmPrefix = await npmGlobalPrefix();
225
+ if (isGlobalNpmInstall(import.meta.url, npmPrefix)) {
226
+ const exitCode = await runSelfUpdateCommand('npm', ['install', '--global', `catence@${runtimeTarget}`]);
227
+ if (exitCode !== 0)
228
+ throw new Error(`npm failed to install catence@${runtimeTarget} (exit ${exitCode}).`);
229
+ applied.push(`runtime: catence@${runtimeTarget}`);
230
+ }
231
+ else {
232
+ process.stderr.write(`Runtime is not a global npm install; run: npm install --global catence@${runtimeTarget}\n`);
233
+ }
234
+ }
235
+ if (plan.console.updateAvailable && updateConsole) {
236
+ const consoleTarget = plan.console.target;
237
+ if (consoleTarget === undefined)
238
+ throw new Error('No Console update target is available.');
239
+ if (await hasCommand('uv')) {
240
+ const exitCode = await runSelfUpdateCommand('uv', ['tool', 'install', '--upgrade', `catence-console==${consoleTarget}`]);
241
+ if (exitCode !== 0)
242
+ throw new Error(`uv failed to install catence-console==${consoleTarget} (exit ${exitCode}).`);
243
+ applied.push(`console: catence-console==${consoleTarget}`);
244
+ }
245
+ else {
246
+ process.stderr.write(`uv is not installed; run: pip install --upgrade 'catence-console==${consoleTarget}'\n`);
247
+ }
248
+ }
249
+ process.stdout.write(`${JSON.stringify({ ...report, applied }, null, 2)}\n`);
250
+ });
161
251
  program.command('build-retrieval-index').description('Build derived local retrieval context for one athlete.').action(async () => {
162
252
  const { paths } = await currentAthlete();
163
253
  process.stdout.write(`${JSON.stringify(await rebuildRetrievalIndex(paths), null, 2)}\n`);
@@ -231,6 +231,13 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
231
231
  data: { ...(await repository.status()), ...(await repository.coverage()) },
232
232
  provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
233
233
  }))));
234
+ server.registerTool('catence_sync_progress', {
235
+ title: 'Catence sync progress',
236
+ description: 'Read live progress heartbeats for active sync runs, plus the most recent completed or interrupted runs. Read-only.',
237
+ }, tool('catence_sync_progress', async () => useRepository(activePaths(), async (repository) => ({
238
+ data: await repository.progress(),
239
+ provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
240
+ }))));
234
241
  server.registerTool('review_daily_recovery_load', {
235
242
  title: 'Review daily recovery and training load',
236
243
  description: 'Return source-cited daily health, training, and nutrition facts for a recovery/load review. It does not prescribe a training plan.',
@@ -7,6 +7,8 @@
7
7
  * without changing the public product adapters.
8
8
  */
9
9
  export type { CatencePaths } from '../contracts/runtime.js';
10
+ export { channelForVersion, compareReleaseVersions, fetchNpmDistTags, fetchPypiReleases, hasCommand, isGlobalNpmInstall, latestRelease, npmGlobalPrefix, parseReleaseVersion, planSelfUpdate, readInstalledConsoleVersion, runSelfUpdateCommand, } from '../core/runtime/self-update.js';
11
+ export type { SelfUpdatePlan, UpdateChannel } from '../core/runtime/self-update.js';
10
12
  export { CATENCE_PROTOCOL_VERSION, CATENCE_RUNTIME_VERSION, catenceRuntimeHealth } from '../contracts/release.js';
11
13
  export { configuredMcpRateLimit, loadCatenceConfig, resolvePaths } from '../core/runtime/configuration.js';
12
14
  export { addAthlete, athleteStorePaths, defaultAthlete, defaultCatalogHome, initializeCatalog, loadCatalog, resolveAthlete, resolveCatalogPaths, } from '../core/runtime/catalog.js';
@@ -26,8 +28,9 @@ export { WELLNESS_METRICS, WellnessService } from '../core/query/wellness.js';
26
28
  export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
27
29
  export { queryReadOnlyData } from '../core/query/sql-guard.js';
28
30
  export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
29
- export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, unlinkActivity, } from '../elt/application/management.js';
31
+ export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
30
32
  export type { ProviderChoice } from '../elt/application/management.js';
33
+ export type { InterruptedRuns, SyncProgressSnapshot, SyncProgressState } from '../contracts/progress.js';
31
34
  export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
32
35
  export { DataWriteBusyError } from '../elt/storage/write-lock.js';
33
36
  export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
@@ -1,3 +1,4 @@
1
+ export { channelForVersion, compareReleaseVersions, fetchNpmDistTags, fetchPypiReleases, hasCommand, isGlobalNpmInstall, latestRelease, npmGlobalPrefix, parseReleaseVersion, planSelfUpdate, readInstalledConsoleVersion, runSelfUpdateCommand, } from '../core/runtime/self-update.js';
1
2
  export { CATENCE_PROTOCOL_VERSION, CATENCE_RUNTIME_VERSION, catenceRuntimeHealth } from '../contracts/release.js';
2
3
  export { configuredMcpRateLimit, loadCatenceConfig, resolvePaths } from '../core/runtime/configuration.js';
3
4
  export { addAthlete, athleteStorePaths, defaultAthlete, defaultCatalogHome, initializeCatalog, loadCatalog, resolveAthlete, resolveCatalogPaths, } from '../core/runtime/catalog.js';
@@ -14,7 +15,7 @@ export { WELLNESS_METRICS, WellnessService } from '../core/query/wellness.js';
14
15
  export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
15
16
  export { queryReadOnlyData } from '../core/query/sql-guard.js';
16
17
  export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
17
- export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, unlinkActivity, } from '../elt/application/management.js';
18
+ export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
18
19
  export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
19
20
  export { DataWriteBusyError } from '../elt/storage/write-lock.js';
20
21
  export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
@@ -87,7 +87,17 @@ The query layer has four shared pieces:
87
87
 
88
88
  ### 4. Serving and retrieval
89
89
 
90
- The server exposes `catence_status`, `describe_data`, `describe_dataset`, `read_series`, `aggregate_data`, `analyze_series`, `fit_series_model`, `query_read_only_data`, `find_activities`, `get_ftp_history`, `get_vo2max_history`, `power_curve_trend`, `power_coverage_report`, `latest_cycling_activities`, `cycling_progress_report`, `review_daily_recovery_load`, `review_weekly_training`, `review_activity_deep_dive`, `search_context`, `hydrate_strava_activity`, `hydrate_recent_strava_activities`, and `hydrate_strava_segment_history`, plus resources for status, catalog, a single activity, and a date-range summary.
90
+ The server exposes these tools, grouped by purpose:
91
+
92
+ - Catalog and status: `list_athletes`, `catence_status`, `catence_sync_progress`.
93
+ - Review workflows: `review_daily_recovery_load`, `review_weekly_training`, `review_activity_deep_dive`, plus the matching `daily_recovery_load_review`, `weekly_training_review`, and `activity_deep_dive` prompts.
94
+ - General data and analytics: `describe_data`, `describe_dataset`, `read_series`, `aggregate_data`, `analyze_series`, `fit_series_model`, `query_read_only_data`, `search_context`.
95
+ - Wellness: `wellness_correlate`, `wellness_baselines`, `wellness_anomalies`, `wellness_coverage`.
96
+ - Fitness and progress: `get_ftp_history`, `get_vo2max_history`, `power_curve_trend`, `power_coverage_report`, `latest_cycling_activities`, `cycling_progress_report`, `get_swim_laps`, `swim_progress_report`.
97
+ - Activity detail and segments: `find_activities`, `get_activity_segments`.
98
+ - Strava hydration (the only writers): `hydrate_strava_activity`, `hydrate_recent_strava_activities`, `hydrate_strava_segment_history`.
99
+
100
+ Resources are mode-dependent. In catalog mode the server registers only `athletes` (`catence://athletes`), the machine-readable roster of configured athlete IDs and labels; in a single-store (non-catalog) deployment it registers resources for status, catalog, a single activity template (`catence://activity/{activityId}`), and a date-range summary template (`catence://summary/{startDate}/{endDate}`).
91
101
 
92
102
  `catence serve` is a loopback-only-by-default Streamable HTTP adapter. It creates a stateless MCP server and transport for each `/mcp` request, so the HTTP and stdio surfaces use the same tool implementation. `GET /health` is a versioned client handshake with runtime version, protocol version, and capabilities; distributed Console builds reject an incompatible protocol before starting a chat. The same listener serves a small read-only `/api/v1/dashboard` snapshot for the local Console: canonical daily-health rows, weekly canonical activity aggregates, recent canonical activities, status, coverage, and explicit missing-data caveats.
93
103