wxcli 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (413) hide show
  1. wxcli/__init__.py +4 -0
  2. wxcli/_playbook/.claude/agents/migration-advisor.md +239 -0
  3. wxcli/_playbook/.claude/agents/wxc-calling-builder.md +946 -0
  4. wxcli/_playbook/.claude/rules/cleanup.md +29 -0
  5. wxcli/_playbook/.claude/rules/cucm-migration.md +58 -0
  6. wxcli/_playbook/.claude/rules/org-health.md +14 -0
  7. wxcli/_playbook/.claude/settings.json +8 -0
  8. wxcli/_playbook/.claude/skills/audit-compliance/SKILL.md +515 -0
  9. wxcli/_playbook/.claude/skills/call-control/SKILL.md +747 -0
  10. wxcli/_playbook/.claude/skills/configure-features/SKILL.md +623 -0
  11. wxcli/_playbook/.claude/skills/configure-routing/SKILL.md +627 -0
  12. wxcli/_playbook/.claude/skills/contact-center/SKILL.md +1181 -0
  13. wxcli/_playbook/.claude/skills/cucm-migrate/SKILL.md +820 -0
  14. wxcli/_playbook/.claude/skills/customer-assist/SKILL.md +443 -0
  15. wxcli/_playbook/.claude/skills/device-platform/SKILL.md +407 -0
  16. wxcli/_playbook/.claude/skills/manage-call-settings/SKILL.md +757 -0
  17. wxcli/_playbook/.claude/skills/manage-devices/SKILL.md +790 -0
  18. wxcli/_playbook/.claude/skills/manage-identity/SKILL.md +638 -0
  19. wxcli/_playbook/.claude/skills/manage-licensing/SKILL.md +528 -0
  20. wxcli/_playbook/.claude/skills/manage-meetings/SKILL.md +708 -0
  21. wxcli/_playbook/.claude/skills/messaging-bots/SKILL.md +365 -0
  22. wxcli/_playbook/.claude/skills/messaging-spaces/SKILL.md +520 -0
  23. wxcli/_playbook/.claude/skills/org-health/SKILL.md +159 -0
  24. wxcli/_playbook/.claude/skills/provision-calling/SKILL.md +421 -0
  25. wxcli/_playbook/.claude/skills/query-live/SKILL.md +183 -0
  26. wxcli/_playbook/.claude/skills/query-live/domains/call-flow-trace.md +223 -0
  27. wxcli/_playbook/.claude/skills/query-live/domains/call-history.md +44 -0
  28. wxcli/_playbook/.claude/skills/query-live/domains/features.md +169 -0
  29. wxcli/_playbook/.claude/skills/query-live/domains/numbers.md +93 -0
  30. wxcli/_playbook/.claude/skills/query-live/domains/people-and-settings.md +146 -0
  31. wxcli/_playbook/.claude/skills/query-live/domains/routing.md +124 -0
  32. wxcli/_playbook/.claude/skills/reporting/SKILL.md +501 -0
  33. wxcli/_playbook/.claude/skills/reporting/references/cdr-recipes.md +1628 -0
  34. wxcli/_playbook/.claude/skills/reporting-cc/SKILL.md +251 -0
  35. wxcli/_playbook/.claude/skills/reporting-meetings/SKILL.md +290 -0
  36. wxcli/_playbook/.claude/skills/teardown/SKILL.md +214 -0
  37. wxcli/_playbook/.claude/skills/video-mesh/SKILL.md +379 -0
  38. wxcli/_playbook/.claude/skills/wxc-calling-debug/SKILL.md +475 -0
  39. wxcli/_playbook/CLAUDE.md +373 -0
  40. wxcli/_playbook/docs/reference/CLAUDE.md +25 -0
  41. wxcli/_playbook/docs/reference/admin-apps-data.md +643 -0
  42. wxcli/_playbook/docs/reference/admin-audit-security.md +322 -0
  43. wxcli/_playbook/docs/reference/admin-hybrid.md +383 -0
  44. wxcli/_playbook/docs/reference/admin-identity-scim.md +1007 -0
  45. wxcli/_playbook/docs/reference/admin-licensing.md +414 -0
  46. wxcli/_playbook/docs/reference/admin-org-management.md +586 -0
  47. wxcli/_playbook/docs/reference/admin-partner.md +357 -0
  48. wxcli/_playbook/docs/reference/archive/wxc-sdk-patterns.md +1229 -0
  49. wxcli/_playbook/docs/reference/archive/wxcadm-advanced.md +835 -0
  50. wxcli/_playbook/docs/reference/archive/wxcadm-core.md +743 -0
  51. wxcli/_playbook/docs/reference/archive/wxcadm-devices-workspaces.md +1305 -0
  52. wxcli/_playbook/docs/reference/archive/wxcadm-features.md +849 -0
  53. wxcli/_playbook/docs/reference/archive/wxcadm-locations.md +938 -0
  54. wxcli/_playbook/docs/reference/archive/wxcadm-person.md +1255 -0
  55. wxcli/_playbook/docs/reference/archive/wxcadm-routing.md +1262 -0
  56. wxcli/_playbook/docs/reference/authentication.md +965 -0
  57. wxcli/_playbook/docs/reference/call-control.md +1205 -0
  58. wxcli/_playbook/docs/reference/call-features-additional.md +2541 -0
  59. wxcli/_playbook/docs/reference/call-features-major.md +1320 -0
  60. wxcli/_playbook/docs/reference/call-routing.md +2304 -0
  61. wxcli/_playbook/docs/reference/contact-center-analytics.md +1049 -0
  62. wxcli/_playbook/docs/reference/contact-center-core.md +1614 -0
  63. wxcli/_playbook/docs/reference/contact-center-journey.md +457 -0
  64. wxcli/_playbook/docs/reference/contact-center-routing.md +1138 -0
  65. wxcli/_playbook/docs/reference/devices-core.md +1869 -0
  66. wxcli/_playbook/docs/reference/devices-dect.md +1150 -0
  67. wxcli/_playbook/docs/reference/devices-platform.md +467 -0
  68. wxcli/_playbook/docs/reference/devices-workspaces.md +1426 -0
  69. wxcli/_playbook/docs/reference/emergency-services.md +1122 -0
  70. wxcli/_playbook/docs/reference/location-calling-core.md +2037 -0
  71. wxcli/_playbook/docs/reference/location-calling-media.md +1334 -0
  72. wxcli/_playbook/docs/reference/location-recording-advanced.md +1482 -0
  73. wxcli/_playbook/docs/reference/meetings-content.md +473 -0
  74. wxcli/_playbook/docs/reference/meetings-core.md +903 -0
  75. wxcli/_playbook/docs/reference/meetings-infrastructure.md +899 -0
  76. wxcli/_playbook/docs/reference/meetings-settings.md +850 -0
  77. wxcli/_playbook/docs/reference/messaging-bots.md +1091 -0
  78. wxcli/_playbook/docs/reference/messaging-spaces.md +695 -0
  79. wxcli/_playbook/docs/reference/person-call-settings-behavior.md +1639 -0
  80. wxcli/_playbook/docs/reference/person-call-settings-handling.md +1394 -0
  81. wxcli/_playbook/docs/reference/person-call-settings-media.md +1898 -0
  82. wxcli/_playbook/docs/reference/person-call-settings-permissions.md +1329 -0
  83. wxcli/_playbook/docs/reference/provisioning.md +1366 -0
  84. wxcli/_playbook/docs/reference/reporting-analytics.md +1318 -0
  85. wxcli/_playbook/docs/reference/self-service-call-settings.md +1020 -0
  86. wxcli/_playbook/docs/reference/virtual-lines.md +1132 -0
  87. wxcli/_playbook/docs/reference/webhooks-events.md +1444 -0
  88. wxcli/_playbook/docs/reference/wxcadm-xsi-realtime.md +953 -0
  89. wxcli/_version.py +24 -0
  90. wxcli/auth.py +137 -0
  91. wxcli/commands/__init__.py +0 -0
  92. wxcli/commands/_registry.py +181 -0
  93. wxcli/commands/activation_email.py +103 -0
  94. wxcli/commands/admin_recordings.py +585 -0
  95. wxcli/commands/analytics.py +105 -0
  96. wxcli/commands/announcement_playlists.py +219 -0
  97. wxcli/commands/announcements.py +519 -0
  98. wxcli/commands/archive_users.py +36 -0
  99. wxcli/commands/attachment_actions.py +60 -0
  100. wxcli/commands/audit_events.py +83 -0
  101. wxcli/commands/authorizations.py +113 -0
  102. wxcli/commands/auto_attendant.py +642 -0
  103. wxcli/commands/broadworks_billing_reports.py +126 -0
  104. wxcli/commands/broadworks_enterprises.py +160 -0
  105. wxcli/commands/broadworks_subscribers.py +319 -0
  106. wxcli/commands/broadworks_workspaces.py +108 -0
  107. wxcli/commands/call_controls.py +1304 -0
  108. wxcli/commands/call_park.py +494 -0
  109. wxcli/commands/call_pickup.py +229 -0
  110. wxcli/commands/call_queue.py +1878 -0
  111. wxcli/commands/call_recording.py +707 -0
  112. wxcli/commands/call_routing.py +1610 -0
  113. wxcli/commands/call_settings_for_me_phase_5.py +212 -0
  114. wxcli/commands/caller_reputation.py +169 -0
  115. wxcli/commands/calling_service.py +305 -0
  116. wxcli/commands/cc_address_book.py +770 -0
  117. wxcli/commands/cc_agent_greetings.py +242 -0
  118. wxcli/commands/cc_agent_summaries.py +39 -0
  119. wxcli/commands/cc_agent_wellbeing.py +216 -0
  120. wxcli/commands/cc_agents.py +425 -0
  121. wxcli/commands/cc_ai_assistant.py +55 -0
  122. wxcli/commands/cc_ai_feature.py +294 -0
  123. wxcli/commands/cc_audio_files.py +149 -0
  124. wxcli/commands/cc_auto_csat.py +311 -0
  125. wxcli/commands/cc_aux_code.py +458 -0
  126. wxcli/commands/cc_business_hour.py +353 -0
  127. wxcli/commands/cc_call_monitoring.py +228 -0
  128. wxcli/commands/cc_callbacks.py +194 -0
  129. wxcli/commands/cc_campaign.py +212 -0
  130. wxcli/commands/cc_captures.py +39 -0
  131. wxcli/commands/cc_contact_list.py +169 -0
  132. wxcli/commands/cc_contact_number.py +295 -0
  133. wxcli/commands/cc_data_sources.py +235 -0
  134. wxcli/commands/cc_desktop_layout.py +380 -0
  135. wxcli/commands/cc_desktop_profile.py +322 -0
  136. wxcli/commands/cc_dial_number.py +511 -0
  137. wxcli/commands/cc_dial_plan.py +347 -0
  138. wxcli/commands/cc_dnc.py +101 -0
  139. wxcli/commands/cc_entry_point.py +545 -0
  140. wxcli/commands/cc_ewt.py +51 -0
  141. wxcli/commands/cc_flow.py +477 -0
  142. wxcli/commands/cc_global_vars.py +430 -0
  143. wxcli/commands/cc_holiday_list.py +335 -0
  144. wxcli/commands/cc_journey.py +1261 -0
  145. wxcli/commands/cc_legacy_flows.py +49 -0
  146. wxcli/commands/cc_multimedia_profile.py +436 -0
  147. wxcli/commands/cc_notification.py +51 -0
  148. wxcli/commands/cc_outdial_ani.py +604 -0
  149. wxcli/commands/cc_overrides.py +341 -0
  150. wxcli/commands/cc_queue.py +1083 -0
  151. wxcli/commands/cc_queue_stats.py +55 -0
  152. wxcli/commands/cc_realtime.py +51 -0
  153. wxcli/commands/cc_resource_collection.py +312 -0
  154. wxcli/commands/cc_search.py +46 -0
  155. wxcli/commands/cc_site.py +400 -0
  156. wxcli/commands/cc_skill.py +350 -0
  157. wxcli/commands/cc_skill_profile.py +289 -0
  158. wxcli/commands/cc_subscriptions.py +426 -0
  159. wxcli/commands/cc_summaries.py +130 -0
  160. wxcli/commands/cc_tasks.py +844 -0
  161. wxcli/commands/cc_team.py +325 -0
  162. wxcli/commands/cc_user_profiles.py +349 -0
  163. wxcli/commands/cc_users.py +701 -0
  164. wxcli/commands/cc_work_types.py +363 -0
  165. wxcli/commands/cdr.py +89 -0
  166. wxcli/commands/classifications.py +38 -0
  167. wxcli/commands/cleanup.py +1427 -0
  168. wxcli/commands/client_settings.py +72 -0
  169. wxcli/commands/conference.py +328 -0
  170. wxcli/commands/configure.py +72 -0
  171. wxcli/commands/converged_recordings.py +347 -0
  172. wxcli/commands/converged_recordings_export.py +310 -0
  173. wxcli/commands/cq_playlists.py +42 -0
  174. wxcli/commands/cucm.py +2794 -0
  175. wxcli/commands/cucm_config.py +129 -0
  176. wxcli/commands/customer_assist.py +373 -0
  177. wxcli/commands/data_sources.py +227 -0
  178. wxcli/commands/dect_devices.py +787 -0
  179. wxcli/commands/device_configurations.py +77 -0
  180. wxcli/commands/device_dynamic_settings.py +320 -0
  181. wxcli/commands/device_settings.py +1489 -0
  182. wxcli/commands/devices.py +280 -0
  183. wxcli/commands/domains.py +142 -0
  184. wxcli/commands/ecm.py +178 -0
  185. wxcli/commands/emergency_services.py +827 -0
  186. wxcli/commands/events.py +83 -0
  187. wxcli/commands/external_voicemail.py +52 -0
  188. wxcli/commands/groups.py +206 -0
  189. wxcli/commands/guest_management.py +76 -0
  190. wxcli/commands/hds.py +340 -0
  191. wxcli/commands/hot_desk.py +67 -0
  192. wxcli/commands/hot_desking_members.py +118 -0
  193. wxcli/commands/hot_desking_portal.py +124 -0
  194. wxcli/commands/hunt_group.py +577 -0
  195. wxcli/commands/hybrid_clusters.py +71 -0
  196. wxcli/commands/hybrid_connectors.py +67 -0
  197. wxcli/commands/identity_org.py +159 -0
  198. wxcli/commands/init_playbook.py +155 -0
  199. wxcli/commands/licenses.py +106 -0
  200. wxcli/commands/live_monitoring.py +40 -0
  201. wxcli/commands/location_call_handling.py +577 -0
  202. wxcli/commands/location_schedules.py +341 -0
  203. wxcli/commands/location_settings.py +1297 -0
  204. wxcli/commands/location_voicemail.py +494 -0
  205. wxcli/commands/locations.py +336 -0
  206. wxcli/commands/meeting_captions.py +110 -0
  207. wxcli/commands/meeting_chats.py +63 -0
  208. wxcli/commands/meeting_invitees.py +234 -0
  209. wxcli/commands/meeting_messages.py +28 -0
  210. wxcli/commands/meeting_participants.py +278 -0
  211. wxcli/commands/meeting_polls.py +111 -0
  212. wxcli/commands/meeting_preferences.py +515 -0
  213. wxcli/commands/meeting_qa.py +78 -0
  214. wxcli/commands/meeting_qualities.py +43 -0
  215. wxcli/commands/meeting_reports.py +101 -0
  216. wxcli/commands/meeting_session_types.py +105 -0
  217. wxcli/commands/meeting_site.py +57 -0
  218. wxcli/commands/meeting_slido.py +44 -0
  219. wxcli/commands/meeting_summaries.py +92 -0
  220. wxcli/commands/meeting_tracking_codes.py +233 -0
  221. wxcli/commands/meeting_transcripts.py +230 -0
  222. wxcli/commands/meetings.py +1997 -0
  223. wxcli/commands/memberships.py +164 -0
  224. wxcli/commands/messages.py +213 -0
  225. wxcli/commands/mode_management.py +244 -0
  226. wxcli/commands/my_call_settings.py +3577 -0
  227. wxcli/commands/numbers.py +405 -0
  228. wxcli/commands/operating_modes.py +410 -0
  229. wxcli/commands/org_contacts.py +284 -0
  230. wxcli/commands/org_health_cli.py +55 -0
  231. wxcli/commands/org_settings.py +71 -0
  232. wxcli/commands/organizations.py +83 -0
  233. wxcli/commands/paging_group.py +257 -0
  234. wxcli/commands/partner_admins.py +141 -0
  235. wxcli/commands/partner_reports.py +188 -0
  236. wxcli/commands/partner_tags.py +216 -0
  237. wxcli/commands/people.py +279 -0
  238. wxcli/commands/person_call_settings.py +71 -0
  239. wxcli/commands/pstn.py +280 -0
  240. wxcli/commands/recording_report.py +154 -0
  241. wxcli/commands/report_templates.py +38 -0
  242. wxcli/commands/reports.py +144 -0
  243. wxcli/commands/resource_group_memberships.py +160 -0
  244. wxcli/commands/resource_groups.py +67 -0
  245. wxcli/commands/roles.py +63 -0
  246. wxcli/commands/room_tabs.py +160 -0
  247. wxcli/commands/rooms.py +225 -0
  248. wxcli/commands/scim_bulk.py +46 -0
  249. wxcli/commands/scim_groups.py +246 -0
  250. wxcli/commands/scim_schemas.py +82 -0
  251. wxcli/commands/scim_users.py +283 -0
  252. wxcli/commands/security_audit.py +54 -0
  253. wxcli/commands/service_apps.py +51 -0
  254. wxcli/commands/single_number_reach.py +209 -0
  255. wxcli/commands/team_memberships.py +155 -0
  256. wxcli/commands/teams.py +153 -0
  257. wxcli/commands/ucm_profile.py +42 -0
  258. wxcli/commands/update.py +219 -0
  259. wxcli/commands/user_settings.py +5116 -0
  260. wxcli/commands/video_mesh.py +1090 -0
  261. wxcli/commands/virtual_extensions.py +502 -0
  262. wxcli/commands/virtual_line_settings.py +2072 -0
  263. wxcli/commands/webhooks.py +176 -0
  264. wxcli/commands/wholesale_billing_reports.py +147 -0
  265. wxcli/commands/wholesale_provisioning.py +523 -0
  266. wxcli/commands/workspace_locations.py +339 -0
  267. wxcli/commands/workspace_metrics.py +103 -0
  268. wxcli/commands/workspace_personalization.py +70 -0
  269. wxcli/commands/workspace_settings.py +3347 -0
  270. wxcli/commands/workspaces.py +279 -0
  271. wxcli/commands/xapi.py +114 -0
  272. wxcli/config.py +139 -0
  273. wxcli/errors.py +77 -0
  274. wxcli/main.py +210 -0
  275. wxcli/migration/CLAUDE.md +106 -0
  276. wxcli/migration/__init__.py +1 -0
  277. wxcli/migration/advisory/CLAUDE.md +196 -0
  278. wxcli/migration/advisory/__init__.py +46 -0
  279. wxcli/migration/advisory/advisor.py +107 -0
  280. wxcli/migration/advisory/advisory_patterns.py +2552 -0
  281. wxcli/migration/advisory/recommendation_rules.py +835 -0
  282. wxcli/migration/cucm/CLAUDE.md +74 -0
  283. wxcli/migration/cucm/__init__.py +5 -0
  284. wxcli/migration/cucm/connection.py +265 -0
  285. wxcli/migration/cucm/discovery.py +225 -0
  286. wxcli/migration/cucm/extractors/__init__.py +1 -0
  287. wxcli/migration/cucm/extractors/announcements.py +92 -0
  288. wxcli/migration/cucm/extractors/base.py +90 -0
  289. wxcli/migration/cucm/extractors/device_profiles.py +98 -0
  290. wxcli/migration/cucm/extractors/devices.py +193 -0
  291. wxcli/migration/cucm/extractors/e911.py +103 -0
  292. wxcli/migration/cucm/extractors/features.py +309 -0
  293. wxcli/migration/cucm/extractors/helpers.py +73 -0
  294. wxcli/migration/cucm/extractors/informational.py +295 -0
  295. wxcli/migration/cucm/extractors/locations.py +221 -0
  296. wxcli/migration/cucm/extractors/moh.py +100 -0
  297. wxcli/migration/cucm/extractors/remote_destinations.py +96 -0
  298. wxcli/migration/cucm/extractors/routing.py +493 -0
  299. wxcli/migration/cucm/extractors/shared_lines.py +146 -0
  300. wxcli/migration/cucm/extractors/templates.py +229 -0
  301. wxcli/migration/cucm/extractors/tier4.py +173 -0
  302. wxcli/migration/cucm/extractors/users.py +240 -0
  303. wxcli/migration/cucm/extractors/voicemail.py +243 -0
  304. wxcli/migration/cucm/extractors/workspaces.py +88 -0
  305. wxcli/migration/cucm/unity_connection.py +304 -0
  306. wxcli/migration/execute/CLAUDE.md +372 -0
  307. wxcli/migration/execute/__init__.py +303 -0
  308. wxcli/migration/execute/batch.py +277 -0
  309. wxcli/migration/execute/dependency.py +483 -0
  310. wxcli/migration/execute/engine.py +964 -0
  311. wxcli/migration/execute/handlers.py +2232 -0
  312. wxcli/migration/execute/planner.py +1939 -0
  313. wxcli/migration/execute/runtime.py +571 -0
  314. wxcli/migration/export/CLAUDE.md +49 -0
  315. wxcli/migration/export/__init__.py +8 -0
  316. wxcli/migration/export/csv_export.py +126 -0
  317. wxcli/migration/export/deployment_plan.py +479 -0
  318. wxcli/migration/export/json_export.py +60 -0
  319. wxcli/migration/models.py +941 -0
  320. wxcli/migration/phone_models.py +50 -0
  321. wxcli/migration/preflight/CLAUDE.md +52 -0
  322. wxcli/migration/preflight/__init__.py +89 -0
  323. wxcli/migration/preflight/checks.py +911 -0
  324. wxcli/migration/preflight/runner.py +293 -0
  325. wxcli/migration/rate_limiter.py +144 -0
  326. wxcli/migration/report/CLAUDE.md +245 -0
  327. wxcli/migration/report/__init__.py +7 -0
  328. wxcli/migration/report/appendix.py +2437 -0
  329. wxcli/migration/report/assembler.py +183 -0
  330. wxcli/migration/report/charts.py +263 -0
  331. wxcli/migration/report/executive.py +776 -0
  332. wxcli/migration/report/explainer.py +704 -0
  333. wxcli/migration/report/helpers.py +74 -0
  334. wxcli/migration/report/ingest.py +152 -0
  335. wxcli/migration/report/notice_templates.py +192 -0
  336. wxcli/migration/report/score.py +375 -0
  337. wxcli/migration/report/styles.py +1052 -0
  338. wxcli/migration/report/user_diff.py +1052 -0
  339. wxcli/migration/report/user_notice.py +425 -0
  340. wxcli/migration/state.py +139 -0
  341. wxcli/migration/store.py +860 -0
  342. wxcli/migration/transform/CLAUDE.md +107 -0
  343. wxcli/migration/transform/__init__.py +12 -0
  344. wxcli/migration/transform/analysis_pipeline.py +453 -0
  345. wxcli/migration/transform/analyzers/__init__.py +142 -0
  346. wxcli/migration/transform/analyzers/css_permission.py +189 -0
  347. wxcli/migration/transform/analyzers/css_routing.py +335 -0
  348. wxcli/migration/transform/analyzers/device_compatibility.py +122 -0
  349. wxcli/migration/transform/analyzers/dn_ambiguity.py +131 -0
  350. wxcli/migration/transform/analyzers/duplicate_user.py +221 -0
  351. wxcli/migration/transform/analyzers/extension_conflict.py +173 -0
  352. wxcli/migration/transform/analyzers/feature_approximation.py +227 -0
  353. wxcli/migration/transform/analyzers/layout_overflow.py +174 -0
  354. wxcli/migration/transform/analyzers/location_ambiguity.py +184 -0
  355. wxcli/migration/transform/analyzers/missing_data.py +300 -0
  356. wxcli/migration/transform/analyzers/selective_call_handling.py +394 -0
  357. wxcli/migration/transform/analyzers/shared_line.py +170 -0
  358. wxcli/migration/transform/analyzers/voicemail_compatibility.py +267 -0
  359. wxcli/migration/transform/analyzers/workspace_license.py +212 -0
  360. wxcli/migration/transform/cross_reference.py +1136 -0
  361. wxcli/migration/transform/cucm_pattern.py +246 -0
  362. wxcli/migration/transform/decisions.py +301 -0
  363. wxcli/migration/transform/e164.py +145 -0
  364. wxcli/migration/transform/engine.py +256 -0
  365. wxcli/migration/transform/mappers/CLAUDE.md +123 -0
  366. wxcli/migration/transform/mappers/__init__.py +76 -0
  367. wxcli/migration/transform/mappers/announcement_mapper.py +122 -0
  368. wxcli/migration/transform/mappers/base.py +190 -0
  369. wxcli/migration/transform/mappers/button_template_mapper.py +186 -0
  370. wxcli/migration/transform/mappers/call_forwarding_mapper.py +228 -0
  371. wxcli/migration/transform/mappers/call_settings_mapper.py +144 -0
  372. wxcli/migration/transform/mappers/css_mapper.py +868 -0
  373. wxcli/migration/transform/mappers/dect_mapper.py +367 -0
  374. wxcli/migration/transform/mappers/device_layout_mapper.py +289 -0
  375. wxcli/migration/transform/mappers/device_mapper.py +227 -0
  376. wxcli/migration/transform/mappers/device_profile_mapper.py +190 -0
  377. wxcli/migration/transform/mappers/device_settings_mapper.py +464 -0
  378. wxcli/migration/transform/mappers/e911_mapper.py +129 -0
  379. wxcli/migration/transform/mappers/ecbn_mapper.py +263 -0
  380. wxcli/migration/transform/mappers/executive_assistant_mapper.py +156 -0
  381. wxcli/migration/transform/mappers/feature_mapper.py +1179 -0
  382. wxcli/migration/transform/mappers/line_mapper.py +310 -0
  383. wxcli/migration/transform/mappers/location_mapper.py +235 -0
  384. wxcli/migration/transform/mappers/moh_mapper.py +114 -0
  385. wxcli/migration/transform/mappers/monitoring_mapper.py +221 -0
  386. wxcli/migration/transform/mappers/receptionist_mapper.py +245 -0
  387. wxcli/migration/transform/mappers/routing_mapper.py +821 -0
  388. wxcli/migration/transform/mappers/snr_mapper.py +201 -0
  389. wxcli/migration/transform/mappers/softkey_mapper.py +205 -0
  390. wxcli/migration/transform/mappers/user_mapper.py +264 -0
  391. wxcli/migration/transform/mappers/voicemail_group_mapper.py +286 -0
  392. wxcli/migration/transform/mappers/voicemail_mapper.py +514 -0
  393. wxcli/migration/transform/mappers/workspace_mapper.py +464 -0
  394. wxcli/migration/transform/normalizers.py +2008 -0
  395. wxcli/migration/transform/pattern_converter.py +106 -0
  396. wxcli/migration/transform/pipeline.py +271 -0
  397. wxcli/migration/transform/rules.py +255 -0
  398. wxcli/org_health/CLAUDE.md +63 -0
  399. wxcli/org_health/__init__.py +7 -0
  400. wxcli/org_health/analyze.py +93 -0
  401. wxcli/org_health/checks.py +458 -0
  402. wxcli/org_health/collector.py +62 -0
  403. wxcli/org_health/models.py +78 -0
  404. wxcli/org_health/report.py +300 -0
  405. wxcli/output.py +101 -0
  406. wxcli-1.2.0.dist-info/METADATA +505 -0
  407. wxcli-1.2.0.dist-info/RECORD +413 -0
  408. wxcli-1.2.0.dist-info/WHEEL +5 -0
  409. wxcli-1.2.0.dist-info/entry_points.txt +2 -0
  410. wxcli-1.2.0.dist-info/licenses/LICENSE +191 -0
  411. wxcli-1.2.0.dist-info/scm_file_list.json +751 -0
  412. wxcli-1.2.0.dist-info/scm_version.json +8 -0
  413. wxcli-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,860 @@
1
+ """SQLite-backed migration store.
2
+
3
+ Thin wrapper over sqlite3 for migration persistence. Uses WAL mode for
4
+ concurrent reads and foreign keys for referential integrity.
5
+
6
+ Schema from: 01-data-representation.md (objects, cross_refs, decisions, journal)
7
+ 07-idempotency-resumability.md (merge_log)
8
+ 05-dependency-graph.md (plan_operations, plan_edges)
9
+ Query helpers from: 03b-transform-mappers.md shared patterns section
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sqlite3
16
+ import uuid
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from wxcli.migration.models import (
22
+ CANONICAL_CLASS_TO_TYPE,
23
+ CANONICAL_TYPE_REGISTRY,
24
+ MigrationObject,
25
+ MigrationStatus,
26
+ )
27
+
28
+
29
+ def _now() -> str:
30
+ return datetime.now(timezone.utc).isoformat()
31
+
32
+
33
+ class MigrationStore:
34
+ """SQLite-backed store for migration objects, cross-refs, and decisions.
35
+
36
+ (from 01-data-representation.md lines 112-136,
37
+ 03b-transform-mappers.md shared patterns lines 999-1031)
38
+ """
39
+
40
+ def __init__(self, db_path: Path | str) -> None:
41
+ self.db_path = Path(db_path)
42
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
43
+ self.conn = sqlite3.connect(str(self.db_path))
44
+ self.conn.row_factory = sqlite3.Row
45
+ # WAL mode for concurrent reads (from 01-data-representation.md line 118)
46
+ self.conn.execute("PRAGMA journal_mode=WAL")
47
+ # Foreign keys (from 01-data-representation.md line 119)
48
+ self.conn.execute("PRAGMA foreign_keys=ON")
49
+ self._run_id: str | None = None
50
+ self._ensure_schema()
51
+ self._decision_counter: int = self._max_decision_number()
52
+
53
+ def close(self) -> None:
54
+ self.conn.close()
55
+
56
+ def __enter__(self) -> MigrationStore:
57
+ return self
58
+
59
+ def __exit__(self, *args: object) -> None:
60
+ self.close()
61
+
62
+ # ------------------------------------------------------------------
63
+ # Schema
64
+ # ------------------------------------------------------------------
65
+
66
+ def _ensure_schema(self) -> None:
67
+ """Create all tables and indexes if they don't exist.
68
+ (from 01-data-representation.md lines 30-91,
69
+ 07-idempotency-resumability.md lines 209-218,
70
+ 05-dependency-graph.md lines 304-325)
71
+ """
72
+ self.conn.executescript("""
73
+ -- Core object store (from 01-data-representation.md lines 34-48)
74
+ CREATE TABLE IF NOT EXISTS objects (
75
+ canonical_id TEXT PRIMARY KEY,
76
+ object_type TEXT NOT NULL,
77
+ status TEXT NOT NULL DEFAULT 'discovered',
78
+ location_id TEXT,
79
+ webex_id TEXT,
80
+ batch TEXT,
81
+ tier INTEGER,
82
+ data TEXT NOT NULL,
83
+ created_at TEXT NOT NULL,
84
+ updated_at TEXT NOT NULL
85
+ );
86
+ CREATE INDEX IF NOT EXISTS idx_type_status ON objects(object_type, status);
87
+ CREATE INDEX IF NOT EXISTS idx_location ON objects(location_id);
88
+ CREATE INDEX IF NOT EXISTS idx_batch_tier ON objects(batch, tier);
89
+
90
+ -- Cross-references (from 01-data-representation.md lines 50-61)
91
+ CREATE TABLE IF NOT EXISTS cross_refs (
92
+ from_id TEXT NOT NULL,
93
+ to_id TEXT NOT NULL,
94
+ relationship TEXT NOT NULL,
95
+ ordinal INTEGER,
96
+ PRIMARY KEY (from_id, to_id, relationship),
97
+ FOREIGN KEY (from_id) REFERENCES objects(canonical_id),
98
+ FOREIGN KEY (to_id) REFERENCES objects(canonical_id)
99
+ );
100
+ CREATE INDEX IF NOT EXISTS idx_xref_to ON cross_refs(to_id, relationship);
101
+
102
+ -- Decisions (from 01-data-representation.md lines 63-77)
103
+ CREATE TABLE IF NOT EXISTS decisions (
104
+ decision_id TEXT PRIMARY KEY,
105
+ type TEXT NOT NULL,
106
+ severity TEXT NOT NULL,
107
+ summary TEXT NOT NULL,
108
+ context TEXT NOT NULL,
109
+ options TEXT NOT NULL,
110
+ chosen_option TEXT,
111
+ resolved_at TEXT,
112
+ resolved_by TEXT,
113
+ fingerprint TEXT NOT NULL,
114
+ run_id TEXT NOT NULL,
115
+ UNIQUE(fingerprint)
116
+ );
117
+
118
+ -- Execution journal (from 01-data-representation.md lines 79-90)
119
+ CREATE TABLE IF NOT EXISTS journal (
120
+ entry_id INTEGER PRIMARY KEY AUTOINCREMENT,
121
+ timestamp TEXT NOT NULL,
122
+ entry_type TEXT NOT NULL,
123
+ canonical_id TEXT NOT NULL,
124
+ resource_type TEXT NOT NULL,
125
+ request TEXT NOT NULL,
126
+ response TEXT,
127
+ pre_state TEXT,
128
+ FOREIGN KEY (canonical_id) REFERENCES objects(canonical_id)
129
+ );
130
+
131
+ -- Merge log (from 07-idempotency-resumability.md lines 209-218)
132
+ CREATE TABLE IF NOT EXISTS merge_log (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ timestamp TEXT NOT NULL,
135
+ stage TEXT NOT NULL,
136
+ run_id TEXT NOT NULL,
137
+ action TEXT NOT NULL,
138
+ target_type TEXT NOT NULL,
139
+ target_id TEXT NOT NULL,
140
+ details TEXT
141
+ );
142
+
143
+ -- Execution plan DAG (from 05-dependency-graph.md lines 304-325)
144
+ CREATE TABLE IF NOT EXISTS plan_operations (
145
+ node_id TEXT PRIMARY KEY,
146
+ canonical_id TEXT NOT NULL,
147
+ op_type TEXT NOT NULL,
148
+ resource_type TEXT NOT NULL,
149
+ tier INTEGER NOT NULL,
150
+ batch TEXT,
151
+ api_calls INTEGER DEFAULT 1,
152
+ description TEXT,
153
+ status TEXT DEFAULT 'pending',
154
+ -- Phase 12b execution tracking
155
+ webex_id TEXT,
156
+ error_message TEXT,
157
+ completed_at TEXT,
158
+ attempts INTEGER DEFAULT 0,
159
+ FOREIGN KEY (canonical_id) REFERENCES objects(canonical_id)
160
+ );
161
+
162
+ CREATE TABLE IF NOT EXISTS plan_edges (
163
+ from_node TEXT NOT NULL,
164
+ to_node TEXT NOT NULL,
165
+ dep_type TEXT NOT NULL,
166
+ broken INTEGER DEFAULT 0,
167
+ PRIMARY KEY (from_node, to_node),
168
+ FOREIGN KEY (from_node) REFERENCES plan_operations(node_id),
169
+ FOREIGN KEY (to_node) REFERENCES plan_operations(node_id)
170
+ );
171
+ """)
172
+ self.conn.commit()
173
+
174
+ # Schema migration for Phase 12b execution tracking
175
+ for col, col_def in [
176
+ ("webex_id", "TEXT"),
177
+ ("error_message", "TEXT"),
178
+ ("completed_at", "TEXT"),
179
+ ("attempts", "INTEGER DEFAULT 0"),
180
+ ]:
181
+ try:
182
+ self.conn.execute(
183
+ f"ALTER TABLE plan_operations ADD COLUMN {col} {col_def}"
184
+ )
185
+ except Exception:
186
+ pass # Column already exists
187
+
188
+ # Bulk operations payload column (Phase: bulk-operations)
189
+ try:
190
+ self.conn.execute("ALTER TABLE plan_operations ADD COLUMN data_json TEXT")
191
+ except Exception:
192
+ pass # Column already exists
193
+
194
+ # Advisory system schema migration (Phase 13a)
195
+ for col in ["recommendation", "recommendation_reasoning"]:
196
+ try:
197
+ self.conn.execute(f"ALTER TABLE decisions ADD COLUMN {col} TEXT")
198
+ except Exception:
199
+ pass # Column already exists
200
+ self.conn.commit()
201
+
202
+ # ------------------------------------------------------------------
203
+ # Run ID (from 03b-transform-mappers.md line 1025-1026)
204
+ # ------------------------------------------------------------------
205
+
206
+ @property
207
+ def current_run_id(self) -> str:
208
+ if self._run_id is None:
209
+ self._run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S-") + uuid.uuid4().hex[:8]
210
+ return self._run_id
211
+
212
+ def set_run_id(self, run_id: str) -> None:
213
+ self._run_id = run_id
214
+
215
+ # ------------------------------------------------------------------
216
+ # Decision ID (from 03b-transform-mappers.md line 1029-1030)
217
+ # ------------------------------------------------------------------
218
+
219
+ def next_decision_id(self) -> str:
220
+ self._decision_counter += 1
221
+ return f"D{self._decision_counter:04d}"
222
+
223
+ def _max_decision_number(self) -> int:
224
+ """Read the highest existing decision number from the DB so new IDs
225
+ don't collide with decisions from a previous session."""
226
+ row = self.conn.execute(
227
+ "SELECT MAX(CAST(SUBSTR(decision_id, 2) AS INTEGER)) AS n FROM decisions"
228
+ ).fetchone()
229
+ return row["n"] or 0
230
+
231
+ # ------------------------------------------------------------------
232
+ # Object CRUD
233
+ # ------------------------------------------------------------------
234
+
235
+ def upsert_object(self, obj: MigrationObject) -> None:
236
+ """Insert or update a canonical migration object.
237
+ (from 01-data-representation.md lines 122-128)
238
+ """
239
+ now = _now()
240
+ data_json = obj.model_dump_json()
241
+ object_type = self._object_type_for(obj)
242
+ location_id = getattr(obj, "location_id", None)
243
+ self.conn.execute(
244
+ """
245
+ INSERT INTO objects (canonical_id, object_type, status, location_id,
246
+ webex_id, batch, tier, data, created_at, updated_at)
247
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
248
+ ON CONFLICT(canonical_id) DO UPDATE SET
249
+ object_type = excluded.object_type,
250
+ status = excluded.status,
251
+ location_id = excluded.location_id,
252
+ webex_id = excluded.webex_id,
253
+ batch = excluded.batch,
254
+ data = excluded.data,
255
+ updated_at = excluded.updated_at
256
+ """,
257
+ (
258
+ obj.canonical_id,
259
+ object_type,
260
+ obj.status.value,
261
+ location_id,
262
+ obj.webex_id,
263
+ obj.batch,
264
+ None, # tier set by planner
265
+ data_json,
266
+ now,
267
+ now,
268
+ ),
269
+ )
270
+ self.conn.commit()
271
+
272
+ def get_object(self, canonical_id: str) -> dict[str, Any] | None:
273
+ """Get a single object by canonical_id.
274
+ (from 03b-transform-mappers.md line 1008-1009)
275
+ """
276
+ row = self.conn.execute(
277
+ "SELECT data FROM objects WHERE canonical_id = ?", (canonical_id,)
278
+ ).fetchone()
279
+ if row is None:
280
+ return None
281
+ return json.loads(row["data"])
282
+
283
+ def get_object_typed(self, canonical_id: str) -> MigrationObject | None:
284
+ """Get a single object deserialized into its Pydantic type."""
285
+ row = self.conn.execute(
286
+ "SELECT object_type, data FROM objects WHERE canonical_id = ?",
287
+ (canonical_id,),
288
+ ).fetchone()
289
+ if row is None:
290
+ return None
291
+ return self._deserialize(row["object_type"], row["data"])
292
+
293
+ def get_objects(self, object_type: str) -> list[dict[str, Any]]:
294
+ """Get all objects of a given type as dicts.
295
+ (from 03b-transform-mappers.md line 1005-1006)
296
+ """
297
+ rows = self.conn.execute(
298
+ "SELECT data FROM objects WHERE object_type = ?", (object_type,)
299
+ ).fetchall()
300
+ return [json.loads(r["data"]) for r in rows]
301
+
302
+ def query_by_type(self, object_type: str, status: str | None = None) -> list[MigrationObject]:
303
+ """Query objects by type, optionally filtered by status.
304
+ (from 01-data-representation.md line 130-131)
305
+ """
306
+ if status:
307
+ rows = self.conn.execute(
308
+ "SELECT object_type, data FROM objects WHERE object_type = ? AND status = ?",
309
+ (object_type, status),
310
+ ).fetchall()
311
+ else:
312
+ rows = self.conn.execute(
313
+ "SELECT object_type, data FROM objects WHERE object_type = ?",
314
+ (object_type,),
315
+ ).fetchall()
316
+ return [self._deserialize(r["object_type"], r["data"]) for r in rows]
317
+
318
+ # Synthetic bulk-op placeholder types stored in the objects table as FK anchors.
319
+ # These have minimal JSON (no canonical_id/provenance) and cannot be deserialized
320
+ # as MigrationObject subclasses. The planner handles them via _optimize_for_bulk()
321
+ # after expand_to_operations(), so they must be excluded from query_by_status().
322
+ _BULK_PLACEHOLDER_TYPES: frozenset[str] = frozenset({
323
+ "bulk_device_settings",
324
+ "bulk_line_key_template",
325
+ "bulk_rebuild_phones",
326
+ "bulk_dynamic_settings",
327
+ })
328
+
329
+ def query_by_status(self, status: str) -> list[MigrationObject]:
330
+ """Query all objects with a given status.
331
+
332
+ Excludes synthetic bulk-op placeholder rows (_BULK_PLACEHOLDER_TYPES)
333
+ which are stored in the objects table as FK anchors but do not
334
+ deserialize as MigrationObject subclasses.
335
+ """
336
+ placeholders = ",".join("?" * len(self._BULK_PLACEHOLDER_TYPES))
337
+ rows = self.conn.execute(
338
+ f"SELECT canonical_id, object_type, data FROM objects "
339
+ f"WHERE status = ? AND object_type NOT IN ({placeholders})",
340
+ (status, *self._BULK_PLACEHOLDER_TYPES),
341
+ ).fetchall()
342
+ return [self._deserialize(r["object_type"], r["data"]) for r in rows]
343
+
344
+ def count_by_type(self, object_type: str) -> int:
345
+ row = self.conn.execute(
346
+ "SELECT COUNT(*) as cnt FROM objects WHERE object_type = ?", (object_type,)
347
+ ).fetchone()
348
+ return row["cnt"]
349
+
350
+ # ------------------------------------------------------------------
351
+ # Cross-references
352
+ # ------------------------------------------------------------------
353
+
354
+ def add_cross_ref(
355
+ self,
356
+ from_id: str,
357
+ to_id: str,
358
+ relationship: str,
359
+ ordinal: int | None = None,
360
+ ) -> None:
361
+ """Add a cross-reference between two objects.
362
+ (from 01-data-representation.md lines 50-61)
363
+ """
364
+ self.conn.execute(
365
+ """
366
+ INSERT OR REPLACE INTO cross_refs (from_id, to_id, relationship, ordinal)
367
+ VALUES (?, ?, ?, ?)
368
+ """,
369
+ (from_id, to_id, relationship, ordinal),
370
+ )
371
+ self.conn.commit()
372
+
373
+ def find_cross_refs(self, canonical_id: str, relationship: str) -> list[str]:
374
+ """Find all target IDs for a source + relationship.
375
+ (from 01-data-representation.md lines 133-135)
376
+ """
377
+ rows = self.conn.execute(
378
+ "SELECT to_id FROM cross_refs WHERE from_id = ? AND relationship = ?",
379
+ (canonical_id, relationship),
380
+ ).fetchall()
381
+ return [r["to_id"] for r in rows]
382
+
383
+ def get_cross_refs(
384
+ self,
385
+ from_id: str | None = None,
386
+ to_id: str | None = None,
387
+ relationship: str | None = None,
388
+ ) -> list[dict[str, Any]]:
389
+ """Flexible cross-ref query with optional filters.
390
+ (from 03b-transform-mappers.md lines 1014-1016)
391
+ """
392
+ clauses: list[str] = []
393
+ params: list[Any] = []
394
+ if from_id is not None:
395
+ clauses.append("from_id = ?")
396
+ params.append(from_id)
397
+ if to_id is not None:
398
+ clauses.append("to_id = ?")
399
+ params.append(to_id)
400
+ if relationship is not None:
401
+ clauses.append("relationship = ?")
402
+ params.append(relationship)
403
+ where = " AND ".join(clauses) if clauses else "1=1"
404
+ rows = self.conn.execute(
405
+ f"SELECT from_id, to_id, relationship, ordinal FROM cross_refs WHERE {where}",
406
+ params,
407
+ ).fetchall()
408
+ return [dict(r) for r in rows]
409
+
410
+ def get_cross_ref_targets(self, from_id: str, relationship: str) -> list[str]:
411
+ """Get all target IDs for a given source and relationship type.
412
+ (from 03b-transform-mappers.md lines 1018-1019)
413
+ """
414
+ return self.find_cross_refs(from_id, relationship)
415
+
416
+ def resolve_chain(self, start_id: str, *relationships: str) -> str | None:
417
+ """Follow a chain of cross-refs to reach a final target.
418
+ (from 03b-transform-mappers.md lines 1021-1023)
419
+
420
+ Example: resolve_chain(dn_id, 'device_has_dn', 'device_in_pool', 'device_pool_to_location')
421
+ """
422
+ current = start_id
423
+ for rel in relationships:
424
+ targets = self.find_cross_refs(current, rel)
425
+ if not targets:
426
+ return None
427
+ current = targets[0]
428
+ return current
429
+
430
+ def clear_cross_refs(self) -> None:
431
+ """Delete all cross-refs for rebuild during normalization pass 2.
432
+ (from 07-idempotency-resumability.md lines 183-187)
433
+ """
434
+ self.conn.execute("DELETE FROM cross_refs")
435
+ self.conn.commit()
436
+
437
+ # ------------------------------------------------------------------
438
+ # Cleanup (for pipeline re-run safety)
439
+ # ------------------------------------------------------------------
440
+
441
+ def clear_all(self) -> dict[str, int]:
442
+ """Delete all data from every table in FK-safe order. Returns counts per table."""
443
+ counts: dict[str, int] = {}
444
+ for table in [
445
+ "plan_edges", "plan_operations", "merge_log",
446
+ "decisions", "journal", "cross_refs", "objects",
447
+ ]:
448
+ cursor = self.conn.execute(f"DELETE FROM {table}") # noqa: S608
449
+ counts[table] = cursor.rowcount
450
+ self.conn.commit()
451
+ return counts
452
+
453
+ def clear_journal(self) -> int:
454
+ """Delete all journal entries. Returns count deleted."""
455
+ cursor = self.conn.execute("DELETE FROM journal")
456
+ self.conn.commit()
457
+ return cursor.rowcount
458
+
459
+ def clear_objects(self) -> int:
460
+ """Delete all objects. Must clear cross_refs and journal first (FK constraints)."""
461
+ cursor = self.conn.execute("DELETE FROM objects")
462
+ self.conn.commit()
463
+ return cursor.rowcount
464
+
465
+ def clear_objects_by_types(self, types: list[str]) -> int:
466
+ """Delete objects of specific resource types. Returns count deleted."""
467
+ if not types:
468
+ return 0
469
+ placeholders = ",".join("?" for _ in types)
470
+ cursor = self.conn.execute(
471
+ f"DELETE FROM objects WHERE resource_type IN ({placeholders})", types
472
+ )
473
+ self.conn.commit()
474
+ return cursor.rowcount
475
+
476
+ def clear_decisions_by_source(self, source: str) -> int:
477
+ """Delete decisions from a specific source (e.g. 'mapper', 'analyzer'). Returns count deleted."""
478
+ cursor = self.conn.execute("DELETE FROM decisions WHERE source = ?", (source,))
479
+ self.conn.commit()
480
+ return cursor.rowcount
481
+
482
+ # ------------------------------------------------------------------
483
+ # Decisions
484
+ # ------------------------------------------------------------------
485
+
486
+ def save_decision(self, decision: dict[str, Any]) -> None:
487
+ """Insert or update a decision (upsert by fingerprint).
488
+ (from 01-data-representation.md lines 63-77)
489
+
490
+ On fingerprint conflict the existing ``decision_id`` is preserved
491
+ and the incoming ID is discarded. This matches the merge algorithm
492
+ in 07-idempotency-resumability.md: existing decisions keep their
493
+ identity across re-analysis runs.
494
+ """
495
+ self.conn.execute(
496
+ """
497
+ INSERT INTO decisions (decision_id, type, severity, summary, context,
498
+ options, chosen_option, resolved_at, resolved_by,
499
+ fingerprint, run_id,
500
+ recommendation, recommendation_reasoning)
501
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
502
+ ON CONFLICT(fingerprint) DO UPDATE SET
503
+ type = excluded.type,
504
+ severity = excluded.severity,
505
+ summary = excluded.summary,
506
+ context = excluded.context,
507
+ options = excluded.options,
508
+ run_id = excluded.run_id,
509
+ recommendation = excluded.recommendation,
510
+ recommendation_reasoning = excluded.recommendation_reasoning
511
+ """,
512
+ (
513
+ decision["decision_id"],
514
+ decision["type"],
515
+ decision["severity"],
516
+ decision["summary"],
517
+ json.dumps(decision.get("context", {})),
518
+ json.dumps(decision.get("options", [])),
519
+ decision.get("chosen_option"),
520
+ decision.get("resolved_at"),
521
+ decision.get("resolved_by"),
522
+ decision["fingerprint"],
523
+ decision.get("run_id", self.current_run_id),
524
+ decision.get("recommendation"),
525
+ decision.get("recommendation_reasoning"),
526
+ ),
527
+ )
528
+ self.conn.commit()
529
+
530
+ def update_decision_context(
531
+ self,
532
+ decision_id: str,
533
+ context: dict[str, Any],
534
+ ) -> None:
535
+ """Patch only the ``context`` column of an existing decision row.
536
+
537
+ Used by ``enrich_cross_decision_context()`` to write cross-decision
538
+ fields (e.g., ``is_on_incompatible_device``) into a MISSING_DATA
539
+ decision without re-fingerprinting or touching other columns.
540
+
541
+ Raises ``KeyError`` if the decision_id does not exist.
542
+ """
543
+ cursor = self.conn.execute(
544
+ "UPDATE decisions SET context = ? WHERE decision_id = ?",
545
+ (json.dumps(context), decision_id),
546
+ )
547
+ if cursor.rowcount == 0:
548
+ raise KeyError(f"decision_id not found: {decision_id}")
549
+ self.conn.commit()
550
+
551
+ def get_decision(self, decision_id: str) -> dict[str, Any] | None:
552
+ row = self.conn.execute(
553
+ "SELECT * FROM decisions WHERE decision_id = ?", (decision_id,)
554
+ ).fetchone()
555
+ if row is None:
556
+ return None
557
+ return self._decision_row_to_dict(row)
558
+
559
+ def get_all_decisions(self) -> list[dict[str, Any]]:
560
+ rows = self.conn.execute("SELECT * FROM decisions").fetchall()
561
+ return [self._decision_row_to_dict(r) for r in rows]
562
+
563
+ def resolve_decision(
564
+ self, decision_id: str, chosen_option: str, resolved_by: str = "user"
565
+ ) -> None:
566
+ self.conn.execute(
567
+ """
568
+ UPDATE decisions SET chosen_option = ?, resolved_at = ?, resolved_by = ?
569
+ WHERE decision_id = ?
570
+ """,
571
+ (chosen_option, _now(), resolved_by, decision_id),
572
+ )
573
+ self.conn.commit()
574
+
575
+ def update_recommendation(self, decision_id: str, recommendation: str | None,
576
+ recommendation_reasoning: str | None) -> None:
577
+ """Update recommendation fields on an existing decision."""
578
+ self.conn.execute(
579
+ "UPDATE decisions SET recommendation = ?, recommendation_reasoning = ? "
580
+ "WHERE decision_id = ?",
581
+ (recommendation, recommendation_reasoning, decision_id),
582
+ )
583
+ self.conn.commit()
584
+
585
+ # ------------------------------------------------------------------
586
+ # Decision merge (from 07-idempotency-resumability.md lines 66-120)
587
+ # ------------------------------------------------------------------
588
+
589
+ def merge_decisions(
590
+ self, new_decisions: list[dict[str, Any]],
591
+ decision_types: list[str] | None = None,
592
+ stage: str = "analyze",
593
+ ) -> dict[str, int]:
594
+ """Three-way merge of new analysis results with existing decisions.
595
+
596
+ On re-analysis, matches new decisions against existing by fingerprint:
597
+ - Fingerprint in both: keep existing (preserve resolution if resolved)
598
+ - Fingerprint only in new: insert as new decision
599
+ - Fingerprint only in old: mark stale
600
+
601
+ If ``decision_types`` is provided, only existing decisions of those
602
+ types are considered for the merge. This prevents preflight from
603
+ stale-marking analyzer decisions (from 05a-preflight-checks.md
604
+ lines 920-929).
605
+
606
+ Returns dict with counts: kept, updated, new, stale, invalidated.
607
+ Logs each action to the merge_log table.
608
+
609
+ (from 07-idempotency-resumability.md lines 66-120)
610
+ """
611
+ existing_rows = self.get_all_decisions()
612
+ existing_by_fp: dict[str, dict[str, Any]] = {}
613
+ for d in existing_rows:
614
+ fp = d.get("fingerprint", "")
615
+ # Skip already-stale decisions
616
+ if d.get("chosen_option") == "__stale__":
617
+ continue
618
+ # Scope to specific decision types if provided
619
+ if decision_types is not None and d.get("type") not in decision_types:
620
+ continue
621
+ existing_by_fp[fp] = d
622
+
623
+ new_by_fp: dict[str, dict[str, Any]] = {}
624
+ for d in new_decisions:
625
+ fp = d.get("fingerprint", "")
626
+ new_by_fp[fp] = d
627
+
628
+ result = {"kept": 0, "updated": 0, "new": 0, "stale": 0, "invalidated": 0}
629
+
630
+ existing_fps = set(existing_by_fp.keys())
631
+ new_fps = set(new_by_fp.keys())
632
+
633
+ # Case 1: Fingerprint in both old and new — condition still exists
634
+ for fp in existing_fps & new_fps:
635
+ old = existing_by_fp[fp]
636
+ new = new_by_fp[fp]
637
+ if old.get("chosen_option") is not None:
638
+ # Resolved decision, same fingerprint → keep resolution
639
+ # Update display-only fields (summary, options, context)
640
+ # while preserving chosen_option, resolved_at, resolved_by
641
+ self.conn.execute(
642
+ """UPDATE decisions SET context = ?, summary = ?,
643
+ options = ?, run_id = ?,
644
+ recommendation = ?, recommendation_reasoning = ?
645
+ WHERE decision_id = ?""",
646
+ (
647
+ json.dumps(new.get("context", {})),
648
+ new.get("summary", old.get("summary", "")),
649
+ json.dumps(new.get("options", [])),
650
+ new.get("run_id", self.current_run_id),
651
+ new.get("recommendation"),
652
+ new.get("recommendation_reasoning"),
653
+ old["decision_id"],
654
+ ),
655
+ )
656
+ self.add_merge_log(
657
+ stage, "kept", "decision", old["decision_id"],
658
+ {"reason": "resolved, fingerprint unchanged"},
659
+ )
660
+ result["kept"] += 1
661
+ else:
662
+ # Pending decision, same fingerprint → update context
663
+ self.conn.execute(
664
+ """UPDATE decisions SET context = ?, summary = ?,
665
+ severity = ?, options = ?, run_id = ?,
666
+ recommendation = ?, recommendation_reasoning = ?
667
+ WHERE decision_id = ?""",
668
+ (
669
+ json.dumps(new.get("context", {})),
670
+ new.get("summary", old.get("summary", "")),
671
+ new.get("severity", old.get("severity", "")),
672
+ json.dumps(new.get("options", [])),
673
+ new.get("run_id", self.current_run_id),
674
+ new.get("recommendation"),
675
+ new.get("recommendation_reasoning"),
676
+ old["decision_id"],
677
+ ),
678
+ )
679
+ self.add_merge_log(
680
+ stage, "updated", "decision", old["decision_id"],
681
+ {"reason": "pending, context updated"},
682
+ )
683
+ result["updated"] += 1
684
+
685
+ # Case 2: Fingerprint only in new — new decision
686
+ for fp in new_fps - existing_fps:
687
+ self.save_decision(new_by_fp[fp])
688
+ new_id = new_by_fp[fp].get("decision_id", "?")
689
+ self.add_merge_log(
690
+ stage, "new", "decision", new_id,
691
+ {"reason": "new conflict detected"},
692
+ )
693
+ result["new"] += 1
694
+
695
+ # Case 3: Fingerprint only in old — condition gone
696
+ for fp in existing_fps - new_fps:
697
+ old = existing_by_fp[fp]
698
+ old_id = old["decision_id"]
699
+ if old.get("chosen_option") is not None:
700
+ # Was resolved, now irrelevant
701
+ self.conn.execute(
702
+ """UPDATE decisions SET chosen_option = '__stale__',
703
+ resolved_by = 'stale' WHERE decision_id = ?""",
704
+ (old_id,),
705
+ )
706
+ self.add_merge_log(
707
+ stage, "invalidated", "decision", old_id,
708
+ {"reason": "resolved decision no longer relevant"},
709
+ )
710
+ result["invalidated"] += 1
711
+ else:
712
+ self.conn.execute(
713
+ """UPDATE decisions SET chosen_option = '__stale__',
714
+ resolved_by = 'stale' WHERE decision_id = ?""",
715
+ (old_id,),
716
+ )
717
+ self.add_merge_log(
718
+ stage, "stale", "decision", old_id,
719
+ {"reason": "condition no longer exists"},
720
+ )
721
+ result["stale"] += 1
722
+
723
+ self.conn.commit()
724
+ return result
725
+
726
+ # ------------------------------------------------------------------
727
+ # Journal
728
+ # ------------------------------------------------------------------
729
+
730
+ def add_journal_entry(
731
+ self,
732
+ entry_type: str,
733
+ canonical_id: str,
734
+ resource_type: str,
735
+ request: dict[str, Any],
736
+ response: dict[str, Any] | None = None,
737
+ pre_state: dict[str, Any] | None = None,
738
+ ) -> None:
739
+ """Append to the execution journal.
740
+ (from 01-data-representation.md lines 79-90)
741
+ """
742
+ self.conn.execute(
743
+ """
744
+ INSERT INTO journal (timestamp, entry_type, canonical_id, resource_type,
745
+ request, response, pre_state)
746
+ VALUES (?, ?, ?, ?, ?, ?, ?)
747
+ """,
748
+ (
749
+ _now(),
750
+ entry_type,
751
+ canonical_id,
752
+ resource_type,
753
+ json.dumps(request),
754
+ json.dumps(response) if response else None,
755
+ json.dumps(pre_state) if pre_state else None,
756
+ ),
757
+ )
758
+ self.conn.commit()
759
+
760
+ def get_journal_count(self, entry_type: str | None = None) -> int:
761
+ """Count journal entries, optionally filtered by entry_type."""
762
+ if entry_type:
763
+ row = self.conn.execute(
764
+ "SELECT COUNT(*) as cnt FROM journal WHERE entry_type = ?",
765
+ (entry_type,),
766
+ ).fetchone()
767
+ else:
768
+ row = self.conn.execute(
769
+ "SELECT COUNT(*) as cnt FROM journal"
770
+ ).fetchone()
771
+ return row["cnt"]
772
+
773
+ def get_journal_entries(
774
+ self,
775
+ entry_type: str | None = None,
776
+ limit: int | None = None,
777
+ ) -> list[dict[str, Any]]:
778
+ """Get journal entries, optionally filtered by entry_type.
779
+
780
+ Returns list of dicts with keys: entry_id, timestamp, entry_type,
781
+ canonical_id, resource_type, request, response, pre_state.
782
+ """
783
+ if entry_type:
784
+ sql = "SELECT * FROM journal WHERE entry_type = ?"
785
+ params: tuple[Any, ...] = (entry_type,)
786
+ else:
787
+ sql = "SELECT * FROM journal"
788
+ params = ()
789
+ if limit:
790
+ sql += " LIMIT ?"
791
+ params = (*params, limit)
792
+ rows = self.conn.execute(sql, params).fetchall()
793
+ return [dict(r) for r in rows]
794
+
795
+ # ------------------------------------------------------------------
796
+ # Merge log
797
+ # ------------------------------------------------------------------
798
+
799
+ def add_merge_log(
800
+ self,
801
+ stage: str,
802
+ action: str,
803
+ target_type: str,
804
+ target_id: str,
805
+ details: dict[str, Any] | None = None,
806
+ ) -> None:
807
+ """Log a merge operation.
808
+ (from 07-idempotency-resumability.md lines 209-218)
809
+ """
810
+ self.conn.execute(
811
+ """
812
+ INSERT INTO merge_log (timestamp, stage, run_id, action, target_type,
813
+ target_id, details)
814
+ VALUES (?, ?, ?, ?, ?, ?, ?)
815
+ """,
816
+ (
817
+ _now(),
818
+ stage,
819
+ self.current_run_id,
820
+ action,
821
+ target_type,
822
+ target_id,
823
+ json.dumps(details) if details else None,
824
+ ),
825
+ )
826
+ self.conn.commit()
827
+
828
+ # ------------------------------------------------------------------
829
+ # Helpers
830
+ # ------------------------------------------------------------------
831
+
832
+ @staticmethod
833
+ def _object_type_for(obj: MigrationObject) -> str:
834
+ """Derive the object_type string from a Pydantic model class (O(1) lookup).
835
+
836
+ For canonical subtypes (CanonicalUser, CanonicalDevice, etc.), uses the
837
+ class→type registry. For base MigrationObject instances (intermediate types
838
+ like device_pool, css, partition), extracts the type from the canonical_id
839
+ prefix (e.g., "device_pool:DP-HQ" → "device_pool").
840
+ """
841
+ registered = CANONICAL_CLASS_TO_TYPE.get(type(obj))
842
+ if registered is not None:
843
+ return registered
844
+ # Intermediate types use canonical_id prefix convention: "type:name"
845
+ if type(obj) is MigrationObject and ":" in obj.canonical_id:
846
+ return obj.canonical_id.split(":")[0]
847
+ return obj.__class__.__name__.lower()
848
+
849
+ @staticmethod
850
+ def _deserialize(object_type: str, data_json: str) -> MigrationObject:
851
+ """Deserialize JSON data into the correct Pydantic type."""
852
+ cls = CANONICAL_TYPE_REGISTRY.get(object_type, MigrationObject)
853
+ return cls.model_validate_json(data_json)
854
+
855
+ @staticmethod
856
+ def _decision_row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
857
+ d = dict(row)
858
+ d["context"] = json.loads(d["context"]) if d["context"] else {}
859
+ d["options"] = json.loads(d["options"]) if d["options"] else []
860
+ return d