catence 0.2.1-beta.15 → 0.2.1-beta.17

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.
@@ -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.1-beta.15";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.17";
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.1-beta.15';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.17';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -128,12 +128,30 @@ export async function buildRetrievalIndex(database) {
128
128
  await database.run('BEGIN TRANSACTION');
129
129
  try {
130
130
  await database.run('DELETE FROM retrieval_documents');
131
- for (const document of deduplicated.values()) {
132
- const id = documentId(document);
133
- await database.run(`INSERT INTO retrieval_documents (document_id, entity_type, entity_id, activity_source_id, provider, occurred_on, document_text, metadata_json, content_hash)
134
- VALUES ($id, $entityType, $entityId, $activitySourceId, $provider, $occurredOn, $text, $metadata, $hash)`, {
135
- id, entityType: document.entityType, entityId: document.entityId, activitySourceId: document.activitySourceId ?? null, provider: document.provider ?? null, occurredOn: document.occurredOn || null, text: document.text, metadata: JSON.stringify(document.metadata), hash: id,
131
+ // Multi-row INSERT batches: one statement per 500 documents instead of
132
+ // one round trip per document keeps a full 125k-document rebuild from
133
+ // being dominated by per-statement overhead.
134
+ const BATCH_SIZE = 500;
135
+ const allDocuments = [...deduplicated.values()];
136
+ for (let offset = 0; offset < allDocuments.length; offset += BATCH_SIZE) {
137
+ const batch = allDocuments.slice(offset, offset + BATCH_SIZE);
138
+ const values = {};
139
+ const tuples = batch.map((document, index) => {
140
+ const id = documentId(document);
141
+ const prefix = `d${index}`;
142
+ values[`${prefix}id`] = id;
143
+ values[`${prefix}entityType`] = document.entityType;
144
+ values[`${prefix}entityId`] = document.entityId;
145
+ values[`${prefix}activitySourceId`] = document.activitySourceId ?? null;
146
+ values[`${prefix}provider`] = document.provider ?? null;
147
+ values[`${prefix}occurredOn`] = document.occurredOn || null;
148
+ values[`${prefix}text`] = document.text;
149
+ values[`${prefix}metadata`] = JSON.stringify(document.metadata);
150
+ values[`${prefix}hash`] = id;
151
+ return `($${prefix}id, $${prefix}entityType, $${prefix}entityId, $${prefix}activitySourceId, $${prefix}provider, $${prefix}occurredOn, $${prefix}text, $${prefix}metadata, $${prefix}hash)`;
136
152
  });
153
+ await database.run(`INSERT INTO retrieval_documents (document_id, entity_type, entity_id, activity_source_id, provider, occurred_on, document_text, metadata_json, content_hash)
154
+ VALUES ${tuples.join(', ')}`, values);
137
155
  }
138
156
  await database.run(`INSERT INTO retrieval_index_state (index_name, status, mode, source_watermark, built_at, detail_json)
139
157
  VALUES ('context', 'ready', $mode, $watermark, now(), $detail)
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod';
2
+ import { type CatalogPaths } from '../../core/runtime/catalog.js';
3
+ import type { CatencePaths } from '../../contracts/runtime.js';
4
+ import { startDetachedSync, type DetachedSyncHandle } from './detached-sync.js';
5
+ /** The sync cadences the scheduler accepts, in minutes. */
6
+ export declare const AUTO_SYNC_INTERVAL_MINUTES: readonly [15, 30, 60, 120, 360, 720, 1440];
7
+ export type AutoSyncIntervalMinutes = (typeof AUTO_SYNC_INTERVAL_MINUTES)[number];
8
+ /** Auto-sync is on by default at 2h: fresh data several times a day, far inside provider limits. */
9
+ export declare const DEFAULT_AUTO_SYNC_INTERVAL_MINUTES: AutoSyncIntervalMinutes;
10
+ declare const autoSyncFileSchema: z.ZodObject<{
11
+ enabled: z.ZodBoolean;
12
+ intervalMinutes: z.ZodNumber & z.ZodType<15 | 60 | 30 | 360 | 120 | 720 | 1440, number, z.core.$ZodTypeInternals<15 | 60 | 30 | 360 | 120 | 720 | 1440, number>>;
13
+ lastTriggeredAt: z.ZodDefault<z.ZodNullable<z.ZodString>>;
14
+ lastLogFile: z.ZodDefault<z.ZodNullable<z.ZodString>>;
15
+ }, z.core.$strict>;
16
+ export type AutoSyncConfig = z.infer<typeof autoSyncFileSchema>;
17
+ export type AutoSyncSchedule = {
18
+ athleteId: string;
19
+ enabled: boolean;
20
+ intervalMinutes: AutoSyncIntervalMinutes;
21
+ intervalOptions: ReadonlyArray<{
22
+ minutes: AutoSyncIntervalMinutes;
23
+ label: string;
24
+ }>;
25
+ lastTriggeredAt: string | null;
26
+ lastLogFile: string | null;
27
+ nextRunAt: string | null;
28
+ };
29
+ export declare function autoSyncFilePath(paths: CatencePaths): string;
30
+ /**
31
+ * Parse one of the interval labels the UI and CLI accept (`15m`, `2h`, `24h`)
32
+ * or a bare minute count into a valid cadence. Anything else is rejected at
33
+ * the boundary so stored state can never hold an unsupported interval.
34
+ */
35
+ export declare function parseAutoSyncInterval(value: string): AutoSyncIntervalMinutes;
36
+ /**
37
+ * Read the athlete's schedule. A missing file means "on by default": the
38
+ * effective schedule is reported without writing anything, so a fresh store
39
+ * stays pristine until the first explicit change (the scheduler does write
40
+ * trigger bookkeeping once it actually fires).
41
+ */
42
+ export declare function readAutoSyncSchedule(paths: CatencePaths, athleteId: string): Promise<AutoSyncSchedule>;
43
+ /**
44
+ * Apply an explicit schedule change (UI or CLI). Only the requested fields
45
+ * change; trigger bookkeeping survives an interval switch or a pause.
46
+ */
47
+ export declare function updateAutoSyncSchedule(paths: CatencePaths, athleteId: string, change: {
48
+ enabled?: boolean;
49
+ intervalMinutes?: AutoSyncIntervalMinutes;
50
+ }): Promise<AutoSyncSchedule>;
51
+ export type AutoSyncSchedulerOptions = {
52
+ catalogPaths: CatalogPaths;
53
+ /** Internal compatibility hook for tests. */
54
+ startSync?: (request: Parameters<typeof startDetachedSync>[0]) => Promise<DetachedSyncHandle>;
55
+ now?: () => Date;
56
+ /** How often due schedules are checked; default every 30 seconds. */
57
+ tickMs?: number;
58
+ };
59
+ export type AutoSyncScheduler = {
60
+ stop(): void;
61
+ };
62
+ /**
63
+ * The auto-sync scheduler: a periodic timer that triggers a detached sync for
64
+ * every athlete whose schedule is due. It opens no database connection and
65
+ * takes no locks — the detached child and the single-writer DuckDB lock stay
66
+ * the only sync executors, so an already-running sync simply makes the tick a
67
+ * no-op (DetachedSyncBusyError is skipped quietly and retried on a later
68
+ * tick, once the active run has finished).
69
+ */
70
+ export declare function startAutoSyncScheduler(options: AutoSyncSchedulerOptions): AutoSyncScheduler;
71
+ export {};
@@ -0,0 +1,173 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { athleteStorePaths, loadCatalog } from '../../core/runtime/catalog.js';
5
+ import { createSyncLogger } from '../../core/logging.js';
6
+ import { DetachedSyncBusyError, startDetachedSync } from './detached-sync.js';
7
+ /** The sync cadences the scheduler accepts, in minutes. */
8
+ export const AUTO_SYNC_INTERVAL_MINUTES = [15, 30, 60, 120, 360, 720, 1440];
9
+ /** Auto-sync is on by default at 2h: fresh data several times a day, far inside provider limits. */
10
+ export const DEFAULT_AUTO_SYNC_INTERVAL_MINUTES = 120;
11
+ const INTERVAL_CHOICE_LABELS = new Map([
12
+ [15, '15m'], [30, '30m'], [60, '1h'], [120, '2h'], [360, '6h'], [720, '12h'], [1440, '24h'],
13
+ ]);
14
+ const autoSyncFileSchema = z.object({
15
+ enabled: z.boolean(),
16
+ intervalMinutes: z.number().int().refine((value) => AUTO_SYNC_INTERVAL_MINUTES.includes(value), 'intervalMinutes must be one of 15, 30, 60, 120, 360, 720, 1440.'),
17
+ lastTriggeredAt: z.string().datetime().nullable().default(null),
18
+ lastLogFile: z.string().nullable().default(null),
19
+ }).strict();
20
+ export function autoSyncFilePath(paths) {
21
+ return path.join(paths.root, 'autosync.json');
22
+ }
23
+ /**
24
+ * Parse one of the interval labels the UI and CLI accept (`15m`, `2h`, `24h`)
25
+ * or a bare minute count into a valid cadence. Anything else is rejected at
26
+ * the boundary so stored state can never hold an unsupported interval.
27
+ */
28
+ export function parseAutoSyncInterval(value) {
29
+ const trimmed = value.trim().toLowerCase();
30
+ const match = /^(\d+)(m|h)$/.exec(trimmed);
31
+ const minutes = match ? (match[2] === 'h' ? Number(match[1]) * 60 : Number(match[1])) : Number(trimmed);
32
+ if (Number.isInteger(minutes) && AUTO_SYNC_INTERVAL_MINUTES.includes(minutes)) {
33
+ return minutes;
34
+ }
35
+ throw new Error(`interval must be one of: ${[...INTERVAL_CHOICE_LABELS.values()].join(', ')}.`);
36
+ }
37
+ function defaultConfig() {
38
+ return { enabled: true, intervalMinutes: DEFAULT_AUTO_SYNC_INTERVAL_MINUTES, lastTriggeredAt: null, lastLogFile: null };
39
+ }
40
+ function nextRunAt(config) {
41
+ if (!config.enabled || !config.lastTriggeredAt)
42
+ return null;
43
+ return new Date(Date.parse(config.lastTriggeredAt) + config.intervalMinutes * 60_000).toISOString();
44
+ }
45
+ function toSchedule(athleteId, config) {
46
+ return {
47
+ athleteId,
48
+ enabled: config.enabled,
49
+ intervalMinutes: config.intervalMinutes,
50
+ intervalOptions: AUTO_SYNC_INTERVAL_MINUTES.map((minutes) => ({ minutes, label: INTERVAL_CHOICE_LABELS.get(minutes) ?? `${minutes}m` })),
51
+ lastTriggeredAt: config.lastTriggeredAt,
52
+ lastLogFile: config.lastLogFile,
53
+ nextRunAt: nextRunAt(config),
54
+ };
55
+ }
56
+ function isMissingFile(error) {
57
+ return typeof error === 'object' && error !== null && error.code === 'ENOENT';
58
+ }
59
+ async function readEffectiveConfig(paths) {
60
+ let raw;
61
+ try {
62
+ raw = await readFile(autoSyncFilePath(paths), 'utf8');
63
+ }
64
+ catch (error) {
65
+ if (!isMissingFile(error))
66
+ throw error;
67
+ return defaultConfig();
68
+ }
69
+ const parsed = autoSyncFileSchema.safeParse(JSON.parse(raw));
70
+ if (!parsed.success) {
71
+ throw new Error(`autosync.json is not a valid auto-sync schedule (${parsed.error.issues[0]?.message ?? 'unknown issue'}).`);
72
+ }
73
+ return parsed.data;
74
+ }
75
+ /**
76
+ * Read the athlete's schedule. A missing file means "on by default": the
77
+ * effective schedule is reported without writing anything, so a fresh store
78
+ * stays pristine until the first explicit change (the scheduler does write
79
+ * trigger bookkeeping once it actually fires).
80
+ */
81
+ export async function readAutoSyncSchedule(paths, athleteId) {
82
+ return toSchedule(athleteId, await readEffectiveConfig(paths));
83
+ }
84
+ async function writeAutoSyncConfig(paths, config) {
85
+ const file = autoSyncFilePath(paths);
86
+ // A freshly added athlete has no store directory yet; the schedule file is
87
+ // often its first artifact.
88
+ await mkdir(path.dirname(file), { recursive: true });
89
+ const temporary = `${file}.${process.pid}.tmp`;
90
+ await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
91
+ await rename(temporary, file);
92
+ }
93
+ /**
94
+ * Apply an explicit schedule change (UI or CLI). Only the requested fields
95
+ * change; trigger bookkeeping survives an interval switch or a pause.
96
+ */
97
+ export async function updateAutoSyncSchedule(paths, athleteId, change) {
98
+ const current = await readEffectiveConfig(paths);
99
+ const updated = {
100
+ ...current,
101
+ ...(change.enabled === undefined ? {} : { enabled: change.enabled }),
102
+ ...(change.intervalMinutes === undefined ? {} : { intervalMinutes: change.intervalMinutes }),
103
+ };
104
+ await writeAutoSyncConfig(paths, updated);
105
+ return toSchedule(athleteId, updated);
106
+ }
107
+ /** Record that the scheduler fired (or attempted to fire) for this athlete. */
108
+ async function recordTrigger(paths, logFile, at) {
109
+ const config = await readEffectiveConfig(paths);
110
+ await writeAutoSyncConfig(paths, { ...config, lastTriggeredAt: at.toISOString(), lastLogFile: logFile });
111
+ }
112
+ /**
113
+ * The auto-sync scheduler: a periodic timer that triggers a detached sync for
114
+ * every athlete whose schedule is due. It opens no database connection and
115
+ * takes no locks — the detached child and the single-writer DuckDB lock stay
116
+ * the only sync executors, so an already-running sync simply makes the tick a
117
+ * no-op (DetachedSyncBusyError is skipped quietly and retried on a later
118
+ * tick, once the active run has finished).
119
+ */
120
+ export function startAutoSyncScheduler(options) {
121
+ const startSync = options.startSync ?? startDetachedSync;
122
+ const now = options.now ?? (() => new Date());
123
+ const tickMs = options.tickMs ?? 30_000;
124
+ let ticking = false;
125
+ const tick = async () => {
126
+ if (ticking)
127
+ return; // Never overlap ticks; the in-flight one wins.
128
+ ticking = true;
129
+ try {
130
+ const catalog = await loadCatalog(options.catalogPaths);
131
+ await Promise.allSettled(catalog.athletes.map(async (athlete) => {
132
+ const paths = athleteStorePaths(options.catalogPaths, athlete.id);
133
+ const log = createSyncLogger(paths);
134
+ try {
135
+ const config = await readEffectiveConfig(paths);
136
+ if (!config.enabled)
137
+ return;
138
+ const dueAt = config.lastTriggeredAt ? Date.parse(config.lastTriggeredAt) + config.intervalMinutes * 60_000 : 0;
139
+ if (now().getTime() < dueAt)
140
+ return;
141
+ const handle = await startSync({ paths, athleteId: athlete.id, provider: 'all', cliHome: options.catalogPaths.root });
142
+ await recordTrigger(paths, handle.logFile, now());
143
+ log.info('Auto-sync triggered', { athleteId: athlete.id, intervalMinutes: config.intervalMinutes, logFile: handle.logFile });
144
+ }
145
+ catch (error) {
146
+ if (error instanceof DetachedSyncBusyError)
147
+ return; // A run is active; retry on a later tick.
148
+ // Any other failure is recorded as a trigger attempt so a broken
149
+ // store cannot be hammered every tick; the next cadence retries.
150
+ await recordTrigger(paths, null, now()).catch(() => undefined);
151
+ log.error('Auto-sync trigger failed', { athleteId: athlete.id, error: error instanceof Error ? error.message : String(error) });
152
+ }
153
+ }));
154
+ }
155
+ catch (error) {
156
+ process.stderr.write(`[catence] Auto-sync tick failed: ${error instanceof Error ? error.message : String(error)}\n`);
157
+ }
158
+ finally {
159
+ ticking = false;
160
+ }
161
+ };
162
+ const timer = setInterval(() => { void tick(); }, tickMs);
163
+ timer.unref?.();
164
+ // The first tick is immediate: a restart should heal stale data within a
165
+ // minute instead of waiting a full interval.
166
+ void tick();
167
+ return {
168
+ stop() {
169
+ clearInterval(timer);
170
+ },
171
+ };
172
+ }
173
+ //# sourceMappingURL=autosync.js.map
@@ -114,9 +114,16 @@ async function backfillGarminSwimLengths(database, paths, parentRunId, log) {
114
114
  }
115
115
  // The swim list is queried here and handed to the Python worker as a file:
116
116
  // the child must never open DuckDB itself because the parent holds the
117
- // write lock for the whole run.
117
+ // write lock for the whole run. Only activities without per-length rows are
118
+ // handed over: re-parsing every archived FIT on every sync cost ~23s/run
119
+ // with nothing new to stage.
118
120
  const activitiesFile = path.join(paths.staging, 'garmin', `${parentRunId}.swim_activities.json`);
119
- await writeFile(activitiesFile, JSON.stringify(await database.garminSwimActivitySources()));
121
+ const pendingSwims = await database.garminSwimActivitySourcesWithoutLengths();
122
+ if (pendingSwims.length === 0) {
123
+ log.info('Garmin swim backfill skipped: every swim activity already has lengths', { parentRunId });
124
+ return;
125
+ }
126
+ await writeFile(activitiesFile, JSON.stringify(pendingSwims));
120
127
  const arguments_ = [
121
128
  'run',
122
129
  'python',
@@ -1,7 +1,17 @@
1
1
  import type { CatenceDatabase } from '../storage/database.js';
2
2
  import { type StagingRecord } from '../../contracts/staging.js';
3
3
  import type { SyncLogger } from '../../core/logging.js';
4
- export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord, log?: SyncLogger): Promise<void>;
4
+ /**
5
+ * Per-pass import state. When present, activity-quality refreshes are
6
+ * deferred to the end of the pass: a staging file lists an activity before
7
+ * its detail records, so refreshing inline would compute flags from partial
8
+ * data, and one refresh per activity is enough regardless of how many
9
+ * detail entity types follow it.
10
+ */
11
+ export type ImportPassContext = {
12
+ deferredQualityActivityIds: Set<string>;
13
+ };
14
+ export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord, log?: SyncLogger, pass?: ImportPassContext): Promise<void>;
5
15
  export declare function importJsonl(database: CatenceDatabase, runId: string, filePath: string, log?: SyncLogger): Promise<number>;
6
16
  export declare function importSingleStagingFile(database: CatenceDatabase, runId: string, filePath: string): Promise<number>;
7
17
  export declare function queueWorkItem(database: CatenceDatabase, runId: string, provider: 'intervals' | 'garmin' | 'strava', endpoint: string, remoteId: string | null, scope: unknown): Promise<string>;
@@ -7,7 +7,12 @@ 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, log) {
10
+ const GARMIN_ACTIVITY_DETAIL_ENTITY_TYPES = ['activity_detail', 'activity_interval', 'activity_exercise_set', 'activity_swim_lengths'];
11
+ async function activityIdForSource(database, activitySourceId) {
12
+ const activity = await database.rows('SELECT activity_id FROM activity_sources WHERE activity_source_id = $activitySourceId', { activitySourceId });
13
+ return activity[0]?.activity_id ?? null;
14
+ }
15
+ export async function importRecord(database, runId, record, log, pass) {
11
16
  if (record.kind === 'run_manifest') {
12
17
  if (record.runId !== runId)
13
18
  throw new Error(`Staging manifest run ${record.runId} does not match importer run ${runId}.`);
@@ -29,14 +34,14 @@ export async function importRecord(database, runId, record, log) {
29
34
  await importSourceEntity(database, record);
30
35
  if (record.entityType === 'activity') {
31
36
  await reconcileActivityLink(database, record.provider + ':' + record.remoteId);
32
- const activity = await database.rows('SELECT activity_id FROM activity_sources WHERE activity_source_id = $activitySourceId', { activitySourceId: record.provider + ':' + record.remoteId });
33
- if (activity[0]?.activity_id)
34
- await refreshActivityQuality(database, activity[0].activity_id);
37
+ const activityId = await activityIdForSource(database, record.provider + ':' + record.remoteId);
38
+ if (activityId)
39
+ await queueActivityQualityRefresh(database, activityId, pass);
35
40
  }
36
- if (record.provider === 'garmin' && ['activity_detail', 'activity_interval', 'activity_exercise_set', 'activity_swim_lengths'].includes(record.entityType) && record.parentRemoteId) {
37
- const activity = await database.rows('SELECT activity_id FROM activity_sources WHERE activity_source_id = $activitySourceId', { activitySourceId: `garmin:${record.parentRemoteId}` });
38
- if (activity[0]?.activity_id)
39
- await refreshActivityQuality(database, activity[0].activity_id);
41
+ if (record.provider === 'garmin' && GARMIN_ACTIVITY_DETAIL_ENTITY_TYPES.includes(record.entityType) && record.parentRemoteId) {
42
+ const activityId = await activityIdForSource(database, `garmin:${record.parentRemoteId}`);
43
+ if (activityId)
44
+ await queueActivityQualityRefresh(database, activityId, pass);
40
45
  }
41
46
  return;
42
47
  }
@@ -56,17 +61,61 @@ export async function importRecord(database, runId, record, log) {
56
61
  await database.addError(runId, record.provider, record.endpoint, record.remoteId, record.message, record.retryable);
57
62
  log?.warn('Staging record reported an extraction error', { runId, provider: record.provider, endpoint: record.endpoint, remoteId: record.remoteId, error: record.message });
58
63
  }
64
+ /**
65
+ * Refresh now (single-record callers) or defer to the end of the pass
66
+ * (bulk importJsonl callers) — see ImportPassContext.
67
+ */
68
+ async function queueActivityQualityRefresh(database, activityId, pass) {
69
+ if (pass) {
70
+ pass.deferredQualityActivityIds.add(activityId);
71
+ return;
72
+ }
73
+ await refreshActivityQuality(database, activityId);
74
+ }
75
+ /** Records per transaction: large enough to amortize DuckDB commits, small enough to bound a mid-file failure. */
76
+ const IMPORT_CHUNK_SIZE = 200;
59
77
  export async function importJsonl(database, runId, filePath, log) {
60
78
  let count = 0;
79
+ const pass = { deferredQualityActivityIds: new Set() };
61
80
  const file = createReadStream(filePath, { encoding: 'utf8' });
62
81
  const lines = createInterface({ input: file, crlfDelay: Infinity });
63
- for await (const line of lines) {
64
- if (!line.trim())
65
- continue;
66
- const record = stagingRecordSchema.parse(JSON.parse(line));
67
- await importRecord(database, runId, record, log);
68
- count += 1;
82
+ let inTransaction = false;
83
+ const startedAt = Date.now();
84
+ try {
85
+ for await (const line of lines) {
86
+ if (!line.trim())
87
+ continue;
88
+ if (count % IMPORT_CHUNK_SIZE === 0) {
89
+ await database.run('BEGIN TRANSACTION');
90
+ inTransaction = true;
91
+ }
92
+ const record = stagingRecordSchema.parse(JSON.parse(line));
93
+ await importRecord(database, runId, record, log, pass);
94
+ count += 1;
95
+ if (count % IMPORT_CHUNK_SIZE === 0) {
96
+ await database.run('COMMIT');
97
+ inTransaction = false;
98
+ }
99
+ }
100
+ }
101
+ catch (error) {
102
+ if (inTransaction)
103
+ await database.run('ROLLBACK');
104
+ throw error;
105
+ }
106
+ if (inTransaction) {
107
+ await database.run('COMMIT');
108
+ inTransaction = false;
109
+ }
110
+ const qualityStartedAt = Date.now();
111
+ for (const activityId of pass.deferredQualityActivityIds) {
112
+ await refreshActivityQuality(database, activityId);
69
113
  }
114
+ log?.info('Staging import finished with stage timings', {
115
+ runId, records: count, durationMs: Date.now() - startedAt,
116
+ qualityRefreshes: pass.deferredQualityActivityIds.size,
117
+ qualityRefreshMs: Date.now() - qualityStartedAt,
118
+ });
70
119
  return count;
71
120
  }
72
121
  export async function importSingleStagingFile(database, runId, filePath) {
@@ -27,6 +27,13 @@ export declare class IntervalsExtractor {
27
27
  private captureActivity;
28
28
  private capture;
29
29
  private importEntities;
30
+ /**
31
+ * Intervals.icu enforces 2500 requests per rolling 15 minutes and 5000 per
32
+ * day; over-limit calls answer 429 with a Retry-After header. Back off
33
+ * cooperatively and retry a bounded number of times so one auto-sync run
34
+ * degrades instead of recording a wall of retryable errors.
35
+ */
36
+ private request;
30
37
  private getJson;
31
38
  private getBinary;
32
39
  }
@@ -243,14 +243,33 @@ export class IntervalsExtractor {
243
243
  }
244
244
  }
245
245
  }
246
+ /**
247
+ * Intervals.icu enforces 2500 requests per rolling 15 minutes and 5000 per
248
+ * day; over-limit calls answer 429 with a Retry-After header. Back off
249
+ * cooperatively and retry a bounded number of times so one auto-sync run
250
+ * degrades instead of recording a wall of retryable errors.
251
+ */
252
+ async request(route, accept) {
253
+ for (let attempt = 0;; attempt++) {
254
+ const headers = { Authorization: basicAuth(this.apiKey) };
255
+ if (accept)
256
+ headers.Accept = accept;
257
+ const response = await fetch(`${apiBaseUrl}${route}`, { headers });
258
+ if (response.status !== 429 || attempt >= 2)
259
+ return response;
260
+ const retryAfterSeconds = Math.min(Math.max(Number(response.headers.get('retry-after')) || 30, 1), 300);
261
+ this.log.warn('Intervals rate limit hit; backing off', { route, retryAfterSeconds, attempt: attempt + 1 });
262
+ await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1_000));
263
+ }
264
+ }
246
265
  async getJson(route) {
247
- const response = await fetch(`${apiBaseUrl}${route}`, { headers: { Authorization: basicAuth(this.apiKey), Accept: 'application/json' } });
266
+ const response = await this.request(route, 'application/json');
248
267
  if (!response.ok)
249
268
  throw new Error(`Intervals ${route} failed with HTTP ${response.status}`);
250
269
  return response.json();
251
270
  }
252
271
  async getBinary(route) {
253
- const response = await fetch(`${apiBaseUrl}${route}`, { headers: { Authorization: basicAuth(this.apiKey) } });
272
+ const response = await this.request(route, null);
254
273
  if (!response.ok)
255
274
  throw new Error(`Intervals ${route} failed with HTTP ${response.status}`);
256
275
  return new Uint8Array(await response.arrayBuffer());
@@ -60,7 +60,12 @@ export declare class CatenceDatabase implements WriteDataStore {
60
60
  knownActivitySummaryHashes(provider: Provider): Promise<Record<string, string>>;
61
61
  hasGarminLactateThresholdHistory(): Promise<boolean>;
62
62
  earliestActivityStartDate(provider: Provider): Promise<string | null>;
63
- garminSwimActivitySources(): Promise<Array<{
63
+ /**
64
+ * Swim activities that still lack per-length rows: the FIT backfill only
65
+ * has work to do for these, so syncs skip re-parsing already-derived
66
+ * archives entirely.
67
+ */
68
+ garminSwimActivitySourcesWithoutLengths(): Promise<Array<{
64
69
  remote_activity_id: string;
65
70
  started_on: string | null;
66
71
  }>>;
@@ -319,13 +319,19 @@ export class CatenceDatabase {
319
319
  JOIN activity_sources source USING (activity_id) WHERE source.provider = $provider`, { provider });
320
320
  return toIsoDate(rows[0]?.earliest ?? null);
321
321
  }
322
- async garminSwimActivitySources() {
322
+ /**
323
+ * Swim activities that still lack per-length rows: the FIT backfill only
324
+ * has work to do for these, so syncs skip re-parsing already-derived
325
+ * archives entirely.
326
+ */
327
+ async garminSwimActivitySourcesWithoutLengths() {
323
328
  const sports = ['lap_swimming', 'swimming', 'open_water_swimming', 'openwaterswimming'];
324
329
  return this.rows(`SELECT source.remote_activity_id, cast(activity.started_at_utc AS VARCHAR) AS started_on
325
330
  FROM activity_sources AS source
326
331
  JOIN activities AS activity USING (activity_id)
327
332
  WHERE source.provider = 'garmin'
328
333
  AND lower(coalesce(activity.sport, '')) IN (${sports.map((sport) => `'${sport}'`).join(', ')})
334
+ AND NOT EXISTS (SELECT 1 FROM swim_lengths WHERE swim_lengths.activity_source_id = source.activity_source_id)
329
335
  ORDER BY activity.started_at_utc`);
330
336
  }
331
337
  async latestSourceDate(provider, cursorName) {
@@ -3,7 +3,7 @@ import { createInterface } from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
4
  import { Command } from 'commander';
5
5
  import { defaultBearerTokenPath, disableBearerToken, ensureBearerToken, mcpExposureStatus, readBearerToken, } from '../http/cli-options.js';
6
- import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, CatenceDatabase, channelForVersion, createDemoStore, dataStatus, deleteAthleteSecret, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, reimportWeight, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
6
+ import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, CatenceDatabase, channelForVersion, createDemoStore, dataStatus, deleteAthleteSecret, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, parseAutoSyncInterval, planSelfUpdate, readAutoSyncSchedule, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, reimportWeight, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, updateAutoSyncSchedule, } from '../../runtime/index.js';
7
7
  const program = new Command()
8
8
  .name('catence-data')
9
9
  .description('Athlete-scoped Catence data setup, extraction, and normalization.')
@@ -166,6 +166,27 @@ program.command('retry')
166
166
  const { paths } = await currentAthlete();
167
167
  process.stdout.write(`${JSON.stringify(await retryDataSync(paths, options.run), null, 2)}\n`);
168
168
  });
169
+ const autoSyncCommand = program.command('auto-sync').description('Manage the automatic sync schedule for one athlete. The scheduler runs inside the Catence server (default: enabled, every 2 hours).');
170
+ autoSyncCommand.command('status')
171
+ .description('Report the athlete’s automatic sync schedule: enabled flag, interval, and next run.')
172
+ .action(async () => {
173
+ const { athlete, paths } = await currentAthlete();
174
+ process.stdout.write(`${JSON.stringify(await readAutoSyncSchedule(paths, athlete.id), null, 2)}\n`);
175
+ });
176
+ autoSyncCommand.command('enable')
177
+ .description('Turn automatic syncing on, optionally changing the interval (15m, 30m, 1h, 2h, 6h, 12h, 24h).')
178
+ .option('--interval <interval>', 'sync interval label or minutes', '2h')
179
+ .action(async (options) => {
180
+ const { athlete, paths } = await currentAthlete();
181
+ const intervalMinutes = parseAutoSyncInterval(options.interval);
182
+ process.stdout.write(`${JSON.stringify(await updateAutoSyncSchedule(paths, athlete.id, { enabled: true, intervalMinutes }), null, 2)}\n`);
183
+ });
184
+ autoSyncCommand.command('disable')
185
+ .description('Turn automatic syncing off. Manual sync keeps working.')
186
+ .action(async () => {
187
+ const { athlete, paths } = await currentAthlete();
188
+ process.stdout.write(`${JSON.stringify(await updateAutoSyncSchedule(paths, athlete.id, { enabled: false }), null, 2)}\n`);
189
+ });
169
190
  program.command('auth').description('Connect a source account using one athlete’s local credentials.')
170
191
  .command('strava')
171
192
  .option('--code <authorization-code>', 'OAuth authorization code returned by Strava')
@@ -2,7 +2,7 @@ import { timingSafeEqual } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
3
  import path from 'node:path';
4
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
- import { addAthlete, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, catenceRuntimeHealth, DashboardSnapshotService, deleteAthleteSecret, describeAthleteSecrets, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, listAthleteFileRevisions, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, readAthleteFile, readAthleteFileRevision, resolveAthlete, resolveCatalogPaths, setAthleteSecret, startDetachedSync, syncProgress, updateAthleteFile, } from '../../runtime/index.js';
5
+ import { addAthlete, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, catenceRuntimeHealth, DashboardSnapshotService, deleteAthleteSecret, describeAthleteSecrets, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, listAthleteFileRevisions, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, parseAutoSyncInterval, readAutoSyncSchedule, readAthleteFile, readAthleteFileRevision, resolveAthlete, resolveCatalogPaths, setAthleteSecret, startDetachedSync, syncProgress, updateAthleteFile, updateAutoSyncSchedule, } from '../../runtime/index.js';
6
6
  import { activeBearerToken, ensureBearerToken, disableBearerToken, mcpExposureStatus } from './cli-options.js';
7
7
  import { createCatenceMcpServer } from '../mcp/server.js';
8
8
  import { generationStatus } from './generation-status.js';
@@ -103,6 +103,34 @@ async function dashboardPaths(catalogPaths, staticPaths, url) {
103
103
  return staticPaths;
104
104
  throw new Error('No Catence data store is configured.');
105
105
  }
106
+ function parseSyncScheduleBody(body) {
107
+ if (body === undefined)
108
+ return {};
109
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
110
+ throw new Error('Request body must be a JSON object.');
111
+ const raw = body;
112
+ for (const key of Object.keys(raw)) {
113
+ if (!['athleteId', 'enabled', 'intervalMinutes'].includes(key)) {
114
+ throw new Error(`Unknown auto-sync field: ${key}.`);
115
+ }
116
+ }
117
+ if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
118
+ throw new Error('athleteId must be a string.');
119
+ if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean')
120
+ throw new Error('enabled must be a boolean.');
121
+ if (raw.intervalMinutes !== undefined) {
122
+ if (typeof raw.intervalMinutes === 'string') {
123
+ raw.intervalMinutes = parseAutoSyncInterval(raw.intervalMinutes);
124
+ }
125
+ else if (typeof raw.intervalMinutes !== 'number') {
126
+ throw new Error('intervalMinutes must be a number or a label like 15m, 2h, 24h.');
127
+ }
128
+ }
129
+ if (raw.enabled === undefined && raw.intervalMinutes === undefined) {
130
+ throw new Error('Provide enabled, intervalMinutes, or both.');
131
+ }
132
+ return raw;
133
+ }
106
134
  function parseSyncBody(body) {
107
135
  if (body === undefined)
108
136
  return {};
@@ -548,6 +576,34 @@ export function createCatenceHttpServer(options = {}) {
548
576
  }
549
577
  return;
550
578
  }
579
+ if (pathname === '/api/v1/sync/schedule' && request.method === 'GET') {
580
+ try {
581
+ const url = new URL(request.url ?? '/', 'http://localhost');
582
+ const paths = await dashboardPaths(catalogPaths, staticPaths, url);
583
+ const athleteId = catalogPaths ? (url.searchParams.get('athleteId') ?? '') : 'local';
584
+ json(response, 200, await readAutoSyncSchedule(paths, athleteId));
585
+ }
586
+ catch (error) {
587
+ json(response, 400, { error: { code: 'schedule_unavailable', message: error instanceof Error ? error.message : String(error) } });
588
+ }
589
+ return;
590
+ }
591
+ if (pathname === '/api/v1/sync/schedule' && request.method === 'PUT') {
592
+ try {
593
+ const body = parseSyncScheduleBody(await readJsonBody(request));
594
+ const url = new URL(request.url ?? '/', 'http://localhost');
595
+ const paths = await resolveSyncPaths(catalogPaths, staticPaths, { athleteId: body.athleteId });
596
+ const athleteId = catalogPaths ? (body.athleteId ?? '') : 'local';
597
+ json(response, 200, await updateAutoSyncSchedule(paths, athleteId, {
598
+ enabled: body.enabled,
599
+ ...(body.intervalMinutes === undefined ? {} : { intervalMinutes: parseAutoSyncInterval(String(body.intervalMinutes)) }),
600
+ }));
601
+ }
602
+ catch (error) {
603
+ json(response, 400, { error: { code: 'schedule_update_failed', message: error instanceof Error ? error.message : String(error) } });
604
+ }
605
+ return;
606
+ }
551
607
  if (pathname === '/api/v1/models/discover' && request.method === 'POST') {
552
608
  // Refresh the OpenCode Go profiles without starting a data sync. The
553
609
  // Console's Models page calls this behind its authenticated proxy.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { existsSync } from 'node:fs';
4
- import { addAthlete, athleteStorePaths, CatenceDatabase, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
4
+ import { addAthlete, athleteStorePaths, CatenceDatabase, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths, startAutoSyncScheduler } from '../../runtime/index.js';
5
5
  import { defaultBearerTokenPath, parseServeCliOptions, SERVE_USAGE } from '../http/cli-options.js';
6
6
  import { createCatenceHttpServer } from '../http/server.js';
7
7
  import { MCP_USAGE, parseMcpCliOptions } from './cli-options.js';
@@ -63,7 +63,14 @@ async function run() {
63
63
  });
64
64
  console.error(`Catence Streamable HTTP server is listening at http://${options.host}:${options.port}/mcp`);
65
65
  console.error(`Bearer-token authentication is armed at ${options.authTokenFile ?? defaultBearerTokenPath(catalogPaths.root)}; enable it with 'catence-data mcp enable'. Health endpoints stay open for readiness probes.`);
66
+ // Auto-sync runs while the server is up: each athlete's autosync.json
67
+ // (default: enabled, every 2 hours) triggers a detached sync. The
68
+ // detached child and the single-writer DuckDB lock stay the only
69
+ // executors, so concurrent ticks are no-ops by design.
70
+ const scheduler = startAutoSyncScheduler({ catalogPaths });
71
+ console.error('Auto-sync scheduler started (default: enabled, every 2 hours per athlete; configure with auto-sync enable --interval or Console Settings).');
66
72
  const close = () => {
73
+ scheduler.stop();
67
74
  httpServer.close(() => process.exit(0));
68
75
  };
69
76
  process.once('SIGINT', close);
@@ -312,7 +312,17 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
312
312
  }, tool('start_detached_sync', async (input) => {
313
313
  const paths = activePaths();
314
314
  const athleteId = activeAthlete()?.id ?? 'local';
315
- const handle = await startSync({ paths, athleteId, provider: input.provider, from: input.from, refresh: input.refresh });
315
+ const handle = await startSync({
316
+ paths,
317
+ athleteId,
318
+ provider: input.provider,
319
+ from: input.from,
320
+ refresh: input.refresh,
321
+ // The child resolves the athlete within the catalog, not within the
322
+ // athlete store this tool resolved for locks and logs: passing the
323
+ // store itself trips the legacy-store guard in catalog deployments.
324
+ ...(catalogPaths ? { cliHome: catalogPaths.root } : {}),
325
+ });
316
326
  return {
317
327
  data: { ...handle, progressTool: 'catence_sync_progress' },
318
328
  provenance: { operation: 'detached_sync_spawn' },
@@ -35,6 +35,8 @@ export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaA
35
35
  export type { ProviderChoice } from '../elt/application/management.js';
36
36
  export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
37
37
  export type { DetachedSyncHandle, DetachedSyncProvider, DetachedSyncRequest, DetachedSyncSpawner } from '../elt/application/detached-sync.js';
38
+ export { AUTO_SYNC_INTERVAL_MINUTES, DEFAULT_AUTO_SYNC_INTERVAL_MINUTES, parseAutoSyncInterval, readAutoSyncSchedule, startAutoSyncScheduler, updateAutoSyncSchedule, } from '../elt/application/autosync.js';
39
+ export type { AutoSyncConfig, AutoSyncIntervalMinutes, AutoSyncSchedule, AutoSyncScheduler, AutoSyncSchedulerOptions } from '../elt/application/autosync.js';
38
40
  export { buildOpenCodeGoConsoleProfiles, classifyOpenCodeGoModel, fetchOpenCodeGoModelIds, mergeOpenCodeGoConsoleProfiles, OPENCODE_GO_API_BASE_ENV, OPENCODE_GO_API_KEY_ENV, OPENCODE_GO_DEFAULT_BASE_URL, OPENCODE_GO_MESSAGES_API_BASE_ENV, } from '../core/runtime/opencode-go.js';
39
41
  export type { OpenCodeGoConsoleProfiles, OpenCodeGoMergeResult, OpenCodeGoRoute } from '../core/runtime/opencode-go.js';
40
42
  export type { InterruptedRuns, SyncProgressSnapshot, SyncProgressState } from '../contracts/progress.js';
@@ -19,6 +19,7 @@ export { queryReadOnlyData } from '../core/query/sql-guard.js';
19
19
  export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
20
20
  export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, reimportWeight, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
21
21
  export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
22
+ export { AUTO_SYNC_INTERVAL_MINUTES, DEFAULT_AUTO_SYNC_INTERVAL_MINUTES, parseAutoSyncInterval, readAutoSyncSchedule, startAutoSyncScheduler, updateAutoSyncSchedule, } from '../elt/application/autosync.js';
22
23
  export { buildOpenCodeGoConsoleProfiles, classifyOpenCodeGoModel, fetchOpenCodeGoModelIds, mergeOpenCodeGoConsoleProfiles, OPENCODE_GO_API_BASE_ENV, OPENCODE_GO_API_KEY_ENV, OPENCODE_GO_DEFAULT_BASE_URL, OPENCODE_GO_MESSAGES_API_BASE_ENV, } from '../core/runtime/opencode-go.js';
23
24
  export { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
24
25
  export { DataWriteBusyError } from '../elt/storage/write-lock.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.15",
3
+ "version": "0.2.1-beta.17",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -75,12 +75,32 @@ def main() -> None:
75
75
  email = os.environ.get("GARMIN_EMAIL")
76
76
  if not email:
77
77
  raise SystemExit("GARMIN_EMAIL is required.")
78
- password = os.environ.get("GARMIN_PASSWORD") or getpass.getpass("Garmin password: ")
79
- writer = StagingWriter(args.data_dir, args.output)
80
- writer.manifest(args.run_id, args.from_date.isoformat())
81
78
  token_store = args.data_dir / "auth" / "garmin_tokens.json"
82
79
  token_store.parent.mkdir(parents=True, exist_ok=True)
83
- client = Garmin(email, password, prompt_mfa=lambda: input("Garmin MFA code: "))
80
+ if not sys.stdin.isatty():
81
+ # Unattended auto-sync must never hang on a hidden prompt: fail fast
82
+ # so the run records a visible error instead.
83
+ password = os.environ.get("GARMIN_PASSWORD")
84
+ if not password and not token_store.exists():
85
+ raise SystemExit(
86
+ "Garmin login is required (no stored token, no GARMIN_PASSWORD, "
87
+ "and stdin is not interactive). Run an interactive sync once to "
88
+ "store the session token, or set GARMIN_PASSWORD."
89
+ )
90
+ else:
91
+ password = os.environ.get("GARMIN_PASSWORD") or getpass.getpass("Garmin password: ")
92
+
93
+ def prompt_mfa() -> str:
94
+ if not sys.stdin.isatty():
95
+ raise SystemExit(
96
+ "Garmin MFA is required but stdin is not interactive; the "
97
+ "session token expired. Run an interactive sync once to renew it."
98
+ )
99
+ return input("Garmin MFA code: ")
100
+
101
+ writer = StagingWriter(args.data_dir, args.output)
102
+ writer.manifest(args.run_id, args.from_date.isoformat())
103
+ client = Garmin(email, password, prompt_mfa=prompt_mfa)
84
104
  client.login(str(token_store))
85
105
  reporter = ProgressReporter(args.run_id)
86
106
  reporter.set_stage("login")