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.
- package/README.md +38 -180
- 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/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 +420 -18
- package/dist/elt/ingestion/importer.js +1 -1
- package/dist/elt/ingestion/providers/intervals.d.ts +9 -0
- package/dist/elt/ingestion/providers/intervals.js +33 -1
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +19 -1
- package/dist/elt/normalization/normalizers.js +207 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +182 -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 +24 -2
- 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
|
@@ -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
|
|
@@ -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.
|