rails-markup 1.2.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +304 -0
- data/app/assets/javascripts/rails_markup/toolbar.js +2225 -0
- data/app/controllers/rails_markup/annotations_controller.rb +244 -0
- data/app/controllers/rails_markup/application_controller.rb +7 -0
- data/app/controllers/rails_markup/dashboard_controller.rb +230 -0
- data/app/controllers/rails_markup/external/annotations_controller.rb +68 -0
- data/app/models/rails_markup/annotation.rb +176 -0
- data/app/views/layouts/rails_markup/application.html.erb +158 -0
- data/app/views/rails_markup/dashboard/_annotation_card.html.erb +25 -0
- data/app/views/rails_markup/dashboard/_annotation_list.html.erb +57 -0
- data/app/views/rails_markup/dashboard/_annotation_page.html.erb +13 -0
- data/app/views/rails_markup/dashboard/_filters.html.erb +75 -0
- data/app/views/rails_markup/dashboard/_stats.html.erb +22 -0
- data/app/views/rails_markup/dashboard/_styles.html.erb +115 -0
- data/app/views/rails_markup/dashboard/board.html.erb +135 -0
- data/app/views/rails_markup/dashboard/index.html.erb +38 -0
- data/app/views/rails_markup/dashboard/show.html.erb +129 -0
- data/app/views/rails_markup/shared/_toolbar.html.erb +28 -0
- data/bin/rails-markup +5 -0
- data/config/routes.rb +45 -0
- data/db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb +13 -0
- data/db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb +17 -0
- data/lib/generators/rails_markup/install/templates/auth_controller.rb.erb +17 -0
- data/lib/generators/rails_markup/install/templates/bin_markup.erb +5 -0
- data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +27 -0
- data/lib/generators/rails_markup/install/templates/initializer.rb.erb +54 -0
- data/lib/generators/rails_markup/install_generator.rb +167 -0
- data/lib/generators/rails_markup/uninstall_generator.rb +110 -0
- data/lib/rails_markup/cli/base.rb +8 -0
- data/lib/rails_markup/cli/initializer_writer.rb +54 -0
- data/lib/rails_markup/cli/setup_wizard.rb +229 -0
- data/lib/rails_markup/cli.rb +831 -0
- data/lib/rails_markup/client_uuid_maintenance.rb +174 -0
- data/lib/rails_markup/configuration.rb +160 -0
- data/lib/rails_markup/engine.rb +18 -0
- data/lib/rails_markup/http_server.rb +226 -0
- data/lib/rails_markup/http_store_proxy.rb +122 -0
- data/lib/rails_markup/mcp_config.rb +258 -0
- data/lib/rails_markup/mcp_server.rb +639 -0
- data/lib/rails_markup/server.rb +73 -0
- data/lib/rails_markup/store.rb +219 -0
- data/lib/rails_markup/version.rb +5 -0
- data/lib/rails_markup.rb +11 -0
- data/lib/tasks/rails_markup_client_uuids.rake +19 -0
- metadata +198 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "set"
|
|
5
|
+
require "digest/sha1"
|
|
6
|
+
|
|
7
|
+
module RailsMarkup
|
|
8
|
+
module ClientUuidMaintenance
|
|
9
|
+
UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/
|
|
10
|
+
UUID_INPUT_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
|
|
11
|
+
COLLISION_UUID_NAMESPACE = "102f1c26-3ad7-5b5c-871a-44c5f2392790"
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def repair!(connection: ActiveRecord::Base.connection, table_name: RailsMarkup.config.table_name)
|
|
16
|
+
table = table_name.to_sym
|
|
17
|
+
return 0 unless connection.table_exists?(table)
|
|
18
|
+
|
|
19
|
+
connection.add_column(table, :client_uuid, :string, limit: 64) unless connection.column_exists?(table, :client_uuid)
|
|
20
|
+
repaired = repair_rows(connection, table)
|
|
21
|
+
ensure_unique_index(connection, table)
|
|
22
|
+
repaired
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def invalid_count(connection: ActiveRecord::Base.connection, table_name: RailsMarkup.config.table_name)
|
|
26
|
+
table = table_name.to_sym
|
|
27
|
+
return 0 unless connection.table_exists?(table) && connection.column_exists?(table, :client_uuid)
|
|
28
|
+
|
|
29
|
+
quoted_table = connection.quote_table_name(table)
|
|
30
|
+
quoted_client_uuid = connection.quote_column_name(:client_uuid)
|
|
31
|
+
connection.select_values("SELECT #{quoted_client_uuid} FROM #{quoted_table}").count do |value|
|
|
32
|
+
!canonical_uuid?(value)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def verify!(connection: ActiveRecord::Base.connection, table_name: RailsMarkup.config.table_name)
|
|
37
|
+
table = table_name.to_sym
|
|
38
|
+
unless connection.table_exists?(table) && connection.column_exists?(table, :client_uuid)
|
|
39
|
+
raise "Rails Markup client UUID storage is not installed"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
invalid = invalid_count(connection:, table_name:)
|
|
43
|
+
duplicates = casefold_duplicate_count(connection, table)
|
|
44
|
+
raise "Rails Markup has #{duplicates} case-fold duplicate client UUID(s)" if duplicates.positive?
|
|
45
|
+
raise "Rails Markup has #{invalid} annotation(s) without canonical client UUIDs" if invalid.positive?
|
|
46
|
+
unless connection.indexes(table).any? { |index| full_unique_client_uuid_index?(index) }
|
|
47
|
+
raise "Rails Markup client UUID unique index is missing"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def repair_rows(connection, table)
|
|
54
|
+
primary_key = connection.primary_key(table)
|
|
55
|
+
raise "Rails Markup annotations table must have a primary key" unless primary_key
|
|
56
|
+
|
|
57
|
+
quoted_table = connection.quote_table_name(table)
|
|
58
|
+
quoted_primary_key = connection.quote_column_name(primary_key)
|
|
59
|
+
quoted_client_uuid = connection.quote_column_name(:client_uuid)
|
|
60
|
+
rows = connection.select_rows(<<~SQL.squish)
|
|
61
|
+
SELECT #{quoted_primary_key}, #{quoted_client_uuid}
|
|
62
|
+
FROM #{quoted_table}
|
|
63
|
+
ORDER BY #{quoted_primary_key}
|
|
64
|
+
SQL
|
|
65
|
+
used = Set.new
|
|
66
|
+
assignments = []
|
|
67
|
+
|
|
68
|
+
rows.each do |id, current_uuid|
|
|
69
|
+
normalized = normalize_uuid(current_uuid)
|
|
70
|
+
desired = if normalized && used.add?(normalized)
|
|
71
|
+
normalized
|
|
72
|
+
elsif normalized
|
|
73
|
+
next_collision_uuid(used, normalized, id)
|
|
74
|
+
else
|
|
75
|
+
next_unique_uuid(used)
|
|
76
|
+
end
|
|
77
|
+
assignments << [id, current_uuid, desired] unless current_uuid == desired
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
reserved = Set.new(rows.filter_map { |_id, value| value.to_s.downcase.presence }).merge(used)
|
|
81
|
+
assignments.each do |id, _current_uuid, _desired|
|
|
82
|
+
temporary = next_unique_uuid(reserved)
|
|
83
|
+
update_uuid(connection, quoted_table, quoted_primary_key, quoted_client_uuid, id, temporary)
|
|
84
|
+
end
|
|
85
|
+
assignments.each do |id, _current_uuid, desired|
|
|
86
|
+
update_uuid(connection, quoted_table, quoted_primary_key, quoted_client_uuid, id, desired)
|
|
87
|
+
end
|
|
88
|
+
assignments.length
|
|
89
|
+
end
|
|
90
|
+
private_class_method :repair_rows
|
|
91
|
+
|
|
92
|
+
def canonical_uuid?(value)
|
|
93
|
+
value.is_a?(String) && UUID_PATTERN.match?(value)
|
|
94
|
+
end
|
|
95
|
+
private_class_method :canonical_uuid?
|
|
96
|
+
|
|
97
|
+
def normalize_uuid(value)
|
|
98
|
+
value.downcase if value.is_a?(String) && UUID_INPUT_PATTERN.match?(value)
|
|
99
|
+
end
|
|
100
|
+
private_class_method :normalize_uuid
|
|
101
|
+
|
|
102
|
+
def update_uuid(connection, table, primary_key, client_uuid, id, value)
|
|
103
|
+
connection.execute <<~SQL.squish
|
|
104
|
+
UPDATE #{table}
|
|
105
|
+
SET #{client_uuid} = #{connection.quote(value)}
|
|
106
|
+
WHERE #{primary_key} = #{connection.quote(id)}
|
|
107
|
+
SQL
|
|
108
|
+
end
|
|
109
|
+
private_class_method :update_uuid
|
|
110
|
+
|
|
111
|
+
def next_unique_uuid(used)
|
|
112
|
+
loop do
|
|
113
|
+
uuid = SecureRandom.uuid
|
|
114
|
+
return uuid if used.add?(uuid)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
private_class_method :next_unique_uuid
|
|
118
|
+
|
|
119
|
+
def next_collision_uuid(used, normalized, id)
|
|
120
|
+
counter = 0
|
|
121
|
+
loop do
|
|
122
|
+
uuid = namespaced_uuid("casefold\0#{normalized}\0#{id}\0#{counter}")
|
|
123
|
+
return uuid if used.add?(uuid)
|
|
124
|
+
counter += 1
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
private_class_method :next_collision_uuid
|
|
128
|
+
|
|
129
|
+
def namespaced_uuid(name)
|
|
130
|
+
namespace = [COLLISION_UUID_NAMESPACE.delete("-")].pack("H*")
|
|
131
|
+
bytes = Digest::SHA1.digest(namespace + name).bytes.first(16)
|
|
132
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50
|
|
133
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
134
|
+
hex = bytes.pack("C*").unpack1("H*")
|
|
135
|
+
"#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
|
|
136
|
+
end
|
|
137
|
+
private_class_method :namespaced_uuid
|
|
138
|
+
|
|
139
|
+
def ensure_unique_index(connection, table)
|
|
140
|
+
indexes = connection.indexes(table)
|
|
141
|
+
return if indexes.any? { |index| full_unique_client_uuid_index?(index) }
|
|
142
|
+
|
|
143
|
+
default_name = connection.index_name(table, column: [:client_uuid])
|
|
144
|
+
conflict = indexes.find { |index| index.name == default_name }
|
|
145
|
+
connection.remove_index(table, name: conflict.name) if conflict
|
|
146
|
+
connection.add_index(table, :client_uuid, unique: true, name: default_name)
|
|
147
|
+
end
|
|
148
|
+
private_class_method :ensure_unique_index
|
|
149
|
+
|
|
150
|
+
def casefold_duplicate_count(connection, table)
|
|
151
|
+
quoted_table = connection.quote_table_name(table)
|
|
152
|
+
quoted_client_uuid = connection.quote_column_name(:client_uuid)
|
|
153
|
+
normalized = connection.select_values("SELECT #{quoted_client_uuid} FROM #{quoted_table}").filter_map do |value|
|
|
154
|
+
normalize_uuid(value)
|
|
155
|
+
end
|
|
156
|
+
normalized.length - normalized.uniq.length
|
|
157
|
+
end
|
|
158
|
+
private_class_method :casefold_duplicate_count
|
|
159
|
+
|
|
160
|
+
def full_unique_client_uuid_index?(index)
|
|
161
|
+
return false unless index.unique && index.columns == ["client_uuid"]
|
|
162
|
+
return false unless !index.respond_to?(:where) || index.where.nil? || index.where.to_s.strip.empty?
|
|
163
|
+
return true unless index.respond_to?(:lengths)
|
|
164
|
+
|
|
165
|
+
lengths = index.lengths
|
|
166
|
+
client_length = case lengths
|
|
167
|
+
when Hash then lengths["client_uuid"] || lengths[:client_uuid]
|
|
168
|
+
when Array then lengths[index.columns.index("client_uuid")]
|
|
169
|
+
end
|
|
170
|
+
client_length.nil?
|
|
171
|
+
end
|
|
172
|
+
private_class_method :full_unique_client_uuid_index?
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMarkup
|
|
4
|
+
class Configuration
|
|
5
|
+
ALLOWED_ACCENTS = %w[indigo amber blue emerald rose].freeze
|
|
6
|
+
ALLOWED_POSITIONS = %w[tl tr br bl].freeze
|
|
7
|
+
ALLOWED_SIZES = %w[slim compact default].freeze
|
|
8
|
+
|
|
9
|
+
# Base controller class name for dashboard/API controllers.
|
|
10
|
+
# Set to a controller that provides authentication.
|
|
11
|
+
# Example: "AdminAuthController"
|
|
12
|
+
attr_accessor :base_controller_class
|
|
13
|
+
|
|
14
|
+
# Bearer token for the external API (MCP production tools).
|
|
15
|
+
# Set to nil to disable external API.
|
|
16
|
+
attr_accessor :api_token
|
|
17
|
+
|
|
18
|
+
# Database table name for annotations.
|
|
19
|
+
attr_accessor :table_name
|
|
20
|
+
|
|
21
|
+
# Accent color for the toolbar FAB and UI elements.
|
|
22
|
+
attr_reader :toolbar_accent
|
|
23
|
+
|
|
24
|
+
# Render the annotation toolbar system at all (FAB, panel, pins, sync).
|
|
25
|
+
# Set to false to disable rails-markup entirely for a request/environment.
|
|
26
|
+
# Default: true
|
|
27
|
+
attr_accessor :toolbar_enabled
|
|
28
|
+
|
|
29
|
+
# Show the floating action button (FAB) specifically.
|
|
30
|
+
# When false, the FAB is hidden but the toolbar system stays active — pins
|
|
31
|
+
# still render and the panel is reachable via its toggle. Use when the host
|
|
32
|
+
# provides its own trigger or wants read-only pins. Requires toolbar_enabled.
|
|
33
|
+
# Default: true
|
|
34
|
+
attr_accessor :fab_visible
|
|
35
|
+
|
|
36
|
+
# URL path for "Back to app" link in the dashboard header.
|
|
37
|
+
# Set to nil to hide the link.
|
|
38
|
+
# Example: "/admin"
|
|
39
|
+
attr_accessor :return_url
|
|
40
|
+
|
|
41
|
+
# Layout for the dashboard views.
|
|
42
|
+
# Set to a host app layout name to embed within that layout.
|
|
43
|
+
# Default: "rails_markup/application" (engine's own layout)
|
|
44
|
+
# Example: "admin"
|
|
45
|
+
attr_accessor :dashboard_layout
|
|
46
|
+
|
|
47
|
+
# Number of annotations per page on the dashboard.
|
|
48
|
+
attr_reader :per_page
|
|
49
|
+
|
|
50
|
+
# Method name (Symbol) or Proc to resolve the author's display name from the current_user.
|
|
51
|
+
# Symbol: calls user.public_send(method_name) — e.g. :name, :email, :display_name
|
|
52
|
+
# Proc: receives user, returns string — e.g. ->(u) { u.full_name }
|
|
53
|
+
# Default: :email
|
|
54
|
+
attr_accessor :author_name_method
|
|
55
|
+
|
|
56
|
+
# Proc called after an annotation is created. Receives the annotation record.
|
|
57
|
+
# Use for email/Slack/webhook notifications.
|
|
58
|
+
# Default: nil (no callback)
|
|
59
|
+
attr_accessor :on_create_callback
|
|
60
|
+
|
|
61
|
+
# Enable element screenshot capture in the toolbar.
|
|
62
|
+
# Default: true
|
|
63
|
+
attr_accessor :enable_screenshots
|
|
64
|
+
|
|
65
|
+
# FAB button position: "bl" (bottom-left), "br" (bottom-right),
|
|
66
|
+
# "tl" (top-left), "tr" (top-right). Default: "bl"
|
|
67
|
+
attr_reader :toolbar_position
|
|
68
|
+
|
|
69
|
+
# FAB button size: "default" (48px), "compact" (40px), "slim" (32px).
|
|
70
|
+
# Default: "default"
|
|
71
|
+
attr_reader :toolbar_size
|
|
72
|
+
|
|
73
|
+
# Health check poll interval in seconds.
|
|
74
|
+
# Default: 60
|
|
75
|
+
attr_reader :health_interval
|
|
76
|
+
|
|
77
|
+
def initialize
|
|
78
|
+
@base_controller_class = "RailsMarkup::ApplicationController"
|
|
79
|
+
@api_token = nil
|
|
80
|
+
@table_name = "rails_markup_annotations"
|
|
81
|
+
@per_page = 25
|
|
82
|
+
@toolbar_accent = "indigo"
|
|
83
|
+
@toolbar_enabled = true
|
|
84
|
+
@fab_visible = true
|
|
85
|
+
@toolbar_position = "bl"
|
|
86
|
+
@toolbar_size = "default"
|
|
87
|
+
@return_url = nil
|
|
88
|
+
@dashboard_layout = "rails_markup/application"
|
|
89
|
+
@author_name_method = :email
|
|
90
|
+
@on_create_callback = nil
|
|
91
|
+
@enable_screenshots = true
|
|
92
|
+
@health_interval = 60
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def resolve_author_name(user)
|
|
96
|
+
return nil unless user
|
|
97
|
+
|
|
98
|
+
case author_name_method
|
|
99
|
+
when Symbol then user.respond_to?(author_name_method) ? user.public_send(author_name_method) : nil
|
|
100
|
+
when Proc then author_name_method.call(user)
|
|
101
|
+
end
|
|
102
|
+
rescue => e
|
|
103
|
+
Rails.logger.warn("[rails-markup] author_name_method error: #{e.message}") if defined?(Rails)
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def per_page=(value)
|
|
108
|
+
raise ArgumentError, "per_page must be a positive integer" unless value.is_a?(Integer) && value.positive?
|
|
109
|
+
|
|
110
|
+
@per_page = value
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def toolbar_accent=(value)
|
|
114
|
+
unless ALLOWED_ACCENTS.include?(value.to_s)
|
|
115
|
+
raise ArgumentError, "toolbar_accent must be one of: #{ALLOWED_ACCENTS.join(', ')}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@toolbar_accent = value.to_s
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def toolbar_position=(value)
|
|
122
|
+
unless ALLOWED_POSITIONS.include?(value.to_s)
|
|
123
|
+
raise ArgumentError, "toolbar_position must be one of: #{ALLOWED_POSITIONS.join(', ')}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
@toolbar_position = value.to_s
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def toolbar_size=(value)
|
|
130
|
+
unless ALLOWED_SIZES.include?(value.to_s)
|
|
131
|
+
raise ArgumentError, "toolbar_size must be one of: #{ALLOWED_SIZES.join(', ')}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
@toolbar_size = value.to_s
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def health_interval=(value)
|
|
138
|
+
val = value.to_i
|
|
139
|
+
raise ArgumentError, "health_interval must be at least 10 (seconds)" unless val >= 10
|
|
140
|
+
|
|
141
|
+
@health_interval = val
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
class << self
|
|
146
|
+
def configuration
|
|
147
|
+
@configuration ||= Configuration.new
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
alias_method :config, :configuration
|
|
151
|
+
|
|
152
|
+
def configure
|
|
153
|
+
yield(configuration)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def reset_configuration!
|
|
157
|
+
@configuration = Configuration.new
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMarkup
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace RailsMarkup
|
|
6
|
+
|
|
7
|
+
initializer "rails_markup.configuration" do
|
|
8
|
+
RailsMarkup.configuration # ensure defaults are set
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "rails_markup.assets" do |app|
|
|
12
|
+
if app.config.respond_to?(:assets)
|
|
13
|
+
# toolbar.js is inlined via the toolbar partial, no separate asset needed
|
|
14
|
+
app.config.assets.precompile += %w[rails_markup/toolbar.js]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webrick"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RailsMarkup
|
|
7
|
+
# HTTP server providing REST API + SSE for the browser-side annotation controller.
|
|
8
|
+
# Wire-compatible with Agentation's HTTP API.
|
|
9
|
+
class HttpServer
|
|
10
|
+
attr_reader :port, :store
|
|
11
|
+
|
|
12
|
+
def initialize(store:, port: 4747, logger: nil)
|
|
13
|
+
@store = store
|
|
14
|
+
@port = port
|
|
15
|
+
@logger = logger || WEBrick::Log.new($stderr, WEBrick::Log::WARN)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def start
|
|
19
|
+
@server = WEBrick::HTTPServer.new(
|
|
20
|
+
Port: @port,
|
|
21
|
+
Logger: @logger,
|
|
22
|
+
AccessLog: [],
|
|
23
|
+
DoNotReverseLookup: true
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@server.mount("/", CorsServlet, @store)
|
|
27
|
+
|
|
28
|
+
trap("INT") { @server.shutdown }
|
|
29
|
+
trap("TERM") { @server.shutdown }
|
|
30
|
+
|
|
31
|
+
@server.start
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def shutdown
|
|
35
|
+
@server&.shutdown
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Single servlet handling all routes with proper CORS support.
|
|
40
|
+
# WEBrick dispatches do_GET, do_POST, do_OPTIONS to us directly.
|
|
41
|
+
class CorsServlet < WEBrick::HTTPServlet::AbstractServlet
|
|
42
|
+
def initialize(server, store)
|
|
43
|
+
super(server)
|
|
44
|
+
@store = store
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def do_OPTIONS(req, res)
|
|
48
|
+
cors(res)
|
|
49
|
+
res.status = 204
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def do_GET(req, res)
|
|
53
|
+
cors(res)
|
|
54
|
+
route(req, res)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def do_POST(req, res)
|
|
58
|
+
cors(res)
|
|
59
|
+
route(req, res)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def route(req, res)
|
|
65
|
+
case req.path
|
|
66
|
+
when "/health"
|
|
67
|
+
json_response(res, { ok: true })
|
|
68
|
+
|
|
69
|
+
when "/pending"
|
|
70
|
+
pending = @store.all_pending
|
|
71
|
+
json_response(res, pending.map { |a| @store.serialize_annotation(a) })
|
|
72
|
+
|
|
73
|
+
when "/sessions"
|
|
74
|
+
if req.request_method == "GET"
|
|
75
|
+
sessions = @store.list_sessions
|
|
76
|
+
json_response(res, sessions.map { |s| @store.serialize_session(s) })
|
|
77
|
+
else
|
|
78
|
+
handle_create_session(req, res)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
when %r{\A/sessions/([^/]+)/annotations\z}
|
|
82
|
+
handle_create_annotation(req, res, $1)
|
|
83
|
+
|
|
84
|
+
when %r{\A/sessions/([^/]+)/events\z}
|
|
85
|
+
handle_sse(req, res, $1)
|
|
86
|
+
|
|
87
|
+
when %r{\A/sessions/([^/]+)\z}
|
|
88
|
+
handle_get_session(res, $1)
|
|
89
|
+
|
|
90
|
+
when %r{\A/annotations/([^/]+)/resolve\z}
|
|
91
|
+
handle_annotation_action(req, res, $1, :resolve)
|
|
92
|
+
|
|
93
|
+
when %r{\A/annotations/([^/]+)/dismiss\z}
|
|
94
|
+
handle_annotation_action(req, res, $1, :dismiss)
|
|
95
|
+
|
|
96
|
+
when %r{\A/annotations/([^/]+)/acknowledge\z}
|
|
97
|
+
handle_annotation_action(req, res, $1, :acknowledge)
|
|
98
|
+
|
|
99
|
+
when %r{\A/annotations/([^/]+)/reply\z}
|
|
100
|
+
handle_annotation_action(req, res, $1, :reply)
|
|
101
|
+
|
|
102
|
+
else
|
|
103
|
+
not_found(res)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# --- Annotation actions ---
|
|
108
|
+
|
|
109
|
+
def handle_annotation_action(req, res, annotation_id, action)
|
|
110
|
+
body = parse_json(req)
|
|
111
|
+
result = case action
|
|
112
|
+
when :resolve
|
|
113
|
+
@store.resolve(annotation_id, summary: body["summary"])
|
|
114
|
+
when :dismiss
|
|
115
|
+
@store.dismiss(annotation_id, reason: body["reason"])
|
|
116
|
+
when :acknowledge
|
|
117
|
+
@store.acknowledge(annotation_id)
|
|
118
|
+
when :reply
|
|
119
|
+
@store.reply(annotation_id, message: body["message"])
|
|
120
|
+
end
|
|
121
|
+
return not_found(res) unless result
|
|
122
|
+
|
|
123
|
+
json_response(res, @store.serialize_annotation(result))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# --- Sessions ---
|
|
127
|
+
|
|
128
|
+
def handle_create_session(req, res)
|
|
129
|
+
body = parse_json(req)
|
|
130
|
+
session = @store.create_session(url: body["url"], metadata: body["metadata"])
|
|
131
|
+
json_response(res, @store.serialize_session(session), status: 201)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def handle_get_session(res, session_id)
|
|
135
|
+
session = @store.get_session(session_id)
|
|
136
|
+
return not_found(res) unless session
|
|
137
|
+
|
|
138
|
+
json_response(res, @store.serialize_session(session))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# --- Annotations ---
|
|
142
|
+
|
|
143
|
+
def handle_create_annotation(req, res, session_id)
|
|
144
|
+
body = parse_json(req)
|
|
145
|
+
annotation = @store.create_annotation(
|
|
146
|
+
session_id: session_id,
|
|
147
|
+
target: body["target"],
|
|
148
|
+
content: body["content"],
|
|
149
|
+
intent: body["intent"] || "change",
|
|
150
|
+
severity: body["severity"] || "suggestion",
|
|
151
|
+
selected_text: body["selectedText"],
|
|
152
|
+
metadata: body["metadata"]
|
|
153
|
+
)
|
|
154
|
+
return not_found(res) unless annotation
|
|
155
|
+
|
|
156
|
+
json_response(res, @store.serialize_annotation(annotation), status: 201)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# --- SSE ---
|
|
160
|
+
|
|
161
|
+
def handle_sse(_req, res, session_id)
|
|
162
|
+
session = @store.get_session(session_id)
|
|
163
|
+
return not_found(res) unless session
|
|
164
|
+
|
|
165
|
+
res.status = 200
|
|
166
|
+
res["Content-Type"] = "text/event-stream"
|
|
167
|
+
res["Cache-Control"] = "no-cache"
|
|
168
|
+
res["Connection"] = "keep-alive"
|
|
169
|
+
res["Access-Control-Allow-Origin"] = "*"
|
|
170
|
+
|
|
171
|
+
res.chunked = true
|
|
172
|
+
res.body = proc do |out|
|
|
173
|
+
sub = @store.subscribe(session_id) do |data|
|
|
174
|
+
event_type = data[:type] || "annotation_update"
|
|
175
|
+
out.write("event: #{event_type}\ndata: #{data.to_json}\n\n")
|
|
176
|
+
rescue Errno::EPIPE, IOError
|
|
177
|
+
@store.unsubscribe(sub)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
deadline = Time.now + 1800 # 30 minutes max
|
|
181
|
+
loop do
|
|
182
|
+
break if Time.now >= deadline
|
|
183
|
+
|
|
184
|
+
sleep 15
|
|
185
|
+
out.write(": keepalive\n\n")
|
|
186
|
+
rescue Errno::EPIPE, IOError
|
|
187
|
+
break
|
|
188
|
+
end
|
|
189
|
+
ensure
|
|
190
|
+
@store.unsubscribe(sub) if defined?(sub) && sub
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# --- Helpers ---
|
|
195
|
+
|
|
196
|
+
def cors(res)
|
|
197
|
+
port = @server[:Port] rescue 4747
|
|
198
|
+
res["Access-Control-Allow-Origin"] = "http://localhost:#{port}"
|
|
199
|
+
res["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
|
200
|
+
res["Access-Control-Allow-Headers"] = "Content-Type"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def json_response(res, data, status: 200)
|
|
204
|
+
res.status = status
|
|
205
|
+
res["Content-Type"] = "application/json"
|
|
206
|
+
res.body = data.to_json
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def not_found(res)
|
|
210
|
+
res.status = 404
|
|
211
|
+
res["Content-Type"] = "application/json"
|
|
212
|
+
res.body = { error: "not_found" }.to_json
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
MAX_BODY_SIZE = 1_000_000 # 1MB
|
|
216
|
+
|
|
217
|
+
def parse_json(req)
|
|
218
|
+
return {} if req.body.nil?
|
|
219
|
+
return {} if req.body.bytesize > MAX_BODY_SIZE
|
|
220
|
+
|
|
221
|
+
JSON.parse(req.body)
|
|
222
|
+
rescue JSON::ParserError
|
|
223
|
+
{}
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module RailsMarkup
|
|
8
|
+
# Proxies store operations to an existing HTTP server.
|
|
9
|
+
# Used when MCP starts but the HTTP port is already taken —
|
|
10
|
+
# delegates reads/writes to the running server's HTTP API.
|
|
11
|
+
class HttpStoreProxy
|
|
12
|
+
def initialize(base_url: "http://localhost:4747")
|
|
13
|
+
@base_url = base_url
|
|
14
|
+
@uri = URI.parse(base_url)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def list_sessions
|
|
18
|
+
get("/sessions") || []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get_session(id)
|
|
22
|
+
get("/sessions/#{id}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_session(url:, metadata: nil)
|
|
26
|
+
post("/sessions", { url: url, metadata: metadata })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def all_pending
|
|
30
|
+
get("/pending") || []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def pending_for_session(session_id)
|
|
34
|
+
session = get_session(session_id)
|
|
35
|
+
return [] unless session
|
|
36
|
+
|
|
37
|
+
(session["annotations"] || []).select { |a| a["status"] == "pending" }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get_annotation(id)
|
|
41
|
+
# Search all sessions for this annotation (not just pending)
|
|
42
|
+
list_sessions.each do |session|
|
|
43
|
+
(session["annotations"] || []).each do |ann|
|
|
44
|
+
return ann if ann["id"] == id
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def create_annotation(session_id:, target:, content:, intent: "change", severity: "suggestion", selected_text: nil, metadata: nil)
|
|
51
|
+
post("/sessions/#{session_id}/annotations", {
|
|
52
|
+
target: target, content: content, intent: intent,
|
|
53
|
+
severity: severity, selectedText: selected_text, metadata: metadata
|
|
54
|
+
})
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def acknowledge(id)
|
|
58
|
+
post("/annotations/#{id}/acknowledge", {})
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resolve(id, summary: nil)
|
|
62
|
+
post("/annotations/#{id}/resolve", { summary: summary })
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def dismiss(id, reason: nil)
|
|
66
|
+
post("/annotations/#{id}/dismiss", { reason: reason })
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reply(id, message:)
|
|
70
|
+
post("/annotations/#{id}/reply", { message: message })
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Proxy serialization — data is already serialized from HTTP
|
|
74
|
+
def serialize_session(data)
|
|
75
|
+
data
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def serialize_annotation(data)
|
|
79
|
+
data
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Watch not supported via HTTP proxy — return empty after timeout
|
|
83
|
+
def subscribe(_session_id = nil, &_block)
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def unsubscribe(_sub)
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def get(path)
|
|
94
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
95
|
+
http.open_timeout = 5
|
|
96
|
+
http.read_timeout = 10
|
|
97
|
+
req = Net::HTTP::Get.new(path)
|
|
98
|
+
resp = http.request(req)
|
|
99
|
+
return nil unless resp.is_a?(Net::HTTPSuccess)
|
|
100
|
+
|
|
101
|
+
JSON.parse(resp.body)
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
$stderr.puts "[rails-markup proxy] GET #{path} failed: #{e.message}"
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def post(path, body)
|
|
108
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
109
|
+
http.open_timeout = 5
|
|
110
|
+
http.read_timeout = 10
|
|
111
|
+
req = Net::HTTP::Post.new(path, "Content-Type" => "application/json")
|
|
112
|
+
req.body = body.to_json
|
|
113
|
+
resp = http.request(req)
|
|
114
|
+
return nil unless resp.is_a?(Net::HTTPSuccess)
|
|
115
|
+
|
|
116
|
+
JSON.parse(resp.body)
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
$stderr.puts "[rails-markup proxy] POST #{path} failed: #{e.message}"
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|