rails-markup 1.4.0 → 1.4.1
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 +4 -4
- data/app/assets/javascripts/rails_markup/toolbar.js +91 -10
- data/app/controllers/rails_markup/annotations_controller.rb +15 -2
- data/app/controllers/rails_markup/dashboard_controller.rb +9 -2
- data/app/models/rails_markup/annotation.rb +15 -0
- data/lib/rails_markup/mcp_server.rb +21 -3
- data/lib/rails_markup/store.rb +71 -22
- data/lib/rails_markup/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 665bd02a5e8830d8565296c7623f4136513a61a2c1574e68c0c2b6802daf7ea2
|
|
4
|
+
data.tar.gz: fd6290208722a52ecc255f3d01cdf017de31010157f6c67cc4386423e848eb98
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 32afe3f8d8ea940731cde32f9524738189ca7d54b1bc1d124adc8888adb9fce35347647bc5844ca15dac6c918a546cef2d9ca048e0901e98ebc87f17db34ec4e
|
|
7
|
+
data.tar.gz: 2c173c2623816601f0cc5ab730960cc035f2523a14adad0de4d130163f358d47562959f8fbd6c4b9e2e2ea9a0442e6d711dc6ba35070d89ae7d23e17c6cb2b15
|
|
@@ -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")) {
|
|
@@ -1215,13 +1217,14 @@
|
|
|
1215
1217
|
continue;
|
|
1216
1218
|
}
|
|
1217
1219
|
if (result.kind === "conflict") {
|
|
1218
|
-
const
|
|
1219
|
-
|
|
1220
|
+
const resolution = this._reconcileSyncConflict(snapshot, result);
|
|
1221
|
+
await this._pullAnnotations();
|
|
1222
|
+
if (resolution === "retry" && this.outbox[clientId]) {
|
|
1220
1223
|
clientIds.push(clientId);
|
|
1221
1224
|
continue;
|
|
1222
1225
|
}
|
|
1223
|
-
this.
|
|
1224
|
-
|
|
1226
|
+
this._resetSyncRetry();
|
|
1227
|
+
continue;
|
|
1225
1228
|
}
|
|
1226
1229
|
if (result.kind === "malformed") {
|
|
1227
1230
|
if (!this._outboxEntryMatches(snapshot)) {
|
|
@@ -1250,7 +1253,11 @@
|
|
|
1250
1253
|
redirect: "manual",
|
|
1251
1254
|
signal: AbortSignal.timeout(5000)
|
|
1252
1255
|
};
|
|
1253
|
-
if (snapshot.type === "
|
|
1256
|
+
if (snapshot.type === "delete") {
|
|
1257
|
+
options.body = JSON.stringify({
|
|
1258
|
+
baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
|
|
1259
|
+
});
|
|
1260
|
+
} else {
|
|
1254
1261
|
options.body = JSON.stringify(Object.assign({}, snapshot.annotation, {
|
|
1255
1262
|
dirtyFields: snapshot.dirtyFields || [],
|
|
1256
1263
|
baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
|
|
@@ -1292,7 +1299,7 @@
|
|
|
1292
1299
|
if ([408, 425, 429].includes(status) || status >= 500) {
|
|
1293
1300
|
return { kind: "retryable", retryAfter: this._retryAfterDelay(response) };
|
|
1294
1301
|
}
|
|
1295
|
-
if (status === 409
|
|
1302
|
+
if (status === 409) return this._classifyConflictResponse(snapshot, response);
|
|
1296
1303
|
if (status >= 400) return { kind: "terminal" };
|
|
1297
1304
|
if (!response.ok) return { kind: "retryable" };
|
|
1298
1305
|
if (snapshot.type === "delete") return { kind: "success", data: null };
|
|
@@ -1311,6 +1318,25 @@
|
|
|
1311
1318
|
}
|
|
1312
1319
|
},
|
|
1313
1320
|
|
|
1321
|
+
async _classifyConflictResponse(snapshot, response) {
|
|
1322
|
+
const contentType = response.headers.get("Content-Type") || "";
|
|
1323
|
+
if (!contentType.toLowerCase().includes("application/json")) return { kind: "malformed" };
|
|
1324
|
+
|
|
1325
|
+
try {
|
|
1326
|
+
const body = await response.json();
|
|
1327
|
+
if (!this._plainObject(body) || !Object.prototype.hasOwnProperty.call(body, "annotation")) {
|
|
1328
|
+
return { kind: "malformed" };
|
|
1329
|
+
}
|
|
1330
|
+
if (body.annotation === null && snapshot.type === "upsert") {
|
|
1331
|
+
return { kind: "conflict", missing: true, data: null };
|
|
1332
|
+
}
|
|
1333
|
+
if (!this._validServerAnnotation(body.annotation, snapshot.clientId)) return { kind: "malformed" };
|
|
1334
|
+
return { kind: "conflict", missing: false, data: body.annotation };
|
|
1335
|
+
} catch {
|
|
1336
|
+
return { kind: "malformed" };
|
|
1337
|
+
}
|
|
1338
|
+
},
|
|
1339
|
+
|
|
1314
1340
|
_validServerAnnotation(data, expectedClientId) {
|
|
1315
1341
|
if (!this._plainObject(data)) return false;
|
|
1316
1342
|
const required = [
|
|
@@ -1385,6 +1411,57 @@
|
|
|
1385
1411
|
annotation.syncState = "synced";
|
|
1386
1412
|
},
|
|
1387
1413
|
|
|
1414
|
+
_reconcileSyncConflict(snapshot, conflict) {
|
|
1415
|
+
if (!this._outboxEntryMatches(snapshot)) return "stop";
|
|
1416
|
+
|
|
1417
|
+
let resolution = "stop";
|
|
1418
|
+
const committed = this._commitLocalStateChange(() => {
|
|
1419
|
+
if (!this._outboxEntryMatches(snapshot)) return;
|
|
1420
|
+
const entry = this.outbox[snapshot.clientId];
|
|
1421
|
+
const annotation = this.annotations.find(candidate => candidate.clientId === snapshot.clientId);
|
|
1422
|
+
|
|
1423
|
+
if (snapshot.type === "delete") {
|
|
1424
|
+
delete this.outbox[snapshot.clientId];
|
|
1425
|
+
if (annotation) this._mergePulledAnnotation(annotation, conflict.data, null);
|
|
1426
|
+
else this.annotations.push(this._annotationFromServer(conflict.data));
|
|
1427
|
+
this._assignDisplayIds();
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
if (conflict.missing) {
|
|
1432
|
+
if (entry.missingConflictRebased) {
|
|
1433
|
+
entry.syncState = "failed";
|
|
1434
|
+
if (annotation) annotation.syncState = "failed";
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
entry.baseRevision = 0;
|
|
1438
|
+
entry.missingConflictRebased = true;
|
|
1439
|
+
if (annotation) {
|
|
1440
|
+
annotation.serverId = null;
|
|
1441
|
+
annotation.serverRevision = 0;
|
|
1442
|
+
}
|
|
1443
|
+
resolution = "retry";
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
if (!annotation) {
|
|
1448
|
+
entry.syncState = "failed";
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
this._mergePulledAnnotation(annotation, conflict.data, entry);
|
|
1452
|
+
entry.annotation = this._desiredState(annotation);
|
|
1453
|
+
entry.dirtyFields = (annotation.dirtyFields || []).slice();
|
|
1454
|
+
entry.baseRevision = conflict.data.revision;
|
|
1455
|
+
delete entry.missingConflictRebased;
|
|
1456
|
+
resolution = "retry";
|
|
1457
|
+
});
|
|
1458
|
+
if (!committed) return "stop";
|
|
1459
|
+
this._renderPins();
|
|
1460
|
+
this._rebuildList();
|
|
1461
|
+
this._updateCount();
|
|
1462
|
+
return resolution;
|
|
1463
|
+
},
|
|
1464
|
+
|
|
1388
1465
|
_markSyncFailed(snapshot) {
|
|
1389
1466
|
if (!this._outboxEntryMatches(snapshot)) return;
|
|
1390
1467
|
this._commitLocalStateChange(() => {
|
|
@@ -1479,9 +1556,9 @@
|
|
|
1479
1556
|
? data.legacyMigrations
|
|
1480
1557
|
: {};
|
|
1481
1558
|
}
|
|
1482
|
-
// Pre-1.3 storage
|
|
1483
|
-
//
|
|
1484
|
-
//
|
|
1559
|
+
// Pre-1.3 bare storage has no endpoint provenance, so it is left intact
|
|
1560
|
+
// unless the host explicitly designates this endpoint. Page-qualified
|
|
1561
|
+
// legacy keys are only claimed by a toolbar currently on that exact page.
|
|
1485
1562
|
const migratedKeys = this._migrateUnnamespacedStorage();
|
|
1486
1563
|
migratedKeys.push(...this._migratePageAnnotations());
|
|
1487
1564
|
this._normalizeStoredState();
|
|
@@ -1494,9 +1571,13 @@
|
|
|
1494
1571
|
|
|
1495
1572
|
_migrateUnnamespacedStorage() {
|
|
1496
1573
|
const sourceKeys = [];
|
|
1574
|
+
const designatedEndpoint = (this.legacyStorageEndpoint || "").replace(/\/+$/, "");
|
|
1575
|
+
const currentEndpoint = (this.endpoint || "").replace(/\/+$/, "");
|
|
1576
|
+
const claimBareStorage = Boolean(designatedEndpoint) && designatedEndpoint === currentEndpoint;
|
|
1577
|
+
const currentPageKey = `rm-annotations:${this._pageUrl()}`;
|
|
1497
1578
|
for (let index = 0; index < localStorage.length; index++) {
|
|
1498
1579
|
const key = localStorage.key(index);
|
|
1499
|
-
if (key === "rm-annotations" ||
|
|
1580
|
+
if ((claimBareStorage && key === "rm-annotations") || key === currentPageKey) sourceKeys.push(key);
|
|
1500
1581
|
}
|
|
1501
1582
|
|
|
1502
1583
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
data/lib/rails_markup/store.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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 =
|
|
147
|
-
|
|
175
|
+
ann = @mutex.synchronize do
|
|
176
|
+
annotation = @annotations_index[annotation_id]
|
|
177
|
+
return nil unless annotation
|
|
148
178
|
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
return nil unless ann
|
|
234
|
+
def validate_transition!(annotation, new_status, from:)
|
|
235
|
+
return if from.include?(annotation.status)
|
|
207
236
|
|
|
208
|
-
|
|
209
|
-
|
|
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 =
|
|
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
|
|
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
|
|
data/lib/rails_markup/version.rb
CHANGED