anima-core 0.2.1 → 1.0.0

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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +213 -43
  5. data/agents/codebase-analyzer.md +88 -0
  6. data/agents/codebase-pattern-finder.md +83 -0
  7. data/agents/documentation-researcher.md +59 -0
  8. data/agents/thoughts-analyzer.md +102 -0
  9. data/agents/web-search-researcher.md +71 -0
  10. data/anima-core.gemspec +3 -0
  11. data/app/channels/session_channel.rb +195 -45
  12. data/app/decorators/user_message_decorator.rb +16 -5
  13. data/app/jobs/agent_request_job.rb +55 -2
  14. data/app/jobs/analytical_brain_job.rb +33 -0
  15. data/app/jobs/count_event_tokens_job.rb +15 -4
  16. data/app/models/concerns/event/broadcasting.rb +81 -0
  17. data/app/models/event.rb +20 -1
  18. data/app/models/goal.rb +91 -0
  19. data/app/models/session.rb +366 -21
  20. data/config/application.rb +2 -0
  21. data/config/initializers/event_subscribers.rb +0 -1
  22. data/config/routes.rb +0 -6
  23. data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
  24. data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
  25. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  26. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  27. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  28. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  29. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  30. data/db/migrate/20260315140843_create_goals.rb +16 -0
  31. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  32. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  33. data/lib/agent_loop.rb +65 -6
  34. data/lib/agents/definition.rb +116 -0
  35. data/lib/agents/registry.rb +106 -0
  36. data/lib/analytical_brain/runner.rb +276 -0
  37. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  38. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  39. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  40. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  41. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  42. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  43. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  44. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  45. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  46. data/lib/analytical_brain.rb +23 -0
  47. data/lib/anima/cli/mcp/secrets.rb +76 -0
  48. data/lib/anima/cli/mcp.rb +197 -0
  49. data/lib/anima/cli.rb +5 -40
  50. data/lib/anima/installer.rb +168 -0
  51. data/lib/anima/settings.rb +226 -0
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +9 -0
  54. data/lib/credential_store.rb +103 -0
  55. data/lib/environment_probe.rb +232 -0
  56. data/lib/events/subscribers/persister.rb +1 -0
  57. data/lib/events/user_message.rb +17 -0
  58. data/lib/llm/client.rb +29 -10
  59. data/lib/mcp/client_manager.rb +86 -0
  60. data/lib/mcp/config.rb +213 -0
  61. data/lib/mcp/health_check.rb +77 -0
  62. data/lib/mcp/secrets.rb +73 -0
  63. data/lib/mcp/stdio_transport.rb +206 -0
  64. data/lib/providers/anthropic.rb +11 -20
  65. data/lib/shell_session.rb +11 -10
  66. data/lib/skills/definition.rb +97 -0
  67. data/lib/skills/registry.rb +105 -0
  68. data/lib/tools/edit.rb +226 -0
  69. data/lib/tools/mcp_tool.rb +114 -0
  70. data/lib/tools/read.rb +151 -0
  71. data/lib/tools/registry.rb +14 -12
  72. data/lib/tools/request_feature.rb +121 -0
  73. data/lib/tools/return_result.rb +81 -0
  74. data/lib/tools/spawn_specialist.rb +109 -0
  75. data/lib/tools/spawn_subagent.rb +111 -0
  76. data/lib/tools/subagent_prompts.rb +12 -0
  77. data/lib/tools/web_get.rb +8 -9
  78. data/lib/tools/write.rb +86 -0
  79. data/lib/tui/app.rb +985 -26
  80. data/lib/tui/cable_client.rb +69 -31
  81. data/lib/tui/message_store.rb +103 -8
  82. data/lib/tui/screens/chat.rb +293 -45
  83. data/lib/workflows/definition.rb +97 -0
  84. data/lib/workflows/registry.rb +89 -0
  85. data/skills/activerecord/SKILL.md +255 -0
  86. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  87. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  88. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  89. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  90. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  91. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  92. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  93. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  94. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  95. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  96. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  97. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  98. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  99. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  100. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  101. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  102. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  103. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  104. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  105. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  106. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  107. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  108. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  109. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  110. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  111. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  112. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  113. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  114. data/skills/activerecord/references/associations.md +709 -0
  115. data/skills/activerecord/references/basics.md +622 -0
  116. data/skills/activerecord/references/callbacks.md +738 -0
  117. data/skills/activerecord/references/migrations.md +657 -0
  118. data/skills/activerecord/references/querying.md +655 -0
  119. data/skills/activerecord/references/validations.md +596 -0
  120. data/skills/dragonruby/SKILL.md +250 -0
  121. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  122. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  123. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  124. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  125. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  126. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  127. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  128. data/skills/dragonruby/examples/core/labels.rb +22 -0
  129. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  130. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  131. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  132. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  133. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  134. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  135. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  136. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  137. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  138. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  139. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  140. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  141. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  142. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  143. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  144. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  145. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  146. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  147. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  148. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  149. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  150. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  151. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  152. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  153. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  154. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  155. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  156. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  157. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  158. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  159. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  160. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  161. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  162. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  163. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  164. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  165. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  166. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  167. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  168. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  169. data/skills/dragonruby/references/audio.md +396 -0
  170. data/skills/dragonruby/references/core.md +385 -0
  171. data/skills/dragonruby/references/distribution.md +434 -0
  172. data/skills/dragonruby/references/entities.md +516 -0
  173. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  174. data/skills/dragonruby/references/game-logic/state.md +389 -0
  175. data/skills/dragonruby/references/input.md +414 -0
  176. data/skills/dragonruby/references/rendering/animation.md +467 -0
  177. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  178. data/skills/dragonruby/references/scenes.md +443 -0
  179. data/skills/draper-decorators/SKILL.md +344 -0
  180. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  181. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  182. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  183. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  184. data/skills/draper-decorators/references/patterns.md +507 -0
  185. data/skills/draper-decorators/references/testing.md +559 -0
  186. data/skills/gh-issue.md +182 -0
  187. data/skills/mcp-server/SKILL.md +177 -0
  188. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  189. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  190. data/skills/mcp-server/examples/http_client.rb +48 -0
  191. data/skills/mcp-server/examples/http_server.rb +97 -0
  192. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  193. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  194. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  195. data/skills/mcp-server/references/gotchas.md +183 -0
  196. data/skills/mcp-server/references/prompts.md +98 -0
  197. data/skills/mcp-server/references/resources.md +53 -0
  198. data/skills/mcp-server/references/server.md +140 -0
  199. data/skills/mcp-server/references/tools.md +146 -0
  200. data/skills/mcp-server/references/transport.md +104 -0
  201. data/skills/ratatui-ruby/SKILL.md +315 -0
  202. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  203. data/skills/ratatui-ruby/references/events.md +387 -0
  204. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  205. data/skills/ratatui-ruby/references/layout.md +423 -0
  206. data/skills/ratatui-ruby/references/styling.md +268 -0
  207. data/skills/ratatui-ruby/references/testing.md +433 -0
  208. data/skills/ratatui-ruby/references/widgets.md +532 -0
  209. data/skills/rspec/SKILL.md +340 -0
  210. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  211. data/skills/rspec/examples/core/configuration.rb +126 -0
  212. data/skills/rspec/examples/core/hooks.rb +126 -0
  213. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  214. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  215. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  216. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  217. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  218. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  219. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  220. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  221. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  222. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  223. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  224. data/skills/rspec/examples/matchers/change.rb +115 -0
  225. data/skills/rspec/examples/matchers/collections.rb +154 -0
  226. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  227. data/skills/rspec/examples/matchers/composing.rb +155 -0
  228. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  229. data/skills/rspec/examples/matchers/equality.rb +58 -0
  230. data/skills/rspec/examples/matchers/errors.rb +136 -0
  231. data/skills/rspec/examples/matchers/output.rb +103 -0
  232. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  233. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  234. data/skills/rspec/examples/matchers/types.rb +82 -0
  235. data/skills/rspec/examples/matchers/yield.rb +147 -0
  236. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  237. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  238. data/skills/rspec/examples/mocks/constants.rb +177 -0
  239. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  240. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  241. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  242. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  243. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  244. data/skills/rspec/examples/mocks/responses.rb +223 -0
  245. data/skills/rspec/examples/mocks/spies.rb +149 -0
  246. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  247. data/skills/rspec/examples/rails/channels.rb +250 -0
  248. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  249. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  250. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  251. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  252. data/skills/rspec/examples/rails/matchers.rb +374 -0
  253. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  254. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  255. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  256. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  257. data/skills/rspec/examples/rails/transactions.rb +254 -0
  258. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  259. data/skills/rspec/references/core.md +816 -0
  260. data/skills/rspec/references/factory_bot.md +641 -0
  261. data/skills/rspec/references/matchers.md +516 -0
  262. data/skills/rspec/references/mocks.md +381 -0
  263. data/skills/rspec/references/rails.md +528 -0
  264. data/templates/soul.md +40 -0
  265. data/workflows/commit.md +45 -0
  266. data/workflows/create_handoff.md +98 -0
  267. data/workflows/create_note.md +82 -0
  268. data/workflows/create_plan.md +457 -0
  269. data/workflows/decompose_ticket.md +109 -0
  270. data/workflows/feature.md +91 -0
  271. data/workflows/implement_plan.md +87 -0
  272. data/workflows/iterate_plan.md +247 -0
  273. data/workflows/research_codebase.md +210 -0
  274. data/workflows/resume_handoff.md +217 -0
  275. data/workflows/review_pr.md +320 -0
  276. data/workflows/thoughts_init.md +71 -0
  277. data/workflows/validate_plan.md +166 -0
  278. metadata +290 -3
  279. data/app/controllers/api/sessions_controller.rb +0 -25
  280. data/lib/events/subscribers/action_cable_bridge.rb +0 -59
@@ -0,0 +1,353 @@
1
+ # Conditional Callbacks Examples
2
+ # Demonstrates :if, :unless, :on options and callback scoping
3
+
4
+ # =============================================================================
5
+ # Symbol Conditions (Method Names)
6
+ # =============================================================================
7
+
8
+ class Order < ApplicationRecord
9
+ before_save :calculate_tax, if: :taxable?
10
+ before_save :apply_discount, if: :has_coupon?
11
+ after_create :send_confirmation, unless: :guest_checkout?
12
+ after_save :notify_warehouse, if: :ready_for_fulfillment?
13
+
14
+ private
15
+
16
+ def taxable?
17
+ !tax_exempt? && total > 0
18
+ end
19
+
20
+ def has_coupon?
21
+ coupon_code.present?
22
+ end
23
+
24
+ def guest_checkout?
25
+ user_id.nil?
26
+ end
27
+
28
+ def ready_for_fulfillment?
29
+ saved_change_to_status? && status == "paid"
30
+ end
31
+
32
+ def calculate_tax
33
+ self.tax_amount = (subtotal * tax_rate).round(2)
34
+ end
35
+
36
+ def apply_discount
37
+ self.discount_amount = Coupon.find_by(code: coupon_code)&.calculate_discount(self) || 0
38
+ end
39
+
40
+ def send_confirmation
41
+ OrderMailer.confirmation(self).deliver_later
42
+ end
43
+
44
+ def notify_warehouse
45
+ WarehouseNotificationJob.perform_later(id)
46
+ end
47
+ end
48
+
49
+ # =============================================================================
50
+ # Proc/Lambda Conditions
51
+ # =============================================================================
52
+
53
+ class User < ApplicationRecord
54
+ # Simple lambda
55
+ before_save :encrypt_password, if: -> { password.present? }
56
+
57
+ # Lambda with record parameter
58
+ after_create :send_welcome_email, if: ->(user) { user.email_verified? }
59
+
60
+ # Complex inline condition
61
+ before_validation :normalize_phone, if: -> { phone.present? && phone_changed? }
62
+
63
+ # Using saved_change_to_attribute? in after callbacks
64
+ after_save :sync_to_crm, if: -> { saved_change_to_email? || saved_change_to_name? }
65
+
66
+ private
67
+
68
+ def encrypt_password
69
+ self.encrypted_password = BCrypt::Password.create(password)
70
+ end
71
+
72
+ def send_welcome_email
73
+ WelcomeMailer.welcome(self).deliver_later
74
+ end
75
+
76
+ def normalize_phone
77
+ self.phone = phone.gsub(/\D/, "")
78
+ end
79
+
80
+ def sync_to_crm
81
+ CrmSyncJob.perform_later(id)
82
+ end
83
+ end
84
+
85
+ # =============================================================================
86
+ # Multiple Conditions (Array)
87
+ # =============================================================================
88
+
89
+ class Article < ApplicationRecord
90
+ # All conditions must be true (AND logic)
91
+ before_save :schedule_publication,
92
+ if: [:published?, :publication_date_set?, :not_already_scheduled?]
93
+
94
+ # Mix of symbols and lambdas
95
+ after_update :notify_author,
96
+ if: [:status_changed?, -> { previous_changes[:status]&.last == "rejected" }]
97
+
98
+ # Multiple unless conditions
99
+ before_destroy :archive_content,
100
+ unless: [:draft?, :never_published?]
101
+
102
+ private
103
+
104
+ def published?
105
+ status == "published"
106
+ end
107
+
108
+ def publication_date_set?
109
+ publish_at.present?
110
+ end
111
+
112
+ def not_already_scheduled?
113
+ !scheduled?
114
+ end
115
+
116
+ def status_changed?
117
+ saved_change_to_status?
118
+ end
119
+
120
+ def draft?
121
+ status == "draft"
122
+ end
123
+
124
+ def never_published?
125
+ published_at.nil?
126
+ end
127
+
128
+ def schedule_publication
129
+ PublicationJob.set(wait_until: publish_at).perform_later(id)
130
+ self.scheduled = true
131
+ end
132
+
133
+ def notify_author
134
+ ArticleMailer.rejection_notice(self).deliver_later
135
+ end
136
+
137
+ def archive_content
138
+ ArticleArchive.create!(article_attributes: attributes)
139
+ end
140
+ end
141
+
142
+ # =============================================================================
143
+ # Combining :if and :unless
144
+ # =============================================================================
145
+
146
+ class Payment < ApplicationRecord
147
+ # Callback runs when :if is true AND :unless is false
148
+ after_create :send_receipt,
149
+ if: :successful?,
150
+ unless: :receipt_sent?
151
+
152
+ # Multiple conditions on both
153
+ before_save :validate_card,
154
+ if: [:card_payment?, :new_card?],
155
+ unless: -> { skip_validation? || test_mode? }
156
+
157
+ private
158
+
159
+ def successful?
160
+ status == "success"
161
+ end
162
+
163
+ def receipt_sent?
164
+ receipt_sent_at.present?
165
+ end
166
+
167
+ def card_payment?
168
+ payment_method == "card"
169
+ end
170
+
171
+ def new_card?
172
+ card_token_changed?
173
+ end
174
+
175
+ def skip_validation?
176
+ Rails.env.development? && ENV["SKIP_CARD_VALIDATION"]
177
+ end
178
+
179
+ def test_mode?
180
+ card_token&.start_with?("tok_test_")
181
+ end
182
+
183
+ def send_receipt
184
+ PaymentMailer.receipt(self).deliver_later
185
+ update_column(:receipt_sent_at, Time.current)
186
+ end
187
+
188
+ def validate_card
189
+ unless CardValidator.valid?(card_token)
190
+ errors.add(:card_token, "is invalid")
191
+ throw(:abort)
192
+ end
193
+ end
194
+ end
195
+
196
+ # =============================================================================
197
+ # :on Option - Context Scoping
198
+ # =============================================================================
199
+
200
+ class User < ApplicationRecord
201
+ # Only on create
202
+ before_validation :generate_username, on: :create
203
+ after_create :send_verification_email
204
+
205
+ # Only on update
206
+ before_save :track_email_change, on: :update
207
+ after_update :notify_email_change, if: :saved_change_to_email?
208
+
209
+ # Multiple contexts
210
+ after_save :sync_profile, on: [:create, :update]
211
+
212
+ private
213
+
214
+ def generate_username
215
+ self.username ||= email.split("@").first.parameterize
216
+ end
217
+
218
+ def send_verification_email
219
+ VerificationMailer.verify(self).deliver_later
220
+ end
221
+
222
+ def track_email_change
223
+ self.previous_email = email_was if email_changed?
224
+ end
225
+
226
+ def notify_email_change
227
+ UserMailer.email_changed(self).deliver_later
228
+ end
229
+
230
+ def sync_profile
231
+ ProfileSyncJob.perform_later(id)
232
+ end
233
+ end
234
+
235
+ # =============================================================================
236
+ # Custom Validation Contexts
237
+ # =============================================================================
238
+
239
+ class Article < ApplicationRecord
240
+ # Standard validations run on all contexts
241
+ validates :title, presence: true
242
+
243
+ # Only on :publish context
244
+ validates :body, presence: true, on: :publish
245
+ validates :category_id, presence: true, on: :publish
246
+ validates :meta_description, length: { maximum: 160 }, on: :publish
247
+
248
+ # Callbacks can also use custom contexts
249
+ before_validation :prepare_for_publish, on: :publish
250
+
251
+ def publish!
252
+ self.status = "published"
253
+ self.published_at = Time.current
254
+ save!(context: :publish) # Runs :publish validations and callbacks
255
+ end
256
+
257
+ private
258
+
259
+ def prepare_for_publish
260
+ self.slug ||= title.parameterize
261
+ self.excerpt ||= body.truncate(200)
262
+ end
263
+ end
264
+
265
+ # =============================================================================
266
+ # Conditional Callbacks with Dirty Tracking
267
+ # =============================================================================
268
+
269
+ class Product < ApplicationRecord
270
+ # Before save - use *_changed? methods
271
+ before_save :recalculate_margin, if: :cost_or_price_changed?
272
+
273
+ # After save - use saved_change_to_*? methods
274
+ after_save :update_search_index, if: :searchable_fields_changed?
275
+ after_save :notify_price_watchers, if: :price_decreased?
276
+
277
+ private
278
+
279
+ def cost_or_price_changed?
280
+ cost_changed? || price_changed?
281
+ end
282
+
283
+ def searchable_fields_changed?
284
+ saved_change_to_name? || saved_change_to_description? || saved_change_to_category_id?
285
+ end
286
+
287
+ def price_decreased?
288
+ saved_change_to_price? && price < price_before_last_save
289
+ end
290
+
291
+ def recalculate_margin
292
+ return unless cost.present? && price.present?
293
+
294
+ self.margin = ((price - cost) / price * 100).round(2)
295
+ end
296
+
297
+ def update_search_index
298
+ SearchIndexJob.perform_later("product", id)
299
+ end
300
+
301
+ def notify_price_watchers
302
+ PriceAlertJob.perform_later(id, price_before_last_save, price)
303
+ end
304
+ end
305
+
306
+ # =============================================================================
307
+ # Grouping with with_options
308
+ # =============================================================================
309
+
310
+ class Account < ApplicationRecord
311
+ # Apply same condition to multiple callbacks
312
+ with_options if: :premium_account? do |premium|
313
+ premium.before_save :apply_premium_features
314
+ premium.after_create :send_premium_welcome
315
+ premium.after_save :sync_to_premium_crm
316
+ end
317
+
318
+ with_options unless: :suspended? do |active|
319
+ active.after_save :update_activity_log
320
+ active.after_save :refresh_dashboard_cache
321
+ end
322
+
323
+ private
324
+
325
+ def premium_account?
326
+ plan_type == "premium"
327
+ end
328
+
329
+ def suspended?
330
+ status == "suspended"
331
+ end
332
+
333
+ def apply_premium_features
334
+ self.storage_limit = 100.gigabytes
335
+ self.api_rate_limit = 10_000
336
+ end
337
+
338
+ def send_premium_welcome
339
+ PremiumMailer.welcome(self).deliver_later
340
+ end
341
+
342
+ def sync_to_premium_crm
343
+ PremiumCrmSyncJob.perform_later(id)
344
+ end
345
+
346
+ def update_activity_log
347
+ ActivityLog.create!(account: self, action: "updated")
348
+ end
349
+
350
+ def refresh_dashboard_cache
351
+ Rails.cache.delete("dashboard_#{id}")
352
+ end
353
+ end
@@ -0,0 +1,280 @@
1
+ # Lifecycle Callbacks Examples
2
+ # Demonstrates before/after/around callbacks for validation, save, create, update, destroy
3
+
4
+ # =============================================================================
5
+ # Basic Callback Declaration
6
+ # =============================================================================
7
+
8
+ class Article < ApplicationRecord
9
+ # Method reference (preferred style)
10
+ before_validation :normalize_title
11
+ before_save :set_published_at
12
+ after_create :notify_subscribers
13
+ after_update :log_changes
14
+ before_destroy :check_deletable
15
+
16
+ private
17
+
18
+ def normalize_title
19
+ self.title = title&.strip&.titleize
20
+ end
21
+
22
+ def set_published_at
23
+ self.published_at ||= Time.current if published?
24
+ end
25
+
26
+ def notify_subscribers
27
+ NotificationJob.perform_later(id, "article_created")
28
+ end
29
+
30
+ def log_changes
31
+ Rails.logger.info("Article #{id} updated: #{previous_changes.keys.join(', ')}")
32
+ end
33
+
34
+ def check_deletable
35
+ throw(:abort) if comments.any?
36
+ end
37
+ end
38
+
39
+ # =============================================================================
40
+ # Around Callbacks (Must Yield!)
41
+ # =============================================================================
42
+
43
+ class Order < ApplicationRecord
44
+ around_save :measure_save_time
45
+ around_create :wrap_with_logging
46
+
47
+ private
48
+
49
+ def measure_save_time
50
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
51
+ yield # CRITICAL: Must call yield or save won't happen
52
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
53
+ Rails.logger.info("Order save took #{(duration * 1000).round(2)}ms")
54
+ end
55
+
56
+ def wrap_with_logging
57
+ Rails.logger.info("Creating order...")
58
+ yield
59
+ Rails.logger.info("Order #{id} created successfully")
60
+ rescue => e
61
+ Rails.logger.error("Order creation failed: #{e.message}")
62
+ raise
63
+ end
64
+ end
65
+
66
+ # =============================================================================
67
+ # Callback Ordering and Prepend
68
+ # =============================================================================
69
+
70
+ class Topic < ApplicationRecord
71
+ has_many :comments, dependent: :destroy
72
+
73
+ # Without prepend: runs AFTER dependent: :destroy (comments already gone)
74
+ # before_destroy :archive_comments
75
+
76
+ # With prepend: runs BEFORE dependent: :destroy
77
+ before_destroy :archive_comments, prepend: true
78
+
79
+ private
80
+
81
+ def archive_comments
82
+ comments.find_each do |comment|
83
+ CommentArchive.create!(
84
+ topic_id: id,
85
+ content: comment.content,
86
+ author: comment.author
87
+ )
88
+ end
89
+ end
90
+ end
91
+
92
+ # =============================================================================
93
+ # Special Callbacks: after_initialize and after_find
94
+ # =============================================================================
95
+
96
+ class Configuration < ApplicationRecord
97
+ after_initialize :set_defaults
98
+ after_find :decrypt_secrets
99
+
100
+ private
101
+
102
+ def set_defaults
103
+ # Runs for new() AND records loaded from DB
104
+ self.settings ||= {}
105
+ self.version ||= 1
106
+ end
107
+
108
+ def decrypt_secrets
109
+ # Runs only for records loaded from DB (before after_initialize)
110
+ self.api_key = decrypt(encrypted_api_key) if encrypted_api_key.present?
111
+ end
112
+
113
+ def decrypt(value)
114
+ # decryption logic
115
+ end
116
+ end
117
+
118
+ # =============================================================================
119
+ # Halting the Callback Chain
120
+ # =============================================================================
121
+
122
+ class Payment < ApplicationRecord
123
+ before_save :validate_amount
124
+ before_save :check_fraud
125
+ before_save :reserve_funds
126
+ after_save :send_receipt
127
+
128
+ private
129
+
130
+ def validate_amount
131
+ if amount <= 0
132
+ errors.add(:amount, "must be positive")
133
+ throw(:abort) # Halts chain, rolls back transaction
134
+ end
135
+ end
136
+
137
+ def check_fraud
138
+ if FraudDetector.suspicious?(self)
139
+ errors.add(:base, "Payment flagged for review")
140
+ throw(:abort)
141
+ end
142
+ end
143
+
144
+ def reserve_funds
145
+ # Only runs if previous callbacks didn't abort
146
+ PaymentGateway.reserve(amount, card_token)
147
+ end
148
+
149
+ def send_receipt
150
+ # Only runs if save succeeded
151
+ PaymentMailer.receipt(self).deliver_later
152
+ end
153
+ end
154
+
155
+ # =============================================================================
156
+ # Callback Object Pattern (Reusable)
157
+ # =============================================================================
158
+
159
+ class AuditLogger
160
+ def after_create(record)
161
+ AuditLog.create!(
162
+ action: "create",
163
+ auditable: record,
164
+ changes: record.attributes
165
+ )
166
+ end
167
+
168
+ def after_update(record)
169
+ AuditLog.create!(
170
+ action: "update",
171
+ auditable: record,
172
+ changes: record.previous_changes
173
+ )
174
+ end
175
+
176
+ def after_destroy(record)
177
+ AuditLog.create!(
178
+ action: "destroy",
179
+ auditable_type: record.class.name,
180
+ auditable_id: record.id,
181
+ changes: record.attributes
182
+ )
183
+ end
184
+ end
185
+
186
+ class Invoice < ApplicationRecord
187
+ after_create AuditLogger.new
188
+ after_update AuditLogger.new
189
+ after_destroy AuditLogger.new
190
+ end
191
+
192
+ class Refund < ApplicationRecord
193
+ after_create AuditLogger.new
194
+ after_update AuditLogger.new
195
+ end
196
+
197
+ # =============================================================================
198
+ # Inline Block Callbacks
199
+ # =============================================================================
200
+
201
+ class User < ApplicationRecord
202
+ # Simple inline normalization
203
+ before_validation { self.email = email&.downcase&.strip }
204
+
205
+ # Block with record parameter
206
+ after_create do |user|
207
+ WelcomeMailer.welcome(user).deliver_later
208
+ end
209
+
210
+ # Multiline block
211
+ before_save do
212
+ if email_changed?
213
+ self.email_verified = false
214
+ self.verification_token = SecureRandom.urlsafe_base64
215
+ end
216
+ end
217
+ end
218
+
219
+ # =============================================================================
220
+ # Validation Callbacks
221
+ # =============================================================================
222
+
223
+ class Product < ApplicationRecord
224
+ before_validation :generate_sku, on: :create
225
+ after_validation :log_validation_errors
226
+
227
+ private
228
+
229
+ def generate_sku
230
+ self.sku ||= "PRD-#{SecureRandom.alphanumeric(8).upcase}"
231
+ end
232
+
233
+ def log_validation_errors
234
+ return if errors.empty?
235
+
236
+ Rails.logger.warn("Product validation failed: #{errors.full_messages.join(', ')}")
237
+ end
238
+ end
239
+
240
+ # =============================================================================
241
+ # Touch Callback
242
+ # =============================================================================
243
+
244
+ class Comment < ApplicationRecord
245
+ belongs_to :post, touch: true # Automatically touches post on save
246
+
247
+ after_touch :update_cache
248
+
249
+ private
250
+
251
+ def update_cache
252
+ Rails.cache.delete("comment_#{id}_preview")
253
+ end
254
+ end
255
+
256
+ # =============================================================================
257
+ # Callback Inheritance
258
+ # =============================================================================
259
+
260
+ class Document < ApplicationRecord
261
+ before_save :set_version
262
+
263
+ private
264
+
265
+ def set_version
266
+ self.version = (version || 0) + 1
267
+ end
268
+ end
269
+
270
+ class Contract < Document
271
+ before_save :require_signatures
272
+
273
+ private
274
+
275
+ def require_signatures
276
+ throw(:abort) if requires_signature? && !signed?
277
+ end
278
+ end
279
+
280
+ # Contract.create! runs: set_version, then require_signatures