forge-openclaw-plugin 0.2.68 → 0.2.70

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 (28) hide show
  1. package/dist/assets/{board-DFNV9VAZ.js → board-BfqxFNiQ.js} +1 -1
  2. package/dist/assets/index-BfLQnCNZ.js +91 -0
  3. package/dist/assets/index-DIapFz9v.css +1 -0
  4. package/dist/assets/{motion-CXdn34ih.js → motion-C0ALlgho.js} +1 -1
  5. package/dist/assets/{table-CEq3bTDv.js → table-WcMjnJll.js} +1 -1
  6. package/dist/assets/{ui-g7FaEglG.js → ui-B5I-3U91.js} +1 -1
  7. package/dist/assets/vendor-B-Lq_OG3.css +1 -0
  8. package/dist/assets/vendor-C56o26_3.js +2163 -0
  9. package/dist/index.html +8 -8
  10. package/dist/server/server/migrations/061_health_workout_raw_evidence.sql +95 -0
  11. package/dist/server/server/migrations/062_health_mobile_sync_sessions.sql +55 -0
  12. package/dist/server/server/src/app.js +149 -21
  13. package/dist/server/server/src/health-workout-analytics.js +572 -0
  14. package/dist/server/server/src/health.js +612 -4
  15. package/dist/server/server/src/openapi.js +162 -0
  16. package/dist/server/server/src/psyche-types.js +59 -0
  17. package/dist/server/server/src/services/devrage.js +191 -0
  18. package/dist/server/src/lib/api.js +13 -0
  19. package/openclaw.plugin.json +1 -1
  20. package/package.json +2 -1
  21. package/server/migrations/061_health_workout_raw_evidence.sql +95 -0
  22. package/server/migrations/062_health_mobile_sync_sessions.sql +55 -0
  23. package/skills/forge-openclaw/SKILL.md +35 -6
  24. package/skills/forge-openclaw/entity_conversation_playbooks.md +179 -18
  25. package/dist/assets/index-B0PIKEnz.css +0 -1
  26. package/dist/assets/index-BofyMuFh.js +0 -90
  27. package/dist/assets/vendor-BcOHGipZ.js +0 -1341
  28. package/dist/assets/vendor-DT3pnAKJ.css +0 -1
@@ -1,8 +1,9 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { getDatabase, runInTransaction } from "./db.js";
4
4
  import { HttpError } from "./errors.js";
5
5
  import { buildWorkoutSessionPersistenceSeed, buildWorkoutSessionPresentation, workoutActivityDescriptorSchema, workoutDetailsSchema } from "./health-workout-adapters.js";
6
+ import { getHealthZoneProfile, getStoredWorkoutAnalytics, getWorkoutRawEvidence, healthZoneProfilePatchSchema, recomputeAndStoreWorkoutAnalytics, upsertWorkoutRoutePoints, upsertWorkoutTimeSeries, workoutCaptureQualitySchema, workoutRoutePointSchema, workoutTimeSeriesSampleSchema, patchHealthZoneProfile } from "./health-workout-analytics.js";
6
7
  import { getMovementMobileBootstrap, ingestMovementSync, movementSyncPayloadSchema } from "./movement.js";
7
8
  import { ingestScreenTimeSync, screenTimeSyncPayloadSchema } from "./screen-time.js";
8
9
  import { recordActivityEvent } from "./repositories/activity-events.js";
@@ -245,6 +246,10 @@ export const mobileHealthSyncSchema = z.object({
245
246
  sourceProductType: z.string().trim().optional(),
246
247
  activity: workoutActivityDescriptorSchema.optional(),
247
248
  details: workoutDetailsSchema.optional(),
249
+ timeSeriesSamples: z.array(workoutTimeSeriesSampleSchema).default([]),
250
+ routePoints: z.array(workoutRoutePointSchema).default([]),
251
+ captureQuality: workoutCaptureQualitySchema.optional(),
252
+ syncCursor: z.record(z.string(), z.unknown()).default({}),
248
253
  links: z.array(healthLinkSchema).default([]),
249
254
  annotations: workoutAnnotationSchema.partial().default({})
250
255
  }))
@@ -254,6 +259,92 @@ export const mobileHealthSyncSchema = z.object({
254
259
  movement: movementSyncPayloadSchema.default({}),
255
260
  screenTime: screenTimeSyncPayloadSchema.default({})
256
261
  });
262
+ const HEALTH_MOBILE_SYNC_SCHEMA_VERSION = "healthkit-sync-v2";
263
+ const HEALTH_MOBILE_SYNC_CHUNK_TARGET_BYTES = 512_000;
264
+ const HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES = 1_000_000;
265
+ const HEALTH_MOBILE_SYNC_SESSION_TTL_MS = 1000 * 60 * 60 * 24;
266
+ const mobileHealthSyncFamilySchema = z.enum([
267
+ "sleep_nights",
268
+ "sleep_segments",
269
+ "sleep_raw_records",
270
+ "workout_summaries",
271
+ "workout_time_series",
272
+ "workout_routes",
273
+ "workout_tombstones",
274
+ "vitals",
275
+ "movement",
276
+ "screen_time"
277
+ ]);
278
+ const defaultMobileHealthSyncFamilies = mobileHealthSyncFamilySchema.options;
279
+ export const mobileHealthSyncSessionStartSchema = mobileHealthSyncSchema
280
+ .pick({
281
+ sessionId: true,
282
+ pairingToken: true,
283
+ device: true,
284
+ permissions: true,
285
+ sourceStates: true
286
+ })
287
+ .extend({
288
+ schemaVersion: z
289
+ .string()
290
+ .trim()
291
+ .default(HEALTH_MOBILE_SYNC_SCHEMA_VERSION),
292
+ requestedFamilies: z
293
+ .array(mobileHealthSyncFamilySchema)
294
+ .default(defaultMobileHealthSyncFamilies),
295
+ expectedCounts: z.record(z.string(), z.number().int().nonnegative()).default({}),
296
+ metadata: z.record(z.string(), z.unknown()).default({})
297
+ });
298
+ const workoutTimeSeriesChunkPayloadSchema = z.object({
299
+ workouts: z
300
+ .array(z.object({
301
+ externalUid: z.string().trim().min(1),
302
+ samples: z.array(workoutTimeSeriesSampleSchema).default([])
303
+ }))
304
+ .default([])
305
+ });
306
+ const workoutRouteChunkPayloadSchema = z.object({
307
+ workouts: z
308
+ .array(z.object({
309
+ externalUid: z.string().trim().min(1),
310
+ routePoints: z.array(workoutRoutePointSchema).default([])
311
+ }))
312
+ .default([])
313
+ });
314
+ const workoutTombstoneChunkPayloadSchema = z.object({
315
+ workouts: z
316
+ .array(z.object({
317
+ externalUid: z.string().trim().min(1),
318
+ deletedAt: z.string().datetime().nullable().optional(),
319
+ reason: z.string().trim().default("healthkit_deleted")
320
+ }))
321
+ .default([])
322
+ });
323
+ const mobileHealthSyncChunkPayloadSchema = z.object({
324
+ sleepNights: mobileHealthSyncSchema.shape.sleepNights.optional(),
325
+ sleepSegments: mobileHealthSyncSchema.shape.sleepSegments.optional(),
326
+ sleepRawRecords: mobileHealthSyncSchema.shape.sleepRawRecords.optional(),
327
+ workouts: mobileHealthSyncSchema.shape.workouts.optional(),
328
+ workoutTimeSeries: workoutTimeSeriesChunkPayloadSchema.shape.workouts.optional(),
329
+ workoutRoutes: workoutRouteChunkPayloadSchema.shape.workouts.optional(),
330
+ workoutTombstones: workoutTombstoneChunkPayloadSchema.shape.workouts.optional(),
331
+ vitals: vitalsSyncPayloadSchema.optional(),
332
+ movement: movementSyncPayloadSchema.optional(),
333
+ screenTime: screenTimeSyncPayloadSchema.optional()
334
+ });
335
+ export const mobileHealthSyncChunkSchema = z.object({
336
+ chunkId: z.string().trim().min(1),
337
+ sequence: z.number().int().nonnegative(),
338
+ family: mobileHealthSyncFamilySchema,
339
+ recordCount: z.number().int().nonnegative().default(0),
340
+ byteCount: z.number().int().nonnegative().default(0),
341
+ checksumSha256: z.string().trim().min(1),
342
+ payload: mobileHealthSyncChunkPayloadSchema.default({})
343
+ });
344
+ export const mobileHealthSyncSessionCompleteSchema = z.object({
345
+ finalCursor: z.record(z.string(), z.unknown()).default({}),
346
+ expectedCounts: z.record(z.string(), z.number().int().nonnegative()).default({})
347
+ });
257
348
  export const verifyCompanionPairingSchema = z.object({
258
349
  sessionId: z.string().trim().min(1),
259
350
  pairingToken: z.string().trim().min(1),
@@ -788,6 +879,7 @@ function mapSleepRawLog(row) {
788
879
  function mapWorkoutSession(row) {
789
880
  const provenance = safeJsonParse(row.provenance_json, {});
790
881
  const derived = safeJsonParse(row.derived_json, {});
882
+ const analytics = getStoredWorkoutAnalytics(row);
791
883
  const presentation = buildWorkoutSessionPresentation({
792
884
  source: row.source,
793
885
  sourceType: row.source_type,
@@ -833,6 +925,7 @@ function mapWorkoutSession(row) {
833
925
  annotations: safeJsonParse(row.annotations_json, {}),
834
926
  provenance,
835
927
  derived,
928
+ analytics,
836
929
  generatedFromHabitId: row.generated_from_habit_id,
837
930
  generatedFromCheckInId: row.generated_from_check_in_id,
838
931
  reconciliationStatus: row.reconciliation_status,
@@ -1352,6 +1445,28 @@ export function getWorkoutSessionById(workoutId) {
1352
1445
  .get(workoutId);
1353
1446
  return row ? mapWorkoutSession(row) : undefined;
1354
1447
  }
1448
+ export function getWorkoutSessionDetailById(workoutId, resolution = "adaptive") {
1449
+ const row = getDatabase()
1450
+ .prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
1451
+ .get(workoutId);
1452
+ if (!row) {
1453
+ return undefined;
1454
+ }
1455
+ const workout = mapWorkoutSession(row);
1456
+ return {
1457
+ workout,
1458
+ analytics: getStoredWorkoutAnalytics(row),
1459
+ evidence: getWorkoutRawEvidence(row, resolution),
1460
+ zoneProfile: getHealthZoneProfile(row.user_id)
1461
+ };
1462
+ }
1463
+ export { healthZoneProfilePatchSchema };
1464
+ export function getHealthZoneProfileForUser(userId) {
1465
+ return getHealthZoneProfile(userId);
1466
+ }
1467
+ export function patchHealthZoneProfileForUser(userId, patch) {
1468
+ return patchHealthZoneProfile(userId, patch);
1469
+ }
1355
1470
  function listPairingRows(userIds) {
1356
1471
  const params = [];
1357
1472
  const where = userIds && userIds.length > 0
@@ -2072,6 +2187,7 @@ function insertOrUpdateWorkoutSession(pairing, input) {
2072
2187
  ? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
2073
2188
  : null
2074
2189
  }), matchedGenerated.generated_from_habit_id ? "merged" : "standalone", now, matchedGenerated.id);
2190
+ persistWorkoutEvidenceForInput(matchedGenerated.id, pairing.user_id, input);
2075
2191
  return {
2076
2192
  mode: matchedGenerated.generated_from_habit_id || matchedGenerated.source !== "apple_health"
2077
2193
  ? "merged"
@@ -2105,8 +2221,43 @@ function insertOrUpdateWorkoutSession(pairing, input) {
2105
2221
  ? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
2106
2222
  : null
2107
2223
  }), now, now);
2224
+ persistWorkoutEvidenceForInput(id, pairing.user_id, input);
2108
2225
  return { mode: "created", id };
2109
2226
  }
2227
+ function persistWorkoutEvidenceForInput(workoutId, userId, input) {
2228
+ if (input.timeSeriesSamples.length > 0) {
2229
+ upsertWorkoutTimeSeries({
2230
+ workoutId,
2231
+ userId,
2232
+ samples: input.timeSeriesSamples
2233
+ });
2234
+ }
2235
+ if (input.routePoints.length > 0) {
2236
+ upsertWorkoutRoutePoints({
2237
+ workoutId,
2238
+ userId,
2239
+ points: input.routePoints
2240
+ });
2241
+ }
2242
+ const row = getDatabase()
2243
+ .prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
2244
+ .get(workoutId);
2245
+ if (row) {
2246
+ recomputeAndStoreWorkoutAnalytics(row);
2247
+ if (input.captureQuality) {
2248
+ const derived = safeJsonParse(row.derived_json, {});
2249
+ getDatabase()
2250
+ .prepare(`UPDATE health_workout_sessions
2251
+ SET derived_json = ?, updated_at = ?
2252
+ WHERE id = ?`)
2253
+ .run(JSON.stringify({
2254
+ ...derived,
2255
+ captureQuality: input.captureQuality,
2256
+ syncCursor: input.syncCursor
2257
+ }), nowIso(), workoutId);
2258
+ }
2259
+ }
2260
+ }
2110
2261
  function clusterSleepRowsByGap(rows) {
2111
2262
  const clusters = [];
2112
2263
  let currentCluster = [];
@@ -2497,6 +2648,415 @@ function replaceHistoricalSleepSessionsForDate(userId, localDateKey, providerBac
2497
2648
  .run(historical.id);
2498
2649
  }
2499
2650
  }
2651
+ function mobileSyncSessionId() {
2652
+ return `hms_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
2653
+ }
2654
+ function mobileSyncChunkRecordId() {
2655
+ return `hmsc_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
2656
+ }
2657
+ function expireStaleMobileSyncSessions() {
2658
+ const cutoff = new Date(Date.now() - HEALTH_MOBILE_SYNC_SESSION_TTL_MS).toISOString();
2659
+ const now = nowIso();
2660
+ getDatabase()
2661
+ .prepare(`UPDATE health_mobile_sync_sessions
2662
+ SET status = 'expired', expired_at = ?, updated_at = ?
2663
+ WHERE status = 'running' AND started_at < ?`)
2664
+ .run(now, now, cutoff);
2665
+ }
2666
+ function readMobileSyncSession(syncSessionId) {
2667
+ return getDatabase()
2668
+ .prepare(`SELECT * FROM health_mobile_sync_sessions WHERE id = ?`)
2669
+ .get(syncSessionId);
2670
+ }
2671
+ function ensureRunningMobileSyncSession(syncSessionId) {
2672
+ expireStaleMobileSyncSessions();
2673
+ const session = readMobileSyncSession(syncSessionId);
2674
+ if (!session) {
2675
+ throw new HttpError(404, "sync_session_not_found", "The HealthKit sync session does not exist.");
2676
+ }
2677
+ if (session.status !== "running") {
2678
+ throw new HttpError(409, "sync_session_closed", "The HealthKit sync session is no longer accepting chunks.", { status: session.status });
2679
+ }
2680
+ return session;
2681
+ }
2682
+ function chunkPayloadJson(payload) {
2683
+ return JSON.stringify(payload);
2684
+ }
2685
+ function chunkPayloadChecksum(payloadJson) {
2686
+ return createHash("sha256").update(payloadJson).digest("hex");
2687
+ }
2688
+ function summarizeChunkPayload(family, payload) {
2689
+ switch (family) {
2690
+ case "sleep_nights":
2691
+ return { sleepNights: payload.sleepNights?.length ?? 0 };
2692
+ case "sleep_segments":
2693
+ return { sleepSegments: payload.sleepSegments?.length ?? 0 };
2694
+ case "sleep_raw_records":
2695
+ return { sleepRawRecords: payload.sleepRawRecords?.length ?? 0 };
2696
+ case "workout_summaries":
2697
+ return { workouts: payload.workouts?.length ?? 0 };
2698
+ case "workout_time_series":
2699
+ return {
2700
+ workouts: payload.workoutTimeSeries?.length ?? 0,
2701
+ samples: payload.workoutTimeSeries?.reduce((sum, entry) => sum + entry.samples.length, 0) ?? 0
2702
+ };
2703
+ case "workout_routes":
2704
+ return {
2705
+ workouts: payload.workoutRoutes?.length ?? 0,
2706
+ routePoints: payload.workoutRoutes?.reduce((sum, entry) => sum + entry.routePoints.length, 0) ?? 0
2707
+ };
2708
+ case "workout_tombstones":
2709
+ return { workouts: payload.workoutTombstones?.length ?? 0 };
2710
+ case "vitals":
2711
+ return { daySummaries: payload.vitals?.daySummaries.length ?? 0 };
2712
+ case "movement":
2713
+ return {
2714
+ stays: payload.movement?.stays.length ?? 0,
2715
+ trips: payload.movement?.trips.length ?? 0
2716
+ };
2717
+ case "screen_time":
2718
+ return {
2719
+ daySummaries: payload.screenTime?.daySummaries.length ?? 0,
2720
+ hourlySegments: payload.screenTime?.hourlySegments.length ?? 0
2721
+ };
2722
+ }
2723
+ }
2724
+ function updateMobileSyncSessionProgress(syncSessionId) {
2725
+ const chunks = getDatabase()
2726
+ .prepare(`SELECT family, record_count, byte_count
2727
+ FROM health_mobile_sync_chunks
2728
+ WHERE sync_session_id = ?`)
2729
+ .all(syncSessionId);
2730
+ const receivedCounts = {};
2731
+ const byteTotals = {};
2732
+ for (const chunk of chunks) {
2733
+ receivedCounts[chunk.family] =
2734
+ (receivedCounts[chunk.family] ?? 0) + chunk.record_count;
2735
+ byteTotals[chunk.family] =
2736
+ (byteTotals[chunk.family] ?? 0) + chunk.byte_count;
2737
+ }
2738
+ getDatabase()
2739
+ .prepare(`UPDATE health_mobile_sync_sessions
2740
+ SET received_counts_json = ?, byte_totals_json = ?, updated_at = ?
2741
+ WHERE id = ?`)
2742
+ .run(JSON.stringify(receivedCounts), JSON.stringify(byteTotals), nowIso(), syncSessionId);
2743
+ return {
2744
+ receivedCounts,
2745
+ byteTotals,
2746
+ chunkCount: chunks.length,
2747
+ receivedBytes: chunks.reduce((sum, chunk) => sum + chunk.byte_count, 0)
2748
+ };
2749
+ }
2750
+ export function startMobileHealthSyncSession(payload) {
2751
+ const parsed = mobileHealthSyncSessionStartSchema.parse(payload);
2752
+ if (parsed.schemaVersion !== HEALTH_MOBILE_SYNC_SCHEMA_VERSION) {
2753
+ throw new HttpError(409, "schema_version_unsupported", "The companion HealthKit sync protocol is not supported by this Forge runtime.", {
2754
+ schemaVersion: HEALTH_MOBILE_SYNC_SCHEMA_VERSION,
2755
+ requestedSchemaVersion: parsed.schemaVersion
2756
+ });
2757
+ }
2758
+ expireStaleMobileSyncSessions();
2759
+ const pairing = requireValidPairing(parsed.sessionId, parsed.pairingToken);
2760
+ const resumeSyncSessionId = typeof parsed.metadata.resumeSyncSessionId === "string"
2761
+ ? parsed.metadata.resumeSyncSessionId.trim()
2762
+ : "";
2763
+ if (resumeSyncSessionId.length > 0) {
2764
+ const existing = readMobileSyncSession(resumeSyncSessionId);
2765
+ if (existing &&
2766
+ existing.pairing_session_id === pairing.id &&
2767
+ existing.status === "running") {
2768
+ const receivedChunkIds = getDatabase()
2769
+ .prepare(`SELECT chunk_id
2770
+ FROM health_mobile_sync_chunks
2771
+ WHERE sync_session_id = ?
2772
+ ORDER BY sequence ASC`)
2773
+ .all(resumeSyncSessionId)
2774
+ .map((row) => row.chunk_id);
2775
+ return {
2776
+ syncSessionId: resumeSyncSessionId,
2777
+ schemaVersion: HEALTH_MOBILE_SYNC_SCHEMA_VERSION,
2778
+ chunkTargetBytes: HEALTH_MOBILE_SYNC_CHUNK_TARGET_BYTES,
2779
+ chunkMaxBytes: HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES,
2780
+ supportsCompression: false,
2781
+ acceptedFamilies: safeJsonParse(existing.requested_families_json, parsed.requestedFamilies),
2782
+ receivedChunkIds
2783
+ };
2784
+ }
2785
+ }
2786
+ const now = nowIso();
2787
+ const syncSessionId = mobileSyncSessionId();
2788
+ getDatabase()
2789
+ .prepare(`INSERT INTO health_mobile_sync_sessions (
2790
+ id, pairing_session_id, user_id, status, schema_version,
2791
+ requested_families_json, source_metadata_json, expected_counts_json,
2792
+ received_counts_json, byte_totals_json, started_at, created_at, updated_at
2793
+ )
2794
+ VALUES (?, ?, ?, 'running', ?, ?, ?, ?, '{}', '{}', ?, ?, ?)`)
2795
+ .run(syncSessionId, pairing.id, pairing.user_id, HEALTH_MOBILE_SYNC_SCHEMA_VERSION, JSON.stringify(parsed.requestedFamilies), JSON.stringify({
2796
+ sessionId: parsed.sessionId,
2797
+ device: parsed.device,
2798
+ permissions: parsed.permissions,
2799
+ sourceStates: parsed.sourceStates,
2800
+ metadata: parsed.metadata
2801
+ }), JSON.stringify(parsed.expectedCounts), now, now, now);
2802
+ return {
2803
+ syncSessionId,
2804
+ schemaVersion: HEALTH_MOBILE_SYNC_SCHEMA_VERSION,
2805
+ chunkTargetBytes: HEALTH_MOBILE_SYNC_CHUNK_TARGET_BYTES,
2806
+ chunkMaxBytes: HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES,
2807
+ supportsCompression: false,
2808
+ acceptedFamilies: parsed.requestedFamilies,
2809
+ receivedChunkIds: []
2810
+ };
2811
+ }
2812
+ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJson) {
2813
+ const parsed = mobileHealthSyncChunkSchema.parse(payload);
2814
+ const session = ensureRunningMobileSyncSession(syncSessionId);
2815
+ const requestedFamilies = safeJsonParse(session.requested_families_json, []);
2816
+ if (requestedFamilies.length > 0 &&
2817
+ !requestedFamilies.includes(parsed.family)) {
2818
+ throw new HttpError(400, "unsupported_family", "This sync session did not request that HealthKit family.", { family: parsed.family });
2819
+ }
2820
+ const existing = getDatabase()
2821
+ .prepare(`SELECT * FROM health_mobile_sync_chunks
2822
+ WHERE sync_session_id = ? AND chunk_id = ?`)
2823
+ .get(syncSessionId, parsed.chunkId);
2824
+ if (existing) {
2825
+ if (existing.checksum_sha256 !== parsed.checksumSha256) {
2826
+ throw new HttpError(409, "chunk_checksum_mismatch", "A chunk with the same id was already accepted with different content.");
2827
+ }
2828
+ const progress = updateMobileSyncSessionProgress(syncSessionId);
2829
+ return {
2830
+ accepted: true,
2831
+ duplicate: true,
2832
+ receivedCount: progress.chunkCount,
2833
+ receivedBytes: progress.receivedBytes,
2834
+ progress
2835
+ };
2836
+ }
2837
+ const payloadJson = chunkPayloadJson(parsed.payload);
2838
+ const checksumPayloadJson = rawPayloadJson ?? payloadJson;
2839
+ const actualByteCount = Buffer.byteLength(checksumPayloadJson, "utf8");
2840
+ if (actualByteCount > HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES) {
2841
+ throw new HttpError(413, "chunk_too_large", "The HealthKit sync chunk is too large.", {
2842
+ maxBytes: HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES,
2843
+ actualBytes: actualByteCount
2844
+ });
2845
+ }
2846
+ const serverChecksum = chunkPayloadChecksum(checksumPayloadJson);
2847
+ if (parsed.checksumSha256 !== serverChecksum) {
2848
+ throw new HttpError(409, "chunk_checksum_mismatch", "The HealthKit sync chunk checksum does not match its payload.", { actualBytes: actualByteCount });
2849
+ }
2850
+ const now = nowIso();
2851
+ getDatabase()
2852
+ .prepare(`INSERT INTO health_mobile_sync_chunks (
2853
+ id, sync_session_id, chunk_id, sequence, family, checksum_sha256,
2854
+ record_count, byte_count, payload_json, payload_summary_json,
2855
+ received_at, applied_at, created_at, updated_at
2856
+ )
2857
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2858
+ .run(mobileSyncChunkRecordId(), syncSessionId, parsed.chunkId, parsed.sequence, parsed.family, serverChecksum, parsed.recordCount, parsed.byteCount || actualByteCount, payloadJson, JSON.stringify({
2859
+ ...summarizeChunkPayload(parsed.family, parsed.payload),
2860
+ clientByteCount: parsed.byteCount,
2861
+ actualByteCount,
2862
+ serverChecksum
2863
+ }), now, now, now, now);
2864
+ const progress = updateMobileSyncSessionProgress(syncSessionId);
2865
+ return {
2866
+ accepted: true,
2867
+ duplicate: false,
2868
+ receivedCount: progress.chunkCount,
2869
+ receivedBytes: progress.receivedBytes,
2870
+ progress
2871
+ };
2872
+ }
2873
+ function mergeMobileHealthSyncChunks(session, chunks) {
2874
+ const pairing = getDatabase()
2875
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
2876
+ .get(session.pairing_session_id);
2877
+ if (!pairing) {
2878
+ throw new HttpError(404, "pairing_invalid", "The sync pairing no longer exists.");
2879
+ }
2880
+ const metadata = safeJsonParse(session.source_metadata_json, {});
2881
+ const assembled = mobileHealthSyncSchema.parse({
2882
+ sessionId: metadata.sessionId ?? pairing.id,
2883
+ pairingToken: pairing.pairing_token,
2884
+ device: metadata.device ??
2885
+ {
2886
+ name: pairing.device_name ?? "iPhone",
2887
+ platform: pairing.platform ?? "ios",
2888
+ appVersion: pairing.app_version ?? "",
2889
+ sourceDevice: pairing.device_name ?? "iPhone"
2890
+ },
2891
+ permissions: metadata.permissions ??
2892
+ {
2893
+ healthKitAuthorized: false,
2894
+ backgroundRefreshEnabled: false,
2895
+ motionReady: false,
2896
+ locationReady: false,
2897
+ screenTimeReady: false
2898
+ },
2899
+ sourceStates: metadata.sourceStates,
2900
+ sleepSessions: [],
2901
+ sleepNights: [],
2902
+ sleepSegments: [],
2903
+ sleepRawRecords: [],
2904
+ workouts: [],
2905
+ vitals: { daySummaries: [] },
2906
+ movement: {},
2907
+ screenTime: {}
2908
+ });
2909
+ const workoutsByExternalUid = new Map();
2910
+ const tombstones = [];
2911
+ for (const chunk of chunks.sort((left, right) => left.sequence - right.sequence)) {
2912
+ const payload = mobileHealthSyncChunkPayloadSchema.parse(safeJsonParse(chunk.payload_json, {}));
2913
+ if (payload.sleepNights) {
2914
+ assembled.sleepNights.push(...payload.sleepNights);
2915
+ }
2916
+ if (payload.sleepSegments) {
2917
+ assembled.sleepSegments.push(...payload.sleepSegments);
2918
+ }
2919
+ if (payload.sleepRawRecords) {
2920
+ assembled.sleepRawRecords.push(...payload.sleepRawRecords);
2921
+ }
2922
+ if (payload.workouts) {
2923
+ for (const workout of payload.workouts) {
2924
+ const previous = workoutsByExternalUid.get(workout.externalUid);
2925
+ workoutsByExternalUid.set(workout.externalUid, {
2926
+ ...(previous ?? workout),
2927
+ ...workout,
2928
+ timeSeriesSamples: [
2929
+ ...(previous?.timeSeriesSamples ?? []),
2930
+ ...workout.timeSeriesSamples
2931
+ ],
2932
+ routePoints: [
2933
+ ...(previous?.routePoints ?? []),
2934
+ ...workout.routePoints
2935
+ ]
2936
+ });
2937
+ }
2938
+ }
2939
+ if (payload.workoutTimeSeries) {
2940
+ for (const entry of payload.workoutTimeSeries) {
2941
+ const workout = workoutsByExternalUid.get(entry.externalUid);
2942
+ if (!workout) {
2943
+ throw new HttpError(409, "missing_required_chunks", "A workout time-series chunk arrived without a matching workout summary.", { workoutExternalUid: entry.externalUid });
2944
+ }
2945
+ workout.timeSeriesSamples.push(...entry.samples);
2946
+ }
2947
+ }
2948
+ if (payload.workoutRoutes) {
2949
+ for (const entry of payload.workoutRoutes) {
2950
+ const workout = workoutsByExternalUid.get(entry.externalUid);
2951
+ if (!workout) {
2952
+ throw new HttpError(409, "missing_required_chunks", "A workout route chunk arrived without a matching workout summary.", { workoutExternalUid: entry.externalUid });
2953
+ }
2954
+ workout.routePoints.push(...entry.routePoints);
2955
+ }
2956
+ }
2957
+ if (payload.workoutTombstones) {
2958
+ tombstones.push(...payload.workoutTombstones);
2959
+ }
2960
+ if (payload.vitals) {
2961
+ assembled.vitals.daySummaries.push(...payload.vitals.daySummaries);
2962
+ }
2963
+ if (payload.movement) {
2964
+ assembled.movement.settings = payload.movement.settings;
2965
+ assembled.movement.knownPlaces.push(...payload.movement.knownPlaces);
2966
+ assembled.movement.stays.push(...payload.movement.stays);
2967
+ assembled.movement.trips.push(...payload.movement.trips);
2968
+ }
2969
+ if (payload.screenTime) {
2970
+ assembled.screenTime.settings = payload.screenTime.settings;
2971
+ assembled.screenTime.daySummaries.push(...payload.screenTime.daySummaries);
2972
+ assembled.screenTime.hourlySegments.push(...payload.screenTime.hourlySegments);
2973
+ }
2974
+ }
2975
+ assembled.workouts = [...workoutsByExternalUid.values()].sort((left, right) => right.startedAt.localeCompare(left.startedAt));
2976
+ return { assembled, tombstones };
2977
+ }
2978
+ function applyWorkoutTombstones(pairing, tombstones) {
2979
+ if (tombstones.length === 0) {
2980
+ return 0;
2981
+ }
2982
+ const deleteStmt = getDatabase().prepare(`DELETE FROM health_workout_sessions
2983
+ WHERE user_id = ? AND source = 'apple_health' AND external_uid = ?`);
2984
+ let deleted = 0;
2985
+ for (const tombstone of tombstones) {
2986
+ const result = deleteStmt.run(pairing.user_id, tombstone.externalUid);
2987
+ deleted += Number(result.changes ?? 0);
2988
+ }
2989
+ return deleted;
2990
+ }
2991
+ function upsertMobileSyncFamilyCursors(pairing, finalCursor) {
2992
+ const now = nowIso();
2993
+ const stmt = getDatabase().prepare(`INSERT INTO health_mobile_sync_family_cursors (
2994
+ id, pairing_session_id, user_id, family, cursor_json, updated_at
2995
+ )
2996
+ VALUES (?, ?, ?, ?, ?, ?)
2997
+ ON CONFLICT(pairing_session_id, family)
2998
+ DO UPDATE SET cursor_json = excluded.cursor_json, updated_at = excluded.updated_at`);
2999
+ for (const [family, cursor] of Object.entries(finalCursor)) {
3000
+ stmt.run(`hmscur_${randomUUID().replaceAll("-", "").slice(0, 10)}`, pairing.id, pairing.user_id, family, JSON.stringify(cursor), now);
3001
+ }
3002
+ }
3003
+ export function completeMobileHealthSyncSession(syncSessionId, payload) {
3004
+ const parsed = mobileHealthSyncSessionCompleteSchema.parse(payload);
3005
+ const session = ensureRunningMobileSyncSession(syncSessionId);
3006
+ const chunks = getDatabase()
3007
+ .prepare(`SELECT * FROM health_mobile_sync_chunks
3008
+ WHERE sync_session_id = ?
3009
+ ORDER BY sequence ASC`)
3010
+ .all(syncSessionId);
3011
+ if (chunks.length === 0) {
3012
+ throw new HttpError(409, "missing_required_chunks", "The HealthKit sync session has no accepted chunks.");
3013
+ }
3014
+ const pairing = getDatabase()
3015
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
3016
+ .get(session.pairing_session_id);
3017
+ try {
3018
+ const { assembled, tombstones } = mergeMobileHealthSyncChunks(session, chunks);
3019
+ const sync = ingestMobileHealthSync(assembled);
3020
+ const deletedWorkoutCount = applyWorkoutTombstones(pairing, tombstones);
3021
+ upsertMobileSyncFamilyCursors(pairing, parsed.finalCursor);
3022
+ const now = nowIso();
3023
+ getDatabase()
3024
+ .prepare(`UPDATE health_mobile_sync_sessions
3025
+ SET status = 'completed', expected_counts_json = ?, completed_at = ?,
3026
+ updated_at = ?
3027
+ WHERE id = ?`)
3028
+ .run(JSON.stringify(parsed.expectedCounts), now, now, syncSessionId);
3029
+ return {
3030
+ ...sync,
3031
+ upload: {
3032
+ syncSessionId,
3033
+ chunks: chunks.length,
3034
+ deletedWorkoutCount
3035
+ }
3036
+ };
3037
+ }
3038
+ catch (error) {
3039
+ const now = nowIso();
3040
+ getDatabase()
3041
+ .prepare(`UPDATE health_mobile_sync_sessions
3042
+ SET status = 'failed', failed_at = ?, error_json = ?, updated_at = ?
3043
+ WHERE id = ?`)
3044
+ .run(now, JSON.stringify({
3045
+ message: error instanceof Error ? error.message : String(error)
3046
+ }), now, syncSessionId);
3047
+ throw error;
3048
+ }
3049
+ }
3050
+ export function abortMobileHealthSyncSession(syncSessionId) {
3051
+ const session = ensureRunningMobileSyncSession(syncSessionId);
3052
+ const now = nowIso();
3053
+ getDatabase()
3054
+ .prepare(`UPDATE health_mobile_sync_sessions
3055
+ SET status = 'aborted', aborted_at = ?, updated_at = ?
3056
+ WHERE id = ?`)
3057
+ .run(now, now, session.id);
3058
+ return { syncSessionId, status: "aborted" };
3059
+ }
2500
3060
  export function ingestMobileHealthSync(payload) {
2501
3061
  const parsed = mobileHealthSyncSchema.parse(payload);
2502
3062
  ensureLegacyAppleSleepHistoryRepaired();
@@ -2920,6 +3480,42 @@ export function getFitnessViewData(userIds) {
2920
3480
  workoutTypeBreakdown.set(session.workoutType, current);
2921
3481
  }
2922
3482
  const orderedWorkoutTypes = [...workoutTypeBreakdown.entries()].sort((left, right) => right[1].totalMinutes - left[1].totalMinutes);
3483
+ const zoneTotals = new Map();
3484
+ let totalZoneSeconds = 0;
3485
+ let heartRateCoverageSum = 0;
3486
+ let heartRateCoverageCount = 0;
3487
+ let totalTrainingLoad = 0;
3488
+ let routeWorkoutCount = 0;
3489
+ for (const session of recent) {
3490
+ const analytics = session.analytics;
3491
+ for (const zone of analytics?.zoneDurations ?? []) {
3492
+ const current = zoneTotals.get(zone.key) ?? {
3493
+ label: zone.label,
3494
+ seconds: 0
3495
+ };
3496
+ current.seconds += zone.seconds;
3497
+ totalZoneSeconds += zone.seconds;
3498
+ zoneTotals.set(zone.key, current);
3499
+ }
3500
+ if (typeof analytics?.dataQuality?.sampleCoverage === "number") {
3501
+ heartRateCoverageSum += analytics.dataQuality.sampleCoverage;
3502
+ heartRateCoverageCount += 1;
3503
+ }
3504
+ if (typeof analytics?.load?.trimp === "number") {
3505
+ totalTrainingLoad += analytics.load.trimp;
3506
+ }
3507
+ if (analytics?.routeSummary?.hasRoute) {
3508
+ routeWorkoutCount += 1;
3509
+ }
3510
+ }
3511
+ const zoneMix = [...zoneTotals.entries()].map(([key, value]) => ({
3512
+ key,
3513
+ label: value.label,
3514
+ seconds: Math.round(value.seconds),
3515
+ percentage: totalZoneSeconds > 0
3516
+ ? Number((value.seconds / totalZoneSeconds).toFixed(4))
3517
+ : 0
3518
+ }));
2923
3519
  return {
2924
3520
  summary: {
2925
3521
  workoutCount: weekly.length,
@@ -2940,7 +3536,13 @@ export function getFitnessViewData(userIds) {
2940
3536
  topWorkoutType: orderedWorkoutTypes[0]?.[0] ?? null,
2941
3537
  topWorkoutTypeLabel: recent.find((session) => session.workoutType === orderedWorkoutTypes[0]?.[0])
2942
3538
  ?.workoutTypeLabel ?? null,
2943
- streakDays: Array.from(new Set(weekly.map((session) => dayKey(session.startedAt)))).length
3539
+ streakDays: Array.from(new Set(weekly.map((session) => dayKey(session.startedAt)))).length,
3540
+ averageHeartRateCoverage: heartRateCoverageCount > 0
3541
+ ? Number((heartRateCoverageSum / heartRateCoverageCount).toFixed(3))
3542
+ : 0,
3543
+ totalTrainingLoad: Number(totalTrainingLoad.toFixed(1)),
3544
+ routeWorkoutCount,
3545
+ zoneMix
2944
3546
  },
2945
3547
  weeklyTrend: weekly
2946
3548
  .map((session) => ({
@@ -2951,7 +3553,12 @@ export function getFitnessViewData(userIds) {
2951
3553
  activityFamily: session.activityFamily,
2952
3554
  activityFamilyLabel: session.activityFamilyLabel,
2953
3555
  durationMinutes: Math.round(session.durationSeconds / 60),
2954
- energyKcal: Math.round(session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0)
3556
+ energyKcal: Math.round(session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0),
3557
+ zoneDurations: session.analytics
3558
+ ?.zoneDurations ?? [],
3559
+ trainingLoad: (session.analytics
3560
+ ?.load?.trimp ?? null),
3561
+ heartRateCoverage: (session.analytics?.dataQuality?.sampleCoverage ?? 0)
2955
3562
  }))
2956
3563
  .reverse(),
2957
3564
  typeBreakdown: orderedWorkoutTypes.map(([workoutType, metrics]) => ({
@@ -3482,6 +4089,7 @@ export function deleteWorkoutSession(workoutId, activity) {
3482
4089
  if (!current) {
3483
4090
  return undefined;
3484
4091
  }
4092
+ const deletedWorkout = mapWorkoutSession(current);
3485
4093
  getDatabase()
3486
4094
  .prepare(`DELETE FROM health_workout_sessions WHERE id = ?`)
3487
4095
  .run(workoutId);
@@ -3499,7 +4107,7 @@ export function deleteWorkoutSession(workoutId, activity) {
3499
4107
  startedAt: current.started_at
3500
4108
  }
3501
4109
  });
3502
- return mapWorkoutSession(current);
4110
+ return deletedWorkout;
3503
4111
  }
3504
4112
  export function updateWorkoutMetadata(workoutId, patch, activity) {
3505
4113
  const parsed = updateWorkoutMetadataSchema.parse(patch);