catence 0.2.0-beta.11 → 0.2.0-beta.12

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.
@@ -1,5 +1,5 @@
1
1
  export declare const STALE_RUN_TIMEOUT_MS: number;
2
- export declare const SYNC_STAGES: readonly ["starting", "login", "singletons", "daily", "range", "ftp_history", "max_metrics", "hrv_history", "scores", "activities", "collections", "importing", "completed", "interrupted", "failed", "timed_out"];
2
+ export declare const SYNC_STAGES: readonly ["starting", "login", "singletons", "daily", "range", "ftp_history", "max_metrics", "hrv_history", "scores", "activities", "collections", "intervals", "importing", "completed", "interrupted", "failed", "timed_out"];
3
3
  export type SyncStage = (typeof SYNC_STAGES)[number];
4
4
  export interface SyncProgressState {
5
5
  runId: string;
@@ -11,6 +11,7 @@ export const SYNC_STAGES = [
11
11
  'scores',
12
12
  'activities',
13
13
  'collections',
14
+ 'intervals',
14
15
  'importing',
15
16
  'completed',
16
17
  'interrupted',
@@ -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.11";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.12";
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.11';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.12';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -15,7 +15,7 @@ import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
15
15
  import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUrl, syncStravaGear } from '../ingestion/providers/strava/service.js';
16
16
  import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
17
17
  import { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../storage/database.js';
18
- import { mergeProgress, readRunningProgress, removeProgressSidecar, writeProgressSidecar } from '../storage/progress-sidecar.js';
18
+ import { mergeProgress, ProgressPump, readRunningProgress, removeProgressSidecar } from '../storage/progress-sidecar.js';
19
19
  import { withDataWriteLock } from '../storage/write-lock.js';
20
20
  function packageRoot() {
21
21
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
@@ -33,7 +33,7 @@ function baseProgress(runId, provider, stage) {
33
33
  * Progress NDJSON lines (kind 'progress') are normalized and persisted as
34
34
  * throttled heartbeats; other output is forwarded to the sync logger.
35
35
  */
36
- function runGarminWorker(runId, arguments_, environment, database, paths, log, onInterruptReady) {
36
+ function runGarminWorker(runId, arguments_, environment, pump, log, onInterruptReady) {
37
37
  return new Promise((resolve) => {
38
38
  const child = spawn('uv', arguments_, { ...environment, stdio: ['ignore', 'pipe', 'pipe'] });
39
39
  let killTimer;
@@ -48,24 +48,6 @@ function runGarminWorker(runId, arguments_, environment, database, paths, log, o
48
48
  }, 10_000);
49
49
  killTimer.unref();
50
50
  });
51
- let lastProgress = null;
52
- let lastHeartbeatAt = 0;
53
- const heartbeat = (state) => {
54
- const now = Date.now();
55
- if (now - lastHeartbeatAt < 2_000)
56
- return;
57
- lastHeartbeatAt = now;
58
- void database.heartbeatRun(runId, state).catch((error) => {
59
- log.debug('Progress heartbeat failed', { runId, error: error instanceof Error ? error.message : String(error) });
60
- });
61
- void writeProgressSidecar(paths, runId, state).catch((error) => {
62
- log.debug('Progress sidecar write failed', { runId, error: error instanceof Error ? error.message : String(error) });
63
- });
64
- };
65
- const fallback = setInterval(() => {
66
- heartbeat(lastProgress ?? baseProgress(runId, 'garmin', 'starting'));
67
- }, 30_000);
68
- fallback.unref();
69
51
  const buffer = (stream, sink) => {
70
52
  let pending = '';
71
53
  stream.on('data', (chunk) => {
@@ -95,29 +77,24 @@ function runGarminWorker(runId, arguments_, environment, database, paths, log, o
95
77
  }
96
78
  if (typeof parsed === 'object' && parsed !== null && parsed.kind === 'progress') {
97
79
  const state = normalizeProgress(parsed);
98
- if (state) {
99
- lastProgress = state;
100
- heartbeat(state);
101
- }
80
+ if (state)
81
+ pump.publish(state);
102
82
  return;
103
83
  }
104
84
  log.info(`[garmin] ${line}`);
105
85
  });
106
86
  buffer(child.stderr, (line) => log.error(`[garmin] ${line}`));
107
87
  child.on('error', (error) => {
108
- clearInterval(fallback);
109
88
  if (killTimer)
110
89
  clearTimeout(killTimer);
111
90
  resolve({ code: 1, signal: null });
112
91
  log.error('Garmin staging worker could not be started', { runId, error: error.message });
113
92
  });
114
93
  child.on('close', (code, signal) => {
115
- clearInterval(fallback);
116
94
  if (killTimer)
117
95
  clearTimeout(killTimer);
118
- if (code === 0 && lastProgress) {
119
- void writeProgressSidecar(paths, runId, { ...lastProgress, stage: 'importing' }).catch(() => undefined);
120
- }
96
+ if (code === 0)
97
+ pump.setStageImporting();
121
98
  resolve({ code, signal });
122
99
  });
123
100
  });
@@ -188,11 +165,14 @@ async function syncProvider(database, paths, provider, options, log) {
188
165
  });
189
166
  let extractionStarted = false;
190
167
  const guard = { interrupted: false };
168
+ const pump = new ProgressPump(runId, provider, { database, paths, log });
169
+ pump.start();
191
170
  try {
192
171
  if (provider === 'intervals') {
193
172
  const environment = await athleteProviderEnvironment(paths);
194
173
  const config = requireIntervalsConfig(environment);
195
174
  extractionStarted = true;
175
+ pump.setStage('intervals');
196
176
  await runWithInterruptGuard(runId, database, () => undefined, guard, async () => {
197
177
  await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId, log).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
198
178
  });
@@ -222,7 +202,7 @@ async function syncProvider(database, paths, provider, options, log) {
222
202
  const outcome = await runGarminWorker(runId, arguments_, {
223
203
  cwd: packageRoot(),
224
204
  env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
225
- }, database, paths, log, (kill) => { interruptChild = kill; });
205
+ }, pump, log, (kill) => { interruptChild = kill; });
226
206
  if (!guard.interrupted && outcome.code !== 0) {
227
207
  throw new Error(`Garmin staging worker exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
228
208
  }
@@ -243,12 +223,14 @@ async function syncProvider(database, paths, provider, options, log) {
243
223
  }
244
224
  }
245
225
  finally {
226
+ pump.stop();
246
227
  if (guard.interrupted) {
247
228
  await database.markRunInterrupted(runId);
248
229
  log.warn('Sync run interrupted', { runId, provider });
249
230
  }
250
231
  else {
251
232
  await database.finishRun(runId);
233
+ pump.publishFinal(baseProgress(runId, provider, 'completed'));
252
234
  }
253
235
  await removeProgressSidecar(paths, runId).catch(() => undefined);
254
236
  const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
@@ -1,7 +1,11 @@
1
- import { type SyncProgressSnapshot, type SyncProgressState } from '../../contracts/progress.js';
1
+ import { type SyncProgressSnapshot, type SyncProgressState, type SyncStage } from '../../contracts/progress.js';
2
2
  import type { CatencePaths } from '../../contracts/runtime.js';
3
+ import type { SyncLogger } from '../../core/logging.js';
4
+ import type { CatenceDatabase } from './database.js';
3
5
  export declare const PROGRESS_SIDECAR_SUFFIX = ".progress.json";
4
6
  export declare const PROGRESS_SIDECAR_STALE_MS: number;
7
+ export declare const PROGRESS_KEEPALIVE_MS: number;
8
+ export declare const PROGRESS_PUBLISH_THROTTLE_MS: number;
5
9
  export declare function progressSidecarPath(paths: CatencePaths, runId: string): string;
6
10
  /**
7
11
  * Persist one progress heartbeat as a sidecar file next to the Garmin staging
@@ -24,3 +28,34 @@ export declare function readRunningProgress(paths: CatencePaths, staleAfterMs?:
24
28
  * both sources and sorted by heartbeat recency.
25
29
  */
26
30
  export declare function mergeProgress(dbRunning: SyncProgressState[], dbRecent: SyncProgressState[], sidecars: SyncProgressState[]): SyncProgressSnapshot;
31
+ export interface ProgressPumpOptions {
32
+ database: CatenceDatabase;
33
+ paths: CatencePaths;
34
+ log: SyncLogger;
35
+ }
36
+ /**
37
+ * Drives progress heartbeats for the whole lifetime of a sync run - including
38
+ * phases that emit no worker output (the Intervals extractor and the Garmin
39
+ * JSONL import). `publish` throttles writes to the database and sidecar; a
40
+ * 30-second keep-alive interval refreshes the last known state so a run never
41
+ * looks stale while it is still active, even during the lock-holding import.
42
+ */
43
+ export declare class ProgressPump {
44
+ private readonly runId;
45
+ private readonly provider;
46
+ private readonly options;
47
+ private lastProgress;
48
+ private lastHeartbeatAt;
49
+ private interval;
50
+ private stageOverride;
51
+ constructor(runId: string, provider: string, options: ProgressPumpOptions);
52
+ start(): void;
53
+ publish(state: SyncProgressState): void;
54
+ /** Force subsequent keep-alive heartbeats to report the given stage. */
55
+ setStage(stage: SyncStage): void;
56
+ /** Transition the run to the JSONL import stage; keep-alives persist it. */
57
+ setStageImporting(): void;
58
+ /** Write a terminal heartbeat immediately, bypassing the publish throttle. */
59
+ publishFinal(state: SyncProgressState): void;
60
+ stop(): void;
61
+ }
@@ -1,9 +1,11 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { normalizeProgress } from '../../contracts/progress.js';
4
+ import { normalizeProgress, } from '../../contracts/progress.js';
5
5
  export const PROGRESS_SIDECAR_SUFFIX = '.progress.json';
6
6
  export const PROGRESS_SIDECAR_STALE_MS = 5 * 60 * 1000;
7
+ export const PROGRESS_KEEPALIVE_MS = 30 * 1000;
8
+ export const PROGRESS_PUBLISH_THROTTLE_MS = 2 * 1000;
7
9
  const TERMINAL_STAGES = new Set(['completed', 'failed', 'interrupted', 'timed_out']);
8
10
  export function progressSidecarPath(paths, runId) {
9
11
  return path.join(paths.staging, 'garmin', `${runId}${PROGRESS_SIDECAR_SUFFIX}`);
@@ -16,7 +18,7 @@ export function progressSidecarPath(paths, runId) {
16
18
  export async function writeProgressSidecar(paths, runId, state) {
17
19
  const target = progressSidecarPath(paths, runId);
18
20
  await mkdir(path.dirname(target), { recursive: true });
19
- const temporary = `${target}.tmp`;
21
+ const temporary = `${target}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}.tmp`;
20
22
  await writeFile(temporary, JSON.stringify(state));
21
23
  await rename(temporary, target);
22
24
  }
@@ -84,4 +86,95 @@ export function mergeProgress(dbRunning, dbRecent, sidecars) {
84
86
  .slice(0, 10),
85
87
  };
86
88
  }
89
+ function baselineState(runId, provider, stage) {
90
+ return {
91
+ runId,
92
+ provider,
93
+ stage,
94
+ currentStep: null,
95
+ completedUnits: 0,
96
+ totalUnits: null,
97
+ percentComplete: 0,
98
+ elapsedSeconds: 0,
99
+ estimatedRemainingSeconds: null,
100
+ heartbeatAt: new Date().toISOString(),
101
+ };
102
+ }
103
+ /**
104
+ * Drives progress heartbeats for the whole lifetime of a sync run - including
105
+ * phases that emit no worker output (the Intervals extractor and the Garmin
106
+ * JSONL import). `publish` throttles writes to the database and sidecar; a
107
+ * 30-second keep-alive interval refreshes the last known state so a run never
108
+ * looks stale while it is still active, even during the lock-holding import.
109
+ */
110
+ export class ProgressPump {
111
+ runId;
112
+ provider;
113
+ options;
114
+ lastProgress = null;
115
+ lastHeartbeatAt = 0;
116
+ interval = null;
117
+ stageOverride = null;
118
+ constructor(runId, provider, options) {
119
+ this.runId = runId;
120
+ this.provider = provider;
121
+ this.options = options;
122
+ }
123
+ start() {
124
+ if (this.interval)
125
+ return;
126
+ this.interval = setInterval(() => {
127
+ this.publish(this.lastProgress
128
+ ? { ...this.lastProgress, heartbeatAt: new Date().toISOString() }
129
+ : baselineState(this.runId, this.provider, this.stageOverride ?? 'starting'));
130
+ }, PROGRESS_KEEPALIVE_MS);
131
+ this.interval.unref();
132
+ }
133
+ publish(state) {
134
+ const now = Date.now();
135
+ if (this.lastHeartbeatAt !== 0 && now - this.lastHeartbeatAt < PROGRESS_PUBLISH_THROTTLE_MS) {
136
+ this.lastProgress = state;
137
+ return;
138
+ }
139
+ this.lastHeartbeatAt = now;
140
+ this.lastProgress = state;
141
+ const { database, paths, log } = this.options;
142
+ void database.heartbeatRun(this.runId, state).catch((error) => {
143
+ log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
144
+ });
145
+ void writeProgressSidecar(paths, this.runId, state).catch((error) => {
146
+ log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
147
+ });
148
+ }
149
+ /** Force subsequent keep-alive heartbeats to report the given stage. */
150
+ setStage(stage) {
151
+ this.stageOverride = stage;
152
+ if (this.lastProgress) {
153
+ this.lastProgress = { ...this.lastProgress, stage };
154
+ this.publish(this.lastProgress);
155
+ }
156
+ }
157
+ /** Transition the run to the JSONL import stage; keep-alives persist it. */
158
+ setStageImporting() {
159
+ this.setStage('importing');
160
+ }
161
+ /** Write a terminal heartbeat immediately, bypassing the publish throttle. */
162
+ publishFinal(state) {
163
+ this.lastHeartbeatAt = Date.now();
164
+ this.lastProgress = state;
165
+ const { database, paths, log } = this.options;
166
+ void database.heartbeatRun(this.runId, state).catch((error) => {
167
+ log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
168
+ });
169
+ void writeProgressSidecar(paths, this.runId, state).catch((error) => {
170
+ log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
171
+ });
172
+ }
173
+ stop() {
174
+ if (this.interval) {
175
+ clearInterval(this.interval);
176
+ this.interval = null;
177
+ }
178
+ }
179
+ }
87
180
  //# sourceMappingURL=progress-sidecar.js.map
@@ -209,6 +209,10 @@ for live runs and only touch the `sync_run_progress` table for recent-run
209
209
  history once the lock is free — so they keep working while a backfill holds the
210
210
  lock, and never crash with a lock error.
211
211
 
212
+ Progress also stays live through the entire run, not just worker extraction: a
213
+ 30-second keep-alive covers the JSONL import phase (which emits no worker
214
+ output) and the `intervals` phase of a provider-all sync or backfill.
215
+
212
216
  Other read-only tools (`status`, `query_read_only_data`, and friends) still
213
217
  need the database and therefore return a clean `data_sync_in_progress` error
214
218
  while a sync run is active. That is expected behavior: the store is locked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.0-beta.11",
3
+ "version": "0.2.0-beta.12",
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": {