catence 0.2.0-beta.2 → 0.2.0-beta.21
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 +28 -82
- package/config.example.json +25 -0
- 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 +49 -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 +48 -0
- package/dist/core/runtime/catalog.js +17 -7
- package/dist/core/runtime/configuration.d.ts +3 -1
- package/dist/core/runtime/configuration.js +5 -1
- package/dist/core/runtime/opencode-go.d.ts +58 -0
- package/dist/core/runtime/opencode-go.js +246 -0
- package/dist/core/runtime/secrets.js +51 -13
- package/dist/core/runtime/self-update.d.ts +44 -0
- package/dist/core/runtime/self-update.js +138 -0
- package/dist/elt/application/detached-sync.d.ts +50 -0
- package/dist/elt/application/detached-sync.js +99 -0
- package/dist/elt/application/management.d.ts +15 -0
- package/dist/elt/application/management.js +435 -19
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +5 -4
- package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
- package/dist/elt/ingestion/providers/intervals.js +37 -2
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +18 -1
- package/dist/elt/ingestion/providers/strava/service.js +7 -1
- package/dist/elt/normalization/normalizers.js +111 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +184 -6
- package/dist/elt/storage/migrations.js +25 -1
- package/dist/elt/storage/progress-sidecar.d.ts +61 -0
- package/dist/elt/storage/progress-sidecar.js +200 -0
- package/dist/interfaces/cli/commands.js +116 -1
- package/dist/interfaces/http/generation-status.d.ts +19 -0
- package/dist/interfaces/http/generation-status.js +55 -0
- package/dist/interfaces/http/server.d.ts +7 -1
- package/dist/interfaces/http/server.js +184 -1
- package/dist/interfaces/mcp/server.d.ts +2 -1
- package/dist/interfaces/mcp/server.js +50 -13
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +4 -1
- package/docs/architecture.md +11 -1
- package/docs/configuration.md +243 -0
- package/docs/deployment/docker.md +528 -0
- package/docs/deployment/local-console.md +263 -0
- package/docs/deployment/local-mcp.md +329 -0
- package/docs/development.md +337 -0
- package/docs/distribution.md +58 -0
- package/docs/llm-providers.md +249 -6
- package/docs/mcp-activity-retrieval.md +37 -30
- package/package.json +5 -1
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +43 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/registry.py +1 -0
- package/python/catence/providers/garmin/streams.py +38 -0
- package/python/catence/providers/garmin/swim_backfill.py +83 -0
- package/python/catence/providers/garmin/worker.py +372 -11
- package/scripts/discover-opencode-go.mjs +102 -0
- package/scripts/discover-opencode-zen.mjs +218 -0
- package/uv.lock +3 -3
|
@@ -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,19 @@ 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>>;
|
|
27
|
+
export declare function importStagedFile(paths: CatencePaths, provider: ProviderChoice, filePath: string): Promise<Record<string, unknown>>;
|
|
28
|
+
export declare function resolveExtractionErrors(paths: CatencePaths, opts?: {
|
|
29
|
+
runIds?: string[];
|
|
30
|
+
provider?: string;
|
|
31
|
+
before?: string;
|
|
32
|
+
all?: boolean;
|
|
33
|
+
}): Promise<Record<string, unknown>>;
|
|
34
|
+
/**
|
|
35
|
+
* Re-run normalization over already-captured nutrition entities without
|
|
36
|
+
* contacting any provider. Idempotent upserts let a fixed normalizer backfill
|
|
37
|
+
* previously mis-parsed nutrition_days/nutrition_items from stored payloads.
|
|
38
|
+
*/
|
|
39
|
+
export declare function reimportNutrition(paths: CatencePaths): Promise<Record<string, unknown>>;
|
|
25
40
|
export {};
|
|
@@ -1,25 +1,262 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { createReadStream } from 'node:fs';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
import { EVENT_WINDOW_PAST_DAYS } from '../ingestion/providers/registry.js';
|
|
5
8
|
import { createServer } from 'node:http';
|
|
6
9
|
import path from 'node:path';
|
|
7
|
-
import { promisify } from 'node:util';
|
|
8
10
|
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { normalizeProgress, STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
|
|
9
12
|
import { buildRetrievalIndex } from '../../core/retrieval/index.js';
|
|
10
13
|
import { defaultFromDate, ensurePaths, requireIntervalsConfig } from '../../core/runtime/configuration.js';
|
|
14
|
+
import { createSyncLogger } from '../../core/logging.js';
|
|
11
15
|
import { athleteProviderEnvironment } from '../../core/runtime/secrets.js';
|
|
12
16
|
import { importJsonl } from '../ingestion/importer.js';
|
|
13
17
|
import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
|
|
14
18
|
import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUrl, syncStravaGear } from '../ingestion/providers/strava/service.js';
|
|
15
19
|
import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
|
|
16
|
-
import {
|
|
20
|
+
import { importSourceEntity } from '../normalization/normalizers.js';
|
|
21
|
+
import { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../storage/database.js';
|
|
22
|
+
import { mergeProgress, ProgressPump, readRunningProgress, removeProgressSidecar } from '../storage/progress-sidecar.js';
|
|
17
23
|
import { withDataWriteLock } from '../storage/write-lock.js';
|
|
18
|
-
const execFileAsync = promisify(execFile);
|
|
19
24
|
function packageRoot() {
|
|
20
25
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
21
26
|
}
|
|
22
|
-
|
|
27
|
+
// Mirrors python/catence/providers/garmin/worker.py fetch windows: the events
|
|
28
|
+
// listing looks back a year and scheduled workouts start three months ago, so
|
|
29
|
+
// only rows inside those windows are authoritative for deletion sweeps.
|
|
30
|
+
const GARMIN_EVENTS_LOOKBACK_DAYS = 365;
|
|
31
|
+
const GARMIN_SCHEDULED_MONTHS_BACK = 3;
|
|
32
|
+
/**
|
|
33
|
+
* Delete calendar entities that the latest Garmin listing no longer contains.
|
|
34
|
+
* The staging manifest is the authority on which ids were just fetched; any
|
|
35
|
+
* in-window row of the same type missing from it was deleted (or un-enrolled)
|
|
36
|
+
* upstream and would otherwise linger as a ghost.
|
|
37
|
+
*/
|
|
38
|
+
async function reconcileGarminCalendarEntities(database, stagedManifestPath) {
|
|
39
|
+
const events = [];
|
|
40
|
+
const scheduled = [];
|
|
41
|
+
const file = createReadStream(stagedManifestPath, { encoding: 'utf8' });
|
|
42
|
+
const lines = createInterface({ input: file, crlfDelay: Infinity });
|
|
43
|
+
for await (const line of lines) {
|
|
44
|
+
if (!line.trim())
|
|
45
|
+
continue;
|
|
46
|
+
let record;
|
|
47
|
+
try {
|
|
48
|
+
record = JSON.parse(line);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
continue; // The importer owns malformed-line reporting.
|
|
52
|
+
}
|
|
53
|
+
const candidate = record;
|
|
54
|
+
if (candidate?.kind !== 'source_entity' || candidate.provider !== 'garmin')
|
|
55
|
+
continue;
|
|
56
|
+
if (candidate.entityType === 'event' && typeof candidate.remoteId === 'string')
|
|
57
|
+
events.push(candidate.remoteId);
|
|
58
|
+
if (candidate.entityType === 'scheduled_workout' && typeof candidate.remoteId === 'string')
|
|
59
|
+
scheduled.push(candidate.remoteId);
|
|
60
|
+
}
|
|
61
|
+
const now = new Date();
|
|
62
|
+
const eventCutoff = new Date(now.getTime() - GARMIN_EVENTS_LOOKBACK_DAYS * 86_400_000).toISOString().slice(0, 10);
|
|
63
|
+
const scheduledCutoff = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - GARMIN_SCHEDULED_MONTHS_BACK, 1)).toISOString().slice(0, 10);
|
|
64
|
+
await database.deleteMissingCalendarEntities('garmin', ['event'], events, eventCutoff);
|
|
65
|
+
await database.deleteMissingCalendarEntities('garmin', ['scheduled_workout'], scheduled, scheduledCutoff);
|
|
66
|
+
}
|
|
67
|
+
function baseProgress(runId, provider, stage) {
|
|
68
|
+
return {
|
|
69
|
+
runId, provider, stage, currentStep: null,
|
|
70
|
+
completedUnits: 0, totalUnits: null, percentComplete: 0, elapsedSeconds: 0,
|
|
71
|
+
estimatedRemainingSeconds: null, heartbeatAt: new Date().toISOString(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Run the standalone swim-length backfill without progress streaming.
|
|
76
|
+
* The backfill reads every archived Garmin FIT (already on disk) and emits
|
|
77
|
+
* source_entity rows for any swim that actually contains length messages;
|
|
78
|
+
* importing them is idempotent, so re-parsing already-imported swims is
|
|
79
|
+
* harmless.
|
|
80
|
+
*/
|
|
81
|
+
function runSwimBackfillProcess(arguments_, environment, log) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
const child = spawn('uv', arguments_, { ...environment, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
84
|
+
const stdout = [];
|
|
85
|
+
const stderr = [];
|
|
86
|
+
child.stdout.on('data', (chunk) => stdout.push(chunk.toString('utf8')));
|
|
87
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk.toString('utf8')));
|
|
88
|
+
child.on('error', (error) => {
|
|
89
|
+
log.error('Garmin swim-backfill process could not be started', { error: error.message });
|
|
90
|
+
resolve({ code: 1, signal: null });
|
|
91
|
+
});
|
|
92
|
+
child.on('close', (code, signal) => {
|
|
93
|
+
const out = stdout.join('').trim();
|
|
94
|
+
const err = stderr.join('').trim();
|
|
95
|
+
if (out)
|
|
96
|
+
log.info(`[garmin swim_backfill] ${out}`);
|
|
97
|
+
if (err)
|
|
98
|
+
log.error(`[garmin swim_backfill] ${err}`);
|
|
99
|
+
resolve({ code, signal });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async function backfillGarminSwimLengths(database, paths, parentRunId, log) {
|
|
104
|
+
const output = path.join(paths.staging, 'garmin', `${parentRunId}.swim_lengths.jsonl`);
|
|
105
|
+
await mkdir(path.dirname(output), { recursive: true });
|
|
106
|
+
// Fresh file for deterministic counting; a stale file would make
|
|
107
|
+
// line counting ambiguous.
|
|
108
|
+
try {
|
|
109
|
+
const { rm } = await import('node:fs/promises');
|
|
110
|
+
await rm(output, { force: true });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Best-effort cleanup.
|
|
114
|
+
}
|
|
115
|
+
// The swim list is queried here and handed to the Python worker as a file:
|
|
116
|
+
// the child must never open DuckDB itself because the parent holds the
|
|
117
|
+
// write lock for the whole run.
|
|
118
|
+
const activitiesFile = path.join(paths.staging, 'garmin', `${parentRunId}.swim_activities.json`);
|
|
119
|
+
await writeFile(activitiesFile, JSON.stringify(await database.garminSwimActivitySources()));
|
|
120
|
+
const arguments_ = [
|
|
121
|
+
'run',
|
|
122
|
+
'python',
|
|
123
|
+
'-m',
|
|
124
|
+
'python.catence.providers.garmin.swim_backfill',
|
|
125
|
+
'--data-dir',
|
|
126
|
+
paths.root,
|
|
127
|
+
'--output',
|
|
128
|
+
output,
|
|
129
|
+
'--activities',
|
|
130
|
+
activitiesFile,
|
|
131
|
+
];
|
|
132
|
+
const environment = {
|
|
133
|
+
cwd: packageRoot(),
|
|
134
|
+
env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
|
|
135
|
+
};
|
|
136
|
+
const outcome = await runSwimBackfillProcess(arguments_, environment, log);
|
|
137
|
+
if (outcome.code !== 0) {
|
|
138
|
+
throw new Error(`Garmin swim_backfill exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
|
|
139
|
+
}
|
|
140
|
+
if (!existsSync(output)) {
|
|
141
|
+
log.info('Garmin swim backfill produced no output file', { parentRunId });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const content = await readFile(output, 'utf8');
|
|
145
|
+
const lines = content.split('\n').filter((line) => line.trim().length > 0);
|
|
146
|
+
if (lines.length === 0) {
|
|
147
|
+
log.info('Garmin swim backfill produced no staged lengths', { parentRunId });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const backfillRunId = randomUUID();
|
|
151
|
+
const imported = await importJsonl(database, backfillRunId, output, log);
|
|
152
|
+
log.info('Imported swim lengths from automatic backfill', { parentRunId, backfillRunId, staged: lines.length, imported });
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Run the Garmin staging worker with streamed stdout/stderr so progress and
|
|
156
|
+
* errors surface in real time instead of buffering until the child exits.
|
|
157
|
+
* Progress NDJSON lines (kind 'progress') are normalized and persisted as
|
|
158
|
+
* throttled heartbeats; other output is forwarded to the sync logger.
|
|
159
|
+
*/
|
|
160
|
+
function runGarminWorker(runId, arguments_, environment, pump, log, onInterruptReady) {
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
const child = spawn('uv', arguments_, { ...environment, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
163
|
+
let killTimer;
|
|
164
|
+
onInterruptReady(() => {
|
|
165
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
166
|
+
return;
|
|
167
|
+
child.kill('SIGTERM');
|
|
168
|
+
killTimer = setTimeout(() => {
|
|
169
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
170
|
+
return;
|
|
171
|
+
child.kill('SIGKILL');
|
|
172
|
+
}, 10_000);
|
|
173
|
+
killTimer.unref();
|
|
174
|
+
});
|
|
175
|
+
const buffer = (stream, sink) => {
|
|
176
|
+
let pending = '';
|
|
177
|
+
stream.on('data', (chunk) => {
|
|
178
|
+
pending += chunk.toString('utf8');
|
|
179
|
+
let newline;
|
|
180
|
+
while ((newline = pending.indexOf('\n')) >= 0) {
|
|
181
|
+
const line = pending.slice(0, newline).trim();
|
|
182
|
+
pending = pending.slice(newline + 1);
|
|
183
|
+
if (line)
|
|
184
|
+
sink(line);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
stream.on('end', () => {
|
|
188
|
+
const line = pending.trim();
|
|
189
|
+
if (line)
|
|
190
|
+
sink(line);
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
buffer(child.stdout, (line) => {
|
|
194
|
+
let parsed;
|
|
195
|
+
try {
|
|
196
|
+
parsed = JSON.parse(line);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
log.info(`[garmin] ${line}`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (typeof parsed === 'object' && parsed !== null && parsed.kind === 'progress') {
|
|
203
|
+
const state = normalizeProgress(parsed);
|
|
204
|
+
if (state)
|
|
205
|
+
pump.publish(state);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
log.info(`[garmin] ${line}`);
|
|
209
|
+
});
|
|
210
|
+
buffer(child.stderr, (line) => log.error(`[garmin] ${line}`));
|
|
211
|
+
child.on('error', (error) => {
|
|
212
|
+
if (killTimer)
|
|
213
|
+
clearTimeout(killTimer);
|
|
214
|
+
resolve({ code: 1, signal: null });
|
|
215
|
+
log.error('Garmin staging worker could not be started', { runId, error: error.message });
|
|
216
|
+
});
|
|
217
|
+
child.on('close', (code, signal) => {
|
|
218
|
+
if (killTimer)
|
|
219
|
+
clearTimeout(killTimer);
|
|
220
|
+
if (code === 0)
|
|
221
|
+
pump.setStageImporting();
|
|
222
|
+
resolve({ code, signal });
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Trap termination signals for the duration of a sync run so an interrupted
|
|
228
|
+
* run is marked 'interrupted' in sync_runs instead of being left as a zombie
|
|
229
|
+
* 'running' row. The process exit code mirrors the received signal.
|
|
230
|
+
*/ async function runWithInterruptGuard(runId, database, onInterrupt, state, work) {
|
|
231
|
+
let exitCode = 0;
|
|
232
|
+
const handler = (signal) => {
|
|
233
|
+
if (state.interrupted)
|
|
234
|
+
return;
|
|
235
|
+
state.interrupted = true;
|
|
236
|
+
exitCode = signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 129;
|
|
237
|
+
void database.markRunInterrupted(runId).catch(() => undefined);
|
|
238
|
+
try {
|
|
239
|
+
onInterrupt();
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// The child may not exist yet; the run is still marked interrupted.
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
process.once('SIGINT', handler);
|
|
246
|
+
process.once('SIGTERM', handler);
|
|
247
|
+
process.once('SIGHUP', handler);
|
|
248
|
+
try {
|
|
249
|
+
await work();
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
process.removeListener('SIGINT', handler);
|
|
253
|
+
process.removeListener('SIGTERM', handler);
|
|
254
|
+
process.removeListener('SIGHUP', handler);
|
|
255
|
+
if (state.interrupted)
|
|
256
|
+
process.exitCode = exitCode;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async function syncProvider(database, paths, provider, options, log) {
|
|
23
260
|
const explicitWindow = options.explicitFrom ? { fromDate: options.explicitFrom, toDate: options.toDate, source: 'explicit' } : null;
|
|
24
261
|
const uncoveredExplicitWindow = async (cursorName) => {
|
|
25
262
|
if (!explicitWindow)
|
|
@@ -33,6 +270,7 @@ async function syncProvider(database, paths, provider, options) {
|
|
|
33
270
|
const activityWindow = explicitWindow ? await uncoveredExplicitWindow('activities') : await database.resolveIncrementalWindow(provider, 'activities', options.toDate, defaultFromDate(), 14);
|
|
34
271
|
const hasProviderWork = provider === 'intervals' ? activityWindow !== null : dailyWindow !== null || activityWindow !== null;
|
|
35
272
|
if (!hasProviderWork) {
|
|
273
|
+
log.debug('No uncovered work; skipping provider', { provider, daily: dailyWindow ?? null, activities: activityWindow ?? null });
|
|
36
274
|
return {
|
|
37
275
|
runId: null, provider, fromDate: null,
|
|
38
276
|
dailyFromDate: dailyWindow?.fromDate ?? null, dailyToDate: dailyWindow?.toDate ?? null,
|
|
@@ -43,13 +281,25 @@ async function syncProvider(database, paths, provider, options) {
|
|
|
43
281
|
}
|
|
44
282
|
const fromDate = [dailyWindow?.fromDate, activityWindow?.fromDate].filter((date) => Boolean(date)).sort()[0];
|
|
45
283
|
const runId = await database.beginRun(provider, fromDate);
|
|
284
|
+
log.info('Starting sync run', {
|
|
285
|
+
runId, provider, fromDate, toDate: options.toDate,
|
|
286
|
+
dailyFrom: dailyWindow?.fromDate ?? null, dailyTo: dailyWindow?.toDate ?? null,
|
|
287
|
+
activityFrom: activityWindow?.fromDate ?? null, activityTo: activityWindow?.toDate ?? null,
|
|
288
|
+
backfill: options.backfill, refreshActivities: options.refreshActivities,
|
|
289
|
+
});
|
|
46
290
|
let extractionStarted = false;
|
|
291
|
+
const guard = { interrupted: false };
|
|
292
|
+
const pump = new ProgressPump(runId, provider, { database, paths, log });
|
|
293
|
+
pump.start();
|
|
47
294
|
try {
|
|
48
295
|
if (provider === 'intervals') {
|
|
49
296
|
const environment = await athleteProviderEnvironment(paths);
|
|
50
297
|
const config = requireIntervalsConfig(environment);
|
|
51
298
|
extractionStarted = true;
|
|
52
|
-
|
|
299
|
+
pump.setStage('intervals');
|
|
300
|
+
await runWithInterruptGuard(runId, database, () => undefined, guard, async () => {
|
|
301
|
+
await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId, log).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
|
|
302
|
+
});
|
|
53
303
|
}
|
|
54
304
|
else {
|
|
55
305
|
const output = path.join(paths.staging, 'garmin', `${runId}.jsonl`);
|
|
@@ -71,22 +321,77 @@ async function syncProvider(database, paths, provider, options) {
|
|
|
71
321
|
arguments_.push('--skip-activities');
|
|
72
322
|
if (options.backfill && !options.refreshActivities)
|
|
73
323
|
arguments_.push('--historical-only');
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
324
|
+
// Data-completeness catch-up: stores synced before lactate-threshold
|
|
325
|
+
// history capture only hold the latest singleton point. When no
|
|
326
|
+
// per-day LT observations exist yet, widen the worker's LT window to
|
|
327
|
+
// the athlete's first activity so the history backfills once; the
|
|
328
|
+
// condition self-clears after the first successful import.
|
|
329
|
+
if (!(await database.hasGarminLactateThresholdHistory())) {
|
|
330
|
+
const earliest = await database.earliestActivityStartDate('garmin');
|
|
331
|
+
if (earliest)
|
|
332
|
+
arguments_.push('--lt-history-from', earliest);
|
|
333
|
+
}
|
|
334
|
+
// Calendar-events coverage backfill: a store whose activities predate
|
|
335
|
+
// the short lookback gets exactly one deep pull (to its earliest
|
|
336
|
+
// activity); once an older event lands, normal syncs stay narrow.
|
|
337
|
+
const eventsShortFrom = new Date(Date.now() - EVENT_WINDOW_PAST_DAYS * 86_400_000).toISOString().slice(0, 10);
|
|
338
|
+
const needsEventBackfill = !(await database.hasCalendarEventOlderThan('garmin', eventsShortFrom));
|
|
339
|
+
const earliestActivity = needsEventBackfill ? await database.earliestActivityStartDate('garmin') : null;
|
|
340
|
+
if (needsEventBackfill && earliestActivity && earliestActivity < eventsShortFrom) {
|
|
341
|
+
arguments_.push('--events-from', earliestActivity);
|
|
342
|
+
}
|
|
343
|
+
let interruptChild = null;
|
|
344
|
+
await runWithInterruptGuard(runId, database, () => interruptChild?.(), guard, async () => {
|
|
345
|
+
const outcome = await runGarminWorker(runId, arguments_, {
|
|
346
|
+
cwd: packageRoot(),
|
|
347
|
+
env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
|
|
348
|
+
}, pump, log, (kill) => { interruptChild = kill; });
|
|
349
|
+
if (!guard.interrupted && outcome.code !== 0) {
|
|
350
|
+
throw new Error(`Garmin staging worker exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
|
|
351
|
+
}
|
|
77
352
|
});
|
|
78
|
-
if (!
|
|
79
|
-
|
|
80
|
-
|
|
353
|
+
if (!guard.interrupted) {
|
|
354
|
+
if (!existsSync(output))
|
|
355
|
+
throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
|
|
356
|
+
await importJsonl(database, runId, output, log);
|
|
357
|
+
await reconcileGarminCalendarEntities(database, output);
|
|
358
|
+
if (provider === 'garmin') {
|
|
359
|
+
try {
|
|
360
|
+
await backfillGarminSwimLengths(database, paths, runId, log);
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
log.warn('Garmin swim-length backfill after sync failed', {
|
|
364
|
+
runId,
|
|
365
|
+
error: error instanceof Error ? error.message : String(error),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
81
370
|
}
|
|
82
371
|
}
|
|
83
372
|
catch (error) {
|
|
84
|
-
|
|
373
|
+
if (!guard.interrupted) {
|
|
374
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
375
|
+
log.error('Sync run failed', { runId, provider, error: message, stack: error instanceof Error ? error.stack : undefined });
|
|
376
|
+
await database.addError(runId, provider, 'sync', null, message, true);
|
|
377
|
+
await database.heartbeatRun(runId, baseProgress(runId, provider, 'failed')).catch(() => undefined);
|
|
378
|
+
}
|
|
85
379
|
}
|
|
86
380
|
finally {
|
|
87
|
-
|
|
381
|
+
pump.stop();
|
|
382
|
+
if (guard.interrupted) {
|
|
383
|
+
await database.markRunInterrupted(runId);
|
|
384
|
+
log.warn('Sync run interrupted', { runId, provider });
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
await database.finishRun(runId);
|
|
388
|
+
await pump.publishFinal(baseProgress(runId, provider, 'completed'));
|
|
389
|
+
}
|
|
390
|
+
await removeProgressSidecar(paths, runId).catch(() => undefined);
|
|
391
|
+
const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
|
|
392
|
+
log.info('Sync run finished', { runId, provider, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
|
|
88
393
|
}
|
|
89
|
-
if (options.advanceCursor && !options.explicitFrom && extractionStarted) {
|
|
394
|
+
if (!guard.interrupted && options.advanceCursor && !options.explicitFrom && extractionStarted) {
|
|
90
395
|
await database.advanceIncrementalCursor(provider, 'daily', runId, options.toDate, 3);
|
|
91
396
|
await database.advanceIncrementalCursor(provider, 'activities', runId, options.toDate, 14);
|
|
92
397
|
}
|
|
@@ -109,13 +414,26 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
|
|
|
109
414
|
if (provider === 'strava')
|
|
110
415
|
return { provider, result: await syncStravaGear(paths) };
|
|
111
416
|
const database = await CatenceDatabase.open(paths);
|
|
417
|
+
const log = createSyncLogger(paths);
|
|
112
418
|
try {
|
|
419
|
+
const timedOutRuns = await database.interruptStaleSyncRuns(STALE_RUN_TIMEOUT_MS);
|
|
420
|
+
if (timedOutRuns.runIds.length > 0) {
|
|
421
|
+
log.warn('Marked stale sync runs as timed out', { runIds: timedOutRuns.runIds, timeoutMs: STALE_RUN_TIMEOUT_MS });
|
|
422
|
+
}
|
|
423
|
+
for (const runId of timedOutRuns.runIds) {
|
|
424
|
+
await removeProgressSidecar(paths, runId).catch(() => undefined);
|
|
425
|
+
}
|
|
113
426
|
const providers = provider === 'all' ? ['intervals', 'garmin'] : [provider];
|
|
114
427
|
const toDate = new Date().toISOString().slice(0, 10);
|
|
115
428
|
const runs = [];
|
|
116
429
|
for (const source of providers)
|
|
117
|
-
runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }));
|
|
118
|
-
|
|
430
|
+
runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }, log));
|
|
431
|
+
const executedRuns = runs.filter((run) => !run.skipped);
|
|
432
|
+
if (executedRuns.length > 0) {
|
|
433
|
+
const index = await buildRetrievalIndex(database);
|
|
434
|
+
log.info('Rebuilt retrieval index after sync', { documents: index.documents, mode: index.mode, watermark: index.watermark });
|
|
435
|
+
}
|
|
436
|
+
return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs, timedOutRuns: timedOutRuns.runIds };
|
|
119
437
|
}
|
|
120
438
|
finally {
|
|
121
439
|
await database.close();
|
|
@@ -125,12 +443,14 @@ export async function retryDataSync(paths, previousRunId) {
|
|
|
125
443
|
await ensurePaths(paths);
|
|
126
444
|
const database = await CatenceDatabase.open(paths);
|
|
127
445
|
try {
|
|
446
|
+
await database.interruptStaleSyncRuns(STALE_RUN_TIMEOUT_MS);
|
|
128
447
|
const previous = await database.getRun(previousRunId);
|
|
129
448
|
if (!previous)
|
|
130
449
|
throw new Error(`No sync run found for ${previousRunId}.`);
|
|
131
450
|
if (previous.provider === 'strava')
|
|
132
451
|
throw new Error('Retry Strava gear sync with `catence-data sync --provider strava`.');
|
|
133
|
-
const
|
|
452
|
+
const log = createSyncLogger(paths);
|
|
453
|
+
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
454
|
return { retriedRun: previousRunId, runId: run.runId, run };
|
|
135
455
|
}
|
|
136
456
|
finally {
|
|
@@ -271,6 +591,30 @@ export async function dataStatus(paths) {
|
|
|
271
591
|
await database.close();
|
|
272
592
|
}
|
|
273
593
|
}
|
|
594
|
+
export async function syncProgress(paths) {
|
|
595
|
+
await ensurePaths(paths);
|
|
596
|
+
// Live progress must stay readable while a sync run holds the single-writer
|
|
597
|
+
// DuckDB lock, so the running set comes from sidecar heartbeat files first.
|
|
598
|
+
const sidecars = await readRunningProgress(paths);
|
|
599
|
+
let dbRunning = [];
|
|
600
|
+
let dbRecent = [];
|
|
601
|
+
try {
|
|
602
|
+
const repository = await openReadOnlyRepository(paths);
|
|
603
|
+
try {
|
|
604
|
+
const snapshot = await repository.progress();
|
|
605
|
+
dbRunning = snapshot.running;
|
|
606
|
+
dbRecent = snapshot.recent;
|
|
607
|
+
}
|
|
608
|
+
finally {
|
|
609
|
+
await repository.close();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
if (!(error instanceof ReadOnlyDatabaseError))
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
return mergeProgress(dbRunning, dbRecent, sidecars);
|
|
617
|
+
}
|
|
274
618
|
export async function rebuildRetrievalIndex(paths) {
|
|
275
619
|
if (!existsSync(paths.database))
|
|
276
620
|
throw new Error('No Catence database exists yet. Complete a sync before building the retrieval index.');
|
|
@@ -282,4 +626,76 @@ export async function rebuildRetrievalIndex(paths) {
|
|
|
282
626
|
await database.close();
|
|
283
627
|
}
|
|
284
628
|
}
|
|
629
|
+
export async function importStagedFile(paths, provider, filePath) {
|
|
630
|
+
if (!existsSync(paths.database))
|
|
631
|
+
throw new Error('No Catence database exists yet. Complete a sync before importing staged records.');
|
|
632
|
+
if (!existsSync(filePath))
|
|
633
|
+
throw new Error(`Staging file not found: ${filePath}`);
|
|
634
|
+
const database = await CatenceDatabase.open(paths);
|
|
635
|
+
try {
|
|
636
|
+
const runId = randomUUID();
|
|
637
|
+
const importedRecords = await importJsonl(database, runId, filePath);
|
|
638
|
+
return { runId, provider, importedRecords };
|
|
639
|
+
}
|
|
640
|
+
finally {
|
|
641
|
+
await database.close();
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
export async function resolveExtractionErrors(paths, opts = {}) {
|
|
645
|
+
if (!existsSync(paths.database))
|
|
646
|
+
throw new Error('No Catence database exists yet.');
|
|
647
|
+
const database = await CatenceDatabase.open(paths);
|
|
648
|
+
try {
|
|
649
|
+
const resolved = await database.resolveErrors({
|
|
650
|
+
runIds: opts.runIds,
|
|
651
|
+
provider: opts.provider,
|
|
652
|
+
before: opts.before,
|
|
653
|
+
all: opts.all,
|
|
654
|
+
});
|
|
655
|
+
return { resolvedErrors: resolved };
|
|
656
|
+
}
|
|
657
|
+
finally {
|
|
658
|
+
await database.close();
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Re-run normalization over already-captured nutrition entities without
|
|
663
|
+
* contacting any provider. Idempotent upserts let a fixed normalizer backfill
|
|
664
|
+
* previously mis-parsed nutrition_days/nutrition_items from stored payloads.
|
|
665
|
+
*/
|
|
666
|
+
export async function reimportNutrition(paths) {
|
|
667
|
+
if (!existsSync(paths.database))
|
|
668
|
+
throw new Error('No Catence database exists yet. Complete a sync before re-importing nutrition.');
|
|
669
|
+
const database = await CatenceDatabase.open(paths);
|
|
670
|
+
try {
|
|
671
|
+
const rows = await database.rows(`SELECT provider, entity_type, remote_id, parent_remote_id, occurred_on, source_updated_at, raw_object_hash,
|
|
672
|
+
cast(payload_json AS VARCHAR) AS payload_json, cast(extension_json AS VARCHAR) AS extension_json
|
|
673
|
+
FROM source_entities
|
|
674
|
+
WHERE entity_type IN ('nutrition_log', 'nutrition_day')
|
|
675
|
+
ORDER BY provider, occurred_on, remote_id`);
|
|
676
|
+
for (const row of rows) {
|
|
677
|
+
await importSourceEntity(database, {
|
|
678
|
+
kind: 'source_entity', schemaVersion: 1, provider: row.provider, entityType: row.entity_type,
|
|
679
|
+
remoteId: row.remote_id, parentRemoteId: row.parent_remote_id, occurredOn: row.occurred_on, sourceUpdatedAt: row.source_updated_at,
|
|
680
|
+
rawObjectHash: row.raw_object_hash, payload: parseJsonObject(row.payload_json), extension: parseJsonObject(row.extension_json),
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
const days = (await database.rows('SELECT count(*) AS count FROM nutrition_days'))[0]?.count ?? 0;
|
|
684
|
+
const items = (await database.rows('SELECT count(*) AS count FROM nutrition_items'))[0]?.count ?? 0;
|
|
685
|
+
return { reimportedEntities: rows.length, nutritionDays: Number(days), nutritionItems: Number(items) };
|
|
686
|
+
}
|
|
687
|
+
finally {
|
|
688
|
+
await database.close();
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
function parseJsonObject(value) {
|
|
692
|
+
if (!value)
|
|
693
|
+
return {};
|
|
694
|
+
try {
|
|
695
|
+
return JSON.parse(value);
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
return {};
|
|
699
|
+
}
|
|
700
|
+
}
|
|
285
701
|
//# sourceMappingURL=management.js.map
|
|
@@ -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}.`);
|
|
@@ -33,7 +33,7 @@ export async function importRecord(database, runId, record) {
|
|
|
33
33
|
if (activity[0]?.activity_id)
|
|
34
34
|
await refreshActivityQuality(database, activity[0].activity_id);
|
|
35
35
|
}
|
|
36
|
-
if (record.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set'].includes(record.entityType) && record.parentRemoteId) {
|
|
36
|
+
if (record.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set', 'activity_swim_lengths'].includes(record.entityType) && record.parentRemoteId) {
|
|
37
37
|
const activity = await database.rows('SELECT activity_id FROM activity_sources WHERE activity_source_id = $activitySourceId', { activitySourceId: `garmin:${record.parentRemoteId}` });
|
|
38
38
|
if (activity[0]?.activity_id)
|
|
39
39
|
await refreshActivityQuality(database, activity[0].activity_id);
|
|
@@ -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,16 +1,27 @@
|
|
|
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;
|
|
13
15
|
} | null, includeAccount?: boolean): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Regular syncs reach back EVENT_WINDOW_PAST_DAYS — just enough to
|
|
18
|
+
* associate recently finished events with their activities. A store whose
|
|
19
|
+
* own activity history predates that span gets an automatic one-time
|
|
20
|
+
* coverage backfill instead: the window stretches to the earliest
|
|
21
|
+
* activity until an event older than the short window lands (self-
|
|
22
|
+
* clearing), after which deeper history is the backfill command's job.
|
|
23
|
+
*/
|
|
24
|
+
private resolveEventsSyncWindow;
|
|
14
25
|
private captureCollectionChildren;
|
|
15
26
|
private getCollection;
|
|
16
27
|
private captureActivity;
|