rails-markup 1.3.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 +177 -7
- data/app/controllers/rails_markup/annotations_controller.rb +60 -9
- data/app/controllers/rails_markup/dashboard_controller.rb +9 -2
- data/app/controllers/rails_markup/external/annotations_controller.rb +4 -1
- data/app/models/rails_markup/annotation.rb +51 -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 +25 -3
- data/lib/rails_markup/store.rb +160 -26
- 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: 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")) {
|
|
@@ -714,6 +716,7 @@
|
|
|
714
716
|
id: this.nextId,
|
|
715
717
|
clientId: this._newClientId(),
|
|
716
718
|
serverId: null,
|
|
719
|
+
serverRevision: 0,
|
|
717
720
|
syncState: "pending",
|
|
718
721
|
serverUpdatedAt: null,
|
|
719
722
|
dirtyFields: [],
|
|
@@ -1077,12 +1080,16 @@
|
|
|
1077
1080
|
_queueLocalMutation(type, annotation, dirtyFields) {
|
|
1078
1081
|
const currentEntry = this.outbox[annotation.clientId];
|
|
1079
1082
|
const revision = Math.max(annotation.revision || 0, currentEntry?.revision || 0) + 1;
|
|
1083
|
+
const baseRevision = Number.isInteger(currentEntry?.baseRevision)
|
|
1084
|
+
? currentEntry.baseRevision
|
|
1085
|
+
: (Number.isInteger(annotation.serverRevision) ? annotation.serverRevision : 0);
|
|
1080
1086
|
|
|
1081
1087
|
if (type === "delete") {
|
|
1082
1088
|
this.outbox[annotation.clientId] = {
|
|
1083
1089
|
type: "delete",
|
|
1084
1090
|
clientId: annotation.clientId,
|
|
1085
1091
|
revision,
|
|
1092
|
+
baseRevision,
|
|
1086
1093
|
syncState: "pending"
|
|
1087
1094
|
};
|
|
1088
1095
|
} else {
|
|
@@ -1094,6 +1101,7 @@
|
|
|
1094
1101
|
type: "upsert",
|
|
1095
1102
|
clientId: annotation.clientId,
|
|
1096
1103
|
revision,
|
|
1104
|
+
baseRevision,
|
|
1097
1105
|
syncState: "pending",
|
|
1098
1106
|
annotation: this._desiredState(annotation),
|
|
1099
1107
|
dirtyFields: annotation.dirtyFields.slice()
|
|
@@ -1208,6 +1216,16 @@
|
|
|
1208
1216
|
this._markSyncFailed(snapshot);
|
|
1209
1217
|
continue;
|
|
1210
1218
|
}
|
|
1219
|
+
if (result.kind === "conflict") {
|
|
1220
|
+
const resolution = this._reconcileSyncConflict(snapshot, result);
|
|
1221
|
+
await this._pullAnnotations();
|
|
1222
|
+
if (resolution === "retry" && this.outbox[clientId]) {
|
|
1223
|
+
clientIds.push(clientId);
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
this._resetSyncRetry();
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1211
1229
|
if (result.kind === "malformed") {
|
|
1212
1230
|
if (!this._outboxEntryMatches(snapshot)) {
|
|
1213
1231
|
clientIds.push(clientId);
|
|
@@ -1235,8 +1253,15 @@
|
|
|
1235
1253
|
redirect: "manual",
|
|
1236
1254
|
signal: AbortSignal.timeout(5000)
|
|
1237
1255
|
};
|
|
1238
|
-
if (snapshot.type === "
|
|
1239
|
-
options.body = JSON.stringify(
|
|
1256
|
+
if (snapshot.type === "delete") {
|
|
1257
|
+
options.body = JSON.stringify({
|
|
1258
|
+
baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
|
|
1259
|
+
});
|
|
1260
|
+
} else {
|
|
1261
|
+
options.body = JSON.stringify(Object.assign({}, snapshot.annotation, {
|
|
1262
|
+
dirtyFields: snapshot.dirtyFields || [],
|
|
1263
|
+
baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
|
|
1264
|
+
}));
|
|
1240
1265
|
}
|
|
1241
1266
|
|
|
1242
1267
|
const response = await fetch(request.url, options);
|
|
@@ -1274,6 +1299,7 @@
|
|
|
1274
1299
|
if ([408, 425, 429].includes(status) || status >= 500) {
|
|
1275
1300
|
return { kind: "retryable", retryAfter: this._retryAfterDelay(response) };
|
|
1276
1301
|
}
|
|
1302
|
+
if (status === 409) return this._classifyConflictResponse(snapshot, response);
|
|
1277
1303
|
if (status >= 400) return { kind: "terminal" };
|
|
1278
1304
|
if (!response.ok) return { kind: "retryable" };
|
|
1279
1305
|
if (snapshot.type === "delete") return { kind: "success", data: null };
|
|
@@ -1292,12 +1318,31 @@
|
|
|
1292
1318
|
}
|
|
1293
1319
|
},
|
|
1294
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
|
+
|
|
1295
1340
|
_validServerAnnotation(data, expectedClientId) {
|
|
1296
1341
|
if (!this._plainObject(data)) return false;
|
|
1297
1342
|
const required = [
|
|
1298
1343
|
"id", "clientId", "userId", "authorName", "content", "intent", "severity",
|
|
1299
1344
|
"status", "selectedText", "pageUrl", "target", "metadata", "thread",
|
|
1300
|
-
"createdAt", "updatedAt"
|
|
1345
|
+
"createdAt", "updatedAt", "revision"
|
|
1301
1346
|
];
|
|
1302
1347
|
if (!required.every(key => Object.prototype.hasOwnProperty.call(data, key))) return false;
|
|
1303
1348
|
if (typeof data.id !== "string" || data.id.length === 0) return false;
|
|
@@ -1313,6 +1358,7 @@
|
|
|
1313
1358
|
if (!this._plainObject(data.target) || !this._plainObject(data.metadata)) return false;
|
|
1314
1359
|
if (!Array.isArray(data.thread)) return false;
|
|
1315
1360
|
if (!this._validServerTimestamp(data.createdAt) || !this._validServerTimestamp(data.updatedAt)) return false;
|
|
1361
|
+
if (!Number.isInteger(data.revision) || data.revision < 0) return false;
|
|
1316
1362
|
return true;
|
|
1317
1363
|
},
|
|
1318
1364
|
|
|
@@ -1346,6 +1392,7 @@
|
|
|
1346
1392
|
return;
|
|
1347
1393
|
}
|
|
1348
1394
|
annotation.serverId = server.id;
|
|
1395
|
+
annotation.serverRevision = server.revision;
|
|
1349
1396
|
annotation.userId = server.userId;
|
|
1350
1397
|
annotation.authorName = server.authorName;
|
|
1351
1398
|
annotation.createdAt = server.createdAt;
|
|
@@ -1364,6 +1411,57 @@
|
|
|
1364
1411
|
annotation.syncState = "synced";
|
|
1365
1412
|
},
|
|
1366
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
|
+
|
|
1367
1465
|
_markSyncFailed(snapshot) {
|
|
1368
1466
|
if (!this._outboxEntryMatches(snapshot)) return;
|
|
1369
1467
|
this._commitLocalStateChange(() => {
|
|
@@ -1447,8 +1545,6 @@
|
|
|
1447
1545
|
|
|
1448
1546
|
_loadFromStorage() {
|
|
1449
1547
|
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
1548
|
let raw = localStorage.getItem(this._storageKey());
|
|
1453
1549
|
if (raw) {
|
|
1454
1550
|
const data = JSON.parse(raw);
|
|
@@ -1460,8 +1556,11 @@
|
|
|
1460
1556
|
? data.legacyMigrations
|
|
1461
1557
|
: {};
|
|
1462
1558
|
}
|
|
1463
|
-
//
|
|
1464
|
-
|
|
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.
|
|
1562
|
+
const migratedKeys = this._migrateUnnamespacedStorage();
|
|
1563
|
+
migratedKeys.push(...this._migratePageAnnotations());
|
|
1465
1564
|
this._normalizeStoredState();
|
|
1466
1565
|
this._recordLegacyMigrations();
|
|
1467
1566
|
if (this._saveToStorage()) this._cleanupMigratedKeys(migratedKeys);
|
|
@@ -1470,6 +1569,58 @@
|
|
|
1470
1569
|
} catch (e) { console.warn("[rails-markup] load failed:", e); }
|
|
1471
1570
|
},
|
|
1472
1571
|
|
|
1572
|
+
_migrateUnnamespacedStorage() {
|
|
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()}`;
|
|
1578
|
+
for (let index = 0; index < localStorage.length; index++) {
|
|
1579
|
+
const key = localStorage.key(index);
|
|
1580
|
+
if ((claimBareStorage && key === "rm-annotations") || key === currentPageKey) sourceKeys.push(key);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const migratedKeys = [];
|
|
1584
|
+
const legacyAnnotations = [];
|
|
1585
|
+
const legacyOutbox = {};
|
|
1586
|
+
const consolidatedClientIds = new Set(
|
|
1587
|
+
this.annotations.map(annotation => annotation.clientId).filter(clientId => this._validClientId(clientId))
|
|
1588
|
+
);
|
|
1589
|
+
sourceKeys.forEach(key => {
|
|
1590
|
+
try {
|
|
1591
|
+
const data = JSON.parse(localStorage.getItem(key));
|
|
1592
|
+
if (!this._plainObject(data)) return;
|
|
1593
|
+
const hasAnnotations = Array.isArray(data.annotations);
|
|
1594
|
+
const hasOutbox = key === "rm-annotations" && this._plainObject(data.outbox);
|
|
1595
|
+
if (!hasAnnotations && !hasOutbox) return;
|
|
1596
|
+
|
|
1597
|
+
if (hasAnnotations) {
|
|
1598
|
+
data.annotations.forEach((annotation, index) => {
|
|
1599
|
+
if (!this._plainObject(annotation)) return;
|
|
1600
|
+
const fingerprint = this._legacyMigrationFingerprint(key, index, annotation);
|
|
1601
|
+
const migratedClientId = this.legacyMigrations[fingerprint];
|
|
1602
|
+
if (this._validClientId(migratedClientId) && consolidatedClientIds.has(migratedClientId)) return;
|
|
1603
|
+
if (this._validClientId(migratedClientId)) annotation.clientId = migratedClientId;
|
|
1604
|
+
if (this._validClientId(annotation.clientId) && consolidatedClientIds.has(annotation.clientId)) return;
|
|
1605
|
+
Object.defineProperty(annotation, "_legacyMigrationFingerprint", {
|
|
1606
|
+
configurable: true,
|
|
1607
|
+
value: fingerprint
|
|
1608
|
+
});
|
|
1609
|
+
legacyAnnotations.push(annotation);
|
|
1610
|
+
if (this._validClientId(annotation.clientId)) consolidatedClientIds.add(annotation.clientId);
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
if (hasOutbox) Object.assign(legacyOutbox, data.outbox);
|
|
1614
|
+
if (Number.isInteger(data.nextId) && data.nextId > this.nextId) this.nextId = data.nextId;
|
|
1615
|
+
migratedKeys.push(key);
|
|
1616
|
+
} catch {}
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
this.annotations = legacyAnnotations.concat(this.annotations);
|
|
1620
|
+
this.outbox = Object.assign({}, legacyOutbox, this.outbox);
|
|
1621
|
+
return migratedKeys;
|
|
1622
|
+
},
|
|
1623
|
+
|
|
1473
1624
|
_migratePageAnnotations() {
|
|
1474
1625
|
// Find and merge per-page annotation keys only within this endpoint namespace.
|
|
1475
1626
|
const prefix = this._storageKey() + ":";
|
|
@@ -1542,6 +1693,11 @@
|
|
|
1542
1693
|
}
|
|
1543
1694
|
if (annotation.serverId == null) annotation.serverId = annotation.server_id ?? null;
|
|
1544
1695
|
if (annotation.serverUpdatedAt == null) annotation.serverUpdatedAt = annotation.server_updated_at ?? null;
|
|
1696
|
+
if (!Number.isInteger(annotation.serverRevision) || annotation.serverRevision < 0) {
|
|
1697
|
+
annotation.serverRevision = Number.isInteger(annotation.server_revision) && annotation.server_revision >= 0
|
|
1698
|
+
? annotation.server_revision
|
|
1699
|
+
: 0;
|
|
1700
|
+
}
|
|
1545
1701
|
if (!Array.isArray(annotation.dirtyFields)) annotation.dirtyFields = [];
|
|
1546
1702
|
annotation.pageUrl = annotation.pageUrl || annotation.pathname || this._pageUrl();
|
|
1547
1703
|
annotation.pathname = annotation.pageUrl;
|
|
@@ -1576,6 +1732,7 @@
|
|
|
1576
1732
|
type: "upsert",
|
|
1577
1733
|
clientId: annotation.clientId,
|
|
1578
1734
|
revision: annotation.revision,
|
|
1735
|
+
baseRevision: annotation.serverRevision,
|
|
1579
1736
|
syncState: "pending",
|
|
1580
1737
|
annotation: this._desiredState(annotation),
|
|
1581
1738
|
dirtyFields: annotation.dirtyFields.slice()
|
|
@@ -1606,10 +1763,17 @@
|
|
|
1606
1763
|
const annotation = this.annotations.find(record => record.clientId === clientId);
|
|
1607
1764
|
const candidateRevision = Number.isInteger(candidate.revision) && candidate.revision >= 0 ? candidate.revision : 0;
|
|
1608
1765
|
const annotationRevision = Number.isInteger(annotation?.revision) && annotation.revision >= 0 ? annotation.revision : 0;
|
|
1766
|
+
const candidateBaseRevision = Number.isInteger(candidate.baseRevision) && candidate.baseRevision >= 0
|
|
1767
|
+
? candidate.baseRevision
|
|
1768
|
+
: 0;
|
|
1769
|
+
const annotationBaseRevision = Number.isInteger(annotation?.serverRevision) && annotation.serverRevision >= 0
|
|
1770
|
+
? annotation.serverRevision
|
|
1771
|
+
: 0;
|
|
1609
1772
|
const envelope = Object.assign({}, candidate, {
|
|
1610
1773
|
type,
|
|
1611
1774
|
clientId,
|
|
1612
1775
|
revision: Math.max(candidateRevision, annotationRevision),
|
|
1776
|
+
baseRevision: Math.max(candidateBaseRevision, annotationBaseRevision),
|
|
1613
1777
|
syncState: candidate.syncState === "failed" ? "failed" : "pending"
|
|
1614
1778
|
});
|
|
1615
1779
|
|
|
@@ -1823,6 +1987,7 @@
|
|
|
1823
1987
|
if (!annotation) return;
|
|
1824
1988
|
entry.annotation = this._desiredState(annotation);
|
|
1825
1989
|
entry.dirtyFields = (annotation.dirtyFields || []).slice();
|
|
1990
|
+
entry.baseRevision = Number.isInteger(annotation.serverRevision) ? annotation.serverRevision : 0;
|
|
1826
1991
|
});
|
|
1827
1992
|
});
|
|
1828
1993
|
if (!committed) return false;
|
|
@@ -1856,6 +2021,7 @@
|
|
|
1856
2021
|
annotation.pathname = server.pageUrl;
|
|
1857
2022
|
}
|
|
1858
2023
|
annotation.serverId = server.id;
|
|
2024
|
+
annotation.serverRevision = server.revision;
|
|
1859
2025
|
annotation.userId = server.userId;
|
|
1860
2026
|
annotation.authorName = server.authorName;
|
|
1861
2027
|
annotation.createdAt = server.createdAt;
|
|
@@ -1871,6 +2037,7 @@
|
|
|
1871
2037
|
id: null,
|
|
1872
2038
|
clientId: server.clientId,
|
|
1873
2039
|
serverId: server.id,
|
|
2040
|
+
serverRevision: server.revision,
|
|
1874
2041
|
userId: server.userId,
|
|
1875
2042
|
authorName: server.authorName,
|
|
1876
2043
|
syncState: "synced",
|
|
@@ -1893,6 +2060,9 @@
|
|
|
1893
2060
|
},
|
|
1894
2061
|
|
|
1895
2062
|
_serverRepresentationIsStale(annotation, server) {
|
|
2063
|
+
if (Number.isInteger(annotation.serverRevision) && Number.isInteger(server.revision)) {
|
|
2064
|
+
return server.revision < annotation.serverRevision;
|
|
2065
|
+
}
|
|
1896
2066
|
const localTimestamp = Date.parse(annotation.serverUpdatedAt || "");
|
|
1897
2067
|
const serverTimestamp = Date.parse(server.updatedAt || "");
|
|
1898
2068
|
return Number.isFinite(localTimestamp) && Number.isFinite(serverTimestamp) && serverTimestamp < localTimestamp;
|
|
@@ -59,31 +59,50 @@ 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
|
|
82
88
|
def destroy
|
|
83
89
|
client_uuid = normalized_route_uuid
|
|
84
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
|
|
85
99
|
|
|
86
|
-
|
|
100
|
+
annotation.destroy!
|
|
101
|
+
end
|
|
102
|
+
head :no_content
|
|
103
|
+
rescue Annotation::RevisionConflict => error
|
|
104
|
+
render_revision_conflict(error)
|
|
105
|
+
rescue ActiveRecord::RecordNotFound
|
|
87
106
|
head :no_content
|
|
88
107
|
end
|
|
89
108
|
|
|
@@ -183,9 +202,25 @@ module RailsMarkup
|
|
|
183
202
|
permitted.to_h.stringify_keys
|
|
184
203
|
end
|
|
185
204
|
|
|
186
|
-
def
|
|
187
|
-
|
|
188
|
-
|
|
205
|
+
def save_browser_state!(annotation, attributes, dirty_fields, base_revision)
|
|
206
|
+
if annotation.new_record?
|
|
207
|
+
assign_current_user(annotation)
|
|
208
|
+
annotation.apply_browser_state(
|
|
209
|
+
attributes,
|
|
210
|
+
dirty_fields: dirty_fields,
|
|
211
|
+
base_revision: base_revision
|
|
212
|
+
)
|
|
213
|
+
annotation.save!
|
|
214
|
+
else
|
|
215
|
+
annotation.with_lock do
|
|
216
|
+
annotation.apply_browser_state(
|
|
217
|
+
attributes,
|
|
218
|
+
dirty_fields: dirty_fields,
|
|
219
|
+
base_revision: base_revision
|
|
220
|
+
)
|
|
221
|
+
annotation.save!
|
|
222
|
+
end
|
|
223
|
+
end
|
|
189
224
|
end
|
|
190
225
|
|
|
191
226
|
def normalized_route_uuid
|
|
@@ -201,6 +236,11 @@ module RailsMarkup
|
|
|
201
236
|
fields if (fields - ALLOWED_DIRTY_FIELDS).empty?
|
|
202
237
|
end
|
|
203
238
|
|
|
239
|
+
def normalized_base_revision
|
|
240
|
+
revision = params[:baseRevision]
|
|
241
|
+
revision if revision.is_a?(Integer) && revision >= 0
|
|
242
|
+
end
|
|
243
|
+
|
|
204
244
|
def client_supplied_author?
|
|
205
245
|
metadata = params[:metadata]
|
|
206
246
|
metadata.respond_to?(:key?) && (metadata.key?(:author) || metadata.key?("author"))
|
|
@@ -226,6 +266,17 @@ module RailsMarkup
|
|
|
226
266
|
render json: { error: "invalid status" }, status: :unprocessable_entity
|
|
227
267
|
end
|
|
228
268
|
|
|
269
|
+
def render_invalid_base_revision
|
|
270
|
+
render json: { error: "base revision must be a non-negative integer" }, status: :unprocessable_entity
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def render_revision_conflict(error)
|
|
274
|
+
render json: {
|
|
275
|
+
error: "revision conflict",
|
|
276
|
+
annotation: error.annotation.new_record? ? nil : error.annotation.as_api_json
|
|
277
|
+
}, status: :conflict
|
|
278
|
+
end
|
|
279
|
+
|
|
229
280
|
def normalize_target(target)
|
|
230
281
|
case target
|
|
231
282
|
when String then { "selector" => target }
|
|
@@ -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
|
|
@@ -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
|
|
@@ -17,6 +26,10 @@ module RailsMarkup
|
|
|
17
26
|
LEGACY_SESSION_PATTERN = /\Arm-[0-9a-f]{16}\z/i
|
|
18
27
|
LEGACY_CLIENT_ID_LIMIT = 256
|
|
19
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
|
|
20
33
|
|
|
21
34
|
# Optional user association — no FK constraint, engine doesn't know host users table
|
|
22
35
|
belongs_to :user, optional: true
|
|
@@ -36,6 +49,7 @@ module RailsMarkup
|
|
|
36
49
|
validates :severity, inclusion: { in: SEVERITIES }
|
|
37
50
|
validates :status, inclusion: { in: STATUSES }
|
|
38
51
|
validate :thread_must_be_array
|
|
52
|
+
validate :thread_within_limits
|
|
39
53
|
|
|
40
54
|
def self.valid_client_uuid?(value)
|
|
41
55
|
value.is_a?(String) && CLIENT_UUID_PATTERN.match?(value)
|
|
@@ -99,18 +113,33 @@ module RailsMarkup
|
|
|
99
113
|
metadata&.dig("author")
|
|
100
114
|
end
|
|
101
115
|
|
|
102
|
-
def apply_browser_state(attributes, dirty_fields:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
def apply_browser_state(attributes, dirty_fields:, base_revision:)
|
|
117
|
+
raise RevisionConflict, self unless base_revision == revision
|
|
118
|
+
|
|
119
|
+
dirty_fields.each do |field|
|
|
120
|
+
if BROWSER_ATTRIBUTES.include?(field)
|
|
121
|
+
public_send("#{field}=", attributes[field]) if attributes.key?(field)
|
|
122
|
+
elsif field == "metadata" && attributes.key?("metadata")
|
|
123
|
+
self.metadata = (metadata || {}).merge(attributes["metadata"].slice(*BROWSER_METADATA_KEYS))
|
|
124
|
+
elsif field == "status" && attributes.key?("status")
|
|
125
|
+
self.status = attributes["status"]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
self.revision += 1 if changed?
|
|
106
129
|
self
|
|
107
130
|
end
|
|
108
131
|
|
|
109
132
|
def acknowledge!
|
|
110
|
-
|
|
111
|
-
|
|
133
|
+
# Lock + reload so a concurrent resolve!/dismiss! can't be clobbered:
|
|
134
|
+
# without it, an acknowledge validated against a stale "pending" could
|
|
135
|
+
# write "acknowledged" over an already-resolved record.
|
|
136
|
+
with_lock do
|
|
137
|
+
return self if status == "acknowledged" # idempotent — re-acknowledging is a no-op
|
|
138
|
+
raise "Cannot acknowledge a #{status} annotation" unless status == "pending"
|
|
112
139
|
|
|
113
|
-
|
|
140
|
+
update!(status: "acknowledged", revision: revision + 1)
|
|
141
|
+
end
|
|
142
|
+
self
|
|
114
143
|
end
|
|
115
144
|
|
|
116
145
|
def resolve!(summary: nil)
|
|
@@ -121,7 +150,7 @@ module RailsMarkup
|
|
|
121
150
|
raise "Cannot resolve a #{status} annotation" unless status.in?(%w[pending acknowledged])
|
|
122
151
|
|
|
123
152
|
add_thread_entry(role: "agent", message: summary) if summary.present?
|
|
124
|
-
update!(status: "resolved")
|
|
153
|
+
update!(status: "resolved", revision: revision + 1)
|
|
125
154
|
end
|
|
126
155
|
self
|
|
127
156
|
end
|
|
@@ -132,7 +161,7 @@ module RailsMarkup
|
|
|
132
161
|
raise "Cannot dismiss a #{status} annotation" unless status.in?(%w[pending acknowledged])
|
|
133
162
|
|
|
134
163
|
add_thread_entry(role: "agent", message: reason) if reason.present?
|
|
135
|
-
update!(status: "dismissed")
|
|
164
|
+
update!(status: "dismissed", revision: revision + 1)
|
|
136
165
|
end
|
|
137
166
|
self
|
|
138
167
|
end
|
|
@@ -140,6 +169,7 @@ module RailsMarkup
|
|
|
140
169
|
def add_reply!(message:, role: "agent")
|
|
141
170
|
with_lock do
|
|
142
171
|
add_thread_entry(role: role, message: message)
|
|
172
|
+
self.revision += 1
|
|
143
173
|
save!
|
|
144
174
|
end
|
|
145
175
|
self
|
|
@@ -161,7 +191,8 @@ module RailsMarkup
|
|
|
161
191
|
metadata: metadata,
|
|
162
192
|
thread: thread,
|
|
163
193
|
createdAt: created_at&.iso8601,
|
|
164
|
-
updatedAt: updated_at&.iso8601
|
|
194
|
+
updatedAt: updated_at&.iso8601,
|
|
195
|
+
revision: revision
|
|
165
196
|
}
|
|
166
197
|
end
|
|
167
198
|
|
|
@@ -182,5 +213,15 @@ module RailsMarkup
|
|
|
182
213
|
def thread_must_be_array
|
|
183
214
|
errors.add(:thread, "must be an array") unless thread.is_a?(Array)
|
|
184
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
|
|
185
226
|
end
|
|
186
227
|
end
|
|
@@ -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
|
|
@@ -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
|
|
@@ -589,6 +607,10 @@ module RailsMarkup
|
|
|
589
607
|
# ── Watch mode ────────────────────────────────────────────
|
|
590
608
|
|
|
591
609
|
def handle_watch(args)
|
|
610
|
+
unless @store.respond_to?(:supports_subscriptions?) && @store.supports_subscriptions?
|
|
611
|
+
raise ToolError, "Watch is unsupported in HTTP proxy (mcp-only) mode; poll with rails_markup_read instead."
|
|
612
|
+
end
|
|
613
|
+
|
|
592
614
|
sub = nil
|
|
593
615
|
timeout = [args["timeoutSeconds"]&.to_i || 120, 300].min
|
|
594
616
|
batch_window = [args["batchWindowSeconds"]&.to_i || 10, 60].min
|
data/lib/rails_markup/store.rb
CHANGED
|
@@ -7,20 +7,35 @@ 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
|
+
MAX_THREAD_MESSAGE_BYTES = 5_000
|
|
26
|
+
INTENTS = %w[fix change question approve].freeze
|
|
27
|
+
SEVERITIES = %w[suggestion important blocking].freeze
|
|
16
28
|
|
|
17
29
|
attr_reader :sessions
|
|
18
30
|
|
|
19
|
-
def initialize
|
|
31
|
+
def initialize(max_annotations_per_session: MAX_ANNOTATIONS_PER_SESSION,
|
|
32
|
+
max_annotation_bytes: MAX_ANNOTATION_BYTES)
|
|
20
33
|
@sessions = {}
|
|
21
34
|
@annotations_index = {} # id -> annotation (O(1) lookup)
|
|
22
35
|
@subscribers = [] # SSE callbacks: [session_id, callback]
|
|
23
36
|
@mutex = Mutex.new
|
|
37
|
+
@max_annotations_per_session = max_annotations_per_session
|
|
38
|
+
@max_annotation_bytes = max_annotation_bytes
|
|
24
39
|
end
|
|
25
40
|
|
|
26
41
|
# --- Sessions ---
|
|
@@ -53,6 +68,9 @@ module RailsMarkup
|
|
|
53
68
|
|
|
54
69
|
def create_annotation(session_id:, target:, content:, intent: "change", severity: "suggestion",
|
|
55
70
|
selected_text: nil, metadata: {})
|
|
71
|
+
annotation_bytes = validate_annotation!(
|
|
72
|
+
target:, content:, intent:, severity:, selected_text:, metadata:
|
|
73
|
+
)
|
|
56
74
|
id = SecureRandom.hex(8)
|
|
57
75
|
annotation = Annotation.new(
|
|
58
76
|
id: id,
|
|
@@ -73,6 +91,7 @@ module RailsMarkup
|
|
|
73
91
|
session = @sessions[session_id]
|
|
74
92
|
return nil unless session
|
|
75
93
|
|
|
94
|
+
enforce_session_capacity!(session, annotation_bytes)
|
|
76
95
|
session.annotations << annotation
|
|
77
96
|
@annotations_index[id] = annotation
|
|
78
97
|
end
|
|
@@ -101,35 +120,64 @@ module RailsMarkup
|
|
|
101
120
|
# --- Status transitions ---
|
|
102
121
|
|
|
103
122
|
def acknowledge(annotation_id)
|
|
104
|
-
|
|
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
|
|
105
132
|
end
|
|
106
133
|
|
|
107
134
|
def resolve(annotation_id, summary: nil)
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
110
148
|
|
|
111
|
-
ann.thread << { role: "agent", message: summary, timestamp: Time.now.iso8601 } if summary
|
|
112
149
|
notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
|
|
113
150
|
status: "resolved", summary: summary)
|
|
114
151
|
ann
|
|
115
152
|
end
|
|
116
153
|
|
|
117
154
|
def dismiss(annotation_id, reason: nil)
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
120
168
|
|
|
121
|
-
ann.thread << { role: "agent", message: reason, timestamp: Time.now.iso8601 } if reason
|
|
122
169
|
notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
|
|
123
170
|
status: "dismissed", reason: reason)
|
|
124
171
|
ann
|
|
125
172
|
end
|
|
126
173
|
|
|
127
174
|
def reply(annotation_id, message:)
|
|
128
|
-
ann =
|
|
129
|
-
|
|
175
|
+
ann = @mutex.synchronize do
|
|
176
|
+
annotation = @annotations_index[annotation_id]
|
|
177
|
+
return nil unless annotation
|
|
130
178
|
|
|
131
|
-
|
|
132
|
-
|
|
179
|
+
append_thread_message!(annotation, message)
|
|
180
|
+
annotation
|
|
133
181
|
end
|
|
134
182
|
notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
|
|
135
183
|
status: ann.status, message: message)
|
|
@@ -148,6 +196,10 @@ module RailsMarkup
|
|
|
148
196
|
@mutex.synchronize { @subscribers.delete(sub) }
|
|
149
197
|
end
|
|
150
198
|
|
|
199
|
+
def supports_subscriptions?
|
|
200
|
+
true
|
|
201
|
+
end
|
|
202
|
+
|
|
151
203
|
# --- Serialization ---
|
|
152
204
|
|
|
153
205
|
def serialize_session(session)
|
|
@@ -179,27 +231,109 @@ module RailsMarkup
|
|
|
179
231
|
|
|
180
232
|
private
|
|
181
233
|
|
|
182
|
-
def
|
|
183
|
-
|
|
184
|
-
return nil unless ann
|
|
234
|
+
def validate_transition!(annotation, new_status, from:)
|
|
235
|
+
return if from.include?(annotation.status)
|
|
185
236
|
|
|
186
|
-
|
|
187
|
-
|
|
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"
|
|
188
254
|
end
|
|
189
255
|
|
|
190
256
|
def notify(session_id, data)
|
|
257
|
+
subscribers = @mutex.synchronize do
|
|
258
|
+
@subscribers.select { |sid, _callback| sid.nil? || sid == session_id }
|
|
259
|
+
end
|
|
191
260
|
dead = []
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
261
|
+
subscribers.each do |sub|
|
|
262
|
+
_sid, callback = sub
|
|
263
|
+
callback.call(data)
|
|
264
|
+
rescue StandardError
|
|
265
|
+
dead << sub
|
|
266
|
+
end
|
|
267
|
+
@mutex.synchronize { dead.each { |sub| @subscribers.delete(sub) } } unless dead.empty?
|
|
268
|
+
end
|
|
196
269
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
270
|
+
def validate_annotation!(target:, content:, intent:, severity:, selected_text:, metadata:)
|
|
271
|
+
validate_target!(target)
|
|
272
|
+
validate_string!("content", content, maximum: MAX_CONTENT_BYTES)
|
|
273
|
+
validate_optional_string!("selected_text", selected_text, maximum: MAX_SELECTED_TEXT_BYTES)
|
|
274
|
+
raise ValidationError, "intent is invalid" unless INTENTS.include?(intent)
|
|
275
|
+
raise ValidationError, "severity is invalid" unless SEVERITIES.include?(severity)
|
|
276
|
+
raise ValidationError, "metadata must be an object" unless metadata.nil? || metadata.is_a?(Hash)
|
|
277
|
+
|
|
278
|
+
metadata_bytes = JSON.generate(metadata || {}).bytesize
|
|
279
|
+
raise ValidationError, "metadata exceeds #{MAX_METADATA_BYTES} bytes" if metadata_bytes > MAX_METADATA_BYTES
|
|
280
|
+
|
|
281
|
+
JSON.generate(
|
|
282
|
+
target:, content:, intent:, severity:, selected_text:, metadata: metadata || {}, thread: []
|
|
283
|
+
).bytesize
|
|
284
|
+
rescue JSON::GeneratorError, Encoding::UndefinedConversionError
|
|
285
|
+
raise ValidationError, "annotation fields must be JSON serializable"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def validate_string!(name, value, maximum:)
|
|
289
|
+
raise ValidationError, "#{name} must be a non-empty string" unless value.is_a?(String) && !value.empty?
|
|
290
|
+
raise ValidationError, "#{name} exceeds #{maximum} bytes" if value.bytesize > maximum
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def validate_target!(target)
|
|
294
|
+
if target.is_a?(String)
|
|
295
|
+
return validate_string!("target", target, maximum: MAX_TARGET_BYTES)
|
|
202
296
|
end
|
|
297
|
+
raise ValidationError, "target must be a non-empty string or object" unless target.is_a?(Hash)
|
|
298
|
+
|
|
299
|
+
target_bytes = JSON.generate(target).bytesize
|
|
300
|
+
raise ValidationError, "target exceeds #{MAX_TARGET_BYTES} bytes" if target_bytes > MAX_TARGET_BYTES
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def validate_optional_string!(name, value, maximum:)
|
|
304
|
+
return if value.nil?
|
|
305
|
+
|
|
306
|
+
raise ValidationError, "#{name} must be a string" unless value.is_a?(String)
|
|
307
|
+
raise ValidationError, "#{name} exceeds #{maximum} bytes" if value.bytesize > maximum
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def enforce_session_capacity!(session, incoming_bytes)
|
|
311
|
+
if session.annotations.length >= @max_annotations_per_session
|
|
312
|
+
raise CapacityError, "session annotation limit of #{@max_annotations_per_session} reached"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
current_bytes = aggregate_annotation_bytes
|
|
316
|
+
return if current_bytes + incoming_bytes <= @max_annotation_bytes
|
|
317
|
+
|
|
318
|
+
raise CapacityError, "aggregate annotation byte limit of #{@max_annotation_bytes} reached"
|
|
319
|
+
end
|
|
320
|
+
|
|
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)
|
|
328
|
+
JSON.generate(
|
|
329
|
+
target: annotation.target,
|
|
330
|
+
content: annotation.content,
|
|
331
|
+
intent: annotation.intent,
|
|
332
|
+
severity: annotation.severity,
|
|
333
|
+
selected_text: annotation.selected_text,
|
|
334
|
+
metadata: annotation.metadata,
|
|
335
|
+
thread: thread
|
|
336
|
+
).bytesize
|
|
203
337
|
end
|
|
204
338
|
|
|
205
339
|
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.1
|
|
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
|