catence 0.2.0-beta.19 → 0.2.0-beta.20
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/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/runtime/opencode-go.d.ts +7 -4
- package/dist/core/runtime/opencode-go.js +15 -6
- package/dist/elt/application/detached-sync.d.ts +10 -0
- package/dist/elt/application/detached-sync.js +29 -8
- package/dist/elt/application/management.js +53 -0
- 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 +18 -1
- package/dist/elt/storage/database.d.ts +3 -1
- package/dist/elt/storage/database.js +41 -2
- package/dist/interfaces/http/server.js +40 -19
- package/docs/development.md +15 -7
- package/docs/llm-providers.md +4 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +7 -1
- package/python/catence/providers/garmin/registry.py +1 -0
- package/python/catence/providers/garmin/worker.py +90 -4
- package/uv.lock +3 -3
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.20";
|
|
7
7
|
export declare const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export type CatenceRuntimeCapabilities = {
|
|
9
9
|
mcp: true;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.20';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -37,16 +37,19 @@ export type OpenCodeGoMergeResult = {
|
|
|
37
37
|
messages: number;
|
|
38
38
|
};
|
|
39
39
|
guessedRoutes: string[];
|
|
40
|
+
/** Model ids removed from the managed profiles because they left the live catalog. */
|
|
41
|
+
prunedModelIds?: string[];
|
|
40
42
|
};
|
|
41
43
|
/**
|
|
42
44
|
* Fetch the live OpenCode Go catalog and merge the two Console profiles into
|
|
43
45
|
* `configPath`, preserving every other section plus existing console fields
|
|
44
46
|
* (`limits`, other profiles) and the existing `defaultProfile` unless
|
|
45
47
|
* `setDefault` is passed. Model routing (`model`) is refreshed from the live
|
|
46
|
-
* catalog,
|
|
47
|
-
* `variants`)
|
|
48
|
-
*
|
|
49
|
-
*
|
|
48
|
+
* catalog, per-model customizations (custom label, `reasoningEffort`,
|
|
49
|
+
* `variants`) survive for models still in the catalog, and models that
|
|
50
|
+
* disappeared upstream are pruned so the Console never offers stale routes.
|
|
51
|
+
* The written file is re-validated with the same strict schema the runtime
|
|
52
|
+
* applies on load, then replaced atomically.
|
|
50
53
|
*/
|
|
51
54
|
export declare function mergeOpenCodeGoConsoleProfiles(options: {
|
|
52
55
|
configPath: string;
|
|
@@ -163,10 +163,11 @@ function preservedModelFields(previous) {
|
|
|
163
163
|
* `configPath`, preserving every other section plus existing console fields
|
|
164
164
|
* (`limits`, other profiles) and the existing `defaultProfile` unless
|
|
165
165
|
* `setDefault` is passed. Model routing (`model`) is refreshed from the live
|
|
166
|
-
* catalog,
|
|
167
|
-
* `variants`)
|
|
168
|
-
*
|
|
169
|
-
*
|
|
166
|
+
* catalog, per-model customizations (custom label, `reasoningEffort`,
|
|
167
|
+
* `variants`) survive for models still in the catalog, and models that
|
|
168
|
+
* disappeared upstream are pruned so the Console never offers stale routes.
|
|
169
|
+
* The written file is re-validated with the same strict schema the runtime
|
|
170
|
+
* applies on load, then replaced atomically.
|
|
170
171
|
*/
|
|
171
172
|
export async function mergeOpenCodeGoConsoleProfiles(options) {
|
|
172
173
|
const baseUrl = options.baseUrl ?? OPENCODE_GO_DEFAULT_BASE_URL;
|
|
@@ -184,8 +185,11 @@ export async function mergeOpenCodeGoConsoleProfiles(options) {
|
|
|
184
185
|
: built.defaultProfile;
|
|
185
186
|
// Merge per model, not per profile: discovery owns routing and the id list,
|
|
186
187
|
// but a human's label, reasoningEffort, and variants must survive every
|
|
187
|
-
// re-discovery. Models that vanished upstream
|
|
188
|
+
// re-discovery. Models that vanished upstream are pruned so the managed
|
|
189
|
+
// profiles mirror the live catalog instead of going stale; a failed or
|
|
190
|
+
// empty catalog fetch never reaches this loop (fetch throws first).
|
|
188
191
|
const mergedProfiles = {};
|
|
192
|
+
const prunedModelIds = [];
|
|
189
193
|
for (const [profileId, builtProfile] of Object.entries(built.profiles)) {
|
|
190
194
|
const previousProfile = existingProfiles[profileId];
|
|
191
195
|
const rawModels = typeof previousProfile === 'object' && previousProfile !== null
|
|
@@ -194,7 +198,7 @@ export async function mergeOpenCodeGoConsoleProfiles(options) {
|
|
|
194
198
|
const previousModels = typeof rawModels === 'object' && rawModels !== null && !Array.isArray(rawModels)
|
|
195
199
|
? rawModels
|
|
196
200
|
: {};
|
|
197
|
-
const models = {
|
|
201
|
+
const models = {};
|
|
198
202
|
for (const [modelId, entry] of Object.entries(builtProfile.models)) {
|
|
199
203
|
models[modelId] = {
|
|
200
204
|
...preservedModelFields(previousModels[modelId]),
|
|
@@ -202,6 +206,10 @@ export async function mergeOpenCodeGoConsoleProfiles(options) {
|
|
|
202
206
|
model: entry.model,
|
|
203
207
|
};
|
|
204
208
|
}
|
|
209
|
+
for (const modelId of Object.keys(previousModels)) {
|
|
210
|
+
if (!(modelId in models))
|
|
211
|
+
prunedModelIds.push(`${profileId}:${modelId}`);
|
|
212
|
+
}
|
|
205
213
|
const previousDefaultModel = typeof previousProfile === 'object' && previousProfile !== null
|
|
206
214
|
? previousProfile.defaultModel
|
|
207
215
|
: undefined;
|
|
@@ -232,6 +240,7 @@ export async function mergeOpenCodeGoConsoleProfiles(options) {
|
|
|
232
240
|
defaultProfile,
|
|
233
241
|
counts: built.counts,
|
|
234
242
|
guessedRoutes: built.guessedRoutes,
|
|
243
|
+
prunedModelIds,
|
|
235
244
|
};
|
|
236
245
|
}
|
|
237
246
|
//# sourceMappingURL=opencode-go.js.map
|
|
@@ -8,6 +8,16 @@ export type DetachedSyncRequest = {
|
|
|
8
8
|
provider?: DetachedSyncProvider;
|
|
9
9
|
from?: string;
|
|
10
10
|
refresh?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The `--home` value the detached `catence-data` child must receive.
|
|
13
|
+
*
|
|
14
|
+
* Catalog deployments pass the catalog root here: `paths` points at the
|
|
15
|
+
* resolved athlete store (for locks/logs), but the child resolves
|
|
16
|
+
* `--athlete` within a catalog, so pointing `--home` at the store itself
|
|
17
|
+
* makes it fail with the legacy-store guard. Standalone stores omit this
|
|
18
|
+
* and default to `paths.root`.
|
|
19
|
+
*/
|
|
20
|
+
cliHome?: string;
|
|
11
21
|
};
|
|
12
22
|
export type DetachedSyncHandle = {
|
|
13
23
|
started: true;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { open, mkdir, writeFile } from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
|
|
5
7
|
import { syncProgress } from './management.js';
|
|
6
8
|
export const DETACHED_SYNC_PROVIDERS = ['intervals', 'garmin', 'strava', 'all'];
|
|
7
9
|
/** Raised when another sync run is already active for this store. */
|
|
@@ -14,12 +16,25 @@ export class DetachedSyncBusyError extends Error {
|
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
18
|
/**
|
|
17
|
-
* Resolve the
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* Resolve how to launch the `catence-data` CLI next to this module.
|
|
20
|
+
*
|
|
21
|
+
* Built installs: both modules live under dist/, so the child is plain
|
|
22
|
+
* `node …/dist/interfaces/cli/main.js`. Source checkouts run through tsx,
|
|
23
|
+
* where the same relative path points at a nonexistent `.js` file — there the
|
|
24
|
+
* TypeScript entry is launched through the checkout's local tsx binary
|
|
25
|
+
* instead. Tests inject their own spawner and never reach this resolution.
|
|
20
26
|
*/
|
|
21
|
-
function
|
|
22
|
-
|
|
27
|
+
function cliInvocation() {
|
|
28
|
+
const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const builtEntry = path.resolve(moduleDirectory, '../../interfaces/cli/main.js');
|
|
30
|
+
if (existsSync(builtEntry))
|
|
31
|
+
return { command: process.execPath, entry: builtEntry };
|
|
32
|
+
const sourceEntry = path.resolve(moduleDirectory, '../../interfaces/cli/main.ts');
|
|
33
|
+
const tsxBinary = path.resolve(moduleDirectory, '../../../node_modules/.bin/tsx');
|
|
34
|
+
if (!existsSync(sourceEntry) || !existsSync(tsxBinary)) {
|
|
35
|
+
throw new Error(`Could not locate the catence-data CLI entry (tried ${builtEntry} and ${sourceEntry}). Install the package or run from a checkout with node_modules present.`);
|
|
36
|
+
}
|
|
37
|
+
return { command: tsxBinary, entry: sourceEntry };
|
|
23
38
|
}
|
|
24
39
|
function assertValidRequest(request) {
|
|
25
40
|
if (!request.athleteId || !/^[a-z][a-z0-9-]{0,62}$/.test(request.athleteId)) {
|
|
@@ -46,14 +61,20 @@ export async function startDetachedSync(request, dependencies = {}) {
|
|
|
46
61
|
assertValidRequest(request);
|
|
47
62
|
const provider = request.provider ?? 'all';
|
|
48
63
|
const snapshot = await syncProgress(request.paths);
|
|
49
|
-
|
|
64
|
+
// Belt-and-braces: syncProgress already age-filters stale runs, so this
|
|
65
|
+
// predicate only re-asserts the sidecar contract at the decision point —
|
|
66
|
+
// a heartbeat older than the stale timeout must never block a new run.
|
|
67
|
+
const activeRun = snapshot.running.find((state) => {
|
|
68
|
+
const ageMs = Date.now() - Date.parse(state.heartbeatAt);
|
|
69
|
+
return Number.isFinite(ageMs) && ageMs <= STALE_RUN_TIMEOUT_MS;
|
|
70
|
+
});
|
|
50
71
|
if (activeRun)
|
|
51
72
|
throw new DetachedSyncBusyError(activeRun.runId);
|
|
52
73
|
const stamp = (dependencies.now ?? (() => new Date()))().toISOString().replace(/[:.]/g, '-');
|
|
53
74
|
const logFile = path.join(request.paths.root, 'logs', `sync-${request.athleteId}-${stamp}.log`);
|
|
54
75
|
await mkdir(path.dirname(logFile), { recursive: true });
|
|
55
76
|
await writeFile(logFile, `[${new Date().toISOString()}] catence-data --athlete ${request.athleteId} sync --provider ${provider}${request.from ? ` --from ${request.from}` : ''}${request.refresh ? ' --refresh' : ''}\n`, 'utf8');
|
|
56
|
-
const arguments_ = [
|
|
77
|
+
const arguments_ = [cliInvocation().entry, '--home', request.cliHome ?? request.paths.root, '--athlete', request.athleteId, 'sync', '--provider', provider];
|
|
57
78
|
if (request.from)
|
|
58
79
|
arguments_.push('--from', request.from);
|
|
59
80
|
if (request.refresh)
|
|
@@ -63,7 +84,7 @@ export async function startDetachedSync(request, dependencies = {}) {
|
|
|
63
84
|
const logHandle = await open(logFile, 'a');
|
|
64
85
|
try {
|
|
65
86
|
const spawnProcess = dependencies.spawnProcess ?? spawn;
|
|
66
|
-
const child = spawnProcess(
|
|
87
|
+
const child = spawnProcess(cliInvocation().command, arguments_, {
|
|
67
88
|
detached: true,
|
|
68
89
|
stdio: ['ignore', logHandle.fd, logHandle.fd],
|
|
69
90
|
env: process.env,
|
|
@@ -2,6 +2,9 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
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
10
|
import { fileURLToPath } from 'node:url';
|
|
@@ -21,6 +24,46 @@ import { withDataWriteLock } from '../storage/write-lock.js';
|
|
|
21
24
|
function packageRoot() {
|
|
22
25
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
23
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
|
+
}
|
|
24
67
|
function baseProgress(runId, provider, stage) {
|
|
25
68
|
return {
|
|
26
69
|
runId, provider, stage, currentStep: null,
|
|
@@ -288,6 +331,15 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
288
331
|
if (earliest)
|
|
289
332
|
arguments_.push('--lt-history-from', earliest);
|
|
290
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
|
+
}
|
|
291
343
|
let interruptChild = null;
|
|
292
344
|
await runWithInterruptGuard(runId, database, () => interruptChild?.(), guard, async () => {
|
|
293
345
|
const outcome = await runGarminWorker(runId, arguments_, {
|
|
@@ -302,6 +354,7 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
302
354
|
if (!existsSync(output))
|
|
303
355
|
throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
|
|
304
356
|
await importJsonl(database, runId, output, log);
|
|
357
|
+
await reconcileGarminCalendarEntities(database, output);
|
|
305
358
|
if (provider === 'garmin') {
|
|
306
359
|
try {
|
|
307
360
|
await backfillGarminSwimLengths(database, paths, runId, log);
|
|
@@ -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,7 @@ 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'],
|
|
46
63
|
['training_plans', 'collection', 'training_plan'], ['goals', 'collection', 'goal'], ['personal_records', 'singleton', 'personal_record'],
|
|
47
64
|
['earned_badges', 'collection', 'badge'], ['available_badges', 'collection', 'badge'], ['challenges', 'collection', 'challenge'],
|
|
48
65
|
['gear', 'collection', 'gear'], ['golf_summary', 'collection', 'golf_scorecard'], ['golf_user_stats', 'singleton', 'golf_user_stat'],
|
|
@@ -20,7 +20,9 @@ export declare class CatenceDatabase implements WriteDataStore {
|
|
|
20
20
|
heartbeatRun(runId: string, progress: SyncProgressState): Promise<void>;
|
|
21
21
|
markRunInterrupted(runId: string, stage?: string): Promise<void>;
|
|
22
22
|
interruptStaleSyncRuns(timeoutMs?: number): Promise<InterruptedRuns>;
|
|
23
|
-
syncProgress(): Promise<SyncProgressSnapshot>;
|
|
23
|
+
syncProgress(timeoutMs?: number): Promise<SyncProgressSnapshot>;
|
|
24
|
+
hasCalendarEventOlderThan(provider: Provider, beforeIsoDate: string, entityType?: string): Promise<boolean>;
|
|
25
|
+
deleteMissingCalendarEntities(provider: Provider, entityTypes: readonly string[], keepRemoteIds: readonly string[], minOccurredOn: string): Promise<void>;
|
|
24
26
|
addError(runId: string, provider: Provider, endpoint: string, remoteId: string | null, message: string, retryable: boolean): Promise<void>;
|
|
25
27
|
resolveErrors(opts?: {
|
|
26
28
|
runIds?: string[];
|
|
@@ -142,7 +142,14 @@ export class CatenceDatabase {
|
|
|
142
142
|
}
|
|
143
143
|
return { runIds: stale.map((row) => row.run_id) };
|
|
144
144
|
}
|
|
145
|
-
async syncProgress() {
|
|
145
|
+
async syncProgress(timeoutMs = STALE_RUN_TIMEOUT_MS) {
|
|
146
|
+
// A run killed with its container (docker restart, OOM) keeps
|
|
147
|
+
// status='running' until something cleans it up, and that cleanup only
|
|
148
|
+
// runs inside a starting sync child — which the busy guard refuses to
|
|
149
|
+
// spawn while the row reads as active. Age-filtering here mirrors the
|
|
150
|
+
// sidecar contract: a heartbeat older than the stale timeout can never
|
|
151
|
+
// block a new run or disable the dashboard's Sync button forever.
|
|
152
|
+
const cutoff = new Date(Date.now() - timeoutMs).toISOString();
|
|
146
153
|
const running = await this.rows(`SELECT runs.run_id, runs.provider,
|
|
147
154
|
coalesce(progress.stage, 'starting') AS stage,
|
|
148
155
|
progress.current_step AS current_step,
|
|
@@ -155,7 +162,8 @@ export class CatenceDatabase {
|
|
|
155
162
|
FROM sync_runs AS runs
|
|
156
163
|
LEFT JOIN sync_run_progress AS progress USING (run_id)
|
|
157
164
|
WHERE runs.status = 'running'
|
|
158
|
-
|
|
165
|
+
AND coalesce(progress.heartbeat_at, runs.started_at) > $cutoff
|
|
166
|
+
ORDER BY runs.started_at DESC`, { cutoff });
|
|
159
167
|
const recent = await this.rows(`SELECT progress.run_id, progress.provider, progress.stage, progress.current_step,
|
|
160
168
|
progress.completed_units, progress.total_units, progress.percent, progress.elapsed_seconds,
|
|
161
169
|
progress.estimated_remaining_seconds, cast(progress.heartbeat_at AS VARCHAR) AS heartbeat_at
|
|
@@ -164,6 +172,37 @@ export class CatenceDatabase {
|
|
|
164
172
|
LIMIT 10`);
|
|
165
173
|
return { running: running.map(toSyncProgressState), recent: recent.map(toSyncProgressState) };
|
|
166
174
|
}
|
|
175
|
+
async hasCalendarEventOlderThan(provider, beforeIsoDate, entityType = 'event') {
|
|
176
|
+
// Coverage signal for the automatic calendar-events backfill: true once
|
|
177
|
+
// the store already holds an event older than the regular short window,
|
|
178
|
+
// meaning the one-time deep pull has landed and normal syncs can stay
|
|
179
|
+
// narrow. Athletes with genuinely no old events re-run the deep window;
|
|
180
|
+
// it costs one small paginated listing per sync.
|
|
181
|
+
const rows = await this.rows(`SELECT 1 FROM source_entities WHERE provider = $provider AND entity_type = $entityType AND occurred_on < $before LIMIT 1`, { provider, entityType, before: beforeIsoDate });
|
|
182
|
+
return rows.length > 0;
|
|
183
|
+
}
|
|
184
|
+
async deleteMissingCalendarEntities(provider, entityTypes, keepRemoteIds, minOccurredOn) {
|
|
185
|
+
// Calendar entities (joined races, scheduled workouts) are mutable and
|
|
186
|
+
// deletable upstream. Re-fetches upsert survivors via remote_id; this
|
|
187
|
+
// removes in-window rows absent from the latest authoritative listing so
|
|
188
|
+
// upstream deletions propagate instead of leaving ghosts forever. Rows
|
|
189
|
+
// without a date are preserved (they predate strict dating).
|
|
190
|
+
if (!entityTypes.length)
|
|
191
|
+
return;
|
|
192
|
+
const values = { provider, minOccurredOn };
|
|
193
|
+
const typePlaceholders = entityTypes.map((name, index) => {
|
|
194
|
+
values[`type${index}`] = name;
|
|
195
|
+
return `$type${index}`;
|
|
196
|
+
});
|
|
197
|
+
let keepClause = '';
|
|
198
|
+
if (keepRemoteIds.length) {
|
|
199
|
+
keepClause = ` AND remote_id NOT IN (${keepRemoteIds.map((_, index) => `$keep${index}`).join(', ')})`;
|
|
200
|
+
keepRemoteIds.forEach((id, index) => { values[`keep${index}`] = id; });
|
|
201
|
+
}
|
|
202
|
+
for (const table of ['source_entities', 'domain_entities']) {
|
|
203
|
+
await this.run(`DELETE FROM ${table} WHERE provider = $provider AND entity_type IN (${typePlaceholders}) AND occurred_on >= $minOccurredOn${keepClause}`, values);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
167
206
|
async addError(runId, provider, endpoint, remoteId, message, retryable) {
|
|
168
207
|
await this.run(`INSERT INTO normalization_errors (error_id, run_id, provider, endpoint, remote_id, message, retryable)
|
|
169
208
|
VALUES ($errorId, $runId, $provider, $endpoint, $remoteId, $message, $retryable)`, { errorId: randomUUID(), runId, provider, endpoint, remoteId, message, retryable });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
|
-
import { catenceRuntimeHealth, DashboardSnapshotService,
|
|
4
|
+
import { catenceRuntimeHealth, DashboardSnapshotService, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, } from '../../runtime/index.js';
|
|
5
5
|
import { createCatenceMcpServer } from '../mcp/server.js';
|
|
6
6
|
const MAX_REQUEST_BYTES = 1_000_000;
|
|
7
7
|
function json(response, statusCode, value) {
|
|
@@ -131,25 +131,37 @@ async function lastSyncSummary(paths) {
|
|
|
131
131
|
return null;
|
|
132
132
|
};
|
|
133
133
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
134
|
+
// Read-only repository on purpose: it exposes per-run history (`syncRuns`)
|
|
135
|
+
// and degrades through the typed lock-conflict error while a detached
|
|
136
|
+
// sync holds DuckDB's single-writer lock. The write-capable dataStatus()
|
|
137
|
+
// would both block here and has no run history at all.
|
|
138
|
+
const repository = await openReadOnlyRepository(paths);
|
|
139
|
+
try {
|
|
140
|
+
const status = await repository.status();
|
|
141
|
+
const runs = Array.isArray(status.syncRuns) ? status.syncRuns : [];
|
|
142
|
+
const completed = runs.filter((run) => String(run.status ?? '').startsWith('completed') && completedAtIso(run.completed_at));
|
|
143
|
+
const providers = {};
|
|
144
|
+
let lastCompletedAt = null;
|
|
145
|
+
for (const run of completed) {
|
|
146
|
+
const completedAt = completedAtIso(run.completed_at);
|
|
147
|
+
const provider = String(run.provider ?? 'unknown');
|
|
148
|
+
if (!providers[provider] || providers[provider] < completedAt)
|
|
149
|
+
providers[provider] = completedAt;
|
|
150
|
+
if (!lastCompletedAt || lastCompletedAt < completedAt)
|
|
151
|
+
lastCompletedAt = completedAt;
|
|
152
|
+
}
|
|
153
|
+
return { lastCompletedAt, providers, recentRuns: runs.slice(0, 5) };
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
await repository.close();
|
|
146
157
|
}
|
|
147
|
-
return { lastCompletedAt, providers, recentRuns: runs.slice(0, 5) };
|
|
148
158
|
}
|
|
149
|
-
catch
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
159
|
+
catch {
|
|
160
|
+
// Best-effort by contract: while a detached sync holds the single-writer
|
|
161
|
+
// DuckDB lock, even opening a snapshot fails cross-process. Progress (from
|
|
162
|
+
// sidecar heartbeats) must still reach the dashboard, so any failure here
|
|
163
|
+
// degrades to "no last-sync summary" instead of failing /sync/status.
|
|
164
|
+
return null;
|
|
153
165
|
}
|
|
154
166
|
}
|
|
155
167
|
async function handleMcpRequest(request, response, paths) {
|
|
@@ -271,7 +283,16 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
271
283
|
discoveryWarning = `OpenCode Go model discovery failed; profiles were left unchanged (${error instanceof Error ? error.message : String(error)}).`;
|
|
272
284
|
}
|
|
273
285
|
}
|
|
274
|
-
const handle = await startSync({
|
|
286
|
+
const handle = await startSync({
|
|
287
|
+
paths,
|
|
288
|
+
athleteId: body.athleteId ?? 'local',
|
|
289
|
+
provider: body.provider,
|
|
290
|
+
from: body.from,
|
|
291
|
+
refresh: body.refresh,
|
|
292
|
+
// The child resolves the athlete within the catalog, not within
|
|
293
|
+
// the athlete store this route resolved for locks and logs.
|
|
294
|
+
cliHome: catalogPaths ? catalogPaths.root : undefined,
|
|
295
|
+
}, { spawnProcess: options.syncSpawnProcess });
|
|
275
296
|
json(response, 202, { ...handle, ...(discoveryWarning ? { warning: discoveryWarning } : {}), progressEndpoint: '/api/v1/sync/status' });
|
|
276
297
|
}
|
|
277
298
|
catch (error) {
|
package/docs/development.md
CHANGED
|
@@ -142,7 +142,7 @@ npm run catence-data -- --home "$CATENCE_HOME" sync --athlete alex --provider ga
|
|
|
142
142
|
npm run catence-data -- --home "$CATENCE_HOME" build-retrieval-index --athlete alex
|
|
143
143
|
|
|
144
144
|
# Hot-reload loop for MCP iteration (restarts on src/ change)
|
|
145
|
-
npx tsx watch src/interfaces/mcp/main.ts --
|
|
145
|
+
npx tsx watch src/interfaces/mcp/main.ts --home "$CATENCE_HOME"
|
|
146
146
|
|
|
147
147
|
# Tests (DuckDB integration tests use a 15s per-test timeout)
|
|
148
148
|
npm test
|
|
@@ -157,15 +157,18 @@ npm run check
|
|
|
157
157
|
The Console normally auto-starts a matching packaged runtime (`npx catence@<pinned>`). For development, point it at your live `tsx` runtime instead via `--mcp-url`:
|
|
158
158
|
|
|
159
159
|
```sh
|
|
160
|
-
# Terminal 1 — live MCP/ELT runtime on loopback
|
|
161
|
-
|
|
160
|
+
# Terminal 1 — live MCP/ELT runtime on loopback (no `--` separator; tsx
|
|
161
|
+
# forwards arguments verbatim, so a bare `--` would reach the CLI unparsed)
|
|
162
|
+
npx tsx watch src/interfaces/mcp/main.ts serve --home "$CATENCE_HOME" --host 127.0.0.1 --port 8787
|
|
162
163
|
|
|
163
|
-
# Terminal 2 — Console using that runtime (no auto-started `npx` runtime)
|
|
164
|
+
# Terminal 2 — Console using that runtime (no auto-started `npx` runtime).
|
|
165
|
+
# The `console` npm script already ends in the `serve` subcommand, so extra
|
|
166
|
+
# arguments after `--` are appended AFTER `serve` — never repeat it here.
|
|
164
167
|
export CATENCE_CONSOLE_USERNAME='coach'
|
|
165
168
|
export CATENCE_CONSOLE_PASSWORD_HASH="$(uv run --project console catence-console auth hash-password)"
|
|
166
169
|
export CHAINLIT_AUTH_SECRET="$(openssl rand -hex 32)"
|
|
167
170
|
export OPENAI_API_KEY='…' # or ANTHROPIC_API_KEY / OPENCODE_API_KEY …
|
|
168
|
-
npm run console --
|
|
171
|
+
npm run console -- --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp --ui-port 8000
|
|
169
172
|
# open http://127.0.0.1:8000
|
|
170
173
|
```
|
|
171
174
|
|
|
@@ -264,7 +267,7 @@ services:
|
|
|
264
267
|
--home /data --ui-host 0.0.0.0 --ui-port 8000
|
|
265
268
|
--mcp-host 0.0.0.0 --mcp-port 8787"
|
|
266
269
|
# for no-restart TS hot-reload, replace the command with:
|
|
267
|
-
# npx tsx watch src/interfaces/mcp/main.ts
|
|
270
|
+
# npx tsx watch src/interfaces/mcp/main.ts serve --home /data --host 0.0.0.0 --port 8787
|
|
268
271
|
# and run Console separately with --mcp-url
|
|
269
272
|
|
|
270
273
|
volumes:
|
|
@@ -293,7 +296,7 @@ docker compose -f docker-compose.dev.yml --env-file .env.dev exec console \
|
|
|
293
296
|
catence-data --home /data status --athlete alex
|
|
294
297
|
```
|
|
295
298
|
|
|
296
|
-
An alternative that avoids running `npm` inside the mount loop is to keep the runtime on the host entirely: run `npx tsx watch src/interfaces/mcp/main.ts
|
|
299
|
+
An alternative that avoids running `npm` inside the mount loop is to keep the runtime on the host entirely: run `npx tsx watch src/interfaces/mcp/main.ts serve --home /srv/catence-dev ...` on the host and start the Console container with `--mcp-url http://host.docker.internal:8787/mcp` (or the host's Tailscale IP) and `--env-file .env.dev` — the same `--mcp-url` trick from the local Console section.
|
|
297
300
|
|
|
298
301
|
## Where secrets and model definitions live (dev and Docker)
|
|
299
302
|
|
|
@@ -323,3 +326,8 @@ Recommended dev defaults:
|
|
|
323
326
|
- **Doctor reports runtime unreachable** — the Console was started without `--mcp-url` and its auto-started runtime is on a different port. Start the runtime explicitly and pass `--mcp-url http://127.0.0.1:8787/mcp` to the Console, or use `uv run --project console catence-console doctor --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp`.
|
|
324
327
|
- **Docker `run` container has a separate loopback** — `docker compose run` cannot see a `docker compose exec` MCP server. Use `exec` for `doctor`/`sync` against the running container, or use `run` only for one-off `setup`/`secret set` with `--value-stdin`.
|
|
325
328
|
- **Refusing to initialize** — the data home contains unrelated files. The error lists them; only `config.json`, `console/`, and Chainlit's `.files/`, `.chainlit/`, `public/` are allowed alongside a catalog.
|
|
329
|
+
- **Is a sync actually running? (no deploy scaffold needed)** — three checks, all safe while a sync holds the write lock:
|
|
330
|
+
1. Runtime status API: `curl -s "http://127.0.0.1:8787/api/v1/sync/status?athleteId=<id>" | jq '.progress.running'` (through the Console instead: `/api/v1/sync/status` behind your login).
|
|
331
|
+
2. Detached-child log files: `ls -t "$CATENCE_HOME/logs" | head`. A successful `POST /api/v1/sync` creates `logs/sync-<athlete>-<stamp>.log` before spawning, so an absent or empty `logs/` directory proves no detached sync ever started.
|
|
332
|
+
3. The MCP progress tools (`catence_sync_progress`) read the same heartbeats.
|
|
333
|
+
- **Models page clutter** — unused shipped profiles can be hidden per device from the Models page (`Hide` on the profile card); hidden profiles disappear from the chat Model dropdown but stay configured, and are restored with `Unhide`. Hiding lives in the Console database, never in `config.json`.
|
package/docs/llm-providers.md
CHANGED
|
@@ -198,8 +198,10 @@ The script warns on stderr and skips any model that does not fit one of these
|
|
|
198
198
|
families. Rerun it to refresh the model lists; existing profiles, limits, and
|
|
199
199
|
`defaultProfile` are preserved unless `--set-default` is passed. Re-running
|
|
200
200
|
never discards your own tweaks: per-model custom labels, `reasoningEffort`
|
|
201
|
-
pins, and `variants` maps survive every refresh
|
|
202
|
-
|
|
201
|
+
pins, and `variants` maps survive every refresh. Models that have dropped out
|
|
202
|
+
of the live catalog are pruned from the managed profiles, so the Console never
|
|
203
|
+
offers a route the gateway no longer serves; if a pruned model was the
|
|
204
|
+
profile's default, discovery selects a current one instead.
|
|
203
205
|
|
|
204
206
|
You do not have to run the script manually: the Console dashboard's **Sync
|
|
205
207
|
data** button (and `POST /api/v1/sync` with `"refreshModels": true`) refreshes
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -15,7 +15,7 @@ dependencies = [
|
|
|
15
15
|
|
|
16
16
|
[tool.uv.sources]
|
|
17
17
|
# fork commit
|
|
18
|
-
garminconnect = { git = "https://github.com/rifusaki/python-garminconnect.git", rev = "
|
|
18
|
+
garminconnect = { git = "https://github.com/rifusaki/python-garminconnect.git", rev = "cd4e3d9ef81a93681950138e08f57af226fd5735" }
|
|
19
19
|
|
|
20
20
|
[tool.uv]
|
|
21
21
|
package = false
|
|
@@ -32,8 +32,13 @@ def parse_args() -> argparse.Namespace:
|
|
|
32
32
|
parser.add_argument(
|
|
33
33
|
"--lt-history-from",
|
|
34
34
|
dest="lt_history_from",
|
|
35
|
+
type=date.fromisoformat, help="Backfill lactate-threshold history from this date instead of the daily window.",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--events-from",
|
|
39
|
+
dest="events_from",
|
|
35
40
|
type=date.fromisoformat,
|
|
36
|
-
help="
|
|
41
|
+
help="Fetch calendar events from this date instead of the default short lookback.",
|
|
37
42
|
)
|
|
38
43
|
return parser.parse_args()
|
|
39
44
|
|
|
@@ -79,6 +84,7 @@ def main() -> None:
|
|
|
79
84
|
activity_to_date=args.activity_to_date,
|
|
80
85
|
include_non_historical=not args.historical_only,
|
|
81
86
|
lactate_threshold_history_from=args.lt_history_from,
|
|
87
|
+
events_from=args.events_from.isoformat() if args.events_from else None,
|
|
82
88
|
)
|
|
83
89
|
except WorkerInterrupted:
|
|
84
90
|
reporter.finish("interrupted")
|
|
@@ -109,6 +109,25 @@ def remote_id(payload: dict[str, Any], fallback: str) -> str:
|
|
|
109
109
|
return fallback
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
# Scheduled workouts are tiny calendar payloads (one GET per month), so a year
|
|
113
|
+
# of lookahead costs little next to a normal Garmin sync. Months back keep
|
|
114
|
+
# recently completed schedules associatable with their finished activities.
|
|
115
|
+
SCHEDULED_WORKOUTS_MONTHS_BACK = 3
|
|
116
|
+
SCHEDULED_WORKOUTS_MONTHS_AHEAD = 12
|
|
117
|
+
|
|
118
|
+
# Regular syncs reach back only far enough to associate recently finished
|
|
119
|
+
# events with their activities; deeper history arrives via the automatic
|
|
120
|
+
# coverage backfill (the parent passes --events-from when the store's own
|
|
121
|
+
# window predates this span).
|
|
122
|
+
EVENTS_PAST_DAYS = 30
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def shift_month(base: date, offset: int) -> date:
|
|
126
|
+
"""Return the first day of the month `offset` months from `base`'s month."""
|
|
127
|
+
total = base.year * 12 + (base.month - 1) + offset
|
|
128
|
+
return date(total // 12, total % 12 + 1, 1)
|
|
129
|
+
|
|
130
|
+
|
|
112
131
|
def payload_date(payload: Mapping[str, Any]) -> str | None:
|
|
113
132
|
"""Return the first ISO calendar day exposed by a Garmin payload."""
|
|
114
133
|
for key in ("calendarDate", "date", "until", "from", "timestamp", "timestampLocal", "startTimestampGMT"):
|
|
@@ -158,6 +177,7 @@ class GarminStagingWorker:
|
|
|
158
177
|
activity_to_date: date | None = None,
|
|
159
178
|
include_non_historical: bool = True,
|
|
160
179
|
lactate_threshold_history_from: date | None = None,
|
|
180
|
+
events_from: str | None = None,
|
|
161
181
|
) -> None:
|
|
162
182
|
assert_read_only_registry()
|
|
163
183
|
end = to_date or date.today()
|
|
@@ -192,7 +212,7 @@ class GarminStagingWorker:
|
|
|
192
212
|
self._activities(activity_from_date, activity_end)
|
|
193
213
|
if include_non_historical:
|
|
194
214
|
self._check_interrupted()
|
|
195
|
-
self._collections()
|
|
215
|
+
self._collections(events_from=events_from)
|
|
196
216
|
|
|
197
217
|
def _capture(self, endpoint: str, action: Callable[[], Any], remote_id_value: str | None = None, scope: dict[str, Any] | None = None) -> tuple[Any | None, str | None]:
|
|
198
218
|
try:
|
|
@@ -705,15 +725,33 @@ class GarminStagingWorker:
|
|
|
705
725
|
occurred_on=start_date[:10],
|
|
706
726
|
)
|
|
707
727
|
|
|
708
|
-
def
|
|
728
|
+
def _fetch_garmin_events(self, start_date: str) -> None:
|
|
729
|
+
"""Fetch calendar events from start_date, following pagination."""
|
|
730
|
+
page = 1
|
|
731
|
+
while True:
|
|
732
|
+
payload, raw_hash = self._capture(
|
|
733
|
+
"garmin_events",
|
|
734
|
+
lambda page=page: self.api.get_events(start_date, limit=100, page=page),
|
|
735
|
+
scope={"startDate": start_date, "page": page},
|
|
736
|
+
)
|
|
737
|
+
items = payload if isinstance(payload, list) else []
|
|
738
|
+
if items:
|
|
739
|
+
self._entity("garmin_events", "event", items, raw_hash)
|
|
740
|
+
if len(items) < 100:
|
|
741
|
+
return
|
|
742
|
+
page += 1
|
|
743
|
+
|
|
744
|
+
def _collections(self, events_from: str | None = None) -> None:
|
|
709
745
|
today = date.today()
|
|
710
746
|
calls = {
|
|
711
|
-
"workouts": ("get_workouts", (), "workout"),
|
|
747
|
+
"workouts": ("get_workouts", (), "workout"),
|
|
712
748
|
"goals": ("get_goals", (), "goal"), "golf_summary": ("get_golf_summary", (), "golf_scorecard"),
|
|
713
749
|
}
|
|
750
|
+
months = [shift_month(today, offset) for offset in range(-SCHEDULED_WORKOUTS_MONTHS_BACK, SCHEDULED_WORKOUTS_MONTHS_AHEAD + 1)]
|
|
751
|
+
events_start = events_from or (today - timedelta(days=EVENTS_PAST_DAYS)).isoformat()
|
|
714
752
|
if self.progress is not None:
|
|
715
753
|
self.progress.set_stage("collections")
|
|
716
|
-
self.progress.advance(total=len(calls))
|
|
754
|
+
self.progress.advance(total=len(calls) + len(months) + 1)
|
|
717
755
|
for index, (endpoint, (method, arguments, entity_type)) in enumerate(calls.items(), start=1):
|
|
718
756
|
self._check_interrupted()
|
|
719
757
|
if self.progress is not None:
|
|
@@ -723,3 +761,51 @@ class GarminStagingWorker:
|
|
|
723
761
|
self._entity(endpoint, entity_type, payload, raw_hash)
|
|
724
762
|
if self.progress is not None:
|
|
725
763
|
self.progress.advance(completed=index)
|
|
764
|
+
# Scheduled workouts span past-to-future months so upcoming sessions
|
|
765
|
+
# surface ahead of time and recently completed ones stay associatable
|
|
766
|
+
# with their finished activities. The calendar service answers each
|
|
767
|
+
# month with a grid wrapper whose items live under `calendarItems`;
|
|
768
|
+
# empty months must stage nothing, and real items stage one row each
|
|
769
|
+
# with their own calendar date.
|
|
770
|
+
for month_index, month in enumerate(months, start=1):
|
|
771
|
+
self._check_interrupted()
|
|
772
|
+
payload, raw_hash = self._capture(
|
|
773
|
+
"scheduled_workouts",
|
|
774
|
+
lambda month=month: self.api.get_scheduled_workouts(month.year, month.month),
|
|
775
|
+
scope={"month": month.isoformat()},
|
|
776
|
+
)
|
|
777
|
+
items = payload.get("calendarItems") if isinstance(payload, Mapping) else payload
|
|
778
|
+
# itemType 'activity' entries are echoes of already-recorded
|
|
779
|
+
# activities (the activities endpoint owns those); only true
|
|
780
|
+
# scheduled workout templates belong in this entity family.
|
|
781
|
+
if items:
|
|
782
|
+
items = [item for item in items if isinstance(item, Mapping) and item.get("itemType") == "workout"]
|
|
783
|
+
if items:
|
|
784
|
+
self._entity("scheduled_workouts", "scheduled_workout", items, raw_hash)
|
|
785
|
+
if self.progress is not None:
|
|
786
|
+
self.progress.advance(completed=len(calls) + month_index)
|
|
787
|
+
# Joined events (races etc.) look back only far enough to associate
|
|
788
|
+
# finished activities; the parent widens start_date into a one-time
|
|
789
|
+
# coverage backfill when the store predates this span.
|
|
790
|
+
self._check_interrupted()
|
|
791
|
+
if self.progress is not None:
|
|
792
|
+
self.progress.advance(step="garmin_events")
|
|
793
|
+
self._fetch_garmin_events(events_start)
|
|
794
|
+
if self.progress is not None:
|
|
795
|
+
self.progress.advance(completed=len(calls) + len(months) + 1)
|
|
796
|
+
|
|
797
|
+
def _fetch_garmin_events(self, start_date: str) -> None:
|
|
798
|
+
"""Fetch calendar events from start_date, following pagination."""
|
|
799
|
+
page = 1
|
|
800
|
+
while True:
|
|
801
|
+
payload, raw_hash = self._capture(
|
|
802
|
+
"garmin_events",
|
|
803
|
+
lambda page=page: self.api.get_events(start_date, limit=100, page=page),
|
|
804
|
+
scope={"startDate": start_date, "page": page},
|
|
805
|
+
)
|
|
806
|
+
items = payload if isinstance(payload, list) else []
|
|
807
|
+
if items:
|
|
808
|
+
self._entity("garmin_events", "event", items, raw_hash)
|
|
809
|
+
if len(items) < 100:
|
|
810
|
+
return
|
|
811
|
+
page += 1
|
package/uv.lock
CHANGED
|
@@ -47,7 +47,7 @@ dev = [
|
|
|
47
47
|
requires-dist = [
|
|
48
48
|
{ name = "duckdb", specifier = ">=1.4.0" },
|
|
49
49
|
{ name = "fitdecode", specifier = ">=0.10.0" },
|
|
50
|
-
{ name = "garminconnect", git = "https://github.com/rifusaki/python-garminconnect.git?rev=
|
|
50
|
+
{ name = "garminconnect", git = "https://github.com/rifusaki/python-garminconnect.git?rev=cd4e3d9ef81a93681950138e08f57af226fd5735" },
|
|
51
51
|
{ name = "polars", specifier = ">=1.0.0" },
|
|
52
52
|
{ name = "pyarrow", specifier = ">=18.0.0" },
|
|
53
53
|
{ name = "pydantic", specifier = ">=2.0.0" },
|
|
@@ -318,8 +318,8 @@ wheels = [
|
|
|
318
318
|
|
|
319
319
|
[[package]]
|
|
320
320
|
name = "garminconnect"
|
|
321
|
-
version = "0.3.
|
|
322
|
-
source = { git = "https://github.com/rifusaki/python-garminconnect.git?rev=
|
|
321
|
+
version = "0.3.12"
|
|
322
|
+
source = { git = "https://github.com/rifusaki/python-garminconnect.git?rev=cd4e3d9ef81a93681950138e08f57af226fd5735#cd4e3d9ef81a93681950138e08f57af226fd5735" }
|
|
323
323
|
dependencies = [
|
|
324
324
|
{ name = "curl-cffi" },
|
|
325
325
|
{ name = "requests" },
|