rails-markup 1.4.0 → 1.4.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2efa8f1fca5e20c13c63a7157b47553cb893df6384bb0799b57b1dd9caa2bb6
4
- data.tar.gz: 5b244141b9d9bab054f7baa2f15dd3af9560e0ee3aaefcaeaef81c5e4e3541bd
3
+ metadata.gz: 34e44f1b30e4c51432e862ee09efd9d1f72e906a41e0e38f9c7553267f737d6f
4
+ data.tar.gz: '0909684467d87ed6a1248657ea2fc0e120956b66203c55b47750e74b57971bcb'
5
5
  SHA512:
6
- metadata.gz: d6b1da9212c6140e1db0767b6a27b972497063657a8b5f23d28e1ba84d21d1ed606473182516730708bd7f29447df448baf967f34cf291de40374b724b984cd4
7
- data.tar.gz: 73f188f0de27d3fc628bc31388daa1a6e0048a124c3d1a0dfb91dcf9be0b048470179c4b47be58b403c2850653064d005fde99f10738bb394433c4cd17ba0cbd
6
+ metadata.gz: ea571e4c90037645f9f81ddc6e95710ffd6333addee88f6b43b3352ee8be8bcf9afb9b4d0d2868965f30825c370bf668a6e3a61e7fe345c6ae2469e8e205216c
7
+ data.tar.gz: ec0f2ff1ae946b95e7e6c6a7c0fb90429faa8bab7e8cbe30763a09440056760ab9049aed14b296a36c52095f9c1eae5d45941c6a1ccae8bcc346e5f12d03a5d3
@@ -43,6 +43,7 @@
43
43
  _syncMaxRetryDelay: 30000,
44
44
  _syncMalformedLimit: 3,
45
45
  _syncUnavailable: null,
46
+ legacyStorageEndpoint: null,
46
47
 
47
48
  // Drawing state
48
49
  drawingMode: null, // null | "arrow" | "rect" | "highlight"
@@ -73,6 +74,7 @@
73
74
  this.size = opts.size || "default";
74
75
  this.fabVisible = opts.fabVisible !== false;
75
76
  this.enableScreenshots = opts.enableScreenshots !== false;
77
+ this.legacyStorageEndpoint = opts.legacyStorageEndpoint || this.legacyStorageEndpoint;
76
78
  this.healthIntervalMs = (opts.healthInterval || 60) * 1000;
77
79
 
78
80
  if (document.getElementById("rm-toolbar-root")) {
@@ -1195,6 +1197,8 @@
1195
1197
 
1196
1198
  if (result.kind === "success") {
1197
1199
  if (!this._outboxEntryMatches(snapshot)) {
1200
+ const supersedingDelete = this._advanceSupersedingDeleteBaseRevision(snapshot, result.data);
1201
+ if (supersedingDelete === "failed") break;
1198
1202
  clientIds.push(clientId);
1199
1203
  continue;
1200
1204
  }
@@ -1215,13 +1219,14 @@
1215
1219
  continue;
1216
1220
  }
1217
1221
  if (result.kind === "conflict") {
1218
- const pulled = await this._pullAnnotations();
1219
- if (pulled && this.outbox[clientId]) {
1222
+ const resolution = this._reconcileSyncConflict(snapshot, result);
1223
+ await this._pullAnnotations();
1224
+ if (resolution === "retry" && this.outbox[clientId]) {
1220
1225
  clientIds.push(clientId);
1221
1226
  continue;
1222
1227
  }
1223
- this._scheduleSyncRetry();
1224
- break;
1228
+ this._resetSyncRetry();
1229
+ continue;
1225
1230
  }
1226
1231
  if (result.kind === "malformed") {
1227
1232
  if (!this._outboxEntryMatches(snapshot)) {
@@ -1239,6 +1244,25 @@
1239
1244
  }
1240
1245
  },
1241
1246
 
1247
+ _advanceSupersedingDeleteBaseRevision(snapshot, server) {
1248
+ const current = this.outbox[snapshot.clientId];
1249
+ const supersedesUpsert = snapshot.type === "upsert"
1250
+ && current?.type === "delete"
1251
+ && current.clientId === snapshot.clientId
1252
+ && Number.isInteger(current.revision)
1253
+ && current.revision > snapshot.revision
1254
+ && Number.isInteger(server?.revision);
1255
+ if (!supersedesUpsert) return "not-applicable";
1256
+
1257
+ const tombstone = this._immutableCopy(current);
1258
+ const committed = this._commitLocalStateChange(() => {
1259
+ if (!this._outboxEntryMatches(tombstone)) return;
1260
+ const baseRevision = Number.isInteger(tombstone.baseRevision) ? tombstone.baseRevision : 0;
1261
+ this.outbox[snapshot.clientId].baseRevision = Math.max(baseRevision, server.revision);
1262
+ });
1263
+ return committed ? "advanced" : "failed";
1264
+ },
1265
+
1242
1266
  async _sendOutboxEntry(snapshot) {
1243
1267
  const request = this._sameOriginMutationRequest(`/annotations/${encodeURIComponent(snapshot.clientId)}`);
1244
1268
  if (!request) return { kind: "auth", message: "Sync unavailable: endpoint must be same-origin" };
@@ -1250,7 +1274,11 @@
1250
1274
  redirect: "manual",
1251
1275
  signal: AbortSignal.timeout(5000)
1252
1276
  };
1253
- if (snapshot.type === "upsert") {
1277
+ if (snapshot.type === "delete") {
1278
+ options.body = JSON.stringify({
1279
+ baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
1280
+ });
1281
+ } else {
1254
1282
  options.body = JSON.stringify(Object.assign({}, snapshot.annotation, {
1255
1283
  dirtyFields: snapshot.dirtyFields || [],
1256
1284
  baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
@@ -1292,7 +1320,7 @@
1292
1320
  if ([408, 425, 429].includes(status) || status >= 500) {
1293
1321
  return { kind: "retryable", retryAfter: this._retryAfterDelay(response) };
1294
1322
  }
1295
- if (status === 409 && snapshot.type === "upsert") return { kind: "conflict" };
1323
+ if (status === 409) return this._classifyConflictResponse(snapshot, response);
1296
1324
  if (status >= 400) return { kind: "terminal" };
1297
1325
  if (!response.ok) return { kind: "retryable" };
1298
1326
  if (snapshot.type === "delete") return { kind: "success", data: null };
@@ -1311,6 +1339,25 @@
1311
1339
  }
1312
1340
  },
1313
1341
 
1342
+ async _classifyConflictResponse(snapshot, response) {
1343
+ const contentType = response.headers.get("Content-Type") || "";
1344
+ if (!contentType.toLowerCase().includes("application/json")) return { kind: "malformed" };
1345
+
1346
+ try {
1347
+ const body = await response.json();
1348
+ if (!this._plainObject(body) || !Object.prototype.hasOwnProperty.call(body, "annotation")) {
1349
+ return { kind: "malformed" };
1350
+ }
1351
+ if (body.annotation === null && snapshot.type === "upsert") {
1352
+ return { kind: "conflict", missing: true, data: null };
1353
+ }
1354
+ if (!this._validServerAnnotation(body.annotation, snapshot.clientId)) return { kind: "malformed" };
1355
+ return { kind: "conflict", missing: false, data: body.annotation };
1356
+ } catch {
1357
+ return { kind: "malformed" };
1358
+ }
1359
+ },
1360
+
1314
1361
  _validServerAnnotation(data, expectedClientId) {
1315
1362
  if (!this._plainObject(data)) return false;
1316
1363
  const required = [
@@ -1385,6 +1432,65 @@
1385
1432
  annotation.syncState = "synced";
1386
1433
  },
1387
1434
 
1435
+ _reconcileSyncConflict(snapshot, conflict) {
1436
+ if (!this._outboxEntryMatches(snapshot)) return "stop";
1437
+
1438
+ let resolution = "stop";
1439
+ const committed = this._commitLocalStateChange(() => {
1440
+ if (!this._outboxEntryMatches(snapshot)) return;
1441
+ const entry = this.outbox[snapshot.clientId];
1442
+ const annotation = this.annotations.find(candidate => candidate.clientId === snapshot.clientId);
1443
+
1444
+ if (snapshot.type === "delete") {
1445
+ delete this.outbox[snapshot.clientId];
1446
+ if (annotation) this._mergePulledAnnotation(annotation, conflict.data, null);
1447
+ else this.annotations.push(this._annotationFromServer(conflict.data));
1448
+ this._assignDisplayIds();
1449
+ return;
1450
+ }
1451
+
1452
+ if (conflict.missing) {
1453
+ if (entry.missingConflictRebased) {
1454
+ entry.syncState = "failed";
1455
+ if (annotation) annotation.syncState = "failed";
1456
+ return;
1457
+ }
1458
+ if (!annotation) {
1459
+ entry.syncState = "failed";
1460
+ return;
1461
+ }
1462
+ // Recreate once from the complete desired browser state. Replaying a
1463
+ // narrow edit delta onto a new row can omit required fields and lose
1464
+ // the user's annotation to a permanent validation failure.
1465
+ annotation.dirtyFields = this._legacyDirtyFields(annotation);
1466
+ entry.annotation = this._desiredState(annotation);
1467
+ entry.dirtyFields = annotation.dirtyFields.slice();
1468
+ entry.baseRevision = 0;
1469
+ entry.missingConflictRebased = true;
1470
+ annotation.serverId = null;
1471
+ annotation.serverRevision = 0;
1472
+ resolution = "retry";
1473
+ return;
1474
+ }
1475
+
1476
+ if (!annotation) {
1477
+ entry.syncState = "failed";
1478
+ return;
1479
+ }
1480
+ this._mergePulledAnnotation(annotation, conflict.data, entry);
1481
+ entry.annotation = this._desiredState(annotation);
1482
+ entry.dirtyFields = (annotation.dirtyFields || []).slice();
1483
+ entry.baseRevision = conflict.data.revision;
1484
+ delete entry.missingConflictRebased;
1485
+ resolution = "retry";
1486
+ });
1487
+ if (!committed) return "stop";
1488
+ this._renderPins();
1489
+ this._rebuildList();
1490
+ this._updateCount();
1491
+ return resolution;
1492
+ },
1493
+
1388
1494
  _markSyncFailed(snapshot) {
1389
1495
  if (!this._outboxEntryMatches(snapshot)) return;
1390
1496
  this._commitLocalStateChange(() => {
@@ -1479,9 +1585,9 @@
1479
1585
  ? data.legacyMigrations
1480
1586
  : {};
1481
1587
  }
1482
- // Pre-1.3 storage had no endpoint identity. The first configured endpoint
1483
- // to load on this origin claims it once; successful consolidation removes
1484
- // the source keys so another endpoint cannot import the same data later.
1588
+ // Pre-1.3 bare storage has no endpoint provenance, so it is left intact
1589
+ // unless the host explicitly designates this endpoint. Page-qualified
1590
+ // legacy keys are only claimed by a toolbar currently on that exact page.
1485
1591
  const migratedKeys = this._migrateUnnamespacedStorage();
1486
1592
  migratedKeys.push(...this._migratePageAnnotations());
1487
1593
  this._normalizeStoredState();
@@ -1494,9 +1600,13 @@
1494
1600
 
1495
1601
  _migrateUnnamespacedStorage() {
1496
1602
  const sourceKeys = [];
1603
+ const designatedEndpoint = (this.legacyStorageEndpoint || "").replace(/\/+$/, "");
1604
+ const currentEndpoint = (this.endpoint || "").replace(/\/+$/, "");
1605
+ const claimBareStorage = Boolean(designatedEndpoint) && designatedEndpoint === currentEndpoint;
1606
+ const currentPageKey = `rm-annotations:${this._pageUrl()}`;
1497
1607
  for (let index = 0; index < localStorage.length; index++) {
1498
1608
  const key = localStorage.key(index);
1499
- if (key === "rm-annotations" || (key && key.startsWith("rm-annotations:/"))) sourceKeys.push(key);
1609
+ if ((claimBareStorage && key === "rm-annotations") || key === currentPageKey) sourceKeys.push(key);
1500
1610
  }
1501
1611
 
1502
1612
  const migratedKeys = [];
@@ -88,8 +88,21 @@ module RailsMarkup
88
88
  def destroy
89
89
  client_uuid = normalized_route_uuid
90
90
  return render_invalid_uuid unless client_uuid
91
+ base_revision = normalized_base_revision
92
+ return render_invalid_base_revision unless base_revision
93
+
94
+ annotation = Annotation.find_by(client_uuid: client_uuid)
95
+ return head :no_content unless annotation
96
+
97
+ annotation.with_lock do
98
+ raise Annotation::RevisionConflict, annotation unless annotation.revision == base_revision
91
99
 
92
- Annotation.find_by(client_uuid: client_uuid)&.destroy!
100
+ annotation.destroy!
101
+ end
102
+ head :no_content
103
+ rescue Annotation::RevisionConflict => error
104
+ render_revision_conflict(error)
105
+ rescue ActiveRecord::RecordNotFound
93
106
  head :no_content
94
107
  end
95
108
 
@@ -260,7 +273,7 @@ module RailsMarkup
260
273
  def render_revision_conflict(error)
261
274
  render json: {
262
275
  error: "revision conflict",
263
- annotation: error.annotation.as_api_json
276
+ annotation: error.annotation.new_record? ? nil : error.annotation.as_api_json
264
277
  }, status: :conflict
265
278
  end
266
279
 
@@ -106,7 +106,10 @@ module RailsMarkup
106
106
  return redirect_to root_path, alert: "Invalid status for bulk dismiss."
107
107
  end
108
108
 
109
- count = Annotation.where(status: status).update_all(status: "dismissed")
109
+ # Bump revision per row so a stale toolbar edit (which checks baseRevision)
110
+ # conflicts (409) instead of silently overwriting this bulk dismiss.
111
+ count = Annotation.where(status: status)
112
+ .update_all("status = 'dismissed', revision = revision + 1")
110
113
  redirect_to root_path(status: "dismissed"), notice: "#{count} annotations dismissed."
111
114
  end
112
115
 
@@ -122,7 +125,11 @@ module RailsMarkup
122
125
  when "transition"
123
126
  new_status = params[:status]
124
127
  if Annotation::STATUSES.include?(new_status)
125
- @annotation.update!(status: new_status)
128
+ # Lock + bump revision so a concurrent toolbar edit conflicts (409)
129
+ # instead of this board move silently losing to (or clobbering) it.
130
+ @annotation.with_lock do
131
+ @annotation.update!(status: new_status, revision: @annotation.revision + 1)
132
+ end
126
133
  return head :ok
127
134
  else
128
135
  return render json: { error: "invalid status" }, status: :unprocessable_entity
@@ -26,6 +26,10 @@ module RailsMarkup
26
26
  LEGACY_SESSION_PATTERN = /\Arm-[0-9a-f]{16}\z/i
27
27
  LEGACY_CLIENT_ID_LIMIT = 256
28
28
  LEGACY_UUID_NAMESPACE = "265e7cf0-8be6-5e21-8f31-a582cfde8646"
29
+ # Bound thread growth so large or many reply/summary/reason messages (incl.
30
+ # via the MCP tools) can't grow a row without limit.
31
+ MAX_THREAD_ENTRIES = 500
32
+ MAX_THREAD_MESSAGE = 5000
29
33
 
30
34
  # Optional user association — no FK constraint, engine doesn't know host users table
31
35
  belongs_to :user, optional: true
@@ -45,6 +49,7 @@ module RailsMarkup
45
49
  validates :severity, inclusion: { in: SEVERITIES }
46
50
  validates :status, inclusion: { in: STATUSES }
47
51
  validate :thread_must_be_array
52
+ validate :thread_within_limits
48
53
 
49
54
  def self.valid_client_uuid?(value)
50
55
  value.is_a?(String) && CLIENT_UUID_PATTERN.match?(value)
@@ -208,5 +213,15 @@ module RailsMarkup
208
213
  def thread_must_be_array
209
214
  errors.add(:thread, "must be an array") unless thread.is_a?(Array)
210
215
  end
216
+
217
+ def thread_within_limits
218
+ return unless thread.is_a?(Array)
219
+
220
+ errors.add(:thread, "cannot exceed #{MAX_THREAD_ENTRIES} entries") if thread.size > MAX_THREAD_ENTRIES
221
+
222
+ if thread.any? { |entry| entry.is_a?(Hash) && entry["message"].to_s.length > MAX_THREAD_MESSAGE }
223
+ errors.add(:thread, "entry message cannot exceed #{MAX_THREAD_MESSAGE} characters")
224
+ end
225
+ end
211
226
  end
212
227
  end
@@ -22,6 +22,9 @@ module RailsMarkup
22
22
  class ToolError < StandardError; end
23
23
  class TargetError < ToolError; end
24
24
 
25
+ MAX_ACTION_MESSAGE_LENGTH = 5_000
26
+ MAX_ACTION_MESSAGE_BYTES = 5_000
27
+
25
28
  ENV_SCHEMA = {
26
29
  environment: {
27
30
  type: "string",
@@ -74,7 +77,11 @@ module RailsMarkup
74
77
  properties: {
75
78
  action: { type: "string", enum: %w[acknowledge resolve], description: "State transition to apply." },
76
79
  annotationId: { type: "string", description: "The annotation ID" },
77
- summary: { type: "string", description: "Optional resolution summary; valid only for resolve." },
80
+ summary: {
81
+ type: "string",
82
+ maxLength: MAX_ACTION_MESSAGE_LENGTH,
83
+ description: "Optional resolution summary; valid only for resolve."
84
+ },
78
85
  **ENV_SCHEMA
79
86
  },
80
87
  required: %w[action annotationId],
@@ -89,7 +96,7 @@ module RailsMarkup
89
96
  type: "object",
90
97
  properties: {
91
98
  annotationId: { type: "string", description: "The annotation ID" },
92
- message: { type: "string", description: "Reply message" },
99
+ message: { type: "string", maxLength: MAX_ACTION_MESSAGE_LENGTH, description: "Reply message" },
93
100
  **ENV_SCHEMA
94
101
  },
95
102
  required: %w[annotationId message],
@@ -104,7 +111,7 @@ module RailsMarkup
104
111
  type: "object",
105
112
  properties: {
106
113
  annotationId: { type: "string", description: "The annotation ID" },
107
- reason: { type: "string", description: "Reason for dismissal" },
114
+ reason: { type: "string", maxLength: MAX_ACTION_MESSAGE_LENGTH, description: "Reason for dismissal" },
108
115
  **ENV_SCHEMA
109
116
  },
110
117
  required: %w[annotationId reason],
@@ -372,11 +379,14 @@ module RailsMarkup
372
379
  return "action must be acknowledge or resolve." unless %w[acknowledge resolve].include?(args["action"])
373
380
  return "annotationId must be a non-empty string." unless nonempty_string?(args["annotationId"])
374
381
  return "summary must be a string." if args.key?("summary") && !args["summary"].is_a?(String)
382
+ return oversized_action_message_error("summary", args["summary"]) if oversized_action_message?(args["summary"])
375
383
  return "summary is only valid for resolve." if args["action"] != "resolve" && args.key?("summary")
376
384
  when "rails_markup_reply"
377
385
  return "annotationId and message must be non-empty strings." unless nonempty_string?(args["annotationId"]) && nonempty_string?(args["message"])
386
+ return oversized_action_message_error("message", args["message"]) if oversized_action_message?(args["message"])
378
387
  when "rails_markup_dismiss"
379
388
  return "annotationId and reason must be non-empty strings." unless nonempty_string?(args["annotationId"]) && nonempty_string?(args["reason"])
389
+ return oversized_action_message_error("reason", args["reason"]) if oversized_action_message?(args["reason"])
380
390
  when "rails_markup_watch"
381
391
  return "sessionId must be a non-empty string." if args.key?("sessionId") && !nonempty_string?(args["sessionId"])
382
392
  unless !args.key?("timeoutSeconds") || numeric_between?(args["timeoutSeconds"], 0, 300)
@@ -400,6 +410,14 @@ module RailsMarkup
400
410
  value.is_a?(Numeric) && value.finite? && value.between?(minimum, maximum)
401
411
  end
402
412
 
413
+ def oversized_action_message?(value)
414
+ value.is_a?(String) && value.bytesize > MAX_ACTION_MESSAGE_BYTES
415
+ end
416
+
417
+ def oversized_action_message_error(name, _value)
418
+ "#{name} exceeds #{MAX_ACTION_MESSAGE_BYTES} bytes."
419
+ end
420
+
403
421
  def invalid_arguments_response(id, _unknown)
404
422
  tool_error_response(id, "Remove unsupported arguments.")
405
423
  end
@@ -22,6 +22,7 @@ module RailsMarkup
22
22
  MAX_TARGET_BYTES = 16_384
23
23
  MAX_SELECTED_TEXT_BYTES = 2_000
24
24
  MAX_METADATA_BYTES = 65_536
25
+ MAX_THREAD_MESSAGE_BYTES = 5_000
25
26
  INTENTS = %w[fix change question approve].freeze
26
27
  SEVERITIES = %w[suggestion important blocking].freeze
27
28
 
@@ -119,35 +120,64 @@ module RailsMarkup
119
120
  # --- Status transitions ---
120
121
 
121
122
  def acknowledge(annotation_id)
122
- update_status(annotation_id, "acknowledged")
123
+ @mutex.synchronize do
124
+ ann = @annotations_index[annotation_id]
125
+ return nil unless ann
126
+ return ann if ann.status == "acknowledged"
127
+
128
+ validate_transition!(ann, "acknowledged", from: %w[pending])
129
+ ann.status = "acknowledged"
130
+ ann
131
+ end
123
132
  end
124
133
 
125
134
  def resolve(annotation_id, summary: nil)
126
- ann = update_status(annotation_id, "resolved")
127
- return nil unless ann
135
+ changed = false
136
+ ann = @mutex.synchronize do
137
+ annotation = @annotations_index[annotation_id]
138
+ return nil unless annotation
139
+ return annotation if annotation.status == "resolved"
140
+
141
+ validate_transition!(annotation, "resolved", from: %w[pending acknowledged])
142
+ append_thread_message!(annotation, summary) unless summary.nil? || summary == ""
143
+ annotation.status = "resolved"
144
+ changed = true
145
+ annotation
146
+ end
147
+ return ann unless changed
128
148
 
129
- ann.thread << { role: "agent", message: summary, timestamp: Time.now.iso8601 } if summary
130
149
  notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
131
150
  status: "resolved", summary: summary)
132
151
  ann
133
152
  end
134
153
 
135
154
  def dismiss(annotation_id, reason: nil)
136
- ann = update_status(annotation_id, "dismissed")
137
- return nil unless ann
155
+ changed = false
156
+ ann = @mutex.synchronize do
157
+ annotation = @annotations_index[annotation_id]
158
+ return nil unless annotation
159
+ return annotation if annotation.status == "dismissed"
160
+
161
+ validate_transition!(annotation, "dismissed", from: %w[pending acknowledged])
162
+ append_thread_message!(annotation, reason) unless reason.nil? || reason == ""
163
+ annotation.status = "dismissed"
164
+ changed = true
165
+ annotation
166
+ end
167
+ return ann unless changed
138
168
 
139
- ann.thread << { role: "agent", message: reason, timestamp: Time.now.iso8601 } if reason
140
169
  notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
141
170
  status: "dismissed", reason: reason)
142
171
  ann
143
172
  end
144
173
 
145
174
  def reply(annotation_id, message:)
146
- ann = get_annotation(annotation_id)
147
- return nil unless ann
175
+ ann = @mutex.synchronize do
176
+ annotation = @annotations_index[annotation_id]
177
+ return nil unless annotation
148
178
 
149
- @mutex.synchronize do
150
- ann.thread << { role: "agent", message: message, timestamp: Time.now.iso8601 }
179
+ append_thread_message!(annotation, message)
180
+ annotation
151
181
  end
152
182
  notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
153
183
  status: ann.status, message: message)
@@ -201,12 +231,26 @@ module RailsMarkup
201
231
 
202
232
  private
203
233
 
204
- def update_status(annotation_id, new_status)
205
- ann = get_annotation(annotation_id)
206
- return nil unless ann
234
+ def validate_transition!(annotation, new_status, from:)
235
+ return if from.include?(annotation.status)
207
236
 
208
- @mutex.synchronize { ann.status = new_status }
209
- ann
237
+ raise ValidationError, "cannot transition #{annotation.status} annotation to #{new_status}"
238
+ end
239
+
240
+ def append_thread_message!(annotation, message)
241
+ validate_string!("message", message, maximum: MAX_THREAD_MESSAGE_BYTES)
242
+ new_thread = annotation.thread + [{ role: "agent", message: message, timestamp: Time.now.iso8601 }]
243
+ enforce_thread_capacity!(annotation, new_thread)
244
+ annotation.thread = new_thread
245
+ end
246
+
247
+ def enforce_thread_capacity!(annotation, new_thread)
248
+ current_bytes = aggregate_annotation_bytes
249
+ proposed_bytes = current_bytes - annotation_storage_bytes(annotation) +
250
+ annotation_storage_bytes(annotation, thread: new_thread)
251
+ return if proposed_bytes <= @max_annotation_bytes
252
+
253
+ raise CapacityError, "aggregate annotation byte limit of #{@max_annotation_bytes} reached"
210
254
  end
211
255
 
212
256
  def notify(session_id, data)
@@ -235,7 +279,7 @@ module RailsMarkup
235
279
  raise ValidationError, "metadata exceeds #{MAX_METADATA_BYTES} bytes" if metadata_bytes > MAX_METADATA_BYTES
236
280
 
237
281
  JSON.generate(
238
- target:, content:, intent:, severity:, selected_text:, metadata: metadata || {}
282
+ target:, content:, intent:, severity:, selected_text:, metadata: metadata || {}, thread: []
239
283
  ).bytesize
240
284
  rescue JSON::GeneratorError, Encoding::UndefinedConversionError
241
285
  raise ValidationError, "annotation fields must be JSON serializable"
@@ -268,22 +312,27 @@ module RailsMarkup
268
312
  raise CapacityError, "session annotation limit of #{@max_annotations_per_session} reached"
269
313
  end
270
314
 
271
- current_bytes = @sessions.values.sum do |stored_session|
272
- stored_session.annotations.sum { |annotation| annotation_storage_bytes(annotation) }
273
- end
315
+ current_bytes = aggregate_annotation_bytes
274
316
  return if current_bytes + incoming_bytes <= @max_annotation_bytes
275
317
 
276
318
  raise CapacityError, "aggregate annotation byte limit of #{@max_annotation_bytes} reached"
277
319
  end
278
320
 
279
- def annotation_storage_bytes(annotation)
321
+ def aggregate_annotation_bytes
322
+ @sessions.values.sum do |stored_session|
323
+ stored_session.annotations.sum { |annotation| annotation_storage_bytes(annotation) }
324
+ end
325
+ end
326
+
327
+ def annotation_storage_bytes(annotation, thread: annotation.thread)
280
328
  JSON.generate(
281
329
  target: annotation.target,
282
330
  content: annotation.content,
283
331
  intent: annotation.intent,
284
332
  severity: annotation.severity,
285
333
  selected_text: annotation.selected_text,
286
- metadata: annotation.metadata
334
+ metadata: annotation.metadata,
335
+ thread: thread
287
336
  ).bytesize
288
337
  end
289
338
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsMarkup
4
- VERSION = "1.4.0"
4
+ VERSION = "1.4.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-markup
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - InventList