storage-normalization.ts
1 import { normalizeCapabilitySelection } from '@/lib/capability-selection' 2 import { normalizeAgentSandboxConfig } from '@/lib/agent-sandbox-defaults' 3 import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind' 4 import { WORKER_ONLY_PROVIDER_IDS } from '@/lib/provider-sets' 5 6 type StoredObject = Record<string, unknown> 7 8 // --- Schedule helpers --- 9 10 const VALID_SCHEDULE_STATUSES = new Set(['active', 'paused', 'completed', 'failed', 'archived']) 11 12 function normalizeStoredScheduleType(primary: unknown, legacy: unknown): 'cron' | 'interval' | 'once' { 13 const explicit = primary === 'cron' || primary === 'interval' || primary === 'once' 14 ? primary 15 : null 16 const legacyValue = legacy === 'cron' || legacy === 'interval' || legacy === 'once' 17 ? legacy 18 : null 19 if (!explicit && legacyValue) return legacyValue 20 if (explicit === 'interval' && legacyValue && legacyValue !== 'interval') return legacyValue 21 return explicit || legacyValue || 'interval' 22 } 23 24 function normalizeStoredScheduleTimestamp(value: unknown): number | null { 25 if (typeof value === 'number' && Number.isFinite(value)) { 26 const intValue = Math.trunc(value) 27 return intValue > 0 ? intValue : null 28 } 29 if (typeof value !== 'string') return null 30 const trimmed = value.trim() 31 if (!trimmed) return null 32 if (/^\d+$/.test(trimmed)) { 33 const parsed = Number.parseInt(trimmed, 10) 34 return Number.isFinite(parsed) && parsed > 0 ? parsed : null 35 } 36 const parsedTime = Date.parse(trimmed) 37 if (!Number.isFinite(parsedTime) || parsedTime <= 0) return null 38 return Math.trunc(parsedTime) 39 } 40 41 function normalizeStoredSchedulePositiveInt(value: unknown): number | null { 42 if (typeof value === 'number' && Number.isFinite(value)) { 43 const intValue = Math.trunc(value) 44 return intValue > 0 ? intValue : null 45 } 46 if (typeof value !== 'string') return null 47 const trimmed = value.trim() 48 if (!trimmed || !/^\d+$/.test(trimmed)) return null 49 const parsed = Number.parseInt(trimmed, 10) 50 return Number.isFinite(parsed) && parsed > 0 ? parsed : null 51 } 52 53 function normalizeStoredConnectorChannelId(platform: unknown, raw: unknown): string | null { 54 if (typeof raw !== 'string') return null 55 const trimmed = raw.trim() 56 if (!trimmed) return null 57 if (platform !== 'whatsapp') return trimmed 58 const withoutPrefix = trimmed.replace(/^whatsapp:/i, '').trim() 59 if (!withoutPrefix) return null 60 if (/^[\d]+(-[\d]+)*@g\.us$/i.test(withoutPrefix)) return withoutPrefix 61 const userMatch = withoutPrefix.match(/^(\d+)(?::\d+)?@s\.whatsapp\.net$/i) 62 if (userMatch) return `${userMatch[1]}@s.whatsapp.net` 63 const lidMatch = withoutPrefix.match(/^(\d+)(?::\d+)?@lid$/i) 64 if (lidMatch) return withoutPrefix 65 if (withoutPrefix.includes('@')) return withoutPrefix 66 const digits = withoutPrefix.replace(/[^\d+]/g, '') 67 const cleaned = digits.startsWith('+') ? digits.slice(1) : digits 68 return cleaned ? `${cleaned}@s.whatsapp.net` : null 69 } 70 71 /** 72 * Lookup function type for loading individual collection items. 73 * Injected to avoid circular dependency between normalization and storage. 74 */ 75 export type CollectionItemLoader = (table: string, id: string) => unknown | null 76 77 function resolveStoredOwnerFollowupTarget( 78 schedule: StoredObject, 79 loadItem: CollectionItemLoader, 80 ): { 81 connectorId: string 82 channelId: string 83 senderId: string | null 84 senderName: string | null 85 } | null { 86 const createdInSessionId = typeof schedule.createdInSessionId === 'string' ? schedule.createdInSessionId.trim() : '' 87 const agentId = typeof schedule.agentId === 'string' ? schedule.agentId.trim() : '' 88 if (!createdInSessionId || !agentId) return null 89 90 const sourceSession = loadItem('sessions', createdInSessionId) as StoredObject | null 91 if (!sourceSession || isDirectConnectorSession(sourceSession)) return null 92 93 const agent = loadItem('agents', agentId) as StoredObject | null 94 const threadSessionId = typeof agent?.threadSessionId === 'string' ? agent.threadSessionId.trim() : '' 95 if (threadSessionId && createdInSessionId !== threadSessionId) return null 96 97 const sessionConnectorContext = sourceSession.connectorContext && typeof sourceSession.connectorContext === 'object' 98 ? sourceSession.connectorContext as StoredObject 99 : null 100 const contextIsOwnerConversation = sessionConnectorContext?.isOwnerConversation === true 101 const contextConnectorId = typeof sessionConnectorContext?.connectorId === 'string' ? sessionConnectorContext.connectorId.trim() : '' 102 const contextChannelId = normalizeStoredConnectorChannelId(sessionConnectorContext?.platform, sessionConnectorContext?.channelId) 103 if (contextIsOwnerConversation && contextConnectorId && contextChannelId) { 104 const contextSenderId = typeof sessionConnectorContext?.senderId === 'string' ? sessionConnectorContext.senderId.trim() : '' 105 const contextSenderName = typeof sessionConnectorContext?.senderName === 'string' ? sessionConnectorContext.senderName.trim() : '' 106 return { 107 connectorId: contextConnectorId, 108 channelId: contextChannelId, 109 senderId: contextSenderId || null, 110 senderName: contextSenderName || null, 111 } 112 } 113 114 const connectorId = typeof schedule.followupConnectorId === 'string' ? schedule.followupConnectorId.trim() : '' 115 if (!connectorId) return null 116 const connector = loadItem('connectors', connectorId) as StoredObject | null 117 if (!connector) return null 118 const connectorAgentId = typeof connector.agentId === 'string' ? connector.agentId.trim() : '' 119 if (connectorAgentId && connectorAgentId !== agentId) return null 120 121 const connectorConfig = connector.config && typeof connector.config === 'object' 122 ? connector.config as Record<string, unknown> 123 : {} 124 const ownerSenderId = typeof connectorConfig.ownerSenderId === 'string' ? connectorConfig.ownerSenderId.trim() : '' 125 const ownerChannelId = normalizeStoredConnectorChannelId( 126 connector.platform, 127 ownerSenderId || connectorConfig.outboundJid || connectorConfig.outboundTarget, 128 ) 129 if (!ownerChannelId) return null 130 131 return { 132 connectorId, 133 channelId: ownerChannelId, 134 senderId: ownerSenderId || null, 135 senderName: null, 136 } 137 } 138 139 function normalizeStoredScheduleRecord(value: unknown, loadItem: CollectionItemLoader): unknown { 140 if (!value || typeof value !== 'object' || Array.isArray(value)) return value 141 142 const schedule = value as StoredObject 143 schedule.scheduleType = normalizeStoredScheduleType(schedule.scheduleType, schedule.type) 144 if ('type' in schedule) delete schedule.type 145 146 const status = typeof schedule.status === 'string' ? schedule.status.trim().toLowerCase() : '' 147 schedule.status = VALID_SCHEDULE_STATUSES.has(status) ? status : 'active' 148 149 const intervalMs = normalizeStoredSchedulePositiveInt(schedule.intervalMs) 150 if (intervalMs != null) schedule.intervalMs = intervalMs 151 else delete schedule.intervalMs 152 153 const staggerSec = normalizeStoredSchedulePositiveInt(schedule.staggerSec) 154 if (staggerSec != null) schedule.staggerSec = staggerSec 155 else delete schedule.staggerSec 156 157 const runAt = normalizeStoredScheduleTimestamp(schedule.runAt) 158 if (runAt != null) schedule.runAt = runAt 159 else delete schedule.runAt 160 161 const lastRunAt = normalizeStoredScheduleTimestamp(schedule.lastRunAt) 162 if (lastRunAt != null) schedule.lastRunAt = lastRunAt 163 else delete schedule.lastRunAt 164 165 const nextRunAt = normalizeStoredScheduleTimestamp(schedule.nextRunAt) 166 if (nextRunAt != null) schedule.nextRunAt = nextRunAt 167 else delete schedule.nextRunAt 168 169 const archivedAt = normalizeStoredScheduleTimestamp(schedule.archivedAt) 170 if (archivedAt != null) schedule.archivedAt = archivedAt 171 else delete schedule.archivedAt 172 173 const archivedFromStatus = typeof schedule.archivedFromStatus === 'string' 174 ? schedule.archivedFromStatus.trim().toLowerCase() 175 : '' 176 if (archivedFromStatus === 'active' || archivedFromStatus === 'paused' || archivedFromStatus === 'completed' || archivedFromStatus === 'failed') { 177 schedule.archivedFromStatus = archivedFromStatus 178 } else { 179 delete schedule.archivedFromStatus 180 } 181 182 if (schedule.status === 'archived') { 183 delete schedule.nextRunAt 184 } else if (schedule.scheduleType === 'once') { 185 if (typeof schedule.runAt === 'number') { 186 if (schedule.status === 'completed' || schedule.status === 'failed') { 187 delete schedule.nextRunAt 188 } else if (typeof schedule.nextRunAt !== 'number' || schedule.nextRunAt !== schedule.runAt) { 189 schedule.nextRunAt = schedule.runAt 190 } 191 } 192 } else if (schedule.scheduleType === 'cron') { 193 if (!schedule.cron) delete schedule.nextRunAt 194 } 195 196 const ownerTarget = resolveStoredOwnerFollowupTarget(schedule, loadItem) 197 if (ownerTarget) { 198 schedule.followupConnectorId = ownerTarget.connectorId 199 schedule.followupChannelId = ownerTarget.channelId 200 if (ownerTarget.senderId) schedule.followupSenderId = ownerTarget.senderId 201 else delete schedule.followupSenderId 202 if (ownerTarget.senderName) schedule.followupSenderName = ownerTarget.senderName 203 else delete schedule.followupSenderName 204 delete schedule.followupThreadId 205 } 206 207 return schedule 208 } 209 210 // --- String array helper --- 211 212 function normalizeStoredStringArray(value: unknown, maxItems = 128): string[] { 213 if (!Array.isArray(value)) return [] 214 const seen = new Set<string>() 215 const out: string[] = [] 216 for (const entry of value) { 217 if (typeof entry !== 'string') continue 218 const trimmed = entry.trim() 219 if (!trimmed || seen.has(trimmed)) continue 220 seen.add(trimmed) 221 out.push(trimmed) 222 if (out.length >= maxItems) break 223 } 224 return out 225 } 226 227 // --- Mission normalizer --- 228 229 function normalizeStoredMissionRecord(value: unknown): unknown { 230 if (!value || typeof value !== 'object' || Array.isArray(value)) return value 231 const mission = value as StoredObject 232 233 const validStatuses = new Set(['active', 'waiting', 'completed', 'failed', 'cancelled']) 234 const validPhases = new Set(['intake', 'planning', 'dispatching', 'executing', 'verifying', 'waiting', 'completed', 'failed']) 235 const validWaitKinds = new Set(['human_reply', 'approval', 'external_dependency', 'provider', 'blocked_task', 'blocked_mission', 'scheduled', 'other']) 236 const validPlannerDecisions = new Set(['dispatch_task', 'dispatch_session_turn', 'spawn_child_mission', 'wait', 'verify_now', 'complete_candidate', 'replan', 'fail_terminal', 'cancel']) 237 const validVerificationVerdicts = new Set(['continue', 'waiting', 'completed', 'failed', 'replan']) 238 239 const status = typeof mission.status === 'string' ? mission.status.trim().toLowerCase() : '' 240 mission.status = validStatuses.has(status) ? status : 'active' 241 242 const phase = typeof mission.phase === 'string' ? mission.phase.trim().toLowerCase() : '' 243 mission.phase = validPhases.has(phase) ? phase : 'planning' 244 245 const sourceRef = mission.sourceRef && typeof mission.sourceRef === 'object' && !Array.isArray(mission.sourceRef) 246 ? mission.sourceRef as StoredObject 247 : null 248 if (sourceRef && typeof sourceRef.kind === 'string') { 249 mission.sourceRef = sourceRef 250 } else if (typeof mission.sessionId === 'string' && mission.sessionId.trim()) { 251 mission.sourceRef = { kind: 'chat', sessionId: mission.sessionId.trim() } 252 } else { 253 mission.sourceRef = { kind: 'manual' } 254 } 255 256 const childMissionIds = normalizeStoredStringArray(mission.childMissionIds, 256) 257 if (childMissionIds.length > 0) mission.childMissionIds = childMissionIds 258 else delete mission.childMissionIds 259 260 const dependencyMissionIds = normalizeStoredStringArray(mission.dependencyMissionIds, 256) 261 if (dependencyMissionIds.length > 0) mission.dependencyMissionIds = dependencyMissionIds 262 else delete mission.dependencyMissionIds 263 264 const dependencyTaskIds = normalizeStoredStringArray(mission.dependencyTaskIds, 256) 265 if (dependencyTaskIds.length > 0) mission.dependencyTaskIds = dependencyTaskIds 266 else delete mission.dependencyTaskIds 267 268 const taskIds = normalizeStoredStringArray(mission.taskIds, 256) 269 if (taskIds.length > 0) mission.taskIds = taskIds 270 else delete mission.taskIds 271 272 const parentMissionId = typeof mission.parentMissionId === 'string' && mission.parentMissionId.trim() 273 ? mission.parentMissionId.trim() 274 : '' 275 if (parentMissionId) mission.parentMissionId = parentMissionId 276 else delete mission.parentMissionId 277 278 const rootMissionId = typeof mission.rootMissionId === 'string' && mission.rootMissionId.trim() 279 ? mission.rootMissionId.trim() 280 : '' 281 mission.rootMissionId = rootMissionId || (typeof mission.id === 'string' ? mission.id : null) 282 283 const waitState = mission.waitState && typeof mission.waitState === 'object' && !Array.isArray(mission.waitState) 284 ? mission.waitState as StoredObject 285 : null 286 if (waitState) { 287 const waitKind = typeof waitState.kind === 'string' ? waitState.kind.trim().toLowerCase() : '' 288 waitState.kind = validWaitKinds.has(waitKind) ? waitKind : 'other' 289 if (typeof waitState.reason !== 'string' || !waitState.reason.trim()) waitState.reason = 'Mission is waiting.' 290 const dependencyTaskId = typeof waitState.dependencyTaskId === 'string' && waitState.dependencyTaskId.trim() 291 ? waitState.dependencyTaskId.trim() 292 : '' 293 if (dependencyTaskId) waitState.dependencyTaskId = dependencyTaskId 294 else delete waitState.dependencyTaskId 295 const dependencyMissionId = typeof waitState.dependencyMissionId === 'string' && waitState.dependencyMissionId.trim() 296 ? waitState.dependencyMissionId.trim() 297 : '' 298 if (dependencyMissionId) waitState.dependencyMissionId = dependencyMissionId 299 else delete waitState.dependencyMissionId 300 const providerKey = typeof waitState.providerKey === 'string' && waitState.providerKey.trim() 301 ? waitState.providerKey.trim() 302 : '' 303 if (providerKey) waitState.providerKey = providerKey 304 else delete waitState.providerKey 305 mission.waitState = waitState 306 } else { 307 delete mission.waitState 308 } 309 310 const controllerState = mission.controllerState && typeof mission.controllerState === 'object' && !Array.isArray(mission.controllerState) 311 ? mission.controllerState as StoredObject 312 : null 313 if (controllerState) mission.controllerState = controllerState 314 else delete mission.controllerState 315 316 const plannerState = mission.plannerState && typeof mission.plannerState === 'object' && !Array.isArray(mission.plannerState) 317 ? mission.plannerState as StoredObject 318 : null 319 if (plannerState) { 320 const decision = typeof plannerState.lastDecision === 'string' ? plannerState.lastDecision.trim() : '' 321 if (!validPlannerDecisions.has(decision)) delete plannerState.lastDecision 322 mission.plannerState = plannerState 323 } else { 324 delete mission.plannerState 325 } 326 327 const verificationState = mission.verificationState && typeof mission.verificationState === 'object' && !Array.isArray(mission.verificationState) 328 ? mission.verificationState as StoredObject 329 : { candidate: false } 330 verificationState.candidate = verificationState.candidate === true 331 const requiredTaskIds = normalizeStoredStringArray(verificationState.requiredTaskIds, 128) 332 if (requiredTaskIds.length > 0) verificationState.requiredTaskIds = requiredTaskIds 333 else delete verificationState.requiredTaskIds 334 const requiredChildMissionIds = normalizeStoredStringArray(verificationState.requiredChildMissionIds, 128) 335 if (requiredChildMissionIds.length > 0) verificationState.requiredChildMissionIds = requiredChildMissionIds 336 else delete verificationState.requiredChildMissionIds 337 const requiredArtifacts = normalizeStoredStringArray(verificationState.requiredArtifacts, 128) 338 if (requiredArtifacts.length > 0) verificationState.requiredArtifacts = requiredArtifacts 339 else delete verificationState.requiredArtifacts 340 const lastVerdict = typeof verificationState.lastVerdict === 'string' ? verificationState.lastVerdict.trim().toLowerCase() : '' 341 if (validVerificationVerdicts.has(lastVerdict)) verificationState.lastVerdict = lastVerdict 342 else delete verificationState.lastVerdict 343 mission.verificationState = verificationState 344 345 return mission 346 } 347 348 // --- Mission event normalizer --- 349 350 function normalizeStoredMissionEventRecord(value: unknown): unknown { 351 if (!value || typeof value !== 'object' || Array.isArray(value)) return value 352 const event = value as StoredObject 353 if (!event.data || typeof event.data !== 'object' || Array.isArray(event.data)) event.data = null 354 if (typeof event.source !== 'string' || !event.source.trim()) event.source = 'system' 355 return event 356 } 357 358 // --- Agent Mission normalizers (autonomous goal-driven runs, v1.5.49+) --- 359 360 const VALID_AGENT_MISSION_STATUSES = new Set([ 361 'draft', 362 'running', 363 'paused', 364 'completed', 365 'failed', 366 'cancelled', 367 'budget_exhausted', 368 ]) 369 370 const VALID_AGENT_MISSION_REPORT_FORMATS = new Set(['markdown', 'slack', 'discord', 'email', 'audio']) 371 372 function normalizeFiniteNumber(value: unknown): number | null { 373 if (typeof value !== 'number' || !Number.isFinite(value)) return null 374 return value 375 } 376 377 function normalizeNonNegativeNumber(value: unknown, fallback: number): number { 378 const n = normalizeFiniteNumber(value) 379 if (n == null || n < 0) return fallback 380 return n 381 } 382 383 function normalizeStoredAgentMissionRecord(value: unknown): unknown { 384 if (!value || typeof value !== 'object' || Array.isArray(value)) return value 385 const mission = value as StoredObject 386 387 const status = typeof mission.status === 'string' ? mission.status.trim().toLowerCase() : '' 388 mission.status = VALID_AGENT_MISSION_STATUSES.has(status) ? status : 'draft' 389 390 mission.successCriteria = normalizeStoredStringArray(mission.successCriteria, 64) 391 mission.agentIds = normalizeStoredStringArray(mission.agentIds, 32) 392 mission.reportConnectorIds = normalizeStoredStringArray(mission.reportConnectorIds, 16) 393 394 const budget = mission.budget && typeof mission.budget === 'object' && !Array.isArray(mission.budget) 395 ? mission.budget as StoredObject 396 : {} 397 budget.maxUsd = normalizeFiniteNumber(budget.maxUsd) 398 budget.maxTokens = normalizeFiniteNumber(budget.maxTokens) 399 budget.maxToolCalls = normalizeFiniteNumber(budget.maxToolCalls) 400 budget.maxWallclockSec = normalizeFiniteNumber(budget.maxWallclockSec) 401 budget.maxTurns = normalizeFiniteNumber(budget.maxTurns) 402 budget.maxParallelBranches = normalizeFiniteNumber(budget.maxParallelBranches) 403 if (!Array.isArray(budget.warnAtFractions)) { 404 budget.warnAtFractions = [0.5, 0.8, 0.95] 405 } else { 406 budget.warnAtFractions = (budget.warnAtFractions as unknown[]) 407 .map((entry) => normalizeFiniteNumber(entry)) 408 .filter((entry): entry is number => entry != null && entry > 0 && entry < 1) 409 if ((budget.warnAtFractions as number[]).length === 0) { 410 budget.warnAtFractions = [0.5, 0.8, 0.95] 411 } 412 } 413 mission.budget = budget 414 415 const usage = mission.usage && typeof mission.usage === 'object' && !Array.isArray(mission.usage) 416 ? mission.usage as StoredObject 417 : {} 418 usage.usdSpent = normalizeNonNegativeNumber(usage.usdSpent, 0) 419 usage.tokensUsed = normalizeNonNegativeNumber(usage.tokensUsed, 0) 420 usage.toolCallsUsed = normalizeNonNegativeNumber(usage.toolCallsUsed, 0) 421 usage.turnsRun = normalizeNonNegativeNumber(usage.turnsRun, 0) 422 usage.wallclockMsElapsed = normalizeNonNegativeNumber(usage.wallclockMsElapsed, 0) 423 usage.startedAt = normalizeFiniteNumber(usage.startedAt) 424 usage.lastUpdatedAt = normalizeNonNegativeNumber(usage.lastUpdatedAt, 0) 425 if (!Array.isArray(usage.warnFractionsHit)) { 426 usage.warnFractionsHit = [] 427 } else { 428 usage.warnFractionsHit = (usage.warnFractionsHit as unknown[]) 429 .map((entry) => normalizeFiniteNumber(entry)) 430 .filter((entry): entry is number => entry != null) 431 } 432 mission.usage = usage 433 434 if (!Array.isArray(mission.milestones)) mission.milestones = [] 435 // Cap the stored tail so missions don't balloon 436 if ((mission.milestones as unknown[]).length > 200) { 437 mission.milestones = (mission.milestones as unknown[]).slice(-200) 438 } 439 440 const reportSchedule = mission.reportSchedule 441 && typeof mission.reportSchedule === 'object' 442 && !Array.isArray(mission.reportSchedule) 443 ? mission.reportSchedule as StoredObject 444 : null 445 if (reportSchedule) { 446 const format = typeof reportSchedule.format === 'string' ? reportSchedule.format.trim().toLowerCase() : '' 447 reportSchedule.format = VALID_AGENT_MISSION_REPORT_FORMATS.has(format) ? format : 'markdown' 448 reportSchedule.intervalSec = normalizeNonNegativeNumber(reportSchedule.intervalSec, 3600) 449 reportSchedule.enabled = reportSchedule.enabled !== false 450 reportSchedule.lastReportAt = normalizeFiniteNumber(reportSchedule.lastReportAt) 451 mission.reportSchedule = reportSchedule 452 } else if (mission.reportSchedule !== undefined) { 453 mission.reportSchedule = null 454 } 455 456 if (typeof mission.createdAt !== 'number') mission.createdAt = Date.now() 457 if (typeof mission.updatedAt !== 'number') mission.updatedAt = mission.createdAt as number 458 if (mission.startedAt === undefined) mission.startedAt = null 459 if (mission.endedAt === undefined) mission.endedAt = null 460 if (mission.endReason === undefined) mission.endReason = null 461 462 if (typeof mission.templateId === 'string' && mission.templateId.trim()) { 463 mission.templateId = mission.templateId.trim().slice(0, 64) 464 } else { 465 mission.templateId = null 466 } 467 468 return mission 469 } 470 471 function normalizeStoredMissionReportRecord(value: unknown): unknown { 472 if (!value || typeof value !== 'object' || Array.isArray(value)) return value 473 const report = value as StoredObject 474 const format = typeof report.format === 'string' ? report.format.trim().toLowerCase() : '' 475 report.format = VALID_AGENT_MISSION_REPORT_FORMATS.has(format) ? format : 'markdown' 476 if (!Array.isArray(report.highlights)) report.highlights = [] 477 if (!Array.isArray(report.deliveredTo)) report.deliveredTo = [] 478 if (typeof report.body !== 'string') report.body = '' 479 if (typeof report.title !== 'string') report.title = 'Mission report' 480 return report 481 } 482 483 function normalizeStoredAgentMissionEventRecord(value: unknown): unknown { 484 if (!value || typeof value !== 'object' || Array.isArray(value)) return value 485 const event = value as StoredObject 486 if (!event.payload || typeof event.payload !== 'object' || Array.isArray(event.payload)) { 487 event.payload = {} 488 } 489 if (typeof event.kind !== 'string' || !event.kind.trim()) event.kind = 'unknown' 490 if (typeof event.at !== 'number' || !Number.isFinite(event.at)) event.at = Date.now() 491 return event 492 } 493 494 // --- Delegation job normalizer --- 495 496 function normalizeStoredDelegationJobRecord(value: unknown): unknown { 497 if (!value || typeof value !== 'object' || Array.isArray(value)) return value 498 const job = value as StoredObject 499 const missionId = typeof job.missionId === 'string' && job.missionId.trim() ? job.missionId.trim() : '' 500 if (missionId) job.missionId = missionId 501 else delete job.missionId 502 const parentMissionId = typeof job.parentMissionId === 'string' && job.parentMissionId.trim() ? job.parentMissionId.trim() : '' 503 if (parentMissionId) job.parentMissionId = parentMissionId 504 else delete job.parentMissionId 505 return job 506 } 507 508 function normalizeStoredRuntimeRunRecord(value: unknown): unknown { 509 if (!value || typeof value !== 'object' || Array.isArray(value)) return value 510 const run = value as StoredObject 511 512 if (typeof run.kind !== 'string' || !run.kind.trim()) run.kind = 'session_turn' 513 if (run.ownerType === undefined) run.ownerType = 'session' 514 if (run.ownerId === undefined) { 515 const sessionId = typeof run.sessionId === 'string' && run.sessionId.trim() ? run.sessionId.trim() : '' 516 run.ownerId = sessionId || null 517 } 518 if (run.parentExecutionId === undefined) run.parentExecutionId = null 519 if (run.recoveryPolicy === undefined) { 520 const source = typeof run.source === 'string' ? run.source.trim().toLowerCase() : '' 521 run.recoveryPolicy = source === 'heartbeat' 522 || source === 'heartbeat-wake' 523 || source === 'schedule' 524 || source === 'task' 525 || source === 'delegation' 526 || source === 'subagent' 527 ? 'restart_recoverable' 528 : 'ephemeral' 529 } 530 531 return run 532 } 533 534 function normalizeStoredRuntimeRunEventRecord(value: unknown): unknown { 535 if (!value || typeof value !== 'object' || Array.isArray(value)) return value 536 const event = value as StoredObject 537 538 if (typeof event.kind !== 'string' || !event.kind.trim()) event.kind = 'session_turn' 539 if (event.ownerType === undefined) event.ownerType = 'session' 540 if (event.ownerId === undefined) { 541 const sessionId = typeof event.sessionId === 'string' && event.sessionId.trim() ? event.sessionId.trim() : '' 542 event.ownerId = sessionId || null 543 } 544 if (event.parentExecutionId === undefined) event.parentExecutionId = null 545 546 return event 547 } 548 549 // --- Main dispatch function --- 550 551 export interface NormalizationResult { 552 value: unknown 553 changed: boolean 554 } 555 556 /** 557 * Normalize a stored record based on its table. 558 * Returns `{ value, changed }` so callers can skip re-serialization when nothing was modified. 559 * Requires a `loadItem` callback to resolve cross-table references 560 * (used by schedule normalization to look up sessions and connectors). 561 */ 562 export function normalizeStoredRecord( 563 table: string, 564 value: unknown, 565 loadItem: CollectionItemLoader, 566 ): NormalizationResult { 567 // Tables with no normalization, early exit. 568 if ( 569 table !== 'agents' && table !== 'tasks' && table !== 'missions' 570 && table !== 'mission_events' && table !== 'delegation_jobs' 571 && table !== 'schedules' && table !== 'sessions' 572 && table !== 'provider_configs' 573 && table !== 'runtime_runs' && table !== 'runtime_run_events' 574 && table !== 'wallets' 575 && table !== 'agent_missions' 576 && table !== 'mission_reports' 577 && table !== 'agent_mission_events' 578 ) { 579 return { value, changed: false } 580 } 581 582 if (!value || typeof value !== 'object' || Array.isArray(value)) { 583 return { value, changed: false } 584 } 585 586 // Snapshot before mutation for dirty tracking 587 const before = JSON.stringify(value) 588 589 const normalized = normalizeStoredRecordInner(table, value, loadItem) 590 591 const after = JSON.stringify(normalized) 592 return { value: normalized, changed: after !== before } 593 } 594 595 function normalizeStoredRecordInner( 596 table: string, 597 value: unknown, 598 loadItem: CollectionItemLoader, 599 ): unknown { 600 if (table === 'agents') { 601 const agent = value as StoredObject 602 const normalizedCapabilities = normalizeCapabilitySelection({ 603 tools: Array.isArray(agent.tools) ? agent.tools as string[] : undefined, 604 extensions: Array.isArray(agent.extensions) ? agent.extensions as string[] : undefined, 605 }) 606 agent.tools = normalizedCapabilities.tools 607 agent.extensions = normalizedCapabilities.extensions 608 if ('plugins' in agent) delete agent.plugins 609 const legacyAssignScope = agent.platformAssignScope === 'all' || agent.platformAssignScope === 'self' 610 ? agent.platformAssignScope 611 : null 612 const legacyTargetIds = Array.isArray(agent.subAgentIds) 613 ? agent.subAgentIds.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) 614 : [] 615 if (typeof agent.delegationEnabled !== 'boolean') { 616 agent.delegationEnabled = legacyAssignScope === 'all' 617 } 618 if (agent.delegationTargetMode !== 'all' && agent.delegationTargetMode !== 'selected') { 619 agent.delegationTargetMode = legacyTargetIds.length > 0 ? 'selected' : 'all' 620 } 621 if (!Array.isArray(agent.delegationTargetAgentIds)) { 622 agent.delegationTargetAgentIds = legacyTargetIds 623 } 624 agent.maxParallelDelegations = normalizeFiniteNumber(agent.maxParallelDelegations) 625 delete agent.platformAssignScope 626 delete agent.subAgentIds 627 agent.sandboxConfig = normalizeAgentSandboxConfig(agent.sandboxConfig) 628 // Default executeConfig — null means not configured (falls back to defaults in execute.ts) 629 if (agent.executeConfig === undefined) agent.executeConfig = null 630 // Default proactiveMemory to true for existing agents 631 if (agent.proactiveMemory === undefined) agent.proactiveMemory = true 632 if (!Array.isArray(agent.capabilities)) agent.capabilities = [] 633 // Role normalization — default to 'worker' 634 if (agent.role !== 'worker' && agent.role !== 'coordinator') { 635 agent.role = 'worker' 636 } 637 // Coordinators always have delegation enabled 638 if (agent.role === 'coordinator') { 639 agent.delegationEnabled = true 640 if (agent.delegationTargetMode !== 'selected') { 641 agent.delegationTargetMode = 'all' 642 } 643 } 644 // Worker-only providers cannot be coordinators, delegate, or have heartbeats 645 if (WORKER_ONLY_PROVIDER_IDS.has(agent.provider as string)) { 646 agent.role = 'worker' 647 agent.delegationEnabled = false 648 agent.heartbeatEnabled = false 649 } 650 // Dreaming defaults 651 if (agent.dreamEnabled === undefined) agent.dreamEnabled = false 652 if (agent.dreamConfig === undefined) agent.dreamConfig = null 653 if (agent.lastDreamAt === undefined) agent.lastDreamAt = null 654 if (typeof agent.dreamCycleCount !== 'number') agent.dreamCycleCount = 0 655 // Persisted spend rollup defaults 656 if (typeof agent.spentMonthlyCents !== 'number') agent.spentMonthlyCents = 0 657 if (typeof agent.spentDailyCents !== 'number') agent.spentDailyCents = 0 658 if (typeof agent.spentHourlyCents !== 'number') agent.spentHourlyCents = 0 659 if (typeof agent.lastSpendRollupAt !== 'number') agent.lastSpendRollupAt = 0 660 // SwarmFeed defaults 661 if (typeof agent.swarmfeedEnabled !== 'boolean') agent.swarmfeedEnabled = false 662 if (agent.swarmfeedJoinedAt === undefined) agent.swarmfeedJoinedAt = null 663 if (typeof agent.swarmfeedBio !== 'string' && agent.swarmfeedBio !== null) agent.swarmfeedBio = null 664 if (agent.swarmfeedPinnedPostId === undefined) agent.swarmfeedPinnedPostId = null 665 if (typeof agent.swarmfeedAutoPost !== 'boolean') agent.swarmfeedAutoPost = false 666 if (!Array.isArray(agent.swarmfeedAutoPostChannels)) agent.swarmfeedAutoPostChannels = [] 667 if (typeof agent.swarmfeedApiKey !== 'string' && agent.swarmfeedApiKey !== null) agent.swarmfeedApiKey = null 668 if (typeof agent.swarmfeedAgentId !== 'string' && agent.swarmfeedAgentId !== null) agent.swarmfeedAgentId = null 669 if (typeof agent.swarmfeedLastAutoPostAt !== 'number' && agent.swarmfeedLastAutoPostAt !== null) agent.swarmfeedLastAutoPostAt = null 670 if (!agent.origin) agent.origin = 'swarmclaw' 671 if (agent.swarmfeedHeartbeat === undefined) agent.swarmfeedHeartbeat = null 672 // SwarmDock defaults 673 if (typeof agent.swarmdockEnabled !== 'boolean') agent.swarmdockEnabled = false 674 if (agent.swarmdockListedAt === undefined) agent.swarmdockListedAt = null 675 if (typeof agent.swarmdockDescription !== 'string' && agent.swarmdockDescription !== null) agent.swarmdockDescription = null 676 if (!Array.isArray(agent.swarmdockSkills)) agent.swarmdockSkills = [] 677 if (typeof agent.swarmdockWalletId !== 'string' && agent.swarmdockWalletId !== null) agent.swarmdockWalletId = null 678 if (typeof agent.swarmdockAgentId !== 'string' && agent.swarmdockAgentId !== null) agent.swarmdockAgentId = null 679 if (typeof agent.swarmdockDid !== 'string' && agent.swarmdockDid !== null) agent.swarmdockDid = null 680 if (typeof agent.swarmdockApiKey !== 'string' && agent.swarmdockApiKey !== null) agent.swarmdockApiKey = null 681 if (agent.swarmdockMarketplace === undefined) agent.swarmdockMarketplace = null 682 // Org chart normalization 683 if (agent.orgChart && typeof agent.orgChart === 'object' && !Array.isArray(agent.orgChart)) { 684 const oc = agent.orgChart as Record<string, unknown> 685 oc.parentId ??= null 686 oc.teamLabel ??= null 687 oc.teamColor ??= null 688 oc.x ??= null 689 oc.y ??= null 690 } else { 691 agent.orgChart = null 692 } 693 return agent 694 } 695 696 if (table === 'tasks') { 697 const task = value as StoredObject 698 if ('missionSummary' in task) delete task.missionSummary 699 if (!Array.isArray(task.subtaskIds)) task.subtaskIds = [] 700 return task 701 } 702 703 if (table === 'mcp_servers') { 704 const server = value as StoredObject 705 // Back-compat: existing servers had no alwaysExpose; match historical behavior 706 // where every tool was eagerly bound on every turn. 707 if (server.alwaysExpose === undefined) { 708 server.alwaysExpose = true 709 } 710 return server 711 } 712 713 if (table === 'provider_configs') { 714 const provider = value as StoredObject 715 provider.type = provider.type === 'builtin' ? 'builtin' : 'custom' 716 if (typeof provider.name !== 'string' || !provider.name.trim()) { 717 provider.name = provider.type === 'builtin' ? 'Built-in Provider' : 'Custom Provider' 718 } else { 719 provider.name = provider.name.trim() 720 } 721 provider.baseUrl = typeof provider.baseUrl === 'string' ? provider.baseUrl.trim() : '' 722 provider.models = normalizeStoredStringArray(provider.models) 723 724 if (typeof provider.requiresApiKey !== 'boolean') provider.requiresApiKey = true 725 if (typeof provider.isEnabled !== 'boolean') provider.isEnabled = true 726 727 const credentialId = typeof provider.credentialId === 'string' ? provider.credentialId.trim() : '' 728 provider.credentialId = credentialId || null 729 730 if (typeof provider.createdAt !== 'number') provider.createdAt = Date.now() 731 if (typeof provider.updatedAt !== 'number') provider.updatedAt = provider.createdAt as number 732 return provider 733 } 734 735 if (table === 'missions') { 736 return normalizeStoredMissionRecord(value) 737 } 738 739 if (table === 'mission_events') { 740 return normalizeStoredMissionEventRecord(value) 741 } 742 743 if (table === 'agent_missions') { 744 return normalizeStoredAgentMissionRecord(value) 745 } 746 747 if (table === 'mission_reports') { 748 return normalizeStoredMissionReportRecord(value) 749 } 750 751 if (table === 'agent_mission_events') { 752 return normalizeStoredAgentMissionEventRecord(value) 753 } 754 755 if (table === 'delegation_jobs') { 756 return normalizeStoredDelegationJobRecord(value) 757 } 758 759 if (table === 'runtime_runs') { 760 return normalizeStoredRuntimeRunRecord(value) 761 } 762 763 if (table === 'runtime_run_events') { 764 return normalizeStoredRuntimeRunEventRecord(value) 765 } 766 767 if (table === 'schedules') { 768 return normalizeStoredScheduleRecord(value, loadItem) 769 } 770 771 if (table === 'wallets') { 772 const wallet = value as StoredObject 773 if (wallet.chain !== 'base') wallet.chain = 'base' 774 if (typeof wallet.createdAt !== 'number') wallet.createdAt = Date.now() 775 return wallet 776 } 777 778 // sessions 779 const session = value as StoredObject 780 // Migrate legacy 'orchestrated' → 'delegated' 781 if (session.sessionType === 'orchestrated') session.sessionType = 'delegated' 782 if (session.sessionType !== 'human' && session.sessionType !== 'delegated') session.sessionType = 'human' 783 const isLegacyShortcut = ( 784 (typeof session.id === 'string' && session.id.startsWith('agent-thread-')) 785 || (typeof session.name === 'string' && session.name.startsWith('agent-thread:')) 786 ) 787 if ( 788 isLegacyShortcut 789 && typeof session.agentId === 'string' 790 && session.agentId.trim() 791 && (!session.shortcutForAgentId || session.shortcutForAgentId !== session.agentId) 792 ) { 793 session.shortcutForAgentId = session.agentId 794 } 795 const normalizedCapabilities = normalizeCapabilitySelection({ 796 tools: Array.isArray(session.tools) ? session.tools as string[] : undefined, 797 extensions: Array.isArray(session.extensions) ? session.extensions as string[] : undefined, 798 }) 799 session.tools = normalizedCapabilities.tools 800 session.extensions = normalizedCapabilities.extensions 801 if ('plugins' in session) delete session.plugins 802 if ('mainLoopState' in session) delete session.mainLoopState 803 if ('missionSummary' in session) delete session.missionSummary 804 // Messages are now stored in session_messages table — ensure default empty array 805 if (!Array.isArray(session.messages)) session.messages = [] 806 // Default messageCount for pre-migration blobs 807 if (typeof session.messageCount !== 'number') { 808 session.messageCount = (session.messages as unknown[]).length 809 } 810 // Default geminiSessionId for new field 811 if (session.geminiSessionId === undefined) session.geminiSessionId = null 812 // Default copilotSessionId for new field 813 if (session.copilotSessionId === undefined) session.copilotSessionId = null 814 if (session.opencodeWebSessionId === undefined) session.opencodeWebSessionId = null 815 if (session.droidSessionId === undefined) session.droidSessionId = null 816 if (session.cursorSessionId === undefined) session.cursorSessionId = null 817 if (session.qwenSessionId === undefined) session.qwenSessionId = null 818 if (session.acpSessionId === undefined) session.acpSessionId = null 819 if (!session.delegateResumeIds || typeof session.delegateResumeIds !== 'object') { 820 session.delegateResumeIds = { 821 claudeCode: null, 822 codex: null, 823 opencode: null, 824 gemini: null, 825 copilot: null, 826 droid: null, 827 cursor: null, 828 qwen: null, 829 } 830 } else { 831 const resumeIds = session.delegateResumeIds as Record<string, unknown> 832 if (resumeIds.copilot === undefined) resumeIds.copilot = null 833 if (resumeIds.droid === undefined) resumeIds.droid = null 834 if (resumeIds.cursor === undefined) resumeIds.cursor = null 835 if (resumeIds.qwen === undefined) resumeIds.qwen = null 836 } 837 // Default injectedMemoryIds for proactive recall dedup 838 if (!session.injectedMemoryIds || typeof session.injectedMemoryIds !== 'object') { 839 session.injectedMemoryIds = {} 840 } 841 // Validate runContext if present — leave null/undefined alone (created on demand) 842 if (session.runContext != null) { 843 if (typeof session.runContext !== 'object' || Array.isArray(session.runContext)) { 844 session.runContext = null 845 } else { 846 const rc = session.runContext as Record<string, unknown> 847 if (typeof rc.objective !== 'string' && rc.objective !== null) rc.objective = null 848 if (!Array.isArray(rc.constraints)) rc.constraints = [] 849 if (!Array.isArray(rc.keyFacts)) rc.keyFacts = [] 850 if (!Array.isArray(rc.discoveries)) rc.discoveries = [] 851 if (!Array.isArray(rc.failedApproaches)) rc.failedApproaches = [] 852 if (!Array.isArray(rc.currentPlan)) rc.currentPlan = [] 853 if (!Array.isArray(rc.completedSteps)) rc.completedSteps = [] 854 if (!Array.isArray(rc.blockers)) rc.blockers = [] 855 if (typeof rc.parentContext !== 'string' && rc.parentContext !== null) rc.parentContext = null 856 if (typeof rc.updatedAt !== 'number') rc.updatedAt = Date.now() 857 if (typeof rc.version !== 'number') rc.version = 0 858 } 859 } 860 return session 861 }