forge-openclaw-plugin 0.2.69 → 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.
- package/dist/server/server/migrations/062_health_mobile_sync_sessions.sql +55 -0
- package/dist/server/server/src/app.js +88 -14
- package/dist/server/server/src/health.js +496 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/server/migrations/062_health_mobile_sync_sessions.sql +55 -0
- package/skills/forge-openclaw/SKILL.md +10 -5
- package/skills/forge-openclaw/entity_conversation_playbooks.md +133 -4
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS health_mobile_sync_sessions (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
pairing_session_id TEXT NOT NULL REFERENCES companion_pairing_sessions(id) ON DELETE CASCADE,
|
|
4
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
5
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
6
|
+
schema_version TEXT NOT NULL DEFAULT 'healthkit-sync-v2',
|
|
7
|
+
requested_families_json TEXT NOT NULL DEFAULT '[]',
|
|
8
|
+
source_metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
9
|
+
expected_counts_json TEXT NOT NULL DEFAULT '{}',
|
|
10
|
+
received_counts_json TEXT NOT NULL DEFAULT '{}',
|
|
11
|
+
byte_totals_json TEXT NOT NULL DEFAULT '{}',
|
|
12
|
+
affected_workout_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
13
|
+
error_json TEXT NOT NULL DEFAULT '{}',
|
|
14
|
+
started_at TEXT NOT NULL,
|
|
15
|
+
completed_at TEXT,
|
|
16
|
+
failed_at TEXT,
|
|
17
|
+
aborted_at TEXT,
|
|
18
|
+
expired_at TEXT,
|
|
19
|
+
created_at TEXT NOT NULL,
|
|
20
|
+
updated_at TEXT NOT NULL
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_health_mobile_sync_sessions_pairing_status
|
|
24
|
+
ON health_mobile_sync_sessions(pairing_session_id, status, started_at DESC);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS health_mobile_sync_chunks (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
sync_session_id TEXT NOT NULL REFERENCES health_mobile_sync_sessions(id) ON DELETE CASCADE,
|
|
29
|
+
chunk_id TEXT NOT NULL,
|
|
30
|
+
sequence INTEGER NOT NULL,
|
|
31
|
+
family TEXT NOT NULL,
|
|
32
|
+
checksum_sha256 TEXT NOT NULL,
|
|
33
|
+
record_count INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
byte_count INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
36
|
+
payload_summary_json TEXT NOT NULL DEFAULT '{}',
|
|
37
|
+
received_at TEXT NOT NULL,
|
|
38
|
+
applied_at TEXT,
|
|
39
|
+
created_at TEXT NOT NULL,
|
|
40
|
+
updated_at TEXT NOT NULL,
|
|
41
|
+
UNIQUE(sync_session_id, chunk_id)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_health_mobile_sync_chunks_session_sequence
|
|
45
|
+
ON health_mobile_sync_chunks(sync_session_id, sequence);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS health_mobile_sync_family_cursors (
|
|
48
|
+
id TEXT PRIMARY KEY,
|
|
49
|
+
pairing_session_id TEXT NOT NULL REFERENCES companion_pairing_sessions(id) ON DELETE CASCADE,
|
|
50
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
51
|
+
family TEXT NOT NULL,
|
|
52
|
+
cursor_json TEXT NOT NULL DEFAULT '{}',
|
|
53
|
+
updated_at TEXT NOT NULL,
|
|
54
|
+
UNIQUE(pairing_session_id, family)
|
|
55
|
+
);
|
|
@@ -66,7 +66,7 @@ import { registerWebRoutes } from "./web.js";
|
|
|
66
66
|
import { createManagerRuntime } from "./managers/runtime.js";
|
|
67
67
|
import { isManagerError } from "./managers/type-guards.js";
|
|
68
68
|
import { buildCompanionPairingTransport, getCompanionIrohStatus, stopCompanionIroh } from "./services/companion-iroh.js";
|
|
69
|
-
import { createCompanionPairingSession, createCompanionPairingSessionSchema, createSleepSession, createSleepSessionSchema, createWorkoutSession, createWorkoutSessionSchema, deleteSleepSession, deleteWorkoutSession, getCompanionPairingSessionById, getCompanionOverview, getFitnessViewData, getSleepSessionById, getSleepSessionDetailById, getSleepTimelineOverlaysForRange, getSleepViewData, getVitalsViewData, getHealthZoneProfileForUser, getWorkoutSessionById, getWorkoutSessionDetailById, heartbeatCompanionPairing, heartbeatCompanionPairingSchema, healthZoneProfilePatchSchema, ingestMobileHealthSync, mobileHealthSyncSchema, patchHealthZoneProfileForUser, patchCompanionPairingSourceState, patchCompanionPairingSourceStateSchema, companionSourceKeySchema, requireValidPairing, revokeAllCompanionPairingSessions, revokeAllCompanionPairingSessionsSchema, revokeCompanionPairingSession, updateMobileCompanionSourceState, updateMobileCompanionSourceStateSchema, verifyCompanionPairing, verifyCompanionPairingSchema, updateSleepMetadata, updateSleepMetadataSchema, updateWorkoutMetadata, updateWorkoutMetadataSchema } from "./health.js";
|
|
69
|
+
import { createCompanionPairingSession, createCompanionPairingSessionSchema, createSleepSession, createSleepSessionSchema, createWorkoutSession, createWorkoutSessionSchema, deleteSleepSession, deleteWorkoutSession, getCompanionPairingSessionById, getCompanionOverview, getFitnessViewData, getSleepSessionById, getSleepSessionDetailById, getSleepTimelineOverlaysForRange, getSleepViewData, getVitalsViewData, getHealthZoneProfileForUser, getWorkoutSessionById, getWorkoutSessionDetailById, heartbeatCompanionPairing, heartbeatCompanionPairingSchema, healthZoneProfilePatchSchema, abortMobileHealthSyncSession, completeMobileHealthSyncSession, ingestMobileHealthSync, ingestMobileHealthSyncChunk, mobileHealthSyncChunkSchema, mobileHealthSyncSessionCompleteSchema, mobileHealthSyncSessionStartSchema, mobileHealthSyncSchema, patchHealthZoneProfileForUser, patchCompanionPairingSourceState, patchCompanionPairingSourceStateSchema, companionSourceKeySchema, requireValidPairing, startMobileHealthSyncSession, revokeAllCompanionPairingSessions, revokeAllCompanionPairingSessionsSchema, revokeCompanionPairingSession, updateMobileCompanionSourceState, updateMobileCompanionSourceStateSchema, verifyCompanionPairing, verifyCompanionPairingSchema, updateSleepMetadata, updateSleepMetadataSchema, updateWorkoutMetadata, updateWorkoutMetadataSchema } from "./health.js";
|
|
70
70
|
import { analyzeMovementUserBoxPreflight, createMovementUserBox, createMovementPlace, deleteMovementUserBox, getMovementAllTimeSummary, getMovementBoxDetail, getMovementDayDetail, getMovementMobileBootstrap, getMovementTimeline, getMovementSelectionAggregate, getMovementSettings, getMovementTripDetail, getMovementMonthSummary, invalidateAutomaticMovementBox, listMovementPlaces, movementAutomaticBoxInvalidateSchema, movementMobileBootstrapSchema, movementMobilePlaceMutationSchema, movementMobileStayPatchSchema, movementMobileUserBoxCreateSchema, movementMobileUserBoxPreflightSchema, movementMobileUserBoxPatchSchema, movementMobileAutomaticBoxInvalidateSchema, movementMobileTimelineSchema, movementPlaceMutationSchema, movementPlacePatchSchema, movementSelectionAggregateSchema, movementStayPatchSchema, movementTripPatchSchema, movementUserBoxCreateSchema, movementUserBoxPreflightSchema, movementUserBoxPatchSchema, movementSettingsPatchSchema, movementTimelineQuerySchema, movementTripPointPatchSchema, deleteMovementStay, deleteMovementTrip, deleteMovementTripPoint, updateMovementPlace, updateMovementSettings, updateMovementStay, updateMovementTrip, updateMovementUserBox, updateMovementTripPoint, resolveMovementTimelineSegmentForBox } from "./movement.js";
|
|
71
71
|
import { getScreenTimeAllTimeSummary, getScreenTimeDayDetail, getScreenTimeMonthSummary, getScreenTimeSettings, screenTimeSettingsPatchSchema, updateScreenTimeSettings } from "./screen-time.js";
|
|
72
72
|
import { assertWatchReady, buildWatchBootstrap, ingestWatchCaptureBatch, mobileWatchBootstrapSchema, mobileWatchCaptureBatchSchema, mobileWatchHabitCheckInSchema } from "./watch-mobile.js";
|
|
@@ -2969,6 +2969,18 @@ const AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS = [
|
|
|
2969
2969
|
"Ask about source or override reason only when that context matters."
|
|
2970
2970
|
]
|
|
2971
2971
|
},
|
|
2972
|
+
{
|
|
2973
|
+
focus: "calendar_overview",
|
|
2974
|
+
openingQuestion: "What are you trying to understand or decide from your calendar picture?",
|
|
2975
|
+
coachingGoal: "Review commitments, work blocks, provider state, and existing timeboxes before creating or changing calendar records.",
|
|
2976
|
+
askSequence: [
|
|
2977
|
+
"Ask what practical calendar question the user wants the overview to answer.",
|
|
2978
|
+
"Ask which day, week, date range, or owner scope matters only if it changes the read.",
|
|
2979
|
+
"Use forge_get_calendar_overview before asking the user to reconstruct availability from memory.",
|
|
2980
|
+
"Reflect the scheduling or planning decision the user is trying to make.",
|
|
2981
|
+
"Move to calendar_event, work_block_template, task_timebox, or calendar_connection only when one concrete follow-up action is visible."
|
|
2982
|
+
]
|
|
2983
|
+
},
|
|
2972
2984
|
{
|
|
2973
2985
|
focus: "calendar_connection",
|
|
2974
2986
|
openingQuestion: "Which calendar provider are you trying to connect, and what do you want Forge to do with it?",
|
|
@@ -3004,6 +3016,29 @@ const AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS = [
|
|
|
3004
3016
|
"Ask for a short audit note only if the reason would otherwise be unclear later."
|
|
3005
3017
|
]
|
|
3006
3018
|
},
|
|
3019
|
+
{
|
|
3020
|
+
focus: "operator_overview",
|
|
3021
|
+
openingQuestion: "What are you trying to understand about Forge overall right now?",
|
|
3022
|
+
coachingGoal: "Read the broad Forge state before choosing a specific entity action.",
|
|
3023
|
+
askSequence: [
|
|
3024
|
+
"Ask what broad Forge question the user wants the overview to answer.",
|
|
3025
|
+
"Ask which owner or user scope matters only if several humans or bots are in play.",
|
|
3026
|
+
"Use forge_get_operator_overview before reopening a create or update intake.",
|
|
3027
|
+
"Reflect the attention cue, priority, or handoff decision the overview should support.",
|
|
3028
|
+
"Move into a specific entity flow only when the overview points to a concrete goal, project, task, habit, note, Psyche record, or action."
|
|
3029
|
+
]
|
|
3030
|
+
},
|
|
3031
|
+
{
|
|
3032
|
+
focus: "operator_context",
|
|
3033
|
+
openingQuestion: "What current work, risk, or next move are you trying to check?",
|
|
3034
|
+
coachingGoal: "Inspect current work, active runs, risk, and next moves before changing records.",
|
|
3035
|
+
askSequence: [
|
|
3036
|
+
"Ask whether the user is checking current work, risk, blockers, active sessions, or the next move.",
|
|
3037
|
+
"Use forge_get_operator_context before mutating tasks, projects, runs, or notes when the current state is uncertain.",
|
|
3038
|
+
"Reflect whether the read is meant to decide continue, stop, reprioritize, update, or create.",
|
|
3039
|
+
"Move to task_run, work_adjustment, task, project, or note flow only when one concrete follow-up is visible."
|
|
3040
|
+
]
|
|
3041
|
+
},
|
|
3007
3042
|
{
|
|
3008
3043
|
focus: "self_observation",
|
|
3009
3044
|
openingQuestion: "What happened in the situation, and what did you feel, think, or do next?",
|
|
@@ -4592,7 +4627,8 @@ function buildAgentOnboardingPayload(request) {
|
|
|
4592
4627
|
calendar_overview: "/api/v1/calendar/overview",
|
|
4593
4628
|
operatorOverview: "/api/v1/operator/overview",
|
|
4594
4629
|
operator_overview: "/api/v1/operator/overview",
|
|
4595
|
-
operatorContext: "/api/v1/operator/context"
|
|
4630
|
+
operatorContext: "/api/v1/operator/context",
|
|
4631
|
+
operator_context: "/api/v1/operator/context"
|
|
4596
4632
|
}
|
|
4597
4633
|
},
|
|
4598
4634
|
multiUserModel: {
|
|
@@ -6355,6 +6391,8 @@ export async function buildServer(options = {}) {
|
|
|
6355
6391
|
app.setErrorHandler((error, request, reply) => {
|
|
6356
6392
|
const validationIssues = error instanceof ZodError ? formatValidationIssues(error) : undefined;
|
|
6357
6393
|
const routeUrl = request.routeOptions.url || request.url;
|
|
6394
|
+
const isBodyTooLarge = typeof error.code === "string" &&
|
|
6395
|
+
error.code === "FST_ERR_CTP_BODY_TOO_LARGE";
|
|
6358
6396
|
const validationHelp = validationIssues
|
|
6359
6397
|
? buildValidationHelp(request.method, routeUrl, validationIssues)
|
|
6360
6398
|
: undefined;
|
|
@@ -6364,20 +6402,24 @@ export async function buildServer(options = {}) {
|
|
|
6364
6402
|
? error.statusCode
|
|
6365
6403
|
: error instanceof ZodError
|
|
6366
6404
|
? 400
|
|
6367
|
-
:
|
|
6405
|
+
: isBodyTooLarge
|
|
6406
|
+
? 413
|
|
6407
|
+
: 500;
|
|
6368
6408
|
if (!shouldSkipAutomaticDiagnosticRoute(routeUrl)) {
|
|
6369
6409
|
try {
|
|
6370
6410
|
recordDiagnosticLog({
|
|
6371
6411
|
level: statusCode >= 500 ? "error" : "warning",
|
|
6372
6412
|
source: normalizeDiagnosticSource(request.headers["x-forge-source"]),
|
|
6373
6413
|
scope: "api_error",
|
|
6374
|
-
eventKey:
|
|
6375
|
-
?
|
|
6376
|
-
:
|
|
6414
|
+
eventKey: isBodyTooLarge
|
|
6415
|
+
? "payload_too_large"
|
|
6416
|
+
: isHttpError(error)
|
|
6377
6417
|
? error.code
|
|
6378
|
-
:
|
|
6379
|
-
?
|
|
6380
|
-
:
|
|
6418
|
+
: isManagerError(error)
|
|
6419
|
+
? error.code
|
|
6420
|
+
: statusCode === 400
|
|
6421
|
+
? "invalid_request"
|
|
6422
|
+
: "internal_error",
|
|
6381
6423
|
message: getErrorMessage(error),
|
|
6382
6424
|
route: routeUrl,
|
|
6383
6425
|
functionName: "setErrorHandler",
|
|
@@ -6401,13 +6443,25 @@ export async function buildServer(options = {}) {
|
|
|
6401
6443
|
? error.code
|
|
6402
6444
|
: isManagerError(error)
|
|
6403
6445
|
? error.code
|
|
6404
|
-
:
|
|
6405
|
-
? "
|
|
6406
|
-
:
|
|
6446
|
+
: isBodyTooLarge
|
|
6447
|
+
? "payload_too_large"
|
|
6448
|
+
: statusCode === 400
|
|
6449
|
+
? "invalid_request"
|
|
6450
|
+
: "internal_error",
|
|
6407
6451
|
error: validationIssues
|
|
6408
6452
|
? `Request validation failed for ${request.method.toUpperCase()} ${routeUrl}. ${validationHelp?.validationSummary ?? ""}`.trim()
|
|
6409
|
-
:
|
|
6453
|
+
: isBodyTooLarge
|
|
6454
|
+
? "The request body is too large. Use chunked HealthKit sync."
|
|
6455
|
+
: getErrorMessage(error),
|
|
6410
6456
|
statusCode,
|
|
6457
|
+
...(isBodyTooLarge
|
|
6458
|
+
? {
|
|
6459
|
+
recommendedMode: "chunked",
|
|
6460
|
+
maxBytes: typeof request.routeOptions.bodyLimit === "number"
|
|
6461
|
+
? request.routeOptions.bodyLimit
|
|
6462
|
+
: undefined
|
|
6463
|
+
}
|
|
6464
|
+
: {}),
|
|
6411
6465
|
...(validationIssues ? { details: validationIssues } : {}),
|
|
6412
6466
|
...(validationHelp ?? {}),
|
|
6413
6467
|
...(isHttpError(error) && error.details ? error.details : {}),
|
|
@@ -7343,7 +7397,27 @@ export async function buildServer(options = {}) {
|
|
|
7343
7397
|
watch: buildWatchBootstrap(pairing)
|
|
7344
7398
|
};
|
|
7345
7399
|
});
|
|
7346
|
-
app.post("/api/v1/mobile/healthkit/sync", async (request) => ({
|
|
7400
|
+
app.post("/api/v1/mobile/healthkit/sync-sessions", async (request) => ({
|
|
7401
|
+
upload: startMobileHealthSyncSession(mobileHealthSyncSessionStartSchema.parse(request.body ?? {}))
|
|
7402
|
+
}));
|
|
7403
|
+
app.post("/api/v1/mobile/healthkit/sync-sessions/:id/chunks", { bodyLimit: 1_250_000 }, async (request) => {
|
|
7404
|
+
const { id } = request.params;
|
|
7405
|
+
const rawPayloadJson = JSON.stringify((request.body ?? {}).payload ?? {});
|
|
7406
|
+
return {
|
|
7407
|
+
chunk: ingestMobileHealthSyncChunk(id, mobileHealthSyncChunkSchema.parse(request.body ?? {}), rawPayloadJson)
|
|
7408
|
+
};
|
|
7409
|
+
});
|
|
7410
|
+
app.post("/api/v1/mobile/healthkit/sync-sessions/:id/complete", async (request) => {
|
|
7411
|
+
const { id } = request.params;
|
|
7412
|
+
return {
|
|
7413
|
+
sync: completeMobileHealthSyncSession(id, mobileHealthSyncSessionCompleteSchema.parse(request.body ?? {}))
|
|
7414
|
+
};
|
|
7415
|
+
});
|
|
7416
|
+
app.delete("/api/v1/mobile/healthkit/sync-sessions/:id", async (request) => {
|
|
7417
|
+
const { id } = request.params;
|
|
7418
|
+
return { upload: abortMobileHealthSyncSession(id) };
|
|
7419
|
+
});
|
|
7420
|
+
app.post("/api/v1/mobile/healthkit/sync", { bodyLimit: 8_000_000 }, async (request) => ({
|
|
7347
7421
|
sync: ingestMobileHealthSync(mobileHealthSyncSchema.parse(request.body ?? {}))
|
|
7348
7422
|
}));
|
|
7349
7423
|
app.patch("/api/v1/health/workouts/:id", async (request, reply) => {
|
|
@@ -1,4 +1,4 @@
|
|
|
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";
|
|
@@ -259,6 +259,92 @@ export const mobileHealthSyncSchema = z.object({
|
|
|
259
259
|
movement: movementSyncPayloadSchema.default({}),
|
|
260
260
|
screenTime: screenTimeSyncPayloadSchema.default({})
|
|
261
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
|
+
});
|
|
262
348
|
export const verifyCompanionPairingSchema = z.object({
|
|
263
349
|
sessionId: z.string().trim().min(1),
|
|
264
350
|
pairingToken: z.string().trim().min(1),
|
|
@@ -2562,6 +2648,415 @@ function replaceHistoricalSleepSessionsForDate(userId, localDateKey, providerBac
|
|
|
2562
2648
|
.run(historical.id);
|
|
2563
2649
|
}
|
|
2564
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
|
+
}
|
|
2565
3060
|
export function ingestMobileHealthSync(payload) {
|
|
2566
3061
|
const parsed = mobileHealthSyncSchema.parse(payload);
|
|
2567
3062
|
ensureLegacyAppleSleepHistoryRepaired();
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "forge-openclaw-plugin",
|
|
3
3
|
"name": "Forge",
|
|
4
4
|
"description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.70",
|
|
6
6
|
"activation": {
|
|
7
7
|
"onStartup": true,
|
|
8
8
|
"onCapabilities": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forge-openclaw-plugin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.70",
|
|
4
4
|
"description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -105,6 +105,7 @@
|
|
|
105
105
|
"follow-redirects": "^1.16.0",
|
|
106
106
|
"hono": "^4.12.18",
|
|
107
107
|
"ip-address": "^10.2.0",
|
|
108
|
+
"ws": "^8.20.1",
|
|
108
109
|
"uuid": "^14.0.0"
|
|
109
110
|
},
|
|
110
111
|
"scripts": {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS health_mobile_sync_sessions (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
pairing_session_id TEXT NOT NULL REFERENCES companion_pairing_sessions(id) ON DELETE CASCADE,
|
|
4
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
5
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
6
|
+
schema_version TEXT NOT NULL DEFAULT 'healthkit-sync-v2',
|
|
7
|
+
requested_families_json TEXT NOT NULL DEFAULT '[]',
|
|
8
|
+
source_metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
9
|
+
expected_counts_json TEXT NOT NULL DEFAULT '{}',
|
|
10
|
+
received_counts_json TEXT NOT NULL DEFAULT '{}',
|
|
11
|
+
byte_totals_json TEXT NOT NULL DEFAULT '{}',
|
|
12
|
+
affected_workout_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
13
|
+
error_json TEXT NOT NULL DEFAULT '{}',
|
|
14
|
+
started_at TEXT NOT NULL,
|
|
15
|
+
completed_at TEXT,
|
|
16
|
+
failed_at TEXT,
|
|
17
|
+
aborted_at TEXT,
|
|
18
|
+
expired_at TEXT,
|
|
19
|
+
created_at TEXT NOT NULL,
|
|
20
|
+
updated_at TEXT NOT NULL
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_health_mobile_sync_sessions_pairing_status
|
|
24
|
+
ON health_mobile_sync_sessions(pairing_session_id, status, started_at DESC);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS health_mobile_sync_chunks (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
sync_session_id TEXT NOT NULL REFERENCES health_mobile_sync_sessions(id) ON DELETE CASCADE,
|
|
29
|
+
chunk_id TEXT NOT NULL,
|
|
30
|
+
sequence INTEGER NOT NULL,
|
|
31
|
+
family TEXT NOT NULL,
|
|
32
|
+
checksum_sha256 TEXT NOT NULL,
|
|
33
|
+
record_count INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
byte_count INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
36
|
+
payload_summary_json TEXT NOT NULL DEFAULT '{}',
|
|
37
|
+
received_at TEXT NOT NULL,
|
|
38
|
+
applied_at TEXT,
|
|
39
|
+
created_at TEXT NOT NULL,
|
|
40
|
+
updated_at TEXT NOT NULL,
|
|
41
|
+
UNIQUE(sync_session_id, chunk_id)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_health_mobile_sync_chunks_session_sequence
|
|
45
|
+
ON health_mobile_sync_chunks(sync_session_id, sequence);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS health_mobile_sync_family_cursors (
|
|
48
|
+
id TEXT PRIMARY KEY,
|
|
49
|
+
pairing_session_id TEXT NOT NULL REFERENCES companion_pairing_sessions(id) ON DELETE CASCADE,
|
|
50
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
51
|
+
family TEXT NOT NULL,
|
|
52
|
+
cursor_json TEXT NOT NULL DEFAULT '{}',
|
|
53
|
+
updated_at TEXT NOT NULL,
|
|
54
|
+
UNIQUE(pairing_session_id, family)
|
|
55
|
+
);
|
|
@@ -185,11 +185,16 @@ Health rule:
|
|
|
185
185
|
|
|
186
186
|
- Sleep and sports records are first-class health surfaces, not generic notes or tasks.
|
|
187
187
|
- Use `forge_get_sleep_overview` and `forge_get_sports_overview` for review and trend reading.
|
|
188
|
-
- In `forge_get_agent_onboarding.entityRouteModel.readModelOnlySurfaces`,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
`
|
|
192
|
-
|
|
188
|
+
- In `forge_get_agent_onboarding.entityRouteModel.readModelOnlySurfaces`, operator,
|
|
189
|
+
calendar, self-observation, sleep, and sports read models are published with
|
|
190
|
+
both camelCase names and entity-style aliases where useful, including
|
|
191
|
+
`operatorOverview`, `operatorContext`, `calendarOverview`, `sleepOverview`,
|
|
192
|
+
`sportsOverview`, `operator_overview`, `operator_context`,
|
|
193
|
+
`calendar_overview`, `self_observation`, `sleep_overview`, and
|
|
194
|
+
`sports_overview`. Treat those as read-only surfaces, not batch CRUD entities.
|
|
195
|
+
- Use `forge_get_operator_overview` for a broad Forge status read, `forge_get_operator_context`
|
|
196
|
+
for current work and risk, and `forge_get_calendar_overview` before calendar-aware
|
|
197
|
+
planning or scheduling mutations.
|
|
193
198
|
- Use the shared batch entity tools for ordinary `sleep_session` and `workout_session` create, update, delete, and search work. Do not force agents into a large one-route-per-entity mental model when the batch routes already cover the record cleanly.
|
|
194
199
|
- Use `forge_update_sleep_session` and `forge_update_workout_session` only when the job is reflective enrichment on one existing health record after review, such as attaching notes, tags, mood, meaning, or Forge links.
|
|
195
200
|
- Habit-generated workouts and imported HealthKit workouts belong to the same workout record model, so do not invent a separate storage path for sport sessions.
|
|
@@ -237,10 +237,11 @@ Use this quick split before the conversation gets too detailed.
|
|
|
237
237
|
`preference_signal`, and `self_observation` are action workflows. Start from what
|
|
238
238
|
the user is trying to do, then use the dedicated action tool or note-backed write
|
|
239
239
|
model.
|
|
240
|
-
- `
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
240
|
+
- `operator_overview`, `operator_context`, `calendar_overview`, `sleep_overview`,
|
|
241
|
+
and `sports_overview` are read-model-only surfaces. Use them when the user wants
|
|
242
|
+
to understand current Forge state, work risk, calendar commitments, nights,
|
|
243
|
+
workouts, training load, recovery context, or health patterns before deciding
|
|
244
|
+
whether a stored entity needs creation or enrichment.
|
|
244
245
|
- Movement, Life Force, and Workbench are specialized domain areas. Use their
|
|
245
246
|
dedicated route families for timelines and overlays, energy profile/templates and
|
|
246
247
|
fatigue signals, and Workbench flow execution or result artifacts. When available,
|
|
@@ -282,6 +283,19 @@ still knowing the exact write/read family before it acts.
|
|
|
282
283
|
- `calendar_connection`: specialized CRUD. Use provider discovery, connection CRUD,
|
|
283
284
|
selected-calendar rediscovery, sync, and delete routes rather than batch entity
|
|
284
285
|
tools.
|
|
286
|
+
- `operator_overview`: read-model-only operator surface. Use
|
|
287
|
+
`forge_get_operator_overview` or `/api/v1/operator/overview` when the user wants
|
|
288
|
+
the current Forge picture, attention cues, or broad status before choosing a
|
|
289
|
+
specific entity action.
|
|
290
|
+
- `operator_context`: read-model-only operator surface. Use
|
|
291
|
+
`forge_get_operator_context` or `/api/v1/operator/context` when the user wants
|
|
292
|
+
current work, active runs, risks, board context, or next moves before mutating
|
|
293
|
+
anything.
|
|
294
|
+
- `calendar_overview`: read-model-only calendar surface. Use
|
|
295
|
+
`forge_get_calendar_overview` or `/api/v1/calendar/overview` when the user wants
|
|
296
|
+
mirrored events, work blocks, timeboxes, provider state, or availability before
|
|
297
|
+
creating a `calendar_event`, `work_block_template`, `task_timebox`, or
|
|
298
|
+
`calendar_connection`.
|
|
285
299
|
- `task_run`: action workflow. Use task-run start, heartbeat, focus, complete, and
|
|
286
300
|
release routes; never treat status changes as proof of live work.
|
|
287
301
|
- `work_adjustment`: action workflow. Use the signed work-adjustment route for real
|
|
@@ -1061,6 +1075,81 @@ Preferred opening question:
|
|
|
1061
1075
|
|
|
1062
1076
|
- "Which task or project should this time correction belong to?"
|
|
1063
1077
|
|
|
1078
|
+
## Operator Overview
|
|
1079
|
+
|
|
1080
|
+
Aim: read the broad Forge state before choosing a specific action, without turning a
|
|
1081
|
+
status check into generic intake.
|
|
1082
|
+
|
|
1083
|
+
Arc:
|
|
1084
|
+
|
|
1085
|
+
1. Ask what the user is trying to understand about Forge overall.
|
|
1086
|
+
2. Read the operator overview before asking the user to reconstruct active work,
|
|
1087
|
+
attention cues, or broad status from memory.
|
|
1088
|
+
3. Reflect the practical decision the overview should support.
|
|
1089
|
+
4. Move into a specific entity flow only when the overview points to a concrete goal,
|
|
1090
|
+
project, task, habit, note, Psyche record, or follow-up action.
|
|
1091
|
+
|
|
1092
|
+
Helpful follow-up lanes:
|
|
1093
|
+
|
|
1094
|
+
- whether the user wants a broad status read, a priority decision, or a handoff
|
|
1095
|
+
- which owner or user scope matters if several humans or bots are involved
|
|
1096
|
+
- what decision the overview should help them make next
|
|
1097
|
+
|
|
1098
|
+
Route note:
|
|
1099
|
+
|
|
1100
|
+
- `operator_overview` is a read-model-only operator surface. Use
|
|
1101
|
+
`forge_get_operator_overview` or `/api/v1/operator/overview`; do not create,
|
|
1102
|
+
update, or delete `operator_overview` through batch CRUD.
|
|
1103
|
+
- If the read reveals a specific record that needs work, switch to that record's
|
|
1104
|
+
normal route posture after the user chooses the follow-up.
|
|
1105
|
+
|
|
1106
|
+
Ready to review when:
|
|
1107
|
+
|
|
1108
|
+
- the broad question is clear enough
|
|
1109
|
+
- any owner or user scope that changes the read is clear enough
|
|
1110
|
+
|
|
1111
|
+
Preferred opening question:
|
|
1112
|
+
|
|
1113
|
+
- "What are you trying to understand about Forge overall right now?"
|
|
1114
|
+
|
|
1115
|
+
## Operator Context
|
|
1116
|
+
|
|
1117
|
+
Aim: inspect current work, active runs, risk, and next moves before changing records.
|
|
1118
|
+
|
|
1119
|
+
Arc:
|
|
1120
|
+
|
|
1121
|
+
1. Ask whether the user is checking current work, risk, blockers, active sessions, or
|
|
1122
|
+
the next move.
|
|
1123
|
+
2. Read operator context before reopening a create or update intake.
|
|
1124
|
+
3. Reflect what the read is meant to decide: continue, stop, reprioritize, update, or
|
|
1125
|
+
create.
|
|
1126
|
+
4. Move to task-run, work-adjustment, task, project, or note flow only when one
|
|
1127
|
+
concrete follow-up is visible.
|
|
1128
|
+
|
|
1129
|
+
Helpful follow-up lanes:
|
|
1130
|
+
|
|
1131
|
+
- current task or active run
|
|
1132
|
+
- blocked or stale work
|
|
1133
|
+
- next move versus broad review
|
|
1134
|
+
- owner or user scope when bot and human work are both present
|
|
1135
|
+
|
|
1136
|
+
Route note:
|
|
1137
|
+
|
|
1138
|
+
- `operator_context` is a read-model-only operator surface. Use
|
|
1139
|
+
`forge_get_operator_context` or `/api/v1/operator/context`; do not mutate it
|
|
1140
|
+
through batch CRUD.
|
|
1141
|
+
- If the user decides to start, complete, release, or adjust work after the read,
|
|
1142
|
+
switch to the dedicated action workflow for that operation.
|
|
1143
|
+
|
|
1144
|
+
Ready to review when:
|
|
1145
|
+
|
|
1146
|
+
- the current-work question is clear
|
|
1147
|
+
- any user or owner scope is clear enough
|
|
1148
|
+
|
|
1149
|
+
Preferred opening question:
|
|
1150
|
+
|
|
1151
|
+
- "What current work, risk, or next move are you trying to check?"
|
|
1152
|
+
|
|
1064
1153
|
## Self Observation
|
|
1065
1154
|
|
|
1066
1155
|
Aim: capture one observed episode in a structured chain without letting a loose note
|
|
@@ -1251,6 +1340,46 @@ Preferred opening question:
|
|
|
1251
1340
|
|
|
1252
1341
|
- "What are you trying to understand from your workout picture right now?"
|
|
1253
1342
|
|
|
1343
|
+
## Calendar Overview
|
|
1344
|
+
|
|
1345
|
+
Aim: review commitments, work blocks, provider state, and existing timeboxes before
|
|
1346
|
+
creating or changing calendar records.
|
|
1347
|
+
|
|
1348
|
+
Arc:
|
|
1349
|
+
|
|
1350
|
+
1. Ask what the user is trying to understand or decide from the calendar picture.
|
|
1351
|
+
2. Ask for the date range or owner scope only if it changes the read.
|
|
1352
|
+
3. Read the calendar overview before asking the user to recreate availability from
|
|
1353
|
+
memory.
|
|
1354
|
+
4. Reflect the practical decision the overview should support.
|
|
1355
|
+
5. Move to `calendar_event`, `work_block_template`, `task_timebox`, or
|
|
1356
|
+
`calendar_connection` only when a specific follow-up action is visible.
|
|
1357
|
+
|
|
1358
|
+
Helpful follow-up lanes:
|
|
1359
|
+
|
|
1360
|
+
- which day, week, or date range matters
|
|
1361
|
+
- whether the question is availability, conflicts, provider health, work blocks, or
|
|
1362
|
+
existing timeboxes
|
|
1363
|
+
- what scheduling or planning decision the review should support
|
|
1364
|
+
|
|
1365
|
+
Route note:
|
|
1366
|
+
|
|
1367
|
+
- `calendar_overview` is a read-model-only calendar surface. Use
|
|
1368
|
+
`forge_get_calendar_overview` or `/api/v1/calendar/overview`; do not create,
|
|
1369
|
+
update, or delete `calendar_overview` through batch CRUD.
|
|
1370
|
+
- If the review reveals a concrete scheduling action, switch to the stored
|
|
1371
|
+
`calendar_event`, `work_block_template`, or `task_timebox` batch route, or to the
|
|
1372
|
+
specialized `calendar_connection` lifecycle route.
|
|
1373
|
+
|
|
1374
|
+
Ready to review when:
|
|
1375
|
+
|
|
1376
|
+
- the user's practical calendar question is clear
|
|
1377
|
+
- the relevant date range or owner scope is clear enough
|
|
1378
|
+
|
|
1379
|
+
Preferred opening question:
|
|
1380
|
+
|
|
1381
|
+
- "What are you trying to understand or decide from your calendar picture?"
|
|
1382
|
+
|
|
1254
1383
|
## Calendar Connection
|
|
1255
1384
|
|
|
1256
1385
|
Aim: connect the right provider deliberately without turning setup into a credential
|