catence 0.2.0-beta.18 → 0.2.0-beta.19

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.
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.18";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.19";
7
7
  export declare const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export type CatenceRuntimeCapabilities = {
9
9
  mcp: true;
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.18';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.19';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { SYNC_STAGES } from '../../contracts/progress.js';
4
- import { mergeProgress, readProgressSidecars } from '../../elt/storage/progress-sidecar.js';
4
+ import { mergeProgress, readRunningProgress } from '../../elt/storage/progress-sidecar.js';
5
5
  import { sqlString } from './sql.js';
6
6
  import { DATASET_CATALOG, getDataset } from './catalog.js';
7
7
  export class ReadOnlyRepository {
@@ -165,9 +165,11 @@ export class ReadOnlyRepository {
165
165
  LIMIT 10
166
166
  `);
167
167
  // Merge with sidecar heartbeat files so an active Garmin run stays visible
168
- // even while the same process holds the single-writer database lock.
169
- const sidecars = await readProgressSidecars(this.paths);
170
- return mergeProgress(running.map(toSyncProgressState), recent.map(toSyncProgressState), sidecars);
168
+ // even while the same process holds the single-writer database lock. The
169
+ // filtered reader guarantees a terminal or stale heartbeat can never read
170
+ // as an active run again, so a finished sync cannot block new ones.
171
+ const runningSidecars = await readRunningProgress(this.paths);
172
+ return mergeProgress(running.map(toSyncProgressState), recent.map(toSyncProgressState), runningSidecars);
171
173
  }
172
174
  async coverage() {
173
175
  const rows = await this.rows(`
@@ -42,8 +42,11 @@ export type OpenCodeGoMergeResult = {
42
42
  * Fetch the live OpenCode Go catalog and merge the two Console profiles into
43
43
  * `configPath`, preserving every other section plus existing console fields
44
44
  * (`limits`, other profiles) and the existing `defaultProfile` unless
45
- * `setDefault` is passed. The written file is re-validated with the same
46
- * strict schema the runtime applies on load, then replaced atomically.
45
+ * `setDefault` is passed. Model routing (`model`) is refreshed from the live
46
+ * catalog, while per-model customizations (custom label, `reasoningEffort`,
47
+ * `variants`) and models that disappeared upstream are kept. The written file
48
+ * is re-validated with the same strict schema the runtime applies on load,
49
+ * then replaced atomically.
47
50
  */
48
51
  export declare function mergeOpenCodeGoConsoleProfiles(options: {
49
52
  configPath: string;
@@ -144,12 +144,29 @@ async function readConfigRoot(configPath) {
144
144
  throw error;
145
145
  }
146
146
  }
147
+ /** Per-model fields a human may have customized after the initial discovery. */
148
+ function preservedModelFields(previous) {
149
+ if (typeof previous !== 'object' || previous === null || Array.isArray(previous))
150
+ return {};
151
+ const raw = previous;
152
+ const preserved = {};
153
+ if (typeof raw.label === 'string' && raw.label.trim())
154
+ preserved.label = raw.label;
155
+ if (typeof raw.reasoningEffort === 'string')
156
+ preserved.reasoningEffort = raw.reasoningEffort;
157
+ if (raw.variants !== undefined)
158
+ preserved.variants = raw.variants;
159
+ return preserved;
160
+ }
147
161
  /**
148
162
  * Fetch the live OpenCode Go catalog and merge the two Console profiles into
149
163
  * `configPath`, preserving every other section plus existing console fields
150
164
  * (`limits`, other profiles) and the existing `defaultProfile` unless
151
- * `setDefault` is passed. The written file is re-validated with the same
152
- * strict schema the runtime applies on load, then replaced atomically.
165
+ * `setDefault` is passed. Model routing (`model`) is refreshed from the live
166
+ * catalog, while per-model customizations (custom label, `reasoningEffort`,
167
+ * `variants`) and models that disappeared upstream are kept. The written file
168
+ * is re-validated with the same strict schema the runtime applies on load,
169
+ * then replaced atomically.
153
170
  */
154
171
  export async function mergeOpenCodeGoConsoleProfiles(options) {
155
172
  const baseUrl = options.baseUrl ?? OPENCODE_GO_DEFAULT_BASE_URL;
@@ -165,10 +182,41 @@ export async function mergeOpenCodeGoConsoleProfiles(options) {
165
182
  const defaultProfile = typeof existingConsole.defaultProfile === 'string' && existingConsole.defaultProfile && !options.setDefault
166
183
  ? existingConsole.defaultProfile
167
184
  : built.defaultProfile;
185
+ // Merge per model, not per profile: discovery owns routing and the id list,
186
+ // but a human's label, reasoningEffort, and variants must survive every
187
+ // re-discovery. Models that vanished upstream stay configured.
188
+ const mergedProfiles = {};
189
+ for (const [profileId, builtProfile] of Object.entries(built.profiles)) {
190
+ const previousProfile = existingProfiles[profileId];
191
+ const rawModels = typeof previousProfile === 'object' && previousProfile !== null
192
+ ? previousProfile.models
193
+ : undefined;
194
+ const previousModels = typeof rawModels === 'object' && rawModels !== null && !Array.isArray(rawModels)
195
+ ? rawModels
196
+ : {};
197
+ const models = { ...previousModels };
198
+ for (const [modelId, entry] of Object.entries(builtProfile.models)) {
199
+ models[modelId] = {
200
+ ...preservedModelFields(previousModels[modelId]),
201
+ label: entry.label,
202
+ model: entry.model,
203
+ };
204
+ }
205
+ const previousDefaultModel = typeof previousProfile === 'object' && previousProfile !== null
206
+ ? previousProfile.defaultModel
207
+ : undefined;
208
+ mergedProfiles[profileId] = {
209
+ ...builtProfile,
210
+ ...(typeof previousDefaultModel === 'string' && previousDefaultModel in models
211
+ ? { defaultModel: previousDefaultModel }
212
+ : {}),
213
+ models,
214
+ };
215
+ }
168
216
  const updatedConsole = {
169
217
  ...existingConsole,
170
218
  defaultProfile,
171
- profiles: { ...existingProfiles, ...built.profiles },
219
+ profiles: { ...existingProfiles, ...mergedProfiles },
172
220
  };
173
221
  // Validate before writing anything: the runtime rejects invalid configs at
174
222
  // load time, so a bad merge must never reach disk.
@@ -332,7 +332,7 @@ async function syncProvider(database, paths, provider, options, log) {
332
332
  }
333
333
  else {
334
334
  await database.finishRun(runId);
335
- pump.publishFinal(baseProgress(runId, provider, 'completed'));
335
+ await pump.publishFinal(baseProgress(runId, provider, 'completed'));
336
336
  }
337
337
  await removeProgressSidecar(paths, runId).catch(() => undefined);
338
338
  const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
@@ -56,6 +56,6 @@ export declare class ProgressPump {
56
56
  /** Transition the run to the JSONL import stage; keep-alives persist it. */
57
57
  setStageImporting(): void;
58
58
  /** Write a terminal heartbeat immediately, bypassing the publish throttle. */
59
- publishFinal(state: SyncProgressState): void;
59
+ publishFinal(state: SyncProgressState): Promise<void>;
60
60
  stop(): void;
61
61
  }
@@ -50,15 +50,33 @@ export async function readProgressSidecars(paths) {
50
50
  /** Active runs from sidecars: non-terminal stage and a fresh heartbeat. */
51
51
  export async function readRunningProgress(paths, staleAfterMs = PROGRESS_SIDECAR_STALE_MS) {
52
52
  const now = Date.now();
53
+ const directory = path.join(paths.staging, 'garmin');
54
+ if (!existsSync(directory))
55
+ return [];
56
+ const files = (await readdir(directory)).filter((file) => file.endsWith(PROGRESS_SIDECAR_SUFFIX));
53
57
  const running = [];
54
- for (const state of await readProgressSidecars(paths)) {
55
- if (TERMINAL_STAGES.has(state.stage))
58
+ const prunable = [];
59
+ for (const file of files) {
60
+ let state = null;
61
+ try {
62
+ state = normalizeProgress(JSON.parse(await readFile(path.join(directory, file), 'utf8')));
63
+ }
64
+ catch {
65
+ // Torn or partial sidecar write; it is not authoritative.
66
+ }
67
+ if (!state)
56
68
  continue;
57
69
  const ageMs = now - Date.parse(state.heartbeatAt);
58
- if (!Number.isFinite(ageMs) || ageMs > staleAfterMs)
70
+ if (TERMINAL_STAGES.has(state.stage) || !Number.isFinite(ageMs) || ageMs > staleAfterMs) {
71
+ // A terminal heartbeat that survived its own cleanup (a crash between
72
+ // the final publish and the sidecar removal) must never read as an
73
+ // active run again, so sweep it instead of leaving it behind forever.
74
+ prunable.push(file);
59
75
  continue;
76
+ }
60
77
  running.push(state);
61
78
  }
79
+ await Promise.all(prunable.map((file) => rm(path.join(directory, file), { force: true }).catch(() => undefined)));
62
80
  return running;
63
81
  }
64
82
  /**
@@ -159,14 +177,16 @@ export class ProgressPump {
159
177
  this.setStage('importing');
160
178
  }
161
179
  /** Write a terminal heartbeat immediately, bypassing the publish throttle. */
162
- publishFinal(state) {
180
+ async publishFinal(state) {
163
181
  this.lastHeartbeatAt = Date.now();
164
182
  this.lastProgress = state;
165
183
  const { database, paths, log } = this.options;
166
- void database.heartbeatRun(this.runId, state).catch((error) => {
184
+ // Both terminal writes complete before the caller removes the sidecar, so
185
+ // a finished run can never resurrect its own progress file after cleanup.
186
+ await database.heartbeatRun(this.runId, state).catch((error) => {
167
187
  log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
168
188
  });
169
- void writeProgressSidecar(paths, this.runId, state).catch((error) => {
189
+ await writeProgressSidecar(paths, this.runId, state).catch((error) => {
170
190
  log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
171
191
  });
172
192
  }
@@ -1,4 +1,5 @@
1
1
  import { createServer } from 'node:http';
2
+ import path from 'node:path';
2
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
4
  import { catenceRuntimeHealth, DashboardSnapshotService, dataStatus, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, startDetachedSync, syncProgress, } from '../../runtime/index.js';
4
5
  import { createCatenceMcpServer } from '../mcp/server.js';
@@ -122,14 +123,21 @@ async function resolveSyncPaths(catalogPaths, staticPaths, body) {
122
123
  }
123
124
  /** Best-effort last-success timestamps; null while a run holds the write lock. */
124
125
  async function lastSyncSummary(paths) {
126
+ const completedAtIso = (value) => {
127
+ if (value instanceof Date)
128
+ return Number.isNaN(value.getTime()) ? null : value.toISOString();
129
+ if (typeof value === 'string' && value.trim())
130
+ return value;
131
+ return null;
132
+ };
125
133
  try {
126
134
  const status = await dataStatus(paths);
127
135
  const runs = Array.isArray(status.syncRuns) ? status.syncRuns : [];
128
- const completed = runs.filter((run) => typeof run.completed_at === 'string' && String(run.status).startsWith('completed'));
136
+ const completed = runs.filter((run) => String(run.status ?? '').startsWith('completed') && completedAtIso(run.completed_at));
129
137
  const providers = {};
130
138
  let lastCompletedAt = null;
131
139
  for (const run of completed) {
132
- const completedAt = String(run.completed_at);
140
+ const completedAt = completedAtIso(run.completed_at);
133
141
  const provider = String(run.provider ?? 'unknown');
134
142
  if (!providers[provider] || providers[provider] < completedAt)
135
143
  providers[provider] = completedAt;
@@ -250,11 +258,14 @@ export function createCatenceHttpServer(options = {}) {
250
258
  const paths = await resolveSyncPaths(catalogPaths, staticPaths, body);
251
259
  // Optional model self-discovery runs before the data sync so new
252
260
  // OpenCode Go models appear without a manual script run. Failure is
253
- // non-fatal: it must never block or fail a data sync.
261
+ // non-fatal: it must never block or fail a data sync. The Console
262
+ // reads <CATENCE_HOME>/config.json, so catalog deployments merge
263
+ // into the catalog root's config instead of the athlete store.
264
+ const discoveryConfigPath = catalogPaths ? path.join(catalogPaths.root, 'config.json') : paths.config;
254
265
  let discoveryWarning = null;
255
266
  if (body.refreshModels) {
256
267
  try {
257
- await mergeModels({ configPath: paths.config, setDefault: false });
268
+ await mergeModels({ configPath: discoveryConfigPath, setDefault: false });
258
269
  }
259
270
  catch (error) {
260
271
  discoveryWarning = `OpenCode Go model discovery failed; profiles were left unchanged (${error instanceof Error ? error.message : String(error)}).`;
@@ -273,6 +284,24 @@ export function createCatenceHttpServer(options = {}) {
273
284
  }
274
285
  return;
275
286
  }
287
+ if (pathname === '/api/v1/models/discover' && request.method === 'POST') {
288
+ // Refresh the OpenCode Go profiles without starting a data sync. The
289
+ // Console's Models page calls this behind its authenticated proxy.
290
+ try {
291
+ const body = await readJsonBody(request).catch(() => ({}));
292
+ const raw = (body ?? {});
293
+ if (raw.setDefault !== undefined && typeof raw.setDefault !== 'boolean')
294
+ throw new Error('setDefault must be a boolean.');
295
+ const configPath = catalogPaths ? path.join(catalogPaths.root, 'config.json') : staticPaths?.config;
296
+ if (!configPath)
297
+ throw new Error('No Catence data store is configured.');
298
+ json(response, 200, jsonSafe(await mergeModels({ configPath, setDefault: raw.setDefault === true })));
299
+ }
300
+ catch (error) {
301
+ json(response, 400, { error: { code: 'model_discovery_failed', message: error instanceof Error ? error.message : String(error) } });
302
+ }
303
+ return;
304
+ }
276
305
  if (pathname === '/api/v1/sync/status' && request.method === 'GET') {
277
306
  try {
278
307
  const url = new URL(request.url ?? '/', 'http://localhost');
@@ -196,12 +196,21 @@ script fetches it and merges two ready-made profiles into `config.json`:
196
196
  `opencode-go` (chat + responses models, base `…/zen/go/v1`) and
197
197
  `opencode-go-messages` (messages models, base `…/zen/go`). Existing profiles,
198
198
  limits, and `defaultProfile` are preserved unless `--set-default` is passed.
199
+ Re-discovery refreshes each known model's routing reference but keeps your
200
+ custom label, `reasoningEffort`, and `variants`, and keeps models that have
201
+ disappeared from the live catalog.
199
202
 
200
203
  ```sh
201
204
  # Local (from a checkout)
202
205
  npm run discover:opencode-go -- --write ~/.catence/config.json
203
206
  ```
204
207
 
208
+ Inside the Console's Models page, **Discover OpenCode Go models** runs the
209
+ same merge for the current home without touching athlete data (runtime route
210
+ `POST /api/v1/models/discover`, proxied behind the Console login). Models can
211
+ also be edited in place there — the update action rewrites the `console`
212
+ section atomically and never stores secret values.
213
+
205
214
  Then set the credentials and verify:
206
215
 
207
216
  ```sh
@@ -313,7 +313,12 @@ override the host and port when the flags are omitted.
313
313
  - `GET /api/v1/sync/status?athleteId=alex` reports live progress heartbeats,
314
314
  recent runs, and best-effort last-completion timestamps per provider. It
315
315
  keeps working while a sync holds the write lock (`lastSync` becomes `null`
316
- then).
316
+ then). Finished runs are never reported as active, so a completed or
317
+ crashed run can never wedge the busy guard.
318
+ - `POST /api/v1/models/discover` refreshes the OpenCode Go profiles in
319
+ `<home>/config.json` without starting a data sync:
320
+ `{"setDefault": false}` (both fields optional). Returns the merged profile
321
+ ids, per-route counts, and any prefix-guessed routes.
317
322
  - Browser origins must be listed with `--allow-origin` (repeatable). The
318
323
  packaged Console instead proxies the dashboard through its authenticated
319
324
  same-origin route.
@@ -196,13 +196,18 @@ different API shapes:
196
196
 
197
197
  The script warns on stderr and skips any model that does not fit one of these
198
198
  families. Rerun it to refresh the model lists; existing profiles, limits, and
199
- `defaultProfile` are preserved unless `--set-default` is passed.
199
+ `defaultProfile` are preserved unless `--set-default` is passed. Re-running
200
+ never discards your own tweaks: per-model custom labels, `reasoningEffort`
201
+ pins, and `variants` maps survive every refresh, and models that have dropped
202
+ out of the live catalog stay configured.
200
203
 
201
204
  You do not have to run the script manually: the Console dashboard's **Sync
202
205
  data** button (and `POST /api/v1/sync` with `"refreshModels": true`) refreshes
203
- these profiles from the live catalog before starting a data sync. Discovery
204
- failures — offline machine, catalog unreachable — are reported as a warning and
205
- never block or fail the sync itself.
206
+ these profiles from the live catalog before starting a data sync, and the
207
+ Models page's **Discover OpenCode Go models** button (or
208
+ `POST /api/v1/models/discover`) refreshes them without syncing anything.
209
+ Discovery failures — offline machine, catalog unreachable — are reported as a
210
+ warning and never block or fail the sync itself.
206
211
 
207
212
  ```sh
208
213
  export OPENCODE_GO_API_KEY='…' # any non-empty value satisfies the key check
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.0-beta.18",
3
+ "version": "0.2.0-beta.19",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {