catence 0.2.0-beta.8 → 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.
- package/README.md +38 -166
- package/config.example.json +5 -1
- 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 +49 -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/activity-discovery.d.ts +2 -0
- package/dist/core/query/activity-discovery.js +1 -1
- package/dist/core/query/analytics.d.ts +10 -0
- package/dist/core/query/analytics.js +494 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +8 -4
- package/dist/core/query/events.d.ts +34 -0
- package/dist/core/query/events.js +121 -0
- package/dist/core/query/fitness.d.ts +4 -0
- package/dist/core/query/fitness.js +60 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +48 -0
- package/dist/core/query/swimming.js +2 -2
- package/dist/core/runtime/configuration.d.ts +3 -1
- package/dist/core/runtime/configuration.js +5 -1
- package/dist/core/runtime/opencode-go.d.ts +58 -0
- package/dist/core/runtime/opencode-go.js +246 -0
- package/dist/core/runtime/secrets.js +51 -13
- package/dist/elt/application/detached-sync.d.ts +50 -0
- package/dist/elt/application/detached-sync.js +99 -0
- package/dist/elt/application/management.d.ts +15 -0
- package/dist/elt/application/management.js +435 -19
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +5 -4
- package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
- package/dist/elt/ingestion/providers/intervals.js +37 -2
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +19 -1
- package/dist/elt/ingestion/providers/strava/service.js +7 -1
- package/dist/elt/normalization/normalizers.js +207 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +184 -6
- package/dist/elt/storage/migrations.js +145 -1
- package/dist/elt/storage/progress-sidecar.d.ts +61 -0
- package/dist/elt/storage/progress-sidecar.js +200 -0
- package/dist/interfaces/cli/commands.js +94 -1
- package/dist/interfaces/http/generation-status.d.ts +19 -0
- package/dist/interfaces/http/generation-status.js +55 -0
- package/dist/interfaces/http/server.d.ts +7 -1
- package/dist/interfaces/http/server.js +184 -1
- package/dist/interfaces/mcp/main.js +33 -1
- package/dist/interfaces/mcp/server.d.ts +2 -1
- package/dist/interfaces/mcp/server.js +168 -45
- package/dist/runtime/index.d.ts +9 -2
- package/dist/runtime/index.js +6 -2
- package/docs/architecture.md +13 -2
- package/docs/assets/image.png +0 -0
- package/docs/configuration.md +243 -0
- package/docs/deployment/docker.md +528 -0
- package/docs/deployment/local-console.md +263 -0
- package/docs/deployment/local-mcp.md +329 -0
- package/docs/development.md +337 -0
- package/docs/distribution.md +45 -0
- package/docs/llm-providers.md +66 -1
- package/docs/mcp-activity-retrieval.md +90 -210
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +43 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/registry.py +2 -0
- package/python/catence/providers/garmin/streams.py +38 -0
- package/python/catence/providers/garmin/swim_backfill.py +83 -0
- package/python/catence/providers/garmin/worker.py +415 -12
- package/scripts/discover-opencode-go.mjs +36 -144
- package/uv.lock +3 -3
- 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
|
-
##
|
|
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.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
146
|
-
only after their shared service methods and fixture tests exist.
|
|
165
|
+
### `review_activity_deep_dive`
|
|
147
166
|
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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 `
|
|
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
|
|
345
|
-
4. Use `
|
|
346
|
-
question about HR evolution, temperature,
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
##
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
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 = "
|
|
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)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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")
|