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.
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/query/repository.js +6 -4
- package/dist/core/runtime/opencode-go.d.ts +5 -2
- package/dist/core/runtime/opencode-go.js +51 -3
- package/dist/elt/application/management.js +1 -1
- package/dist/elt/storage/progress-sidecar.d.ts +1 -1
- package/dist/elt/storage/progress-sidecar.js +26 -6
- package/dist/interfaces/http/server.js +33 -4
- package/docs/deployment/local-console.md +9 -0
- package/docs/deployment/local-mcp.md +6 -1
- package/docs/llm-providers.md +9 -4
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.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.
|
|
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,
|
|
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
|
-
|
|
170
|
-
|
|
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.
|
|
46
|
-
*
|
|
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.
|
|
152
|
-
*
|
|
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, ...
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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 =
|
|
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:
|
|
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.
|
package/docs/llm-providers.md
CHANGED
|
@@ -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
|
|
204
|
-
|
|
205
|
-
|
|
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