catence 0.2.0-beta.10 → 0.2.0-beta.12
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 +21 -217
- package/dist/contracts/progress.d.ts +1 -1
- package/dist/contracts/progress.js +1 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/query/repository.js +5 -1
- package/dist/elt/application/management.js +35 -28
- package/dist/elt/storage/progress-sidecar.d.ts +61 -0
- package/dist/elt/storage/progress-sidecar.js +180 -0
- 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 +308 -0
- package/docs/distribution.md +58 -0
- package/docs/mcp-activity-retrieval.md +37 -30
- package/package.json +1 -1
- package/docs/deployment.md +0 -338
- package/docs/docker-deployment.md +0 -232
package/README.md
CHANGED
|
@@ -22,14 +22,14 @@ 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.
|
|
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,108 +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
|
-
catence-data --athlete alex progress --watch
|
|
92
|
-
|
|
93
|
-
# Strava OAuth uses clientId and clientSecret previously stored for this athlete
|
|
76
|
+
catence-data --athlete alex progress --watch
|
|
94
77
|
catence-data --athlete alex auth strava --callback
|
|
95
78
|
catence-data --athlete alex disconnect strava
|
|
96
|
-
|
|
97
|
-
# Update the runtime and Console together on the tracked release channel
|
|
98
79
|
catence-data update --check
|
|
99
80
|
catence-data update
|
|
100
|
-
|
|
101
|
-
# Choose a non-default catalog location
|
|
102
|
-
catence-data --home /srv/catence setup --athlete alex --label "Alex"
|
|
103
|
-
catence --home /srv/catence
|
|
104
|
-
```
|
|
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
|
-
|
|
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.
|
|
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
81
|
```
|
|
144
82
|
|
|
145
|
-
|
|
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
|
-
```
|
|
83
|
+
## Documentation
|
|
159
84
|
|
|
160
|
-
|
|
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 |
|
|
161
95
|
|
|
162
96
|
## Safe demo and Glama
|
|
163
97
|
|
|
164
98
|
Run a clearly marked generated dataset without provider accounts:
|
|
165
99
|
|
|
166
100
|
```sh
|
|
167
|
-
npx --yes catence@
|
|
101
|
+
npx --yes catence@beta demo
|
|
168
102
|
```
|
|
169
103
|
|
|
170
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.
|
|
@@ -196,125 +130,11 @@ Optional local Streamable HTTP MCP and dashboard APIs:
|
|
|
196
130
|
catence serve --host 127.0.0.1 --port 8787
|
|
197
131
|
```
|
|
198
132
|
|
|
199
|
-
`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.
|
|
200
|
-
|
|
201
|
-
## Catence Console
|
|
202
|
-
|
|
203
|
-
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.
|
|
204
|
-
|
|
205
|
-
```sh
|
|
206
|
-
export CATENCE_HOME="$HOME/.catence"
|
|
207
|
-
export CATENCE_CONSOLE_USERNAME='coach'
|
|
208
|
-
export CATENCE_CONSOLE_PASSWORD_HASH="$(catence-console auth hash-password)"
|
|
209
|
-
export CHAINLIT_AUTH_SECRET="$(openssl rand -hex 32)"
|
|
210
|
-
export OPENAI_API_KEY='…'
|
|
211
|
-
|
|
212
|
-
uvx catence-console@0.2.0 serve
|
|
213
|
-
```
|
|
214
|
-
|
|
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.
|
|
216
|
-
|
|
217
|
-
To preflight the Console and a running runtime:
|
|
218
|
-
|
|
219
|
-
```sh
|
|
220
|
-
uvx catence-console@0.2.0 doctor --home "$HOME/.catence" --mcp-url http://127.0.0.1:8787/mcp
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
Console chats and saved tool context are local at `~/.catence/console/chat-history.sqlite3`.
|
|
224
|
-
|
|
225
|
-
### Docker and Cloudflare Tunnel
|
|
226
|
-
|
|
227
|
-
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.
|
|
228
|
-
|
|
229
|
-
```sh
|
|
230
|
-
docker build -f console/Dockerfile -t catence-console .
|
|
231
|
-
docker run --rm -p 127.0.0.1:8000:8000 -v catence-data:/data \
|
|
232
|
-
-e CATENCE_CONSOLE_USERNAME='coach' \
|
|
233
|
-
-e CATENCE_CONSOLE_PASSWORD_HASH='replace-with-bcrypt-hash' \
|
|
234
|
-
-e CHAINLIT_AUTH_SECRET='replace-with-a-long-random-secret' \
|
|
235
|
-
-e OPENAI_API_KEY='…' \
|
|
236
|
-
catence-console
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
Initialize the mounted volume before its first production sync, for example:
|
|
240
|
-
|
|
241
|
-
```sh
|
|
242
|
-
docker run --rm -it -v catence-data:/data --entrypoint catence-data catence-console \
|
|
243
|
-
setup --athlete alex --label "Alex"
|
|
244
|
-
```
|
|
245
|
-
|
|
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.
|
|
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
|
-
```
|
|
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).
|
|
260
134
|
|
|
261
135
|
## Configuration
|
|
262
136
|
|
|
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`.
|
|
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.
|
|
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).
|
|
318
138
|
|
|
319
139
|
For source development:
|
|
320
140
|
|
|
@@ -338,22 +158,6 @@ data.
|
|
|
338
158
|
|
|
339
159
|
## Data and caveats
|
|
340
160
|
|
|
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
|
|
342
|
-
|
|
343
|
-
See [distribution notes](docs/distribution.md) for release artifacts, including APM and MCPB demo bundles.
|
|
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.
|
|
344
162
|
|
|
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
|
-
```
|
|
163
|
+
See [distribution notes](docs/distribution.md) for release artifacts, including APM and MCPB demo bundles.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
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"];
|
|
2
|
+
export declare const SYNC_STAGES: readonly ["starting", "login", "singletons", "daily", "range", "ftp_history", "max_metrics", "hrv_history", "scores", "activities", "collections", "intervals", "importing", "completed", "interrupted", "failed", "timed_out"];
|
|
3
3
|
export type SyncStage = (typeof SYNC_STAGES)[number];
|
|
4
4
|
export interface SyncProgressState {
|
|
5
5
|
runId: string;
|
|
@@ -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.12";
|
|
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.12';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { SYNC_STAGES } from '../../contracts/progress.js';
|
|
4
|
+
import { mergeProgress, readProgressSidecars } from '../../elt/storage/progress-sidecar.js';
|
|
4
5
|
import { sqlString } from './sql.js';
|
|
5
6
|
import { DATASET_CATALOG, getDataset } from './catalog.js';
|
|
6
7
|
export class ReadOnlyRepository {
|
|
@@ -163,7 +164,10 @@ export class ReadOnlyRepository {
|
|
|
163
164
|
ORDER BY heartbeat_at DESC
|
|
164
165
|
LIMIT 10
|
|
165
166
|
`);
|
|
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);
|
|
167
171
|
}
|
|
168
172
|
async coverage() {
|
|
169
173
|
const rows = await this.rows(`
|
|
@@ -14,7 +14,8 @@ import { importJsonl } from '../ingestion/importer.js';
|
|
|
14
14
|
import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
|
|
15
15
|
import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUrl, syncStravaGear } from '../ingestion/providers/strava/service.js';
|
|
16
16
|
import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
|
|
17
|
-
import { CatenceDatabase } from '../storage/database.js';
|
|
17
|
+
import { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../storage/database.js';
|
|
18
|
+
import { mergeProgress, ProgressPump, readRunningProgress, removeProgressSidecar } from '../storage/progress-sidecar.js';
|
|
18
19
|
import { withDataWriteLock } from '../storage/write-lock.js';
|
|
19
20
|
function packageRoot() {
|
|
20
21
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
@@ -32,7 +33,7 @@ function baseProgress(runId, provider, stage) {
|
|
|
32
33
|
* Progress NDJSON lines (kind 'progress') are normalized and persisted as
|
|
33
34
|
* throttled heartbeats; other output is forwarded to the sync logger.
|
|
34
35
|
*/
|
|
35
|
-
function runGarminWorker(runId, arguments_, environment,
|
|
36
|
+
function runGarminWorker(runId, arguments_, environment, pump, log, onInterruptReady) {
|
|
36
37
|
return new Promise((resolve) => {
|
|
37
38
|
const child = spawn('uv', arguments_, { ...environment, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
38
39
|
let killTimer;
|
|
@@ -47,21 +48,6 @@ function runGarminWorker(runId, arguments_, environment, database, log, onInterr
|
|
|
47
48
|
}, 10_000);
|
|
48
49
|
killTimer.unref();
|
|
49
50
|
});
|
|
50
|
-
let lastProgress = null;
|
|
51
|
-
let lastHeartbeatAt = 0;
|
|
52
|
-
const heartbeat = (state) => {
|
|
53
|
-
const now = Date.now();
|
|
54
|
-
if (now - lastHeartbeatAt < 2_000)
|
|
55
|
-
return;
|
|
56
|
-
lastHeartbeatAt = now;
|
|
57
|
-
void database.heartbeatRun(runId, state).catch((error) => {
|
|
58
|
-
log.debug('Progress heartbeat failed', { runId, error: error instanceof Error ? error.message : String(error) });
|
|
59
|
-
});
|
|
60
|
-
};
|
|
61
|
-
const fallback = setInterval(() => {
|
|
62
|
-
heartbeat(lastProgress ?? baseProgress(runId, 'garmin', 'starting'));
|
|
63
|
-
}, 30_000);
|
|
64
|
-
fallback.unref();
|
|
65
51
|
const buffer = (stream, sink) => {
|
|
66
52
|
let pending = '';
|
|
67
53
|
stream.on('data', (chunk) => {
|
|
@@ -91,26 +77,24 @@ function runGarminWorker(runId, arguments_, environment, database, log, onInterr
|
|
|
91
77
|
}
|
|
92
78
|
if (typeof parsed === 'object' && parsed !== null && parsed.kind === 'progress') {
|
|
93
79
|
const state = normalizeProgress(parsed);
|
|
94
|
-
if (state)
|
|
95
|
-
|
|
96
|
-
heartbeat(state);
|
|
97
|
-
}
|
|
80
|
+
if (state)
|
|
81
|
+
pump.publish(state);
|
|
98
82
|
return;
|
|
99
83
|
}
|
|
100
84
|
log.info(`[garmin] ${line}`);
|
|
101
85
|
});
|
|
102
86
|
buffer(child.stderr, (line) => log.error(`[garmin] ${line}`));
|
|
103
87
|
child.on('error', (error) => {
|
|
104
|
-
clearInterval(fallback);
|
|
105
88
|
if (killTimer)
|
|
106
89
|
clearTimeout(killTimer);
|
|
107
90
|
resolve({ code: 1, signal: null });
|
|
108
91
|
log.error('Garmin staging worker could not be started', { runId, error: error.message });
|
|
109
92
|
});
|
|
110
93
|
child.on('close', (code, signal) => {
|
|
111
|
-
clearInterval(fallback);
|
|
112
94
|
if (killTimer)
|
|
113
95
|
clearTimeout(killTimer);
|
|
96
|
+
if (code === 0)
|
|
97
|
+
pump.setStageImporting();
|
|
114
98
|
resolve({ code, signal });
|
|
115
99
|
});
|
|
116
100
|
});
|
|
@@ -181,11 +165,14 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
181
165
|
});
|
|
182
166
|
let extractionStarted = false;
|
|
183
167
|
const guard = { interrupted: false };
|
|
168
|
+
const pump = new ProgressPump(runId, provider, { database, paths, log });
|
|
169
|
+
pump.start();
|
|
184
170
|
try {
|
|
185
171
|
if (provider === 'intervals') {
|
|
186
172
|
const environment = await athleteProviderEnvironment(paths);
|
|
187
173
|
const config = requireIntervalsConfig(environment);
|
|
188
174
|
extractionStarted = true;
|
|
175
|
+
pump.setStage('intervals');
|
|
189
176
|
await runWithInterruptGuard(runId, database, () => undefined, guard, async () => {
|
|
190
177
|
await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId, log).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
|
|
191
178
|
});
|
|
@@ -215,7 +202,7 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
215
202
|
const outcome = await runGarminWorker(runId, arguments_, {
|
|
216
203
|
cwd: packageRoot(),
|
|
217
204
|
env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
|
|
218
|
-
},
|
|
205
|
+
}, pump, log, (kill) => { interruptChild = kill; });
|
|
219
206
|
if (!guard.interrupted && outcome.code !== 0) {
|
|
220
207
|
throw new Error(`Garmin staging worker exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
|
|
221
208
|
}
|
|
@@ -236,13 +223,16 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
236
223
|
}
|
|
237
224
|
}
|
|
238
225
|
finally {
|
|
226
|
+
pump.stop();
|
|
239
227
|
if (guard.interrupted) {
|
|
240
228
|
await database.markRunInterrupted(runId);
|
|
241
229
|
log.warn('Sync run interrupted', { runId, provider });
|
|
242
230
|
}
|
|
243
231
|
else {
|
|
244
232
|
await database.finishRun(runId);
|
|
233
|
+
pump.publishFinal(baseProgress(runId, provider, 'completed'));
|
|
245
234
|
}
|
|
235
|
+
await removeProgressSidecar(paths, runId).catch(() => undefined);
|
|
246
236
|
const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
|
|
247
237
|
log.info('Sync run finished', { runId, provider, status: run?.status ?? 'unknown', errorCount: run?.error_count ?? 0 });
|
|
248
238
|
}
|
|
@@ -275,6 +265,9 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
|
|
|
275
265
|
if (timedOutRuns.runIds.length > 0) {
|
|
276
266
|
log.warn('Marked stale sync runs as timed out', { runIds: timedOutRuns.runIds, timeoutMs: STALE_RUN_TIMEOUT_MS });
|
|
277
267
|
}
|
|
268
|
+
for (const runId of timedOutRuns.runIds) {
|
|
269
|
+
await removeProgressSidecar(paths, runId).catch(() => undefined);
|
|
270
|
+
}
|
|
278
271
|
const providers = provider === 'all' ? ['intervals', 'garmin'] : [provider];
|
|
279
272
|
const toDate = new Date().toISOString().slice(0, 10);
|
|
280
273
|
const runs = [];
|
|
@@ -440,13 +433,27 @@ export async function dataStatus(paths) {
|
|
|
440
433
|
}
|
|
441
434
|
export async function syncProgress(paths) {
|
|
442
435
|
await ensurePaths(paths);
|
|
443
|
-
|
|
436
|
+
// Live progress must stay readable while a sync run holds the single-writer
|
|
437
|
+
// DuckDB lock, so the running set comes from sidecar heartbeat files first.
|
|
438
|
+
const sidecars = await readRunningProgress(paths);
|
|
439
|
+
let dbRunning = [];
|
|
440
|
+
let dbRecent = [];
|
|
444
441
|
try {
|
|
445
|
-
|
|
442
|
+
const repository = await openReadOnlyRepository(paths);
|
|
443
|
+
try {
|
|
444
|
+
const snapshot = await repository.progress();
|
|
445
|
+
dbRunning = snapshot.running;
|
|
446
|
+
dbRecent = snapshot.recent;
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
await repository.close();
|
|
450
|
+
}
|
|
446
451
|
}
|
|
447
|
-
|
|
448
|
-
|
|
452
|
+
catch (error) {
|
|
453
|
+
if (!(error instanceof ReadOnlyDatabaseError))
|
|
454
|
+
throw error;
|
|
449
455
|
}
|
|
456
|
+
return mergeProgress(dbRunning, dbRecent, sidecars);
|
|
450
457
|
}
|
|
451
458
|
export async function rebuildRetrievalIndex(paths) {
|
|
452
459
|
if (!existsSync(paths.database))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type SyncProgressSnapshot, type SyncProgressState, type SyncStage } from '../../contracts/progress.js';
|
|
2
|
+
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
3
|
+
import type { SyncLogger } from '../../core/logging.js';
|
|
4
|
+
import type { CatenceDatabase } from './database.js';
|
|
5
|
+
export declare const PROGRESS_SIDECAR_SUFFIX = ".progress.json";
|
|
6
|
+
export declare const PROGRESS_SIDECAR_STALE_MS: number;
|
|
7
|
+
export declare const PROGRESS_KEEPALIVE_MS: number;
|
|
8
|
+
export declare const PROGRESS_PUBLISH_THROTTLE_MS: number;
|
|
9
|
+
export declare function progressSidecarPath(paths: CatencePaths, runId: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Persist one progress heartbeat as a sidecar file next to the Garmin staging
|
|
12
|
+
* manifest. Reading this file needs no DuckDB lock, so `progress` stays live
|
|
13
|
+
* while a sync run holds the single-writer database lock.
|
|
14
|
+
*/
|
|
15
|
+
export declare function writeProgressSidecar(paths: CatencePaths, runId: string, state: SyncProgressState): Promise<void>;
|
|
16
|
+
export declare function removeProgressSidecar(paths: CatencePaths, runId: string): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Scan every persisted sidecar heartbeat for an athlete store. Corrupt or
|
|
19
|
+
* unreadable files are skipped so a torn write can never break progress reads.
|
|
20
|
+
*/
|
|
21
|
+
export declare function readProgressSidecars(paths: CatencePaths): Promise<SyncProgressState[]>;
|
|
22
|
+
/** Active runs from sidecars: non-terminal stage and a fresh heartbeat. */
|
|
23
|
+
export declare function readRunningProgress(paths: CatencePaths, staleAfterMs?: number): Promise<SyncProgressState[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Merge database-backed progress with sidecar heartbeats. Sidecars win for a
|
|
26
|
+
* given run (they are freshest); database rows fill runs that never emitted a
|
|
27
|
+
* sidecar (for example Intervals runs). Recent history is deduplicated across
|
|
28
|
+
* both sources and sorted by heartbeat recency.
|
|
29
|
+
*/
|
|
30
|
+
export declare function mergeProgress(dbRunning: SyncProgressState[], dbRecent: SyncProgressState[], sidecars: SyncProgressState[]): SyncProgressSnapshot;
|
|
31
|
+
export interface ProgressPumpOptions {
|
|
32
|
+
database: CatenceDatabase;
|
|
33
|
+
paths: CatencePaths;
|
|
34
|
+
log: SyncLogger;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Drives progress heartbeats for the whole lifetime of a sync run - including
|
|
38
|
+
* phases that emit no worker output (the Intervals extractor and the Garmin
|
|
39
|
+
* JSONL import). `publish` throttles writes to the database and sidecar; a
|
|
40
|
+
* 30-second keep-alive interval refreshes the last known state so a run never
|
|
41
|
+
* looks stale while it is still active, even during the lock-holding import.
|
|
42
|
+
*/
|
|
43
|
+
export declare class ProgressPump {
|
|
44
|
+
private readonly runId;
|
|
45
|
+
private readonly provider;
|
|
46
|
+
private readonly options;
|
|
47
|
+
private lastProgress;
|
|
48
|
+
private lastHeartbeatAt;
|
|
49
|
+
private interval;
|
|
50
|
+
private stageOverride;
|
|
51
|
+
constructor(runId: string, provider: string, options: ProgressPumpOptions);
|
|
52
|
+
start(): void;
|
|
53
|
+
publish(state: SyncProgressState): void;
|
|
54
|
+
/** Force subsequent keep-alive heartbeats to report the given stage. */
|
|
55
|
+
setStage(stage: SyncStage): void;
|
|
56
|
+
/** Transition the run to the JSONL import stage; keep-alives persist it. */
|
|
57
|
+
setStageImporting(): void;
|
|
58
|
+
/** Write a terminal heartbeat immediately, bypassing the publish throttle. */
|
|
59
|
+
publishFinal(state: SyncProgressState): void;
|
|
60
|
+
stop(): void;
|
|
61
|
+
}
|