source_monitor 0.13.0 → 0.14.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/sm-configuration-setting/reference/settings-catalog.md +1 -0
  3. data/.claude/skills/sm-configure/SKILL.md +8 -1
  4. data/.claude/skills/sm-configure/reference/configuration-reference.md +11 -0
  5. data/.claude/skills/sm-event-handler/SKILL.md +1 -1
  6. data/.claude/skills/sm-event-handler/reference/events-api.md +1 -1
  7. data/.claude/skills/sm-host-setup/SKILL.md +13 -3
  8. data/.claude/skills/sm-host-setup/reference/initializer-template.md +11 -0
  9. data/.claude/skills/sm-host-setup/reference/setup-checklist.md +9 -1
  10. data/.claude/skills/sm-upgrade/reference/version-history.md +12 -0
  11. data/CHANGELOG.md +19 -0
  12. data/Gemfile.lock +1 -1
  13. data/README.md +3 -3
  14. data/VERSION +1 -1
  15. data/app/assets/builds/source_monitor/application.css +4 -0
  16. data/app/controllers/source_monitor/application_controller.rb +73 -14
  17. data/app/controllers/source_monitor/bulk_scrape_enablements_controller.rb +1 -1
  18. data/app/controllers/source_monitor/import_sessions/bulk_configuration.rb +3 -1
  19. data/app/controllers/source_monitor/import_sessions_controller.rb +118 -72
  20. data/app/controllers/source_monitor/sources_controller.rb +4 -18
  21. data/app/models/source_monitor/source.rb +1 -1
  22. data/app/views/layouts/source_monitor/application.html.erb +6 -0
  23. data/docs/configuration.md +18 -1
  24. data/docs/deployment.md +1 -1
  25. data/docs/goals/engine-hardening/.goalbuddy-board/app.js +543 -0
  26. data/docs/goals/engine-hardening/.goalbuddy-board/goalbuddy-mark.png +0 -0
  27. data/docs/goals/engine-hardening/.goalbuddy-board/index.html +111 -0
  28. data/docs/goals/engine-hardening/.goalbuddy-board/styles.css +991 -0
  29. data/docs/goals/engine-hardening/goal.md +97 -0
  30. data/docs/goals/engine-hardening/notes/T001-spec-validation.md +37 -0
  31. data/docs/goals/engine-hardening/state.yaml +324 -0
  32. data/docs/setup.md +3 -3
  33. data/docs/upgrade.md +41 -0
  34. data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +10 -0
  35. data/lib/source_monitor/analytics/scrape_recommendations.rb +21 -2
  36. data/lib/source_monitor/configuration/authentication_settings.rb +5 -1
  37. data/lib/source_monitor/fetching/feed_fetcher/failure_outcome.rb +85 -0
  38. data/lib/source_monitor/fetching/feed_fetcher/success_outcome.rb +85 -0
  39. data/lib/source_monitor/fetching/feed_fetcher.rb +27 -88
  40. data/lib/source_monitor/fetching/fetch_runner.rb +12 -5
  41. data/lib/source_monitor/import_sessions/wizard.rb +612 -0
  42. data/lib/source_monitor/items/batch_item_creator.rb +7 -6
  43. data/lib/source_monitor/items/item_creator.rb +7 -14
  44. data/lib/source_monitor/items/normalized_entry.rb +61 -0
  45. data/lib/source_monitor/security/authentication.rb +10 -0
  46. data/lib/source_monitor/version.rb +1 -1
  47. data/lib/source_monitor.rb +2 -0
  48. data/source_monitor.gemspec +7 -2
  49. metadata +12 -68
  50. data/.claude/agent-memory/vbw-vbw-debugger/MEMORY.md +0 -15
  51. data/.claude/agent-memory/vbw-vbw-dev/MEMORY.md +0 -34
  52. data/.claude/agent-memory/vbw-vbw-lead/MEMORY.md +0 -49
  53. data/.claude/agents/rails-concern.md +0 -464
  54. data/.claude/agents/rails-controller.md +0 -424
  55. data/.claude/agents/rails-hotwire.md +0 -446
  56. data/.claude/agents/rails-implement.md +0 -374
  57. data/.claude/agents/rails-job.md +0 -334
  58. data/.claude/agents/rails-lint.md +0 -294
  59. data/.claude/agents/rails-mailer.md +0 -371
  60. data/.claude/agents/rails-migration.md +0 -449
  61. data/.claude/agents/rails-model.md +0 -420
  62. data/.claude/agents/rails-policy.md +0 -443
  63. data/.claude/agents/rails-presenter.md +0 -427
  64. data/.claude/agents/rails-query.md +0 -412
  65. data/.claude/agents/rails-review.md +0 -490
  66. data/.claude/agents/rails-service.md +0 -458
  67. data/.claude/agents/rails-state-records.md +0 -465
  68. data/.claude/agents/rails-tdd.md +0 -314
  69. data/.claude/agents/rails-test.md +0 -441
  70. data/.claude/agents/rails-view-component.md +0 -418
  71. data/.claude/commands/rails-audit.md +0 -77
  72. data/.claude/commands/release.md +0 -366
  73. data/.claude/hooks/block-secrets.sh +0 -52
  74. data/.claude/settings.json +0 -85
  75. data/.claude/skills/action-cable-patterns/SKILL.md +0 -296
  76. data/.claude/skills/action-mailer-patterns/SKILL.md +0 -295
  77. data/.claude/skills/active-storage-setup/SKILL.md +0 -311
  78. data/.claude/skills/api-versioning/SKILL.md +0 -294
  79. data/.claude/skills/authentication-flow/SKILL.md +0 -335
  80. data/.claude/skills/authentication-flow/reference/current.md +0 -248
  81. data/.claude/skills/authentication-flow/reference/passwordless.md +0 -253
  82. data/.claude/skills/authentication-flow/reference/sessions.md +0 -201
  83. data/.claude/skills/authorization-pundit/SKILL.md +0 -462
  84. data/.claude/skills/caching-strategies/SKILL.md +0 -350
  85. data/.claude/skills/database-migrations/SKILL.md +0 -354
  86. data/.claude/skills/form-object-patterns/SKILL.md +0 -399
  87. data/.claude/skills/hotwire-patterns/SKILL.md +0 -247
  88. data/.claude/skills/hotwire-patterns/reference/stimulus.md +0 -307
  89. data/.claude/skills/hotwire-patterns/reference/tailwind-integration.md +0 -112
  90. data/.claude/skills/hotwire-patterns/reference/turbo-frames.md +0 -158
  91. data/.claude/skills/hotwire-patterns/reference/turbo-streams.md +0 -218
  92. data/.claude/skills/i18n-patterns/SKILL.md +0 -320
  93. data/.claude/skills/install/SKILL.md +0 -367
  94. data/.claude/skills/performance-optimization/SKILL.md +0 -311
  95. data/.claude/skills/rails-architecture/SKILL.md +0 -259
  96. data/.claude/skills/rails-architecture/reference/error-handling.md +0 -333
  97. data/.claude/skills/rails-architecture/reference/event-tracking.md +0 -142
  98. data/.claude/skills/rails-architecture/reference/layer-interactions.md +0 -417
  99. data/.claude/skills/rails-architecture/reference/multi-tenancy.md +0 -152
  100. data/.claude/skills/rails-architecture/reference/query-patterns.md +0 -342
  101. data/.claude/skills/rails-architecture/reference/service-patterns.md +0 -286
  102. data/.claude/skills/rails-architecture/reference/state-records.md +0 -250
  103. data/.claude/skills/rails-architecture/reference/testing-strategy.md +0 -326
  104. data/.claude/skills/rails-concern/SKILL.md +0 -399
  105. data/.claude/skills/rails-controller/SKILL.md +0 -336
  106. data/.claude/skills/rails-model-generator/SKILL.md +0 -321
  107. data/.claude/skills/rails-model-generator/reference/validations.md +0 -298
  108. data/.claude/skills/rails-presenter/SKILL.md +0 -274
  109. data/.claude/skills/rails-query-object/SKILL.md +0 -289
  110. data/.claude/skills/rails-service-object/SKILL.md +0 -349
  111. data/.claude/skills/solid-queue-setup/SKILL.md +0 -307
  112. data/.claude/skills/tdd-cycle/SKILL.md +0 -359
  113. data/.claude/skills/viewcomponent-patterns/SKILL.md +0 -333
  114. data/app/controllers/source_monitor/import_sessions/entry_annotation.rb +0 -187
  115. data/app/controllers/source_monitor/import_sessions/health_check_management.rb +0 -112
  116. data/app/controllers/source_monitor/import_sessions/opml_parser.rb +0 -130
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Fetching
5
+ class FeedFetcher
6
+ class FailureOutcome
7
+ def initialize(error:)
8
+ @error = error
9
+ end
10
+
11
+ attr_reader :error, :retry_decision
12
+
13
+ def apply(source_updater:, started_at:, instrumentation_payload:)
14
+ duration_ms = source_updater.elapsed_ms(started_at)
15
+ @retry_decision = update_source(source_updater, duration_ms)
16
+ create_fetch_log(source_updater, duration_ms, started_at)
17
+ apply_instrumentation(instrumentation_payload)
18
+ result
19
+ end
20
+
21
+ def status
22
+ :failed
23
+ end
24
+
25
+ def response
26
+ error.response
27
+ end
28
+
29
+ def body
30
+ response&.body
31
+ end
32
+
33
+ def feed
34
+ nil
35
+ end
36
+
37
+ def item_processing
38
+ @item_processing ||= EntryProcessingResult.empty
39
+ end
40
+
41
+ private
42
+
43
+ def update_source(source_updater, duration_ms)
44
+ source_updater.update_source_for_failure(error, duration_ms)
45
+ end
46
+
47
+ def create_fetch_log(source_updater, duration_ms, started_at)
48
+ source_updater.create_fetch_log(
49
+ response: response,
50
+ duration_ms: duration_ms,
51
+ started_at: started_at,
52
+ success: false,
53
+ error: error,
54
+ body: body
55
+ )
56
+ end
57
+
58
+ def apply_instrumentation(instrumentation_payload)
59
+ instrumentation_payload[:success] = false
60
+ instrumentation_payload[:status] = status
61
+ instrumentation_payload[:error_class] = error.class.name
62
+ instrumentation_payload[:error_message] = error.message
63
+ instrumentation_payload[:http_status] = error.http_status if error.http_status
64
+ instrumentation_payload[:error_code] = error.code if error.respond_to?(:code)
65
+ instrumentation_payload[:items_created] = 0
66
+ instrumentation_payload[:items_updated] = 0
67
+ instrumentation_payload[:items_failed] = 0
68
+ instrumentation_payload[:retry_attempt] = retry_decision&.next_attempt ? retry_decision.next_attempt : 0
69
+ end
70
+
71
+ def result
72
+ Result.new(
73
+ status: status,
74
+ response: response,
75
+ body: body,
76
+ error: error,
77
+ retry_decision: retry_decision,
78
+ item_processing: item_processing,
79
+ outcome: self
80
+ )
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Fetching
5
+ class FeedFetcher
6
+ class SuccessOutcome
7
+ def initialize(response:, body:, feed:, item_processing:, feed_signature:, content_changed:, entries_digest:)
8
+ @response = response
9
+ @body = body
10
+ @feed = feed
11
+ @item_processing = item_processing
12
+ @feed_signature = feed_signature
13
+ @content_changed = content_changed
14
+ @entries_digest = entries_digest
15
+ end
16
+
17
+ attr_reader :response, :body, :feed, :item_processing, :feed_signature, :content_changed, :entries_digest
18
+
19
+ def apply(source_updater:, started_at:, instrumentation_payload:)
20
+ duration_ms = source_updater.elapsed_ms(started_at)
21
+ update_source(source_updater, duration_ms)
22
+ create_fetch_log(source_updater, duration_ms, started_at)
23
+ apply_instrumentation(instrumentation_payload)
24
+ result
25
+ end
26
+
27
+ def status
28
+ :fetched
29
+ end
30
+
31
+ def error
32
+ nil
33
+ end
34
+
35
+ def retry_decision
36
+ nil
37
+ end
38
+
39
+ def result
40
+ Result.new(status: status, feed: feed, response: response, body: body, item_processing: item_processing, outcome: self)
41
+ end
42
+
43
+ private
44
+
45
+ def update_source(source_updater, duration_ms)
46
+ source_updater.update_source_for_success(
47
+ response,
48
+ duration_ms,
49
+ feed,
50
+ feed_signature,
51
+ content_changed: content_changed,
52
+ entries_digest: entries_digest
53
+ )
54
+ end
55
+
56
+ def create_fetch_log(source_updater, duration_ms, started_at)
57
+ source_updater.create_fetch_log(
58
+ response: response,
59
+ duration_ms: duration_ms,
60
+ started_at: started_at,
61
+ feed: feed,
62
+ success: true,
63
+ body: body,
64
+ feed_signature: feed_signature,
65
+ items_created: item_processing.created,
66
+ items_updated: item_processing.updated,
67
+ items_failed: item_processing.failed,
68
+ item_errors: item_processing.errors
69
+ )
70
+ end
71
+
72
+ def apply_instrumentation(instrumentation_payload)
73
+ instrumentation_payload[:success] = true
74
+ instrumentation_payload[:status] = :fetched
75
+ instrumentation_payload[:http_status] = response.status
76
+ instrumentation_payload[:parser] = feed.class.name if feed
77
+ instrumentation_payload[:items_created] = item_processing.created
78
+ instrumentation_payload[:items_updated] = item_processing.updated
79
+ instrumentation_payload[:items_failed] = item_processing.failed
80
+ instrumentation_payload[:retry_attempt] = 0
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -9,11 +9,13 @@ require "source_monitor/items/item_creator"
9
9
  require "source_monitor/fetching/feed_fetcher/adaptive_interval"
10
10
  require "source_monitor/fetching/feed_fetcher/source_updater"
11
11
  require "source_monitor/fetching/feed_fetcher/entry_processor"
12
+ require "source_monitor/fetching/feed_fetcher/success_outcome"
13
+ require "source_monitor/fetching/feed_fetcher/failure_outcome"
12
14
 
13
15
  module SourceMonitor
14
16
  module Fetching
15
17
  class FeedFetcher
16
- Result = Struct.new(:status, :feed, :response, :body, :error, :item_processing, :retry_decision, keyword_init: true)
18
+ Result = Struct.new(:status, :feed, :response, :body, :error, :item_processing, :retry_decision, :outcome, keyword_init: true)
17
19
  EntryProcessingResult = Struct.new(
18
20
  :created,
19
21
  :updated,
@@ -24,7 +26,20 @@ module SourceMonitor
24
26
  :created_items,
25
27
  :updated_items,
26
28
  keyword_init: true
27
- )
29
+ ) do
30
+ def self.empty
31
+ new(
32
+ created: 0,
33
+ updated: 0,
34
+ unchanged: 0,
35
+ failed: 0,
36
+ items: [],
37
+ errors: [],
38
+ created_items: [],
39
+ updated_items: []
40
+ )
41
+ end
42
+ end
28
43
  ResponseWrapper = Struct.new(:status, :headers, :body, keyword_init: true)
29
44
 
30
45
  attr_reader :source, :client, :jitter_proc
@@ -116,7 +131,6 @@ module SourceMonitor
116
131
  end
117
132
 
118
133
  def handle_success(response, started_at, instrumentation_payload)
119
- duration_ms = source_updater.elapsed_ms(started_at)
120
134
  body = response.body
121
135
  feed_body_signature = body_digest(body)
122
136
  feed = parse_feed(body, response)
@@ -125,45 +139,19 @@ module SourceMonitor
125
139
  processing = entry_processor.process_feed_entries(feed)
126
140
  content_changed = entries_digest_changed?(feed)
127
141
  else
128
- processing = EntryProcessingResult.new(
129
- created: 0,
130
- updated: 0,
131
- unchanged: 0,
132
- failed: 0,
133
- items: [],
134
- errors: [],
135
- created_items: [],
136
- updated_items: []
137
- )
142
+ processing = EntryProcessingResult.empty
138
143
  content_changed = false
139
144
  end
140
145
 
141
- feed_entries_digest = entries_digest(feed)
142
- source_updater.update_source_for_success(response, duration_ms, feed, feed_body_signature, content_changed: content_changed, entries_digest: feed_entries_digest)
143
- source_updater.create_fetch_log(
146
+ SuccessOutcome.new(
144
147
  response: response,
145
- duration_ms: duration_ms,
146
- started_at: started_at,
147
- feed: feed,
148
- success: true,
149
148
  body: body,
149
+ feed: feed,
150
+ item_processing: processing,
150
151
  feed_signature: feed_body_signature,
151
- items_created: processing.created,
152
- items_updated: processing.updated,
153
- items_failed: processing.failed,
154
- item_errors: processing.errors
155
- )
156
-
157
- instrumentation_payload[:success] = true
158
- instrumentation_payload[:status] = :fetched
159
- instrumentation_payload[:http_status] = response.status
160
- instrumentation_payload[:parser] = feed.class.name if feed
161
- instrumentation_payload[:items_created] = processing.created
162
- instrumentation_payload[:items_updated] = processing.updated
163
- instrumentation_payload[:items_failed] = processing.failed
164
- instrumentation_payload[:retry_attempt] = 0
165
-
166
- Result.new(status: :fetched, feed:, response:, body:, item_processing: processing)
152
+ content_changed: content_changed,
153
+ entries_digest: entries_digest(feed)
154
+ ).apply(source_updater: source_updater, started_at: started_at, instrumentation_payload: instrumentation_payload)
167
155
  end
168
156
 
169
157
  def handle_not_modified(response, started_at, instrumentation_payload)
@@ -189,16 +177,7 @@ module SourceMonitor
189
177
  status: :not_modified,
190
178
  response: response,
191
179
  body: nil,
192
- item_processing: EntryProcessingResult.new(
193
- created: 0,
194
- updated: 0,
195
- unchanged: 0,
196
- failed: 0,
197
- items: [],
198
- errors: [],
199
- created_items: [],
200
- updated_items: []
201
- )
180
+ item_processing: EntryProcessingResult.empty
202
181
  )
203
182
  end
204
183
 
@@ -263,48 +242,8 @@ module SourceMonitor
263
242
  end
264
243
 
265
244
  def handle_failure(error, started_at:, instrumentation_payload:)
266
- response = error.response
267
- body = response&.body
268
- duration_ms = source_updater.elapsed_ms(started_at)
269
-
270
- retry_decision = source_updater.update_source_for_failure(error, duration_ms)
271
- source_updater.create_fetch_log(
272
- response: response,
273
- duration_ms: duration_ms,
274
- started_at: started_at,
275
- success: false,
276
- error: error,
277
- body: body
278
- )
279
-
280
- instrumentation_payload[:success] = false
281
- instrumentation_payload[:status] = :failed
282
- instrumentation_payload[:error_class] = error.class.name
283
- instrumentation_payload[:error_message] = error.message
284
- instrumentation_payload[:http_status] = error.http_status if error.http_status
285
- instrumentation_payload[:error_code] = error.code if error.respond_to?(:code)
286
- instrumentation_payload[:items_created] = 0
287
- instrumentation_payload[:items_updated] = 0
288
- instrumentation_payload[:items_failed] = 0
289
- instrumentation_payload[:retry_attempt] = retry_decision&.next_attempt ? retry_decision.next_attempt : 0
290
-
291
- Result.new(
292
- status: :failed,
293
- response: response,
294
- body: body,
295
- error: error,
296
- retry_decision: retry_decision,
297
- item_processing: EntryProcessingResult.new(
298
- created: 0,
299
- updated: 0,
300
- unchanged: 0,
301
- failed: 0,
302
- items: [],
303
- errors: [],
304
- created_items: [],
305
- updated_items: []
306
- )
307
- )
245
+ FailureOutcome.new(error: error)
246
+ .apply(source_updater: source_updater, started_at: started_at, instrumentation_payload: instrumentation_payload)
308
247
  end
309
248
 
310
249
  def attempt_aia_recovery(_error, started_at, instrumentation_payload)
@@ -78,16 +78,17 @@ module SourceMonitor
78
78
  end
79
79
 
80
80
  # Phase 3: Post-fetch DB writes under the advisory lock (still held).
81
+ completion_result = completion_result_for(result)
81
82
  begin
82
- log_handler_result("RetentionHandler", retention_handler.call(source:, result:))
83
- log_handler_result("FollowUpHandler", follow_up_handler.call(source:, result:))
84
- schedule_retry_if_needed(result)
85
- mark_complete!(result)
83
+ log_handler_result("RetentionHandler", retention_handler.call(source:, result: completion_result))
84
+ log_handler_result("FollowUpHandler", follow_up_handler.call(source:, result: completion_result))
85
+ schedule_retry_if_needed(completion_result)
86
+ mark_complete!(completion_result)
86
87
  ensure
87
88
  lock.release!
88
89
  end
89
90
 
90
- log_handler_result("EventPublisher", event_publisher.call(source:, result:))
91
+ log_handler_result("EventPublisher", event_publisher.call(source:, result: result))
91
92
  result
92
93
  rescue SourceMonitor::Fetching::AdvisoryLock::NotAcquiredError => error
93
94
  raise ConcurrencyError, error.message
@@ -156,6 +157,12 @@ module SourceMonitor
156
157
  update_source_state(fetch_status: "failed")
157
158
  end
158
159
 
160
+ def completion_result_for(result)
161
+ return result unless result.respond_to?(:outcome)
162
+
163
+ result.outcome || result
164
+ end
165
+
159
166
  def update_source_state(attrs)
160
167
  self.class.send(:update_source_state!, source, attrs)
161
168
  end