catence 0.2.0-beta.1 → 0.2.0-beta.10
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 +144 -2
- 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 +42 -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 +194 -17
- 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/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/deployment.md +338 -0
- package/docs/docker-deployment.md +232 -0
- package/docs/llm-providers.md +467 -0
- 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
|
@@ -22,7 +22,7 @@ Catence helps explore recovery, training load, trends, activity detail, swimming
|
|
|
22
22
|
|
|
23
23
|
## Quick start
|
|
24
24
|
|
|
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.
|
|
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. For the full local or Docker deployment walkthrough, see [deployment notes](docs/deployment.md).
|
|
26
26
|
|
|
27
27
|
```sh
|
|
28
28
|
npm install --global catence@0.2.0
|
|
@@ -88,18 +88,77 @@ catence-data --athlete alex sync --provider intervals
|
|
|
88
88
|
catence-data --athlete alex sync --provider garmin --from 2025-07-29
|
|
89
89
|
catence-data --athlete alex backfill --provider garmin --from 2020-01-01 --refresh
|
|
90
90
|
catence-data --athlete alex retry --run <run-id>
|
|
91
|
+
catence-data --athlete alex progress --watch # follow an active sync run live
|
|
91
92
|
|
|
92
93
|
# Strava OAuth uses clientId and clientSecret previously stored for this athlete
|
|
93
94
|
catence-data --athlete alex auth strava --callback
|
|
94
95
|
catence-data --athlete alex disconnect strava
|
|
95
96
|
|
|
97
|
+
# Update the runtime and Console together on the tracked release channel
|
|
98
|
+
catence-data update --check
|
|
99
|
+
catence-data update
|
|
100
|
+
|
|
96
101
|
# Choose a non-default catalog location
|
|
97
102
|
catence-data --home /srv/catence setup --athlete alex --label "Alex"
|
|
98
103
|
catence --home /srv/catence
|
|
99
104
|
```
|
|
100
105
|
|
|
106
|
+
`catence-data update` follows the channel of the installed runtime: a beta
|
|
107
|
+
install tracks the npm `beta` tag and the matching PyPI prereleases, a stable
|
|
108
|
+
install tracks `latest`. Pass `--channel stable` to move off a beta. The command
|
|
109
|
+
replaces the global `catence` package and installs or upgrades
|
|
110
|
+
`catence-console` with `uv tool` when `uv` is available; `--check` only reports.
|
|
111
|
+
|
|
101
112
|
Register `http://127.0.0.1:8765/strava/callback` with the Strava application for the callback flow. The manual `auth strava --code <authorization-code>` flow remains available for headless environments.
|
|
102
113
|
|
|
114
|
+
## Background sync and live progress
|
|
115
|
+
|
|
116
|
+
Long Garmin backfills fetch hundreds of days of data and can take 20-30+ minutes. Sync runs stream worker output to the console in real time, tolerate Ctrl+C and SSH/terminal deaths cleanly, and expose a live progress heartbeat you can follow from the CLI, the MCP server, or a Docker helper.
|
|
117
|
+
|
|
118
|
+
### What happens during a run
|
|
119
|
+
|
|
120
|
+
When a sync starts, Catence creates a row in `sync_runs` with status `running` and begins persisting heartbeats to the `sync_run_progress` table (schema migration 14). The Garmin staging worker emits one compact JSON progress record every few seconds on stdout; the runtime parses it, normalizes it, and upserts it. Even if the worker cannot emit — for example while stuck in login — the runtime persists a 30-second fallback heartbeat so a run is always observable and never falsely looks stale.
|
|
121
|
+
|
|
122
|
+
Each progress record follows the same contract:
|
|
123
|
+
|
|
124
|
+
- `runId`, `provider` — which run the record belongs to
|
|
125
|
+
- `stage` — `starting`, `login`, `singletons`, `daily`, `range`, `ftp_history`, `max_metrics`, `hrv_history`, `scores`, `activities`, `collections` while extracting; `completed` on success; `failed`, `interrupted`, or `timed_out` as terminal states written by the runtime
|
|
126
|
+
- `currentStep` — the endpoint, date, or activity being fetched right now
|
|
127
|
+
- `completedUnits` / `totalUnits` — for example days fetched out of the total days in the window
|
|
128
|
+
- `percentComplete` — 0 to 100
|
|
129
|
+
- `elapsedSeconds` / `estimatedRemainingSeconds` — the ETA is computed once the run has a known total and has completed at least one unit
|
|
130
|
+
- `heartbeatAt` — UTC ISO timestamp of the record
|
|
131
|
+
|
|
132
|
+
### Interruption and stale-run recovery
|
|
133
|
+
|
|
134
|
+
- SIGINT, SIGTERM, and SIGHUP (Ctrl+C, terminal close, `kill`) are trapped on both the Node runtime and the Python worker. The child stops between days and the run is marked `interrupted` in `sync_runs` — never a zombie `running` row — and the process exits with 130/143/129.
|
|
135
|
+
- Before every sync or retry, runs still stuck in `running` for more than 15 minutes are marked `timed_out` (with `completed_at` set), so a dead SSH session cannot block later syncs.
|
|
136
|
+
- Resume a failed or interrupted run without re-reading history with `retry --run <run-id>`.
|
|
137
|
+
|
|
138
|
+
### Watching progress
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
catence-data --athlete alex progress # one-shot JSON snapshot
|
|
142
|
+
catence-data --athlete alex progress --watch # live view; exits when no run is active
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`--watch` renders a compact progress table on a TTY and exits when the last active run finishes; piped output stays JSON.
|
|
146
|
+
|
|
147
|
+
The read-only MCP tool `catence_sync_progress` returns the same snapshot — active runs with their latest heartbeat plus the most recent runs — and takes `athleteId` like every other personal-data tool.
|
|
148
|
+
|
|
149
|
+
### Docker deployments
|
|
150
|
+
|
|
151
|
+
`scripts/deploy-console.sh` generates a `sync.sh` helper that gained a `progress` command and a `--detach` flag:
|
|
152
|
+
|
|
153
|
+
```sh
|
|
154
|
+
cd catence-deploy
|
|
155
|
+
./sync.sh --athlete alex sync --detach # start the run in the background and return
|
|
156
|
+
./sync.sh --athlete alex progress --watch # follow it live
|
|
157
|
+
./sync.sh --athlete alex progress # one-shot snapshot
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`--detach` works with `backfill` too. A detached run keeps streaming into the container logs (`docker compose -f catence-deploy/docker-compose.yml logs -f console`) and keeps writing heartbeats, so it survives the shell that started it and is recoverable by the next sync.
|
|
161
|
+
|
|
103
162
|
## Safe demo and Glama
|
|
104
163
|
|
|
105
164
|
Run a clearly marked generated dataset without provider accounts:
|
|
@@ -153,7 +212,7 @@ export OPENAI_API_KEY='…'
|
|
|
153
212
|
uvx catence-console@0.2.0 serve
|
|
154
213
|
```
|
|
155
214
|
|
|
156
|
-
On the first authenticated visit, the Console wizard asks for an
|
|
215
|
+
On the first authenticated visit, the Console wizard asks for an OpenAI-compatible, OpenAI, or Anthropic model and writes only the non-secret model configuration to `~/.catence/config.json`. The `openai-compatible` option covers Azure and Opencode; see [`docs/llm-providers.md`](docs/llm-providers.md). 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
216
|
|
|
158
217
|
To preflight the Console and a running runtime:
|
|
159
218
|
|
|
@@ -186,10 +245,77 @@ docker run --rm -it -v catence-data:/data --entrypoint catence-data catence-cons
|
|
|
186
245
|
|
|
187
246
|
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.
|
|
188
247
|
|
|
248
|
+
[`scripts/deploy-console.sh`](scripts/deploy-console.sh) deploys this stack from
|
|
249
|
+
the public registries and seeds a starter `config.json` (the Console model
|
|
250
|
+
profiles from [`config.example.json`](config.example.json)) into the data
|
|
251
|
+
volume, so the settings panel works before the first chat. Edit
|
|
252
|
+
`/data/config.json` in the volume (or the bind-mounted `--home` directory) to
|
|
253
|
+
change models and defaults, or discover OpenCode Go profiles from the image:
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm \
|
|
257
|
+
--entrypoint node console \
|
|
258
|
+
/usr/local/lib/node_modules/catence/scripts/discover-opencode-go.mjs --write /data/config.json
|
|
259
|
+
```
|
|
260
|
+
|
|
189
261
|
## Configuration
|
|
190
262
|
|
|
191
263
|
[`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`.
|
|
192
264
|
|
|
265
|
+
### Per-model reasoning effort
|
|
266
|
+
|
|
267
|
+
Models that support reasoning effort can set a fixed level per model in `config.json`, or expose a per-chat **Thinking effort** dropdown with custom levels via `variants`.
|
|
268
|
+
|
|
269
|
+
**Fixed level** (all requests for the model use it):
|
|
270
|
+
|
|
271
|
+
```json
|
|
272
|
+
{
|
|
273
|
+
"console": {
|
|
274
|
+
"profiles": {
|
|
275
|
+
"opencode-zen": {
|
|
276
|
+
"label": "OpenCode Zen",
|
|
277
|
+
"defaultModel": "deepseek-v4-flash-free",
|
|
278
|
+
"models": {
|
|
279
|
+
"deepseek-v4-flash-free": {
|
|
280
|
+
"label": "DeepSeek V4 Flash Free",
|
|
281
|
+
"model": "openai/deepseek-v4-flash-free",
|
|
282
|
+
"reasoningEffort": "high"
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
"apiKeyEnv": "OPENCODE_API_KEY",
|
|
286
|
+
"apiBaseEnv": "OPENCODE_ZEN_API_BASE"
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**Custom variants** (per-chat dropdown): `variants` maps each dropdown label to the value sent to the provider. This is how non-OpenAI models (for example DeepSeek V4 Flash, which uses its own Default/High/Max scheme rather than the OpenAI-standard list) expose their native levels:
|
|
294
|
+
|
|
295
|
+
```json
|
|
296
|
+
"deepseek-v4-flash-free": {
|
|
297
|
+
"label": "DeepSeek V4 Flash Free",
|
|
298
|
+
"model": "openai/deepseek-v4-flash-free",
|
|
299
|
+
"variants": { "High": "high", "Max": "max" }
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
With `variants`, the chat **Thinking effort** dropdown shows `Provider default` plus each label; selecting one sends that exact value (e.g. `reasoning_effort: "high"`). Without `variants`, the dropdown falls back to the OpenAI-standard set (`minimal`, `low`, `medium`, `high`, `xhigh`).
|
|
304
|
+
|
|
305
|
+
**Disabling reasoning effort**: an explicitly empty `variants` map (`"variants": {}`) disables the feature for that model — no `reasoning_effort` is ever sent and the **Thinking effort** dropdown is greyed out. This is how models that reject the parameter (for example MiMo, which expects its own `thinking` toggle) are configured:
|
|
306
|
+
|
|
307
|
+
```json
|
|
308
|
+
"mimo-v2.5": {
|
|
309
|
+
"label": "MiMo V2.5",
|
|
310
|
+
"model": "openai/mimo-v2.5",
|
|
311
|
+
"variants": {}
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
A model cannot combine `variants: {}` with a fixed `reasoningEffort`.
|
|
316
|
+
|
|
317
|
+
A fixed `reasoningEffort` overrides any profile-level `defaultReasoningEffort`; the per-chat dropdown selection (a variant value) overrides both. If nothing is set, the provider default is used. The dropdown options always follow the model currently selected in the **Model** dropdown.
|
|
318
|
+
|
|
193
319
|
For source development:
|
|
194
320
|
|
|
195
321
|
```sh
|
|
@@ -215,3 +341,19 @@ data.
|
|
|
215
341
|
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
342
|
|
|
217
343
|
See [distribution notes](docs/distribution.md) for release artifacts, including APM and MCPB demo bundles.
|
|
344
|
+
|
|
345
|
+
##
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
cd catence-deploy
|
|
349
|
+
docker compose -f docker-compose.yml --env-file .env exec console \
|
|
350
|
+
catence-data sync --athlete martina --provider all --home /data # incremental sync, all providers
|
|
351
|
+
docker compose -f docker-compose.yml --env-file .env exec console \
|
|
352
|
+
catence-data backfill --athlete martina --from 2026-01-01 --home /data # full backfill from a date
|
|
353
|
+
docker compose -f docker-compose.yml --env-file .env exec console \
|
|
354
|
+
catence-data status --athlete martina --home /data # coverage/errors
|
|
355
|
+
docker compose -f docker-compose.yml --env-file .env exec console \
|
|
356
|
+
catence-data progress --athlete martina --home /data --watch # live sync progress
|
|
357
|
+
docker compose -f docker-compose.yml --env-file .env exec console \
|
|
358
|
+
catence-data athlete list --home /data # list athlete IDs
|
|
359
|
+
```
|
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.10";
|
|
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.10';
|
|
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,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { SYNC_STAGES } from '../../contracts/progress.js';
|
|
3
4
|
import { sqlString } from './sql.js';
|
|
4
5
|
import { DATASET_CATALOG, getDataset } from './catalog.js';
|
|
5
6
|
export class ReadOnlyRepository {
|
|
@@ -137,6 +138,33 @@ export class ReadOnlyRepository {
|
|
|
137
138
|
incrementalCursors: cursors,
|
|
138
139
|
};
|
|
139
140
|
}
|
|
141
|
+
async progress() {
|
|
142
|
+
// Same sequential-read discipline as status(): the shared native
|
|
143
|
+
// connection must not run these small reads concurrently.
|
|
144
|
+
const running = await this.rows(`
|
|
145
|
+
SELECT runs.run_id, runs.provider, coalesce(progress.stage, 'starting') AS stage,
|
|
146
|
+
progress.current_step,
|
|
147
|
+
coalesce(progress.completed_units, 0) AS completed_units,
|
|
148
|
+
progress.total_units,
|
|
149
|
+
coalesce(progress.percent, 0) AS percent,
|
|
150
|
+
coalesce(progress.elapsed_seconds, 0) AS elapsed_seconds,
|
|
151
|
+
progress.estimated_remaining_seconds,
|
|
152
|
+
cast(coalesce(progress.heartbeat_at, runs.started_at) AS VARCHAR) AS heartbeat_at
|
|
153
|
+
FROM sync_runs AS runs
|
|
154
|
+
LEFT JOIN sync_run_progress AS progress USING (run_id)
|
|
155
|
+
WHERE runs.status = 'running'
|
|
156
|
+
ORDER BY runs.started_at DESC
|
|
157
|
+
`);
|
|
158
|
+
const recent = await this.rows(`
|
|
159
|
+
SELECT run_id, provider, stage, current_step, completed_units, total_units, percent,
|
|
160
|
+
elapsed_seconds, estimated_remaining_seconds,
|
|
161
|
+
cast(heartbeat_at AS VARCHAR) AS heartbeat_at
|
|
162
|
+
FROM sync_run_progress
|
|
163
|
+
ORDER BY heartbeat_at DESC
|
|
164
|
+
LIMIT 10
|
|
165
|
+
`);
|
|
166
|
+
return { running: running.map(toSyncProgressState), recent: recent.map(toSyncProgressState) };
|
|
167
|
+
}
|
|
140
168
|
async coverage() {
|
|
141
169
|
const rows = await this.rows(`
|
|
142
170
|
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 +194,20 @@ export class ReadOnlyRepository {
|
|
|
166
194
|
return Object.values(DATASET_CATALOG);
|
|
167
195
|
}
|
|
168
196
|
}
|
|
197
|
+
function toSyncProgressState(row) {
|
|
198
|
+
return {
|
|
199
|
+
runId: row.run_id,
|
|
200
|
+
provider: row.provider,
|
|
201
|
+
stage: SYNC_STAGES.includes(row.stage) ? row.stage : 'starting',
|
|
202
|
+
currentStep: row.current_step,
|
|
203
|
+
completedUnits: Math.max(0, Number(row.completed_units) || 0),
|
|
204
|
+
totalUnits: row.total_units === null || row.total_units === undefined ? null : Math.max(0, Number(row.total_units) || 0),
|
|
205
|
+
percentComplete: Math.min(100, Math.max(0, Number(row.percent) || 0)),
|
|
206
|
+
elapsedSeconds: Math.max(0, Number(row.elapsed_seconds) || 0),
|
|
207
|
+
estimatedRemainingSeconds: row.estimated_remaining_seconds === null || row.estimated_remaining_seconds === undefined ? null : Math.max(0, Number(row.estimated_remaining_seconds) || 0),
|
|
208
|
+
heartbeatAt: row.heartbeat_at,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
169
211
|
/** Converts DuckDB's Date/BigInt values into MCP-safe JSON values. */
|
|
170
212
|
export function jsonSafe(value) {
|
|
171
213
|
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;
|