catence 0.2.1-beta.14 → 0.2.1-beta.16
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 +1 -1
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/retrieval/index.js +23 -5
- package/dist/elt/application/autosync.d.ts +71 -0
- package/dist/elt/application/autosync.js +173 -0
- package/dist/elt/application/management.js +9 -2
- package/dist/elt/ingestion/importer.d.ts +11 -1
- package/dist/elt/ingestion/importer.js +59 -14
- package/dist/elt/ingestion/providers/intervals.d.ts +7 -0
- package/dist/elt/ingestion/providers/intervals.js +21 -2
- package/dist/elt/storage/database.d.ts +6 -1
- package/dist/elt/storage/database.js +7 -1
- package/dist/interfaces/cli/commands.js +58 -1
- package/dist/interfaces/http/cli-options.d.ts +29 -2
- package/dist/interfaces/http/cli-options.js +72 -4
- package/dist/interfaces/http/server.d.ts +12 -4
- package/dist/interfaces/http/server.js +93 -4
- package/dist/interfaces/mcp/main.js +15 -7
- package/dist/interfaces/mcp/server.js +11 -1
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/docs/configuration.md +1 -1
- package/docs/deployment/docker.md +27 -14
- package/docs/deployment/local-mcp.md +25 -11
- package/package.json +1 -1
- package/python/catence/providers/garmin/cli.py +24 -4
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ Optional local Streamable HTTP MCP and HTTP APIs:
|
|
|
120
120
|
catence serve --host 127.0.0.1 --port 8787
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
`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. `POST /api/v1/athletes` creates a catalog athlete (`{"id": "sam", "label": "Sam", "setDefault": false}`); the packaged Console exposes the same call to admins from **Settings → Athletes**. The MCP server has no authentication of its own
|
|
123
|
+
`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. `POST /api/v1/athletes` creates a catalog athlete (`{"id": "sam", "label": "Sam", "setDefault": false}`); the packaged Console exposes the same call to admins from **Settings → Athletes**. The MCP server has no authentication of its own beyond the optional bearer-token gate: `catence-data mcp enable` generates a token at `<home>/mcp-token` and every endpoint except the health probes then requires `Authorization: Bearer <token>`; `mcp status`/`mcp token`/`mcp disable` manage it, and the Console Settings expose the same controls to admins.
|
|
124
124
|
|
|
125
125
|
## Configuration
|
|
126
126
|
|
|
@@ -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.1-beta.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.16";
|
|
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.1-beta.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.16';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -128,12 +128,30 @@ export async function buildRetrievalIndex(database) {
|
|
|
128
128
|
await database.run('BEGIN TRANSACTION');
|
|
129
129
|
try {
|
|
130
130
|
await database.run('DELETE FROM retrieval_documents');
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
131
|
+
// Multi-row INSERT batches: one statement per 500 documents instead of
|
|
132
|
+
// one round trip per document keeps a full 125k-document rebuild from
|
|
133
|
+
// being dominated by per-statement overhead.
|
|
134
|
+
const BATCH_SIZE = 500;
|
|
135
|
+
const allDocuments = [...deduplicated.values()];
|
|
136
|
+
for (let offset = 0; offset < allDocuments.length; offset += BATCH_SIZE) {
|
|
137
|
+
const batch = allDocuments.slice(offset, offset + BATCH_SIZE);
|
|
138
|
+
const values = {};
|
|
139
|
+
const tuples = batch.map((document, index) => {
|
|
140
|
+
const id = documentId(document);
|
|
141
|
+
const prefix = `d${index}`;
|
|
142
|
+
values[`${prefix}id`] = id;
|
|
143
|
+
values[`${prefix}entityType`] = document.entityType;
|
|
144
|
+
values[`${prefix}entityId`] = document.entityId;
|
|
145
|
+
values[`${prefix}activitySourceId`] = document.activitySourceId ?? null;
|
|
146
|
+
values[`${prefix}provider`] = document.provider ?? null;
|
|
147
|
+
values[`${prefix}occurredOn`] = document.occurredOn || null;
|
|
148
|
+
values[`${prefix}text`] = document.text;
|
|
149
|
+
values[`${prefix}metadata`] = JSON.stringify(document.metadata);
|
|
150
|
+
values[`${prefix}hash`] = id;
|
|
151
|
+
return `($${prefix}id, $${prefix}entityType, $${prefix}entityId, $${prefix}activitySourceId, $${prefix}provider, $${prefix}occurredOn, $${prefix}text, $${prefix}metadata, $${prefix}hash)`;
|
|
136
152
|
});
|
|
153
|
+
await database.run(`INSERT INTO retrieval_documents (document_id, entity_type, entity_id, activity_source_id, provider, occurred_on, document_text, metadata_json, content_hash)
|
|
154
|
+
VALUES ${tuples.join(', ')}`, values);
|
|
137
155
|
}
|
|
138
156
|
await database.run(`INSERT INTO retrieval_index_state (index_name, status, mode, source_watermark, built_at, detail_json)
|
|
139
157
|
VALUES ('context', 'ready', $mode, $watermark, now(), $detail)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type CatalogPaths } from '../../core/runtime/catalog.js';
|
|
3
|
+
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
4
|
+
import { startDetachedSync, type DetachedSyncHandle } from './detached-sync.js';
|
|
5
|
+
/** The sync cadences the scheduler accepts, in minutes. */
|
|
6
|
+
export declare const AUTO_SYNC_INTERVAL_MINUTES: readonly [15, 30, 60, 120, 360, 720, 1440];
|
|
7
|
+
export type AutoSyncIntervalMinutes = (typeof AUTO_SYNC_INTERVAL_MINUTES)[number];
|
|
8
|
+
/** Auto-sync is on by default at 2h: fresh data several times a day, far inside provider limits. */
|
|
9
|
+
export declare const DEFAULT_AUTO_SYNC_INTERVAL_MINUTES: AutoSyncIntervalMinutes;
|
|
10
|
+
declare const autoSyncFileSchema: z.ZodObject<{
|
|
11
|
+
enabled: z.ZodBoolean;
|
|
12
|
+
intervalMinutes: z.ZodNumber & z.ZodType<15 | 60 | 30 | 360 | 120 | 720 | 1440, number, z.core.$ZodTypeInternals<15 | 60 | 30 | 360 | 120 | 720 | 1440, number>>;
|
|
13
|
+
lastTriggeredAt: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
14
|
+
lastLogFile: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
15
|
+
}, z.core.$strict>;
|
|
16
|
+
export type AutoSyncConfig = z.infer<typeof autoSyncFileSchema>;
|
|
17
|
+
export type AutoSyncSchedule = {
|
|
18
|
+
athleteId: string;
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
intervalMinutes: AutoSyncIntervalMinutes;
|
|
21
|
+
intervalOptions: ReadonlyArray<{
|
|
22
|
+
minutes: AutoSyncIntervalMinutes;
|
|
23
|
+
label: string;
|
|
24
|
+
}>;
|
|
25
|
+
lastTriggeredAt: string | null;
|
|
26
|
+
lastLogFile: string | null;
|
|
27
|
+
nextRunAt: string | null;
|
|
28
|
+
};
|
|
29
|
+
export declare function autoSyncFilePath(paths: CatencePaths): string;
|
|
30
|
+
/**
|
|
31
|
+
* Parse one of the interval labels the UI and CLI accept (`15m`, `2h`, `24h`)
|
|
32
|
+
* or a bare minute count into a valid cadence. Anything else is rejected at
|
|
33
|
+
* the boundary so stored state can never hold an unsupported interval.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseAutoSyncInterval(value: string): AutoSyncIntervalMinutes;
|
|
36
|
+
/**
|
|
37
|
+
* Read the athlete's schedule. A missing file means "on by default": the
|
|
38
|
+
* effective schedule is reported without writing anything, so a fresh store
|
|
39
|
+
* stays pristine until the first explicit change (the scheduler does write
|
|
40
|
+
* trigger bookkeeping once it actually fires).
|
|
41
|
+
*/
|
|
42
|
+
export declare function readAutoSyncSchedule(paths: CatencePaths, athleteId: string): Promise<AutoSyncSchedule>;
|
|
43
|
+
/**
|
|
44
|
+
* Apply an explicit schedule change (UI or CLI). Only the requested fields
|
|
45
|
+
* change; trigger bookkeeping survives an interval switch or a pause.
|
|
46
|
+
*/
|
|
47
|
+
export declare function updateAutoSyncSchedule(paths: CatencePaths, athleteId: string, change: {
|
|
48
|
+
enabled?: boolean;
|
|
49
|
+
intervalMinutes?: AutoSyncIntervalMinutes;
|
|
50
|
+
}): Promise<AutoSyncSchedule>;
|
|
51
|
+
export type AutoSyncSchedulerOptions = {
|
|
52
|
+
catalogPaths: CatalogPaths;
|
|
53
|
+
/** Internal compatibility hook for tests. */
|
|
54
|
+
startSync?: (request: Parameters<typeof startDetachedSync>[0]) => Promise<DetachedSyncHandle>;
|
|
55
|
+
now?: () => Date;
|
|
56
|
+
/** How often due schedules are checked; default every 30 seconds. */
|
|
57
|
+
tickMs?: number;
|
|
58
|
+
};
|
|
59
|
+
export type AutoSyncScheduler = {
|
|
60
|
+
stop(): void;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* The auto-sync scheduler: a periodic timer that triggers a detached sync for
|
|
64
|
+
* every athlete whose schedule is due. It opens no database connection and
|
|
65
|
+
* takes no locks — the detached child and the single-writer DuckDB lock stay
|
|
66
|
+
* the only sync executors, so an already-running sync simply makes the tick a
|
|
67
|
+
* no-op (DetachedSyncBusyError is skipped quietly and retried on a later
|
|
68
|
+
* tick, once the active run has finished).
|
|
69
|
+
*/
|
|
70
|
+
export declare function startAutoSyncScheduler(options: AutoSyncSchedulerOptions): AutoSyncScheduler;
|
|
71
|
+
export {};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { athleteStorePaths, loadCatalog } from '../../core/runtime/catalog.js';
|
|
5
|
+
import { createSyncLogger } from '../../core/logging.js';
|
|
6
|
+
import { DetachedSyncBusyError, startDetachedSync } from './detached-sync.js';
|
|
7
|
+
/** The sync cadences the scheduler accepts, in minutes. */
|
|
8
|
+
export const AUTO_SYNC_INTERVAL_MINUTES = [15, 30, 60, 120, 360, 720, 1440];
|
|
9
|
+
/** Auto-sync is on by default at 2h: fresh data several times a day, far inside provider limits. */
|
|
10
|
+
export const DEFAULT_AUTO_SYNC_INTERVAL_MINUTES = 120;
|
|
11
|
+
const INTERVAL_CHOICE_LABELS = new Map([
|
|
12
|
+
[15, '15m'], [30, '30m'], [60, '1h'], [120, '2h'], [360, '6h'], [720, '12h'], [1440, '24h'],
|
|
13
|
+
]);
|
|
14
|
+
const autoSyncFileSchema = z.object({
|
|
15
|
+
enabled: z.boolean(),
|
|
16
|
+
intervalMinutes: z.number().int().refine((value) => AUTO_SYNC_INTERVAL_MINUTES.includes(value), 'intervalMinutes must be one of 15, 30, 60, 120, 360, 720, 1440.'),
|
|
17
|
+
lastTriggeredAt: z.string().datetime().nullable().default(null),
|
|
18
|
+
lastLogFile: z.string().nullable().default(null),
|
|
19
|
+
}).strict();
|
|
20
|
+
export function autoSyncFilePath(paths) {
|
|
21
|
+
return path.join(paths.root, 'autosync.json');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse one of the interval labels the UI and CLI accept (`15m`, `2h`, `24h`)
|
|
25
|
+
* or a bare minute count into a valid cadence. Anything else is rejected at
|
|
26
|
+
* the boundary so stored state can never hold an unsupported interval.
|
|
27
|
+
*/
|
|
28
|
+
export function parseAutoSyncInterval(value) {
|
|
29
|
+
const trimmed = value.trim().toLowerCase();
|
|
30
|
+
const match = /^(\d+)(m|h)$/.exec(trimmed);
|
|
31
|
+
const minutes = match ? (match[2] === 'h' ? Number(match[1]) * 60 : Number(match[1])) : Number(trimmed);
|
|
32
|
+
if (Number.isInteger(minutes) && AUTO_SYNC_INTERVAL_MINUTES.includes(minutes)) {
|
|
33
|
+
return minutes;
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`interval must be one of: ${[...INTERVAL_CHOICE_LABELS.values()].join(', ')}.`);
|
|
36
|
+
}
|
|
37
|
+
function defaultConfig() {
|
|
38
|
+
return { enabled: true, intervalMinutes: DEFAULT_AUTO_SYNC_INTERVAL_MINUTES, lastTriggeredAt: null, lastLogFile: null };
|
|
39
|
+
}
|
|
40
|
+
function nextRunAt(config) {
|
|
41
|
+
if (!config.enabled || !config.lastTriggeredAt)
|
|
42
|
+
return null;
|
|
43
|
+
return new Date(Date.parse(config.lastTriggeredAt) + config.intervalMinutes * 60_000).toISOString();
|
|
44
|
+
}
|
|
45
|
+
function toSchedule(athleteId, config) {
|
|
46
|
+
return {
|
|
47
|
+
athleteId,
|
|
48
|
+
enabled: config.enabled,
|
|
49
|
+
intervalMinutes: config.intervalMinutes,
|
|
50
|
+
intervalOptions: AUTO_SYNC_INTERVAL_MINUTES.map((minutes) => ({ minutes, label: INTERVAL_CHOICE_LABELS.get(minutes) ?? `${minutes}m` })),
|
|
51
|
+
lastTriggeredAt: config.lastTriggeredAt,
|
|
52
|
+
lastLogFile: config.lastLogFile,
|
|
53
|
+
nextRunAt: nextRunAt(config),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function isMissingFile(error) {
|
|
57
|
+
return typeof error === 'object' && error !== null && error.code === 'ENOENT';
|
|
58
|
+
}
|
|
59
|
+
async function readEffectiveConfig(paths) {
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = await readFile(autoSyncFilePath(paths), 'utf8');
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (!isMissingFile(error))
|
|
66
|
+
throw error;
|
|
67
|
+
return defaultConfig();
|
|
68
|
+
}
|
|
69
|
+
const parsed = autoSyncFileSchema.safeParse(JSON.parse(raw));
|
|
70
|
+
if (!parsed.success) {
|
|
71
|
+
throw new Error(`autosync.json is not a valid auto-sync schedule (${parsed.error.issues[0]?.message ?? 'unknown issue'}).`);
|
|
72
|
+
}
|
|
73
|
+
return parsed.data;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Read the athlete's schedule. A missing file means "on by default": the
|
|
77
|
+
* effective schedule is reported without writing anything, so a fresh store
|
|
78
|
+
* stays pristine until the first explicit change (the scheduler does write
|
|
79
|
+
* trigger bookkeeping once it actually fires).
|
|
80
|
+
*/
|
|
81
|
+
export async function readAutoSyncSchedule(paths, athleteId) {
|
|
82
|
+
return toSchedule(athleteId, await readEffectiveConfig(paths));
|
|
83
|
+
}
|
|
84
|
+
async function writeAutoSyncConfig(paths, config) {
|
|
85
|
+
const file = autoSyncFilePath(paths);
|
|
86
|
+
// A freshly added athlete has no store directory yet; the schedule file is
|
|
87
|
+
// often its first artifact.
|
|
88
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
89
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
90
|
+
await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
91
|
+
await rename(temporary, file);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Apply an explicit schedule change (UI or CLI). Only the requested fields
|
|
95
|
+
* change; trigger bookkeeping survives an interval switch or a pause.
|
|
96
|
+
*/
|
|
97
|
+
export async function updateAutoSyncSchedule(paths, athleteId, change) {
|
|
98
|
+
const current = await readEffectiveConfig(paths);
|
|
99
|
+
const updated = {
|
|
100
|
+
...current,
|
|
101
|
+
...(change.enabled === undefined ? {} : { enabled: change.enabled }),
|
|
102
|
+
...(change.intervalMinutes === undefined ? {} : { intervalMinutes: change.intervalMinutes }),
|
|
103
|
+
};
|
|
104
|
+
await writeAutoSyncConfig(paths, updated);
|
|
105
|
+
return toSchedule(athleteId, updated);
|
|
106
|
+
}
|
|
107
|
+
/** Record that the scheduler fired (or attempted to fire) for this athlete. */
|
|
108
|
+
async function recordTrigger(paths, logFile, at) {
|
|
109
|
+
const config = await readEffectiveConfig(paths);
|
|
110
|
+
await writeAutoSyncConfig(paths, { ...config, lastTriggeredAt: at.toISOString(), lastLogFile: logFile });
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The auto-sync scheduler: a periodic timer that triggers a detached sync for
|
|
114
|
+
* every athlete whose schedule is due. It opens no database connection and
|
|
115
|
+
* takes no locks — the detached child and the single-writer DuckDB lock stay
|
|
116
|
+
* the only sync executors, so an already-running sync simply makes the tick a
|
|
117
|
+
* no-op (DetachedSyncBusyError is skipped quietly and retried on a later
|
|
118
|
+
* tick, once the active run has finished).
|
|
119
|
+
*/
|
|
120
|
+
export function startAutoSyncScheduler(options) {
|
|
121
|
+
const startSync = options.startSync ?? startDetachedSync;
|
|
122
|
+
const now = options.now ?? (() => new Date());
|
|
123
|
+
const tickMs = options.tickMs ?? 30_000;
|
|
124
|
+
let ticking = false;
|
|
125
|
+
const tick = async () => {
|
|
126
|
+
if (ticking)
|
|
127
|
+
return; // Never overlap ticks; the in-flight one wins.
|
|
128
|
+
ticking = true;
|
|
129
|
+
try {
|
|
130
|
+
const catalog = await loadCatalog(options.catalogPaths);
|
|
131
|
+
await Promise.allSettled(catalog.athletes.map(async (athlete) => {
|
|
132
|
+
const paths = athleteStorePaths(options.catalogPaths, athlete.id);
|
|
133
|
+
const log = createSyncLogger(paths);
|
|
134
|
+
try {
|
|
135
|
+
const config = await readEffectiveConfig(paths);
|
|
136
|
+
if (!config.enabled)
|
|
137
|
+
return;
|
|
138
|
+
const dueAt = config.lastTriggeredAt ? Date.parse(config.lastTriggeredAt) + config.intervalMinutes * 60_000 : 0;
|
|
139
|
+
if (now().getTime() < dueAt)
|
|
140
|
+
return;
|
|
141
|
+
const handle = await startSync({ paths, athleteId: athlete.id, provider: 'all', cliHome: options.catalogPaths.root });
|
|
142
|
+
await recordTrigger(paths, handle.logFile, now());
|
|
143
|
+
log.info('Auto-sync triggered', { athleteId: athlete.id, intervalMinutes: config.intervalMinutes, logFile: handle.logFile });
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error instanceof DetachedSyncBusyError)
|
|
147
|
+
return; // A run is active; retry on a later tick.
|
|
148
|
+
// Any other failure is recorded as a trigger attempt so a broken
|
|
149
|
+
// store cannot be hammered every tick; the next cadence retries.
|
|
150
|
+
await recordTrigger(paths, null, now()).catch(() => undefined);
|
|
151
|
+
log.error('Auto-sync trigger failed', { athleteId: athlete.id, error: error instanceof Error ? error.message : String(error) });
|
|
152
|
+
}
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
process.stderr.write(`[catence] Auto-sync tick failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
ticking = false;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const timer = setInterval(() => { void tick(); }, tickMs);
|
|
163
|
+
timer.unref?.();
|
|
164
|
+
// The first tick is immediate: a restart should heal stale data within a
|
|
165
|
+
// minute instead of waiting a full interval.
|
|
166
|
+
void tick();
|
|
167
|
+
return {
|
|
168
|
+
stop() {
|
|
169
|
+
clearInterval(timer);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=autosync.js.map
|
|
@@ -114,9 +114,16 @@ async function backfillGarminSwimLengths(database, paths, parentRunId, log) {
|
|
|
114
114
|
}
|
|
115
115
|
// The swim list is queried here and handed to the Python worker as a file:
|
|
116
116
|
// the child must never open DuckDB itself because the parent holds the
|
|
117
|
-
// write lock for the whole run.
|
|
117
|
+
// write lock for the whole run. Only activities without per-length rows are
|
|
118
|
+
// handed over: re-parsing every archived FIT on every sync cost ~23s/run
|
|
119
|
+
// with nothing new to stage.
|
|
118
120
|
const activitiesFile = path.join(paths.staging, 'garmin', `${parentRunId}.swim_activities.json`);
|
|
119
|
-
|
|
121
|
+
const pendingSwims = await database.garminSwimActivitySourcesWithoutLengths();
|
|
122
|
+
if (pendingSwims.length === 0) {
|
|
123
|
+
log.info('Garmin swim backfill skipped: every swim activity already has lengths', { parentRunId });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await writeFile(activitiesFile, JSON.stringify(pendingSwims));
|
|
120
127
|
const arguments_ = [
|
|
121
128
|
'run',
|
|
122
129
|
'python',
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import type { CatenceDatabase } from '../storage/database.js';
|
|
2
2
|
import { type StagingRecord } from '../../contracts/staging.js';
|
|
3
3
|
import type { SyncLogger } from '../../core/logging.js';
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Per-pass import state. When present, activity-quality refreshes are
|
|
6
|
+
* deferred to the end of the pass: a staging file lists an activity before
|
|
7
|
+
* its detail records, so refreshing inline would compute flags from partial
|
|
8
|
+
* data, and one refresh per activity is enough regardless of how many
|
|
9
|
+
* detail entity types follow it.
|
|
10
|
+
*/
|
|
11
|
+
export type ImportPassContext = {
|
|
12
|
+
deferredQualityActivityIds: Set<string>;
|
|
13
|
+
};
|
|
14
|
+
export declare function importRecord(database: CatenceDatabase, runId: string, record: StagingRecord, log?: SyncLogger, pass?: ImportPassContext): Promise<void>;
|
|
5
15
|
export declare function importJsonl(database: CatenceDatabase, runId: string, filePath: string, log?: SyncLogger): Promise<number>;
|
|
6
16
|
export declare function importSingleStagingFile(database: CatenceDatabase, runId: string, filePath: string): Promise<number>;
|
|
7
17
|
export declare function queueWorkItem(database: CatenceDatabase, runId: string, provider: 'intervals' | 'garmin' | 'strava', endpoint: string, remoteId: string | null, scope: unknown): Promise<string>;
|
|
@@ -7,7 +7,12 @@ import { importSourceEntity, refreshActivityQuality } from '../normalization/nor
|
|
|
7
7
|
import { normalizeStravaEntity } from '../normalization/segments/strava-normalizer.js';
|
|
8
8
|
import { reconcileActivityLink } from '../normalization/activities/linking.js';
|
|
9
9
|
import { json } from '../storage/sql.js';
|
|
10
|
-
|
|
10
|
+
const GARMIN_ACTIVITY_DETAIL_ENTITY_TYPES = ['activity_detail', 'activity_interval', 'activity_exercise_set', 'activity_swim_lengths'];
|
|
11
|
+
async function activityIdForSource(database, activitySourceId) {
|
|
12
|
+
const activity = await database.rows('SELECT activity_id FROM activity_sources WHERE activity_source_id = $activitySourceId', { activitySourceId });
|
|
13
|
+
return activity[0]?.activity_id ?? null;
|
|
14
|
+
}
|
|
15
|
+
export async function importRecord(database, runId, record, log, pass) {
|
|
11
16
|
if (record.kind === 'run_manifest') {
|
|
12
17
|
if (record.runId !== runId)
|
|
13
18
|
throw new Error(`Staging manifest run ${record.runId} does not match importer run ${runId}.`);
|
|
@@ -29,14 +34,14 @@ export async function importRecord(database, runId, record, log) {
|
|
|
29
34
|
await importSourceEntity(database, record);
|
|
30
35
|
if (record.entityType === 'activity') {
|
|
31
36
|
await reconcileActivityLink(database, record.provider + ':' + record.remoteId);
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
await
|
|
37
|
+
const activityId = await activityIdForSource(database, record.provider + ':' + record.remoteId);
|
|
38
|
+
if (activityId)
|
|
39
|
+
await queueActivityQualityRefresh(database, activityId, pass);
|
|
35
40
|
}
|
|
36
|
-
if (record.provider === 'garmin' &&
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
await
|
|
41
|
+
if (record.provider === 'garmin' && GARMIN_ACTIVITY_DETAIL_ENTITY_TYPES.includes(record.entityType) && record.parentRemoteId) {
|
|
42
|
+
const activityId = await activityIdForSource(database, `garmin:${record.parentRemoteId}`);
|
|
43
|
+
if (activityId)
|
|
44
|
+
await queueActivityQualityRefresh(database, activityId, pass);
|
|
40
45
|
}
|
|
41
46
|
return;
|
|
42
47
|
}
|
|
@@ -56,17 +61,57 @@ export async function importRecord(database, runId, record, log) {
|
|
|
56
61
|
await database.addError(runId, record.provider, record.endpoint, record.remoteId, record.message, record.retryable);
|
|
57
62
|
log?.warn('Staging record reported an extraction error', { runId, provider: record.provider, endpoint: record.endpoint, remoteId: record.remoteId, error: record.message });
|
|
58
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Refresh now (single-record callers) or defer to the end of the pass
|
|
66
|
+
* (bulk importJsonl callers) — see ImportPassContext.
|
|
67
|
+
*/
|
|
68
|
+
async function queueActivityQualityRefresh(database, activityId, pass) {
|
|
69
|
+
if (pass) {
|
|
70
|
+
pass.deferredQualityActivityIds.add(activityId);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await refreshActivityQuality(database, activityId);
|
|
74
|
+
}
|
|
75
|
+
/** Records per transaction: large enough to amortize DuckDB commits, small enough to bound a mid-file failure. */
|
|
76
|
+
const IMPORT_CHUNK_SIZE = 200;
|
|
59
77
|
export async function importJsonl(database, runId, filePath, log) {
|
|
60
78
|
let count = 0;
|
|
79
|
+
const pass = { deferredQualityActivityIds: new Set() };
|
|
61
80
|
const file = createReadStream(filePath, { encoding: 'utf8' });
|
|
62
81
|
const lines = createInterface({ input: file, crlfDelay: Infinity });
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
let inTransaction = false;
|
|
83
|
+
const startedAt = Date.now();
|
|
84
|
+
try {
|
|
85
|
+
for await (const line of lines) {
|
|
86
|
+
if (!line.trim())
|
|
87
|
+
continue;
|
|
88
|
+
if (count % IMPORT_CHUNK_SIZE === 0) {
|
|
89
|
+
await database.run('BEGIN TRANSACTION');
|
|
90
|
+
inTransaction = true;
|
|
91
|
+
}
|
|
92
|
+
const record = stagingRecordSchema.parse(JSON.parse(line));
|
|
93
|
+
await importRecord(database, runId, record, log, pass);
|
|
94
|
+
count += 1;
|
|
95
|
+
if (count % IMPORT_CHUNK_SIZE === 0) {
|
|
96
|
+
await database.run('COMMIT');
|
|
97
|
+
inTransaction = false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (inTransaction)
|
|
103
|
+
await database.run('ROLLBACK');
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
const qualityStartedAt = Date.now();
|
|
107
|
+
for (const activityId of pass.deferredQualityActivityIds) {
|
|
108
|
+
await refreshActivityQuality(database, activityId);
|
|
69
109
|
}
|
|
110
|
+
log?.info('Staging import finished with stage timings', {
|
|
111
|
+
runId, records: count, durationMs: Date.now() - startedAt,
|
|
112
|
+
qualityRefreshes: pass.deferredQualityActivityIds.size,
|
|
113
|
+
qualityRefreshMs: Date.now() - qualityStartedAt,
|
|
114
|
+
});
|
|
70
115
|
return count;
|
|
71
116
|
}
|
|
72
117
|
export async function importSingleStagingFile(database, runId, filePath) {
|
|
@@ -27,6 +27,13 @@ export declare class IntervalsExtractor {
|
|
|
27
27
|
private captureActivity;
|
|
28
28
|
private capture;
|
|
29
29
|
private importEntities;
|
|
30
|
+
/**
|
|
31
|
+
* Intervals.icu enforces 2500 requests per rolling 15 minutes and 5000 per
|
|
32
|
+
* day; over-limit calls answer 429 with a Retry-After header. Back off
|
|
33
|
+
* cooperatively and retry a bounded number of times so one auto-sync run
|
|
34
|
+
* degrades instead of recording a wall of retryable errors.
|
|
35
|
+
*/
|
|
36
|
+
private request;
|
|
30
37
|
private getJson;
|
|
31
38
|
private getBinary;
|
|
32
39
|
}
|
|
@@ -243,14 +243,33 @@ export class IntervalsExtractor {
|
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Intervals.icu enforces 2500 requests per rolling 15 minutes and 5000 per
|
|
248
|
+
* day; over-limit calls answer 429 with a Retry-After header. Back off
|
|
249
|
+
* cooperatively and retry a bounded number of times so one auto-sync run
|
|
250
|
+
* degrades instead of recording a wall of retryable errors.
|
|
251
|
+
*/
|
|
252
|
+
async request(route, accept) {
|
|
253
|
+
for (let attempt = 0;; attempt++) {
|
|
254
|
+
const headers = { Authorization: basicAuth(this.apiKey) };
|
|
255
|
+
if (accept)
|
|
256
|
+
headers.Accept = accept;
|
|
257
|
+
const response = await fetch(`${apiBaseUrl}${route}`, { headers });
|
|
258
|
+
if (response.status !== 429 || attempt >= 2)
|
|
259
|
+
return response;
|
|
260
|
+
const retryAfterSeconds = Math.min(Math.max(Number(response.headers.get('retry-after')) || 30, 1), 300);
|
|
261
|
+
this.log.warn('Intervals rate limit hit; backing off', { route, retryAfterSeconds, attempt: attempt + 1 });
|
|
262
|
+
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1_000));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
246
265
|
async getJson(route) {
|
|
247
|
-
const response = await
|
|
266
|
+
const response = await this.request(route, 'application/json');
|
|
248
267
|
if (!response.ok)
|
|
249
268
|
throw new Error(`Intervals ${route} failed with HTTP ${response.status}`);
|
|
250
269
|
return response.json();
|
|
251
270
|
}
|
|
252
271
|
async getBinary(route) {
|
|
253
|
-
const response = await
|
|
272
|
+
const response = await this.request(route, null);
|
|
254
273
|
if (!response.ok)
|
|
255
274
|
throw new Error(`Intervals ${route} failed with HTTP ${response.status}`);
|
|
256
275
|
return new Uint8Array(await response.arrayBuffer());
|
|
@@ -60,7 +60,12 @@ export declare class CatenceDatabase implements WriteDataStore {
|
|
|
60
60
|
knownActivitySummaryHashes(provider: Provider): Promise<Record<string, string>>;
|
|
61
61
|
hasGarminLactateThresholdHistory(): Promise<boolean>;
|
|
62
62
|
earliestActivityStartDate(provider: Provider): Promise<string | null>;
|
|
63
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Swim activities that still lack per-length rows: the FIT backfill only
|
|
65
|
+
* has work to do for these, so syncs skip re-parsing already-derived
|
|
66
|
+
* archives entirely.
|
|
67
|
+
*/
|
|
68
|
+
garminSwimActivitySourcesWithoutLengths(): Promise<Array<{
|
|
64
69
|
remote_activity_id: string;
|
|
65
70
|
started_on: string | null;
|
|
66
71
|
}>>;
|
|
@@ -319,13 +319,19 @@ export class CatenceDatabase {
|
|
|
319
319
|
JOIN activity_sources source USING (activity_id) WHERE source.provider = $provider`, { provider });
|
|
320
320
|
return toIsoDate(rows[0]?.earliest ?? null);
|
|
321
321
|
}
|
|
322
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Swim activities that still lack per-length rows: the FIT backfill only
|
|
324
|
+
* has work to do for these, so syncs skip re-parsing already-derived
|
|
325
|
+
* archives entirely.
|
|
326
|
+
*/
|
|
327
|
+
async garminSwimActivitySourcesWithoutLengths() {
|
|
323
328
|
const sports = ['lap_swimming', 'swimming', 'open_water_swimming', 'openwaterswimming'];
|
|
324
329
|
return this.rows(`SELECT source.remote_activity_id, cast(activity.started_at_utc AS VARCHAR) AS started_on
|
|
325
330
|
FROM activity_sources AS source
|
|
326
331
|
JOIN activities AS activity USING (activity_id)
|
|
327
332
|
WHERE source.provider = 'garmin'
|
|
328
333
|
AND lower(coalesce(activity.sport, '')) IN (${sports.map((sport) => `'${sport}'`).join(', ')})
|
|
334
|
+
AND NOT EXISTS (SELECT 1 FROM swim_lengths WHERE swim_lengths.activity_source_id = source.activity_source_id)
|
|
329
335
|
ORDER BY activity.started_at_utc`);
|
|
330
336
|
}
|
|
331
337
|
async latestSourceDate(provider, cursorName) {
|
|
@@ -2,7 +2,8 @@
|
|
|
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 {
|
|
5
|
+
import { defaultBearerTokenPath, disableBearerToken, ensureBearerToken, mcpExposureStatus, readBearerToken, } from '../http/cli-options.js';
|
|
6
|
+
import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, CatenceDatabase, channelForVersion, createDemoStore, dataStatus, deleteAthleteSecret, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, parseAutoSyncInterval, planSelfUpdate, readAutoSyncSchedule, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, reimportWeight, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, updateAutoSyncSchedule, } from '../../runtime/index.js';
|
|
6
7
|
const program = new Command()
|
|
7
8
|
.name('catence-data')
|
|
8
9
|
.description('Athlete-scoped Catence data setup, extraction, and normalization.')
|
|
@@ -37,6 +38,41 @@ async function stdinSecret() {
|
|
|
37
38
|
throw new Error('--value-stdin received an empty secret.');
|
|
38
39
|
return value;
|
|
39
40
|
}
|
|
41
|
+
const mcpCommand = program.command('mcp').description('Manage Streamable HTTP MCP exposure: the bearer-token gate, its token, and endpoint URLs.');
|
|
42
|
+
const mcpLocalEndpoint = () => `http://127.0.0.1:${process.env.CATENCE_HTTP_PORT ?? '8787'}`;
|
|
43
|
+
mcpCommand.command('status')
|
|
44
|
+
.description('Report whether the bearer-token gate is enabled, the token, and the endpoint URLs.')
|
|
45
|
+
.option('--file <path>', 'non-default token file path')
|
|
46
|
+
.action(async (options) => {
|
|
47
|
+
const tokenFile = options.file ?? defaultBearerTokenPath(currentCatalog().root);
|
|
48
|
+
process.stdout.write(`${JSON.stringify(mcpExposureStatus(tokenFile, mcpLocalEndpoint()), null, 2)}\n`);
|
|
49
|
+
});
|
|
50
|
+
mcpCommand.command('enable')
|
|
51
|
+
.description('Enable the bearer-token gate: generate the token file (mode 0600) if it does not exist.')
|
|
52
|
+
.option('--file <path>', 'non-default token file path')
|
|
53
|
+
.action(async (options) => {
|
|
54
|
+
const tokenFile = options.file ?? defaultBearerTokenPath(currentCatalog().root);
|
|
55
|
+
ensureBearerToken(tokenFile);
|
|
56
|
+
process.stdout.write(`${JSON.stringify(mcpExposureStatus(tokenFile, mcpLocalEndpoint()), null, 2)}\n`);
|
|
57
|
+
});
|
|
58
|
+
mcpCommand.command('disable')
|
|
59
|
+
.description('Disable the bearer-token gate by removing the token file. Remote access is refused again.')
|
|
60
|
+
.option('--file <path>', 'non-default token file path')
|
|
61
|
+
.action(async (options) => {
|
|
62
|
+
const tokenFile = options.file ?? defaultBearerTokenPath(currentCatalog().root);
|
|
63
|
+
disableBearerToken(tokenFile);
|
|
64
|
+
process.stdout.write(`${JSON.stringify(mcpExposureStatus(tokenFile, mcpLocalEndpoint()), null, 2)}\n`);
|
|
65
|
+
});
|
|
66
|
+
mcpCommand.command('token')
|
|
67
|
+
.description('Print only the bearer token for pasting into an MCP client credential flow.')
|
|
68
|
+
.option('--file <path>', 'non-default token file path')
|
|
69
|
+
.action(async (options) => {
|
|
70
|
+
const tokenFile = options.file ?? defaultBearerTokenPath(currentCatalog().root);
|
|
71
|
+
const token = readBearerToken(tokenFile);
|
|
72
|
+
if (token === null)
|
|
73
|
+
throw new Error(`MCP exposure is not enabled; run 'catence-data mcp enable' first.`);
|
|
74
|
+
process.stdout.write(`${token}\n`);
|
|
75
|
+
});
|
|
40
76
|
program.command('setup')
|
|
41
77
|
.description('Create a home catalog and its first athlete store. Existing 0.1 stores must be re-synced.')
|
|
42
78
|
.option('--athlete <id>', 'first athlete ID')
|
|
@@ -130,6 +166,27 @@ program.command('retry')
|
|
|
130
166
|
const { paths } = await currentAthlete();
|
|
131
167
|
process.stdout.write(`${JSON.stringify(await retryDataSync(paths, options.run), null, 2)}\n`);
|
|
132
168
|
});
|
|
169
|
+
const autoSyncCommand = program.command('auto-sync').description('Manage the automatic sync schedule for one athlete. The scheduler runs inside the Catence server (default: enabled, every 2 hours).');
|
|
170
|
+
autoSyncCommand.command('status')
|
|
171
|
+
.description('Report the athlete’s automatic sync schedule: enabled flag, interval, and next run.')
|
|
172
|
+
.action(async () => {
|
|
173
|
+
const { athlete, paths } = await currentAthlete();
|
|
174
|
+
process.stdout.write(`${JSON.stringify(await readAutoSyncSchedule(paths, athlete.id), null, 2)}\n`);
|
|
175
|
+
});
|
|
176
|
+
autoSyncCommand.command('enable')
|
|
177
|
+
.description('Turn automatic syncing on, optionally changing the interval (15m, 30m, 1h, 2h, 6h, 12h, 24h).')
|
|
178
|
+
.option('--interval <interval>', 'sync interval label or minutes', '2h')
|
|
179
|
+
.action(async (options) => {
|
|
180
|
+
const { athlete, paths } = await currentAthlete();
|
|
181
|
+
const intervalMinutes = parseAutoSyncInterval(options.interval);
|
|
182
|
+
process.stdout.write(`${JSON.stringify(await updateAutoSyncSchedule(paths, athlete.id, { enabled: true, intervalMinutes }), null, 2)}\n`);
|
|
183
|
+
});
|
|
184
|
+
autoSyncCommand.command('disable')
|
|
185
|
+
.description('Turn automatic syncing off. Manual sync keeps working.')
|
|
186
|
+
.action(async () => {
|
|
187
|
+
const { athlete, paths } = await currentAthlete();
|
|
188
|
+
process.stdout.write(`${JSON.stringify(await updateAutoSyncSchedule(paths, athlete.id, { enabled: false }), null, 2)}\n`);
|
|
189
|
+
});
|
|
133
190
|
program.command('auth').description('Connect a source account using one athlete’s local credentials.')
|
|
134
191
|
.command('strava')
|
|
135
192
|
.option('--code <authorization-code>', 'OAuth authorization code returned by Strava')
|
|
@@ -6,7 +6,34 @@ export type ServeCliOptions = {
|
|
|
6
6
|
authTokenFile?: string;
|
|
7
7
|
help: boolean;
|
|
8
8
|
};
|
|
9
|
-
export declare const SERVE_USAGE = "Usage: catence serve [options]\n\nRun Catence as a local Streamable HTTP MCP server.\n\nOptions:\n --home <directory> Catence catalog home. Defaults to CATENCE_HOME or ~/.catence.\n --host <host> Listener host. Defaults to 127.0.0.1.\n --port <port> Listener port. Defaults to 8787.\n --allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.\n --auth-token-file <path>
|
|
10
|
-
/**
|
|
9
|
+
export declare const SERVE_USAGE = "Usage: catence serve [options]\n\nRun Catence as a local Streamable HTTP MCP server.\n\nOptions:\n --home <directory> Catence catalog home. Defaults to CATENCE_HOME or ~/.catence.\n --host <host> Listener host. Defaults to 127.0.0.1.\n --port <port> Listener port. Defaults to 8787.\n --allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.\n --auth-token-file <path> Bearer-token file for the HTTP gate. Its presence switches authentication on;\n generate/inspect it with 'catence-data mcp enable|token'.\n Defaults to CATENCE_MCP_TOKEN_FILE or <catalog-home>/mcp-token.\n -h, --help Show this help text.";
|
|
10
|
+
/** The canonical token location inside a catalog home. */
|
|
11
|
+
export declare function defaultBearerTokenPath(catalogRoot: string): string;
|
|
12
|
+
/** Read and validate a stored bearer token: trimmed, non-empty, or fail loudly. */
|
|
11
13
|
export declare function loadBearerToken(filePath: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* The bearer token for the server: the stored one, or a freshly generated
|
|
16
|
+
* token written to the file (mode 0600) when it does not exist yet. Users
|
|
17
|
+
* never hand-craft tokens; they enable auth and read the result with
|
|
18
|
+
* `catence-data mcp-token`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function ensureBearerToken(filePath: string): string;
|
|
21
|
+
export declare function disableBearerToken(filePath: string): void;
|
|
22
|
+
/** The bearer token when exposure is enabled, or null when no token file exists. */
|
|
23
|
+
export declare function readBearerToken(filePath: string): string | null;
|
|
24
|
+
/**
|
|
25
|
+
* The currently active bearer token for the HTTP gate, cached by the token
|
|
26
|
+
* file's mtime so enabling or disabling is picked up without a restart. A
|
|
27
|
+
* missing file disables the gate; an existing but unreadable or empty file
|
|
28
|
+
* fails closed (the gate rejects everything except health probes).
|
|
29
|
+
*/
|
|
30
|
+
export declare function activeBearerToken(filePath: string): string | null | 'invalid';
|
|
31
|
+
export type McpExposureStatus = {
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
tokenFile: string;
|
|
34
|
+
endpoint: string;
|
|
35
|
+
healthEndpoint: string;
|
|
36
|
+
token?: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function mcpExposureStatus(filePath: string, endpointBase: string): McpExposureStatus;
|
|
12
39
|
export declare function parseServeCliOptions(arguments_: readonly string[]): ServeCliOptions;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
3
|
export const SERVE_USAGE = `Usage: catence serve [options]
|
|
3
4
|
|
|
4
5
|
Run Catence as a local Streamable HTTP MCP server.
|
|
@@ -8,10 +9,15 @@ Options:
|
|
|
8
9
|
--host <host> Listener host. Defaults to 127.0.0.1.
|
|
9
10
|
--port <port> Listener port. Defaults to 8787.
|
|
10
11
|
--allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.
|
|
11
|
-
--auth-token-file <path>
|
|
12
|
-
|
|
12
|
+
--auth-token-file <path> Bearer-token file for the HTTP gate. Its presence switches authentication on;
|
|
13
|
+
generate/inspect it with 'catence-data mcp enable|token'.
|
|
14
|
+
Defaults to CATENCE_MCP_TOKEN_FILE or <catalog-home>/mcp-token.
|
|
13
15
|
-h, --help Show this help text.`;
|
|
14
|
-
/**
|
|
16
|
+
/** The canonical token location inside a catalog home. */
|
|
17
|
+
export function defaultBearerTokenPath(catalogRoot) {
|
|
18
|
+
return `${catalogRoot.replace(/[/\\]+$/, '')}/mcp-token`;
|
|
19
|
+
}
|
|
20
|
+
/** Read and validate a stored bearer token: trimmed, non-empty, or fail loudly. */
|
|
15
21
|
export function loadBearerToken(filePath) {
|
|
16
22
|
let token;
|
|
17
23
|
try {
|
|
@@ -24,6 +30,68 @@ export function loadBearerToken(filePath) {
|
|
|
24
30
|
throw new Error(`The auth token file ${filePath} is empty.`);
|
|
25
31
|
return token;
|
|
26
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The bearer token for the server: the stored one, or a freshly generated
|
|
35
|
+
* token written to the file (mode 0600) when it does not exist yet. Users
|
|
36
|
+
* never hand-craft tokens; they enable auth and read the result with
|
|
37
|
+
* `catence-data mcp-token`.
|
|
38
|
+
*/
|
|
39
|
+
export function ensureBearerToken(filePath) {
|
|
40
|
+
if (existsSync(filePath))
|
|
41
|
+
return loadBearerToken(filePath);
|
|
42
|
+
const token = randomBytes(32).toString('hex');
|
|
43
|
+
writeFileSync(filePath, token, { mode: 0o600 });
|
|
44
|
+
return token;
|
|
45
|
+
}
|
|
46
|
+
export function disableBearerToken(filePath) {
|
|
47
|
+
if (existsSync(filePath))
|
|
48
|
+
rmSync(filePath);
|
|
49
|
+
}
|
|
50
|
+
/** The bearer token when exposure is enabled, or null when no token file exists. */
|
|
51
|
+
export function readBearerToken(filePath) {
|
|
52
|
+
return existsSync(filePath) ? loadBearerToken(filePath) : null;
|
|
53
|
+
}
|
|
54
|
+
const tokenCache = new Map();
|
|
55
|
+
/**
|
|
56
|
+
* The currently active bearer token for the HTTP gate, cached by the token
|
|
57
|
+
* file's mtime so enabling or disabling is picked up without a restart. A
|
|
58
|
+
* missing file disables the gate; an existing but unreadable or empty file
|
|
59
|
+
* fails closed (the gate rejects everything except health probes).
|
|
60
|
+
*/
|
|
61
|
+
export function activeBearerToken(filePath) {
|
|
62
|
+
let mtimeMs = 0;
|
|
63
|
+
try {
|
|
64
|
+
mtimeMs = statSync(filePath).mtimeMs;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
tokenCache.set(filePath, { mtimeMs: 0, token: null });
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const cached = tokenCache.get(filePath);
|
|
71
|
+
if (cached && cached.mtimeMs === mtimeMs) {
|
|
72
|
+
return cached.token === null ? 'invalid' : cached.token;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const token = loadBearerToken(filePath);
|
|
76
|
+
tokenCache.set(filePath, { mtimeMs, token });
|
|
77
|
+
return token;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
tokenCache.set(filePath, { mtimeMs, token: null });
|
|
81
|
+
return 'invalid';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function mcpExposureStatus(filePath, endpointBase) {
|
|
85
|
+
const token = readBearerToken(filePath);
|
|
86
|
+
const base = endpointBase.replace(/\/+$/, '');
|
|
87
|
+
return {
|
|
88
|
+
enabled: token !== null,
|
|
89
|
+
tokenFile: filePath,
|
|
90
|
+
endpoint: `${base}/mcp`,
|
|
91
|
+
healthEndpoint: `${base}/health`,
|
|
92
|
+
...(token !== null ? { token } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
27
95
|
function requiredValue(arguments_, index, option) {
|
|
28
96
|
const value = arguments_[index + 1];
|
|
29
97
|
if (!value || value.startsWith('-'))
|
|
@@ -7,12 +7,20 @@ export type CatenceHttpServerOptions = {
|
|
|
7
7
|
paths?: CatencePaths;
|
|
8
8
|
allowedOrigins?: readonly string[];
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* authentication of its own; this gates it (and the JSON APIs) for
|
|
13
|
-
* non-loopback exposure such as a Cloudflare Tunnel to an MCP client.
|
|
10
|
+
* Static bearer token for the gate (tests and embedded single-store callers).
|
|
11
|
+
* Mutually exclusive with bearerTokenFile.
|
|
14
12
|
*/
|
|
15
13
|
bearerToken?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Token file whose presence switches the gate on: every request except the
|
|
16
|
+
* health endpoints and CORS preflights must present
|
|
17
|
+
* `Authorization: Bearer <token>`. The file is re-read on mtime change, so
|
|
18
|
+
* `catence-data mcp enable|disable` (and the /api/v1/mcp-exposure route)
|
|
19
|
+
* take effect without a restart. The MCP server has no other
|
|
20
|
+
* authentication; this gates it (and the JSON APIs) for non-loopback
|
|
21
|
+
* exposure such as a Cloudflare Tunnel to an MCP client.
|
|
22
|
+
*/
|
|
23
|
+
bearerTokenFile?: string;
|
|
16
24
|
/** Internal compatibility hook for tests: override the detached sync spawner. */
|
|
17
25
|
startSync?: typeof startDetachedSync;
|
|
18
26
|
/** Internal compatibility hook for tests: passed into the default startDetachedSync. */
|
|
@@ -2,16 +2,20 @@ import { timingSafeEqual } from 'node:crypto';
|
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
|
-
import { addAthlete, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, catenceRuntimeHealth, DashboardSnapshotService, deleteAthleteSecret, describeAthleteSecrets, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, listAthleteFileRevisions, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, readAthleteFile, readAthleteFileRevision, resolveAthlete, resolveCatalogPaths, setAthleteSecret, startDetachedSync, syncProgress, updateAthleteFile, } from '../../runtime/index.js';
|
|
5
|
+
import { addAthlete, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, catenceRuntimeHealth, DashboardSnapshotService, deleteAthleteSecret, describeAthleteSecrets, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, listAthleteFileRevisions, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, parseAutoSyncInterval, readAutoSyncSchedule, readAthleteFile, readAthleteFileRevision, resolveAthlete, resolveCatalogPaths, setAthleteSecret, startDetachedSync, syncProgress, updateAthleteFile, updateAutoSyncSchedule, } from '../../runtime/index.js';
|
|
6
|
+
import { activeBearerToken, ensureBearerToken, disableBearerToken, mcpExposureStatus } from './cli-options.js';
|
|
6
7
|
import { createCatenceMcpServer } from '../mcp/server.js';
|
|
7
8
|
import { generationStatus } from './generation-status.js';
|
|
8
9
|
const MAX_REQUEST_BYTES = 1_000_000;
|
|
9
10
|
/** Endpoints that stay open when bearer authentication is enabled: they carry
|
|
10
11
|
* no personal data and must answer unauthenticated readiness probes. */
|
|
11
12
|
const UNAUTHENTICATED_PATHS = new Set(['/health', '/api/v1/health']);
|
|
12
|
-
function isAuthorized(request,
|
|
13
|
-
|
|
13
|
+
function isAuthorized(request, options) {
|
|
14
|
+
const bearerToken = options.bearerToken !== undefined ? options.bearerToken : options.bearerTokenFile !== undefined ? activeBearerToken(options.bearerTokenFile) : undefined;
|
|
15
|
+
if (bearerToken === undefined || bearerToken === null)
|
|
14
16
|
return true;
|
|
17
|
+
if (bearerToken === 'invalid')
|
|
18
|
+
return false;
|
|
15
19
|
const received = Buffer.from(request.headers.authorization ?? '');
|
|
16
20
|
const expected = Buffer.from(`Bearer ${bearerToken}`);
|
|
17
21
|
// timingSafeEqual throws on length mismatch, so compare sizes first.
|
|
@@ -99,6 +103,34 @@ async function dashboardPaths(catalogPaths, staticPaths, url) {
|
|
|
99
103
|
return staticPaths;
|
|
100
104
|
throw new Error('No Catence data store is configured.');
|
|
101
105
|
}
|
|
106
|
+
function parseSyncScheduleBody(body) {
|
|
107
|
+
if (body === undefined)
|
|
108
|
+
return {};
|
|
109
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body))
|
|
110
|
+
throw new Error('Request body must be a JSON object.');
|
|
111
|
+
const raw = body;
|
|
112
|
+
for (const key of Object.keys(raw)) {
|
|
113
|
+
if (!['athleteId', 'enabled', 'intervalMinutes'].includes(key)) {
|
|
114
|
+
throw new Error(`Unknown auto-sync field: ${key}.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (raw.athleteId !== undefined && typeof raw.athleteId !== 'string')
|
|
118
|
+
throw new Error('athleteId must be a string.');
|
|
119
|
+
if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean')
|
|
120
|
+
throw new Error('enabled must be a boolean.');
|
|
121
|
+
if (raw.intervalMinutes !== undefined) {
|
|
122
|
+
if (typeof raw.intervalMinutes === 'string') {
|
|
123
|
+
raw.intervalMinutes = parseAutoSyncInterval(raw.intervalMinutes);
|
|
124
|
+
}
|
|
125
|
+
else if (typeof raw.intervalMinutes !== 'number') {
|
|
126
|
+
throw new Error('intervalMinutes must be a number or a label like 15m, 2h, 24h.');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (raw.enabled === undefined && raw.intervalMinutes === undefined) {
|
|
130
|
+
throw new Error('Provide enabled, intervalMinutes, or both.');
|
|
131
|
+
}
|
|
132
|
+
return raw;
|
|
133
|
+
}
|
|
102
134
|
function parseSyncBody(body) {
|
|
103
135
|
if (body === undefined)
|
|
104
136
|
return {};
|
|
@@ -325,7 +357,7 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
325
357
|
response.writeHead(204).end();
|
|
326
358
|
return;
|
|
327
359
|
}
|
|
328
|
-
if (!UNAUTHENTICATED_PATHS.has(pathname) && !isAuthorized(request, options
|
|
360
|
+
if (!UNAUTHENTICATED_PATHS.has(pathname) && !isAuthorized(request, options)) {
|
|
329
361
|
// No WWW-Authenticate on purpose: MCP connector auto-detection treats a
|
|
330
362
|
// plain 401 as a bearer gate rather than an OAuth server.
|
|
331
363
|
json(response, 401, { error: { code: 'unauthorized', message: 'A valid Authorization: Bearer token is required.' } });
|
|
@@ -544,6 +576,34 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
544
576
|
}
|
|
545
577
|
return;
|
|
546
578
|
}
|
|
579
|
+
if (pathname === '/api/v1/sync/schedule' && request.method === 'GET') {
|
|
580
|
+
try {
|
|
581
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
582
|
+
const paths = await dashboardPaths(catalogPaths, staticPaths, url);
|
|
583
|
+
const athleteId = catalogPaths ? (url.searchParams.get('athleteId') ?? '') : 'local';
|
|
584
|
+
json(response, 200, await readAutoSyncSchedule(paths, athleteId));
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
json(response, 400, { error: { code: 'schedule_unavailable', message: error instanceof Error ? error.message : String(error) } });
|
|
588
|
+
}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (pathname === '/api/v1/sync/schedule' && request.method === 'PUT') {
|
|
592
|
+
try {
|
|
593
|
+
const body = parseSyncScheduleBody(await readJsonBody(request));
|
|
594
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
595
|
+
const paths = await resolveSyncPaths(catalogPaths, staticPaths, { athleteId: body.athleteId });
|
|
596
|
+
const athleteId = catalogPaths ? (body.athleteId ?? '') : 'local';
|
|
597
|
+
json(response, 200, await updateAutoSyncSchedule(paths, athleteId, {
|
|
598
|
+
enabled: body.enabled,
|
|
599
|
+
...(body.intervalMinutes === undefined ? {} : { intervalMinutes: parseAutoSyncInterval(String(body.intervalMinutes)) }),
|
|
600
|
+
}));
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
json(response, 400, { error: { code: 'schedule_update_failed', message: error instanceof Error ? error.message : String(error) } });
|
|
604
|
+
}
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
547
607
|
if (pathname === '/api/v1/models/discover' && request.method === 'POST') {
|
|
548
608
|
// Refresh the OpenCode Go profiles without starting a data sync. The
|
|
549
609
|
// Console's Models page calls this behind its authenticated proxy.
|
|
@@ -599,6 +659,35 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
599
659
|
}
|
|
600
660
|
return;
|
|
601
661
|
}
|
|
662
|
+
if (pathname === '/api/v1/mcp-exposure' && request.method === 'GET') {
|
|
663
|
+
if (!options.bearerTokenFile) {
|
|
664
|
+
json(response, 400, { error: { code: 'exposure_not_configured', message: 'This server was started without a bearer-token file; restart it with --auth-token-file to manage MCP exposure.' } });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
json(response, 200, mcpExposureStatus(options.bearerTokenFile, `http://${request.headers.host ?? '127.0.0.1:8787'}`));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (pathname === '/api/v1/mcp-exposure' && request.method === 'POST') {
|
|
671
|
+
if (!options.bearerTokenFile) {
|
|
672
|
+
json(response, 400, { error: { code: 'exposure_not_configured', message: 'This server was started without a bearer-token file; restart it with --auth-token-file to manage MCP exposure.' } });
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
const body = await readJsonBody(request);
|
|
677
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body) || !('enabled' in body) || typeof body.enabled !== 'boolean') {
|
|
678
|
+
throw new Error('Request body must be a JSON object with a boolean "enabled" field.');
|
|
679
|
+
}
|
|
680
|
+
if (body.enabled)
|
|
681
|
+
ensureBearerToken(options.bearerTokenFile);
|
|
682
|
+
else
|
|
683
|
+
disableBearerToken(options.bearerTokenFile);
|
|
684
|
+
json(response, 200, mcpExposureStatus(options.bearerTokenFile, `http://${request.headers.host ?? '127.0.0.1:8787'}`));
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
json(response, 400, { error: { code: 'exposure_update_failed', message: error instanceof Error ? error.message : String(error) } });
|
|
688
|
+
}
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
602
691
|
if (pathname === '/mcp') {
|
|
603
692
|
await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths());
|
|
604
693
|
return;
|
|
@@ -1,8 +1,8 @@
|
|
|
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, CatenceDatabase, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
|
|
5
|
-
import {
|
|
4
|
+
import { addAthlete, athleteStorePaths, CatenceDatabase, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths, startAutoSyncScheduler } from '../../runtime/index.js';
|
|
5
|
+
import { defaultBearerTokenPath, 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';
|
|
@@ -46,11 +46,13 @@ async function run() {
|
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
await migrateCatalogStores(options.home);
|
|
49
|
-
const
|
|
49
|
+
const catalogPaths = resolveCatalogPaths(options.home);
|
|
50
50
|
const httpServer = createCatenceHttpServer({
|
|
51
|
-
catalogPaths
|
|
51
|
+
catalogPaths,
|
|
52
52
|
allowedOrigins: options.allowedOrigins,
|
|
53
|
-
|
|
53
|
+
// The token file's presence switches authentication on; enable it with
|
|
54
|
+
// `catence-data mcp enable` (or the Console Settings / exposure API).
|
|
55
|
+
bearerTokenFile: options.authTokenFile ?? defaultBearerTokenPath(catalogPaths.root),
|
|
54
56
|
});
|
|
55
57
|
await new Promise((resolve, reject) => {
|
|
56
58
|
httpServer.once('error', reject);
|
|
@@ -60,9 +62,15 @@ async function run() {
|
|
|
60
62
|
});
|
|
61
63
|
});
|
|
62
64
|
console.error(`Catence Streamable HTTP server is listening at http://${options.host}:${options.port}/mcp`);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
console.error(`Bearer-token authentication is armed at ${options.authTokenFile ?? defaultBearerTokenPath(catalogPaths.root)}; enable it with 'catence-data mcp enable'. Health endpoints stay open for readiness probes.`);
|
|
66
|
+
// Auto-sync runs while the server is up: each athlete's autosync.json
|
|
67
|
+
// (default: enabled, every 2 hours) triggers a detached sync. The
|
|
68
|
+
// detached child and the single-writer DuckDB lock stay the only
|
|
69
|
+
// executors, so concurrent ticks are no-ops by design.
|
|
70
|
+
const scheduler = startAutoSyncScheduler({ catalogPaths });
|
|
71
|
+
console.error('Auto-sync scheduler started (default: enabled, every 2 hours per athlete; configure with auto-sync enable --interval or Console Settings).');
|
|
65
72
|
const close = () => {
|
|
73
|
+
scheduler.stop();
|
|
66
74
|
httpServer.close(() => process.exit(0));
|
|
67
75
|
};
|
|
68
76
|
process.once('SIGINT', close);
|
|
@@ -312,7 +312,17 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
312
312
|
}, tool('start_detached_sync', async (input) => {
|
|
313
313
|
const paths = activePaths();
|
|
314
314
|
const athleteId = activeAthlete()?.id ?? 'local';
|
|
315
|
-
const handle = await startSync({
|
|
315
|
+
const handle = await startSync({
|
|
316
|
+
paths,
|
|
317
|
+
athleteId,
|
|
318
|
+
provider: input.provider,
|
|
319
|
+
from: input.from,
|
|
320
|
+
refresh: input.refresh,
|
|
321
|
+
// The child resolves the athlete within the catalog, not within the
|
|
322
|
+
// athlete store this tool resolved for locks and logs: passing the
|
|
323
|
+
// store itself trips the legacy-store guard in catalog deployments.
|
|
324
|
+
...(catalogPaths ? { cliHome: catalogPaths.root } : {}),
|
|
325
|
+
});
|
|
316
326
|
return {
|
|
317
327
|
data: { ...handle, progressTool: 'catence_sync_progress' },
|
|
318
328
|
provenance: { operation: 'detached_sync_spawn' },
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaA
|
|
|
35
35
|
export type { ProviderChoice } from '../elt/application/management.js';
|
|
36
36
|
export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
|
|
37
37
|
export type { DetachedSyncHandle, DetachedSyncProvider, DetachedSyncRequest, DetachedSyncSpawner } from '../elt/application/detached-sync.js';
|
|
38
|
+
export { AUTO_SYNC_INTERVAL_MINUTES, DEFAULT_AUTO_SYNC_INTERVAL_MINUTES, parseAutoSyncInterval, readAutoSyncSchedule, startAutoSyncScheduler, updateAutoSyncSchedule, } from '../elt/application/autosync.js';
|
|
39
|
+
export type { AutoSyncConfig, AutoSyncIntervalMinutes, AutoSyncSchedule, AutoSyncScheduler, AutoSyncSchedulerOptions } from '../elt/application/autosync.js';
|
|
38
40
|
export { buildOpenCodeGoConsoleProfiles, classifyOpenCodeGoModel, fetchOpenCodeGoModelIds, mergeOpenCodeGoConsoleProfiles, OPENCODE_GO_API_BASE_ENV, OPENCODE_GO_API_KEY_ENV, OPENCODE_GO_DEFAULT_BASE_URL, OPENCODE_GO_MESSAGES_API_BASE_ENV, } from '../core/runtime/opencode-go.js';
|
|
39
41
|
export type { OpenCodeGoConsoleProfiles, OpenCodeGoMergeResult, OpenCodeGoRoute } from '../core/runtime/opencode-go.js';
|
|
40
42
|
export type { InterruptedRuns, SyncProgressSnapshot, SyncProgressState } from '../contracts/progress.js';
|
package/dist/runtime/index.js
CHANGED
|
@@ -19,6 +19,7 @@ export { queryReadOnlyData } from '../core/query/sql-guard.js';
|
|
|
19
19
|
export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
|
|
20
20
|
export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, reimportWeight, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
|
|
21
21
|
export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
|
|
22
|
+
export { AUTO_SYNC_INTERVAL_MINUTES, DEFAULT_AUTO_SYNC_INTERVAL_MINUTES, parseAutoSyncInterval, readAutoSyncSchedule, startAutoSyncScheduler, updateAutoSyncSchedule, } from '../elt/application/autosync.js';
|
|
22
23
|
export { buildOpenCodeGoConsoleProfiles, classifyOpenCodeGoModel, fetchOpenCodeGoModelIds, mergeOpenCodeGoConsoleProfiles, OPENCODE_GO_API_BASE_ENV, OPENCODE_GO_API_KEY_ENV, OPENCODE_GO_DEFAULT_BASE_URL, OPENCODE_GO_MESSAGES_API_BASE_ENV, } from '../core/runtime/opencode-go.js';
|
|
23
24
|
export { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
|
|
24
25
|
export { DataWriteBusyError } from '../elt/storage/write-lock.js';
|
package/docs/configuration.md
CHANGED
|
@@ -247,7 +247,7 @@ profiles and preserve existing ones, limits, and `defaultProfile` unless
|
|
|
247
247
|
| --- | --- |
|
|
248
248
|
| `CATENCE_HOME` | Catalog home (default `~/.catence`); equivalent to `--home` |
|
|
249
249
|
| `CATENCE_HTTP_HOST` / `CATENCE_HTTP_PORT` | Defaults for `catence serve --host/--port` (host `127.0.0.1`, port `8787`) |
|
|
250
|
-
| `CATENCE_MCP_TOKEN_FILE` |
|
|
250
|
+
| `CATENCE_MCP_TOKEN_FILE` | Overrides the bearer-token file path for `catence serve` (default `<catalog-home>/mcp-token`); the file's presence switches the gate on, managed with `catence-data mcp enable/disable` or the Console Settings |
|
|
251
251
|
|
|
252
252
|
Provider credentials are stored per athlete, either with `catence-data secret
|
|
253
253
|
set` (see [`local-mcp.md`](deployment/local-mcp.md#3-store-provider-credentials-stdin-only-never-shell-history))
|
|
@@ -509,30 +509,43 @@ claude mcp add --transport http catence -- http://<tailscale-ip>:8787/mcp
|
|
|
509
509
|
### Bearer-token authentication for remote MCP clients
|
|
510
510
|
|
|
511
511
|
To serve the MCP endpoint to a remote MCP client (for example a Mistral Work
|
|
512
|
-
custom connector) over a Cloudflare Tunnel,
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
read-only into the container at the same path:
|
|
512
|
+
custom connector) over a Cloudflare Tunnel, switch on the native
|
|
513
|
+
bearer-token gate. The deploy scaffold arms it at `/data/mcp-token` (inside
|
|
514
|
+
the data volume, so it survives rebuilds); the file's presence is the switch
|
|
515
|
+
and Catence generates the token itself:
|
|
517
516
|
|
|
518
517
|
```sh
|
|
519
|
-
#
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
518
|
+
# enable from the host (or use the Console Settings once logged in):
|
|
519
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env \
|
|
520
|
+
exec console catence-data --home /data mcp enable
|
|
521
|
+
|
|
522
|
+
# print the token for the client's credential flow:
|
|
523
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env \
|
|
524
|
+
exec console catence-data --home /data mcp token
|
|
525
|
+
|
|
526
|
+
# disable again:
|
|
527
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env \
|
|
528
|
+
exec console catence-data --home /data mcp disable
|
|
523
529
|
```
|
|
524
530
|
|
|
525
|
-
With the
|
|
531
|
+
With the gate enabled, every runtime endpoint except `/health` and
|
|
526
532
|
`/api/v1/health` answers `401` without an exact `Authorization: Bearer
|
|
527
|
-
<token>` header — including `/mcp` and the JSON APIs.
|
|
528
|
-
|
|
529
|
-
|
|
533
|
+
<token>` header — including `/mcp` and the JSON APIs. Toggling takes effect
|
|
534
|
+
without a restart; the Console attaches the header automatically to its own
|
|
535
|
+
runtime calls.
|
|
530
536
|
|
|
531
537
|
Keep `CATENCE_MCP_BIND=127.0.0.1` (the default): the tunnel client on the
|
|
532
538
|
host forwards to the host-loopback mapping, so the port still never binds a
|
|
533
539
|
LAN interface. Point the tunnel ingress for the MCP hostname at
|
|
534
540
|
`http://127.0.0.1:8787` and complete the client's credential flow with the
|
|
535
|
-
|
|
541
|
+
token from `mcp token`. Rotate with `mcp disable && mcp enable` and
|
|
542
|
+
re-authenticate the client. The only additional setup the tunnel needs is
|
|
543
|
+
the ingress rule for the MCP hostname.
|
|
544
|
+
|
|
545
|
+
A custom host-side token file is still possible: set
|
|
546
|
+
`CATENCE_MCP_TOKEN_FILE=<host path>` in `.env`. The file is then bind-mounted
|
|
547
|
+
read-only at the same path, so it must already exist on the host — enabling
|
|
548
|
+
from the Console cannot create it.
|
|
536
549
|
|
|
537
550
|
## Exposing the Console safely
|
|
538
551
|
|
|
@@ -306,26 +306,40 @@ override the host and port when the flags are omitted.
|
|
|
306
306
|
|
|
307
307
|
The server has no authentication of its own; when it must be reachable beyond
|
|
308
308
|
loopback (for example through a Cloudflare Tunnel to an MCP client such as
|
|
309
|
-
Mistral Work),
|
|
310
|
-
|
|
309
|
+
Mistral Work), switch on the bearer-token gate. The gate is armed at a token
|
|
310
|
+
file — `<catalog-home>/mcp-token` by default, or the path given by
|
|
311
|
+
`--auth-token-file` (environment equivalent: `CATENCE_MCP_TOKEN_FILE`) — and
|
|
312
|
+
the **file's presence is the switch**: create it to enable, remove it to
|
|
313
|
+
disable, no restart needed.
|
|
314
|
+
|
|
315
|
+
Catence generates the token itself; never hand-craft one:
|
|
311
316
|
|
|
312
317
|
```sh
|
|
313
|
-
catence
|
|
318
|
+
catence-data mcp status # enabled, token file, endpoint URLs
|
|
319
|
+
catence-data mcp enable # generate the token file (mode 0600) if missing
|
|
320
|
+
catence-data mcp token # print the token for the client credential flow
|
|
321
|
+
catence-data mcp disable # remove the file; remote access is refused again
|
|
314
322
|
```
|
|
315
323
|
|
|
316
|
-
- The file holds one token (whitespace-trimmed); generate one with
|
|
317
|
-
`openssl rand -hex 32`. An unreadable or empty file aborts startup.
|
|
318
324
|
- Every endpoint except `/health` and `/api/v1/health` (kept open for readiness
|
|
319
|
-
probes)
|
|
320
|
-
|
|
325
|
+
probes) answers `401` without an exact `Authorization: Bearer <token>`
|
|
326
|
+
header while the gate is on. The `401` deliberately carries no
|
|
321
327
|
`WWW-Authenticate`, so MCP clients with auth auto-detection (Mistral Work)
|
|
322
328
|
classify the server as bearer-auth rather than OAuth.
|
|
323
329
|
- CORS preflights (`OPTIONS`) pass through without a token.
|
|
324
|
-
-
|
|
330
|
+
- An existing but unreadable or empty token file fails closed: the gate
|
|
331
|
+
rejects everything except the health probes until the file is fixed or
|
|
332
|
+
removed.
|
|
333
|
+
- `GET /api/v1/mcp-exposure` reports the same status the Console Settings page
|
|
334
|
+
shows (including the token); `POST /api/v1/mcp-exposure` with
|
|
335
|
+
`{"enabled": true|false}` toggles the gate. The Console exposes both to
|
|
336
|
+
admins only.
|
|
337
|
+
- Rotation: `catence-data mcp disable && catence-data mcp enable` (or rewrite
|
|
338
|
+
the file), then re-authenticate the MCP client. Keep the token out of
|
|
325
339
|
`.env`, `config.json`, shell history, and repositories.
|
|
326
|
-
- The Console attaches the same header automatically to its runtime calls
|
|
327
|
-
|
|
328
|
-
|
|
340
|
+
- The Console attaches the same header automatically to its runtime calls and
|
|
341
|
+
reads the token file lazily, so enabling the gate from the Console or CLI
|
|
342
|
+
needs no Console restart either.
|
|
329
343
|
|
|
330
344
|
- `GET /api/v1/athletes` returns IDs and labels only.
|
|
331
345
|
- `POST /api/v1/athletes` creates a catalog athlete from
|
package/package.json
CHANGED
|
@@ -75,12 +75,32 @@ def main() -> None:
|
|
|
75
75
|
email = os.environ.get("GARMIN_EMAIL")
|
|
76
76
|
if not email:
|
|
77
77
|
raise SystemExit("GARMIN_EMAIL is required.")
|
|
78
|
-
password = os.environ.get("GARMIN_PASSWORD") or getpass.getpass("Garmin password: ")
|
|
79
|
-
writer = StagingWriter(args.data_dir, args.output)
|
|
80
|
-
writer.manifest(args.run_id, args.from_date.isoformat())
|
|
81
78
|
token_store = args.data_dir / "auth" / "garmin_tokens.json"
|
|
82
79
|
token_store.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
-
|
|
80
|
+
if not sys.stdin.isatty():
|
|
81
|
+
# Unattended auto-sync must never hang on a hidden prompt: fail fast
|
|
82
|
+
# so the run records a visible error instead.
|
|
83
|
+
password = os.environ.get("GARMIN_PASSWORD")
|
|
84
|
+
if not password and not token_store.exists():
|
|
85
|
+
raise SystemExit(
|
|
86
|
+
"Garmin login is required (no stored token, no GARMIN_PASSWORD, "
|
|
87
|
+
"and stdin is not interactive). Run an interactive sync once to "
|
|
88
|
+
"store the session token, or set GARMIN_PASSWORD."
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
password = os.environ.get("GARMIN_PASSWORD") or getpass.getpass("Garmin password: ")
|
|
92
|
+
|
|
93
|
+
def prompt_mfa() -> str:
|
|
94
|
+
if not sys.stdin.isatty():
|
|
95
|
+
raise SystemExit(
|
|
96
|
+
"Garmin MFA is required but stdin is not interactive; the "
|
|
97
|
+
"session token expired. Run an interactive sync once to renew it."
|
|
98
|
+
)
|
|
99
|
+
return input("Garmin MFA code: ")
|
|
100
|
+
|
|
101
|
+
writer = StagingWriter(args.data_dir, args.output)
|
|
102
|
+
writer.manifest(args.run_id, args.from_date.isoformat())
|
|
103
|
+
client = Garmin(email, password, prompt_mfa=prompt_mfa)
|
|
84
104
|
client.login(str(token_store))
|
|
85
105
|
reporter = ProgressReporter(args.run_id)
|
|
86
106
|
reporter.set_stage("login")
|