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.
- package/README.md +23 -77
- package/config.example.json +32 -20
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/progress.d.ts +23 -0
- package/dist/contracts/progress.js +48 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/logging.d.ts +16 -0
- package/dist/core/logging.js +68 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +46 -0
- package/dist/core/runtime/catalog.js +17 -7
- package/dist/core/runtime/self-update.d.ts +44 -0
- package/dist/core/runtime/self-update.js +138 -0
- package/dist/elt/application/management.d.ts +2 -0
- package/dist/elt/application/management.js +220 -18
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +4 -3
- package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
- package/dist/elt/ingestion/providers/intervals.js +4 -1
- package/dist/elt/ingestion/providers/strava/service.js +7 -1
- package/dist/elt/storage/database.d.ts +7 -2
- package/dist/elt/storage/database.js +81 -4
- package/dist/elt/storage/migrations.js +25 -1
- package/dist/elt/storage/progress-sidecar.d.ts +26 -0
- package/dist/elt/storage/progress-sidecar.js +87 -0
- package/dist/interfaces/cli/commands.js +91 -1
- package/dist/interfaces/mcp/server.js +7 -0
- package/dist/runtime/index.d.ts +4 -1
- package/dist/runtime/index.js +2 -1
- package/docs/architecture.md +11 -1
- package/docs/configuration.md +221 -0
- package/docs/deployment/docker.md +528 -0
- package/docs/deployment/local-console.md +230 -0
- package/docs/deployment/local-mcp.md +304 -0
- package/docs/distribution.md +58 -0
- package/docs/llm-providers.md +467 -0
- package/docs/mcp-activity-retrieval.md +37 -30
- package/package.json +5 -1
- package/python/catence/providers/garmin/cli.py +30 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/worker.py +132 -5
- package/scripts/discover-opencode-go.mjs +210 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 (!
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4
|
-
export declare function
|
|
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>;
|