catence 0.2.0-beta.11 → 0.2.0-beta.12
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/progress.d.ts +1 -1
- package/dist/contracts/progress.js +1 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/elt/application/management.js +12 -30
- package/dist/elt/storage/progress-sidecar.d.ts +36 -1
- package/dist/elt/storage/progress-sidecar.js +95 -2
- package/docs/deployment/local-mcp.md +4 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare const STALE_RUN_TIMEOUT_MS: number;
|
|
2
|
-
export declare const SYNC_STAGES: readonly ["starting", "login", "singletons", "daily", "range", "ftp_history", "max_metrics", "hrv_history", "scores", "activities", "collections", "importing", "completed", "interrupted", "failed", "timed_out"];
|
|
2
|
+
export declare const SYNC_STAGES: readonly ["starting", "login", "singletons", "daily", "range", "ftp_history", "max_metrics", "hrv_history", "scores", "activities", "collections", "intervals", "importing", "completed", "interrupted", "failed", "timed_out"];
|
|
3
3
|
export type SyncStage = (typeof SYNC_STAGES)[number];
|
|
4
4
|
export interface SyncProgressState {
|
|
5
5
|
runId: string;
|
|
@@ -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.12";
|
|
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.12';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -15,7 +15,7 @@ import { IntervalsExtractor } from '../ingestion/providers/intervals.js';
|
|
|
15
15
|
import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUrl, syncStravaGear } from '../ingestion/providers/strava/service.js';
|
|
16
16
|
import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
|
|
17
17
|
import { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../storage/database.js';
|
|
18
|
-
import { mergeProgress, readRunningProgress, removeProgressSidecar
|
|
18
|
+
import { mergeProgress, ProgressPump, readRunningProgress, removeProgressSidecar } from '../storage/progress-sidecar.js';
|
|
19
19
|
import { withDataWriteLock } from '../storage/write-lock.js';
|
|
20
20
|
function packageRoot() {
|
|
21
21
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
@@ -33,7 +33,7 @@ function baseProgress(runId, provider, stage) {
|
|
|
33
33
|
* Progress NDJSON lines (kind 'progress') are normalized and persisted as
|
|
34
34
|
* throttled heartbeats; other output is forwarded to the sync logger.
|
|
35
35
|
*/
|
|
36
|
-
function runGarminWorker(runId, arguments_, environment,
|
|
36
|
+
function runGarminWorker(runId, arguments_, environment, pump, log, onInterruptReady) {
|
|
37
37
|
return new Promise((resolve) => {
|
|
38
38
|
const child = spawn('uv', arguments_, { ...environment, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
39
39
|
let killTimer;
|
|
@@ -48,24 +48,6 @@ function runGarminWorker(runId, arguments_, environment, database, paths, log, o
|
|
|
48
48
|
}, 10_000);
|
|
49
49
|
killTimer.unref();
|
|
50
50
|
});
|
|
51
|
-
let lastProgress = null;
|
|
52
|
-
let lastHeartbeatAt = 0;
|
|
53
|
-
const heartbeat = (state) => {
|
|
54
|
-
const now = Date.now();
|
|
55
|
-
if (now - lastHeartbeatAt < 2_000)
|
|
56
|
-
return;
|
|
57
|
-
lastHeartbeatAt = now;
|
|
58
|
-
void database.heartbeatRun(runId, state).catch((error) => {
|
|
59
|
-
log.debug('Progress heartbeat failed', { runId, error: error instanceof Error ? error.message : String(error) });
|
|
60
|
-
});
|
|
61
|
-
void writeProgressSidecar(paths, runId, state).catch((error) => {
|
|
62
|
-
log.debug('Progress sidecar write failed', { runId, error: error instanceof Error ? error.message : String(error) });
|
|
63
|
-
});
|
|
64
|
-
};
|
|
65
|
-
const fallback = setInterval(() => {
|
|
66
|
-
heartbeat(lastProgress ?? baseProgress(runId, 'garmin', 'starting'));
|
|
67
|
-
}, 30_000);
|
|
68
|
-
fallback.unref();
|
|
69
51
|
const buffer = (stream, sink) => {
|
|
70
52
|
let pending = '';
|
|
71
53
|
stream.on('data', (chunk) => {
|
|
@@ -95,29 +77,24 @@ function runGarminWorker(runId, arguments_, environment, database, paths, log, o
|
|
|
95
77
|
}
|
|
96
78
|
if (typeof parsed === 'object' && parsed !== null && parsed.kind === 'progress') {
|
|
97
79
|
const state = normalizeProgress(parsed);
|
|
98
|
-
if (state)
|
|
99
|
-
|
|
100
|
-
heartbeat(state);
|
|
101
|
-
}
|
|
80
|
+
if (state)
|
|
81
|
+
pump.publish(state);
|
|
102
82
|
return;
|
|
103
83
|
}
|
|
104
84
|
log.info(`[garmin] ${line}`);
|
|
105
85
|
});
|
|
106
86
|
buffer(child.stderr, (line) => log.error(`[garmin] ${line}`));
|
|
107
87
|
child.on('error', (error) => {
|
|
108
|
-
clearInterval(fallback);
|
|
109
88
|
if (killTimer)
|
|
110
89
|
clearTimeout(killTimer);
|
|
111
90
|
resolve({ code: 1, signal: null });
|
|
112
91
|
log.error('Garmin staging worker could not be started', { runId, error: error.message });
|
|
113
92
|
});
|
|
114
93
|
child.on('close', (code, signal) => {
|
|
115
|
-
clearInterval(fallback);
|
|
116
94
|
if (killTimer)
|
|
117
95
|
clearTimeout(killTimer);
|
|
118
|
-
if (code === 0
|
|
119
|
-
|
|
120
|
-
}
|
|
96
|
+
if (code === 0)
|
|
97
|
+
pump.setStageImporting();
|
|
121
98
|
resolve({ code, signal });
|
|
122
99
|
});
|
|
123
100
|
});
|
|
@@ -188,11 +165,14 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
188
165
|
});
|
|
189
166
|
let extractionStarted = false;
|
|
190
167
|
const guard = { interrupted: false };
|
|
168
|
+
const pump = new ProgressPump(runId, provider, { database, paths, log });
|
|
169
|
+
pump.start();
|
|
191
170
|
try {
|
|
192
171
|
if (provider === 'intervals') {
|
|
193
172
|
const environment = await athleteProviderEnvironment(paths);
|
|
194
173
|
const config = requireIntervalsConfig(environment);
|
|
195
174
|
extractionStarted = true;
|
|
175
|
+
pump.setStage('intervals');
|
|
196
176
|
await runWithInterruptGuard(runId, database, () => undefined, guard, async () => {
|
|
197
177
|
await new IntervalsExtractor(database, paths, config.apiKey, config.athleteId, log).sync(runId, activityWindow, !options.backfill || options.refreshActivities);
|
|
198
178
|
});
|
|
@@ -222,7 +202,7 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
222
202
|
const outcome = await runGarminWorker(runId, arguments_, {
|
|
223
203
|
cwd: packageRoot(),
|
|
224
204
|
env: { ...(await athleteProviderEnvironment(paths)), UV_PROJECT_ENVIRONMENT: process.env.UV_PROJECT_ENVIRONMENT ?? path.join(paths.root, 'python-venv') },
|
|
225
|
-
},
|
|
205
|
+
}, pump, log, (kill) => { interruptChild = kill; });
|
|
226
206
|
if (!guard.interrupted && outcome.code !== 0) {
|
|
227
207
|
throw new Error(`Garmin staging worker exited with code ${outcome.code}${outcome.signal ? ` (${outcome.signal})` : ''}.`);
|
|
228
208
|
}
|
|
@@ -243,12 +223,14 @@ async function syncProvider(database, paths, provider, options, log) {
|
|
|
243
223
|
}
|
|
244
224
|
}
|
|
245
225
|
finally {
|
|
226
|
+
pump.stop();
|
|
246
227
|
if (guard.interrupted) {
|
|
247
228
|
await database.markRunInterrupted(runId);
|
|
248
229
|
log.warn('Sync run interrupted', { runId, provider });
|
|
249
230
|
}
|
|
250
231
|
else {
|
|
251
232
|
await database.finishRun(runId);
|
|
233
|
+
pump.publishFinal(baseProgress(runId, provider, 'completed'));
|
|
252
234
|
}
|
|
253
235
|
await removeProgressSidecar(paths, runId).catch(() => undefined);
|
|
254
236
|
const [run] = await database.rows('SELECT status, error_count FROM sync_runs WHERE run_id = $runId', { runId });
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { type SyncProgressSnapshot, type SyncProgressState } from '../../contracts/progress.js';
|
|
1
|
+
import { type SyncProgressSnapshot, type SyncProgressState, type SyncStage } from '../../contracts/progress.js';
|
|
2
2
|
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
3
|
+
import type { SyncLogger } from '../../core/logging.js';
|
|
4
|
+
import type { CatenceDatabase } from './database.js';
|
|
3
5
|
export declare const PROGRESS_SIDECAR_SUFFIX = ".progress.json";
|
|
4
6
|
export declare const PROGRESS_SIDECAR_STALE_MS: number;
|
|
7
|
+
export declare const PROGRESS_KEEPALIVE_MS: number;
|
|
8
|
+
export declare const PROGRESS_PUBLISH_THROTTLE_MS: number;
|
|
5
9
|
export declare function progressSidecarPath(paths: CatencePaths, runId: string): string;
|
|
6
10
|
/**
|
|
7
11
|
* Persist one progress heartbeat as a sidecar file next to the Garmin staging
|
|
@@ -24,3 +28,34 @@ export declare function readRunningProgress(paths: CatencePaths, staleAfterMs?:
|
|
|
24
28
|
* both sources and sorted by heartbeat recency.
|
|
25
29
|
*/
|
|
26
30
|
export declare function mergeProgress(dbRunning: SyncProgressState[], dbRecent: SyncProgressState[], sidecars: SyncProgressState[]): SyncProgressSnapshot;
|
|
31
|
+
export interface ProgressPumpOptions {
|
|
32
|
+
database: CatenceDatabase;
|
|
33
|
+
paths: CatencePaths;
|
|
34
|
+
log: SyncLogger;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Drives progress heartbeats for the whole lifetime of a sync run - including
|
|
38
|
+
* phases that emit no worker output (the Intervals extractor and the Garmin
|
|
39
|
+
* JSONL import). `publish` throttles writes to the database and sidecar; a
|
|
40
|
+
* 30-second keep-alive interval refreshes the last known state so a run never
|
|
41
|
+
* looks stale while it is still active, even during the lock-holding import.
|
|
42
|
+
*/
|
|
43
|
+
export declare class ProgressPump {
|
|
44
|
+
private readonly runId;
|
|
45
|
+
private readonly provider;
|
|
46
|
+
private readonly options;
|
|
47
|
+
private lastProgress;
|
|
48
|
+
private lastHeartbeatAt;
|
|
49
|
+
private interval;
|
|
50
|
+
private stageOverride;
|
|
51
|
+
constructor(runId: string, provider: string, options: ProgressPumpOptions);
|
|
52
|
+
start(): void;
|
|
53
|
+
publish(state: SyncProgressState): void;
|
|
54
|
+
/** Force subsequent keep-alive heartbeats to report the given stage. */
|
|
55
|
+
setStage(stage: SyncStage): void;
|
|
56
|
+
/** Transition the run to the JSONL import stage; keep-alives persist it. */
|
|
57
|
+
setStageImporting(): void;
|
|
58
|
+
/** Write a terminal heartbeat immediately, bypassing the publish throttle. */
|
|
59
|
+
publishFinal(state: SyncProgressState): void;
|
|
60
|
+
stop(): void;
|
|
61
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { normalizeProgress } from '../../contracts/progress.js';
|
|
4
|
+
import { normalizeProgress, } from '../../contracts/progress.js';
|
|
5
5
|
export const PROGRESS_SIDECAR_SUFFIX = '.progress.json';
|
|
6
6
|
export const PROGRESS_SIDECAR_STALE_MS = 5 * 60 * 1000;
|
|
7
|
+
export const PROGRESS_KEEPALIVE_MS = 30 * 1000;
|
|
8
|
+
export const PROGRESS_PUBLISH_THROTTLE_MS = 2 * 1000;
|
|
7
9
|
const TERMINAL_STAGES = new Set(['completed', 'failed', 'interrupted', 'timed_out']);
|
|
8
10
|
export function progressSidecarPath(paths, runId) {
|
|
9
11
|
return path.join(paths.staging, 'garmin', `${runId}${PROGRESS_SIDECAR_SUFFIX}`);
|
|
@@ -16,7 +18,7 @@ export function progressSidecarPath(paths, runId) {
|
|
|
16
18
|
export async function writeProgressSidecar(paths, runId, state) {
|
|
17
19
|
const target = progressSidecarPath(paths, runId);
|
|
18
20
|
await mkdir(path.dirname(target), { recursive: true });
|
|
19
|
-
const temporary = `${target}.tmp`;
|
|
21
|
+
const temporary = `${target}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
20
22
|
await writeFile(temporary, JSON.stringify(state));
|
|
21
23
|
await rename(temporary, target);
|
|
22
24
|
}
|
|
@@ -84,4 +86,95 @@ export function mergeProgress(dbRunning, dbRecent, sidecars) {
|
|
|
84
86
|
.slice(0, 10),
|
|
85
87
|
};
|
|
86
88
|
}
|
|
89
|
+
function baselineState(runId, provider, stage) {
|
|
90
|
+
return {
|
|
91
|
+
runId,
|
|
92
|
+
provider,
|
|
93
|
+
stage,
|
|
94
|
+
currentStep: null,
|
|
95
|
+
completedUnits: 0,
|
|
96
|
+
totalUnits: null,
|
|
97
|
+
percentComplete: 0,
|
|
98
|
+
elapsedSeconds: 0,
|
|
99
|
+
estimatedRemainingSeconds: null,
|
|
100
|
+
heartbeatAt: new Date().toISOString(),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Drives progress heartbeats for the whole lifetime of a sync run - including
|
|
105
|
+
* phases that emit no worker output (the Intervals extractor and the Garmin
|
|
106
|
+
* JSONL import). `publish` throttles writes to the database and sidecar; a
|
|
107
|
+
* 30-second keep-alive interval refreshes the last known state so a run never
|
|
108
|
+
* looks stale while it is still active, even during the lock-holding import.
|
|
109
|
+
*/
|
|
110
|
+
export class ProgressPump {
|
|
111
|
+
runId;
|
|
112
|
+
provider;
|
|
113
|
+
options;
|
|
114
|
+
lastProgress = null;
|
|
115
|
+
lastHeartbeatAt = 0;
|
|
116
|
+
interval = null;
|
|
117
|
+
stageOverride = null;
|
|
118
|
+
constructor(runId, provider, options) {
|
|
119
|
+
this.runId = runId;
|
|
120
|
+
this.provider = provider;
|
|
121
|
+
this.options = options;
|
|
122
|
+
}
|
|
123
|
+
start() {
|
|
124
|
+
if (this.interval)
|
|
125
|
+
return;
|
|
126
|
+
this.interval = setInterval(() => {
|
|
127
|
+
this.publish(this.lastProgress
|
|
128
|
+
? { ...this.lastProgress, heartbeatAt: new Date().toISOString() }
|
|
129
|
+
: baselineState(this.runId, this.provider, this.stageOverride ?? 'starting'));
|
|
130
|
+
}, PROGRESS_KEEPALIVE_MS);
|
|
131
|
+
this.interval.unref();
|
|
132
|
+
}
|
|
133
|
+
publish(state) {
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
if (this.lastHeartbeatAt !== 0 && now - this.lastHeartbeatAt < PROGRESS_PUBLISH_THROTTLE_MS) {
|
|
136
|
+
this.lastProgress = state;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.lastHeartbeatAt = now;
|
|
140
|
+
this.lastProgress = state;
|
|
141
|
+
const { database, paths, log } = this.options;
|
|
142
|
+
void database.heartbeatRun(this.runId, state).catch((error) => {
|
|
143
|
+
log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
|
|
144
|
+
});
|
|
145
|
+
void writeProgressSidecar(paths, this.runId, state).catch((error) => {
|
|
146
|
+
log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/** Force subsequent keep-alive heartbeats to report the given stage. */
|
|
150
|
+
setStage(stage) {
|
|
151
|
+
this.stageOverride = stage;
|
|
152
|
+
if (this.lastProgress) {
|
|
153
|
+
this.lastProgress = { ...this.lastProgress, stage };
|
|
154
|
+
this.publish(this.lastProgress);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Transition the run to the JSONL import stage; keep-alives persist it. */
|
|
158
|
+
setStageImporting() {
|
|
159
|
+
this.setStage('importing');
|
|
160
|
+
}
|
|
161
|
+
/** Write a terminal heartbeat immediately, bypassing the publish throttle. */
|
|
162
|
+
publishFinal(state) {
|
|
163
|
+
this.lastHeartbeatAt = Date.now();
|
|
164
|
+
this.lastProgress = state;
|
|
165
|
+
const { database, paths, log } = this.options;
|
|
166
|
+
void database.heartbeatRun(this.runId, state).catch((error) => {
|
|
167
|
+
log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
|
|
168
|
+
});
|
|
169
|
+
void writeProgressSidecar(paths, this.runId, state).catch((error) => {
|
|
170
|
+
log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
stop() {
|
|
174
|
+
if (this.interval) {
|
|
175
|
+
clearInterval(this.interval);
|
|
176
|
+
this.interval = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
87
180
|
//# sourceMappingURL=progress-sidecar.js.map
|
|
@@ -209,6 +209,10 @@ for live runs and only touch the `sync_run_progress` table for recent-run
|
|
|
209
209
|
history once the lock is free — so they keep working while a backfill holds the
|
|
210
210
|
lock, and never crash with a lock error.
|
|
211
211
|
|
|
212
|
+
Progress also stays live through the entire run, not just worker extraction: a
|
|
213
|
+
30-second keep-alive covers the JSONL import phase (which emits no worker
|
|
214
|
+
output) and the `intervals` phase of a provider-all sync or backfill.
|
|
215
|
+
|
|
212
216
|
Other read-only tools (`status`, `query_read_only_data`, and friends) still
|
|
213
217
|
need the database and therefore return a clean `data_sync_in_progress` error
|
|
214
218
|
while a sync run is active. That is expected behavior: the store is locked
|
package/package.json
CHANGED