catence 0.2.0-beta.16 → 0.2.0-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.
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/runtime/secrets.js +51 -13
- package/dist/elt/application/management.js +102 -1
- package/dist/elt/storage/database.d.ts +6 -0
- package/dist/elt/storage/database.js +18 -0
- package/docs/configuration.md +16 -0
- package/docs/development.md +325 -0
- package/package.json +1 -1
- package/python/catence/providers/garmin/cli.py +7 -0
- package/python/catence/providers/garmin/swim_backfill.py +22 -5
- package/python/catence/providers/garmin/worker.py +138 -4
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.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.0-beta.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.17';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
environment.
|
|
62
|
-
if (
|
|
63
|
-
environment.
|
|
64
|
-
if (
|
|
65
|
-
environment.
|
|
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
|
|
@@ -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
|
}
|
|
@@ -271,6 +271,24 @@ export class CatenceDatabase {
|
|
|
271
271
|
const rows = await this.rows('SELECT remote_activity_id, summary_hash FROM activity_sync_state WHERE provider = $provider', { provider });
|
|
272
272
|
return Object.fromEntries(rows.map((row) => [row.remote_activity_id, row.summary_hash]));
|
|
273
273
|
}
|
|
274
|
+
async hasGarminLactateThresholdHistory() {
|
|
275
|
+
const rows = await this.rows("SELECT count(*) AS n FROM training_metric_observations WHERE observation_id LIKE 'garmin:lactate_threshold:daily:%'");
|
|
276
|
+
return (rows[0]?.n ?? 0) > 0;
|
|
277
|
+
}
|
|
278
|
+
async earliestActivityStartDate(provider) {
|
|
279
|
+
const rows = await this.rows(`SELECT min(cast(started_at_utc AS DATE)) AS earliest FROM activities activity
|
|
280
|
+
JOIN activity_sources source USING (activity_id) WHERE source.provider = $provider`, { provider });
|
|
281
|
+
return toIsoDate(rows[0]?.earliest ?? null);
|
|
282
|
+
}
|
|
283
|
+
async garminSwimActivitySources() {
|
|
284
|
+
const sports = ['lap_swimming', 'swimming', 'open_water_swimming', 'openwaterswimming'];
|
|
285
|
+
return this.rows(`SELECT source.remote_activity_id, cast(activity.started_at_utc AS VARCHAR) AS started_on
|
|
286
|
+
FROM activity_sources AS source
|
|
287
|
+
JOIN activities AS activity USING (activity_id)
|
|
288
|
+
WHERE source.provider = 'garmin'
|
|
289
|
+
AND lower(coalesce(activity.sport, '')) IN (${sports.map((sport) => `'${sport}'`).join(', ')})
|
|
290
|
+
ORDER BY activity.started_at_utc`);
|
|
291
|
+
}
|
|
274
292
|
async latestSourceDate(provider, cursorName) {
|
|
275
293
|
const observations = cursorName === 'daily'
|
|
276
294
|
? `SELECT max(metric_date) AS observed_date FROM daily_metrics WHERE provider = $provider
|
package/docs/configuration.md
CHANGED
|
@@ -185,6 +185,22 @@ into the provider worker environment, and inherited `GARMIN_*`/`INTERVALS_*`/
|
|
|
185
185
|
`STRAVA_*` variables are stripped first so an athlete without a provider cannot
|
|
186
186
|
inherit its credentials.
|
|
187
187
|
|
|
188
|
+
For local and Docker development you can also keep credentials in env without
|
|
189
|
+
`secret set` — see the two fallback modes below. File values always win over
|
|
190
|
+
env, so a `providers.json` entry overrides any env fallback for that field.
|
|
191
|
+
|
|
192
|
+
### Provider credentials via env (dev fallback)
|
|
193
|
+
|
|
194
|
+
Two opt-in env fallbacks are checked **only when a `providers.json` field is
|
|
195
|
+
missing** — file values always win. See `src/core/runtime/secrets.ts:51`.
|
|
196
|
+
|
|
197
|
+
| Mode | Variables | When allowed | Example for athlete `alex` |
|
|
198
|
+
|------|-----------|--------------|----------------------------|
|
|
199
|
+
| **Per-athlete scoped** (recommended, multi-athlete safe) | `CATENCE_ATHLETE_<ID>_<PROVIDER_VAR>` — prefix is `CATENCE_ATHLETE_` + upper-cased athlete id with `-` → `_` + `_` | **Always** — checked even when the generic flag is off. Only maps to that one athlete. | `CATENCE_ATHLETE_ALEX_GARMIN_EMAIL=alex@example.com` `CATENCE_ATHLETE_ALEX_INTERVALS_API_KEY=…` |
|
|
200
|
+
| **Generic** (single-athlete dev, opt-in) | `GARMIN_EMAIL`, `GARMIN_PASSWORD`, `INTERVALS_API_KEY`, `INTERVALS_ATHLETE_ID`, `STRAVA_CLIENT_ID`, `STRAVA_CLIENT_SECRET` | Only when `CATENCE_ALLOW_ENV_SECRETS` (or alias `CATENCE_SECRETS_FROM_ENV`) is `1`/`true`/`yes`/`on` in the worker's env. Without the flag, bare provider env vars are stripped. | `CATENCE_ALLOW_ENV_SECRETS=1` + `GARMIN_EMAIL=…` |
|
|
201
|
+
|
|
202
|
+
Resolution order per field: `providers.json` → `CATENCE_ATHLETE_<ID>_<VAR>` → (if flag set) `VAR`. Set `CATENCE_ALLOW_ENV_SECRETS=1` in `.env.dev` / `docker-compose.dev.yml` for single-athlete Docker dev, or prefer per-athlete vars for a shared catalog.
|
|
203
|
+
|
|
188
204
|
### Console auth and serving
|
|
189
205
|
|
|
190
206
|
| Variable | Purpose |
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# Catence development guide
|
|
2
|
+
|
|
3
|
+
This is the from-source counterpart to the three deployment guides. Use it to run, edit, and verify Catence without publishing a beta — locally on your machine and on a remote Docker host where you cannot live-debug until a new image is built.
|
|
4
|
+
|
|
5
|
+
Production installs use published artifacts (`npm install catence@beta` / `uv tool install catence-console` and `scripts/deploy-console.sh` → `console/Dockerfile.registry`). Everything below uses **your checkout directly**, so you can iterate on runtime, ELT, and Console code and test the real volume/config/secret paths before any release.
|
|
6
|
+
|
|
7
|
+
Related references: [`deployment/local-mcp.md`](deployment/local-mcp.md), [`deployment/local-console.md`](deployment/local-console.md), [`deployment/docker.md`](deployment/docker.md), [`configuration.md`](configuration.md), [`llm-providers.md`](llm-providers.md).
|
|
8
|
+
|
|
9
|
+
## Which path when
|
|
10
|
+
|
|
11
|
+
| Goal | Path | Why |
|
|
12
|
+
|------|------|-----|
|
|
13
|
+
| Test ELT, MCP tools, queries, or TypeScript/Python logic | **Local from source** (`tsx` + `uv run`) | Fastest loop; no Docker, no image build |
|
|
14
|
+
| Test the Console UI, Chainlit auth, or the authenticated dashboard proxy | **Local Console from source** (`catence-console serve` against a live `CATENCE_HOME`) | Same stack as production but without a container |
|
|
15
|
+
| Test the Docker volume layout, `/data` seeding, `config.json` / `chat-history.sqlite3` persistence, or `deploy-console.sh`-adjacent wiring | **Docker source build** (`console/Dockerfile`) | One `docker build` from the checkout — no registry, no `CATENCE_NPM_VERSION` |
|
|
16
|
+
| Iterate inside a container without rebuilding (host file → container) | **Docker live bind-mount** (override entrypoint/command, keep `/data` on a volume) | Edit on host, restart container, re-test |
|
|
17
|
+
|
|
18
|
+
Most changes do not need Docker at all. Use Docker only when you are explicitly testing the container boundary (file permissions, volume seeding, Console networking, MCP bind).
|
|
19
|
+
|
|
20
|
+
## Prerequisites
|
|
21
|
+
|
|
22
|
+
- Node.js 22+
|
|
23
|
+
- Python 3.12 ≤ version < 3.14, with [`uv`](https://docs.astral.sh/uv/)
|
|
24
|
+
- `git`, `curl`
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
git clone https://github.com/rifusaki/catence.git
|
|
28
|
+
cd catence
|
|
29
|
+
npm ci
|
|
30
|
+
uv sync --project console
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
No install step writes to a data home. The data home is created only by `catence-data setup` / `catence-data demo`.
|
|
34
|
+
|
|
35
|
+
## Local from source (no Docker, no beta)
|
|
36
|
+
|
|
37
|
+
### Choose a catalog home
|
|
38
|
+
|
|
39
|
+
The runtime, CLI, and Console all resolve the same home (`~/.catence` by default). Point it elsewhere with `CATENCE_HOME` or per-command `--home` so development never touches a personal catalog.
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
# Isolated home for this checkout/session (recommended for development)
|
|
43
|
+
export CATENCE_HOME="$PWD/.catence-dev"
|
|
44
|
+
catence-data setup --athlete alex --label "Alex"
|
|
45
|
+
# or explicitly: catence-data --home "$PWD/.catence-dev" setup --athlete alex --label "Alex"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The layout after `setup` is the same one Docker later mounts at `/data` — see [`configuration.md`](configuration.md) and [`deployment/local-console.md`](deployment/local-console.md#what-gets-stored-where):
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
$CATENCE_HOME/
|
|
52
|
+
catalog.json
|
|
53
|
+
config.json # Console model profiles — no secrets
|
|
54
|
+
console/chat-history.sqlite3
|
|
55
|
+
athletes/alex/
|
|
56
|
+
secrets/providers.json # mode 0600, written only via `secret set --value-stdin`
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Tear down a dev home with `rm -rf "$CATENCE_HOME"`; it is never committed (covered by `.gitignore`).
|
|
60
|
+
|
|
61
|
+
For a no-credentials smoke test, use the generated dataset instead:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
catence-data demo # creates/reuses ~/.catence-demo with athlete `demo`
|
|
65
|
+
npm run mcp -- demo # stdio MCP against the demo catalog
|
|
66
|
+
# The demo home is fixed to ~/.catence-demo; pass --home to move it.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Provider credentials — file (production) or env (dev)
|
|
70
|
+
|
|
71
|
+
**Preferred (production):** credentials are stored per athlete in `secrets/providers.json` (mode 0600) — never in `config.json`. Same interface as [`deployment/local-mcp.md`](deployment/local-mcp.md#3-store-provider-credentials-stdin-only-never-shell-history):
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
# Garmin
|
|
75
|
+
printf %s 'alex@example.com' | npm run catence-data -- --athlete alex secret set --provider garmin --field email --value-stdin
|
|
76
|
+
printf %s 'your-garmin-password' | npm run catence-data -- --athlete alex secret set --provider garmin --field password --value-stdin
|
|
77
|
+
# Intervals.icu
|
|
78
|
+
printf %s 'intervals-api-key' | npm run catence-data -- --athlete alex secret set --provider intervals --field apiKey --value-stdin
|
|
79
|
+
printf %s '12345' | npm run catence-data -- --athlete alex secret set --provider intervals --field athleteId --value-stdin
|
|
80
|
+
# Strava (create an API application at https://www.strava.com/settings/api)
|
|
81
|
+
printf %s 'strava-client-id' | npm run catence-data -- --athlete alex secret set --provider strava --field clientId --value-stdin
|
|
82
|
+
printf %s 'strava-client-secret' | npm run catence-data -- --athlete alex secret set --provider strava --field clientSecret --value-stdin
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
File values always win. Without a file entry the worker's inherited `GARMIN_*` / `INTERVALS_*` / `STRAVA_*` are normally stripped so an unset provider cannot silently inherit another athlete's credentials.
|
|
86
|
+
|
|
87
|
+
**Easier for local/Docker dev (`src/core/runtime/secrets.ts:51`):** when a `providers.json` field is missing, the worker also checks env fallbacks:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
# Option A — per-athlete scoped (always allowed, multi-athlete safe, no flag)
|
|
91
|
+
export CATENCE_ATHLETE_ALEX_GARMIN_EMAIL='alex@example.com'
|
|
92
|
+
export CATENCE_ATHLETE_ALEX_GARMIN_PASSWORD='…'
|
|
93
|
+
export CATENCE_ATHLETE_ALEX_INTERVALS_API_KEY='…'
|
|
94
|
+
export CATENCE_ATHLETE_ALEX_INTERVALS_ATHLETE_ID='12345'
|
|
95
|
+
export CATENCE_ATHLETE_ALEX_STRAVA_CLIENT_ID='…'
|
|
96
|
+
export CATENCE_ATHLETE_ALEX_STRAVA_CLIENT_SECRET='…'
|
|
97
|
+
# Prefix is CATENCE_ATHLETE_<ID>_ where <ID> is the athlete id upper-cased with '-' → '_'.
|
|
98
|
+
|
|
99
|
+
# Option B — generic (single-athlete dev, opt-in via flag)
|
|
100
|
+
export CATENCE_ALLOW_ENV_SECRETS=1 # or CATENCE_SECRETS_FROM_ENV=1 — values 1/true/yes/on
|
|
101
|
+
export GARMIN_EMAIL='alex@example.com'
|
|
102
|
+
export GARMIN_PASSWORD='…'
|
|
103
|
+
export INTERVALS_API_KEY='…'
|
|
104
|
+
export INTERVALS_ATHLETE_ID='12345'
|
|
105
|
+
export STRAVA_CLIENT_ID='…'
|
|
106
|
+
export STRAVA_CLIENT_SECRET='…'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Resolution per field: `providers.json` → `CATENCE_ATHLETE_<ID>_<VAR>` → (if `CATENCE_ALLOW_ENV_SECRETS` set) `VAR`. For Docker, put these in `.env.dev` and pass through `environment:` in `docker-compose.dev.yml` (or `--env-file .env.dev`); the `secret set --value-stdin` flow still works inside `docker compose run --entrypoint catence-data` for one-off seeding. See [`configuration.md`](configuration.md#provider-credentials-via-env-dev-fallback) and `.env.example`.
|
|
110
|
+
|
|
111
|
+
### Model profiles (where model definitions live)
|
|
112
|
+
|
|
113
|
+
`config.json` in the data home holds **definitions only** — profile ids, LiteLLM model names, and the *names* of environment variables that hold keys/URLs (`apiKeyEnv`, `apiBaseEnv`, `apiVersionEnv`). The values stay in the process environment. See [`configuration.md`](configuration.md#consoleprofiles) and [`llm-providers.md`](llm-providers.md) for the schema.
|
|
114
|
+
|
|
115
|
+
For development, three options (all write to `config.json` in `CATENCE_HOME`):
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
# 1) Start from the documented example
|
|
119
|
+
cp config.example.json "$CATENCE_HOME/config.json"
|
|
120
|
+
# then set the env var the profile names, e.g. OPENAI_API_KEY / OPENCODE_API_KEY
|
|
121
|
+
|
|
122
|
+
# 2) Let the Console wizard create one on first chat (replaces the whole `console` section)
|
|
123
|
+
# 3) Merge OpenCode Go / Zen profiles without clobbering existing ones
|
|
124
|
+
npm run discover:opencode-go -- --write "$CATENCE_HOME/config.json"
|
|
125
|
+
npm run discover:opencode-zen -- --write "$CATENCE_HOME/config.json" --free-only
|
|
126
|
+
|
|
127
|
+
# Verify
|
|
128
|
+
npm run console:doctor -- --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Changing `config.json` is picked up on the next Console request — no repository file tracks deployment-specific models or keys. Keep `config.example.json` as the canonical checked-in example.
|
|
132
|
+
|
|
133
|
+
### Running MCP, ELT, and tests without building `dist`
|
|
134
|
+
|
|
135
|
+
`package.json` exposes `tsx` dev scripts so edits are live without `npm run build`. Large edits should still pass `npm run check` (`tsc --noEmit` plus the runtime-boundary guard):
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
npm run mcp -- --help
|
|
139
|
+
npm run mcp -- --home "$CATENCE_HOME" # stdio MCP (same wire as the packaged `catence` bin)
|
|
140
|
+
npm run catence-data -- --home "$CATENCE_HOME" status --athlete alex
|
|
141
|
+
npm run catence-data -- --home "$CATENCE_HOME" sync --athlete alex --provider garmin --from 2025-07-29
|
|
142
|
+
npm run catence-data -- --home "$CATENCE_HOME" build-retrieval-index --athlete alex
|
|
143
|
+
|
|
144
|
+
# Hot-reload loop for MCP iteration (restarts on src/ change)
|
|
145
|
+
npx tsx watch src/interfaces/mcp/main.ts -- --home "$CATENCE_HOME"
|
|
146
|
+
|
|
147
|
+
# Tests (DuckDB integration tests use a 15s per-test timeout)
|
|
148
|
+
npm test
|
|
149
|
+
npm run test:watch
|
|
150
|
+
npm run check
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`npm run mcp` and `npm run catence-data` are thin wrappers for `tsx src/interfaces/{mcp,cli}/main.ts`; the packaged `dist/` is only needed for `npm pack` / `prepublishOnly` validation (`npm run release:check`).
|
|
154
|
+
|
|
155
|
+
### Running the Console locally against the dev catalog
|
|
156
|
+
|
|
157
|
+
The Console normally auto-starts a matching packaged runtime (`npx catence@<pinned>`). For development, point it at your live `tsx` runtime instead via `--mcp-url`:
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
# Terminal 1 — live MCP/ELT runtime on loopback
|
|
161
|
+
npx tsx watch src/interfaces/mcp/main.ts -- serve --home "$CATENCE_HOME" --host 127.0.0.1 --port 8787
|
|
162
|
+
|
|
163
|
+
# Terminal 2 — Console using that runtime (no auto-started `npx` runtime)
|
|
164
|
+
export CATENCE_CONSOLE_USERNAME='coach'
|
|
165
|
+
export CATENCE_CONSOLE_PASSWORD_HASH="$(uv run --project console catence-console auth hash-password)"
|
|
166
|
+
export CHAINLIT_AUTH_SECRET="$(openssl rand -hex 32)"
|
|
167
|
+
export OPENAI_API_KEY='…' # or ANTHROPIC_API_KEY / OPENCODE_API_KEY …
|
|
168
|
+
npm run console -- serve --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp --ui-port 8000
|
|
169
|
+
# open http://127.0.0.1:8000
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`--mcp-url` is the feature that makes the live bind-mount loop below possible — the same flag works when the runtime lives inside a container (see next section).
|
|
173
|
+
|
|
174
|
+
## Docker without publishing a beta
|
|
175
|
+
|
|
176
|
+
### Why `scripts/deploy-console.sh` forces a publish
|
|
177
|
+
|
|
178
|
+
The production scaffold (`scripts/deploy-console.sh`) resolves `CATENCE_NPM_VERSION` / `CATENCE_CONSOLE_VERSION` from the **public registries** and bakes `console/Dockerfile.registry` — a three-line image that runs `npm install --global catence@${CATENCE_NPM_VERSION}` and `uv pip install catence-console==${CATENCE_CONSOLE_VERSION}`. Rebuilding that scaffold on a server cannot test local edits until they exist on npm/PyPI, which is the bottleneck this guide removes.
|
|
179
|
+
|
|
180
|
+
### Source build — `console/Dockerfile` (reliable, still requires a rebuild)
|
|
181
|
+
|
|
182
|
+
`console/Dockerfile` is the from-source image: build context is the **repository root**, it runs `npm ci && npm run build && npm install --global .`, then `uv sync --project console`. Build it directly in any checkout, on any server with Docker:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
# From the repository root — tagged independently from the registry channel tags
|
|
186
|
+
docker build -f console/Dockerfile -t catence-console:dev .
|
|
187
|
+
|
|
188
|
+
# Run it the same way `docker.md` does, but with your local env
|
|
189
|
+
docker run --rm -p 127.0.0.1:8000:8000 -v catence-dev-data:/data \
|
|
190
|
+
-e CATENCE_CONSOLE_USERNAME='coach' \
|
|
191
|
+
-e CATENCE_CONSOLE_PASSWORD_HASH='replace-with-bcrypt-hash' \
|
|
192
|
+
-e CHAINLIT_AUTH_SECRET='replace-with-a-long-random-secret' \
|
|
193
|
+
-e OPENAI_API_KEY='…' \
|
|
194
|
+
catence-console:dev
|
|
195
|
+
|
|
196
|
+
# Or bind-mount a host data directory for easy inspection
|
|
197
|
+
mkdir -p /srv/catence-dev
|
|
198
|
+
docker run --rm -p 127.0.0.1:8000:8000 -v /srv/catence-dev:/data \
|
|
199
|
+
--env-file .env.dev \
|
|
200
|
+
catence-console:dev
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Seed and inspect the volume exactly as in [`deployment/docker.md`](deployment/docker.md):
|
|
204
|
+
|
|
205
|
+
```sh
|
|
206
|
+
docker run --rm -v catence-dev-data:/data --entrypoint catence-data catence-console:dev setup --athlete alex --label "Alex" --home /data
|
|
207
|
+
docker run --rm -v catence-dev-data:/data --entrypoint sh catence-console:dev -c 'ls -R /data; cat /data/config.json'
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Rebuilding the image is the only step that reflects code changes under this path; the data volume and `config.json` are preserved across rebuilds.
|
|
211
|
+
|
|
212
|
+
### Live loop — bind-mount the checkout into the container (no rebuild per edit)
|
|
213
|
+
|
|
214
|
+
When you need to edit on the server itself, mount the checkout into a dev container and have the entrypoint run the `tsx` watcher / `uv run` directly. The scaffold below is intentionally small — it keeps the **data volume** as the only state, so Console auth and `config.json` behave identically to production.
|
|
215
|
+
|
|
216
|
+
Minimal override compose (save as `docker-compose.dev.yml` alongside the checkout):
|
|
217
|
+
|
|
218
|
+
```yaml
|
|
219
|
+
services:
|
|
220
|
+
console:
|
|
221
|
+
build:
|
|
222
|
+
context: .
|
|
223
|
+
dockerfile: console/Dockerfile
|
|
224
|
+
image: catence-console:dev
|
|
225
|
+
ports:
|
|
226
|
+
- "127.0.0.1:8000:8000"
|
|
227
|
+
- "127.0.0.1:8787:8787"
|
|
228
|
+
environment:
|
|
229
|
+
CATENCE_HOME: /data
|
|
230
|
+
CATENCE_CONSOLE_USERNAME: ${CATENCE_CONSOLE_USERNAME:-}
|
|
231
|
+
CATENCE_CONSOLE_PASSWORD_HASH: ${CATENCE_CONSOLE_PASSWORD_HASH:-}
|
|
232
|
+
CHAINLIT_AUTH_SECRET: ${CHAINLIT_AUTH_SECRET:-}
|
|
233
|
+
# Provider credentials via env (see "Provider credentials" above):
|
|
234
|
+
# Per-athlete scoped vars work without a flag; generic vars need CATENCE_ALLOW_ENV_SECRETS=1.
|
|
235
|
+
CATENCE_ALLOW_ENV_SECRETS: ${CATENCE_ALLOW_ENV_SECRETS:-}
|
|
236
|
+
GARMIN_EMAIL: ${GARMIN_EMAIL:-}
|
|
237
|
+
GARMIN_PASSWORD: ${GARMIN_PASSWORD:-}
|
|
238
|
+
INTERVALS_API_KEY: ${INTERVALS_API_KEY:-}
|
|
239
|
+
INTERVALS_ATHLETE_ID: ${INTERVALS_ATHLETE_ID:-}
|
|
240
|
+
STRAVA_CLIENT_ID: ${STRAVA_CLIENT_ID:-}
|
|
241
|
+
STRAVA_CLIENT_SECRET: ${STRAVA_CLIENT_SECRET:-}
|
|
242
|
+
# Per-athlete example (add your athlete id upper-cased, '-' → '_'):
|
|
243
|
+
# CATENCE_ATHLETE_ALEX_GARMIN_EMAIL: ${CATENCE_ATHLETE_ALEX_GARMIN_EMAIL:-}
|
|
244
|
+
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
|
245
|
+
OPENAI_API_BASE: ${OPENAI_API_BASE:-}
|
|
246
|
+
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
|
247
|
+
OPENCODE_API_KEY: ${OPENCODE_API_KEY:-}
|
|
248
|
+
OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-}
|
|
249
|
+
OPENCODE_GO_API_BASE: ${OPENCODE_GO_API_BASE:-}
|
|
250
|
+
OPENCODE_GO_MESSAGES_API_BASE: ${OPENCODE_GO_MESSAGES_API_BASE:-}
|
|
251
|
+
OPENCODE_ZEN_API_BASE: ${OPENCODE_ZEN_API_BASE:-}
|
|
252
|
+
OPENCODE_ZEN_RESPONSES_API_BASE: ${OPENCODE_ZEN_RESPONSES_API_BASE:-}
|
|
253
|
+
volumes:
|
|
254
|
+
- catence-dev-data:/data # keep Console data on the volume
|
|
255
|
+
- .:/app # live source
|
|
256
|
+
- /app/node_modules # keep container's installed deps
|
|
257
|
+
- /app/console/.venv # keep container's venv
|
|
258
|
+
- /app/dist # keep container build isolated (see .dockerignore)
|
|
259
|
+
- /app/.catence-dev # hide host dev home from container
|
|
260
|
+
- /app/.catence # hide host home from container
|
|
261
|
+
command: >-
|
|
262
|
+
sh -c "npm run build &&
|
|
263
|
+
uv run --project /app/console catence-console serve
|
|
264
|
+
--home /data --ui-host 0.0.0.0 --ui-port 8000
|
|
265
|
+
--mcp-host 0.0.0.0 --mcp-port 8787"
|
|
266
|
+
# for no-restart TS hot-reload, replace the command with:
|
|
267
|
+
# npx tsx watch src/interfaces/mcp/main.ts -- serve --home /data --host 0.0.0.0 --port 8787
|
|
268
|
+
# and run Console separately with --mcp-url
|
|
269
|
+
|
|
270
|
+
volumes:
|
|
271
|
+
catence-dev-data:
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
# .env.dev — never committed
|
|
276
|
+
cat > .env.dev <<'EOF'
|
|
277
|
+
CATENCE_CONSOLE_USERNAME=coach
|
|
278
|
+
CATENCE_CONSOLE_PASSWORD_HASH='replace-with-bcrypt-hash'
|
|
279
|
+
CHAINLIT_AUTH_SECRET='replace-with-long-random-string'
|
|
280
|
+
OPENAI_API_KEY='…'
|
|
281
|
+
# Provider creds via env (easier than secret set for dev) — either:
|
|
282
|
+
# Per-athlete (always, multi-athlete safe): CATENCE_ATHLETE_ALEX_GARMIN_EMAIL=…
|
|
283
|
+
# Generic (needs flag): CATENCE_ALLOW_ENV_SECRETS=1 + GARMIN_EMAIL=…
|
|
284
|
+
EOF
|
|
285
|
+
|
|
286
|
+
docker compose -f docker-compose.dev.yml --env-file .env.dev up --build
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Edits to `src/` and `console/catence_console/` on the host are visible inside; `docker compose restart console` picks up TypeScript changes after `npm run build`, while adding `npx tsx watch` to the command removes even that step. The `/data` volume is still the authority for `config.json`, `secrets/providers.json`, and `console/chat-history.sqlite3`, so the same `catence-data secret set --value-stdin --home /data` flow works via:
|
|
290
|
+
|
|
291
|
+
```sh
|
|
292
|
+
docker compose -f docker-compose.dev.yml --env-file .env.dev exec console \
|
|
293
|
+
catence-data --home /data status --athlete alex
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
An alternative that avoids running `npm` inside the mount loop is to keep the runtime on the host entirely: run `npx tsx watch src/interfaces/mcp/main.ts -- serve --home /srv/catence-dev ...` on the host and start the Console container with `--mcp-url http://host.docker.internal:8787/mcp` (or the host's Tailscale IP) and `--env-file .env.dev` — the same `--mcp-url` trick from the local Console section.
|
|
297
|
+
|
|
298
|
+
## Where secrets and model definitions live (dev and Docker)
|
|
299
|
+
|
|
300
|
+
| What | Local dev | Docker dev/prod | Notes |
|
|
301
|
+
|------|-----------|-----------------|-------|
|
|
302
|
+
| Catalog + athlete stores | `CATENCE_HOME` (default `~/.catence`, dev: `$PWD/.catence-dev`) | `/data` (volume or bind mount) | Same on-disk layout; `CATENCE_HOME` controls it in both modes |
|
|
303
|
+
| Provider credentials (Garmin, Intervals, Strava) | `CATENCE_HOME/athletes/<id>/secrets/providers.json` (mode 0600) **or** env fallback when file field missing | `/data/athletes/<id>/secrets/providers.json` or same env fallback inside container | File wins; env fallback is `CATENCE_ATHLETE_<ID>_<VAR>` (always) or bare `GARMIN_*`/`INTERVALS_*`/`STRAVA_*` when `CATENCE_ALLOW_ENV_SECRETS=1` — see § Provider credentials above. Bare provider env vars are otherwise stripped before workers start |
|
|
304
|
+
| Model profiles + limits (`console.profiles`, `console.limits`) | `CATENCE_HOME/config.json` | `/data/config.json` (seeded from `config.example.json` on first deploy) | Versioned definitions + env-var names only. Credentials are read from the environment at serve time |
|
|
305
|
+
| API keys / base URLs | Process env (shell `export`, Docker `--env-file .env.dev`) | `.env` / `.env.dev` passed via `environment:` / `--env-file` | Named by the profile's `apiKeyEnv`/`apiBaseEnv`/`apiVersionEnv` (`OPENAI_API_KEY`, `OPENCODE_API_KEY`, `OPENCODE_GO_API_KEY`, …). `catence-console doctor` reports missing vars without printing values |
|
|
306
|
+
| Chat history | `CATENCE_HOME/console/chat-history.sqlite3` | `/data/console/chat-history.sqlite3` | Shared between runs when the volume is reused |
|
|
307
|
+
|
|
308
|
+
Recommended dev defaults:
|
|
309
|
+
|
|
310
|
+
- Keep a dedicated `CATENCE_HOME` (or local bind-mount path) per checkout or per test run; do not point development at a personal `~/.catence` catalog with real provider tokens.
|
|
311
|
+
- Commit no secrets: `.env*` (including `.env.dev`/`.env.beta`) and checkout-local `.catence-*` homes (which contain `athletes/<id>/secrets/providers.json`) are already ignored by `.gitignore`; the default `~/.catence` is outside the repo and naturally untracked.
|
|
312
|
+
- Treat `config.example.json` as the canonical model-definition template — copy its `console` section into the dev home's `config.json` and set only the one provider key you are testing.
|
|
313
|
+
|
|
314
|
+
## Iteration notes
|
|
315
|
+
|
|
316
|
+
- Re-run `npm run check && npm test` before pushing; `vitest` integration tests create and migrate real DuckDB stores with a 15 s per-test timeout.
|
|
317
|
+
- `catence-data update` tracks the registry channel (`latest` vs `beta`); from source the update path is `git pull`, `npm ci && npm run build`, `uv sync --project console`.
|
|
318
|
+
- The deployed scaffold's `sync.sh` / `hash-password.sh` / `doctor.sh` helpers are generated by `scripts/deploy-console.sh`; the dev container does not generate them — use the `npm run` / `uv run` equivalents above, or run the same `catence-data --home /data` commands via `docker compose exec`.
|
|
319
|
+
- If the Console settings panel shows no Model dropdown, the data home's `config.json` has no `console.profiles` — re-copy `config.example.json` or re-run the discovery script, then restart the Console.
|
|
320
|
+
|
|
321
|
+
## Troubleshooting
|
|
322
|
+
|
|
323
|
+
- **Doctor reports runtime unreachable** — the Console was started without `--mcp-url` and its auto-started runtime is on a different port. Start the runtime explicitly and pass `--mcp-url http://127.0.0.1:8787/mcp` to the Console, or use `uv run --project console catence-console doctor --home "$CATENCE_HOME" --mcp-url http://127.0.0.1:8787/mcp`.
|
|
324
|
+
- **Docker `run` container has a separate loopback** — `docker compose run` cannot see a `docker compose exec` MCP server. Use `exec` for `doctor`/`sync` against the running container, or use `run` only for one-off `setup`/`secret set` with `--value-stdin`.
|
|
325
|
+
- **Refusing to initialize** — the data home contains unrelated files. The error lists them; only `config.json`, `console/`, and Chainlit's `.files/`, `.chainlit/`, `public/` are allowed alongside a catalog.
|
package/package.json
CHANGED
|
@@ -29,6 +29,12 @@ def parse_args() -> argparse.Namespace:
|
|
|
29
29
|
parser.add_argument("--historical-only", action="store_true", help="Skip current account and collection endpoints.")
|
|
30
30
|
parser.add_argument("--skip-daily", action="store_true", help="Do not fetch date-scoped daily data.")
|
|
31
31
|
parser.add_argument("--skip-activities", action="store_true", help="Do not fetch date-scoped activities.")
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--lt-history-from",
|
|
34
|
+
dest="lt_history_from",
|
|
35
|
+
type=date.fromisoformat,
|
|
36
|
+
help="Backfill lactate-threshold history from this date instead of the daily window.",
|
|
37
|
+
)
|
|
32
38
|
return parser.parse_args()
|
|
33
39
|
|
|
34
40
|
|
|
@@ -72,6 +78,7 @@ def main() -> None:
|
|
|
72
78
|
daily_to_date=args.daily_to_date,
|
|
73
79
|
activity_to_date=args.activity_to_date,
|
|
74
80
|
include_non_historical=not args.historical_only,
|
|
81
|
+
lactate_threshold_history_from=args.lt_history_from,
|
|
75
82
|
)
|
|
76
83
|
except WorkerInterrupted:
|
|
77
84
|
reporter.finish("interrupted")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import json
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from .staging import StagingWriter
|
|
@@ -13,14 +14,21 @@ def parse_args() -> argparse.Namespace:
|
|
|
13
14
|
parser = argparse.ArgumentParser(description="Backfill swim lengths from already-archived Garmin activity FIT files.")
|
|
14
15
|
parser.add_argument("--data-dir", required=True, type=Path)
|
|
15
16
|
parser.add_argument("--output", required=True, type=Path)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--activities",
|
|
19
|
+
type=Path,
|
|
20
|
+
help=(
|
|
21
|
+
"JSON list of {remote_activity_id, started_on} rows produced by the TypeScript side. "
|
|
22
|
+
"Preferred over reading DuckDB because the parent process may hold the write lock."
|
|
23
|
+
),
|
|
24
|
+
)
|
|
16
25
|
return parser.parse_args()
|
|
17
26
|
|
|
18
27
|
|
|
19
|
-
def
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
raise SystemExit(f"Catalog database not found: {database_path}")
|
|
28
|
+
def load_swim_activities(activities_file: Path | None, database_path: Path) -> list[tuple[str, str | None]]:
|
|
29
|
+
if activities_file is not None:
|
|
30
|
+
payload = json.loads(activities_file.read_text())
|
|
31
|
+
return [(str(row["remote_activity_id"]), row.get("started_on")) for row in payload]
|
|
24
32
|
import duckdb
|
|
25
33
|
|
|
26
34
|
con = duckdb.connect(str(database_path), read_only=True)
|
|
@@ -35,6 +43,15 @@ def main() -> None:
|
|
|
35
43
|
""".format(placeholders=", ".join(f"'{sport}'" for sport in SWIM_SPORTS)),
|
|
36
44
|
).fetchall()
|
|
37
45
|
con.close()
|
|
46
|
+
return [(str(remote_id), started_at_utc) for remote_id, started_at_utc in rows]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> None:
|
|
50
|
+
args = parse_args()
|
|
51
|
+
database_path = args.data_dir / "catence.duckdb"
|
|
52
|
+
if args.activities is None and not database_path.exists():
|
|
53
|
+
raise SystemExit(f"Catalog database not found: {database_path}")
|
|
54
|
+
rows = load_swim_activities(args.activities, database_path)
|
|
38
55
|
|
|
39
56
|
writer = StagingWriter(args.data_dir, args.output)
|
|
40
57
|
staged = 0
|
|
@@ -44,6 +44,52 @@ def functional_threshold_power_items(value: Any) -> list[dict[str, Any]]:
|
|
|
44
44
|
return []
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def threshold_range_items(value: Any) -> list[dict[str, Any]]:
|
|
48
|
+
"""Extract dated points from a lactate-threshold range series response."""
|
|
49
|
+
items = functional_threshold_power_items(value)
|
|
50
|
+
return [item for item in items if isinstance(item.get("value"), (int, float)) and not isinstance(item.get("value"), bool)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def lactate_threshold_days(payload: Any) -> dict[str, dict[str, float]]:
|
|
54
|
+
"""Combine speed/heart-rate and power range series into per-day thresholds.
|
|
55
|
+
|
|
56
|
+
The range responses carry no weight or power-to-weight fields; deriving
|
|
57
|
+
W/kg is left to downstream joins against daily weight history.
|
|
58
|
+
"""
|
|
59
|
+
combined: dict[str, dict[str, float]] = {}
|
|
60
|
+
if not isinstance(payload, Mapping):
|
|
61
|
+
return combined
|
|
62
|
+
for block, key in (("speed", "speed"), ("heart_rate", "heartRate")):
|
|
63
|
+
for item in threshold_range_items(payload.get(block)):
|
|
64
|
+
day = ftp_history_date(item)
|
|
65
|
+
if day:
|
|
66
|
+
combined.setdefault(day, {})[key] = float(item["value"])
|
|
67
|
+
for item in threshold_range_items(payload.get("power")):
|
|
68
|
+
day = ftp_history_date(item)
|
|
69
|
+
if day:
|
|
70
|
+
combined.setdefault(day, {})["functionalThresholdPower"] = float(item["value"])
|
|
71
|
+
return combined
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def lactate_threshold_payload(day: str, threshold: Mapping[str, float]) -> dict[str, Any]:
|
|
75
|
+
"""Compose a singleton-shaped payload the normalizer already understands."""
|
|
76
|
+
observed_at = f"{day}T12:00:00"
|
|
77
|
+
payload: dict[str, Any] = {"calendarDate": observed_at}
|
|
78
|
+
speed = threshold.get("speed")
|
|
79
|
+
heart_rate = threshold.get("heartRate")
|
|
80
|
+
power = threshold.get("functionalThresholdPower")
|
|
81
|
+
if speed is not None or heart_rate is not None:
|
|
82
|
+
speed_and_heart_rate: dict[str, Any] = {"calendarDate": observed_at}
|
|
83
|
+
if speed is not None:
|
|
84
|
+
speed_and_heart_rate["speed"] = speed
|
|
85
|
+
if heart_rate is not None:
|
|
86
|
+
speed_and_heart_rate["heartRate"] = heart_rate
|
|
87
|
+
payload["speed_and_heart_rate"] = speed_and_heart_rate
|
|
88
|
+
if power is not None:
|
|
89
|
+
payload["power"] = {"calendarDate": observed_at, "functionalThresholdPower": power, "origin": "RUNNING"}
|
|
90
|
+
return payload
|
|
91
|
+
|
|
92
|
+
|
|
47
93
|
def ftp_history_date(payload: Mapping[str, Any]) -> str | None:
|
|
48
94
|
for key in ("until", "calendarDate", "date", "updatedDate", "from"):
|
|
49
95
|
value = payload.get(key)
|
|
@@ -111,6 +157,7 @@ class GarminStagingWorker:
|
|
|
111
157
|
daily_to_date: date | None = None,
|
|
112
158
|
activity_to_date: date | None = None,
|
|
113
159
|
include_non_historical: bool = True,
|
|
160
|
+
lactate_threshold_history_from: date | None = None,
|
|
114
161
|
) -> None:
|
|
115
162
|
assert_read_only_registry()
|
|
116
163
|
end = to_date or date.today()
|
|
@@ -132,6 +179,14 @@ class GarminStagingWorker:
|
|
|
132
179
|
self._hrv_history(daily_from_date, daily_end)
|
|
133
180
|
self._check_interrupted()
|
|
134
181
|
self._score_history(daily_from_date, daily_end)
|
|
182
|
+
# Lactate-threshold history uses the widest requested window so a
|
|
183
|
+
# single sync can catch stores up that were created before per-day
|
|
184
|
+
# LT capture existed; runs even when the daily window is skipped.
|
|
185
|
+
lt_windows = [window for window in (daily_from_date, lactate_threshold_history_from) if window is not None]
|
|
186
|
+
lt_start = min(lt_windows) if lt_windows else None
|
|
187
|
+
if lt_start is not None and lt_start <= daily_end:
|
|
188
|
+
self._check_interrupted()
|
|
189
|
+
self._lactate_threshold_history(lt_start, daily_end)
|
|
135
190
|
if activity_from_date and activity_from_date <= activity_end:
|
|
136
191
|
self._check_interrupted()
|
|
137
192
|
self._activities(activity_from_date, activity_end)
|
|
@@ -319,6 +374,41 @@ class GarminStagingWorker:
|
|
|
319
374
|
self.progress.advance(completed=completed_windows)
|
|
320
375
|
window_start = window_end + timedelta(days=1)
|
|
321
376
|
|
|
377
|
+
def _lactate_threshold_history(self, from_date: date, to_date: date) -> None:
|
|
378
|
+
"""Stage daily running lactate-threshold history from Garmin's range endpoints."""
|
|
379
|
+
window_start = from_date
|
|
380
|
+
total_windows = (to_date - from_date).days // 365 + 1
|
|
381
|
+
if self.progress is not None:
|
|
382
|
+
self.progress.set_stage("lt_history")
|
|
383
|
+
self.progress.advance(total=total_windows)
|
|
384
|
+
completed_windows = 0
|
|
385
|
+
while window_start <= to_date:
|
|
386
|
+
self._check_interrupted()
|
|
387
|
+
# Garmin rejects range windows longer than 366 days.
|
|
388
|
+
window_end = min(to_date, window_start + timedelta(days=365))
|
|
389
|
+
start, end = window_start.isoformat(), window_end.isoformat()
|
|
390
|
+
if self.progress is not None:
|
|
391
|
+
self.progress.advance(step=start)
|
|
392
|
+
payload, raw_hash = self._capture(
|
|
393
|
+
"lactate_threshold_range",
|
|
394
|
+
lambda start=start, end=end: self.api.get_lactate_threshold(latest=False, start_date=start, end_date=end, aggregation="daily"),
|
|
395
|
+
None,
|
|
396
|
+
{"from": start, "to": end, "aggregation": "daily"},
|
|
397
|
+
)
|
|
398
|
+
if payload is not None and raw_hash is not None:
|
|
399
|
+
for item_day, threshold in lactate_threshold_days(payload).items():
|
|
400
|
+
self.writer.source_entity(
|
|
401
|
+
"lactate_threshold",
|
|
402
|
+
f"daily:{item_day}",
|
|
403
|
+
lactate_threshold_payload(item_day, threshold),
|
|
404
|
+
raw_hash,
|
|
405
|
+
occurred_on=item_day,
|
|
406
|
+
)
|
|
407
|
+
completed_windows += 1
|
|
408
|
+
if self.progress is not None:
|
|
409
|
+
self.progress.advance(completed=completed_windows)
|
|
410
|
+
window_start = window_end + timedelta(days=1)
|
|
411
|
+
|
|
322
412
|
def _max_metrics_history(self, from_date: date, to_date: date) -> None:
|
|
323
413
|
"""Stage range max-metric responses by their actual calendar date."""
|
|
324
414
|
window_start = from_date
|
|
@@ -492,6 +582,49 @@ class GarminStagingWorker:
|
|
|
492
582
|
if isinstance(payload, dict):
|
|
493
583
|
self.writer.source_entity("activity", child_id, payload, raw_hash, parent_remote_id=parent_activity_id)
|
|
494
584
|
|
|
585
|
+
def _is_swim_activity(self, summary: dict[str, Any], activity: dict[str, Any] | None, details: dict[str, Any] | None) -> bool:
|
|
586
|
+
"""Return True when any of the Garmin sport/type fields indicates a swim."""
|
|
587
|
+
candidates: list[str] = []
|
|
588
|
+
# Direct sport string fields on the list summary (including flat activityType string).
|
|
589
|
+
for key in ("sport", "sportType", "sport_type", "activityType", "activityTypeKey"):
|
|
590
|
+
value = summary.get(key)
|
|
591
|
+
if isinstance(value, str) and value:
|
|
592
|
+
candidates.append(value)
|
|
593
|
+
# Nested activityType shapes on the list summary.
|
|
594
|
+
for key in ("activityType", "activityTypeDTO"):
|
|
595
|
+
nested = summary.get(key)
|
|
596
|
+
if isinstance(nested, dict):
|
|
597
|
+
type_key = nested.get("typeKey") or nested.get("type_key") or nested.get("type") or nested.get("key")
|
|
598
|
+
if isinstance(type_key, str) and type_key:
|
|
599
|
+
candidates.append(type_key)
|
|
600
|
+
# Detailed payloads provide the authoritative typeKey.
|
|
601
|
+
for payload in (activity, details):
|
|
602
|
+
if not isinstance(payload, dict):
|
|
603
|
+
continue
|
|
604
|
+
for key in ("activityType", "activityTypeDTO"):
|
|
605
|
+
nested = payload.get(key)
|
|
606
|
+
if isinstance(nested, dict):
|
|
607
|
+
type_key = nested.get("typeKey")
|
|
608
|
+
if isinstance(type_key, str) and type_key:
|
|
609
|
+
candidates.append(type_key)
|
|
610
|
+
for key in ("sport", "sportType"):
|
|
611
|
+
value = payload.get(key)
|
|
612
|
+
if isinstance(value, str) and value:
|
|
613
|
+
candidates.append(value)
|
|
614
|
+
summary_dto = payload.get("summaryDTO")
|
|
615
|
+
if isinstance(summary_dto, dict):
|
|
616
|
+
for key in ("activityType", "activityTypeDTO"):
|
|
617
|
+
nested = summary_dto.get(key)
|
|
618
|
+
if isinstance(nested, dict):
|
|
619
|
+
type_key = nested.get("typeKey")
|
|
620
|
+
if isinstance(type_key, str) and type_key:
|
|
621
|
+
candidates.append(type_key)
|
|
622
|
+
for candidate in candidates:
|
|
623
|
+
lowered = candidate.lower()
|
|
624
|
+
if lowered == "lap_swimming" or "swim" in lowered:
|
|
625
|
+
return True
|
|
626
|
+
return False
|
|
627
|
+
|
|
495
628
|
def _activity_details(self, activity_id: str, summary: dict[str, Any]) -> None:
|
|
496
629
|
calls = {
|
|
497
630
|
"activity": ("get_activity", "activity_detail"), "activity_details": ("get_activity_details", "activity_detail"),
|
|
@@ -501,17 +634,19 @@ class GarminStagingWorker:
|
|
|
501
634
|
"activity_exercise_sets": ("get_activity_exercise_sets", "activity_exercise_set"), "activity_gear": ("get_activity_gear", "activity_gear"),
|
|
502
635
|
}
|
|
503
636
|
details: dict[str, Any] | None = None
|
|
637
|
+
activity_payload: dict[str, Any] | None = None
|
|
504
638
|
for endpoint, (method, entity_type) in calls.items():
|
|
505
639
|
payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)(activity_id), activity_id)
|
|
506
640
|
if payload is not None:
|
|
507
641
|
self._entity(endpoint, entity_type, payload, raw_hash, parent_remote_id=activity_id)
|
|
508
642
|
if endpoint == "activity" and isinstance(payload, dict):
|
|
643
|
+
activity_payload = payload
|
|
509
644
|
self._multisport_children(activity_id, payload)
|
|
510
645
|
if endpoint == "activity_details" and isinstance(payload, dict):
|
|
511
646
|
details = payload
|
|
512
|
-
self._activity_files(activity_id, summary, details)
|
|
647
|
+
self._activity_files(activity_id, summary, activity_payload, details)
|
|
513
648
|
|
|
514
|
-
def _activity_files(self, activity_id: str, summary: dict[str, Any], details: dict[str, Any] | None) -> None:
|
|
649
|
+
def _activity_files(self, activity_id: str, summary: dict[str, Any], activity: dict[str, Any] | None, details: dict[str, Any] | None) -> None:
|
|
515
650
|
try:
|
|
516
651
|
from garminconnect import Garmin
|
|
517
652
|
formats = {
|
|
@@ -557,8 +692,7 @@ class GarminStagingWorker:
|
|
|
557
692
|
occurred_on=start_date[:10],
|
|
558
693
|
)
|
|
559
694
|
if original_path and original_path.exists():
|
|
560
|
-
|
|
561
|
-
if sport == "lap_swimming" or "swim" in sport:
|
|
695
|
+
if self._is_swim_activity(summary, activity, details):
|
|
562
696
|
lengths = fit_archive_to_swim_lengths(f"garmin:{activity_id}", original_path)
|
|
563
697
|
if lengths:
|
|
564
698
|
start_date = str(summary.get("startTimeLocal") or summary.get("startTimeGMT") or date.today().isoformat())
|