catence 0.1.0 → 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 (51) hide show
  1. package/README.md +239 -269
  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.d.ts +41 -0
  14. package/dist/core/runtime/catalog.js +121 -0
  15. package/dist/core/runtime/configuration.d.ts +1 -1
  16. package/dist/core/runtime/configuration.js +3 -3
  17. package/dist/core/runtime/secrets.d.ts +24 -0
  18. package/dist/core/runtime/secrets.js +68 -0
  19. package/dist/core/runtime/self-update.d.ts +44 -0
  20. package/dist/core/runtime/self-update.js +138 -0
  21. package/dist/elt/application/management.d.ts +2 -0
  22. package/dist/elt/application/management.js +197 -18
  23. package/dist/elt/ingestion/importer.d.ts +3 -2
  24. package/dist/elt/ingestion/importer.js +4 -3
  25. package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
  26. package/dist/elt/ingestion/providers/intervals.js +4 -1
  27. package/dist/elt/ingestion/providers/strava/service.js +9 -2
  28. package/dist/elt/storage/database.d.ts +7 -2
  29. package/dist/elt/storage/database.js +81 -4
  30. package/dist/elt/storage/migrations.js +25 -1
  31. package/dist/interfaces/cli/commands.js +201 -46
  32. package/dist/interfaces/http/cli-options.d.ts +2 -2
  33. package/dist/interfaces/http/cli-options.js +10 -10
  34. package/dist/interfaces/http/server.d.ts +4 -1
  35. package/dist/interfaces/http/server.js +31 -4
  36. package/dist/interfaces/mcp/cli-options.d.ts +2 -2
  37. package/dist/interfaces/mcp/cli-options.js +14 -14
  38. package/dist/interfaces/mcp/main.js +12 -5
  39. package/dist/interfaces/mcp/server.d.ts +2 -2
  40. package/dist/interfaces/mcp/server.js +156 -98
  41. package/dist/runtime/index.d.ts +8 -1
  42. package/dist/runtime/index.js +4 -1
  43. package/docs/deployment.md +338 -0
  44. package/docs/docker-deployment.md +232 -0
  45. package/docs/llm-providers.md +467 -0
  46. package/package.json +6 -2
  47. package/python/catence/providers/garmin/cli.py +30 -9
  48. package/python/catence/providers/garmin/progress.py +85 -0
  49. package/python/catence/providers/garmin/worker.py +132 -5
  50. package/scripts/discover-opencode-go.mjs +210 -0
  51. package/scripts/discover-opencode-zen.mjs +218 -0
@@ -0,0 +1,44 @@
1
+ export type UpdateChannel = 'stable' | 'beta';
2
+ export type ParsedRelease = {
3
+ major: number;
4
+ minor: number;
5
+ patch: number;
6
+ /** Present only for prerelease builds; a stable release beats every beta of the same tuple. */
7
+ beta?: number;
8
+ };
9
+ export type SelfUpdatePlan = {
10
+ channel: UpdateChannel;
11
+ runtime: {
12
+ current: string;
13
+ target?: string;
14
+ updateAvailable: boolean;
15
+ };
16
+ console: {
17
+ installed?: string;
18
+ target?: string;
19
+ updateAvailable: boolean;
20
+ };
21
+ /** True when neither component has anything to apply. */
22
+ ok: boolean;
23
+ };
24
+ export declare function parseReleaseVersion(version: string): ParsedRelease | null;
25
+ export declare function compareReleaseVersions(left: ParsedRelease, right: ParsedRelease): -1 | 0 | 1;
26
+ export declare function channelForVersion(version: string): UpdateChannel;
27
+ export declare function latestRelease(versions: string[], channel: UpdateChannel): string | undefined;
28
+ export declare function planSelfUpdate(options: {
29
+ runtimeVersion: string;
30
+ channel: UpdateChannel;
31
+ npmDistTags: Record<string, string>;
32
+ pypiReleases: string[];
33
+ consoleInstalled?: string;
34
+ }): SelfUpdatePlan;
35
+ export declare function fetchNpmDistTags(packageName: string, fetchImpl?: typeof fetch): Promise<Record<string, string>>;
36
+ export declare function fetchPypiReleases(packageName: string, fetchImpl?: typeof fetch): Promise<string[]>;
37
+ /** Run an update command (npm or uv) with the user's terminal attached. */
38
+ export declare function runSelfUpdateCommand(command: string, args: string[]): Promise<number>;
39
+ export declare function hasCommand(command: string): Promise<boolean>;
40
+ /** Read `catence-console --version` from PATH; undefined when the Console is not installed. */
41
+ export declare function readInstalledConsoleVersion(): Promise<string | undefined>;
42
+ /** Global npm package root, used to decide whether `catence` can update itself in place. */
43
+ export declare function npmGlobalPrefix(): Promise<string | undefined>;
44
+ export declare function isGlobalNpmInstall(moduleUrl: string, npmPrefix: string | undefined): boolean;
@@ -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,24 +1,154 @@
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';
12
+ import { athleteProviderEnvironment } from '../../core/runtime/secrets.js';
11
13
  import { importJsonl } from '../ingestion/importer.js';
12
14
  import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
13
15
  import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUrl, syncStravaGear } from '../ingestion/providers/strava/service.js';
14
16
  import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
15
17
  import { CatenceDatabase } from '../storage/database.js';
16
18
  import { withDataWriteLock } from '../storage/write-lock.js';
17
- const execFileAsync = promisify(execFile);
18
19
  function packageRoot() {
19
20
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
20
21
  }
21
- 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) {
22
152
  const explicitWindow = options.explicitFrom ? { fromDate: options.explicitFrom, toDate: options.toDate, source: 'explicit' } : null;
23
153
  const uncoveredExplicitWindow = async (cursorName) => {
24
154
  if (!explicitWindow)
@@ -32,6 +162,7 @@ async function syncProvider(database, paths, provider, options) {
32
162
  const activityWindow = explicitWindow ? await uncoveredExplicitWindow('activities') : await database.resolveIncrementalWindow(provider, 'activities', options.toDate, defaultFromDate(), 14);
33
163
  const hasProviderWork = provider === 'intervals' ? activityWindow !== null : dailyWindow !== null || activityWindow !== null;
34
164
  if (!hasProviderWork) {
165
+ log.debug('No uncovered work; skipping provider', { provider, daily: dailyWindow ?? null, activities: activityWindow ?? null });
35
166
  return {
36
167
  runId: null, provider, fromDate: null,
37
168
  dailyFromDate: dailyWindow?.fromDate ?? null, dailyToDate: dailyWindow?.toDate ?? null,
@@ -42,12 +173,22 @@ async function syncProvider(database, paths, provider, options) {
42
173
  }
43
174
  const fromDate = [dailyWindow?.fromDate, activityWindow?.fromDate].filter((date) => Boolean(date)).sort()[0];
44
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
+ });
45
182
  let extractionStarted = false;
183
+ const guard = { interrupted: false };
46
184
  try {
47
185
  if (provider === 'intervals') {
48
- const config = requireIntervalsConfig();
186
+ const environment = await athleteProviderEnvironment(paths);
187
+ const config = requireIntervalsConfig(environment);
49
188
  extractionStarted = true;
50
- 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
+ });
51
192
  }
52
193
  else {
53
194
  const output = path.join(paths.staging, 'garmin', `${runId}.jsonl`);
@@ -69,22 +210,43 @@ async function syncProvider(database, paths, provider, options) {
69
210
  arguments_.push('--skip-activities');
70
211
  if (options.backfill && !options.refreshActivities)
71
212
  arguments_.push('--historical-only');
72
- await execFileAsync('uv', arguments_, {
73
- cwd: packageRoot(),
74
- env: { ...process.env, 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
+ }
75
222
  });
76
- if (!existsSync(output))
77
- throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
78
- 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
+ }
79
228
  }
80
229
  }
81
230
  catch (error) {
82
- 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
+ }
83
237
  }
84
238
  finally {
85
- 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 });
86
248
  }
87
- if (options.advanceCursor && !options.explicitFrom && extractionStarted) {
249
+ if (!guard.interrupted && options.advanceCursor && !options.explicitFrom && extractionStarted) {
88
250
  await database.advanceIncrementalCursor(provider, 'daily', runId, options.toDate, 3);
89
251
  await database.advanceIncrementalCursor(provider, 'activities', runId, options.toDate, 14);
90
252
  }
@@ -107,13 +269,18 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
107
269
  if (provider === 'strava')
108
270
  return { provider, result: await syncStravaGear(paths) };
109
271
  const database = await CatenceDatabase.open(paths);
272
+ const log = createSyncLogger(paths);
110
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
+ }
111
278
  const providers = provider === 'all' ? ['intervals', 'garmin'] : [provider];
112
279
  const toDate = new Date().toISOString().slice(0, 10);
113
280
  const runs = [];
114
281
  for (const source of providers)
115
- runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }));
116
- 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 };
117
284
  }
118
285
  finally {
119
286
  await database.close();
@@ -123,12 +290,14 @@ export async function retryDataSync(paths, previousRunId) {
123
290
  await ensurePaths(paths);
124
291
  const database = await CatenceDatabase.open(paths);
125
292
  try {
293
+ await database.interruptStaleSyncRuns(STALE_RUN_TIMEOUT_MS);
126
294
  const previous = await database.getRun(previousRunId);
127
295
  if (!previous)
128
296
  throw new Error(`No sync run found for ${previousRunId}.`);
129
297
  if (previous.provider === 'strava')
130
298
  throw new Error('Retry Strava gear sync with `catence-data sync --provider strava`.');
131
- 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);
132
301
  return { retriedRun: previousRunId, runId: run.runId, run };
133
302
  }
134
303
  finally {
@@ -269,6 +438,16 @@ export async function dataStatus(paths) {
269
438
  await database.close();
270
439
  }
271
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
+ }
272
451
  export async function rebuildRetrievalIndex(paths) {
273
452
  if (!existsSync(paths.database))
274
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
  }
@@ -5,8 +5,10 @@ import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { ensurePaths, loadCatenceConfig } from '../../../../core/runtime/configuration.js';
8
+ import { athleteProviderEnvironment } from '../../../../core/runtime/secrets.js';
8
9
  import { CatenceDatabase, openReadOnlyRepository } from '../../../storage/database.js';
9
10
  import { importJsonl } from '../../importer.js';
11
+ import { createSyncLogger } from '../../../../core/logging.js';
10
12
  import { withDataWriteLock } from '../../../storage/write-lock.js';
11
13
  const execFileAsync = promisify(execFile);
12
14
  export class StravaRateLimitError extends Error {
@@ -50,7 +52,7 @@ async function invokeWorker(paths, args, resultPath) {
50
52
  try {
51
53
  await execFileAsync('uv', ['run', 'python', '-m', 'python.catence.providers.strava.cli', ...args, '--data-dir', paths.root, '--result', resultPath], {
52
54
  cwd: packageRoot(),
53
- env: { ...process.env, UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
55
+ env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
54
56
  });
55
57
  }
56
58
  catch (error) {
@@ -107,6 +109,7 @@ async function cachedState(database, resourceType, remoteId) {
107
109
  async function runReadOperation(paths, operation, extraArgs, resource, refresh = false) {
108
110
  return withDataWriteLock(paths, async () => {
109
111
  await ensurePaths(paths);
112
+ const log = createSyncLogger(paths);
110
113
  const database = await CatenceDatabase.open(paths);
111
114
  try {
112
115
  if (!refresh) {
@@ -116,6 +119,7 @@ async function runReadOperation(paths, operation, extraArgs, resource, refresh =
116
119
  }
117
120
  await checkBudget(database, paths);
118
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 });
119
123
  const directory = path.join(paths.staging, 'strava');
120
124
  const stagingPath = path.join(directory, `${runId}.jsonl`);
121
125
  const resultPath = path.join(directory, `${runId}.result.json`);
@@ -123,17 +127,20 @@ async function runReadOperation(paths, operation, extraArgs, resource, refresh =
123
127
  try {
124
128
  result = await invokeWorker(paths, ['--mode', operation, '--run-id', runId, '--output', stagingPath, ...extraArgs], resultPath);
125
129
  if (existsSync(stagingPath))
126
- await importJsonl(database, runId, stagingPath);
130
+ await importJsonl(database, runId, stagingPath, log);
127
131
  await storeRateState(database, result);
128
132
  await writeState(database, resource.type, resource.id, result);
129
133
  }
130
134
  catch (error) {
131
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 });
132
137
  await database.addError(runId, 'strava', operation, resource.id, message, true);
133
138
  throw error;
134
139
  }
135
140
  finally {
136
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 });
137
144
  }
138
145
  if (result.status === 'error')
139
146
  throw new StravaEnrichmentError(result.message ?? 'Strava did not complete the requested enrichment.');