catence 0.2.0-beta.9 → 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.
Files changed (71) hide show
  1. package/README.md +38 -180
  2. package/dist/contracts/index.d.ts +1 -0
  3. package/dist/contracts/index.js +1 -0
  4. package/dist/contracts/progress.d.ts +23 -0
  5. package/dist/contracts/progress.js +49 -0
  6. package/dist/contracts/release.d.ts +1 -1
  7. package/dist/contracts/release.js +1 -1
  8. package/dist/core/query/activity-discovery.d.ts +2 -0
  9. package/dist/core/query/activity-discovery.js +1 -1
  10. package/dist/core/query/analytics.d.ts +10 -0
  11. package/dist/core/query/analytics.js +494 -0
  12. package/dist/core/query/catalog.d.ts +1 -1
  13. package/dist/core/query/catalog.js +8 -4
  14. package/dist/core/query/events.d.ts +34 -0
  15. package/dist/core/query/events.js +121 -0
  16. package/dist/core/query/fitness.d.ts +4 -0
  17. package/dist/core/query/fitness.js +60 -0
  18. package/dist/core/query/repository.d.ts +2 -0
  19. package/dist/core/query/repository.js +48 -0
  20. package/dist/core/query/swimming.js +2 -2
  21. package/dist/core/runtime/configuration.d.ts +3 -1
  22. package/dist/core/runtime/configuration.js +5 -1
  23. package/dist/core/runtime/opencode-go.d.ts +58 -0
  24. package/dist/core/runtime/opencode-go.js +246 -0
  25. package/dist/core/runtime/secrets.js +51 -13
  26. package/dist/elt/application/detached-sync.d.ts +50 -0
  27. package/dist/elt/application/detached-sync.js +99 -0
  28. package/dist/elt/application/management.d.ts +15 -0
  29. package/dist/elt/application/management.js +420 -18
  30. package/dist/elt/ingestion/importer.js +1 -1
  31. package/dist/elt/ingestion/providers/intervals.d.ts +9 -0
  32. package/dist/elt/ingestion/providers/intervals.js +33 -1
  33. package/dist/elt/ingestion/providers/registry.d.ts +13 -0
  34. package/dist/elt/ingestion/providers/registry.js +19 -1
  35. package/dist/elt/normalization/normalizers.js +207 -15
  36. package/dist/elt/storage/database.d.ts +21 -2
  37. package/dist/elt/storage/database.js +182 -6
  38. package/dist/elt/storage/migrations.js +145 -1
  39. package/dist/elt/storage/progress-sidecar.d.ts +61 -0
  40. package/dist/elt/storage/progress-sidecar.js +200 -0
  41. package/dist/interfaces/cli/commands.js +94 -1
  42. package/dist/interfaces/http/generation-status.d.ts +19 -0
  43. package/dist/interfaces/http/generation-status.js +55 -0
  44. package/dist/interfaces/http/server.d.ts +7 -1
  45. package/dist/interfaces/http/server.js +184 -1
  46. package/dist/interfaces/mcp/main.js +33 -1
  47. package/dist/interfaces/mcp/server.d.ts +2 -1
  48. package/dist/interfaces/mcp/server.js +168 -45
  49. package/dist/runtime/index.d.ts +9 -2
  50. package/dist/runtime/index.js +6 -2
  51. package/docs/architecture.md +13 -2
  52. package/docs/assets/image.png +0 -0
  53. package/docs/configuration.md +243 -0
  54. package/docs/deployment/docker.md +528 -0
  55. package/docs/deployment/local-console.md +263 -0
  56. package/docs/deployment/local-mcp.md +329 -0
  57. package/docs/development.md +337 -0
  58. package/docs/distribution.md +45 -0
  59. package/docs/llm-providers.md +24 -2
  60. package/docs/mcp-activity-retrieval.md +90 -210
  61. package/package.json +1 -1
  62. package/pyproject.toml +1 -1
  63. package/python/catence/providers/garmin/cli.py +43 -9
  64. package/python/catence/providers/garmin/progress.py +85 -0
  65. package/python/catence/providers/garmin/registry.py +2 -0
  66. package/python/catence/providers/garmin/streams.py +38 -0
  67. package/python/catence/providers/garmin/swim_backfill.py +83 -0
  68. package/python/catence/providers/garmin/worker.py +415 -12
  69. package/scripts/discover-opencode-go.mjs +36 -144
  70. package/uv.lock +3 -3
  71. package/docs/deployment.md +0 -338
@@ -828,5 +828,149 @@ const swimFactsAndQualityMigration = {
828
828
  UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context';
829
829
  `,
830
830
  };
831
- export const migrations = [...baseMigrations, stravaAndIdentityMigration, garminPrimaryAndTrainingMetricsMigration, garminActivitySportAndFtpBackfillMigration, garminPrimaryDailyHealthMigration, garminHistoricalMetricsAndStreamsMigration, garminUtcActivityTimestampMigration, garminLactateThresholdMetricsMigration, swimFactsAndQualityMigration];
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
@@ -2,7 +2,7 @@
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
4
  import { Command } from 'commander';
5
- import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
5
+ import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, CatenceDatabase, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
6
6
  const program = new Command()
7
7
  .name('catence-data')
8
8
  .description('Athlete-scoped Catence data setup, extraction, and normalization.')
@@ -158,6 +158,38 @@ program.command('status').action(async () => {
158
158
  const { paths } = await currentAthlete();
159
159
  process.stdout.write(`${JSON.stringify(await dataStatus(paths), null, 2)}\n`);
160
160
  });
161
+ function renderProgress(snapshot) {
162
+ const lines = snapshot.running.map((run) => {
163
+ const pct = run.percentComplete.toFixed(1).padStart(5);
164
+ const units = run.totalUnits !== null ? `${run.completedUnits}/${run.totalUnits}` : String(run.completedUnits);
165
+ const eta = run.estimatedRemainingSeconds !== null ? `${Math.round(run.estimatedRemainingSeconds)}s` : '?';
166
+ const step = run.currentStep ?? '';
167
+ return `${pct}% ${run.stage.padEnd(12)} ${units.padEnd(11)} ${eta.padEnd(6)} ${step}`;
168
+ });
169
+ return lines.length === 0 ? 'no active sync runs' : lines.join('\n');
170
+ }
171
+ program.command('progress')
172
+ .description('Show live progress for active sync runs.')
173
+ .option('--watch', 'refresh every few seconds until no sync runs are active')
174
+ .action(async (options) => {
175
+ const { paths } = await currentAthlete();
176
+ if (!options.watch) {
177
+ process.stdout.write(`${JSON.stringify(await syncProgress(paths), null, 2)}\n`);
178
+ return;
179
+ }
180
+ while (true) {
181
+ const snapshot = await syncProgress(paths);
182
+ if (output.isTTY) {
183
+ output.write(`\x1b[2J\x1b[H${renderProgress(snapshot)}\n`);
184
+ }
185
+ else {
186
+ process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`);
187
+ }
188
+ if (snapshot.running.length === 0)
189
+ break;
190
+ await new Promise((resolve) => setTimeout(resolve, 3000));
191
+ }
192
+ });
161
193
  program.command('update')
162
194
  .description('Check for and apply the newest Catence release on the tracked channel (runtime and Console).')
163
195
  .option('--check', 'report available releases without changing anything')
@@ -220,5 +252,66 @@ program.command('build-retrieval-index').description('Build derived local retrie
220
252
  const { paths } = await currentAthlete();
221
253
  process.stdout.write(`${JSON.stringify(await rebuildRetrievalIndex(paths), null, 2)}\n`);
222
254
  });
255
+ program.command('reimport-nutrition').description('Re-run normalization over captured nutrition entities without contacting providers.').action(async () => {
256
+ const { paths } = await currentAthlete();
257
+ process.stdout.write(`${JSON.stringify(await reimportNutrition(paths), null, 2)}\n`);
258
+ });
259
+ program
260
+ .command('import')
261
+ .description('Import a staged JSONL file (for example a swim-length backfill) into one athlete’s data store.')
262
+ .requiredOption('--provider <provider>', 'garmin, intervals, or strava')
263
+ .argument('<file>', 'path to the staged JSONL file')
264
+ .action(async (file, options) => {
265
+ const { paths } = await currentAthlete();
266
+ process.stdout.write(`${JSON.stringify(await importStagedFile(paths, options.provider, file), null, 2)}\n`);
267
+ });
268
+ program
269
+ .command('resolve-errors')
270
+ .description('Mark unresolved extraction errors as resolved (for example stale bootstrap failures from an environment that has since recovered).')
271
+ .option('--run <run-id>', 'resolve errors from a specific run only (repeatable)')
272
+ .option('--provider <provider>', 'resolve errors for garmin, intervals, or strava only')
273
+ .option('--before <timestamp>', 'resolve errors created before this ISO timestamp')
274
+ .option('--all', 'resolve every unresolved error')
275
+ .action(async (options) => {
276
+ const { paths } = await currentAthlete();
277
+ const runIds = Array.isArray(options.run) ? options.run : options.run ? [options.run] : undefined;
278
+ process.stdout.write(`${JSON.stringify(await resolveExtractionErrors(paths, { runIds, provider: options.provider, before: options.before, all: options.all }), null, 2)}\n`);
279
+ });
280
+ program
281
+ .command('migrate')
282
+ .description('Apply pending DuckDB schema migrations. Without --all it migrates the selected --athlete store; with --all it migrates every athlete in the catalog.')
283
+ .option('--all', 'migrate every athlete in the catalog')
284
+ .option('--home <directory>', 'Catence catalog home directory (also accepts global --home)')
285
+ .action(async (options) => {
286
+ const effectiveHome = options.home ?? program.opts().home;
287
+ const effectiveCatalog = effectiveHome ? resolveCatalogPaths(effectiveHome) : currentCatalog();
288
+ const migratePaths = async (paths) => {
289
+ const database = await CatenceDatabase.open(paths);
290
+ const applied = await database.rows('SELECT version FROM schema_migrations ORDER BY version');
291
+ await database.close();
292
+ return { migrated: true, appliedVersions: applied.map((row) => row.version) };
293
+ };
294
+ if (options.all) {
295
+ const catalog = await loadCatalog(effectiveCatalog);
296
+ const results = {};
297
+ for (const athlete of catalog.athletes) {
298
+ const paths = athleteStorePaths(effectiveCatalog, athlete.id);
299
+ try {
300
+ results[athlete.id] = await migratePaths(paths);
301
+ }
302
+ catch (error) {
303
+ results[athlete.id] = { migrated: false, error: error instanceof Error ? error.message : String(error) };
304
+ }
305
+ }
306
+ process.stdout.write(`${JSON.stringify({ catalog: effectiveCatalog.root, results }, null, 2)}\n`);
307
+ return;
308
+ }
309
+ const athleteId = program.opts().athlete;
310
+ if (!athleteId)
311
+ throw new Error('--athlete is required for this command. Run catence-data athlete list to inspect the catalog or use --all.');
312
+ const { athlete, paths } = await resolveAthlete(effectiveCatalog, athleteId);
313
+ const result = await migratePaths(paths);
314
+ process.stdout.write(`${JSON.stringify({ athleteId: athlete.id, ...result }, null, 2)}\n`);
315
+ });
223
316
  await program.parseAsync();
224
317
  //# sourceMappingURL=commands.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Read + classify the generation progress sidecar written by the Catence
3
+ * Console agent turn (see `catence_console/generation_sidecar.py`). Mirrors the
4
+ * detached-sync sidecar logic: a run is "running" until it reaches a terminal
5
+ * stage, and "stale" (i.e. likely stuck) when its heartbeat is older than the
6
+ * threshold. This powers the chat UI's "still thinking vs stalled" signal.
7
+ */
8
+ export interface GenerationStatus {
9
+ threadId: string;
10
+ stage?: string;
11
+ running: boolean;
12
+ stale: boolean;
13
+ heartbeatAt?: string;
14
+ startedAt?: string;
15
+ updatedAt?: string;
16
+ toolCallCount: number;
17
+ lastTool?: string | null;
18
+ }
19
+ export declare function generationStatus(homeRoot: string, threadId: string): Promise<GenerationStatus>;
@@ -0,0 +1,55 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const TERMINAL_STAGES = new Set([
4
+ 'completed',
5
+ 'failed',
6
+ 'interrupted',
7
+ 'timed_out'
8
+ ]);
9
+ const STALE_MS = 5 * 60 * 1000; // 5 minutes
10
+ function emptyStatus(threadId) {
11
+ return {
12
+ threadId,
13
+ running: false,
14
+ stale: false,
15
+ toolCallCount: 0
16
+ };
17
+ }
18
+ export async function generationStatus(homeRoot, threadId) {
19
+ const safe = threadId.replace(/[^a-zA-Z0-9_-]/g, '');
20
+ if (!safe)
21
+ return emptyStatus(threadId);
22
+ const file = path.join(homeRoot, 'generation', `${safe}.generation.json`);
23
+ let raw;
24
+ try {
25
+ raw = await readFile(file, 'utf8');
26
+ }
27
+ catch {
28
+ return emptyStatus(threadId);
29
+ }
30
+ let data;
31
+ try {
32
+ data = JSON.parse(raw);
33
+ }
34
+ catch {
35
+ return emptyStatus(threadId);
36
+ }
37
+ const base = {
38
+ threadId,
39
+ stage: typeof data.stage === 'string' ? data.stage : undefined,
40
+ heartbeatAt: typeof data.heartbeatAt === 'string' ? data.heartbeatAt : undefined,
41
+ startedAt: typeof data.startedAt === 'string' ? data.startedAt : undefined,
42
+ updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : undefined,
43
+ toolCallCount: typeof data.toolCallCount === 'number' ? data.toolCallCount : 0,
44
+ lastTool: data.lastTool === null || typeof data.lastTool === 'string'
45
+ ? data.lastTool
46
+ : undefined
47
+ };
48
+ if (base.stage && TERMINAL_STAGES.has(base.stage)) {
49
+ return { ...base, running: false, stale: false };
50
+ }
51
+ const hb = base.heartbeatAt ? Date.parse(base.heartbeatAt) : NaN;
52
+ const stale = !Number.isFinite(hb) || Date.now() - hb > STALE_MS;
53
+ return { ...base, running: true, stale };
54
+ }
55
+ //# sourceMappingURL=generation-status.js.map
@@ -1,11 +1,17 @@
1
1
  import { type Server } from 'node:http';
2
- import { type CatalogPaths, type CatencePaths } from '../../runtime/index.js';
2
+ import { type DetachedSyncSpawner, mergeOpenCodeGoConsoleProfiles, startDetachedSync, type CatalogPaths, type CatencePaths } from '../../runtime/index.js';
3
3
  export type CatenceHttpServerOptions = {
4
4
  /** A shared catalog for public servers. All data APIs then require athleteId. */
5
5
  catalogPaths?: CatalogPaths;
6
6
  /** Internal compatibility hook for tests and embedded single-store callers. */
7
7
  paths?: CatencePaths;
8
8
  allowedOrigins?: readonly string[];
9
+ /** Internal compatibility hook for tests: override the detached sync spawner. */
10
+ startSync?: typeof startDetachedSync;
11
+ /** Internal compatibility hook for tests: passed into the default startDetachedSync. */
12
+ syncSpawnProcess?: DetachedSyncSpawner;
13
+ /** Internal compatibility hook for tests: override OpenCode Go model discovery. */
14
+ mergeModels?: typeof mergeOpenCodeGoConsoleProfiles;
9
15
  };
10
16
  /**
11
17
  * Create Catence's local Streamable HTTP server.