catence 0.2.0-beta.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +38 -180
  2. package/dist/contracts/index.d.ts +1 -0
  3. package/dist/contracts/index.js +1 -0
  4. package/dist/contracts/progress.d.ts +23 -0
  5. package/dist/contracts/progress.js +49 -0
  6. package/dist/contracts/release.d.ts +1 -1
  7. package/dist/contracts/release.js +1 -1
  8. package/dist/core/query/activity-discovery.d.ts +2 -0
  9. package/dist/core/query/activity-discovery.js +1 -1
  10. package/dist/core/query/analytics.d.ts +10 -0
  11. package/dist/core/query/analytics.js +494 -0
  12. package/dist/core/query/catalog.d.ts +1 -1
  13. package/dist/core/query/catalog.js +8 -4
  14. package/dist/core/query/events.d.ts +34 -0
  15. package/dist/core/query/events.js +121 -0
  16. package/dist/core/query/fitness.d.ts +4 -0
  17. package/dist/core/query/fitness.js +60 -0
  18. package/dist/core/query/repository.d.ts +2 -0
  19. package/dist/core/query/repository.js +48 -0
  20. package/dist/core/query/swimming.js +2 -2
  21. package/dist/core/runtime/configuration.d.ts +3 -1
  22. package/dist/core/runtime/configuration.js +5 -1
  23. package/dist/core/runtime/opencode-go.d.ts +58 -0
  24. package/dist/core/runtime/opencode-go.js +246 -0
  25. package/dist/core/runtime/secrets.js +51 -13
  26. package/dist/elt/application/detached-sync.d.ts +50 -0
  27. package/dist/elt/application/detached-sync.js +99 -0
  28. package/dist/elt/application/management.d.ts +15 -0
  29. package/dist/elt/application/management.js +420 -18
  30. package/dist/elt/ingestion/importer.js +1 -1
  31. package/dist/elt/ingestion/providers/intervals.d.ts +9 -0
  32. package/dist/elt/ingestion/providers/intervals.js +33 -1
  33. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  34. package/dist/elt/ingestion/providers/registry.js +19 -1
  35. package/dist/elt/normalization/normalizers.js +207 -15
  36. package/dist/elt/storage/database.d.ts +21 -2
  37. package/dist/elt/storage/database.js +182 -6
  38. package/dist/elt/storage/migrations.js +145 -1
  39. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  40. package/dist/elt/storage/progress-sidecar.js +200 -0
  41. package/dist/interfaces/cli/commands.js +94 -1
  42. package/dist/interfaces/http/generation-status.d.ts +19 -0
  43. package/dist/interfaces/http/generation-status.js +55 -0
  44. package/dist/interfaces/http/server.d.ts +7 -1
  45. package/dist/interfaces/http/server.js +184 -1
  46. package/dist/interfaces/mcp/main.js +33 -1
  47. package/dist/interfaces/mcp/server.d.ts +2 -1
  48. package/dist/interfaces/mcp/server.js +168 -45
  49. package/dist/runtime/index.d.ts +9 -2
  50. package/dist/runtime/index.js +6 -2
  51. package/docs/architecture.md +13 -2
  52. package/docs/assets/image.png +0 -0
  53. package/docs/configuration.md +243 -0
  54. package/docs/deployment/docker.md +528 -0
  55. package/docs/deployment/local-console.md +263 -0
  56. package/docs/deployment/local-mcp.md +329 -0
  57. package/docs/development.md +337 -0
  58. package/docs/distribution.md +45 -0
  59. package/docs/llm-providers.md +24 -2
  60. package/docs/mcp-activity-retrieval.md +90 -210
  61. package/package.json +1 -1
  62. package/pyproject.toml +1 -1
  63. package/python/catence/providers/garmin/cli.py +43 -9
  64. package/python/catence/providers/garmin/progress.py +85 -0
  65. package/python/catence/providers/garmin/registry.py +2 -0
  66. package/python/catence/providers/garmin/streams.py +38 -0
  67. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  68. package/python/catence/providers/garmin/worker.py +415 -12
  69. package/scripts/discover-opencode-go.mjs +36 -144
  70. package/uv.lock +3 -3
  71. package/docs/deployment.md +0 -338
@@ -102,19 +102,16 @@ sessions, active-length/distance mismatches, linked-provider distance
102
102
  disagreements, and missing explicit length records. Flags are caveats; they
103
103
  never overwrite or discard a provider's source values.
104
104
 
105
- ## Current investigation procedure
106
-
107
- Use this procedure until the endpoints below exist.
105
+ ## Investigation procedure
108
106
 
109
107
  1. Check `catence_status`. If the sync snapshot is unavailable, report the MCP
110
108
  error; do not retry by opening the database for writing.
111
- 2. List provider records from newest to oldest with `activities` joined to
112
- `activity_sources` and, where needed, `activity_summary_facts`. Include
113
- both the logical and source IDs in the result.
114
- 3. For one selected source, query `activity_interval_facts` ordered by
115
- `start_s`. Do not use a time/distance heuristic to select a different
116
- source.
117
- 4. If a provider’s interval rows are empty or all structural fields are null,
109
+ 2. Use `find_activities` to list provider records filtered by sport, distance,
110
+ name, or date. Results are paginated and return both logical and source IDs.
111
+ 3. For a selected source, call `get_activity_segments` for intervals, laps,
112
+ or structured splits — it automatically hydrates the matching Strava activity
113
+ when needed.
114
+ 4. If a provider's interval rows are empty or all structural fields are null,
118
115
  say that detailed splits were not supplied by that imported endpoint. A
119
116
  summary field such as Garmin's lap count or fastest split is still useful,
120
117
  but it does **not** reconstruct per-lap records.
@@ -122,6 +119,8 @@ Use this procedure until the endpoints below exist.
122
119
  `activity_source_id`; include its resolution/downsampling caveat. The
123
120
  original activity file may exist in the raw archive, but raw archives are
124
121
  not exposed directly to MCP in this release.
122
+ 6. For aerobic decoupling or grade-adjusted pace, call `activity_decoupling`.
123
+ For race-readiness questions, call `readiness_baseline` first.
125
124
 
126
125
  ### Segment calculations
127
126
 
@@ -140,15 +139,55 @@ as `no_distance_segment`; calling it a recovery/rest requires a source label
140
139
  or a clearly labelled user interpretation. If the sum of chunk distances
141
140
  differs from the activity summary, report both values and the difference.
142
141
 
143
- ## Proposed dedicated MCP endpoints
142
+ ## Activity discovery, detail, and stream endpoints
143
+
144
+ The originally proposed `list_recent_activities`, `get_activity_detail`,
145
+ `list_activity_segments`, and `read_activity_stream` endpoints (see the
146
+ [design notes](#design-notes-for-original-endpoints) below) were implemented
147
+ as registered tools under different names that better reflect their final
148
+ scope.
149
+
150
+ ### `find_activities`
151
+
152
+ Compact, canonical, paginated activity/race discovery by sport, distance, name,
153
+ and date. Filters include `sport`/`sports`, `distanceKm` (min/max tuple),
154
+ `nameContains`, and `startDate`/`endDate`. Results are sorted by date
155
+ (descending by default) with opaque cursor pagination. Race flags are
156
+ transparent heuristics, not provider-confirmed race metadata.
157
+
158
+ ### `get_activity_segments`
159
+
160
+ The required segment/climb path. Performs targeted Strava hydration before
161
+ returning persisted effort and segment facts; an empty result must be
162
+ interpreted alongside its hydration status. Accepts an `activityId` (logical)
163
+ and returns segment efforts, climbs, KOM/PRs, and per-segment analysis.
144
164
 
145
- These endpoints are drafts, not registered tools yet. Add them incrementally
146
- only after their shared service methods and fixture tests exist.
165
+ ### `review_activity_deep_dive`
147
166
 
148
- ## Implemented fitness and hydration endpoints
167
+ Returns canonical activity identity, source summaries, and structured intervals
168
+ for a single `activityId`. Call `read_series` separately only when a specific
169
+ sampled metric is required.
170
+
171
+ ### `read_series` (sample-level inspection)
172
+
173
+ Available through `read_series({ dataset: "activity_samples" })` for
174
+ one `activity_source_id`. Inherits the 1,000-point page limit, cursor format,
175
+ automatic downsampling, manifest-only Parquet access, and null handling. Returns
176
+ no data — not an error — when the source has no registered stream, with a caveat
177
+ explaining that sample availability is provider/activity-specific.
178
+
179
+ ### `get_swim_laps`
180
+
181
+ Source-aware swim lengths when a provider actually supplied them, plus Garmin
182
+ detected and Intervals.icu auto-detected sets. A missing length list is
183
+ reported as unavailable; laps are never reconstructed from samples.
184
+
185
+ ## Fitness, readiness, and derived-metrics endpoints
149
186
 
150
187
  - `describe_dataset(dataset)` returns one cataloged schema and its coverage. `training_metric_observations` also reports its observed sports, metric names, units, and source types.
151
188
  - `get_ftp_history`, `get_vo2max_history`, `power_curve_trend`, and `power_coverage_report` read normalized, source-aware fitness facts. Power tools require an explicit sport or sport family, use `power_bests`/`power_best_facts` for FIT-derived duration values, and do not treat sparse `avg_power` summaries as coverage. `get_vo2max_history` does not default to cycling: omission returns available source labels and requires an explicit choice. For Garmin running VO₂max, request `sport: "running"`; the tool reads the raw `generic` source series and preserves that label on returned observations.
189
+ - `readiness_baseline` combines the full running/race performance-indicator set in one call: running lactate threshold (pace/power/HR), VO₂max, race predictions, fitness trend, endurance score, and FIT-derived power bests/coverage as trends. Use this before any single-activity comparison for race-readiness or fitness-target questions.
190
+ - `activity_decoupling` derives aerobic decoupling (Pa:Hr for running using speed/HR, Pw:Hr for cycling using power/HR — drift across steady first/second halves) and grade-adjusted pace (GAP, Minetti 2002 cost model) from stored `activity_samples`. Returns null with explanatory caveats when samples are insufficient, walk-heavy, or stop-heavy. Derived values are descriptive only and never overwrite provider-supplied decoupling/GAP. Pass `includeGap: false` to skip GAP computation.
152
191
  - `find_activities` is implemented for compact, canonical, paginated activity/race discovery by sport, distance, name, and date. Its race flags are transparent heuristics, not provider-confirmed race metadata.
153
192
  - `get_activity_segments` is the required segment/climb path. It performs the
154
193
  targeted Strava hydration before returning the persisted effort and segment
@@ -157,6 +196,10 @@ only after their shared service methods and fixture tests exist.
157
196
  - `cycling_progress_report` composes the preceding read-only outputs with monthly canonical volume/load. It is descriptive, not a physiological model.
158
197
  - `hydrate_recent_strava_activities` accepts an explicit list or a bounded date/sport window and awaits each write in sequence. Its unmatched output includes the exact Strava search window and safe-match rejection diagnostics.
159
198
 
199
+ ## Events and race course endpoints
200
+
201
+ - `resolve_event_course` takes a Garmin/Intervals `eventId` and returns the `courseId` it references plus whether that course geometry has been synced. When geometry is absent the result carries an explicit caveat so a prior course profile is never reused. Optionally pass `pastActivityId` to diff the event course against that activity's course profile.
202
+
160
203
  ### Power investigation procedure
161
204
 
162
205
  For a question about running or cycling power, do not begin with
@@ -172,206 +215,43 @@ null even when the FIT stream has power. Instead:
172
215
  specific extreme needs validation. Raw sample reads are paginated and are
173
216
  not evidence that the global power dataset is absent.
174
217
 
175
- ### `list_recent_activities`
176
-
177
- Purpose: answer “what is my latest activity?” without requiring the agent to
178
- invent joins, provider precedence, or identifier handling.
179
-
180
- Input:
181
-
182
- ```ts
183
- {
184
- before?: string, // ISO timestamp; exclusive cursor anchor
185
- after?: string, // ISO timestamp; inclusive
186
- provider?: "garmin" | "intervals",
187
- sport?: string,
188
- sourceMode?: "source" | "logical", // default: "source"
189
- limit?: number, // 1–100; default: 20
190
- cursor?: string
191
- }
192
- ```
193
-
194
- `sourceMode: "source"` is the default because it never conceals an unlinked
195
- provider record. It returns one provider source per item. `sourceMode:
196
- "logical"` returns an activity plus all its source records only when the
197
- database already has that logical relationship; unlinked records remain
198
- separate items. Both modes sort by `started_at_utc DESC`, then stable source
199
- identity. Cursor contents are opaque, signed/validated query state—not a raw
200
- SQL offset.
201
-
202
- Each item should include:
203
-
204
- ```ts
205
- {
206
- activityId: string,
207
- activitySourceId: string,
208
- provider: "garmin" | "intervals",
209
- remoteActivityId: string,
210
- externalId: string | null,
211
- linkState: string,
212
- startedAtUtc: string | null,
213
- startedAtLocal: string | null,
214
- timezone: string | null,
215
- sport: string | null,
216
- name: string | null,
217
- summary: {
218
- distanceM: number | null,
219
- movingS: number | null,
220
- elapsedS: number | null,
221
- avgHr: number | null,
222
- maxHr: number | null,
223
- trainingLoad: number | null
224
- } | null,
225
- availability: { intervals: boolean, samples: boolean }
226
- }
227
- ```
228
-
229
- The query is a read-only projection of `activities`, `activity_sources`,
230
- `activity_summary_facts`, and `stream_manifest`. It must not use
231
- `canonical_activity_training` to suppress a Garmin source. Provenance names
232
- the selected relations and source artifact hash where available.
233
-
234
- ### `get_activity_detail`
235
-
236
- Purpose: return the compact, authoritative detail an agent needs after a
237
- selection from `list_recent_activities`.
238
-
239
- Input is exactly one of:
218
+ ## Agent workflow
240
219
 
241
- ```ts
242
- { activitySourceId: string, include?: ["summary", "intervals", "availability"] }
243
- // or
244
- { activityId: string, include?: ["summary", "intervals", "availability"] }
245
- ```
246
-
247
- `activitySourceId` returns exactly that provider record. `activityId` returns
248
- the logical activity plus **all** linked sources; it never selects a “best”
249
- source silently. The default includes `summary` and `availability`, not every
250
- interval. Intervals are limited to 500 items and receive deterministic cursor
251
- pagination. A compact response should contain activity identity/timestamps,
252
- source identities and external IDs, all available provider summaries, and:
253
-
254
- ```ts
255
- availability: {
256
- intervalCount: number,
257
- intervalDetailsAvailable: boolean,
258
- stream: { available: boolean, rowCount?: string, columns?: string[] },
259
- originalArchiveRecorded: boolean
260
- }
261
- ```
262
-
263
- `originalArchiveRecorded` only states that a raw object/manifest exists. It
264
- does not expose a filesystem path or claim that FIT parsing supplied laps.
265
- `include: ["intervals"]` follows the segment contract below.
266
-
267
- ### `list_activity_segments`
268
-
269
- Purpose: replace the manual interval query used for lap/interval questions.
270
- It should have a neutral name because source “intervals”, swim laps, and
271
- device splits have different meanings.
272
-
273
- Input:
274
-
275
- ```ts
276
- {
277
- activitySourceId: string,
278
- kind?: "all" | "structured_interval" | "lap_or_split", // default: "all"
279
- includeDerived?: boolean, // default: true
280
- limit?: number, // 1–500; default: 100
281
- cursor?: string
282
- }
283
- ```
284
-
285
- `kind` filters only by explicit source classification. It must return an
286
- empty result with `lap_or_split_available: false` when the provider supplied
287
- no detailed split records; it must not manufacture laps from sample points.
288
-
289
- For each item return source fields (`intervalKey`, `label`, `startS`, `endS`,
290
- `distanceM`, `avgHr`, `avgPower`, `avgPace`, `intensity`, and optionally
291
- `metricsJson`) plus safe derived fields:
292
-
293
- ```ts
294
- {
295
- derived: {
296
- durationS: number | null,
297
- paceSPer100m: number | null,
298
- distanceClass: "positive_distance" | "no_distance" | "unknown"
299
- }
300
- }
301
- ```
302
-
303
- The top-level result includes total supplied distance, total derived distance,
304
- summary distance, and their difference. It must explicitly disclose whether
305
- the response is made of structured intervals, provider laps/splits, or both.
220
+ For a question like "how did this go in the context of my recent swims?" an
221
+ agent should:
306
222
 
307
- ### `read_activity_stream`
308
-
309
- Purpose: make selected-source sample inspection straightforward while retaining
310
- the existing bounded stream rules.
311
-
312
- Input:
313
-
314
- ```ts
315
- {
316
- activitySourceId: string,
317
- metrics: Array<"elapsed_s" | "distance_m" | "heart_rate_bpm" |
318
- "power_w" | "cadence_rpm" | "speed_mps" | "temperature_c">,
319
- resolution?: "raw" | "1m" | "5m" | "15m" | "auto",
320
- startElapsedS?: number,
321
- endElapsedS?: number,
322
- pageSize?: number,
323
- cursor?: string
324
- }
325
- ```
326
-
327
- This is a source-ID wrapper over `read_series({ dataset: "activity_samples" })`.
328
- It inherits the 1,000-point page limit, cursor format, automatic downsampling,
329
- manifest-only Parquet access, and null handling. It returns no data—not an
330
- error—when the source has no registered stream, with a caveat explaining that
331
- sample availability is provider/activity-specific.
332
-
333
- ## Intended agent behaviour
334
-
335
- For “how did this go in the context of my recent swims?” an agent should:
336
-
337
- 1. Use `list_recent_activities` filtered to swimming to identify the selected
338
- source and comparable source records.
339
- 2. Use `get_activity_detail` for the selected source. Prefer canonical
223
+ 1. Use `find_activities` filtered to swimming to identify the selected source
224
+ and comparable source records.
225
+ 2. Use `review_activity_deep_dive` for the selected activity. Prefer canonical
340
226
  Intervals training values only when the logical activity is strongly linked;
341
227
  otherwise state the provider used.
342
- 3. Use `list_activity_segments` only for set/lap questions. Compute pace using
228
+ 3. Use `get_activity_segments` only for set/lap questions. Compute pace using
343
229
  the documented formula, name it as derived, and do not call unlabelled
344
- sections “recovery” as a fact.
345
- 4. Use `read_activity_stream` when the summary/segments cannot answer a
346
- question about HR evolution, temperature, or other sample-level behaviour.
347
- 5. State missing data and provenance in the answer. Do not imply that an empty
348
- Garmin split list means no laps occurred—only that detailed split records
230
+ sections "recovery" as a fact.
231
+ 4. Use `read_series` with `dataset: "activity_samples"` when the
232
+ summary/segments cannot answer a question about HR evolution, temperature,
233
+ or other sample-level behaviour.
234
+ 5. For race-readiness questions, call `readiness_baseline` first to establish
235
+ the fitness context before any single-activity comparison.
236
+ 6. For aerobic decoupling or grade-adjusted pace, call `activity_decoupling`.
237
+ 7. State missing data and provenance in the answer. Do not imply that an empty
238
+ Garmin split list means no laps occurred — only that detailed split records
349
239
  were not returned by the imported endpoint.
350
240
 
351
- ## Implementation order and acceptance criteria
352
-
353
- 1. Add repository methods that return typed source-first activity records and
354
- manifest availability. Keep SQL there; MCP handlers remain thin.
355
- 2. Implement `list_recent_activities`, including stable ordering and tamper-
356
- resistant cursor validation.
357
- 3. Implement `get_activity_detail` and `list_activity_segments` against the
358
- repository methods. Reuse the existing JSON-safe result/error envelope.
359
- 4. Add `read_activity_stream` as a validated adapter to `AnalyticsService`;
360
- do not duplicate downsampling or Parquet path checks.
361
- 5. Add Zod schemas and one independent module/test file per tool. Update the
362
- catalog/README once the tools are registered.
363
-
364
- Fixture tests must cover:
365
-
366
- - two unlinked Garmin/Intervals records with similar timestamps and distances;
367
- list results remain distinct;
368
- - a truly strong-ID-linked activity; detail returns both sources without
369
- concealing either;
370
- - labelled intervals, unlabelled positive-distance chunks, and no-distance
371
- chunks; pace is derived only when valid;
372
- - a Garmin summary with lap count/fastest split but an empty split list;
373
- `lap_or_split_available` is false and no laps are invented;
374
- - no stream manifest, missing Parquet file, and a valid registered stream;
375
- - pagination, malformed/tampered cursors, limits, and read-only snapshot
376
- errors;
377
- - response provenance and caveats for every branch.
241
+ ## Design notes for original endpoints
242
+
243
+ The originally proposed `list_recent_activities`, `get_activity_detail`,
244
+ `list_activity_segments`, and `read_activity_stream` endpoints were designed
245
+ before the registered tools existed. Their semantic intent is preserved in the
246
+ implemented tools described above, under different names that reflect their
247
+ final scope:
248
+
249
+ | Proposed name | Implemented as | Notes |
250
+ |---|---|---|
251
+ | `list_recent_activities` | `find_activities` | Adds sport/distance/name filters, opaque cursor pagination |
252
+ | `get_activity_detail` | `review_activity_deep_dive` + `get_activity_segments` | Detail split across review and segment tools |
253
+ | `list_activity_segments` | `get_activity_segments` | Includes automatic Strava hydration |
254
+ | `read_activity_stream` | `read_series({ dataset: "activity_samples" })` | Source-ID validation added in the handler |
255
+
256
+ The original input/output contracts (see git history) remain useful as
257
+ specifications for acceptance criteria in fixture tests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.0-beta.9",
3
+ "version": "0.2.0",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
package/pyproject.toml CHANGED
@@ -15,7 +15,7 @@ dependencies = [
15
15
 
16
16
  [tool.uv.sources]
17
17
  # fork commit
18
- garminconnect = { git = "https://github.com/rifusaki/python-garminconnect.git", rev = "732bf14602bce2ca22cb569ba6583c3229bf904d" }
18
+ garminconnect = { git = "https://github.com/rifusaki/python-garminconnect.git", rev = "cd4e3d9ef81a93681950138e08f57af226fd5735" }
19
19
 
20
20
  [tool.uv]
21
21
  package = false
@@ -4,11 +4,14 @@ import argparse
4
4
  import getpass
5
5
  import json
6
6
  import os
7
+ import signal
8
+ import sys
7
9
  from datetime import date
8
10
  from pathlib import Path
9
11
 
12
+ from .progress import ProgressReporter
10
13
  from .staging import StagingWriter
11
- from .worker import GarminStagingWorker
14
+ from .worker import GarminStagingWorker, WorkerInterrupted
12
15
 
13
16
 
14
17
  def parse_args() -> argparse.Namespace:
@@ -26,6 +29,17 @@ def parse_args() -> argparse.Namespace:
26
29
  parser.add_argument("--historical-only", action="store_true", help="Skip current account and collection endpoints.")
27
30
  parser.add_argument("--skip-daily", action="store_true", help="Do not fetch date-scoped daily data.")
28
31
  parser.add_argument("--skip-activities", action="store_true", help="Do not fetch date-scoped activities.")
32
+ parser.add_argument(
33
+ "--lt-history-from",
34
+ dest="lt_history_from",
35
+ type=date.fromisoformat, help="Backfill lactate-threshold history from this date instead of the daily window.",
36
+ )
37
+ parser.add_argument(
38
+ "--events-from",
39
+ dest="events_from",
40
+ type=date.fromisoformat,
41
+ help="Fetch calendar events from this date instead of the default short lookback.",
42
+ )
29
43
  return parser.parse_args()
30
44
 
31
45
 
@@ -43,19 +57,39 @@ def main() -> None:
43
57
  token_store.parent.mkdir(parents=True, exist_ok=True)
44
58
  client = Garmin(email, password, prompt_mfa=lambda: input("Garmin MFA code: "))
45
59
  client.login(str(token_store))
60
+ reporter = ProgressReporter(args.run_id)
61
+ reporter.set_stage("login")
62
+ reporter.finish("login")
46
63
  known_hashes: dict[str, str] = {}
47
64
  if args.known_activities and args.known_activities.exists():
48
65
  loaded = json.loads(args.known_activities.read_text(encoding="utf-8"))
49
66
  if isinstance(loaded, dict):
50
67
  known_hashes = {str(key): str(value) for key, value in loaded.items()}
51
- GarminStagingWorker(client, writer, args.data_dir, known_hashes).sync(
52
- daily_from_date=None if args.skip_daily else args.daily_from_date or args.from_date,
53
- activity_from_date=None if args.skip_activities else args.activity_from_date or args.from_date,
54
- to_date=args.to_date,
55
- daily_to_date=args.daily_to_date,
56
- activity_to_date=args.activity_to_date,
57
- include_non_historical=not args.historical_only,
58
- )
68
+ worker = GarminStagingWorker(client, writer, args.data_dir, known_hashes, progress=reporter)
69
+ interrupt_signal: int | None = None
70
+
71
+ def _request_interrupt(signum: int, _frame: object) -> None:
72
+ nonlocal interrupt_signal
73
+ interrupt_signal = signum
74
+ worker.request_interrupt()
75
+
76
+ signal.signal(signal.SIGINT, _request_interrupt)
77
+ signal.signal(signal.SIGTERM, _request_interrupt)
78
+ try:
79
+ worker.sync(
80
+ daily_from_date=None if args.skip_daily else args.daily_from_date or args.from_date,
81
+ activity_from_date=None if args.skip_activities else args.activity_from_date or args.from_date,
82
+ to_date=args.to_date,
83
+ daily_to_date=args.daily_to_date,
84
+ activity_to_date=args.activity_to_date,
85
+ include_non_historical=not args.historical_only,
86
+ lactate_threshold_history_from=args.lt_history_from,
87
+ events_from=args.events_from.isoformat() if args.events_from else None,
88
+ )
89
+ except WorkerInterrupted:
90
+ reporter.finish("interrupted")
91
+ raise SystemExit(130 if interrupt_signal == signal.SIGINT else 143)
92
+ reporter.finish("completed")
59
93
 
60
94
 
61
95
  if __name__ == "__main__":
@@ -0,0 +1,85 @@
1
+ """Progress reporting for Garmin sync runs.
2
+
3
+ Emits single-line JSON progress records (kind: 'progress') on stdout so the
4
+ Node management layer can persist heartbeats without parsing the staging
5
+ JSONL consumed by the importer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Callable
14
+
15
+
16
+ class ProgressReporter:
17
+ """Throttled progress publisher writing NDJSON records to stdout."""
18
+
19
+ def __init__(
20
+ self,
21
+ run_id: str,
22
+ provider: str = "garmin",
23
+ emit: Callable[[str], None] = lambda line: print(line, flush=True),
24
+ interval_seconds: float = 2.0,
25
+ ) -> None:
26
+ self.run_id = run_id
27
+ self.provider = provider
28
+ self.emit = emit
29
+ self.interval_seconds = interval_seconds
30
+ self._started = time.monotonic()
31
+ self._last_emitted = 0.0
32
+ self.stage = "starting"
33
+ self.current_step: str | None = None
34
+ self.completed_units = 0
35
+ self.total_units: int | None = None
36
+
37
+ def set_stage(self, stage: str) -> None:
38
+ """Switch phases, resetting phase-local counters and forcing a publish."""
39
+ if stage != self.stage:
40
+ self.stage = stage
41
+ self.current_step = None
42
+ self.completed_units = 0
43
+ self.total_units = None
44
+ self.publish(force=True)
45
+
46
+ def advance(self, completed: int | None = None, total: int | None = None, step: str | None = None) -> None:
47
+ if completed is not None:
48
+ self.completed_units = completed
49
+ if total is not None:
50
+ self.total_units = total
51
+ if step is not None:
52
+ self.current_step = step
53
+ self.publish()
54
+
55
+ def publish(self, force: bool = False) -> None:
56
+ now = time.monotonic()
57
+ if not force and now - self._last_emitted < self.interval_seconds:
58
+ return
59
+ self._last_emitted = now
60
+ elapsed = now - self._started
61
+ percent = 0.0
62
+ eta: float | None = None
63
+ if self.total_units and self.total_units > 0:
64
+ percent = min(100.0, max(0.0, self.completed_units / self.total_units * 100.0))
65
+ if self.completed_units > 0 and self.completed_units < self.total_units:
66
+ rate = elapsed / self.completed_units
67
+ eta = rate * (self.total_units - self.completed_units)
68
+ record = {
69
+ "kind": "progress",
70
+ "runId": self.run_id,
71
+ "provider": self.provider,
72
+ "stage": self.stage,
73
+ "currentStep": self.current_step,
74
+ "completedUnits": self.completed_units,
75
+ "totalUnits": self.total_units,
76
+ "percentComplete": round(percent, 2),
77
+ "elapsedSeconds": round(elapsed, 1),
78
+ "estimatedRemainingSeconds": round(eta, 1) if eta is not None else None,
79
+ "heartbeatAt": datetime.now(timezone.utc).isoformat(),
80
+ }
81
+ self.emit(json.dumps(record, separators=(",", ":")))
82
+
83
+ def finish(self, stage: str = "completed") -> None:
84
+ self.stage = stage
85
+ self.publish(force=True)
@@ -32,6 +32,8 @@ GARMIN_READ_METHODS = {
32
32
  "get_activity_gear", "download_activity",
33
33
  ),
34
34
  "workouts": ("get_workouts", "get_scheduled_workouts"),
35
+ "events": ("get_events",),
36
+ "course": ("connectapi",),
35
37
  "goals": ("get_goals",),
36
38
  "gear": ("get_gear",),
37
39
  "golf": ("get_golf_summary",),
@@ -183,6 +183,44 @@ def fit_archive_to_samples(activity_source_id: str, archive: Path) -> list[dict[
183
183
  return []
184
184
 
185
185
 
186
+ def fit_archive_to_swim_lengths(activity_source_id: str, archive: Path) -> list[dict[str, Any]]:
187
+ """Best-effort per-length parsing from the FIT 'length' messages; malformed/unavailable archives yield no rows."""
188
+ try:
189
+ import fitdecode
190
+ with zipfile.ZipFile(archive) as zip_file:
191
+ fit_name = next((name for name in zip_file.namelist() if name.lower().endswith(".fit")), None)
192
+ if not fit_name:
193
+ return []
194
+ with zip_file.open(fit_name) as stream, fitdecode.FitReader(stream) as reader:
195
+ rows: list[dict[str, Any]] = []
196
+ for frame in reader:
197
+ if not isinstance(frame, fitdecode.records.FitDataMessage) or frame.name != "length":
198
+ continue
199
+ fields = {field.name: field.value for field in frame.fields}
200
+ length_type = str(fields.get("length_type") or "").lower()
201
+ start_time = fields.get("start_time") or fields.get("timestamp")
202
+ row = {
203
+ "lengthIndex": _number(fields.get("message_index")),
204
+ "lapIndex": None,
205
+ "startTime": start_time.isoformat().replace("+00:00", "Z") if hasattr(start_time, "isoformat") else None,
206
+ "durationS": _number(fields.get("total_elapsed_time") or fields.get("total_timer_time")),
207
+ "activeDurationS": _number(fields.get("total_timer_time")),
208
+ "distanceM": _number(fields.get("distance")),
209
+ "strokeCount": _number(fields.get("total_strokes")),
210
+ "strokeRate": _number(fields.get("avg_swimming_cadence") or fields.get("avg_cadence")),
211
+ "swolf": _number(fields.get("avg_swolf")),
212
+ "avgHr": _number(fields.get("avg_heart_rate") or fields.get("average_heart_rate")),
213
+ "maxHr": _number(fields.get("max_heart_rate")),
214
+ "isRest": length_type in {"rest", "idle", "pause"},
215
+ "label": str(fields.get("swim_stroke") or "").lower() or None,
216
+ "raw": {name: (value.isoformat().replace("+00:00", "Z") if hasattr(value, "isoformat") else value) for name, value in fields.items()},
217
+ }
218
+ rows.append(row)
219
+ return rows
220
+ except (ImportError, OSError, ValueError, zipfile.BadZipFile):
221
+ return []
222
+
223
+
186
224
  def write_parquet(data_dir: Path, activity_id: str, start_date: str, samples: Iterable[dict[str, Any]]) -> tuple[Path, str, int]:
187
225
  records = list(samples)
188
226
  year, month = (start_date[:4] or "unknown"), (start_date[5:7] or "unknown")