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.
- package/README.md +23 -77
- package/config.example.json +32 -20
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/progress.d.ts +23 -0
- package/dist/contracts/progress.js +48 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/logging.d.ts +16 -0
- package/dist/core/logging.js +68 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +46 -0
- package/dist/core/runtime/catalog.js +17 -7
- package/dist/core/runtime/self-update.d.ts +44 -0
- package/dist/core/runtime/self-update.js +138 -0
- package/dist/elt/application/management.d.ts +2 -0
- package/dist/elt/application/management.js +220 -18
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +4 -3
- package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
- package/dist/elt/ingestion/providers/intervals.js +4 -1
- package/dist/elt/ingestion/providers/strava/service.js +7 -1
- package/dist/elt/storage/database.d.ts +7 -2
- package/dist/elt/storage/database.js +81 -4
- package/dist/elt/storage/migrations.js +25 -1
- package/dist/elt/storage/progress-sidecar.d.ts +26 -0
- package/dist/elt/storage/progress-sidecar.js +87 -0
- package/dist/interfaces/cli/commands.js +91 -1
- package/dist/interfaces/mcp/server.js +7 -0
- package/dist/runtime/index.d.ts +4 -1
- package/dist/runtime/index.js +2 -1
- package/docs/architecture.md +11 -1
- package/docs/configuration.md +221 -0
- package/docs/deployment/docker.md +528 -0
- package/docs/deployment/local-console.md +230 -0
- package/docs/deployment/local-mcp.md +304 -0
- package/docs/distribution.md +58 -0
- package/docs/llm-providers.md +467 -0
- package/docs/mcp-activity-retrieval.md +37 -30
- package/package.json +5 -1
- package/python/catence/providers/garmin/cli.py +30 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/worker.py +132 -5
- package/scripts/discover-opencode-go.mjs +210 -0
- 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
|
-
|
|
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.',
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/runtime/index.js
CHANGED
|
@@ -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';
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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
|
|