catence 0.2.0-beta.1 → 0.2.0-beta.10

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 (38) hide show
  1. package/README.md +144 -2
  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 +42 -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 +194 -17
  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/interfaces/cli/commands.js +91 -1
  27. package/dist/interfaces/mcp/server.js +7 -0
  28. package/dist/runtime/index.d.ts +4 -1
  29. package/dist/runtime/index.js +2 -1
  30. package/docs/deployment.md +338 -0
  31. package/docs/docker-deployment.md +232 -0
  32. package/docs/llm-providers.md +467 -0
  33. package/package.json +5 -1
  34. package/python/catence/providers/garmin/cli.py +30 -9
  35. package/python/catence/providers/garmin/progress.py +85 -0
  36. package/python/catence/providers/garmin/worker.py +132 -5
  37. package/scripts/discover-opencode-go.mjs +210 -0
  38. 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,13 +1,14 @@
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';
@@ -15,11 +16,139 @@ import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUr
15
16
  import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
16
17
  import { CatenceDatabase } from '../storage/database.js';
17
18
  import { withDataWriteLock } from '../storage/write-lock.js';
18
- const execFileAsync = promisify(execFile);
19
19
  function packageRoot() {
20
20
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
21
21
  }
22
- async function syncProvider(database, paths, provider, options) {
22
+ function baseProgress(runId, provider, stage) {
23
+ return {
24
+ runId, provider, stage, currentStep: null,
25
+ completedUnits: 0, totalUnits: null, percentComplete: 0, elapsedSeconds: 0,
26
+ estimatedRemainingSeconds: null, heartbeatAt: new Date().toISOString(),
27
+ };
28
+ }
29
+ /**
30
+ * Run the Garmin staging worker with streamed stdout/stderr so progress and
31
+ * errors surface in real time instead of buffering until the child exits.
32
+ * Progress NDJSON lines (kind 'progress') are normalized and persisted as
33
+ * throttled heartbeats; other output is forwarded to the sync logger.
34
+ */
35
+ function runGarminWorker(runId, arguments_, environment, database, log, onInterruptReady) {
36
+ return new Promise((resolve) => {
37
+ const child = spawn('uv', arguments_, { ...environment, stdio: ['ignore', 'pipe', 'pipe'] });
38
+ let killTimer;
39
+ onInterruptReady(() => {
40
+ if (child.exitCode !== null || child.signalCode !== null)
41
+ return;
42
+ child.kill('SIGTERM');
43
+ killTimer = setTimeout(() => {
44
+ if (child.exitCode !== null || child.signalCode !== null)
45
+ return;
46
+ child.kill('SIGKILL');
47
+ }, 10_000);
48
+ killTimer.unref();
49
+ });
50
+ let lastProgress = null;
51
+ let lastHeartbeatAt = 0;
52
+ const heartbeat = (state) => {
53
+ const now = Date.now();
54
+ if (now - lastHeartbeatAt < 2_000)
55
+ return;
56
+ lastHeartbeatAt = now;
57
+ void database.heartbeatRun(runId, state).catch((error) => {
58
+ log.debug('Progress heartbeat failed', { runId, error: error instanceof Error ? error.message : String(error) });
59
+ });
60
+ };
61
+ const fallback = setInterval(() => {
62
+ heartbeat(lastProgress ?? baseProgress(runId, 'garmin', 'starting'));
63
+ }, 30_000);
64
+ fallback.unref();
65
+ const buffer = (stream, sink) => {
66
+ let pending = '';
67
+ stream.on('data', (chunk) => {
68
+ pending += chunk.toString('utf8');
69
+ let newline;
70
+ while ((newline = pending.indexOf('\n')) >= 0) {
71
+ const line = pending.slice(0, newline).trim();
72
+ pending = pending.slice(newline + 1);
73
+ if (line)
74
+ sink(line);
75
+ }
76
+ });
77
+ stream.on('end', () => {
78
+ const line = pending.trim();
79
+ if (line)
80
+ sink(line);
81
+ });
82
+ };
83
+ buffer(child.stdout, (line) => {
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(line);
87
+ }
88
+ catch {
89
+ log.info(`[garmin] ${line}`);
90
+ return;
91
+ }
92
+ if (typeof parsed === 'object' && parsed !== null && parsed.kind === 'progress') {
93
+ const state = normalizeProgress(parsed);
94
+ if (state) {
95
+ lastProgress = state;
96
+ heartbeat(state);
97
+ }
98
+ return;
99
+ }
100
+ log.info(`[garmin] ${line}`);
101
+ });
102
+ buffer(child.stderr, (line) => log.error(`[garmin] ${line}`));
103
+ child.on('error', (error) => {
104
+ clearInterval(fallback);
105
+ if (killTimer)
106
+ clearTimeout(killTimer);
107
+ resolve({ code: 1, signal: null });
108
+ log.error('Garmin staging worker could not be started', { runId, error: error.message });
109
+ });
110
+ child.on('close', (code, signal) => {
111
+ clearInterval(fallback);
112
+ if (killTimer)
113
+ clearTimeout(killTimer);
114
+ resolve({ code, signal });
115
+ });
116
+ });
117
+ }
118
+ /**
119
+ * Trap termination signals for the duration of a sync run so an interrupted
120
+ * run is marked 'interrupted' in sync_runs instead of being left as a zombie
121
+ * 'running' row. The process exit code mirrors the received signal.
122
+ */ async function runWithInterruptGuard(runId, database, onInterrupt, state, work) {
123
+ let exitCode = 0;
124
+ const handler = (signal) => {
125
+ if (state.interrupted)
126
+ return;
127
+ state.interrupted = true;
128
+ exitCode = signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 129;
129
+ void database.markRunInterrupted(runId).catch(() => undefined);
130
+ try {
131
+ onInterrupt();
132
+ }
133
+ catch {
134
+ // The child may not exist yet; the run is still marked interrupted.
135
+ }
136
+ };
137
+ process.once('SIGINT', handler);
138
+ process.once('SIGTERM', handler);
139
+ process.once('SIGHUP', handler);
140
+ try {
141
+ await work();
142
+ }
143
+ finally {
144
+ process.removeListener('SIGINT', handler);
145
+ process.removeListener('SIGTERM', handler);
146
+ process.removeListener('SIGHUP', handler);
147
+ if (state.interrupted)
148
+ process.exitCode = exitCode;
149
+ }
150
+ }
151
+ async function syncProvider(database, paths, provider, options, log) {
23
152
  const explicitWindow = options.explicitFrom ? { fromDate: options.explicitFrom, toDate: options.toDate, source: 'explicit' } : null;
24
153
  const uncoveredExplicitWindow = async (cursorName) => {
25
154
  if (!explicitWindow)
@@ -33,6 +162,7 @@ async function syncProvider(database, paths, provider, options) {
33
162
  const activityWindow = explicitWindow ? await uncoveredExplicitWindow('activities') : await database.resolveIncrementalWindow(provider, 'activities', options.toDate, defaultFromDate(), 14);
34
163
  const hasProviderWork = provider === 'intervals' ? activityWindow !== null : dailyWindow !== null || activityWindow !== null;
35
164
  if (!hasProviderWork) {
165
+ log.debug('No uncovered work; skipping provider', { provider, daily: dailyWindow ?? null, activities: activityWindow ?? null });
36
166
  return {
37
167
  runId: null, provider, fromDate: null,
38
168
  dailyFromDate: dailyWindow?.fromDate ?? null, dailyToDate: dailyWindow?.toDate ?? null,
@@ -43,13 +173,22 @@ async function syncProvider(database, paths, provider, options) {
43
173
  }
44
174
  const fromDate = [dailyWindow?.fromDate, activityWindow?.fromDate].filter((date) => Boolean(date)).sort()[0];
45
175
  const runId = await database.beginRun(provider, fromDate);
176
+ log.info('Starting sync run', {
177
+ runId, provider, fromDate, toDate: options.toDate,
178
+ dailyFrom: dailyWindow?.fromDate ?? null, dailyTo: dailyWindow?.toDate ?? null,
179
+ activityFrom: activityWindow?.fromDate ?? null, activityTo: activityWindow?.toDate ?? null,
180
+ backfill: options.backfill, refreshActivities: options.refreshActivities,
181
+ });
46
182
  let extractionStarted = false;
183
+ const guard = { interrupted: false };
47
184
  try {
48
185
  if (provider === 'intervals') {
49
186
  const environment = await athleteProviderEnvironment(paths);
50
187
  const config = requireIntervalsConfig(environment);
51
188
  extractionStarted = true;
52
- await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
189
+ await runWithInterruptGuard(runId, database, () => undefined, guard, async () => {
190
+ await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId, log).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
191
+ });
53
192
  }
54
193
  else {
55
194
  const output = path.join(paths.staging, 'garmin', `${runId}.jsonl`);
@@ -71,22 +210,43 @@ async function syncProvider(database, paths, provider, options) {
71
210
  arguments_.push('--skip-activities');
72
211
  if (options.backfill && !options.refreshActivities)
73
212
  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') },
213
+ let interruptChild = null;
214
+ await runWithInterruptGuard(runId, database, () => interruptChild?.(), guard, async () => {
215
+ const outcome = await runGarminWorker(runId, arguments_, {
216
+ cwd: packageRoot(),
217
+ env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
218
+ }, database, log, (kill) => { interruptChild = kill; });
219
+ if (!guard.interrupted && outcome.code !== 0) {
220
+ throw new Error(`Garmin staging worker exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
221
+ }
77
222
  });
78
- if (!existsSync(output))
79
- throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
80
- await importJsonl(database, runId, output);
223
+ if (!guard.interrupted) {
224
+ if (!existsSync(output))
225
+ throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
226
+ await importJsonl(database, runId, output, log);
227
+ }
81
228
  }
82
229
  }
83
230
  catch (error) {
84
- await database.addError(runId, provider, 'sync', null, error instanceof Error ? error.message : String(error), true);
231
+ if (!guard.interrupted) {
232
+ const message = error instanceof Error ? error.message : String(error);
233
+ log.error('Sync run failed', { runId, provider, error: message, stack: error instanceof Error ? error.stack : undefined });
234
+ await database.addError(runId, provider, 'sync', null, message, true);
235
+ await database.heartbeatRun(runId, baseProgress(runId, provider, 'failed')).catch(() => undefined);
236
+ }
85
237
  }
86
238
  finally {
87
- await database.finishRun(runId);
239
+ if (guard.interrupted) {
240
+ await database.markRunInterrupted(runId);
241
+ log.warn('Sync run interrupted', { runId, provider });
242
+ }
243
+ else {
244
+ await database.finishRun(runId);
245
+ }
246
+ const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
247
+ log.info('Sync run finished', { runId, provider, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
88
248
  }
89
- if (options.advanceCursor && !options.explicitFrom && extractionStarted) {
249
+ if (!guard.interrupted && options.advanceCursor && !options.explicitFrom && extractionStarted) {
90
250
  await database.advanceIncrementalCursor(provider, 'daily', runId, options.toDate, 3);
91
251
  await database.advanceIncrementalCursor(provider, 'activities', runId, options.toDate, 14);
92
252
  }
@@ -109,13 +269,18 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
109
269
  if (provider === 'strava')
110
270
  return { provider, result: await syncStravaGear(paths) };
111
271
  const database = await CatenceDatabase.open(paths);
272
+ const log = createSyncLogger(paths);
112
273
  try {
274
+ const timedOutRuns = await database.interruptStaleSyncRuns(STALE_RUN_TIMEOUT_MS);
275
+ if (timedOutRuns.runIds.length > 0) {
276
+ log.warn('Marked stale sync runs as timed out', { runIds: timedOutRuns.runIds, timeoutMs: STALE_RUN_TIMEOUT_MS });
277
+ }
113
278
  const providers = provider === 'all' ? ['intervals', 'garmin'] : [provider];
114
279
  const toDate = new Date().toISOString().slice(0, 10);
115
280
  const runs = [];
116
281
  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 };
282
+ runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }, log));
283
+ return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs, timedOutRuns: timedOutRuns.runIds };
119
284
  }
120
285
  finally {
121
286
  await database.close();
@@ -125,12 +290,14 @@ export async function retryDataSync(paths, previousRunId) {
125
290
  await ensurePaths(paths);
126
291
  const database = await CatenceDatabase.open(paths);
127
292
  try {
293
+ await database.interruptStaleSyncRuns(STALE_RUN_TIMEOUT_MS);
128
294
  const previous = await database.getRun(previousRunId);
129
295
  if (!previous)
130
296
  throw new Error(`No sync run found for ${previousRunId}.`);
131
297
  if (previous.provider === 'strava')
132
298
  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 });
299
+ const log = createSyncLogger(paths);
300
+ 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
301
  return { retriedRun: previousRunId, runId: run.runId, run };
135
302
  }
136
303
  finally {
@@ -271,6 +438,16 @@ export async function dataStatus(paths) {
271
438
  await database.close();
272
439
  }
273
440
  }
441
+ export async function syncProgress(paths) {
442
+ await ensurePaths(paths);
443
+ const database = await CatenceDatabase.open(paths);
444
+ try {
445
+ return await database.syncProgress();
446
+ }
447
+ finally {
448
+ await database.close();
449
+ }
450
+ }
274
451
  export async function rebuildRetrievalIndex(paths) {
275
452
  if (!existsSync(paths.database))
276
453
  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>;