catence 0.2.0-beta.1 → 0.2.0-beta.11
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 +23 -77
- package/config.example.json +32 -20
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/progress.d.ts +23 -0
- package/dist/contracts/progress.js +48 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/logging.d.ts +16 -0
- package/dist/core/logging.js +68 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +46 -0
- package/dist/core/runtime/catalog.js +17 -7
- package/dist/core/runtime/self-update.d.ts +44 -0
- package/dist/core/runtime/self-update.js +138 -0
- package/dist/elt/application/management.d.ts +2 -0
- package/dist/elt/application/management.js +220 -18
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +4 -3
- package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
- package/dist/elt/ingestion/providers/intervals.js +4 -1
- package/dist/elt/ingestion/providers/strava/service.js +7 -1
- package/dist/elt/storage/database.d.ts +7 -2
- package/dist/elt/storage/database.js +81 -4
- package/dist/elt/storage/migrations.js +25 -1
- package/dist/elt/storage/progress-sidecar.d.ts +26 -0
- package/dist/elt/storage/progress-sidecar.js +87 -0
- package/dist/interfaces/cli/commands.js +91 -1
- package/dist/interfaces/mcp/server.js +7 -0
- package/dist/runtime/index.d.ts +4 -1
- package/dist/runtime/index.js +2 -1
- package/docs/architecture.md +11 -1
- package/docs/configuration.md +221 -0
- package/docs/deployment/docker.md +528 -0
- package/docs/deployment/local-console.md +230 -0
- package/docs/deployment/local-mcp.md +304 -0
- package/docs/distribution.md +58 -0
- package/docs/llm-providers.md +467 -0
- package/docs/mcp-activity-retrieval.md +37 -30
- package/package.json +5 -1
- package/python/catence/providers/garmin/cli.py +30 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/worker.py +132 -5
- package/scripts/discover-opencode-go.mjs +210 -0
- package/scripts/discover-opencode-zen.mjs +218 -0
package/README.md
CHANGED
|
@@ -25,11 +25,11 @@ Catence helps explore recovery, training load, trends, activity detail, swimming
|
|
|
25
25
|
Install Catence and create the first athlete. The default catalog home is `~/.catence`; set `CATENCE_HOME` or pass `--home` to use a different location.
|
|
26
26
|
|
|
27
27
|
```sh
|
|
28
|
-
npm install --global catence@
|
|
28
|
+
npm install --global catence@beta # or catence@latest for stable
|
|
29
29
|
catence-data setup --athlete alex --label "Alex"
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
Store each provider value through stdin so it does not enter shell history. Values are written to the selected athlete
|
|
32
|
+
Store each provider value through stdin so it does not enter shell history. Values are written to the selected athlete's owner-only local secret file.
|
|
33
33
|
|
|
34
34
|
```sh
|
|
35
35
|
printf %s 'alex@example.com' | catence-data --athlete alex secret set --provider garmin --field email --value-stdin
|
|
@@ -63,49 +63,42 @@ catence-data --athlete sam sync --provider garmin
|
|
|
63
63
|
catence-data athlete list
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
The
|
|
67
|
-
|
|
68
|
-
```text
|
|
69
|
-
~/.catence/
|
|
70
|
-
catalog.json
|
|
71
|
-
config.json # shared Console configuration; no secrets
|
|
72
|
-
console/chat-history.sqlite3
|
|
73
|
-
athletes/
|
|
74
|
-
alex/ # isolated DuckDB, raw data, and tokens
|
|
75
|
-
secrets/providers.json # mode 0600
|
|
76
|
-
sam/
|
|
77
|
-
...
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
Garmin, Intervals, and Strava client credentials are isolated per athlete. Strava OAuth tokens remain in that athlete’s own store. The old 0.1 single-store directory cannot be migrated safely: create a fresh home and re-sync each athlete.
|
|
66
|
+
Garmin, Intervals, and Strava client credentials are isolated per athlete. Strava OAuth tokens remain in that athlete's own store. The old 0.1 single-store directory cannot be migrated safely: create a fresh home and re-sync each athlete.
|
|
81
67
|
|
|
82
68
|
### Common operations
|
|
83
69
|
|
|
84
70
|
```sh
|
|
85
|
-
# Incremental or explicit data work for one athlete
|
|
86
71
|
catence-data --athlete alex status
|
|
87
72
|
catence-data --athlete alex sync --provider intervals
|
|
88
73
|
catence-data --athlete alex sync --provider garmin --from 2025-07-29
|
|
89
74
|
catence-data --athlete alex backfill --provider garmin --from 2020-01-01 --refresh
|
|
90
75
|
catence-data --athlete alex retry --run <run-id>
|
|
91
|
-
|
|
92
|
-
# Strava OAuth uses clientId and clientSecret previously stored for this athlete
|
|
76
|
+
catence-data --athlete alex progress --watch
|
|
93
77
|
catence-data --athlete alex auth strava --callback
|
|
94
78
|
catence-data --athlete alex disconnect strava
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
catence-data --home /srv/catence setup --athlete alex --label "Alex"
|
|
98
|
-
catence --home /srv/catence
|
|
79
|
+
catence-data update --check
|
|
80
|
+
catence-data update
|
|
99
81
|
```
|
|
100
82
|
|
|
101
|
-
|
|
83
|
+
## Documentation
|
|
84
|
+
|
|
85
|
+
| Document | Covers |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| [Deployment: local MCP](docs/deployment/local-mcp.md) | Install, catalog, secrets, sync, background progress, stdio/HTTP MCP, demo, common operations |
|
|
88
|
+
| [Deployment: local Console](docs/deployment/local-console.md) | Local web chat setup, model config, doctor, updates, troubleshooting |
|
|
89
|
+
| [Deployment: Docker](docs/deployment/docker.md) | One-container stack: deploy script, `.env`, sync.sh, MCP exposure (SSH/Tailscale), Cloudflare Tunnel |
|
|
90
|
+
| [Configuration](docs/configuration.md) | Complete `config.json` schema: rate limits, Strava budget, Console profiles, environment variables |
|
|
91
|
+
| [LLM providers](docs/llm-providers.md) | Model providers (OpenAI, Anthropic, OpenAI-compatible/Azure, Opencode Go/Zen), reasoning effort |
|
|
92
|
+
| [Architecture](docs/architecture.md) | Design rationale, layers, data flow, serving |
|
|
93
|
+
| [MCP activity retrieval](docs/mcp-activity-retrieval.md) | Implementation contract for answering questions, current tools, planned endpoints |
|
|
94
|
+
| [Distribution](docs/distribution.md) | Release artifacts: npm, PyPI, APM, MCPB |
|
|
102
95
|
|
|
103
96
|
## Safe demo and Glama
|
|
104
97
|
|
|
105
98
|
Run a clearly marked generated dataset without provider accounts:
|
|
106
99
|
|
|
107
100
|
```sh
|
|
108
|
-
npx --yes catence@
|
|
101
|
+
npx --yes catence@beta demo
|
|
109
102
|
```
|
|
110
103
|
|
|
111
104
|
It creates or reuses `~/.catence-demo` with one `demo` athlete. The generated data has explicit caveats in every tool response and never contains personal measurements. The Glama registry entry uses this command for **Try in Browser**, so the hosted sandbox has no access to local credentials or personal data.
|
|
@@ -137,58 +130,11 @@ Optional local Streamable HTTP MCP and dashboard APIs:
|
|
|
137
130
|
catence serve --host 127.0.0.1 --port 8787
|
|
138
131
|
```
|
|
139
132
|
|
|
140
|
-
`GET /api/v1/athletes` returns IDs and labels only. `GET /api/v1/dashboard` requires `athleteId`, for example `http://127.0.0.1:8787/api/v1/dashboard?athleteId=alex&days=28`. Browser origins must be listed with `--allow-origin`; the packaged Console instead proxies the dashboard through its authenticated same-origin route.
|
|
141
|
-
|
|
142
|
-
## Catence Console
|
|
143
|
-
|
|
144
|
-
The Console is a local web chat that starts a matching Catence HTTP server on loopback. It uses Chainlit password login and fails closed unless all three authentication variables are supplied.
|
|
145
|
-
|
|
146
|
-
```sh
|
|
147
|
-
export CATENCE_HOME="$HOME/.catence"
|
|
148
|
-
export CATENCE_CONSOLE_USERNAME='coach'
|
|
149
|
-
export CATENCE_CONSOLE_PASSWORD_HASH="$(catence-console auth hash-password)"
|
|
150
|
-
export CHAINLIT_AUTH_SECRET="$(openssl rand -hex 32)"
|
|
151
|
-
export OPENAI_API_KEY='…'
|
|
152
|
-
|
|
153
|
-
uvx catence-console@0.2.0 serve
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
On the first authenticated visit, the Console wizard asks for an Azure, OpenAI, or Anthropic model and writes only the non-secret model configuration to `~/.catence/config.json`. Model-provider keys remain process environment variables. The Console settings include an athlete selector; the server overwrites model-supplied athlete scope with that selected value on every personal-data call.
|
|
157
|
-
|
|
158
|
-
To preflight the Console and a running runtime:
|
|
159
|
-
|
|
160
|
-
```sh
|
|
161
|
-
uvx catence-console@0.2.0 doctor --home "$HOME/.catence" --mcp-url http://127.0.0.1:8787/mcp
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
Console chats and saved tool context are local at `~/.catence/console/chat-history.sqlite3`.
|
|
165
|
-
|
|
166
|
-
### Docker and Cloudflare Tunnel
|
|
167
|
-
|
|
168
|
-
The Console image contains the Node runtime, Catence, and Console. Its MCP server remains loopback-only inside the container; only the Console port is exposed.
|
|
169
|
-
|
|
170
|
-
```sh
|
|
171
|
-
docker build -f console/Dockerfile -t catence-console .
|
|
172
|
-
docker run --rm -p 127.0.0.1:8000:8000 -v catence-data:/data \
|
|
173
|
-
-e CATENCE_CONSOLE_USERNAME='coach' \
|
|
174
|
-
-e CATENCE_CONSOLE_PASSWORD_HASH='replace-with-bcrypt-hash' \
|
|
175
|
-
-e CHAINLIT_AUTH_SECRET='replace-with-a-long-random-secret' \
|
|
176
|
-
-e OPENAI_API_KEY='…' \
|
|
177
|
-
catence-console
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
Initialize the mounted volume before its first production sync, for example:
|
|
181
|
-
|
|
182
|
-
```sh
|
|
183
|
-
docker run --rm -it -v catence-data:/data --entrypoint catence-data catence-console \
|
|
184
|
-
setup --athlete alex --label "Alex"
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
Use the same `--entrypoint catence-data` pattern for `secret set` and `sync`. Route a Cloudflare Tunnel to `http://127.0.0.1:8000`; do not run a tunnel client in this image. Use HTTPS at the tunnel and retain the Console login—the dashboard is fetched through the authenticated Console origin rather than directly from port 8787.
|
|
133
|
+
`GET /api/v1/athletes` returns IDs and labels only. `GET /api/v1/dashboard` requires `athleteId`, for example `http://127.0.0.1:8787/api/v1/dashboard?athleteId=alex&days=28`. Browser origins must be listed with `--allow-origin`; the packaged Console instead proxies the dashboard through its authenticated same-origin route. The MCP server has no authentication of its own — keep it on loopback or restrict it at the network layer (see [Docker MCP exposure](docs/deployment/docker.md#connecting-to-the-docker-based-mcp-from-outside) for the remote-access patterns).
|
|
188
134
|
|
|
189
135
|
## Configuration
|
|
190
136
|
|
|
191
|
-
[`config.example.json`](config.example.json) documents rate limits, Strava budgets, and Console model profiles. It intentionally contains environment-variable names rather than credential values. Keep per-athlete provider values in `catence-data secret set`, not in `.env` or `config.json`.
|
|
137
|
+
[`config.example.json`](config.example.json) documents rate limits, Strava budgets, and Console model profiles. It intentionally contains environment-variable names rather than credential values. Keep per-athlete provider values in `catence-data secret set`, not in `.env` or `config.json`. The full `config.json` schema — including per-model reasoning effort — is documented in [`docs/configuration.md`](docs/configuration.md) and [`docs/llm-providers.md`](docs/llm-providers.md).
|
|
192
138
|
|
|
193
139
|
For source development:
|
|
194
140
|
|
|
@@ -212,6 +158,6 @@ data.
|
|
|
212
158
|
|
|
213
159
|
## Data and caveats
|
|
214
160
|
|
|
215
|
-
Garmin is the primary source for activity and wellness data; Intervals.icu supplements training analysis; Strava is used for targeted segments, efforts, and gear. Missing fields mean a provider did not supply a value. The source credentials and source APIs determine the data available. Catence does not use the official Garmin API; Garmin sync relies on the selected athlete
|
|
161
|
+
Garmin is the primary source for activity and wellness data; Intervals.icu supplements training analysis; Strava is used for targeted segments, efforts, and gear. Missing fields mean a provider did not supply a value. The source credentials and source APIs determine the data available. Catence does not use the official Garmin API; Garmin sync relies on the selected athlete's email/password credentials.
|
|
216
162
|
|
|
217
|
-
See [distribution notes](docs/distribution.md) for release artifacts, including APM and MCPB demo bundles.
|
|
163
|
+
See [distribution notes](docs/distribution.md) for release artifacts, including APM and MCPB demo bundles.
|
package/config.example.json
CHANGED
|
@@ -23,40 +23,52 @@
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"console": {
|
|
26
|
-
"defaultProfile": "
|
|
26
|
+
"defaultProfile": "openai",
|
|
27
27
|
"limits": {
|
|
28
28
|
"toolRounds": 8,
|
|
29
29
|
"toolResultCharacters": 24000
|
|
30
30
|
},
|
|
31
31
|
"profiles": {
|
|
32
|
-
"azure-foundry": {
|
|
33
|
-
"label": "Azure Foundry",
|
|
34
|
-
"defaultModel": "terra",
|
|
35
|
-
"defaultReasoningEffort": "medium",
|
|
36
|
-
"models": {
|
|
37
|
-
"terra": { "label": "GPT-5.6 Terra", "model": "azure_ai/gpt-5.6-terra" },
|
|
38
|
-
"luna": { "label": "GPT-5.6 Luna", "model": "azure_ai/gpt-5.6-luna" },
|
|
39
|
-
"sol": { "label": "GPT-5.6 Sol", "model": "azure_ai/gpt-5.6-sol" }
|
|
40
|
-
},
|
|
41
|
-
"apiKeyEnv": "AZURE_API_KEY",
|
|
42
|
-
"apiBaseEnv": "AZURE_API_BASE",
|
|
43
|
-
"apiVersionEnv": "AZURE_API_VERSION"
|
|
44
|
-
},
|
|
45
|
-
"opencode": {
|
|
46
|
-
"label": "OpenCode-compatible",
|
|
47
|
-
"model": "openai/your-model-name",
|
|
48
|
-
"apiKeyEnv": "OPENCODE_API_KEY",
|
|
49
|
-
"apiBaseEnv": "OPENCODE_API_BASE"
|
|
50
|
-
},
|
|
51
32
|
"openai": {
|
|
52
33
|
"label": "OpenAI",
|
|
53
34
|
"model": "openai/gpt-5-mini",
|
|
54
35
|
"apiKeyEnv": "OPENAI_API_KEY"
|
|
55
36
|
},
|
|
37
|
+
"openai-compatible": {
|
|
38
|
+
"label": "OpenAI-compatible",
|
|
39
|
+
"model": "openai/your-model-name",
|
|
40
|
+
"apiKeyEnv": "OPENAI_API_KEY",
|
|
41
|
+
"apiBaseEnv": "OPENAI_API_BASE"
|
|
42
|
+
},
|
|
56
43
|
"anthropic": {
|
|
57
44
|
"label": "Anthropic",
|
|
58
45
|
"model": "anthropic/claude-sonnet-4-5",
|
|
59
46
|
"apiKeyEnv": "ANTHROPIC_API_KEY"
|
|
47
|
+
},
|
|
48
|
+
"opencode-zen": {
|
|
49
|
+
"label": "OpenCode Zen",
|
|
50
|
+
"defaultModel": "deepseek-v4-flash-free",
|
|
51
|
+
"models": {
|
|
52
|
+
"deepseek-v4-flash-free": {
|
|
53
|
+
"label": "DeepSeek V4 Flash Free",
|
|
54
|
+
"model": "openai/deepseek-v4-flash-free",
|
|
55
|
+
"variants": { "High": "high", "Max": "max" }
|
|
56
|
+
},
|
|
57
|
+
"nemotron-3-ultra-free": { "label": "Nemotron 3 Ultra Free", "model": "openai/nemotron-3-ultra-free" },
|
|
58
|
+
"big-pickle": { "label": "Big Pickle", "model": "openai/big-pickle" }
|
|
59
|
+
},
|
|
60
|
+
"apiKeyEnv": "OPENCODE_API_KEY",
|
|
61
|
+
"apiBaseEnv": "OPENCODE_ZEN_API_BASE"
|
|
62
|
+
},
|
|
63
|
+
"opencode-zen-responses": {
|
|
64
|
+
"label": "OpenCode Zen (Responses)",
|
|
65
|
+
"defaultModel": "gpt-5.6-luna",
|
|
66
|
+
"models": {
|
|
67
|
+
"gpt-5.6-luna": { "label": "GPT 5.6 Luna", "model": "openai/responses/gpt-5.6-luna" },
|
|
68
|
+
"gpt-5.6-terra": { "label": "GPT 5.6 Terra", "model": "openai/responses/gpt-5.6-terra" }
|
|
69
|
+
},
|
|
70
|
+
"apiKeyEnv": "OPENCODE_API_KEY",
|
|
71
|
+
"apiBaseEnv": "OPENCODE_ZEN_RESPONSES_API_BASE"
|
|
60
72
|
}
|
|
61
73
|
}
|
|
62
74
|
}
|
package/dist/contracts/index.js
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const STALE_RUN_TIMEOUT_MS: number;
|
|
2
|
+
export declare const SYNC_STAGES: readonly ["starting", "login", "singletons", "daily", "range", "ftp_history", "max_metrics", "hrv_history", "scores", "activities", "collections", "importing", "completed", "interrupted", "failed", "timed_out"];
|
|
3
|
+
export type SyncStage = (typeof SYNC_STAGES)[number];
|
|
4
|
+
export interface SyncProgressState {
|
|
5
|
+
runId: string;
|
|
6
|
+
provider: string;
|
|
7
|
+
stage: SyncStage;
|
|
8
|
+
currentStep: string | null;
|
|
9
|
+
completedUnits: number;
|
|
10
|
+
totalUnits: number | null;
|
|
11
|
+
percentComplete: number;
|
|
12
|
+
elapsedSeconds: number;
|
|
13
|
+
estimatedRemainingSeconds: number | null;
|
|
14
|
+
heartbeatAt: string;
|
|
15
|
+
}
|
|
16
|
+
export interface SyncProgressSnapshot {
|
|
17
|
+
running: SyncProgressState[];
|
|
18
|
+
recent: SyncProgressState[];
|
|
19
|
+
}
|
|
20
|
+
export interface InterruptedRuns {
|
|
21
|
+
runIds: string[];
|
|
22
|
+
}
|
|
23
|
+
export declare function normalizeProgress(value: unknown): SyncProgressState | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const STALE_RUN_TIMEOUT_MS = 15 * 60 * 1000;
|
|
2
|
+
export const SYNC_STAGES = [
|
|
3
|
+
'starting',
|
|
4
|
+
'login',
|
|
5
|
+
'singletons',
|
|
6
|
+
'daily',
|
|
7
|
+
'range',
|
|
8
|
+
'ftp_history',
|
|
9
|
+
'max_metrics',
|
|
10
|
+
'hrv_history',
|
|
11
|
+
'scores',
|
|
12
|
+
'activities',
|
|
13
|
+
'collections',
|
|
14
|
+
'importing',
|
|
15
|
+
'completed',
|
|
16
|
+
'interrupted',
|
|
17
|
+
'failed',
|
|
18
|
+
'timed_out',
|
|
19
|
+
];
|
|
20
|
+
export function normalizeProgress(value) {
|
|
21
|
+
if (typeof value !== 'object' || value === null) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const record = value;
|
|
25
|
+
const runId = typeof record.runId === 'string' ? record.runId : '';
|
|
26
|
+
const provider = typeof record.provider === 'string' ? record.provider : '';
|
|
27
|
+
if (!runId || !provider) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const stage = SYNC_STAGES.includes(record.stage)
|
|
31
|
+
? record.stage
|
|
32
|
+
: 'starting';
|
|
33
|
+
const numberOr = (value, fallback) => typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
34
|
+
const roundOrNull = (value) => typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.round(value)) : null;
|
|
35
|
+
return {
|
|
36
|
+
runId,
|
|
37
|
+
provider,
|
|
38
|
+
stage,
|
|
39
|
+
currentStep: typeof record.currentStep === 'string' ? record.currentStep : null,
|
|
40
|
+
completedUnits: Math.max(0, Math.round(numberOr(record.completedUnits, 0))),
|
|
41
|
+
totalUnits: roundOrNull(record.totalUnits),
|
|
42
|
+
percentComplete: Math.min(100, Math.max(0, numberOr(record.percentComplete, 0))),
|
|
43
|
+
elapsedSeconds: Math.max(0, numberOr(record.elapsedSeconds, 0)),
|
|
44
|
+
estimatedRemainingSeconds: roundOrNull(record.estimatedRemainingSeconds),
|
|
45
|
+
heartbeatAt: typeof record.heartbeatAt === 'string' ? record.heartbeatAt : new Date().toISOString(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=progress.js.map
|
|
@@ -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.11";
|
|
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.11';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CatencePaths } from '../contracts/runtime.js';
|
|
2
|
+
export type SyncLogger = {
|
|
3
|
+
info: (message: string, context?: Record<string, unknown>) => void;
|
|
4
|
+
warn: (message: string, context?: Record<string, unknown>) => void;
|
|
5
|
+
error: (message: string, context?: Record<string, unknown>) => void;
|
|
6
|
+
debug: (message: string, context?: Record<string, unknown>) => void;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the sync log file path.
|
|
10
|
+
*
|
|
11
|
+
* `CATENCE_LOG` may be an absolute path or a path relative to the catalog home.
|
|
12
|
+
* When unset (the default), logs go to stderr only so the console stays clean
|
|
13
|
+
* and stdout remains machine-readable for callers like sync.sh.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveLogFile(paths: CatencePaths, environment?: NodeJS.ProcessEnv): string | null;
|
|
16
|
+
export declare function createSyncLogger(paths: CatencePaths, environment?: NodeJS.ProcessEnv): SyncLogger;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const LEVEL_WEIGHT = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
4
|
+
function configuredLevel(environment = process.env) {
|
|
5
|
+
const raw = (environment.CATENCE_LOG_LEVEL ?? 'info').toLowerCase();
|
|
6
|
+
if (raw === 'debug')
|
|
7
|
+
return LEVEL_WEIGHT.debug;
|
|
8
|
+
if (raw === 'warn')
|
|
9
|
+
return LEVEL_WEIGHT.warn;
|
|
10
|
+
if (raw === 'error')
|
|
11
|
+
return LEVEL_WEIGHT.error;
|
|
12
|
+
return LEVEL_WEIGHT.info;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the sync log file path.
|
|
16
|
+
*
|
|
17
|
+
* `CATENCE_LOG` may be an absolute path or a path relative to the catalog home.
|
|
18
|
+
* When unset (the default), logs go to stderr only so the console stays clean
|
|
19
|
+
* and stdout remains machine-readable for callers like sync.sh.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveLogFile(paths, environment = process.env) {
|
|
22
|
+
const configured = environment.CATENCE_LOG;
|
|
23
|
+
if (!configured)
|
|
24
|
+
return null;
|
|
25
|
+
return path.isAbsolute(configured) ? configured : path.join(paths.root, configured);
|
|
26
|
+
}
|
|
27
|
+
export function createSyncLogger(paths, environment = process.env) {
|
|
28
|
+
const threshold = configuredLevel(environment);
|
|
29
|
+
const logFile = resolveLogFile(paths, environment);
|
|
30
|
+
const write = (level, message, context) => {
|
|
31
|
+
if (LEVEL_WEIGHT[level] < threshold)
|
|
32
|
+
return;
|
|
33
|
+
const line = formatLogLine(level, message, context);
|
|
34
|
+
process.stderr.write(`${line}\n`);
|
|
35
|
+
if (logFile) {
|
|
36
|
+
// Fire-and-forget: never block a sync run on log persistence.
|
|
37
|
+
appendLogLine(logFile, `${line}\n`).catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
info: (message, context) => write('info', message, context),
|
|
42
|
+
warn: (message, context) => write('warn', message, context),
|
|
43
|
+
error: (message, context) => write('error', message, context),
|
|
44
|
+
debug: (message, context) => write('debug', message, context),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function formatLogLine(level, message, context) {
|
|
48
|
+
const timestamp = new Date().toISOString();
|
|
49
|
+
const tag = level.toUpperCase().padEnd(5);
|
|
50
|
+
let line = `[${timestamp}] [${tag}] ${message}`;
|
|
51
|
+
if (context && Object.keys(context).length > 0) {
|
|
52
|
+
line += ` ${serializeContext(context)}`;
|
|
53
|
+
}
|
|
54
|
+
return line;
|
|
55
|
+
}
|
|
56
|
+
function serializeContext(context) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.stringify(context);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
let appendLogLine = async (file, line) => {
|
|
65
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
66
|
+
await appendFile(file, line, 'utf8');
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=logging.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
|
import type { QueryValues, ReadDataStore } from '../../contracts/storage.js';
|
|
3
4
|
import { type DatasetDefinition } from './catalog.js';
|
|
4
5
|
export declare class ReadOnlyRepository {
|
|
@@ -10,6 +11,7 @@ export declare class ReadOnlyRepository {
|
|
|
10
11
|
datasetRelation(datasetName: string): Promise<string>;
|
|
11
12
|
activitySampleRelation(): Promise<string>;
|
|
12
13
|
status(): Promise<Record<string, unknown>>;
|
|
14
|
+
progress(): Promise<SyncProgressSnapshot>;
|
|
13
15
|
coverage(): Promise<Record<string, unknown>>;
|
|
14
16
|
activity(activityId: string): Promise<Record<string, unknown> | null>;
|
|
15
17
|
summary(startDate: string, endDate: string): Promise<Record<string, unknown>>;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { SYNC_STAGES } from '../../contracts/progress.js';
|
|
4
|
+
import { mergeProgress, readProgressSidecars } from '../../elt/storage/progress-sidecar.js';
|
|
3
5
|
import { sqlString } from './sql.js';
|
|
4
6
|
import { DATASET_CATALOG, getDataset } from './catalog.js';
|
|
5
7
|
export class ReadOnlyRepository {
|
|
@@ -137,6 +139,36 @@ export class ReadOnlyRepository {
|
|
|
137
139
|
incrementalCursors: cursors,
|
|
138
140
|
};
|
|
139
141
|
}
|
|
142
|
+
async progress() {
|
|
143
|
+
// Same sequential-read discipline as status(): the shared native
|
|
144
|
+
// connection must not run these small reads concurrently.
|
|
145
|
+
const running = await this.rows(`
|
|
146
|
+
SELECT runs.run_id, runs.provider, coalesce(progress.stage, 'starting') AS stage,
|
|
147
|
+
progress.current_step,
|
|
148
|
+
coalesce(progress.completed_units, 0) AS completed_units,
|
|
149
|
+
progress.total_units,
|
|
150
|
+
coalesce(progress.percent, 0) AS percent,
|
|
151
|
+
coalesce(progress.elapsed_seconds, 0) AS elapsed_seconds,
|
|
152
|
+
progress.estimated_remaining_seconds,
|
|
153
|
+
cast(coalesce(progress.heartbeat_at, runs.started_at) AS VARCHAR) AS heartbeat_at
|
|
154
|
+
FROM sync_runs AS runs
|
|
155
|
+
LEFT JOIN sync_run_progress AS progress USING (run_id)
|
|
156
|
+
WHERE runs.status = 'running'
|
|
157
|
+
ORDER BY runs.started_at DESC
|
|
158
|
+
`);
|
|
159
|
+
const recent = await this.rows(`
|
|
160
|
+
SELECT run_id, provider, stage, current_step, completed_units, total_units, percent,
|
|
161
|
+
elapsed_seconds, estimated_remaining_seconds,
|
|
162
|
+
cast(heartbeat_at AS VARCHAR) AS heartbeat_at
|
|
163
|
+
FROM sync_run_progress
|
|
164
|
+
ORDER BY heartbeat_at DESC
|
|
165
|
+
LIMIT 10
|
|
166
|
+
`);
|
|
167
|
+
// Merge with sidecar heartbeat files so an active Garmin run stays visible
|
|
168
|
+
// even while the same process holds the single-writer database lock.
|
|
169
|
+
const sidecars = await readProgressSidecars(this.paths);
|
|
170
|
+
return mergeProgress(running.map(toSyncProgressState), recent.map(toSyncProgressState), sidecars);
|
|
171
|
+
}
|
|
140
172
|
async coverage() {
|
|
141
173
|
const rows = await this.rows(`
|
|
142
174
|
SELECT 'activities' AS dataset, min(cast(started_at_utc AS DATE)) AS start_date, max(cast(started_at_utc AS DATE)) AS end_date, count(*)::INTEGER AS row_count FROM activities
|
|
@@ -166,6 +198,20 @@ export class ReadOnlyRepository {
|
|
|
166
198
|
return Object.values(DATASET_CATALOG);
|
|
167
199
|
}
|
|
168
200
|
}
|
|
201
|
+
function toSyncProgressState(row) {
|
|
202
|
+
return {
|
|
203
|
+
runId: row.run_id,
|
|
204
|
+
provider: row.provider,
|
|
205
|
+
stage: SYNC_STAGES.includes(row.stage) ? row.stage : 'starting',
|
|
206
|
+
currentStep: row.current_step,
|
|
207
|
+
completedUnits: Math.max(0, Number(row.completed_units) || 0),
|
|
208
|
+
totalUnits: row.total_units === null || row.total_units === undefined ? null : Math.max(0, Number(row.total_units) || 0),
|
|
209
|
+
percentComplete: Math.min(100, Math.max(0, Number(row.percent) || 0)),
|
|
210
|
+
elapsedSeconds: Math.max(0, Number(row.elapsed_seconds) || 0),
|
|
211
|
+
estimatedRemainingSeconds: row.estimated_remaining_seconds === null || row.estimated_remaining_seconds === undefined ? null : Math.max(0, Number(row.estimated_remaining_seconds) || 0),
|
|
212
|
+
heartbeatAt: row.heartbeat_at,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
169
215
|
/** Converts DuckDB's Date/BigInt values into MCP-safe JSON values. */
|
|
170
216
|
export function jsonSafe(value) {
|
|
171
217
|
if (typeof value === 'bigint')
|
|
@@ -40,9 +40,8 @@ export function athleteStorePaths(catalogPaths, athleteId) {
|
|
|
40
40
|
function legacyStoreMessage(paths) {
|
|
41
41
|
return `Catence 0.2 uses a catalog at ${paths.root}. The existing directory looks like a 0.1 data store and cannot be migrated automatically. Create a fresh catalog and re-sync each athlete.`;
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
43
|
+
/** Home-directory entries the Console creates on its own and must not block catalog initialization. */
|
|
44
|
+
const CONSOLE_ARTIFACTS = new Set(['config.json', 'console', '.files', '.chainlit', 'public']);
|
|
46
45
|
export async function loadCatalog(paths = resolveCatalogPaths()) {
|
|
47
46
|
const contents = await readFile(paths.catalog, 'utf8').catch((error) => {
|
|
48
47
|
if (error.code !== 'ENOENT')
|
|
@@ -68,10 +67,21 @@ async function writeCatalog(paths, catalog) {
|
|
|
68
67
|
export async function initializeCatalog(paths = resolveCatalogPaths(), athlete) {
|
|
69
68
|
if (existsSync(paths.catalog))
|
|
70
69
|
return loadCatalog(paths);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = await readdir(paths.root);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error.code === 'ENOENT')
|
|
76
|
+
entries = [];
|
|
77
|
+
else
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
if (entries.includes('catence.duckdb'))
|
|
81
|
+
throw new Error(legacyStoreMessage(paths));
|
|
82
|
+
const unrelated = entries.filter((entry) => !CONSOLE_ARTIFACTS.has(entry));
|
|
83
|
+
if (unrelated.length > 0) {
|
|
84
|
+
throw new Error(`Refusing to initialize ${paths.root}: found ${unrelated.join(', ')}. Remove these or choose an empty --home directory.`);
|
|
75
85
|
}
|
|
76
86
|
const id = athleteIdSchema.parse(athlete.id);
|
|
77
87
|
const record = { id, label: athlete.label.trim(), createdAt: new Date().toISOString() };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type UpdateChannel = 'stable' | 'beta';
|
|
2
|
+
export type ParsedRelease = {
|
|
3
|
+
major: number;
|
|
4
|
+
minor: number;
|
|
5
|
+
patch: number;
|
|
6
|
+
/** Present only for prerelease builds; a stable release beats every beta of the same tuple. */
|
|
7
|
+
beta?: number;
|
|
8
|
+
};
|
|
9
|
+
export type SelfUpdatePlan = {
|
|
10
|
+
channel: UpdateChannel;
|
|
11
|
+
runtime: {
|
|
12
|
+
current: string;
|
|
13
|
+
target?: string;
|
|
14
|
+
updateAvailable: boolean;
|
|
15
|
+
};
|
|
16
|
+
console: {
|
|
17
|
+
installed?: string;
|
|
18
|
+
target?: string;
|
|
19
|
+
updateAvailable: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** True when neither component has anything to apply. */
|
|
22
|
+
ok: boolean;
|
|
23
|
+
};
|
|
24
|
+
export declare function parseReleaseVersion(version: string): ParsedRelease | null;
|
|
25
|
+
export declare function compareReleaseVersions(left: ParsedRelease, right: ParsedRelease): -1 | 0 | 1;
|
|
26
|
+
export declare function channelForVersion(version: string): UpdateChannel;
|
|
27
|
+
export declare function latestRelease(versions: string[], channel: UpdateChannel): string | undefined;
|
|
28
|
+
export declare function planSelfUpdate(options: {
|
|
29
|
+
runtimeVersion: string;
|
|
30
|
+
channel: UpdateChannel;
|
|
31
|
+
npmDistTags: Record<string, string>;
|
|
32
|
+
pypiReleases: string[];
|
|
33
|
+
consoleInstalled?: string;
|
|
34
|
+
}): SelfUpdatePlan;
|
|
35
|
+
export declare function fetchNpmDistTags(packageName: string, fetchImpl?: typeof fetch): Promise<Record<string, string>>;
|
|
36
|
+
export declare function fetchPypiReleases(packageName: string, fetchImpl?: typeof fetch): Promise<string[]>;
|
|
37
|
+
/** Run an update command (npm or uv) with the user's terminal attached. */
|
|
38
|
+
export declare function runSelfUpdateCommand(command: string, args: string[]): Promise<number>;
|
|
39
|
+
export declare function hasCommand(command: string): Promise<boolean>;
|
|
40
|
+
/** Read `catence-console --version` from PATH; undefined when the Console is not installed. */
|
|
41
|
+
export declare function readInstalledConsoleVersion(): Promise<string | undefined>;
|
|
42
|
+
/** Global npm package root, used to decide whether `catence` can update itself in place. */
|
|
43
|
+
export declare function npmGlobalPrefix(): Promise<string | undefined>;
|
|
44
|
+
export declare function isGlobalNpmInstall(moduleUrl: string, npmPrefix: string | undefined): boolean;
|