dsh-diff-approval 0.30.1 → 0.32.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.
- package/README.md +11 -23
- package/README.zh.md +9 -21
- package/assets/fonts/README.md +10 -8
- package/assets/fonts/manifest.json +1 -1
- package/lib/client.js +2791 -894
- package/lib/index.js +1755 -269
- package/lib/types/annotate-skill.d.ts +1 -1
- package/lib/types/annotate-tool.d.ts +11 -0
- package/lib/types/annotate.d.ts +28 -0
- package/lib/types/client/PendingPanel.d.ts +31 -21
- package/lib/types/client/category-color.d.ts +62 -0
- package/lib/types/client/chords.d.ts +70 -17
- package/lib/types/client/discussion.d.ts +10 -3
- package/lib/types/client/dock.d.ts +8 -3
- package/lib/types/client/dsh-icons.d.ts +39 -1
- package/lib/types/client/header-entry.d.ts +8 -7
- package/lib/types/client/locales.d.ts +52 -12
- package/lib/types/client/panel-memory.d.ts +24 -6
- package/lib/types/client/path-match.d.ts +50 -0
- package/lib/types/client/port.d.ts +29 -2
- package/lib/types/client/produced-diff.d.ts +83 -6
- package/lib/types/client/reference.d.ts +0 -2
- package/lib/types/client/remap-sync.d.ts +18 -1
- package/lib/types/client/session-seat.d.ts +47 -7
- package/lib/types/client/settings.d.ts +40 -14
- package/lib/types/client/slots.d.ts +115 -3
- package/lib/types/client/store.d.ts +50 -0
- package/lib/types/client/whole-file-diff.d.ts +42 -0
- package/lib/types/comment-ask.d.ts +47 -20
- package/lib/types/comments.d.ts +141 -33
- package/lib/types/index.d.ts +1 -1
- package/lib/types/pending.d.ts +30 -4
- package/lib/types/types.d.ts +186 -4
- package/lib/types/vcs.d.ts +35 -1
- package/lib/types/version.d.ts +162 -0
- package/package.json +4 -2
package/lib/index.js
CHANGED
|
@@ -896,7 +896,7 @@ function touchedBy(entry) {
|
|
|
896
896
|
function sameEntry(left, right) {
|
|
897
897
|
const lIds = touchedBy(left);
|
|
898
898
|
const rIds = touchedBy(right);
|
|
899
|
-
return left.path === right.path && left.
|
|
899
|
+
return left.path === right.path && left.earlierVersion === right.earlierVersion && left.oldText === right.oldText && left.newText === right.newText && left.updatedAt === right.updatedAt && left.sessionId === right.sessionId && lIds.length === rIds.length && lIds.every((id, index) => id === rIds[index]);
|
|
900
900
|
}
|
|
901
901
|
/**
|
|
902
902
|
* The pending-diff store: one entry per file path across all sessions. Keep /
|
|
@@ -951,21 +951,24 @@ var PendingDiffStore = class {
|
|
|
951
951
|
return true;
|
|
952
952
|
}
|
|
953
953
|
/**
|
|
954
|
-
* Combine a stored entry and a new capture. `oldText` comes from the earlier
|
|
955
|
-
* capture
|
|
956
|
-
*
|
|
957
|
-
* whole pending change regardless of
|
|
954
|
+
* Combine a stored entry and a new capture. `oldText` comes from the earlier capture (the file's
|
|
955
|
+
* original basis) — except where that capture was the file's creation and this one edits it, when
|
|
956
|
+
* the creation's own content becomes the basis — and `newText` from the later (latest content).
|
|
957
|
+
* The session set is the union, so the entry always spans the whole pending change regardless of
|
|
958
|
+
* which session contributed which part.
|
|
958
959
|
*/
|
|
959
960
|
mergeEntry(a, b) {
|
|
960
961
|
const earlier = a.updatedAt <= b.updatedAt ? a : b;
|
|
961
962
|
const later = earlier === a ? b : a;
|
|
962
963
|
const sessionIds = [.../* @__PURE__ */ new Set([...touchedBy(a), b.sessionId])];
|
|
964
|
+
const onlyCreations = earlier.earlierVersion === "none" && later.earlierVersion === "none";
|
|
963
965
|
return {
|
|
964
966
|
...later,
|
|
965
967
|
id: later.path,
|
|
966
|
-
oldText: earlier.oldText,
|
|
967
|
-
|
|
968
|
-
sessionIds
|
|
968
|
+
oldText: onlyCreations ? "" : earlier.earlierVersion === "none" ? earlier.newText : earlier.oldText,
|
|
969
|
+
earlierVersion: onlyCreations ? "none" : "file",
|
|
970
|
+
sessionIds,
|
|
971
|
+
unseen: earlier.unseen === true || later.unseen === true
|
|
969
972
|
};
|
|
970
973
|
}
|
|
971
974
|
/**
|
|
@@ -1006,6 +1009,15 @@ var PendingDiffStore = class {
|
|
|
1006
1009
|
/**
|
|
1007
1010
|
* Advance one entry's tracked content after a block-level keep/revert. The
|
|
1008
1011
|
* entry keeps its path; only the given side's text and capture time move.
|
|
1012
|
+
*
|
|
1013
|
+
* A keep that advances `oldText` also ends the entry's life as a creation. `earlierVersion` is the fact
|
|
1014
|
+
* "this file did not exist before", and once part of the file has been accepted there IS an earlier
|
|
1015
|
+
* version to restore — what the reader kept. The whole-file action must therefore become 回退, not 删除:
|
|
1016
|
+
* deleting would throw away the part they just accepted. (Leaving the field alone kept a partly-kept new
|
|
1017
|
+
* file offering to delete itself, which is what sent the reader here.)
|
|
1018
|
+
*
|
|
1019
|
+
* Every caller of this is a reader action — a block keep or revert, a refresh from version control — so it
|
|
1020
|
+
* also takes the unseen dot down: the reader has just handled this file.
|
|
1009
1021
|
* @param path - the file path.
|
|
1010
1022
|
* @param patch - the side to advance (`oldText` for keep, `newText` for revert).
|
|
1011
1023
|
* @returns whether the entry changed.
|
|
@@ -1013,9 +1025,12 @@ var PendingDiffStore = class {
|
|
|
1013
1025
|
update(path, patch) {
|
|
1014
1026
|
const entry = this.entries.get(pathKeyOf(path));
|
|
1015
1027
|
if (entry === void 0) return false;
|
|
1028
|
+
const kept = patch.oldText !== void 0;
|
|
1016
1029
|
const next = {
|
|
1017
1030
|
...entry,
|
|
1018
1031
|
...patch,
|
|
1032
|
+
...kept ? { earlierVersion: "file" } : {},
|
|
1033
|
+
unseen: false,
|
|
1019
1034
|
updatedAt: Date.now()
|
|
1020
1035
|
};
|
|
1021
1036
|
this.entries.set(pathKeyOf(path), next);
|
|
@@ -1023,6 +1038,50 @@ var PendingDiffStore = class {
|
|
|
1023
1038
|
return true;
|
|
1024
1039
|
}
|
|
1025
1040
|
/**
|
|
1041
|
+
* Mark paths the reader has now seen — the file was opened, or its row was acted on. The dot is about
|
|
1042
|
+
* attention rather than content, so this and the entry leaving the list are the only ways it goes out, and
|
|
1043
|
+
* nothing brings it back except a new capture.
|
|
1044
|
+
* @param paths - one path, or several.
|
|
1045
|
+
* @returns whether anything changed.
|
|
1046
|
+
*/
|
|
1047
|
+
markSeen(paths) {
|
|
1048
|
+
const list = typeof paths === "string" ? [paths] : paths;
|
|
1049
|
+
let changed = false;
|
|
1050
|
+
for (const path of list) {
|
|
1051
|
+
const key = pathKeyOf(path);
|
|
1052
|
+
const entry = this.entries.get(key);
|
|
1053
|
+
if (entry === void 0 || entry.unseen !== true) continue;
|
|
1054
|
+
this.entries.set(key, {
|
|
1055
|
+
...entry,
|
|
1056
|
+
unseen: false
|
|
1057
|
+
});
|
|
1058
|
+
changed = true;
|
|
1059
|
+
}
|
|
1060
|
+
return changed;
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Raise the dot for paths whose content moved WITHOUT the reader asking — a writer outside the panel, a
|
|
1064
|
+
* second process. The capture sites set the field on the entry they fold; this is for the changes the
|
|
1065
|
+
* plugin notices by itself while refreshing, which do not come through a capture.
|
|
1066
|
+
* @param paths - one path, or several.
|
|
1067
|
+
* @returns whether anything changed.
|
|
1068
|
+
*/
|
|
1069
|
+
markUnseen(paths) {
|
|
1070
|
+
const list = typeof paths === "string" ? [paths] : paths;
|
|
1071
|
+
let changed = false;
|
|
1072
|
+
for (const path of list) {
|
|
1073
|
+
const key = pathKeyOf(path);
|
|
1074
|
+
const entry = this.entries.get(key);
|
|
1075
|
+
if (entry === void 0 || entry.unseen === true) continue;
|
|
1076
|
+
this.entries.set(key, {
|
|
1077
|
+
...entry,
|
|
1078
|
+
unseen: true
|
|
1079
|
+
});
|
|
1080
|
+
changed = true;
|
|
1081
|
+
}
|
|
1082
|
+
return changed;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1026
1085
|
* Restore one entry exactly as given (an undo/redo replays a snapshot). The
|
|
1027
1086
|
* entry is inserted or replaced by path.
|
|
1028
1087
|
* @param entry - the entry state to restore.
|
|
@@ -1032,7 +1091,10 @@ var PendingDiffStore = class {
|
|
|
1032
1091
|
const key = pathKeyOf(entry.path);
|
|
1033
1092
|
const existing = this.entries.get(key);
|
|
1034
1093
|
if (existing !== void 0 && sameEntry(existing, entry)) return false;
|
|
1035
|
-
this.entries.set(key, {
|
|
1094
|
+
this.entries.set(key, {
|
|
1095
|
+
...entry,
|
|
1096
|
+
unseen: existing?.unseen === true
|
|
1097
|
+
});
|
|
1036
1098
|
if (existing === void 0 || existing.newText !== entry.newText) this.bumpContent(entry.path);
|
|
1037
1099
|
return true;
|
|
1038
1100
|
}
|
|
@@ -1173,13 +1235,43 @@ function defaultStorageDir() {
|
|
|
1173
1235
|
const FILE_VERSION = 3;
|
|
1174
1236
|
/** Legacy per-workspace-file version, accepted only for migration. */
|
|
1175
1237
|
const LEGACY_FILE_VERSION = 2;
|
|
1238
|
+
/** The pre-rename spelling of the entry's `earlierVersion`, or undefined for anything else. */
|
|
1239
|
+
function legacyEarlierVersion(kind) {
|
|
1240
|
+
if (kind === "edit") return "file";
|
|
1241
|
+
if (kind === "create") return "none";
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* The lineage one persisted row carries, or `undefined` when it carries none worth keeping.
|
|
1245
|
+
*
|
|
1246
|
+
* Every part is read independently and dropped when it is not the shape this record writes: a row from
|
|
1247
|
+
* an older build has no `lineage` key at all (that is the `undefined` value here, not an error), and a
|
|
1248
|
+
* hand-edited or truncated one may carry anything. A row whose parts are all missing is stored WITHOUT
|
|
1249
|
+
* a lineage rather than as an empty object, so "not known" has exactly one spelling on disk.
|
|
1250
|
+
* @param value - the row's `lineage` field, as parsed JSON.
|
|
1251
|
+
* @returns the lineage to keep, or `undefined`.
|
|
1252
|
+
*/
|
|
1253
|
+
function lineageOfRow(value) {
|
|
1254
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
1255
|
+
const record = value;
|
|
1256
|
+
const parent = record.parentSessionId;
|
|
1257
|
+
const depth = record.delegationDepth;
|
|
1258
|
+
const lineage = {
|
|
1259
|
+
parentSessionId: typeof parent === "string" && parent.length > 0 ? parent : void 0,
|
|
1260
|
+
origin: typeof record.origin === "string" && record.origin.length > 0 ? record.origin : void 0,
|
|
1261
|
+
delegationDepth: typeof depth === "number" && Number.isFinite(depth) ? depth : void 0,
|
|
1262
|
+
cwd: typeof record.cwd === "string" && record.cwd.length > 0 ? record.cwd : void 0
|
|
1263
|
+
};
|
|
1264
|
+
return lineage.parentSessionId !== void 0 || lineage.origin !== void 0 || lineage.delegationDepth !== void 0 || lineage.cwd !== void 0 ? lineage : void 0;
|
|
1265
|
+
}
|
|
1176
1266
|
/** Narrow one JSON value to a pending entry; malformed rows are skipped. */
|
|
1177
1267
|
function pendingEntryOf(value) {
|
|
1178
1268
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
1179
|
-
const
|
|
1269
|
+
const record = value;
|
|
1270
|
+
const { id, path, oldText, newText, updatedAt, sessionId, sessionIds } = record;
|
|
1271
|
+
const earlierVersion = record.earlierVersion ?? legacyEarlierVersion(record.kind);
|
|
1180
1272
|
if (typeof id !== "string" || id.length === 0) return void 0;
|
|
1181
1273
|
if (typeof path !== "string" || path.length === 0) return void 0;
|
|
1182
|
-
if (
|
|
1274
|
+
if (earlierVersion !== "file" && earlierVersion !== "none") return void 0;
|
|
1183
1275
|
if (typeof oldText !== "string" || typeof newText !== "string") return void 0;
|
|
1184
1276
|
if (typeof updatedAt !== "number") return void 0;
|
|
1185
1277
|
if (typeof sessionId !== "string" || sessionId.length === 0) return void 0;
|
|
@@ -1187,12 +1279,14 @@ function pendingEntryOf(value) {
|
|
|
1187
1279
|
return {
|
|
1188
1280
|
id: path,
|
|
1189
1281
|
path,
|
|
1190
|
-
|
|
1282
|
+
earlierVersion,
|
|
1191
1283
|
oldText,
|
|
1192
1284
|
newText,
|
|
1193
1285
|
updatedAt,
|
|
1286
|
+
unseen: record.unseen === true ? true : void 0,
|
|
1194
1287
|
sessionId,
|
|
1195
|
-
sessionIds: ids.length > 0 ? ids : [sessionId]
|
|
1288
|
+
sessionIds: ids.length > 0 ? ids : [sessionId],
|
|
1289
|
+
lineage: lineageOfRow(record.lineage)
|
|
1196
1290
|
};
|
|
1197
1291
|
}
|
|
1198
1292
|
/** Validate a parsed global file. */
|
|
@@ -1371,6 +1465,19 @@ function commentsDirFor(storageRoot) {
|
|
|
1371
1465
|
function fileOf(root, sessionId) {
|
|
1372
1466
|
return join(root, `${encodeURIComponent(String(sessionId))}.json`);
|
|
1373
1467
|
}
|
|
1468
|
+
/**
|
|
1469
|
+
* One answer map as a comment record keeps it: keys that name a question, values that are the text
|
|
1470
|
+
* read for it. Narrowed here like every other stored field, so a file that has drifted (or been
|
|
1471
|
+
* hand-edited) cannot put a non-string where an answer's text is expected.
|
|
1472
|
+
* @param value - the stored value.
|
|
1473
|
+
* @returns the map, or undefined when there is nothing usable in it.
|
|
1474
|
+
*/
|
|
1475
|
+
function answersOf(value) {
|
|
1476
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
1477
|
+
const map = {};
|
|
1478
|
+
for (const [requestId, text] of Object.entries(value)) if (typeof text === "string") map[requestId] = text;
|
|
1479
|
+
return Object.keys(map).length === 0 ? void 0 : map;
|
|
1480
|
+
}
|
|
1374
1481
|
/** Narrow one JSON value to a comment record; malformed rows are skipped. */
|
|
1375
1482
|
function commentOf(value) {
|
|
1376
1483
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
@@ -1389,6 +1496,10 @@ function commentOf(value) {
|
|
|
1389
1496
|
const context = row.quoteContext;
|
|
1390
1497
|
const quoteLines = Array.isArray(row.quoteLines) ? row.quoteLines.filter((entry) => typeof entry === "object" && entry !== null) : [];
|
|
1391
1498
|
const author = row.author === "agent" ? "agent" : void 0;
|
|
1499
|
+
const category = typeof row.category === "string" ? row.category.trim() : "";
|
|
1500
|
+
const asks = asksOf(row.asks);
|
|
1501
|
+
const answerSeen = answersOf(row.answerSeen);
|
|
1502
|
+
const answerNow = answersOf(row.answerNow);
|
|
1392
1503
|
return {
|
|
1393
1504
|
id,
|
|
1394
1505
|
sessionId,
|
|
@@ -1402,10 +1513,14 @@ function commentOf(value) {
|
|
|
1402
1513
|
text,
|
|
1403
1514
|
createdAt,
|
|
1404
1515
|
updatedAt,
|
|
1516
|
+
...row.unseen === true ? { unseen: true } : {},
|
|
1405
1517
|
...author === void 0 ? {} : { author },
|
|
1518
|
+
...category === "" ? {} : { category },
|
|
1406
1519
|
...typeof context === "string" && context !== "" ? { quoteContext: context } : {},
|
|
1407
1520
|
...quoteLines.length > 0 ? { quoteLines } : {},
|
|
1408
|
-
...
|
|
1521
|
+
...asks.length > 0 ? { asks } : {},
|
|
1522
|
+
...answerSeen === void 0 ? {} : { answerSeen },
|
|
1523
|
+
...answerNow === void 0 ? {} : { answerNow }
|
|
1409
1524
|
};
|
|
1410
1525
|
}
|
|
1411
1526
|
/** Narrow a stored thread's questions; malformed rows are skipped. */
|
|
@@ -1419,8 +1534,10 @@ function asksOf(value) {
|
|
|
1419
1534
|
if (typeof requestId !== "string" || requestId.length === 0) continue;
|
|
1420
1535
|
const turn = record.turn;
|
|
1421
1536
|
const text = record.text;
|
|
1537
|
+
const sessionId = record.sessionId;
|
|
1422
1538
|
asks.push({
|
|
1423
1539
|
requestId,
|
|
1540
|
+
...typeof sessionId === "string" && sessionId !== "" ? { sessionId } : {},
|
|
1424
1541
|
...typeof text === "string" && text !== "" ? { text } : {},
|
|
1425
1542
|
...typeof turn === "number" ? { turn } : {},
|
|
1426
1543
|
...record.dropped === true ? { dropped: true } : {},
|
|
@@ -1433,6 +1550,98 @@ function asksOf(value) {
|
|
|
1433
1550
|
function hasAsk(comment, requestId, wanted) {
|
|
1434
1551
|
return (comment.asks ?? []).some((ask) => ask.requestId === requestId && wanted(ask));
|
|
1435
1552
|
}
|
|
1553
|
+
/**
|
|
1554
|
+
* The transcript one question's answer will be written in.
|
|
1555
|
+
*
|
|
1556
|
+
* `ask.sessionId` when the question recorded one — every question asked since a lineage could read more
|
|
1557
|
+
* than one seat's threads does — otherwise the comment's own session, which is where a question asked
|
|
1558
|
+
* before that field existed necessarily went: the only session that could ask it was the one that wrote
|
|
1559
|
+
* the thread. ONE definition, shared by the answer fold, the turn-end match and the asker's grouping,
|
|
1560
|
+
* so the three cannot disagree about which transcript a question belongs to.
|
|
1561
|
+
* @param ask - the question.
|
|
1562
|
+
* @param comment - the thread it was asked in.
|
|
1563
|
+
* @returns the session whose transcript holds its answer.
|
|
1564
|
+
*/
|
|
1565
|
+
function askTranscript(ask, comment) {
|
|
1566
|
+
return ask.sessionId ?? comment.sessionId;
|
|
1567
|
+
}
|
|
1568
|
+
/** Whether two answer maps say the same thing, key for key and text for text. */
|
|
1569
|
+
function sameAnswers(left, right) {
|
|
1570
|
+
const one = Object.entries(left ?? {});
|
|
1571
|
+
const two = right ?? {};
|
|
1572
|
+
if (one.length !== Object.keys(two).length) return false;
|
|
1573
|
+
return one.every(([requestId, text]) => two[requestId] === text);
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* What ONE transcript currently says about the questions it owns, and which questions those are.
|
|
1577
|
+
*
|
|
1578
|
+
* Only the questions asked in that transcript are taken: a read is one session's, and a thread that
|
|
1579
|
+
* stored every other transcript's answers would be a copy of the conversation rather than its own
|
|
1580
|
+
* state. A question the read found no answer for is left out rather than stored empty, so a turn that
|
|
1581
|
+
* has not written yet cannot look like an answer that changed.
|
|
1582
|
+
* @param comment - the thread, for the questions it asked.
|
|
1583
|
+
* @param sessionId - the transcript being read.
|
|
1584
|
+
* @param answers - that transcript's answer text per question id.
|
|
1585
|
+
* @returns the thread's own question ids there, and the answers it holds for them.
|
|
1586
|
+
*/
|
|
1587
|
+
function answersInTranscript(comment, sessionId, answers) {
|
|
1588
|
+
const own = [];
|
|
1589
|
+
const now = {};
|
|
1590
|
+
for (const ask of comment.asks ?? []) {
|
|
1591
|
+
if (askTranscript(ask, comment) !== sessionId) continue;
|
|
1592
|
+
own.push(ask.requestId);
|
|
1593
|
+
const text = answers[ask.requestId]?.answer;
|
|
1594
|
+
if (text !== void 0) now[ask.requestId] = text;
|
|
1595
|
+
}
|
|
1596
|
+
return {
|
|
1597
|
+
own,
|
|
1598
|
+
now
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* One thread as a read of ONE transcript leaves it: its questions' current answers, and the dot up if
|
|
1603
|
+
* those say something the reader has not been told about.
|
|
1604
|
+
*
|
|
1605
|
+
* "New" is a DIFFERENCE, not a presence: the transcript answers a question once and hands back the
|
|
1606
|
+
* same text on every read afterwards, so "is there an answer" would leave the dot up forever, and
|
|
1607
|
+
* "did it change since the last read" would lose the answer entirely on the restart that forgot the
|
|
1608
|
+
* previous read. The comparison is against `answerSeen` — the baseline the reader's own visit writes
|
|
1609
|
+
* down — and it is STICKY: an answer that arrived while the dot was already up does not need to raise
|
|
1610
|
+
* it again. A question the transcript shows no answer for is absent here, so a log this process cannot
|
|
1611
|
+
* read, or a turn that has not written yet, leaves the thread exactly as it was.
|
|
1612
|
+
*
|
|
1613
|
+
* A thread may hold questions asked in MORE THAN ONE transcript (one card, two seats, each asking from
|
|
1614
|
+
* its own conversation). This fold owns only its own transcript's entries: it updates those, clears the
|
|
1615
|
+
* ones that transcript no longer shows an answer for, and leaves every other group exactly as that
|
|
1616
|
+
* group's own fold left it. Replacing the map wholesale would erase a second transcript's answers on
|
|
1617
|
+
* the next poll — the reader would watch an answer appear and then vanish.
|
|
1618
|
+
* @param comment - the thread as it stands.
|
|
1619
|
+
* @param sessionId - the transcript being read.
|
|
1620
|
+
* @param answers - that transcript's answer text per question id.
|
|
1621
|
+
* @returns what the record should hold after this read.
|
|
1622
|
+
*/
|
|
1623
|
+
function answerStateOf(comment, sessionId, answers) {
|
|
1624
|
+
const { own, now } = answersInTranscript(comment, sessionId, answers);
|
|
1625
|
+
const answerNow = { ...comment.answerNow ?? {} };
|
|
1626
|
+
for (const requestId of own) {
|
|
1627
|
+
const text = now[requestId];
|
|
1628
|
+
if (text === void 0) delete answerNow[requestId];
|
|
1629
|
+
else answerNow[requestId] = text;
|
|
1630
|
+
}
|
|
1631
|
+
const seen = comment.answerSeen ?? {};
|
|
1632
|
+
const lit = comment.unseen === true || Object.entries(answerNow).some(([requestId, text]) => seen[requestId] !== text);
|
|
1633
|
+
const next = {
|
|
1634
|
+
...comment,
|
|
1635
|
+
answerNow,
|
|
1636
|
+
unseen: lit
|
|
1637
|
+
};
|
|
1638
|
+
if (!lit) delete next.unseen;
|
|
1639
|
+
return next;
|
|
1640
|
+
}
|
|
1641
|
+
/** Whether a patch would leave a comment exactly as it stands (the check that keeps a read from saving). */
|
|
1642
|
+
function sameRecord(before, after) {
|
|
1643
|
+
return before.unseen === after.unseen && sameAnswers(before.answerNow, after.answerNow);
|
|
1644
|
+
}
|
|
1436
1645
|
/** Read one file's JSON; `undefined` when absent (the normal empty state). */
|
|
1437
1646
|
async function readJson(file) {
|
|
1438
1647
|
let raw;
|
|
@@ -1593,8 +1802,24 @@ var CommentStore = class {
|
|
|
1593
1802
|
}
|
|
1594
1803
|
this.options.onLoadSkipped?.(file, error);
|
|
1595
1804
|
}
|
|
1596
|
-
/**
|
|
1597
|
-
|
|
1805
|
+
/**
|
|
1806
|
+
* The comments one SCOPE may read, oldest first (the order threads are read in).
|
|
1807
|
+
*
|
|
1808
|
+
* The scope is the caller's lineage rule (`CommentScope`), so a seat reads every thread of the seats
|
|
1809
|
+
* it shares a lineage root with — not only the ones it wrote. The files do not move for that: each
|
|
1810
|
+
* comment still lives in its author's file (see `save`), and the fan-out happens here, in memory,
|
|
1811
|
+
* over the one map `loadAll` filled from every file. That is what keeps a teammate's already-written
|
|
1812
|
+
* threads visible, with no migration.
|
|
1813
|
+
* @param scope - which comment authors this caller may read.
|
|
1814
|
+
* @returns the comments those authors wrote, oldest first.
|
|
1815
|
+
*/
|
|
1816
|
+
list(scope) {
|
|
1817
|
+
const listed = [];
|
|
1818
|
+
for (const comment of this.byId.values()) if (scope(comment.sessionId)) listed.push(comment);
|
|
1819
|
+
return listed.sort((left, right) => left.createdAt - right.createdAt);
|
|
1820
|
+
}
|
|
1821
|
+
/** One author's own comments, oldest first: the WRITE-SET of that author's file (see `save`). */
|
|
1822
|
+
authoredBy(sessionId) {
|
|
1598
1823
|
const listed = [];
|
|
1599
1824
|
for (const comment of this.byId.values()) if (comment.sessionId === sessionId) listed.push(comment);
|
|
1600
1825
|
return listed.sort((left, right) => left.createdAt - right.createdAt);
|
|
@@ -1612,6 +1837,63 @@ var CommentStore = class {
|
|
|
1612
1837
|
return this.revision;
|
|
1613
1838
|
}
|
|
1614
1839
|
/**
|
|
1840
|
+
* Fold what ONE transcript now says into the threads that asked there, so a thread whose answer has
|
|
1841
|
+
* arrived (or been rewritten) wears the dot.
|
|
1842
|
+
*
|
|
1843
|
+
* The answers are DERIVED on every list read and never trusted from the file (see
|
|
1844
|
+
* `CommentRecord.answerNow`): the read is the only moment the host knows what the agent has said, and
|
|
1845
|
+
* the comparison it makes is against a baseline that survives a restart — a restart must not light a
|
|
1846
|
+
* dot the reader already cleared, and a read that changed nothing must not light one at all. That is
|
|
1847
|
+
* what `answerSeen` is, and why this writes a baseline only when the text actually moved.
|
|
1848
|
+
*
|
|
1849
|
+
* A question the transcript shows no answer for goes back to ABSENT rather than looking like an
|
|
1850
|
+
* empty answer: a log this process cannot read (or a turn that has not written yet) then leaves the
|
|
1851
|
+
* dot exactly as it was, instead of raising it on every poll.
|
|
1852
|
+
*
|
|
1853
|
+
* The patch's own key is the TRANSCRIPT, not the comment's author: a thread written in one seat can
|
|
1854
|
+
* hold a question asked from another (see `askTranscript`), and this read is that other seat's log.
|
|
1855
|
+
* Only the questions whose answer lives in `sessionId` are folded; a thread that also holds questions
|
|
1856
|
+
* asked elsewhere keeps those exactly as that transcript's own fold left them.
|
|
1857
|
+
*
|
|
1858
|
+
* @param sessionId - the transcript being folded, which owns the questions it is handed.
|
|
1859
|
+
* @param answers - the answer text per question id, as that transcript read it.
|
|
1860
|
+
* @returns whether any thread changed.
|
|
1861
|
+
*/
|
|
1862
|
+
syncAnswers(sessionId, answers) {
|
|
1863
|
+
const lit = (comment) => {
|
|
1864
|
+
if (!(comment.asks ?? []).some((ask) => askTranscript(ask, comment) === sessionId)) return void 0;
|
|
1865
|
+
const next = answerStateOf(comment, sessionId, answers);
|
|
1866
|
+
return sameRecord(comment, next) ? void 0 : next;
|
|
1867
|
+
};
|
|
1868
|
+
return this.patch((comment) => lit(comment) !== void 0, (comment) => lit(comment) ?? comment) > 0;
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* Mark one comment as looked at: the dot goes out, and the answers it currently shows become the
|
|
1872
|
+
* baseline a later rewrite is measured against — otherwise the next read would find the same answer
|
|
1873
|
+
* "new" again and put the dot straight back up.
|
|
1874
|
+
*
|
|
1875
|
+
* Deliberately its own seam rather than something a read does: only a reader action (the card coming
|
|
1876
|
+
* into view) may clear attention, and a read that cleared it would make the dot impossible to see.
|
|
1877
|
+
*
|
|
1878
|
+
* @param id - the comment the reader has in front of them.
|
|
1879
|
+
* @returns whether the comment existed.
|
|
1880
|
+
*/
|
|
1881
|
+
markSeen(id) {
|
|
1882
|
+
const before = this.byId.get(id);
|
|
1883
|
+
if (before === void 0) return false;
|
|
1884
|
+
const seen = before.answerNow ?? {};
|
|
1885
|
+
const next = {
|
|
1886
|
+
...before,
|
|
1887
|
+
answerSeen: { ...seen }
|
|
1888
|
+
};
|
|
1889
|
+
delete next.unseen;
|
|
1890
|
+
if (before.unseen !== true && sameAnswers(before.answerSeen, next.answerSeen)) return false;
|
|
1891
|
+
this.byId.set(id, next);
|
|
1892
|
+
this.revision += 1;
|
|
1893
|
+
this.save(before.sessionId);
|
|
1894
|
+
return true;
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1615
1897
|
* Record one annotation.
|
|
1616
1898
|
*
|
|
1617
1899
|
* Writing a comment that is already here is the SAME comment written again — a client
|
|
@@ -1678,50 +1960,62 @@ var CommentStore = class {
|
|
|
1678
1960
|
*/
|
|
1679
1961
|
mergeOf(record) {
|
|
1680
1962
|
const before = this.byId.get(record.id);
|
|
1681
|
-
|
|
1963
|
+
if (before === void 0) return record;
|
|
1964
|
+
const merged = {
|
|
1682
1965
|
...record,
|
|
1683
1966
|
createdAt: before.createdAt,
|
|
1684
|
-
...before.asks === void 0 ? {} : { asks: before.asks }
|
|
1967
|
+
...before.asks === void 0 ? {} : { asks: before.asks },
|
|
1968
|
+
...record.answerSeen === void 0 && before.answerSeen !== void 0 ? { answerSeen: before.answerSeen } : {}
|
|
1685
1969
|
};
|
|
1970
|
+
delete merged.unseen;
|
|
1971
|
+
if (before.unseen === true) merged.unseen = true;
|
|
1972
|
+
return merged;
|
|
1686
1973
|
}
|
|
1687
1974
|
/**
|
|
1688
|
-
* Drop one comment.
|
|
1689
|
-
* @param
|
|
1975
|
+
* Drop one comment the caller's scope covers.
|
|
1976
|
+
* @param scope - which comment authors the caller may act on.
|
|
1690
1977
|
* @param id - the comment to drop.
|
|
1691
|
-
* @returns whether it was there (and
|
|
1978
|
+
* @returns whether it was there (and was in scope).
|
|
1692
1979
|
*/
|
|
1693
|
-
remove(
|
|
1694
|
-
return this.removeMany(
|
|
1980
|
+
remove(scope, id) {
|
|
1981
|
+
return this.removeMany(scope, [id]).length === 1;
|
|
1695
1982
|
}
|
|
1696
1983
|
/**
|
|
1697
|
-
* Drop several comments
|
|
1984
|
+
* Drop several comments in one write PER FILE that holds them.
|
|
1698
1985
|
*
|
|
1699
1986
|
* This is what a batch action needs and what a loop of `remove` calls cannot give it: each
|
|
1700
|
-
* `remove` saves the
|
|
1701
|
-
*
|
|
1702
|
-
*
|
|
1703
|
-
*
|
|
1987
|
+
* `remove` saves the file, so ending ten comments wrote that file ten times — ten chances for a
|
|
1988
|
+
* transient failure to leave the store and the disk disagreeing about an action the reader asked
|
|
1989
|
+
* for once. The save happens only when something was actually dropped, so a batch that matches
|
|
1990
|
+
* nothing does not rewrite a file.
|
|
1991
|
+
*
|
|
1992
|
+
* Each record is removed from the file that HOLDS it, which is its author's (`save`), not the
|
|
1993
|
+
* caller's: once a lineage reads every seat's threads, a batch can name comments written by several
|
|
1994
|
+
* seats, and routing them all to the caller's file would leave the real files holding records the
|
|
1995
|
+
* store no longer has — the next load would resurrect every one of them.
|
|
1704
1996
|
*
|
|
1705
|
-
* An id
|
|
1997
|
+
* An id outside the scope, or one that is not here at all, is skipped rather than refused:
|
|
1706
1998
|
* a batch is one request and the comments it names are the ones it can act on, and a comment
|
|
1707
1999
|
* another client already ended is the state the caller was asking for anyway.
|
|
1708
2000
|
*
|
|
1709
|
-
* @param
|
|
2001
|
+
* @param scope - which comment authors the caller may act on.
|
|
1710
2002
|
* @param ids - the comments to drop; a repeated id is one comment.
|
|
1711
2003
|
* @returns the ids that were dropped, in the order given.
|
|
1712
2004
|
*/
|
|
1713
|
-
removeMany(
|
|
2005
|
+
removeMany(scope, ids) {
|
|
1714
2006
|
const removed = [];
|
|
2007
|
+
const sessions = /* @__PURE__ */ new Set();
|
|
1715
2008
|
for (const id of ids) {
|
|
1716
2009
|
if (removed.includes(id)) continue;
|
|
1717
2010
|
const comment = this.byId.get(id);
|
|
1718
|
-
if (comment === void 0 || comment.sessionId
|
|
2011
|
+
if (comment === void 0 || !scope(comment.sessionId)) continue;
|
|
1719
2012
|
this.byId.delete(id);
|
|
1720
2013
|
removed.push(id);
|
|
2014
|
+
sessions.add(comment.sessionId);
|
|
1721
2015
|
}
|
|
1722
2016
|
if (removed.length === 0) return removed;
|
|
1723
2017
|
this.revision += 1;
|
|
1724
|
-
this.save(sessionId);
|
|
2018
|
+
for (const sessionId of sessions) this.save(sessionId);
|
|
1725
2019
|
return removed;
|
|
1726
2020
|
}
|
|
1727
2021
|
/**
|
|
@@ -1762,31 +2056,38 @@ var CommentStore = class {
|
|
|
1762
2056
|
return removed;
|
|
1763
2057
|
}
|
|
1764
2058
|
/**
|
|
1765
|
-
* The orphan sweep:
|
|
2059
|
+
* The orphan sweep: prune every comment whose entry is not in `entryIds`, in MEMORY ONLY.
|
|
2060
|
+
*
|
|
2061
|
+
* The explicit `removeForEntry` is the primary path and the ONLY one that erases; this is the view
|
|
2062
|
+
* filter that guarantees no read is ever handed a comment naming a file the list does not carry. Run it
|
|
2063
|
+
* when the store loads and on every list read.
|
|
2064
|
+
*
|
|
2065
|
+
* WHY IT DOES NOT SAVE (2026-10-06, a data loss measured on the live host). The sweep used to write the
|
|
2066
|
+
* pruned state to the files, on the assumption that an entry absent here had been removed while the host
|
|
2067
|
+
* was running. That is only one of two ways an entry can be missing; the other is that its ADD never
|
|
2068
|
+
* reached `pending.json` — a pending entry is folded in memory and persisted on a later flush — and then
|
|
2069
|
+
* the sweep deleted the comments hanging on it AND saved the deletion. Three real annotations were gone
|
|
2070
|
+
* from the disk after a restart, not merely hidden: the reader's cards were written first and their
|
|
2071
|
+
* entries never landed. An entry that exists but is not on disk yet is not an orphan.
|
|
2072
|
+
*
|
|
2073
|
+
* A write would not even help the case it was added for. A second client must never be handed a comment
|
|
2074
|
+
* naming a file the same read no longer lists — that is a VIEW requirement, and the prune satisfies it.
|
|
2075
|
+
* Erasure belongs to the paths where a removal was actually ASKED FOR: `removeForEntry` (what the panel's
|
|
2076
|
+
* own drop calls, and it removes the comments in the same tick), and `remove`/`removeMany` for one
|
|
2077
|
+
* thread. A genuine orphan therefore stays on the disk, hidden, until the next boot that sees the entry
|
|
2078
|
+
* again — a row nobody reads is a smaller price than a reader's annotation nobody can recover.
|
|
1766
2079
|
*
|
|
1767
|
-
* The explicit `removeForEntry` is the primary path; this is the backstop that
|
|
1768
|
-
* catches a comment left behind by a crash between the two writes. Run it when
|
|
1769
|
-
* the store loads and on every list read, and the one race it could lose — an
|
|
1770
|
-
* entry re-added after its comments were already swept — cannot happen: entries
|
|
1771
|
-
* only leave through this host, so an entry absent at load time was removed
|
|
1772
|
-
* while the host was running, and the sweep at load runs before anything can
|
|
1773
|
-
* add it back.
|
|
1774
2080
|
* @param entryIds - every entry id the pending store currently holds.
|
|
1775
|
-
* @returns how many
|
|
2081
|
+
* @returns how many comments the view dropped.
|
|
1776
2082
|
*/
|
|
1777
2083
|
retain(entryIds) {
|
|
1778
|
-
const sessions = /* @__PURE__ */ new Set();
|
|
1779
2084
|
let removed = 0;
|
|
1780
2085
|
for (const comment of [...this.byId.values()]) {
|
|
1781
2086
|
if (entryIds.has(comment.entryId)) continue;
|
|
1782
2087
|
this.byId.delete(comment.id);
|
|
1783
|
-
sessions.add(comment.sessionId);
|
|
1784
2088
|
removed += 1;
|
|
1785
2089
|
}
|
|
1786
|
-
if (removed > 0)
|
|
1787
|
-
this.revision += 1;
|
|
1788
|
-
for (const sessionId of sessions) this.save(sessionId);
|
|
1789
|
-
}
|
|
2090
|
+
if (removed > 0) this.revision += 1;
|
|
1790
2091
|
return removed;
|
|
1791
2092
|
}
|
|
1792
2093
|
/**
|
|
@@ -1800,7 +2101,14 @@ var CommentStore = class {
|
|
|
1800
2101
|
* marker, the reference and the rules, and a thread that showed the prompt would show
|
|
1801
2102
|
* the scaffolding rather than the question.
|
|
1802
2103
|
*
|
|
1803
|
-
*
|
|
2104
|
+
* The ASKING session is recorded on the question, not taken from the comment: a thread another
|
|
2105
|
+
* seat wrote can be asked from this one, and the prompt then goes into the seat the human is
|
|
2106
|
+
* using, so that session's transcript is where the answer will appear (see `askTranscript`).
|
|
2107
|
+
*
|
|
2108
|
+
* Matched by comment ID alone: the request id is minted here and is globally unique, and the
|
|
2109
|
+
* caller has already checked that the thread is in its scope.
|
|
2110
|
+
*
|
|
2111
|
+
* @param sessionId - the transcript the question was submitted into.
|
|
1804
2112
|
* @param id - the comment that was asked in.
|
|
1805
2113
|
* @param requestId - the identity the submission carries into the transcript.
|
|
1806
2114
|
* @param turn - the turn that claimed it, when that is already known.
|
|
@@ -1808,10 +2116,11 @@ var CommentStore = class {
|
|
|
1808
2116
|
* @returns whether the comment existed.
|
|
1809
2117
|
*/
|
|
1810
2118
|
recordAsk(sessionId, id, requestId, turn, text) {
|
|
1811
|
-
return this.patch((comment) => comment.
|
|
2119
|
+
return this.patch((comment) => comment.id === id, (comment) => ({
|
|
1812
2120
|
...comment,
|
|
1813
2121
|
asks: [...comment.asks ?? [], {
|
|
1814
2122
|
requestId,
|
|
2123
|
+
sessionId,
|
|
1815
2124
|
text,
|
|
1816
2125
|
...turn === void 0 ? {} : { turn }
|
|
1817
2126
|
}],
|
|
@@ -1822,13 +2131,16 @@ var CommentStore = class {
|
|
|
1822
2131
|
* Record the turn that claimed one question, as the agent's inbox reported it. Two
|
|
1823
2132
|
* questions claimed by the same turn were answered together, which is a fact the
|
|
1824
2133
|
* panel can state instead of guessing from transcript positions.
|
|
1825
|
-
*
|
|
2134
|
+
*
|
|
2135
|
+
* Matched by REQUEST ID alone: it is minted per submission and globally unique, so it names the
|
|
2136
|
+
* question on its own — and a thread another seat wrote can hold a question asked from this seat,
|
|
2137
|
+
* which the old `comment.sessionId === sessionId` guard would refuse to write down.
|
|
1826
2138
|
* @param requestId - the request identity the inbox reported back.
|
|
1827
2139
|
* @param turn - the turn that claimed it.
|
|
1828
2140
|
* @returns how many threads the patch touched.
|
|
1829
2141
|
*/
|
|
1830
|
-
recordTurnForRequest(
|
|
1831
|
-
return this.patch((comment) =>
|
|
2142
|
+
recordTurnForRequest(requestId, turn) {
|
|
2143
|
+
return this.patch((comment) => hasAsk(comment, requestId, (ask) => ask.turn !== turn), (comment) => ({
|
|
1832
2144
|
...comment,
|
|
1833
2145
|
asks: (comment.asks ?? []).map((ask) => ask.requestId === requestId ? {
|
|
1834
2146
|
...ask,
|
|
@@ -1838,14 +2150,15 @@ var CommentStore = class {
|
|
|
1838
2150
|
}));
|
|
1839
2151
|
}
|
|
1840
2152
|
/**
|
|
1841
|
-
* Record that
|
|
2153
|
+
* Record that a session dropped one question before a turn claimed it: nothing is
|
|
1842
2154
|
* coming, and the reader is told rather than left waiting.
|
|
1843
|
-
*
|
|
2155
|
+
*
|
|
2156
|
+
* Matched by REQUEST ID alone, for the same reason as `recordTurnForRequest`.
|
|
1844
2157
|
* @param requestId - the request identity the inbox reported back.
|
|
1845
2158
|
* @returns how many threads the patch touched.
|
|
1846
2159
|
*/
|
|
1847
|
-
markDroppedForRequest(
|
|
1848
|
-
return this.patch((comment) =>
|
|
2160
|
+
markDroppedForRequest(requestId) {
|
|
2161
|
+
return this.patch((comment) => hasAsk(comment, requestId, (ask) => ask.dropped !== true), (comment) => ({
|
|
1849
2162
|
...comment,
|
|
1850
2163
|
asks: (comment.asks ?? []).map((ask) => ask.requestId === requestId ? {
|
|
1851
2164
|
...ask,
|
|
@@ -1865,14 +2178,19 @@ var CommentStore = class {
|
|
|
1865
2178
|
* that: whether an answer exists is read from the transcript on each list read, so a
|
|
1866
2179
|
* turn that stopped AFTER writing its answer keeps showing the answer.
|
|
1867
2180
|
*
|
|
1868
|
-
*
|
|
2181
|
+
* Matched by TURN **within one transcript** (`askTranscript`), never by the thread's author: turn
|
|
2182
|
+
* numbers are only unique inside a session, so two sessions can both have a turn 7, and a thread that
|
|
2183
|
+
* holds questions from both would otherwise have one session's ending mark the other's questions
|
|
2184
|
+
* over — the reader would be told a question was cut off while its answer was still coming.
|
|
2185
|
+
* @param sessionId - the transcript whose turn ended.
|
|
1869
2186
|
* @param turn - the turn that stopped.
|
|
1870
2187
|
* @returns how many threads the patch touched.
|
|
1871
2188
|
*/
|
|
1872
2189
|
markTurnEnded(sessionId, turn) {
|
|
1873
|
-
|
|
2190
|
+
const endedHere = (ask, comment) => askTranscript(ask, comment) === sessionId && ask.turn === turn;
|
|
2191
|
+
return this.patch((comment) => (comment.asks ?? []).some((ask) => endedHere(ask, comment) && ask.ended !== true), (comment) => ({
|
|
1874
2192
|
...comment,
|
|
1875
|
-
asks: (comment.asks ?? []).map((ask) => ask
|
|
2193
|
+
asks: (comment.asks ?? []).map((ask) => endedHere(ask, comment) ? {
|
|
1876
2194
|
...ask,
|
|
1877
2195
|
ended: true
|
|
1878
2196
|
} : ask),
|
|
@@ -1929,7 +2247,12 @@ var CommentStore = class {
|
|
|
1929
2247
|
* memory-only view would TRUNCATE that copy — turning a read failure into a permanent
|
|
1930
2248
|
* loss — so the save is refused and reported instead, which is what tells the reader
|
|
1931
2249
|
* those comments are memory-only.
|
|
1932
|
-
*
|
|
2250
|
+
*
|
|
2251
|
+
* The write-set is the AUTHOR's own comments (`authoredBy`), never the wider read scope
|
|
2252
|
+
* (`list`): a read spans the lineage, but a file must hold exactly the comments whose
|
|
2253
|
+
* author it is named for — writing the scope's comments into one seat's file would
|
|
2254
|
+
* duplicate every other seat's threads on disk and, on the next load, in memory.
|
|
2255
|
+
* @param sessionId - the author whose file to rewrite.
|
|
1933
2256
|
*/
|
|
1934
2257
|
save(sessionId) {
|
|
1935
2258
|
const file = fileOf(this.root, sessionId);
|
|
@@ -1938,7 +2261,7 @@ var CommentStore = class {
|
|
|
1938
2261
|
this.reportPersist(file, `'${file}' ${refused}, so this session's comments are kept in memory rather than written over it`);
|
|
1939
2262
|
return;
|
|
1940
2263
|
}
|
|
1941
|
-
const comments = this.
|
|
2264
|
+
const comments = this.authoredBy(sessionId);
|
|
1942
2265
|
const task = async () => {
|
|
1943
2266
|
await writeJsonAtomic(file, {
|
|
1944
2267
|
version: COMMENT_FILE_VERSION,
|
|
@@ -2166,16 +2489,19 @@ var CommentAsker = class {
|
|
|
2166
2489
|
* arrives while the submission is still in flight already finds its comment. A
|
|
2167
2490
|
* submission that rejects is marked dropped rather than left looking pending.
|
|
2168
2491
|
*
|
|
2169
|
-
* @param sessionId - the session the
|
|
2492
|
+
* @param sessionId - the session the question is asked FROM: the prompt goes into it, so its
|
|
2493
|
+
* transcript is where the answer will appear.
|
|
2170
2494
|
* @param commentId - the comment to ask.
|
|
2171
2495
|
* @param prompt - the prompt text to send verbatim.
|
|
2172
2496
|
* @param text - the reader's own words, which the prompt wraps: the thread keeps these.
|
|
2173
2497
|
* @param signal - aborts the submission.
|
|
2498
|
+
* @param inScope - which comment authors the caller's lineage covers, so a thread another seat
|
|
2499
|
+
* wrote can be asked from this one.
|
|
2174
2500
|
* @returns what the request did.
|
|
2175
2501
|
*/
|
|
2176
|
-
async ask(sessionId, commentId, prompt, text, signal) {
|
|
2502
|
+
async ask(sessionId, commentId, prompt, text, signal, inScope) {
|
|
2177
2503
|
const comment = this.comments.get(commentId);
|
|
2178
|
-
if (comment === void 0 || comment.sessionId
|
|
2504
|
+
if (comment === void 0 || !inScope(comment.sessionId)) return { outcome: "missing" };
|
|
2179
2505
|
const controller = this.ctx.get("sessionController");
|
|
2180
2506
|
if (typeof controller?.prompt !== "function") {
|
|
2181
2507
|
this.ctx.logger.warn("diff-approval: no sessionController.prompt in this host, so a comment cannot be asked");
|
|
@@ -2205,7 +2531,7 @@ var CommentAsker = class {
|
|
|
2205
2531
|
} catch (error) {
|
|
2206
2532
|
const reason = rejectionText(error);
|
|
2207
2533
|
this.ctx.logger.warn(`diff-approval: asking a comment failed: ${reason}`);
|
|
2208
|
-
this.comments.markDroppedForRequest(
|
|
2534
|
+
this.comments.markDroppedForRequest(requestId);
|
|
2209
2535
|
return {
|
|
2210
2536
|
outcome: "failed",
|
|
2211
2537
|
message: reason
|
|
@@ -2213,25 +2539,44 @@ var CommentAsker = class {
|
|
|
2213
2539
|
}
|
|
2214
2540
|
}
|
|
2215
2541
|
/**
|
|
2216
|
-
*
|
|
2542
|
+
* One scope's questions, grouped by THE TRANSCRIPT each was submitted into.
|
|
2543
|
+
*
|
|
2544
|
+
* A lineage's threads may hold questions asked from several seats, and an answer lives in the
|
|
2545
|
+
* transcript of the session that was asked — so the read is per transcript, not per thread. Grouping
|
|
2546
|
+
* here (rather than reading each thread separately) is what keeps a poll to ONE log walk per
|
|
2547
|
+
* transcript, however many threads asked there.
|
|
2548
|
+
*
|
|
2549
|
+
* The legacy case is why `askTranscript` exists: a question asked before the asking session was
|
|
2550
|
+
* recorded has only the comment's own session to go on, which is where it went.
|
|
2551
|
+
* @param visible - the comments this read may see (already scoped by the caller).
|
|
2552
|
+
* @returns the request ids per transcript; empty when nothing was ever asked.
|
|
2553
|
+
*/
|
|
2554
|
+
answerGroups(visible) {
|
|
2555
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2556
|
+
for (const comment of visible) for (const ask of comment.asks ?? []) {
|
|
2557
|
+
const transcript = askTranscript(ask, comment);
|
|
2558
|
+
const ids = groups.get(transcript);
|
|
2559
|
+
if (ids === void 0) groups.set(transcript, [ask.requestId]);
|
|
2560
|
+
else ids.push(ask.requestId);
|
|
2561
|
+
}
|
|
2562
|
+
return groups;
|
|
2563
|
+
}
|
|
2564
|
+
/**
|
|
2565
|
+
* Every question submitted into ONE transcript and what it says about them, keyed by the
|
|
2217
2566
|
* question's request id (which is what `CommentAsk` records and what a client matches
|
|
2218
|
-
* against). Derived from
|
|
2219
|
-
*
|
|
2220
|
-
*
|
|
2221
|
-
* @param sessionId - the
|
|
2567
|
+
* against). Derived from that session's event log on every call and never stored: the log
|
|
2568
|
+
* is the one source of truth for what the agent said, so a stored copy could only ever
|
|
2569
|
+
* disagree with it.
|
|
2570
|
+
* @param sessionId - the transcript to read.
|
|
2571
|
+
* @param requestIds - the questions that were submitted into it.
|
|
2572
|
+
* @param cache - this request's already-read logs, so the answer fold and the turn-end read
|
|
2573
|
+
* below share ONE walk of a log that can hold >100k events.
|
|
2222
2574
|
* @returns the read per question; empty when the session holds no questions.
|
|
2223
2575
|
*/
|
|
2224
|
-
|
|
2576
|
+
answersFor(sessionId, requestIds, cache) {
|
|
2225
2577
|
this.watch(sessionId);
|
|
2226
|
-
const requestIds = [];
|
|
2227
|
-
for (const comment of this.comments.list(sessionId)) for (const ask of comment.asks ?? []) requestIds.push(ask.requestId);
|
|
2228
2578
|
if (requestIds.length === 0) return {};
|
|
2229
|
-
|
|
2230
|
-
try {
|
|
2231
|
-
events = this.sessionEvents(sessionId);
|
|
2232
|
-
} catch {
|
|
2233
|
-
events = void 0;
|
|
2234
|
-
}
|
|
2579
|
+
const events = this.sessionEvents(sessionId, cache);
|
|
2235
2580
|
if (events === void 0) return {};
|
|
2236
2581
|
return answersIn(events, requestIds);
|
|
2237
2582
|
}
|
|
@@ -2244,16 +2589,12 @@ var CommentAsker = class {
|
|
|
2244
2589
|
* a client that connected afterwards, a deployment whose agent events cannot be
|
|
2245
2590
|
* subscribed at all — because `turn/end` is durable and the event is not.
|
|
2246
2591
|
*
|
|
2247
|
-
* @param sessionId - the
|
|
2592
|
+
* @param sessionId - the transcript whose turns to check.
|
|
2593
|
+
* @param cache - this request's already-read logs (see `answersFor`).
|
|
2248
2594
|
* @returns the turns the log closed, or empty when the log cannot be read.
|
|
2249
2595
|
*/
|
|
2250
|
-
endedTurns(sessionId) {
|
|
2251
|
-
|
|
2252
|
-
try {
|
|
2253
|
-
events = this.sessionEvents(sessionId);
|
|
2254
|
-
} catch {
|
|
2255
|
-
events = void 0;
|
|
2256
|
-
}
|
|
2596
|
+
endedTurns(sessionId, cache) {
|
|
2597
|
+
const events = this.sessionEvents(sessionId, cache);
|
|
2257
2598
|
if (events === void 0) return [];
|
|
2258
2599
|
const turns = [];
|
|
2259
2600
|
for (const event of events) {
|
|
@@ -2272,11 +2613,24 @@ var CommentAsker = class {
|
|
|
2272
2613
|
* and `snapshotEvents` hands back a frozen, sequence-ordered copy of it, so walking it
|
|
2273
2614
|
* is a plain read that cannot disturb the session.
|
|
2274
2615
|
*
|
|
2616
|
+
* The cache is per REQUEST, not per session: one list read asks this twice for every transcript
|
|
2617
|
+
* (the answer fold and the turn-end read), and the copy is the expensive part of a long session's
|
|
2618
|
+
* log. `has` rather than a truthy check, so "cannot be read" is cached as such instead of being
|
|
2619
|
+
* asked again.
|
|
2275
2620
|
* @param sessionId - the session to read.
|
|
2621
|
+
* @param cache - this request's already-read logs, when the caller is doing several reads.
|
|
2276
2622
|
* @returns its events, or undefined when no live session can be read.
|
|
2277
2623
|
*/
|
|
2278
|
-
sessionEvents(sessionId) {
|
|
2279
|
-
return
|
|
2624
|
+
sessionEvents(sessionId, cache) {
|
|
2625
|
+
if (cache !== void 0 && cache.has(sessionId)) return cache.get(sessionId);
|
|
2626
|
+
let events;
|
|
2627
|
+
try {
|
|
2628
|
+
events = this.ctx.sessions.get(sessionId)?.snapshotEvents?.();
|
|
2629
|
+
} catch {
|
|
2630
|
+
events = void 0;
|
|
2631
|
+
}
|
|
2632
|
+
cache?.set(sessionId, events);
|
|
2633
|
+
return events;
|
|
2280
2634
|
}
|
|
2281
2635
|
/** The live agent for one session, or `undefined` when this host drives none. */
|
|
2282
2636
|
agentOf(sessionId) {
|
|
@@ -2300,7 +2654,7 @@ var CommentAsker = class {
|
|
|
2300
2654
|
* released first, so re-arming is not a leak: one set of handlers per live agent,
|
|
2301
2655
|
* not one more per identity change.
|
|
2302
2656
|
*
|
|
2303
|
-
* Called on every ask AND on every poll read (`
|
|
2657
|
+
* Called on every ask AND on every poll read (`answersFor`), because the agent can be
|
|
2304
2658
|
* replaced while a question is still pending — the case where nothing asks again.
|
|
2305
2659
|
* @param sessionId - the session whose agent to watch.
|
|
2306
2660
|
*/
|
|
@@ -2329,11 +2683,11 @@ var CommentAsker = class {
|
|
|
2329
2683
|
if (requestId === void 0) return;
|
|
2330
2684
|
const turn = typeof payload.turn === "number" ? payload.turn : void 0;
|
|
2331
2685
|
if (turn === void 0) return;
|
|
2332
|
-
this.comments.recordTurnForRequest(
|
|
2686
|
+
this.comments.recordTurnForRequest(requestId, turn);
|
|
2333
2687
|
});
|
|
2334
2688
|
listen("agent/inbox/discarded", (payload) => {
|
|
2335
2689
|
const requestId = requestIdOf(payload);
|
|
2336
|
-
if (requestId !== void 0) this.comments.markDroppedForRequest(
|
|
2690
|
+
if (requestId !== void 0) this.comments.markDroppedForRequest(requestId);
|
|
2337
2691
|
});
|
|
2338
2692
|
listen("agent/turn-stopping", (payload) => {
|
|
2339
2693
|
const turn = payload.turn;
|
|
@@ -2611,6 +2965,9 @@ const ANNOTATE_SKILL = {
|
|
|
2611
2965
|
- \`startLine\` / \`endLine\`:**现在**的行号(1 起,含两端;省略 \`endLine\` 就是一行)。先读文件再写号,别凭记忆。
|
|
2612
2966
|
- \`note\`:卡片上写的话。要短——卡片是**按行**排版、行高按行算的(上限 1000 字符,超了会被拒)。讲流程时,把这一步的序号写在 note 的**最前面**(\`1. …\`、\`2. …\`)。
|
|
2613
2967
|
- \`quote\`(可选,建议给):你读到的那几行原文(换行连接)。文件在你读完之后又变了的话,会当场被拒并把现在的原文给你,避免批注挂在错误的行上。
|
|
2968
|
+
- \`category\`(可选):**给"一类批注"起一个 id**。同一类(同一轮、同一主题、同一次讲解)每次调用都传**同一个** id,换成另一类就换一个 id。面板会按这个 id 给评论列表里的那条评论画一个**彩色圆点**,所以**同一类的批注是同一个颜色**,两类之间可以靠颜色区分。**不传 \`category\` 就完全不画圆点**——它是可选的,圆点只为"有类别"的批注而画。
|
|
2969
|
+
- 颜色只有 12 种,id 是散列到颜色上的,所以**两个不同的 id 可能撞成同一个颜色**,那时这两类看起来一样:这不是"分了几类一目了然",只是"同一类颜色一致"。想让人分得清,就**有意识地复用同一个 id**,不要给同一类换新 id。
|
|
2970
|
+
- id 本身不解析:随便给(uuid 也行)。颜色由它算出来;你写的 id 只出现在圆点的 **\`title\` / \`aria-label\`** 里(鼠标悬停、或读屏软件),**不是屏幕上的文字**。会去掉两端空白;最多 64 个字符,超了**截断**(不会因此拒掉整条批注)。
|
|
2614
2971
|
|
|
2615
2972
|
## 讲流程就在正文开头写序号
|
|
2616
2973
|
|
|
@@ -2649,6 +3006,22 @@ const ANNOTATE_SKILL = {
|
|
|
2649
3006
|
*/
|
|
2650
3007
|
const MAX_ANNOTATION_CHARS = 1e3;
|
|
2651
3008
|
/**
|
|
3009
|
+
* The category id as it is stored: trimmed, and cut to {@link MAX_ANNOTATION_CATEGORY_CHARS}.
|
|
3010
|
+
*
|
|
3011
|
+
* TRUNCATED rather than refused: the id is only ever hashed into a colour and echoed in the mark's label,
|
|
3012
|
+
* so an over-long one still groups its annotations consistently — every call of that class is cut in the
|
|
3013
|
+
* same place — while refusing would fail an entire annotation over a cosmetic detail. Absent, empty and
|
|
3014
|
+
* whitespace-only all mean the same thing: no class, so no dot (see `CommentRecord.category`).
|
|
3015
|
+
*
|
|
3016
|
+
* @param value - the id the caller passed, if any.
|
|
3017
|
+
* @returns the id to store, or `undefined` when there is none.
|
|
3018
|
+
*/
|
|
3019
|
+
function normalizeCategory(value) {
|
|
3020
|
+
const trimmed = value?.trim() ?? "";
|
|
3021
|
+
if (trimmed === "") return void 0;
|
|
3022
|
+
return trimmed.slice(0, 64);
|
|
3023
|
+
}
|
|
3024
|
+
/**
|
|
2652
3025
|
* Whether two line ranges share a line. Ranges are inclusive on both ends, which is how the panel's own
|
|
2653
3026
|
* selection rule reads them (`discussionOverlapping`).
|
|
2654
3027
|
*
|
|
@@ -2751,6 +3124,7 @@ function annotateLines(request, existing) {
|
|
|
2751
3124
|
};
|
|
2752
3125
|
}
|
|
2753
3126
|
const now = request.now ?? Date.now();
|
|
3127
|
+
const category = normalizeCategory(request.category);
|
|
2754
3128
|
return {
|
|
2755
3129
|
outcome: "annotated",
|
|
2756
3130
|
comment: {
|
|
@@ -2766,6 +3140,7 @@ function annotateLines(request, existing) {
|
|
|
2766
3140
|
createdAt: now,
|
|
2767
3141
|
updatedAt: now,
|
|
2768
3142
|
author: "agent",
|
|
3143
|
+
...category === void 0 ? {} : { category },
|
|
2769
3144
|
...annotationQuote(lines, start, end)
|
|
2770
3145
|
}
|
|
2771
3146
|
};
|
|
@@ -2825,7 +3200,7 @@ const ANNOTATE_TOOL_NAME = "diff_approval_annotate";
|
|
|
2825
3200
|
* The routing line the model always sees, which has to carry the two things that decide whether it calls
|
|
2826
3201
|
* this at all: what appears for the reader, and that the lines must be free.
|
|
2827
3202
|
*/
|
|
2828
|
-
const ANNOTATE_TOOL_DESCRIPTION = "Annotate a few lines of a file in the review panel: the note becomes a card the user sees on those exact lines, and they can reply to it there. Use it to explain code the user is reading (a call chain is one card per step), to point at a piece of code while discussing it, or to record a review finding on the lines it is about. A file the panel is not showing yet is added to it, so this works whether or not anything in that file has changed. The card shows exactly the text you write, so how the note is worded — including any numbering the user needs to read it in order — is yours. The lines must not already be inside an existing card: that case is refused, and the refusal names the card holding them, so say it in your reply instead of annotating those lines again.";
|
|
3203
|
+
const ANNOTATE_TOOL_DESCRIPTION = "Annotate a few lines of a file in the review panel: the note becomes a card the user sees on those exact lines, and they can reply to it there. Use it to explain code the user is reading (a call chain is one card per step), to point at a piece of code while discussing it, or to record a review finding on the lines it is about. A file the panel is not showing yet is added to it, so this works whether or not anything in that file has changed. The card shows exactly the text you write, so how the note is worded — including any numbering the user needs to read it in order — is yours. Give every annotation of one kind the same `category` id, and a different id for another kind (one pass, one topic, one round of review): the panel paints a coloured round dot beside each of those comments from it, which is how one class of annotation is told from another. Omit `category` and no dot is drawn at all. The lines must not already be inside an existing card: that case is refused, and the refusal names the card holding them, so say it in your reply instead of annotating those lines again.";
|
|
2829
3204
|
/**
|
|
2830
3205
|
* The argument schema, as raw JSON Schema.
|
|
2831
3206
|
*
|
|
@@ -2861,6 +3236,10 @@ const ANNOTATE_TOOL_PARAMETERS = {
|
|
|
2861
3236
|
quote: {
|
|
2862
3237
|
type: "string",
|
|
2863
3238
|
description: "Optional guard: the exact text you believe those lines hold (newline-joined). A mismatch is refused with the text that is actually there, which catches a file that moved between your read and this call."
|
|
3239
|
+
},
|
|
3240
|
+
category: {
|
|
3241
|
+
type: "string",
|
|
3242
|
+
description: "Optional class for this annotation. Pass the SAME short id for every annotation that belongs to one kind (one pass, one topic, one review round) and a DIFFERENT one for the next: the panel paints a coloured round dot beside these comments from it, so two classes can be told apart. OMIT this argument and no dot is drawn at all — it is optional, and the mark exists only for a class. Opaque: a uuid is fine, it is hashed for the colour and shown to the user in the mark's own label (hover, or a screen reader) as you wrote it. Trimmed, and an id longer than 64 characters is cut to 64 rather than refused."
|
|
2864
3243
|
}
|
|
2865
3244
|
}
|
|
2866
3245
|
};
|
|
@@ -3018,7 +3397,8 @@ function annotateRun(deps) {
|
|
|
3018
3397
|
startLine: args.startLine,
|
|
3019
3398
|
endLine: args.endLine,
|
|
3020
3399
|
note: args.note,
|
|
3021
|
-
quote: args.quote
|
|
3400
|
+
quote: args.quote,
|
|
3401
|
+
category: args.category
|
|
3022
3402
|
}, deps.commentsOf(sessionId));
|
|
3023
3403
|
if (result.outcome !== "annotated") {
|
|
3024
3404
|
deps.log?.(`annotate: ${refusalSummary(result)} (${entry.path})`);
|
|
@@ -3277,9 +3657,27 @@ function parseGitPorcelainZ(output) {
|
|
|
3277
3657
|
* a rename reads as its two halves (a delete and an add) rather than a row the review cannot
|
|
3278
3658
|
* express. */
|
|
3279
3659
|
const GIT_STATUS_COMMAND = "git -c status.renames=false status --porcelain=v1 -z --untracked-files=all";
|
|
3660
|
+
/** The message a failed command carries; `runShell` puts the command's own stderr into it. */
|
|
3661
|
+
function errorMessage$1(error) {
|
|
3662
|
+
return error instanceof Error ? error.message : String(error);
|
|
3663
|
+
}
|
|
3664
|
+
/**
|
|
3665
|
+
* git's wording for a path a revision does not carry, and ONLY that.
|
|
3666
|
+
*
|
|
3667
|
+
* Measured against git 2.x: `path 'x' does not exist in 'HEAD'`, and `Not a valid object name
|
|
3668
|
+
* HEAD:x` when HEAD is unborn (a repository with no commits yet — there every file is genuinely
|
|
3669
|
+
* new). A failure that is none of these — a corrupt object, a refused spawn, a timeout — is not
|
|
3670
|
+
* absence, and must never be read as one.
|
|
3671
|
+
*/
|
|
3672
|
+
const ABSENT_AT_REVISION = /does not exist in|exists on disk, but not in|not a valid object name|invalid object name|unknown revision|bad revision/i;
|
|
3280
3673
|
/**
|
|
3281
|
-
* Read one file's content at a revision (`<rev>:<path>`),
|
|
3282
|
-
*
|
|
3674
|
+
* Read one file's content at a revision (`<rev>:<path>`), and say so when that revision has no such
|
|
3675
|
+
* path.
|
|
3676
|
+
*
|
|
3677
|
+
* The two answers must stay distinguishable. Absence is what makes a file a creation, and a
|
|
3678
|
+
* creation's revert DELETES the file — so reading a FAILED read as absence offers to delete a file
|
|
3679
|
+
* that is committed and fine. Only git's own words decide (see {@link ABSENT_AT_REVISION}); anything
|
|
3680
|
+
* else is thrown with the path named, because a reader can act on that and cannot act on a guess.
|
|
3283
3681
|
*
|
|
3284
3682
|
* The blob is read straight off `git show` with a per-call stdout budget raised to the blob's own
|
|
3285
3683
|
* size the way `git cat-file -s` reports it, so the executor cannot truncate a large baseline.
|
|
@@ -3290,15 +3688,20 @@ const GIT_STATUS_COMMAND = "git -c status.renames=false status --porcelain=v1 -z
|
|
|
3290
3688
|
* @param rel - the repository-relative path.
|
|
3291
3689
|
* @param shell - the deployment's shell executor.
|
|
3292
3690
|
* @param signal - the caller's abort signal.
|
|
3293
|
-
* @returns the file's content at that revision, or
|
|
3691
|
+
* @returns the file's content at that revision, or `{ found: false }` when it has none.
|
|
3692
|
+
* @throws when the read failed for any reason other than absence.
|
|
3294
3693
|
*/
|
|
3295
3694
|
async function readGitBlob(root, revision, rel, shell, signal) {
|
|
3296
3695
|
try {
|
|
3297
3696
|
const reported = Number.parseInt((await runShell(shell, `git cat-file -s ${revision}:${shq(rel)}`, root, signal)).trim(), 10);
|
|
3298
3697
|
const size = Number.isFinite(reported) ? reported : 0;
|
|
3299
|
-
return
|
|
3300
|
-
|
|
3301
|
-
|
|
3698
|
+
return {
|
|
3699
|
+
found: true,
|
|
3700
|
+
text: await runShell(shell, `git show ${revision}:${shq(rel)}`, root, signal, size + GIT_BLOB_STDOUT_SLACK)
|
|
3701
|
+
};
|
|
3702
|
+
} catch (error) {
|
|
3703
|
+
if (ABSENT_AT_REVISION.test(errorMessage$1(error))) return { found: false };
|
|
3704
|
+
throw new Error(`could not read ${rel} at ${revision}: ${errorMessage$1(error)}`);
|
|
3302
3705
|
}
|
|
3303
3706
|
}
|
|
3304
3707
|
/** Enumerate the workspace's local changes in a git checkout. */
|
|
@@ -3315,7 +3718,7 @@ async function gitChanges(input) {
|
|
|
3315
3718
|
const newText = await readText(absolute) ?? "";
|
|
3316
3719
|
changes.push({
|
|
3317
3720
|
path: absolute,
|
|
3318
|
-
|
|
3721
|
+
earlierVersion: "none",
|
|
3319
3722
|
oldText: "",
|
|
3320
3723
|
newText
|
|
3321
3724
|
});
|
|
@@ -3323,12 +3726,12 @@ async function gitChanges(input) {
|
|
|
3323
3726
|
}
|
|
3324
3727
|
const baseline = await readGitBlob(root, "HEAD", rel, shell, signal);
|
|
3325
3728
|
const worktree = xy[1] ?? " ";
|
|
3326
|
-
const oldText = baseline
|
|
3729
|
+
const oldText = baseline.found ? baseline.text : "";
|
|
3327
3730
|
const newText = worktree === "D" ? "" : await readText(absolute) ?? "";
|
|
3328
3731
|
if (oldText === "" && newText === "") continue;
|
|
3329
3732
|
changes.push({
|
|
3330
3733
|
path: absolute,
|
|
3331
|
-
|
|
3734
|
+
earlierVersion: baseline.found ? "file" : "none",
|
|
3332
3735
|
oldText,
|
|
3333
3736
|
newText
|
|
3334
3737
|
});
|
|
@@ -3353,17 +3756,17 @@ async function svnChanges(input) {
|
|
|
3353
3756
|
const absolute = resolve(root, rel);
|
|
3354
3757
|
if (!inScanScope(absolute, roots)) continue;
|
|
3355
3758
|
if (item === "modified" || item === "deleted") {
|
|
3356
|
-
let oldText
|
|
3759
|
+
let oldText;
|
|
3357
3760
|
try {
|
|
3358
3761
|
oldText = await runShell(shell, `svn cat -r BASE ${shq(rel)}`, root, signal);
|
|
3359
|
-
} catch {
|
|
3360
|
-
|
|
3762
|
+
} catch (error) {
|
|
3763
|
+
throw new Error(`could not read ${rel} at BASE: ${errorMessage$1(error)}`);
|
|
3361
3764
|
}
|
|
3362
3765
|
const newText = item === "deleted" ? "" : await readText(absolute) ?? "";
|
|
3363
3766
|
if (oldText === "" && newText === "") continue;
|
|
3364
3767
|
changes.push({
|
|
3365
3768
|
path: absolute,
|
|
3366
|
-
|
|
3769
|
+
earlierVersion: "file",
|
|
3367
3770
|
oldText,
|
|
3368
3771
|
newText
|
|
3369
3772
|
});
|
|
@@ -3371,7 +3774,7 @@ async function svnChanges(input) {
|
|
|
3371
3774
|
const newText = await readText(absolute) ?? "";
|
|
3372
3775
|
changes.push({
|
|
3373
3776
|
path: absolute,
|
|
3374
|
-
|
|
3777
|
+
earlierVersion: "none",
|
|
3375
3778
|
oldText: "",
|
|
3376
3779
|
newText
|
|
3377
3780
|
});
|
|
@@ -3379,7 +3782,7 @@ async function svnChanges(input) {
|
|
|
3379
3782
|
const newText = await readText(absolute) ?? "";
|
|
3380
3783
|
changes.push({
|
|
3381
3784
|
path: absolute,
|
|
3382
|
-
|
|
3785
|
+
earlierVersion: "none",
|
|
3383
3786
|
oldText: "",
|
|
3384
3787
|
newText
|
|
3385
3788
|
});
|
|
@@ -3424,13 +3827,13 @@ async function p4Changes(input) {
|
|
|
3424
3827
|
let oldText = "";
|
|
3425
3828
|
if (!created) try {
|
|
3426
3829
|
oldText = await runShell(shell, `p4 print -q ${shq(opened.depot)}#have`, root, signal);
|
|
3427
|
-
} catch {
|
|
3428
|
-
|
|
3830
|
+
} catch (error) {
|
|
3831
|
+
throw new Error(`could not read ${opened.depot}#have: ${errorMessage$1(error)}`);
|
|
3429
3832
|
}
|
|
3430
3833
|
if (oldText === "" && newText === "") continue;
|
|
3431
3834
|
changes.push({
|
|
3432
3835
|
path: absolute,
|
|
3433
|
-
|
|
3836
|
+
earlierVersion: created ? "none" : "file",
|
|
3434
3837
|
oldText,
|
|
3435
3838
|
newText
|
|
3436
3839
|
});
|
|
@@ -3451,6 +3854,229 @@ async function listVcsChanges(input) {
|
|
|
3451
3854
|
case "p4": return p4Changes(input);
|
|
3452
3855
|
}
|
|
3453
3856
|
}
|
|
3857
|
+
/**
|
|
3858
|
+
* Read one file's BASELINE content — the version every branch above reads to build a change's
|
|
3859
|
+
* `oldText`: git's last commit (`HEAD`, when the scan could not see the change), svn's `BASE`, p4's
|
|
3860
|
+
* `#have`. The commands are the same ones those branches run, so a caller that needs one file's
|
|
3861
|
+
* pre-change content outside a scan (the per-file refresh's "was this committed?" check) does not
|
|
3862
|
+
* invent a second way to ask the same question — or a second set of failure modes to reason about.
|
|
3863
|
+
*
|
|
3864
|
+
* The two answers stay distinguishable, exactly as they do in a scan: a baseline that carries no
|
|
3865
|
+
* version of the path is {@link VcsBaseline} `none` (git is the only branch that can report it,
|
|
3866
|
+
* through {@link readGitBlob}'s own absence pattern), while a read that FAILED — a refused command,
|
|
3867
|
+
* a timeout, a missing client — throws, because "could not read" is not "there is nothing there".
|
|
3868
|
+
*
|
|
3869
|
+
* @param kind - the VCS to read.
|
|
3870
|
+
* @param root - the repository/checkout/client root, as {@link detectVcsRoot} found it.
|
|
3871
|
+
* @param absolutePath - the file's absolute path (inside the root's tree).
|
|
3872
|
+
* @param shell - the deployment's shell executor.
|
|
3873
|
+
* @param signal - the caller's abort signal.
|
|
3874
|
+
* @returns the baseline content, or `none` when the baseline has no version of the path.
|
|
3875
|
+
* @throws when the baseline read failed for any other reason.
|
|
3876
|
+
*/
|
|
3877
|
+
async function readVcsBaseline(kind, root, absolutePath, shell, signal) {
|
|
3878
|
+
switch (kind) {
|
|
3879
|
+
case "git": {
|
|
3880
|
+
const blob = await readGitBlob(root, "HEAD", relative(root, resolve(absolutePath)).split(sep).join("/"), shell, signal);
|
|
3881
|
+
return blob.found ? {
|
|
3882
|
+
state: "file",
|
|
3883
|
+
text: blob.text
|
|
3884
|
+
} : { state: "none" };
|
|
3885
|
+
}
|
|
3886
|
+
case "svn": try {
|
|
3887
|
+
return {
|
|
3888
|
+
state: "file",
|
|
3889
|
+
text: await runShell(shell, `svn cat -r BASE ${shq(absolutePath)}`, root, signal)
|
|
3890
|
+
};
|
|
3891
|
+
} catch (error) {
|
|
3892
|
+
throw new Error(`could not read ${absolutePath} at BASE: ${errorMessage$1(error)}`);
|
|
3893
|
+
}
|
|
3894
|
+
case "p4": try {
|
|
3895
|
+
return {
|
|
3896
|
+
state: "file",
|
|
3897
|
+
text: await runShell(shell, `p4 print -q ${shq(absolutePath)}#have`, root, signal)
|
|
3898
|
+
};
|
|
3899
|
+
} catch (error) {
|
|
3900
|
+
throw new Error(`could not read ${absolutePath}#have: ${errorMessage$1(error)}`);
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
}
|
|
3904
|
+
/**
|
|
3905
|
+
* Parse `major.minor.patch`, and NOTHING else.
|
|
3906
|
+
*
|
|
3907
|
+
* Deliberately strict, because the alternative is a bogus "newer": a pre-release
|
|
3908
|
+
* (`0.31.0-rc.1`), a range (`^0.31.0`), a dist-tag (`latest`) or a stray suffix all
|
|
3909
|
+
* answer `undefined`, and an unparsable side of a comparison is never allowed to look
|
|
3910
|
+
* newer. A leading `v` is tolerated because tags carry one.
|
|
3911
|
+
*
|
|
3912
|
+
* @param text - the candidate, from a registry document or a `package.json`.
|
|
3913
|
+
* @returns the three numbers, or `undefined` when the text is not exactly that shape.
|
|
3914
|
+
*/
|
|
3915
|
+
function parseVersion(text) {
|
|
3916
|
+
if (typeof text !== "string") return void 0;
|
|
3917
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)$/u.exec(text.trim());
|
|
3918
|
+
if (match === null) return void 0;
|
|
3919
|
+
const parts = [
|
|
3920
|
+
Number(match[1]),
|
|
3921
|
+
Number(match[2]),
|
|
3922
|
+
Number(match[3])
|
|
3923
|
+
];
|
|
3924
|
+
if (parts.some((part) => !Number.isSafeInteger(part))) return void 0;
|
|
3925
|
+
return parts;
|
|
3926
|
+
}
|
|
3927
|
+
/**
|
|
3928
|
+
* Is `latest` strictly newer than `current`?
|
|
3929
|
+
*
|
|
3930
|
+
* Strictly: equal versions are NOT newer, and neither side parsing means NOT newer —
|
|
3931
|
+
* the rule the code above exists to make safe. Comparison is component-wise on the
|
|
3932
|
+
* three numbers, so `0.9.0` precedes `0.10.0` (string comparison would get that wrong).
|
|
3933
|
+
*
|
|
3934
|
+
* @param latest - the published version.
|
|
3935
|
+
* @param current - the installed version.
|
|
3936
|
+
* @returns whether a notice is warranted.
|
|
3937
|
+
*/
|
|
3938
|
+
function isNewerVersion(latest, current) {
|
|
3939
|
+
const next = parseVersion(latest);
|
|
3940
|
+
const here = parseVersion(current);
|
|
3941
|
+
if (next === void 0 || here === void 0) return false;
|
|
3942
|
+
return compareVersions(next, here) > 0;
|
|
3943
|
+
}
|
|
3944
|
+
/**
|
|
3945
|
+
* Compare two already-parsed versions.
|
|
3946
|
+
*
|
|
3947
|
+
* Component-wise, because a string comparison gets `0.9.0` vs `0.10.0` wrong — the reason both
|
|
3948
|
+
* callers go through here rather than comparing something textual.
|
|
3949
|
+
*
|
|
3950
|
+
* @param left - one version.
|
|
3951
|
+
* @param right - the other.
|
|
3952
|
+
* @returns negative, zero or positive, as a comparator should.
|
|
3953
|
+
*/
|
|
3954
|
+
function compareVersions(left, right) {
|
|
3955
|
+
for (let index = 0; index < 3; index += 1) if (left[index] !== right[index]) return left[index] - right[index];
|
|
3956
|
+
return 0;
|
|
3957
|
+
}
|
|
3958
|
+
/**
|
|
3959
|
+
* The version installed in this package, read from the `package.json` beside the built
|
|
3960
|
+
* `lib/` — the only authority on what the reader is running, and the value the registry
|
|
3961
|
+
* answer is compared against.
|
|
3962
|
+
*
|
|
3963
|
+
* Resolved relative to this module, so it holds for the bundled `lib/index.js`
|
|
3964
|
+
* (`../package.json` is the package root) and for the source under test (`../package.json`
|
|
3965
|
+
* is the repository root) alike.
|
|
3966
|
+
*
|
|
3967
|
+
* @param manifestUrl - the manifest to read; the package's own `package.json` unless a caller
|
|
3968
|
+
* says otherwise. It is a parameter because a test runner can serve modules over a non-file
|
|
3969
|
+
* URL, where the default cannot be resolved — the production path is the default, and the
|
|
3970
|
+
* end-to-end case is what proves the default itself.
|
|
3971
|
+
* @returns the version, or `undefined` when the file is missing or has none.
|
|
3972
|
+
*/
|
|
3973
|
+
async function installedVersion(manifestUrl = new URL("../package.json", import.meta.url)) {
|
|
3974
|
+
try {
|
|
3975
|
+
const text = await readFile(manifestUrl, "utf8");
|
|
3976
|
+
const document = JSON.parse(text);
|
|
3977
|
+
return typeof document.version === "string" && document.version.length > 0 ? document.version : void 0;
|
|
3978
|
+
} catch {
|
|
3979
|
+
return;
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
/**
|
|
3983
|
+
* Ask the registry for the published version.
|
|
3984
|
+
*
|
|
3985
|
+
* @param probe - where to ask and how; every field defaults (see {@link UpdateProbe}).
|
|
3986
|
+
* @returns the version, or `undefined` on any failure — including an answer that is not
|
|
3987
|
+
* a version, which must never become a notice.
|
|
3988
|
+
*/
|
|
3989
|
+
async function fetchLatestVersion(probe = {}) {
|
|
3990
|
+
const body = await fetchText(probe.registryUrl ?? "https://registry.npmjs.org/dsh-diff-approval/latest", probe);
|
|
3991
|
+
if (body === void 0) return void 0;
|
|
3992
|
+
try {
|
|
3993
|
+
const document = JSON.parse(body);
|
|
3994
|
+
return parseVersion(document.version) === void 0 ? void 0 : String(document.version);
|
|
3995
|
+
} catch {
|
|
3996
|
+
return;
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
/**
|
|
4000
|
+
* Build the answer behind the `update-check` endpoint: one function a host calls per request.
|
|
4001
|
+
*
|
|
4002
|
+
* WHAT IS CACHED, AND FOR HOW LONG — the whole point of this factory:
|
|
4003
|
+
*
|
|
4004
|
+
* - The REMOTE answer (the registry's latest version) is cached, because that is the network cost. It is held
|
|
4005
|
+
* for {@link UPDATE_CACHE_TTL_MS} and then fetched again, so a release published while the host is running
|
|
4006
|
+
* still reaches the reader without a restart. A FAILED fetch is not cached at all: remembering a failure
|
|
4007
|
+
* would hide the notice for the whole TTL after one offline moment, and there is no network cost to save.
|
|
4008
|
+
* - The INSTALLED version is read from the manifest on EVERY call. It is a file read, not a request, and it is
|
|
4009
|
+
* the side that changes when the reader upgrades — so a verdict computed before that upgrade must never be
|
|
4010
|
+
* replayed. Caching it was a real defect: a host that answered while an older version was installed kept
|
|
4011
|
+
* announcing "a newer release" to every page until it was restarted.
|
|
4012
|
+
*
|
|
4013
|
+
* `newer` is therefore decided afresh on every call, from today's manifest against the cached registry answer.
|
|
4014
|
+
* NEVER THROWS: every failure — no manifest, an unreachable registry, a nonsense answer — ends as "nothing to
|
|
4015
|
+
* say" (`newer: false`), so a network problem can never become an error in the reader's status bar.
|
|
4016
|
+
*
|
|
4017
|
+
* @param options - where to read and how; every field defaults (see {@link UpdateCheckOptions}).
|
|
4018
|
+
* @returns the check itself; call it as often as you like.
|
|
4019
|
+
*/
|
|
4020
|
+
function createUpdateCheck(options = {}) {
|
|
4021
|
+
const ttl = options.ttlMs ?? 6e5;
|
|
4022
|
+
const now = options.now ?? (() => Date.now());
|
|
4023
|
+
const probe = {
|
|
4024
|
+
...options.registryUrl === void 0 ? {} : { registryUrl: options.registryUrl },
|
|
4025
|
+
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
|
|
4026
|
+
...options.fetchImpl === void 0 ? {} : { fetchImpl: options.fetchImpl }
|
|
4027
|
+
};
|
|
4028
|
+
const manifestUrl = options.manifestUrl ?? new URL("../package.json", import.meta.url);
|
|
4029
|
+
/** The cached REMOTE answer, with the moment it was fetched. */
|
|
4030
|
+
let remote;
|
|
4031
|
+
return async () => {
|
|
4032
|
+
const current = await installedVersion(manifestUrl);
|
|
4033
|
+
if (current === void 0) return {
|
|
4034
|
+
current: "",
|
|
4035
|
+
newer: false
|
|
4036
|
+
};
|
|
4037
|
+
const at = now();
|
|
4038
|
+
if (remote === void 0 || at - remote.at >= ttl) {
|
|
4039
|
+
const fetched = await fetchLatestVersion(probe);
|
|
4040
|
+
if (fetched !== void 0) remote = {
|
|
4041
|
+
version: fetched,
|
|
4042
|
+
at
|
|
4043
|
+
};
|
|
4044
|
+
}
|
|
4045
|
+
const latest = remote?.version;
|
|
4046
|
+
if (latest === void 0 || !isNewerVersion(latest, current)) return {
|
|
4047
|
+
current,
|
|
4048
|
+
...latest === void 0 ? {} : { latest },
|
|
4049
|
+
newer: false
|
|
4050
|
+
};
|
|
4051
|
+
return {
|
|
4052
|
+
current,
|
|
4053
|
+
latest,
|
|
4054
|
+
newer: true
|
|
4055
|
+
};
|
|
4056
|
+
};
|
|
4057
|
+
}
|
|
4058
|
+
/** One `GET`, with the module's timeout, mapping every failure to `undefined`. */
|
|
4059
|
+
async function fetchText(url, probe) {
|
|
4060
|
+
const implementation = probe.fetchImpl ?? globalThis.fetch;
|
|
4061
|
+
if (implementation === void 0) return void 0;
|
|
4062
|
+
const controller = new AbortController();
|
|
4063
|
+
const timer = setTimeout(() => {
|
|
4064
|
+
controller.abort();
|
|
4065
|
+
}, probe.timeoutMs ?? 3e3);
|
|
4066
|
+
timer.unref?.();
|
|
4067
|
+
try {
|
|
4068
|
+
const response = await implementation(url, {
|
|
4069
|
+
signal: controller.signal,
|
|
4070
|
+
headers: { accept: "application/json, text/plain, */*" }
|
|
4071
|
+
});
|
|
4072
|
+
if (!response.ok) return void 0;
|
|
4073
|
+
return await response.text();
|
|
4074
|
+
} catch {
|
|
4075
|
+
return;
|
|
4076
|
+
} finally {
|
|
4077
|
+
clearTimeout(timer);
|
|
4078
|
+
}
|
|
4079
|
+
}
|
|
3454
4080
|
//#endregion
|
|
3455
4081
|
//#region lib/types/index.js
|
|
3456
4082
|
/**
|
|
@@ -3648,6 +4274,29 @@ function normalizeEol(text) {
|
|
|
3648
4274
|
function contentEqual(a, b) {
|
|
3649
4275
|
return contentLinesOf(normalizeEol(a)).join("\n") === contentLinesOf(normalizeEol(b)).join("\n");
|
|
3650
4276
|
}
|
|
4277
|
+
/**
|
|
4278
|
+
* Whether one entry's change is COMMITTED: its content on disk equals its content at the VCS
|
|
4279
|
+
* baseline.
|
|
4280
|
+
*
|
|
4281
|
+
* Read off the same helpers a scan uses — {@link readVcsBaseline} runs the very commands
|
|
4282
|
+
* `listVcsChanges` runs to build a change's `oldText` (git `HEAD`, svn `BASE`, p4 `#have`) — and
|
|
4283
|
+
* compared with {@link contentEqual}, the tolerance the whole-file diff already uses. A baseline
|
|
4284
|
+
* that carries no version of the path (a file the scan cannot see because it was never
|
|
4285
|
+
* committed-and-modified) is NOT "committed": it answers false, exactly like a baseline whose
|
|
4286
|
+
* content differs. Anything that failed to read throws, for the caller to read as "not proven".
|
|
4287
|
+
*
|
|
4288
|
+
* @param root - the detected VCS root.
|
|
4289
|
+
* @param kind - the VCS to read the baseline from.
|
|
4290
|
+
* @param path - the entry's absolute file path.
|
|
4291
|
+
* @param shell - the deployment's shell executor.
|
|
4292
|
+
* @param signal - the caller's abort signal.
|
|
4293
|
+
* @returns whether the on-disk content is the baseline's.
|
|
4294
|
+
*/
|
|
4295
|
+
async function committedChange(root, kind, path, shell, signal) {
|
|
4296
|
+
const baseline = await readVcsBaseline(kind, root, path, shell, signal);
|
|
4297
|
+
if (baseline.state !== "file") return false;
|
|
4298
|
+
return contentEqual(await readFile(path, "utf8"), baseline.text);
|
|
4299
|
+
}
|
|
3651
4300
|
/** Re-encode `text`'s line endings to `eol` (its content is unchanged). */
|
|
3652
4301
|
function reencodeEol(text, eol) {
|
|
3653
4302
|
const normalized = normalizeEol(text);
|
|
@@ -3667,7 +4316,7 @@ function editOutcomeOf(value) {
|
|
|
3667
4316
|
if (typeof before !== "string" || typeof after !== "string") return void 0;
|
|
3668
4317
|
return {
|
|
3669
4318
|
path,
|
|
3670
|
-
|
|
4319
|
+
earlierVersion: "file",
|
|
3671
4320
|
oldText: before,
|
|
3672
4321
|
newText: after
|
|
3673
4322
|
};
|
|
@@ -3675,10 +4324,11 @@ function editOutcomeOf(value) {
|
|
|
3675
4324
|
/**
|
|
3676
4325
|
* Narrow a successful `write` result value to an operation outcome. The write
|
|
3677
4326
|
* tool's output schema declares `{ path, operation, before, after }`;
|
|
3678
|
-
* `operation: 'create'` becomes
|
|
3679
|
-
*
|
|
3680
|
-
*
|
|
3681
|
-
*
|
|
4327
|
+
* `operation: 'create'` becomes an entry with `earlierVersion: 'none'` (there is
|
|
4328
|
+
* no earlier version, so the whole-file action removes the file),
|
|
4329
|
+
* `operation: 'update'` one with `earlierVersion: 'file'`. An update whose
|
|
4330
|
+
* `before` is null carried no contextual basis, so it is skipped rather than
|
|
4331
|
+
* tracked as an un-revertable overwrite.
|
|
3682
4332
|
* @param value - the successful result's JSON value.
|
|
3683
4333
|
* @returns the outcome, or `undefined` when the value is not a trackable write.
|
|
3684
4334
|
*/
|
|
@@ -3690,14 +4340,14 @@ function writeOutcomeOf(value) {
|
|
|
3690
4340
|
if (typeof after !== "string") return void 0;
|
|
3691
4341
|
if (operation === "create") return {
|
|
3692
4342
|
path,
|
|
3693
|
-
|
|
4343
|
+
earlierVersion: "none",
|
|
3694
4344
|
oldText: "",
|
|
3695
4345
|
newText: after
|
|
3696
4346
|
};
|
|
3697
4347
|
if (typeof before !== "string") return void 0;
|
|
3698
4348
|
return {
|
|
3699
4349
|
path,
|
|
3700
|
-
|
|
4350
|
+
earlierVersion: "file",
|
|
3701
4351
|
oldText: before,
|
|
3702
4352
|
newText: after
|
|
3703
4353
|
};
|
|
@@ -3802,6 +4452,31 @@ function rpcError(message) {
|
|
|
3802
4452
|
}
|
|
3803
4453
|
};
|
|
3804
4454
|
}
|
|
4455
|
+
/** The process's one update check: see {@link checkForUpdate}. */
|
|
4456
|
+
let updateCheck;
|
|
4457
|
+
/**
|
|
4458
|
+
* Whether a newer release of this package is published — the answer behind the channel's
|
|
4459
|
+
* `update-check` endpoint.
|
|
4460
|
+
*
|
|
4461
|
+
* ONE CHECK PER PROCESS, and what it caches is the REMOTE half only (`createUpdateCheck` in `version.ts`):
|
|
4462
|
+
* the registry's latest version, held for a short TTL, while the INSTALLED version is read from the manifest
|
|
4463
|
+
* on every call. So an upgrade, a reinstall or a downgrade under a running host is reflected immediately,
|
|
4464
|
+
* and a release published while the host runs appears within the TTL rather than at the next restart.
|
|
4465
|
+
*
|
|
4466
|
+
* NEVER THROWS and never rejects: the endpoint must not be able to turn a network
|
|
4467
|
+
* problem into an error in the reader's status bar. A failure to read the version at
|
|
4468
|
+
* all (a stripped install with no `package.json`) answers `newer: false`.
|
|
4469
|
+
*
|
|
4470
|
+
* The registry URL can be overridden by environment — `DSH_DIFF_APPROVAL_UPDATE_REGISTRY_URL` — because
|
|
4471
|
+
* the end-to-end case must not depend on the public internet. It is read HERE, on the first call, which is
|
|
4472
|
+
* after a test has set it and before the host answers anything. It is the ONLY such override.
|
|
4473
|
+
*
|
|
4474
|
+
* @returns what the panel needs to decide about its notice.
|
|
4475
|
+
*/
|
|
4476
|
+
function checkForUpdate() {
|
|
4477
|
+
updateCheck ??= createUpdateCheck({ ...process.env.DSH_DIFF_APPROVAL_UPDATE_REGISTRY_URL === void 0 ? {} : { registryUrl: process.env.DSH_DIFF_APPROVAL_UPDATE_REGISTRY_URL } });
|
|
4478
|
+
return updateCheck();
|
|
4479
|
+
}
|
|
3805
4480
|
/**
|
|
3806
4481
|
* Mount the pending-edit review surface.
|
|
3807
4482
|
* @param ctx - Cordis context carrying the filesystem, connection, and workspace registry services.
|
|
@@ -3845,12 +4520,14 @@ function apply(ctx, config) {
|
|
|
3845
4520
|
* A comment whose quote is gone from the content is ABSENT from the map rather than guessed at:
|
|
3846
4521
|
* the caller then falls back to `record.anchor`, the line the comment was written on, which is
|
|
3847
4522
|
* the same answer an outdated thread shows.
|
|
3848
|
-
* @param
|
|
4523
|
+
* @param listed - the comments this read is handing over: the caller took ONE scoped list and reuses it
|
|
4524
|
+
* here, because resolving lines is derived from a record's own fields (`quote`, `anchor`, `entryId`),
|
|
4525
|
+
* none of which a fold touches — so the pre-fold and post-fold lists resolve identically.
|
|
3849
4526
|
* @returns the resolved lines per comment id; comments that do not resolve are left out.
|
|
3850
4527
|
*/
|
|
3851
|
-
function resolvedCommentLines(
|
|
4528
|
+
function resolvedCommentLines(listed) {
|
|
3852
4529
|
const resolved = {};
|
|
3853
|
-
for (const comment of
|
|
4530
|
+
for (const comment of listed) {
|
|
3854
4531
|
const entry = store.get(comment.entryId);
|
|
3855
4532
|
if (entry === void 0) continue;
|
|
3856
4533
|
const version = store.contentVersion(comment.entryId);
|
|
@@ -3909,10 +4586,16 @@ function apply(ctx, config) {
|
|
|
3909
4586
|
};
|
|
3910
4587
|
registerAnnotateTool(ctx, {
|
|
3911
4588
|
ready: ensureLoaded,
|
|
3912
|
-
entriesOf: (sessionId) =>
|
|
3913
|
-
|
|
4589
|
+
entriesOf: (sessionId) => {
|
|
4590
|
+
const view = lineageView();
|
|
4591
|
+
return store.all().filter((entry) => view.sees(sessionId, entry));
|
|
4592
|
+
},
|
|
4593
|
+
commentsOf: (sessionId) => comments.list(commentScopeOf(sessionId, lineageView())),
|
|
3914
4594
|
addComment: (record) => {
|
|
3915
|
-
comments.add(record
|
|
4595
|
+
comments.add(record.author === "agent" ? {
|
|
4596
|
+
...record,
|
|
4597
|
+
unseen: true
|
|
4598
|
+
} : record);
|
|
3916
4599
|
},
|
|
3917
4600
|
listFile: (sessionId, asked, signal) => listFileForAnnotation(sessionId, asked, signal),
|
|
3918
4601
|
log: (message) => ctx.logger.info(`diff-approval: ${message}`)
|
|
@@ -3954,11 +4637,12 @@ function apply(ctx, config) {
|
|
|
3954
4637
|
id: absolute,
|
|
3955
4638
|
sessionId,
|
|
3956
4639
|
path: absolute,
|
|
3957
|
-
|
|
4640
|
+
earlierVersion: "file",
|
|
3958
4641
|
oldText: content,
|
|
3959
4642
|
newText: content,
|
|
3960
4643
|
updatedAt: Date.now(),
|
|
3961
|
-
sessionIds: [sessionId]
|
|
4644
|
+
sessionIds: [sessionId],
|
|
4645
|
+
unseen: true
|
|
3962
4646
|
}], true);
|
|
3963
4647
|
const landed = store.list(sessionId).find((entry) => pathIdentity(entry.path) === pathIdentity(absolute));
|
|
3964
4648
|
return landed === void 0 ? { kind: "missing" } : {
|
|
@@ -3987,28 +4671,30 @@ function apply(ctx, config) {
|
|
|
3987
4671
|
const removed = store.remove(id);
|
|
3988
4672
|
comments.removeForEntry(id);
|
|
3989
4673
|
forgetCommentLinesForEntry(id);
|
|
4674
|
+
liveObservations.delete(id);
|
|
3990
4675
|
return removed;
|
|
3991
4676
|
}
|
|
3992
4677
|
/** Pre-write bases captured at the intent seams, keyed by the tool call id. */
|
|
3993
4678
|
const editorIntents = /* @__PURE__ */ new Map();
|
|
3994
4679
|
/**
|
|
3995
|
-
* Snapshot one str_replace_editor mutation's basis at its intent seam. A
|
|
3996
|
-
*
|
|
3997
|
-
* failure tracks nothing — the settle-side pairing then
|
|
4680
|
+
* Snapshot one str_replace_editor mutation's basis at its intent seam. A file
|
|
4681
|
+
* creation has no earlier version, so its basis is empty; an edit reads the
|
|
4682
|
+
* pre-write content. Any failure tracks nothing — the settle-side pairing then
|
|
4683
|
+
* sees no basis.
|
|
3998
4684
|
* @param target - the resolved target about to be written.
|
|
3999
4685
|
* @param actor - the tool execution running the mutation.
|
|
4000
|
-
* @param
|
|
4686
|
+
* @param earlierVersion - whether the mutation creates the file (`'none'`: no earlier version) or edits it (`'file'`).
|
|
4001
4687
|
*/
|
|
4002
|
-
async function stashEditorIntent(target, actor,
|
|
4688
|
+
async function stashEditorIntent(target, actor, earlierVersion) {
|
|
4003
4689
|
const shaped = actorOf(actor);
|
|
4004
4690
|
if (shaped === void 0 || shaped.name !== "str_replace_editor") return;
|
|
4005
4691
|
if (typeof shaped.callId !== "string") return;
|
|
4006
4692
|
const sessionId = sessionOfAgent(shaped.agent);
|
|
4007
4693
|
if (sessionId === void 0) return;
|
|
4008
|
-
if (
|
|
4694
|
+
if (earlierVersion === "none") {
|
|
4009
4695
|
editorIntents.set(shaped.callId, {
|
|
4010
4696
|
target,
|
|
4011
|
-
|
|
4697
|
+
earlierVersion,
|
|
4012
4698
|
before: "",
|
|
4013
4699
|
sessionId
|
|
4014
4700
|
});
|
|
@@ -4018,7 +4704,7 @@ function apply(ctx, config) {
|
|
|
4018
4704
|
const before = await ctx.fs.readText(target, void 0) ?? "";
|
|
4019
4705
|
editorIntents.set(shaped.callId, {
|
|
4020
4706
|
target,
|
|
4021
|
-
|
|
4707
|
+
earlierVersion,
|
|
4022
4708
|
before,
|
|
4023
4709
|
sessionId
|
|
4024
4710
|
});
|
|
@@ -4037,29 +4723,43 @@ function apply(ctx, config) {
|
|
|
4037
4723
|
if (basis === void 0 || basis.sessionId === void 0 || result.isError) return;
|
|
4038
4724
|
const argumentsValue = exec.arguments;
|
|
4039
4725
|
const command = typeof argumentsValue === "object" && argumentsValue !== null ? argumentsValue.command : void 0;
|
|
4040
|
-
if (!(basis.
|
|
4726
|
+
if (!(basis.earlierVersion === "none" ? command === "create" : command === "str_replace" || command === "insert")) return;
|
|
4041
4727
|
let after;
|
|
4042
4728
|
try {
|
|
4043
4729
|
after = await ctx.fs.readText(basis.target, void 0) ?? "";
|
|
4044
4730
|
} catch {
|
|
4045
4731
|
return;
|
|
4046
4732
|
}
|
|
4047
|
-
if (basis.
|
|
4733
|
+
if (basis.earlierVersion === "file" && after === basis.before) return;
|
|
4048
4734
|
const sessionId = basis.sessionId;
|
|
4049
4735
|
const path = basis.target.displayPath;
|
|
4050
4736
|
const entry = {
|
|
4051
4737
|
id: path,
|
|
4052
4738
|
sessionId,
|
|
4053
4739
|
path,
|
|
4054
|
-
|
|
4740
|
+
earlierVersion: basis.earlierVersion,
|
|
4055
4741
|
oldText: basis.before,
|
|
4056
4742
|
newText: after,
|
|
4057
4743
|
updatedAt: Date.now(),
|
|
4058
|
-
sessionIds: [sessionId]
|
|
4744
|
+
sessionIds: [sessionId],
|
|
4745
|
+
lineage: lineageOf(sessionId, exec.agent),
|
|
4746
|
+
unseen: true
|
|
4059
4747
|
};
|
|
4060
4748
|
await ensureLoaded();
|
|
4061
4749
|
if (store.fold(entry)) persistSession();
|
|
4062
4750
|
}
|
|
4751
|
+
/**
|
|
4752
|
+
* What was last READ from one tracked path, and the freshness token that read was taken at.
|
|
4753
|
+
*
|
|
4754
|
+
* `ctx.fs.stat` returns no `mtimeMs` (see `FsStat`): its freshness signal is `version`, an opaque token
|
|
4755
|
+
* the backend documents as "the freshness token a write/edit guards against" and derives from the file's
|
|
4756
|
+
* identity and times — the local backend builds it as `dev:ino:size:mtimeNs:ctimeNs`, so it moves when
|
|
4757
|
+
* the size or either timestamp does, which is exactly the `mtimeMs`+`size` signal wanted here. `size` is
|
|
4758
|
+
* carried beside it as a second, plain-text field.
|
|
4759
|
+
*
|
|
4760
|
+
* In memory only, one entry per tracked path: a restart has read nothing, so it observes afresh.
|
|
4761
|
+
*/
|
|
4762
|
+
const liveObservations = /* @__PURE__ */ new Map();
|
|
4063
4763
|
/** The stable `FsError.code`, when the thrown value carries one. */
|
|
4064
4764
|
function fsErrorCodeOf(error) {
|
|
4065
4765
|
if (typeof error !== "object" || error === null) return void 0;
|
|
@@ -4067,13 +4767,16 @@ function apply(ctx, config) {
|
|
|
4067
4767
|
return typeof code === "string" ? code : void 0;
|
|
4068
4768
|
}
|
|
4069
4769
|
/**
|
|
4070
|
-
*
|
|
4071
|
-
*
|
|
4072
|
-
*
|
|
4770
|
+
* Ask the backend where one tracked path is and what it is, reading NOTHING.
|
|
4771
|
+
*
|
|
4772
|
+
* This is the whole liveness test, shared by the full read (`liveStateOf`) and the light count
|
|
4773
|
+
* (`list-count`): `resolve` + `stat`, with `undefined` from `stat` meaning the target is absent, and a
|
|
4774
|
+
* thrown `FS_NOT_FOUND` meaning the same one step earlier. `version`/`size` come back as `undefined` when
|
|
4775
|
+
* the backend does not report them, which is what tells `liveStateOf` it has no freshness evidence.
|
|
4073
4776
|
* @param path - backend display path to probe through `ctx.fs`.
|
|
4074
|
-
* @returns the
|
|
4777
|
+
* @returns what the backend says, and the target a caller may then read.
|
|
4075
4778
|
*/
|
|
4076
|
-
async function
|
|
4779
|
+
async function probePath(path) {
|
|
4077
4780
|
let target;
|
|
4078
4781
|
try {
|
|
4079
4782
|
target = await ctx.fs.resolve(path, {});
|
|
@@ -4087,12 +4790,55 @@ function apply(ctx, config) {
|
|
|
4087
4790
|
return fsErrorCodeOf(error) === "FS_NOT_FOUND" ? { kind: "deleted" } : { kind: "unavailable" };
|
|
4088
4791
|
}
|
|
4089
4792
|
if (info === void 0) return { kind: "deleted" };
|
|
4793
|
+
return {
|
|
4794
|
+
kind: "present",
|
|
4795
|
+
target,
|
|
4796
|
+
version: typeof info.version === "string" && info.version.length > 0 ? info.version : void 0,
|
|
4797
|
+
size: typeof info.size === "number" ? info.size : void 0
|
|
4798
|
+
};
|
|
4799
|
+
}
|
|
4800
|
+
/**
|
|
4801
|
+
* Read one path's live state. The only existence test is `stat`: it returns
|
|
4802
|
+
* `undefined` for an absent target (gone), so a deleted file never falls into
|
|
4803
|
+
* the unreadable bucket. A file that exists but cannot be read is `unavailable`.
|
|
4804
|
+
*
|
|
4805
|
+
* The CONTENT is not re-read when the backend's own freshness evidence says the file has not moved since
|
|
4806
|
+
* the last observation: an identical `version` AND an identical numeric `size`, both reported. The stat
|
|
4807
|
+
* still runs — it is the cheap half, and it is what notices a file that has gone — so a deleted file is
|
|
4808
|
+
* still `deleted`, a moved one is read again, and `diverged`/`missing` are computed from real content
|
|
4809
|
+
* either way. The skip needs BOTH fields: a stat that reports no size is a weaker observation than the
|
|
4810
|
+
* contract allows, so it falls back to reading rather than trusting a token alone.
|
|
4811
|
+
* @param path - backend display path to probe through `ctx.fs`.
|
|
4812
|
+
* @returns the live state.
|
|
4813
|
+
*/
|
|
4814
|
+
async function liveStateOf(path) {
|
|
4815
|
+
const probe = await probePath(path);
|
|
4816
|
+
if (probe.kind !== "present") {
|
|
4817
|
+
liveObservations.delete(path);
|
|
4818
|
+
return probe;
|
|
4819
|
+
}
|
|
4820
|
+
const { target, version, size } = probe;
|
|
4821
|
+
const trustable = version !== void 0 && size !== void 0;
|
|
4822
|
+
if (trustable) {
|
|
4823
|
+
const observed = liveObservations.get(path);
|
|
4824
|
+
if (observed !== void 0 && observed.version === version && observed.size === size) return {
|
|
4825
|
+
kind: "present",
|
|
4826
|
+
content: observed.content
|
|
4827
|
+
};
|
|
4828
|
+
}
|
|
4090
4829
|
try {
|
|
4830
|
+
const content = await ctx.fs.readText(target, void 0);
|
|
4831
|
+
if (trustable) liveObservations.set(path, {
|
|
4832
|
+
version,
|
|
4833
|
+
size,
|
|
4834
|
+
content
|
|
4835
|
+
});
|
|
4091
4836
|
return {
|
|
4092
4837
|
kind: "present",
|
|
4093
|
-
content
|
|
4838
|
+
content
|
|
4094
4839
|
};
|
|
4095
4840
|
} catch (error) {
|
|
4841
|
+
liveObservations.delete(path);
|
|
4096
4842
|
return fsErrorCodeOf(error) === "FS_NOT_FOUND" ? { kind: "deleted" } : { kind: "unavailable" };
|
|
4097
4843
|
}
|
|
4098
4844
|
}
|
|
@@ -4124,12 +4870,17 @@ function apply(ctx, config) {
|
|
|
4124
4870
|
* list and has its undo/redo records dropped, and externally changed content
|
|
4125
4871
|
* is adopted as the new baseline with its own checkpoint.
|
|
4126
4872
|
* @param sessionId - the session being listed (its session-scoped view).
|
|
4873
|
+
* @param view - the request's lineage view: the caller builds it once and hands it here, because the
|
|
4874
|
+
* list read needs the same view for the comment scope, the answer fold and the undo keys, and a
|
|
4875
|
+
* second `lineageView()` would walk the store again for nothing.
|
|
4876
|
+
* @param entries - that request's ONE `store.all()` snapshot (see `lineageView`).
|
|
4127
4877
|
* @returns the listed entries plus whether an external change cleared redo.
|
|
4128
4878
|
*/
|
|
4129
|
-
async function listWithState(sessionId) {
|
|
4879
|
+
async function listWithState(sessionId, view, entries) {
|
|
4130
4880
|
const listed = [];
|
|
4131
4881
|
let redoCleared = false;
|
|
4132
|
-
for (const entry of
|
|
4882
|
+
for (const entry of entries) {
|
|
4883
|
+
if (!view.sees(sessionId, entry)) continue;
|
|
4133
4884
|
const live = await liveStateOf(entry.path);
|
|
4134
4885
|
if (live.kind === "deleted") {
|
|
4135
4886
|
const checkpoint = droppingComments({
|
|
@@ -4144,7 +4895,7 @@ function apply(ctx, config) {
|
|
|
4144
4895
|
path: entry.path,
|
|
4145
4896
|
entry: void 0,
|
|
4146
4897
|
fileText: void 0
|
|
4147
|
-
});
|
|
4898
|
+
}, view);
|
|
4148
4899
|
persistSession();
|
|
4149
4900
|
continue;
|
|
4150
4901
|
}
|
|
@@ -4162,6 +4913,7 @@ function apply(ctx, config) {
|
|
|
4162
4913
|
const beforeText = entry.newText;
|
|
4163
4914
|
const redoWasPresent = redoStackOf(sessionId).length > 0;
|
|
4164
4915
|
store.update(entry.path, { newText: content });
|
|
4916
|
+
store.markUnseen(entry.path);
|
|
4165
4917
|
pushUndo(sessionId, {
|
|
4166
4918
|
id: entry.path,
|
|
4167
4919
|
path: entry.path,
|
|
@@ -4176,7 +4928,7 @@ function apply(ctx, config) {
|
|
|
4176
4928
|
updatedAt: Date.now()
|
|
4177
4929
|
},
|
|
4178
4930
|
fileText: content
|
|
4179
|
-
});
|
|
4931
|
+
}, view);
|
|
4180
4932
|
persistSession();
|
|
4181
4933
|
if (redoWasPresent) redoCleared = true;
|
|
4182
4934
|
}
|
|
@@ -4184,10 +4936,16 @@ function apply(ctx, config) {
|
|
|
4184
4936
|
missing: false,
|
|
4185
4937
|
diverged: hasContent ? content !== adopted : true
|
|
4186
4938
|
};
|
|
4939
|
+
const viaLineage = ownersOf(entry).includes(sessionId) ? void 0 : true;
|
|
4940
|
+
const hasChildContribution = ownersOf(entry).some((owner) => owner !== sessionId && view.sameRoot(owner, sessionId));
|
|
4941
|
+
const lineageDirection = view.directionOf(sessionId, entry);
|
|
4187
4942
|
listed.push({
|
|
4188
4943
|
...entry,
|
|
4189
4944
|
newText: adopted,
|
|
4190
|
-
...state
|
|
4945
|
+
...state,
|
|
4946
|
+
viaLineage,
|
|
4947
|
+
hasChildContribution,
|
|
4948
|
+
lineageDirection
|
|
4191
4949
|
});
|
|
4192
4950
|
}
|
|
4193
4951
|
return {
|
|
@@ -4217,7 +4975,7 @@ function apply(ctx, config) {
|
|
|
4217
4975
|
changedPaths.push(entry.path);
|
|
4218
4976
|
}
|
|
4219
4977
|
if (folded === 0) return 0;
|
|
4220
|
-
persistSession(true);
|
|
4978
|
+
await persistSession(true);
|
|
4221
4979
|
const after = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
|
|
4222
4980
|
const batchBefore = [];
|
|
4223
4981
|
const batchAfter = [];
|
|
@@ -4326,6 +5084,47 @@ function apply(ctx, config) {
|
|
|
4326
5084
|
for (const workspace of ctx.workspaceRegistry.list()) if (workspace.sessionIds.includes(sessionId)) return workspace;
|
|
4327
5085
|
}
|
|
4328
5086
|
/**
|
|
5087
|
+
* The identity one session's workspace is compared by, or `undefined` when this
|
|
5088
|
+
* deployment does not account the session.
|
|
5089
|
+
*
|
|
5090
|
+
* The merged view needs it because the pending list is NOT scoped by workspace: the store is one map
|
|
5091
|
+
* keyed by absolute path (`pending.ts`) persisted to one `pending.json` under the storage root
|
|
5092
|
+
* (`persist.ts`), and that root comes from the plugin's `storageDir` alone — the `workspaces/`
|
|
5093
|
+
* component of the default path is the LEGACY per-workspace layout whose files are folded in and
|
|
5094
|
+
* deleted on load. Nothing else in the list path consults the workspace, so a row recorded by a
|
|
5095
|
+
* session in another one would otherwise be listed under this session's view.
|
|
5096
|
+
*
|
|
5097
|
+
* `resolve` folds separators and `.`/`..`; Windows additionally compares case-insensitively, the same
|
|
5098
|
+
* way `withinSandboxRoot` and the backend's own containment do (`C:\Repo` and `c:\repo` are one
|
|
5099
|
+
* directory there). An ABSENT workspace, or one the registry records with no path, answers
|
|
5100
|
+
* `undefined` — "not known", never a guess: the caller keeps the pre-workspace behaviour for it
|
|
5101
|
+
* rather than refusing a row it cannot show to be elsewhere.
|
|
5102
|
+
* @param sessionId - the session whose workspace is wanted.
|
|
5103
|
+
* @returns the comparison key, or `undefined` when it is not known.
|
|
5104
|
+
*/
|
|
5105
|
+
function workspaceKeyOf(sessionId) {
|
|
5106
|
+
const path = workspaceOf(sessionId)?.path;
|
|
5107
|
+
if (typeof path !== "string" || path.length === 0) return void 0;
|
|
5108
|
+
const resolved = resolve(path);
|
|
5109
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
5110
|
+
}
|
|
5111
|
+
/**
|
|
5112
|
+
* Whether two workspaces, already reduced to comparison keys by {@link workspaceKeyOf}, are the same
|
|
5113
|
+
* one as far as this deployment can say.
|
|
5114
|
+
*
|
|
5115
|
+
* TRUE when either side is unknown, which is the whole point: an unknown workspace must not turn into
|
|
5116
|
+
* a refusal. The pending list is not scoped by workspace (see `workspaceKeyOf`), so a session the
|
|
5117
|
+
* registry does not account — a memory-only session, a row from a host that named no workspace, a
|
|
5118
|
+
* deployment whose registry the plugin cannot read — would otherwise lose rows it has always shown.
|
|
5119
|
+
* The check is for the case this code can actually see: two KNOWN roots that differ.
|
|
5120
|
+
* @param left - one session's key, or `undefined` when unknown.
|
|
5121
|
+
* @param right - the other's.
|
|
5122
|
+
* @returns whether they can be shown to share a workspace.
|
|
5123
|
+
*/
|
|
5124
|
+
function sameWorkspaceKey(left, right) {
|
|
5125
|
+
return left === void 0 || right === void 0 || left === right;
|
|
5126
|
+
}
|
|
5127
|
+
/**
|
|
4329
5128
|
* Resolve the file-sandbox policy for one session's Revert write, or
|
|
4330
5129
|
* undefined when no confining backend is mounted. A live session carries
|
|
4331
5130
|
* its workspace root through `ctx.sessions`; a persisted entry from a
|
|
@@ -4346,6 +5145,326 @@ function apply(ctx, config) {
|
|
|
4346
5145
|
};
|
|
4347
5146
|
}
|
|
4348
5147
|
/**
|
|
5148
|
+
* The session header entry lineage is read from.
|
|
5149
|
+
*
|
|
5150
|
+
* Two routes, in the order a capture can answer them. The live one is the tool execution's own agent:
|
|
5151
|
+
* `Agent` is AUGMENTED at run time with `session` (dsh-agent's `runtime-types`), and the DSH's own
|
|
5152
|
+
* bundled plugins read exactly that on a `tools/*` execution — `dsh-tool-present` uses
|
|
5153
|
+
* `exec.agent.session.header.cwd` and `dsh-experimental-agent-team` reads
|
|
5154
|
+
* `agent.session.header.parentSession` for precisely the lineage question this records. `Agent` is typed
|
|
5155
|
+
* `{ id }` in the types this package compiles against (the augmentation is not among them), so the field
|
|
5156
|
+
* is read structurally — the same way `sessionOfAgent` reads `id` off the same object.
|
|
5157
|
+
*
|
|
5158
|
+
* The fallback is this plugin's own session registry, which `sandboxPolicyOf` already consults
|
|
5159
|
+
* (`ctx.sessions.get(id)`); it is what answers when the agent carries no `session`, and it is also the
|
|
5160
|
+
* only route at a capture site that has no execution in hand (the editor-intent seam, version control,
|
|
5161
|
+
* a hand-added path). It is read through `ctx.get`, the way this file reads every optional service
|
|
5162
|
+
* (`sandboxPolicy`, `shell`): the capture must never fail because the lineage could not be asked for,
|
|
5163
|
+
* and a context that was never handed a session service throws on the plain property instead.
|
|
5164
|
+
* @param sessionId - the session whose header is wanted.
|
|
5165
|
+
* @param agent - the live agent of a tool execution, when the caller has one.
|
|
5166
|
+
* @returns the header, or `undefined` when neither route has one.
|
|
5167
|
+
*/
|
|
5168
|
+
function headerOf(sessionId, agent) {
|
|
5169
|
+
if (typeof agent === "object" && agent !== null) {
|
|
5170
|
+
const session = agent.session;
|
|
5171
|
+
if (typeof session === "object" && session !== null) {
|
|
5172
|
+
const header = session.header;
|
|
5173
|
+
if (typeof header === "object" && header !== null) return header;
|
|
5174
|
+
}
|
|
5175
|
+
}
|
|
5176
|
+
return sessionRegistry()?.get(sessionId)?.header;
|
|
5177
|
+
}
|
|
5178
|
+
/**
|
|
5179
|
+
* The lineage one entry records for its session, read off that session's own header.
|
|
5180
|
+
*
|
|
5181
|
+
* Every part is taken on its own and only when it is there, and NO lineage is recorded when the header
|
|
5182
|
+
* held none of it. Absence is the honest value: an ordinary root session has no parent by design, and a
|
|
5183
|
+
* host older than these header fields reports none of them — while a merged view has to be able to tell
|
|
5184
|
+
* "this session is a root" from "this host could not say", and guessing a root would file a session
|
|
5185
|
+
* under the wrong one.
|
|
5186
|
+
* @param sessionId - the session the entry belongs to.
|
|
5187
|
+
* @param agent - the live agent, when the capture has one (see `headerOf`).
|
|
5188
|
+
* @returns the lineage to store, or `undefined` when the header named none of it.
|
|
5189
|
+
*/
|
|
5190
|
+
function lineageOf(sessionId, agent) {
|
|
5191
|
+
const header = headerOf(sessionId, agent);
|
|
5192
|
+
if (header === void 0) return void 0;
|
|
5193
|
+
const parent = header.parentSession;
|
|
5194
|
+
const depth = header.delegationDepth;
|
|
5195
|
+
const lineage = {
|
|
5196
|
+
parentSessionId: typeof parent === "string" && parent.length > 0 ? SessionId(parent) : void 0,
|
|
5197
|
+
origin: typeof header.origin === "string" && header.origin.length > 0 ? header.origin : void 0,
|
|
5198
|
+
delegationDepth: typeof depth === "number" && Number.isFinite(depth) ? depth : void 0,
|
|
5199
|
+
cwd: typeof header.cwd === "string" && header.cwd.length > 0 ? header.cwd : void 0
|
|
5200
|
+
};
|
|
5201
|
+
return lineage.parentSessionId !== void 0 || lineage.origin !== void 0 || lineage.delegationDepth !== void 0 || lineage.cwd !== void 0 ? lineage : void 0;
|
|
5202
|
+
}
|
|
5203
|
+
/** The session registry, read the way this file reads every optional service (see `headerOf`). */
|
|
5204
|
+
function sessionRegistry() {
|
|
5205
|
+
return ctx.get("sessions");
|
|
5206
|
+
}
|
|
5207
|
+
/** The most lineage hops one walk will take: a bound, not a depth anyone should reach. */
|
|
5208
|
+
const MAX_LINEAGE_DEPTH = 16;
|
|
5209
|
+
/** Every session that touched an entry (its own fallback, the way the store reads an old row).
|
|
5210
|
+
*
|
|
5211
|
+
* PROVENANCE, and the merge's basis: it is who WROTE the row, which is what the row's mark and its
|
|
5212
|
+
* `viaLineage` flag are made of. It is deliberately NOT the write policy any more — an action's write
|
|
5213
|
+
* runs under the REQUESTING session (see `DiffApprovalUndoPair.policySession` and the
|
|
5214
|
+
* `revertEntryContent` call sites), so nothing here decides who may write.
|
|
5215
|
+
*/
|
|
5216
|
+
function ownersOf(entry) {
|
|
5217
|
+
return Array.isArray(entry.sessionIds) && entry.sessionIds.length > 0 ? entry.sessionIds : [entry.sessionId];
|
|
5218
|
+
}
|
|
5219
|
+
/**
|
|
5220
|
+
* What one session is known by when a lineage is walked: the lineage STEP 1 recorded for it, and
|
|
5221
|
+
* otherwise the live header.
|
|
5222
|
+
*
|
|
5223
|
+
* Recorded first is deliberate: it is what the row itself says, it survives a session that is no longer
|
|
5224
|
+
* live, and it is the same answer the entry carries — so the walk and the row cannot disagree. The
|
|
5225
|
+
* registry is the fallback for a session that recorded nothing (a row written before this step, or a
|
|
5226
|
+
* session whose entries have all been settled away).
|
|
5227
|
+
*
|
|
5228
|
+
* `subagent` is the only field the walk trusts for a LINK: `parentSession` alone is not one. The header
|
|
5229
|
+
* documents it as "the session this one was forked from (seed lineage)", so a host fork or a seeded
|
|
5230
|
+
* continuation carries it while being an INDEPENDENT ROOT — the Team package reads exactly that
|
|
5231
|
+
* distinction (`dsh-experimental-agent-team/lib/types/roster.js:76-81`: "Ordinary host forks are
|
|
5232
|
+
* independent roots"). `origin: 'subagent'` is the header's own marker for a session "created as a
|
|
5233
|
+
* subagent child", and the one function that builds a child's meta sets it together with `parentSession`
|
|
5234
|
+
* and `delegationDepth` (`dsh-subagent/lib/types/child-agent.js:111-125`).
|
|
5235
|
+
* @param sessionId - the session being asked about.
|
|
5236
|
+
* @param recorded - the lineages step 1 recorded, read once per request.
|
|
5237
|
+
* @returns whether the session is a subagent child, and the parent it names when it is.
|
|
5238
|
+
*/
|
|
5239
|
+
function knownLineageOf(sessionId, recorded) {
|
|
5240
|
+
const entry = recorded.get(sessionId);
|
|
5241
|
+
if (entry !== void 0) return {
|
|
5242
|
+
parent: entry.parentSessionId,
|
|
5243
|
+
subagent: entry.origin === "subagent"
|
|
5244
|
+
};
|
|
5245
|
+
const header = sessionRegistry()?.get(sessionId)?.header;
|
|
5246
|
+
const parent = header?.parentSession;
|
|
5247
|
+
return {
|
|
5248
|
+
parent: typeof parent === "string" && parent.length > 0 ? SessionId(parent) : void 0,
|
|
5249
|
+
subagent: header?.origin === "subagent"
|
|
5250
|
+
};
|
|
5251
|
+
}
|
|
5252
|
+
/**
|
|
5253
|
+
* The lineage view one request is answered from — which sessions' entries the requester may SEE, and
|
|
5254
|
+
* therefore act on.
|
|
5255
|
+
*
|
|
5256
|
+
* The rule is ROOT-EQUAL **AND SAME-WORKSPACE**: an entry is visible when any session that touched it
|
|
5257
|
+
* walks to the same lineage root as the requester AND can be shown to sit in the requester's workspace.
|
|
5258
|
+
* Walking is `knownLineageOf` links only, so a teammate's rows appear in the lead's list (both walk to
|
|
5259
|
+
* the lead) and the lead's appear in the teammate's — while a host fork, which names a parent but is not
|
|
5260
|
+
* a subagent child, stays an independent root and shares nothing.
|
|
5261
|
+
*
|
|
5262
|
+
* The workspace half is not redundant with the lineage half, because NOTHING ELSE IN THE LIST PATH
|
|
5263
|
+
* SCOPES BY WORKSPACE: the store is one map keyed by absolute path persisted to one `pending.json` under
|
|
5264
|
+
* the storage root (`pending.ts`, `persist.ts` — the `workspaces/` component of the default path is the
|
|
5265
|
+
* legacy per-workspace layout, folded in and deleted on load). So without this a row recorded by a
|
|
5266
|
+
* session in ANOTHER workspace would be listed here whenever the two share a lineage root — which is a
|
|
5267
|
+
* real shape: this view merges every subagent child (see the KNOWN LIMIT below), and the harness's
|
|
5268
|
+
* out-of-process shape lets a child run under a configured `cwd` of its own, its cwd being documented as
|
|
5269
|
+
* the child's "workspace identity" (`dsh-subagent/lib/types/out-of-process.js`:
|
|
5270
|
+
* `resolveChildCwd`/`assertUsableCwd`). An in-process child copies its parent's cwd verbatim
|
|
5271
|
+
* (`…/child-agent.js:childSessionMeta`), so the two agree there; this rule is what makes the plugin's
|
|
5272
|
+
* answer independent of which of those two shapes it is handed.
|
|
5273
|
+
*
|
|
5274
|
+
* A session the registry does not account is NOT refused: `sameWorkspace` answers true when either
|
|
5275
|
+
* side's workspace is unknown, so an unaccounted or memory-only session keeps exactly the view it had
|
|
5276
|
+
* before this term existed. UNKNOWN DEGRADES TO SELF on the lineage half and TO THE OLD ANSWER here —
|
|
5277
|
+
* never to a guess, and never to a refusal the reader cannot see the reason for.
|
|
5278
|
+
*
|
|
5279
|
+
* UNKNOWN DEGRADES TO SELF, never to a guess: with no recorded lineage and no header facts every
|
|
5280
|
+
* session is its own root, so only the requester matches — byte-for-byte today's behaviour. A cycle
|
|
5281
|
+
* (which no shell should produce) stops at the first repeat, and the walk is bounded by
|
|
5282
|
+
* {@link MAX_LINEAGE_DEPTH}.
|
|
5283
|
+
*
|
|
5284
|
+
* TWO THINGS A MERGED VIEW DOES NOT MERGE, so the next reader does not expect them:
|
|
5285
|
+
* comments stay per-session (`comments` is read with the requester's id, so a merged list shows the
|
|
5286
|
+
* requester's threads only), and undo stacks stay per-lineage — every ACTION records its pair under the
|
|
5287
|
+
* canonical session of the seat that PRESSED it, so the seats of one lineage share one history and a
|
|
5288
|
+
* different lineage cannot reach into it (`undoStackOf`/`popOwnPair` are keyed by that root). The
|
|
5289
|
+
* session-scoped thing a merged press does NOT take from the ROW is the write policy: an action's file
|
|
5290
|
+
* write runs under the REQUESTING session's own workspace and sandbox (see
|
|
5291
|
+
* `DiffApprovalUndoPair.policySession`), never the owner's.
|
|
5292
|
+
*
|
|
5293
|
+
* THE MARK (`hasChildContribution`) IS A DIFFERENT QUESTION and keeps asking only the lineage half: it
|
|
5294
|
+
* claims an owner other than the requester WROTE part of the row, which a child in another workspace
|
|
5295
|
+
* really did. Adding the workspace term there would make that copy lie, so it deliberately does not ask
|
|
5296
|
+
* it (see `sameRoot` below).
|
|
5297
|
+
*
|
|
5298
|
+
* KNOWN LIMIT: a teammate child cannot be told from ANY OTHER subagent child by its header. Both are
|
|
5299
|
+
* built by the one `childSessionMeta` above, and the Team spawner adds nothing to the child — it passes
|
|
5300
|
+
* only `childId`/`provider`/`label` into `ctx.subagents.startContinuable`
|
|
5301
|
+
* (`dsh-experimental-agent-team/lib/types/roster.js:253-262`) and records membership in the LEAD's own
|
|
5302
|
+
* journal (`:249`), which this plugin cannot read. So this view merges every subagent child, not only
|
|
5303
|
+
* teammates; narrowing it to the roster would need a service the plugin does not inject.
|
|
5304
|
+
* @param entries - the store's entries, when the caller already took ONE snapshot for the whole request
|
|
5305
|
+
* (`store.all()` rebuilds and re-sorts the array, so a read takes it once and hands it down). Defaults
|
|
5306
|
+
* to a fresh snapshot for the callers that only need the walk.
|
|
5307
|
+
* @returns the view: `sees` answers the visibility question for one entry, `sameRoot` answers whether two
|
|
5308
|
+
* sessions share a lineage root (the question the row's mark asks about its other owners), `directionOf`
|
|
5309
|
+
* says WHICH WAY that other owner stands for the sentence the mark wears, and `rootOf` is the CANONICAL
|
|
5310
|
+
* session — the lineage root, or the session itself when its lineage is unknown — that every
|
|
5311
|
+
* session-scoped store is keyed by (see `canonicalOf`).
|
|
5312
|
+
*/
|
|
5313
|
+
function lineageView(entries = store.all()) {
|
|
5314
|
+
const recorded = /* @__PURE__ */ new Map();
|
|
5315
|
+
for (const entry of entries) if (entry.lineage !== void 0 && !recorded.has(entry.sessionId)) recorded.set(entry.sessionId, entry.lineage);
|
|
5316
|
+
const facts = /* @__PURE__ */ new Map();
|
|
5317
|
+
/**
|
|
5318
|
+
* One session's UPWARD CHAIN: itself, then each recorded subagent parent above it, in order.
|
|
5319
|
+
*
|
|
5320
|
+
* This is the one walk the whole view is made of. `rootOf` is its last element, and the mark's
|
|
5321
|
+
* DIRECTION is a membership question over two of these chains — so the merge, the root and the
|
|
5322
|
+
* direction can never be computed from different links. The links are `knownLineageOf`'s, which reads
|
|
5323
|
+
* the lineage the entry RECORDED before it asks the live header (see that function), and only an
|
|
5324
|
+
* `origin: 'subagent'` link is followed: a host fork names a parent while being an independent root.
|
|
5325
|
+
*
|
|
5326
|
+
* Memoized per view like `roots` was, because the list read asks this per entry per owner on a path
|
|
5327
|
+
* every client repeats once a second. The walk stops at a repeat (`walked`) and at
|
|
5328
|
+
* {@link MAX_LINEAGE_DEPTH}, so a malformed cycle cannot hang it — the same two guards the root walk
|
|
5329
|
+
* has always had.
|
|
5330
|
+
*/
|
|
5331
|
+
const chains = /* @__PURE__ */ new Map();
|
|
5332
|
+
/**
|
|
5333
|
+
* Each session's workspace key, resolved once per view.
|
|
5334
|
+
*
|
|
5335
|
+
* Memoized for the same reason `chains` is: this runs inside `sees`, which the list read calls once per
|
|
5336
|
+
* entry per owner (`listWithState`), on a path every client repeats once a second — and the registry
|
|
5337
|
+
* lookup walks the deployment's workspace list. `has` rather than a truthy check so "known to be
|
|
5338
|
+
* unknown" is cached as such instead of being asked again for an unaccounted session.
|
|
5339
|
+
*/
|
|
5340
|
+
const workspaceKeys = /* @__PURE__ */ new Map();
|
|
5341
|
+
const workspaceKey = (sessionId) => {
|
|
5342
|
+
if (workspaceKeys.has(sessionId)) return workspaceKeys.get(sessionId);
|
|
5343
|
+
const key = workspaceKeyOf(sessionId);
|
|
5344
|
+
workspaceKeys.set(sessionId, key);
|
|
5345
|
+
return key;
|
|
5346
|
+
};
|
|
5347
|
+
const chainOf = (sessionId) => {
|
|
5348
|
+
const known = chains.get(sessionId);
|
|
5349
|
+
if (known !== void 0) return known;
|
|
5350
|
+
const chain = [sessionId];
|
|
5351
|
+
const walked = /* @__PURE__ */ new Set([sessionId]);
|
|
5352
|
+
let current = sessionId;
|
|
5353
|
+
for (let depth = 0; depth < MAX_LINEAGE_DEPTH; depth += 1) {
|
|
5354
|
+
let fact = facts.get(current);
|
|
5355
|
+
if (fact === void 0) {
|
|
5356
|
+
fact = knownLineageOf(current, recorded);
|
|
5357
|
+
facts.set(current, fact);
|
|
5358
|
+
}
|
|
5359
|
+
if (!fact.subagent || fact.parent === void 0 || walked.has(fact.parent)) break;
|
|
5360
|
+
walked.add(fact.parent);
|
|
5361
|
+
chain.push(fact.parent);
|
|
5362
|
+
current = fact.parent;
|
|
5363
|
+
}
|
|
5364
|
+
chains.set(sessionId, chain);
|
|
5365
|
+
return chain;
|
|
5366
|
+
};
|
|
5367
|
+
/** The top of one session's chain: the lineage root both `sees` and `sameRoot` compare. */
|
|
5368
|
+
const rootOf = (sessionId) => {
|
|
5369
|
+
const chain = chainOf(sessionId);
|
|
5370
|
+
return chain[chain.length - 1];
|
|
5371
|
+
};
|
|
5372
|
+
return {
|
|
5373
|
+
sees: (sessionId, entry) => {
|
|
5374
|
+
const root = rootOf(sessionId);
|
|
5375
|
+
const mine = workspaceKey(sessionId);
|
|
5376
|
+
return ownersOf(entry).some((owner) => {
|
|
5377
|
+
if (rootOf(owner) !== root) return false;
|
|
5378
|
+
if (owner === sessionId) return true;
|
|
5379
|
+
return sameWorkspaceKey(workspaceKey(owner), mine);
|
|
5380
|
+
});
|
|
5381
|
+
},
|
|
5382
|
+
sameRoot: (a, b) => rootOf(a) === rootOf(b),
|
|
5383
|
+
rootOf,
|
|
5384
|
+
/**
|
|
5385
|
+
* Which way the row's other contributors stand, for the marker's sentence.
|
|
5386
|
+
*
|
|
5387
|
+
* Read off the SAME recorded links `rootOf` walks, never guessed and never inferred from a name: an
|
|
5388
|
+
* owner is the requester's CHILD when the requester sits on that owner's upward chain, its PARENT
|
|
5389
|
+
* when that owner sits on the requester's chain, and a SIBLING when the two merely share a root.
|
|
5390
|
+
* `parent` therefore means ANCESTOR, however many hops up — which is exactly what the Chinese 上级
|
|
5391
|
+
* says and what the English copy is written to mean.
|
|
5392
|
+
*
|
|
5393
|
+
* Owners of DIFFERENT directions answer `mixed`: the row carries two different relationships and
|
|
5394
|
+
* picking one would state a fact about the row that is only true of part of it. The set is
|
|
5395
|
+
* unresolvable in the honest direction too — "another session" is true of every member — which is
|
|
5396
|
+
* why `mixed` is a real answer and not a failure. `undefined` when no other same-root owner
|
|
5397
|
+
* contributed, i.e. when there is no mark to word.
|
|
5398
|
+
*
|
|
5399
|
+
* The criterion is `hasChildContribution`'s exactly (non-requester, same root) — the workspace term
|
|
5400
|
+
* belongs to visibility, not to this claim (see `sameRoot`).
|
|
5401
|
+
* @param sessionId - the session listing the row.
|
|
5402
|
+
* @param entry - the row being listed.
|
|
5403
|
+
* @returns the direction, or `undefined` when nothing but the lister touched the row.
|
|
5404
|
+
*/
|
|
5405
|
+
directionOf: (sessionId, entry) => {
|
|
5406
|
+
const root = rootOf(sessionId);
|
|
5407
|
+
const mine = chainOf(sessionId);
|
|
5408
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5409
|
+
for (const owner of ownersOf(entry)) {
|
|
5410
|
+
if (owner === sessionId) continue;
|
|
5411
|
+
if (rootOf(owner) !== root) continue;
|
|
5412
|
+
if (chainOf(owner).includes(sessionId)) seen.add("child");
|
|
5413
|
+
else if (mine.includes(owner)) seen.add("parent");
|
|
5414
|
+
else seen.add("sibling");
|
|
5415
|
+
}
|
|
5416
|
+
if (seen.size === 0) return void 0;
|
|
5417
|
+
return seen.size === 1 ? [...seen][0] : "mixed";
|
|
5418
|
+
}
|
|
5419
|
+
};
|
|
5420
|
+
}
|
|
5421
|
+
/**
|
|
5422
|
+
* THE CANONICALIZATION SEAM: the one session-scoped state of a request belongs to.
|
|
5423
|
+
*
|
|
5424
|
+
* The lineage ROOT, because the seats of one lineage read one list — so the undo history they share and
|
|
5425
|
+
* the comment threads they read must be keyed by the lineage, not by whichever seat is looking. A
|
|
5426
|
+
* session whose lineage is unknown (no recorded link, no header facts) is its own root, which is
|
|
5427
|
+
* byte-for-byte the behaviour before any of this existed.
|
|
5428
|
+
*
|
|
5429
|
+
* Every session-scoped seam goes through this function, and the endpoints that already hold a view pass
|
|
5430
|
+
* it so the walk is done once per request (`view` is optional only so a push deep inside a helper cannot
|
|
5431
|
+
* forget to canonicalize; it never means "do not canonicalize").
|
|
5432
|
+
* @param sessionId - the session the request came from.
|
|
5433
|
+
* @param view - the request's lineage view, when the caller already built one.
|
|
5434
|
+
* @returns the canonical session: the lineage root, or `sessionId` itself when it has none.
|
|
5435
|
+
*/
|
|
5436
|
+
function canonicalOf(sessionId, view) {
|
|
5437
|
+
return (view ?? lineageView()).rootOf(sessionId);
|
|
5438
|
+
}
|
|
5439
|
+
/**
|
|
5440
|
+
* The comment scope of one request: which comment AUTHORS this lineage may read and act on.
|
|
5441
|
+
*
|
|
5442
|
+
* `sameRoot` is the view's own question, so the scope and the merge cannot disagree about who is kin —
|
|
5443
|
+
* and it is the same predicate for reading, removing, marking seen and asking, so no verb can widen or
|
|
5444
|
+
* narrow the lineage on its own.
|
|
5445
|
+
* @param sessionId - the session the request came from.
|
|
5446
|
+
* @param view - the request's lineage view.
|
|
5447
|
+
* @returns the predicate the comment store's `CommentScope` takes.
|
|
5448
|
+
*/
|
|
5449
|
+
function commentScopeOf(sessionId, view) {
|
|
5450
|
+
return (author) => view.sameRoot(author, sessionId);
|
|
5451
|
+
}
|
|
5452
|
+
/**
|
|
5453
|
+
* The entry one id names, when the requesting session may act on it; `undefined` otherwise.
|
|
5454
|
+
*
|
|
5455
|
+
* Every action path resolves its entry through this, so an id outside the requester's lineage is
|
|
5456
|
+
* answered exactly like an id that names nothing — `missing` — and no action can reach a row the
|
|
5457
|
+
* requester cannot see.
|
|
5458
|
+
* @param view - the request's lineage view.
|
|
5459
|
+
* @param sessionId - the requesting session.
|
|
5460
|
+
* @param id - the entry id (its path).
|
|
5461
|
+
* @returns the entry, or `undefined` when it is absent or outside the view.
|
|
5462
|
+
*/
|
|
5463
|
+
function actionableEntryOf(view, sessionId, id) {
|
|
5464
|
+
const entry = store.get(id);
|
|
5465
|
+
return entry !== void 0 && view.sees(sessionId, entry) ? entry : void 0;
|
|
5466
|
+
}
|
|
5467
|
+
/**
|
|
4349
5468
|
* Write a Revert through `ctx.fs`, carrying the session's sandbox policy
|
|
4350
5469
|
* only when a confining backend is mounted (so the plain 4-arg call shape is
|
|
4351
5470
|
* preserved for the unsandboxed composition).
|
|
@@ -4379,7 +5498,7 @@ function apply(ctx, config) {
|
|
|
4379
5498
|
async function removeRevert(target, sessionId, signal) {
|
|
4380
5499
|
const policy = sandboxPolicyOf(sessionId);
|
|
4381
5500
|
const osPath = processPathOf(target);
|
|
4382
|
-
if (policy !== void 0 && !withinSandboxRoot(osPath, policy)) throw new Error(
|
|
5501
|
+
if (policy !== void 0 && !withinSandboxRoot(osPath, policy)) throw new Error(sandboxDenial(osPath, policy));
|
|
4383
5502
|
const fs = ctx.fs;
|
|
4384
5503
|
if (policy !== void 0 && typeof fs.remove === "function") {
|
|
4385
5504
|
await fs.remove(target, policy, signal);
|
|
@@ -4410,10 +5529,157 @@ function apply(ctx, config) {
|
|
|
4410
5529
|
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
4411
5530
|
}
|
|
4412
5531
|
/**
|
|
5532
|
+
* The sentence a confining policy uses when it refuses one path, wherever it is produced.
|
|
5533
|
+
*
|
|
5534
|
+
* ONE producer, so the check a delete performs, the preflight a restore runs and the message a reader is
|
|
5535
|
+
* handed can never describe the refusal differently.
|
|
5536
|
+
* @param path - the OS path the policy refused.
|
|
5537
|
+
* @param policy - the session's resolved policy.
|
|
5538
|
+
* @returns the refusal sentence.
|
|
5539
|
+
*/
|
|
5540
|
+
function sandboxDenial(path, policy) {
|
|
5541
|
+
return `file access denied under ${policy.mode} mode: '${path}' is outside the sandbox workspace root '${policy.workspaceRoot}'`;
|
|
5542
|
+
}
|
|
5543
|
+
/**
|
|
5544
|
+
* Whether a thrown write error is the session's own AUTHORITY refusing it, rather than the file being
|
|
5545
|
+
* gone, changed, or unreadable.
|
|
5546
|
+
*
|
|
5547
|
+
* Exactly two shapes qualify, and nothing else does: a confining backend raises `FS_SANDBOX_DENIED` (the
|
|
5548
|
+
* `FsErrorCode` the fs package owns for "the policy layer said no"), and this file's own containment
|
|
5549
|
+
* check in `removeRevert` raises a plain Error saying the path is outside the sandbox workspace root.
|
|
5550
|
+
* Everything else keeps its own answer: `FS_NOT_FOUND` is a missing file, the divergence guard's "changed
|
|
5551
|
+
* outside the review" is a diverged one, `FS_PERMISSION_DENIED` is the FILE SYSTEM refusing (a fact about
|
|
5552
|
+
* the file, not about this session's grant) and the rest are IO or aborted faults. The predicate is
|
|
5553
|
+
* deliberately this narrow: widening it is how a reader loses the difference between "I may not write
|
|
5554
|
+
* there" and "that file is gone".
|
|
5555
|
+
* @param error - the value a write path threw.
|
|
5556
|
+
* @returns whether the sandbox/policy refused the write.
|
|
5557
|
+
*/
|
|
5558
|
+
function isPolicyRefusal(error) {
|
|
5559
|
+
if (fsErrorCodeOf(error) === "FS_SANDBOX_DENIED") return true;
|
|
5560
|
+
return /outside the sandbox workspace root/i.test(errorMessage(error));
|
|
5561
|
+
}
|
|
5562
|
+
/**
|
|
5563
|
+
* The answer for a write the session's authority refused: a sentence that NAMES THE PATH and says, in
|
|
5564
|
+
* words, that this session may not write there, carrying the sandbox's own reason with it.
|
|
5565
|
+
*
|
|
5566
|
+
* Why TEXT and not a code: the RPC error vocabulary this channel answers in is closed and owned by the
|
|
5567
|
+
* vendor (`RpcErrorDetailsMap` in `@deepseek-ai/dsh-host-apiproxy` — `internal` is its catch-all, and a
|
|
5568
|
+
* new code needs a row in that table), so this package cannot mint a `permission` code of its own. The
|
|
5569
|
+
* reader still gets the sentence: the port folds a failed call into `Error(code: message)` and the
|
|
5570
|
+
* panel's failed-row notice prints that message verbatim, so the refusal arrives as words on the path the
|
|
5571
|
+
* panel already has for a failed action. A LOCALIZED route would need a client-side classifier over the
|
|
5572
|
+
* message plus new locale keys — a copy change across the host/client seam — for no difference the reader
|
|
5573
|
+
* can see, since every other message on this channel is host English too.
|
|
5574
|
+
* @param path - the file the refused write was about (the entry's own path).
|
|
5575
|
+
* @param error - what the write threw.
|
|
5576
|
+
* @returns the error branch to answer with.
|
|
5577
|
+
*/
|
|
5578
|
+
function policyRefusal(path, error) {
|
|
5579
|
+
return rpcError(refusalText(path, error));
|
|
5580
|
+
}
|
|
5581
|
+
/** The refusal sentence itself, shared by every path that answers with one. */
|
|
5582
|
+
function refusalText(path, error) {
|
|
5583
|
+
return `'${path}' is outside this session's write authority: the workspace this session may write does not cover it. The sandbox refused the write: ${errorMessage(error)}`;
|
|
5584
|
+
}
|
|
5585
|
+
/**
|
|
5586
|
+
* Every file write a restore will make, in the order it will make them.
|
|
5587
|
+
*
|
|
5588
|
+
* Mirrors `restoreState`'s own walk, index-aligned with the counterpart side, so the preflight below and
|
|
5589
|
+
* the writer can never disagree about WHICH files a restore touches. A state with no `fileText` writes
|
|
5590
|
+
* nothing: it puts an entry back in the list (or takes it out) and never opens a file.
|
|
5591
|
+
* @param state - the side being installed.
|
|
5592
|
+
* @param expectedFile - the other side, whose per-index `fileText` is the divergence snapshot.
|
|
5593
|
+
* @returns the writes, in order.
|
|
5594
|
+
*/
|
|
5595
|
+
function restoreWritesOf(state, expectedFile) {
|
|
5596
|
+
const writes = [];
|
|
5597
|
+
if (state.batch !== void 0) {
|
|
5598
|
+
const counterpart = expectedFile?.batch;
|
|
5599
|
+
for (const [index, item] of state.batch.entries()) writes.push(...restoreWritesOf({
|
|
5600
|
+
...item,
|
|
5601
|
+
batch: void 0
|
|
5602
|
+
}, counterpart?.[index]));
|
|
5603
|
+
return writes;
|
|
5604
|
+
}
|
|
5605
|
+
if (state.fileText !== void 0) writes.push({
|
|
5606
|
+
path: state.path,
|
|
5607
|
+
expected: expectedFile?.fileText
|
|
5608
|
+
});
|
|
5609
|
+
return writes;
|
|
5610
|
+
}
|
|
5611
|
+
/**
|
|
5612
|
+
* Check EVERY write a restore will make before it makes the first one, and refuse the whole restore if any
|
|
5613
|
+
* of them would be refused.
|
|
5614
|
+
*
|
|
5615
|
+
* WHAT IS GUARANTEED, and what is not (this is the honest half of the design):
|
|
5616
|
+
*
|
|
5617
|
+
* - AUTHORITY is guaranteed all-or-nothing. Each target is checked with the SAME predicate the write path
|
|
5618
|
+
* enforces (`withinSandboxRoot`, the one `removeRevert` refuses with and the policy `writeRevert` hands
|
|
5619
|
+
* the backend), under `pair.policySession` — the seat the action ran under. A refusal anywhere means no
|
|
5620
|
+
* file is written at all, so an undo can never take back half of a decision. This is a check made before
|
|
5621
|
+
* the first byte, so it is a guarantee rather than a hope.
|
|
5622
|
+
* - DIVERGENCE is also preflighted, because it is cheap and detectable up front: a target whose
|
|
5623
|
+
* counterpart snapshot no longer matches the file refuses the whole restore rather than stopping
|
|
5624
|
+
* half-way through it.
|
|
5625
|
+
* - A RUNTIME failure is NOT preflightable and is not promised away: a file that vanishes between this
|
|
5626
|
+
* check and the write, or an IO fault on the third of five files, can still leave the earlier writes
|
|
5627
|
+
* applied. When that happens the caller reports exactly how far the restore got (`RestoreProgress`)
|
|
5628
|
+
* instead of claiming nothing happened. Do not read this function as an atomicity promise; it is a
|
|
5629
|
+
* permission and staleness promise, and the mid-way case is reported as the partial state it is.
|
|
5630
|
+
* @param writes - the plan, from `restoreWritesOf`.
|
|
5631
|
+
* @param policySession - the session whose policy the write runs under (the pair's recorded seat).
|
|
5632
|
+
* @param signal - aborts the checks too.
|
|
5633
|
+
* @returns the first refusal (its path and the reason to report), or undefined when the plan may run.
|
|
5634
|
+
*/
|
|
5635
|
+
async function preflightRestore(writes, policySession, signal) {
|
|
5636
|
+
const policy = sandboxPolicyOf(policySession);
|
|
5637
|
+
for (const write of writes) {
|
|
5638
|
+
let resolved;
|
|
5639
|
+
try {
|
|
5640
|
+
resolved = await ctx.fs.resolve(write.path, { signal });
|
|
5641
|
+
} catch (error) {
|
|
5642
|
+
return {
|
|
5643
|
+
path: write.path,
|
|
5644
|
+
error
|
|
5645
|
+
};
|
|
5646
|
+
}
|
|
5647
|
+
if (policy !== void 0 && !withinSandboxRoot(processPathOf(resolved), policy)) return {
|
|
5648
|
+
path: write.path,
|
|
5649
|
+
error: new Error(sandboxDenial(processPathOf(resolved), policy))
|
|
5650
|
+
};
|
|
5651
|
+
if (write.expected !== void 0) {
|
|
5652
|
+
if (await ctx.fs.readText(resolved, void 0) !== write.expected) return {
|
|
5653
|
+
path: write.path,
|
|
5654
|
+
error: /* @__PURE__ */ new Error("the file changed outside the review after the action; undo is unavailable")
|
|
5655
|
+
};
|
|
5656
|
+
}
|
|
5657
|
+
}
|
|
5658
|
+
}
|
|
5659
|
+
/**
|
|
5660
|
+
* The answer for a restore that did not finish: the refusal sentence when the sandbox was the reason, the
|
|
5661
|
+
* ordinary `undo failed:`/`redo failed:` text otherwise — and in EVERY case, when the restore had already
|
|
5662
|
+
* written something, how far it got. "Never that nothing happened": a partial restore is reported as the
|
|
5663
|
+
* partial state it is.
|
|
5664
|
+
* @param action - which direction was running.
|
|
5665
|
+
* @param path - the file the failure is about.
|
|
5666
|
+
* @param error - what the restore threw.
|
|
5667
|
+
* @param progress - the writes it completed, and how many it had to make.
|
|
5668
|
+
* @returns the error branch to answer with.
|
|
5669
|
+
*/
|
|
5670
|
+
function restoreFailure(action, path, error, progress) {
|
|
5671
|
+
if (isPolicyRefusal(error)) {
|
|
5672
|
+
const far = progress.written === 0 ? "" : ` The ${action} had already written ${progress.written} of ${progress.total} files before that.`;
|
|
5673
|
+
return rpcError(refusalText(path, error) + far);
|
|
5674
|
+
}
|
|
5675
|
+
return rpcError(`${action} failed${progress.written === 0 ? "" : ` after writing ${progress.written} of ${progress.total} files`}: ${errorMessage(error)}`);
|
|
5676
|
+
}
|
|
5677
|
+
/**
|
|
4413
5678
|
* Revert one entry's file back to its baseline (the shared per-entry logic
|
|
4414
|
-
* behind both the single `revert` endpoint and the bulk `revert-all`).
|
|
4415
|
-
*
|
|
4416
|
-
*
|
|
5679
|
+
* behind both the single `revert` endpoint and the bulk `revert-all`). An
|
|
5680
|
+
* entry with no earlier version (`earlierVersion: 'none'`) has its file
|
|
5681
|
+
* deleted (not undoable: the file is gone); one that has an earlier version
|
|
5682
|
+
* (`'file'`) writes it back and yields the before/after undo snapshot.
|
|
4417
5683
|
* @param entry - the entry to revert.
|
|
4418
5684
|
* @param sessionId - the entry's session (for the per-session write policy).
|
|
4419
5685
|
* @param signal - aborts before atomic publication takes effect.
|
|
@@ -4421,7 +5687,7 @@ function apply(ctx, config) {
|
|
|
4421
5687
|
*/
|
|
4422
5688
|
async function revertEntryContent(entry, sessionId, signal) {
|
|
4423
5689
|
const resolved = await ctx.fs.resolve(entry.path, { signal });
|
|
4424
|
-
if (entry.
|
|
5690
|
+
if (entry.earlierVersion === "none") {
|
|
4425
5691
|
await removeRevert(resolved, sessionId, signal);
|
|
4426
5692
|
return;
|
|
4427
5693
|
}
|
|
@@ -4445,33 +5711,91 @@ function apply(ctx, config) {
|
|
|
4445
5711
|
}
|
|
4446
5712
|
const undoStacks = /* @__PURE__ */ new Map();
|
|
4447
5713
|
const redoStacks = /* @__PURE__ */ new Map();
|
|
4448
|
-
/** The undo stack of one
|
|
4449
|
-
function undoStackOf(
|
|
4450
|
-
let stack = undoStacks.get(
|
|
5714
|
+
/** The undo stack of one lineage root, created on first use. Its key is a CANONICAL session. */
|
|
5715
|
+
function undoStackOf(root) {
|
|
5716
|
+
let stack = undoStacks.get(root);
|
|
4451
5717
|
if (stack === void 0) {
|
|
4452
5718
|
stack = [];
|
|
4453
|
-
undoStacks.set(
|
|
5719
|
+
undoStacks.set(root, stack);
|
|
4454
5720
|
}
|
|
4455
5721
|
return stack;
|
|
4456
5722
|
}
|
|
4457
|
-
/** The redo stack of one
|
|
4458
|
-
function redoStackOf(
|
|
4459
|
-
let stack = redoStacks.get(
|
|
5723
|
+
/** The redo stack of one lineage root, created on first use. Its key is a CANONICAL session. */
|
|
5724
|
+
function redoStackOf(root) {
|
|
5725
|
+
let stack = redoStacks.get(root);
|
|
4460
5726
|
if (stack === void 0) {
|
|
4461
5727
|
stack = [];
|
|
4462
|
-
redoStacks.set(
|
|
5728
|
+
redoStacks.set(root, stack);
|
|
4463
5729
|
}
|
|
4464
5730
|
return stack;
|
|
4465
5731
|
}
|
|
4466
|
-
/**
|
|
4467
|
-
*
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
5732
|
+
/**
|
|
5733
|
+
* One entry as an undo target sees it, for the "did this really change?" comparison.
|
|
5734
|
+
*
|
|
5735
|
+
* `updatedAt` is deliberately NOT in the key: every action stamps it with `Date.now()`, so including it
|
|
5736
|
+
* would make every recorded target look changed — the exact false positive the comparison exists to stop.
|
|
5737
|
+
*/
|
|
5738
|
+
function entryKeyOf(entry) {
|
|
5739
|
+
if (entry === void 0) return "(no entry)";
|
|
5740
|
+
return JSON.stringify(entry, (key, value) => key === "updatedAt" ? void 0 : value);
|
|
5741
|
+
}
|
|
5742
|
+
/** One side's comment records as a comparable key (a pair that carries threads changes when they do). */
|
|
5743
|
+
function commentsKeyOf(comments) {
|
|
5744
|
+
return (comments ?? []).map((comment) => `${comment.id}@${comment.anchor.startLine}-${comment.anchor.endLine}:${comment.text}`).join("|");
|
|
5745
|
+
}
|
|
5746
|
+
/**
|
|
5747
|
+
* Whether one target of a forward action really changed anything.
|
|
5748
|
+
*
|
|
5749
|
+
* THE RULE the reader asked for: a pair records only what changed. `before` is what the action found and
|
|
5750
|
+
* `after` is what it left — which is also what the file and the entry hold NOW, since the action has just
|
|
5751
|
+
* finished — so comparing the two sides is comparing the target against the world. Three things can
|
|
5752
|
+
* differ and each counts on its own: the file bytes (`fileText`, with `undefined` meaning "no write"), the
|
|
5753
|
+
* entry (its content-bearing fields; see `entryKeyOf`), and a comment pair's records. A target where none
|
|
5754
|
+
* of them differs is not a change and is not recorded, so Ctrl+Z cannot walk a file the action never
|
|
5755
|
+
* moved.
|
|
5756
|
+
*
|
|
5757
|
+
* A BATCH state is not compared here: its members are filtered one by one in `pushBatchUndo`, which is
|
|
5758
|
+
* where the index alignment lives.
|
|
5759
|
+
* @param before - the state the action found.
|
|
5760
|
+
* @param after - the state it left.
|
|
5761
|
+
* @returns whether recording this target would give the reader something to take back.
|
|
5762
|
+
*/
|
|
5763
|
+
function stateChanged(before, after) {
|
|
5764
|
+
if (before.batch !== void 0 || after.batch !== void 0) return true;
|
|
5765
|
+
if (before.kind === "comments" || after.kind === "comments") return commentsKeyOf(before.comments) !== commentsKeyOf(after.comments);
|
|
5766
|
+
if (before.fileText !== after.fileText) return true;
|
|
5767
|
+
return entryKeyOf(before.entry) !== entryKeyOf(after.entry);
|
|
5768
|
+
}
|
|
5769
|
+
/**
|
|
5770
|
+
* Record one undoable action in its lineage's history. A fresh action invalidates that lineage's redo
|
|
5771
|
+
* history (and only that lineage's).
|
|
5772
|
+
*
|
|
5773
|
+
* THE RULE: the pair is filed under the CANONICAL session, so every seat of one lineage shares one
|
|
5774
|
+
* history and any of them can take the action back; the session that pressed is remembered on the pair
|
|
5775
|
+
* (`pressedBy` for attribution, and `policySession` for the write the RESTORE runs) — and the write
|
|
5776
|
+
* policy is recorded rather than re-derived, because the seat that POPS a pair need not be the seat that
|
|
5777
|
+
* made it. Canonicalizing HERE rather than at the call site is what makes the rule unforgettable: every
|
|
5778
|
+
* push in this file goes through this function.
|
|
5779
|
+
*
|
|
5780
|
+
* `policySession` is the presser because the forward action's own write is the requester's (see the
|
|
5781
|
+
* `revertEntryContent` call sites) — so every push here records the seat whose authority the action ran
|
|
5782
|
+
* under, and the restore replays exactly that.
|
|
5783
|
+
* @param sessionId - the session that pressed the action.
|
|
5784
|
+
* @param before - the state the action found, and what a restore puts back.
|
|
5785
|
+
* @param after - the state the action left, and what a redo re-applies.
|
|
5786
|
+
* @param view - the request's lineage view, when the caller already built one.
|
|
5787
|
+
*/
|
|
5788
|
+
function pushUndo(sessionId, before, after, view) {
|
|
5789
|
+
if (!stateChanged(before, after)) return;
|
|
5790
|
+
const root = canonicalOf(sessionId, view);
|
|
5791
|
+
undoStackOf(root).push({
|
|
5792
|
+
root,
|
|
5793
|
+
pressedBy: sessionId,
|
|
5794
|
+
policySession: sessionId,
|
|
4471
5795
|
before,
|
|
4472
5796
|
after
|
|
4473
5797
|
});
|
|
4474
|
-
redoStackOf(
|
|
5798
|
+
redoStackOf(root).length = 0;
|
|
4475
5799
|
}
|
|
4476
5800
|
/**
|
|
4477
5801
|
* Record several entries' worth of ONE decision as a single undo step.
|
|
@@ -4480,36 +5804,45 @@ function apply(ctx, config) {
|
|
|
4480
5804
|
* take the whole thing back. A loop of single actions would push one step per entry instead, and undo
|
|
4481
5805
|
* would then peel that one decision apart file by file.
|
|
4482
5806
|
*
|
|
4483
|
-
* @param sessionId - the session
|
|
5807
|
+
* @param sessionId - the session that pressed.
|
|
4484
5808
|
* @param before - one state per entry, in the order the decision touched them.
|
|
4485
5809
|
* @param after - the same entries as the decision left them; the two arrays are index-aligned, which
|
|
4486
5810
|
* is also what the divergence guard on a restore reads (see `restoreState`).
|
|
5811
|
+
* @param view - the request's lineage view, when the caller already built one.
|
|
4487
5812
|
*/
|
|
4488
|
-
function pushBatchUndo(sessionId, before, after) {
|
|
4489
|
-
|
|
5813
|
+
function pushBatchUndo(sessionId, before, after, view) {
|
|
5814
|
+
const changedBefore = [];
|
|
5815
|
+
const changedAfter = [];
|
|
5816
|
+
for (const [index, item] of before.entries()) {
|
|
5817
|
+
const counterpart = after[index];
|
|
5818
|
+
if (counterpart === void 0 || !stateChanged(item, counterpart)) continue;
|
|
5819
|
+
changedBefore.push(item);
|
|
5820
|
+
changedAfter.push(counterpart);
|
|
5821
|
+
}
|
|
5822
|
+
if (changedBefore.length === 0) return;
|
|
4490
5823
|
pushUndo(sessionId, {
|
|
4491
|
-
id:
|
|
4492
|
-
path:
|
|
5824
|
+
id: changedBefore[0].id,
|
|
5825
|
+
path: changedBefore[0].path,
|
|
4493
5826
|
entry: void 0,
|
|
4494
5827
|
fileText: void 0,
|
|
4495
|
-
batch:
|
|
5828
|
+
batch: changedBefore
|
|
4496
5829
|
}, {
|
|
4497
|
-
id:
|
|
4498
|
-
path:
|
|
5830
|
+
id: changedAfter[0].id,
|
|
5831
|
+
path: changedAfter[0].path,
|
|
4499
5832
|
entry: void 0,
|
|
4500
5833
|
fileText: void 0,
|
|
4501
|
-
batch:
|
|
4502
|
-
});
|
|
4503
|
-
}
|
|
4504
|
-
/** Drop the top pair of one stack when it belongs to
|
|
4505
|
-
* stack is left exactly as it was, so another
|
|
4506
|
-
* @param stack - the
|
|
4507
|
-
* @param
|
|
4508
|
-
* @returns the pair to move, or undefined when this
|
|
5834
|
+
batch: changedAfter
|
|
5835
|
+
}, view);
|
|
5836
|
+
}
|
|
5837
|
+
/** Drop the top pair of one lineage root's stack, when it belongs to that root; otherwise the
|
|
5838
|
+
* stack is left exactly as it was, so another lineage's action is untouched.
|
|
5839
|
+
* @param stack - the root's stack (undo or redo).
|
|
5840
|
+
* @param root - the canonical session the request is for.
|
|
5841
|
+
* @returns the pair to move, or undefined when this lineage has nothing to move.
|
|
4509
5842
|
*/
|
|
4510
|
-
function popOwnPair(stack,
|
|
5843
|
+
function popOwnPair(stack, root) {
|
|
4511
5844
|
const pair = stack[stack.length - 1];
|
|
4512
|
-
if (pair === void 0 || pair.
|
|
5845
|
+
if (pair === void 0 || pair.root !== root) return void 0;
|
|
4513
5846
|
stack.pop();
|
|
4514
5847
|
return pair;
|
|
4515
5848
|
}
|
|
@@ -4578,16 +5911,18 @@ function apply(ctx, config) {
|
|
|
4578
5911
|
*
|
|
4579
5912
|
* Nothing here touches `ctx.fs` or the pending store: a comment is not a change to a file, and a
|
|
4580
5913
|
* restore that also wrote one would turn "take the comment back" into "take the edit back".
|
|
4581
|
-
* @param sessionId - the session the pair was taken in (a pair's comments are one session's).
|
|
4582
5914
|
* @param state - the side to install.
|
|
4583
5915
|
* @param expectedFile - the other side; the comments only it lists are the ones to remove.
|
|
5916
|
+
* @param scope - which comment authors this restore may remove: the undo's lineage. The stale side is
|
|
5917
|
+
* the comments the OTHER side held, and they belong to whichever seats wrote them, so the removal has
|
|
5918
|
+
* to be allowed to reach every file the pair's records live in (see `CommentStore.removeMany`).
|
|
4584
5919
|
*/
|
|
4585
|
-
function restoreComments(
|
|
5920
|
+
function restoreComments(state, expectedFile, scope) {
|
|
4586
5921
|
const installing = state.comments ?? [];
|
|
4587
5922
|
const installed = new Set(installing.map((record) => record.id));
|
|
4588
5923
|
const stale = (expectedFile?.comments ?? []).map((record) => record.id).filter((id) => !installed.has(id));
|
|
4589
5924
|
if (stale.length > 0) {
|
|
4590
|
-
comments.removeMany(
|
|
5925
|
+
comments.removeMany(scope, stale);
|
|
4591
5926
|
for (const id of stale) commentLines.delete(id);
|
|
4592
5927
|
}
|
|
4593
5928
|
if (installing.length > 0) comments.addMany(installing);
|
|
@@ -4600,14 +5935,18 @@ function apply(ctx, config) {
|
|
|
4600
5935
|
* only after the write succeeds, keeping the restore all-or-nothing — and the
|
|
4601
5936
|
* comments a dropping action removed are the LAST thing put back, so a refused
|
|
4602
5937
|
* restore cannot leave a thread on a row that did not come back.
|
|
4603
|
-
* @param sessionId - the
|
|
5938
|
+
* @param sessionId - the session the policy is resolved from (the pair's presser — see `undoStackOf`).
|
|
4604
5939
|
* @param state - the snapshot to restore.
|
|
4605
5940
|
* @param expectedFile - the other side's file content, checked before a write.
|
|
4606
5941
|
* @param signal - aborts before atomic publication takes effect.
|
|
5942
|
+
* @param scope - which comment authors this restore may remove (the undo request's lineage).
|
|
5943
|
+
* @param progress - counts the file writes as they land, so a mid-way failure can report exactly how far
|
|
5944
|
+
* it got instead of claiming nothing happened (see `restoreFailure`). Omitted where the caller has no
|
|
5945
|
+
* use for it; a restore that gets no `progress` is unchanged.
|
|
4607
5946
|
*/
|
|
4608
|
-
async function restoreState(sessionId, state, expectedFile, signal) {
|
|
5947
|
+
async function restoreState(sessionId, state, expectedFile, signal, scope, progress) {
|
|
4609
5948
|
if (state.kind === "comments") {
|
|
4610
|
-
restoreComments(
|
|
5949
|
+
restoreComments(state, expectedFile, scope);
|
|
4611
5950
|
return;
|
|
4612
5951
|
}
|
|
4613
5952
|
if (state.fileText !== void 0) {
|
|
@@ -4615,14 +5954,16 @@ function apply(ctx, config) {
|
|
|
4615
5954
|
if (expectedFile?.fileText !== void 0) {
|
|
4616
5955
|
if (await ctx.fs.readText(resolved, void 0) !== expectedFile.fileText) throw new Error("the file changed outside the review after the action; undo is unavailable");
|
|
4617
5956
|
}
|
|
5957
|
+
if (progress !== void 0) progress.path = state.path;
|
|
4618
5958
|
await writeRevert(resolved, state.fileText, sessionId, signal);
|
|
5959
|
+
if (progress !== void 0) progress.written += 1;
|
|
4619
5960
|
}
|
|
4620
5961
|
if (state.batch !== void 0) {
|
|
4621
5962
|
const counterpart = expectedFile?.batch;
|
|
4622
5963
|
for (const [index, item] of state.batch.entries()) await restoreState(sessionId, {
|
|
4623
5964
|
...item,
|
|
4624
5965
|
batch: void 0
|
|
4625
|
-
}, counterpart?.[index], signal);
|
|
5966
|
+
}, counterpart?.[index], signal, scope, progress);
|
|
4626
5967
|
return;
|
|
4627
5968
|
}
|
|
4628
5969
|
if (state.entry !== void 0) store.restore(state.entry);
|
|
@@ -4666,6 +6007,21 @@ function apply(ctx, config) {
|
|
|
4666
6007
|
}
|
|
4667
6008
|
/** Mark the store dirty and schedule one throttled, coalesced write. Pass
|
|
4668
6009
|
* `force` to write immediately (user actions need durable, immediate results). */
|
|
6010
|
+
/**
|
|
6011
|
+
* Mark the list dirty and start its write. The throttle is for the panel's own churn; `force` writes
|
|
6012
|
+
* NOW and, since 2026-10-06, RETURNS the write's promise.
|
|
6013
|
+
*
|
|
6014
|
+
* That return value is a durability guarantee, not a convenience: a caller that is about to write
|
|
6015
|
+
* something NAMING an entry — the annotate tool's comment — has to know the entry is readable at the
|
|
6016
|
+
* next boot first. Folded in memory and written on a later flush, an entry can be lost to a host that
|
|
6017
|
+
* stops in between, and the load-time sweep then hides (and used to delete) the comments that named
|
|
6018
|
+
* it. Awaiting here is what makes "the comment exists" imply "its entry was on disk first".
|
|
6019
|
+
*
|
|
6020
|
+
* @param force - write immediately rather than on the throttle's schedule.
|
|
6021
|
+
* @returns a promise that settles when the write this call started has finished (never rejects: a
|
|
6022
|
+
* failure is logged and reported through `persistFailureReported`, because a store that cannot write
|
|
6023
|
+
* must keep answering reads).
|
|
6024
|
+
*/
|
|
4669
6025
|
function persistSession(force = false) {
|
|
4670
6026
|
persistDirty = true;
|
|
4671
6027
|
if (force) {
|
|
@@ -4673,20 +6029,18 @@ function apply(ctx, config) {
|
|
|
4673
6029
|
clearTimeout(persistTimer);
|
|
4674
6030
|
persistScheduled = false;
|
|
4675
6031
|
}
|
|
4676
|
-
flushPersist();
|
|
4677
|
-
return;
|
|
6032
|
+
return flushPersist();
|
|
4678
6033
|
}
|
|
4679
|
-
if (persistScheduled) return;
|
|
6034
|
+
if (persistScheduled) return Promise.resolve();
|
|
4680
6035
|
const delay = PERSIST_THROTTLE_MS - (Date.now() - lastPersistAt);
|
|
4681
|
-
if (delay <= 0) flushPersist();
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
}
|
|
6036
|
+
if (delay <= 0) return flushPersist();
|
|
6037
|
+
persistScheduled = true;
|
|
6038
|
+
persistTimer = setTimeout(() => {
|
|
6039
|
+
persistScheduled = false;
|
|
6040
|
+
flushPersist();
|
|
6041
|
+
}, delay);
|
|
6042
|
+
persistTimer.unref?.();
|
|
6043
|
+
return Promise.resolve();
|
|
4690
6044
|
}
|
|
4691
6045
|
ctx.on("tools/result", (exec, result) => {
|
|
4692
6046
|
if (exec.name === "str_replace_editor") {
|
|
@@ -4702,31 +6056,47 @@ function apply(ctx, config) {
|
|
|
4702
6056
|
sessionId,
|
|
4703
6057
|
...outcome,
|
|
4704
6058
|
updatedAt: Date.now(),
|
|
4705
|
-
sessionIds: [sessionId]
|
|
6059
|
+
sessionIds: [sessionId],
|
|
6060
|
+
lineage: lineageOf(sessionId, exec.agent),
|
|
6061
|
+
unseen: true
|
|
4706
6062
|
};
|
|
4707
6063
|
if (store.fold(entry)) persistSession();
|
|
4708
6064
|
});
|
|
4709
6065
|
const handle = async (endpoint, payload, signal) => {
|
|
4710
6066
|
switch (endpoint) {
|
|
6067
|
+
case "update-check": return {
|
|
6068
|
+
ok: true,
|
|
6069
|
+
value: await checkForUpdate()
|
|
6070
|
+
};
|
|
4711
6071
|
case "list": {
|
|
4712
6072
|
const sessionId = sessionOf(payload);
|
|
4713
6073
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
4714
6074
|
await ensureLoaded();
|
|
4715
|
-
|
|
4716
|
-
const
|
|
6075
|
+
const entries = store.all();
|
|
6076
|
+
const view = lineageView(entries);
|
|
6077
|
+
if (comments.retain(new Set(entries.map((entry) => entry.id))) > 0) forgetOrphanedCommentLines();
|
|
6078
|
+
const { files, redoCleared } = await listWithState(sessionId, view, entries);
|
|
6079
|
+
const scope = commentScopeOf(sessionId, view);
|
|
6080
|
+
const visibleComments = comments.list(scope);
|
|
6081
|
+
let folded = false;
|
|
4717
6082
|
const commentAnswers = {};
|
|
4718
|
-
if (
|
|
4719
|
-
const
|
|
4720
|
-
for (const [
|
|
4721
|
-
|
|
4722
|
-
|
|
6083
|
+
if (visibleComments.length > 0) {
|
|
6084
|
+
const transcripts = /* @__PURE__ */ new Map();
|
|
6085
|
+
for (const [transcript, requestIds] of commentAsker.answerGroups(visibleComments)) {
|
|
6086
|
+
const answers = commentAsker.answersFor(transcript, requestIds, transcripts);
|
|
6087
|
+
for (const [requestId, read] of Object.entries(answers)) if (read.turn !== void 0 && comments.recordTurnForRequest(requestId, read.turn) > 0) folded = true;
|
|
6088
|
+
for (const turn of commentAsker.endedTurns(transcript, transcripts)) if (comments.markTurnEnded(transcript, turn) > 0) folded = true;
|
|
6089
|
+
if (comments.syncAnswers(transcript, answers)) folded = true;
|
|
6090
|
+
for (const [requestId, read] of Object.entries(answers)) if (read.answer !== void 0) commentAnswers[requestId] = read.answer;
|
|
6091
|
+
}
|
|
4723
6092
|
}
|
|
6093
|
+
const listedComments = folded ? comments.list(scope) : visibleComments;
|
|
4724
6094
|
return {
|
|
4725
6095
|
ok: true,
|
|
4726
6096
|
value: {
|
|
4727
6097
|
files,
|
|
4728
|
-
comments:
|
|
4729
|
-
commentLines: resolvedCommentLines(
|
|
6098
|
+
comments: listedComments,
|
|
6099
|
+
commentLines: resolvedCommentLines(listedComments),
|
|
4730
6100
|
commentsRevision: comments.commentsRevision(),
|
|
4731
6101
|
commentAnswers,
|
|
4732
6102
|
workspacePath: workspaceOf(sessionId)?.path,
|
|
@@ -4737,11 +6107,44 @@ function apply(ctx, config) {
|
|
|
4737
6107
|
}
|
|
4738
6108
|
};
|
|
4739
6109
|
}
|
|
6110
|
+
case "list-count": {
|
|
6111
|
+
const sessionId = sessionOf(payload);
|
|
6112
|
+
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
6113
|
+
await ensureLoaded();
|
|
6114
|
+
const entries = store.all();
|
|
6115
|
+
const view = lineageView(entries);
|
|
6116
|
+
let count = 0;
|
|
6117
|
+
for (const entry of entries) {
|
|
6118
|
+
if (!view.sees(sessionId, entry)) continue;
|
|
6119
|
+
if ((await probePath(entry.path)).kind !== "present") continue;
|
|
6120
|
+
count += 1;
|
|
6121
|
+
}
|
|
6122
|
+
return {
|
|
6123
|
+
ok: true,
|
|
6124
|
+
value: { count }
|
|
6125
|
+
};
|
|
6126
|
+
}
|
|
6127
|
+
case "comment-seen": {
|
|
6128
|
+
const seen = targetOf(payload);
|
|
6129
|
+
if (seen === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
6130
|
+
await ensureLoaded();
|
|
6131
|
+
const record = comments.get(seen.id);
|
|
6132
|
+
if (record === void 0 || !commentScopeOf(seen.sessionId, lineageView())(record.sessionId)) return {
|
|
6133
|
+
ok: true,
|
|
6134
|
+
value: { outcome: "missing" }
|
|
6135
|
+
};
|
|
6136
|
+
comments.markSeen(seen.id);
|
|
6137
|
+
return {
|
|
6138
|
+
ok: true,
|
|
6139
|
+
value: { outcome: "seen" }
|
|
6140
|
+
};
|
|
6141
|
+
}
|
|
4740
6142
|
case "comment-add": {
|
|
4741
6143
|
const input = commentAddOf(payload);
|
|
4742
6144
|
if (input === void 0) return rpcError("sessionId, entryId, anchor, quote and text must be valid");
|
|
4743
6145
|
await ensureLoaded();
|
|
4744
|
-
const
|
|
6146
|
+
const view = lineageView();
|
|
6147
|
+
const entry = actionableEntryOf(view, input.sessionId, input.entryId);
|
|
4745
6148
|
if (entry === void 0) return {
|
|
4746
6149
|
ok: true,
|
|
4747
6150
|
value: { outcome: "missing" }
|
|
@@ -4761,7 +6164,7 @@ function apply(ctx, config) {
|
|
|
4761
6164
|
...input.quoteLines === void 0 ? {} : { quoteLines: input.quoteLines }
|
|
4762
6165
|
};
|
|
4763
6166
|
comments.add(record);
|
|
4764
|
-
pushUndo(input.sessionId, commentState(entry.path, []), commentState(entry.path, [record]));
|
|
6167
|
+
pushUndo(input.sessionId, commentState(entry.path, []), commentState(entry.path, [record]), view);
|
|
4765
6168
|
return {
|
|
4766
6169
|
ok: true,
|
|
4767
6170
|
value: {
|
|
@@ -4774,10 +6177,11 @@ function apply(ctx, config) {
|
|
|
4774
6177
|
const target = targetOf(payload);
|
|
4775
6178
|
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
4776
6179
|
await ensureLoaded();
|
|
6180
|
+
const view = lineageView();
|
|
4777
6181
|
const record = comments.get(target.id);
|
|
4778
|
-
const removed = comments.remove(target.sessionId, target.id);
|
|
6182
|
+
const removed = comments.remove(commentScopeOf(target.sessionId, view), target.id);
|
|
4779
6183
|
const value = { outcome: removed ? "removed" : "missing" };
|
|
4780
|
-
if (removed && record !== void 0) pushUndo(target.sessionId, commentState(record.entryId, [record]), commentState(record.entryId, []));
|
|
6184
|
+
if (removed && record !== void 0) pushUndo(target.sessionId, commentState(record.entryId, [record]), commentState(record.entryId, []), view);
|
|
4781
6185
|
commentLines.delete(target.id);
|
|
4782
6186
|
return {
|
|
4783
6187
|
ok: true,
|
|
@@ -4788,11 +6192,13 @@ function apply(ctx, config) {
|
|
|
4788
6192
|
const request = commentRemoveManyOf(payload);
|
|
4789
6193
|
if (request === void 0) return rpcError("sessionId and a non-empty ids array of strings must be given");
|
|
4790
6194
|
await ensureLoaded();
|
|
4791
|
-
const
|
|
4792
|
-
const
|
|
6195
|
+
const view = lineageView();
|
|
6196
|
+
const scope = commentScopeOf(request.sessionId, view);
|
|
6197
|
+
const snapshot = request.ids.map((id) => comments.get(id)).filter((record) => record !== void 0 && scope(record.sessionId));
|
|
6198
|
+
const removed = comments.removeMany(scope, request.ids);
|
|
4793
6199
|
for (const id of removed) commentLines.delete(id);
|
|
4794
6200
|
const first = snapshot[0];
|
|
4795
|
-
if (first !== void 0) pushUndo(request.sessionId, commentState(first.entryId, snapshot), commentState(first.entryId, []));
|
|
6201
|
+
if (first !== void 0) pushUndo(request.sessionId, commentState(first.entryId, snapshot), commentState(first.entryId, []), view);
|
|
4796
6202
|
return {
|
|
4797
6203
|
ok: true,
|
|
4798
6204
|
value: { removed: removed.length }
|
|
@@ -4802,18 +6208,20 @@ function apply(ctx, config) {
|
|
|
4802
6208
|
const input = commentAskOf(payload);
|
|
4803
6209
|
if (input === void 0) return rpcError("sessionId, id, prompt and text must be valid");
|
|
4804
6210
|
await ensureLoaded();
|
|
6211
|
+
const view = lineageView();
|
|
4805
6212
|
return {
|
|
4806
6213
|
ok: true,
|
|
4807
|
-
value: await commentAsker.ask(input.sessionId, input.id, input.prompt, input.text, signal)
|
|
6214
|
+
value: await commentAsker.ask(input.sessionId, input.id, input.prompt, input.text, signal, commentScopeOf(input.sessionId, view))
|
|
4808
6215
|
};
|
|
4809
6216
|
}
|
|
4810
6217
|
case "keep": {
|
|
4811
6218
|
const target = targetOf(payload);
|
|
4812
6219
|
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
4813
|
-
|
|
6220
|
+
const lineage = lineageView();
|
|
6221
|
+
let entry = actionableEntryOf(lineage, target.sessionId, target.id);
|
|
4814
6222
|
if (entry === void 0) {
|
|
4815
6223
|
await ensureLoaded();
|
|
4816
|
-
entry =
|
|
6224
|
+
entry = actionableEntryOf(lineage, target.sessionId, target.id);
|
|
4817
6225
|
if (entry === void 0) return {
|
|
4818
6226
|
ok: true,
|
|
4819
6227
|
value: { outcome: "missing" }
|
|
@@ -4868,10 +6276,11 @@ function apply(ctx, config) {
|
|
|
4868
6276
|
case "revert": {
|
|
4869
6277
|
const target = targetOf(payload);
|
|
4870
6278
|
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
4871
|
-
|
|
6279
|
+
const lineage = lineageView();
|
|
6280
|
+
let entry = actionableEntryOf(lineage, target.sessionId, target.id);
|
|
4872
6281
|
if (entry === void 0) {
|
|
4873
6282
|
await ensureLoaded();
|
|
4874
|
-
entry =
|
|
6283
|
+
entry = actionableEntryOf(lineage, target.sessionId, target.id);
|
|
4875
6284
|
if (entry === void 0) return {
|
|
4876
6285
|
ok: true,
|
|
4877
6286
|
value: { outcome: "missing" }
|
|
@@ -4881,6 +6290,7 @@ function apply(ctx, config) {
|
|
|
4881
6290
|
try {
|
|
4882
6291
|
undo = await revertEntryContent(entry, target.sessionId, signal);
|
|
4883
6292
|
} catch (error) {
|
|
6293
|
+
if (isPolicyRefusal(error)) return policyRefusal(entry.path, error);
|
|
4884
6294
|
return rpcError(`revert failed: ${errorMessage(error)}`);
|
|
4885
6295
|
}
|
|
4886
6296
|
if (payload.keepListed === true) {
|
|
@@ -4927,7 +6337,8 @@ function apply(ctx, config) {
|
|
|
4927
6337
|
const sessionId = sessionOf(payload);
|
|
4928
6338
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
4929
6339
|
await ensureLoaded();
|
|
4930
|
-
const
|
|
6340
|
+
const lineage = lineageView();
|
|
6341
|
+
const entries = store.all().filter((entry) => lineage.sees(sessionId, entry));
|
|
4931
6342
|
const before = [];
|
|
4932
6343
|
const after = [];
|
|
4933
6344
|
for (const entry of entries) {
|
|
@@ -4956,16 +6367,22 @@ function apply(ctx, config) {
|
|
|
4956
6367
|
const sessionId = sessionOf(payload);
|
|
4957
6368
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
4958
6369
|
await ensureLoaded();
|
|
4959
|
-
const
|
|
6370
|
+
const lineage = lineageView();
|
|
6371
|
+
const entries = store.all().filter((entry) => lineage.sees(sessionId, entry));
|
|
4960
6372
|
const batchBefore = [];
|
|
4961
6373
|
const batchAfter = [];
|
|
6374
|
+
/** How many files this press had already put back when it stopped: said, never hidden (see 3c). */
|
|
6375
|
+
let reverted = 0;
|
|
4962
6376
|
for (const entry of entries) {
|
|
4963
6377
|
let undo;
|
|
4964
6378
|
try {
|
|
4965
6379
|
undo = await revertEntryContent(entry, sessionId, signal);
|
|
4966
|
-
} catch {
|
|
4967
|
-
|
|
6380
|
+
} catch (error) {
|
|
6381
|
+
const far = reverted === 0 ? "" : ` (this press had already reverted ${reverted} of ${entries.length} files)`;
|
|
6382
|
+
if (isPolicyRefusal(error)) return rpcError(refusalText(entry.path, error) + far);
|
|
6383
|
+
return rpcError(`revert-all failed for ${entry.path}${far}`);
|
|
4968
6384
|
}
|
|
6385
|
+
reverted += 1;
|
|
4969
6386
|
if (undo !== void 0) {
|
|
4970
6387
|
batchBefore.push(droppingComments(undo.before));
|
|
4971
6388
|
batchAfter.push(undo.after);
|
|
@@ -4983,10 +6400,11 @@ function apply(ctx, config) {
|
|
|
4983
6400
|
const request = manyTargetsOf(payload);
|
|
4984
6401
|
if (request === void 0) return rpcError("sessionId and a non-empty ids array of strings must be given");
|
|
4985
6402
|
await ensureLoaded();
|
|
6403
|
+
const lineage = lineageView();
|
|
4986
6404
|
const before = [];
|
|
4987
6405
|
const after = [];
|
|
4988
6406
|
for (const id of request.ids) {
|
|
4989
|
-
const entry =
|
|
6407
|
+
const entry = actionableEntryOf(lineage, request.sessionId, id);
|
|
4990
6408
|
if (entry === void 0) continue;
|
|
4991
6409
|
if (request.keepListed === true) {
|
|
4992
6410
|
store.update(id, { oldText: entry.newText });
|
|
@@ -5033,18 +6451,21 @@ function apply(ctx, config) {
|
|
|
5033
6451
|
const request = manyTargetsOf(payload);
|
|
5034
6452
|
if (request === void 0) return rpcError("sessionId and a non-empty ids array of strings must be given");
|
|
5035
6453
|
await ensureLoaded();
|
|
6454
|
+
const lineage = lineageView();
|
|
5036
6455
|
const before = [];
|
|
5037
6456
|
const after = [];
|
|
5038
6457
|
let affected = 0;
|
|
5039
6458
|
for (const id of request.ids) {
|
|
5040
|
-
const entry =
|
|
6459
|
+
const entry = actionableEntryOf(lineage, request.sessionId, id);
|
|
5041
6460
|
if (entry === void 0) continue;
|
|
5042
6461
|
affected += 1;
|
|
5043
6462
|
let undo;
|
|
5044
6463
|
try {
|
|
5045
6464
|
undo = await revertEntryContent(entry, request.sessionId, signal);
|
|
5046
|
-
} catch {
|
|
5047
|
-
|
|
6465
|
+
} catch (error) {
|
|
6466
|
+
const far = affected - 1 === 0 ? "" : ` (this pick had already reverted ${affected - 1} of ${request.ids.length} files)`;
|
|
6467
|
+
if (isPolicyRefusal(error)) return rpcError(refusalText(entry.path, error) + far);
|
|
6468
|
+
return rpcError(`revert-many failed for ${entry.path}${far}`);
|
|
5048
6469
|
}
|
|
5049
6470
|
if (request.keepListed === true) {
|
|
5050
6471
|
const content = undo?.after.fileText ?? "";
|
|
@@ -5086,7 +6507,7 @@ function apply(ctx, config) {
|
|
|
5086
6507
|
const blockTarget = blockTargetOf(payload);
|
|
5087
6508
|
if (blockTarget === void 0) return rpcError("sessionId, id, and block must be valid");
|
|
5088
6509
|
await ensureLoaded();
|
|
5089
|
-
const entry =
|
|
6510
|
+
const entry = actionableEntryOf(lineageView(), blockTarget.sessionId, blockTarget.id);
|
|
5090
6511
|
if (entry === void 0) return {
|
|
5091
6512
|
ok: true,
|
|
5092
6513
|
value: { outcome: "missing" }
|
|
@@ -5140,7 +6561,7 @@ function apply(ctx, config) {
|
|
|
5140
6561
|
const blockTarget = blockTargetOf(payload);
|
|
5141
6562
|
if (blockTarget === void 0) return rpcError("sessionId, id, and block must be valid");
|
|
5142
6563
|
await ensureLoaded();
|
|
5143
|
-
const entry =
|
|
6564
|
+
const entry = actionableEntryOf(lineageView(), blockTarget.sessionId, blockTarget.id);
|
|
5144
6565
|
if (entry === void 0) return {
|
|
5145
6566
|
ok: true,
|
|
5146
6567
|
value: { outcome: "missing" }
|
|
@@ -5157,7 +6578,7 @@ function apply(ctx, config) {
|
|
|
5157
6578
|
let undo;
|
|
5158
6579
|
try {
|
|
5159
6580
|
const target = await ctx.fs.resolve(entry.path, { signal });
|
|
5160
|
-
if (entry.
|
|
6581
|
+
if (entry.earlierVersion === "none" && content === "") await removeRevert(target, blockTarget.sessionId, signal);
|
|
5161
6582
|
else {
|
|
5162
6583
|
const preWrite = await ctx.fs.readText(target, void 0) ?? entry.newText;
|
|
5163
6584
|
await writeRevert(target, content, blockTarget.sessionId, signal);
|
|
@@ -5177,6 +6598,7 @@ function apply(ctx, config) {
|
|
|
5177
6598
|
};
|
|
5178
6599
|
}
|
|
5179
6600
|
} catch (error) {
|
|
6601
|
+
if (isPolicyRefusal(error)) return policyRefusal(entry.path, error);
|
|
5180
6602
|
return rpcError(`block revert failed: ${errorMessage(error)}`);
|
|
5181
6603
|
}
|
|
5182
6604
|
if (undo !== void 0) pushUndo(blockTarget.sessionId, undo.before, undo.after);
|
|
@@ -5209,18 +6631,35 @@ function apply(ctx, config) {
|
|
|
5209
6631
|
case "undo": {
|
|
5210
6632
|
const sessionId = sessionOf(payload);
|
|
5211
6633
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
5212
|
-
const
|
|
6634
|
+
const view = lineageView();
|
|
6635
|
+
const root = canonicalOf(sessionId, view);
|
|
6636
|
+
const pair = popOwnPair(undoStackOf(root), root);
|
|
5213
6637
|
if (pair === void 0) return {
|
|
5214
6638
|
ok: true,
|
|
5215
6639
|
value: { outcome: "nothing" }
|
|
5216
6640
|
};
|
|
6641
|
+
const writes = restoreWritesOf(pair.before, pair.after);
|
|
6642
|
+
const refused = await preflightRestore(writes, pair.policySession, signal);
|
|
6643
|
+
if (refused !== void 0) {
|
|
6644
|
+
undoStackOf(root).push(pair);
|
|
6645
|
+
return restoreFailure("undo", refused.path, refused.error, {
|
|
6646
|
+
written: 0,
|
|
6647
|
+
total: writes.length,
|
|
6648
|
+
path: refused.path
|
|
6649
|
+
});
|
|
6650
|
+
}
|
|
6651
|
+
const progress = {
|
|
6652
|
+
written: 0,
|
|
6653
|
+
total: writes.length,
|
|
6654
|
+
path: pair.before.path
|
|
6655
|
+
};
|
|
5217
6656
|
try {
|
|
5218
|
-
await restoreState(pair.
|
|
6657
|
+
await restoreState(pair.policySession, pair.before, pair.after, signal, commentScopeOf(pair.pressedBy, view), progress);
|
|
5219
6658
|
} catch (error) {
|
|
5220
|
-
undoStackOf(
|
|
5221
|
-
return
|
|
6659
|
+
undoStackOf(root).push(pair);
|
|
6660
|
+
return restoreFailure("undo", progress.path, error, progress);
|
|
5222
6661
|
}
|
|
5223
|
-
redoStackOf(
|
|
6662
|
+
redoStackOf(root).push(pair);
|
|
5224
6663
|
persistSession(true);
|
|
5225
6664
|
return {
|
|
5226
6665
|
ok: true,
|
|
@@ -5233,18 +6672,35 @@ function apply(ctx, config) {
|
|
|
5233
6672
|
case "redo": {
|
|
5234
6673
|
const sessionId = sessionOf(payload);
|
|
5235
6674
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
5236
|
-
const
|
|
6675
|
+
const view = lineageView();
|
|
6676
|
+
const root = canonicalOf(sessionId, view);
|
|
6677
|
+
const pair = popOwnPair(redoStackOf(root), root);
|
|
5237
6678
|
if (pair === void 0) return {
|
|
5238
6679
|
ok: true,
|
|
5239
6680
|
value: { outcome: "nothing" }
|
|
5240
6681
|
};
|
|
6682
|
+
const writes = restoreWritesOf(pair.after, pair.before);
|
|
6683
|
+
const refused = await preflightRestore(writes, pair.policySession, signal);
|
|
6684
|
+
if (refused !== void 0) {
|
|
6685
|
+
redoStackOf(root).push(pair);
|
|
6686
|
+
return restoreFailure("redo", refused.path, refused.error, {
|
|
6687
|
+
written: 0,
|
|
6688
|
+
total: writes.length,
|
|
6689
|
+
path: refused.path
|
|
6690
|
+
});
|
|
6691
|
+
}
|
|
6692
|
+
const progress = {
|
|
6693
|
+
written: 0,
|
|
6694
|
+
total: writes.length,
|
|
6695
|
+
path: pair.after.path
|
|
6696
|
+
};
|
|
5241
6697
|
try {
|
|
5242
|
-
await restoreState(pair.
|
|
6698
|
+
await restoreState(pair.policySession, pair.after, pair.before, signal, commentScopeOf(pair.pressedBy, view), progress);
|
|
5243
6699
|
} catch (error) {
|
|
5244
|
-
redoStackOf(
|
|
5245
|
-
return
|
|
6700
|
+
redoStackOf(root).push(pair);
|
|
6701
|
+
return restoreFailure("redo", progress.path, error, progress);
|
|
5246
6702
|
}
|
|
5247
|
-
undoStackOf(
|
|
6703
|
+
undoStackOf(root).push(pair);
|
|
5248
6704
|
persistSession(true);
|
|
5249
6705
|
return {
|
|
5250
6706
|
ok: true,
|
|
@@ -5292,11 +6748,12 @@ function apply(ctx, config) {
|
|
|
5292
6748
|
id: change.path,
|
|
5293
6749
|
sessionId,
|
|
5294
6750
|
path: change.path,
|
|
5295
|
-
|
|
6751
|
+
earlierVersion: change.earlierVersion,
|
|
5296
6752
|
oldText: change.oldText,
|
|
5297
6753
|
newText: change.newText,
|
|
5298
6754
|
updatedAt: Date.now(),
|
|
5299
|
-
sessionIds: [sessionId]
|
|
6755
|
+
sessionIds: [sessionId],
|
|
6756
|
+
lineage: lineageOf(sessionId)
|
|
5300
6757
|
}))),
|
|
5301
6758
|
detected: true
|
|
5302
6759
|
}
|
|
@@ -5306,7 +6763,7 @@ function apply(ctx, config) {
|
|
|
5306
6763
|
const target = targetOf(payload);
|
|
5307
6764
|
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
5308
6765
|
await ensureLoaded();
|
|
5309
|
-
const entry =
|
|
6766
|
+
const entry = actionableEntryOf(lineageView(), target.sessionId, target.id);
|
|
5310
6767
|
if (entry === void 0) return {
|
|
5311
6768
|
ok: true,
|
|
5312
6769
|
value: { outcome: "missing" }
|
|
@@ -5336,22 +6793,32 @@ function apply(ctx, config) {
|
|
|
5336
6793
|
return rpcError(`refresh failed: ${errorMessage(error)}`);
|
|
5337
6794
|
}
|
|
5338
6795
|
const change = changes.find((candidate) => pathIdentity(candidate.path) === pathIdentity(entry.path));
|
|
5339
|
-
if (change === void 0)
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
6796
|
+
if (change === void 0) {
|
|
6797
|
+
let committed = false;
|
|
6798
|
+
try {
|
|
6799
|
+
committed = await committedChange(root.root, root.kind, entry.path, shell, signal);
|
|
6800
|
+
} catch {}
|
|
6801
|
+
return {
|
|
6802
|
+
ok: true,
|
|
6803
|
+
value: committed ? {
|
|
6804
|
+
outcome: "committed",
|
|
6805
|
+
resolved: true
|
|
6806
|
+
} : { outcome: "no-change" }
|
|
6807
|
+
};
|
|
6808
|
+
}
|
|
6809
|
+
if (change.earlierVersion === entry.earlierVersion && change.oldText === entry.oldText && change.newText === entry.newText) return {
|
|
5344
6810
|
ok: true,
|
|
5345
6811
|
value: { outcome: "unchanged" }
|
|
5346
6812
|
};
|
|
5347
6813
|
const refreshed = {
|
|
5348
6814
|
...entry,
|
|
5349
|
-
|
|
6815
|
+
earlierVersion: change.earlierVersion,
|
|
5350
6816
|
oldText: change.oldText,
|
|
5351
6817
|
newText: change.newText,
|
|
5352
6818
|
updatedAt: Date.now()
|
|
5353
6819
|
};
|
|
5354
6820
|
store.restore(refreshed);
|
|
6821
|
+
store.markSeen(entry.path);
|
|
5355
6822
|
pushUndo(target.sessionId, {
|
|
5356
6823
|
id: entry.path,
|
|
5357
6824
|
path: entry.path,
|
|
@@ -5492,11 +6959,12 @@ function apply(ctx, config) {
|
|
|
5492
6959
|
id: absolute,
|
|
5493
6960
|
sessionId: target.sessionId,
|
|
5494
6961
|
path: absolute,
|
|
5495
|
-
|
|
6962
|
+
earlierVersion: "file",
|
|
5496
6963
|
oldText: content,
|
|
5497
6964
|
newText: content,
|
|
5498
6965
|
updatedAt: now,
|
|
5499
|
-
sessionIds: [target.sessionId]
|
|
6966
|
+
sessionIds: [target.sessionId],
|
|
6967
|
+
lineage: lineageOf(target.sessionId)
|
|
5500
6968
|
}], false, false);
|
|
5501
6969
|
}
|
|
5502
6970
|
const root = detectVcsRoot(workspace.path);
|
|
@@ -5537,11 +7005,12 @@ function apply(ctx, config) {
|
|
|
5537
7005
|
id: change.path,
|
|
5538
7006
|
sessionId: target.sessionId,
|
|
5539
7007
|
path: change.path,
|
|
5540
|
-
|
|
7008
|
+
earlierVersion: change.earlierVersion,
|
|
5541
7009
|
oldText: change.oldText,
|
|
5542
7010
|
newText: change.newText,
|
|
5543
7011
|
updatedAt: now,
|
|
5544
|
-
sessionIds: [target.sessionId]
|
|
7012
|
+
sessionIds: [target.sessionId],
|
|
7013
|
+
lineage: lineageOf(target.sessionId)
|
|
5545
7014
|
}));
|
|
5546
7015
|
let truncated = false;
|
|
5547
7016
|
if (target.includeUnchanged) {
|
|
@@ -5562,25 +7031,42 @@ function apply(ctx, config) {
|
|
|
5562
7031
|
id: file.path,
|
|
5563
7032
|
sessionId: target.sessionId,
|
|
5564
7033
|
path: file.path,
|
|
5565
|
-
|
|
7034
|
+
earlierVersion: "file",
|
|
5566
7035
|
oldText: file.content,
|
|
5567
7036
|
newText: file.content,
|
|
5568
7037
|
updatedAt: now,
|
|
5569
|
-
sessionIds: [target.sessionId]
|
|
7038
|
+
sessionIds: [target.sessionId],
|
|
7039
|
+
lineage: lineageOf(target.sessionId)
|
|
5570
7040
|
});
|
|
5571
7041
|
}
|
|
5572
7042
|
}
|
|
5573
7043
|
return await admitNamed(candidates, isDirectory, truncated);
|
|
5574
7044
|
}
|
|
7045
|
+
case "seen": {
|
|
7046
|
+
const seen = payload ?? {};
|
|
7047
|
+
if (typeof seen.id !== "string" || seen.id.length === 0) return rpcError("id must be a non-empty string");
|
|
7048
|
+
await ensureLoaded();
|
|
7049
|
+
if (store.markSeen(seen.id)) persistSession();
|
|
7050
|
+
return {
|
|
7051
|
+
ok: true,
|
|
7052
|
+
value: { outcome: "seen" }
|
|
7053
|
+
};
|
|
7054
|
+
}
|
|
5575
7055
|
case "open": {
|
|
5576
7056
|
const target = openTargetOf(payload);
|
|
5577
7057
|
if (target === void 0) return rpcError("sessionId, id, and action must be valid");
|
|
5578
7058
|
await ensureLoaded();
|
|
5579
|
-
const
|
|
7059
|
+
const lineage = lineageView();
|
|
7060
|
+
let entry = actionableEntryOf(lineage, target.sessionId, target.id);
|
|
7061
|
+
if (entry === void 0) {
|
|
7062
|
+
await ensureLoaded();
|
|
7063
|
+
entry = actionableEntryOf(lineage, target.sessionId, target.id);
|
|
7064
|
+
}
|
|
5580
7065
|
if (entry === void 0) return {
|
|
5581
7066
|
ok: true,
|
|
5582
7067
|
value: { outcome: "missing" }
|
|
5583
7068
|
};
|
|
7069
|
+
if (store.markSeen(entry.path)) persistSession();
|
|
5584
7070
|
try {
|
|
5585
7071
|
const resolved = await ctx.fs.resolve(entry.path, { signal });
|
|
5586
7072
|
await launchPath(ctx.fs.processPath(resolved), target.action);
|
|
@@ -5632,11 +7118,11 @@ function apply(ctx, config) {
|
|
|
5632
7118
|
return server.register(route);
|
|
5633
7119
|
}, "diff-approval: code font slices");
|
|
5634
7120
|
ctx.effect(() => ctx.on("fs/edit-intent", async (target, actor, next) => {
|
|
5635
|
-
await stashEditorIntent(target, actor, "
|
|
7121
|
+
await stashEditorIntent(target, actor, "file");
|
|
5636
7122
|
return next();
|
|
5637
7123
|
}, { prepend: true }), "diff-approval: str_replace_editor edit basis");
|
|
5638
7124
|
ctx.effect(() => ctx.on("fs/write-intent", async (target, actor, next) => {
|
|
5639
|
-
await stashEditorIntent(target, actor, "
|
|
7125
|
+
await stashEditorIntent(target, actor, "none");
|
|
5640
7126
|
return next();
|
|
5641
7127
|
}, { prepend: true }), "diff-approval: str_replace_editor create basis");
|
|
5642
7128
|
}
|