catence 0.2.0-beta.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +38 -166
  2. package/config.example.json +5 -1
  3. package/dist/contracts/index.d.ts +1 -0
  4. package/dist/contracts/index.js +1 -0
  5. package/dist/contracts/progress.d.ts +23 -0
  6. package/dist/contracts/progress.js +49 -0
  7. package/dist/contracts/release.d.ts +1 -1
  8. package/dist/contracts/release.js +1 -1
  9. package/dist/core/logging.d.ts +16 -0
  10. package/dist/core/logging.js +68 -0
  11. package/dist/core/query/activity-discovery.d.ts +2 -0
  12. package/dist/core/query/activity-discovery.js +1 -1
  13. package/dist/core/query/analytics.d.ts +10 -0
  14. package/dist/core/query/analytics.js +494 -0
  15. package/dist/core/query/catalog.d.ts +1 -1
  16. package/dist/core/query/catalog.js +8 -4
  17. package/dist/core/query/events.d.ts +34 -0
  18. package/dist/core/query/events.js +121 -0
  19. package/dist/core/query/fitness.d.ts +4 -0
  20. package/dist/core/query/fitness.js +60 -0
  21. package/dist/core/query/repository.d.ts +2 -0
  22. package/dist/core/query/repository.js +48 -0
  23. package/dist/core/query/swimming.js +2 -2
  24. package/dist/core/runtime/configuration.d.ts +3 -1
  25. package/dist/core/runtime/configuration.js +5 -1
  26. package/dist/core/runtime/opencode-go.d.ts +58 -0
  27. package/dist/core/runtime/opencode-go.js +246 -0
  28. package/dist/core/runtime/secrets.js +51 -13
  29. package/dist/elt/application/detached-sync.d.ts +50 -0
  30. package/dist/elt/application/detached-sync.js +99 -0
  31. package/dist/elt/application/management.d.ts +15 -0
  32. package/dist/elt/application/management.js +435 -19
  33. package/dist/elt/ingestion/importer.d.ts +3 -2
  34. package/dist/elt/ingestion/importer.js +5 -4
  35. package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
  36. package/dist/elt/ingestion/providers/intervals.js +37 -2
  37. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  38. package/dist/elt/ingestion/providers/registry.js +19 -1
  39. package/dist/elt/ingestion/providers/strava/service.js +7 -1
  40. package/dist/elt/normalization/normalizers.js +207 -15
  41. package/dist/elt/storage/database.d.ts +21 -2
  42. package/dist/elt/storage/database.js +184 -6
  43. package/dist/elt/storage/migrations.js +145 -1
  44. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  45. package/dist/elt/storage/progress-sidecar.js +200 -0
  46. package/dist/interfaces/cli/commands.js +94 -1
  47. package/dist/interfaces/http/generation-status.d.ts +19 -0
  48. package/dist/interfaces/http/generation-status.js +55 -0
  49. package/dist/interfaces/http/server.d.ts +7 -1
  50. package/dist/interfaces/http/server.js +184 -1
  51. package/dist/interfaces/mcp/main.js +33 -1
  52. package/dist/interfaces/mcp/server.d.ts +2 -1
  53. package/dist/interfaces/mcp/server.js +168 -45
  54. package/dist/runtime/index.d.ts +9 -2
  55. package/dist/runtime/index.js +6 -2
  56. package/docs/architecture.md +13 -2
  57. package/docs/assets/image.png +0 -0
  58. package/docs/configuration.md +243 -0
  59. package/docs/deployment/docker.md +528 -0
  60. package/docs/deployment/local-console.md +263 -0
  61. package/docs/deployment/local-mcp.md +329 -0
  62. package/docs/development.md +337 -0
  63. package/docs/distribution.md +45 -0
  64. package/docs/llm-providers.md +66 -1
  65. package/docs/mcp-activity-retrieval.md +90 -210
  66. package/package.json +1 -1
  67. package/pyproject.toml +1 -1
  68. package/python/catence/providers/garmin/cli.py +43 -9
  69. package/python/catence/providers/garmin/progress.py +85 -0
  70. package/python/catence/providers/garmin/registry.py +2 -0
  71. package/python/catence/providers/garmin/streams.py +38 -0
  72. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  73. package/python/catence/providers/garmin/worker.py +415 -12
  74. package/scripts/discover-opencode-go.mjs +36 -144
  75. package/uv.lock +3 -3
  76. package/docs/deployment.md +0 -338
@@ -42,27 +42,65 @@ export async function setAthleteSecret(paths, provider, field, value) {
42
42
  await rename(temporary, destination);
43
43
  await chmod(destination, 0o600);
44
44
  }
45
+ function isTruthyEnvValue(value) {
46
+ if (!value)
47
+ return false;
48
+ return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
49
+ }
50
+ function isGenericEnvFallbackAllowed(base) {
51
+ const direct = base.CATENCE_ALLOW_ENV_SECRETS ?? base.CATENCE_SECRETS_FROM_ENV ?? process.env.CATENCE_ALLOW_ENV_SECRETS ?? process.env.CATENCE_SECRETS_FROM_ENV;
52
+ return isTruthyEnvValue(direct);
53
+ }
54
+ function athleteEnvPrefix(paths) {
55
+ const athleteId = path.basename(path.dirname(paths.secrets));
56
+ const normalized = athleteId.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase();
57
+ return `CATENCE_ATHLETE_${normalized}_`;
58
+ }
59
+ function resolveWithEnvFallback(fileValue, envName, perAthletePrefix, base, allowGeneric) {
60
+ if (fileValue)
61
+ return fileValue;
62
+ const perAthleteName = `${perAthletePrefix}${envName}`;
63
+ const perAthleteValue = base[perAthleteName] ?? process.env[perAthleteName];
64
+ if (perAthleteValue)
65
+ return perAthleteValue;
66
+ if (!allowGeneric)
67
+ return undefined;
68
+ const genericValue = base[envName] ?? process.env[envName];
69
+ if (genericValue)
70
+ return genericValue;
71
+ return undefined;
72
+ }
45
73
  /** Build only the provider environment needed by a selected athlete worker. */
46
74
  export async function athleteProviderEnvironment(paths, base = process.env) {
47
75
  const secrets = await readAthleteSecrets(paths);
48
76
  const environment = { ...base };
77
+ const allowGenericEnv = isGenericEnvFallbackAllowed(base);
78
+ const perAthletePrefix = athleteEnvPrefix(paths);
49
79
  // Never let credentials inherited by the shared process leak into an
50
- // athlete store that has not explicitly configured that provider.
80
+ // athlete store that has not explicitly configured that provider, unless
81
+ // the operator has opted in via CATENCE_ALLOW_ENV_SECRETS or is using a
82
+ // per-athlete scoped variable (CATENCE_ATHLETE_<ID>_GARMIN_EMAIL etc.).
51
83
  for (const name of ['GARMIN_EMAIL', 'GARMIN_PASSWORD', 'INTERVALS_API_KEY', 'INTERVALS_ATHLETE_ID', 'STRAVA_CLIENT_ID', 'STRAVA_CLIENT_SECRET']) {
52
84
  delete environment[name];
53
85
  }
54
- if (secrets.garmin?.email)
55
- environment.GARMIN_EMAIL = secrets.garmin.email;
56
- if (secrets.garmin?.password)
57
- environment.GARMIN_PASSWORD = secrets.garmin.password;
58
- if (secrets.intervals?.apiKey)
59
- environment.INTERVALS_API_KEY = secrets.intervals.apiKey;
60
- if (secrets.intervals?.athleteId)
61
- environment.INTERVALS_ATHLETE_ID = secrets.intervals.athleteId;
62
- if (secrets.strava?.clientId)
63
- environment.STRAVA_CLIENT_ID = secrets.strava.clientId;
64
- if (secrets.strava?.clientSecret)
65
- environment.STRAVA_CLIENT_SECRET = secrets.strava.clientSecret;
86
+ const garminEmail = resolveWithEnvFallback(secrets.garmin?.email, 'GARMIN_EMAIL', perAthletePrefix, base, allowGenericEnv);
87
+ const garminPassword = resolveWithEnvFallback(secrets.garmin?.password, 'GARMIN_PASSWORD', perAthletePrefix, base, allowGenericEnv);
88
+ const intervalsApiKey = resolveWithEnvFallback(secrets.intervals?.apiKey, 'INTERVALS_API_KEY', perAthletePrefix, base, allowGenericEnv);
89
+ const intervalsAthleteId = resolveWithEnvFallback(secrets.intervals?.athleteId, 'INTERVALS_ATHLETE_ID', perAthletePrefix, base, allowGenericEnv);
90
+ const stravaClientId = resolveWithEnvFallback(secrets.strava?.clientId, 'STRAVA_CLIENT_ID', perAthletePrefix, base, allowGenericEnv);
91
+ const stravaClientSecret = resolveWithEnvFallback(secrets.strava?.clientSecret, 'STRAVA_CLIENT_SECRET', perAthletePrefix, base, allowGenericEnv);
92
+ if (garminEmail)
93
+ environment.GARMIN_EMAIL = garminEmail;
94
+ if (garminPassword)
95
+ environment.GARMIN_PASSWORD = garminPassword;
96
+ if (intervalsApiKey)
97
+ environment.INTERVALS_API_KEY = intervalsApiKey;
98
+ if (intervalsAthleteId)
99
+ environment.INTERVALS_ATHLETE_ID = intervalsAthleteId;
100
+ if (stravaClientId)
101
+ environment.STRAVA_CLIENT_ID = stravaClientId;
102
+ if (stravaClientSecret)
103
+ environment.STRAVA_CLIENT_SECRET = stravaClientSecret;
66
104
  return environment;
67
105
  }
68
106
  //# sourceMappingURL=secrets.js.map
@@ -0,0 +1,50 @@
1
+ import { type SpawnOptions } from 'node:child_process';
2
+ import type { CatencePaths } from '../../contracts/runtime.js';
3
+ export declare const DETACHED_SYNC_PROVIDERS: readonly ["intervals", "garmin", "strava", "all"];
4
+ export type DetachedSyncProvider = (typeof DETACHED_SYNC_PROVIDERS)[number];
5
+ export type DetachedSyncRequest = {
6
+ paths: CatencePaths;
7
+ athleteId: string;
8
+ provider?: DetachedSyncProvider;
9
+ from?: string;
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;
21
+ };
22
+ export type DetachedSyncHandle = {
23
+ started: true;
24
+ athleteId: string;
25
+ provider: DetachedSyncProvider;
26
+ logFile: string;
27
+ };
28
+ /** Raised when another sync run is already active for this store. */
29
+ export declare class DetachedSyncBusyError extends Error {
30
+ readonly runId: string;
31
+ constructor(runId: string);
32
+ }
33
+ /** Minimal structural spawner so tests can inject a fake child process. */
34
+ export type DetachedSyncSpawner = (command: string, arguments_: readonly string[], options: SpawnOptions) => {
35
+ unref(): void;
36
+ };
37
+ /**
38
+ * Spawn one detached `catence-data sync` process for an athlete store and
39
+ * return immediately.
40
+ *
41
+ * The child outlives this process (SSH deaths, Console restarts), logs to a
42
+ * file under the catalog home, and is guarded by the same live-progress
43
+ * heartbeat check the progress tools use. The guard and the spawn are not
44
+ * atomic; the single-writer DuckDB lock remains the authoritative guard, so a
45
+ * racing second child fails cleanly in its own log file.
46
+ */
47
+ export declare function startDetachedSync(request: DetachedSyncRequest, dependencies?: {
48
+ now?: () => Date;
49
+ spawnProcess?: DetachedSyncSpawner;
50
+ }): Promise<DetachedSyncHandle>;
@@ -0,0 +1,99 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { open, mkdir, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
7
+ import { syncProgress } from './management.js';
8
+ export const DETACHED_SYNC_PROVIDERS = ['intervals', 'garmin', 'strava', 'all'];
9
+ /** Raised when another sync run is already active for this store. */
10
+ export class DetachedSyncBusyError extends Error {
11
+ runId;
12
+ constructor(runId) {
13
+ super(`A Catence sync run is already active (${runId}). Watch it with the progress tools instead of starting another.`);
14
+ this.name = 'DetachedSyncBusyError';
15
+ this.runId = runId;
16
+ }
17
+ }
18
+ /**
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.
26
+ */
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 };
38
+ }
39
+ function assertValidRequest(request) {
40
+ if (!request.athleteId || !/^[a-z][a-z0-9-]{0,62}$/.test(request.athleteId)) {
41
+ throw new Error('athleteId is required and must be a lowercase athlete identifier.');
42
+ }
43
+ if (request.provider !== undefined && !DETACHED_SYNC_PROVIDERS.includes(request.provider)) {
44
+ throw new Error('provider must be intervals, garmin, strava, or all.');
45
+ }
46
+ if (request.from !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(request.from)) {
47
+ throw new Error('from must use YYYY-MM-DD.');
48
+ }
49
+ }
50
+ /**
51
+ * Spawn one detached `catence-data sync` process for an athlete store and
52
+ * return immediately.
53
+ *
54
+ * The child outlives this process (SSH deaths, Console restarts), logs to a
55
+ * file under the catalog home, and is guarded by the same live-progress
56
+ * heartbeat check the progress tools use. The guard and the spawn are not
57
+ * atomic; the single-writer DuckDB lock remains the authoritative guard, so a
58
+ * racing second child fails cleanly in its own log file.
59
+ */
60
+ export async function startDetachedSync(request, dependencies = {}) {
61
+ assertValidRequest(request);
62
+ const provider = request.provider ?? 'all';
63
+ const snapshot = await syncProgress(request.paths);
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
+ });
71
+ if (activeRun)
72
+ throw new DetachedSyncBusyError(activeRun.runId);
73
+ const stamp = (dependencies.now ?? (() => new Date()))().toISOString().replace(/[:.]/g, '-');
74
+ const logFile = path.join(request.paths.root, 'logs', `sync-${request.athleteId}-${stamp}.log`);
75
+ await mkdir(path.dirname(logFile), { recursive: true });
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');
77
+ const arguments_ = [cliInvocation().entry, '--home', request.cliHome ?? request.paths.root, '--athlete', request.athleteId, 'sync', '--provider', provider];
78
+ if (request.from)
79
+ arguments_.push('--from', request.from);
80
+ if (request.refresh)
81
+ arguments_.push('--refresh');
82
+ // The file handle stays open only for the spawn; the detached child holds
83
+ // its own duplicate of the descriptor afterwards.
84
+ const logHandle = await open(logFile, 'a');
85
+ try {
86
+ const spawnProcess = dependencies.spawnProcess ?? spawn;
87
+ const child = spawnProcess(cliInvocation().command, arguments_, {
88
+ detached: true,
89
+ stdio: ['ignore', logHandle.fd, logHandle.fd],
90
+ env: process.env,
91
+ });
92
+ child.unref();
93
+ }
94
+ finally {
95
+ await logHandle.close();
96
+ }
97
+ return { started: true, athleteId: request.athleteId, provider, logFile };
98
+ }
99
+ //# sourceMappingURL=detached-sync.js.map
@@ -1,4 +1,5 @@
1
1
  import type { CatencePaths } from '../../contracts/runtime.js';
2
+ import { type SyncProgressSnapshot } from '../../contracts/progress.js';
2
3
  type SyncProviderChoice = 'intervals' | 'garmin';
3
4
  export type ProviderChoice = SyncProviderChoice | 'strava' | 'all';
4
5
  export declare function initializeDataStore(paths: CatencePaths): Promise<Record<string, unknown>>;
@@ -21,5 +22,19 @@ export declare function disconnectStravaAccount(paths: CatencePaths): Promise<{
21
22
  export declare function linkActivity(paths: CatencePaths, source: string, activity: string): Promise<Record<string, string>>;
22
23
  export declare function unlinkActivity(paths: CatencePaths, source: string): Promise<Record<string, string>>;
23
24
  export declare function dataStatus(paths: CatencePaths): Promise<Record<string, unknown>>;
25
+ export declare function syncProgress(paths: CatencePaths): Promise<SyncProgressSnapshot>;
24
26
  export declare function rebuildRetrievalIndex(paths: CatencePaths): Promise<Record<string, unknown>>;
27
+ export declare function importStagedFile(paths: CatencePaths, provider: ProviderChoice, filePath: string): Promise<Record<string, unknown>>;
28
+ export declare function resolveExtractionErrors(paths: CatencePaths, opts?: {
29
+ runIds?: string[];
30
+ provider?: string;
31
+ before?: string;
32
+ all?: boolean;
33
+ }): Promise<Record<string, unknown>>;
34
+ /**
35
+ * Re-run normalization over already-captured nutrition entities without
36
+ * contacting any provider. Idempotent upserts let a fixed normalizer backfill
37
+ * previously mis-parsed nutrition_days/nutrition_items from stored payloads.
38
+ */
39
+ export declare function reimportNutrition(paths: CatencePaths): Promise<Record<string, unknown>>;
25
40
  export {};