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.
- package/README.md +239 -269
- 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 +42 -0
- package/dist/core/runtime/catalog.d.ts +41 -0
- package/dist/core/runtime/catalog.js +121 -0
- package/dist/core/runtime/configuration.d.ts +1 -1
- package/dist/core/runtime/configuration.js +3 -3
- package/dist/core/runtime/secrets.d.ts +24 -0
- package/dist/core/runtime/secrets.js +68 -0
- 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 +197 -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 +9 -2
- 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/interfaces/cli/commands.js +201 -46
- package/dist/interfaces/http/cli-options.d.ts +2 -2
- package/dist/interfaces/http/cli-options.js +10 -10
- package/dist/interfaces/http/server.d.ts +4 -1
- package/dist/interfaces/http/server.js +31 -4
- package/dist/interfaces/mcp/cli-options.d.ts +2 -2
- package/dist/interfaces/mcp/cli-options.js +14 -14
- package/dist/interfaces/mcp/main.js +12 -5
- package/dist/interfaces/mcp/server.d.ts +2 -2
- package/dist/interfaces/mcp/server.js +156 -98
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +4 -1
- package/docs/deployment.md +338 -0
- package/docs/docker-deployment.md +232 -0
- package/docs/llm-providers.md +467 -0
- package/package.json +6 -2
- 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,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 {
|
|
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
|
-
|
|
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
|
|
186
|
+
const environment = await athleteProviderEnvironment(paths);
|
|
187
|
+
const config = requireIntervalsConfig(environment);
|
|
49
188
|
extractionStarted = true;
|
|
50
|
-
await
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 (!
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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: { ...
|
|
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.');
|