catence 0.2.0-beta.8 → 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 -166
- package/config.example.json +5 -1
- 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/logging.d.ts +16 -0
- package/dist/core/logging.js +68 -0
- 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 +435 -19
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +5 -4
- package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
- package/dist/elt/ingestion/providers/intervals.js +37 -2
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +19 -1
- package/dist/elt/ingestion/providers/strava/service.js +7 -1
- package/dist/elt/normalization/normalizers.js +207 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +184 -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 +66 -1
- 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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { createInterface } from 'node:readline/promises';
|
|
3
3
|
import { stdin as input, stdout as output } from 'node:process';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
|
-
import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
|
|
5
|
+
import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, CatenceDatabase, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
|
|
6
6
|
const program = new Command()
|
|
7
7
|
.name('catence-data')
|
|
8
8
|
.description('Athlete-scoped Catence data setup, extraction, and normalization.')
|
|
@@ -158,6 +158,38 @@ program.command('status').action(async () => {
|
|
|
158
158
|
const { paths } = await currentAthlete();
|
|
159
159
|
process.stdout.write(`${JSON.stringify(await dataStatus(paths), null, 2)}\n`);
|
|
160
160
|
});
|
|
161
|
+
function renderProgress(snapshot) {
|
|
162
|
+
const lines = snapshot.running.map((run) => {
|
|
163
|
+
const pct = run.percentComplete.toFixed(1).padStart(5);
|
|
164
|
+
const units = run.totalUnits !== null ? `${run.completedUnits}/${run.totalUnits}` : String(run.completedUnits);
|
|
165
|
+
const eta = run.estimatedRemainingSeconds !== null ? `${Math.round(run.estimatedRemainingSeconds)}s` : '?';
|
|
166
|
+
const step = run.currentStep ?? '';
|
|
167
|
+
return `${pct}% ${run.stage.padEnd(12)} ${units.padEnd(11)} ${eta.padEnd(6)} ${step}`;
|
|
168
|
+
});
|
|
169
|
+
return lines.length === 0 ? 'no active sync runs' : lines.join('\n');
|
|
170
|
+
}
|
|
171
|
+
program.command('progress')
|
|
172
|
+
.description('Show live progress for active sync runs.')
|
|
173
|
+
.option('--watch', 'refresh every few seconds until no sync runs are active')
|
|
174
|
+
.action(async (options) => {
|
|
175
|
+
const { paths } = await currentAthlete();
|
|
176
|
+
if (!options.watch) {
|
|
177
|
+
process.stdout.write(`${JSON.stringify(await syncProgress(paths), null, 2)}\n`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
while (true) {
|
|
181
|
+
const snapshot = await syncProgress(paths);
|
|
182
|
+
if (output.isTTY) {
|
|
183
|
+
output.write(`\x1b[2J\x1b[H${renderProgress(snapshot)}\n`);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`);
|
|
187
|
+
}
|
|
188
|
+
if (snapshot.running.length === 0)
|
|
189
|
+
break;
|
|
190
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
191
|
+
}
|
|
192
|
+
});
|
|
161
193
|
program.command('update')
|
|
162
194
|
.description('Check for and apply the newest Catence release on the tracked channel (runtime and Console).')
|
|
163
195
|
.option('--check', 'report available releases without changing anything')
|
|
@@ -220,5 +252,66 @@ program.command('build-retrieval-index').description('Build derived local retrie
|
|
|
220
252
|
const { paths } = await currentAthlete();
|
|
221
253
|
process.stdout.write(`${JSON.stringify(await rebuildRetrievalIndex(paths), null, 2)}\n`);
|
|
222
254
|
});
|
|
255
|
+
program.command('reimport-nutrition').description('Re-run normalization over captured nutrition entities without contacting providers.').action(async () => {
|
|
256
|
+
const { paths } = await currentAthlete();
|
|
257
|
+
process.stdout.write(`${JSON.stringify(await reimportNutrition(paths), null, 2)}\n`);
|
|
258
|
+
});
|
|
259
|
+
program
|
|
260
|
+
.command('import')
|
|
261
|
+
.description('Import a staged JSONL file (for example a swim-length backfill) into one athlete’s data store.')
|
|
262
|
+
.requiredOption('--provider <provider>', 'garmin, intervals, or strava')
|
|
263
|
+
.argument('<file>', 'path to the staged JSONL file')
|
|
264
|
+
.action(async (file, options) => {
|
|
265
|
+
const { paths } = await currentAthlete();
|
|
266
|
+
process.stdout.write(`${JSON.stringify(await importStagedFile(paths, options.provider, file), null, 2)}\n`);
|
|
267
|
+
});
|
|
268
|
+
program
|
|
269
|
+
.command('resolve-errors')
|
|
270
|
+
.description('Mark unresolved extraction errors as resolved (for example stale bootstrap failures from an environment that has since recovered).')
|
|
271
|
+
.option('--run <run-id>', 'resolve errors from a specific run only (repeatable)')
|
|
272
|
+
.option('--provider <provider>', 'resolve errors for garmin, intervals, or strava only')
|
|
273
|
+
.option('--before <timestamp>', 'resolve errors created before this ISO timestamp')
|
|
274
|
+
.option('--all', 'resolve every unresolved error')
|
|
275
|
+
.action(async (options) => {
|
|
276
|
+
const { paths } = await currentAthlete();
|
|
277
|
+
const runIds = Array.isArray(options.run) ? options.run : options.run ? [options.run] : undefined;
|
|
278
|
+
process.stdout.write(`${JSON.stringify(await resolveExtractionErrors(paths, { runIds, provider: options.provider, before: options.before, all: options.all }), null, 2)}\n`);
|
|
279
|
+
});
|
|
280
|
+
program
|
|
281
|
+
.command('migrate')
|
|
282
|
+
.description('Apply pending DuckDB schema migrations. Without --all it migrates the selected --athlete store; with --all it migrates every athlete in the catalog.')
|
|
283
|
+
.option('--all', 'migrate every athlete in the catalog')
|
|
284
|
+
.option('--home <directory>', 'Catence catalog home directory (also accepts global --home)')
|
|
285
|
+
.action(async (options) => {
|
|
286
|
+
const effectiveHome = options.home ?? program.opts().home;
|
|
287
|
+
const effectiveCatalog = effectiveHome ? resolveCatalogPaths(effectiveHome) : currentCatalog();
|
|
288
|
+
const migratePaths = async (paths) => {
|
|
289
|
+
const database = await CatenceDatabase.open(paths);
|
|
290
|
+
const applied = await database.rows('SELECT version FROM schema_migrations ORDER BY version');
|
|
291
|
+
await database.close();
|
|
292
|
+
return { migrated: true, appliedVersions: applied.map((row) => row.version) };
|
|
293
|
+
};
|
|
294
|
+
if (options.all) {
|
|
295
|
+
const catalog = await loadCatalog(effectiveCatalog);
|
|
296
|
+
const results = {};
|
|
297
|
+
for (const athlete of catalog.athletes) {
|
|
298
|
+
const paths = athleteStorePaths(effectiveCatalog, athlete.id);
|
|
299
|
+
try {
|
|
300
|
+
results[athlete.id] = await migratePaths(paths);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
results[athlete.id] = { migrated: false, error: error instanceof Error ? error.message : String(error) };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
process.stdout.write(`${JSON.stringify({ catalog: effectiveCatalog.root, results }, null, 2)}\n`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const athleteId = program.opts().athlete;
|
|
310
|
+
if (!athleteId)
|
|
311
|
+
throw new Error('--athlete is required for this command. Run catence-data athlete list to inspect the catalog or use --all.');
|
|
312
|
+
const { athlete, paths } = await resolveAthlete(effectiveCatalog, athleteId);
|
|
313
|
+
const result = await migratePaths(paths);
|
|
314
|
+
process.stdout.write(`${JSON.stringify({ athleteId: athlete.id, ...result }, null, 2)}\n`);
|
|
315
|
+
});
|
|
223
316
|
await program.parseAsync();
|
|
224
317
|
//# sourceMappingURL=commands.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read + classify the generation progress sidecar written by the Catence
|
|
3
|
+
* Console agent turn (see `catence_console/generation_sidecar.py`). Mirrors the
|
|
4
|
+
* detached-sync sidecar logic: a run is "running" until it reaches a terminal
|
|
5
|
+
* stage, and "stale" (i.e. likely stuck) when its heartbeat is older than the
|
|
6
|
+
* threshold. This powers the chat UI's "still thinking vs stalled" signal.
|
|
7
|
+
*/
|
|
8
|
+
export interface GenerationStatus {
|
|
9
|
+
threadId: string;
|
|
10
|
+
stage?: string;
|
|
11
|
+
running: boolean;
|
|
12
|
+
stale: boolean;
|
|
13
|
+
heartbeatAt?: string;
|
|
14
|
+
startedAt?: string;
|
|
15
|
+
updatedAt?: string;
|
|
16
|
+
toolCallCount: number;
|
|
17
|
+
lastTool?: string | null;
|
|
18
|
+
}
|
|
19
|
+
export declare function generationStatus(homeRoot: string, threadId: string): Promise<GenerationStatus>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const TERMINAL_STAGES = new Set([
|
|
4
|
+
'completed',
|
|
5
|
+
'failed',
|
|
6
|
+
'interrupted',
|
|
7
|
+
'timed_out'
|
|
8
|
+
]);
|
|
9
|
+
const STALE_MS = 5 * 60 * 1000; // 5 minutes
|
|
10
|
+
function emptyStatus(threadId) {
|
|
11
|
+
return {
|
|
12
|
+
threadId,
|
|
13
|
+
running: false,
|
|
14
|
+
stale: false,
|
|
15
|
+
toolCallCount: 0
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export async function generationStatus(homeRoot, threadId) {
|
|
19
|
+
const safe = threadId.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
20
|
+
if (!safe)
|
|
21
|
+
return emptyStatus(threadId);
|
|
22
|
+
const file = path.join(homeRoot, 'generation', `${safe}.generation.json`);
|
|
23
|
+
let raw;
|
|
24
|
+
try {
|
|
25
|
+
raw = await readFile(file, 'utf8');
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return emptyStatus(threadId);
|
|
29
|
+
}
|
|
30
|
+
let data;
|
|
31
|
+
try {
|
|
32
|
+
data = JSON.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return emptyStatus(threadId);
|
|
36
|
+
}
|
|
37
|
+
const base = {
|
|
38
|
+
threadId,
|
|
39
|
+
stage: typeof data.stage === 'string' ? data.stage : undefined,
|
|
40
|
+
heartbeatAt: typeof data.heartbeatAt === 'string' ? data.heartbeatAt : undefined,
|
|
41
|
+
startedAt: typeof data.startedAt === 'string' ? data.startedAt : undefined,
|
|
42
|
+
updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : undefined,
|
|
43
|
+
toolCallCount: typeof data.toolCallCount === 'number' ? data.toolCallCount : 0,
|
|
44
|
+
lastTool: data.lastTool === null || typeof data.lastTool === 'string'
|
|
45
|
+
? data.lastTool
|
|
46
|
+
: undefined
|
|
47
|
+
};
|
|
48
|
+
if (base.stage && TERMINAL_STAGES.has(base.stage)) {
|
|
49
|
+
return { ...base, running: false, stale: false };
|
|
50
|
+
}
|
|
51
|
+
const hb = base.heartbeatAt ? Date.parse(base.heartbeatAt) : NaN;
|
|
52
|
+
const stale = !Number.isFinite(hb) || Date.now() - hb > STALE_MS;
|
|
53
|
+
return { ...base, running: true, stale };
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=generation-status.js.map
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { type Server } from 'node:http';
|
|
2
|
-
import { type CatalogPaths, type CatencePaths } from '../../runtime/index.js';
|
|
2
|
+
import { type DetachedSyncSpawner, mergeOpenCodeGoConsoleProfiles, startDetachedSync, type CatalogPaths, type CatencePaths } from '../../runtime/index.js';
|
|
3
3
|
export type CatenceHttpServerOptions = {
|
|
4
4
|
/** A shared catalog for public servers. All data APIs then require athleteId. */
|
|
5
5
|
catalogPaths?: CatalogPaths;
|
|
6
6
|
/** Internal compatibility hook for tests and embedded single-store callers. */
|
|
7
7
|
paths?: CatencePaths;
|
|
8
8
|
allowedOrigins?: readonly string[];
|
|
9
|
+
/** Internal compatibility hook for tests: override the detached sync spawner. */
|
|
10
|
+
startSync?: typeof startDetachedSync;
|
|
11
|
+
/** Internal compatibility hook for tests: passed into the default startDetachedSync. */
|
|
12
|
+
syncSpawnProcess?: DetachedSyncSpawner;
|
|
13
|
+
/** Internal compatibility hook for tests: override OpenCode Go model discovery. */
|
|
14
|
+
mergeModels?: typeof mergeOpenCodeGoConsoleProfiles;
|
|
9
15
|
};
|
|
10
16
|
/**
|
|
11
17
|
* Create Catence's local Streamable HTTP server.
|
|
@@ -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 {};
|