catence 0.2.0-beta.9 → 0.2.0
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 +38 -180
- 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 +49 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/query/activity-discovery.d.ts +2 -0
- package/dist/core/query/activity-discovery.js +1 -1
- package/dist/core/query/analytics.d.ts +10 -0
- package/dist/core/query/analytics.js +494 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +8 -4
- package/dist/core/query/events.d.ts +34 -0
- package/dist/core/query/events.js +121 -0
- package/dist/core/query/fitness.d.ts +4 -0
- package/dist/core/query/fitness.js +60 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +48 -0
- package/dist/core/query/swimming.js +2 -2
- package/dist/core/runtime/configuration.d.ts +3 -1
- package/dist/core/runtime/configuration.js +5 -1
- package/dist/core/runtime/opencode-go.d.ts +58 -0
- package/dist/core/runtime/opencode-go.js +246 -0
- package/dist/core/runtime/secrets.js +51 -13
- package/dist/elt/application/detached-sync.d.ts +50 -0
- package/dist/elt/application/detached-sync.js +99 -0
- package/dist/elt/application/management.d.ts +15 -0
- package/dist/elt/application/management.js +420 -18
- package/dist/elt/ingestion/importer.js +1 -1
- package/dist/elt/ingestion/providers/intervals.d.ts +9 -0
- package/dist/elt/ingestion/providers/intervals.js +33 -1
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +19 -1
- package/dist/elt/normalization/normalizers.js +207 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +182 -6
- package/dist/elt/storage/migrations.js +145 -1
- package/dist/elt/storage/progress-sidecar.d.ts +61 -0
- package/dist/elt/storage/progress-sidecar.js +200 -0
- package/dist/interfaces/cli/commands.js +94 -1
- package/dist/interfaces/http/generation-status.d.ts +19 -0
- package/dist/interfaces/http/generation-status.js +55 -0
- package/dist/interfaces/http/server.d.ts +7 -1
- package/dist/interfaces/http/server.js +184 -1
- package/dist/interfaces/mcp/main.js +33 -1
- package/dist/interfaces/mcp/server.d.ts +2 -1
- package/dist/interfaces/mcp/server.js +168 -45
- package/dist/runtime/index.d.ts +9 -2
- package/dist/runtime/index.js +6 -2
- package/docs/architecture.md +13 -2
- package/docs/assets/image.png +0 -0
- package/docs/configuration.md +243 -0
- package/docs/deployment/docker.md +528 -0
- package/docs/deployment/local-console.md +263 -0
- package/docs/deployment/local-mcp.md +329 -0
- package/docs/development.md +337 -0
- package/docs/distribution.md +45 -0
- package/docs/llm-providers.md +24 -2
- package/docs/mcp-activity-retrieval.md +90 -210
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +43 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/registry.py +2 -0
- package/python/catence/providers/garmin/streams.py +38 -0
- package/python/catence/providers/garmin/swim_backfill.py +83 -0
- package/python/catence/providers/garmin/worker.py +415 -12
- package/scripts/discover-opencode-go.mjs +36 -144
- package/uv.lock +3 -3
- package/docs/deployment.md +0 -338
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
-
import { catenceRuntimeHealth, DashboardSnapshotService, jsonSafe, loadCatalog, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths } from '../../runtime/index.js';
|
|
4
|
+
import { catenceRuntimeHealth, DashboardSnapshotService, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, } from '../../runtime/index.js';
|
|
4
5
|
import { createCatenceMcpServer } from '../mcp/server.js';
|
|
6
|
+
import { generationStatus } from './generation-status.js';
|
|
5
7
|
const MAX_REQUEST_BYTES = 1_000_000;
|
|
6
8
|
function json(response, statusCode, value) {
|
|
7
9
|
const body = JSON.stringify(jsonSafe(value));
|
|
@@ -85,6 +87,84 @@ async function dashboardPaths(catalogPaths, staticPaths, url) {
|
|
|
85
87
|
return staticPaths;
|
|
86
88
|
throw new Error('No Catence data store is configured.');
|
|
87
89
|
}
|
|
90
|
+
function parseSyncBody(body) {
|
|
91
|
+
if (body === undefined)
|
|
92
|
+
return {};
|
|
93
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body))
|
|
94
|
+
throw new Error('Request body must be a JSON object.');
|
|
95
|
+
const raw = body;
|
|
96
|
+
for (const key of Object.keys(raw)) {
|
|
97
|
+
if (!['athleteId', 'provider', 'from', 'refresh', 'refreshModels'].includes(key)) {
|
|
98
|
+
throw new Error(`Unknown sync field: ${key}.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
|
|
102
|
+
throw new Error('athleteId must be a string.');
|
|
103
|
+
if (raw.provider !== undefined && (typeof raw.provider !== 'string' || !DETACHED_SYNC_PROVIDERS.includes(raw.provider))) {
|
|
104
|
+
throw new Error('provider must be intervals, garmin, strava, or all.');
|
|
105
|
+
}
|
|
106
|
+
if (raw.from !== undefined && (typeof raw.from !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.from))) {
|
|
107
|
+
throw new Error('from must use YYYY-MM-DD.');
|
|
108
|
+
}
|
|
109
|
+
for (const flag of ['refresh', 'refreshModels']) {
|
|
110
|
+
if (raw[flag] !== undefined && typeof raw[flag] !== 'boolean')
|
|
111
|
+
throw new Error(`${flag} must be a boolean.`);
|
|
112
|
+
}
|
|
113
|
+
return raw;
|
|
114
|
+
}
|
|
115
|
+
async function resolveSyncPaths(catalogPaths, staticPaths, body) {
|
|
116
|
+
if (catalogPaths) {
|
|
117
|
+
if (!body.athleteId)
|
|
118
|
+
throw new Error('athleteId is required. Call /api/v1/athletes to inspect the configured catalog.');
|
|
119
|
+
return (await resolveAthlete(catalogPaths, body.athleteId)).paths;
|
|
120
|
+
}
|
|
121
|
+
if (staticPaths)
|
|
122
|
+
return staticPaths;
|
|
123
|
+
throw new Error('No Catence data store is configured.');
|
|
124
|
+
}
|
|
125
|
+
/** Best-effort last-success timestamps; null while a run holds the write lock. */
|
|
126
|
+
async function lastSyncSummary(paths) {
|
|
127
|
+
const completedAtIso = (value) => {
|
|
128
|
+
if (value instanceof Date)
|
|
129
|
+
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
130
|
+
if (typeof value === 'string' && value.trim())
|
|
131
|
+
return value;
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
// Read-only repository on purpose: it exposes per-run history (`syncRuns`)
|
|
136
|
+
// and degrades through the typed lock-conflict error while a detached
|
|
137
|
+
// sync holds DuckDB's single-writer lock. The write-capable dataStatus()
|
|
138
|
+
// would both block here and has no run history at all.
|
|
139
|
+
const repository = await openReadOnlyRepository(paths);
|
|
140
|
+
try {
|
|
141
|
+
const status = await repository.status();
|
|
142
|
+
const runs = Array.isArray(status.syncRuns) ? status.syncRuns : [];
|
|
143
|
+
const completed = runs.filter((run) => String(run.status ?? '').startsWith('completed') && completedAtIso(run.completed_at));
|
|
144
|
+
const providers = {};
|
|
145
|
+
let lastCompletedAt = null;
|
|
146
|
+
for (const run of completed) {
|
|
147
|
+
const completedAt = completedAtIso(run.completed_at);
|
|
148
|
+
const provider = String(run.provider ?? 'unknown');
|
|
149
|
+
if (!providers[provider] || providers[provider] < completedAt)
|
|
150
|
+
providers[provider] = completedAt;
|
|
151
|
+
if (!lastCompletedAt || lastCompletedAt < completedAt)
|
|
152
|
+
lastCompletedAt = completedAt;
|
|
153
|
+
}
|
|
154
|
+
return { lastCompletedAt, providers, recentRuns: runs.slice(0, 5) };
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
await repository.close();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Best-effort by contract: while a detached sync holds the single-writer
|
|
162
|
+
// DuckDB lock, even opening a snapshot fails cross-process. Progress (from
|
|
163
|
+
// sidecar heartbeats) must still reach the dashboard, so any failure here
|
|
164
|
+
// degrades to "no last-sync summary" instead of failing /sync/status.
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
88
168
|
async function handleMcpRequest(request, response, paths) {
|
|
89
169
|
if (request.method !== 'POST') {
|
|
90
170
|
methodNotAllowed(response);
|
|
@@ -143,6 +223,8 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
143
223
|
const catalogPaths = options.catalogPaths ?? (options.paths ? null : resolveCatalogPaths());
|
|
144
224
|
const staticPaths = options.paths ?? null;
|
|
145
225
|
const allowedOrigins = options.allowedOrigins ?? [];
|
|
226
|
+
const startSync = options.startSync ?? startDetachedSync;
|
|
227
|
+
const mergeModels = options.mergeModels ?? mergeOpenCodeGoConsoleProfiles;
|
|
146
228
|
return createServer((request, response) => {
|
|
147
229
|
void (async () => {
|
|
148
230
|
if (!addCorsHeaders(request, response, allowedOrigins)) {
|
|
@@ -158,6 +240,10 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
158
240
|
json(response, 200, catenceRuntimeHealth());
|
|
159
241
|
return;
|
|
160
242
|
}
|
|
243
|
+
if (pathname === '/api/v1/health' && request.method === 'GET') {
|
|
244
|
+
json(response, 200, catenceRuntimeHealth());
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
161
247
|
if (pathname === '/api/v1/athletes' && request.method === 'GET') {
|
|
162
248
|
if (!catalogPaths) {
|
|
163
249
|
json(response, 200, { defaultAthleteId: 'local', athletes: [{ id: 'local', label: 'Local athlete' }] });
|
|
@@ -182,6 +268,103 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
182
268
|
}
|
|
183
269
|
return;
|
|
184
270
|
}
|
|
271
|
+
if (pathname === '/api/v1/sync' && request.method === 'POST') {
|
|
272
|
+
let body;
|
|
273
|
+
try {
|
|
274
|
+
body = parseSyncBody(await readJsonBody(request));
|
|
275
|
+
const paths = await resolveSyncPaths(catalogPaths, staticPaths, body);
|
|
276
|
+
// Optional model self-discovery runs before the data sync so new
|
|
277
|
+
// OpenCode Go models appear without a manual script run. Failure is
|
|
278
|
+
// non-fatal: it must never block or fail a data sync. The Console
|
|
279
|
+
// reads <CATENCE_HOME>/config.json, so catalog deployments merge
|
|
280
|
+
// into the catalog root's config instead of the athlete store.
|
|
281
|
+
const discoveryConfigPath = catalogPaths ? path.join(catalogPaths.root, 'config.json') : paths.config;
|
|
282
|
+
let discoveryWarning = null;
|
|
283
|
+
if (body.refreshModels) {
|
|
284
|
+
try {
|
|
285
|
+
await mergeModels({ configPath: discoveryConfigPath, setDefault: false });
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
discoveryWarning = `OpenCode Go model discovery failed; profiles were left unchanged (${error instanceof Error ? error.message : String(error)}).`;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const handle = await startSync({
|
|
292
|
+
paths,
|
|
293
|
+
athleteId: body.athleteId ?? 'local',
|
|
294
|
+
provider: body.provider,
|
|
295
|
+
from: body.from,
|
|
296
|
+
refresh: body.refresh,
|
|
297
|
+
// The child resolves the athlete within the catalog, not within
|
|
298
|
+
// the athlete store this route resolved for locks and logs.
|
|
299
|
+
cliHome: catalogPaths ? catalogPaths.root : undefined,
|
|
300
|
+
}, { spawnProcess: options.syncSpawnProcess });
|
|
301
|
+
json(response, 202, { ...handle, ...(discoveryWarning ? { warning: discoveryWarning } : {}), progressEndpoint: '/api/v1/sync/status' });
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
json(response, error instanceof DetachedSyncBusyError ? 409 : 400, {
|
|
305
|
+
error: {
|
|
306
|
+
code: error instanceof DetachedSyncBusyError ? 'sync_in_progress' : 'sync_start_failed',
|
|
307
|
+
message: error instanceof Error ? error.message : String(error),
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (pathname === '/api/v1/models/discover' && request.method === 'POST') {
|
|
314
|
+
// Refresh the OpenCode Go profiles without starting a data sync. The
|
|
315
|
+
// Console's Models page calls this behind its authenticated proxy.
|
|
316
|
+
try {
|
|
317
|
+
const body = await readJsonBody(request).catch(() => ({}));
|
|
318
|
+
const raw = (body ?? {});
|
|
319
|
+
if (raw.setDefault !== undefined && typeof raw.setDefault !== 'boolean')
|
|
320
|
+
throw new Error('setDefault must be a boolean.');
|
|
321
|
+
const configPath = catalogPaths ? path.join(catalogPaths.root, 'config.json') : staticPaths?.config;
|
|
322
|
+
if (!configPath)
|
|
323
|
+
throw new Error('No Catence data store is configured.');
|
|
324
|
+
json(response, 200, jsonSafe(await mergeModels({ configPath, setDefault: raw.setDefault === true })));
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
json(response, 400, { error: { code: 'model_discovery_failed', message: error instanceof Error ? error.message : String(error) } });
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (pathname === '/api/v1/sync/status' && request.method === 'GET') {
|
|
332
|
+
try {
|
|
333
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
334
|
+
const paths = await dashboardPaths(catalogPaths, staticPaths, url);
|
|
335
|
+
const athleteId = catalogPaths ? (await resolveAthlete(catalogPaths, url.searchParams.get('athleteId') ?? '')).athlete.id : 'local';
|
|
336
|
+
json(response, 200, { athleteId, progress: await syncProgress(paths), lastSync: await lastSyncSummary(paths) });
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
json(response, 400, { error: { code: 'sync_status_unavailable', message: error instanceof Error ? error.message : String(error) } });
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (pathname.startsWith('/api/v1/threads/') &&
|
|
344
|
+
pathname.endsWith('/generation') &&
|
|
345
|
+
request.method === 'GET') {
|
|
346
|
+
try {
|
|
347
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
348
|
+
const threadId = segments[segments.length - 2];
|
|
349
|
+
if (!threadId)
|
|
350
|
+
throw new Error('Missing threadId in request path.');
|
|
351
|
+
const base = (catalogPaths ?? staticPaths ?? resolveCatalogPaths());
|
|
352
|
+
const root = base?.root;
|
|
353
|
+
if (!root || typeof root !== 'string') {
|
|
354
|
+
throw new Error('No Catence data store is configured.');
|
|
355
|
+
}
|
|
356
|
+
json(response, 200, await generationStatus(root, threadId));
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
json(response, 400, {
|
|
360
|
+
error: {
|
|
361
|
+
code: 'generation_status_unavailable',
|
|
362
|
+
message: error instanceof Error ? error.message : String(error)
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
185
368
|
if (pathname === '/mcp') {
|
|
186
369
|
await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths());
|
|
187
370
|
return;
|
|
@@ -1,11 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { addAthlete, athleteStorePaths, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
|
|
4
|
+
import { addAthlete, athleteStorePaths, CatenceDatabase, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
|
|
5
5
|
import { parseServeCliOptions, SERVE_USAGE } from '../http/cli-options.js';
|
|
6
6
|
import { createCatenceHttpServer } from '../http/server.js';
|
|
7
7
|
import { MCP_USAGE, parseMcpCliOptions } from './cli-options.js';
|
|
8
8
|
import { createCatenceMcpServer } from './server.js';
|
|
9
|
+
async function migrateCatalogStores(home) {
|
|
10
|
+
// On Docker image bumps the DuckDB schema version increases. The
|
|
11
|
+
// read-only MCP health probe would otherwise report "version X required"
|
|
12
|
+
// until the next manual sync. Proactively opening each athlete store in
|
|
13
|
+
// writable mode runs the idempotent migrations (database.ts:migrate)
|
|
14
|
+
// before the HTTP server starts listening, so a plain `docker compose up`
|
|
15
|
+
// after `deploy-console.sh beta` heals existing installs without an extra
|
|
16
|
+
// manual `catence-data migrate --all` step. Failures are best-effort:
|
|
17
|
+
// a missing catalog or a store still locked by another process is retried
|
|
18
|
+
// on the next sync or health check.
|
|
19
|
+
try {
|
|
20
|
+
const catalogPaths = resolveCatalogPaths(home);
|
|
21
|
+
const catalog = await loadCatalog(catalogPaths).catch(() => null);
|
|
22
|
+
if (!catalog || catalog.athletes.length === 0)
|
|
23
|
+
return;
|
|
24
|
+
for (const athlete of catalog.athletes) {
|
|
25
|
+
const paths = athleteStorePaths(catalogPaths, athlete.id);
|
|
26
|
+
try {
|
|
27
|
+
const database = await CatenceDatabase.open(paths);
|
|
28
|
+
await database.close();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Best-effort: a lock or a corrupted store must not prevent the
|
|
32
|
+
// server from starting; the next sync will retry the migration.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Catalog read failures are non-fatal for startup.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
9
40
|
async function run() {
|
|
10
41
|
const arguments_ = process.argv.slice(2);
|
|
11
42
|
if (arguments_[0] === 'serve') {
|
|
@@ -14,6 +45,7 @@ async function run() {
|
|
|
14
45
|
process.stderr.write(`${SERVE_USAGE}\n`);
|
|
15
46
|
return;
|
|
16
47
|
}
|
|
48
|
+
await migrateCatalogStores(options.home);
|
|
17
49
|
const httpServer = createCatenceHttpServer({
|
|
18
50
|
catalogPaths: resolveCatalogPaths(options.home),
|
|
19
51
|
allowedOrigins: options.allowedOrigins,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import { type CatalogPaths, hydrateStravaActivity, type CatencePaths } from '../../runtime/index.js';
|
|
2
|
+
import { type CatalogPaths, hydrateStravaActivity, startDetachedSync, type CatencePaths } from '../../runtime/index.js';
|
|
3
3
|
type StravaActivityHydrator = typeof hydrateStravaActivity;
|
|
4
4
|
type McpDependencies = {
|
|
5
5
|
hydrateStravaActivity?: StravaActivityHydrator;
|
|
6
|
+
startDetachedSync?: typeof startDetachedSync;
|
|
6
7
|
};
|
|
7
8
|
export declare function createCatenceMcpServer(paths?: CatencePaths | CatalogPaths, dependencies?: McpDependencies): McpServer;
|
|
8
9
|
export {};
|