catence 0.2.1-beta.6 → 0.2.1-beta.8
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 +2 -2
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/contracts/runtime.d.ts +4 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +16 -10
- package/dist/core/query/repository.d.ts +1 -1
- package/dist/core/query/repository.js +31 -2
- package/dist/core/runtime/athlete-file.d.ts +49 -0
- package/dist/core/runtime/athlete-file.js +177 -0
- package/dist/core/runtime/configuration.js +2 -0
- package/dist/elt/application/management.js +28 -3
- package/dist/elt/normalization/normalizers.js +62 -0
- package/dist/elt/storage/migrations.js +45 -1
- package/dist/interfaces/http/server.js +98 -3
- package/dist/interfaces/mcp/server.js +69 -14
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/docs/deployment/docker.md +87 -8
- package/docs/deployment/local-console.md +98 -11
- package/docs/deployment/local-mcp.md +8 -5
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +31 -3
- package/python/catence/providers/garmin/progress.py +71 -35
- package/python/catence/providers/garmin/staging.py +11 -4
- package/python/catence/providers/garmin/worker.py +420 -206
- package/uv.lock +3 -3
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ As a context, I'm both an endurance athlete and a data junkie. I already got a c
|
|
|
12
12
|
|
|
13
13
|
- Garmin, Intervals.icu, and Strava ingestion with source-aware normalization.
|
|
14
14
|
- Read-only MCP tools, plus explicitly named, lock-guarded write tools for Strava hydration and detached syncs.
|
|
15
|
-
- One
|
|
15
|
+
- One Catence MCP server serves several athlete stores side by side. Every personal-data MCP call names an `athleteId`; tools act only on that store and never merge athletes implicitly. The MCP server does not authenticate callers — it trusts the `athleteId` each call names.
|
|
16
16
|
- A password-protected Chainlit Console with an authenticated dashboard, a data-sync button with live progress, and in-app model management.
|
|
17
17
|
- A generated demo catalog for safe evaluation in Glama, desktop MCP clients, or local development.
|
|
18
18
|
|
|
@@ -57,7 +57,7 @@ Start the stdio MCP server:
|
|
|
57
57
|
catence
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
An agent first calls `list_athletes`, then includes `athleteId` with every data tool.
|
|
60
|
+
An agent first calls `list_athletes`, then includes `athleteId` with every data tool. Tools act only on the named athlete store and never merge athletes implicitly. The MCP server does not authenticate callers — it trusts the `athleteId` each call names — so keep it on loopback or restrict it at the network layer (see [MCP clients and HTTP](#mcp-clients-and-http)).
|
|
61
61
|
|
|
62
62
|
### Add another athlete
|
|
63
63
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.8";
|
|
7
7
|
export declare const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export type CatenceRuntimeCapabilities = {
|
|
9
9
|
mcp: true;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.8';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -8,4 +8,8 @@ export type CatencePaths = {
|
|
|
8
8
|
config: string;
|
|
9
9
|
secrets: string;
|
|
10
10
|
lock: string;
|
|
11
|
+
/** Athlete-authored markdown document shared by the athlete and the agent. */
|
|
12
|
+
athleteFile: string;
|
|
13
|
+
/** Rolling snapshots of prior athlete-file content. Created on first write. */
|
|
14
|
+
athleteFileHistory: string;
|
|
11
15
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type DatasetName = 'activities' | 'canonical_activities' | 'canonical_activity_facts' | 'activity_sources' | 'activity_summaries' | 'activity_intervals' | 'swim_lengths' | 'swim_sets' | 'activity_quality_flags' | 'daily_metrics' | 'daily_health' | 'training_metrics' | 'training_metric_observations' | 'wellness_samples' | 'health_sessions' | 'power_bests' | 'activity_decoupling' | 'nutrition_days' | 'nutrition_items' | 'events' | 'workouts' | 'workout_documents' | 'training_plans' | 'routes' | 'gear' | 'devices' | 'goals' | 'achievements' | 'messages' | 'source_entities' | 'activity_samples' | 'activity_links' | 'activity_segments' | 'segments' | 'segment_effort_history' | 'strava_gear' | 'course_geometry';
|
|
1
|
+
export type DatasetName = 'activities' | 'canonical_activities' | 'canonical_activity_facts' | 'activity_sources' | 'activity_summaries' | 'activity_intervals' | 'swim_lengths' | 'swim_sets' | 'activity_quality_flags' | 'daily_metrics' | 'daily_health' | 'training_metrics' | 'training_metric_observations' | 'wellness_samples' | 'health_sessions' | 'power_bests' | 'activity_decoupling' | 'run_dynamics' | 'nutrition_days' | 'nutrition_items' | 'events' | 'workouts' | 'workout_documents' | 'training_plans' | 'routes' | 'gear' | 'devices' | 'goals' | 'achievements' | 'messages' | 'source_entities' | 'activity_samples' | 'activity_links' | 'activity_segments' | 'segments' | 'segment_effort_history' | 'strava_gear' | 'course_geometry';
|
|
2
2
|
export type CatalogColumn = {
|
|
3
3
|
name: string;
|
|
4
4
|
type: string;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const column = (name, type, options = {}) => ({ name, type, ...options });
|
|
2
|
+
const PAYLOAD_JSON_DESCRIPTION = 'Raw provider object retained for identifier and string lookups; its structure varies by provider and endpoint and is not a stable schema.';
|
|
3
|
+
const EXTENSION_JSON_DESCRIPTION = 'Provider extension object retained alongside payload_json for identifier and string lookups; its structure varies by provider and endpoint.';
|
|
2
4
|
export const DATASET_CATALOG = {
|
|
3
5
|
activities: {
|
|
4
6
|
name: 'activities', relation: 'activities', description: 'Logical activities linked by provider IDs, unique high-confidence fuzzy evidence, or an explicit manual correction.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_id', 'link_state'], permittedGroupings: ['sport', 'link_state'],
|
|
@@ -17,8 +19,8 @@ export const DATASET_CATALOG = {
|
|
|
17
19
|
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('remote_activity_id', 'VARCHAR', { filterable: true }), column('external_id', 'VARCHAR', { filterable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
18
20
|
},
|
|
19
21
|
activity_summaries: {
|
|
20
|
-
name: 'activity_summaries', relation: 'activity_summary_facts', description: 'Provider activity summary metrics with activity and timestamp provenance. A NULL avg_power does not mean no power; FIT-derived power is in power_best_facts. Prefer power_coverage_report or read_series over inferring absence.
|
|
21
|
-
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR', { filterable: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('elevation_gain_m', 'DOUBLE', { unit: 'm', metric: true }), column('calories', 'DOUBLE', { unit: 'kcal', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('weighted_power', 'DOUBLE', { unit: 'W', metric: true }), column('avg_cadence', 'DOUBLE', { unit: 'rpm', metric: true }), column('training_load', 'DOUBLE', { metric: true }), column('rpe', 'DOUBLE', { metric: true }), column('feel', 'DOUBLE', { metric: true })],
|
|
22
|
+
name: 'activity_summaries', relation: 'activity_summary_facts', description: 'Provider activity summary metrics with activity and timestamp provenance. A NULL avg_power does not mean no power; FIT-derived power is in power_best_facts. Prefer power_coverage_report or read_series over inferring absence. Running form metrics (vertical oscillation, ground contact time, flight time, stride length, vertical ratio, cadence) are not columns here; use the run_dynamics dataset for those. metrics_json is the source-specific provider payload and its keys vary by provider: single Garmin activities carry flat keys (for example avgVerticalOscillation), while multisport legs nest the same values under summaryDTO.*. Never treat an absent key as a zero value.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'activity_id', 'provider', 'raw_object_hash'], permittedGroupings: ['provider', 'sport'],
|
|
23
|
+
columns: [column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('name', 'VARCHAR', { filterable: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('moving_s', 'DOUBLE', { unit: 's', metric: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('elevation_gain_m', 'DOUBLE', { unit: 'm', metric: true }), column('calories', 'DOUBLE', { unit: 'kcal', metric: true }), column('avg_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('max_hr', 'DOUBLE', { unit: 'bpm', metric: true }), column('avg_power', 'DOUBLE', { unit: 'W', metric: true }), column('weighted_power', 'DOUBLE', { unit: 'W', metric: true }), column('avg_cadence', 'DOUBLE', { unit: 'rpm', metric: true }), column('training_load', 'DOUBLE', { metric: true }), column('rpe', 'DOUBLE', { metric: true }), column('feel', 'DOUBLE', { metric: true }), column('metrics_json', 'JSON', { description: 'Source-specific provider payload; keys vary by provider and are not a stable schema. Flat keys for single activities; multisport legs nest values under summaryDTO.*. Use run_dynamics for normalized running form metrics.' })],
|
|
22
24
|
},
|
|
23
25
|
activity_intervals: {
|
|
24
26
|
name: 'activity_intervals', relation: 'activity_interval_facts', description: 'Structured intervals attached to an activity source.', dateColumn: 'started_at_utc', provenanceColumns: ['activity_source_id', 'activity_id', 'provider'], permittedGroupings: ['provider', 'sport', 'label'],
|
|
@@ -54,7 +56,7 @@ export const DATASET_CATALOG = {
|
|
|
54
56
|
},
|
|
55
57
|
health_sessions: {
|
|
56
58
|
name: 'health_sessions', relation: 'health_sessions', description: 'Garmin sleep, nap, daily-event, and Body Battery event records with their source payload retained.', dateColumn: 'occurred_on', provenanceColumns: ['provider', 'source_type', 'source_remote_id', 'raw_object_hash'], permittedGroupings: ['provider', 'session_type', 'source_type'],
|
|
57
|
-
columns: [column('session_id', 'VARCHAR', { filterable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('session_type', 'VARCHAR', { filterable: true, groupable: true }), column('occurred_on', 'DATE', { filterable: true, groupable: true }), column('started_at', 'TIMESTAMPTZ', { filterable: true }), column('ended_at', 'TIMESTAMPTZ', { filterable: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('source_remote_id', 'VARCHAR', { filterable: true }), column('payload_json', 'JSON'), column('raw_object_hash', 'VARCHAR')],
|
|
59
|
+
columns: [column('session_id', 'VARCHAR', { filterable: true }), column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('session_type', 'VARCHAR', { filterable: true, groupable: true }), column('occurred_on', 'DATE', { filterable: true, groupable: true }), column('started_at', 'TIMESTAMPTZ', { filterable: true }), column('ended_at', 'TIMESTAMPTZ', { filterable: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('source_remote_id', 'VARCHAR', { filterable: true }), column('payload_json', 'JSON', { description: PAYLOAD_JSON_DESCRIPTION }), column('raw_object_hash', 'VARCHAR')],
|
|
58
60
|
},
|
|
59
61
|
power_bests: {
|
|
60
62
|
name: 'power_bests', relation: 'power_best_facts', description: 'Power-duration bests derived from Garmin FIT streams; they are labelled derived while retaining the Garmin activity provenance.', dateColumn: 'started_at_utc', provenanceColumns: ['provider', 'activity_source_id', 'source_type', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'duration_s', 'source_type'],
|
|
@@ -64,19 +66,23 @@ export const DATASET_CATALOG = {
|
|
|
64
66
|
name: 'activity_decoupling', relation: 'activity_decoupling_facts', description: 'Persisted per-activity aerobic decoupling (Pa:Hr, pct) and grade-adjusted pace (s/km) with provenance. source_type provider means the provider payload supplied the value (authoritative); derived values are tagged locally. Values never overwrite provider output. Not a physiological performance model.', dateColumn: 'started_at_utc', provenanceColumns: ['provider', 'activity_source_id', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'metric', 'source_type'],
|
|
65
67
|
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('metric', 'VARCHAR', { filterable: true, groupable: true }), column('value_number', 'DOUBLE', { metric: true }), column('unit', 'VARCHAR', { filterable: true, groupable: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
66
68
|
},
|
|
69
|
+
run_dynamics: {
|
|
70
|
+
name: 'run_dynamics', relation: 'run_dynamics', description: 'Provider-normalized running form metrics per activity source: vertical oscillation (cm), ground contact time (ms), flight time (ms), stride length (cm), vertical ratio (%), and running cadence (spm). Only activities with at least one reported value have a row, and a null column means the provider did not report that metric; never treat null as zero or derive it from other metrics. Values come from the provider payload (flat keys for single activities, summaryDTO for multisport legs) and never overwrite provider output. Rows are per provider source and carry activity_id; when an activity is linked across providers, filter provider or group by activity_id before averaging.', dateColumn: 'started_at_utc', provenanceColumns: ['provider', 'activity_source_id', 'raw_object_hash'], permittedGroupings: ['provider', 'sport', 'source_type'],
|
|
71
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('activity_id', 'VARCHAR', { filterable: true, groupable: true }), column('sport', 'VARCHAR', { filterable: true, groupable: true }), column('started_at_utc', 'TIMESTAMPTZ', { filterable: true }), column('name', 'VARCHAR', { filterable: true }), column('vertical_oscillation_cm', 'DOUBLE', { unit: 'cm', metric: true }), column('ground_contact_time_ms', 'DOUBLE', { unit: 'ms', metric: true }), column('flight_time_ms', 'DOUBLE', { unit: 'ms', metric: true }), column('stride_length_cm', 'DOUBLE', { unit: 'cm', metric: true }), column('vertical_ratio_pct', 'DOUBLE', { unit: '%', metric: true }), column('cadence_spm', 'DOUBLE', { unit: 'spm', metric: true }), column('source_type', 'VARCHAR', { filterable: true, groupable: true }), column('raw_object_hash', 'VARCHAR')],
|
|
72
|
+
},
|
|
67
73
|
nutrition_days: {
|
|
68
|
-
name: 'nutrition_days', relation: 'nutrition_days', description: 'Daily nutrition totals, never a replacement for the item-level food log.', dateColumn: 'nutrition_date', provenanceColumns: ['provider', 'raw_object_hash'], permittedGroupings: ['provider'],
|
|
69
|
-
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('nutrition_date', 'DATE', { filterable: true, groupable: true }), column('energy_kcal', 'DOUBLE', { unit: 'kcal', metric: true }), column('carbohydrates_g', 'DOUBLE', { unit: 'g', metric: true }), column('protein_g', 'DOUBLE', { unit: 'g', metric: true }), column('fat_g', 'DOUBLE', { unit: 'g', metric: true }), column('hydration_ml', 'DOUBLE', { unit: 'ml', metric: true }), column('raw_object_hash', 'VARCHAR')],
|
|
74
|
+
name: 'nutrition_days', relation: 'nutrition_days', description: 'Daily nutrition totals, never a replacement for the item-level food log. metrics_json is the source-specific provider payload; its keys vary by provider and are not a stable schema.', dateColumn: 'nutrition_date', provenanceColumns: ['provider', 'raw_object_hash'], permittedGroupings: ['provider'],
|
|
75
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('nutrition_date', 'DATE', { filterable: true, groupable: true }), column('energy_kcal', 'DOUBLE', { unit: 'kcal', metric: true }), column('carbohydrates_g', 'DOUBLE', { unit: 'g', metric: true }), column('protein_g', 'DOUBLE', { unit: 'g', metric: true }), column('fat_g', 'DOUBLE', { unit: 'g', metric: true }), column('hydration_ml', 'DOUBLE', { unit: 'ml', metric: true }), column('metrics_json', 'JSON', { description: 'Source-specific provider payload; keys vary by provider and are not a stable schema.' }), column('raw_object_hash', 'VARCHAR')],
|
|
70
76
|
},
|
|
71
77
|
nutrition_items: {
|
|
72
78
|
name: 'nutrition_items', relation: 'nutrition_items', description: 'Meal and food-item detail retained from the provider.', dateColumn: 'nutrition_date', provenanceColumns: ['provider', 'remote_item_id', 'raw_object_hash'], permittedGroupings: ['provider', 'meal', 'food_name'],
|
|
73
|
-
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('remote_item_id', 'VARCHAR', { filterable: true }), column('nutrition_date', 'DATE', { filterable: true, groupable: true }), column('meal', 'VARCHAR', { filterable: true, groupable: true }), column('consumed_at', 'TIMESTAMPTZ', { filterable: true }), column('food_name', 'VARCHAR', { filterable: true, groupable: true }), column('quantity', 'DOUBLE', { metric: true }), column('energy_kcal', 'DOUBLE', { unit: 'kcal', metric: true }), column('carbohydrates_g', 'DOUBLE', { unit: 'g', metric: true }), column('protein_g', 'DOUBLE', { unit: 'g', metric: true }), column('fat_g', 'DOUBLE', { unit: 'g', metric: true }), column('raw_object_hash', 'VARCHAR')],
|
|
79
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('remote_item_id', 'VARCHAR', { filterable: true }), column('nutrition_date', 'DATE', { filterable: true, groupable: true }), column('meal', 'VARCHAR', { filterable: true, groupable: true }), column('consumed_at', 'TIMESTAMPTZ', { filterable: true }), column('food_name', 'VARCHAR', { filterable: true, groupable: true }), column('quantity', 'DOUBLE', { metric: true }), column('energy_kcal', 'DOUBLE', { unit: 'kcal', metric: true }), column('carbohydrates_g', 'DOUBLE', { unit: 'g', metric: true }), column('protein_g', 'DOUBLE', { unit: 'g', metric: true }), column('fat_g', 'DOUBLE', { unit: 'g', metric: true }), column('payload_json', 'JSON', { description: PAYLOAD_JSON_DESCRIPTION }), column('raw_object_hash', 'VARCHAR')],
|
|
74
80
|
},
|
|
75
81
|
events: domain('events', 'event_id', 'Planned and completed calendar events.', 'occurred_on'),
|
|
76
82
|
workouts: domain('workouts', 'workout_id', 'Provider workouts and scheduled workouts.', 'occurred_on'),
|
|
77
83
|
workout_documents: {
|
|
78
84
|
name: 'workout_documents', relation: 'workout_documents', description: 'Structured workout documents.', provenanceColumns: ['provider', 'workout_id'], permittedGroupings: ['provider'],
|
|
79
|
-
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('workout_id', 'VARCHAR', { filterable: true, groupable: true }), column('workout_doc', 'JSON'), column('payload_json', 'JSON')],
|
|
85
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('workout_id', 'VARCHAR', { filterable: true, groupable: true }), column('workout_doc', 'JSON'), column('payload_json', 'JSON', { description: PAYLOAD_JSON_DESCRIPTION })],
|
|
80
86
|
},
|
|
81
87
|
training_plans: domain('training_plans', 'training_plan_id', 'Training plans and plan hierarchy.', 'occurred_on'),
|
|
82
88
|
routes: domain('routes', 'route_id', 'Saved routes.'),
|
|
@@ -87,7 +93,7 @@ export const DATASET_CATALOG = {
|
|
|
87
93
|
messages: domain('messages', 'message_id', 'Provider chat/messages.', 'occurred_on'),
|
|
88
94
|
source_entities: {
|
|
89
95
|
name: 'source_entities', relation: 'source_entities', description: 'All raw-normalized source entities, including fields not modelled elsewhere.', dateColumn: 'occurred_on', provenanceColumns: ['provider', 'entity_type', 'remote_id', 'raw_object_hash'], permittedGroupings: ['provider', 'entity_type'],
|
|
90
|
-
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('entity_type', 'VARCHAR', { filterable: true, groupable: true }), column('remote_id', 'VARCHAR', { filterable: true }), column('parent_remote_id', 'VARCHAR', { filterable: true }), column('occurred_on', 'DATE', { filterable: true, groupable: true }), column('source_updated_at', 'TIMESTAMPTZ', { filterable: true }), column('raw_object_hash', 'VARCHAR'), column('payload_json', 'JSON'), column('extension_json', 'JSON')],
|
|
96
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('entity_type', 'VARCHAR', { filterable: true, groupable: true }), column('remote_id', 'VARCHAR', { filterable: true }), column('parent_remote_id', 'VARCHAR', { filterable: true }), column('occurred_on', 'DATE', { filterable: true, groupable: true }), column('source_updated_at', 'TIMESTAMPTZ', { filterable: true }), column('raw_object_hash', 'VARCHAR'), column('payload_json', 'JSON', { description: PAYLOAD_JSON_DESCRIPTION }), column('extension_json', 'JSON', { description: EXTENSION_JSON_DESCRIPTION })],
|
|
91
97
|
},
|
|
92
98
|
activity_links: {
|
|
93
99
|
name: 'activity_links', relation: 'activity_links', description: 'Auditable source-to-logical activity links and their evidence.', provenanceColumns: ['activity_source_id', 'method', 'evidence_json'], permittedGroupings: ['method'],
|
|
@@ -107,7 +113,7 @@ export const DATASET_CATALOG = {
|
|
|
107
113
|
},
|
|
108
114
|
strava_gear: {
|
|
109
115
|
name: 'strava_gear', relation: 'strava_gear', description: 'Current Strava bikes and shoes, refreshed from the authenticated athlete profile.', provenanceColumns: ['gear_id', 'raw_object_hash'], permittedGroupings: ['provider'],
|
|
110
|
-
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('gear_id', 'VARCHAR', { filterable: true, groupable: true }), column('payload_json', 'JSON'), column('extension_json', 'JSON'), column('raw_object_hash', 'VARCHAR')],
|
|
116
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('gear_id', 'VARCHAR', { filterable: true, groupable: true }), column('payload_json', 'JSON', { description: PAYLOAD_JSON_DESCRIPTION }), column('extension_json', 'JSON', { description: EXTENSION_JSON_DESCRIPTION }), column('raw_object_hash', 'VARCHAR')],
|
|
111
117
|
},
|
|
112
118
|
activity_samples: {
|
|
113
119
|
name: 'activity_samples', description: 'Registered normalized activity samples from immutable Parquet; callers cannot supply file paths.', dateColumn: 'timestamp_utc', provenanceColumns: ['provider', 'activity_source_id', 'content_hash'], permittedGroupings: ['provider', 'activity_source_id'], samplesOnly: true,
|
|
@@ -121,7 +127,7 @@ export const DATASET_CATALOG = {
|
|
|
121
127
|
function domain(name, id, description, dateColumn) {
|
|
122
128
|
return {
|
|
123
129
|
name, relation: name, description, dateColumn, provenanceColumns: ['provider', id, 'raw_object_hash'], permittedGroupings: ['provider'],
|
|
124
|
-
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column(id, 'VARCHAR', { filterable: true, groupable: true }), ...(dateColumn ? [column(dateColumn, 'DATE', { filterable: true, groupable: true })] : []), column('payload_json', 'JSON'), column('extension_json', 'JSON'), column('raw_object_hash', 'VARCHAR')],
|
|
130
|
+
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column(id, 'VARCHAR', { filterable: true, groupable: true }), ...(dateColumn ? [column(dateColumn, 'DATE', { filterable: true, groupable: true })] : []), column('payload_json', 'JSON', { description: PAYLOAD_JSON_DESCRIPTION }), column('extension_json', 'JSON', { description: EXTENSION_JSON_DESCRIPTION }), column('raw_object_hash', 'VARCHAR')],
|
|
125
131
|
};
|
|
126
132
|
}
|
|
127
133
|
export function getDataset(name) {
|
|
@@ -14,7 +14,7 @@ export declare class ReadOnlyRepository {
|
|
|
14
14
|
progress(): Promise<SyncProgressSnapshot>;
|
|
15
15
|
coverage(): Promise<Record<string, unknown>>;
|
|
16
16
|
activity(activityId: string): Promise<Record<string, unknown> | null>;
|
|
17
|
-
summary(startDate: string, endDate: string): Promise<Record<string, unknown>>;
|
|
17
|
+
summary(startDate: string, endDate: string, detail?: 'summary' | 'columns'): Promise<Record<string, unknown>>;
|
|
18
18
|
catalog(): DatasetDefinition[];
|
|
19
19
|
}
|
|
20
20
|
/** Converts DuckDB's Date/BigInt values into MCP-safe JSON values. */
|
|
@@ -172,12 +172,39 @@ export class ReadOnlyRepository {
|
|
|
172
172
|
return mergeProgress(running.map(toSyncProgressState), recent.map(toSyncProgressState), runningSidecars);
|
|
173
173
|
}
|
|
174
174
|
async coverage() {
|
|
175
|
+
// One UNION keeps the per-relation min/max/count in a single metadata
|
|
176
|
+
// query. Every cataloged dataset with a usable date axis gets a row;
|
|
177
|
+
// segments and course_geometry are dictionaries without a data date, so
|
|
178
|
+
// they report a count and null dates rather than inventing a timeline from
|
|
179
|
+
// ingestion timestamps.
|
|
175
180
|
const rows = await this.rows(`
|
|
176
181
|
SELECT 'activities' AS dataset, min(cast(started_at_utc AS DATE)) AS start_date, max(cast(started_at_utc AS DATE)) AS end_date, count(*)::INTEGER AS row_count FROM activities
|
|
182
|
+
UNION ALL SELECT 'canonical_activities', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM canonical_activity_training
|
|
183
|
+
UNION ALL SELECT 'canonical_activity_facts', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM canonical_activity_facts
|
|
184
|
+
UNION ALL SELECT 'activity_summaries', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM activity_summary_facts
|
|
185
|
+
UNION ALL SELECT 'activity_intervals', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM activity_interval_facts
|
|
186
|
+
UNION ALL SELECT 'swim_lengths', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM swim_length_facts
|
|
187
|
+
UNION ALL SELECT 'swim_sets', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM swim_set_facts
|
|
188
|
+
UNION ALL SELECT 'activity_quality_flags', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM activity_quality_flag_facts
|
|
189
|
+
UNION ALL SELECT 'power_bests', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM power_best_facts
|
|
190
|
+
UNION ALL SELECT 'activity_decoupling', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM activity_decoupling_facts
|
|
191
|
+
UNION ALL SELECT 'run_dynamics', min(cast(started_at_utc AS DATE)), max(cast(started_at_utc AS DATE)), count(*)::INTEGER FROM run_dynamics_facts
|
|
177
192
|
UNION ALL SELECT 'daily_metrics', min(metric_date), max(metric_date), count(*)::INTEGER FROM daily_metrics
|
|
193
|
+
UNION ALL SELECT 'daily_health', min(metric_date), max(metric_date), count(*)::INTEGER FROM daily_health
|
|
178
194
|
UNION ALL SELECT 'training_metric_observations', min(cast(observed_at AS DATE)), max(cast(observed_at AS DATE)), count(*)::INTEGER FROM training_metric_observations
|
|
195
|
+
UNION ALL SELECT 'wellness_samples', min(cast(observed_at AS DATE)), max(cast(observed_at AS DATE)), count(*)::INTEGER FROM wellness_samples
|
|
196
|
+
UNION ALL SELECT 'health_sessions', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM health_sessions
|
|
179
197
|
UNION ALL SELECT 'nutrition_days', min(nutrition_date), max(nutrition_date), count(*)::INTEGER FROM nutrition_days
|
|
198
|
+
UNION ALL SELECT 'nutrition_items', min(nutrition_date), max(nutrition_date), count(*)::INTEGER FROM nutrition_items
|
|
199
|
+
UNION ALL SELECT 'events', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM events
|
|
200
|
+
UNION ALL SELECT 'workouts', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM workouts
|
|
201
|
+
UNION ALL SELECT 'training_plans', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM training_plans
|
|
202
|
+
UNION ALL SELECT 'messages', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM messages
|
|
180
203
|
UNION ALL SELECT 'source_entities', min(occurred_on), max(occurred_on), count(*)::INTEGER FROM source_entities
|
|
204
|
+
UNION ALL SELECT 'activity_segments', min(cast(started_at AS DATE)), max(cast(started_at AS DATE)), count(*)::INTEGER FROM activity_segments
|
|
205
|
+
UNION ALL SELECT 'segment_effort_history', min(cast(started_at AS DATE)), max(cast(started_at AS DATE)), count(*)::INTEGER FROM segment_effort_history
|
|
206
|
+
UNION ALL SELECT 'segments', CAST(NULL AS DATE), CAST(NULL AS DATE), count(*)::INTEGER FROM strava_segments
|
|
207
|
+
UNION ALL SELECT 'course_geometry', CAST(NULL AS DATE), CAST(NULL AS DATE), count(*)::INTEGER FROM course_geometry
|
|
181
208
|
`);
|
|
182
209
|
return { coverage: rows, providers: await this.rows(`SELECT provider, count(*)::INTEGER AS activity_sources FROM activity_sources GROUP BY provider ORDER BY provider`) };
|
|
183
210
|
}
|
|
@@ -190,10 +217,12 @@ export class ReadOnlyRepository {
|
|
|
190
217
|
const intervals = await this.rows(`SELECT * FROM activity_interval_facts WHERE activity_id = $activityId ORDER BY CASE provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END, provider, start_s`, { activityId });
|
|
191
218
|
return { activity: activity[0], sources, summaries, intervals };
|
|
192
219
|
}
|
|
193
|
-
async summary(startDate, endDate) {
|
|
220
|
+
async summary(startDate, endDate, detail = 'summary') {
|
|
194
221
|
const training = await this.rows(`SELECT cast(started_at_utc AS DATE) AS date, count(*)::INTEGER AS activities, sum(distance_m) AS distance_m, sum(moving_s) AS moving_s, sum(training_load) AS training_load FROM canonical_activity_training WHERE cast(started_at_utc AS DATE) BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE) GROUP BY 1 ORDER BY 1`, { startDate, endDate });
|
|
195
222
|
const health = await this.rows(`SELECT * FROM daily_health WHERE metric_date BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE) ORDER BY metric_date, provider`, { startDate, endDate });
|
|
196
|
-
const nutrition =
|
|
223
|
+
const nutrition = detail === 'columns'
|
|
224
|
+
? await this.rows(`SELECT nutrition_date, provider, energy_kcal, carbohydrates_g, protein_g, fat_g, hydration_ml FROM nutrition_days WHERE nutrition_date BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE) ORDER BY nutrition_date, provider`, { startDate, endDate })
|
|
225
|
+
: await this.rows(`SELECT count(*)::INTEGER AS days_logged, min(nutrition_date) AS first_date, max(nutrition_date) AS last_date, sum(energy_kcal) AS energy_kcal, sum(carbohydrates_g) AS carbohydrates_g, sum(protein_g) AS protein_g, sum(fat_g) AS fat_g, sum(hydration_ml) AS hydration_ml FROM nutrition_days WHERE nutrition_date BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE)`, { startDate, endDate });
|
|
197
226
|
return { training, health, nutrition };
|
|
198
227
|
}
|
|
199
228
|
catalog() {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
2
|
+
/**
|
|
3
|
+
* One athlete-authored markdown document per athlete store. Both the athlete
|
|
4
|
+
* (Console/UI) and the agent (MCP tools) may write it; every write is guarded
|
|
5
|
+
* by an optimistic compare-and-swap on the sha256 of the current content so
|
|
6
|
+
* neither side silently clobbers the other. Prior content is snapshotted into
|
|
7
|
+
* a small rolling revision history.
|
|
8
|
+
*/
|
|
9
|
+
export declare const ATHLETE_FILE_MAX_CHARACTERS = 32768;
|
|
10
|
+
export declare const ATHLETE_FILE_TEMPLATE = "# Athlete file\n\nThis file is shared by the athlete and the Catence agent. Keep durable facts here:\nprofile, goals, constraints, injuries, preferences, and standing notes.\nDo not store credentials, provider configuration, or transient daily status.\n\n## Profile\n\n## Goals\n\n## Constraints and injuries\n\n## Preferences\n\n## Notes\n";
|
|
11
|
+
export type AthleteFileSnapshot = {
|
|
12
|
+
exists: boolean;
|
|
13
|
+
/** Stored content, or the starter template while the file does not exist yet. */
|
|
14
|
+
content: string;
|
|
15
|
+
/** sha256 of the stored content; null while the file does not exist. */
|
|
16
|
+
hash: string | null;
|
|
17
|
+
updatedAt: string | null;
|
|
18
|
+
};
|
|
19
|
+
export type AthleteFileOperation = 'replace' | 'append' | 'replace_section';
|
|
20
|
+
export type AthleteFileUpdate = {
|
|
21
|
+
operation: AthleteFileOperation;
|
|
22
|
+
/** Required when operation is replace_section. */
|
|
23
|
+
section?: string;
|
|
24
|
+
content: string;
|
|
25
|
+
/** Hash the caller read before this update; null when creating the file. */
|
|
26
|
+
expectedHash: string | null;
|
|
27
|
+
};
|
|
28
|
+
export type AthleteFileRevision = {
|
|
29
|
+
revisionId: string;
|
|
30
|
+
hash: string;
|
|
31
|
+
savedAt: string;
|
|
32
|
+
};
|
|
33
|
+
export declare class AthleteFileConflictError extends Error {
|
|
34
|
+
readonly currentHash: string;
|
|
35
|
+
readonly currentContent: string;
|
|
36
|
+
constructor(currentHash: string, currentContent: string);
|
|
37
|
+
}
|
|
38
|
+
export declare class AthleteFileTooLargeError extends Error {
|
|
39
|
+
readonly characters: number;
|
|
40
|
+
constructor(characters: number);
|
|
41
|
+
}
|
|
42
|
+
export declare class AthleteFileValidationError extends Error {
|
|
43
|
+
constructor(message: string);
|
|
44
|
+
}
|
|
45
|
+
export declare function hashAthleteFileContent(content: string): string;
|
|
46
|
+
export declare function readAthleteFile(paths: CatencePaths): Promise<AthleteFileSnapshot>;
|
|
47
|
+
export declare function updateAthleteFile(paths: CatencePaths, update: AthleteFileUpdate): Promise<AthleteFileSnapshot>;
|
|
48
|
+
export declare function listAthleteFileRevisions(paths: CatencePaths): Promise<AthleteFileRevision[]>;
|
|
49
|
+
export declare function readAthleteFileRevision(paths: CatencePaths, revisionId: string): Promise<string | null>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* One athlete-authored markdown document per athlete store. Both the athlete
|
|
6
|
+
* (Console/UI) and the agent (MCP tools) may write it; every write is guarded
|
|
7
|
+
* by an optimistic compare-and-swap on the sha256 of the current content so
|
|
8
|
+
* neither side silently clobbers the other. Prior content is snapshotted into
|
|
9
|
+
* a small rolling revision history.
|
|
10
|
+
*/
|
|
11
|
+
export const ATHLETE_FILE_MAX_CHARACTERS = 32_768;
|
|
12
|
+
const ATHLETE_FILE_HISTORY_LIMIT = 10;
|
|
13
|
+
const REVISION_PATTERN = /^(\d{1,20})-([0-9a-f]{8})\.md$/;
|
|
14
|
+
export const ATHLETE_FILE_TEMPLATE = `# Athlete file
|
|
15
|
+
|
|
16
|
+
This file is shared by the athlete and the Catence agent. Keep durable facts here:
|
|
17
|
+
profile, goals, constraints, injuries, preferences, and standing notes.
|
|
18
|
+
Do not store credentials, provider configuration, or transient daily status.
|
|
19
|
+
|
|
20
|
+
## Profile
|
|
21
|
+
|
|
22
|
+
## Goals
|
|
23
|
+
|
|
24
|
+
## Constraints and injuries
|
|
25
|
+
|
|
26
|
+
## Preferences
|
|
27
|
+
|
|
28
|
+
## Notes
|
|
29
|
+
`;
|
|
30
|
+
export class AthleteFileConflictError extends Error {
|
|
31
|
+
currentHash;
|
|
32
|
+
currentContent;
|
|
33
|
+
constructor(currentHash, currentContent) {
|
|
34
|
+
super('The athlete file changed since it was read. Read it again before writing.');
|
|
35
|
+
this.currentHash = currentHash;
|
|
36
|
+
this.currentContent = currentContent;
|
|
37
|
+
this.name = 'AthleteFileConflictError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export class AthleteFileTooLargeError extends Error {
|
|
41
|
+
characters;
|
|
42
|
+
constructor(characters) {
|
|
43
|
+
super(`Athlete file would be ${characters} characters; the limit is ${ATHLETE_FILE_MAX_CHARACTERS}. Shorten the write or split it into sections.`);
|
|
44
|
+
this.characters = characters;
|
|
45
|
+
this.name = 'AthleteFileTooLargeError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export class AthleteFileValidationError extends Error {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'AthleteFileValidationError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function hashAthleteFileContent(content) {
|
|
55
|
+
return createHash('sha256').update(content).digest('hex');
|
|
56
|
+
}
|
|
57
|
+
function normalizeContent(content) {
|
|
58
|
+
return `${content.replace(/\r\n?/g, '\n').replace(/\n+$/, '')}\n`;
|
|
59
|
+
}
|
|
60
|
+
function escapeRegExp(value) {
|
|
61
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
62
|
+
}
|
|
63
|
+
function applySection(content, section, replacement) {
|
|
64
|
+
const name = section.trim();
|
|
65
|
+
if (!name)
|
|
66
|
+
throw new AthleteFileValidationError('replace_section requires a non-empty section name.');
|
|
67
|
+
const body = normalizeContent(replacement).trim();
|
|
68
|
+
const match = new RegExp(`^##\\s+${escapeRegExp(name)}\\s*$`, 'im').exec(content);
|
|
69
|
+
if (!match) {
|
|
70
|
+
const base = normalizeContent(content).trimEnd();
|
|
71
|
+
return body ? `${base}\n\n## ${name}\n\n${body}\n` : `${base}\n\n## ${name}\n`;
|
|
72
|
+
}
|
|
73
|
+
const bodyStart = content.indexOf('\n', match.index) + 1;
|
|
74
|
+
const rest = content.slice(bodyStart);
|
|
75
|
+
const nextHeading = /^##\s+/m.exec(rest);
|
|
76
|
+
const bodyEnd = nextHeading ? bodyStart + nextHeading.index : content.length;
|
|
77
|
+
const before = content.slice(0, bodyStart);
|
|
78
|
+
const after = content.slice(bodyEnd).replace(/^\n+/, '');
|
|
79
|
+
const combined = body ? `${before}\n${body}\n\n${after}` : `${before}\n${after}`;
|
|
80
|
+
return normalizeContent(combined).replace(/\n{3,}/g, '\n\n');
|
|
81
|
+
}
|
|
82
|
+
export async function readAthleteFile(paths) {
|
|
83
|
+
let stored;
|
|
84
|
+
try {
|
|
85
|
+
stored = await readFile(paths.athleteFile, 'utf8');
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error.code !== 'ENOENT')
|
|
89
|
+
throw error;
|
|
90
|
+
stored = null;
|
|
91
|
+
}
|
|
92
|
+
if (stored === null)
|
|
93
|
+
return { exists: false, content: ATHLETE_FILE_TEMPLATE, hash: null, updatedAt: null };
|
|
94
|
+
const content = normalizeContent(stored);
|
|
95
|
+
const stats = await stat(paths.athleteFile);
|
|
96
|
+
return { exists: true, content, hash: hashAthleteFileContent(content), updatedAt: stats.mtime.toISOString() };
|
|
97
|
+
}
|
|
98
|
+
async function writeAtomically(filePath, content) {
|
|
99
|
+
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
|
|
100
|
+
await writeFile(temporaryPath, content, { encoding: 'utf8', mode: 0o600 });
|
|
101
|
+
try {
|
|
102
|
+
await rename(temporaryPath, filePath);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function listRevisionNames(paths) {
|
|
110
|
+
const names = await readdir(paths.athleteFileHistory).catch((error) => {
|
|
111
|
+
if (error.code === 'ENOENT')
|
|
112
|
+
return [];
|
|
113
|
+
throw error;
|
|
114
|
+
});
|
|
115
|
+
return names
|
|
116
|
+
.map((name) => ({ name, match: REVISION_PATTERN.exec(name) }))
|
|
117
|
+
.filter((entry) => entry.match !== null)
|
|
118
|
+
.map((entry) => ({ name: entry.name, epoch: Number(entry.match[1]) }))
|
|
119
|
+
.sort((left, right) => right.epoch - left.epoch);
|
|
120
|
+
}
|
|
121
|
+
async function snapshotRevision(paths, content) {
|
|
122
|
+
await mkdir(paths.athleteFileHistory, { recursive: true, mode: 0o700 });
|
|
123
|
+
const name = `${Date.now()}-${hashAthleteFileContent(content).slice(0, 8)}.md`;
|
|
124
|
+
await writeFile(path.join(paths.athleteFileHistory, name), content, { encoding: 'utf8', mode: 0o600 });
|
|
125
|
+
const revisions = await listRevisionNames(paths);
|
|
126
|
+
for (const stale of revisions.slice(ATHLETE_FILE_HISTORY_LIMIT)) {
|
|
127
|
+
await unlink(path.join(paths.athleteFileHistory, stale.name)).catch(() => undefined);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export async function updateAthleteFile(paths, update) {
|
|
131
|
+
if (!['replace', 'append', 'replace_section'].includes(update.operation)) {
|
|
132
|
+
throw new AthleteFileValidationError(`Unknown athlete-file operation: ${String(update.operation)}.`);
|
|
133
|
+
}
|
|
134
|
+
const sparse = update.content.trim();
|
|
135
|
+
if (update.operation !== 'replace_section' && !sparse) {
|
|
136
|
+
throw new AthleteFileValidationError(`${update.operation} requires non-empty content.`);
|
|
137
|
+
}
|
|
138
|
+
const current = await readAthleteFile(paths);
|
|
139
|
+
if (current.hash !== update.expectedHash) {
|
|
140
|
+
throw new AthleteFileConflictError(current.hash ?? 'absent', current.content);
|
|
141
|
+
}
|
|
142
|
+
let next;
|
|
143
|
+
if (update.operation === 'replace')
|
|
144
|
+
next = normalizeContent(update.content);
|
|
145
|
+
else if (update.operation === 'append')
|
|
146
|
+
next = normalizeContent(`${current.content.trimEnd()}\n\n${update.content.trim()}`);
|
|
147
|
+
else
|
|
148
|
+
next = applySection(current.content, update.section ?? '', update.content);
|
|
149
|
+
next = normalizeContent(next);
|
|
150
|
+
if (next.length > ATHLETE_FILE_MAX_CHARACTERS)
|
|
151
|
+
throw new AthleteFileTooLargeError(next.length);
|
|
152
|
+
if (current.exists)
|
|
153
|
+
await snapshotRevision(paths, current.content);
|
|
154
|
+
await writeAtomically(paths.athleteFile, next);
|
|
155
|
+
return readAthleteFile(paths);
|
|
156
|
+
}
|
|
157
|
+
export async function listAthleteFileRevisions(paths) {
|
|
158
|
+
const revisions = await listRevisionNames(paths);
|
|
159
|
+
return revisions.map((revision) => ({
|
|
160
|
+
revisionId: revision.name,
|
|
161
|
+
hash: REVISION_PATTERN.exec(revision.name)[2],
|
|
162
|
+
savedAt: new Date(revision.epoch).toISOString(),
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
export async function readAthleteFileRevision(paths, revisionId) {
|
|
166
|
+
if (!REVISION_PATTERN.test(revisionId))
|
|
167
|
+
throw new AthleteFileValidationError('Unknown athlete-file revision identifier.');
|
|
168
|
+
try {
|
|
169
|
+
return normalizeContent(await readFile(path.join(paths.athleteFileHistory, revisionId), 'utf8'));
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error.code === 'ENOENT')
|
|
173
|
+
return null;
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
//# sourceMappingURL=athlete-file.js.map
|
|
@@ -14,6 +14,8 @@ export function resolvePaths(root = process.env.CATENCE_DATA_DIR ?? '.catence')
|
|
|
14
14
|
config: path.join(absoluteRoot, 'config.json'),
|
|
15
15
|
secrets: path.join(absoluteRoot, 'secrets'),
|
|
16
16
|
lock: path.join(absoluteRoot, '.catence-write.lock'),
|
|
17
|
+
athleteFile: path.join(absoluteRoot, 'athlete.md'),
|
|
18
|
+
athleteFileHistory: path.join(absoluteRoot, 'athlete-revisions'),
|
|
17
19
|
};
|
|
18
20
|
}
|
|
19
21
|
export async function ensurePaths(paths) {
|
|
@@ -207,7 +207,21 @@ function runGarminWorker(runId, arguments_, environment, pump, log, onInterruptR
|
|
|
207
207
|
}
|
|
208
208
|
log.info(`[garmin] ${line}`);
|
|
209
209
|
});
|
|
210
|
-
buffer(child.stderr, (line) =>
|
|
210
|
+
buffer(child.stderr, (line) => {
|
|
211
|
+
let parsed;
|
|
212
|
+
try {
|
|
213
|
+
parsed = JSON.parse(line);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
log.error(`[garmin] ${line}`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (typeof parsed === 'object' && parsed !== null && parsed.kind === 'stage_timings') {
|
|
220
|
+
log.info('Garmin staging worker stage timings', parsed);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
log.error(`[garmin] ${line}`);
|
|
224
|
+
});
|
|
211
225
|
child.on('error', (error) => {
|
|
212
226
|
if (killTimer)
|
|
213
227
|
clearTimeout(killTimer);
|
|
@@ -310,7 +324,9 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
310
324
|
// streams even when the list-summary hash is unchanged.
|
|
311
325
|
await writeFile(knownActivities, JSON.stringify(options.refreshActivities ? {} : await database.knownActivitySummaryHashes('garmin')));
|
|
312
326
|
extractionStarted = true;
|
|
313
|
-
const
|
|
327
|
+
const requestedConcurrency = Number.parseInt(process.env.CATENCE_GARMIN_CONCURRENCY ?? '5', 10);
|
|
328
|
+
const workerConcurrency = Number.isFinite(requestedConcurrency) && requestedConcurrency > 0 ? Math.trunc(requestedConcurrency) : 5;
|
|
329
|
+
const arguments_ = ['run', 'python', '-m', 'python.catence.providers.garmin.cli', '--from', fromDate, '--to', options.toDate, '--data-dir', paths.root, '--output', output, '--run-id', runId, '--known-activities', knownActivities, '--concurrency', String(workerConcurrency)];
|
|
314
330
|
if (dailyWindow)
|
|
315
331
|
arguments_.push('--daily-from', dailyWindow.fromDate, '--daily-to', dailyWindow.toDate);
|
|
316
332
|
else
|
|
@@ -340,6 +356,7 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
340
356
|
if (needsEventBackfill && earliestActivity && earliestActivity < eventsShortFrom) {
|
|
341
357
|
arguments_.push('--events-from', earliestActivity);
|
|
342
358
|
}
|
|
359
|
+
const workerStartedAt = Date.now();
|
|
343
360
|
let interruptChild = null;
|
|
344
361
|
await runWithInterruptGuard(runId, database, () => interruptChild?.(), guard, async () => {
|
|
345
362
|
const outcome = await runGarminWorker(runId, arguments_, {
|
|
@@ -350,14 +367,21 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
350
367
|
throw new Error(`Garmin staging worker exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
|
|
351
368
|
}
|
|
352
369
|
});
|
|
370
|
+
log.info('Garmin staging worker finished', { runId, durationMs: Date.now() - workerStartedAt, concurrency: workerConcurrency });
|
|
353
371
|
if (!guard.interrupted) {
|
|
354
372
|
if (!existsSync(output))
|
|
355
373
|
throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
|
|
374
|
+
let phaseStartedAt = Date.now();
|
|
356
375
|
await importJsonl(database, runId, output, log);
|
|
376
|
+
log.info('Garmin staging import finished', { runId, durationMs: Date.now() - phaseStartedAt });
|
|
377
|
+
phaseStartedAt = Date.now();
|
|
357
378
|
await reconcileGarminCalendarEntities(database, output);
|
|
379
|
+
log.info('Garmin calendar reconciliation finished', { runId, durationMs: Date.now() - phaseStartedAt });
|
|
358
380
|
if (provider === 'garmin') {
|
|
359
381
|
try {
|
|
382
|
+
phaseStartedAt = Date.now();
|
|
360
383
|
await backfillGarminSwimLengths(database, paths, runId, log);
|
|
384
|
+
log.info('Garmin swim-length backfill finished', { runId, durationMs: Date.now() - phaseStartedAt });
|
|
361
385
|
}
|
|
362
386
|
catch (error) {
|
|
363
387
|
log.warn('Garmin swim-length backfill after sync failed', {
|
|
@@ -457,8 +481,9 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
|
|
|
457
481
|
indexingKeepalive.unref?.();
|
|
458
482
|
}
|
|
459
483
|
try {
|
|
484
|
+
const indexStartedAt = Date.now();
|
|
460
485
|
const index = await buildRetrievalIndex(database);
|
|
461
|
-
log.info('Rebuilt retrieval index after sync', { documents: index.documents, mode: index.mode, watermark: index.watermark });
|
|
486
|
+
log.info('Rebuilt retrieval index after sync', { documents: index.documents, mode: index.mode, watermark: index.watermark, durationMs: Date.now() - indexStartedAt });
|
|
462
487
|
}
|
|
463
488
|
finally {
|
|
464
489
|
if (indexingKeepalive)
|