catence 0.2.0-beta.1 → 0.2.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +23 -77
  2. package/config.example.json +32 -20
  3. package/dist/contracts/index.d.ts +1 -0
  4. package/dist/contracts/index.js +1 -0
  5. package/dist/contracts/progress.d.ts +23 -0
  6. package/dist/contracts/progress.js +48 -0
  7. package/dist/contracts/release.d.ts +1 -1
  8. package/dist/contracts/release.js +1 -1
  9. package/dist/core/logging.d.ts +16 -0
  10. package/dist/core/logging.js +68 -0
  11. package/dist/core/query/repository.d.ts +2 -0
  12. package/dist/core/query/repository.js +46 -0
  13. package/dist/core/runtime/catalog.js +17 -7
  14. package/dist/core/runtime/self-update.d.ts +44 -0
  15. package/dist/core/runtime/self-update.js +138 -0
  16. package/dist/elt/application/management.d.ts +2 -0
  17. package/dist/elt/application/management.js +220 -18
  18. package/dist/elt/ingestion/importer.d.ts +3 -2
  19. package/dist/elt/ingestion/importer.js +4 -3
  20. package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
  21. package/dist/elt/ingestion/providers/intervals.js +4 -1
  22. package/dist/elt/ingestion/providers/strava/service.js +7 -1
  23. package/dist/elt/storage/database.d.ts +7 -2
  24. package/dist/elt/storage/database.js +81 -4
  25. package/dist/elt/storage/migrations.js +25 -1
  26. package/dist/elt/storage/progress-sidecar.d.ts +26 -0
  27. package/dist/elt/storage/progress-sidecar.js +87 -0
  28. package/dist/interfaces/cli/commands.js +91 -1
  29. package/dist/interfaces/mcp/server.js +7 -0
  30. package/dist/runtime/index.d.ts +4 -1
  31. package/dist/runtime/index.js +2 -1
  32. package/docs/architecture.md +11 -1
  33. package/docs/configuration.md +221 -0
  34. package/docs/deployment/docker.md +528 -0
  35. package/docs/deployment/local-console.md +230 -0
  36. package/docs/deployment/local-mcp.md +304 -0
  37. package/docs/distribution.md +58 -0
  38. package/docs/llm-providers.md +467 -0
  39. package/docs/mcp-activity-retrieval.md +37 -30
  40. package/package.json +5 -1
  41. package/python/catence/providers/garmin/cli.py +30 -9
  42. package/python/catence/providers/garmin/progress.py +85 -0
  43. package/python/catence/providers/garmin/worker.py +132 -5
  44. package/scripts/discover-opencode-go.mjs +210 -0
  45. package/scripts/discover-opencode-zen.mjs +218 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Self-update support for the Catence runtime and its Console.
3
+ *
4
+ * The runtime tracks npm dist-tags on the `catence` package; the Console
5
+ * tracks PEP 440 releases on PyPI (`catence-console`). A release is "newer"
6
+ * only when it strictly dominates the installed version inside the tracked
7
+ * channel: `stable` for non-prerelease installs, or the beta prerelease
8
+ * channel for beta installs. Channel names match npm dist-tags, so a beta
9
+ * runtime consults the `beta` tag and an explicit `--channel stable` moves a
10
+ * beta install onto the `latest` tag.
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { fileURLToPath } from 'node:url';
14
+ const NPM_VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
15
+ const PEP440_BETA = /^(\d+)\.(\d+)\.(\d+)b(\d+)$/;
16
+ export function parseReleaseVersion(version) {
17
+ const npm = NPM_VERSION.exec(version.trim());
18
+ if (npm) {
19
+ return {
20
+ major: Number(npm[1]),
21
+ minor: Number(npm[2]),
22
+ patch: Number(npm[3]),
23
+ beta: npm[4] === undefined ? undefined : Number(npm[4]),
24
+ };
25
+ }
26
+ const pep440 = PEP440_BETA.exec(version.trim());
27
+ if (pep440) {
28
+ return { major: Number(pep440[1]), minor: Number(pep440[2]), patch: Number(pep440[3]), beta: Number(pep440[4]) };
29
+ }
30
+ return null;
31
+ }
32
+ export function compareReleaseVersions(left, right) {
33
+ for (const field of ['major', 'minor', 'patch']) {
34
+ if (left[field] !== right[field])
35
+ return left[field] < right[field] ? -1 : 1;
36
+ }
37
+ const leftBeta = left.beta ?? Number.POSITIVE_INFINITY;
38
+ const rightBeta = right.beta ?? Number.POSITIVE_INFINITY;
39
+ if (leftBeta === rightBeta)
40
+ return 0;
41
+ return leftBeta < rightBeta ? -1 : 1;
42
+ }
43
+ export function channelForVersion(version) {
44
+ return parseReleaseVersion(version)?.beta !== undefined ? 'beta' : 'stable';
45
+ }
46
+ export function latestRelease(versions, channel) {
47
+ let best;
48
+ for (const version of versions) {
49
+ const parsed = parseReleaseVersion(version);
50
+ if (!parsed)
51
+ continue;
52
+ const isBeta = parsed.beta !== undefined;
53
+ if (channel === 'beta' ? !isBeta : isBeta)
54
+ continue;
55
+ if (!best || compareReleaseVersions(parsed, best.parsed) > 0)
56
+ best = { version, parsed };
57
+ }
58
+ return best?.version;
59
+ }
60
+ export function planSelfUpdate(options) {
61
+ const distTag = options.channel === 'beta' ? 'beta' : 'latest';
62
+ const runtimeTarget = options.npmDistTags[distTag];
63
+ const consoleTarget = latestRelease(options.pypiReleases, options.channel);
64
+ const runtimeUpdateAvailable = isNewer(runtimeTarget, parseReleaseVersion(options.runtimeVersion));
65
+ const consoleUpdateAvailable = options.consoleInstalled
66
+ ? isNewer(consoleTarget, parseReleaseVersion(options.consoleInstalled))
67
+ : consoleTarget !== undefined;
68
+ return {
69
+ channel: options.channel,
70
+ runtime: { current: options.runtimeVersion, target: runtimeTarget, updateAvailable: runtimeUpdateAvailable },
71
+ console: { installed: options.consoleInstalled, target: consoleTarget, updateAvailable: consoleUpdateAvailable },
72
+ ok: !runtimeUpdateAvailable && !consoleUpdateAvailable,
73
+ };
74
+ }
75
+ function isNewer(target, current) {
76
+ if (!target || !current)
77
+ return false;
78
+ const parsed = parseReleaseVersion(target);
79
+ return parsed !== null && compareReleaseVersions(parsed, current) > 0;
80
+ }
81
+ export async function fetchNpmDistTags(packageName, fetchImpl = fetch) {
82
+ const response = await fetchImpl(`https://registry.npmjs.org/${packageName}`);
83
+ if (!response.ok)
84
+ throw new Error(`npm registry returned HTTP ${response.status} for ${packageName}.`);
85
+ const payload = (await response.json());
86
+ return payload['dist-tags'] ?? {};
87
+ }
88
+ export async function fetchPypiReleases(packageName, fetchImpl = fetch) {
89
+ const response = await fetchImpl(`https://pypi.org/pypi/${packageName}/json`);
90
+ if (!response.ok)
91
+ throw new Error(`PyPI returned HTTP ${response.status} for ${packageName}.`);
92
+ const payload = (await response.json());
93
+ return Object.keys(payload.releases ?? {});
94
+ }
95
+ /** Run an update command (npm or uv) with the user's terminal attached. */
96
+ export function runSelfUpdateCommand(command, args) {
97
+ return new Promise((resolve, reject) => {
98
+ const child = spawn(command, args, { stdio: 'inherit' });
99
+ child.once('error', reject);
100
+ child.once('close', (code) => resolve(code ?? 1));
101
+ });
102
+ }
103
+ export function hasCommand(command) {
104
+ return new Promise((resolve) => {
105
+ const child = spawn(command, ['--version'], { stdio: 'ignore' });
106
+ child.once('error', () => resolve(false));
107
+ child.once('close', (code) => resolve(code === 0));
108
+ });
109
+ }
110
+ /** Read `catence-console --version` from PATH; undefined when the Console is not installed. */
111
+ export function readInstalledConsoleVersion() {
112
+ return new Promise((resolve) => {
113
+ const child = spawn('catence-console', ['--version'], { stdio: ['ignore', 'pipe', 'inherit'] });
114
+ let stdout = '';
115
+ child.stdout?.on('data', (chunk) => (stdout += chunk.toString('utf8')));
116
+ child.once('error', () => resolve(undefined));
117
+ child.once('close', (code) => {
118
+ const match = /^catence-console (\S+)/.exec(stdout.trim());
119
+ resolve(code === 0 && match ? match[1] : undefined);
120
+ });
121
+ });
122
+ }
123
+ /** Global npm package root, used to decide whether `catence` can update itself in place. */
124
+ export function npmGlobalPrefix() {
125
+ return new Promise((resolve) => {
126
+ const child = spawn('npm', ['prefix', '-g'], { stdio: ['ignore', 'pipe', 'inherit'] });
127
+ let stdout = '';
128
+ child.stdout?.on('data', (chunk) => (stdout += chunk.toString('utf8')));
129
+ child.once('error', () => resolve(undefined));
130
+ child.once('close', (code) => resolve(code === 0 ? stdout.trim() : undefined));
131
+ });
132
+ }
133
+ export function isGlobalNpmInstall(moduleUrl, npmPrefix) {
134
+ if (!npmPrefix)
135
+ return false;
136
+ return fileURLToPath(moduleUrl).startsWith(`${npmPrefix}/lib/node_modules/`);
137
+ }
138
+ //# sourceMappingURL=self-update.js.map
@@ -1,4 +1,5 @@
1
1
  import type { CatencePaths } from '../../contracts/runtime.js';
2
+ import { type SyncProgressSnapshot } from '../../contracts/progress.js';
2
3
  type SyncProviderChoice = 'intervals' | 'garmin';
3
4
  export type ProviderChoice = SyncProviderChoice | 'strava' | 'all';
4
5
  export declare function initializeDataStore(paths: CatencePaths): Promise<Record<string, unknown>>;
@@ -21,5 +22,6 @@ export declare function disconnectStravaAccount(paths: CatencePaths): Promise<{
21
22
  export declare function linkActivity(paths: CatencePaths, source: string, activity: string): Promise<Record<string, string>>;
22
23
  export declare function unlinkActivity(paths: CatencePaths, source: string): Promise<Record<string, string>>;
23
24
  export declare function dataStatus(paths: CatencePaths): Promise<Record<string, unknown>>;
25
+ export declare function syncProgress(paths: CatencePaths): Promise<SyncProgressSnapshot>;
24
26
  export declare function rebuildRetrievalIndex(paths: CatencePaths): Promise<Record<string, unknown>>;
25
27
  export {};
@@ -1,25 +1,161 @@
1
- import { execFile } from 'node:child_process';
1
+ import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { mkdir, writeFile } from 'node:fs/promises';
5
5
  import { createServer } from 'node:http';
6
6
  import path from 'node:path';
7
- import { promisify } from 'node:util';
8
7
  import { fileURLToPath } from 'node:url';
8
+ import { normalizeProgress, STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
9
9
  import { buildRetrievalIndex } from '../../core/retrieval/index.js';
10
10
  import { defaultFromDate, ensurePaths, requireIntervalsConfig } from '../../core/runtime/configuration.js';
11
+ import { createSyncLogger } from '../../core/logging.js';
11
12
  import { athleteProviderEnvironment } from '../../core/runtime/secrets.js';
12
13
  import { importJsonl } from '../ingestion/importer.js';
13
14
  import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
14
15
  import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUrl, syncStravaGear } from '../ingestion/providers/strava/service.js';
15
16
  import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
16
- import { CatenceDatabase } from '../storage/database.js';
17
+ import { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../storage/database.js';
18
+ import { mergeProgress, readRunningProgress, removeProgressSidecar, writeProgressSidecar } from '../storage/progress-sidecar.js';
17
19
  import { withDataWriteLock } from '../storage/write-lock.js';
18
- const execFileAsync = promisify(execFile);
19
20
  function packageRoot() {
20
21
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
21
22
  }
22
- async function syncProvider(database, paths, provider, options) {
23
+ function baseProgress(runId, provider, stage) {
24
+ return {
25
+ runId, provider, stage, currentStep: null,
26
+ completedUnits: 0, totalUnits: null, percentComplete: 0, elapsedSeconds: 0,
27
+ estimatedRemainingSeconds: null, heartbeatAt: new Date().toISOString(),
28
+ };
29
+ }
30
+ /**
31
+ * Run the Garmin staging worker with streamed stdout/stderr so progress and
32
+ * errors surface in real time instead of buffering until the child exits.
33
+ * Progress NDJSON lines (kind 'progress') are normalized and persisted as
34
+ * throttled heartbeats; other output is forwarded to the sync logger.
35
+ */
36
+ function runGarminWorker(runId, arguments_, environment, database, paths, log, onInterruptReady) {
37
+ return new Promise((resolve) => {
38
+ const child = spawn('uv', arguments_, { ...environment, stdio: ['ignore', 'pipe', 'pipe'] });
39
+ let killTimer;
40
+ onInterruptReady(() => {
41
+ if (child.exitCode !== null || child.signalCode !== null)
42
+ return;
43
+ child.kill('SIGTERM');
44
+ killTimer = setTimeout(() => {
45
+ if (child.exitCode !== null || child.signalCode !== null)
46
+ return;
47
+ child.kill('SIGKILL');
48
+ }, 10_000);
49
+ killTimer.unref();
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
+ const buffer = (stream, sink) => {
70
+ let pending = '';
71
+ stream.on('data', (chunk) => {
72
+ pending += chunk.toString('utf8');
73
+ let newline;
74
+ while ((newline = pending.indexOf('\n')) >= 0) {
75
+ const line = pending.slice(0, newline).trim();
76
+ pending = pending.slice(newline + 1);
77
+ if (line)
78
+ sink(line);
79
+ }
80
+ });
81
+ stream.on('end', () => {
82
+ const line = pending.trim();
83
+ if (line)
84
+ sink(line);
85
+ });
86
+ };
87
+ buffer(child.stdout, (line) => {
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(line);
91
+ }
92
+ catch {
93
+ log.info(`[garmin] ${line}`);
94
+ return;
95
+ }
96
+ if (typeof parsed === 'object' && parsed !== null && parsed.kind === 'progress') {
97
+ const state = normalizeProgress(parsed);
98
+ if (state) {
99
+ lastProgress = state;
100
+ heartbeat(state);
101
+ }
102
+ return;
103
+ }
104
+ log.info(`[garmin] ${line}`);
105
+ });
106
+ buffer(child.stderr, (line) => log.error(`[garmin] ${line}`));
107
+ child.on('error', (error) => {
108
+ clearInterval(fallback);
109
+ if (killTimer)
110
+ clearTimeout(killTimer);
111
+ resolve({ code: 1, signal: null });
112
+ log.error('Garmin staging worker could not be started', { runId, error: error.message });
113
+ });
114
+ child.on('close', (code, signal) => {
115
+ clearInterval(fallback);
116
+ if (killTimer)
117
+ clearTimeout(killTimer);
118
+ if (code === 0 && lastProgress) {
119
+ void writeProgressSidecar(paths, runId, { ...lastProgress, stage: 'importing' }).catch(() => undefined);
120
+ }
121
+ resolve({ code, signal });
122
+ });
123
+ });
124
+ }
125
+ /**
126
+ * Trap termination signals for the duration of a sync run so an interrupted
127
+ * run is marked 'interrupted' in sync_runs instead of being left as a zombie
128
+ * 'running' row. The process exit code mirrors the received signal.
129
+ */ async function runWithInterruptGuard(runId, database, onInterrupt, state, work) {
130
+ let exitCode = 0;
131
+ const handler = (signal) => {
132
+ if (state.interrupted)
133
+ return;
134
+ state.interrupted = true;
135
+ exitCode = signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 129;
136
+ void database.markRunInterrupted(runId).catch(() => undefined);
137
+ try {
138
+ onInterrupt();
139
+ }
140
+ catch {
141
+ // The child may not exist yet; the run is still marked interrupted.
142
+ }
143
+ };
144
+ process.once('SIGINT', handler);
145
+ process.once('SIGTERM', handler);
146
+ process.once('SIGHUP', handler);
147
+ try {
148
+ await work();
149
+ }
150
+ finally {
151
+ process.removeListener('SIGINT', handler);
152
+ process.removeListener('SIGTERM', handler);
153
+ process.removeListener('SIGHUP', handler);
154
+ if (state.interrupted)
155
+ process.exitCode = exitCode;
156
+ }
157
+ }
158
+ async function syncProvider(database, paths, provider, options, log) {
23
159
  const explicitWindow = options.explicitFrom ? { fromDate: options.explicitFrom, toDate: options.toDate, source: 'explicit' } : null;
24
160
  const uncoveredExplicitWindow = async (cursorName) => {
25
161
  if (!explicitWindow)
@@ -33,6 +169,7 @@ async function syncProvider(database, paths, provider, options) {
33
169
  const activityWindow = explicitWindow ? await uncoveredExplicitWindow('activities') : await database.resolveIncrementalWindow(provider, 'activities', options.toDate, defaultFromDate(), 14);
34
170
  const hasProviderWork = provider === 'intervals' ? activityWindow !== null : dailyWindow !== null || activityWindow !== null;
35
171
  if (!hasProviderWork) {
172
+ log.debug('No uncovered work; skipping provider', { provider, daily: dailyWindow ?? null, activities: activityWindow ?? null });
36
173
  return {
37
174
  runId: null, provider, fromDate: null,
38
175
  dailyFromDate: dailyWindow?.fromDate ?? null, dailyToDate: dailyWindow?.toDate ?? null,
@@ -43,13 +180,22 @@ async function syncProvider(database, paths, provider, options) {
43
180
  }
44
181
  const fromDate = [dailyWindow?.fromDate, activityWindow?.fromDate].filter((date) => Boolean(date)).sort()[0];
45
182
  const runId = await database.beginRun(provider, fromDate);
183
+ log.info('Starting sync run', {
184
+ runId, provider, fromDate, toDate: options.toDate,
185
+ dailyFrom: dailyWindow?.fromDate ?? null, dailyTo: dailyWindow?.toDate ?? null,
186
+ activityFrom: activityWindow?.fromDate ?? null, activityTo: activityWindow?.toDate ?? null,
187
+ backfill: options.backfill, refreshActivities: options.refreshActivities,
188
+ });
46
189
  let extractionStarted = false;
190
+ const guard = { interrupted: false };
47
191
  try {
48
192
  if (provider === 'intervals') {
49
193
  const environment = await athleteProviderEnvironment(paths);
50
194
  const config = requireIntervalsConfig(environment);
51
195
  extractionStarted = true;
52
- await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
196
+ await runWithInterruptGuard(runId, database, () => undefined, guard, async () => {
197
+ await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId, log).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
198
+ });
53
199
  }
54
200
  else {
55
201
  const output = path.join(paths.staging, 'garmin', `${runId}.jsonl`);
@@ -71,22 +217,44 @@ async function syncProvider(database, paths, provider, options) {
71
217
  arguments_.push('--skip-activities');
72
218
  if (options.backfill && !options.refreshActivities)
73
219
  arguments_.push('--historical-only');
74
- await execFileAsync('uv', arguments_, {
75
- cwd: packageRoot(),
76
- env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
220
+ let interruptChild = null;
221
+ await runWithInterruptGuard(runId, database, () => interruptChild?.(), guard, async () => {
222
+ const outcome = await runGarminWorker(runId, arguments_, {
223
+ cwd: packageRoot(),
224
+ 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; });
226
+ if (!guard.interrupted && outcome.code !== 0) {
227
+ throw new Error(`Garmin staging worker exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
228
+ }
77
229
  });
78
- if (!existsSync(output))
79
- throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
80
- await importJsonl(database, runId, output);
230
+ if (!guard.interrupted) {
231
+ if (!existsSync(output))
232
+ throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
233
+ await importJsonl(database, runId, output, log);
234
+ }
81
235
  }
82
236
  }
83
237
  catch (error) {
84
- await database.addError(runId, provider, 'sync', null, error instanceof Error ? error.message : String(error), true);
238
+ if (!guard.interrupted) {
239
+ const message = error instanceof Error ? error.message : String(error);
240
+ log.error('Sync run failed', { runId, provider, error: message, stack: error instanceof Error ? error.stack : undefined });
241
+ await database.addError(runId, provider, 'sync', null, message, true);
242
+ await database.heartbeatRun(runId, baseProgress(runId, provider, 'failed')).catch(() => undefined);
243
+ }
85
244
  }
86
245
  finally {
87
- await database.finishRun(runId);
246
+ if (guard.interrupted) {
247
+ await database.markRunInterrupted(runId);
248
+ log.warn('Sync run interrupted', { runId, provider });
249
+ }
250
+ else {
251
+ await database.finishRun(runId);
252
+ }
253
+ await removeProgressSidecar(paths, runId).catch(() => undefined);
254
+ const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
255
+ log.info('Sync run finished', { runId, provider, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
88
256
  }
89
- if (options.advanceCursor && !options.explicitFrom && extractionStarted) {
257
+ if (!guard.interrupted && options.advanceCursor && !options.explicitFrom && extractionStarted) {
90
258
  await database.advanceIncrementalCursor(provider, 'daily', runId, options.toDate, 3);
91
259
  await database.advanceIncrementalCursor(provider, 'activities', runId, options.toDate, 14);
92
260
  }
@@ -109,13 +277,21 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
109
277
  if (provider === 'strava')
110
278
  return { provider, result: await syncStravaGear(paths) };
111
279
  const database = await CatenceDatabase.open(paths);
280
+ const log = createSyncLogger(paths);
112
281
  try {
282
+ const timedOutRuns = await database.interruptStaleSyncRuns(STALE_RUN_TIMEOUT_MS);
283
+ if (timedOutRuns.runIds.length > 0) {
284
+ log.warn('Marked stale sync runs as timed out', { runIds: timedOutRuns.runIds, timeoutMs: STALE_RUN_TIMEOUT_MS });
285
+ }
286
+ for (const runId of timedOutRuns.runIds) {
287
+ await removeProgressSidecar(paths, runId).catch(() => undefined);
288
+ }
113
289
  const providers = provider === 'all' ? ['intervals', 'garmin'] : [provider];
114
290
  const toDate = new Date().toISOString().slice(0, 10);
115
291
  const runs = [];
116
292
  for (const source of providers)
117
- runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }));
118
- return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs };
293
+ runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }, log));
294
+ return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs, timedOutRuns: timedOutRuns.runIds };
119
295
  }
120
296
  finally {
121
297
  await database.close();
@@ -125,12 +301,14 @@ export async function retryDataSync(paths, previousRunId) {
125
301
  await ensurePaths(paths);
126
302
  const database = await CatenceDatabase.open(paths);
127
303
  try {
304
+ await database.interruptStaleSyncRuns(STALE_RUN_TIMEOUT_MS);
128
305
  const previous = await database.getRun(previousRunId);
129
306
  if (!previous)
130
307
  throw new Error(`No sync run found for ${previousRunId}.`);
131
308
  if (previous.provider === 'strava')
132
309
  throw new Error('Retry Strava gear sync with `catence-data sync --provider strava`.');
133
- const run = await syncProvider(database, paths, previous.provider, { explicitFrom: previous.from_date, toDate: new Date().toISOString().slice(0, 10), advanceCursor: false, refreshActivities: false, backfill: false });
310
+ const log = createSyncLogger(paths);
311
+ const run = await syncProvider(database, paths, previous.provider, { explicitFrom: previous.from_date, toDate: new Date().toISOString().slice(0, 10), advanceCursor: false, refreshActivities: false, backfill: false }, log);
134
312
  return { retriedRun: previousRunId, runId: run.runId, run };
135
313
  }
136
314
  finally {
@@ -271,6 +449,30 @@ export async function dataStatus(paths) {
271
449
  await database.close();
272
450
  }
273
451
  }
452
+ export async function syncProgress(paths) {
453
+ await ensurePaths(paths);
454
+ // Live progress must stay readable while a sync run holds the single-writer
455
+ // DuckDB lock, so the running set comes from sidecar heartbeat files first.
456
+ const sidecars = await readRunningProgress(paths);
457
+ let dbRunning = [];
458
+ let dbRecent = [];
459
+ try {
460
+ const repository = await openReadOnlyRepository(paths);
461
+ try {
462
+ const snapshot = await repository.progress();
463
+ dbRunning = snapshot.running;
464
+ dbRecent = snapshot.recent;
465
+ }
466
+ finally {
467
+ await repository.close();
468
+ }
469
+ }
470
+ catch (error) {
471
+ if (!(error instanceof ReadOnlyDatabaseError))
472
+ throw error;
473
+ }
474
+ return mergeProgress(dbRunning, dbRecent, sidecars);
475
+ }
274
476
  export async function rebuildRetrievalIndex(paths) {
275
477
  if (!existsSync(paths.database))
276
478
  throw new Error('No Catence database exists yet. Complete a sync before building the retrieval index.');
@@ -1,7 +1,8 @@
1
1
  import type { CatenceDatabase } from '../storage/database.js';
2
2
  import { type StagingRecord } from '../../contracts/staging.js';
3
- export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord): Promise<void>;
4
- export declare function importJsonl(database: CatenceDatabase, runId: string, filePath: string): Promise<number>;
3
+ import type { SyncLogger } from '../../core/logging.js';
4
+ export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord, log?: SyncLogger): Promise<void>;
5
+ export declare function importJsonl(database: CatenceDatabase, runId: string, filePath: string, log?: SyncLogger): Promise<number>;
5
6
  export declare function importSingleStagingFile(database: CatenceDatabase, runId: string, filePath: string): Promise<number>;
6
7
  export declare function queueWorkItem(database: CatenceDatabase, runId: string, provider: 'intervals' | 'garmin' | 'strava', endpoint: string, remoteId: string | null, scope: unknown): Promise<string>;
7
8
  export declare function completeWorkItem(database: CatenceDatabase, itemId: string): Promise<void>;
@@ -7,7 +7,7 @@ import { importSourceEntity, refreshActivityQuality } from '../normalization/nor
7
7
  import { normalizeStravaEntity } from '../normalization/segments/strava-normalizer.js';
8
8
  import { reconcileActivityLink } from '../normalization/activities/linking.js';
9
9
  import { json } from '../storage/sql.js';
10
- export async function importRecord(database, runId, record) {
10
+ export async function importRecord(database, runId, record, log) {
11
11
  if (record.kind === 'run_manifest') {
12
12
  if (record.runId !== runId)
13
13
  throw new Error(`Staging manifest run ${record.runId} does not match importer run ${runId}.`);
@@ -54,8 +54,9 @@ export async function importRecord(database, runId, record) {
54
54
  return;
55
55
  }
56
56
  await database.addError(runId, record.provider, record.endpoint, record.remoteId, record.message, record.retryable);
57
+ log?.warn('Staging record reported an extraction error', { runId, provider: record.provider, endpoint: record.endpoint, remoteId: record.remoteId, error: record.message });
57
58
  }
58
- export async function importJsonl(database, runId, filePath) {
59
+ export async function importJsonl(database, runId, filePath, log) {
59
60
  let count = 0;
60
61
  const file = createReadStream(filePath, { encoding: 'utf8' });
61
62
  const lines = createInterface({ input: file, crlfDelay: Infinity });
@@ -63,7 +64,7 @@ export async function importJsonl(database, runId, filePath) {
63
64
  if (!line.trim())
64
65
  continue;
65
66
  const record = stagingRecordSchema.parse(JSON.parse(line));
66
- await importRecord(database, runId, record);
67
+ await importRecord(database, runId, record, log);
67
68
  count += 1;
68
69
  }
69
70
  return count;
@@ -1,12 +1,14 @@
1
1
  import type { CatenceDatabase } from '../../storage/database.js';
2
2
  import type { CatencePaths } from '../../../contracts/runtime.js';
3
+ import type { SyncLogger } from '../../../core/logging.js';
3
4
  export declare class IntervalsExtractor {
4
5
  private readonly database;
5
6
  private readonly paths;
6
7
  private readonly apiKey;
7
8
  private readonly athleteId;
9
+ private readonly log;
8
10
  private readonly client;
9
- constructor(database: CatenceDatabase, paths: CatencePaths, apiKey: string, athleteId: string);
11
+ constructor(database: CatenceDatabase, paths: CatencePaths, apiKey: string, athleteId: string, log: SyncLogger);
10
12
  sync(runId: string, activityWindow: {
11
13
  fromDate: string;
12
14
  toDate: string;
@@ -42,12 +42,14 @@ export class IntervalsExtractor {
42
42
  paths;
43
43
  apiKey;
44
44
  athleteId;
45
+ log;
45
46
  client;
46
- constructor(database, paths, apiKey, athleteId) {
47
+ constructor(database, paths, apiKey, athleteId, log) {
47
48
  this.database = database;
48
49
  this.paths = paths;
49
50
  this.apiKey = apiKey;
50
51
  this.athleteId = athleteId;
52
+ this.log = log;
51
53
  // The typed client is used for provider-compatible authentication and the
52
54
  // primary identity request. Manifest-only calls below intentionally use
53
55
  // the same read-only credentials to cover endpoint families not wrapped by it.
@@ -189,6 +191,7 @@ export class IntervalsExtractor {
189
191
  catch (error) {
190
192
  const message = error instanceof Error ? error.message : String(error);
191
193
  await failWorkItem(this.database, workId, message);
194
+ this.log.warn('Endpoint capture failed', { runId, provider: 'intervals', endpoint, remoteId, error: message });
192
195
  await this.database.addError(runId, 'intervals', endpoint, remoteId, message, true);
193
196
  return undefined;
194
197
  }
@@ -8,6 +8,7 @@ import { ensurePaths, loadCatenceConfig } from '../../../../core/runtime/configu
8
8
  import { athleteProviderEnvironment } from '../../../../core/runtime/secrets.js';
9
9
  import { CatenceDatabase, openReadOnlyRepository } from '../../../storage/database.js';
10
10
  import { importJsonl } from '../../importer.js';
11
+ import { createSyncLogger } from '../../../../core/logging.js';
11
12
  import { withDataWriteLock } from '../../../storage/write-lock.js';
12
13
  const execFileAsync = promisify(execFile);
13
14
  export class StravaRateLimitError extends Error {
@@ -108,6 +109,7 @@ async function cachedState(database, resourceType, remoteId) {
108
109
  async function runReadOperation(paths, operation, extraArgs, resource, refresh = false) {
109
110
  return withDataWriteLock(paths, async () => {
110
111
  await ensurePaths(paths);
112
+ const log = createSyncLogger(paths);
111
113
  const database = await CatenceDatabase.open(paths);
112
114
  try {
113
115
  if (!refresh) {
@@ -117,6 +119,7 @@ async function runReadOperation(paths, operation, extraArgs, resource, refresh =
117
119
  }
118
120
  await checkBudget(database, paths);
119
121
  const runId = await database.beginRun('strava', new Date().toISOString().slice(0, 10));
122
+ log.info('Starting Strava read operation', { runId, operation, resource: resource.id });
120
123
  const directory = path.join(paths.staging, 'strava');
121
124
  const stagingPath = path.join(directory, `${runId}.jsonl`);
122
125
  const resultPath = path.join(directory, `${runId}.result.json`);
@@ -124,17 +127,20 @@ async function runReadOperation(paths, operation, extraArgs, resource, refresh =
124
127
  try {
125
128
  result = await invokeWorker(paths, ['--mode', operation, '--run-id', runId, '--output', stagingPath, ...extraArgs], resultPath);
126
129
  if (existsSync(stagingPath))
127
- await importJsonl(database, runId, stagingPath);
130
+ await importJsonl(database, runId, stagingPath, log);
128
131
  await storeRateState(database, result);
129
132
  await writeState(database, resource.type, resource.id, result);
130
133
  }
131
134
  catch (error) {
132
135
  const message = error instanceof Error ? error.message : String(error);
136
+ log.error('Strava read operation failed', { runId, operation, resource: resource.id, error: message, stack: error instanceof Error ? error.stack : undefined });
133
137
  await database.addError(runId, 'strava', operation, resource.id, message, true);
134
138
  throw error;
135
139
  }
136
140
  finally {
137
141
  await database.finishRun(runId);
142
+ const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
143
+ log.info('Strava read operation finished', { runId, operation, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
138
144
  }
139
145
  if (result.status === 'error')
140
146
  throw new StravaEnrichmentError(result.message ?? 'Strava did not complete the requested enrichment.');
@@ -2,6 +2,7 @@ import { ReadOnlyRepository } from '../../core/query/repository.js';
2
2
  import type { CatencePaths } from '../../contracts/runtime.js';
3
3
  import type { QueryBindings, WriteDataStore } from '../../contracts/storage.js';
4
4
  import type { Provider } from '../../contracts/staging.js';
5
+ import { type InterruptedRuns, type SyncProgressSnapshot, type SyncProgressState } from '../../contracts/progress.js';
5
6
  export declare class CatenceDatabase implements WriteDataStore {
6
7
  private readonly instance;
7
8
  private readonly connection;
@@ -16,6 +17,10 @@ export declare class CatenceDatabase implements WriteDataStore {
16
17
  interrupt(): void;
17
18
  beginRun(provider: Provider, fromDate: string, runId?: `${string}-${string}-${string}-${string}-${string}`): Promise<string>;
18
19
  finishRun(runId: string): Promise<void>;
20
+ heartbeatRun(runId: string, progress: SyncProgressState): Promise<void>;
21
+ markRunInterrupted(runId: string, stage?: string): Promise<void>;
22
+ interruptStaleSyncRuns(timeoutMs?: number): Promise<InterruptedRuns>;
23
+ syncProgress(): Promise<SyncProgressSnapshot>;
19
24
  addError(runId: string, provider: Provider, endpoint: string, remoteId: string | null, message: string, retryable: boolean): Promise<void>;
20
25
  status(): Promise<Record<string, unknown>>;
21
26
  getRun(runId: string): Promise<{
@@ -66,9 +71,9 @@ export declare class ReadOnlyCatenceDatabase {
66
71
  interrupt(): void;
67
72
  private withConnection;
68
73
  }
69
- /** Compose core read services with the DuckDB-backed, read-only adapter. */
70
- export declare function openReadOnlyRepository(paths: CatencePaths): Promise<ReadOnlyRepository>;
71
74
  export declare class ReadOnlyDatabaseError extends Error {
72
75
  readonly code: 'data_sync_in_progress' | 'data_unavailable';
73
76
  constructor(code: 'data_sync_in_progress' | 'data_unavailable', message: string);
74
77
  }
78
+ /** Compose core read services with the DuckDB-backed, read-only adapter. */
79
+ export declare function openReadOnlyRepository(paths: CatencePaths): Promise<ReadOnlyRepository>;