catence 0.2.0-beta.9 → 0.2.0
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 +38 -180
- 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/query/activity-discovery.d.ts +2 -0
- package/dist/core/query/activity-discovery.js +1 -1
- package/dist/core/query/analytics.d.ts +10 -0
- package/dist/core/query/analytics.js +494 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +8 -4
- package/dist/core/query/events.d.ts +34 -0
- package/dist/core/query/events.js +121 -0
- package/dist/core/query/fitness.d.ts +4 -0
- package/dist/core/query/fitness.js +60 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +48 -0
- package/dist/core/query/swimming.js +2 -2
- 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/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 +420 -18
- package/dist/elt/ingestion/importer.js +1 -1
- package/dist/elt/ingestion/providers/intervals.d.ts +9 -0
- package/dist/elt/ingestion/providers/intervals.js +33 -1
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +19 -1
- package/dist/elt/normalization/normalizers.js +207 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +182 -6
- package/dist/elt/storage/migrations.js +145 -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 +94 -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/main.js +33 -1
- package/dist/interfaces/mcp/server.d.ts +2 -1
- package/dist/interfaces/mcp/server.js +168 -45
- package/dist/runtime/index.d.ts +9 -2
- package/dist/runtime/index.js +6 -2
- package/docs/architecture.md +13 -2
- package/docs/assets/image.png +0 -0
- 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 +45 -0
- package/docs/llm-providers.md +24 -2
- package/docs/mcp-activity-retrieval.md +90 -210
- package/package.json +1 -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 +2 -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 +415 -12
- package/scripts/discover-opencode-go.mjs +36 -144
- package/uv.lock +3 -3
- package/docs/deployment.md +0 -338
|
@@ -1,11 +1,14 @@
|
|
|
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';
|
|
11
14
|
import { createSyncLogger } from '../../core/logging.js';
|
|
@@ -14,12 +17,245 @@ import { importJsonl } from '../ingestion/importer.js';
|
|
|
14
17
|
import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
|
|
15
18
|
import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUrl, syncStravaGear } from '../ingestion/providers/strava/service.js';
|
|
16
19
|
import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
|
|
17
|
-
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';
|
|
18
23
|
import { withDataWriteLock } from '../storage/write-lock.js';
|
|
19
|
-
const execFileAsync = promisify(execFile);
|
|
20
24
|
function packageRoot() {
|
|
21
25
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
22
26
|
}
|
|
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
|
+
}
|
|
23
259
|
async function syncProvider(database, paths, provider, options, log) {
|
|
24
260
|
const explicitWindow = options.explicitFrom ? { fromDate: options.explicitFrom, toDate: options.toDate, source: 'explicit' } : null;
|
|
25
261
|
const uncoveredExplicitWindow = async (cursorName) => {
|
|
@@ -52,12 +288,18 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
52
288
|
backfill: options.backfill, refreshActivities: options.refreshActivities,
|
|
53
289
|
});
|
|
54
290
|
let extractionStarted = false;
|
|
291
|
+
const guard = { interrupted: false };
|
|
292
|
+
const pump = new ProgressPump(runId, provider, { database, paths, log });
|
|
293
|
+
pump.start();
|
|
55
294
|
try {
|
|
56
295
|
if (provider === 'intervals') {
|
|
57
296
|
const environment = await athleteProviderEnvironment(paths);
|
|
58
297
|
const config = requireIntervalsConfig(environment);
|
|
59
298
|
extractionStarted = true;
|
|
60
|
-
|
|
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
|
+
});
|
|
61
303
|
}
|
|
62
304
|
else {
|
|
63
305
|
const output = path.join(paths.staging, 'garmin', `${runId}.jsonl`);
|
|
@@ -79,26 +321,77 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
79
321
|
arguments_.push('--skip-activities');
|
|
80
322
|
if (options.backfill && !options.refreshActivities)
|
|
81
323
|
arguments_.push('--historical-only');
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
}
|
|
85
352
|
});
|
|
86
|
-
if (!
|
|
87
|
-
|
|
88
|
-
|
|
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
|
+
}
|
|
89
370
|
}
|
|
90
371
|
}
|
|
91
372
|
catch (error) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|
|
95
379
|
}
|
|
96
380
|
finally {
|
|
97
|
-
|
|
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);
|
|
98
391
|
const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
|
|
99
392
|
log.info('Sync run finished', { runId, provider, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
|
|
100
393
|
}
|
|
101
|
-
if (options.advanceCursor && !options.explicitFrom && extractionStarted) {
|
|
394
|
+
if (!guard.interrupted && options.advanceCursor && !options.explicitFrom && extractionStarted) {
|
|
102
395
|
await database.advanceIncrementalCursor(provider, 'daily', runId, options.toDate, 3);
|
|
103
396
|
await database.advanceIncrementalCursor(provider, 'activities', runId, options.toDate, 14);
|
|
104
397
|
}
|
|
@@ -123,12 +416,24 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
|
|
|
123
416
|
const database = await CatenceDatabase.open(paths);
|
|
124
417
|
const log = createSyncLogger(paths);
|
|
125
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
|
+
}
|
|
126
426
|
const providers = provider === 'all' ? ['intervals', 'garmin'] : [provider];
|
|
127
427
|
const toDate = new Date().toISOString().slice(0, 10);
|
|
128
428
|
const runs = [];
|
|
129
429
|
for (const source of providers)
|
|
130
430
|
runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }, log));
|
|
131
|
-
|
|
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 };
|
|
132
437
|
}
|
|
133
438
|
finally {
|
|
134
439
|
await database.close();
|
|
@@ -138,6 +443,7 @@ export async function retryDataSync(paths, previousRunId) {
|
|
|
138
443
|
await ensurePaths(paths);
|
|
139
444
|
const database = await CatenceDatabase.open(paths);
|
|
140
445
|
try {
|
|
446
|
+
await database.interruptStaleSyncRuns(STALE_RUN_TIMEOUT_MS);
|
|
141
447
|
const previous = await database.getRun(previousRunId);
|
|
142
448
|
if (!previous)
|
|
143
449
|
throw new Error(`No sync run found for ${previousRunId}.`);
|
|
@@ -285,6 +591,30 @@ export async function dataStatus(paths) {
|
|
|
285
591
|
await database.close();
|
|
286
592
|
}
|
|
287
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
|
+
}
|
|
288
618
|
export async function rebuildRetrievalIndex(paths) {
|
|
289
619
|
if (!existsSync(paths.database))
|
|
290
620
|
throw new Error('No Catence database exists yet. Complete a sync before building the retrieval index.');
|
|
@@ -296,4 +626,76 @@ export async function rebuildRetrievalIndex(paths) {
|
|
|
296
626
|
await database.close();
|
|
297
627
|
}
|
|
298
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
|
+
}
|
|
299
701
|
//# sourceMappingURL=management.js.map
|
|
@@ -33,7 +33,7 @@ export async function importRecord(database, runId, record, log) {
|
|
|
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);
|
|
@@ -13,6 +13,15 @@ export declare class IntervalsExtractor {
|
|
|
13
13
|
fromDate: string;
|
|
14
14
|
toDate: string;
|
|
15
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;
|
|
16
25
|
private captureCollectionChildren;
|
|
17
26
|
private getCollection;
|
|
18
27
|
private captureActivity;
|
|
@@ -2,7 +2,7 @@ import { IntervalsClient } from 'intervals-icu';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { stableJsonHash, storeArtifact, storeJsonArtifact } from '../../storage/artifacts.js';
|
|
4
4
|
import { importRecord, queueWorkItem, completeWorkItem, failWorkItem } from '../importer.js';
|
|
5
|
-
import { intervalsActivityReadEndpoints, intervalsSecondaryReadRegistry, assertReadOnlyRegistry } from './registry.js';
|
|
5
|
+
import { intervalsActivityReadEndpoints, intervalsSecondaryReadRegistry, intervalsEventSyncWindow, assertReadOnlyRegistry } from './registry.js';
|
|
6
6
|
import { intervalsStreamsToSamples, writeParquetSamples } from '../../streams.js';
|
|
7
7
|
const apiBaseUrl = 'https://intervals.icu/api/v1';
|
|
8
8
|
function asObject(value) {
|
|
@@ -67,6 +67,21 @@ export class IntervalsExtractor {
|
|
|
67
67
|
for (const endpoint of intervalsSecondaryReadRegistry) {
|
|
68
68
|
if (endpoint.name === 'athlete')
|
|
69
69
|
continue;
|
|
70
|
+
// Calendar events straddle today (past for activity association,
|
|
71
|
+
// future for planning); they must not inherit the cursor-bounded
|
|
72
|
+
// activity window, which always ends at today.
|
|
73
|
+
if (endpoint.name === 'events') {
|
|
74
|
+
const window = await this.resolveEventsSyncWindow();
|
|
75
|
+
const payload = await this.capture(runId, 'events', null, window, async () => this.getCollection('events', resolvedAthleteId, window.fromDate, window.toDate));
|
|
76
|
+
if (payload === undefined)
|
|
77
|
+
continue;
|
|
78
|
+
await this.importEntities('event', 'events', payload, null, runId);
|
|
79
|
+
// The listing is authoritative: events deleted upstream must not
|
|
80
|
+
// linger as ghosts in the store once this sync succeeded.
|
|
81
|
+
const seenIds = asItems(payload).map((item) => idOf(item, '')).filter(Boolean);
|
|
82
|
+
await this.database.deleteMissingCalendarEntities('intervals', ['event'], seenIds, window.fromDate);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
70
85
|
if (!activityWindow)
|
|
71
86
|
continue;
|
|
72
87
|
const { fromDate, toDate } = activityWindow;
|
|
@@ -86,6 +101,23 @@ export class IntervalsExtractor {
|
|
|
86
101
|
}
|
|
87
102
|
}
|
|
88
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Regular syncs reach back EVENT_WINDOW_PAST_DAYS — just enough to
|
|
106
|
+
* associate recently finished events with their activities. A store whose
|
|
107
|
+
* own activity history predates that span gets an automatic one-time
|
|
108
|
+
* coverage backfill instead: the window stretches to the earliest
|
|
109
|
+
* activity until an event older than the short window lands (self-
|
|
110
|
+
* clearing), after which deeper history is the backfill command's job.
|
|
111
|
+
*/
|
|
112
|
+
async resolveEventsSyncWindow() {
|
|
113
|
+
const short = intervalsEventSyncWindow();
|
|
114
|
+
if (await this.database.hasCalendarEventOlderThan('intervals', short.fromDate))
|
|
115
|
+
return short;
|
|
116
|
+
const earliest = await this.database.earliestActivityStartDate('intervals');
|
|
117
|
+
if (!earliest || earliest >= short.fromDate)
|
|
118
|
+
return short;
|
|
119
|
+
return { fromDate: earliest, toDate: short.toDate };
|
|
120
|
+
}
|
|
89
121
|
async captureCollectionChildren(runId, collection, payload, athleteId) {
|
|
90
122
|
const children = {
|
|
91
123
|
events: [{ suffix: (id) => `/athlete/${athleteId}/events/${id}`, entityType: 'event' }],
|
|
@@ -11,6 +11,19 @@ export declare const intervalsReadRegistry: readonly EndpointSpec[];
|
|
|
11
11
|
* Garmin's activity detail, files, streams, or account data.
|
|
12
12
|
*/
|
|
13
13
|
export declare const intervalsSecondaryReadRegistry: EndpointSpec[];
|
|
14
|
+
/** How far back and ahead of today the planned-events fetch reaches. */
|
|
15
|
+
/** Regular syncs look back a month for activity association; deeper history arrives via the automatic coverage backfill. */
|
|
16
|
+
export declare const EVENT_WINDOW_PAST_DAYS = 30;
|
|
17
|
+
export declare const EVENT_WINDOW_FUTURE_DAYS = 365;
|
|
18
|
+
/**
|
|
19
|
+
* Deliberately cursor-free: activities chase a past-only incremental window,
|
|
20
|
+
* while calendar events must straddle today so upcoming races and recently
|
|
21
|
+
* completed ones (associatable with their finished activities) both arrive.
|
|
22
|
+
*/
|
|
23
|
+
export declare function intervalsEventSyncWindow(now?: Date): {
|
|
24
|
+
fromDate: string;
|
|
25
|
+
toDate: string;
|
|
26
|
+
};
|
|
14
27
|
export declare const intervalsActivityReadEndpoints: readonly ["activity", "streams", "intervals", "map", "weather", "weather_summary", "best_efforts", "power_curve", "pace_curve", "hr_curve", "power_vs_hr", "hr_load_model", "segments", "messages", "original_file", "fit_file", "gpx_file"];
|
|
15
28
|
/** Read-only methods exposed by python-garminconnect, grouped for manifest tests and worker dispatch. */
|
|
16
29
|
export declare const garminReadRegistry: readonly EndpointSpec[];
|
|
@@ -21,7 +21,23 @@ export const intervalsReadRegistry = registry('intervals', [
|
|
|
21
21
|
* supplies the separately named training calculations without duplicating
|
|
22
22
|
* Garmin's activity detail, files, streams, or account data.
|
|
23
23
|
*/
|
|
24
|
-
export const intervalsSecondaryReadRegistry = intervalsReadRegistry.filter((endpoint) => endpoint.name === 'athlete' || endpoint.name === 'activities');
|
|
24
|
+
export const intervalsSecondaryReadRegistry = intervalsReadRegistry.filter((endpoint) => endpoint.name === 'athlete' || endpoint.name === 'activities' || endpoint.name === 'events');
|
|
25
|
+
/** How far back and ahead of today the planned-events fetch reaches. */
|
|
26
|
+
/** Regular syncs look back a month for activity association; deeper history arrives via the automatic coverage backfill. */
|
|
27
|
+
export const EVENT_WINDOW_PAST_DAYS = 30;
|
|
28
|
+
export const EVENT_WINDOW_FUTURE_DAYS = 365;
|
|
29
|
+
/**
|
|
30
|
+
* Deliberately cursor-free: activities chase a past-only incremental window,
|
|
31
|
+
* while calendar events must straddle today so upcoming races and recently
|
|
32
|
+
* completed ones (associatable with their finished activities) both arrive.
|
|
33
|
+
*/
|
|
34
|
+
export function intervalsEventSyncWindow(now = new Date()) {
|
|
35
|
+
const dayMs = 86_400_000;
|
|
36
|
+
return {
|
|
37
|
+
fromDate: new Date(now.getTime() - EVENT_WINDOW_PAST_DAYS * dayMs).toISOString().slice(0, 10),
|
|
38
|
+
toDate: new Date(now.getTime() + EVENT_WINDOW_FUTURE_DAYS * dayMs).toISOString().slice(0, 10),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
25
41
|
export const intervalsActivityReadEndpoints = [
|
|
26
42
|
'activity', 'streams', 'intervals', 'map', 'weather', 'weather_summary', 'best_efforts', 'power_curve', 'pace_curve', 'hr_curve',
|
|
27
43
|
'power_vs_hr', 'hr_load_model', 'segments', 'messages', 'original_file', 'fit_file', 'gpx_file',
|
|
@@ -43,6 +59,8 @@ export const garminReadRegistry = registry('garmin', [
|
|
|
43
59
|
['activity_power_zones', 'activity', 'activity_zone'], ['activity_exercise_sets', 'activity', 'activity_exercise_set'], ['activity_gear', 'activity', 'activity_gear'],
|
|
44
60
|
['activity_original', 'activity', 'activity_file'], ['activity_tcx', 'activity', 'activity_file'], ['activity_gpx', 'activity', 'activity_file'], ['activity_kml', 'activity', 'activity_file'],
|
|
45
61
|
['activity_csv', 'activity', 'activity_file'], ['workouts', 'collection', 'workout'], ['scheduled_workouts', 'date_range', 'scheduled_workout'],
|
|
62
|
+
['events', 'collection', 'event'],
|
|
63
|
+
['course', 'child', 'course'],
|
|
46
64
|
['training_plans', 'collection', 'training_plan'], ['goals', 'collection', 'goal'], ['personal_records', 'singleton', 'personal_record'],
|
|
47
65
|
['earned_badges', 'collection', 'badge'], ['available_badges', 'collection', 'badge'], ['challenges', 'collection', 'challenge'],
|
|
48
66
|
['gear', 'collection', 'gear'], ['golf_summary', 'collection', 'golf_scorecard'], ['golf_user_stats', 'singleton', 'golf_user_stat'],
|