/ src / lib / server / storage-normalization.ts
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  }