dsh-diff-approval 0.30.1 → 0.31.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 +3 -0
- package/README.zh.md +3 -0
- package/assets/fonts/README.md +10 -8
- package/assets/fonts/manifest.json +1 -1
- package/lib/client.js +2414 -848
- package/lib/index.js +1672 -265
- 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 +27 -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 +7 -0
- 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 +42 -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/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 +180 -4
- package/lib/types/vcs.d.ts +2 -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,182 @@ async function listVcsChanges(input) {
|
|
|
3451
3854
|
case "p4": return p4Changes(input);
|
|
3452
3855
|
}
|
|
3453
3856
|
}
|
|
3857
|
+
/**
|
|
3858
|
+
* Parse `major.minor.patch`, and NOTHING else.
|
|
3859
|
+
*
|
|
3860
|
+
* Deliberately strict, because the alternative is a bogus "newer": a pre-release
|
|
3861
|
+
* (`0.31.0-rc.1`), a range (`^0.31.0`), a dist-tag (`latest`) or a stray suffix all
|
|
3862
|
+
* answer `undefined`, and an unparsable side of a comparison is never allowed to look
|
|
3863
|
+
* newer. A leading `v` is tolerated because tags carry one.
|
|
3864
|
+
*
|
|
3865
|
+
* @param text - the candidate, from a registry document or a `package.json`.
|
|
3866
|
+
* @returns the three numbers, or `undefined` when the text is not exactly that shape.
|
|
3867
|
+
*/
|
|
3868
|
+
function parseVersion(text) {
|
|
3869
|
+
if (typeof text !== "string") return void 0;
|
|
3870
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)$/u.exec(text.trim());
|
|
3871
|
+
if (match === null) return void 0;
|
|
3872
|
+
const parts = [
|
|
3873
|
+
Number(match[1]),
|
|
3874
|
+
Number(match[2]),
|
|
3875
|
+
Number(match[3])
|
|
3876
|
+
];
|
|
3877
|
+
if (parts.some((part) => !Number.isSafeInteger(part))) return void 0;
|
|
3878
|
+
return parts;
|
|
3879
|
+
}
|
|
3880
|
+
/**
|
|
3881
|
+
* Is `latest` strictly newer than `current`?
|
|
3882
|
+
*
|
|
3883
|
+
* Strictly: equal versions are NOT newer, and neither side parsing means NOT newer —
|
|
3884
|
+
* the rule the code above exists to make safe. Comparison is component-wise on the
|
|
3885
|
+
* three numbers, so `0.9.0` precedes `0.10.0` (string comparison would get that wrong).
|
|
3886
|
+
*
|
|
3887
|
+
* @param latest - the published version.
|
|
3888
|
+
* @param current - the installed version.
|
|
3889
|
+
* @returns whether a notice is warranted.
|
|
3890
|
+
*/
|
|
3891
|
+
function isNewerVersion(latest, current) {
|
|
3892
|
+
const next = parseVersion(latest);
|
|
3893
|
+
const here = parseVersion(current);
|
|
3894
|
+
if (next === void 0 || here === void 0) return false;
|
|
3895
|
+
return compareVersions(next, here) > 0;
|
|
3896
|
+
}
|
|
3897
|
+
/**
|
|
3898
|
+
* Compare two already-parsed versions.
|
|
3899
|
+
*
|
|
3900
|
+
* Component-wise, because a string comparison gets `0.9.0` vs `0.10.0` wrong — the reason both
|
|
3901
|
+
* callers go through here rather than comparing something textual.
|
|
3902
|
+
*
|
|
3903
|
+
* @param left - one version.
|
|
3904
|
+
* @param right - the other.
|
|
3905
|
+
* @returns negative, zero or positive, as a comparator should.
|
|
3906
|
+
*/
|
|
3907
|
+
function compareVersions(left, right) {
|
|
3908
|
+
for (let index = 0; index < 3; index += 1) if (left[index] !== right[index]) return left[index] - right[index];
|
|
3909
|
+
return 0;
|
|
3910
|
+
}
|
|
3911
|
+
/**
|
|
3912
|
+
* The version installed in this package, read from the `package.json` beside the built
|
|
3913
|
+
* `lib/` — the only authority on what the reader is running, and the value the registry
|
|
3914
|
+
* answer is compared against.
|
|
3915
|
+
*
|
|
3916
|
+
* Resolved relative to this module, so it holds for the bundled `lib/index.js`
|
|
3917
|
+
* (`../package.json` is the package root) and for the source under test (`../package.json`
|
|
3918
|
+
* is the repository root) alike.
|
|
3919
|
+
*
|
|
3920
|
+
* @param manifestUrl - the manifest to read; the package's own `package.json` unless a caller
|
|
3921
|
+
* says otherwise. It is a parameter because a test runner can serve modules over a non-file
|
|
3922
|
+
* URL, where the default cannot be resolved — the production path is the default, and the
|
|
3923
|
+
* end-to-end case is what proves the default itself.
|
|
3924
|
+
* @returns the version, or `undefined` when the file is missing or has none.
|
|
3925
|
+
*/
|
|
3926
|
+
async function installedVersion(manifestUrl = new URL("../package.json", import.meta.url)) {
|
|
3927
|
+
try {
|
|
3928
|
+
const text = await readFile(manifestUrl, "utf8");
|
|
3929
|
+
const document = JSON.parse(text);
|
|
3930
|
+
return typeof document.version === "string" && document.version.length > 0 ? document.version : void 0;
|
|
3931
|
+
} catch {
|
|
3932
|
+
return;
|
|
3933
|
+
}
|
|
3934
|
+
}
|
|
3935
|
+
/**
|
|
3936
|
+
* Ask the registry for the published version.
|
|
3937
|
+
*
|
|
3938
|
+
* @param probe - where to ask and how; every field defaults (see {@link UpdateProbe}).
|
|
3939
|
+
* @returns the version, or `undefined` on any failure — including an answer that is not
|
|
3940
|
+
* a version, which must never become a notice.
|
|
3941
|
+
*/
|
|
3942
|
+
async function fetchLatestVersion(probe = {}) {
|
|
3943
|
+
const body = await fetchText(probe.registryUrl ?? "https://registry.npmjs.org/dsh-diff-approval/latest", probe);
|
|
3944
|
+
if (body === void 0) return void 0;
|
|
3945
|
+
try {
|
|
3946
|
+
const document = JSON.parse(body);
|
|
3947
|
+
return parseVersion(document.version) === void 0 ? void 0 : String(document.version);
|
|
3948
|
+
} catch {
|
|
3949
|
+
return;
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
/**
|
|
3953
|
+
* Build the answer behind the `update-check` endpoint: one function a host calls per request.
|
|
3954
|
+
*
|
|
3955
|
+
* WHAT IS CACHED, AND FOR HOW LONG — the whole point of this factory:
|
|
3956
|
+
*
|
|
3957
|
+
* - The REMOTE answer (the registry's latest version) is cached, because that is the network cost. It is held
|
|
3958
|
+
* for {@link UPDATE_CACHE_TTL_MS} and then fetched again, so a release published while the host is running
|
|
3959
|
+
* still reaches the reader without a restart. A FAILED fetch is not cached at all: remembering a failure
|
|
3960
|
+
* would hide the notice for the whole TTL after one offline moment, and there is no network cost to save.
|
|
3961
|
+
* - The INSTALLED version is read from the manifest on EVERY call. It is a file read, not a request, and it is
|
|
3962
|
+
* the side that changes when the reader upgrades — so a verdict computed before that upgrade must never be
|
|
3963
|
+
* replayed. Caching it was a real defect: a host that answered while an older version was installed kept
|
|
3964
|
+
* announcing "a newer release" to every page until it was restarted.
|
|
3965
|
+
*
|
|
3966
|
+
* `newer` is therefore decided afresh on every call, from today's manifest against the cached registry answer.
|
|
3967
|
+
* NEVER THROWS: every failure — no manifest, an unreachable registry, a nonsense answer — ends as "nothing to
|
|
3968
|
+
* say" (`newer: false`), so a network problem can never become an error in the reader's status bar.
|
|
3969
|
+
*
|
|
3970
|
+
* @param options - where to read and how; every field defaults (see {@link UpdateCheckOptions}).
|
|
3971
|
+
* @returns the check itself; call it as often as you like.
|
|
3972
|
+
*/
|
|
3973
|
+
function createUpdateCheck(options = {}) {
|
|
3974
|
+
const ttl = options.ttlMs ?? 6e5;
|
|
3975
|
+
const now = options.now ?? (() => Date.now());
|
|
3976
|
+
const probe = {
|
|
3977
|
+
...options.registryUrl === void 0 ? {} : { registryUrl: options.registryUrl },
|
|
3978
|
+
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
|
|
3979
|
+
...options.fetchImpl === void 0 ? {} : { fetchImpl: options.fetchImpl }
|
|
3980
|
+
};
|
|
3981
|
+
const manifestUrl = options.manifestUrl ?? new URL("../package.json", import.meta.url);
|
|
3982
|
+
/** The cached REMOTE answer, with the moment it was fetched. */
|
|
3983
|
+
let remote;
|
|
3984
|
+
return async () => {
|
|
3985
|
+
const current = await installedVersion(manifestUrl);
|
|
3986
|
+
if (current === void 0) return {
|
|
3987
|
+
current: "",
|
|
3988
|
+
newer: false
|
|
3989
|
+
};
|
|
3990
|
+
const at = now();
|
|
3991
|
+
if (remote === void 0 || at - remote.at >= ttl) {
|
|
3992
|
+
const fetched = await fetchLatestVersion(probe);
|
|
3993
|
+
if (fetched !== void 0) remote = {
|
|
3994
|
+
version: fetched,
|
|
3995
|
+
at
|
|
3996
|
+
};
|
|
3997
|
+
}
|
|
3998
|
+
const latest = remote?.version;
|
|
3999
|
+
if (latest === void 0 || !isNewerVersion(latest, current)) return {
|
|
4000
|
+
current,
|
|
4001
|
+
...latest === void 0 ? {} : { latest },
|
|
4002
|
+
newer: false
|
|
4003
|
+
};
|
|
4004
|
+
return {
|
|
4005
|
+
current,
|
|
4006
|
+
latest,
|
|
4007
|
+
newer: true
|
|
4008
|
+
};
|
|
4009
|
+
};
|
|
4010
|
+
}
|
|
4011
|
+
/** One `GET`, with the module's timeout, mapping every failure to `undefined`. */
|
|
4012
|
+
async function fetchText(url, probe) {
|
|
4013
|
+
const implementation = probe.fetchImpl ?? globalThis.fetch;
|
|
4014
|
+
if (implementation === void 0) return void 0;
|
|
4015
|
+
const controller = new AbortController();
|
|
4016
|
+
const timer = setTimeout(() => {
|
|
4017
|
+
controller.abort();
|
|
4018
|
+
}, probe.timeoutMs ?? 3e3);
|
|
4019
|
+
timer.unref?.();
|
|
4020
|
+
try {
|
|
4021
|
+
const response = await implementation(url, {
|
|
4022
|
+
signal: controller.signal,
|
|
4023
|
+
headers: { accept: "application/json, text/plain, */*" }
|
|
4024
|
+
});
|
|
4025
|
+
if (!response.ok) return void 0;
|
|
4026
|
+
return await response.text();
|
|
4027
|
+
} catch {
|
|
4028
|
+
return;
|
|
4029
|
+
} finally {
|
|
4030
|
+
clearTimeout(timer);
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
3454
4033
|
//#endregion
|
|
3455
4034
|
//#region lib/types/index.js
|
|
3456
4035
|
/**
|
|
@@ -3667,7 +4246,7 @@ function editOutcomeOf(value) {
|
|
|
3667
4246
|
if (typeof before !== "string" || typeof after !== "string") return void 0;
|
|
3668
4247
|
return {
|
|
3669
4248
|
path,
|
|
3670
|
-
|
|
4249
|
+
earlierVersion: "file",
|
|
3671
4250
|
oldText: before,
|
|
3672
4251
|
newText: after
|
|
3673
4252
|
};
|
|
@@ -3675,10 +4254,11 @@ function editOutcomeOf(value) {
|
|
|
3675
4254
|
/**
|
|
3676
4255
|
* Narrow a successful `write` result value to an operation outcome. The write
|
|
3677
4256
|
* tool's output schema declares `{ path, operation, before, after }`;
|
|
3678
|
-
* `operation: 'create'` becomes
|
|
3679
|
-
*
|
|
3680
|
-
*
|
|
3681
|
-
*
|
|
4257
|
+
* `operation: 'create'` becomes an entry with `earlierVersion: 'none'` (there is
|
|
4258
|
+
* no earlier version, so the whole-file action removes the file),
|
|
4259
|
+
* `operation: 'update'` one with `earlierVersion: 'file'`. An update whose
|
|
4260
|
+
* `before` is null carried no contextual basis, so it is skipped rather than
|
|
4261
|
+
* tracked as an un-revertable overwrite.
|
|
3682
4262
|
* @param value - the successful result's JSON value.
|
|
3683
4263
|
* @returns the outcome, or `undefined` when the value is not a trackable write.
|
|
3684
4264
|
*/
|
|
@@ -3690,14 +4270,14 @@ function writeOutcomeOf(value) {
|
|
|
3690
4270
|
if (typeof after !== "string") return void 0;
|
|
3691
4271
|
if (operation === "create") return {
|
|
3692
4272
|
path,
|
|
3693
|
-
|
|
4273
|
+
earlierVersion: "none",
|
|
3694
4274
|
oldText: "",
|
|
3695
4275
|
newText: after
|
|
3696
4276
|
};
|
|
3697
4277
|
if (typeof before !== "string") return void 0;
|
|
3698
4278
|
return {
|
|
3699
4279
|
path,
|
|
3700
|
-
|
|
4280
|
+
earlierVersion: "file",
|
|
3701
4281
|
oldText: before,
|
|
3702
4282
|
newText: after
|
|
3703
4283
|
};
|
|
@@ -3802,6 +4382,31 @@ function rpcError(message) {
|
|
|
3802
4382
|
}
|
|
3803
4383
|
};
|
|
3804
4384
|
}
|
|
4385
|
+
/** The process's one update check: see {@link checkForUpdate}. */
|
|
4386
|
+
let updateCheck;
|
|
4387
|
+
/**
|
|
4388
|
+
* Whether a newer release of this package is published — the answer behind the channel's
|
|
4389
|
+
* `update-check` endpoint.
|
|
4390
|
+
*
|
|
4391
|
+
* ONE CHECK PER PROCESS, and what it caches is the REMOTE half only (`createUpdateCheck` in `version.ts`):
|
|
4392
|
+
* the registry's latest version, held for a short TTL, while the INSTALLED version is read from the manifest
|
|
4393
|
+
* on every call. So an upgrade, a reinstall or a downgrade under a running host is reflected immediately,
|
|
4394
|
+
* and a release published while the host runs appears within the TTL rather than at the next restart.
|
|
4395
|
+
*
|
|
4396
|
+
* NEVER THROWS and never rejects: the endpoint must not be able to turn a network
|
|
4397
|
+
* problem into an error in the reader's status bar. A failure to read the version at
|
|
4398
|
+
* all (a stripped install with no `package.json`) answers `newer: false`.
|
|
4399
|
+
*
|
|
4400
|
+
* The registry URL can be overridden by environment — `DSH_DIFF_APPROVAL_UPDATE_REGISTRY_URL` — because
|
|
4401
|
+
* the end-to-end case must not depend on the public internet. It is read HERE, on the first call, which is
|
|
4402
|
+
* after a test has set it and before the host answers anything. It is the ONLY such override.
|
|
4403
|
+
*
|
|
4404
|
+
* @returns what the panel needs to decide about its notice.
|
|
4405
|
+
*/
|
|
4406
|
+
function checkForUpdate() {
|
|
4407
|
+
updateCheck ??= createUpdateCheck({ ...process.env.DSH_DIFF_APPROVAL_UPDATE_REGISTRY_URL === void 0 ? {} : { registryUrl: process.env.DSH_DIFF_APPROVAL_UPDATE_REGISTRY_URL } });
|
|
4408
|
+
return updateCheck();
|
|
4409
|
+
}
|
|
3805
4410
|
/**
|
|
3806
4411
|
* Mount the pending-edit review surface.
|
|
3807
4412
|
* @param ctx - Cordis context carrying the filesystem, connection, and workspace registry services.
|
|
@@ -3845,12 +4450,14 @@ function apply(ctx, config) {
|
|
|
3845
4450
|
* A comment whose quote is gone from the content is ABSENT from the map rather than guessed at:
|
|
3846
4451
|
* the caller then falls back to `record.anchor`, the line the comment was written on, which is
|
|
3847
4452
|
* the same answer an outdated thread shows.
|
|
3848
|
-
* @param
|
|
4453
|
+
* @param listed - the comments this read is handing over: the caller took ONE scoped list and reuses it
|
|
4454
|
+
* here, because resolving lines is derived from a record's own fields (`quote`, `anchor`, `entryId`),
|
|
4455
|
+
* none of which a fold touches — so the pre-fold and post-fold lists resolve identically.
|
|
3849
4456
|
* @returns the resolved lines per comment id; comments that do not resolve are left out.
|
|
3850
4457
|
*/
|
|
3851
|
-
function resolvedCommentLines(
|
|
4458
|
+
function resolvedCommentLines(listed) {
|
|
3852
4459
|
const resolved = {};
|
|
3853
|
-
for (const comment of
|
|
4460
|
+
for (const comment of listed) {
|
|
3854
4461
|
const entry = store.get(comment.entryId);
|
|
3855
4462
|
if (entry === void 0) continue;
|
|
3856
4463
|
const version = store.contentVersion(comment.entryId);
|
|
@@ -3909,10 +4516,16 @@ function apply(ctx, config) {
|
|
|
3909
4516
|
};
|
|
3910
4517
|
registerAnnotateTool(ctx, {
|
|
3911
4518
|
ready: ensureLoaded,
|
|
3912
|
-
entriesOf: (sessionId) =>
|
|
3913
|
-
|
|
4519
|
+
entriesOf: (sessionId) => {
|
|
4520
|
+
const view = lineageView();
|
|
4521
|
+
return store.all().filter((entry) => view.sees(sessionId, entry));
|
|
4522
|
+
},
|
|
4523
|
+
commentsOf: (sessionId) => comments.list(commentScopeOf(sessionId, lineageView())),
|
|
3914
4524
|
addComment: (record) => {
|
|
3915
|
-
comments.add(record
|
|
4525
|
+
comments.add(record.author === "agent" ? {
|
|
4526
|
+
...record,
|
|
4527
|
+
unseen: true
|
|
4528
|
+
} : record);
|
|
3916
4529
|
},
|
|
3917
4530
|
listFile: (sessionId, asked, signal) => listFileForAnnotation(sessionId, asked, signal),
|
|
3918
4531
|
log: (message) => ctx.logger.info(`diff-approval: ${message}`)
|
|
@@ -3954,11 +4567,12 @@ function apply(ctx, config) {
|
|
|
3954
4567
|
id: absolute,
|
|
3955
4568
|
sessionId,
|
|
3956
4569
|
path: absolute,
|
|
3957
|
-
|
|
4570
|
+
earlierVersion: "file",
|
|
3958
4571
|
oldText: content,
|
|
3959
4572
|
newText: content,
|
|
3960
4573
|
updatedAt: Date.now(),
|
|
3961
|
-
sessionIds: [sessionId]
|
|
4574
|
+
sessionIds: [sessionId],
|
|
4575
|
+
unseen: true
|
|
3962
4576
|
}], true);
|
|
3963
4577
|
const landed = store.list(sessionId).find((entry) => pathIdentity(entry.path) === pathIdentity(absolute));
|
|
3964
4578
|
return landed === void 0 ? { kind: "missing" } : {
|
|
@@ -3987,28 +4601,30 @@ function apply(ctx, config) {
|
|
|
3987
4601
|
const removed = store.remove(id);
|
|
3988
4602
|
comments.removeForEntry(id);
|
|
3989
4603
|
forgetCommentLinesForEntry(id);
|
|
4604
|
+
liveObservations.delete(id);
|
|
3990
4605
|
return removed;
|
|
3991
4606
|
}
|
|
3992
4607
|
/** Pre-write bases captured at the intent seams, keyed by the tool call id. */
|
|
3993
4608
|
const editorIntents = /* @__PURE__ */ new Map();
|
|
3994
4609
|
/**
|
|
3995
|
-
* Snapshot one str_replace_editor mutation's basis at its intent seam. A
|
|
3996
|
-
*
|
|
3997
|
-
* failure tracks nothing — the settle-side pairing then
|
|
4610
|
+
* Snapshot one str_replace_editor mutation's basis at its intent seam. A file
|
|
4611
|
+
* creation has no earlier version, so its basis is empty; an edit reads the
|
|
4612
|
+
* pre-write content. Any failure tracks nothing — the settle-side pairing then
|
|
4613
|
+
* sees no basis.
|
|
3998
4614
|
* @param target - the resolved target about to be written.
|
|
3999
4615
|
* @param actor - the tool execution running the mutation.
|
|
4000
|
-
* @param
|
|
4616
|
+
* @param earlierVersion - whether the mutation creates the file (`'none'`: no earlier version) or edits it (`'file'`).
|
|
4001
4617
|
*/
|
|
4002
|
-
async function stashEditorIntent(target, actor,
|
|
4618
|
+
async function stashEditorIntent(target, actor, earlierVersion) {
|
|
4003
4619
|
const shaped = actorOf(actor);
|
|
4004
4620
|
if (shaped === void 0 || shaped.name !== "str_replace_editor") return;
|
|
4005
4621
|
if (typeof shaped.callId !== "string") return;
|
|
4006
4622
|
const sessionId = sessionOfAgent(shaped.agent);
|
|
4007
4623
|
if (sessionId === void 0) return;
|
|
4008
|
-
if (
|
|
4624
|
+
if (earlierVersion === "none") {
|
|
4009
4625
|
editorIntents.set(shaped.callId, {
|
|
4010
4626
|
target,
|
|
4011
|
-
|
|
4627
|
+
earlierVersion,
|
|
4012
4628
|
before: "",
|
|
4013
4629
|
sessionId
|
|
4014
4630
|
});
|
|
@@ -4018,7 +4634,7 @@ function apply(ctx, config) {
|
|
|
4018
4634
|
const before = await ctx.fs.readText(target, void 0) ?? "";
|
|
4019
4635
|
editorIntents.set(shaped.callId, {
|
|
4020
4636
|
target,
|
|
4021
|
-
|
|
4637
|
+
earlierVersion,
|
|
4022
4638
|
before,
|
|
4023
4639
|
sessionId
|
|
4024
4640
|
});
|
|
@@ -4037,29 +4653,43 @@ function apply(ctx, config) {
|
|
|
4037
4653
|
if (basis === void 0 || basis.sessionId === void 0 || result.isError) return;
|
|
4038
4654
|
const argumentsValue = exec.arguments;
|
|
4039
4655
|
const command = typeof argumentsValue === "object" && argumentsValue !== null ? argumentsValue.command : void 0;
|
|
4040
|
-
if (!(basis.
|
|
4656
|
+
if (!(basis.earlierVersion === "none" ? command === "create" : command === "str_replace" || command === "insert")) return;
|
|
4041
4657
|
let after;
|
|
4042
4658
|
try {
|
|
4043
4659
|
after = await ctx.fs.readText(basis.target, void 0) ?? "";
|
|
4044
4660
|
} catch {
|
|
4045
4661
|
return;
|
|
4046
4662
|
}
|
|
4047
|
-
if (basis.
|
|
4663
|
+
if (basis.earlierVersion === "file" && after === basis.before) return;
|
|
4048
4664
|
const sessionId = basis.sessionId;
|
|
4049
4665
|
const path = basis.target.displayPath;
|
|
4050
4666
|
const entry = {
|
|
4051
4667
|
id: path,
|
|
4052
4668
|
sessionId,
|
|
4053
4669
|
path,
|
|
4054
|
-
|
|
4670
|
+
earlierVersion: basis.earlierVersion,
|
|
4055
4671
|
oldText: basis.before,
|
|
4056
4672
|
newText: after,
|
|
4057
4673
|
updatedAt: Date.now(),
|
|
4058
|
-
sessionIds: [sessionId]
|
|
4674
|
+
sessionIds: [sessionId],
|
|
4675
|
+
lineage: lineageOf(sessionId, exec.agent),
|
|
4676
|
+
unseen: true
|
|
4059
4677
|
};
|
|
4060
4678
|
await ensureLoaded();
|
|
4061
4679
|
if (store.fold(entry)) persistSession();
|
|
4062
4680
|
}
|
|
4681
|
+
/**
|
|
4682
|
+
* What was last READ from one tracked path, and the freshness token that read was taken at.
|
|
4683
|
+
*
|
|
4684
|
+
* `ctx.fs.stat` returns no `mtimeMs` (see `FsStat`): its freshness signal is `version`, an opaque token
|
|
4685
|
+
* the backend documents as "the freshness token a write/edit guards against" and derives from the file's
|
|
4686
|
+
* identity and times — the local backend builds it as `dev:ino:size:mtimeNs:ctimeNs`, so it moves when
|
|
4687
|
+
* the size or either timestamp does, which is exactly the `mtimeMs`+`size` signal wanted here. `size` is
|
|
4688
|
+
* carried beside it as a second, plain-text field.
|
|
4689
|
+
*
|
|
4690
|
+
* In memory only, one entry per tracked path: a restart has read nothing, so it observes afresh.
|
|
4691
|
+
*/
|
|
4692
|
+
const liveObservations = /* @__PURE__ */ new Map();
|
|
4063
4693
|
/** The stable `FsError.code`, when the thrown value carries one. */
|
|
4064
4694
|
function fsErrorCodeOf(error) {
|
|
4065
4695
|
if (typeof error !== "object" || error === null) return void 0;
|
|
@@ -4067,13 +4697,16 @@ function apply(ctx, config) {
|
|
|
4067
4697
|
return typeof code === "string" ? code : void 0;
|
|
4068
4698
|
}
|
|
4069
4699
|
/**
|
|
4070
|
-
*
|
|
4071
|
-
*
|
|
4072
|
-
*
|
|
4700
|
+
* Ask the backend where one tracked path is and what it is, reading NOTHING.
|
|
4701
|
+
*
|
|
4702
|
+
* This is the whole liveness test, shared by the full read (`liveStateOf`) and the light count
|
|
4703
|
+
* (`list-count`): `resolve` + `stat`, with `undefined` from `stat` meaning the target is absent, and a
|
|
4704
|
+
* thrown `FS_NOT_FOUND` meaning the same one step earlier. `version`/`size` come back as `undefined` when
|
|
4705
|
+
* the backend does not report them, which is what tells `liveStateOf` it has no freshness evidence.
|
|
4073
4706
|
* @param path - backend display path to probe through `ctx.fs`.
|
|
4074
|
-
* @returns the
|
|
4707
|
+
* @returns what the backend says, and the target a caller may then read.
|
|
4075
4708
|
*/
|
|
4076
|
-
async function
|
|
4709
|
+
async function probePath(path) {
|
|
4077
4710
|
let target;
|
|
4078
4711
|
try {
|
|
4079
4712
|
target = await ctx.fs.resolve(path, {});
|
|
@@ -4087,12 +4720,55 @@ function apply(ctx, config) {
|
|
|
4087
4720
|
return fsErrorCodeOf(error) === "FS_NOT_FOUND" ? { kind: "deleted" } : { kind: "unavailable" };
|
|
4088
4721
|
}
|
|
4089
4722
|
if (info === void 0) return { kind: "deleted" };
|
|
4723
|
+
return {
|
|
4724
|
+
kind: "present",
|
|
4725
|
+
target,
|
|
4726
|
+
version: typeof info.version === "string" && info.version.length > 0 ? info.version : void 0,
|
|
4727
|
+
size: typeof info.size === "number" ? info.size : void 0
|
|
4728
|
+
};
|
|
4729
|
+
}
|
|
4730
|
+
/**
|
|
4731
|
+
* Read one path's live state. The only existence test is `stat`: it returns
|
|
4732
|
+
* `undefined` for an absent target (gone), so a deleted file never falls into
|
|
4733
|
+
* the unreadable bucket. A file that exists but cannot be read is `unavailable`.
|
|
4734
|
+
*
|
|
4735
|
+
* The CONTENT is not re-read when the backend's own freshness evidence says the file has not moved since
|
|
4736
|
+
* the last observation: an identical `version` AND an identical numeric `size`, both reported. The stat
|
|
4737
|
+
* still runs — it is the cheap half, and it is what notices a file that has gone — so a deleted file is
|
|
4738
|
+
* still `deleted`, a moved one is read again, and `diverged`/`missing` are computed from real content
|
|
4739
|
+
* either way. The skip needs BOTH fields: a stat that reports no size is a weaker observation than the
|
|
4740
|
+
* contract allows, so it falls back to reading rather than trusting a token alone.
|
|
4741
|
+
* @param path - backend display path to probe through `ctx.fs`.
|
|
4742
|
+
* @returns the live state.
|
|
4743
|
+
*/
|
|
4744
|
+
async function liveStateOf(path) {
|
|
4745
|
+
const probe = await probePath(path);
|
|
4746
|
+
if (probe.kind !== "present") {
|
|
4747
|
+
liveObservations.delete(path);
|
|
4748
|
+
return probe;
|
|
4749
|
+
}
|
|
4750
|
+
const { target, version, size } = probe;
|
|
4751
|
+
const trustable = version !== void 0 && size !== void 0;
|
|
4752
|
+
if (trustable) {
|
|
4753
|
+
const observed = liveObservations.get(path);
|
|
4754
|
+
if (observed !== void 0 && observed.version === version && observed.size === size) return {
|
|
4755
|
+
kind: "present",
|
|
4756
|
+
content: observed.content
|
|
4757
|
+
};
|
|
4758
|
+
}
|
|
4090
4759
|
try {
|
|
4760
|
+
const content = await ctx.fs.readText(target, void 0);
|
|
4761
|
+
if (trustable) liveObservations.set(path, {
|
|
4762
|
+
version,
|
|
4763
|
+
size,
|
|
4764
|
+
content
|
|
4765
|
+
});
|
|
4091
4766
|
return {
|
|
4092
4767
|
kind: "present",
|
|
4093
|
-
content
|
|
4768
|
+
content
|
|
4094
4769
|
};
|
|
4095
4770
|
} catch (error) {
|
|
4771
|
+
liveObservations.delete(path);
|
|
4096
4772
|
return fsErrorCodeOf(error) === "FS_NOT_FOUND" ? { kind: "deleted" } : { kind: "unavailable" };
|
|
4097
4773
|
}
|
|
4098
4774
|
}
|
|
@@ -4124,12 +4800,17 @@ function apply(ctx, config) {
|
|
|
4124
4800
|
* list and has its undo/redo records dropped, and externally changed content
|
|
4125
4801
|
* is adopted as the new baseline with its own checkpoint.
|
|
4126
4802
|
* @param sessionId - the session being listed (its session-scoped view).
|
|
4803
|
+
* @param view - the request's lineage view: the caller builds it once and hands it here, because the
|
|
4804
|
+
* list read needs the same view for the comment scope, the answer fold and the undo keys, and a
|
|
4805
|
+
* second `lineageView()` would walk the store again for nothing.
|
|
4806
|
+
* @param entries - that request's ONE `store.all()` snapshot (see `lineageView`).
|
|
4127
4807
|
* @returns the listed entries plus whether an external change cleared redo.
|
|
4128
4808
|
*/
|
|
4129
|
-
async function listWithState(sessionId) {
|
|
4809
|
+
async function listWithState(sessionId, view, entries) {
|
|
4130
4810
|
const listed = [];
|
|
4131
4811
|
let redoCleared = false;
|
|
4132
|
-
for (const entry of
|
|
4812
|
+
for (const entry of entries) {
|
|
4813
|
+
if (!view.sees(sessionId, entry)) continue;
|
|
4133
4814
|
const live = await liveStateOf(entry.path);
|
|
4134
4815
|
if (live.kind === "deleted") {
|
|
4135
4816
|
const checkpoint = droppingComments({
|
|
@@ -4144,7 +4825,7 @@ function apply(ctx, config) {
|
|
|
4144
4825
|
path: entry.path,
|
|
4145
4826
|
entry: void 0,
|
|
4146
4827
|
fileText: void 0
|
|
4147
|
-
});
|
|
4828
|
+
}, view);
|
|
4148
4829
|
persistSession();
|
|
4149
4830
|
continue;
|
|
4150
4831
|
}
|
|
@@ -4162,6 +4843,7 @@ function apply(ctx, config) {
|
|
|
4162
4843
|
const beforeText = entry.newText;
|
|
4163
4844
|
const redoWasPresent = redoStackOf(sessionId).length > 0;
|
|
4164
4845
|
store.update(entry.path, { newText: content });
|
|
4846
|
+
store.markUnseen(entry.path);
|
|
4165
4847
|
pushUndo(sessionId, {
|
|
4166
4848
|
id: entry.path,
|
|
4167
4849
|
path: entry.path,
|
|
@@ -4176,7 +4858,7 @@ function apply(ctx, config) {
|
|
|
4176
4858
|
updatedAt: Date.now()
|
|
4177
4859
|
},
|
|
4178
4860
|
fileText: content
|
|
4179
|
-
});
|
|
4861
|
+
}, view);
|
|
4180
4862
|
persistSession();
|
|
4181
4863
|
if (redoWasPresent) redoCleared = true;
|
|
4182
4864
|
}
|
|
@@ -4184,10 +4866,16 @@ function apply(ctx, config) {
|
|
|
4184
4866
|
missing: false,
|
|
4185
4867
|
diverged: hasContent ? content !== adopted : true
|
|
4186
4868
|
};
|
|
4869
|
+
const viaLineage = ownersOf(entry).includes(sessionId) ? void 0 : true;
|
|
4870
|
+
const hasChildContribution = ownersOf(entry).some((owner) => owner !== sessionId && view.sameRoot(owner, sessionId));
|
|
4871
|
+
const lineageDirection = view.directionOf(sessionId, entry);
|
|
4187
4872
|
listed.push({
|
|
4188
4873
|
...entry,
|
|
4189
4874
|
newText: adopted,
|
|
4190
|
-
...state
|
|
4875
|
+
...state,
|
|
4876
|
+
viaLineage,
|
|
4877
|
+
hasChildContribution,
|
|
4878
|
+
lineageDirection
|
|
4191
4879
|
});
|
|
4192
4880
|
}
|
|
4193
4881
|
return {
|
|
@@ -4217,7 +4905,7 @@ function apply(ctx, config) {
|
|
|
4217
4905
|
changedPaths.push(entry.path);
|
|
4218
4906
|
}
|
|
4219
4907
|
if (folded === 0) return 0;
|
|
4220
|
-
persistSession(true);
|
|
4908
|
+
await persistSession(true);
|
|
4221
4909
|
const after = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
|
|
4222
4910
|
const batchBefore = [];
|
|
4223
4911
|
const batchAfter = [];
|
|
@@ -4326,6 +5014,47 @@ function apply(ctx, config) {
|
|
|
4326
5014
|
for (const workspace of ctx.workspaceRegistry.list()) if (workspace.sessionIds.includes(sessionId)) return workspace;
|
|
4327
5015
|
}
|
|
4328
5016
|
/**
|
|
5017
|
+
* The identity one session's workspace is compared by, or `undefined` when this
|
|
5018
|
+
* deployment does not account the session.
|
|
5019
|
+
*
|
|
5020
|
+
* The merged view needs it because the pending list is NOT scoped by workspace: the store is one map
|
|
5021
|
+
* keyed by absolute path (`pending.ts`) persisted to one `pending.json` under the storage root
|
|
5022
|
+
* (`persist.ts`), and that root comes from the plugin's `storageDir` alone — the `workspaces/`
|
|
5023
|
+
* component of the default path is the LEGACY per-workspace layout whose files are folded in and
|
|
5024
|
+
* deleted on load. Nothing else in the list path consults the workspace, so a row recorded by a
|
|
5025
|
+
* session in another one would otherwise be listed under this session's view.
|
|
5026
|
+
*
|
|
5027
|
+
* `resolve` folds separators and `.`/`..`; Windows additionally compares case-insensitively, the same
|
|
5028
|
+
* way `withinSandboxRoot` and the backend's own containment do (`C:\Repo` and `c:\repo` are one
|
|
5029
|
+
* directory there). An ABSENT workspace, or one the registry records with no path, answers
|
|
5030
|
+
* `undefined` — "not known", never a guess: the caller keeps the pre-workspace behaviour for it
|
|
5031
|
+
* rather than refusing a row it cannot show to be elsewhere.
|
|
5032
|
+
* @param sessionId - the session whose workspace is wanted.
|
|
5033
|
+
* @returns the comparison key, or `undefined` when it is not known.
|
|
5034
|
+
*/
|
|
5035
|
+
function workspaceKeyOf(sessionId) {
|
|
5036
|
+
const path = workspaceOf(sessionId)?.path;
|
|
5037
|
+
if (typeof path !== "string" || path.length === 0) return void 0;
|
|
5038
|
+
const resolved = resolve(path);
|
|
5039
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
5040
|
+
}
|
|
5041
|
+
/**
|
|
5042
|
+
* Whether two workspaces, already reduced to comparison keys by {@link workspaceKeyOf}, are the same
|
|
5043
|
+
* one as far as this deployment can say.
|
|
5044
|
+
*
|
|
5045
|
+
* TRUE when either side is unknown, which is the whole point: an unknown workspace must not turn into
|
|
5046
|
+
* a refusal. The pending list is not scoped by workspace (see `workspaceKeyOf`), so a session the
|
|
5047
|
+
* registry does not account — a memory-only session, a row from a host that named no workspace, a
|
|
5048
|
+
* deployment whose registry the plugin cannot read — would otherwise lose rows it has always shown.
|
|
5049
|
+
* The check is for the case this code can actually see: two KNOWN roots that differ.
|
|
5050
|
+
* @param left - one session's key, or `undefined` when unknown.
|
|
5051
|
+
* @param right - the other's.
|
|
5052
|
+
* @returns whether they can be shown to share a workspace.
|
|
5053
|
+
*/
|
|
5054
|
+
function sameWorkspaceKey(left, right) {
|
|
5055
|
+
return left === void 0 || right === void 0 || left === right;
|
|
5056
|
+
}
|
|
5057
|
+
/**
|
|
4329
5058
|
* Resolve the file-sandbox policy for one session's Revert write, or
|
|
4330
5059
|
* undefined when no confining backend is mounted. A live session carries
|
|
4331
5060
|
* its workspace root through `ctx.sessions`; a persisted entry from a
|
|
@@ -4346,6 +5075,326 @@ function apply(ctx, config) {
|
|
|
4346
5075
|
};
|
|
4347
5076
|
}
|
|
4348
5077
|
/**
|
|
5078
|
+
* The session header entry lineage is read from.
|
|
5079
|
+
*
|
|
5080
|
+
* Two routes, in the order a capture can answer them. The live one is the tool execution's own agent:
|
|
5081
|
+
* `Agent` is AUGMENTED at run time with `session` (dsh-agent's `runtime-types`), and the DSH's own
|
|
5082
|
+
* bundled plugins read exactly that on a `tools/*` execution — `dsh-tool-present` uses
|
|
5083
|
+
* `exec.agent.session.header.cwd` and `dsh-experimental-agent-team` reads
|
|
5084
|
+
* `agent.session.header.parentSession` for precisely the lineage question this records. `Agent` is typed
|
|
5085
|
+
* `{ id }` in the types this package compiles against (the augmentation is not among them), so the field
|
|
5086
|
+
* is read structurally — the same way `sessionOfAgent` reads `id` off the same object.
|
|
5087
|
+
*
|
|
5088
|
+
* The fallback is this plugin's own session registry, which `sandboxPolicyOf` already consults
|
|
5089
|
+
* (`ctx.sessions.get(id)`); it is what answers when the agent carries no `session`, and it is also the
|
|
5090
|
+
* only route at a capture site that has no execution in hand (the editor-intent seam, version control,
|
|
5091
|
+
* a hand-added path). It is read through `ctx.get`, the way this file reads every optional service
|
|
5092
|
+
* (`sandboxPolicy`, `shell`): the capture must never fail because the lineage could not be asked for,
|
|
5093
|
+
* and a context that was never handed a session service throws on the plain property instead.
|
|
5094
|
+
* @param sessionId - the session whose header is wanted.
|
|
5095
|
+
* @param agent - the live agent of a tool execution, when the caller has one.
|
|
5096
|
+
* @returns the header, or `undefined` when neither route has one.
|
|
5097
|
+
*/
|
|
5098
|
+
function headerOf(sessionId, agent) {
|
|
5099
|
+
if (typeof agent === "object" && agent !== null) {
|
|
5100
|
+
const session = agent.session;
|
|
5101
|
+
if (typeof session === "object" && session !== null) {
|
|
5102
|
+
const header = session.header;
|
|
5103
|
+
if (typeof header === "object" && header !== null) return header;
|
|
5104
|
+
}
|
|
5105
|
+
}
|
|
5106
|
+
return sessionRegistry()?.get(sessionId)?.header;
|
|
5107
|
+
}
|
|
5108
|
+
/**
|
|
5109
|
+
* The lineage one entry records for its session, read off that session's own header.
|
|
5110
|
+
*
|
|
5111
|
+
* Every part is taken on its own and only when it is there, and NO lineage is recorded when the header
|
|
5112
|
+
* held none of it. Absence is the honest value: an ordinary root session has no parent by design, and a
|
|
5113
|
+
* host older than these header fields reports none of them — while a merged view has to be able to tell
|
|
5114
|
+
* "this session is a root" from "this host could not say", and guessing a root would file a session
|
|
5115
|
+
* under the wrong one.
|
|
5116
|
+
* @param sessionId - the session the entry belongs to.
|
|
5117
|
+
* @param agent - the live agent, when the capture has one (see `headerOf`).
|
|
5118
|
+
* @returns the lineage to store, or `undefined` when the header named none of it.
|
|
5119
|
+
*/
|
|
5120
|
+
function lineageOf(sessionId, agent) {
|
|
5121
|
+
const header = headerOf(sessionId, agent);
|
|
5122
|
+
if (header === void 0) return void 0;
|
|
5123
|
+
const parent = header.parentSession;
|
|
5124
|
+
const depth = header.delegationDepth;
|
|
5125
|
+
const lineage = {
|
|
5126
|
+
parentSessionId: typeof parent === "string" && parent.length > 0 ? SessionId(parent) : void 0,
|
|
5127
|
+
origin: typeof header.origin === "string" && header.origin.length > 0 ? header.origin : void 0,
|
|
5128
|
+
delegationDepth: typeof depth === "number" && Number.isFinite(depth) ? depth : void 0,
|
|
5129
|
+
cwd: typeof header.cwd === "string" && header.cwd.length > 0 ? header.cwd : void 0
|
|
5130
|
+
};
|
|
5131
|
+
return lineage.parentSessionId !== void 0 || lineage.origin !== void 0 || lineage.delegationDepth !== void 0 || lineage.cwd !== void 0 ? lineage : void 0;
|
|
5132
|
+
}
|
|
5133
|
+
/** The session registry, read the way this file reads every optional service (see `headerOf`). */
|
|
5134
|
+
function sessionRegistry() {
|
|
5135
|
+
return ctx.get("sessions");
|
|
5136
|
+
}
|
|
5137
|
+
/** The most lineage hops one walk will take: a bound, not a depth anyone should reach. */
|
|
5138
|
+
const MAX_LINEAGE_DEPTH = 16;
|
|
5139
|
+
/** Every session that touched an entry (its own fallback, the way the store reads an old row).
|
|
5140
|
+
*
|
|
5141
|
+
* PROVENANCE, and the merge's basis: it is who WROTE the row, which is what the row's mark and its
|
|
5142
|
+
* `viaLineage` flag are made of. It is deliberately NOT the write policy any more — an action's write
|
|
5143
|
+
* runs under the REQUESTING session (see `DiffApprovalUndoPair.policySession` and the
|
|
5144
|
+
* `revertEntryContent` call sites), so nothing here decides who may write.
|
|
5145
|
+
*/
|
|
5146
|
+
function ownersOf(entry) {
|
|
5147
|
+
return Array.isArray(entry.sessionIds) && entry.sessionIds.length > 0 ? entry.sessionIds : [entry.sessionId];
|
|
5148
|
+
}
|
|
5149
|
+
/**
|
|
5150
|
+
* What one session is known by when a lineage is walked: the lineage STEP 1 recorded for it, and
|
|
5151
|
+
* otherwise the live header.
|
|
5152
|
+
*
|
|
5153
|
+
* Recorded first is deliberate: it is what the row itself says, it survives a session that is no longer
|
|
5154
|
+
* live, and it is the same answer the entry carries — so the walk and the row cannot disagree. The
|
|
5155
|
+
* registry is the fallback for a session that recorded nothing (a row written before this step, or a
|
|
5156
|
+
* session whose entries have all been settled away).
|
|
5157
|
+
*
|
|
5158
|
+
* `subagent` is the only field the walk trusts for a LINK: `parentSession` alone is not one. The header
|
|
5159
|
+
* documents it as "the session this one was forked from (seed lineage)", so a host fork or a seeded
|
|
5160
|
+
* continuation carries it while being an INDEPENDENT ROOT — the Team package reads exactly that
|
|
5161
|
+
* distinction (`dsh-experimental-agent-team/lib/types/roster.js:76-81`: "Ordinary host forks are
|
|
5162
|
+
* independent roots"). `origin: 'subagent'` is the header's own marker for a session "created as a
|
|
5163
|
+
* subagent child", and the one function that builds a child's meta sets it together with `parentSession`
|
|
5164
|
+
* and `delegationDepth` (`dsh-subagent/lib/types/child-agent.js:111-125`).
|
|
5165
|
+
* @param sessionId - the session being asked about.
|
|
5166
|
+
* @param recorded - the lineages step 1 recorded, read once per request.
|
|
5167
|
+
* @returns whether the session is a subagent child, and the parent it names when it is.
|
|
5168
|
+
*/
|
|
5169
|
+
function knownLineageOf(sessionId, recorded) {
|
|
5170
|
+
const entry = recorded.get(sessionId);
|
|
5171
|
+
if (entry !== void 0) return {
|
|
5172
|
+
parent: entry.parentSessionId,
|
|
5173
|
+
subagent: entry.origin === "subagent"
|
|
5174
|
+
};
|
|
5175
|
+
const header = sessionRegistry()?.get(sessionId)?.header;
|
|
5176
|
+
const parent = header?.parentSession;
|
|
5177
|
+
return {
|
|
5178
|
+
parent: typeof parent === "string" && parent.length > 0 ? SessionId(parent) : void 0,
|
|
5179
|
+
subagent: header?.origin === "subagent"
|
|
5180
|
+
};
|
|
5181
|
+
}
|
|
5182
|
+
/**
|
|
5183
|
+
* The lineage view one request is answered from — which sessions' entries the requester may SEE, and
|
|
5184
|
+
* therefore act on.
|
|
5185
|
+
*
|
|
5186
|
+
* The rule is ROOT-EQUAL **AND SAME-WORKSPACE**: an entry is visible when any session that touched it
|
|
5187
|
+
* walks to the same lineage root as the requester AND can be shown to sit in the requester's workspace.
|
|
5188
|
+
* Walking is `knownLineageOf` links only, so a teammate's rows appear in the lead's list (both walk to
|
|
5189
|
+
* the lead) and the lead's appear in the teammate's — while a host fork, which names a parent but is not
|
|
5190
|
+
* a subagent child, stays an independent root and shares nothing.
|
|
5191
|
+
*
|
|
5192
|
+
* The workspace half is not redundant with the lineage half, because NOTHING ELSE IN THE LIST PATH
|
|
5193
|
+
* SCOPES BY WORKSPACE: the store is one map keyed by absolute path persisted to one `pending.json` under
|
|
5194
|
+
* the storage root (`pending.ts`, `persist.ts` — the `workspaces/` component of the default path is the
|
|
5195
|
+
* legacy per-workspace layout, folded in and deleted on load). So without this a row recorded by a
|
|
5196
|
+
* session in ANOTHER workspace would be listed here whenever the two share a lineage root — which is a
|
|
5197
|
+
* real shape: this view merges every subagent child (see the KNOWN LIMIT below), and the harness's
|
|
5198
|
+
* out-of-process shape lets a child run under a configured `cwd` of its own, its cwd being documented as
|
|
5199
|
+
* the child's "workspace identity" (`dsh-subagent/lib/types/out-of-process.js`:
|
|
5200
|
+
* `resolveChildCwd`/`assertUsableCwd`). An in-process child copies its parent's cwd verbatim
|
|
5201
|
+
* (`…/child-agent.js:childSessionMeta`), so the two agree there; this rule is what makes the plugin's
|
|
5202
|
+
* answer independent of which of those two shapes it is handed.
|
|
5203
|
+
*
|
|
5204
|
+
* A session the registry does not account is NOT refused: `sameWorkspace` answers true when either
|
|
5205
|
+
* side's workspace is unknown, so an unaccounted or memory-only session keeps exactly the view it had
|
|
5206
|
+
* before this term existed. UNKNOWN DEGRADES TO SELF on the lineage half and TO THE OLD ANSWER here —
|
|
5207
|
+
* never to a guess, and never to a refusal the reader cannot see the reason for.
|
|
5208
|
+
*
|
|
5209
|
+
* UNKNOWN DEGRADES TO SELF, never to a guess: with no recorded lineage and no header facts every
|
|
5210
|
+
* session is its own root, so only the requester matches — byte-for-byte today's behaviour. A cycle
|
|
5211
|
+
* (which no shell should produce) stops at the first repeat, and the walk is bounded by
|
|
5212
|
+
* {@link MAX_LINEAGE_DEPTH}.
|
|
5213
|
+
*
|
|
5214
|
+
* TWO THINGS A MERGED VIEW DOES NOT MERGE, so the next reader does not expect them:
|
|
5215
|
+
* comments stay per-session (`comments` is read with the requester's id, so a merged list shows the
|
|
5216
|
+
* requester's threads only), and undo stacks stay per-lineage — every ACTION records its pair under the
|
|
5217
|
+
* canonical session of the seat that PRESSED it, so the seats of one lineage share one history and a
|
|
5218
|
+
* different lineage cannot reach into it (`undoStackOf`/`popOwnPair` are keyed by that root). The
|
|
5219
|
+
* session-scoped thing a merged press does NOT take from the ROW is the write policy: an action's file
|
|
5220
|
+
* write runs under the REQUESTING session's own workspace and sandbox (see
|
|
5221
|
+
* `DiffApprovalUndoPair.policySession`), never the owner's.
|
|
5222
|
+
*
|
|
5223
|
+
* THE MARK (`hasChildContribution`) IS A DIFFERENT QUESTION and keeps asking only the lineage half: it
|
|
5224
|
+
* claims an owner other than the requester WROTE part of the row, which a child in another workspace
|
|
5225
|
+
* really did. Adding the workspace term there would make that copy lie, so it deliberately does not ask
|
|
5226
|
+
* it (see `sameRoot` below).
|
|
5227
|
+
*
|
|
5228
|
+
* KNOWN LIMIT: a teammate child cannot be told from ANY OTHER subagent child by its header. Both are
|
|
5229
|
+
* built by the one `childSessionMeta` above, and the Team spawner adds nothing to the child — it passes
|
|
5230
|
+
* only `childId`/`provider`/`label` into `ctx.subagents.startContinuable`
|
|
5231
|
+
* (`dsh-experimental-agent-team/lib/types/roster.js:253-262`) and records membership in the LEAD's own
|
|
5232
|
+
* journal (`:249`), which this plugin cannot read. So this view merges every subagent child, not only
|
|
5233
|
+
* teammates; narrowing it to the roster would need a service the plugin does not inject.
|
|
5234
|
+
* @param entries - the store's entries, when the caller already took ONE snapshot for the whole request
|
|
5235
|
+
* (`store.all()` rebuilds and re-sorts the array, so a read takes it once and hands it down). Defaults
|
|
5236
|
+
* to a fresh snapshot for the callers that only need the walk.
|
|
5237
|
+
* @returns the view: `sees` answers the visibility question for one entry, `sameRoot` answers whether two
|
|
5238
|
+
* sessions share a lineage root (the question the row's mark asks about its other owners), `directionOf`
|
|
5239
|
+
* says WHICH WAY that other owner stands for the sentence the mark wears, and `rootOf` is the CANONICAL
|
|
5240
|
+
* session — the lineage root, or the session itself when its lineage is unknown — that every
|
|
5241
|
+
* session-scoped store is keyed by (see `canonicalOf`).
|
|
5242
|
+
*/
|
|
5243
|
+
function lineageView(entries = store.all()) {
|
|
5244
|
+
const recorded = /* @__PURE__ */ new Map();
|
|
5245
|
+
for (const entry of entries) if (entry.lineage !== void 0 && !recorded.has(entry.sessionId)) recorded.set(entry.sessionId, entry.lineage);
|
|
5246
|
+
const facts = /* @__PURE__ */ new Map();
|
|
5247
|
+
/**
|
|
5248
|
+
* One session's UPWARD CHAIN: itself, then each recorded subagent parent above it, in order.
|
|
5249
|
+
*
|
|
5250
|
+
* This is the one walk the whole view is made of. `rootOf` is its last element, and the mark's
|
|
5251
|
+
* DIRECTION is a membership question over two of these chains — so the merge, the root and the
|
|
5252
|
+
* direction can never be computed from different links. The links are `knownLineageOf`'s, which reads
|
|
5253
|
+
* the lineage the entry RECORDED before it asks the live header (see that function), and only an
|
|
5254
|
+
* `origin: 'subagent'` link is followed: a host fork names a parent while being an independent root.
|
|
5255
|
+
*
|
|
5256
|
+
* Memoized per view like `roots` was, because the list read asks this per entry per owner on a path
|
|
5257
|
+
* every client repeats once a second. The walk stops at a repeat (`walked`) and at
|
|
5258
|
+
* {@link MAX_LINEAGE_DEPTH}, so a malformed cycle cannot hang it — the same two guards the root walk
|
|
5259
|
+
* has always had.
|
|
5260
|
+
*/
|
|
5261
|
+
const chains = /* @__PURE__ */ new Map();
|
|
5262
|
+
/**
|
|
5263
|
+
* Each session's workspace key, resolved once per view.
|
|
5264
|
+
*
|
|
5265
|
+
* Memoized for the same reason `chains` is: this runs inside `sees`, which the list read calls once per
|
|
5266
|
+
* entry per owner (`listWithState`), on a path every client repeats once a second — and the registry
|
|
5267
|
+
* lookup walks the deployment's workspace list. `has` rather than a truthy check so "known to be
|
|
5268
|
+
* unknown" is cached as such instead of being asked again for an unaccounted session.
|
|
5269
|
+
*/
|
|
5270
|
+
const workspaceKeys = /* @__PURE__ */ new Map();
|
|
5271
|
+
const workspaceKey = (sessionId) => {
|
|
5272
|
+
if (workspaceKeys.has(sessionId)) return workspaceKeys.get(sessionId);
|
|
5273
|
+
const key = workspaceKeyOf(sessionId);
|
|
5274
|
+
workspaceKeys.set(sessionId, key);
|
|
5275
|
+
return key;
|
|
5276
|
+
};
|
|
5277
|
+
const chainOf = (sessionId) => {
|
|
5278
|
+
const known = chains.get(sessionId);
|
|
5279
|
+
if (known !== void 0) return known;
|
|
5280
|
+
const chain = [sessionId];
|
|
5281
|
+
const walked = /* @__PURE__ */ new Set([sessionId]);
|
|
5282
|
+
let current = sessionId;
|
|
5283
|
+
for (let depth = 0; depth < MAX_LINEAGE_DEPTH; depth += 1) {
|
|
5284
|
+
let fact = facts.get(current);
|
|
5285
|
+
if (fact === void 0) {
|
|
5286
|
+
fact = knownLineageOf(current, recorded);
|
|
5287
|
+
facts.set(current, fact);
|
|
5288
|
+
}
|
|
5289
|
+
if (!fact.subagent || fact.parent === void 0 || walked.has(fact.parent)) break;
|
|
5290
|
+
walked.add(fact.parent);
|
|
5291
|
+
chain.push(fact.parent);
|
|
5292
|
+
current = fact.parent;
|
|
5293
|
+
}
|
|
5294
|
+
chains.set(sessionId, chain);
|
|
5295
|
+
return chain;
|
|
5296
|
+
};
|
|
5297
|
+
/** The top of one session's chain: the lineage root both `sees` and `sameRoot` compare. */
|
|
5298
|
+
const rootOf = (sessionId) => {
|
|
5299
|
+
const chain = chainOf(sessionId);
|
|
5300
|
+
return chain[chain.length - 1];
|
|
5301
|
+
};
|
|
5302
|
+
return {
|
|
5303
|
+
sees: (sessionId, entry) => {
|
|
5304
|
+
const root = rootOf(sessionId);
|
|
5305
|
+
const mine = workspaceKey(sessionId);
|
|
5306
|
+
return ownersOf(entry).some((owner) => {
|
|
5307
|
+
if (rootOf(owner) !== root) return false;
|
|
5308
|
+
if (owner === sessionId) return true;
|
|
5309
|
+
return sameWorkspaceKey(workspaceKey(owner), mine);
|
|
5310
|
+
});
|
|
5311
|
+
},
|
|
5312
|
+
sameRoot: (a, b) => rootOf(a) === rootOf(b),
|
|
5313
|
+
rootOf,
|
|
5314
|
+
/**
|
|
5315
|
+
* Which way the row's other contributors stand, for the marker's sentence.
|
|
5316
|
+
*
|
|
5317
|
+
* Read off the SAME recorded links `rootOf` walks, never guessed and never inferred from a name: an
|
|
5318
|
+
* owner is the requester's CHILD when the requester sits on that owner's upward chain, its PARENT
|
|
5319
|
+
* when that owner sits on the requester's chain, and a SIBLING when the two merely share a root.
|
|
5320
|
+
* `parent` therefore means ANCESTOR, however many hops up — which is exactly what the Chinese 上级
|
|
5321
|
+
* says and what the English copy is written to mean.
|
|
5322
|
+
*
|
|
5323
|
+
* Owners of DIFFERENT directions answer `mixed`: the row carries two different relationships and
|
|
5324
|
+
* picking one would state a fact about the row that is only true of part of it. The set is
|
|
5325
|
+
* unresolvable in the honest direction too — "another session" is true of every member — which is
|
|
5326
|
+
* why `mixed` is a real answer and not a failure. `undefined` when no other same-root owner
|
|
5327
|
+
* contributed, i.e. when there is no mark to word.
|
|
5328
|
+
*
|
|
5329
|
+
* The criterion is `hasChildContribution`'s exactly (non-requester, same root) — the workspace term
|
|
5330
|
+
* belongs to visibility, not to this claim (see `sameRoot`).
|
|
5331
|
+
* @param sessionId - the session listing the row.
|
|
5332
|
+
* @param entry - the row being listed.
|
|
5333
|
+
* @returns the direction, or `undefined` when nothing but the lister touched the row.
|
|
5334
|
+
*/
|
|
5335
|
+
directionOf: (sessionId, entry) => {
|
|
5336
|
+
const root = rootOf(sessionId);
|
|
5337
|
+
const mine = chainOf(sessionId);
|
|
5338
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5339
|
+
for (const owner of ownersOf(entry)) {
|
|
5340
|
+
if (owner === sessionId) continue;
|
|
5341
|
+
if (rootOf(owner) !== root) continue;
|
|
5342
|
+
if (chainOf(owner).includes(sessionId)) seen.add("child");
|
|
5343
|
+
else if (mine.includes(owner)) seen.add("parent");
|
|
5344
|
+
else seen.add("sibling");
|
|
5345
|
+
}
|
|
5346
|
+
if (seen.size === 0) return void 0;
|
|
5347
|
+
return seen.size === 1 ? [...seen][0] : "mixed";
|
|
5348
|
+
}
|
|
5349
|
+
};
|
|
5350
|
+
}
|
|
5351
|
+
/**
|
|
5352
|
+
* THE CANONICALIZATION SEAM: the one session-scoped state of a request belongs to.
|
|
5353
|
+
*
|
|
5354
|
+
* The lineage ROOT, because the seats of one lineage read one list — so the undo history they share and
|
|
5355
|
+
* the comment threads they read must be keyed by the lineage, not by whichever seat is looking. A
|
|
5356
|
+
* session whose lineage is unknown (no recorded link, no header facts) is its own root, which is
|
|
5357
|
+
* byte-for-byte the behaviour before any of this existed.
|
|
5358
|
+
*
|
|
5359
|
+
* Every session-scoped seam goes through this function, and the endpoints that already hold a view pass
|
|
5360
|
+
* it so the walk is done once per request (`view` is optional only so a push deep inside a helper cannot
|
|
5361
|
+
* forget to canonicalize; it never means "do not canonicalize").
|
|
5362
|
+
* @param sessionId - the session the request came from.
|
|
5363
|
+
* @param view - the request's lineage view, when the caller already built one.
|
|
5364
|
+
* @returns the canonical session: the lineage root, or `sessionId` itself when it has none.
|
|
5365
|
+
*/
|
|
5366
|
+
function canonicalOf(sessionId, view) {
|
|
5367
|
+
return (view ?? lineageView()).rootOf(sessionId);
|
|
5368
|
+
}
|
|
5369
|
+
/**
|
|
5370
|
+
* The comment scope of one request: which comment AUTHORS this lineage may read and act on.
|
|
5371
|
+
*
|
|
5372
|
+
* `sameRoot` is the view's own question, so the scope and the merge cannot disagree about who is kin —
|
|
5373
|
+
* and it is the same predicate for reading, removing, marking seen and asking, so no verb can widen or
|
|
5374
|
+
* narrow the lineage on its own.
|
|
5375
|
+
* @param sessionId - the session the request came from.
|
|
5376
|
+
* @param view - the request's lineage view.
|
|
5377
|
+
* @returns the predicate the comment store's `CommentScope` takes.
|
|
5378
|
+
*/
|
|
5379
|
+
function commentScopeOf(sessionId, view) {
|
|
5380
|
+
return (author) => view.sameRoot(author, sessionId);
|
|
5381
|
+
}
|
|
5382
|
+
/**
|
|
5383
|
+
* The entry one id names, when the requesting session may act on it; `undefined` otherwise.
|
|
5384
|
+
*
|
|
5385
|
+
* Every action path resolves its entry through this, so an id outside the requester's lineage is
|
|
5386
|
+
* answered exactly like an id that names nothing — `missing` — and no action can reach a row the
|
|
5387
|
+
* requester cannot see.
|
|
5388
|
+
* @param view - the request's lineage view.
|
|
5389
|
+
* @param sessionId - the requesting session.
|
|
5390
|
+
* @param id - the entry id (its path).
|
|
5391
|
+
* @returns the entry, or `undefined` when it is absent or outside the view.
|
|
5392
|
+
*/
|
|
5393
|
+
function actionableEntryOf(view, sessionId, id) {
|
|
5394
|
+
const entry = store.get(id);
|
|
5395
|
+
return entry !== void 0 && view.sees(sessionId, entry) ? entry : void 0;
|
|
5396
|
+
}
|
|
5397
|
+
/**
|
|
4349
5398
|
* Write a Revert through `ctx.fs`, carrying the session's sandbox policy
|
|
4350
5399
|
* only when a confining backend is mounted (so the plain 4-arg call shape is
|
|
4351
5400
|
* preserved for the unsandboxed composition).
|
|
@@ -4379,7 +5428,7 @@ function apply(ctx, config) {
|
|
|
4379
5428
|
async function removeRevert(target, sessionId, signal) {
|
|
4380
5429
|
const policy = sandboxPolicyOf(sessionId);
|
|
4381
5430
|
const osPath = processPathOf(target);
|
|
4382
|
-
if (policy !== void 0 && !withinSandboxRoot(osPath, policy)) throw new Error(
|
|
5431
|
+
if (policy !== void 0 && !withinSandboxRoot(osPath, policy)) throw new Error(sandboxDenial(osPath, policy));
|
|
4383
5432
|
const fs = ctx.fs;
|
|
4384
5433
|
if (policy !== void 0 && typeof fs.remove === "function") {
|
|
4385
5434
|
await fs.remove(target, policy, signal);
|
|
@@ -4410,10 +5459,157 @@ function apply(ctx, config) {
|
|
|
4410
5459
|
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
4411
5460
|
}
|
|
4412
5461
|
/**
|
|
5462
|
+
* The sentence a confining policy uses when it refuses one path, wherever it is produced.
|
|
5463
|
+
*
|
|
5464
|
+
* ONE producer, so the check a delete performs, the preflight a restore runs and the message a reader is
|
|
5465
|
+
* handed can never describe the refusal differently.
|
|
5466
|
+
* @param path - the OS path the policy refused.
|
|
5467
|
+
* @param policy - the session's resolved policy.
|
|
5468
|
+
* @returns the refusal sentence.
|
|
5469
|
+
*/
|
|
5470
|
+
function sandboxDenial(path, policy) {
|
|
5471
|
+
return `file access denied under ${policy.mode} mode: '${path}' is outside the sandbox workspace root '${policy.workspaceRoot}'`;
|
|
5472
|
+
}
|
|
5473
|
+
/**
|
|
5474
|
+
* Whether a thrown write error is the session's own AUTHORITY refusing it, rather than the file being
|
|
5475
|
+
* gone, changed, or unreadable.
|
|
5476
|
+
*
|
|
5477
|
+
* Exactly two shapes qualify, and nothing else does: a confining backend raises `FS_SANDBOX_DENIED` (the
|
|
5478
|
+
* `FsErrorCode` the fs package owns for "the policy layer said no"), and this file's own containment
|
|
5479
|
+
* check in `removeRevert` raises a plain Error saying the path is outside the sandbox workspace root.
|
|
5480
|
+
* Everything else keeps its own answer: `FS_NOT_FOUND` is a missing file, the divergence guard's "changed
|
|
5481
|
+
* outside the review" is a diverged one, `FS_PERMISSION_DENIED` is the FILE SYSTEM refusing (a fact about
|
|
5482
|
+
* the file, not about this session's grant) and the rest are IO or aborted faults. The predicate is
|
|
5483
|
+
* deliberately this narrow: widening it is how a reader loses the difference between "I may not write
|
|
5484
|
+
* there" and "that file is gone".
|
|
5485
|
+
* @param error - the value a write path threw.
|
|
5486
|
+
* @returns whether the sandbox/policy refused the write.
|
|
5487
|
+
*/
|
|
5488
|
+
function isPolicyRefusal(error) {
|
|
5489
|
+
if (fsErrorCodeOf(error) === "FS_SANDBOX_DENIED") return true;
|
|
5490
|
+
return /outside the sandbox workspace root/i.test(errorMessage(error));
|
|
5491
|
+
}
|
|
5492
|
+
/**
|
|
5493
|
+
* The answer for a write the session's authority refused: a sentence that NAMES THE PATH and says, in
|
|
5494
|
+
* words, that this session may not write there, carrying the sandbox's own reason with it.
|
|
5495
|
+
*
|
|
5496
|
+
* Why TEXT and not a code: the RPC error vocabulary this channel answers in is closed and owned by the
|
|
5497
|
+
* vendor (`RpcErrorDetailsMap` in `@deepseek-ai/dsh-host-apiproxy` — `internal` is its catch-all, and a
|
|
5498
|
+
* new code needs a row in that table), so this package cannot mint a `permission` code of its own. The
|
|
5499
|
+
* reader still gets the sentence: the port folds a failed call into `Error(code: message)` and the
|
|
5500
|
+
* panel's failed-row notice prints that message verbatim, so the refusal arrives as words on the path the
|
|
5501
|
+
* panel already has for a failed action. A LOCALIZED route would need a client-side classifier over the
|
|
5502
|
+
* message plus new locale keys — a copy change across the host/client seam — for no difference the reader
|
|
5503
|
+
* can see, since every other message on this channel is host English too.
|
|
5504
|
+
* @param path - the file the refused write was about (the entry's own path).
|
|
5505
|
+
* @param error - what the write threw.
|
|
5506
|
+
* @returns the error branch to answer with.
|
|
5507
|
+
*/
|
|
5508
|
+
function policyRefusal(path, error) {
|
|
5509
|
+
return rpcError(refusalText(path, error));
|
|
5510
|
+
}
|
|
5511
|
+
/** The refusal sentence itself, shared by every path that answers with one. */
|
|
5512
|
+
function refusalText(path, error) {
|
|
5513
|
+
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)}`;
|
|
5514
|
+
}
|
|
5515
|
+
/**
|
|
5516
|
+
* Every file write a restore will make, in the order it will make them.
|
|
5517
|
+
*
|
|
5518
|
+
* Mirrors `restoreState`'s own walk, index-aligned with the counterpart side, so the preflight below and
|
|
5519
|
+
* the writer can never disagree about WHICH files a restore touches. A state with no `fileText` writes
|
|
5520
|
+
* nothing: it puts an entry back in the list (or takes it out) and never opens a file.
|
|
5521
|
+
* @param state - the side being installed.
|
|
5522
|
+
* @param expectedFile - the other side, whose per-index `fileText` is the divergence snapshot.
|
|
5523
|
+
* @returns the writes, in order.
|
|
5524
|
+
*/
|
|
5525
|
+
function restoreWritesOf(state, expectedFile) {
|
|
5526
|
+
const writes = [];
|
|
5527
|
+
if (state.batch !== void 0) {
|
|
5528
|
+
const counterpart = expectedFile?.batch;
|
|
5529
|
+
for (const [index, item] of state.batch.entries()) writes.push(...restoreWritesOf({
|
|
5530
|
+
...item,
|
|
5531
|
+
batch: void 0
|
|
5532
|
+
}, counterpart?.[index]));
|
|
5533
|
+
return writes;
|
|
5534
|
+
}
|
|
5535
|
+
if (state.fileText !== void 0) writes.push({
|
|
5536
|
+
path: state.path,
|
|
5537
|
+
expected: expectedFile?.fileText
|
|
5538
|
+
});
|
|
5539
|
+
return writes;
|
|
5540
|
+
}
|
|
5541
|
+
/**
|
|
5542
|
+
* Check EVERY write a restore will make before it makes the first one, and refuse the whole restore if any
|
|
5543
|
+
* of them would be refused.
|
|
5544
|
+
*
|
|
5545
|
+
* WHAT IS GUARANTEED, and what is not (this is the honest half of the design):
|
|
5546
|
+
*
|
|
5547
|
+
* - AUTHORITY is guaranteed all-or-nothing. Each target is checked with the SAME predicate the write path
|
|
5548
|
+
* enforces (`withinSandboxRoot`, the one `removeRevert` refuses with and the policy `writeRevert` hands
|
|
5549
|
+
* the backend), under `pair.policySession` — the seat the action ran under. A refusal anywhere means no
|
|
5550
|
+
* file is written at all, so an undo can never take back half of a decision. This is a check made before
|
|
5551
|
+
* the first byte, so it is a guarantee rather than a hope.
|
|
5552
|
+
* - DIVERGENCE is also preflighted, because it is cheap and detectable up front: a target whose
|
|
5553
|
+
* counterpart snapshot no longer matches the file refuses the whole restore rather than stopping
|
|
5554
|
+
* half-way through it.
|
|
5555
|
+
* - A RUNTIME failure is NOT preflightable and is not promised away: a file that vanishes between this
|
|
5556
|
+
* check and the write, or an IO fault on the third of five files, can still leave the earlier writes
|
|
5557
|
+
* applied. When that happens the caller reports exactly how far the restore got (`RestoreProgress`)
|
|
5558
|
+
* instead of claiming nothing happened. Do not read this function as an atomicity promise; it is a
|
|
5559
|
+
* permission and staleness promise, and the mid-way case is reported as the partial state it is.
|
|
5560
|
+
* @param writes - the plan, from `restoreWritesOf`.
|
|
5561
|
+
* @param policySession - the session whose policy the write runs under (the pair's recorded seat).
|
|
5562
|
+
* @param signal - aborts the checks too.
|
|
5563
|
+
* @returns the first refusal (its path and the reason to report), or undefined when the plan may run.
|
|
5564
|
+
*/
|
|
5565
|
+
async function preflightRestore(writes, policySession, signal) {
|
|
5566
|
+
const policy = sandboxPolicyOf(policySession);
|
|
5567
|
+
for (const write of writes) {
|
|
5568
|
+
let resolved;
|
|
5569
|
+
try {
|
|
5570
|
+
resolved = await ctx.fs.resolve(write.path, { signal });
|
|
5571
|
+
} catch (error) {
|
|
5572
|
+
return {
|
|
5573
|
+
path: write.path,
|
|
5574
|
+
error
|
|
5575
|
+
};
|
|
5576
|
+
}
|
|
5577
|
+
if (policy !== void 0 && !withinSandboxRoot(processPathOf(resolved), policy)) return {
|
|
5578
|
+
path: write.path,
|
|
5579
|
+
error: new Error(sandboxDenial(processPathOf(resolved), policy))
|
|
5580
|
+
};
|
|
5581
|
+
if (write.expected !== void 0) {
|
|
5582
|
+
if (await ctx.fs.readText(resolved, void 0) !== write.expected) return {
|
|
5583
|
+
path: write.path,
|
|
5584
|
+
error: /* @__PURE__ */ new Error("the file changed outside the review after the action; undo is unavailable")
|
|
5585
|
+
};
|
|
5586
|
+
}
|
|
5587
|
+
}
|
|
5588
|
+
}
|
|
5589
|
+
/**
|
|
5590
|
+
* The answer for a restore that did not finish: the refusal sentence when the sandbox was the reason, the
|
|
5591
|
+
* ordinary `undo failed:`/`redo failed:` text otherwise — and in EVERY case, when the restore had already
|
|
5592
|
+
* written something, how far it got. "Never that nothing happened": a partial restore is reported as the
|
|
5593
|
+
* partial state it is.
|
|
5594
|
+
* @param action - which direction was running.
|
|
5595
|
+
* @param path - the file the failure is about.
|
|
5596
|
+
* @param error - what the restore threw.
|
|
5597
|
+
* @param progress - the writes it completed, and how many it had to make.
|
|
5598
|
+
* @returns the error branch to answer with.
|
|
5599
|
+
*/
|
|
5600
|
+
function restoreFailure(action, path, error, progress) {
|
|
5601
|
+
if (isPolicyRefusal(error)) {
|
|
5602
|
+
const far = progress.written === 0 ? "" : ` The ${action} had already written ${progress.written} of ${progress.total} files before that.`;
|
|
5603
|
+
return rpcError(refusalText(path, error) + far);
|
|
5604
|
+
}
|
|
5605
|
+
return rpcError(`${action} failed${progress.written === 0 ? "" : ` after writing ${progress.written} of ${progress.total} files`}: ${errorMessage(error)}`);
|
|
5606
|
+
}
|
|
5607
|
+
/**
|
|
4413
5608
|
* 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
|
-
*
|
|
5609
|
+
* behind both the single `revert` endpoint and the bulk `revert-all`). An
|
|
5610
|
+
* entry with no earlier version (`earlierVersion: 'none'`) has its file
|
|
5611
|
+
* deleted (not undoable: the file is gone); one that has an earlier version
|
|
5612
|
+
* (`'file'`) writes it back and yields the before/after undo snapshot.
|
|
4417
5613
|
* @param entry - the entry to revert.
|
|
4418
5614
|
* @param sessionId - the entry's session (for the per-session write policy).
|
|
4419
5615
|
* @param signal - aborts before atomic publication takes effect.
|
|
@@ -4421,7 +5617,7 @@ function apply(ctx, config) {
|
|
|
4421
5617
|
*/
|
|
4422
5618
|
async function revertEntryContent(entry, sessionId, signal) {
|
|
4423
5619
|
const resolved = await ctx.fs.resolve(entry.path, { signal });
|
|
4424
|
-
if (entry.
|
|
5620
|
+
if (entry.earlierVersion === "none") {
|
|
4425
5621
|
await removeRevert(resolved, sessionId, signal);
|
|
4426
5622
|
return;
|
|
4427
5623
|
}
|
|
@@ -4445,33 +5641,91 @@ function apply(ctx, config) {
|
|
|
4445
5641
|
}
|
|
4446
5642
|
const undoStacks = /* @__PURE__ */ new Map();
|
|
4447
5643
|
const redoStacks = /* @__PURE__ */ new Map();
|
|
4448
|
-
/** The undo stack of one
|
|
4449
|
-
function undoStackOf(
|
|
4450
|
-
let stack = undoStacks.get(
|
|
5644
|
+
/** The undo stack of one lineage root, created on first use. Its key is a CANONICAL session. */
|
|
5645
|
+
function undoStackOf(root) {
|
|
5646
|
+
let stack = undoStacks.get(root);
|
|
4451
5647
|
if (stack === void 0) {
|
|
4452
5648
|
stack = [];
|
|
4453
|
-
undoStacks.set(
|
|
5649
|
+
undoStacks.set(root, stack);
|
|
4454
5650
|
}
|
|
4455
5651
|
return stack;
|
|
4456
5652
|
}
|
|
4457
|
-
/** The redo stack of one
|
|
4458
|
-
function redoStackOf(
|
|
4459
|
-
let stack = redoStacks.get(
|
|
5653
|
+
/** The redo stack of one lineage root, created on first use. Its key is a CANONICAL session. */
|
|
5654
|
+
function redoStackOf(root) {
|
|
5655
|
+
let stack = redoStacks.get(root);
|
|
4460
5656
|
if (stack === void 0) {
|
|
4461
5657
|
stack = [];
|
|
4462
|
-
redoStacks.set(
|
|
5658
|
+
redoStacks.set(root, stack);
|
|
4463
5659
|
}
|
|
4464
5660
|
return stack;
|
|
4465
5661
|
}
|
|
4466
|
-
/**
|
|
4467
|
-
*
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
5662
|
+
/**
|
|
5663
|
+
* One entry as an undo target sees it, for the "did this really change?" comparison.
|
|
5664
|
+
*
|
|
5665
|
+
* `updatedAt` is deliberately NOT in the key: every action stamps it with `Date.now()`, so including it
|
|
5666
|
+
* would make every recorded target look changed — the exact false positive the comparison exists to stop.
|
|
5667
|
+
*/
|
|
5668
|
+
function entryKeyOf(entry) {
|
|
5669
|
+
if (entry === void 0) return "(no entry)";
|
|
5670
|
+
return JSON.stringify(entry, (key, value) => key === "updatedAt" ? void 0 : value);
|
|
5671
|
+
}
|
|
5672
|
+
/** One side's comment records as a comparable key (a pair that carries threads changes when they do). */
|
|
5673
|
+
function commentsKeyOf(comments) {
|
|
5674
|
+
return (comments ?? []).map((comment) => `${comment.id}@${comment.anchor.startLine}-${comment.anchor.endLine}:${comment.text}`).join("|");
|
|
5675
|
+
}
|
|
5676
|
+
/**
|
|
5677
|
+
* Whether one target of a forward action really changed anything.
|
|
5678
|
+
*
|
|
5679
|
+
* THE RULE the reader asked for: a pair records only what changed. `before` is what the action found and
|
|
5680
|
+
* `after` is what it left — which is also what the file and the entry hold NOW, since the action has just
|
|
5681
|
+
* finished — so comparing the two sides is comparing the target against the world. Three things can
|
|
5682
|
+
* differ and each counts on its own: the file bytes (`fileText`, with `undefined` meaning "no write"), the
|
|
5683
|
+
* entry (its content-bearing fields; see `entryKeyOf`), and a comment pair's records. A target where none
|
|
5684
|
+
* of them differs is not a change and is not recorded, so Ctrl+Z cannot walk a file the action never
|
|
5685
|
+
* moved.
|
|
5686
|
+
*
|
|
5687
|
+
* A BATCH state is not compared here: its members are filtered one by one in `pushBatchUndo`, which is
|
|
5688
|
+
* where the index alignment lives.
|
|
5689
|
+
* @param before - the state the action found.
|
|
5690
|
+
* @param after - the state it left.
|
|
5691
|
+
* @returns whether recording this target would give the reader something to take back.
|
|
5692
|
+
*/
|
|
5693
|
+
function stateChanged(before, after) {
|
|
5694
|
+
if (before.batch !== void 0 || after.batch !== void 0) return true;
|
|
5695
|
+
if (before.kind === "comments" || after.kind === "comments") return commentsKeyOf(before.comments) !== commentsKeyOf(after.comments);
|
|
5696
|
+
if (before.fileText !== after.fileText) return true;
|
|
5697
|
+
return entryKeyOf(before.entry) !== entryKeyOf(after.entry);
|
|
5698
|
+
}
|
|
5699
|
+
/**
|
|
5700
|
+
* Record one undoable action in its lineage's history. A fresh action invalidates that lineage's redo
|
|
5701
|
+
* history (and only that lineage's).
|
|
5702
|
+
*
|
|
5703
|
+
* THE RULE: the pair is filed under the CANONICAL session, so every seat of one lineage shares one
|
|
5704
|
+
* history and any of them can take the action back; the session that pressed is remembered on the pair
|
|
5705
|
+
* (`pressedBy` for attribution, and `policySession` for the write the RESTORE runs) — and the write
|
|
5706
|
+
* policy is recorded rather than re-derived, because the seat that POPS a pair need not be the seat that
|
|
5707
|
+
* made it. Canonicalizing HERE rather than at the call site is what makes the rule unforgettable: every
|
|
5708
|
+
* push in this file goes through this function.
|
|
5709
|
+
*
|
|
5710
|
+
* `policySession` is the presser because the forward action's own write is the requester's (see the
|
|
5711
|
+
* `revertEntryContent` call sites) — so every push here records the seat whose authority the action ran
|
|
5712
|
+
* under, and the restore replays exactly that.
|
|
5713
|
+
* @param sessionId - the session that pressed the action.
|
|
5714
|
+
* @param before - the state the action found, and what a restore puts back.
|
|
5715
|
+
* @param after - the state the action left, and what a redo re-applies.
|
|
5716
|
+
* @param view - the request's lineage view, when the caller already built one.
|
|
5717
|
+
*/
|
|
5718
|
+
function pushUndo(sessionId, before, after, view) {
|
|
5719
|
+
if (!stateChanged(before, after)) return;
|
|
5720
|
+
const root = canonicalOf(sessionId, view);
|
|
5721
|
+
undoStackOf(root).push({
|
|
5722
|
+
root,
|
|
5723
|
+
pressedBy: sessionId,
|
|
5724
|
+
policySession: sessionId,
|
|
4471
5725
|
before,
|
|
4472
5726
|
after
|
|
4473
5727
|
});
|
|
4474
|
-
redoStackOf(
|
|
5728
|
+
redoStackOf(root).length = 0;
|
|
4475
5729
|
}
|
|
4476
5730
|
/**
|
|
4477
5731
|
* Record several entries' worth of ONE decision as a single undo step.
|
|
@@ -4480,36 +5734,45 @@ function apply(ctx, config) {
|
|
|
4480
5734
|
* take the whole thing back. A loop of single actions would push one step per entry instead, and undo
|
|
4481
5735
|
* would then peel that one decision apart file by file.
|
|
4482
5736
|
*
|
|
4483
|
-
* @param sessionId - the session
|
|
5737
|
+
* @param sessionId - the session that pressed.
|
|
4484
5738
|
* @param before - one state per entry, in the order the decision touched them.
|
|
4485
5739
|
* @param after - the same entries as the decision left them; the two arrays are index-aligned, which
|
|
4486
5740
|
* is also what the divergence guard on a restore reads (see `restoreState`).
|
|
5741
|
+
* @param view - the request's lineage view, when the caller already built one.
|
|
4487
5742
|
*/
|
|
4488
|
-
function pushBatchUndo(sessionId, before, after) {
|
|
4489
|
-
|
|
5743
|
+
function pushBatchUndo(sessionId, before, after, view) {
|
|
5744
|
+
const changedBefore = [];
|
|
5745
|
+
const changedAfter = [];
|
|
5746
|
+
for (const [index, item] of before.entries()) {
|
|
5747
|
+
const counterpart = after[index];
|
|
5748
|
+
if (counterpart === void 0 || !stateChanged(item, counterpart)) continue;
|
|
5749
|
+
changedBefore.push(item);
|
|
5750
|
+
changedAfter.push(counterpart);
|
|
5751
|
+
}
|
|
5752
|
+
if (changedBefore.length === 0) return;
|
|
4490
5753
|
pushUndo(sessionId, {
|
|
4491
|
-
id:
|
|
4492
|
-
path:
|
|
5754
|
+
id: changedBefore[0].id,
|
|
5755
|
+
path: changedBefore[0].path,
|
|
4493
5756
|
entry: void 0,
|
|
4494
5757
|
fileText: void 0,
|
|
4495
|
-
batch:
|
|
5758
|
+
batch: changedBefore
|
|
4496
5759
|
}, {
|
|
4497
|
-
id:
|
|
4498
|
-
path:
|
|
5760
|
+
id: changedAfter[0].id,
|
|
5761
|
+
path: changedAfter[0].path,
|
|
4499
5762
|
entry: void 0,
|
|
4500
5763
|
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
|
|
5764
|
+
batch: changedAfter
|
|
5765
|
+
}, view);
|
|
5766
|
+
}
|
|
5767
|
+
/** Drop the top pair of one lineage root's stack, when it belongs to that root; otherwise the
|
|
5768
|
+
* stack is left exactly as it was, so another lineage's action is untouched.
|
|
5769
|
+
* @param stack - the root's stack (undo or redo).
|
|
5770
|
+
* @param root - the canonical session the request is for.
|
|
5771
|
+
* @returns the pair to move, or undefined when this lineage has nothing to move.
|
|
4509
5772
|
*/
|
|
4510
|
-
function popOwnPair(stack,
|
|
5773
|
+
function popOwnPair(stack, root) {
|
|
4511
5774
|
const pair = stack[stack.length - 1];
|
|
4512
|
-
if (pair === void 0 || pair.
|
|
5775
|
+
if (pair === void 0 || pair.root !== root) return void 0;
|
|
4513
5776
|
stack.pop();
|
|
4514
5777
|
return pair;
|
|
4515
5778
|
}
|
|
@@ -4578,16 +5841,18 @@ function apply(ctx, config) {
|
|
|
4578
5841
|
*
|
|
4579
5842
|
* Nothing here touches `ctx.fs` or the pending store: a comment is not a change to a file, and a
|
|
4580
5843
|
* 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
5844
|
* @param state - the side to install.
|
|
4583
5845
|
* @param expectedFile - the other side; the comments only it lists are the ones to remove.
|
|
5846
|
+
* @param scope - which comment authors this restore may remove: the undo's lineage. The stale side is
|
|
5847
|
+
* the comments the OTHER side held, and they belong to whichever seats wrote them, so the removal has
|
|
5848
|
+
* to be allowed to reach every file the pair's records live in (see `CommentStore.removeMany`).
|
|
4584
5849
|
*/
|
|
4585
|
-
function restoreComments(
|
|
5850
|
+
function restoreComments(state, expectedFile, scope) {
|
|
4586
5851
|
const installing = state.comments ?? [];
|
|
4587
5852
|
const installed = new Set(installing.map((record) => record.id));
|
|
4588
5853
|
const stale = (expectedFile?.comments ?? []).map((record) => record.id).filter((id) => !installed.has(id));
|
|
4589
5854
|
if (stale.length > 0) {
|
|
4590
|
-
comments.removeMany(
|
|
5855
|
+
comments.removeMany(scope, stale);
|
|
4591
5856
|
for (const id of stale) commentLines.delete(id);
|
|
4592
5857
|
}
|
|
4593
5858
|
if (installing.length > 0) comments.addMany(installing);
|
|
@@ -4600,14 +5865,18 @@ function apply(ctx, config) {
|
|
|
4600
5865
|
* only after the write succeeds, keeping the restore all-or-nothing — and the
|
|
4601
5866
|
* comments a dropping action removed are the LAST thing put back, so a refused
|
|
4602
5867
|
* restore cannot leave a thread on a row that did not come back.
|
|
4603
|
-
* @param sessionId - the
|
|
5868
|
+
* @param sessionId - the session the policy is resolved from (the pair's presser — see `undoStackOf`).
|
|
4604
5869
|
* @param state - the snapshot to restore.
|
|
4605
5870
|
* @param expectedFile - the other side's file content, checked before a write.
|
|
4606
5871
|
* @param signal - aborts before atomic publication takes effect.
|
|
5872
|
+
* @param scope - which comment authors this restore may remove (the undo request's lineage).
|
|
5873
|
+
* @param progress - counts the file writes as they land, so a mid-way failure can report exactly how far
|
|
5874
|
+
* it got instead of claiming nothing happened (see `restoreFailure`). Omitted where the caller has no
|
|
5875
|
+
* use for it; a restore that gets no `progress` is unchanged.
|
|
4607
5876
|
*/
|
|
4608
|
-
async function restoreState(sessionId, state, expectedFile, signal) {
|
|
5877
|
+
async function restoreState(sessionId, state, expectedFile, signal, scope, progress) {
|
|
4609
5878
|
if (state.kind === "comments") {
|
|
4610
|
-
restoreComments(
|
|
5879
|
+
restoreComments(state, expectedFile, scope);
|
|
4611
5880
|
return;
|
|
4612
5881
|
}
|
|
4613
5882
|
if (state.fileText !== void 0) {
|
|
@@ -4615,14 +5884,16 @@ function apply(ctx, config) {
|
|
|
4615
5884
|
if (expectedFile?.fileText !== void 0) {
|
|
4616
5885
|
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
5886
|
}
|
|
5887
|
+
if (progress !== void 0) progress.path = state.path;
|
|
4618
5888
|
await writeRevert(resolved, state.fileText, sessionId, signal);
|
|
5889
|
+
if (progress !== void 0) progress.written += 1;
|
|
4619
5890
|
}
|
|
4620
5891
|
if (state.batch !== void 0) {
|
|
4621
5892
|
const counterpart = expectedFile?.batch;
|
|
4622
5893
|
for (const [index, item] of state.batch.entries()) await restoreState(sessionId, {
|
|
4623
5894
|
...item,
|
|
4624
5895
|
batch: void 0
|
|
4625
|
-
}, counterpart?.[index], signal);
|
|
5896
|
+
}, counterpart?.[index], signal, scope, progress);
|
|
4626
5897
|
return;
|
|
4627
5898
|
}
|
|
4628
5899
|
if (state.entry !== void 0) store.restore(state.entry);
|
|
@@ -4666,6 +5937,21 @@ function apply(ctx, config) {
|
|
|
4666
5937
|
}
|
|
4667
5938
|
/** Mark the store dirty and schedule one throttled, coalesced write. Pass
|
|
4668
5939
|
* `force` to write immediately (user actions need durable, immediate results). */
|
|
5940
|
+
/**
|
|
5941
|
+
* Mark the list dirty and start its write. The throttle is for the panel's own churn; `force` writes
|
|
5942
|
+
* NOW and, since 2026-10-06, RETURNS the write's promise.
|
|
5943
|
+
*
|
|
5944
|
+
* That return value is a durability guarantee, not a convenience: a caller that is about to write
|
|
5945
|
+
* something NAMING an entry — the annotate tool's comment — has to know the entry is readable at the
|
|
5946
|
+
* next boot first. Folded in memory and written on a later flush, an entry can be lost to a host that
|
|
5947
|
+
* stops in between, and the load-time sweep then hides (and used to delete) the comments that named
|
|
5948
|
+
* it. Awaiting here is what makes "the comment exists" imply "its entry was on disk first".
|
|
5949
|
+
*
|
|
5950
|
+
* @param force - write immediately rather than on the throttle's schedule.
|
|
5951
|
+
* @returns a promise that settles when the write this call started has finished (never rejects: a
|
|
5952
|
+
* failure is logged and reported through `persistFailureReported`, because a store that cannot write
|
|
5953
|
+
* must keep answering reads).
|
|
5954
|
+
*/
|
|
4669
5955
|
function persistSession(force = false) {
|
|
4670
5956
|
persistDirty = true;
|
|
4671
5957
|
if (force) {
|
|
@@ -4673,20 +5959,18 @@ function apply(ctx, config) {
|
|
|
4673
5959
|
clearTimeout(persistTimer);
|
|
4674
5960
|
persistScheduled = false;
|
|
4675
5961
|
}
|
|
4676
|
-
flushPersist();
|
|
4677
|
-
return;
|
|
5962
|
+
return flushPersist();
|
|
4678
5963
|
}
|
|
4679
|
-
if (persistScheduled) return;
|
|
5964
|
+
if (persistScheduled) return Promise.resolve();
|
|
4680
5965
|
const delay = PERSIST_THROTTLE_MS - (Date.now() - lastPersistAt);
|
|
4681
|
-
if (delay <= 0) flushPersist();
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
}
|
|
5966
|
+
if (delay <= 0) return flushPersist();
|
|
5967
|
+
persistScheduled = true;
|
|
5968
|
+
persistTimer = setTimeout(() => {
|
|
5969
|
+
persistScheduled = false;
|
|
5970
|
+
flushPersist();
|
|
5971
|
+
}, delay);
|
|
5972
|
+
persistTimer.unref?.();
|
|
5973
|
+
return Promise.resolve();
|
|
4690
5974
|
}
|
|
4691
5975
|
ctx.on("tools/result", (exec, result) => {
|
|
4692
5976
|
if (exec.name === "str_replace_editor") {
|
|
@@ -4702,31 +5986,47 @@ function apply(ctx, config) {
|
|
|
4702
5986
|
sessionId,
|
|
4703
5987
|
...outcome,
|
|
4704
5988
|
updatedAt: Date.now(),
|
|
4705
|
-
sessionIds: [sessionId]
|
|
5989
|
+
sessionIds: [sessionId],
|
|
5990
|
+
lineage: lineageOf(sessionId, exec.agent),
|
|
5991
|
+
unseen: true
|
|
4706
5992
|
};
|
|
4707
5993
|
if (store.fold(entry)) persistSession();
|
|
4708
5994
|
});
|
|
4709
5995
|
const handle = async (endpoint, payload, signal) => {
|
|
4710
5996
|
switch (endpoint) {
|
|
5997
|
+
case "update-check": return {
|
|
5998
|
+
ok: true,
|
|
5999
|
+
value: await checkForUpdate()
|
|
6000
|
+
};
|
|
4711
6001
|
case "list": {
|
|
4712
6002
|
const sessionId = sessionOf(payload);
|
|
4713
6003
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
4714
6004
|
await ensureLoaded();
|
|
4715
|
-
|
|
4716
|
-
const
|
|
6005
|
+
const entries = store.all();
|
|
6006
|
+
const view = lineageView(entries);
|
|
6007
|
+
if (comments.retain(new Set(entries.map((entry) => entry.id))) > 0) forgetOrphanedCommentLines();
|
|
6008
|
+
const { files, redoCleared } = await listWithState(sessionId, view, entries);
|
|
6009
|
+
const scope = commentScopeOf(sessionId, view);
|
|
6010
|
+
const visibleComments = comments.list(scope);
|
|
6011
|
+
let folded = false;
|
|
4717
6012
|
const commentAnswers = {};
|
|
4718
|
-
if (
|
|
4719
|
-
const
|
|
4720
|
-
for (const [
|
|
4721
|
-
|
|
4722
|
-
|
|
6013
|
+
if (visibleComments.length > 0) {
|
|
6014
|
+
const transcripts = /* @__PURE__ */ new Map();
|
|
6015
|
+
for (const [transcript, requestIds] of commentAsker.answerGroups(visibleComments)) {
|
|
6016
|
+
const answers = commentAsker.answersFor(transcript, requestIds, transcripts);
|
|
6017
|
+
for (const [requestId, read] of Object.entries(answers)) if (read.turn !== void 0 && comments.recordTurnForRequest(requestId, read.turn) > 0) folded = true;
|
|
6018
|
+
for (const turn of commentAsker.endedTurns(transcript, transcripts)) if (comments.markTurnEnded(transcript, turn) > 0) folded = true;
|
|
6019
|
+
if (comments.syncAnswers(transcript, answers)) folded = true;
|
|
6020
|
+
for (const [requestId, read] of Object.entries(answers)) if (read.answer !== void 0) commentAnswers[requestId] = read.answer;
|
|
6021
|
+
}
|
|
4723
6022
|
}
|
|
6023
|
+
const listedComments = folded ? comments.list(scope) : visibleComments;
|
|
4724
6024
|
return {
|
|
4725
6025
|
ok: true,
|
|
4726
6026
|
value: {
|
|
4727
6027
|
files,
|
|
4728
|
-
comments:
|
|
4729
|
-
commentLines: resolvedCommentLines(
|
|
6028
|
+
comments: listedComments,
|
|
6029
|
+
commentLines: resolvedCommentLines(listedComments),
|
|
4730
6030
|
commentsRevision: comments.commentsRevision(),
|
|
4731
6031
|
commentAnswers,
|
|
4732
6032
|
workspacePath: workspaceOf(sessionId)?.path,
|
|
@@ -4737,11 +6037,44 @@ function apply(ctx, config) {
|
|
|
4737
6037
|
}
|
|
4738
6038
|
};
|
|
4739
6039
|
}
|
|
6040
|
+
case "list-count": {
|
|
6041
|
+
const sessionId = sessionOf(payload);
|
|
6042
|
+
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
6043
|
+
await ensureLoaded();
|
|
6044
|
+
const entries = store.all();
|
|
6045
|
+
const view = lineageView(entries);
|
|
6046
|
+
let count = 0;
|
|
6047
|
+
for (const entry of entries) {
|
|
6048
|
+
if (!view.sees(sessionId, entry)) continue;
|
|
6049
|
+
if ((await probePath(entry.path)).kind !== "present") continue;
|
|
6050
|
+
count += 1;
|
|
6051
|
+
}
|
|
6052
|
+
return {
|
|
6053
|
+
ok: true,
|
|
6054
|
+
value: { count }
|
|
6055
|
+
};
|
|
6056
|
+
}
|
|
6057
|
+
case "comment-seen": {
|
|
6058
|
+
const seen = targetOf(payload);
|
|
6059
|
+
if (seen === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
6060
|
+
await ensureLoaded();
|
|
6061
|
+
const record = comments.get(seen.id);
|
|
6062
|
+
if (record === void 0 || !commentScopeOf(seen.sessionId, lineageView())(record.sessionId)) return {
|
|
6063
|
+
ok: true,
|
|
6064
|
+
value: { outcome: "missing" }
|
|
6065
|
+
};
|
|
6066
|
+
comments.markSeen(seen.id);
|
|
6067
|
+
return {
|
|
6068
|
+
ok: true,
|
|
6069
|
+
value: { outcome: "seen" }
|
|
6070
|
+
};
|
|
6071
|
+
}
|
|
4740
6072
|
case "comment-add": {
|
|
4741
6073
|
const input = commentAddOf(payload);
|
|
4742
6074
|
if (input === void 0) return rpcError("sessionId, entryId, anchor, quote and text must be valid");
|
|
4743
6075
|
await ensureLoaded();
|
|
4744
|
-
const
|
|
6076
|
+
const view = lineageView();
|
|
6077
|
+
const entry = actionableEntryOf(view, input.sessionId, input.entryId);
|
|
4745
6078
|
if (entry === void 0) return {
|
|
4746
6079
|
ok: true,
|
|
4747
6080
|
value: { outcome: "missing" }
|
|
@@ -4761,7 +6094,7 @@ function apply(ctx, config) {
|
|
|
4761
6094
|
...input.quoteLines === void 0 ? {} : { quoteLines: input.quoteLines }
|
|
4762
6095
|
};
|
|
4763
6096
|
comments.add(record);
|
|
4764
|
-
pushUndo(input.sessionId, commentState(entry.path, []), commentState(entry.path, [record]));
|
|
6097
|
+
pushUndo(input.sessionId, commentState(entry.path, []), commentState(entry.path, [record]), view);
|
|
4765
6098
|
return {
|
|
4766
6099
|
ok: true,
|
|
4767
6100
|
value: {
|
|
@@ -4774,10 +6107,11 @@ function apply(ctx, config) {
|
|
|
4774
6107
|
const target = targetOf(payload);
|
|
4775
6108
|
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
4776
6109
|
await ensureLoaded();
|
|
6110
|
+
const view = lineageView();
|
|
4777
6111
|
const record = comments.get(target.id);
|
|
4778
|
-
const removed = comments.remove(target.sessionId, target.id);
|
|
6112
|
+
const removed = comments.remove(commentScopeOf(target.sessionId, view), target.id);
|
|
4779
6113
|
const value = { outcome: removed ? "removed" : "missing" };
|
|
4780
|
-
if (removed && record !== void 0) pushUndo(target.sessionId, commentState(record.entryId, [record]), commentState(record.entryId, []));
|
|
6114
|
+
if (removed && record !== void 0) pushUndo(target.sessionId, commentState(record.entryId, [record]), commentState(record.entryId, []), view);
|
|
4781
6115
|
commentLines.delete(target.id);
|
|
4782
6116
|
return {
|
|
4783
6117
|
ok: true,
|
|
@@ -4788,11 +6122,13 @@ function apply(ctx, config) {
|
|
|
4788
6122
|
const request = commentRemoveManyOf(payload);
|
|
4789
6123
|
if (request === void 0) return rpcError("sessionId and a non-empty ids array of strings must be given");
|
|
4790
6124
|
await ensureLoaded();
|
|
4791
|
-
const
|
|
4792
|
-
const
|
|
6125
|
+
const view = lineageView();
|
|
6126
|
+
const scope = commentScopeOf(request.sessionId, view);
|
|
6127
|
+
const snapshot = request.ids.map((id) => comments.get(id)).filter((record) => record !== void 0 && scope(record.sessionId));
|
|
6128
|
+
const removed = comments.removeMany(scope, request.ids);
|
|
4793
6129
|
for (const id of removed) commentLines.delete(id);
|
|
4794
6130
|
const first = snapshot[0];
|
|
4795
|
-
if (first !== void 0) pushUndo(request.sessionId, commentState(first.entryId, snapshot), commentState(first.entryId, []));
|
|
6131
|
+
if (first !== void 0) pushUndo(request.sessionId, commentState(first.entryId, snapshot), commentState(first.entryId, []), view);
|
|
4796
6132
|
return {
|
|
4797
6133
|
ok: true,
|
|
4798
6134
|
value: { removed: removed.length }
|
|
@@ -4802,18 +6138,20 @@ function apply(ctx, config) {
|
|
|
4802
6138
|
const input = commentAskOf(payload);
|
|
4803
6139
|
if (input === void 0) return rpcError("sessionId, id, prompt and text must be valid");
|
|
4804
6140
|
await ensureLoaded();
|
|
6141
|
+
const view = lineageView();
|
|
4805
6142
|
return {
|
|
4806
6143
|
ok: true,
|
|
4807
|
-
value: await commentAsker.ask(input.sessionId, input.id, input.prompt, input.text, signal)
|
|
6144
|
+
value: await commentAsker.ask(input.sessionId, input.id, input.prompt, input.text, signal, commentScopeOf(input.sessionId, view))
|
|
4808
6145
|
};
|
|
4809
6146
|
}
|
|
4810
6147
|
case "keep": {
|
|
4811
6148
|
const target = targetOf(payload);
|
|
4812
6149
|
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
4813
|
-
|
|
6150
|
+
const lineage = lineageView();
|
|
6151
|
+
let entry = actionableEntryOf(lineage, target.sessionId, target.id);
|
|
4814
6152
|
if (entry === void 0) {
|
|
4815
6153
|
await ensureLoaded();
|
|
4816
|
-
entry =
|
|
6154
|
+
entry = actionableEntryOf(lineage, target.sessionId, target.id);
|
|
4817
6155
|
if (entry === void 0) return {
|
|
4818
6156
|
ok: true,
|
|
4819
6157
|
value: { outcome: "missing" }
|
|
@@ -4868,10 +6206,11 @@ function apply(ctx, config) {
|
|
|
4868
6206
|
case "revert": {
|
|
4869
6207
|
const target = targetOf(payload);
|
|
4870
6208
|
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
4871
|
-
|
|
6209
|
+
const lineage = lineageView();
|
|
6210
|
+
let entry = actionableEntryOf(lineage, target.sessionId, target.id);
|
|
4872
6211
|
if (entry === void 0) {
|
|
4873
6212
|
await ensureLoaded();
|
|
4874
|
-
entry =
|
|
6213
|
+
entry = actionableEntryOf(lineage, target.sessionId, target.id);
|
|
4875
6214
|
if (entry === void 0) return {
|
|
4876
6215
|
ok: true,
|
|
4877
6216
|
value: { outcome: "missing" }
|
|
@@ -4881,6 +6220,7 @@ function apply(ctx, config) {
|
|
|
4881
6220
|
try {
|
|
4882
6221
|
undo = await revertEntryContent(entry, target.sessionId, signal);
|
|
4883
6222
|
} catch (error) {
|
|
6223
|
+
if (isPolicyRefusal(error)) return policyRefusal(entry.path, error);
|
|
4884
6224
|
return rpcError(`revert failed: ${errorMessage(error)}`);
|
|
4885
6225
|
}
|
|
4886
6226
|
if (payload.keepListed === true) {
|
|
@@ -4927,7 +6267,8 @@ function apply(ctx, config) {
|
|
|
4927
6267
|
const sessionId = sessionOf(payload);
|
|
4928
6268
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
4929
6269
|
await ensureLoaded();
|
|
4930
|
-
const
|
|
6270
|
+
const lineage = lineageView();
|
|
6271
|
+
const entries = store.all().filter((entry) => lineage.sees(sessionId, entry));
|
|
4931
6272
|
const before = [];
|
|
4932
6273
|
const after = [];
|
|
4933
6274
|
for (const entry of entries) {
|
|
@@ -4956,16 +6297,22 @@ function apply(ctx, config) {
|
|
|
4956
6297
|
const sessionId = sessionOf(payload);
|
|
4957
6298
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
4958
6299
|
await ensureLoaded();
|
|
4959
|
-
const
|
|
6300
|
+
const lineage = lineageView();
|
|
6301
|
+
const entries = store.all().filter((entry) => lineage.sees(sessionId, entry));
|
|
4960
6302
|
const batchBefore = [];
|
|
4961
6303
|
const batchAfter = [];
|
|
6304
|
+
/** How many files this press had already put back when it stopped: said, never hidden (see 3c). */
|
|
6305
|
+
let reverted = 0;
|
|
4962
6306
|
for (const entry of entries) {
|
|
4963
6307
|
let undo;
|
|
4964
6308
|
try {
|
|
4965
6309
|
undo = await revertEntryContent(entry, sessionId, signal);
|
|
4966
|
-
} catch {
|
|
4967
|
-
|
|
6310
|
+
} catch (error) {
|
|
6311
|
+
const far = reverted === 0 ? "" : ` (this press had already reverted ${reverted} of ${entries.length} files)`;
|
|
6312
|
+
if (isPolicyRefusal(error)) return rpcError(refusalText(entry.path, error) + far);
|
|
6313
|
+
return rpcError(`revert-all failed for ${entry.path}${far}`);
|
|
4968
6314
|
}
|
|
6315
|
+
reverted += 1;
|
|
4969
6316
|
if (undo !== void 0) {
|
|
4970
6317
|
batchBefore.push(droppingComments(undo.before));
|
|
4971
6318
|
batchAfter.push(undo.after);
|
|
@@ -4983,10 +6330,11 @@ function apply(ctx, config) {
|
|
|
4983
6330
|
const request = manyTargetsOf(payload);
|
|
4984
6331
|
if (request === void 0) return rpcError("sessionId and a non-empty ids array of strings must be given");
|
|
4985
6332
|
await ensureLoaded();
|
|
6333
|
+
const lineage = lineageView();
|
|
4986
6334
|
const before = [];
|
|
4987
6335
|
const after = [];
|
|
4988
6336
|
for (const id of request.ids) {
|
|
4989
|
-
const entry =
|
|
6337
|
+
const entry = actionableEntryOf(lineage, request.sessionId, id);
|
|
4990
6338
|
if (entry === void 0) continue;
|
|
4991
6339
|
if (request.keepListed === true) {
|
|
4992
6340
|
store.update(id, { oldText: entry.newText });
|
|
@@ -5033,18 +6381,21 @@ function apply(ctx, config) {
|
|
|
5033
6381
|
const request = manyTargetsOf(payload);
|
|
5034
6382
|
if (request === void 0) return rpcError("sessionId and a non-empty ids array of strings must be given");
|
|
5035
6383
|
await ensureLoaded();
|
|
6384
|
+
const lineage = lineageView();
|
|
5036
6385
|
const before = [];
|
|
5037
6386
|
const after = [];
|
|
5038
6387
|
let affected = 0;
|
|
5039
6388
|
for (const id of request.ids) {
|
|
5040
|
-
const entry =
|
|
6389
|
+
const entry = actionableEntryOf(lineage, request.sessionId, id);
|
|
5041
6390
|
if (entry === void 0) continue;
|
|
5042
6391
|
affected += 1;
|
|
5043
6392
|
let undo;
|
|
5044
6393
|
try {
|
|
5045
6394
|
undo = await revertEntryContent(entry, request.sessionId, signal);
|
|
5046
|
-
} catch {
|
|
5047
|
-
|
|
6395
|
+
} catch (error) {
|
|
6396
|
+
const far = affected - 1 === 0 ? "" : ` (this pick had already reverted ${affected - 1} of ${request.ids.length} files)`;
|
|
6397
|
+
if (isPolicyRefusal(error)) return rpcError(refusalText(entry.path, error) + far);
|
|
6398
|
+
return rpcError(`revert-many failed for ${entry.path}${far}`);
|
|
5048
6399
|
}
|
|
5049
6400
|
if (request.keepListed === true) {
|
|
5050
6401
|
const content = undo?.after.fileText ?? "";
|
|
@@ -5086,7 +6437,7 @@ function apply(ctx, config) {
|
|
|
5086
6437
|
const blockTarget = blockTargetOf(payload);
|
|
5087
6438
|
if (blockTarget === void 0) return rpcError("sessionId, id, and block must be valid");
|
|
5088
6439
|
await ensureLoaded();
|
|
5089
|
-
const entry =
|
|
6440
|
+
const entry = actionableEntryOf(lineageView(), blockTarget.sessionId, blockTarget.id);
|
|
5090
6441
|
if (entry === void 0) return {
|
|
5091
6442
|
ok: true,
|
|
5092
6443
|
value: { outcome: "missing" }
|
|
@@ -5140,7 +6491,7 @@ function apply(ctx, config) {
|
|
|
5140
6491
|
const blockTarget = blockTargetOf(payload);
|
|
5141
6492
|
if (blockTarget === void 0) return rpcError("sessionId, id, and block must be valid");
|
|
5142
6493
|
await ensureLoaded();
|
|
5143
|
-
const entry =
|
|
6494
|
+
const entry = actionableEntryOf(lineageView(), blockTarget.sessionId, blockTarget.id);
|
|
5144
6495
|
if (entry === void 0) return {
|
|
5145
6496
|
ok: true,
|
|
5146
6497
|
value: { outcome: "missing" }
|
|
@@ -5157,7 +6508,7 @@ function apply(ctx, config) {
|
|
|
5157
6508
|
let undo;
|
|
5158
6509
|
try {
|
|
5159
6510
|
const target = await ctx.fs.resolve(entry.path, { signal });
|
|
5160
|
-
if (entry.
|
|
6511
|
+
if (entry.earlierVersion === "none" && content === "") await removeRevert(target, blockTarget.sessionId, signal);
|
|
5161
6512
|
else {
|
|
5162
6513
|
const preWrite = await ctx.fs.readText(target, void 0) ?? entry.newText;
|
|
5163
6514
|
await writeRevert(target, content, blockTarget.sessionId, signal);
|
|
@@ -5177,6 +6528,7 @@ function apply(ctx, config) {
|
|
|
5177
6528
|
};
|
|
5178
6529
|
}
|
|
5179
6530
|
} catch (error) {
|
|
6531
|
+
if (isPolicyRefusal(error)) return policyRefusal(entry.path, error);
|
|
5180
6532
|
return rpcError(`block revert failed: ${errorMessage(error)}`);
|
|
5181
6533
|
}
|
|
5182
6534
|
if (undo !== void 0) pushUndo(blockTarget.sessionId, undo.before, undo.after);
|
|
@@ -5209,18 +6561,35 @@ function apply(ctx, config) {
|
|
|
5209
6561
|
case "undo": {
|
|
5210
6562
|
const sessionId = sessionOf(payload);
|
|
5211
6563
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
5212
|
-
const
|
|
6564
|
+
const view = lineageView();
|
|
6565
|
+
const root = canonicalOf(sessionId, view);
|
|
6566
|
+
const pair = popOwnPair(undoStackOf(root), root);
|
|
5213
6567
|
if (pair === void 0) return {
|
|
5214
6568
|
ok: true,
|
|
5215
6569
|
value: { outcome: "nothing" }
|
|
5216
6570
|
};
|
|
6571
|
+
const writes = restoreWritesOf(pair.before, pair.after);
|
|
6572
|
+
const refused = await preflightRestore(writes, pair.policySession, signal);
|
|
6573
|
+
if (refused !== void 0) {
|
|
6574
|
+
undoStackOf(root).push(pair);
|
|
6575
|
+
return restoreFailure("undo", refused.path, refused.error, {
|
|
6576
|
+
written: 0,
|
|
6577
|
+
total: writes.length,
|
|
6578
|
+
path: refused.path
|
|
6579
|
+
});
|
|
6580
|
+
}
|
|
6581
|
+
const progress = {
|
|
6582
|
+
written: 0,
|
|
6583
|
+
total: writes.length,
|
|
6584
|
+
path: pair.before.path
|
|
6585
|
+
};
|
|
5217
6586
|
try {
|
|
5218
|
-
await restoreState(pair.
|
|
6587
|
+
await restoreState(pair.policySession, pair.before, pair.after, signal, commentScopeOf(pair.pressedBy, view), progress);
|
|
5219
6588
|
} catch (error) {
|
|
5220
|
-
undoStackOf(
|
|
5221
|
-
return
|
|
6589
|
+
undoStackOf(root).push(pair);
|
|
6590
|
+
return restoreFailure("undo", progress.path, error, progress);
|
|
5222
6591
|
}
|
|
5223
|
-
redoStackOf(
|
|
6592
|
+
redoStackOf(root).push(pair);
|
|
5224
6593
|
persistSession(true);
|
|
5225
6594
|
return {
|
|
5226
6595
|
ok: true,
|
|
@@ -5233,18 +6602,35 @@ function apply(ctx, config) {
|
|
|
5233
6602
|
case "redo": {
|
|
5234
6603
|
const sessionId = sessionOf(payload);
|
|
5235
6604
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
5236
|
-
const
|
|
6605
|
+
const view = lineageView();
|
|
6606
|
+
const root = canonicalOf(sessionId, view);
|
|
6607
|
+
const pair = popOwnPair(redoStackOf(root), root);
|
|
5237
6608
|
if (pair === void 0) return {
|
|
5238
6609
|
ok: true,
|
|
5239
6610
|
value: { outcome: "nothing" }
|
|
5240
6611
|
};
|
|
6612
|
+
const writes = restoreWritesOf(pair.after, pair.before);
|
|
6613
|
+
const refused = await preflightRestore(writes, pair.policySession, signal);
|
|
6614
|
+
if (refused !== void 0) {
|
|
6615
|
+
redoStackOf(root).push(pair);
|
|
6616
|
+
return restoreFailure("redo", refused.path, refused.error, {
|
|
6617
|
+
written: 0,
|
|
6618
|
+
total: writes.length,
|
|
6619
|
+
path: refused.path
|
|
6620
|
+
});
|
|
6621
|
+
}
|
|
6622
|
+
const progress = {
|
|
6623
|
+
written: 0,
|
|
6624
|
+
total: writes.length,
|
|
6625
|
+
path: pair.after.path
|
|
6626
|
+
};
|
|
5241
6627
|
try {
|
|
5242
|
-
await restoreState(pair.
|
|
6628
|
+
await restoreState(pair.policySession, pair.after, pair.before, signal, commentScopeOf(pair.pressedBy, view), progress);
|
|
5243
6629
|
} catch (error) {
|
|
5244
|
-
redoStackOf(
|
|
5245
|
-
return
|
|
6630
|
+
redoStackOf(root).push(pair);
|
|
6631
|
+
return restoreFailure("redo", progress.path, error, progress);
|
|
5246
6632
|
}
|
|
5247
|
-
undoStackOf(
|
|
6633
|
+
undoStackOf(root).push(pair);
|
|
5248
6634
|
persistSession(true);
|
|
5249
6635
|
return {
|
|
5250
6636
|
ok: true,
|
|
@@ -5292,11 +6678,12 @@ function apply(ctx, config) {
|
|
|
5292
6678
|
id: change.path,
|
|
5293
6679
|
sessionId,
|
|
5294
6680
|
path: change.path,
|
|
5295
|
-
|
|
6681
|
+
earlierVersion: change.earlierVersion,
|
|
5296
6682
|
oldText: change.oldText,
|
|
5297
6683
|
newText: change.newText,
|
|
5298
6684
|
updatedAt: Date.now(),
|
|
5299
|
-
sessionIds: [sessionId]
|
|
6685
|
+
sessionIds: [sessionId],
|
|
6686
|
+
lineage: lineageOf(sessionId)
|
|
5300
6687
|
}))),
|
|
5301
6688
|
detected: true
|
|
5302
6689
|
}
|
|
@@ -5306,7 +6693,7 @@ function apply(ctx, config) {
|
|
|
5306
6693
|
const target = targetOf(payload);
|
|
5307
6694
|
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
5308
6695
|
await ensureLoaded();
|
|
5309
|
-
const entry =
|
|
6696
|
+
const entry = actionableEntryOf(lineageView(), target.sessionId, target.id);
|
|
5310
6697
|
if (entry === void 0) return {
|
|
5311
6698
|
ok: true,
|
|
5312
6699
|
value: { outcome: "missing" }
|
|
@@ -5340,18 +6727,19 @@ function apply(ctx, config) {
|
|
|
5340
6727
|
ok: true,
|
|
5341
6728
|
value: { outcome: "no-change" }
|
|
5342
6729
|
};
|
|
5343
|
-
if (change.
|
|
6730
|
+
if (change.earlierVersion === entry.earlierVersion && change.oldText === entry.oldText && change.newText === entry.newText) return {
|
|
5344
6731
|
ok: true,
|
|
5345
6732
|
value: { outcome: "unchanged" }
|
|
5346
6733
|
};
|
|
5347
6734
|
const refreshed = {
|
|
5348
6735
|
...entry,
|
|
5349
|
-
|
|
6736
|
+
earlierVersion: change.earlierVersion,
|
|
5350
6737
|
oldText: change.oldText,
|
|
5351
6738
|
newText: change.newText,
|
|
5352
6739
|
updatedAt: Date.now()
|
|
5353
6740
|
};
|
|
5354
6741
|
store.restore(refreshed);
|
|
6742
|
+
store.markSeen(entry.path);
|
|
5355
6743
|
pushUndo(target.sessionId, {
|
|
5356
6744
|
id: entry.path,
|
|
5357
6745
|
path: entry.path,
|
|
@@ -5492,11 +6880,12 @@ function apply(ctx, config) {
|
|
|
5492
6880
|
id: absolute,
|
|
5493
6881
|
sessionId: target.sessionId,
|
|
5494
6882
|
path: absolute,
|
|
5495
|
-
|
|
6883
|
+
earlierVersion: "file",
|
|
5496
6884
|
oldText: content,
|
|
5497
6885
|
newText: content,
|
|
5498
6886
|
updatedAt: now,
|
|
5499
|
-
sessionIds: [target.sessionId]
|
|
6887
|
+
sessionIds: [target.sessionId],
|
|
6888
|
+
lineage: lineageOf(target.sessionId)
|
|
5500
6889
|
}], false, false);
|
|
5501
6890
|
}
|
|
5502
6891
|
const root = detectVcsRoot(workspace.path);
|
|
@@ -5537,11 +6926,12 @@ function apply(ctx, config) {
|
|
|
5537
6926
|
id: change.path,
|
|
5538
6927
|
sessionId: target.sessionId,
|
|
5539
6928
|
path: change.path,
|
|
5540
|
-
|
|
6929
|
+
earlierVersion: change.earlierVersion,
|
|
5541
6930
|
oldText: change.oldText,
|
|
5542
6931
|
newText: change.newText,
|
|
5543
6932
|
updatedAt: now,
|
|
5544
|
-
sessionIds: [target.sessionId]
|
|
6933
|
+
sessionIds: [target.sessionId],
|
|
6934
|
+
lineage: lineageOf(target.sessionId)
|
|
5545
6935
|
}));
|
|
5546
6936
|
let truncated = false;
|
|
5547
6937
|
if (target.includeUnchanged) {
|
|
@@ -5562,25 +6952,42 @@ function apply(ctx, config) {
|
|
|
5562
6952
|
id: file.path,
|
|
5563
6953
|
sessionId: target.sessionId,
|
|
5564
6954
|
path: file.path,
|
|
5565
|
-
|
|
6955
|
+
earlierVersion: "file",
|
|
5566
6956
|
oldText: file.content,
|
|
5567
6957
|
newText: file.content,
|
|
5568
6958
|
updatedAt: now,
|
|
5569
|
-
sessionIds: [target.sessionId]
|
|
6959
|
+
sessionIds: [target.sessionId],
|
|
6960
|
+
lineage: lineageOf(target.sessionId)
|
|
5570
6961
|
});
|
|
5571
6962
|
}
|
|
5572
6963
|
}
|
|
5573
6964
|
return await admitNamed(candidates, isDirectory, truncated);
|
|
5574
6965
|
}
|
|
6966
|
+
case "seen": {
|
|
6967
|
+
const seen = payload ?? {};
|
|
6968
|
+
if (typeof seen.id !== "string" || seen.id.length === 0) return rpcError("id must be a non-empty string");
|
|
6969
|
+
await ensureLoaded();
|
|
6970
|
+
if (store.markSeen(seen.id)) persistSession();
|
|
6971
|
+
return {
|
|
6972
|
+
ok: true,
|
|
6973
|
+
value: { outcome: "seen" }
|
|
6974
|
+
};
|
|
6975
|
+
}
|
|
5575
6976
|
case "open": {
|
|
5576
6977
|
const target = openTargetOf(payload);
|
|
5577
6978
|
if (target === void 0) return rpcError("sessionId, id, and action must be valid");
|
|
5578
6979
|
await ensureLoaded();
|
|
5579
|
-
const
|
|
6980
|
+
const lineage = lineageView();
|
|
6981
|
+
let entry = actionableEntryOf(lineage, target.sessionId, target.id);
|
|
6982
|
+
if (entry === void 0) {
|
|
6983
|
+
await ensureLoaded();
|
|
6984
|
+
entry = actionableEntryOf(lineage, target.sessionId, target.id);
|
|
6985
|
+
}
|
|
5580
6986
|
if (entry === void 0) return {
|
|
5581
6987
|
ok: true,
|
|
5582
6988
|
value: { outcome: "missing" }
|
|
5583
6989
|
};
|
|
6990
|
+
if (store.markSeen(entry.path)) persistSession();
|
|
5584
6991
|
try {
|
|
5585
6992
|
const resolved = await ctx.fs.resolve(entry.path, { signal });
|
|
5586
6993
|
await launchPath(ctx.fs.processPath(resolved), target.action);
|
|
@@ -5632,11 +7039,11 @@ function apply(ctx, config) {
|
|
|
5632
7039
|
return server.register(route);
|
|
5633
7040
|
}, "diff-approval: code font slices");
|
|
5634
7041
|
ctx.effect(() => ctx.on("fs/edit-intent", async (target, actor, next) => {
|
|
5635
|
-
await stashEditorIntent(target, actor, "
|
|
7042
|
+
await stashEditorIntent(target, actor, "file");
|
|
5636
7043
|
return next();
|
|
5637
7044
|
}, { prepend: true }), "diff-approval: str_replace_editor edit basis");
|
|
5638
7045
|
ctx.effect(() => ctx.on("fs/write-intent", async (target, actor, next) => {
|
|
5639
|
-
await stashEditorIntent(target, actor, "
|
|
7046
|
+
await stashEditorIntent(target, actor, "none");
|
|
5640
7047
|
return next();
|
|
5641
7048
|
}, { prepend: true }), "diff-approval: str_replace_editor create basis");
|
|
5642
7049
|
}
|