catence 0.2.1-beta.2 → 0.2.1-beta.3

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.
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.2";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.3";
7
7
  export declare const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export type CatenceRuntimeCapabilities = {
9
9
  mcp: true;
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.2';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.3';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -19,7 +19,7 @@ import { completeStravaAuthorization, disconnectStrava, getStravaAuthorizationUr
19
19
  import { setManualActivityLink, unlinkActivitySource } from '../normalization/activities/linking.js';
20
20
  import { importSourceEntity } from '../normalization/normalizers.js';
21
21
  import { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../storage/database.js';
22
- import { mergeProgress, ProgressPump, readRunningProgress, removeProgressSidecar } from '../storage/progress-sidecar.js';
22
+ import { mergeProgress, ProgressPump, readRunningProgress, removeProgressSidecar, writeProgressSidecar, PROGRESS_KEEPALIVE_MS } from '../storage/progress-sidecar.js';
23
23
  import { withDataWriteLock } from '../storage/write-lock.js';
24
24
  function packageRoot() {
25
25
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
@@ -430,8 +430,42 @@ export async function syncData(paths, provider, explicitFrom, advanceCursor = tr
430
430
  runs.push(await syncProvider(database, paths, source, { explicitFrom, toDate, advanceCursor, refreshActivities, backfill }, log));
431
431
  const executedRuns = runs.filter((run) => !run.skipped);
432
432
  if (executedRuns.length > 0) {
433
- const index = await buildRetrievalIndex(database);
434
- log.info('Rebuilt retrieval index after sync', { documents: index.documents, mode: index.mode, watermark: index.watermark });
433
+ // The per-provider pumps already stopped (sidecars removed, runs marked
434
+ // completed), but buildRetrievalIndex still holds the single-writer DB
435
+ // lock, so /dashboard reads as unavailable. Hold a non-terminal sidecar
436
+ // for the last executed run so /sync/status keeps reporting running and
437
+ // the Status Sync button stays disabled through local post-processing.
438
+ const last = [...executedRuns].reverse().find((run) => run.runId);
439
+ let indexingKeepalive = null;
440
+ const holdIndexingProgress = () => last?.runId
441
+ ? writeProgressSidecar(paths, last.runId, {
442
+ ...baseProgress(last.runId, last.provider, 'importing'),
443
+ currentStep: 'building retrieval index',
444
+ percentComplete: 99,
445
+ }).catch((error) => {
446
+ log.debug('Indexing progress sidecar write failed', {
447
+ runId: last.runId,
448
+ error: error instanceof Error ? error.message : String(error),
449
+ });
450
+ })
451
+ : Promise.resolve();
452
+ if (last?.runId) {
453
+ await holdIndexingProgress();
454
+ indexingKeepalive = setInterval(() => {
455
+ void holdIndexingProgress();
456
+ }, PROGRESS_KEEPALIVE_MS);
457
+ indexingKeepalive.unref?.();
458
+ }
459
+ try {
460
+ const index = await buildRetrievalIndex(database);
461
+ log.info('Rebuilt retrieval index after sync', { documents: index.documents, mode: index.mode, watermark: index.watermark });
462
+ }
463
+ finally {
464
+ if (indexingKeepalive)
465
+ clearInterval(indexingKeepalive);
466
+ if (last?.runId)
467
+ await removeProgressSidecar(paths, last.runId).catch(() => undefined);
468
+ }
435
469
  }
436
470
  return { runIds: runs.flatMap((run) => run.runId ? [run.runId] : []), runs, timedOutRuns: timedOutRuns.runIds };
437
471
  }
@@ -48,7 +48,16 @@ export declare class ProgressPump {
48
48
  private lastHeartbeatAt;
49
49
  private interval;
50
50
  private stageOverride;
51
+ /**
52
+ * Serializes heartbeat writes in call order. publish() used to fire its
53
+ * database and sidecar writes without awaiting them, so a throttled write
54
+ * could win the sidecar rename race against a later publishFinal() and
55
+ * leave a stale non-terminal stage behind.
56
+ */
57
+ private writeTail;
51
58
  constructor(runId: string, provider: string, options: ProgressPumpOptions);
59
+ private enqueueHeartbeat;
60
+ private writeHeartbeat;
52
61
  start(): void;
53
62
  publish(state: SyncProgressState): void;
54
63
  /** Force subsequent keep-alive heartbeats to report the given stage. */
@@ -133,11 +133,36 @@ export class ProgressPump {
133
133
  lastHeartbeatAt = 0;
134
134
  interval = null;
135
135
  stageOverride = null;
136
+ /**
137
+ * Serializes heartbeat writes in call order. publish() used to fire its
138
+ * database and sidecar writes without awaiting them, so a throttled write
139
+ * could win the sidecar rename race against a later publishFinal() and
140
+ * leave a stale non-terminal stage behind.
141
+ */
142
+ writeTail = Promise.resolve();
136
143
  constructor(runId, provider, options) {
137
144
  this.runId = runId;
138
145
  this.provider = provider;
139
146
  this.options = options;
140
147
  }
148
+ enqueueHeartbeat(state) {
149
+ const run = this.writeTail.then(() => this.writeHeartbeat(state));
150
+ // A write failure is already logged inside writeHeartbeat; never let it
151
+ // break the chain (or surface as an unhandled rejection) for later beats.
152
+ this.writeTail = run.then(() => undefined, () => undefined);
153
+ return run;
154
+ }
155
+ async writeHeartbeat(state) {
156
+ const { database, paths, log } = this.options;
157
+ await Promise.all([
158
+ database.heartbeatRun(this.runId, state).catch((error) => {
159
+ log.debug('Progress heartbeat failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
160
+ }),
161
+ writeProgressSidecar(paths, this.runId, state).catch((error) => {
162
+ log.debug('Progress sidecar write failed', { runId: this.runId, error: error instanceof Error ? error.message : String(error) });
163
+ }),
164
+ ]);
165
+ }
141
166
  start() {
142
167
  if (this.interval)
143
168
  return;
@@ -156,13 +181,7 @@ export class ProgressPump {
156
181
  }
157
182
  this.lastHeartbeatAt = now;
158
183
  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
- });
184
+ void this.enqueueHeartbeat(state).catch(() => undefined);
166
185
  }
167
186
  /** Force subsequent keep-alive heartbeats to report the given stage. */
168
187
  setStage(stage) {
@@ -180,15 +199,11 @@ export class ProgressPump {
180
199
  async publishFinal(state) {
181
200
  this.lastHeartbeatAt = Date.now();
182
201
  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
- });
202
+ // Awaiting the chained write guarantees every earlier heartbeat (in
203
+ // particular a throttled publish still in flight) lands first, so the
204
+ // terminal stage is always the last one persisted before the caller
205
+ // removes the sidecar.
206
+ await this.enqueueHeartbeat(state);
192
207
  }
193
208
  stop() {
194
209
  if (this.interval) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.2",
3
+ "version": "0.2.1-beta.3",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -55,6 +55,7 @@
55
55
  "test:watch": "vitest",
56
56
  "catence-data": "tsx src/interfaces/cli/main.ts",
57
57
  "mcp": "tsx src/interfaces/mcp/main.ts",
58
+ "dev": "node scripts/dev.mjs",
58
59
  "console": "uv run --project console catence-console serve",
59
60
  "console:doctor": "uv run --project console catence-console doctor",
60
61
  "discover:opencode-go": "node scripts/discover-opencode-go.mjs",
@@ -79,11 +80,11 @@
79
80
  "@types/node": "^24.3.0",
80
81
  "tsx": "^4.20.5",
81
82
  "typescript": "^5.9.2",
82
- "vitest": "^3.2.4"
83
+ "vitest": "^5.0.0"
83
84
  },
84
85
  "overrides": {
85
86
  "fast-uri": "3.1.7",
86
- "hono": "4.12.34",
87
+ "hono": "4.13.7",
87
88
  "nanoid": "3.3.18",
88
89
  "qs": "6.16.0"
89
90
  }