catence 0.2.0-beta.16 → 0.2.0-beta.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,9 +7,9 @@ Catence is a local-first fitness MCP server for Garmin, Intervals.icu, and targe
7
7
  ## What is included
8
8
 
9
9
  - Garmin, Intervals.icu, and Strava ingestion with source-aware normalization.
10
- - Read-only MCP tools, plus explicitly named, lock-guarded Strava hydration tools.
10
+ - Read-only MCP tools, plus explicitly named, lock-guarded write tools for Strava hydration and detached syncs.
11
11
  - One shared Catence agent can serve several isolated athlete stores. Every personal-data MCP call names an `athleteId`; Catence never silently combines athletes.
12
- - A password-protected Chainlit Console with an authenticated dashboard.
12
+ - A password-protected Chainlit Console with an authenticated dashboard, a data-sync button with live progress, and in-app model management.
13
13
  - A generated demo catalog for safe evaluation in Glama, desktop MCP clients, or local development.
14
14
 
15
15
  Catence helps explore recovery, training load, trends, activity detail, swimming/cycling/running progress, segments, gear, and data quality. It reports the evidence and coverage it can see; it does not diagnose or prescribe training.
@@ -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.16";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.18";
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.16';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.18';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -12,7 +12,7 @@ declare const rateLimitSchema: z.ZodObject<{
12
12
  requests: z.ZodNumber;
13
13
  windowSeconds: z.ZodNumber;
14
14
  }, z.core.$strict>;
15
- declare const catenceConfigSchema: z.ZodObject<{
15
+ export declare const catenceConfigSchema: z.ZodObject<{
16
16
  mcp: z.ZodOptional<z.ZodObject<{
17
17
  rateLimits: z.ZodOptional<z.ZodObject<{
18
18
  server: z.ZodOptional<z.ZodNullable<z.ZodObject<{
@@ -50,6 +50,8 @@ declare const catenceConfigSchema: z.ZodObject<{
50
50
  models: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
51
51
  label: z.ZodOptional<z.ZodString>;
52
52
  model: z.ZodString;
53
+ reasoningEffort: z.ZodOptional<z.ZodString>;
54
+ variants: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
53
55
  }, z.core.$strict>>>;
54
56
  defaultModel: z.ZodOptional<z.ZodString>;
55
57
  defaultReasoningEffort: z.ZodOptional<z.ZodEnum<{
@@ -32,9 +32,13 @@ export function requireIntervalsConfig(environment = process.env) {
32
32
  const rateLimitSchema = z.object({ requests: z.number().int().positive(), windowSeconds: z.number().int().positive() }).strict();
33
33
  const nullableRateLimitSchema = rateLimitSchema.nullable();
34
34
  const environmentVariableSchema = z.string().regex(/^[A-Z][A-Z0-9_]*$/, 'must be an uppercase environment-variable name');
35
+ // Keep this aligned with the Console's Python validator, which accepts a
36
+ // per-model reasoningEffort and free-form reasoning-effort variants.
35
37
  const consoleModelSchema = z.object({
36
38
  label: z.string().min(1).optional(),
37
39
  model: z.string().min(1),
40
+ reasoningEffort: z.string().min(1).optional(),
41
+ variants: z.record(z.string(), z.string()).optional(),
38
42
  }).strict();
39
43
  const consoleProfileSchema = z.object({
40
44
  label: z.string().min(1).optional(),
@@ -60,7 +64,7 @@ const consoleLimitsSchema = z.object({
60
64
  toolRounds: z.number().int().min(1).max(32).optional(),
61
65
  toolResultCharacters: z.number().int().min(1_000).max(250_000).optional(),
62
66
  }).strict();
63
- const catenceConfigSchema = z.object({
67
+ export const catenceConfigSchema = z.object({
64
68
  mcp: z.object({
65
69
  rateLimits: z.object({
66
70
  server: nullableRateLimitSchema.optional(),
@@ -0,0 +1,52 @@
1
+ export declare const OPENCODE_GO_DEFAULT_BASE_URL = "https://opencode.ai/zen/go/v1";
2
+ export declare const OPENCODE_GO_API_KEY_ENV = "OPENCODE_GO_API_KEY";
3
+ export declare const OPENCODE_GO_API_BASE_ENV = "OPENCODE_GO_API_BASE";
4
+ export declare const OPENCODE_GO_MESSAGES_API_BASE_ENV = "OPENCODE_GO_MESSAGES_API_BASE";
5
+ export type OpenCodeGoRoute = 'chat' | 'responses' | 'messages';
6
+ /** Live model ids plus trusted routing metadata ("live discovery + trusted metadata"). */
7
+ export declare function classifyOpenCodeGoModel(id: string): {
8
+ route: OpenCodeGoRoute;
9
+ guessed: boolean;
10
+ };
11
+ export type OpenCodeGoConsoleProfiles = {
12
+ profiles: Record<string, Record<string, unknown>>;
13
+ defaultProfile: string;
14
+ counts: {
15
+ chat: number;
16
+ responses: number;
17
+ messages: number;
18
+ };
19
+ guessedRoutes: string[];
20
+ };
21
+ /**
22
+ * Build the two ready-made Console profiles from live model ids.
23
+ *
24
+ * The public `GET /models` endpoint returns only ids, not which API each model
25
+ * needs, so routing comes from the trusted table intersected with the live
26
+ * list; prefix-guessed routes are reported so callers can warn.
27
+ */
28
+ export declare function buildOpenCodeGoConsoleProfiles(modelIds: readonly string[], baseUrl?: string): OpenCodeGoConsoleProfiles;
29
+ export declare function fetchOpenCodeGoModelIds(baseUrl?: string): Promise<string[]>;
30
+ export type OpenCodeGoMergeResult = {
31
+ configPath: string;
32
+ mergedProfileIds: string[];
33
+ defaultProfile: string;
34
+ counts: {
35
+ chat: number;
36
+ responses: number;
37
+ messages: number;
38
+ };
39
+ guessedRoutes: string[];
40
+ };
41
+ /**
42
+ * Fetch the live OpenCode Go catalog and merge the two Console profiles into
43
+ * `configPath`, preserving every other section plus existing console fields
44
+ * (`limits`, other profiles) and the existing `defaultProfile` unless
45
+ * `setDefault` is passed. The written file is re-validated with the same
46
+ * strict schema the runtime applies on load, then replaced atomically.
47
+ */
48
+ export declare function mergeOpenCodeGoConsoleProfiles(options: {
49
+ configPath: string;
50
+ baseUrl?: string;
51
+ setDefault?: boolean;
52
+ }): Promise<OpenCodeGoMergeResult>;
@@ -0,0 +1,189 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { catenceConfigSchema } from './configuration.js';
4
+ export const OPENCODE_GO_DEFAULT_BASE_URL = 'https://opencode.ai/zen/go/v1';
5
+ export const OPENCODE_GO_API_KEY_ENV = 'OPENCODE_GO_API_KEY';
6
+ export const OPENCODE_GO_API_BASE_ENV = 'OPENCODE_GO_API_BASE';
7
+ export const OPENCODE_GO_MESSAGES_API_BASE_ENV = 'OPENCODE_GO_MESSAGES_API_BASE';
8
+ // LiteLLM appends a fixed suffix to the profile base URL per provider, so the
9
+ // three APIs need different prefixes and two base URLs:
10
+ // openai/<id> -> {base}/chat/completions
11
+ // openai/responses/<id> -> {base}/responses
12
+ // anthropic/<id> -> {base minus /v1}/v1/messages
13
+ const RESPONSES_MODELS = new Set(['grok-4.5', 'gpt-5.6-luna']);
14
+ const MESSAGES_MODELS = new Set([
15
+ 'minimax-m2.5',
16
+ 'minimax-m2.7',
17
+ 'minimax-m3',
18
+ 'qwen3.5-plus',
19
+ 'qwen3.6-plus',
20
+ 'qwen3.7-plus',
21
+ 'qwen3.7-max',
22
+ 'qwen3.8-max',
23
+ ]);
24
+ const BRAND_NAMES = new Map([
25
+ ['deepseek', 'DeepSeek'],
26
+ ['glm', 'GLM'],
27
+ ['gpt', 'GPT'],
28
+ ['grok', 'Grok'],
29
+ ['hy3', 'Hy3'],
30
+ ['kimi', 'Kimi'],
31
+ ['mimo', 'MiMo'],
32
+ ['minimax', 'MiniMax'],
33
+ ['qwen', 'Qwen'],
34
+ ]);
35
+ const CHAT_PROFILE_ID = 'opencode-go';
36
+ const MESSAGES_PROFILE_ID = 'opencode-go-messages';
37
+ /** Live model ids plus trusted routing metadata ("live discovery + trusted metadata"). */
38
+ export function classifyOpenCodeGoModel(id) {
39
+ if (RESPONSES_MODELS.has(id))
40
+ return { route: 'responses', guessed: false };
41
+ if (MESSAGES_MODELS.has(id))
42
+ return { route: 'messages', guessed: false };
43
+ if (/^(grok-|gpt-)/.test(id))
44
+ return { route: 'responses', guessed: true };
45
+ if (/^(minimax-|qwen)/.test(id))
46
+ return { route: 'messages', guessed: true };
47
+ return { route: 'chat', guessed: false };
48
+ }
49
+ function modelReference(id, route) {
50
+ if (route === 'responses')
51
+ return `openai/responses/${id}`;
52
+ if (route === 'messages')
53
+ return `anthropic/${id}`;
54
+ return `openai/${id}`;
55
+ }
56
+ function labelFor(id) {
57
+ return id
58
+ .split('-')
59
+ .map((segment) => BRAND_NAMES.get(segment) ?? segment.charAt(0).toUpperCase() + segment.slice(1))
60
+ .join(' ');
61
+ }
62
+ /**
63
+ * Build the two ready-made Console profiles from live model ids.
64
+ *
65
+ * The public `GET /models` endpoint returns only ids, not which API each model
66
+ * needs, so routing comes from the trusted table intersected with the live
67
+ * list; prefix-guessed routes are reported so callers can warn.
68
+ */
69
+ export function buildOpenCodeGoConsoleProfiles(modelIds, baseUrl = OPENCODE_GO_DEFAULT_BASE_URL) {
70
+ const groups = { chat: [], responses: [], messages: [] };
71
+ const guessedRoutes = [];
72
+ for (const id of modelIds) {
73
+ if (!id)
74
+ continue;
75
+ const { route, guessed } = classifyOpenCodeGoModel(id);
76
+ groups[route].push(id);
77
+ if (guessed)
78
+ guessedRoutes.push(`${id} -> ${route}`);
79
+ }
80
+ for (const route of Object.keys(groups))
81
+ groups[route].sort();
82
+ const chatModels = {};
83
+ for (const id of [...groups.chat, ...groups.responses]) {
84
+ chatModels[id] = { label: labelFor(id), model: modelReference(id, groups.chat.includes(id) ? 'chat' : 'responses') };
85
+ }
86
+ const messagesModels = {};
87
+ for (const id of groups.messages) {
88
+ messagesModels[id] = { label: labelFor(id), model: modelReference(id, 'messages') };
89
+ }
90
+ const chatDefault = groups.chat[0] ?? groups.responses[0];
91
+ const profiles = {
92
+ [CHAT_PROFILE_ID]: {
93
+ label: 'OpenCode Go',
94
+ ...(chatDefault ? { defaultModel: chatDefault } : {}),
95
+ models: chatModels,
96
+ apiKeyEnv: OPENCODE_GO_API_KEY_ENV,
97
+ apiBaseEnv: OPENCODE_GO_API_BASE_ENV,
98
+ },
99
+ };
100
+ if (groups.messages.length) {
101
+ profiles[MESSAGES_PROFILE_ID] = {
102
+ label: 'OpenCode Go (Anthropic)',
103
+ defaultModel: groups.messages[0],
104
+ models: messagesModels,
105
+ apiKeyEnv: OPENCODE_GO_API_KEY_ENV,
106
+ apiBaseEnv: OPENCODE_GO_MESSAGES_API_BASE_ENV,
107
+ };
108
+ }
109
+ return {
110
+ profiles,
111
+ defaultProfile: CHAT_PROFILE_ID,
112
+ counts: { chat: groups.chat.length, responses: groups.responses.length, messages: groups.messages.length },
113
+ guessedRoutes,
114
+ };
115
+ }
116
+ export async function fetchOpenCodeGoModelIds(baseUrl = OPENCODE_GO_DEFAULT_BASE_URL) {
117
+ const modelsUrl = `${baseUrl.replace(/\/$/, '')}/models`;
118
+ let response;
119
+ try {
120
+ response = await fetch(modelsUrl, { headers: { accept: 'application/json' } });
121
+ }
122
+ catch (error) {
123
+ throw new Error(`Could not reach ${modelsUrl}: ${error instanceof Error ? error.message : String(error)}`);
124
+ }
125
+ if (!response.ok)
126
+ throw new Error(`GET ${modelsUrl} returned HTTP ${response.status}.`);
127
+ const payload = (await response.json());
128
+ const ids = (payload.data ?? []).map((entry) => entry.id).filter((id) => typeof id === 'string' && Boolean(id));
129
+ if (!ids.length)
130
+ throw new Error(`GET ${modelsUrl} returned no models.`);
131
+ return ids;
132
+ }
133
+ async function readConfigRoot(configPath) {
134
+ try {
135
+ const root = JSON.parse(await readFile(configPath, 'utf8'));
136
+ if (typeof root !== 'object' || root === null || Array.isArray(root)) {
137
+ throw new Error(`${configPath} must contain a JSON object.`);
138
+ }
139
+ return root;
140
+ }
141
+ catch (error) {
142
+ if (error.code === 'ENOENT')
143
+ return {};
144
+ throw error;
145
+ }
146
+ }
147
+ /**
148
+ * Fetch the live OpenCode Go catalog and merge the two Console profiles into
149
+ * `configPath`, preserving every other section plus existing console fields
150
+ * (`limits`, other profiles) and the existing `defaultProfile` unless
151
+ * `setDefault` is passed. The written file is re-validated with the same
152
+ * strict schema the runtime applies on load, then replaced atomically.
153
+ */
154
+ export async function mergeOpenCodeGoConsoleProfiles(options) {
155
+ const baseUrl = options.baseUrl ?? OPENCODE_GO_DEFAULT_BASE_URL;
156
+ const modelIds = await fetchOpenCodeGoModelIds(baseUrl);
157
+ const built = buildOpenCodeGoConsoleProfiles(modelIds, baseUrl);
158
+ const root = await readConfigRoot(options.configPath);
159
+ const existingConsole = typeof root.console === 'object' && root.console !== null && !Array.isArray(root.console)
160
+ ? root.console
161
+ : {};
162
+ const existingProfiles = typeof existingConsole.profiles === 'object' && existingConsole.profiles !== null && !Array.isArray(existingConsole.profiles)
163
+ ? existingConsole.profiles
164
+ : {};
165
+ const defaultProfile = typeof existingConsole.defaultProfile === 'string' && existingConsole.defaultProfile && !options.setDefault
166
+ ? existingConsole.defaultProfile
167
+ : built.defaultProfile;
168
+ const updatedConsole = {
169
+ ...existingConsole,
170
+ defaultProfile,
171
+ profiles: { ...existingProfiles, ...built.profiles },
172
+ };
173
+ // Validate before writing anything: the runtime rejects invalid configs at
174
+ // load time, so a bad merge must never reach disk.
175
+ const candidate = { ...root, console: updatedConsole };
176
+ catenceConfigSchema.parse(candidate);
177
+ await mkdir(path.dirname(options.configPath), { recursive: true });
178
+ const temporaryPath = `${options.configPath}.tmp`;
179
+ await writeFile(temporaryPath, `${JSON.stringify(candidate, null, 2)}\n`, 'utf8');
180
+ await rename(temporaryPath, options.configPath);
181
+ return {
182
+ configPath: options.configPath,
183
+ mergedProfileIds: Object.keys(built.profiles),
184
+ defaultProfile,
185
+ counts: built.counts,
186
+ guessedRoutes: built.guessedRoutes,
187
+ };
188
+ }
189
+ //# sourceMappingURL=opencode-go.js.map
@@ -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,40 @@
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
+ export type DetachedSyncHandle = {
13
+ started: true;
14
+ athleteId: string;
15
+ provider: DetachedSyncProvider;
16
+ logFile: string;
17
+ };
18
+ /** Raised when another sync run is already active for this store. */
19
+ export declare class DetachedSyncBusyError extends Error {
20
+ readonly runId: string;
21
+ constructor(runId: string);
22
+ }
23
+ /** Minimal structural spawner so tests can inject a fake child process. */
24
+ export type DetachedSyncSpawner = (command: string, arguments_: readonly string[], options: SpawnOptions) => {
25
+ unref(): void;
26
+ };
27
+ /**
28
+ * Spawn one detached `catence-data sync` process for an athlete store and
29
+ * return immediately.
30
+ *
31
+ * The child outlives this process (SSH deaths, Console restarts), logs to a
32
+ * file under the catalog home, and is guarded by the same live-progress
33
+ * heartbeat check the progress tools use. The guard and the spawn are not
34
+ * atomic; the single-writer DuckDB lock remains the authoritative guard, so a
35
+ * racing second child fails cleanly in its own log file.
36
+ */
37
+ export declare function startDetachedSync(request: DetachedSyncRequest, dependencies?: {
38
+ now?: () => Date;
39
+ spawnProcess?: DetachedSyncSpawner;
40
+ }): Promise<DetachedSyncHandle>;
@@ -0,0 +1,78 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { open, mkdir, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { syncProgress } from './management.js';
6
+ export const DETACHED_SYNC_PROVIDERS = ['intervals', 'garmin', 'strava', 'all'];
7
+ /** Raised when another sync run is already active for this store. */
8
+ export class DetachedSyncBusyError extends Error {
9
+ runId;
10
+ constructor(runId) {
11
+ super(`A Catence sync run is already active (${runId}). Watch it with the progress tools instead of starting another.`);
12
+ this.name = 'DetachedSyncBusyError';
13
+ this.runId = runId;
14
+ }
15
+ }
16
+ /**
17
+ * Resolve the packaged `catence-data` CLI entry next to this module. In a
18
+ * built install both live under dist/; source checkouts run through tsx and
19
+ * tests inject their own spawner.
20
+ */
21
+ function cliScriptPath() {
22
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../interfaces/cli/main.js');
23
+ }
24
+ function assertValidRequest(request) {
25
+ if (!request.athleteId || !/^[a-z][a-z0-9-]{0,62}$/.test(request.athleteId)) {
26
+ throw new Error('athleteId is required and must be a lowercase athlete identifier.');
27
+ }
28
+ if (request.provider !== undefined && !DETACHED_SYNC_PROVIDERS.includes(request.provider)) {
29
+ throw new Error('provider must be intervals, garmin, strava, or all.');
30
+ }
31
+ if (request.from !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(request.from)) {
32
+ throw new Error('from must use YYYY-MM-DD.');
33
+ }
34
+ }
35
+ /**
36
+ * Spawn one detached `catence-data sync` process for an athlete store and
37
+ * return immediately.
38
+ *
39
+ * The child outlives this process (SSH deaths, Console restarts), logs to a
40
+ * file under the catalog home, and is guarded by the same live-progress
41
+ * heartbeat check the progress tools use. The guard and the spawn are not
42
+ * atomic; the single-writer DuckDB lock remains the authoritative guard, so a
43
+ * racing second child fails cleanly in its own log file.
44
+ */
45
+ export async function startDetachedSync(request, dependencies = {}) {
46
+ assertValidRequest(request);
47
+ const provider = request.provider ?? 'all';
48
+ const snapshot = await syncProgress(request.paths);
49
+ const activeRun = snapshot.running[0];
50
+ if (activeRun)
51
+ throw new DetachedSyncBusyError(activeRun.runId);
52
+ const stamp = (dependencies.now ?? (() => new Date()))().toISOString().replace(/[:.]/g, '-');
53
+ const logFile = path.join(request.paths.root, 'logs', `sync-${request.athleteId}-${stamp}.log`);
54
+ await mkdir(path.dirname(logFile), { recursive: true });
55
+ 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_ = [cliScriptPath(), '--home', request.paths.root, '--athlete', request.athleteId, 'sync', '--provider', provider];
57
+ if (request.from)
58
+ arguments_.push('--from', request.from);
59
+ if (request.refresh)
60
+ arguments_.push('--refresh');
61
+ // The file handle stays open only for the spawn; the detached child holds
62
+ // its own duplicate of the descriptor afterwards.
63
+ const logHandle = await open(logFile, 'a');
64
+ try {
65
+ const spawnProcess = dependencies.spawnProcess ?? spawn;
66
+ const child = spawnProcess(process.execPath, arguments_, {
67
+ detached: true,
68
+ stdio: ['ignore', logHandle.fd, logHandle.fd],
69
+ env: process.env,
70
+ });
71
+ child.unref();
72
+ }
73
+ finally {
74
+ await logHandle.close();
75
+ }
76
+ return { started: true, athleteId: request.athleteId, provider, logFile };
77
+ }
78
+ //# sourceMappingURL=detached-sync.js.map
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync } from 'node:fs';
4
- import { mkdir, writeFile } from 'node:fs/promises';
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
5
  import { createServer } from 'node:http';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
@@ -28,6 +28,86 @@ function baseProgress(runId, provider, stage) {
28
28
  estimatedRemainingSeconds: null, heartbeatAt: new Date().toISOString(),
29
29
  };
30
30
  }
31
+ /**
32
+ * Run the standalone swim-length backfill without progress streaming.
33
+ * The backfill reads every archived Garmin FIT (already on disk) and emits
34
+ * source_entity rows for any swim that actually contains length messages;
35
+ * importing them is idempotent, so re-parsing already-imported swims is
36
+ * harmless.
37
+ */
38
+ function runSwimBackfillProcess(arguments_, environment, log) {
39
+ return new Promise((resolve) => {
40
+ const child = spawn('uv', arguments_, { ...environment, stdio: ['ignore', 'pipe', 'pipe'] });
41
+ const stdout = [];
42
+ const stderr = [];
43
+ child.stdout.on('data', (chunk) => stdout.push(chunk.toString('utf8')));
44
+ child.stderr.on('data', (chunk) => stderr.push(chunk.toString('utf8')));
45
+ child.on('error', (error) => {
46
+ log.error('Garmin swim-backfill process could not be started', { error: error.message });
47
+ resolve({ code: 1, signal: null });
48
+ });
49
+ child.on('close', (code, signal) => {
50
+ const out = stdout.join('').trim();
51
+ const err = stderr.join('').trim();
52
+ if (out)
53
+ log.info(`[garmin swim_backfill] ${out}`);
54
+ if (err)
55
+ log.error(`[garmin swim_backfill] ${err}`);
56
+ resolve({ code, signal });
57
+ });
58
+ });
59
+ }
60
+ async function backfillGarminSwimLengths(database, paths, parentRunId, log) {
61
+ const output = path.join(paths.staging, 'garmin', `${parentRunId}.swim_lengths.jsonl`);
62
+ await mkdir(path.dirname(output), { recursive: true });
63
+ // Fresh file for deterministic counting; a stale file would make
64
+ // line counting ambiguous.
65
+ try {
66
+ const { rm } = await import('node:fs/promises');
67
+ await rm(output, { force: true });
68
+ }
69
+ catch {
70
+ // Best-effort cleanup.
71
+ }
72
+ // The swim list is queried here and handed to the Python worker as a file:
73
+ // the child must never open DuckDB itself because the parent holds the
74
+ // write lock for the whole run.
75
+ const activitiesFile = path.join(paths.staging, 'garmin', `${parentRunId}.swim_activities.json`);
76
+ await writeFile(activitiesFile, JSON.stringify(await database.garminSwimActivitySources()));
77
+ const arguments_ = [
78
+ 'run',
79
+ 'python',
80
+ '-m',
81
+ 'python.catence.providers.garmin.swim_backfill',
82
+ '--data-dir',
83
+ paths.root,
84
+ '--output',
85
+ output,
86
+ '--activities',
87
+ activitiesFile,
88
+ ];
89
+ const environment = {
90
+ cwd: packageRoot(),
91
+ env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
92
+ };
93
+ const outcome = await runSwimBackfillProcess(arguments_, environment, log);
94
+ if (outcome.code !== 0) {
95
+ throw new Error(`Garmin swim_backfill exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
96
+ }
97
+ if (!existsSync(output)) {
98
+ log.info('Garmin swim backfill produced no output file', { parentRunId });
99
+ return;
100
+ }
101
+ const content = await readFile(output, 'utf8');
102
+ const lines = content.split('\n').filter((line) => line.trim().length > 0);
103
+ if (lines.length === 0) {
104
+ log.info('Garmin swim backfill produced no staged lengths', { parentRunId });
105
+ return;
106
+ }
107
+ const backfillRunId = randomUUID();
108
+ const imported = await importJsonl(database, backfillRunId, output, log);
109
+ log.info('Imported swim lengths from automatic backfill', { parentRunId, backfillRunId, staged: lines.length, imported });
110
+ }
31
111
  /**
32
112
  * Run the Garmin staging worker with streamed stdout/stderr so progress and
33
113
  * errors surface in real time instead of buffering until the child exits.
@@ -198,6 +278,16 @@ async function syncProvider(database, paths, provider, options, log) {
198
278
  arguments_.push('--skip-activities');
199
279
  if (options.backfill && !options.refreshActivities)
200
280
  arguments_.push('--historical-only');
281
+ // Data-completeness catch-up: stores synced before lactate-threshold
282
+ // history capture only hold the latest singleton point. When no
283
+ // per-day LT observations exist yet, widen the worker's LT window to
284
+ // the athlete's first activity so the history backfills once; the
285
+ // condition self-clears after the first successful import.
286
+ if (!(await database.hasGarminLactateThresholdHistory())) {
287
+ const earliest = await database.earliestActivityStartDate('garmin');
288
+ if (earliest)
289
+ arguments_.push('--lt-history-from', earliest);
290
+ }
201
291
  let interruptChild = null;
202
292
  await runWithInterruptGuard(runId, database, () => interruptChild?.(), guard, async () => {
203
293
  const outcome = await runGarminWorker(runId, arguments_, {
@@ -212,6 +302,17 @@ async function syncProvider(database, paths, provider, options, log) {
212
302
  if (!existsSync(output))
213
303
  throw new Error('Garmin staging worker completed without writing a JSONL manifest.');
214
304
  await importJsonl(database, runId, output, log);
305
+ if (provider === 'garmin') {
306
+ try {
307
+ await backfillGarminSwimLengths(database, paths, runId, log);
308
+ }
309
+ catch (error) {
310
+ log.warn('Garmin swim-length backfill after sync failed', {
311
+ runId,
312
+ error: error instanceof Error ? error.message : String(error),
313
+ });
314
+ }
315
+ }
215
316
  }
216
317
  }
217
318
  }
@@ -56,6 +56,12 @@ export declare class CatenceDatabase implements WriteDataStore {
56
56
  activityNeedsDetailSync(provider: Provider, remoteActivityId: string, summaryHash: string): Promise<boolean>;
57
57
  recordActivitySyncState(provider: Provider, remoteActivityId: string, summaryHash: string, runId: string, detailsFetched: boolean): Promise<void>;
58
58
  knownActivitySummaryHashes(provider: Provider): Promise<Record<string, string>>;
59
+ hasGarminLactateThresholdHistory(): Promise<boolean>;
60
+ earliestActivityStartDate(provider: Provider): Promise<string | null>;
61
+ garminSwimActivitySources(): Promise<Array<{
62
+ remote_activity_id: string;
63
+ started_on: string | null;
64
+ }>>;
59
65
  private latestSourceDate;
60
66
  private upsertCursor;
61
67
  }