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 +4 -4
- data/app/assets/javascripts/rails_markup/toolbar.js +120 -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: 34e44f1b30e4c51432e862ee09efd9d1f72e906a41e0e38f9c7553267f737d6f
|
|
4
|
+
data.tar.gz: '0909684467d87ed6a1248657ea2fc0e120956b66203c55b47750e74b57971bcb'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
1219
|
-
|
|
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.
|
|
1224
|
-
|
|
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 === "
|
|
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
|
|
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
|
|
1483
|
-
//
|
|
1484
|
-
//
|
|
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" ||
|
|
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
|
-
|
|
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