catence 0.2.0-beta.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -166
- package/config.example.json +5 -1
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/progress.d.ts +23 -0
- package/dist/contracts/progress.js +49 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/logging.d.ts +16 -0
- package/dist/core/logging.js +68 -0
- package/dist/core/query/activity-discovery.d.ts +2 -0
- package/dist/core/query/activity-discovery.js +1 -1
- package/dist/core/query/analytics.d.ts +10 -0
- package/dist/core/query/analytics.js +494 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +8 -4
- package/dist/core/query/events.d.ts +34 -0
- package/dist/core/query/events.js +121 -0
- package/dist/core/query/fitness.d.ts +4 -0
- package/dist/core/query/fitness.js +60 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +48 -0
- package/dist/core/query/swimming.js +2 -2
- package/dist/core/runtime/configuration.d.ts +3 -1
- package/dist/core/runtime/configuration.js +5 -1
- package/dist/core/runtime/opencode-go.d.ts +58 -0
- package/dist/core/runtime/opencode-go.js +246 -0
- package/dist/core/runtime/secrets.js +51 -13
- package/dist/elt/application/detached-sync.d.ts +50 -0
- package/dist/elt/application/detached-sync.js +99 -0
- package/dist/elt/application/management.d.ts +15 -0
- package/dist/elt/application/management.js +435 -19
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +5 -4
- package/dist/elt/ingestion/providers/intervals.d.ts +12 -1
- package/dist/elt/ingestion/providers/intervals.js +37 -2
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +19 -1
- package/dist/elt/ingestion/providers/strava/service.js +7 -1
- package/dist/elt/normalization/normalizers.js +207 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +184 -6
- package/dist/elt/storage/migrations.js +145 -1
- package/dist/elt/storage/progress-sidecar.d.ts +61 -0
- package/dist/elt/storage/progress-sidecar.js +200 -0
- package/dist/interfaces/cli/commands.js +94 -1
- package/dist/interfaces/http/generation-status.d.ts +19 -0
- package/dist/interfaces/http/generation-status.js +55 -0
- package/dist/interfaces/http/server.d.ts +7 -1
- package/dist/interfaces/http/server.js +184 -1
- package/dist/interfaces/mcp/main.js +33 -1
- package/dist/interfaces/mcp/server.d.ts +2 -1
- package/dist/interfaces/mcp/server.js +168 -45
- package/dist/runtime/index.d.ts +9 -2
- package/dist/runtime/index.js +6 -2
- package/docs/architecture.md +13 -2
- package/docs/assets/image.png +0 -0
- package/docs/configuration.md +243 -0
- package/docs/deployment/docker.md +528 -0
- package/docs/deployment/local-console.md +263 -0
- package/docs/deployment/local-mcp.md +329 -0
- package/docs/development.md +337 -0
- package/docs/distribution.md +45 -0
- package/docs/llm-providers.md +66 -1
- package/docs/mcp-activity-retrieval.md +90 -210
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +43 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/registry.py +2 -0
- package/python/catence/providers/garmin/streams.py +38 -0
- package/python/catence/providers/garmin/swim_backfill.py +83 -0
- package/python/catence/providers/garmin/worker.py +415 -12
- package/scripts/discover-opencode-go.mjs +36 -144
- package/uv.lock +3 -3
- package/docs/deployment.md +0 -338
|
@@ -2,8 +2,31 @@ import { DuckDBInstance } from '@duckdb/node-api';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { ReadOnlyRepository } from '../../core/query/repository.js';
|
|
5
|
+
import { SYNC_STAGES, STALE_RUN_TIMEOUT_MS } from '../../contracts/progress.js';
|
|
5
6
|
import { migrations } from './migrations.js';
|
|
6
7
|
import { json } from './sql.js';
|
|
8
|
+
/**
|
|
9
|
+
* DuckDB's node API throws "Cannot create values of type ANY" when a bound
|
|
10
|
+
* parameter resolves to an untyped value. JS `Date` objects surface as `ANY`
|
|
11
|
+
* (and are not auto-coerced the way strings are), so any date/timestamp
|
|
12
|
+
* parameter read back from the database and re-bound crashes the statement.
|
|
13
|
+
* Serializing dates to ISO strings keeps every parameter concretely typed.
|
|
14
|
+
*/
|
|
15
|
+
function serializeValue(value) {
|
|
16
|
+
if (value instanceof Date)
|
|
17
|
+
return value.toISOString();
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function serializeBindings(bindings) {
|
|
21
|
+
if (!bindings)
|
|
22
|
+
return bindings;
|
|
23
|
+
if (Array.isArray(bindings))
|
|
24
|
+
return bindings.map(serializeValue);
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const [key, value] of Object.entries(bindings))
|
|
27
|
+
out[key] = serializeValue(value);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
7
30
|
export class CatenceDatabase {
|
|
8
31
|
instance;
|
|
9
32
|
connection;
|
|
@@ -61,10 +84,10 @@ export class CatenceDatabase {
|
|
|
61
84
|
}
|
|
62
85
|
}
|
|
63
86
|
async run(sql, values) {
|
|
64
|
-
await this.connection.run(sql, values);
|
|
87
|
+
await this.connection.run(sql, serializeBindings(values));
|
|
65
88
|
}
|
|
66
89
|
async rows(sql, values) {
|
|
67
|
-
const result = await this.connection.runAndReadAll(sql, values);
|
|
90
|
+
const result = await this.connection.runAndReadAll(sql, serializeBindings(values));
|
|
68
91
|
return result.getRowObjectsJS();
|
|
69
92
|
}
|
|
70
93
|
async tableNames(query) {
|
|
@@ -81,11 +104,132 @@ export class CatenceDatabase {
|
|
|
81
104
|
await this.run(`UPDATE sync_runs SET status = CASE WHEN error_count > 0 THEN 'completed_with_errors' ELSE 'completed' END, completed_at = now() WHERE run_id = $runId`, { runId });
|
|
82
105
|
await this.run("UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context'");
|
|
83
106
|
}
|
|
107
|
+
async heartbeatRun(runId, progress) {
|
|
108
|
+
await this.run(`INSERT INTO sync_run_progress (run_id, provider, stage, current_step, completed_units, total_units, percent, elapsed_seconds, estimated_remaining_seconds, heartbeat_at, detail_json)
|
|
109
|
+
VALUES ($runId, $provider, $stage, $currentStep, $completedUnits, $totalUnits, $percent, $elapsedSeconds, $estimatedRemainingSeconds, now(), '{}')
|
|
110
|
+
ON CONFLICT (run_id) DO UPDATE SET
|
|
111
|
+
provider = EXCLUDED.provider,
|
|
112
|
+
stage = EXCLUDED.stage,
|
|
113
|
+
current_step = EXCLUDED.current_step,
|
|
114
|
+
completed_units = EXCLUDED.completed_units,
|
|
115
|
+
total_units = EXCLUDED.total_units,
|
|
116
|
+
percent = EXCLUDED.percent,
|
|
117
|
+
elapsed_seconds = EXCLUDED.elapsed_seconds,
|
|
118
|
+
estimated_remaining_seconds = EXCLUDED.estimated_remaining_seconds,
|
|
119
|
+
heartbeat_at = now(),
|
|
120
|
+
detail_json = '{}'`, {
|
|
121
|
+
runId,
|
|
122
|
+
provider: progress.provider,
|
|
123
|
+
stage: progress.stage,
|
|
124
|
+
currentStep: progress.currentStep,
|
|
125
|
+
completedUnits: progress.completedUnits,
|
|
126
|
+
totalUnits: progress.totalUnits,
|
|
127
|
+
percent: progress.percentComplete,
|
|
128
|
+
elapsedSeconds: progress.elapsedSeconds,
|
|
129
|
+
estimatedRemainingSeconds: progress.estimatedRemainingSeconds,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async markRunInterrupted(runId, stage = 'interrupted') {
|
|
133
|
+
await this.run("UPDATE sync_runs SET status = 'interrupted', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId });
|
|
134
|
+
await this.run('UPDATE sync_run_progress SET stage = $stage, heartbeat_at = now() WHERE run_id = $runId', { runId, stage });
|
|
135
|
+
}
|
|
136
|
+
async interruptStaleSyncRuns(timeoutMs = STALE_RUN_TIMEOUT_MS) {
|
|
137
|
+
const cutoff = new Date(Date.now() - timeoutMs).toISOString();
|
|
138
|
+
const stale = await this.rows(`SELECT run_id FROM sync_runs WHERE status = 'running' AND started_at < $cutoff`, { cutoff });
|
|
139
|
+
for (const row of stale) {
|
|
140
|
+
await this.run("UPDATE sync_runs SET status = 'timed_out', completed_at = now() WHERE run_id = $runId AND status = 'running'", { runId: row.run_id });
|
|
141
|
+
await this.run("UPDATE sync_run_progress SET stage = 'timed_out', heartbeat_at = now() WHERE run_id = $runId", { runId: row.run_id });
|
|
142
|
+
}
|
|
143
|
+
return { runIds: stale.map((row) => row.run_id) };
|
|
144
|
+
}
|
|
145
|
+
async syncProgress(timeoutMs = STALE_RUN_TIMEOUT_MS) {
|
|
146
|
+
// A run killed with its container (docker restart, OOM) keeps
|
|
147
|
+
// status='running' until something cleans it up, and that cleanup only
|
|
148
|
+
// runs inside a starting sync child — which the busy guard refuses to
|
|
149
|
+
// spawn while the row reads as active. Age-filtering here mirrors the
|
|
150
|
+
// sidecar contract: a heartbeat older than the stale timeout can never
|
|
151
|
+
// block a new run or disable the dashboard's Sync button forever.
|
|
152
|
+
const cutoff = new Date(Date.now() - timeoutMs).toISOString();
|
|
153
|
+
const running = await this.rows(`SELECT runs.run_id, runs.provider,
|
|
154
|
+
coalesce(progress.stage, 'starting') AS stage,
|
|
155
|
+
progress.current_step AS current_step,
|
|
156
|
+
coalesce(progress.completed_units, 0) AS completed_units,
|
|
157
|
+
progress.total_units AS total_units,
|
|
158
|
+
coalesce(progress.percent, 0) AS percent,
|
|
159
|
+
coalesce(progress.elapsed_seconds, 0) AS elapsed_seconds,
|
|
160
|
+
progress.estimated_remaining_seconds AS estimated_remaining_seconds,
|
|
161
|
+
cast(coalesce(progress.heartbeat_at, runs.started_at) AS VARCHAR) AS heartbeat_at
|
|
162
|
+
FROM sync_runs AS runs
|
|
163
|
+
LEFT JOIN sync_run_progress AS progress USING (run_id)
|
|
164
|
+
WHERE runs.status = 'running'
|
|
165
|
+
AND coalesce(progress.heartbeat_at, runs.started_at) > $cutoff
|
|
166
|
+
ORDER BY runs.started_at DESC`, { cutoff });
|
|
167
|
+
const recent = await this.rows(`SELECT progress.run_id, progress.provider, progress.stage, progress.current_step,
|
|
168
|
+
progress.completed_units, progress.total_units, progress.percent, progress.elapsed_seconds,
|
|
169
|
+
progress.estimated_remaining_seconds, cast(progress.heartbeat_at AS VARCHAR) AS heartbeat_at
|
|
170
|
+
FROM sync_run_progress AS progress
|
|
171
|
+
ORDER BY progress.heartbeat_at DESC
|
|
172
|
+
LIMIT 10`);
|
|
173
|
+
return { running: running.map(toSyncProgressState), recent: recent.map(toSyncProgressState) };
|
|
174
|
+
}
|
|
175
|
+
async hasCalendarEventOlderThan(provider, beforeIsoDate, entityType = 'event') {
|
|
176
|
+
// Coverage signal for the automatic calendar-events backfill: true once
|
|
177
|
+
// the store already holds an event older than the regular short window,
|
|
178
|
+
// meaning the one-time deep pull has landed and normal syncs can stay
|
|
179
|
+
// narrow. Athletes with genuinely no old events re-run the deep window;
|
|
180
|
+
// it costs one small paginated listing per sync.
|
|
181
|
+
const rows = await this.rows(`SELECT 1 FROM source_entities WHERE provider = $provider AND entity_type = $entityType AND occurred_on < $before LIMIT 1`, { provider, entityType, before: beforeIsoDate });
|
|
182
|
+
return rows.length > 0;
|
|
183
|
+
}
|
|
184
|
+
async deleteMissingCalendarEntities(provider, entityTypes, keepRemoteIds, minOccurredOn) {
|
|
185
|
+
// Calendar entities (joined races, scheduled workouts) are mutable and
|
|
186
|
+
// deletable upstream. Re-fetches upsert survivors via remote_id; this
|
|
187
|
+
// removes in-window rows absent from the latest authoritative listing so
|
|
188
|
+
// upstream deletions propagate instead of leaving ghosts forever. Rows
|
|
189
|
+
// without a date are preserved (they predate strict dating).
|
|
190
|
+
if (!entityTypes.length)
|
|
191
|
+
return;
|
|
192
|
+
const values = { provider, minOccurredOn };
|
|
193
|
+
const typePlaceholders = entityTypes.map((name, index) => {
|
|
194
|
+
values[`type${index}`] = name;
|
|
195
|
+
return `$type${index}`;
|
|
196
|
+
});
|
|
197
|
+
let keepClause = '';
|
|
198
|
+
if (keepRemoteIds.length) {
|
|
199
|
+
keepClause = ` AND remote_id NOT IN (${keepRemoteIds.map((_, index) => `$keep${index}`).join(', ')})`;
|
|
200
|
+
keepRemoteIds.forEach((id, index) => { values[`keep${index}`] = id; });
|
|
201
|
+
}
|
|
202
|
+
for (const table of ['source_entities', 'domain_entities']) {
|
|
203
|
+
await this.run(`DELETE FROM ${table} WHERE provider = $provider AND entity_type IN (${typePlaceholders}) AND occurred_on >= $minOccurredOn${keepClause}`, values);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
84
206
|
async addError(runId, provider, endpoint, remoteId, message, retryable) {
|
|
85
207
|
await this.run(`INSERT INTO normalization_errors (error_id, run_id, provider, endpoint, remote_id, message, retryable)
|
|
86
208
|
VALUES ($errorId, $runId, $provider, $endpoint, $remoteId, $message, $retryable)`, { errorId: randomUUID(), runId, provider, endpoint, remoteId, message, retryable });
|
|
87
209
|
await this.run('UPDATE sync_runs SET error_count = error_count + 1 WHERE run_id = $runId', { runId });
|
|
88
210
|
}
|
|
211
|
+
async resolveErrors(opts = {}) {
|
|
212
|
+
const clauses = ['resolved_at IS NULL'];
|
|
213
|
+
const values = {};
|
|
214
|
+
if (opts.runIds && opts.runIds.length > 0) {
|
|
215
|
+
const placeholders = opts.runIds.map((_, index) => `$runId${index}`).join(', ');
|
|
216
|
+
clauses.push(`run_id IN (${placeholders})`);
|
|
217
|
+
opts.runIds.forEach((runId, index) => { values[`runId${index}`] = runId; });
|
|
218
|
+
}
|
|
219
|
+
if (opts.provider) {
|
|
220
|
+
clauses.push('provider = $provider');
|
|
221
|
+
values.provider = opts.provider;
|
|
222
|
+
}
|
|
223
|
+
if (opts.before) {
|
|
224
|
+
clauses.push('created_at < $before');
|
|
225
|
+
values.before = opts.before;
|
|
226
|
+
}
|
|
227
|
+
if (!opts.all && clauses.length === 1) {
|
|
228
|
+
throw new Error('Refusing to resolve all unresolved errors without an explicit --all flag.');
|
|
229
|
+
}
|
|
230
|
+
const resolved = await this.rows(`UPDATE normalization_errors SET resolved_at = now() WHERE ${clauses.join(' AND ')} RETURNING error_id`, values);
|
|
231
|
+
return resolved.length;
|
|
232
|
+
}
|
|
89
233
|
async status() {
|
|
90
234
|
// @duckdb/node-api owns native statement state on the connection. Keep
|
|
91
235
|
// status reads sequential instead of issuing several native calls at once.
|
|
@@ -94,9 +238,11 @@ export class CatenceDatabase {
|
|
|
94
238
|
const rawObjects = await this.rows('SELECT count(*)::INTEGER AS count FROM raw_objects');
|
|
95
239
|
const activities = await this.rows('SELECT count(*)::INTEGER AS count FROM activities');
|
|
96
240
|
const streams = await this.rows('SELECT count(*)::INTEGER AS count FROM stream_manifest');
|
|
241
|
+
const errorDetails = await this.rows('SELECT run_id, provider, endpoint, remote_id, message, cast(created_at AS VARCHAR) AS created_at FROM normalization_errors WHERE resolved_at IS NULL ORDER BY created_at DESC LIMIT 100');
|
|
97
242
|
const cursors = await this.rows('SELECT provider, cursor_name, cast(covered_through_date AS VARCHAR) AS covered_through_date, cast(latest_source_date AS VARCHAR) AS latest_source_date, lookback_days, last_successful_run_id, cast(last_completed_at AS VARCHAR) AS last_completed_at, status FROM sync_cursors ORDER BY provider, cursor_name');
|
|
98
243
|
return {
|
|
99
244
|
runs, unresolvedErrors: errors[0]?.count ?? 0, rawObjects: rawObjects[0]?.count ?? 0, activities: activities[0]?.count ?? 0, streams: streams[0]?.count ?? 0,
|
|
245
|
+
errors: errorDetails,
|
|
100
246
|
cursors: cursors.map((cursor) => ({ ...cursor, next_from_date: subtractDays(cursor.covered_through_date, Number(cursor.lookback_days)) })),
|
|
101
247
|
};
|
|
102
248
|
}
|
|
@@ -164,6 +310,24 @@ export class CatenceDatabase {
|
|
|
164
310
|
const rows = await this.rows('SELECT remote_activity_id, summary_hash FROM activity_sync_state WHERE provider = $provider', { provider });
|
|
165
311
|
return Object.fromEntries(rows.map((row) => [row.remote_activity_id, row.summary_hash]));
|
|
166
312
|
}
|
|
313
|
+
async hasGarminLactateThresholdHistory() {
|
|
314
|
+
const rows = await this.rows("SELECT count(*) AS n FROM training_metric_observations WHERE observation_id LIKE 'garmin:lactate_threshold:daily:%'");
|
|
315
|
+
return (rows[0]?.n ?? 0) > 0;
|
|
316
|
+
}
|
|
317
|
+
async earliestActivityStartDate(provider) {
|
|
318
|
+
const rows = await this.rows(`SELECT min(cast(started_at_utc AS DATE)) AS earliest FROM activities activity
|
|
319
|
+
JOIN activity_sources source USING (activity_id) WHERE source.provider = $provider`, { provider });
|
|
320
|
+
return toIsoDate(rows[0]?.earliest ?? null);
|
|
321
|
+
}
|
|
322
|
+
async garminSwimActivitySources() {
|
|
323
|
+
const sports = ['lap_swimming', 'swimming', 'open_water_swimming', 'openwaterswimming'];
|
|
324
|
+
return this.rows(`SELECT source.remote_activity_id, cast(activity.started_at_utc AS VARCHAR) AS started_on
|
|
325
|
+
FROM activity_sources AS source
|
|
326
|
+
JOIN activities AS activity USING (activity_id)
|
|
327
|
+
WHERE source.provider = 'garmin'
|
|
328
|
+
AND lower(coalesce(activity.sport, '')) IN (${sports.map((sport) => `'${sport}'`).join(', ')})
|
|
329
|
+
ORDER BY activity.started_at_utc`);
|
|
330
|
+
}
|
|
167
331
|
async latestSourceDate(provider, cursorName) {
|
|
168
332
|
const observations = cursorName === 'daily'
|
|
169
333
|
? `SELECT max(metric_date) AS observed_date FROM daily_metrics WHERE provider = $provider
|
|
@@ -281,10 +445,6 @@ export class ReadOnlyCatenceDatabase {
|
|
|
281
445
|
}
|
|
282
446
|
}
|
|
283
447
|
}
|
|
284
|
-
/** Compose core read services with the DuckDB-backed, read-only adapter. */
|
|
285
|
-
export async function openReadOnlyRepository(paths) {
|
|
286
|
-
return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
|
|
287
|
-
}
|
|
288
448
|
export class ReadOnlyDatabaseError extends Error {
|
|
289
449
|
code;
|
|
290
450
|
constructor(code, message) {
|
|
@@ -293,4 +453,22 @@ export class ReadOnlyDatabaseError extends Error {
|
|
|
293
453
|
this.name = 'ReadOnlyDatabaseError';
|
|
294
454
|
}
|
|
295
455
|
}
|
|
456
|
+
/** Compose core read services with the DuckDB-backed, read-only adapter. */
|
|
457
|
+
export async function openReadOnlyRepository(paths) {
|
|
458
|
+
return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
|
|
459
|
+
}
|
|
460
|
+
function toSyncProgressState(row) {
|
|
461
|
+
return {
|
|
462
|
+
runId: row.run_id,
|
|
463
|
+
provider: row.provider,
|
|
464
|
+
stage: SYNC_STAGES.includes(row.stage) ? row.stage : 'starting',
|
|
465
|
+
currentStep: row.current_step,
|
|
466
|
+
completedUnits: Math.max(0, Number(row.completed_units) || 0),
|
|
467
|
+
totalUnits: row.total_units === null || row.total_units === undefined ? null : Math.max(0, Number(row.total_units) || 0),
|
|
468
|
+
percentComplete: Math.min(100, Math.max(0, Number(row.percent) || 0)),
|
|
469
|
+
elapsedSeconds: Math.max(0, Number(row.elapsed_seconds) || 0),
|
|
470
|
+
estimatedRemainingSeconds: row.estimated_remaining_seconds === null || row.estimated_remaining_seconds === undefined ? null : Math.max(0, Number(row.estimated_remaining_seconds) || 0),
|
|
471
|
+
heartbeatAt: row.heartbeat_at,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
296
474
|
//# sourceMappingURL=database.js.map
|
|
@@ -828,5 +828,149 @@ const swimFactsAndQualityMigration = {
|
|
|
828
828
|
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
829
829
|
`,
|
|
830
830
|
};
|
|
831
|
-
|
|
831
|
+
const syncRunProgressMigration = {
|
|
832
|
+
version: 14,
|
|
833
|
+
name: 'sync_run_progress_heartbeats',
|
|
834
|
+
sql: `
|
|
835
|
+
-- Live progress heartbeats for in-flight sync runs. Written by the Node
|
|
836
|
+
-- side from progress records streamed by provider workers; read-only
|
|
837
|
+
-- surfaces (status, progress CLI, MCP) join against sync_runs.
|
|
838
|
+
CREATE TABLE IF NOT EXISTS sync_run_progress (
|
|
839
|
+
run_id VARCHAR PRIMARY KEY,
|
|
840
|
+
provider VARCHAR NOT NULL,
|
|
841
|
+
stage VARCHAR NOT NULL,
|
|
842
|
+
current_step VARCHAR,
|
|
843
|
+
completed_units INTEGER NOT NULL DEFAULT 0,
|
|
844
|
+
total_units INTEGER,
|
|
845
|
+
percent DOUBLE NOT NULL DEFAULT 0,
|
|
846
|
+
elapsed_seconds DOUBLE NOT NULL DEFAULT 0,
|
|
847
|
+
estimated_remaining_seconds DOUBLE,
|
|
848
|
+
heartbeat_at TIMESTAMPTZ NOT NULL,
|
|
849
|
+
detail_json JSON NOT NULL DEFAULT '{}'
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
853
|
+
`,
|
|
854
|
+
};
|
|
855
|
+
const canonicalTrainingDerivedAvgPowerMigration = {
|
|
856
|
+
version: 15,
|
|
857
|
+
name: 'canonical_training_derived_avg_power_w',
|
|
858
|
+
sql: `
|
|
859
|
+
-- A2: Stop "NULL avg_power => no power" inference without rewriting provenance.
|
|
860
|
+
-- activity_summaries.avg_power is sparse (NULL for many 2024 running
|
|
861
|
+
-- activities) while authoritative Garmin FIT-derived power is present as
|
|
862
|
+
-- per-duration rows in activity_power_bests (exposed as power_best_facts).
|
|
863
|
+
-- Overwriting the summary value would conflate two layers and break the
|
|
864
|
+
-- provenance contract (summary vs FIT-derived). Instead, extend the
|
|
865
|
+
-- canonical view with a derived column that aggregates the FIT signal:
|
|
866
|
+
-- derived_avg_power_w is the per-activity mean of best_power_w across
|
|
867
|
+
-- all stored durations. Callers should prefer
|
|
868
|
+
-- coalesce(avg_power, derived_avg_power_w) or use power_coverage_report /
|
|
869
|
+
-- read_series on activity_samples for the full signal. NULL remains NULL
|
|
870
|
+
-- when no FIT bests exist for that activity, making absence explicit.
|
|
871
|
+
-- Idempotent via CREATE OR REPLACE VIEW; no table mutation.
|
|
872
|
+
-- Older test fixtures (v9) created activity_summaries with only a subset
|
|
873
|
+
-- of columns; ensure the full canonical set exists so this view's
|
|
874
|
+
-- intervals_analysis (which unconditionally references training_load etc.)
|
|
875
|
+
-- does not throw Binder Error on upgrade.
|
|
876
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS elevation_gain_m DOUBLE;
|
|
877
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS calories DOUBLE;
|
|
878
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS max_hr DOUBLE;
|
|
879
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS avg_power DOUBLE;
|
|
880
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS weighted_power DOUBLE;
|
|
881
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS avg_cadence DOUBLE;
|
|
882
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS training_load DOUBLE;
|
|
883
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS rpe DOUBLE;
|
|
884
|
+
ALTER TABLE activity_summaries ADD COLUMN IF NOT EXISTS feel DOUBLE;
|
|
885
|
+
CREATE OR REPLACE VIEW canonical_activity_training AS
|
|
886
|
+
WITH ranked AS (
|
|
887
|
+
SELECT a.*, source.activity_source_id, source.provider, source.remote_activity_id, source.raw_object_hash,
|
|
888
|
+
summary.*, row_number() OVER (
|
|
889
|
+
PARTITION BY a.activity_id
|
|
890
|
+
ORDER BY CASE source.provider WHEN 'garmin' THEN 0 WHEN 'intervals' THEN 1 ELSE 2 END
|
|
891
|
+
) AS source_rank
|
|
892
|
+
FROM activities a
|
|
893
|
+
JOIN activity_sources source USING (activity_id)
|
|
894
|
+
LEFT JOIN activity_summaries summary USING (activity_source_id)
|
|
895
|
+
), intervals_analysis AS (
|
|
896
|
+
SELECT source.activity_id,
|
|
897
|
+
max(summary.training_load) AS intervals_training_load,
|
|
898
|
+
max(summary.rpe) AS intervals_rpe,
|
|
899
|
+
max(summary.feel) AS intervals_feel,
|
|
900
|
+
max(summary.weighted_power) AS intervals_weighted_power
|
|
901
|
+
FROM activity_sources source
|
|
902
|
+
JOIN activity_summaries summary USING (activity_source_id)
|
|
903
|
+
WHERE source.provider = 'intervals'
|
|
904
|
+
GROUP BY source.activity_id
|
|
905
|
+
), derived_power AS (
|
|
906
|
+
SELECT activity_source_id, avg(best_power_w) AS derived_avg_power_w
|
|
907
|
+
FROM activity_power_bests
|
|
908
|
+
GROUP BY activity_source_id
|
|
909
|
+
)
|
|
910
|
+
SELECT ranked.* EXCLUDE (source_rank),
|
|
911
|
+
intervals_analysis.intervals_training_load,
|
|
912
|
+
intervals_analysis.intervals_rpe,
|
|
913
|
+
intervals_analysis.intervals_feel,
|
|
914
|
+
intervals_analysis.intervals_weighted_power,
|
|
915
|
+
derived_power.derived_avg_power_w
|
|
916
|
+
FROM ranked
|
|
917
|
+
LEFT JOIN intervals_analysis USING (activity_id)
|
|
918
|
+
LEFT JOIN derived_power USING (activity_source_id)
|
|
919
|
+
WHERE source_rank = 1;
|
|
920
|
+
|
|
921
|
+
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
922
|
+
`,
|
|
923
|
+
};
|
|
924
|
+
const courseGeometryMigration = {
|
|
925
|
+
version: 16,
|
|
926
|
+
name: 'course_geometry',
|
|
927
|
+
sql: `
|
|
928
|
+
-- A4: Garmin course/route geometry so a race's elevation/grade profile can be
|
|
929
|
+
-- resolved and compared against a prior activity's course. garminconnect
|
|
930
|
+
-- 0.3.x has no dedicated course method, so courses are staged from the raw
|
|
931
|
+
-- Connect API (connectapi("course-service/course/{id}"), validated live:
|
|
932
|
+
-- returns metadata + a geoPoints geometry array) and normalized here.
|
|
933
|
+
-- courseId is the Garmin course identifier referenced by an event's courseId.
|
|
934
|
+
CREATE TABLE IF NOT EXISTS course_geometry (
|
|
935
|
+
course_id VARCHAR NOT NULL,
|
|
936
|
+
seq INTEGER NOT NULL,
|
|
937
|
+
lat DOUBLE,
|
|
938
|
+
lon DOUBLE,
|
|
939
|
+
altitude_m DOUBLE,
|
|
940
|
+
distance_m DOUBLE,
|
|
941
|
+
raw_object_hash VARCHAR,
|
|
942
|
+
PRIMARY KEY (course_id, seq)
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
946
|
+
`,
|
|
947
|
+
};
|
|
948
|
+
const activityDecouplingFactsMigration = {
|
|
949
|
+
version: 17,
|
|
950
|
+
name: 'activity_decoupling_facts',
|
|
951
|
+
sql: `
|
|
952
|
+
-- A3 (persistence): make aerobic decoupling (Pa:Hr) and grade-adjusted pace
|
|
953
|
+
-- (GAP) queryable historically instead of only inside one activity's raw
|
|
954
|
+
-- metrics_json. Rows are provider-supplied when the provider payload carries
|
|
955
|
+
-- them (source_type 'provider'); locally derived values are tagged 'derived'
|
|
956
|
+
-- with caveats_json. Provider values remain authoritative and are never
|
|
957
|
+
-- overwritten by derivation.
|
|
958
|
+
CREATE TABLE IF NOT EXISTS activity_decoupling_facts (
|
|
959
|
+
provider VARCHAR NOT NULL,
|
|
960
|
+
activity_source_id VARCHAR NOT NULL,
|
|
961
|
+
sport VARCHAR,
|
|
962
|
+
started_at_utc TIMESTAMPTZ,
|
|
963
|
+
metric VARCHAR NOT NULL,
|
|
964
|
+
value_number DOUBLE,
|
|
965
|
+
unit VARCHAR,
|
|
966
|
+
source_type VARCHAR NOT NULL,
|
|
967
|
+
caveats_json JSON,
|
|
968
|
+
raw_object_hash VARCHAR,
|
|
969
|
+
PRIMARY KEY (provider, activity_source_id, metric)
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
|
|
973
|
+
`,
|
|
974
|
+
};
|
|
975
|
+
export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration, syncRunProgressMigration, canonicalTrainingDerivedAvgPowerMigration, courseGeometryMigration, activityDecouplingFactsMigration];
|
|
832
976
|
//# sourceMappingURL=migrations.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type SyncProgressSnapshot, type SyncProgressState, type SyncStage } from '../../contracts/progress.js';
|
|
2
|
+
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
3
|
+
import type { SyncLogger } from '../../core/logging.js';
|
|
4
|
+
import type { CatenceDatabase } from './database.js';
|
|
5
|
+
export declare const PROGRESS_SIDECAR_SUFFIX = ".progress.json";
|
|
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;
|
|
9
|
+
export declare function progressSidecarPath(paths: CatencePaths, runId: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Persist one progress heartbeat as a sidecar file next to the Garmin staging
|
|
12
|
+
* manifest. Reading this file needs no DuckDB lock, so `progress` stays live
|
|
13
|
+
* while a sync run holds the single-writer database lock.
|
|
14
|
+
*/
|
|
15
|
+
export declare function writeProgressSidecar(paths: CatencePaths, runId: string, state: SyncProgressState): Promise<void>;
|
|
16
|
+
export declare function removeProgressSidecar(paths: CatencePaths, runId: string): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Scan every persisted sidecar heartbeat for an athlete store. Corrupt or
|
|
19
|
+
* unreadable files are skipped so a torn write can never break progress reads.
|
|
20
|
+
*/
|
|
21
|
+
export declare function readProgressSidecars(paths: CatencePaths): Promise<SyncProgressState[]>;
|
|
22
|
+
/** Active runs from sidecars: non-terminal stage and a fresh heartbeat. */
|
|
23
|
+
export declare function readRunningProgress(paths: CatencePaths, staleAfterMs?: number): Promise<SyncProgressState[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Merge database-backed progress with sidecar heartbeats. Sidecars win for a
|
|
26
|
+
* given run (they are freshest); database rows fill runs that never emitted a
|
|
27
|
+
* sidecar (for example Intervals runs). Recent history is deduplicated across
|
|
28
|
+
* both sources and sorted by heartbeat recency.
|
|
29
|
+
*/
|
|
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): Promise<void>;
|
|
60
|
+
stop(): void;
|
|
61
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { normalizeProgress, } from '../../contracts/progress.js';
|
|
5
|
+
export const PROGRESS_SIDECAR_SUFFIX = '.progress.json';
|
|
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;
|
|
9
|
+
const TERMINAL_STAGES = new Set(['completed', 'failed', 'interrupted', 'timed_out']);
|
|
10
|
+
export function progressSidecarPath(paths, runId) {
|
|
11
|
+
return path.join(paths.staging, 'garmin', `${runId}${PROGRESS_SIDECAR_SUFFIX}`);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Persist one progress heartbeat as a sidecar file next to the Garmin staging
|
|
15
|
+
* manifest. Reading this file needs no DuckDB lock, so `progress` stays live
|
|
16
|
+
* while a sync run holds the single-writer database lock.
|
|
17
|
+
*/
|
|
18
|
+
export async function writeProgressSidecar(paths, runId, state) {
|
|
19
|
+
const target = progressSidecarPath(paths, runId);
|
|
20
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
21
|
+
const temporary = `${target}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
22
|
+
await writeFile(temporary, JSON.stringify(state));
|
|
23
|
+
await rename(temporary, target);
|
|
24
|
+
}
|
|
25
|
+
export async function removeProgressSidecar(paths, runId) {
|
|
26
|
+
await rm(progressSidecarPath(paths, runId), { force: true });
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Scan every persisted sidecar heartbeat for an athlete store. Corrupt or
|
|
30
|
+
* unreadable files are skipped so a torn write can never break progress reads.
|
|
31
|
+
*/
|
|
32
|
+
export async function readProgressSidecars(paths) {
|
|
33
|
+
const directory = path.join(paths.staging, 'garmin');
|
|
34
|
+
if (!existsSync(directory))
|
|
35
|
+
return [];
|
|
36
|
+
const files = (await readdir(directory)).filter((file) => file.endsWith(PROGRESS_SIDECAR_SUFFIX));
|
|
37
|
+
const states = [];
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
try {
|
|
40
|
+
const state = normalizeProgress(JSON.parse(await readFile(path.join(directory, file), 'utf8')));
|
|
41
|
+
if (state)
|
|
42
|
+
states.push(state);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Torn or partial sidecar write; it is not authoritative.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return states;
|
|
49
|
+
}
|
|
50
|
+
/** Active runs from sidecars: non-terminal stage and a fresh heartbeat. */
|
|
51
|
+
export async function readRunningProgress(paths, staleAfterMs = PROGRESS_SIDECAR_STALE_MS) {
|
|
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));
|
|
57
|
+
const running = [];
|
|
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)
|
|
68
|
+
continue;
|
|
69
|
+
const ageMs = now - Date.parse(state.heartbeatAt);
|
|
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);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
running.push(state);
|
|
78
|
+
}
|
|
79
|
+
await Promise.all(prunable.map((file) => rm(path.join(directory, file), { force: true }).catch(() => undefined)));
|
|
80
|
+
return running;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Merge database-backed progress with sidecar heartbeats. Sidecars win for a
|
|
84
|
+
* given run (they are freshest); database rows fill runs that never emitted a
|
|
85
|
+
* sidecar (for example Intervals runs). Recent history is deduplicated across
|
|
86
|
+
* both sources and sorted by heartbeat recency.
|
|
87
|
+
*/
|
|
88
|
+
export function mergeProgress(dbRunning, dbRecent, sidecars) {
|
|
89
|
+
const running = new Map();
|
|
90
|
+
for (const state of sidecars)
|
|
91
|
+
running.set(state.runId, state);
|
|
92
|
+
for (const state of dbRunning)
|
|
93
|
+
if (!running.has(state.runId))
|
|
94
|
+
running.set(state.runId, state);
|
|
95
|
+
const recent = new Map();
|
|
96
|
+
for (const state of dbRecent)
|
|
97
|
+
recent.set(state.runId, state);
|
|
98
|
+
for (const state of sidecars)
|
|
99
|
+
recent.set(state.runId, state);
|
|
100
|
+
return {
|
|
101
|
+
running: [...running.values()],
|
|
102
|
+
recent: [...recent.values()]
|
|
103
|
+
.sort((a, b) => Date.parse(b.heartbeatAt) - Date.parse(a.heartbeatAt))
|
|
104
|
+
.slice(0, 10),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function baselineState(runId, provider, stage) {
|
|
108
|
+
return {
|
|
109
|
+
runId,
|
|
110
|
+
provider,
|
|
111
|
+
stage,
|
|
112
|
+
currentStep: null,
|
|
113
|
+
completedUnits: 0,
|
|
114
|
+
totalUnits: null,
|
|
115
|
+
percentComplete: 0,
|
|
116
|
+
elapsedSeconds: 0,
|
|
117
|
+
estimatedRemainingSeconds: null,
|
|
118
|
+
heartbeatAt: new Date().toISOString(),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Drives progress heartbeats for the whole lifetime of a sync run - including
|
|
123
|
+
* phases that emit no worker output (the Intervals extractor and the Garmin
|
|
124
|
+
* JSONL import). `publish` throttles writes to the database and sidecar; a
|
|
125
|
+
* 30-second keep-alive interval refreshes the last known state so a run never
|
|
126
|
+
* looks stale while it is still active, even during the lock-holding import.
|
|
127
|
+
*/
|
|
128
|
+
export class ProgressPump {
|
|
129
|
+
runId;
|
|
130
|
+
provider;
|
|
131
|
+
options;
|
|
132
|
+
lastProgress = null;
|
|
133
|
+
lastHeartbeatAt = 0;
|
|
134
|
+
interval = null;
|
|
135
|
+
stageOverride = null;
|
|
136
|
+
constructor(runId, provider, options) {
|
|
137
|
+
this.runId = runId;
|
|
138
|
+
this.provider = provider;
|
|
139
|
+
this.options = options;
|
|
140
|
+
}
|
|
141
|
+
start() {
|
|
142
|
+
if (this.interval)
|
|
143
|
+
return;
|
|
144
|
+
this.interval = setInterval(() => {
|
|
145
|
+
this.publish(this.lastProgress
|
|
146
|
+
? { ...this.lastProgress, heartbeatAt: new Date().toISOString() }
|
|
147
|
+
: baselineState(this.runId, this.provider, this.stageOverride ?? 'starting'));
|
|
148
|
+
}, PROGRESS_KEEPALIVE_MS);
|
|
149
|
+
this.interval.unref();
|
|
150
|
+
}
|
|
151
|
+
publish(state) {
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
if (this.lastHeartbeatAt !== 0 && now - this.lastHeartbeatAt < PROGRESS_PUBLISH_THROTTLE_MS) {
|
|
154
|
+
this.lastProgress = state;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.lastHeartbeatAt = now;
|
|
158
|
+
this.lastProgress = state;
|
|
159
|
+
const { database, paths, log } = this.options;
|
|
160
|
+
void database.heartbeatRun(this.runId, state).catch((error) => {
|
|
161
|
+
log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
|
|
162
|
+
});
|
|
163
|
+
void writeProgressSidecar(paths, this.runId, state).catch((error) => {
|
|
164
|
+
log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/** Force subsequent keep-alive heartbeats to report the given stage. */
|
|
168
|
+
setStage(stage) {
|
|
169
|
+
this.stageOverride = stage;
|
|
170
|
+
if (this.lastProgress) {
|
|
171
|
+
this.lastProgress = { ...this.lastProgress, stage };
|
|
172
|
+
this.publish(this.lastProgress);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Transition the run to the JSONL import stage; keep-alives persist it. */
|
|
176
|
+
setStageImporting() {
|
|
177
|
+
this.setStage('importing');
|
|
178
|
+
}
|
|
179
|
+
/** Write a terminal heartbeat immediately, bypassing the publish throttle. */
|
|
180
|
+
async publishFinal(state) {
|
|
181
|
+
this.lastHeartbeatAt = Date.now();
|
|
182
|
+
this.lastProgress = state;
|
|
183
|
+
const { database, paths, log } = this.options;
|
|
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) => {
|
|
187
|
+
log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
|
|
188
|
+
});
|
|
189
|
+
await writeProgressSidecar(paths, this.runId, state).catch((error) => {
|
|
190
|
+
log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
stop() {
|
|
194
|
+
if (this.interval) {
|
|
195
|
+
clearInterval(this.interval);
|
|
196
|
+
this.interval = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
//# sourceMappingURL=progress-sidecar.js.map
|