rails-markup 1.3.0 → 1.4.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 +4 -4
- data/app/assets/javascripts/rails_markup/toolbar.js +95 -6
- data/app/controllers/rails_markup/annotations_controller.rb +46 -8
- data/app/controllers/rails_markup/external/annotations_controller.rb +4 -1
- data/app/models/rails_markup/annotation.rb +36 -10
- data/app/views/rails_markup/shared/_toolbar.html.erb +8 -0
- data/db/migrate/20260726000000_add_revision_to_rails_markup_annotations.rb +11 -0
- data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +5 -0
- data/lib/generators/rails_markup/install_generator.rb +18 -1
- data/lib/rails_markup/cli.rb +16 -15
- data/lib/rails_markup/http_server.rb +27 -8
- data/lib/rails_markup/http_store_proxy.rb +5 -1
- data/lib/rails_markup/mcp_server.rb +4 -0
- data/lib/rails_markup/store.rb +95 -10
- data/lib/rails_markup/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c2efa8f1fca5e20c13c63a7157b47553cb893df6384bb0799b57b1dd9caa2bb6
|
|
4
|
+
data.tar.gz: 5b244141b9d9bab054f7baa2f15dd3af9560e0ee3aaefcaeaef81c5e4e3541bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d6b1da9212c6140e1db0767b6a27b972497063657a8b5f23d28e1ba84d21d1ed606473182516730708bd7f29447df448baf967f34cf291de40374b724b984cd4
|
|
7
|
+
data.tar.gz: 73f188f0de27d3fc628bc31388daa1a6e0048a124c3d1a0dfb91dcf9be0b048470179c4b47be58b403c2850653064d005fde99f10738bb394433c4cd17ba0cbd
|
|
@@ -714,6 +714,7 @@
|
|
|
714
714
|
id: this.nextId,
|
|
715
715
|
clientId: this._newClientId(),
|
|
716
716
|
serverId: null,
|
|
717
|
+
serverRevision: 0,
|
|
717
718
|
syncState: "pending",
|
|
718
719
|
serverUpdatedAt: null,
|
|
719
720
|
dirtyFields: [],
|
|
@@ -1077,12 +1078,16 @@
|
|
|
1077
1078
|
_queueLocalMutation(type, annotation, dirtyFields) {
|
|
1078
1079
|
const currentEntry = this.outbox[annotation.clientId];
|
|
1079
1080
|
const revision = Math.max(annotation.revision || 0, currentEntry?.revision || 0) + 1;
|
|
1081
|
+
const baseRevision = Number.isInteger(currentEntry?.baseRevision)
|
|
1082
|
+
? currentEntry.baseRevision
|
|
1083
|
+
: (Number.isInteger(annotation.serverRevision) ? annotation.serverRevision : 0);
|
|
1080
1084
|
|
|
1081
1085
|
if (type === "delete") {
|
|
1082
1086
|
this.outbox[annotation.clientId] = {
|
|
1083
1087
|
type: "delete",
|
|
1084
1088
|
clientId: annotation.clientId,
|
|
1085
1089
|
revision,
|
|
1090
|
+
baseRevision,
|
|
1086
1091
|
syncState: "pending"
|
|
1087
1092
|
};
|
|
1088
1093
|
} else {
|
|
@@ -1094,6 +1099,7 @@
|
|
|
1094
1099
|
type: "upsert",
|
|
1095
1100
|
clientId: annotation.clientId,
|
|
1096
1101
|
revision,
|
|
1102
|
+
baseRevision,
|
|
1097
1103
|
syncState: "pending",
|
|
1098
1104
|
annotation: this._desiredState(annotation),
|
|
1099
1105
|
dirtyFields: annotation.dirtyFields.slice()
|
|
@@ -1208,6 +1214,15 @@
|
|
|
1208
1214
|
this._markSyncFailed(snapshot);
|
|
1209
1215
|
continue;
|
|
1210
1216
|
}
|
|
1217
|
+
if (result.kind === "conflict") {
|
|
1218
|
+
const pulled = await this._pullAnnotations();
|
|
1219
|
+
if (pulled && this.outbox[clientId]) {
|
|
1220
|
+
clientIds.push(clientId);
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
this._scheduleSyncRetry();
|
|
1224
|
+
break;
|
|
1225
|
+
}
|
|
1211
1226
|
if (result.kind === "malformed") {
|
|
1212
1227
|
if (!this._outboxEntryMatches(snapshot)) {
|
|
1213
1228
|
clientIds.push(clientId);
|
|
@@ -1236,7 +1251,10 @@
|
|
|
1236
1251
|
signal: AbortSignal.timeout(5000)
|
|
1237
1252
|
};
|
|
1238
1253
|
if (snapshot.type === "upsert") {
|
|
1239
|
-
options.body = JSON.stringify(Object.assign({}, snapshot.annotation, {
|
|
1254
|
+
options.body = JSON.stringify(Object.assign({}, snapshot.annotation, {
|
|
1255
|
+
dirtyFields: snapshot.dirtyFields || [],
|
|
1256
|
+
baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
|
|
1257
|
+
}));
|
|
1240
1258
|
}
|
|
1241
1259
|
|
|
1242
1260
|
const response = await fetch(request.url, options);
|
|
@@ -1274,6 +1292,7 @@
|
|
|
1274
1292
|
if ([408, 425, 429].includes(status) || status >= 500) {
|
|
1275
1293
|
return { kind: "retryable", retryAfter: this._retryAfterDelay(response) };
|
|
1276
1294
|
}
|
|
1295
|
+
if (status === 409 && snapshot.type === "upsert") return { kind: "conflict" };
|
|
1277
1296
|
if (status >= 400) return { kind: "terminal" };
|
|
1278
1297
|
if (!response.ok) return { kind: "retryable" };
|
|
1279
1298
|
if (snapshot.type === "delete") return { kind: "success", data: null };
|
|
@@ -1297,7 +1316,7 @@
|
|
|
1297
1316
|
const required = [
|
|
1298
1317
|
"id", "clientId", "userId", "authorName", "content", "intent", "severity",
|
|
1299
1318
|
"status", "selectedText", "pageUrl", "target", "metadata", "thread",
|
|
1300
|
-
"createdAt", "updatedAt"
|
|
1319
|
+
"createdAt", "updatedAt", "revision"
|
|
1301
1320
|
];
|
|
1302
1321
|
if (!required.every(key => Object.prototype.hasOwnProperty.call(data, key))) return false;
|
|
1303
1322
|
if (typeof data.id !== "string" || data.id.length === 0) return false;
|
|
@@ -1313,6 +1332,7 @@
|
|
|
1313
1332
|
if (!this._plainObject(data.target) || !this._plainObject(data.metadata)) return false;
|
|
1314
1333
|
if (!Array.isArray(data.thread)) return false;
|
|
1315
1334
|
if (!this._validServerTimestamp(data.createdAt) || !this._validServerTimestamp(data.updatedAt)) return false;
|
|
1335
|
+
if (!Number.isInteger(data.revision) || data.revision < 0) return false;
|
|
1316
1336
|
return true;
|
|
1317
1337
|
},
|
|
1318
1338
|
|
|
@@ -1346,6 +1366,7 @@
|
|
|
1346
1366
|
return;
|
|
1347
1367
|
}
|
|
1348
1368
|
annotation.serverId = server.id;
|
|
1369
|
+
annotation.serverRevision = server.revision;
|
|
1349
1370
|
annotation.userId = server.userId;
|
|
1350
1371
|
annotation.authorName = server.authorName;
|
|
1351
1372
|
annotation.createdAt = server.createdAt;
|
|
@@ -1447,8 +1468,6 @@
|
|
|
1447
1468
|
|
|
1448
1469
|
_loadFromStorage() {
|
|
1449
1470
|
try {
|
|
1450
|
-
// Old unnamespaced keys are intentionally not read because they may belong
|
|
1451
|
-
// to another user or toolbar mount on the same origin.
|
|
1452
1471
|
let raw = localStorage.getItem(this._storageKey());
|
|
1453
1472
|
if (raw) {
|
|
1454
1473
|
const data = JSON.parse(raw);
|
|
@@ -1460,8 +1479,11 @@
|
|
|
1460
1479
|
? data.legacyMigrations
|
|
1461
1480
|
: {};
|
|
1462
1481
|
}
|
|
1463
|
-
//
|
|
1464
|
-
|
|
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.
|
|
1485
|
+
const migratedKeys = this._migrateUnnamespacedStorage();
|
|
1486
|
+
migratedKeys.push(...this._migratePageAnnotations());
|
|
1465
1487
|
this._normalizeStoredState();
|
|
1466
1488
|
this._recordLegacyMigrations();
|
|
1467
1489
|
if (this._saveToStorage()) this._cleanupMigratedKeys(migratedKeys);
|
|
@@ -1470,6 +1492,54 @@
|
|
|
1470
1492
|
} catch (e) { console.warn("[rails-markup] load failed:", e); }
|
|
1471
1493
|
},
|
|
1472
1494
|
|
|
1495
|
+
_migrateUnnamespacedStorage() {
|
|
1496
|
+
const sourceKeys = [];
|
|
1497
|
+
for (let index = 0; index < localStorage.length; index++) {
|
|
1498
|
+
const key = localStorage.key(index);
|
|
1499
|
+
if (key === "rm-annotations" || (key && key.startsWith("rm-annotations:/"))) sourceKeys.push(key);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const migratedKeys = [];
|
|
1503
|
+
const legacyAnnotations = [];
|
|
1504
|
+
const legacyOutbox = {};
|
|
1505
|
+
const consolidatedClientIds = new Set(
|
|
1506
|
+
this.annotations.map(annotation => annotation.clientId).filter(clientId => this._validClientId(clientId))
|
|
1507
|
+
);
|
|
1508
|
+
sourceKeys.forEach(key => {
|
|
1509
|
+
try {
|
|
1510
|
+
const data = JSON.parse(localStorage.getItem(key));
|
|
1511
|
+
if (!this._plainObject(data)) return;
|
|
1512
|
+
const hasAnnotations = Array.isArray(data.annotations);
|
|
1513
|
+
const hasOutbox = key === "rm-annotations" && this._plainObject(data.outbox);
|
|
1514
|
+
if (!hasAnnotations && !hasOutbox) return;
|
|
1515
|
+
|
|
1516
|
+
if (hasAnnotations) {
|
|
1517
|
+
data.annotations.forEach((annotation, index) => {
|
|
1518
|
+
if (!this._plainObject(annotation)) return;
|
|
1519
|
+
const fingerprint = this._legacyMigrationFingerprint(key, index, annotation);
|
|
1520
|
+
const migratedClientId = this.legacyMigrations[fingerprint];
|
|
1521
|
+
if (this._validClientId(migratedClientId) && consolidatedClientIds.has(migratedClientId)) return;
|
|
1522
|
+
if (this._validClientId(migratedClientId)) annotation.clientId = migratedClientId;
|
|
1523
|
+
if (this._validClientId(annotation.clientId) && consolidatedClientIds.has(annotation.clientId)) return;
|
|
1524
|
+
Object.defineProperty(annotation, "_legacyMigrationFingerprint", {
|
|
1525
|
+
configurable: true,
|
|
1526
|
+
value: fingerprint
|
|
1527
|
+
});
|
|
1528
|
+
legacyAnnotations.push(annotation);
|
|
1529
|
+
if (this._validClientId(annotation.clientId)) consolidatedClientIds.add(annotation.clientId);
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
if (hasOutbox) Object.assign(legacyOutbox, data.outbox);
|
|
1533
|
+
if (Number.isInteger(data.nextId) && data.nextId > this.nextId) this.nextId = data.nextId;
|
|
1534
|
+
migratedKeys.push(key);
|
|
1535
|
+
} catch {}
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
this.annotations = legacyAnnotations.concat(this.annotations);
|
|
1539
|
+
this.outbox = Object.assign({}, legacyOutbox, this.outbox);
|
|
1540
|
+
return migratedKeys;
|
|
1541
|
+
},
|
|
1542
|
+
|
|
1473
1543
|
_migratePageAnnotations() {
|
|
1474
1544
|
// Find and merge per-page annotation keys only within this endpoint namespace.
|
|
1475
1545
|
const prefix = this._storageKey() + ":";
|
|
@@ -1542,6 +1612,11 @@
|
|
|
1542
1612
|
}
|
|
1543
1613
|
if (annotation.serverId == null) annotation.serverId = annotation.server_id ?? null;
|
|
1544
1614
|
if (annotation.serverUpdatedAt == null) annotation.serverUpdatedAt = annotation.server_updated_at ?? null;
|
|
1615
|
+
if (!Number.isInteger(annotation.serverRevision) || annotation.serverRevision < 0) {
|
|
1616
|
+
annotation.serverRevision = Number.isInteger(annotation.server_revision) && annotation.server_revision >= 0
|
|
1617
|
+
? annotation.server_revision
|
|
1618
|
+
: 0;
|
|
1619
|
+
}
|
|
1545
1620
|
if (!Array.isArray(annotation.dirtyFields)) annotation.dirtyFields = [];
|
|
1546
1621
|
annotation.pageUrl = annotation.pageUrl || annotation.pathname || this._pageUrl();
|
|
1547
1622
|
annotation.pathname = annotation.pageUrl;
|
|
@@ -1576,6 +1651,7 @@
|
|
|
1576
1651
|
type: "upsert",
|
|
1577
1652
|
clientId: annotation.clientId,
|
|
1578
1653
|
revision: annotation.revision,
|
|
1654
|
+
baseRevision: annotation.serverRevision,
|
|
1579
1655
|
syncState: "pending",
|
|
1580
1656
|
annotation: this._desiredState(annotation),
|
|
1581
1657
|
dirtyFields: annotation.dirtyFields.slice()
|
|
@@ -1606,10 +1682,17 @@
|
|
|
1606
1682
|
const annotation = this.annotations.find(record => record.clientId === clientId);
|
|
1607
1683
|
const candidateRevision = Number.isInteger(candidate.revision) && candidate.revision >= 0 ? candidate.revision : 0;
|
|
1608
1684
|
const annotationRevision = Number.isInteger(annotation?.revision) && annotation.revision >= 0 ? annotation.revision : 0;
|
|
1685
|
+
const candidateBaseRevision = Number.isInteger(candidate.baseRevision) && candidate.baseRevision >= 0
|
|
1686
|
+
? candidate.baseRevision
|
|
1687
|
+
: 0;
|
|
1688
|
+
const annotationBaseRevision = Number.isInteger(annotation?.serverRevision) && annotation.serverRevision >= 0
|
|
1689
|
+
? annotation.serverRevision
|
|
1690
|
+
: 0;
|
|
1609
1691
|
const envelope = Object.assign({}, candidate, {
|
|
1610
1692
|
type,
|
|
1611
1693
|
clientId,
|
|
1612
1694
|
revision: Math.max(candidateRevision, annotationRevision),
|
|
1695
|
+
baseRevision: Math.max(candidateBaseRevision, annotationBaseRevision),
|
|
1613
1696
|
syncState: candidate.syncState === "failed" ? "failed" : "pending"
|
|
1614
1697
|
});
|
|
1615
1698
|
|
|
@@ -1823,6 +1906,7 @@
|
|
|
1823
1906
|
if (!annotation) return;
|
|
1824
1907
|
entry.annotation = this._desiredState(annotation);
|
|
1825
1908
|
entry.dirtyFields = (annotation.dirtyFields || []).slice();
|
|
1909
|
+
entry.baseRevision = Number.isInteger(annotation.serverRevision) ? annotation.serverRevision : 0;
|
|
1826
1910
|
});
|
|
1827
1911
|
});
|
|
1828
1912
|
if (!committed) return false;
|
|
@@ -1856,6 +1940,7 @@
|
|
|
1856
1940
|
annotation.pathname = server.pageUrl;
|
|
1857
1941
|
}
|
|
1858
1942
|
annotation.serverId = server.id;
|
|
1943
|
+
annotation.serverRevision = server.revision;
|
|
1859
1944
|
annotation.userId = server.userId;
|
|
1860
1945
|
annotation.authorName = server.authorName;
|
|
1861
1946
|
annotation.createdAt = server.createdAt;
|
|
@@ -1871,6 +1956,7 @@
|
|
|
1871
1956
|
id: null,
|
|
1872
1957
|
clientId: server.clientId,
|
|
1873
1958
|
serverId: server.id,
|
|
1959
|
+
serverRevision: server.revision,
|
|
1874
1960
|
userId: server.userId,
|
|
1875
1961
|
authorName: server.authorName,
|
|
1876
1962
|
syncState: "synced",
|
|
@@ -1893,6 +1979,9 @@
|
|
|
1893
1979
|
},
|
|
1894
1980
|
|
|
1895
1981
|
_serverRepresentationIsStale(annotation, server) {
|
|
1982
|
+
if (Number.isInteger(annotation.serverRevision) && Number.isInteger(server.revision)) {
|
|
1983
|
+
return server.revision < annotation.serverRevision;
|
|
1984
|
+
}
|
|
1896
1985
|
const localTimestamp = Date.parse(annotation.serverUpdatedAt || "");
|
|
1897
1986
|
const serverTimestamp = Date.parse(server.updatedAt || "");
|
|
1898
1987
|
return Number.isFinite(localTimestamp) && Number.isFinite(serverTimestamp) && serverTimestamp < localTimestamp;
|
|
@@ -59,23 +59,29 @@ module RailsMarkup
|
|
|
59
59
|
|
|
60
60
|
dirty_fields = normalized_dirty_fields
|
|
61
61
|
return render_invalid_dirty_fields unless dirty_fields
|
|
62
|
+
base_revision = normalized_base_revision
|
|
63
|
+
return render_invalid_base_revision unless base_revision
|
|
62
64
|
|
|
63
65
|
attributes = browser_attributes
|
|
64
66
|
return render_invalid_status if dirty_fields.include?("status") && !Annotation::STATUSES.include?(attributes["status"])
|
|
65
67
|
|
|
66
68
|
annotation = Annotation.find_or_initialize_by(client_uuid: client_uuid)
|
|
67
69
|
created = annotation.new_record?
|
|
68
|
-
|
|
69
|
-
annotation.save!
|
|
70
|
+
save_browser_state!(annotation, attributes, dirty_fields, base_revision)
|
|
70
71
|
fire_create_callback(annotation) if created
|
|
71
72
|
render json: annotation.as_api_json
|
|
73
|
+
rescue Annotation::RevisionConflict => error
|
|
74
|
+
render_revision_conflict(error)
|
|
72
75
|
rescue ActiveRecord::RecordInvalid => error
|
|
73
76
|
render json: { errors: error.record.errors.full_messages }, status: :unprocessable_entity
|
|
74
77
|
rescue ActiveRecord::RecordNotUnique
|
|
75
78
|
annotation = Annotation.find_by!(client_uuid: client_uuid)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
begin
|
|
80
|
+
save_browser_state!(annotation, attributes, dirty_fields, base_revision)
|
|
81
|
+
render json: annotation.as_api_json
|
|
82
|
+
rescue Annotation::RevisionConflict => error
|
|
83
|
+
render_revision_conflict(error)
|
|
84
|
+
end
|
|
79
85
|
end
|
|
80
86
|
|
|
81
87
|
# DELETE /feedback/api/annotations/:client_uuid
|
|
@@ -183,9 +189,25 @@ module RailsMarkup
|
|
|
183
189
|
permitted.to_h.stringify_keys
|
|
184
190
|
end
|
|
185
191
|
|
|
186
|
-
def
|
|
187
|
-
|
|
188
|
-
|
|
192
|
+
def save_browser_state!(annotation, attributes, dirty_fields, base_revision)
|
|
193
|
+
if annotation.new_record?
|
|
194
|
+
assign_current_user(annotation)
|
|
195
|
+
annotation.apply_browser_state(
|
|
196
|
+
attributes,
|
|
197
|
+
dirty_fields: dirty_fields,
|
|
198
|
+
base_revision: base_revision
|
|
199
|
+
)
|
|
200
|
+
annotation.save!
|
|
201
|
+
else
|
|
202
|
+
annotation.with_lock do
|
|
203
|
+
annotation.apply_browser_state(
|
|
204
|
+
attributes,
|
|
205
|
+
dirty_fields: dirty_fields,
|
|
206
|
+
base_revision: base_revision
|
|
207
|
+
)
|
|
208
|
+
annotation.save!
|
|
209
|
+
end
|
|
210
|
+
end
|
|
189
211
|
end
|
|
190
212
|
|
|
191
213
|
def normalized_route_uuid
|
|
@@ -201,6 +223,11 @@ module RailsMarkup
|
|
|
201
223
|
fields if (fields - ALLOWED_DIRTY_FIELDS).empty?
|
|
202
224
|
end
|
|
203
225
|
|
|
226
|
+
def normalized_base_revision
|
|
227
|
+
revision = params[:baseRevision]
|
|
228
|
+
revision if revision.is_a?(Integer) && revision >= 0
|
|
229
|
+
end
|
|
230
|
+
|
|
204
231
|
def client_supplied_author?
|
|
205
232
|
metadata = params[:metadata]
|
|
206
233
|
metadata.respond_to?(:key?) && (metadata.key?(:author) || metadata.key?("author"))
|
|
@@ -226,6 +253,17 @@ module RailsMarkup
|
|
|
226
253
|
render json: { error: "invalid status" }, status: :unprocessable_entity
|
|
227
254
|
end
|
|
228
255
|
|
|
256
|
+
def render_invalid_base_revision
|
|
257
|
+
render json: { error: "base revision must be a non-negative integer" }, status: :unprocessable_entity
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def render_revision_conflict(error)
|
|
261
|
+
render json: {
|
|
262
|
+
error: "revision conflict",
|
|
263
|
+
annotation: error.annotation.as_api_json
|
|
264
|
+
}, status: :conflict
|
|
265
|
+
end
|
|
266
|
+
|
|
229
267
|
def normalize_target(target)
|
|
230
268
|
case target
|
|
231
269
|
when String then { "selector" => target }
|
|
@@ -61,7 +61,10 @@ module RailsMarkup
|
|
|
61
61
|
return if Rails.env.development? && !RailsMarkup.config.require_api_token_in_development
|
|
62
62
|
|
|
63
63
|
token = RailsMarkup.config.api_token
|
|
64
|
-
|
|
64
|
+
# Treat a nil OR blank token as "external API disabled" — otherwise a
|
|
65
|
+
# token of "" would make secure_compare("", "") true and authenticate
|
|
66
|
+
# every request (including ones with no Authorization header).
|
|
67
|
+
return head(:not_found) if token.nil? || token.to_s.strip.empty?
|
|
65
68
|
|
|
66
69
|
provided = request.headers["Authorization"]&.delete_prefix("Bearer ")
|
|
67
70
|
head(:unauthorized) unless ActiveSupport::SecurityUtils.secure_compare(provided.to_s, token)
|
|
@@ -5,6 +5,15 @@ require "digest/sha1"
|
|
|
5
5
|
|
|
6
6
|
module RailsMarkup
|
|
7
7
|
class Annotation < ActiveRecord::Base
|
|
8
|
+
class RevisionConflict < StandardError
|
|
9
|
+
attr_reader :annotation
|
|
10
|
+
|
|
11
|
+
def initialize(annotation)
|
|
12
|
+
@annotation = annotation
|
|
13
|
+
super("annotation revision conflict")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
8
17
|
self.table_name = RailsMarkup.config.table_name
|
|
9
18
|
|
|
10
19
|
INTENTS = %w[fix change question approve].freeze
|
|
@@ -99,18 +108,33 @@ module RailsMarkup
|
|
|
99
108
|
metadata&.dig("author")
|
|
100
109
|
end
|
|
101
110
|
|
|
102
|
-
def apply_browser_state(attributes, dirty_fields:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
111
|
+
def apply_browser_state(attributes, dirty_fields:, base_revision:)
|
|
112
|
+
raise RevisionConflict, self unless base_revision == revision
|
|
113
|
+
|
|
114
|
+
dirty_fields.each do |field|
|
|
115
|
+
if BROWSER_ATTRIBUTES.include?(field)
|
|
116
|
+
public_send("#{field}=", attributes[field]) if attributes.key?(field)
|
|
117
|
+
elsif field == "metadata" && attributes.key?("metadata")
|
|
118
|
+
self.metadata = (metadata || {}).merge(attributes["metadata"].slice(*BROWSER_METADATA_KEYS))
|
|
119
|
+
elsif field == "status" && attributes.key?("status")
|
|
120
|
+
self.status = attributes["status"]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
self.revision += 1 if changed?
|
|
106
124
|
self
|
|
107
125
|
end
|
|
108
126
|
|
|
109
127
|
def acknowledge!
|
|
110
|
-
|
|
111
|
-
|
|
128
|
+
# Lock + reload so a concurrent resolve!/dismiss! can't be clobbered:
|
|
129
|
+
# without it, an acknowledge validated against a stale "pending" could
|
|
130
|
+
# write "acknowledged" over an already-resolved record.
|
|
131
|
+
with_lock do
|
|
132
|
+
return self if status == "acknowledged" # idempotent — re-acknowledging is a no-op
|
|
133
|
+
raise "Cannot acknowledge a #{status} annotation" unless status == "pending"
|
|
112
134
|
|
|
113
|
-
|
|
135
|
+
update!(status: "acknowledged", revision: revision + 1)
|
|
136
|
+
end
|
|
137
|
+
self
|
|
114
138
|
end
|
|
115
139
|
|
|
116
140
|
def resolve!(summary: nil)
|
|
@@ -121,7 +145,7 @@ module RailsMarkup
|
|
|
121
145
|
raise "Cannot resolve a #{status} annotation" unless status.in?(%w[pending acknowledged])
|
|
122
146
|
|
|
123
147
|
add_thread_entry(role: "agent", message: summary) if summary.present?
|
|
124
|
-
update!(status: "resolved")
|
|
148
|
+
update!(status: "resolved", revision: revision + 1)
|
|
125
149
|
end
|
|
126
150
|
self
|
|
127
151
|
end
|
|
@@ -132,7 +156,7 @@ module RailsMarkup
|
|
|
132
156
|
raise "Cannot dismiss a #{status} annotation" unless status.in?(%w[pending acknowledged])
|
|
133
157
|
|
|
134
158
|
add_thread_entry(role: "agent", message: reason) if reason.present?
|
|
135
|
-
update!(status: "dismissed")
|
|
159
|
+
update!(status: "dismissed", revision: revision + 1)
|
|
136
160
|
end
|
|
137
161
|
self
|
|
138
162
|
end
|
|
@@ -140,6 +164,7 @@ module RailsMarkup
|
|
|
140
164
|
def add_reply!(message:, role: "agent")
|
|
141
165
|
with_lock do
|
|
142
166
|
add_thread_entry(role: role, message: message)
|
|
167
|
+
self.revision += 1
|
|
143
168
|
save!
|
|
144
169
|
end
|
|
145
170
|
self
|
|
@@ -161,7 +186,8 @@ module RailsMarkup
|
|
|
161
186
|
metadata: metadata,
|
|
162
187
|
thread: thread,
|
|
163
188
|
createdAt: created_at&.iso8601,
|
|
164
|
-
updatedAt: updated_at&.iso8601
|
|
189
|
+
updatedAt: updated_at&.iso8601,
|
|
190
|
+
revision: revision
|
|
165
191
|
}
|
|
166
192
|
end
|
|
167
193
|
|
|
@@ -25,6 +25,14 @@
|
|
|
25
25
|
};
|
|
26
26
|
// Init immediately (DOM is ready — script is at end of body)
|
|
27
27
|
RailsMarkupToolbar.init(opts);
|
|
28
|
+
// Tear down before Turbo snapshots the page for its cache — otherwise the
|
|
29
|
+
// cached DOM keeps a toolbar root with no live listeners, and on restore
|
|
30
|
+
// init() would early-return on that dead root instead of rebinding.
|
|
31
|
+
document.addEventListener("turbo:before-cache", function() {
|
|
32
|
+
if (window.RailsMarkupToolbar && typeof RailsMarkupToolbar.destroy === "function") {
|
|
33
|
+
RailsMarkupToolbar.destroy();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
28
36
|
// After Turbo Drive navigations (DOMContentLoaded won't fire again), only
|
|
29
37
|
// re-init when the new page is still authorized (gate sentinel present);
|
|
30
38
|
// otherwise tear the toolbar down so it can't persist past a logout.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddRevisionToRailsMarkupAnnotations < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
table = RailsMarkup.config.table_name
|
|
6
|
+
return unless table_exists?(table)
|
|
7
|
+
return if column_exists?(table, :revision)
|
|
8
|
+
|
|
9
|
+
add_column table, :revision, :integer, null: false, default: 0
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -18,6 +18,7 @@ class CreateRailsMarkupAnnotations < ActiveRecord::Migration<%= migration_versio
|
|
|
18
18
|
t.send json_type, :metadata, default: {}
|
|
19
19
|
t.send json_type, :thread, default: []
|
|
20
20
|
t.string :client_uuid, limit: 64, null: false
|
|
21
|
+
t.integer :revision, null: false, default: 0
|
|
21
22
|
|
|
22
23
|
t.timestamps
|
|
23
24
|
end
|
|
@@ -28,6 +29,10 @@ class CreateRailsMarkupAnnotations < ActiveRecord::Migration<%= migration_versio
|
|
|
28
29
|
if connection.adapter_name.downcase.include?("mysql")
|
|
29
30
|
add_index :<%= options[:table_name] %>, :page_url, length: 191
|
|
30
31
|
else
|
|
32
|
+
# PostgreSQL indexes the full column so `where(page_url:)` lookups stay
|
|
33
|
+
# fast. A btree entry tops out near 2704 bytes, so a pathological
|
|
34
|
+
# multibyte URL approaching the 2048-char cap could exceed it; real URLs
|
|
35
|
+
# are far shorter. Switch to an expression index if that ever bites you.
|
|
31
36
|
add_index :<%= options[:table_name] %>, :page_url
|
|
32
37
|
end
|
|
33
38
|
add_index :<%= options[:table_name] %>, :user_id
|
|
@@ -21,6 +21,20 @@ module RailsMarkup
|
|
|
21
21
|
class_option :table_name, type: :string, default: "rails_markup_annotations",
|
|
22
22
|
desc: "Database table name for annotations (must match config.table_name)"
|
|
23
23
|
|
|
24
|
+
# Conservative SQL identifier: leading letter/underscore, then letters,
|
|
25
|
+
# digits, underscores. Blocks names that would produce invalid Ruby/SQL or
|
|
26
|
+
# inject into the migration/initializer templates (e.g. "feedback-items",
|
|
27
|
+
# quotes, newlines).
|
|
28
|
+
TABLE_NAME_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
|
|
29
|
+
|
|
30
|
+
def validate_table_name
|
|
31
|
+
return if options[:table_name].match?(TABLE_NAME_PATTERN)
|
|
32
|
+
|
|
33
|
+
raise Thor::Error,
|
|
34
|
+
"Invalid --table-name #{options[:table_name].inspect}: use only letters, " \
|
|
35
|
+
"digits, and underscores, starting with a letter or underscore."
|
|
36
|
+
end
|
|
37
|
+
|
|
24
38
|
def copy_migration
|
|
25
39
|
migration_template "create_rails_markup_annotations.rb.erb",
|
|
26
40
|
"db/migrate/create_rails_markup_annotations.rb"
|
|
@@ -76,7 +90,10 @@ module RailsMarkup
|
|
|
76
90
|
# Upgrade the pre-1.2.3 partial-existence gate, which rendered the
|
|
77
91
|
# toolbar for every visitor, to the admin-gated block. Leave any
|
|
78
92
|
# custom (hand-edited) block untouched so we don't clobber it.
|
|
79
|
-
|
|
93
|
+
# Match the exact historical generated block (allowing ERB trim tags)
|
|
94
|
+
# — including its own render line — so we never swallow a hand-written
|
|
95
|
+
# block that merely happens to contain lookup_context…end.
|
|
96
|
+
legacy = /^[ \t]*<%#\s*Rails Markup annotation toolbar\s*%>\r?\n[ \t]*<%-?\s*if\s+lookup_context\.exists\?\("rails_markup\/shared\/toolbar".*?-?%>\r?\n[ \t]*<%=\s*render\s+"rails_markup\/shared\/toolbar"\s*-?%>\r?\n[ \t]*<%-?\s*end\s*-?%>\r?\n?/m
|
|
80
97
|
if content =~ legacy
|
|
81
98
|
gsub_file layout_path, legacy, toolbar_block
|
|
82
99
|
say_status :update, "upgraded toolbar to admin-gated render in #{layout_path}", :green
|
data/lib/rails_markup/cli.rb
CHANGED
|
@@ -643,29 +643,30 @@ module RailsMarkup
|
|
|
643
643
|
"local"
|
|
644
644
|
end
|
|
645
645
|
|
|
646
|
-
#
|
|
647
|
-
#
|
|
648
|
-
#
|
|
649
|
-
#
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
McpConfig::SCOPES.
|
|
646
|
+
# Return the raw env of the highest-precedence scope (local → global → codex)
|
|
647
|
+
# that defines any of the given keys. URL, token, and mount are always read
|
|
648
|
+
# from this single scope so a token is never paired with a URL from another
|
|
649
|
+
# scope (credential provenance). A local config that only defines dev values
|
|
650
|
+
# is skipped for production lookups, so it neither shadows nor captures the
|
|
651
|
+
# production credentials stored globally/in codex.
|
|
652
|
+
def scoped_env(*keys)
|
|
653
|
+
McpConfig::SCOPES.each do |scope|
|
|
654
654
|
config = McpConfig.new(scope: scope)
|
|
655
655
|
next unless config.exist?
|
|
656
656
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
config.raw_env.each do |key, value|
|
|
660
|
-
merged[key] = value unless value.to_s.strip.empty?
|
|
661
|
-
end
|
|
657
|
+
env = config.raw_env
|
|
658
|
+
return env if keys.any? { |k| env[k].to_s.strip != "" }
|
|
662
659
|
end
|
|
663
660
|
|
|
664
|
-
|
|
661
|
+
{}
|
|
665
662
|
end
|
|
666
663
|
|
|
667
664
|
def resolve_env(production)
|
|
668
|
-
mcp_env =
|
|
665
|
+
mcp_env = if production
|
|
666
|
+
scoped_env("RAILS_MARKUP_PROD_URL", "RAILS_MARKUP_PROD_TOKEN")
|
|
667
|
+
else
|
|
668
|
+
scoped_env("RAILS_MARKUP_DEV_URL", "RAILS_MARKUP_DEV_TOKEN")
|
|
669
|
+
end
|
|
669
670
|
|
|
670
671
|
if production
|
|
671
672
|
base_url = options[:url] || mcp_env["RAILS_MARKUP_PROD_URL"]
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "webrick"
|
|
4
4
|
require "json"
|
|
5
|
+
require "ipaddr"
|
|
6
|
+
require "uri"
|
|
5
7
|
|
|
6
8
|
module RailsMarkup
|
|
7
9
|
# HTTP server providing REST API + SSE for the browser-side annotation controller.
|
|
@@ -52,17 +54,17 @@ module RailsMarkup
|
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
def do_OPTIONS(req, res)
|
|
55
|
-
cors(res)
|
|
57
|
+
cors(req, res)
|
|
56
58
|
res.status = 204
|
|
57
59
|
end
|
|
58
60
|
|
|
59
61
|
def do_GET(req, res)
|
|
60
|
-
cors(res)
|
|
62
|
+
cors(req, res)
|
|
61
63
|
route(req, res)
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
def do_POST(req, res)
|
|
65
|
-
cors(res)
|
|
67
|
+
cors(req, res)
|
|
66
68
|
route(req, res)
|
|
67
69
|
end
|
|
68
70
|
|
|
@@ -161,6 +163,10 @@ module RailsMarkup
|
|
|
161
163
|
return not_found(res) unless annotation
|
|
162
164
|
|
|
163
165
|
json_response(res, @store.serialize_annotation(annotation), status: 201)
|
|
166
|
+
rescue Store::ValidationError => error
|
|
167
|
+
json_response(res, { error: error.message }, status: 422)
|
|
168
|
+
rescue Store::CapacityError => error
|
|
169
|
+
json_response(res, { error: error.message }, status: 507)
|
|
164
170
|
end
|
|
165
171
|
|
|
166
172
|
# --- SSE ---
|
|
@@ -173,8 +179,6 @@ module RailsMarkup
|
|
|
173
179
|
res["Content-Type"] = "text/event-stream"
|
|
174
180
|
res["Cache-Control"] = "no-cache"
|
|
175
181
|
res["Connection"] = "keep-alive"
|
|
176
|
-
res["Access-Control-Allow-Origin"] = "*"
|
|
177
|
-
|
|
178
182
|
res.chunked = true
|
|
179
183
|
res.body = proc do |out|
|
|
180
184
|
sub = @store.subscribe(session_id) do |data|
|
|
@@ -200,11 +204,26 @@ module RailsMarkup
|
|
|
200
204
|
|
|
201
205
|
# --- Helpers ---
|
|
202
206
|
|
|
203
|
-
def cors(res)
|
|
204
|
-
|
|
205
|
-
res["Access-Control-Allow-Origin"]
|
|
207
|
+
def cors(req, res)
|
|
208
|
+
origin = req["Origin"]
|
|
209
|
+
res["Access-Control-Allow-Origin"] = origin if loopback_origin?(origin)
|
|
206
210
|
res["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
|
207
211
|
res["Access-Control-Allow-Headers"] = "Content-Type"
|
|
212
|
+
res["Vary"] = "Origin"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def loopback_origin?(origin)
|
|
216
|
+
return false unless origin.is_a?(String)
|
|
217
|
+
|
|
218
|
+
uri = URI.parse(origin)
|
|
219
|
+
return false unless %w[http https].include?(uri.scheme)
|
|
220
|
+
return false if uri.host.nil? || uri.userinfo || uri.query || uri.fragment
|
|
221
|
+
return false unless uri.path.empty?
|
|
222
|
+
return true if uri.host.casecmp?("localhost")
|
|
223
|
+
|
|
224
|
+
IPAddr.new(uri.host).loopback?
|
|
225
|
+
rescue URI::InvalidURIError, IPAddr::InvalidAddressError
|
|
226
|
+
false
|
|
208
227
|
end
|
|
209
228
|
|
|
210
229
|
def json_response(res, data, status: 200)
|
|
@@ -79,7 +79,11 @@ module RailsMarkup
|
|
|
79
79
|
data
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
def supports_subscriptions?
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Watch is rejected by McpServer before subscription in proxy mode.
|
|
83
87
|
def subscribe(_session_id = nil, &_block)
|
|
84
88
|
nil
|
|
85
89
|
end
|
|
@@ -589,6 +589,10 @@ module RailsMarkup
|
|
|
589
589
|
# ── Watch mode ────────────────────────────────────────────
|
|
590
590
|
|
|
591
591
|
def handle_watch(args)
|
|
592
|
+
unless @store.respond_to?(:supports_subscriptions?) && @store.supports_subscriptions?
|
|
593
|
+
raise ToolError, "Watch is unsupported in HTTP proxy (mcp-only) mode; poll with rails_markup_read instead."
|
|
594
|
+
end
|
|
595
|
+
|
|
592
596
|
sub = nil
|
|
593
597
|
timeout = [args["timeoutSeconds"]&.to_i || 120, 300].min
|
|
594
598
|
batch_window = [args["batchWindowSeconds"]&.to_i || 10, 60].min
|
data/lib/rails_markup/store.rb
CHANGED
|
@@ -7,20 +7,34 @@ module RailsMarkup
|
|
|
7
7
|
# In-memory store for sessions and annotations.
|
|
8
8
|
# Ephemeral by design — data lives for one coding session.
|
|
9
9
|
class Store
|
|
10
|
+
class ValidationError < StandardError; end
|
|
11
|
+
class CapacityError < StandardError; end
|
|
12
|
+
|
|
10
13
|
Session = Struct.new(:id, :url, :metadata, :created_at, :annotations, keyword_init: true)
|
|
11
14
|
Annotation = Struct.new(:id, :session_id, :target, :content, :intent, :severity, :status,
|
|
12
15
|
:selected_text, :metadata, :created_at, :thread, keyword_init: true)
|
|
13
16
|
|
|
14
17
|
MAX_SESSIONS = 100
|
|
15
18
|
SESSION_TTL = 4 * 3600 # 4 hours
|
|
19
|
+
MAX_ANNOTATIONS_PER_SESSION = 1_000
|
|
20
|
+
MAX_ANNOTATION_BYTES = 25_000_000
|
|
21
|
+
MAX_CONTENT_BYTES = 5_000
|
|
22
|
+
MAX_TARGET_BYTES = 16_384
|
|
23
|
+
MAX_SELECTED_TEXT_BYTES = 2_000
|
|
24
|
+
MAX_METADATA_BYTES = 65_536
|
|
25
|
+
INTENTS = %w[fix change question approve].freeze
|
|
26
|
+
SEVERITIES = %w[suggestion important blocking].freeze
|
|
16
27
|
|
|
17
28
|
attr_reader :sessions
|
|
18
29
|
|
|
19
|
-
def initialize
|
|
30
|
+
def initialize(max_annotations_per_session: MAX_ANNOTATIONS_PER_SESSION,
|
|
31
|
+
max_annotation_bytes: MAX_ANNOTATION_BYTES)
|
|
20
32
|
@sessions = {}
|
|
21
33
|
@annotations_index = {} # id -> annotation (O(1) lookup)
|
|
22
34
|
@subscribers = [] # SSE callbacks: [session_id, callback]
|
|
23
35
|
@mutex = Mutex.new
|
|
36
|
+
@max_annotations_per_session = max_annotations_per_session
|
|
37
|
+
@max_annotation_bytes = max_annotation_bytes
|
|
24
38
|
end
|
|
25
39
|
|
|
26
40
|
# --- Sessions ---
|
|
@@ -53,6 +67,9 @@ module RailsMarkup
|
|
|
53
67
|
|
|
54
68
|
def create_annotation(session_id:, target:, content:, intent: "change", severity: "suggestion",
|
|
55
69
|
selected_text: nil, metadata: {})
|
|
70
|
+
annotation_bytes = validate_annotation!(
|
|
71
|
+
target:, content:, intent:, severity:, selected_text:, metadata:
|
|
72
|
+
)
|
|
56
73
|
id = SecureRandom.hex(8)
|
|
57
74
|
annotation = Annotation.new(
|
|
58
75
|
id: id,
|
|
@@ -73,6 +90,7 @@ module RailsMarkup
|
|
|
73
90
|
session = @sessions[session_id]
|
|
74
91
|
return nil unless session
|
|
75
92
|
|
|
93
|
+
enforce_session_capacity!(session, annotation_bytes)
|
|
76
94
|
session.annotations << annotation
|
|
77
95
|
@annotations_index[id] = annotation
|
|
78
96
|
end
|
|
@@ -148,6 +166,10 @@ module RailsMarkup
|
|
|
148
166
|
@mutex.synchronize { @subscribers.delete(sub) }
|
|
149
167
|
end
|
|
150
168
|
|
|
169
|
+
def supports_subscriptions?
|
|
170
|
+
true
|
|
171
|
+
end
|
|
172
|
+
|
|
151
173
|
# --- Serialization ---
|
|
152
174
|
|
|
153
175
|
def serialize_session(session)
|
|
@@ -188,18 +210,81 @@ module RailsMarkup
|
|
|
188
210
|
end
|
|
189
211
|
|
|
190
212
|
def notify(session_id, data)
|
|
213
|
+
subscribers = @mutex.synchronize do
|
|
214
|
+
@subscribers.select { |sid, _callback| sid.nil? || sid == session_id }
|
|
215
|
+
end
|
|
191
216
|
dead = []
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
217
|
+
subscribers.each do |sub|
|
|
218
|
+
_sid, callback = sub
|
|
219
|
+
callback.call(data)
|
|
220
|
+
rescue StandardError
|
|
221
|
+
dead << sub
|
|
222
|
+
end
|
|
223
|
+
@mutex.synchronize { dead.each { |sub| @subscribers.delete(sub) } } unless dead.empty?
|
|
224
|
+
end
|
|
196
225
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
226
|
+
def validate_annotation!(target:, content:, intent:, severity:, selected_text:, metadata:)
|
|
227
|
+
validate_target!(target)
|
|
228
|
+
validate_string!("content", content, maximum: MAX_CONTENT_BYTES)
|
|
229
|
+
validate_optional_string!("selected_text", selected_text, maximum: MAX_SELECTED_TEXT_BYTES)
|
|
230
|
+
raise ValidationError, "intent is invalid" unless INTENTS.include?(intent)
|
|
231
|
+
raise ValidationError, "severity is invalid" unless SEVERITIES.include?(severity)
|
|
232
|
+
raise ValidationError, "metadata must be an object" unless metadata.nil? || metadata.is_a?(Hash)
|
|
233
|
+
|
|
234
|
+
metadata_bytes = JSON.generate(metadata || {}).bytesize
|
|
235
|
+
raise ValidationError, "metadata exceeds #{MAX_METADATA_BYTES} bytes" if metadata_bytes > MAX_METADATA_BYTES
|
|
236
|
+
|
|
237
|
+
JSON.generate(
|
|
238
|
+
target:, content:, intent:, severity:, selected_text:, metadata: metadata || {}
|
|
239
|
+
).bytesize
|
|
240
|
+
rescue JSON::GeneratorError, Encoding::UndefinedConversionError
|
|
241
|
+
raise ValidationError, "annotation fields must be JSON serializable"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def validate_string!(name, value, maximum:)
|
|
245
|
+
raise ValidationError, "#{name} must be a non-empty string" unless value.is_a?(String) && !value.empty?
|
|
246
|
+
raise ValidationError, "#{name} exceeds #{maximum} bytes" if value.bytesize > maximum
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def validate_target!(target)
|
|
250
|
+
if target.is_a?(String)
|
|
251
|
+
return validate_string!("target", target, maximum: MAX_TARGET_BYTES)
|
|
202
252
|
end
|
|
253
|
+
raise ValidationError, "target must be a non-empty string or object" unless target.is_a?(Hash)
|
|
254
|
+
|
|
255
|
+
target_bytes = JSON.generate(target).bytesize
|
|
256
|
+
raise ValidationError, "target exceeds #{MAX_TARGET_BYTES} bytes" if target_bytes > MAX_TARGET_BYTES
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def validate_optional_string!(name, value, maximum:)
|
|
260
|
+
return if value.nil?
|
|
261
|
+
|
|
262
|
+
raise ValidationError, "#{name} must be a string" unless value.is_a?(String)
|
|
263
|
+
raise ValidationError, "#{name} exceeds #{maximum} bytes" if value.bytesize > maximum
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def enforce_session_capacity!(session, incoming_bytes)
|
|
267
|
+
if session.annotations.length >= @max_annotations_per_session
|
|
268
|
+
raise CapacityError, "session annotation limit of #{@max_annotations_per_session} reached"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
current_bytes = @sessions.values.sum do |stored_session|
|
|
272
|
+
stored_session.annotations.sum { |annotation| annotation_storage_bytes(annotation) }
|
|
273
|
+
end
|
|
274
|
+
return if current_bytes + incoming_bytes <= @max_annotation_bytes
|
|
275
|
+
|
|
276
|
+
raise CapacityError, "aggregate annotation byte limit of #{@max_annotation_bytes} reached"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def annotation_storage_bytes(annotation)
|
|
280
|
+
JSON.generate(
|
|
281
|
+
target: annotation.target,
|
|
282
|
+
content: annotation.content,
|
|
283
|
+
intent: annotation.intent,
|
|
284
|
+
severity: annotation.severity,
|
|
285
|
+
selected_text: annotation.selected_text,
|
|
286
|
+
metadata: annotation.metadata
|
|
287
|
+
).bytesize
|
|
203
288
|
end
|
|
204
289
|
|
|
205
290
|
def evict_stale_sessions
|
data/lib/rails_markup/version.rb
CHANGED
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
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- InventList
|
|
@@ -139,6 +139,7 @@ files:
|
|
|
139
139
|
- config/routes.rb
|
|
140
140
|
- db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb
|
|
141
141
|
- db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb
|
|
142
|
+
- db/migrate/20260726000000_add_revision_to_rails_markup_annotations.rb
|
|
142
143
|
- lib/generators/rails_markup/install/templates/auth_controller.rb.erb
|
|
143
144
|
- lib/generators/rails_markup/install/templates/bin_markup.erb
|
|
144
145
|
- lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb
|