catence 0.2.0-beta.19 → 0.2.0-beta.20

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.
@@ -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.0-beta.19";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.20";
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.0-beta.19';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.20';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -37,16 +37,19 @@ export type OpenCodeGoMergeResult = {
37
37
  messages: number;
38
38
  };
39
39
  guessedRoutes: string[];
40
+ /** Model ids removed from the managed profiles because they left the live catalog. */
41
+ prunedModelIds?: string[];
40
42
  };
41
43
  /**
42
44
  * Fetch the live OpenCode Go catalog and merge the two Console profiles into
43
45
  * `configPath`, preserving every other section plus existing console fields
44
46
  * (`limits`, other profiles) and the existing `defaultProfile` unless
45
47
  * `setDefault` is passed. Model routing (`model`) is refreshed from the live
46
- * catalog, while per-model customizations (custom label, `reasoningEffort`,
47
- * `variants`) and models that disappeared upstream are kept. The written file
48
- * is re-validated with the same strict schema the runtime applies on load,
49
- * then replaced atomically.
48
+ * catalog, per-model customizations (custom label, `reasoningEffort`,
49
+ * `variants`) survive for models still in the catalog, and models that
50
+ * disappeared upstream are pruned so the Console never offers stale routes.
51
+ * The written file is re-validated with the same strict schema the runtime
52
+ * applies on load, then replaced atomically.
50
53
  */
51
54
  export declare function mergeOpenCodeGoConsoleProfiles(options: {
52
55
  configPath: string;
@@ -163,10 +163,11 @@ function preservedModelFields(previous) {
163
163
  * `configPath`, preserving every other section plus existing console fields
164
164
  * (`limits`, other profiles) and the existing `defaultProfile` unless
165
165
  * `setDefault` is passed. Model routing (`model`) is refreshed from the live
166
- * catalog, while per-model customizations (custom label, `reasoningEffort`,
167
- * `variants`) and models that disappeared upstream are kept. The written file
168
- * is re-validated with the same strict schema the runtime applies on load,
169
- * then replaced atomically.
166
+ * catalog, per-model customizations (custom label, `reasoningEffort`,
167
+ * `variants`) survive for models still in the catalog, and models that
168
+ * disappeared upstream are pruned so the Console never offers stale routes.
169
+ * The written file is re-validated with the same strict schema the runtime
170
+ * applies on load, then replaced atomically.
170
171
  */
171
172
  export async function mergeOpenCodeGoConsoleProfiles(options) {
172
173
  const baseUrl = options.baseUrl ?? OPENCODE_GO_DEFAULT_BASE_URL;
@@ -184,8 +185,11 @@ export async function mergeOpenCodeGoConsoleProfiles(options) {
184
185
  : built.defaultProfile;
185
186
  // Merge per model, not per profile: discovery owns routing and the id list,
186
187
  // but a human's label, reasoningEffort, and variants must survive every
187
- // re-discovery. Models that vanished upstream stay configured.
188
+ // re-discovery. Models that vanished upstream are pruned so the managed
189
+ // profiles mirror the live catalog instead of going stale; a failed or
190
+ // empty catalog fetch never reaches this loop (fetch throws first).
188
191
  const mergedProfiles = {};
192
+ const prunedModelIds = [];
189
193
  for (const [profileId, builtProfile] of Object.entries(built.profiles)) {
190
194
  const previousProfile = existingProfiles[profileId];
191
195
  const rawModels = typeof previousProfile === 'object' && previousProfile !== null
@@ -194,7 +198,7 @@ export async function mergeOpenCodeGoConsoleProfiles(options) {
194
198
  const previousModels = typeof rawModels === 'object' && rawModels !== null && !Array.isArray(rawModels)
195
199
  ? rawModels
196
200
  : {};
197
- const models = { ...previousModels };
201
+ const models = {};
198
202
  for (const [modelId, entry] of Object.entries(builtProfile.models)) {
199
203
  models[modelId] = {
200
204
  ...preservedModelFields(previousModels[modelId]),
@@ -202,6 +206,10 @@ export async function mergeOpenCodeGoConsoleProfiles(options) {
202
206
  model: entry.model,
203
207
  };
204
208
  }
209
+ for (const modelId of Object.keys(previousModels)) {
210
+ if (!(modelId in models))
211
+ prunedModelIds.push(`${profileId}:${modelId}`);
212
+ }
205
213
  const previousDefaultModel = typeof previousProfile === 'object' && previousProfile !== null
206
214
  ? previousProfile.defaultModel
207
215
  : undefined;
@@ -232,6 +240,7 @@ export async function mergeOpenCodeGoConsoleProfiles(options) {
232
240
  defaultProfile,
233
241
  counts: built.counts,
234
242
  guessedRoutes: built.guessedRoutes,
243
+ prunedModelIds,
235
244
  };
236
245
  }
237
246
  //# sourceMappingURL=opencode-go.js.map
@@ -8,6 +8,16 @@ export type DetachedSyncRequest = {
8
8
  provider?: DetachedSyncProvider;
9
9
  from?: string;
10
10
  refresh?: boolean;
11
+ /**
12
+ * The `--home` value the detached `catence-data` child must receive.
13
+ *
14
+ * Catalog deployments pass the catalog root here: `paths` points at the
15
+ * resolved athlete store (for locks/logs), but the child resolves
16
+ * `--athlete` within a catalog, so pointing `--home` at the store itself
17
+ * makes it fail with the legacy-store guard. Standalone stores omit this
18
+ * and default to `paths.root`.
19
+ */
20
+ cliHome?: string;
11
21
  };
12
22
  export type DetachedSyncHandle = {
13
23
  started: true;
@@ -1,7 +1,9 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
2
3
  import { open, mkdir, writeFile } from 'node:fs/promises';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
6
+ import { STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
5
7
  import { syncProgress } from './management.js';
6
8
  export const DETACHED_SYNC_PROVIDERS = ['intervals', 'garmin', 'strava', 'all'];
7
9
  /** Raised when another sync run is already active for this store. */
@@ -14,12 +16,25 @@ export class DetachedSyncBusyError extends Error {
14
16
  }
15
17
  }
16
18
  /**
17
- * Resolve the packaged `catence-data` CLI entry next to this module. In a
18
- * built install both live under dist/; source checkouts run through tsx and
19
- * tests inject their own spawner.
19
+ * Resolve how to launch the `catence-data` CLI next to this module.
20
+ *
21
+ * Built installs: both modules live under dist/, so the child is plain
22
+ * `node …/dist/interfaces/cli/main.js`. Source checkouts run through tsx,
23
+ * where the same relative path points at a nonexistent `.js` file — there the
24
+ * TypeScript entry is launched through the checkout's local tsx binary
25
+ * instead. Tests inject their own spawner and never reach this resolution.
20
26
  */
21
- function cliScriptPath() {
22
- return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../interfaces/cli/main.js');
27
+ function cliInvocation() {
28
+ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
29
+ const builtEntry = path.resolve(moduleDirectory, '../../interfaces/cli/main.js');
30
+ if (existsSync(builtEntry))
31
+ return { command: process.execPath, entry: builtEntry };
32
+ const sourceEntry = path.resolve(moduleDirectory, '../../interfaces/cli/main.ts');
33
+ const tsxBinary = path.resolve(moduleDirectory, '../../../node_modules/.bin/tsx');
34
+ if (!existsSync(sourceEntry) || !existsSync(tsxBinary)) {
35
+ throw new Error(`Could not locate the catence-data CLI entry (tried ${builtEntry} and ${sourceEntry}). Install the package or run from a checkout with node_modules present.`);
36
+ }
37
+ return { command: tsxBinary, entry: sourceEntry };
23
38
  }
24
39
  function assertValidRequest(request) {
25
40
  if (!request.athleteId || !/^[a-z][a-z0-9-]{0,62}$/.test(request.athleteId)) {
@@ -46,14 +61,20 @@ export async function startDetachedSync(request, dependencies = {}) {
46
61
  assertValidRequest(request);
47
62
  const provider = request.provider ?? 'all';
48
63
  const snapshot = await syncProgress(request.paths);
49
- const activeRun = snapshot.running[0];
64
+ // Belt-and-braces: syncProgress already age-filters stale runs, so this
65
+ // predicate only re-asserts the sidecar contract at the decision point —
66
+ // a heartbeat older than the stale timeout must never block a new run.
67
+ const activeRun = snapshot.running.find((state) => {
68
+ const ageMs = Date.now() - Date.parse(state.heartbeatAt);
69
+ return Number.isFinite(ageMs) && ageMs <= STALE_RUN_TIMEOUT_MS;
70
+ });
50
71
  if (activeRun)
51
72
  throw new DetachedSyncBusyError(activeRun.runId);
52
73
  const stamp = (dependencies.now ?? (() => new Date()))().toISOString().replace(/[:.]/g, '-');
53
74
  const logFile = path.join(request.paths.root, 'logs', `sync-${request.athleteId}-${stamp}.log`);
54
75
  await mkdir(path.dirname(logFile), { recursive: true });
55
76
  await writeFile(logFile, `[${new Date().toISOString()}] catence-data --athlete ${request.athleteId} sync --provider ${provider}${request.from ? ` --from ${request.from}` : ''}${request.refresh ? ' --refresh' : ''}\n`, 'utf8');
56
- const arguments_ = [cliScriptPath(), '--home', request.paths.root, '--athlete', request.athleteId, 'sync', '--provider', provider];
77
+ const arguments_ = [cliInvocation().entry, '--home', request.cliHome ?? request.paths.root, '--athlete', request.athleteId, 'sync', '--provider', provider];
57
78
  if (request.from)
58
79
  arguments_.push('--from', request.from);
59
80
  if (request.refresh)
@@ -63,7 +84,7 @@ export async function startDetachedSync(request, dependencies = {}) {
63
84
  const logHandle = await open(logFile, 'a');
64
85
  try {
65
86
  const spawnProcess = dependencies.spawnProcess ?? spawn;
66
- const child = spawnProcess(process.execPath, arguments_, {
87
+ const child = spawnProcess(cliInvocation().command, arguments_, {
67
88
  detached: true,
68
89
  stdio: ['ignore', logHandle.fd, logHandle.fd],
69
90
  env: process.env,
@@ -2,6 +2,9 @@ import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { createReadStream } from 'node:fs';
6
+ import { createInterface } from 'node:readline';
7
+ import { EVENT_WINDOW_PAST_DAYS } from '../ingestion/providers/registry.js';
5
8
  import { createServer } from 'node:http';
6
9
  import path from 'node:path';
7
10
  import { fileURLToPath } from 'node:url';
@@ -21,6 +24,46 @@ import { withDataWriteLock } from '../storage/write-lock.js';
21
24
  function packageRoot() {
22
25
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
23
26
  }
27
+ // Mirrors python/catence/providers/garmin/worker.py fetch windows: the events
28
+ // listing looks back a year and scheduled workouts start three months ago, so
29
+ // only rows inside those windows are authoritative for deletion sweeps.
30
+ const GARMIN_EVENTS_LOOKBACK_DAYS = 365;
31
+ const GARMIN_SCHEDULED_MONTHS_BACK = 3;
32
+ /**
33
+ * Delete calendar entities that the latest Garmin listing no longer contains.
34
+ * The staging manifest is the authority on which ids were just fetched; any
35
+ * in-window row of the same type missing from it was deleted (or un-enrolled)
36
+ * upstream and would otherwise linger as a ghost.
37
+ */
38
+ async function reconcileGarminCalendarEntities(database, stagedManifestPath) {
39
+ const events = [];
40
+ const scheduled = [];
41
+ const file = createReadStream(stagedManifestPath, { encoding: 'utf8' });
42
+ const lines = createInterface({ input: file, crlfDelay: Infinity });
43
+ for await (const line of lines) {
44
+ if (!line.trim())
45
+ continue;
46
+ let record;
47
+ try {
48
+ record = JSON.parse(line);
49
+ }
50
+ catch {
51
+ continue; // The importer owns malformed-line reporting.
52
+ }
53
+ const candidate = record;
54
+ if (candidate?.kind !== 'source_entity' || candidate.provider !== 'garmin')
55
+ continue;
56
+ if (candidate.entityType === 'event' && typeof candidate.remoteId === 'string')
57
+ events.push(candidate.remoteId);
58
+ if (candidate.entityType === 'scheduled_workout' && typeof candidate.remoteId === 'string')
59
+ scheduled.push(candidate.remoteId);
60
+ }
61
+ const now = new Date();
62
+ const eventCutoff = new Date(now.getTime() - GARMIN_EVENTS_LOOKBACK_DAYS * 86_400_000).toISOString().slice(0, 10);
63
+ const scheduledCutoff = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - GARMIN_SCHEDULED_MONTHS_BACK, 1)).toISOString().slice(0, 10);
64
+ await database.deleteMissingCalendarEntities('garmin', ['event'], events, eventCutoff);
65
+ await database.deleteMissingCalendarEntities('garmin', ['scheduled_workout'], scheduled, scheduledCutoff);
66
+ }
24
67
  function baseProgress(runId, provider, stage) {
25
68
  return {
26
69
  runId, provider, stage, currentStep: null,
@@ -288,6 +331,15 @@ async function syncProvider(database, paths, provider, options, log) {
288
331
  if (earliest)
289
332
  arguments_.push('--lt-history-from', earliest);
290
333
  }
334
+ // Calendar-events coverage backfill: a store whose activities predate
335
+ // the short lookback gets exactly one deep pull (to its earliest
336
+ // activity); once an older event lands, normal syncs stay narrow.
337
+ const eventsShortFrom = new Date(Date.now() - EVENT_WINDOW_PAST_DAYS * 86_400_000).toISOString().slice(0, 10);
338
+ const needsEventBackfill = !(await database.hasCalendarEventOlderThan('garmin', eventsShortFrom));
339
+ const earliestActivity = needsEventBackfill ? await database.earliestActivityStartDate('garmin') : null;
340
+ if (needsEventBackfill && earliestActivity && earliestActivity < eventsShortFrom) {
341
+ arguments_.push('--events-from', earliestActivity);
342
+ }
291
343
  let interruptChild = null;
292
344
  await runWithInterruptGuard(runId, database, () => interruptChild?.(), guard, async () => {
293
345
  const outcome = await runGarminWorker(runId, arguments_, {
@@ -302,6 +354,7 @@ async function syncProvider(database, paths, provider, options, log) {
302
354
  if (!existsSync(output))
303
355
  throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
304
356
  await importJsonl(database, runId, output, log);
357
+ await reconcileGarminCalendarEntities(database, output);
305
358
  if (provider === 'garmin') {
306
359
  try {
307
360
  await backfillGarminSwimLengths(database, paths, runId, log);
@@ -13,6 +13,15 @@ export declare class IntervalsExtractor {
13
13
  fromDate: string;
14
14
  toDate: string;
15
15
  } | null, includeAccount?: boolean): Promise<void>;
16
+ /**
17
+ * Regular syncs reach back EVENT_WINDOW_PAST_DAYS — just enough to
18
+ * associate recently finished events with their activities. A store whose
19
+ * own activity history predates that span gets an automatic one-time
20
+ * coverage backfill instead: the window stretches to the earliest
21
+ * activity until an event older than the short window lands (self-
22
+ * clearing), after which deeper history is the backfill command's job.
23
+ */
24
+ private resolveEventsSyncWindow;
16
25
  private captureCollectionChildren;
17
26
  private getCollection;
18
27
  private captureActivity;
@@ -2,7 +2,7 @@ import { IntervalsClient } from 'intervals-icu';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { stableJsonHash, storeArtifact, storeJsonArtifact } from '../../storage/artifacts.js';
4
4
  import { importRecord, queueWorkItem, completeWorkItem, failWorkItem } from '../importer.js';
5
- import { intervalsActivityReadEndpoints, intervalsSecondaryReadRegistry, assertReadOnlyRegistry } from './registry.js';
5
+ import { intervalsActivityReadEndpoints, intervalsSecondaryReadRegistry, intervalsEventSyncWindow, assertReadOnlyRegistry } from './registry.js';
6
6
  import { intervalsStreamsToSamples, writeParquetSamples } from '../../streams.js';
7
7
  const apiBaseUrl = 'https://intervals.icu/api/v1';
8
8
  function asObject(value) {
@@ -67,6 +67,21 @@ export class IntervalsExtractor {
67
67
  for (const endpoint of intervalsSecondaryReadRegistry) {
68
68
  if (endpoint.name === 'athlete')
69
69
  continue;
70
+ // Calendar events straddle today (past for activity association,
71
+ // future for planning); they must not inherit the cursor-bounded
72
+ // activity window, which always ends at today.
73
+ if (endpoint.name === 'events') {
74
+ const window = await this.resolveEventsSyncWindow();
75
+ const payload = await this.capture(runId, 'events', null, window, async () => this.getCollection('events', resolvedAthleteId, window.fromDate, window.toDate));
76
+ if (payload === undefined)
77
+ continue;
78
+ await this.importEntities('event', 'events', payload, null, runId);
79
+ // The listing is authoritative: events deleted upstream must not
80
+ // linger as ghosts in the store once this sync succeeded.
81
+ const seenIds = asItems(payload).map((item) => idOf(item, '')).filter(Boolean);
82
+ await this.database.deleteMissingCalendarEntities('intervals', ['event'], seenIds, window.fromDate);
83
+ continue;
84
+ }
70
85
  if (!activityWindow)
71
86
  continue;
72
87
  const { fromDate, toDate } = activityWindow;
@@ -86,6 +101,23 @@ export class IntervalsExtractor {
86
101
  }
87
102
  }
88
103
  }
104
+ /**
105
+ * Regular syncs reach back EVENT_WINDOW_PAST_DAYS — just enough to
106
+ * associate recently finished events with their activities. A store whose
107
+ * own activity history predates that span gets an automatic one-time
108
+ * coverage backfill instead: the window stretches to the earliest
109
+ * activity until an event older than the short window lands (self-
110
+ * clearing), after which deeper history is the backfill command's job.
111
+ */
112
+ async resolveEventsSyncWindow() {
113
+ const short = intervalsEventSyncWindow();
114
+ if (await this.database.hasCalendarEventOlderThan('intervals', short.fromDate))
115
+ return short;
116
+ const earliest = await this.database.earliestActivityStartDate('intervals');
117
+ if (!earliest || earliest >= short.fromDate)
118
+ return short;
119
+ return { fromDate: earliest, toDate: short.toDate };
120
+ }
89
121
  async captureCollectionChildren(runId, collection, payload, athleteId) {
90
122
  const children = {
91
123
  events: [{ suffix: (id) => `/athlete/${athleteId}/events/${id}`, entityType: 'event' }],
@@ -11,6 +11,19 @@ export declare const intervalsReadRegistry: readonly EndpointSpec[];
11
11
  * Garmin's activity detail, files, streams, or account data.
12
12
  */
13
13
  export declare const intervalsSecondaryReadRegistry: EndpointSpec[];
14
+ /** How far back and ahead of today the planned-events fetch reaches. */
15
+ /** Regular syncs look back a month for activity association; deeper history arrives via the automatic coverage backfill. */
16
+ export declare const EVENT_WINDOW_PAST_DAYS = 30;
17
+ export declare const EVENT_WINDOW_FUTURE_DAYS = 365;
18
+ /**
19
+ * Deliberately cursor-free: activities chase a past-only incremental window,
20
+ * while calendar events must straddle today so upcoming races and recently
21
+ * completed ones (associatable with their finished activities) both arrive.
22
+ */
23
+ export declare function intervalsEventSyncWindow(now?: Date): {
24
+ fromDate: string;
25
+ toDate: string;
26
+ };
14
27
  export declare const intervalsActivityReadEndpoints: readonly ["activity", "streams", "intervals", "map", "weather", "weather_summary", "best_efforts", "power_curve", "pace_curve", "hr_curve", "power_vs_hr", "hr_load_model", "segments", "messages", "original_file", "fit_file", "gpx_file"];
15
28
  /** Read-only methods exposed by python-garminconnect, grouped for manifest tests and worker dispatch. */
16
29
  export declare const garminReadRegistry: readonly EndpointSpec[];
@@ -21,7 +21,23 @@ export const intervalsReadRegistry = registry('intervals', [
21
21
  * supplies the separately named training calculations without duplicating
22
22
  * Garmin's activity detail, files, streams, or account data.
23
23
  */
24
- export const intervalsSecondaryReadRegistry = intervalsReadRegistry.filter((endpoint) => endpoint.name === 'athlete' || endpoint.name === 'activities');
24
+ export const intervalsSecondaryReadRegistry = intervalsReadRegistry.filter((endpoint) => endpoint.name === 'athlete' || endpoint.name === 'activities' || endpoint.name === 'events');
25
+ /** How far back and ahead of today the planned-events fetch reaches. */
26
+ /** Regular syncs look back a month for activity association; deeper history arrives via the automatic coverage backfill. */
27
+ export const EVENT_WINDOW_PAST_DAYS = 30;
28
+ export const EVENT_WINDOW_FUTURE_DAYS = 365;
29
+ /**
30
+ * Deliberately cursor-free: activities chase a past-only incremental window,
31
+ * while calendar events must straddle today so upcoming races and recently
32
+ * completed ones (associatable with their finished activities) both arrive.
33
+ */
34
+ export function intervalsEventSyncWindow(now = new Date()) {
35
+ const dayMs = 86_400_000;
36
+ return {
37
+ fromDate: new Date(now.getTime() - EVENT_WINDOW_PAST_DAYS * dayMs).toISOString().slice(0, 10),
38
+ toDate: new Date(now.getTime() + EVENT_WINDOW_FUTURE_DAYS * dayMs).toISOString().slice(0, 10),
39
+ };
40
+ }
25
41
  export const intervalsActivityReadEndpoints = [
26
42
  'activity', 'streams', 'intervals', 'map', 'weather', 'weather_summary', 'best_efforts', 'power_curve', 'pace_curve', 'hr_curve',
27
43
  'power_vs_hr', 'hr_load_model', 'segments', 'messages', 'original_file', 'fit_file', 'gpx_file',
@@ -43,6 +59,7 @@ export const garminReadRegistry = registry('garmin', [
43
59
  ['activity_power_zones', 'activity', 'activity_zone'], ['activity_exercise_sets', 'activity', 'activity_exercise_set'], ['activity_gear', 'activity', 'activity_gear'],
44
60
  ['activity_original', 'activity', 'activity_file'], ['activity_tcx', 'activity', 'activity_file'], ['activity_gpx', 'activity', 'activity_file'], ['activity_kml', 'activity', 'activity_file'],
45
61
  ['activity_csv', 'activity', 'activity_file'], ['workouts', 'collection', 'workout'], ['scheduled_workouts', 'date_range', 'scheduled_workout'],
62
+ ['events', 'collection', 'event'],
46
63
  ['training_plans', 'collection', 'training_plan'], ['goals', 'collection', 'goal'], ['personal_records', 'singleton', 'personal_record'],
47
64
  ['earned_badges', 'collection', 'badge'], ['available_badges', 'collection', 'badge'], ['challenges', 'collection', 'challenge'],
48
65
  ['gear', 'collection', 'gear'], ['golf_summary', 'collection', 'golf_scorecard'], ['golf_user_stats', 'singleton', 'golf_user_stat'],
@@ -20,7 +20,9 @@ export declare class CatenceDatabase implements WriteDataStore {
20
20
  heartbeatRun(runId: string, progress: SyncProgressState): Promise<void>;
21
21
  markRunInterrupted(runId: string, stage?: string): Promise<void>;
22
22
  interruptStaleSyncRuns(timeoutMs?: number): Promise<InterruptedRuns>;
23
- syncProgress(): Promise<SyncProgressSnapshot>;
23
+ syncProgress(timeoutMs?: number): Promise<SyncProgressSnapshot>;
24
+ hasCalendarEventOlderThan(provider: Provider, beforeIsoDate: string, entityType?: string): Promise<boolean>;
25
+ deleteMissingCalendarEntities(provider: Provider, entityTypes: readonly string[], keepRemoteIds: readonly string[], minOccurredOn: string): Promise<void>;
24
26
  addError(runId: string, provider: Provider, endpoint: string, remoteId: string | null, message: string, retryable: boolean): Promise<void>;
25
27
  resolveErrors(opts?: {
26
28
  runIds?: string[];
@@ -142,7 +142,14 @@ export class CatenceDatabase {
142
142
  }
143
143
  return { runIds: stale.map((row) => row.run_id) };
144
144
  }
145
- async syncProgress() {
145
+ async syncProgress(timeoutMs = STALE_RUN_TIMEOUT_MS) {
146
+ // A run killed with its container (docker restart, OOM) keeps
147
+ // status='running' until something cleans it up, and that cleanup only
148
+ // runs inside a starting sync child — which the busy guard refuses to
149
+ // spawn while the row reads as active. Age-filtering here mirrors the
150
+ // sidecar contract: a heartbeat older than the stale timeout can never
151
+ // block a new run or disable the dashboard's Sync button forever.
152
+ const cutoff = new Date(Date.now() - timeoutMs).toISOString();
146
153
  const running = await this.rows(`SELECT runs.run_id, runs.provider,
147
154
  coalesce(progress.stage, 'starting') AS stage,
148
155
  progress.current_step AS current_step,
@@ -155,7 +162,8 @@ export class CatenceDatabase {
155
162
  FROM sync_runs AS runs
156
163
  LEFT JOIN sync_run_progress AS progress USING (run_id)
157
164
  WHERE runs.status = 'running'
158
- ORDER BY runs.started_at DESC`);
165
+ AND coalesce(progress.heartbeat_at, runs.started_at) > $cutoff
166
+ ORDER BY runs.started_at DESC`, { cutoff });
159
167
  const recent = await this.rows(`SELECT progress.run_id, progress.provider, progress.stage, progress.current_step,
160
168
  progress.completed_units, progress.total_units, progress.percent, progress.elapsed_seconds,
161
169
  progress.estimated_remaining_seconds, cast(progress.heartbeat_at AS VARCHAR) AS heartbeat_at
@@ -164,6 +172,37 @@ export class CatenceDatabase {
164
172
  LIMIT 10`);
165
173
  return { running: running.map(toSyncProgressState), recent: recent.map(toSyncProgressState) };
166
174
  }
175
+ async hasCalendarEventOlderThan(provider, beforeIsoDate, entityType = 'event') {
176
+ // Coverage signal for the automatic calendar-events backfill: true once
177
+ // the store already holds an event older than the regular short window,
178
+ // meaning the one-time deep pull has landed and normal syncs can stay
179
+ // narrow. Athletes with genuinely no old events re-run the deep window;
180
+ // it costs one small paginated listing per sync.
181
+ const rows = await this.rows(`SELECT 1 FROM source_entities WHERE provider = $provider AND entity_type = $entityType AND occurred_on < $before LIMIT 1`, { provider, entityType, before: beforeIsoDate });
182
+ return rows.length > 0;
183
+ }
184
+ async deleteMissingCalendarEntities(provider, entityTypes, keepRemoteIds, minOccurredOn) {
185
+ // Calendar entities (joined races, scheduled workouts) are mutable and
186
+ // deletable upstream. Re-fetches upsert survivors via remote_id; this
187
+ // removes in-window rows absent from the latest authoritative listing so
188
+ // upstream deletions propagate instead of leaving ghosts forever. Rows
189
+ // without a date are preserved (they predate strict dating).
190
+ if (!entityTypes.length)
191
+ return;
192
+ const values = { provider, minOccurredOn };
193
+ const typePlaceholders = entityTypes.map((name, index) => {
194
+ values[`type${index}`] = name;
195
+ return `$type${index}`;
196
+ });
197
+ let keepClause = '';
198
+ if (keepRemoteIds.length) {
199
+ keepClause = ` AND remote_id NOT IN (${keepRemoteIds.map((_, index) => `$keep${index}`).join(', ')})`;
200
+ keepRemoteIds.forEach((id, index) => { values[`keep${index}`] = id; });
201
+ }
202
+ for (const table of ['source_entities', 'domain_entities']) {
203
+ await this.run(`DELETE FROM ${table} WHERE provider = $provider AND entity_type IN (${typePlaceholders}) AND occurred_on >= $minOccurredOn${keepClause}`, values);
204
+ }
205
+ }
167
206
  async addError(runId, provider, endpoint, remoteId, message, retryable) {
168
207
  await this.run(`INSERT INTO normalization_errors (error_id, run_id, provider, endpoint, remote_id, message, retryable)
169
208
  VALUES ($errorId, $runId, $provider, $endpoint, $remoteId, $message, $retryable)`, { errorId: randomUUID(), runId, provider, endpoint, remoteId, message, retryable });
@@ -1,7 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import path from 'node:path';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
- import { catenceRuntimeHealth, DashboardSnapshotService, dataStatus, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, } from '../../runtime/index.js';
4
+ import { catenceRuntimeHealth, DashboardSnapshotService, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, } from '../../runtime/index.js';
5
5
  import { createCatenceMcpServer } from '../mcp/server.js';
6
6
  const MAX_REQUEST_BYTES = 1_000_000;
7
7
  function json(response, statusCode, value) {
@@ -131,25 +131,37 @@ async function lastSyncSummary(paths) {
131
131
  return null;
132
132
  };
133
133
  try {
134
- const status = await dataStatus(paths);
135
- const runs = Array.isArray(status.syncRuns) ? status.syncRuns : [];
136
- const completed = runs.filter((run) => String(run.status ?? '').startsWith('completed') && completedAtIso(run.completed_at));
137
- const providers = {};
138
- let lastCompletedAt = null;
139
- for (const run of completed) {
140
- const completedAt = completedAtIso(run.completed_at);
141
- const provider = String(run.provider ?? 'unknown');
142
- if (!providers[provider] || providers[provider] < completedAt)
143
- providers[provider] = completedAt;
144
- if (!lastCompletedAt || lastCompletedAt < completedAt)
145
- lastCompletedAt = completedAt;
134
+ // Read-only repository on purpose: it exposes per-run history (`syncRuns`)
135
+ // and degrades through the typed lock-conflict error while a detached
136
+ // sync holds DuckDB's single-writer lock. The write-capable dataStatus()
137
+ // would both block here and has no run history at all.
138
+ const repository = await openReadOnlyRepository(paths);
139
+ try {
140
+ const status = await repository.status();
141
+ const runs = Array.isArray(status.syncRuns) ? status.syncRuns : [];
142
+ const completed = runs.filter((run) => String(run.status ?? '').startsWith('completed') && completedAtIso(run.completed_at));
143
+ const providers = {};
144
+ let lastCompletedAt = null;
145
+ for (const run of completed) {
146
+ const completedAt = completedAtIso(run.completed_at);
147
+ const provider = String(run.provider ?? 'unknown');
148
+ if (!providers[provider] || providers[provider] < completedAt)
149
+ providers[provider] = completedAt;
150
+ if (!lastCompletedAt || lastCompletedAt < completedAt)
151
+ lastCompletedAt = completedAt;
152
+ }
153
+ return { lastCompletedAt, providers, recentRuns: runs.slice(0, 5) };
154
+ }
155
+ finally {
156
+ await repository.close();
146
157
  }
147
- return { lastCompletedAt, providers, recentRuns: runs.slice(0, 5) };
148
158
  }
149
- catch (error) {
150
- if (error instanceof ReadOnlyDatabaseError)
151
- return null;
152
- throw error;
159
+ catch {
160
+ // Best-effort by contract: while a detached sync holds the single-writer
161
+ // DuckDB lock, even opening a snapshot fails cross-process. Progress (from
162
+ // sidecar heartbeats) must still reach the dashboard, so any failure here
163
+ // degrades to "no last-sync summary" instead of failing /sync/status.
164
+ return null;
153
165
  }
154
166
  }
155
167
  async function handleMcpRequest(request, response, paths) {
@@ -271,7 +283,16 @@ export function createCatenceHttpServer(options = {}) {
271
283
  discoveryWarning = `OpenCode Go model discovery failed; profiles were left unchanged (${error instanceof Error ? error.message : String(error)}).`;
272
284
  }
273
285
  }
274
- const handle = await startSync({ paths, athleteId: body.athleteId ?? 'local', provider: body.provider, from: body.from, refresh: body.refresh }, { spawnProcess: options.syncSpawnProcess });
286
+ const handle = await startSync({
287
+ paths,
288
+ athleteId: body.athleteId ?? 'local',
289
+ provider: body.provider,
290
+ from: body.from,
291
+ refresh: body.refresh,
292
+ // The child resolves the athlete within the catalog, not within
293
+ // the athlete store this route resolved for locks and logs.
294
+ cliHome: catalogPaths ? catalogPaths.root : undefined,
295
+ }, { spawnProcess: options.syncSpawnProcess });
275
296
  json(response, 202, { ...handle, ...(discoveryWarning ? { warning: discoveryWarning } : {}), progressEndpoint: '/api/v1/sync/status' });
276
297
  }
277
298
  catch (error) {
@@ -142,7 +142,7 @@ npm run catence-data -- --home "$CATENCE_HOME" sync --athlete alex --provider ga
142
142
  npm run catence-data -- --home "$CATENCE_HOME" build-retrieval-index --athlete alex
143
143
 
144
144
  # Hot-reload loop for MCP iteration (restarts on src/ change)
145
- npx tsx watch src/interfaces/mcp/main.ts -- --home "$CATENCE_HOME"
145
+ npx tsx watch src/interfaces/mcp/main.ts --home "$CATENCE_HOME"
146
146
 
147
147
  # Tests (DuckDB integration tests use a 15s per-test timeout)
148
148
  npm test
@@ -157,15 +157,18 @@ npm run check
157
157
  The Console normally auto-starts a matching packaged runtime (`npx catence@<pinned>`). For development, point it at your live `tsx` runtime instead via `--mcp-url`:
158
158
 
159
159
  ```sh
160
- # Terminal 1 — live MCP/ELT runtime on loopback
161
- npx tsx watch src/interfaces/mcp/main.ts -- serve --home "$CATENCE_HOME" --host 127.0.0.1 --port 8787
160
+ # Terminal 1 — live MCP/ELT runtime on loopback (no `--` separator; tsx
161
+ # forwards arguments verbatim, so a bare `--` would reach the CLI unparsed)
162
+ npx tsx watch src/interfaces/mcp/main.ts serve --home "$CATENCE_HOME" --host 127.0.0.1 --port 8787
162
163
 
163
- # Terminal 2 — Console using that runtime (no auto-started `npx` runtime)
164
+ # Terminal 2 — Console using that runtime (no auto-started `npx` runtime).
165
+ # The `console` npm script already ends in the `serve` subcommand, so extra
166
+ # arguments after `--` are appended AFTER `serve` — never repeat it here.
164
167
  export CATENCE_CONSOLE_USERNAME='coach'
165
168
  export CATENCE_CONSOLE_PASSWORD_HASH="$(uv run --project console catence-console auth hash-password)"
166
169
  export CHAINLIT_AUTH_SECRET="$(openssl rand -hex 32)"
167
170
  export OPENAI_API_KEY='…' # or ANTHROPIC_API_KEY / OPENCODE_API_KEY …
168
- npm run console -- serve --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp --ui-port 8000
171
+ npm run console -- --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp --ui-port 8000
169
172
  # open http://127.0.0.1:8000
170
173
  ```
171
174
 
@@ -264,7 +267,7 @@ services:
264
267
  --home /data --ui-host 0.0.0.0 --ui-port 8000
265
268
  --mcp-host 0.0.0.0 --mcp-port 8787"
266
269
  # for no-restart TS hot-reload, replace the command with:
267
- # npx tsx watch src/interfaces/mcp/main.ts -- serve --home /data --host 0.0.0.0 --port 8787
270
+ # npx tsx watch src/interfaces/mcp/main.ts serve --home /data --host 0.0.0.0 --port 8787
268
271
  # and run Console separately with --mcp-url
269
272
 
270
273
  volumes:
@@ -293,7 +296,7 @@ docker compose -f docker-compose.dev.yml --env-file .env.dev exec console \
293
296
  catence-data --home /data status --athlete alex
294
297
  ```
295
298
 
296
- An alternative that avoids running `npm` inside the mount loop is to keep the runtime on the host entirely: run `npx tsx watch src/interfaces/mcp/main.ts -- serve --home /srv/catence-dev ...` on the host and start the Console container with `--mcp-url http://host.docker.internal:8787/mcp` (or the host's Tailscale IP) and `--env-file .env.dev` — the same `--mcp-url` trick from the local Console section.
299
+ An alternative that avoids running `npm` inside the mount loop is to keep the runtime on the host entirely: run `npx tsx watch src/interfaces/mcp/main.ts serve --home /srv/catence-dev ...` on the host and start the Console container with `--mcp-url http://host.docker.internal:8787/mcp` (or the host's Tailscale IP) and `--env-file .env.dev` — the same `--mcp-url` trick from the local Console section.
297
300
 
298
301
  ## Where secrets and model definitions live (dev and Docker)
299
302
 
@@ -323,3 +326,8 @@ Recommended dev defaults:
323
326
  - **Doctor reports runtime unreachable** — the Console was started without `--mcp-url` and its auto-started runtime is on a different port. Start the runtime explicitly and pass `--mcp-url http://127.0.0.1:8787/mcp` to the Console, or use `uv run --project console catence-console doctor --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp`.
324
327
  - **Docker `run` container has a separate loopback** — `docker compose run` cannot see a `docker compose exec` MCP server. Use `exec` for `doctor`/`sync` against the running container, or use `run` only for one-off `setup`/`secret set` with `--value-stdin`.
325
328
  - **Refusing to initialize** — the data home contains unrelated files. The error lists them; only `config.json`, `console/`, and Chainlit's `.files/`, `.chainlit/`, `public/` are allowed alongside a catalog.
329
+ - **Is a sync actually running? (no deploy scaffold needed)** — three checks, all safe while a sync holds the write lock:
330
+ 1. Runtime status API: `curl -s "http://127.0.0.1:8787/api/v1/sync/status?athleteId=<id>" | jq '.progress.running'` (through the Console instead: `/api/v1/sync/status` behind your login).
331
+ 2. Detached-child log files: `ls -t "$CATENCE_HOME/logs" | head`. A successful `POST /api/v1/sync` creates `logs/sync-<athlete>-<stamp>.log` before spawning, so an absent or empty `logs/` directory proves no detached sync ever started.
332
+ 3. The MCP progress tools (`catence_sync_progress`) read the same heartbeats.
333
+ - **Models page clutter** — unused shipped profiles can be hidden per device from the Models page (`Hide` on the profile card); hidden profiles disappear from the chat Model dropdown but stay configured, and are restored with `Unhide`. Hiding lives in the Console database, never in `config.json`.
@@ -198,8 +198,10 @@ The script warns on stderr and skips any model that does not fit one of these
198
198
  families. Rerun it to refresh the model lists; existing profiles, limits, and
199
199
  `defaultProfile` are preserved unless `--set-default` is passed. Re-running
200
200
  never discards your own tweaks: per-model custom labels, `reasoningEffort`
201
- pins, and `variants` maps survive every refresh, and models that have dropped
202
- out of the live catalog stay configured.
201
+ pins, and `variants` maps survive every refresh. Models that have dropped out
202
+ of the live catalog are pruned from the managed profiles, so the Console never
203
+ offers a route the gateway no longer serves; if a pruned model was the
204
+ profile's default, discovery selects a current one instead.
203
205
 
204
206
  You do not have to run the script manually: the Console dashboard's **Sync
205
207
  data** button (and `POST /api/v1/sync` with `"refreshModels": true`) refreshes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.0-beta.19",
3
+ "version": "0.2.0-beta.20",
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
@@ -32,8 +32,13 @@ def parse_args() -> argparse.Namespace:
32
32
  parser.add_argument(
33
33
  "--lt-history-from",
34
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",
35
40
  type=date.fromisoformat,
36
- help="Backfill lactate-threshold history from this date instead of the daily window.",
41
+ help="Fetch calendar events from this date instead of the default short lookback.",
37
42
  )
38
43
  return parser.parse_args()
39
44
 
@@ -79,6 +84,7 @@ def main() -> None:
79
84
  activity_to_date=args.activity_to_date,
80
85
  include_non_historical=not args.historical_only,
81
86
  lactate_threshold_history_from=args.lt_history_from,
87
+ events_from=args.events_from.isoformat() if args.events_from else None,
82
88
  )
83
89
  except WorkerInterrupted:
84
90
  reporter.finish("interrupted")
@@ -32,6 +32,7 @@ GARMIN_READ_METHODS = {
32
32
  "get_activity_gear", "download_activity",
33
33
  ),
34
34
  "workouts": ("get_workouts", "get_scheduled_workouts"),
35
+ "events": ("get_events",),
35
36
  "goals": ("get_goals",),
36
37
  "gear": ("get_gear",),
37
38
  "golf": ("get_golf_summary",),
@@ -109,6 +109,25 @@ def remote_id(payload: dict[str, Any], fallback: str) -> str:
109
109
  return fallback
110
110
 
111
111
 
112
+ # Scheduled workouts are tiny calendar payloads (one GET per month), so a year
113
+ # of lookahead costs little next to a normal Garmin sync. Months back keep
114
+ # recently completed schedules associatable with their finished activities.
115
+ SCHEDULED_WORKOUTS_MONTHS_BACK = 3
116
+ SCHEDULED_WORKOUTS_MONTHS_AHEAD = 12
117
+
118
+ # Regular syncs reach back only far enough to associate recently finished
119
+ # events with their activities; deeper history arrives via the automatic
120
+ # coverage backfill (the parent passes --events-from when the store's own
121
+ # window predates this span).
122
+ EVENTS_PAST_DAYS = 30
123
+
124
+
125
+ def shift_month(base: date, offset: int) -> date:
126
+ """Return the first day of the month `offset` months from `base`'s month."""
127
+ total = base.year * 12 + (base.month - 1) + offset
128
+ return date(total // 12, total % 12 + 1, 1)
129
+
130
+
112
131
  def payload_date(payload: Mapping[str, Any]) -> str | None:
113
132
  """Return the first ISO calendar day exposed by a Garmin payload."""
114
133
  for key in ("calendarDate", "date", "until", "from", "timestamp", "timestampLocal", "startTimestampGMT"):
@@ -158,6 +177,7 @@ class GarminStagingWorker:
158
177
  activity_to_date: date | None = None,
159
178
  include_non_historical: bool = True,
160
179
  lactate_threshold_history_from: date | None = None,
180
+ events_from: str | None = None,
161
181
  ) -> None:
162
182
  assert_read_only_registry()
163
183
  end = to_date or date.today()
@@ -192,7 +212,7 @@ class GarminStagingWorker:
192
212
  self._activities(activity_from_date, activity_end)
193
213
  if include_non_historical:
194
214
  self._check_interrupted()
195
- self._collections()
215
+ self._collections(events_from=events_from)
196
216
 
197
217
  def _capture(self, endpoint: str, action: Callable[[], Any], remote_id_value: str | None = None, scope: dict[str, Any] | None = None) -> tuple[Any | None, str | None]:
198
218
  try:
@@ -705,15 +725,33 @@ class GarminStagingWorker:
705
725
  occurred_on=start_date[:10],
706
726
  )
707
727
 
708
- def _collections(self) -> None:
728
+ def _fetch_garmin_events(self, start_date: str) -> None:
729
+ """Fetch calendar events from start_date, following pagination."""
730
+ page = 1
731
+ while True:
732
+ payload, raw_hash = self._capture(
733
+ "garmin_events",
734
+ lambda page=page: self.api.get_events(start_date, limit=100, page=page),
735
+ scope={"startDate": start_date, "page": page},
736
+ )
737
+ items = payload if isinstance(payload, list) else []
738
+ if items:
739
+ self._entity("garmin_events", "event", items, raw_hash)
740
+ if len(items) < 100:
741
+ return
742
+ page += 1
743
+
744
+ def _collections(self, events_from: str | None = None) -> None:
709
745
  today = date.today()
710
746
  calls = {
711
- "workouts": ("get_workouts", (), "workout"), "scheduled_workouts": ("get_scheduled_workouts", (today.year, today.month), "scheduled_workout"),
747
+ "workouts": ("get_workouts", (), "workout"),
712
748
  "goals": ("get_goals", (), "goal"), "golf_summary": ("get_golf_summary", (), "golf_scorecard"),
713
749
  }
750
+ months = [shift_month(today, offset) for offset in range(-SCHEDULED_WORKOUTS_MONTHS_BACK, SCHEDULED_WORKOUTS_MONTHS_AHEAD + 1)]
751
+ events_start = events_from or (today - timedelta(days=EVENTS_PAST_DAYS)).isoformat()
714
752
  if self.progress is not None:
715
753
  self.progress.set_stage("collections")
716
- self.progress.advance(total=len(calls))
754
+ self.progress.advance(total=len(calls) + len(months) + 1)
717
755
  for index, (endpoint, (method, arguments, entity_type)) in enumerate(calls.items(), start=1):
718
756
  self._check_interrupted()
719
757
  if self.progress is not None:
@@ -723,3 +761,51 @@ class GarminStagingWorker:
723
761
  self._entity(endpoint, entity_type, payload, raw_hash)
724
762
  if self.progress is not None:
725
763
  self.progress.advance(completed=index)
764
+ # Scheduled workouts span past-to-future months so upcoming sessions
765
+ # surface ahead of time and recently completed ones stay associatable
766
+ # with their finished activities. The calendar service answers each
767
+ # month with a grid wrapper whose items live under `calendarItems`;
768
+ # empty months must stage nothing, and real items stage one row each
769
+ # with their own calendar date.
770
+ for month_index, month in enumerate(months, start=1):
771
+ self._check_interrupted()
772
+ payload, raw_hash = self._capture(
773
+ "scheduled_workouts",
774
+ lambda month=month: self.api.get_scheduled_workouts(month.year, month.month),
775
+ scope={"month": month.isoformat()},
776
+ )
777
+ items = payload.get("calendarItems") if isinstance(payload, Mapping) else payload
778
+ # itemType 'activity' entries are echoes of already-recorded
779
+ # activities (the activities endpoint owns those); only true
780
+ # scheduled workout templates belong in this entity family.
781
+ if items:
782
+ items = [item for item in items if isinstance(item, Mapping) and item.get("itemType") == "workout"]
783
+ if items:
784
+ self._entity("scheduled_workouts", "scheduled_workout", items, raw_hash)
785
+ if self.progress is not None:
786
+ self.progress.advance(completed=len(calls) + month_index)
787
+ # Joined events (races etc.) look back only far enough to associate
788
+ # finished activities; the parent widens start_date into a one-time
789
+ # coverage backfill when the store predates this span.
790
+ self._check_interrupted()
791
+ if self.progress is not None:
792
+ self.progress.advance(step="garmin_events")
793
+ self._fetch_garmin_events(events_start)
794
+ if self.progress is not None:
795
+ self.progress.advance(completed=len(calls) + len(months) + 1)
796
+
797
+ def _fetch_garmin_events(self, start_date: str) -> None:
798
+ """Fetch calendar events from start_date, following pagination."""
799
+ page = 1
800
+ while True:
801
+ payload, raw_hash = self._capture(
802
+ "garmin_events",
803
+ lambda page=page: self.api.get_events(start_date, limit=100, page=page),
804
+ scope={"startDate": start_date, "page": page},
805
+ )
806
+ items = payload if isinstance(payload, list) else []
807
+ if items:
808
+ self._entity("garmin_events", "event", items, raw_hash)
809
+ if len(items) < 100:
810
+ return
811
+ page += 1
package/uv.lock CHANGED
@@ -47,7 +47,7 @@ dev = [
47
47
  requires-dist = [
48
48
  { name = "duckdb", specifier = ">=1.4.0" },
49
49
  { name = "fitdecode", specifier = ">=0.10.0" },
50
- { name = "garminconnect", git = "https://github.com/rifusaki/python-garminconnect.git?rev=732bf14602bce2ca22cb569ba6583c3229bf904d" },
50
+ { name = "garminconnect", git = "https://github.com/rifusaki/python-garminconnect.git?rev=cd4e3d9ef81a93681950138e08f57af226fd5735" },
51
51
  { name = "polars", specifier = ">=1.0.0" },
52
52
  { name = "pyarrow", specifier = ">=18.0.0" },
53
53
  { name = "pydantic", specifier = ">=2.0.0" },
@@ -318,8 +318,8 @@ wheels = [
318
318
 
319
319
  [[package]]
320
320
  name = "garminconnect"
321
- version = "0.3.8"
322
- source = { git = "https://github.com/rifusaki/python-garminconnect.git?rev=732bf14602bce2ca22cb569ba6583c3229bf904d#732bf14602bce2ca22cb569ba6583c3229bf904d" }
321
+ version = "0.3.12"
322
+ source = { git = "https://github.com/rifusaki/python-garminconnect.git?rev=cd4e3d9ef81a93681950138e08f57af226fd5735#cd4e3d9ef81a93681950138e08f57af226fd5735" }
323
323
  dependencies = [
324
324
  { name = "curl-cffi" },
325
325
  { name = "requests" },