dsh-diff-approval 0.29.1 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/README.zh.md +4 -4
- package/lib/client.js +3145 -1522
- package/lib/index.js +2655 -173
- package/lib/types/annotate-skill.d.ts +34 -0
- package/lib/types/annotate-tool.d.ts +216 -0
- package/lib/types/annotate.d.ts +158 -0
- package/lib/types/atomic-write.d.ts +77 -0
- package/lib/types/client/PendingPanel.d.ts +66 -4
- package/lib/types/client/discussion.d.ts +69 -16
- package/lib/types/client/dock.d.ts +0 -5
- package/lib/types/client/dsh-icons.d.ts +60 -0
- package/lib/types/client/header-entry.d.ts +20 -5
- package/lib/types/client/ids.d.ts +18 -0
- package/lib/types/client/locales.d.ts +50 -28
- package/lib/types/client/panel-memory.d.ts +94 -29
- package/lib/types/client/port.d.ts +50 -1
- package/lib/types/client/presentation-menu.d.ts +1 -1
- package/lib/types/client/produced-diff.d.ts +57 -20
- package/lib/types/client/session-seat.d.ts +53 -0
- package/lib/types/client/slots.d.ts +71 -11
- package/lib/types/client/store.d.ts +35 -2
- package/lib/types/comment-ask.d.ts +209 -0
- package/lib/types/comment-lines.d.ts +131 -0
- package/lib/types/comments.d.ts +357 -0
- package/lib/types/index.d.ts +7 -2
- package/lib/types/pending.d.ts +23 -0
- package/lib/types/types.d.ts +176 -0
- package/lib/types/vcs.d.ts +51 -25
- package/package.json +4 -1
- package/lib/types/client/chat-bridge.d.ts +0 -70
package/lib/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { dshHomePath, expandHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
6
7
|
import "@deepseek-ai/cordis";
|
|
7
8
|
import { spawn } from "node:child_process";
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
9
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
9
10
|
//#region node_modules/.pnpm/@deepseek-ai+cosmokit@1.8.2/node_modules/@deepseek-ai/cosmokit/lib/index.js
|
|
10
11
|
/** Return true when a value is `null` or `undefined`. */
|
|
11
12
|
function isNullable(value) {
|
|
@@ -904,6 +905,33 @@ function sameEntry(left, right) {
|
|
|
904
905
|
var PendingDiffStore = class {
|
|
905
906
|
entries = /* @__PURE__ */ new Map();
|
|
906
907
|
/**
|
|
908
|
+
* The content version of each entry: bumped whenever its tracked `newText` moves, and never for a
|
|
909
|
+
* change that leaves the content alone (a block keep advances `oldText` only).
|
|
910
|
+
*
|
|
911
|
+
* It is what lets derived, content-dependent state — the lines a comment's quote resolves to, read
|
|
912
|
+
* off `newText` — be cached and refreshed when the content moved rather than recomputed on every
|
|
913
|
+
* read. The versions come from one monotonic counter and are never reused, so an entry that leaves
|
|
914
|
+
* the list and comes back cannot hand back the number a stale cache was keyed to.
|
|
915
|
+
*/
|
|
916
|
+
contentVersions = /* @__PURE__ */ new Map();
|
|
917
|
+
/** The last version handed out; only ever climbs (see `contentVersions`). */
|
|
918
|
+
contentVersionCounter = 0;
|
|
919
|
+
/** Note that one path's tracked content moved, and give it a fresh version. */
|
|
920
|
+
bumpContent(path) {
|
|
921
|
+
this.contentVersions.set(pathKeyOf(path), ++this.contentVersionCounter);
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* The version of one path's tracked content, or 0 while it has none.
|
|
925
|
+
*
|
|
926
|
+
* Two calls that return the same number describe the same `newText`: a caller caching something
|
|
927
|
+
* derived from that content may serve its copy until the number changes.
|
|
928
|
+
* @param path - the file path (the global entry key).
|
|
929
|
+
* @returns the path's content version.
|
|
930
|
+
*/
|
|
931
|
+
contentVersion(path) {
|
|
932
|
+
return this.contentVersions.get(pathKeyOf(path)) ?? 0;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
907
935
|
* Merge one captured operation into its file's entry. A no-op (equal before
|
|
908
936
|
* and after) folds nothing.
|
|
909
937
|
* @param entry - the captured operation (id assigned by the caller).
|
|
@@ -919,6 +947,7 @@ var PendingDiffStore = class {
|
|
|
919
947
|
const merged = current === void 0 ? { ...entry } : this.mergeEntry(current, entry);
|
|
920
948
|
if (current !== void 0 && sameEntry(current, merged)) return false;
|
|
921
949
|
this.entries.set(pathKeyOf(merged.path), merged);
|
|
950
|
+
if (current === void 0 || current.newText !== merged.newText) this.bumpContent(merged.path);
|
|
922
951
|
return true;
|
|
923
952
|
}
|
|
924
953
|
/**
|
|
@@ -969,7 +998,10 @@ var PendingDiffStore = class {
|
|
|
969
998
|
* @returns whether an entry was removed.
|
|
970
999
|
*/
|
|
971
1000
|
remove(path) {
|
|
972
|
-
|
|
1001
|
+
const key = pathKeyOf(path);
|
|
1002
|
+
const removed = this.entries.delete(key);
|
|
1003
|
+
if (removed) this.contentVersions.delete(key);
|
|
1004
|
+
return removed;
|
|
973
1005
|
}
|
|
974
1006
|
/**
|
|
975
1007
|
* Advance one entry's tracked content after a block-level keep/revert. The
|
|
@@ -987,6 +1019,7 @@ var PendingDiffStore = class {
|
|
|
987
1019
|
updatedAt: Date.now()
|
|
988
1020
|
};
|
|
989
1021
|
this.entries.set(pathKeyOf(path), next);
|
|
1022
|
+
if (next.newText !== entry.newText) this.bumpContent(path);
|
|
990
1023
|
return true;
|
|
991
1024
|
}
|
|
992
1025
|
/**
|
|
@@ -1000,6 +1033,7 @@ var PendingDiffStore = class {
|
|
|
1000
1033
|
const existing = this.entries.get(key);
|
|
1001
1034
|
if (existing !== void 0 && sameEntry(existing, entry)) return false;
|
|
1002
1035
|
this.entries.set(key, { ...entry });
|
|
1036
|
+
if (existing === void 0 || existing.newText !== entry.newText) this.bumpContent(entry.path);
|
|
1003
1037
|
return true;
|
|
1004
1038
|
}
|
|
1005
1039
|
/**
|
|
@@ -1029,6 +1063,90 @@ var PendingDiffStore = class {
|
|
|
1029
1063
|
}
|
|
1030
1064
|
};
|
|
1031
1065
|
//#endregion
|
|
1066
|
+
//#region lib/types/atomic-write.js
|
|
1067
|
+
/**
|
|
1068
|
+
* Stage a JSON envelope beside its target and rename it into place, retrying the rename.
|
|
1069
|
+
*
|
|
1070
|
+
* Both stores in this plugin persist the same way — write a sibling temp file, `rename` it over the
|
|
1071
|
+
* real one — because a crash must leave either the old file or the new one and never a torn one. What
|
|
1072
|
+
* neither of them did was survive the rename itself being refused, and on Windows that is not rare:
|
|
1073
|
+
* replacing a file another process has open at that instant answers `EPERM`, and the readers here are
|
|
1074
|
+
* everyone — a second panel, a backup agent, an antivirus, the user's own editor, or this suite's own
|
|
1075
|
+
* 50ms `readFile` loop waiting for the write. The refusal is transient by construction (the handle that
|
|
1076
|
+
* caused it closes within a tick), but the write it killed was the ONLY copy of that update: the
|
|
1077
|
+
* pending list or a session's comment file simply kept its previous content, and the failure was
|
|
1078
|
+
* reported as "your threads will not survive a restart" without ever being retried.
|
|
1079
|
+
*
|
|
1080
|
+
* So the rename is attempted a few times with a short, growing pause. Measured on this machine with a
|
|
1081
|
+
* concurrent reader, the retry turns 6 lost writes in 12 into 0 — see `tests/atomic-write.spec.ts` for
|
|
1082
|
+
* the deterministic shape (an injected rename that fails once) and the commit message for the numbers.
|
|
1083
|
+
*
|
|
1084
|
+
* Failures that are NOT about a held handle (a missing directory, a full disk, a read-only mount) are
|
|
1085
|
+
* rethrown on the first attempt: retrying those only delays the report the caller has to make.
|
|
1086
|
+
*
|
|
1087
|
+
* The directory is `node:path`'s own `dirname`, not a scan for the last separator: on Windows the
|
|
1088
|
+
* storage root is `C:\Users\<user>\.dsh\diff-approval\workspaces`, which holds no forward slash at all —
|
|
1089
|
+
* a scan answered `.`, the `mkdir` before each write became a no-op, and the write threw ENOENT, so
|
|
1090
|
+
* nothing was ever persisted there.
|
|
1091
|
+
*
|
|
1092
|
+
* @module dsh-diff-approval/src/atomic-write
|
|
1093
|
+
*/
|
|
1094
|
+
/**
|
|
1095
|
+
* The default policy: five attempts, 25ms then 50, 100, 200 — a little over a third of a second in all.
|
|
1096
|
+
*
|
|
1097
|
+
* Long enough for the handle that refused the rename to be closed (a reader gets its next turn of the
|
|
1098
|
+
* loop, an antivirus finishes its scan), and short enough that a caller who is waiting on the write
|
|
1099
|
+
* — the panel's keep, a comment being added — cannot tell it retried at all.
|
|
1100
|
+
*/
|
|
1101
|
+
const ATOMIC_WRITE_RETRY = {
|
|
1102
|
+
attempts: 5,
|
|
1103
|
+
delayMs: 25
|
|
1104
|
+
};
|
|
1105
|
+
/**
|
|
1106
|
+
* Whether one rename error is the transient "somebody else has this file open" refusal.
|
|
1107
|
+
*
|
|
1108
|
+
* Windows answers `EPERM` for a replace whose target is open; `EACCES` and `EBUSY` are the same
|
|
1109
|
+
* situation seen through a network share or a mapped drive. Nothing else is retried.
|
|
1110
|
+
*
|
|
1111
|
+
* @param error - whatever the rename threw.
|
|
1112
|
+
* @returns whether another attempt is worth making.
|
|
1113
|
+
*/
|
|
1114
|
+
function isHeldOpen(error) {
|
|
1115
|
+
const code = error?.code;
|
|
1116
|
+
return code === "EPERM" || code === "EACCES" || code === "EBUSY";
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Write one JSON envelope atomically, retrying a rename that a held handle refused.
|
|
1120
|
+
*
|
|
1121
|
+
* @param file - the file to replace.
|
|
1122
|
+
* @param value - the value to serialise into it.
|
|
1123
|
+
* @param options - the fs verbs and the retry policy, for tests.
|
|
1124
|
+
* @throws the rename's own error when the attempts run out, or at once when it is not a held handle.
|
|
1125
|
+
*/
|
|
1126
|
+
async function writeJsonAtomic(file, value, options = {}) {
|
|
1127
|
+
const io = {
|
|
1128
|
+
mkdir,
|
|
1129
|
+
writeFile,
|
|
1130
|
+
rename,
|
|
1131
|
+
remove: (staged) => rm(staged, { force: true }),
|
|
1132
|
+
...options.fs
|
|
1133
|
+
};
|
|
1134
|
+
const retry = options.retry ?? ATOMIC_WRITE_RETRY;
|
|
1135
|
+
await io.mkdir(dirname(file), { recursive: true });
|
|
1136
|
+
const staged = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
1137
|
+
await io.writeFile(staged, JSON.stringify(value), "utf8");
|
|
1138
|
+
for (let attempt = 1;; attempt++) try {
|
|
1139
|
+
await io.rename(staged, file);
|
|
1140
|
+
return;
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
if (attempt >= retry.attempts || !isHeldOpen(error)) {
|
|
1143
|
+
await io.remove(staged).catch(() => {});
|
|
1144
|
+
throw error;
|
|
1145
|
+
}
|
|
1146
|
+
await new Promise((resolve) => setTimeout(resolve, retry.delayMs * 2 ** (attempt - 1)));
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
//#endregion
|
|
1032
1150
|
//#region lib/types/persist.js
|
|
1033
1151
|
/**
|
|
1034
1152
|
* Durable pending-entry persistence under the harness home: one global JSON
|
|
@@ -1089,7 +1207,7 @@ function globalFileOf(file, value) {
|
|
|
1089
1207
|
};
|
|
1090
1208
|
}
|
|
1091
1209
|
/** Read one file's content; `undefined` when absent (the normal empty state). */
|
|
1092
|
-
async function readJson(file) {
|
|
1210
|
+
async function readJson$1(file) {
|
|
1093
1211
|
let raw;
|
|
1094
1212
|
try {
|
|
1095
1213
|
raw = await readFile(file, "utf8");
|
|
@@ -1103,33 +1221,6 @@ async function readJson(file) {
|
|
|
1103
1221
|
throw new Error(`pending persistence file '${file}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
1104
1222
|
}
|
|
1105
1223
|
}
|
|
1106
|
-
/** Stage a JSON envelope as a sibling temp file and atomically rename it into place. */
|
|
1107
|
-
async function writeJson(file, value) {
|
|
1108
|
-
await mkdir(dirName(file), { recursive: true });
|
|
1109
|
-
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
1110
|
-
await writeFile(tmp, JSON.stringify(value), "utf8");
|
|
1111
|
-
try {
|
|
1112
|
-
await rename(tmp, file);
|
|
1113
|
-
} catch (error) {
|
|
1114
|
-
await rm(tmp, { force: true }).catch(() => {});
|
|
1115
|
-
throw error;
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
/**
|
|
1119
|
-
* The directory a path names, in the platform's own spelling.
|
|
1120
|
-
*
|
|
1121
|
-
* `node:path` rather than a scan for the last `/`: on Windows the storage root is
|
|
1122
|
-
* `C:\Users\<user>\.dsh\diff-approval\workspaces`, which contains no forward slash at all — the
|
|
1123
|
-
* scan answered `.`, the `mkdir` before every write became a no-op, and the write itself threw
|
|
1124
|
-
* ENOENT, so no pending change was ever persisted there (the failure only reached a logger
|
|
1125
|
-
* warning). Both separators have to be understood, and only the platform knows which they are.
|
|
1126
|
-
*
|
|
1127
|
-
* @param file - the file whose directory is wanted.
|
|
1128
|
-
* @returns that directory, or `.` for a bare file name.
|
|
1129
|
-
*/
|
|
1130
|
-
function dirName(file) {
|
|
1131
|
-
return dirname(file);
|
|
1132
|
-
}
|
|
1133
1224
|
/**
|
|
1134
1225
|
* File-backed pending entries, one global file keyed by path.
|
|
1135
1226
|
*/
|
|
@@ -1153,7 +1244,7 @@ var PendingPersistence = class {
|
|
|
1153
1244
|
* a save to finalize the migration).
|
|
1154
1245
|
*/
|
|
1155
1246
|
async loadAll() {
|
|
1156
|
-
const global = await readJson(this.globalFile);
|
|
1247
|
+
const global = await readJson$1(this.globalFile);
|
|
1157
1248
|
if (global !== void 0) {
|
|
1158
1249
|
const parsed = globalFileOf(this.globalFile, global);
|
|
1159
1250
|
const entries = [];
|
|
@@ -1185,7 +1276,7 @@ var PendingPersistence = class {
|
|
|
1185
1276
|
*/
|
|
1186
1277
|
save(entries) {
|
|
1187
1278
|
const task = async () => {
|
|
1188
|
-
await
|
|
1279
|
+
await writeJsonAtomic(this.globalFile, {
|
|
1189
1280
|
version: FILE_VERSION,
|
|
1190
1281
|
entries
|
|
1191
1282
|
});
|
|
@@ -1208,7 +1299,7 @@ async function collectLegacy(root) {
|
|
|
1208
1299
|
const entries = [];
|
|
1209
1300
|
for (const name of names) {
|
|
1210
1301
|
if (name === "pending.json" || !name.endsWith(".json")) continue;
|
|
1211
|
-
const value = await readJson(join(root, name));
|
|
1302
|
+
const value = await readJson$1(join(root, name));
|
|
1212
1303
|
if (value === void 0) continue;
|
|
1213
1304
|
if (typeof value !== "object" || value === null) continue;
|
|
1214
1305
|
const { version, sessions } = value;
|
|
@@ -1239,6 +1330,1172 @@ async function removeLegacy(root) {
|
|
|
1239
1330
|
}
|
|
1240
1331
|
}
|
|
1241
1332
|
//#endregion
|
|
1333
|
+
//#region lib/types/comments.js
|
|
1334
|
+
/**
|
|
1335
|
+
* Durable comment threads, one JSON file per session under
|
|
1336
|
+
* `<commentsDir>/<sessionId>.json`, beside the pending-entry storage but never in
|
|
1337
|
+
* the same file: a comment that outlived its entry is exactly the leak this
|
|
1338
|
+
* module exists to prevent, so a pending write that fails must not be able to
|
|
1339
|
+
* take comments with it (or the other way round).
|
|
1340
|
+
*
|
|
1341
|
+
* A comment belongs to ONE pending entry (`entryId`, which is the entry's path —
|
|
1342
|
+
* the store's global identity) and to ONE session. It carries the annotation and
|
|
1343
|
+
* the identity of the turn that answered it (`messageId`, `turn`); it does NOT
|
|
1344
|
+
* carry the answer's text, because the session transcript stays the single source
|
|
1345
|
+
* of truth for what the agent said — a client reads the answer out of the
|
|
1346
|
+
* transcript by that id. Saves rewrite the whole session file, staged as a sibling
|
|
1347
|
+
* temp file and atomically renamed, so a crash leaves either the old or the new
|
|
1348
|
+
* file. A missing file reads as empty; unknown versions and non-JSON content throw.
|
|
1349
|
+
* @module dsh-diff-approval/src/comments
|
|
1350
|
+
*/
|
|
1351
|
+
/** On-disk envelope of one session's comments. */
|
|
1352
|
+
const COMMENT_FILE_VERSION = 1;
|
|
1353
|
+
/**
|
|
1354
|
+
* The comment directory that belongs to a pending-storage root.
|
|
1355
|
+
*
|
|
1356
|
+
* The plugin's own state directory is `<dshHome>/diff-approval`, and inside it the
|
|
1357
|
+
* pending file lives in `workspaces/` and comments in `comments/` — siblings, so a
|
|
1358
|
+
* write that fails on one side cannot take the other with it. Any OTHER root is a
|
|
1359
|
+
* self-contained state directory (a deployment's chosen location, a test's temp
|
|
1360
|
+
* dir) and keeps its comments INSIDE it: the sibling of an arbitrary directory can
|
|
1361
|
+
* be shared by two instances that were each pointed somewhere different, and two
|
|
1362
|
+
* instances writing one comment file is a leak rather than a layout choice.
|
|
1363
|
+
*
|
|
1364
|
+
* @param storageRoot - the resolved pending-storage root.
|
|
1365
|
+
* @returns the directory holding the comments that belong to that root.
|
|
1366
|
+
*/
|
|
1367
|
+
function commentsDirFor(storageRoot) {
|
|
1368
|
+
return basename(storageRoot) === "workspaces" ? join(dirname(storageRoot), "comments") : join(storageRoot, "comments");
|
|
1369
|
+
}
|
|
1370
|
+
/** The file one session's comments are stored in (the id, made filename-safe). */
|
|
1371
|
+
function fileOf(root, sessionId) {
|
|
1372
|
+
return join(root, `${encodeURIComponent(String(sessionId))}.json`);
|
|
1373
|
+
}
|
|
1374
|
+
/** Narrow one JSON value to a comment record; malformed rows are skipped. */
|
|
1375
|
+
function commentOf(value) {
|
|
1376
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
1377
|
+
const row = value;
|
|
1378
|
+
const { id, sessionId, entryId, path, anchor, quote, text, createdAt, updatedAt } = row;
|
|
1379
|
+
if (typeof id !== "string" || id.length === 0) return void 0;
|
|
1380
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return void 0;
|
|
1381
|
+
if (typeof entryId !== "string" || entryId.length === 0) return void 0;
|
|
1382
|
+
if (typeof path !== "string" || path.length === 0) return void 0;
|
|
1383
|
+
if (typeof anchor !== "object" || anchor === null) return void 0;
|
|
1384
|
+
const lines = anchor;
|
|
1385
|
+
if (typeof lines.startLine !== "number" || typeof lines.endLine !== "number") return void 0;
|
|
1386
|
+
if (typeof quote !== "string") return void 0;
|
|
1387
|
+
if (typeof text !== "string") return void 0;
|
|
1388
|
+
if (typeof createdAt !== "number" || typeof updatedAt !== "number") return void 0;
|
|
1389
|
+
const context = row.quoteContext;
|
|
1390
|
+
const quoteLines = Array.isArray(row.quoteLines) ? row.quoteLines.filter((entry) => typeof entry === "object" && entry !== null) : [];
|
|
1391
|
+
const author = row.author === "agent" ? "agent" : void 0;
|
|
1392
|
+
return {
|
|
1393
|
+
id,
|
|
1394
|
+
sessionId,
|
|
1395
|
+
entryId,
|
|
1396
|
+
path,
|
|
1397
|
+
anchor: {
|
|
1398
|
+
startLine: lines.startLine,
|
|
1399
|
+
endLine: lines.endLine
|
|
1400
|
+
},
|
|
1401
|
+
quote,
|
|
1402
|
+
text,
|
|
1403
|
+
createdAt,
|
|
1404
|
+
updatedAt,
|
|
1405
|
+
...author === void 0 ? {} : { author },
|
|
1406
|
+
...typeof context === "string" && context !== "" ? { quoteContext: context } : {},
|
|
1407
|
+
...quoteLines.length > 0 ? { quoteLines } : {},
|
|
1408
|
+
...asksOf(row.asks).length > 0 ? { asks: asksOf(row.asks) } : {}
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
/** Narrow a stored thread's questions; malformed rows are skipped. */
|
|
1412
|
+
function asksOf(value) {
|
|
1413
|
+
if (!Array.isArray(value)) return [];
|
|
1414
|
+
const asks = [];
|
|
1415
|
+
for (const row of value) {
|
|
1416
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) continue;
|
|
1417
|
+
const record = row;
|
|
1418
|
+
const requestId = record.requestId;
|
|
1419
|
+
if (typeof requestId !== "string" || requestId.length === 0) continue;
|
|
1420
|
+
const turn = record.turn;
|
|
1421
|
+
const text = record.text;
|
|
1422
|
+
asks.push({
|
|
1423
|
+
requestId,
|
|
1424
|
+
...typeof text === "string" && text !== "" ? { text } : {},
|
|
1425
|
+
...typeof turn === "number" ? { turn } : {},
|
|
1426
|
+
...record.dropped === true ? { dropped: true } : {},
|
|
1427
|
+
...record.ended === true ? { ended: true } : {}
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
return asks;
|
|
1431
|
+
}
|
|
1432
|
+
/** Whether one thread holds a question with this request id that `wanted` still describes. */
|
|
1433
|
+
function hasAsk(comment, requestId, wanted) {
|
|
1434
|
+
return (comment.asks ?? []).some((ask) => ask.requestId === requestId && wanted(ask));
|
|
1435
|
+
}
|
|
1436
|
+
/** Read one file's JSON; `undefined` when absent (the normal empty state). */
|
|
1437
|
+
async function readJson(file) {
|
|
1438
|
+
let raw;
|
|
1439
|
+
try {
|
|
1440
|
+
raw = await readFile(file, "utf8");
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
if (error.code === "ENOENT") return void 0;
|
|
1443
|
+
throw error;
|
|
1444
|
+
}
|
|
1445
|
+
try {
|
|
1446
|
+
return JSON.parse(raw);
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
throw new Error(`comment file '${file}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
/** Validate a parsed comment file. */
|
|
1452
|
+
function commentFileOf(file, value) {
|
|
1453
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`comment file '${file}' is not a comment file`);
|
|
1454
|
+
const { version, comments } = value;
|
|
1455
|
+
if (version !== COMMENT_FILE_VERSION) throw new Error(`comment file '${file}' has unsupported version ${JSON.stringify(version)}`);
|
|
1456
|
+
if (!Array.isArray(comments)) throw new Error(`comment file '${file}' has no comment list`);
|
|
1457
|
+
return {
|
|
1458
|
+
version,
|
|
1459
|
+
comments
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
/** One thrown value's human text, whatever shape it arrived in. */
|
|
1463
|
+
function reasonOf(error) {
|
|
1464
|
+
return error instanceof Error ? error.message : String(error);
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* File-backed comment threads, one file per session.
|
|
1468
|
+
*
|
|
1469
|
+
* Every mutation is applied to memory synchronously and schedules that session's
|
|
1470
|
+
* file write on its own chain, so a caller removing an entry can drop its comments
|
|
1471
|
+
* in the same tick as the entry itself without awaiting anything — the same
|
|
1472
|
+
* fire-and-forget persistence the pending store uses.
|
|
1473
|
+
*/
|
|
1474
|
+
var CommentStore = class {
|
|
1475
|
+
root;
|
|
1476
|
+
options;
|
|
1477
|
+
byId = /* @__PURE__ */ new Map();
|
|
1478
|
+
tails = /* @__PURE__ */ new Map();
|
|
1479
|
+
revision = 0;
|
|
1480
|
+
/** Files `loadAll` could not read, in the order it met them (see `skippedFiles`). */
|
|
1481
|
+
skipped = /* @__PURE__ */ new Set();
|
|
1482
|
+
/**
|
|
1483
|
+
* The save failure of every session that has one, keyed by that session's file and in the
|
|
1484
|
+
* order the failures happened (see `persistError`). Empty while every write works.
|
|
1485
|
+
*/
|
|
1486
|
+
persistFailures = /* @__PURE__ */ new Map();
|
|
1487
|
+
/**
|
|
1488
|
+
* Sessions whose unreadable file could not even be moved aside, keyed by that file: the
|
|
1489
|
+
* reason their writes are refused rather than allowed to truncate the bytes (see `save`).
|
|
1490
|
+
*/
|
|
1491
|
+
unmovable = /* @__PURE__ */ new Map();
|
|
1492
|
+
/**
|
|
1493
|
+
* @param root - directory holding one `<sessionId>.json` per session.
|
|
1494
|
+
* @param options - the error seam (see {@link CommentStoreOptions}).
|
|
1495
|
+
*/
|
|
1496
|
+
constructor(root, options = {}) {
|
|
1497
|
+
this.root = root;
|
|
1498
|
+
this.options = options;
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Load every session's comments. A malformed row is skipped rather than
|
|
1502
|
+
* failing the load: one bad comment must not cost the whole workspace's
|
|
1503
|
+
* comments (or the plugin's activation).
|
|
1504
|
+
*
|
|
1505
|
+
* A file that cannot be READ is skipped the same way, because the alternative is
|
|
1506
|
+
* worse in both directions: the old all-or-nothing load threw, the caller logged
|
|
1507
|
+
* once and carried on with an EMPTY store, and the next save then wrote that
|
|
1508
|
+
* emptiness back over every good file. One unreadable file therefore cost every
|
|
1509
|
+
* session's comments. Skipping keeps the other sessions' threads, and the skipped
|
|
1510
|
+
* file is moved aside (`.corrupt`) rather than dropped: it is not deleted — the
|
|
1511
|
+
* bytes may be the only copy — but it is also not left where the next save for
|
|
1512
|
+
* that session would silently overwrite it, which would destroy them for good.
|
|
1513
|
+
*
|
|
1514
|
+
* When even that fails — the file cannot be moved aside — that session's writes are
|
|
1515
|
+
* refused from then on (see `save`) rather than allowed to truncate the only copy.
|
|
1516
|
+
* "Skipped but still writable" is exactly the shape that turns a read failure into
|
|
1517
|
+
* data loss: the store would answer "added", write the memory-only view over bytes
|
|
1518
|
+
* nobody could read, and lose what the skip had just refused to guess at.
|
|
1519
|
+
* @returns how many comments were loaded.
|
|
1520
|
+
*/
|
|
1521
|
+
async loadAll() {
|
|
1522
|
+
let names;
|
|
1523
|
+
try {
|
|
1524
|
+
names = await readdir(this.root);
|
|
1525
|
+
} catch (error) {
|
|
1526
|
+
if (error.code === "ENOENT") return 0;
|
|
1527
|
+
throw error;
|
|
1528
|
+
}
|
|
1529
|
+
for (const name of names) {
|
|
1530
|
+
if (!name.endsWith(".json") || name.endsWith(".tmp")) continue;
|
|
1531
|
+
const file = join(this.root, name);
|
|
1532
|
+
let value;
|
|
1533
|
+
let comments;
|
|
1534
|
+
try {
|
|
1535
|
+
value = await readJson(file);
|
|
1536
|
+
if (value === void 0) continue;
|
|
1537
|
+
comments = commentFileOf(file, value).comments;
|
|
1538
|
+
} catch (error) {
|
|
1539
|
+
await this.skip(file, error);
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
for (const row of comments) {
|
|
1543
|
+
const comment = commentOf(row);
|
|
1544
|
+
if (comment !== void 0) this.byId.set(comment.id, comment);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
this.revision += 1;
|
|
1548
|
+
return this.byId.size;
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* The files a load skipped, so the caller can say what went missing instead of
|
|
1552
|
+
* reporting a session whose comments silently vanished.
|
|
1553
|
+
* @returns the skipped files' paths, in load order.
|
|
1554
|
+
*/
|
|
1555
|
+
skippedFiles() {
|
|
1556
|
+
return [...this.skipped];
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* The failure the reader has to be told about, or `undefined` while every write works.
|
|
1560
|
+
* This is what rides the list read so the panel can say the threads are memory-only;
|
|
1561
|
+
* a later successful save retracts it, mirroring the pending half's own report.
|
|
1562
|
+
*
|
|
1563
|
+
* Per SESSION, not one global slot: a deployment where one session's file cannot be
|
|
1564
|
+
* written and another's can is the ordinary case (a locked file, a full quota, a
|
|
1565
|
+
* directory that vanished), and letting the healthy write retract the broken one's
|
|
1566
|
+
* report would say the threads are safe while that session's live in memory only —
|
|
1567
|
+
* the same "a failure that looks like a success" trap, one session over. A session's
|
|
1568
|
+
* own success is what retracts its own entry (see `reportPersist`).
|
|
1569
|
+
* @returns the most recent outstanding failure's message, or undefined.
|
|
1570
|
+
*/
|
|
1571
|
+
persistError() {
|
|
1572
|
+
let message;
|
|
1573
|
+
for (const failure of this.persistFailures.values()) message = failure;
|
|
1574
|
+
return message;
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Record a skipped file: keep it for the caller, name it, and move it aside so a later
|
|
1578
|
+
* save for that session writes a fresh file rather than overwriting the unreadable
|
|
1579
|
+
* bytes. A quarantine that itself fails is still a skip — the store must not be the
|
|
1580
|
+
* reason a single bad file takes the whole load down — but that session's file is then
|
|
1581
|
+
* the only copy of its threads, so its writes are refused (see `save`) instead of being
|
|
1582
|
+
* allowed to truncate it.
|
|
1583
|
+
* @param file - the file that could not be read.
|
|
1584
|
+
* @param error - why it could not be read.
|
|
1585
|
+
*/
|
|
1586
|
+
async skip(file, error) {
|
|
1587
|
+
this.skipped.add(file);
|
|
1588
|
+
const quarantined = `${file}.corrupt`;
|
|
1589
|
+
try {
|
|
1590
|
+
await rename(file, quarantined);
|
|
1591
|
+
} catch (failure) {
|
|
1592
|
+
this.unmovable.set(file, `could not be read at load (${reasonOf(error)}) and could not be moved aside to '${quarantined}' (${reasonOf(failure)})`);
|
|
1593
|
+
}
|
|
1594
|
+
this.options.onLoadSkipped?.(file, error);
|
|
1595
|
+
}
|
|
1596
|
+
/** One session's comments, oldest first (the order threads are read in). */
|
|
1597
|
+
list(sessionId) {
|
|
1598
|
+
const listed = [];
|
|
1599
|
+
for (const comment of this.byId.values()) if (comment.sessionId === sessionId) listed.push(comment);
|
|
1600
|
+
return listed.sort((left, right) => left.createdAt - right.createdAt);
|
|
1601
|
+
}
|
|
1602
|
+
/** A comment by id, whatever session it belongs to. */
|
|
1603
|
+
get(id) {
|
|
1604
|
+
return this.byId.get(id);
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* A counter bumped on every change, so a poller can tell "nothing new" from
|
|
1608
|
+
* "re-read": the list endpoint ships the session's comments anyway, and this is
|
|
1609
|
+
* what says whether anything moved since the last read.
|
|
1610
|
+
*/
|
|
1611
|
+
commentsRevision() {
|
|
1612
|
+
return this.revision;
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Record one annotation.
|
|
1616
|
+
*
|
|
1617
|
+
* Writing a comment that is already here is the SAME comment written again — a client
|
|
1618
|
+
* retrying a request whose response was dropped, or a second client sending the id it
|
|
1619
|
+
* was handed — so the questions already asked in it are kept and only the annotation's
|
|
1620
|
+
* own fields move. Overwriting the whole record would erase the identity of a question
|
|
1621
|
+
* that is in flight, and its answer could then never be matched back to this thread;
|
|
1622
|
+
* `createdAt` is kept for the same reason, since it is what a list read orders by.
|
|
1623
|
+
*
|
|
1624
|
+
* @param record - the comment to store.
|
|
1625
|
+
* @returns the stored comment, which is the existing one when the id was already here.
|
|
1626
|
+
*/
|
|
1627
|
+
add(record) {
|
|
1628
|
+
const stored = this.mergeOf(record);
|
|
1629
|
+
this.byId.set(record.id, stored);
|
|
1630
|
+
this.revision += 1;
|
|
1631
|
+
this.save(record.sessionId);
|
|
1632
|
+
return stored;
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Record several annotations at once, as ONE write per affected session.
|
|
1636
|
+
*
|
|
1637
|
+
* This is what putting a batch of restored comments back needs and what a loop of `add` calls
|
|
1638
|
+
* cannot give it: each `add` saves its session's file, so an undo that restored ten threads wrote
|
|
1639
|
+
* that file ten times — ten chances for a transient failure to leave the store and the disk
|
|
1640
|
+
* disagreeing about an action the reader already watched happen. The records are stored by the SAME
|
|
1641
|
+
* rule as `add` (a new id verbatim, an existing one merged with its `createdAt` and its questions
|
|
1642
|
+
* kept), which is what lets a restore be an ordinary write rather than a special case: a snapshot
|
|
1643
|
+
* that came back as a new comment, with a fresh id and no questions, would be a look-alike of the
|
|
1644
|
+
* reader's thread.
|
|
1645
|
+
*
|
|
1646
|
+
* A batch that carries nothing changes nothing, so a no-op restore does not rewrite a file.
|
|
1647
|
+
*
|
|
1648
|
+
* @param records - the comments to store, in the caller's order.
|
|
1649
|
+
* @returns the stored record for each input, index-aligned: what landed, which is not always what
|
|
1650
|
+
* was handed over (a merge keeps the fields the existing comment owns). A count would drop that,
|
|
1651
|
+
* and a caller needing it would have to `get` each id back and race a later change.
|
|
1652
|
+
*/
|
|
1653
|
+
addMany(records) {
|
|
1654
|
+
const stored = [];
|
|
1655
|
+
const sessions = /* @__PURE__ */ new Set();
|
|
1656
|
+
for (const record of records) {
|
|
1657
|
+
const merged = this.mergeOf(record);
|
|
1658
|
+
this.byId.set(record.id, merged);
|
|
1659
|
+
stored.push(merged);
|
|
1660
|
+
sessions.add(record.sessionId);
|
|
1661
|
+
}
|
|
1662
|
+
if (records.length === 0) return stored;
|
|
1663
|
+
this.revision += 1;
|
|
1664
|
+
for (const sessionId of sessions) this.save(sessionId);
|
|
1665
|
+
return stored;
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* The record as the store keeps it: a new id VERBATIM, an id already here merged.
|
|
1669
|
+
*
|
|
1670
|
+
* The one place the merge rule lives, so `add` and `addMany` cannot drift: writing a comment that
|
|
1671
|
+
* is already here is the same comment written again — a client retrying a request whose response
|
|
1672
|
+
* was dropped, or a second client sending the id it was handed — so the questions already asked in
|
|
1673
|
+
* it are kept and only the annotation's own fields move. Overwriting the whole record would erase
|
|
1674
|
+
* the identity of a question that is in flight, and its answer could then never be matched back to
|
|
1675
|
+
* this thread; `createdAt` is kept for the same reason, since it is what a list read orders by.
|
|
1676
|
+
* @param record - the comment as the caller wrote it.
|
|
1677
|
+
* @returns the record to store under its id.
|
|
1678
|
+
*/
|
|
1679
|
+
mergeOf(record) {
|
|
1680
|
+
const before = this.byId.get(record.id);
|
|
1681
|
+
return before === void 0 ? record : {
|
|
1682
|
+
...record,
|
|
1683
|
+
createdAt: before.createdAt,
|
|
1684
|
+
...before.asks === void 0 ? {} : { asks: before.asks }
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Drop one comment.
|
|
1689
|
+
* @param sessionId - the session the caller believes the comment belongs to.
|
|
1690
|
+
* @param id - the comment to drop.
|
|
1691
|
+
* @returns whether it was there (and belonged to that session).
|
|
1692
|
+
*/
|
|
1693
|
+
remove(sessionId, id) {
|
|
1694
|
+
return this.removeMany(sessionId, [id]).length === 1;
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Drop several comments of one session in ONE write.
|
|
1698
|
+
*
|
|
1699
|
+
* This is what a batch action needs and what a loop of `remove` calls cannot give it: each
|
|
1700
|
+
* `remove` saves the session's file, so ending ten comments wrote that file ten times — ten
|
|
1701
|
+
* chances for a transient failure to leave the store and the disk disagreeing about an action
|
|
1702
|
+
* the reader asked for once. The save happens only when something was actually dropped, so a
|
|
1703
|
+
* batch that matches nothing does not rewrite the file.
|
|
1704
|
+
*
|
|
1705
|
+
* An id of another session, or one that is not here at all, is skipped rather than refused:
|
|
1706
|
+
* a batch is one request and the comments it names are the ones it can act on, and a comment
|
|
1707
|
+
* another client already ended is the state the caller was asking for anyway.
|
|
1708
|
+
*
|
|
1709
|
+
* @param sessionId - the session the caller believes the comments belong to.
|
|
1710
|
+
* @param ids - the comments to drop; a repeated id is one comment.
|
|
1711
|
+
* @returns the ids that were dropped, in the order given.
|
|
1712
|
+
*/
|
|
1713
|
+
removeMany(sessionId, ids) {
|
|
1714
|
+
const removed = [];
|
|
1715
|
+
for (const id of ids) {
|
|
1716
|
+
if (removed.includes(id)) continue;
|
|
1717
|
+
const comment = this.byId.get(id);
|
|
1718
|
+
if (comment === void 0 || comment.sessionId !== sessionId) continue;
|
|
1719
|
+
this.byId.delete(id);
|
|
1720
|
+
removed.push(id);
|
|
1721
|
+
}
|
|
1722
|
+
if (removed.length === 0) return removed;
|
|
1723
|
+
this.revision += 1;
|
|
1724
|
+
this.save(sessionId);
|
|
1725
|
+
return removed;
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* Every comment one pending entry carries, oldest first.
|
|
1729
|
+
*
|
|
1730
|
+
* The read half of `removeForEntry` and kept beside it, because they are two answers about the same
|
|
1731
|
+
* fact: which comments an entry owns. A caller that is about to drop the entry asks this first, so
|
|
1732
|
+
* the undo pair can carry them back; the two must agree on that set, or a restore would put back
|
|
1733
|
+
* fewer (or more) threads than the drop took.
|
|
1734
|
+
* @param entryId - the entry id (= path).
|
|
1735
|
+
* @returns that entry's comments, in list order.
|
|
1736
|
+
*/
|
|
1737
|
+
forEntry(entryId) {
|
|
1738
|
+
const listed = [];
|
|
1739
|
+
for (const comment of this.byId.values()) if (comment.entryId === entryId) listed.push(comment);
|
|
1740
|
+
return listed.sort((left, right) => left.createdAt - right.createdAt);
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Drop every comment of one pending entry. This is what "a comment dies with
|
|
1744
|
+
* its entry" means, and the entry knows nothing about comments, so the caller
|
|
1745
|
+
* runs it beside the entry's own removal.
|
|
1746
|
+
* @param entryId - the entry id (= path) that left the list.
|
|
1747
|
+
* @returns how many comments went with it.
|
|
1748
|
+
*/
|
|
1749
|
+
removeForEntry(entryId) {
|
|
1750
|
+
const sessions = /* @__PURE__ */ new Set();
|
|
1751
|
+
let removed = 0;
|
|
1752
|
+
for (const comment of [...this.byId.values()]) {
|
|
1753
|
+
if (comment.entryId !== entryId) continue;
|
|
1754
|
+
this.byId.delete(comment.id);
|
|
1755
|
+
sessions.add(comment.sessionId);
|
|
1756
|
+
removed += 1;
|
|
1757
|
+
}
|
|
1758
|
+
if (removed > 0) {
|
|
1759
|
+
this.revision += 1;
|
|
1760
|
+
for (const sessionId of sessions) this.save(sessionId);
|
|
1761
|
+
}
|
|
1762
|
+
return removed;
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* The orphan sweep: drop every comment whose entry is not in `entryIds`.
|
|
1766
|
+
*
|
|
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
|
+
* @param entryIds - every entry id the pending store currently holds.
|
|
1775
|
+
* @returns how many orphaned comments were dropped.
|
|
1776
|
+
*/
|
|
1777
|
+
retain(entryIds) {
|
|
1778
|
+
const sessions = /* @__PURE__ */ new Set();
|
|
1779
|
+
let removed = 0;
|
|
1780
|
+
for (const comment of [...this.byId.values()]) {
|
|
1781
|
+
if (entryIds.has(comment.entryId)) continue;
|
|
1782
|
+
this.byId.delete(comment.id);
|
|
1783
|
+
sessions.add(comment.sessionId);
|
|
1784
|
+
removed += 1;
|
|
1785
|
+
}
|
|
1786
|
+
if (removed > 0) {
|
|
1787
|
+
this.revision += 1;
|
|
1788
|
+
for (const sessionId of sessions) this.save(sessionId);
|
|
1789
|
+
}
|
|
1790
|
+
return removed;
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* Record one question asked in a thread. The answer itself is never stored, and the
|
|
1794
|
+
* request id is minted by the caller before it submits, so this runs as part of the
|
|
1795
|
+
* ask rather than waiting for the session to answer. A thread keeps its questions in
|
|
1796
|
+
* order: the first is the annotation's own, and each follow-up appends.
|
|
1797
|
+
*
|
|
1798
|
+
* The reader's own words are stored WITH the question (`text`), because they exist
|
|
1799
|
+
* nowhere else: the transcript holds the prompt, which is those words wrapped in the
|
|
1800
|
+
* marker, the reference and the rules, and a thread that showed the prompt would show
|
|
1801
|
+
* the scaffolding rather than the question.
|
|
1802
|
+
*
|
|
1803
|
+
* @param sessionId - the session the comment belongs to.
|
|
1804
|
+
* @param id - the comment that was asked in.
|
|
1805
|
+
* @param requestId - the identity the submission carries into the transcript.
|
|
1806
|
+
* @param turn - the turn that claimed it, when that is already known.
|
|
1807
|
+
* @param text - the reader's own words, without the prompt's wrapper.
|
|
1808
|
+
* @returns whether the comment existed.
|
|
1809
|
+
*/
|
|
1810
|
+
recordAsk(sessionId, id, requestId, turn, text) {
|
|
1811
|
+
return this.patch((comment) => comment.sessionId === sessionId && comment.id === id, (comment) => ({
|
|
1812
|
+
...comment,
|
|
1813
|
+
asks: [...comment.asks ?? [], {
|
|
1814
|
+
requestId,
|
|
1815
|
+
text,
|
|
1816
|
+
...turn === void 0 ? {} : { turn }
|
|
1817
|
+
}],
|
|
1818
|
+
updatedAt: Date.now()
|
|
1819
|
+
})) > 0;
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Record the turn that claimed one question, as the agent's inbox reported it. Two
|
|
1823
|
+
* questions claimed by the same turn were answered together, which is a fact the
|
|
1824
|
+
* panel can state instead of guessing from transcript positions.
|
|
1825
|
+
* @param sessionId - the session the submission belongs to.
|
|
1826
|
+
* @param requestId - the request identity the inbox reported back.
|
|
1827
|
+
* @param turn - the turn that claimed it.
|
|
1828
|
+
* @returns how many threads the patch touched.
|
|
1829
|
+
*/
|
|
1830
|
+
recordTurnForRequest(sessionId, requestId, turn) {
|
|
1831
|
+
return this.patch((comment) => comment.sessionId === sessionId && hasAsk(comment, requestId, (ask) => ask.turn !== turn), (comment) => ({
|
|
1832
|
+
...comment,
|
|
1833
|
+
asks: (comment.asks ?? []).map((ask) => ask.requestId === requestId ? {
|
|
1834
|
+
...ask,
|
|
1835
|
+
turn
|
|
1836
|
+
} : ask),
|
|
1837
|
+
updatedAt: Date.now()
|
|
1838
|
+
}));
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Record that the session dropped one question before a turn claimed it: nothing is
|
|
1842
|
+
* coming, and the reader is told rather than left waiting.
|
|
1843
|
+
* @param sessionId - the session the submission belongs to.
|
|
1844
|
+
* @param requestId - the request identity the inbox reported back.
|
|
1845
|
+
* @returns how many threads the patch touched.
|
|
1846
|
+
*/
|
|
1847
|
+
markDroppedForRequest(sessionId, requestId) {
|
|
1848
|
+
return this.patch((comment) => comment.sessionId === sessionId && hasAsk(comment, requestId, (ask) => ask.dropped !== true), (comment) => ({
|
|
1849
|
+
...comment,
|
|
1850
|
+
asks: (comment.asks ?? []).map((ask) => ask.requestId === requestId ? {
|
|
1851
|
+
...ask,
|
|
1852
|
+
dropped: true
|
|
1853
|
+
} : ask),
|
|
1854
|
+
updatedAt: Date.now()
|
|
1855
|
+
}));
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Record that a turn is over, so the questions it claimed stop looking pending.
|
|
1859
|
+
*
|
|
1860
|
+
* The turn number is the only handle the stopping event carries — no message, no
|
|
1861
|
+
* request id — so the questions that were claimed by that turn are the ones patched
|
|
1862
|
+
* (see `recordTurnForRequest`, which wrote the number down when the inbox claimed
|
|
1863
|
+
* them). A question whose turn is over and that the transcript shows no answer for
|
|
1864
|
+
* was cut off; the client says so instead of waiting forever. Nothing here decides
|
|
1865
|
+
* that: whether an answer exists is read from the transcript on each list read, so a
|
|
1866
|
+
* turn that stopped AFTER writing its answer keeps showing the answer.
|
|
1867
|
+
*
|
|
1868
|
+
* @param sessionId - the session whose turn ended.
|
|
1869
|
+
* @param turn - the turn that stopped.
|
|
1870
|
+
* @returns how many threads the patch touched.
|
|
1871
|
+
*/
|
|
1872
|
+
markTurnEnded(sessionId, turn) {
|
|
1873
|
+
return this.patch((comment) => comment.sessionId === sessionId && (comment.asks ?? []).some((ask) => ask.turn === turn && ask.ended !== true), (comment) => ({
|
|
1874
|
+
...comment,
|
|
1875
|
+
asks: (comment.asks ?? []).map((ask) => ask.turn === turn ? {
|
|
1876
|
+
...ask,
|
|
1877
|
+
ended: true
|
|
1878
|
+
} : ask),
|
|
1879
|
+
updatedAt: Date.now()
|
|
1880
|
+
}));
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* Apply one patch to every comment it matches, and schedule the affected sessions'
|
|
1884
|
+
* writes. The one mutation seam, so no caller has to remember to bump the revision
|
|
1885
|
+
* or to save: a patch that matches nothing changes nothing.
|
|
1886
|
+
* @param match - which comments to patch.
|
|
1887
|
+
* @param patch - the change to apply.
|
|
1888
|
+
* @returns how many comments changed.
|
|
1889
|
+
*/
|
|
1890
|
+
patch(match, patch) {
|
|
1891
|
+
const sessions = /* @__PURE__ */ new Set();
|
|
1892
|
+
let changed = 0;
|
|
1893
|
+
for (const comment of [...this.byId.values()]) {
|
|
1894
|
+
if (!match(comment)) continue;
|
|
1895
|
+
this.byId.set(comment.id, patch(comment));
|
|
1896
|
+
sessions.add(comment.sessionId);
|
|
1897
|
+
changed += 1;
|
|
1898
|
+
}
|
|
1899
|
+
if (changed > 0) {
|
|
1900
|
+
this.revision += 1;
|
|
1901
|
+
for (const sessionId of sessions) this.save(sessionId);
|
|
1902
|
+
}
|
|
1903
|
+
return changed;
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Wait for every scheduled write to settle.
|
|
1907
|
+
*
|
|
1908
|
+
* The mutators return as soon as a change is in memory — that is what lets an
|
|
1909
|
+
* entry's removal take its comments with it in the same tick — so this is how a
|
|
1910
|
+
* caller that must not lose the write (a dispose path, a test asserting the file)
|
|
1911
|
+
* waits for the disk.
|
|
1912
|
+
* @returns resolution after every session file's write chain has drained.
|
|
1913
|
+
*/
|
|
1914
|
+
async settled() {
|
|
1915
|
+
await Promise.all([...this.tails.values()]);
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Write one session's comments, serialized against that file's own writes.
|
|
1919
|
+
*
|
|
1920
|
+
* A rejected write is reported, not swallowed: the mutation already answered
|
|
1921
|
+
* "added" and the panel is drawing the thread from memory, so a silent failure means
|
|
1922
|
+
* the reader keeps writing into a thread that a restart will erase. The report rides
|
|
1923
|
+
* the list read (`persistError`) and the caller's logger, and a later write that works
|
|
1924
|
+
* retracts it the way the pending half retracts its own.
|
|
1925
|
+
*
|
|
1926
|
+
* A session whose file could not be read at load AND could not be moved aside is not
|
|
1927
|
+
* written at all: the only copy of that session's threads is the bytes the load could
|
|
1928
|
+
* not parse, and the store's memory holds just what has been added since. Writing the
|
|
1929
|
+
* memory-only view would TRUNCATE that copy — turning a read failure into a permanent
|
|
1930
|
+
* loss — so the save is refused and reported instead, which is what tells the reader
|
|
1931
|
+
* those comments are memory-only.
|
|
1932
|
+
* @param sessionId - the session whose file to rewrite.
|
|
1933
|
+
*/
|
|
1934
|
+
save(sessionId) {
|
|
1935
|
+
const file = fileOf(this.root, sessionId);
|
|
1936
|
+
const refused = this.unmovable.get(file);
|
|
1937
|
+
if (refused !== void 0) {
|
|
1938
|
+
this.reportPersist(file, `'${file}' ${refused}, so this session's comments are kept in memory rather than written over it`);
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
const comments = this.list(sessionId);
|
|
1942
|
+
const task = async () => {
|
|
1943
|
+
await writeJsonAtomic(file, {
|
|
1944
|
+
version: COMMENT_FILE_VERSION,
|
|
1945
|
+
comments
|
|
1946
|
+
});
|
|
1947
|
+
};
|
|
1948
|
+
const run = (this.tails.get(file) ?? Promise.resolve()).then(task, task);
|
|
1949
|
+
this.tails.set(file, run.then(() => {
|
|
1950
|
+
this.reportPersist(file, void 0);
|
|
1951
|
+
}, (error) => {
|
|
1952
|
+
this.reportPersist(file, `${file}: ${reasonOf(error)}`);
|
|
1953
|
+
}));
|
|
1954
|
+
}
|
|
1955
|
+
/**
|
|
1956
|
+
* Publish the write state of one session's file, once per distinct report. Kept as its own
|
|
1957
|
+
* step so both the success and the failure of the chain above land in one place — the
|
|
1958
|
+
* retraction is what arms a later failure to be reported again.
|
|
1959
|
+
*
|
|
1960
|
+
* Keyed per file, so a healthy session's write cannot retract a broken one's report (see
|
|
1961
|
+
* `persistError`); the callback sees the aggregate, which is what rides the list read.
|
|
1962
|
+
* @param file - the session file the write was for.
|
|
1963
|
+
* @param message - the failure's text, or undefined when the write worked.
|
|
1964
|
+
*/
|
|
1965
|
+
reportPersist(file, message) {
|
|
1966
|
+
const before = this.persistError();
|
|
1967
|
+
this.persistFailures.delete(file);
|
|
1968
|
+
if (message !== void 0) this.persistFailures.set(file, message);
|
|
1969
|
+
const after = this.persistError();
|
|
1970
|
+
if (before === after) return;
|
|
1971
|
+
this.options.onPersistError?.(after);
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
//#endregion
|
|
1975
|
+
//#region lib/types/comment-ask.js
|
|
1976
|
+
/**
|
|
1977
|
+
* Asking the session's agent about one comment, and reading the answer back out of
|
|
1978
|
+
* the transcript.
|
|
1979
|
+
*
|
|
1980
|
+
* The submission goes through the host's own session API (`ctx.sessionController`),
|
|
1981
|
+
* which is the one path that already knows how to admit a prompt: it mints the
|
|
1982
|
+
* message, persists it, and — the reason this module needs no identity of its own —
|
|
1983
|
+
* stores the request id the caller supplies on the exact user message it accepts
|
|
1984
|
+
* (`source.rpcId`). That id is the join key between a comment and its message, and it
|
|
1985
|
+
* makes a retried submission the same submission rather than a second one.
|
|
1986
|
+
*
|
|
1987
|
+
* The answer is never stored: `answers()` derives it from the session's own derived
|
|
1988
|
+
* message history on every read, so the transcript stays the single source of truth
|
|
1989
|
+
* for what the agent said. The agent is asked with `mode: 'queue'`, so a comment is
|
|
1990
|
+
* its own turn ("one ordinary message of its own turn") — two comments asked at the
|
|
1991
|
+
* same time are two turns, not one turn two blocks have to share.
|
|
1992
|
+
*
|
|
1993
|
+
* @module dsh-diff-approval/src/comment-ask
|
|
1994
|
+
*/
|
|
1995
|
+
/** Nothing was found: no message of ours, so no turn and no end. */
|
|
1996
|
+
const NO_READ = {
|
|
1997
|
+
answer: void 0,
|
|
1998
|
+
turn: void 0,
|
|
1999
|
+
ended: false
|
|
2000
|
+
};
|
|
2001
|
+
/** The prompt text a message holds, or '' when it carries none. */
|
|
2002
|
+
function textOf(message) {
|
|
2003
|
+
if (!Array.isArray(message.content)) return "";
|
|
2004
|
+
const parts = [];
|
|
2005
|
+
for (const block of message.content) {
|
|
2006
|
+
if (typeof block !== "object" || block === null) continue;
|
|
2007
|
+
const { type, text } = block;
|
|
2008
|
+
if (type === "text" && typeof text === "string" && text !== "") parts.push(text);
|
|
2009
|
+
}
|
|
2010
|
+
return parts.join("\n").trim();
|
|
2011
|
+
}
|
|
2012
|
+
/** The request id one message was submitted under, when it has one. */
|
|
2013
|
+
function requestOf(message) {
|
|
2014
|
+
const rpcId = message.source?.rpcId;
|
|
2015
|
+
return typeof rpcId === "string" && rpcId !== "" ? rpcId : void 0;
|
|
2016
|
+
}
|
|
2017
|
+
/** The message one `user/message` or `assistant/message` event carries, or undefined. */
|
|
2018
|
+
function messageOf(event) {
|
|
2019
|
+
const data = event.data;
|
|
2020
|
+
if (data === void 0) return void 0;
|
|
2021
|
+
return event.type === "assistant/message" ? data.message : {
|
|
2022
|
+
role: data.role,
|
|
2023
|
+
content: data.content,
|
|
2024
|
+
source: data.source
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
/** The turn number one event attributes itself to, or `undefined` when the type has none. */
|
|
2028
|
+
function turnOf(event) {
|
|
2029
|
+
const turn = event.data !== void 0 && "turn" in event.data ? event.data.turn : void 0;
|
|
2030
|
+
return typeof turn === "number" ? turn : void 0;
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* The answer to one submitted comment, read from the session's own event log.
|
|
2034
|
+
*
|
|
2035
|
+
* Our prompt and the turn that ran because of it are one thing. The submission carries a
|
|
2036
|
+
* request id on its `user/message` (`source.rpcId`), and `turn/start` is the bracket the
|
|
2037
|
+
* log puts around a turn, so the question's own turn IS the window: the assistant
|
|
2038
|
+
* messages of that turn are the answer, and `turn/end` for it is where the question is
|
|
2039
|
+
* over. Nothing between the two belongs to anyone else.
|
|
2040
|
+
*
|
|
2041
|
+
* That is what keeps a later input from stealing the answer. A session log has
|
|
2042
|
+
* user-role messages no human wrote — a subagent report, a background-job notice, an
|
|
2043
|
+
* injected context — and under the old "walk to the next human message, take the last
|
|
2044
|
+
* assistant text" rule any of them let the span run on into the NEXT turn's answer, so a
|
|
2045
|
+
* comment that had been answered was silently replaced by whatever the agent later said
|
|
2046
|
+
* about something else entirely. A tool-result message (role `user` as well) has the
|
|
2047
|
+
* same problem for the opposite reason: it is a boundary trap that would end a segment
|
|
2048
|
+
* mid-turn and make a question with a tool call behind it look unanswered. Bound by the
|
|
2049
|
+
* turn, both read correctly: the tool result is inside it, and the later input is not.
|
|
2050
|
+
*
|
|
2051
|
+
* @param events - the session's event log, in sequence order.
|
|
2052
|
+
* @param requestId - the request id the comment was submitted under.
|
|
2053
|
+
* @returns the claiming turn, its answer text, and whether the turn is over.
|
|
2054
|
+
*/
|
|
2055
|
+
function answerForRequest(events, requestId) {
|
|
2056
|
+
let askedAt = -1;
|
|
2057
|
+
let open;
|
|
2058
|
+
for (let index = 0; index < events.length; index++) {
|
|
2059
|
+
const event = events[index];
|
|
2060
|
+
if (event === void 0 || event.type === void 0) continue;
|
|
2061
|
+
if (event.type === "turn/start") {
|
|
2062
|
+
open = turnOf(event);
|
|
2063
|
+
continue;
|
|
2064
|
+
}
|
|
2065
|
+
if (event.type === "turn/end") {
|
|
2066
|
+
if (open !== void 0 && turnOf(event) === open) open = void 0;
|
|
2067
|
+
continue;
|
|
2068
|
+
}
|
|
2069
|
+
if (event.type !== "user/message") continue;
|
|
2070
|
+
const message = messageOf(event);
|
|
2071
|
+
if (message === void 0 || requestOf(message) !== requestId) continue;
|
|
2072
|
+
askedAt = index;
|
|
2073
|
+
let turn = turnOf(event) ?? open;
|
|
2074
|
+
for (let back = index; turn === void 0 && back >= 0; back--) {
|
|
2075
|
+
const earlier = events[back];
|
|
2076
|
+
if (earlier?.type === "turn/start") turn = turnOf(earlier);
|
|
2077
|
+
}
|
|
2078
|
+
return readTurn(events, askedAt, turn);
|
|
2079
|
+
}
|
|
2080
|
+
return NO_READ;
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* The answer the turn named by one question produced.
|
|
2084
|
+
*
|
|
2085
|
+
* Reads forward from the question: its turn's assistant messages are the answer, and that
|
|
2086
|
+
* turn's own `turn/end` is where the question is over. A question the bracketing above
|
|
2087
|
+
* could not attribute takes its turn from the FIRST message after it that names one — the
|
|
2088
|
+
* log is append-only and a question is followed by its own turn's step, so that is the
|
|
2089
|
+
* question's turn; it is what keeps a window whose `turn/start` is outside it readable.
|
|
2090
|
+
*
|
|
2091
|
+
* @param events - the session's event log, in sequence order.
|
|
2092
|
+
* @param askedAt - index of the question's own `user/message`.
|
|
2093
|
+
* @param turn - the turn the log attributed the question to, or undefined when it could not.
|
|
2094
|
+
* @returns the answer, the turn, and whether the turn is over.
|
|
2095
|
+
*/
|
|
2096
|
+
function readTurn(events, askedAt, turn) {
|
|
2097
|
+
let claimed = turn;
|
|
2098
|
+
let answer;
|
|
2099
|
+
for (let index = askedAt + 1; index < events.length; index++) {
|
|
2100
|
+
const event = events[index];
|
|
2101
|
+
if (event === void 0) continue;
|
|
2102
|
+
const eventTurn = turnOf(event);
|
|
2103
|
+
if (claimed === void 0 && eventTurn !== void 0) claimed = eventTurn;
|
|
2104
|
+
const mine = claimed === void 0 || eventTurn === void 0 || eventTurn === claimed;
|
|
2105
|
+
if (event.type === "turn/end" && mine) return {
|
|
2106
|
+
answer,
|
|
2107
|
+
turn: claimed,
|
|
2108
|
+
ended: true
|
|
2109
|
+
};
|
|
2110
|
+
if (event.type !== "assistant/message" || !mine) continue;
|
|
2111
|
+
const message = messageOf(event);
|
|
2112
|
+
const text = message === void 0 ? "" : textOf(message);
|
|
2113
|
+
if (text !== "") answer = text;
|
|
2114
|
+
}
|
|
2115
|
+
return {
|
|
2116
|
+
answer,
|
|
2117
|
+
turn: claimed,
|
|
2118
|
+
ended: false
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* Read every question one session has asked, keyed by its request id.
|
|
2123
|
+
* @param events - the session's event log, in sequence order.
|
|
2124
|
+
* @param requestIds - the questions' request ids.
|
|
2125
|
+
* @returns the read per request id; a question the log holds nothing for reads as nothing.
|
|
2126
|
+
*/
|
|
2127
|
+
function answersIn(events, requestIds) {
|
|
2128
|
+
const reads = {};
|
|
2129
|
+
for (const requestId of requestIds) reads[requestId] = answerForRequest(events, requestId);
|
|
2130
|
+
return reads;
|
|
2131
|
+
}
|
|
2132
|
+
/**
|
|
2133
|
+
* Submits comments as turns of their session, and reads their answers back.
|
|
2134
|
+
*
|
|
2135
|
+
* One instance serves every session: the store is the state, and the per-agent
|
|
2136
|
+
* subscription is registered on first use and lives with the agent's own context.
|
|
2137
|
+
*/
|
|
2138
|
+
var CommentAsker = class {
|
|
2139
|
+
ctx;
|
|
2140
|
+
comments;
|
|
2141
|
+
/**
|
|
2142
|
+
* The agent each session's inbox events are subscribed on, keyed by session id, with
|
|
2143
|
+
* the disposers that release those subscriptions.
|
|
2144
|
+
*
|
|
2145
|
+
* Keyed by AGENT IDENTITY, not merely "this session id was watched once": a
|
|
2146
|
+
* resumed or re-created agent for the same session id is a different object
|
|
2147
|
+
* whose listeners live on its OWN context, so a session-id set would consider
|
|
2148
|
+
* the new agent already watched and never subscribe to it — leaving `turn`
|
|
2149
|
+
* undefined forever, `agent/turn-stopping` never arriving, and the panel stuck
|
|
2150
|
+
* on "replying" with no way out (the defect this map replaces). The disposers are
|
|
2151
|
+
* what keeps a replacement from leaking a second set of handlers per event.
|
|
2152
|
+
*/
|
|
2153
|
+
watched = /* @__PURE__ */ new Map();
|
|
2154
|
+
/**
|
|
2155
|
+
* @param ctx - the plugin's host context (its `sessionController` and `agents` are asked for lazily).
|
|
2156
|
+
* @param comments - the comment store the ask is recorded in.
|
|
2157
|
+
*/
|
|
2158
|
+
constructor(ctx, comments) {
|
|
2159
|
+
this.ctx = ctx;
|
|
2160
|
+
this.comments = comments;
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* Ask one stored comment as its own turn.
|
|
2164
|
+
*
|
|
2165
|
+
* The request id is recorded BEFORE the submission leaves, so a claim event that
|
|
2166
|
+
* arrives while the submission is still in flight already finds its comment. A
|
|
2167
|
+
* submission that rejects is marked dropped rather than left looking pending.
|
|
2168
|
+
*
|
|
2169
|
+
* @param sessionId - the session the comment belongs to.
|
|
2170
|
+
* @param commentId - the comment to ask.
|
|
2171
|
+
* @param prompt - the prompt text to send verbatim.
|
|
2172
|
+
* @param text - the reader's own words, which the prompt wraps: the thread keeps these.
|
|
2173
|
+
* @param signal - aborts the submission.
|
|
2174
|
+
* @returns what the request did.
|
|
2175
|
+
*/
|
|
2176
|
+
async ask(sessionId, commentId, prompt, text, signal) {
|
|
2177
|
+
const comment = this.comments.get(commentId);
|
|
2178
|
+
if (comment === void 0 || comment.sessionId !== sessionId) return { outcome: "missing" };
|
|
2179
|
+
const controller = this.ctx.get("sessionController");
|
|
2180
|
+
if (typeof controller?.prompt !== "function") {
|
|
2181
|
+
this.ctx.logger.warn("diff-approval: no sessionController.prompt in this host, so a comment cannot be asked");
|
|
2182
|
+
return { outcome: "no-agent" };
|
|
2183
|
+
}
|
|
2184
|
+
if (this.agentOf(sessionId) === void 0) {
|
|
2185
|
+
this.ctx.logger.warn(`diff-approval: no live agent for session ${String(sessionId)}, so a comment cannot be asked`);
|
|
2186
|
+
return { outcome: "no-agent" };
|
|
2187
|
+
}
|
|
2188
|
+
this.watch(sessionId);
|
|
2189
|
+
const requestId = randomUUID();
|
|
2190
|
+
this.comments.recordAsk(sessionId, commentId, requestId, void 0, text);
|
|
2191
|
+
try {
|
|
2192
|
+
await controller.prompt({
|
|
2193
|
+
requestId,
|
|
2194
|
+
sessionId,
|
|
2195
|
+
mode: "queue",
|
|
2196
|
+
content: [{
|
|
2197
|
+
type: "text",
|
|
2198
|
+
text: prompt
|
|
2199
|
+
}]
|
|
2200
|
+
}, signal);
|
|
2201
|
+
return {
|
|
2202
|
+
outcome: "asked",
|
|
2203
|
+
requestId
|
|
2204
|
+
};
|
|
2205
|
+
} catch (error) {
|
|
2206
|
+
const reason = rejectionText(error);
|
|
2207
|
+
this.ctx.logger.warn(`diff-approval: asking a comment failed: ${reason}`);
|
|
2208
|
+
this.comments.markDroppedForRequest(sessionId, requestId);
|
|
2209
|
+
return {
|
|
2210
|
+
outcome: "failed",
|
|
2211
|
+
message: reason
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
/**
|
|
2216
|
+
* Every question of one session and what the transcript says about it, keyed by the
|
|
2217
|
+
* question's request id (which is what `CommentAsk` records and what a client matches
|
|
2218
|
+
* against). Derived from the session's own event log on every call and never stored:
|
|
2219
|
+
* the log is the one source of truth for what the agent said, so a stored copy could
|
|
2220
|
+
* only ever disagree with it.
|
|
2221
|
+
* @param sessionId - the session whose comments to read.
|
|
2222
|
+
* @returns the read per question; empty when the session holds no questions.
|
|
2223
|
+
*/
|
|
2224
|
+
answers(sessionId) {
|
|
2225
|
+
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
|
+
if (requestIds.length === 0) return {};
|
|
2229
|
+
let events;
|
|
2230
|
+
try {
|
|
2231
|
+
events = this.sessionEvents(sessionId);
|
|
2232
|
+
} catch {
|
|
2233
|
+
events = void 0;
|
|
2234
|
+
}
|
|
2235
|
+
if (events === void 0) return {};
|
|
2236
|
+
return answersIn(events, requestIds);
|
|
2237
|
+
}
|
|
2238
|
+
/**
|
|
2239
|
+
* The turns the session's log shows as over, for the questions it holds.
|
|
2240
|
+
*
|
|
2241
|
+
* `agent/turn-stopping` already reports a turn's end live, and that is what usually
|
|
2242
|
+
* writes `ended` down (see `markTurnEnded`). Reading it out of the log as well is what
|
|
2243
|
+
* makes the flag survive a session this process did not watch end — a resumed session,
|
|
2244
|
+
* a client that connected afterwards, a deployment whose agent events cannot be
|
|
2245
|
+
* subscribed at all — because `turn/end` is durable and the event is not.
|
|
2246
|
+
*
|
|
2247
|
+
* @param sessionId - the session whose turns to check.
|
|
2248
|
+
* @returns the turns the log closed, or empty when the log cannot be read.
|
|
2249
|
+
*/
|
|
2250
|
+
endedTurns(sessionId) {
|
|
2251
|
+
let events;
|
|
2252
|
+
try {
|
|
2253
|
+
events = this.sessionEvents(sessionId);
|
|
2254
|
+
} catch {
|
|
2255
|
+
events = void 0;
|
|
2256
|
+
}
|
|
2257
|
+
if (events === void 0) return [];
|
|
2258
|
+
const turns = [];
|
|
2259
|
+
for (const event of events) {
|
|
2260
|
+
if (event?.type !== "turn/end") continue;
|
|
2261
|
+
const turn = turnOf(event);
|
|
2262
|
+
if (turn !== void 0) turns.push(turn);
|
|
2263
|
+
}
|
|
2264
|
+
return turns;
|
|
2265
|
+
}
|
|
2266
|
+
/**
|
|
2267
|
+
* One session's event log, as the reads above take it.
|
|
2268
|
+
*
|
|
2269
|
+
* `snapshotEvents()` and not `deriveMessages()`: the derived history is a list of
|
|
2270
|
+
* messages with no turn attribution, and the turn is the whole point of the answer read
|
|
2271
|
+
* (see `answerForRequest`). The event log is the session's append-only source of truth
|
|
2272
|
+
* and `snapshotEvents` hands back a frozen, sequence-ordered copy of it, so walking it
|
|
2273
|
+
* is a plain read that cannot disturb the session.
|
|
2274
|
+
*
|
|
2275
|
+
* @param sessionId - the session to read.
|
|
2276
|
+
* @returns its events, or undefined when no live session can be read.
|
|
2277
|
+
*/
|
|
2278
|
+
sessionEvents(sessionId) {
|
|
2279
|
+
return this.ctx.sessions.get(sessionId)?.snapshotEvents?.();
|
|
2280
|
+
}
|
|
2281
|
+
/** The live agent for one session, or `undefined` when this host drives none. */
|
|
2282
|
+
agentOf(sessionId) {
|
|
2283
|
+
try {
|
|
2284
|
+
return this.ctx.get("agents")?.get?.(sessionId);
|
|
2285
|
+
} catch {
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* Subscribe to one agent's inbox, once per LIVE agent, moving to a replacement agent
|
|
2291
|
+
* when the session's agent changes under us.
|
|
2292
|
+
*
|
|
2293
|
+
* The listeners live on the agent's own context, so they go away with the agent
|
|
2294
|
+
* and never outlive it — which is exactly why the dedup has to be per agent and
|
|
2295
|
+
* not per session: a session whose agent was re-created (a resume, a restart, a
|
|
2296
|
+
* rebuilt agent for the same id) has nothing listening on the new one, and every
|
|
2297
|
+
* event this module depends on would silently stop arriving. The remembered
|
|
2298
|
+
* agent is compared by identity, so a different object re-subscribes and a
|
|
2299
|
+
* disposed one's stale entry cannot block it. What the replaced agent held is
|
|
2300
|
+
* released first, so re-arming is not a leak: one set of handlers per live agent,
|
|
2301
|
+
* not one more per identity change.
|
|
2302
|
+
*
|
|
2303
|
+
* Called on every ask AND on every poll read (`answers`), because the agent can be
|
|
2304
|
+
* replaced while a question is still pending — the case where nothing asks again.
|
|
2305
|
+
* @param sessionId - the session whose agent to watch.
|
|
2306
|
+
*/
|
|
2307
|
+
watch(sessionId) {
|
|
2308
|
+
const key = String(sessionId);
|
|
2309
|
+
const agent = this.agentOf(sessionId);
|
|
2310
|
+
const on = agent?.ctx?.on;
|
|
2311
|
+
if (agent === void 0 || typeof on !== "function") return;
|
|
2312
|
+
const watching = this.watched.get(key);
|
|
2313
|
+
if (watching?.agent === agent) return;
|
|
2314
|
+
if (watching !== void 0) {
|
|
2315
|
+
this.watched.delete(key);
|
|
2316
|
+
for (const off of watching.detach) try {
|
|
2317
|
+
off();
|
|
2318
|
+
} catch {}
|
|
2319
|
+
}
|
|
2320
|
+
const detach = [];
|
|
2321
|
+
/** Attach one listener, keeping the disposer when the context offers one. */
|
|
2322
|
+
const listen = (name, handler) => {
|
|
2323
|
+
const handle = on.call(agent.ctx, name, handler);
|
|
2324
|
+
if (typeof handle === "function") detach.push(handle);
|
|
2325
|
+
};
|
|
2326
|
+
try {
|
|
2327
|
+
listen("agent/inbox/claimed", (payload) => {
|
|
2328
|
+
const requestId = requestIdOf(payload);
|
|
2329
|
+
if (requestId === void 0) return;
|
|
2330
|
+
const turn = typeof payload.turn === "number" ? payload.turn : void 0;
|
|
2331
|
+
if (turn === void 0) return;
|
|
2332
|
+
this.comments.recordTurnForRequest(sessionId, requestId, turn);
|
|
2333
|
+
});
|
|
2334
|
+
listen("agent/inbox/discarded", (payload) => {
|
|
2335
|
+
const requestId = requestIdOf(payload);
|
|
2336
|
+
if (requestId !== void 0) this.comments.markDroppedForRequest(sessionId, requestId);
|
|
2337
|
+
});
|
|
2338
|
+
listen("agent/turn-stopping", (payload) => {
|
|
2339
|
+
const turn = payload.turn;
|
|
2340
|
+
if (typeof turn === "number") this.comments.markTurnEnded(sessionId, turn);
|
|
2341
|
+
});
|
|
2342
|
+
this.watched.set(key, {
|
|
2343
|
+
agent,
|
|
2344
|
+
detach
|
|
2345
|
+
});
|
|
2346
|
+
} catch (error) {
|
|
2347
|
+
for (const off of detach) try {
|
|
2348
|
+
off();
|
|
2349
|
+
} catch {}
|
|
2350
|
+
this.ctx.logger.warn(`diff-approval: watching the comment inbox failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
};
|
|
2354
|
+
/** The request id one inbox event names, when it names our kind of message. */
|
|
2355
|
+
function requestIdOf(payload) {
|
|
2356
|
+
const rpcId = payload.message?.source?.rpcId;
|
|
2357
|
+
return typeof rpcId === "string" && rpcId !== "" ? rpcId : void 0;
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* The human text of one rejection.
|
|
2361
|
+
*
|
|
2362
|
+
* A prompt is refused by the host's session API with its own error objects — a
|
|
2363
|
+
* `RemoteError` carrying a code and a message, or a plain thrown value. Reading
|
|
2364
|
+
* `message` off whatever arrived keeps the panel's failure line meaningful
|
|
2365
|
+
* (`session/model-unavailable`, `session/agent-busy`, …) instead of printing
|
|
2366
|
+
* "[object Object]" for the shapes that are not `Error` instances.
|
|
2367
|
+
*
|
|
2368
|
+
* @param error - the thrown value.
|
|
2369
|
+
* @returns its message, or its string form when it carries none.
|
|
2370
|
+
*/
|
|
2371
|
+
function rejectionText(error) {
|
|
2372
|
+
if (typeof error === "object" && error !== null) {
|
|
2373
|
+
const message = error.message;
|
|
2374
|
+
if (typeof message === "string" && message !== "") return message;
|
|
2375
|
+
}
|
|
2376
|
+
return String(error);
|
|
2377
|
+
}
|
|
2378
|
+
//#endregion
|
|
2379
|
+
//#region lib/types/comment-lines.js
|
|
2380
|
+
/**
|
|
2381
|
+
* Where a comment's quote sits in a file's CURRENT content: the rule that turns the lines a comment
|
|
2382
|
+
* was WRITTEN on into the lines it is about now.
|
|
2383
|
+
*
|
|
2384
|
+
* Pure and free of both Node and the DOM, so the two halves of the plugin import the very same rule:
|
|
2385
|
+
* the host resolves each comment against the entry content it hangs off and ships the figures on the
|
|
2386
|
+
* list read, and the browser half draws them. The panel's own row-level re-anchor
|
|
2387
|
+
* (`src/client/discussion.ts`) is this same rule seen through the diff's rows — with one row per
|
|
2388
|
+
* line, a removed line among them, and the quote's context taken from the rows — and it stays the
|
|
2389
|
+
* browser's business: where a card is DRAWN is a layout fact. What a reader is TOLD (the list's
|
|
2390
|
+
* label, the card's own chip, the jump's target line) comes from here, once, so two panes cannot
|
|
2391
|
+
* name two lines for one comment.
|
|
2392
|
+
*
|
|
2393
|
+
* The two models can disagree in one situation, and it is worth knowing which: the host has the
|
|
2394
|
+
* file's own lines, while the client's rows also carry the lines this file DELETED. A quote whose
|
|
2395
|
+
* recorded `quoteContext` was captured across a deleted row therefore matches a context window the
|
|
2396
|
+
* host cannot reproduce, and the host answers `undefined` where the client would re-anchor. The
|
|
2397
|
+
* caller then falls back to `record.anchor` — the line the comment was written on — which is what an
|
|
2398
|
+
* outdated thread shows anyway: the number stays honest rather than drifting.
|
|
2399
|
+
*
|
|
2400
|
+
* What the two may NOT disagree about is how far a match is allowed to be from the stored line: a
|
|
2401
|
+
* look-alike elsewhere in the file is refused by both, through the one bound they share
|
|
2402
|
+
* (`REANCHOR_MAX_LINES`), so the list's figure, the card's chip and the card's place cannot name two
|
|
2403
|
+
* different lines for one comment.
|
|
2404
|
+
* @module dsh-diff-approval/comment-lines
|
|
2405
|
+
*/
|
|
2406
|
+
/**
|
|
2407
|
+
* One file's content lines, numbered exactly as the diff numbers them.
|
|
2408
|
+
*
|
|
2409
|
+
* Both line endings are normalized first (`\r\n` and a lone `\r` become `\n`), and a single trailing
|
|
2410
|
+
* newline is a line TERMINATOR rather than an extra empty line — the same convention the browser
|
|
2411
|
+
* half's whole-file diff splits on (its `contentLines`), so line 1 here is the line the diff calls
|
|
2412
|
+
* line 1 and the two cannot drift by a trailing newline.
|
|
2413
|
+
*
|
|
2414
|
+
* @param text - the file's content.
|
|
2415
|
+
* @returns its lines, without the terminating newline; an empty file has none.
|
|
2416
|
+
*/
|
|
2417
|
+
function commentFileLines(text) {
|
|
2418
|
+
const body = text.replace(/\r\n?/g, "\n");
|
|
2419
|
+
if (body === "") return [];
|
|
2420
|
+
return (body.endsWith("\n") ? body.slice(0, -1) : body).split("\n");
|
|
2421
|
+
}
|
|
2422
|
+
/**
|
|
2423
|
+
* Whether a match that far from where the comment was written is that code having moved.
|
|
2424
|
+
*
|
|
2425
|
+
* The rule the two halves share, so neither can widen or narrow the bound on its own (see
|
|
2426
|
+
* `REANCHOR_MAX_LINES` for the number and for what it separates). The bound is inclusive: at exactly
|
|
2427
|
+
* the limit the code is taken to have moved.
|
|
2428
|
+
*
|
|
2429
|
+
* @param stored - the line (the host's content) or row (the browser half's model) the comment names.
|
|
2430
|
+
* @param found - the line or row the quote was found on.
|
|
2431
|
+
* @returns whether that match is close enough to be the same code.
|
|
2432
|
+
*/
|
|
2433
|
+
function reanchorWithinLimit(stored, found) {
|
|
2434
|
+
return Math.abs(found - stored) <= 30;
|
|
2435
|
+
}
|
|
2436
|
+
/**
|
|
2437
|
+
* Where one comment's quote sits in a file's current content, or `undefined` when it is nowhere.
|
|
2438
|
+
*
|
|
2439
|
+
* The rule, in the order it is applied — the same order the browser half's `remapDiscussion` applies
|
|
2440
|
+
* it in, so the same file and the same record produce the same number in both halves:
|
|
2441
|
+
*
|
|
2442
|
+
* 1. The lines the comment names have to exist in this file at all. When even the anchor's first
|
|
2443
|
+
* line is past the end, nothing here can be re-anchored: `undefined`.
|
|
2444
|
+
* 2. The anchor's own line still reads the quote: the comment has not moved. The stored range is the
|
|
2445
|
+
* answer, `endLine` included — a range whose quote covers a different number of lines (a removed
|
|
2446
|
+
* row was quoted among them) still belongs to the reader's own numbers.
|
|
2447
|
+
* 3. Otherwise the quote is followed: every window of as many lines as the quote has is compared to
|
|
2448
|
+
* it, the recorded context has to agree where the record carries one, and the occurrence CLOSEST
|
|
2449
|
+
* to the stored anchor wins — that is where the reader last saw the code. An equal distance keeps
|
|
2450
|
+
* the earlier occurrence, which is the first window an ascending scan meets.
|
|
2451
|
+
* 4. The occurrence also has to be NEAR the stored anchor (see `REANCHOR_MAX_LINES`): a window
|
|
2452
|
+
* farther than that from the line the comment was written on is not this code having moved but
|
|
2453
|
+
* another place in the file that reads the same — however exactly its recorded context agrees.
|
|
2454
|
+
* 5. Nothing matched: `undefined`. The caller falls back to `record.anchor`, the line the comment was
|
|
2455
|
+
* written on — the same answer the code view gives an outdated thread.
|
|
2456
|
+
*
|
|
2457
|
+
* @param text - the entry's current content (the file as it reads now).
|
|
2458
|
+
* @param comment - the stored comment's anchor and quote.
|
|
2459
|
+
* @returns the new-file line range the quote occupies now, or `undefined` when it is gone.
|
|
2460
|
+
*/
|
|
2461
|
+
function resolveCommentLines(text, comment) {
|
|
2462
|
+
const lines = commentFileLines(text);
|
|
2463
|
+
const quote = comment.quote;
|
|
2464
|
+
const span = quote.split("\n").length - 1;
|
|
2465
|
+
const { startLine, endLine } = comment.anchor;
|
|
2466
|
+
/** The `span + 1` lines from `row` on, joined as the quote is; past the end reads as empty lines. */
|
|
2467
|
+
const windowAt = (row) => {
|
|
2468
|
+
const parts = [];
|
|
2469
|
+
for (let index = row; index <= row + span; index++) parts.push(index < lines.length ? lines[index] : "");
|
|
2470
|
+
return parts.join("\n");
|
|
2471
|
+
};
|
|
2472
|
+
/** That window with one line of context on each side, as far as the file has them. */
|
|
2473
|
+
const contextAt = (row) => {
|
|
2474
|
+
const parts = [];
|
|
2475
|
+
for (let index = Math.max(0, row - 1); index <= Math.min(lines.length - 1, row + span + 1); index++) parts.push(lines[index]);
|
|
2476
|
+
return parts.join("\n");
|
|
2477
|
+
};
|
|
2478
|
+
if (!(startLine >= 1 && startLine <= lines.length)) return void 0;
|
|
2479
|
+
const row = startLine - 1;
|
|
2480
|
+
if (windowAt(row) === quote) return {
|
|
2481
|
+
start: startLine,
|
|
2482
|
+
end: endLine
|
|
2483
|
+
};
|
|
2484
|
+
const fingerprint = comment.quoteContext;
|
|
2485
|
+
let found = -1;
|
|
2486
|
+
for (let index = 0; index + span < lines.length; index++) {
|
|
2487
|
+
if (windowAt(index) !== quote) continue;
|
|
2488
|
+
if (fingerprint !== void 0 && contextAt(index) !== fingerprint) continue;
|
|
2489
|
+
if (!reanchorWithinLimit(startLine, index + 1)) continue;
|
|
2490
|
+
if (found === -1 || Math.abs(index - row) < Math.abs(found - row)) found = index;
|
|
2491
|
+
}
|
|
2492
|
+
if (found === -1) return void 0;
|
|
2493
|
+
return {
|
|
2494
|
+
start: found + 1,
|
|
2495
|
+
end: found + span + 1
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
//#endregion
|
|
1242
2499
|
//#region lib/types/open.js
|
|
1243
2500
|
/**
|
|
1244
2501
|
* OS-level "open file" and "reveal in folder" launches for the host half's
|
|
@@ -1330,6 +2587,450 @@ const COMMENT_SKILL = {
|
|
|
1330
2587
|
`,
|
|
1331
2588
|
source: "runtime"
|
|
1332
2589
|
};
|
|
2590
|
+
/** The contribution to register with the skill registry. */
|
|
2591
|
+
const ANNOTATE_SKILL = {
|
|
2592
|
+
name: "dsh-diff-approval-annotate",
|
|
2593
|
+
description: "How to annotate code with the review panel's cards: one card per place, on the lines as the file reads now, never on lines a card already holds — and the card is a conversation the user can answer.",
|
|
2594
|
+
whenToUse: "The user asks you to explain, walk through, or review code that has pending changes in the review panel (查看差异 / 改动审批), or you want to point at particular lines while discussing them — load this before calling `diff_approval_annotate`.",
|
|
2595
|
+
content: `# 用卡片给代码做批注
|
|
2596
|
+
|
|
2597
|
+
改动审批面板里的卡片 = 针对**具体几行代码**的对话。你写的批注会成为卡片的第一条内容(你在工具里写的 \`note\`),用户就在代码旁边看到它,并且可以在这张卡片里回你;他的回复会以 \`[评论] (path:lines)\` 的消息回到你。
|
|
2598
|
+
|
|
2599
|
+
## 什么时候该用
|
|
2600
|
+
|
|
2601
|
+
- 用户想读代码:把**调用链**一步步批注出来(每个关键调用点一张卡,按阅读顺序),比在回复里贴一大段代码或行号清单清楚得多。
|
|
2602
|
+
- 你要指出某段代码的问题、理由、或需要注意的地方(Review 场景),让证据就贴在证据所在的行上。
|
|
2603
|
+
- 你正在讨论某段代码,想把话直接挂在那些行上,而不是让用户在回复里对着行号找。
|
|
2604
|
+
|
|
2605
|
+
不要在用户没让你讲代码时刷卡片,也不要用它代替回复:卡片是**补充**,你的回答还是要写在回复里(可以简短说"已在代码上标了 3 处"并给出要点)。
|
|
2606
|
+
|
|
2607
|
+
## 怎么调用 \`diff_approval_annotate\`
|
|
2608
|
+
|
|
2609
|
+
- \`path\`:面板里那个文件。你读文件时用的路径也行(按尾部匹配);匹配到多个会被拒,把完整路径给它。
|
|
2610
|
+
**文件还不在面板的列表里也没关系:会自动加进去**(工作区里能读成文本的文件),所以"用户想读懂某个文件"不必先等改动发生。工作区之外、或读不出文本,才会被拒。
|
|
2611
|
+
- \`startLine\` / \`endLine\`:**现在**的行号(1 起,含两端;省略 \`endLine\` 就是一行)。先读文件再写号,别凭记忆。
|
|
2612
|
+
- \`note\`:卡片上写的话。要短——卡片是**按行**排版、行高按行算的(上限 1000 字符,超了会被拒)。讲流程时,把这一步的序号写在 note 的**最前面**(\`1. …\`、\`2. …\`)。
|
|
2613
|
+
- \`quote\`(可选,建议给):你读到的那几行原文(换行连接)。文件在你读完之后又变了的话,会当场被拒并把现在的原文给你,避免批注挂在错误的行上。
|
|
2614
|
+
|
|
2615
|
+
## 讲流程就在正文开头写序号
|
|
2616
|
+
|
|
2617
|
+
如果这几张卡是**一条流程/调用链的步骤**,把序号写在 note 的最前面,用户就能按号读:
|
|
2618
|
+
|
|
2619
|
+
- 序号没有专门参数,也不是面板画的——它就是正文的头几个字符,所以卡片里、列表里、复制出去的内容里都带着它,你在回复里也能引用同一个号。
|
|
2620
|
+
- 号由你排:按你希望用户阅读的顺序;1、2、3… 连不连续、重不重复都随你(**不需要唯一**,讲同一个点的补充卡可以同号)。
|
|
2621
|
+
- 顺序号也写进你的回复("我在代码上标了 1–5,从 X 开始"),这样面板里的号和回复里的号对得上。
|
|
2622
|
+
- 零散的批注(不是一串步骤)**不要**写号。
|
|
2623
|
+
|
|
2624
|
+
## 一张卡一个点
|
|
2625
|
+
|
|
2626
|
+
- 范围要小:**最多 60 行**,通常 1–5 行就够。要讲整段逻辑就拆成几张卡。
|
|
2627
|
+
- 一条调用链 = 一张卡一个调用点,按你应该让用户阅读的顺序依次创建;不要把所有步骤塞进一张卡。
|
|
2628
|
+
- note 里只谈这些行(最多带上直接相邻的一两行),不要扩到整个文件或整个任务。
|
|
2629
|
+
- 写法沿用批注的规矩:不超过 3 行、行间不空行、**不要 Markdown 表格/列表/标题/围栏代码块**(卡片按行排版,表格会读成一堆竖线并把高度算错),行内只用 \`代码\` 和 **加粗**。要引用别处就写 \`文件:行号\`。
|
|
2630
|
+
- 用用户的语言写 note。
|
|
2631
|
+
|
|
2632
|
+
## 那几行已经被占用时
|
|
2633
|
+
|
|
2634
|
+
如果这些行已经落在**某张卡片里**,调用会被拒,拒绝信息里会写明是哪一张(id、行号、它开头的话)。这不是错误,是"这里已经有话说了":
|
|
2635
|
+
|
|
2636
|
+
- 换一处行号;或者
|
|
2637
|
+
- 把这件事写进你的回复里,不要试图硬塞进那张卡:那张卡的对话属于写它的人(可能是用户,也可能是你早先写的)。如果用户想在那张卡上继续聊,他会在卡片里回复你,那时你会收到 \`[评论]\`,按 \`dsh-diff-approval-comment\` 的规矩回答即可。
|
|
2638
|
+
|
|
2639
|
+
**不要**在回复里假装已经批注成功——工具返回的是"refused: ..."就说明没有卡片被创建。
|
|
2640
|
+
`,
|
|
2641
|
+
source: "runtime"
|
|
2642
|
+
};
|
|
2643
|
+
/**
|
|
2644
|
+
* The most characters one annotation's note may carry.
|
|
2645
|
+
*
|
|
2646
|
+
* The card lays its text out one row per line and reserves exactly that many rows in the diff, so an
|
|
2647
|
+
* essay in a card pushes the code it is about off the screen. Anything longer belongs in the agent's
|
|
2648
|
+
* own reply to the user, which is not measured in code rows.
|
|
2649
|
+
*/
|
|
2650
|
+
const MAX_ANNOTATION_CHARS = 1e3;
|
|
2651
|
+
/**
|
|
2652
|
+
* Whether two line ranges share a line. Ranges are inclusive on both ends, which is how the panel's own
|
|
2653
|
+
* selection rule reads them (`discussionOverlapping`).
|
|
2654
|
+
*
|
|
2655
|
+
* @param aStart - first line of one range.
|
|
2656
|
+
* @param aEnd - last line of one range.
|
|
2657
|
+
* @param bStart - first line of the other range.
|
|
2658
|
+
* @param bEnd - last line of the other range.
|
|
2659
|
+
* @returns whether they overlap.
|
|
2660
|
+
*/
|
|
2661
|
+
function overlaps(aStart, aEnd, bStart, bEnd) {
|
|
2662
|
+
return aStart <= bEnd && bStart <= aEnd;
|
|
2663
|
+
}
|
|
2664
|
+
/**
|
|
2665
|
+
* The quote, the context fingerprint and the gutter numbers a range of a file's own lines produces.
|
|
2666
|
+
*
|
|
2667
|
+
* The shape mirrors what the panel records when the reader annotates rows: the quote is the named lines
|
|
2668
|
+
* newline-joined, the context is one line each side of them (as far as the file reaches, which is how
|
|
2669
|
+
* the client's own `contextAt` clamps at the ends), and the gutter numbers are the new-file numbers of
|
|
2670
|
+
* the quoted lines.
|
|
2671
|
+
*
|
|
2672
|
+
* The old-file side is deliberately absent, and so is the add/del/context marker: the host holds the
|
|
2673
|
+
* file's current text, not a diff, so it cannot say which of those lines the change added and which it
|
|
2674
|
+
* left alone. Absent is the honest answer — the panel draws such a quote with the numbers it has and
|
|
2675
|
+
* no wash, exactly as it does for a thread recorded before those fields existed.
|
|
2676
|
+
*
|
|
2677
|
+
* @param lines - the file's lines, as `commentFileLines` splits them.
|
|
2678
|
+
* @param startLine - first quoted line, 1-based.
|
|
2679
|
+
* @param endLine - last quoted line, 1-based and inclusive.
|
|
2680
|
+
* @returns the record's quote fields.
|
|
2681
|
+
*/
|
|
2682
|
+
function annotationQuote(lines, startLine, endLine) {
|
|
2683
|
+
const quoted = lines.slice(startLine - 1, endLine);
|
|
2684
|
+
const before = startLine > 1 ? lines[startLine - 2] : void 0;
|
|
2685
|
+
const after = endLine < lines.length ? lines[endLine] : void 0;
|
|
2686
|
+
const context = [
|
|
2687
|
+
before,
|
|
2688
|
+
...quoted,
|
|
2689
|
+
after
|
|
2690
|
+
].filter((line) => line !== void 0);
|
|
2691
|
+
return {
|
|
2692
|
+
quote: quoted.join("\n"),
|
|
2693
|
+
quoteContext: context.join("\n"),
|
|
2694
|
+
quoteLines: quoted.map((_line, index) => ({ new: startLine + index }))
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
/**
|
|
2698
|
+
* Turn one annotation request into the record the panel draws, or say why it cannot be made.
|
|
2699
|
+
*
|
|
2700
|
+
* The order of the checks is the order a caller can act on them: what it asked for (note, range), then
|
|
2701
|
+
* whether the code it means is still there (`quote`), then whether the reader already has a card on
|
|
2702
|
+
* those lines. Nothing is written here — the caller hands the record to the comment store — so this is
|
|
2703
|
+
* a pure function of the entry, the stored comments and the request, and it can be tested as one.
|
|
2704
|
+
*
|
|
2705
|
+
* @param request - what to annotate and with what words.
|
|
2706
|
+
* @param existing - every comment the store holds for this entry.
|
|
2707
|
+
* @returns the record to store, or the refusal to hand back.
|
|
2708
|
+
*/
|
|
2709
|
+
function annotateLines(request, existing) {
|
|
2710
|
+
const note = request.note.trim();
|
|
2711
|
+
if (note === "") return { outcome: "empty-note" };
|
|
2712
|
+
if (note.length > 1e3) return {
|
|
2713
|
+
outcome: "note-too-long",
|
|
2714
|
+
limit: MAX_ANNOTATION_CHARS,
|
|
2715
|
+
length: note.length
|
|
2716
|
+
};
|
|
2717
|
+
const start = Math.trunc(request.startLine);
|
|
2718
|
+
const end = Math.trunc(request.endLine ?? request.startLine);
|
|
2719
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return {
|
|
2720
|
+
outcome: "range-too-wide",
|
|
2721
|
+
limit: 60,
|
|
2722
|
+
lines: 0
|
|
2723
|
+
};
|
|
2724
|
+
if (end - start + 1 > 60) return {
|
|
2725
|
+
outcome: "range-too-wide",
|
|
2726
|
+
limit: 60,
|
|
2727
|
+
lines: end - start + 1
|
|
2728
|
+
};
|
|
2729
|
+
const lines = commentFileLines(request.entry.newText);
|
|
2730
|
+
if (start < 1 || end > lines.length) return {
|
|
2731
|
+
outcome: "out-of-range",
|
|
2732
|
+
lines: lines.length,
|
|
2733
|
+
startLine: start,
|
|
2734
|
+
endLine: end
|
|
2735
|
+
};
|
|
2736
|
+
const quoted = lines.slice(start - 1, end).join("\n");
|
|
2737
|
+
if (request.quote !== void 0 && request.quote.trim() !== "" && request.quote !== quoted) return {
|
|
2738
|
+
outcome: "quote-mismatch",
|
|
2739
|
+
actual: quoted
|
|
2740
|
+
};
|
|
2741
|
+
for (const comment of existing) {
|
|
2742
|
+
if (comment.entryId !== request.entry.id) continue;
|
|
2743
|
+
const range = resolveCommentLines(request.entry.newText, comment);
|
|
2744
|
+
if (range === void 0) continue;
|
|
2745
|
+
if (!overlaps(start, end, range.start, range.end)) continue;
|
|
2746
|
+
return {
|
|
2747
|
+
outcome: "already-annotated",
|
|
2748
|
+
comment,
|
|
2749
|
+
start: range.start,
|
|
2750
|
+
end: range.end
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
const now = request.now ?? Date.now();
|
|
2754
|
+
return {
|
|
2755
|
+
outcome: "annotated",
|
|
2756
|
+
comment: {
|
|
2757
|
+
id: request.id ?? randomUUID(),
|
|
2758
|
+
sessionId: request.sessionId,
|
|
2759
|
+
entryId: request.entry.id,
|
|
2760
|
+
path: request.entry.path,
|
|
2761
|
+
anchor: {
|
|
2762
|
+
startLine: start,
|
|
2763
|
+
endLine: end
|
|
2764
|
+
},
|
|
2765
|
+
text: note,
|
|
2766
|
+
createdAt: now,
|
|
2767
|
+
updatedAt: now,
|
|
2768
|
+
author: "agent",
|
|
2769
|
+
...annotationQuote(lines, start, end)
|
|
2770
|
+
}
|
|
2771
|
+
};
|
|
2772
|
+
}
|
|
2773
|
+
/**
|
|
2774
|
+
* The one line a refusal is summarised with, for the caller's own log: the outcome and what it says.
|
|
2775
|
+
*
|
|
2776
|
+
* The agent is handed a full sentence by the tool layer (see `annotate-tool.ts`), which is where the
|
|
2777
|
+
* wording lives because that is what a model reads. This is for the plugin's logger.
|
|
2778
|
+
*
|
|
2779
|
+
* @param refusal - the refusal to name.
|
|
2780
|
+
* @returns the outcome, plus the numbers that make two of the same kind tellable apart.
|
|
2781
|
+
*/
|
|
2782
|
+
function refusalSummary(refusal) {
|
|
2783
|
+
switch (refusal.outcome) {
|
|
2784
|
+
case "already-annotated": return `already-annotated(${refusal.comment.id} ${refusal.start}-${refusal.end})`;
|
|
2785
|
+
case "out-of-range": return `out-of-range(${refusal.startLine}-${refusal.endLine} of ${refusal.lines})`;
|
|
2786
|
+
case "quote-mismatch": return "quote-mismatch";
|
|
2787
|
+
case "range-too-wide": return `range-too-wide(${refusal.lines} lines)`;
|
|
2788
|
+
case "note-too-long": return `note-too-long(${refusal.length} chars)`;
|
|
2789
|
+
default: return refusal.outcome;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
//#endregion
|
|
2793
|
+
//#region lib/types/annotate-tool.js
|
|
2794
|
+
/**
|
|
2795
|
+
* The tool the agent calls to annotate code: one card, on the lines it names.
|
|
2796
|
+
*
|
|
2797
|
+
* This is the half a model reads. The rule it applies lives in `annotate.ts` and is a pure function of
|
|
2798
|
+
* the entry, the stored comments and the request; what is here is the model-facing surface — the name,
|
|
2799
|
+
* the argument schema, the sentence each outcome is handed back as — plus the two lookups that only
|
|
2800
|
+
* exist at run time (which session is calling, and which of ITS pending files the caller means).
|
|
2801
|
+
*
|
|
2802
|
+
* Why a tool at all, when the reader can already annotate: the reader annotates what they are looking
|
|
2803
|
+
* at, and the agent annotates what it is EXPLAINING. A call chain the reader asked to have walked, a
|
|
2804
|
+
* piece of code the agent wants to point at, a review finding with its evidence beside it — all of those
|
|
2805
|
+
* are "quote these lines and say something about them", which is exactly the card the panel already
|
|
2806
|
+
* draws. The card is the shared object: the agent's note is its first turn, the reader answers it in the
|
|
2807
|
+
* card, and that answer comes back to the agent as an ordinary `[评论]` message.
|
|
2808
|
+
*
|
|
2809
|
+
* The refusal the caller is most likely to meet is the one that makes the feature coherent: those lines
|
|
2810
|
+
* already belong to a card. Two cards hanging off the same lines cannot be told apart in the panel, so
|
|
2811
|
+
* the annotation is refused and the refusal NAMES the card holding them — the agent can then say its
|
|
2812
|
+
* piece in its own reply, or annotate other lines.
|
|
2813
|
+
*
|
|
2814
|
+
* The definition is built BY HAND rather than with the harness's `defineTool`: the host half of this
|
|
2815
|
+
* plugin imports nothing from the harness at runtime (its harness packages are services it asks for by
|
|
2816
|
+
* name, and types), the registry asks only for `name`, `description`, a raw argument schema and an
|
|
2817
|
+
* `output` with a schema and a `render`, and this way a build whose harness has no `dsh-tools` at all
|
|
2818
|
+
* still loads the plugin — the tool is simply never registered.
|
|
2819
|
+
*
|
|
2820
|
+
* @module dsh-diff-approval/src/annotate-tool
|
|
2821
|
+
*/
|
|
2822
|
+
/** The name the agent calls this by. Prefixed with the package: tool names share one flat namespace. */
|
|
2823
|
+
const ANNOTATE_TOOL_NAME = "diff_approval_annotate";
|
|
2824
|
+
/**
|
|
2825
|
+
* The routing line the model always sees, which has to carry the two things that decide whether it calls
|
|
2826
|
+
* this at all: what appears for the reader, and that the lines must be free.
|
|
2827
|
+
*/
|
|
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.";
|
|
2829
|
+
/**
|
|
2830
|
+
* The argument schema, as raw JSON Schema.
|
|
2831
|
+
*
|
|
2832
|
+
* Raw rather than the harness's author DSL because the definition is hand-built (see the module
|
|
2833
|
+
* comment): the registry validates a hand-built definition's arguments against this subset, and the
|
|
2834
|
+
* model reads the same descriptions either way.
|
|
2835
|
+
*/
|
|
2836
|
+
const ANNOTATE_TOOL_PARAMETERS = {
|
|
2837
|
+
type: "object",
|
|
2838
|
+
additionalProperties: false,
|
|
2839
|
+
required: [
|
|
2840
|
+
"path",
|
|
2841
|
+
"startLine",
|
|
2842
|
+
"note"
|
|
2843
|
+
],
|
|
2844
|
+
properties: {
|
|
2845
|
+
path: {
|
|
2846
|
+
type: "string",
|
|
2847
|
+
description: "The file to annotate: a path of a file with pending changes, as the review panel shows it (the path you read is fine too — the tail is matched)."
|
|
2848
|
+
},
|
|
2849
|
+
startLine: {
|
|
2850
|
+
type: "integer",
|
|
2851
|
+
description: "First line to annotate, 1-based, counted in the file as it reads NOW."
|
|
2852
|
+
},
|
|
2853
|
+
endLine: {
|
|
2854
|
+
type: "integer",
|
|
2855
|
+
description: "Last line, inclusive. Omit to annotate the single line startLine names."
|
|
2856
|
+
},
|
|
2857
|
+
note: {
|
|
2858
|
+
type: "string",
|
|
2859
|
+
description: "What the card says. Keep it to a few lines: the card is drawn line by line next to the code, and the reader can reply to it there."
|
|
2860
|
+
},
|
|
2861
|
+
quote: {
|
|
2862
|
+
type: "string",
|
|
2863
|
+
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."
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
};
|
|
2867
|
+
/**
|
|
2868
|
+
* The definition to hand to `ctx.tools.register`.
|
|
2869
|
+
*
|
|
2870
|
+
* The canonical value is the sentence itself: this tool answers with prose because that is all the model
|
|
2871
|
+
* needs from it, and a one-field schema keeps the wire contract trivial.
|
|
2872
|
+
*
|
|
2873
|
+
* @param run - what fulfils one call; it never throws for a refusal (see below), only for a call that
|
|
2874
|
+
* could not be carried out at all.
|
|
2875
|
+
* @returns the definition object.
|
|
2876
|
+
*/
|
|
2877
|
+
function annotateToolDefinition(run) {
|
|
2878
|
+
return {
|
|
2879
|
+
name: ANNOTATE_TOOL_NAME,
|
|
2880
|
+
description: ANNOTATE_TOOL_DESCRIPTION,
|
|
2881
|
+
parameters: ANNOTATE_TOOL_PARAMETERS,
|
|
2882
|
+
output: {
|
|
2883
|
+
schema: { type: "string" },
|
|
2884
|
+
render: (_args, value) => [{
|
|
2885
|
+
type: "text",
|
|
2886
|
+
text: value
|
|
2887
|
+
}]
|
|
2888
|
+
},
|
|
2889
|
+
async execute(args, exec) {
|
|
2890
|
+
const execution = exec;
|
|
2891
|
+
return run(args, {
|
|
2892
|
+
sessionId: execution?.agent?.id,
|
|
2893
|
+
signal: execution?.signal
|
|
2894
|
+
});
|
|
2895
|
+
}
|
|
2896
|
+
};
|
|
2897
|
+
}
|
|
2898
|
+
/**
|
|
2899
|
+
* One path, folded to the form two spellings of the same file share: separators unified, trailing slash
|
|
2900
|
+
* dropped, case dropped (the panel and the agent can each write `C:\a\b.ts`, `C:/a/b.ts` or `c:\A\B.TS`).
|
|
2901
|
+
*
|
|
2902
|
+
* @param path - the path as written.
|
|
2903
|
+
* @returns the comparable form.
|
|
2904
|
+
*/
|
|
2905
|
+
function foldPath(path) {
|
|
2906
|
+
return path.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, "").toLowerCase();
|
|
2907
|
+
}
|
|
2908
|
+
/**
|
|
2909
|
+
* Which pending file the caller means by `path`.
|
|
2910
|
+
*
|
|
2911
|
+
* A model writing this argument usually hands back the path it just read — absolute, workspace-relative,
|
|
2912
|
+
* or with a slash style of its own. Exact match first, then a trailing-segment match; an AMBIGUOUS tail
|
|
2913
|
+
* is reported as such rather than guessed, because annotating the wrong file's lines is the one mistake
|
|
2914
|
+
* the reader would see as a bug in the panel.
|
|
2915
|
+
*
|
|
2916
|
+
* @param entries - the session's pending files.
|
|
2917
|
+
* @param path - the path the caller wrote.
|
|
2918
|
+
* @returns the one entry, or why it could not be picked.
|
|
2919
|
+
*/
|
|
2920
|
+
function lookupEntry(entries, path) {
|
|
2921
|
+
const asked = foldPath(path);
|
|
2922
|
+
if (asked === "") return { kind: "none" };
|
|
2923
|
+
const exact = entries.filter((file) => foldPath(file.path) === asked);
|
|
2924
|
+
if (exact.length === 1) return {
|
|
2925
|
+
kind: "one",
|
|
2926
|
+
entry: exact[0]
|
|
2927
|
+
};
|
|
2928
|
+
const byTail = entries.filter((file) => {
|
|
2929
|
+
const folded = foldPath(file.path);
|
|
2930
|
+
return folded.endsWith(`/${asked}`) || asked.endsWith(`/${folded}`);
|
|
2931
|
+
});
|
|
2932
|
+
const candidates = exact.length > 1 ? exact : byTail;
|
|
2933
|
+
if (candidates.length === 1) return {
|
|
2934
|
+
kind: "one",
|
|
2935
|
+
entry: candidates[0]
|
|
2936
|
+
};
|
|
2937
|
+
if (candidates.length === 0) return { kind: "none" };
|
|
2938
|
+
return {
|
|
2939
|
+
kind: "many",
|
|
2940
|
+
paths: candidates.map((file) => file.path)
|
|
2941
|
+
};
|
|
2942
|
+
}
|
|
2943
|
+
/**
|
|
2944
|
+
* The sentence a refusal is handed back as.
|
|
2945
|
+
*
|
|
2946
|
+
* Every one of these is written for a model that has to choose what to do next, so each says what was
|
|
2947
|
+
* wrong AND what it can do instead. The lines are named back in the panel's own form (`path:from-to`),
|
|
2948
|
+
* which is the reference the reader sees in a card's header.
|
|
2949
|
+
*
|
|
2950
|
+
* @param refusal - what the rule refused.
|
|
2951
|
+
* @param path - the file the request named, for the reference.
|
|
2952
|
+
* @returns the sentence the caller reads.
|
|
2953
|
+
*/
|
|
2954
|
+
function refusalText(refusal, path) {
|
|
2955
|
+
switch (refusal.outcome) {
|
|
2956
|
+
case "already-annotated": {
|
|
2957
|
+
const held = refusal.comment.text.replace(/\s+/g, " ").slice(0, 60);
|
|
2958
|
+
const who = refusal.comment.author === "agent" ? "your own earlier annotation" : "an existing comment";
|
|
2959
|
+
return `refused: ${path}:${refusal.start}-${refusal.end} is already inside ${who} (id ${refusal.comment.id}, "${held}"). Two cards cannot hang off the same lines, so annotate different lines — or say this in your reply to the user, and mention that card if what you are saying is about it.`;
|
|
2960
|
+
}
|
|
2961
|
+
case "out-of-range": return `refused: ${path} has ${refusal.lines} lines, so ${refusal.startLine}-${refusal.endLine} is not in it. Read the file and use the line numbers it has now.`;
|
|
2962
|
+
case "quote-mismatch": return `refused: those lines do not read what "quote" said they do. They read:\n${refusal.actual}\nRead the file again and either drop "quote" or pass the text that is there now.`;
|
|
2963
|
+
case "range-too-wide": return `refused: ${refusal.lines} lines is more than the ${refusal.limit} one annotation may name. A card is a note on a few lines; a walkthrough is several cards, one per step.`;
|
|
2964
|
+
case "note-too-long": return `refused: the note is ${refusal.length} characters, over the ${refusal.limit} a card may carry. Put the long form in your reply to the user and keep the card to its point.`;
|
|
2965
|
+
default: return "refused: the note is empty, so the card would say nothing.";
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
/**
|
|
2969
|
+
* The sentence a file that could not be listed is refused with.
|
|
2970
|
+
*
|
|
2971
|
+
* All three are the workspace's rules rather than the annotation's: the panel can only show what the
|
|
2972
|
+
* session's workspace contains and the file service can read, and naming which one failed is what lets
|
|
2973
|
+
* the agent pick another file, or stop annotating that one.
|
|
2974
|
+
*
|
|
2975
|
+
* @param kind - why listing failed.
|
|
2976
|
+
* @param path - the path the caller named.
|
|
2977
|
+
* @returns the sentence the caller reads.
|
|
2978
|
+
*/
|
|
2979
|
+
function unlistedText(kind, path) {
|
|
2980
|
+
switch (kind) {
|
|
2981
|
+
case "outside": return `refused: "${path}" is not inside this session's workspace, so the review panel cannot list it. Annotate a file of the workspace the user is working in.`;
|
|
2982
|
+
case "no-workspace": return `refused: this session has no workspace, so there is no file list to add "${path}" to.`;
|
|
2983
|
+
default: return `refused: "${path}" could not be read as a text file, so there is nothing to annotate. Check the path (a workspace-relative path is fine) and try again.`;
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
/**
|
|
2987
|
+
* Build the body one tool call runs: resolve the file, apply the rule, store the record.
|
|
2988
|
+
*
|
|
2989
|
+
* A refusal is NOT an error: "those lines belong to a card" is an answer about the code, not a broken
|
|
2990
|
+
* call, and returning it as text keeps the turn going so the agent can act on it in the same step.
|
|
2991
|
+
* Throwing is left for what it means to the registry: the call could not be carried out at all.
|
|
2992
|
+
*
|
|
2993
|
+
* @param deps - the host lookups and the store write.
|
|
2994
|
+
* @returns the body to hand to {@link annotateToolDefinition}.
|
|
2995
|
+
*/
|
|
2996
|
+
function annotateRun(deps) {
|
|
2997
|
+
return async (args, context) => {
|
|
2998
|
+
if (context.sessionId === void 0) return "refused: this call did not come from a session, so there is no panel to annotate in.";
|
|
2999
|
+
await deps.ready?.();
|
|
3000
|
+
const sessionId = context.sessionId;
|
|
3001
|
+
const lookup = lookupEntry(deps.entriesOf(sessionId), args.path);
|
|
3002
|
+
if (lookup.kind === "many") return `refused: "${args.path}" matches more than one pending file: ${lookup.paths.join(", ")}. Pass the full path of the one you mean.`;
|
|
3003
|
+
let entry;
|
|
3004
|
+
let added = false;
|
|
3005
|
+
if (lookup.kind === "one") entry = lookup.entry;
|
|
3006
|
+
else {
|
|
3007
|
+
const outcome = await deps.listFile(sessionId, args.path, context.signal);
|
|
3008
|
+
if (outcome.kind !== "listed") {
|
|
3009
|
+
deps.log?.(`annotate: not-listed(${outcome.kind}) (${args.path})`);
|
|
3010
|
+
return unlistedText(outcome.kind, args.path);
|
|
3011
|
+
}
|
|
3012
|
+
entry = outcome.entry;
|
|
3013
|
+
added = outcome.added;
|
|
3014
|
+
}
|
|
3015
|
+
const result = annotateLines({
|
|
3016
|
+
sessionId,
|
|
3017
|
+
entry,
|
|
3018
|
+
startLine: args.startLine,
|
|
3019
|
+
endLine: args.endLine,
|
|
3020
|
+
note: args.note,
|
|
3021
|
+
quote: args.quote
|
|
3022
|
+
}, deps.commentsOf(sessionId));
|
|
3023
|
+
if (result.outcome !== "annotated") {
|
|
3024
|
+
deps.log?.(`annotate: ${refusalSummary(result)} (${entry.path})`);
|
|
3025
|
+
return refusalText(result, entry.path);
|
|
3026
|
+
}
|
|
3027
|
+
deps.addComment(result.comment);
|
|
3028
|
+
const { startLine, endLine } = result.comment.anchor;
|
|
3029
|
+
const span = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
|
|
3030
|
+
const listed = added ? ", and the file was added to the list" : "";
|
|
3031
|
+
return `annotated ${entry.path}:${span} (card ${result.comment.id}${listed}). The user sees it in the review panel now; they can reply to it there, and their reply reaches you as a [评论] message.`;
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
1333
3034
|
//#endregion
|
|
1334
3035
|
//#region lib/types/font-slices.js
|
|
1335
3036
|
/**
|
|
@@ -1392,13 +3093,17 @@ const FONT_ASSET_DIR = "../assets/fonts";
|
|
|
1392
3093
|
* directory.
|
|
1393
3094
|
*
|
|
1394
3095
|
* Scope of one import (mirrors the panel's preferences):
|
|
1395
|
-
* - modified files (
|
|
1396
|
-
*
|
|
3096
|
+
* - modified files, imported as `edit` (old = baseline, new = working). The GIT baseline is the
|
|
3097
|
+
* last commit, not the index: this list answers "what is not committed yet", so a change the
|
|
3098
|
+
* reader already `git add`ed is as much a review item as one they have not — staging is a step
|
|
3099
|
+
* towards a commit, not a decision this panel records (`git diff HEAD` semantics). svn compares
|
|
3100
|
+
* against BASE and p4 against `#have`, which are their "last commit" already;
|
|
1397
3101
|
* - deleted files, imported as `edit` with an empty new side (revert restores);
|
|
1398
|
-
* - new/untracked files
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1401
|
-
*
|
|
3102
|
+
* - new/untracked files, imported as `create` ONLY when the untracked preference is on: git `??`
|
|
3103
|
+
* (never added), svn unversioned, p4 not-yet-opened. For p4 that means a full workspace scan
|
|
3104
|
+
* (`p4 status`, which can be slow); with the preference off only already-opened files are read.
|
|
3105
|
+
* A file that is new but already under version control (git `A`, svn `added`) needs no
|
|
3106
|
+
* preference: it is an uncommitted change like any other.
|
|
1402
3107
|
* @module dsh-diff-approval/vcs
|
|
1403
3108
|
*/
|
|
1404
3109
|
/** Cap on one VCS command's runtime; scans (git status, svn status) can be slow
|
|
@@ -1412,25 +3117,66 @@ const GIT_BLOB_STDOUT_SLACK = 1024;
|
|
|
1412
3117
|
function shq(value) {
|
|
1413
3118
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
1414
3119
|
}
|
|
1415
|
-
/**
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
3120
|
+
/** Resolve one path to its real (link-followed) form. A path that is not on disk yet — a file git
|
|
3121
|
+
* still reports as untracked, or a scope below a directory that does not exist — has no real path
|
|
3122
|
+
* of its own, so the deepest ancestor that does exist is resolved and the rest re-appended:
|
|
3123
|
+
* `resolve()` collapses the `..`/`.` segments of that tail. When nothing on the way resolves, the
|
|
3124
|
+
* lexical path is what is left and the comparison falls back to the purely lexical one. */
|
|
3125
|
+
function realpathOrSelf(absolutePath) {
|
|
3126
|
+
const path = resolve(absolutePath);
|
|
3127
|
+
try {
|
|
3128
|
+
return realpathSync.native(path);
|
|
3129
|
+
} catch {
|
|
3130
|
+
const parent = dirname(path);
|
|
3131
|
+
if (parent === path) return path;
|
|
3132
|
+
const resolvedParent = realpathOrSelf(parent);
|
|
3133
|
+
if (resolvedParent === parent) return path;
|
|
3134
|
+
return resolve(resolvedParent, path.slice(parent.length + 1));
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
/** Resolve the workspace root and the scope once, for {@link inScanScope} to reuse per row. */
|
|
3138
|
+
function realRoots(workspaceRoot, scope) {
|
|
3139
|
+
return { real: {
|
|
3140
|
+
workspace: realpathOrSelf(workspaceRoot),
|
|
3141
|
+
scope: scope === void 0 ? void 0 : realpathOrSelf(scope)
|
|
3142
|
+
} };
|
|
3143
|
+
}
|
|
3144
|
+
/** The prefix test itself, on two already-absolute and already-resolved paths: case-insensitive on
|
|
3145
|
+
* Windows, separator-aware so a sibling sharing a name prefix (`/w/repo-x` vs `/w/repo`) is not
|
|
3146
|
+
* read as inside. On Windows `/` and `\` name the same separator, so both are folded to `sep`. */
|
|
3147
|
+
function isLexicallyInside(absolutePath, root) {
|
|
3148
|
+
const fold = (value) => {
|
|
3149
|
+
const unified = sep === "\\" ? value.replace(/\//g, sep) : value;
|
|
3150
|
+
return process.platform === "win32" ? unified.toLowerCase() : unified;
|
|
3151
|
+
};
|
|
3152
|
+
const path = fold(absolutePath);
|
|
3153
|
+
const base = fold(root);
|
|
1420
3154
|
if (path === base) return true;
|
|
1421
3155
|
return path.startsWith(base + sep);
|
|
1422
3156
|
}
|
|
3157
|
+
/** Path-inside check on real paths, case-insensitive on Windows. Both sides are put in their real
|
|
3158
|
+
* (link-followed) form first: a workspace reached through a symlink, a Windows junction or a mapped
|
|
3159
|
+
* path is the same directory as its target, and comparing the two spellings lexically reads one
|
|
3160
|
+
* file as two unrelated paths — which silently drops every imported change. The lexical check is
|
|
3161
|
+
* still the answer when neither side resolves (nothing exists to follow). */
|
|
3162
|
+
function isPathInside(absolutePath, root) {
|
|
3163
|
+
const path = resolve(absolutePath);
|
|
3164
|
+
const base = resolve(root);
|
|
3165
|
+
const realPath = realpathOrSelf(path);
|
|
3166
|
+
const realRoot = realpathOrSelf(base);
|
|
3167
|
+
if (realPath === path && realRoot === base) return isLexicallyInside(path, base);
|
|
3168
|
+
return isLexicallyInside(realPath, realRoot);
|
|
3169
|
+
}
|
|
1423
3170
|
/**
|
|
1424
3171
|
* Whether one changed path belongs to the scan: inside the workspace, and inside
|
|
1425
3172
|
* the narrowed `scope` when the caller set one.
|
|
1426
3173
|
* @param absolutePath - the changed file's absolute path.
|
|
1427
|
-
* @param
|
|
1428
|
-
* @param scope - one path to restrict the scan to, or undefined for all of it.
|
|
3174
|
+
* @param roots - the workspace root and scope, resolved once per scan by {@link realRoots}.
|
|
1429
3175
|
* @returns whether the change is in scope.
|
|
1430
3176
|
*/
|
|
1431
|
-
function inScanScope(absolutePath,
|
|
1432
|
-
if (!isPathInside(absolutePath,
|
|
1433
|
-
return scope === void 0 || isPathInside(absolutePath, scope);
|
|
3177
|
+
function inScanScope(absolutePath, roots) {
|
|
3178
|
+
if (!isPathInside(absolutePath, roots.real.workspace)) return false;
|
|
3179
|
+
return roots.real.scope === void 0 || isPathInside(absolutePath, roots.real.scope);
|
|
1434
3180
|
}
|
|
1435
3181
|
/** The VCS marker of one directory, or undefined when it holds none. */
|
|
1436
3182
|
function markerOf(directory) {
|
|
@@ -1460,23 +3206,49 @@ function detectVcsRoot(start) {
|
|
|
1460
3206
|
}
|
|
1461
3207
|
/** Run one command through the shell executor; a non-zero exit throws. */
|
|
1462
3208
|
async function runShell(shell, command, workdir, signal, stdoutMaxBytes) {
|
|
1463
|
-
const
|
|
3209
|
+
const result = await runThrough(shell, {
|
|
1464
3210
|
command,
|
|
1465
3211
|
workdir,
|
|
1466
3212
|
timeoutMs: VCS_COMMAND_TIMEOUT_MS,
|
|
1467
3213
|
signal,
|
|
1468
3214
|
stdoutMaxBytes
|
|
1469
3215
|
});
|
|
1470
|
-
const result = await shell.run(spec);
|
|
1471
3216
|
if (result.exitCode !== 0) {
|
|
1472
3217
|
const detail = (result.stderr.text || result.stdout.text).trim();
|
|
1473
3218
|
throw new Error(`command failed (exit ${String(result.exitCode)}): ${detail || command}`);
|
|
1474
3219
|
}
|
|
1475
3220
|
return result.stdout.text;
|
|
1476
3221
|
}
|
|
3222
|
+
/**
|
|
3223
|
+
* One command, through whichever era of the shell seam is loaded.
|
|
3224
|
+
*
|
|
3225
|
+
* @param shell - the deployment's shell service.
|
|
3226
|
+
* @param request - the command, its workdir, deadline and capture budget.
|
|
3227
|
+
* @returns the run's exit code and collected streams.
|
|
3228
|
+
* @throws when the service offers neither entry point, naming the methods it does offer — the alternative
|
|
3229
|
+
* is the message that brought this code here, `shell.run is not a function`, which says nothing about
|
|
3230
|
+
* what the seam became.
|
|
3231
|
+
*/
|
|
3232
|
+
async function runThrough(shell, request) {
|
|
3233
|
+
if (typeof shell.execute === "function") {
|
|
3234
|
+
const spec = typeof shell.resolve === "function" ? shell.resolve(request) : request;
|
|
3235
|
+
const handle = await shell.execute(spec);
|
|
3236
|
+
if (handle !== null && typeof handle.result === "function") return await handle.result();
|
|
3237
|
+
throw new Error("the shell executor returned a handle with no result(); the seam changed again");
|
|
3238
|
+
}
|
|
3239
|
+
if (typeof shell.run === "function") return await shell.run(typeof shell.resolve === "function" ? shell.resolve(request) : request);
|
|
3240
|
+
const names = /* @__PURE__ */ new Set();
|
|
3241
|
+
for (let node = shell; node !== null && node !== Object.prototype; node = Object.getPrototypeOf(node)) for (const name of Object.getOwnPropertyNames(node)) names.add(name);
|
|
3242
|
+
const offered = [...names].filter((name) => name !== "constructor" && typeof shell[name] === "function");
|
|
3243
|
+
throw new Error(`the shell service offers neither execute() nor run() (it has: ${offered.join(", ") || "no methods"})`);
|
|
3244
|
+
}
|
|
1477
3245
|
/** Parse `git status --porcelain=v1 -z` output into (XY, repo-relative path)
|
|
1478
|
-
* records.
|
|
1479
|
-
*
|
|
3246
|
+
* records. In `-z` a rename/copy record is `XY <to>\0<from>\0`: the record's own
|
|
3247
|
+
* field holds the path that exists in the worktree, and the ORIGIN follows as its
|
|
3248
|
+
* own NUL field, which is consumed and skipped. (`orig -> dest` is the non-`-z`
|
|
3249
|
+
* spelling; verified against git: `git mv a.txt b.txt` gives `R b.txt\0a.txt\0`.)
|
|
3250
|
+
* The status command below turns rename detection off, so this branch is a fallback
|
|
3251
|
+
* for a caller that enables it. */
|
|
1480
3252
|
function parseGitPorcelainZ(output) {
|
|
1481
3253
|
const records = [];
|
|
1482
3254
|
let index = 0;
|
|
@@ -1501,44 +3273,62 @@ function parseGitPorcelainZ(output) {
|
|
|
1501
3273
|
}
|
|
1502
3274
|
return records;
|
|
1503
3275
|
}
|
|
3276
|
+
/** The status command both the import and the per-file refresh read. Rename detection is off, so
|
|
3277
|
+
* a rename reads as its two halves (a delete and an add) rather than a row the review cannot
|
|
3278
|
+
* express. */
|
|
3279
|
+
const GIT_STATUS_COMMAND = "git -c status.renames=false status --porcelain=v1 -z --untracked-files=all";
|
|
3280
|
+
/**
|
|
3281
|
+
* Read one file's content at a revision (`<rev>:<path>`), or undefined when that revision has no
|
|
3282
|
+
* such path (a new file, or a repo whose HEAD is unborn).
|
|
3283
|
+
*
|
|
3284
|
+
* The blob is read straight off `git show` with a per-call stdout budget raised to the blob's own
|
|
3285
|
+
* size the way `git cat-file -s` reports it, so the executor cannot truncate a large baseline.
|
|
3286
|
+
* Read-only, no temp-file write, so it works even where the sandbox denies a write to the repo
|
|
3287
|
+
* root.
|
|
3288
|
+
* @param root - the repository root (the command's working directory).
|
|
3289
|
+
* @param revision - the revision to read, e.g. `HEAD`.
|
|
3290
|
+
* @param rel - the repository-relative path.
|
|
3291
|
+
* @param shell - the deployment's shell executor.
|
|
3292
|
+
* @param signal - the caller's abort signal.
|
|
3293
|
+
* @returns the file's content at that revision, or undefined when it has none.
|
|
3294
|
+
*/
|
|
3295
|
+
async function readGitBlob(root, revision, rel, shell, signal) {
|
|
3296
|
+
try {
|
|
3297
|
+
const reported = Number.parseInt((await runShell(shell, `git cat-file -s ${revision}:${shq(rel)}`, root, signal)).trim(), 10);
|
|
3298
|
+
const size = Number.isFinite(reported) ? reported : 0;
|
|
3299
|
+
return await runShell(shell, `git show ${revision}:${shq(rel)}`, root, signal, size + GIT_BLOB_STDOUT_SLACK);
|
|
3300
|
+
} catch {
|
|
3301
|
+
return;
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
1504
3304
|
/** Enumerate the workspace's local changes in a git checkout. */
|
|
1505
3305
|
async function gitChanges(input) {
|
|
1506
3306
|
const { root, workspaceRoot, includeUntracked, scope, shell, readText, signal } = input;
|
|
1507
|
-
const
|
|
3307
|
+
const roots = realRoots(workspaceRoot, scope);
|
|
3308
|
+
const stdout = await runShell(shell, GIT_STATUS_COMMAND, root, signal);
|
|
1508
3309
|
const changes = [];
|
|
1509
3310
|
for (const { xy, rel } of parseGitPorcelainZ(stdout)) {
|
|
1510
3311
|
const absolute = resolve(root, rel);
|
|
1511
|
-
if (!inScanScope(absolute,
|
|
3312
|
+
if (!inScanScope(absolute, roots)) continue;
|
|
1512
3313
|
if (xy === "??") {
|
|
1513
3314
|
if (!includeUntracked) continue;
|
|
1514
3315
|
const newText = await readText(absolute) ?? "";
|
|
1515
3316
|
changes.push({
|
|
1516
3317
|
path: absolute,
|
|
1517
3318
|
kind: "create",
|
|
1518
|
-
oldText: "",
|
|
1519
|
-
newText
|
|
1520
|
-
});
|
|
1521
|
-
continue;
|
|
1522
|
-
}
|
|
1523
|
-
const worktree = xy[1] ?? " ";
|
|
1524
|
-
if (worktree !== "M" && worktree !== "D") continue;
|
|
1525
|
-
let oldText = "";
|
|
1526
|
-
try {
|
|
1527
|
-
let size = 0;
|
|
1528
|
-
try {
|
|
1529
|
-
size = Number.parseInt((await runShell(shell, `git cat-file -s :0:${shq(rel)}`, root, signal)).trim(), 10);
|
|
1530
|
-
} catch {
|
|
1531
|
-
size = 0;
|
|
1532
|
-
}
|
|
1533
|
-
if (Number.isFinite(size) && size > 0) oldText = await runShell(shell, `git show :0:${shq(rel)}`, root, signal, size + GIT_BLOB_STDOUT_SLACK);
|
|
1534
|
-
} catch {
|
|
1535
|
-
oldText = "";
|
|
3319
|
+
oldText: "",
|
|
3320
|
+
newText
|
|
3321
|
+
});
|
|
3322
|
+
continue;
|
|
1536
3323
|
}
|
|
3324
|
+
const baseline = await readGitBlob(root, "HEAD", rel, shell, signal);
|
|
3325
|
+
const worktree = xy[1] ?? " ";
|
|
3326
|
+
const oldText = baseline ?? "";
|
|
1537
3327
|
const newText = worktree === "D" ? "" : await readText(absolute) ?? "";
|
|
1538
3328
|
if (oldText === "" && newText === "") continue;
|
|
1539
3329
|
changes.push({
|
|
1540
3330
|
path: absolute,
|
|
1541
|
-
kind: "edit",
|
|
3331
|
+
kind: baseline === void 0 ? "create" : "edit",
|
|
1542
3332
|
oldText,
|
|
1543
3333
|
newText
|
|
1544
3334
|
});
|
|
@@ -1552,6 +3342,7 @@ function xmlUnescape(value) {
|
|
|
1552
3342
|
/** Enumerate the workspace's local changes in an svn working copy. */
|
|
1553
3343
|
async function svnChanges(input) {
|
|
1554
3344
|
const { root, workspaceRoot, includeUntracked, scope, shell, readText, signal } = input;
|
|
3345
|
+
const roots = realRoots(workspaceRoot, scope);
|
|
1555
3346
|
const stdout = await runShell(shell, "svn status --xml", root, signal);
|
|
1556
3347
|
const changes = [];
|
|
1557
3348
|
const entryPattern = /<entry[^>]*path="([^"]*)"[^>]*>\s*<wc-status[^>]*item="([^"]*)"/g;
|
|
@@ -1560,7 +3351,7 @@ async function svnChanges(input) {
|
|
|
1560
3351
|
const rel = xmlUnescape(match[1]);
|
|
1561
3352
|
const item = match[2];
|
|
1562
3353
|
const absolute = resolve(root, rel);
|
|
1563
|
-
if (!inScanScope(absolute,
|
|
3354
|
+
if (!inScanScope(absolute, roots)) continue;
|
|
1564
3355
|
if (item === "modified" || item === "deleted") {
|
|
1565
3356
|
let oldText = "";
|
|
1566
3357
|
try {
|
|
@@ -1616,7 +3407,9 @@ function p4ChangeOf(line) {
|
|
|
1616
3407
|
* the scan — which can be slow — is skipped. */
|
|
1617
3408
|
async function p4Changes(input) {
|
|
1618
3409
|
const { root, workspaceRoot, includeUntracked, scope, shell, readText, signal } = input;
|
|
1619
|
-
const
|
|
3410
|
+
const command = includeUntracked ? "p4 status" : "p4 opened";
|
|
3411
|
+
const roots = realRoots(workspaceRoot, scope);
|
|
3412
|
+
const stdout = await runShell(shell, command, root, signal);
|
|
1620
3413
|
const changes = [];
|
|
1621
3414
|
for (const line of stdout.split("\n")) {
|
|
1622
3415
|
const opened = p4ChangeOf(line);
|
|
@@ -1624,7 +3417,7 @@ async function p4Changes(input) {
|
|
|
1624
3417
|
const local = (await runShell(shell, `p4 where ${shq(opened.depot)}`, root, signal)).trim().split(/\s+/).pop();
|
|
1625
3418
|
if (local === void 0 || local.length === 0) continue;
|
|
1626
3419
|
const absolute = resolve(local);
|
|
1627
|
-
if (!inScanScope(absolute,
|
|
3420
|
+
if (!inScanScope(absolute, roots)) continue;
|
|
1628
3421
|
const deleted = opened.action === "delete" || opened.action === "move/delete";
|
|
1629
3422
|
const created = opened.action === "add" || opened.action === "move/add";
|
|
1630
3423
|
const newText = deleted ? "" : await readText(absolute) ?? "";
|
|
@@ -1701,14 +3494,19 @@ const name = "diff-approval";
|
|
|
1701
3494
|
* Services required before the review surface activates. `webServer` rides with
|
|
1702
3495
|
* `connection` (both come from the web bundle), and is named explicitly because
|
|
1703
3496
|
* the channel's owner context must be able to resolve it — see
|
|
1704
|
-
* `ConnectionServiceSurface`.
|
|
3497
|
+
* `ConnectionServiceSurface`. `sessionController` and `agents` are what a comment is
|
|
3498
|
+
* asked through and what reports the turn that claimed it: both come from the same
|
|
3499
|
+
* base composition this plugin already requires for `sessions`, so a host that could
|
|
3500
|
+
* list pending changes at all has them.
|
|
1705
3501
|
*/
|
|
1706
3502
|
const inject = [
|
|
1707
3503
|
"fs",
|
|
1708
3504
|
"connection",
|
|
1709
3505
|
"webServer",
|
|
1710
3506
|
"workspaceRegistry",
|
|
1711
|
-
"sessions"
|
|
3507
|
+
"sessions",
|
|
3508
|
+
"sessionController",
|
|
3509
|
+
"agents"
|
|
1712
3510
|
];
|
|
1713
3511
|
/** The connection RPC channel this plugin serves. */
|
|
1714
3512
|
const DIFF_APPROVAL_CHANNEL = "/diff-approval";
|
|
@@ -1760,26 +3558,28 @@ function lazyCommentSkill(ctx, registered) {
|
|
|
1760
3558
|
};
|
|
1761
3559
|
}
|
|
1762
3560
|
/**
|
|
1763
|
-
* Register
|
|
1764
|
-
*
|
|
1765
|
-
*
|
|
1766
|
-
*
|
|
3561
|
+
* Register one of this plugin's skills with the harness's skill registry, when it has one. The
|
|
3562
|
+
* registry's own `skill` tool advertises the catalog and loads a body on demand, so this costs nothing
|
|
3563
|
+
* until the situation it is about comes up — and unlike a system-prompt section, a global registration
|
|
3564
|
+
* here cannot shape unrelated turns.
|
|
1767
3565
|
*
|
|
1768
|
-
* Feature-detected and contained: a build without the registry, or a registry that
|
|
1769
|
-
*
|
|
1770
|
-
*
|
|
3566
|
+
* Feature-detected and contained: a build without the registry, or a registry that refuses the
|
|
3567
|
+
* contribution, leaves whatever the prompts already carry as the whole policy (the comment prompt names
|
|
3568
|
+
* its skill inline; the annotating rules lose only their long form, and the tool description still says
|
|
3569
|
+
* what the tool is for).
|
|
1771
3570
|
*
|
|
1772
3571
|
* @param ctx - the plugin's host context.
|
|
3572
|
+
* @param skill - the contribution to register.
|
|
1773
3573
|
* @returns whether the skill is now in the registry.
|
|
1774
3574
|
*/
|
|
1775
|
-
function
|
|
3575
|
+
function registerRuntimeSkill(ctx, skill) {
|
|
1776
3576
|
if (typeof ctx.get("skills")?.register !== "function") return false;
|
|
1777
3577
|
let registered = false;
|
|
1778
3578
|
ctx.effect(() => {
|
|
1779
3579
|
const skills = ctx.get("skills");
|
|
1780
3580
|
if (typeof skills?.register !== "function") return () => {};
|
|
1781
3581
|
try {
|
|
1782
|
-
const dispose = skills.register(
|
|
3582
|
+
const dispose = skills.register(skill);
|
|
1783
3583
|
registered = true;
|
|
1784
3584
|
return typeof dispose === "function" ? dispose : () => {};
|
|
1785
3585
|
} catch {
|
|
@@ -1788,6 +3588,36 @@ function registerCommentSkill(ctx) {
|
|
|
1788
3588
|
});
|
|
1789
3589
|
return registered;
|
|
1790
3590
|
}
|
|
3591
|
+
/**
|
|
3592
|
+
* Register the annotate tool with the harness's tool registry, when it has one.
|
|
3593
|
+
*
|
|
3594
|
+
* Feature-detected like the skills, and deliberately NOT declared in `inject`: the host half of this
|
|
3595
|
+
* plugin imports nothing from the harness at runtime (its harness packages are services it asks for by
|
|
3596
|
+
* name, and types), so a composition without a tool registry keeps working with the tool simply absent
|
|
3597
|
+
* — where an unsatisfied `inject` would defer the whole plugin, the trap `src/index.ts` documents for
|
|
3598
|
+
* `sessionController`.
|
|
3599
|
+
*
|
|
3600
|
+
* @param ctx - the plugin's host context.
|
|
3601
|
+
* @param deps - what fulfils one call (see `annotate-tool.ts`).
|
|
3602
|
+
* @returns whether the tool is now in the registry.
|
|
3603
|
+
*/
|
|
3604
|
+
function registerAnnotateTool(ctx, deps) {
|
|
3605
|
+
if (typeof ctx.get("tools")?.register !== "function") return false;
|
|
3606
|
+
let registered = false;
|
|
3607
|
+
ctx.effect(() => {
|
|
3608
|
+
const tools = ctx.get("tools");
|
|
3609
|
+
if (typeof tools?.register !== "function") return () => {};
|
|
3610
|
+
try {
|
|
3611
|
+
const dispose = tools.register(annotateToolDefinition(annotateRun(deps)));
|
|
3612
|
+
registered = true;
|
|
3613
|
+
return typeof dispose === "function" ? dispose : () => {};
|
|
3614
|
+
} catch (error) {
|
|
3615
|
+
ctx.logger.warn(`diff-approval: the annotate tool was refused by the tool registry: ${errorMessage(error)}`);
|
|
3616
|
+
return () => {};
|
|
3617
|
+
}
|
|
3618
|
+
});
|
|
3619
|
+
return registered;
|
|
3620
|
+
}
|
|
1791
3621
|
/** The dominant line ending of `text`: CRLF when `\r\n` meets or beats lone
|
|
1792
3622
|
* `\n`, else LF; a text with no line breaks falls back to LF.
|
|
1793
3623
|
* @param text - the content to inspect.
|
|
@@ -1981,9 +3811,83 @@ function apply(ctx, config) {
|
|
|
1981
3811
|
const storageDir = config?.storageDir;
|
|
1982
3812
|
if (storageDir !== void 0 && (typeof storageDir !== "string" || storageDir.trim().length === 0)) throw new Error("diff-approval: storageDir must be a non-empty string");
|
|
1983
3813
|
const store = new PendingDiffStore();
|
|
1984
|
-
const
|
|
3814
|
+
const storageRoot = resolve(expandHomePath(storageDir ?? defaultStorageDir()));
|
|
3815
|
+
const persistence = new PendingPersistence(storageRoot);
|
|
3816
|
+
const comments = new CommentStore(commentsDirFor(storageRoot), {
|
|
3817
|
+
onLoadSkipped: (file, error) => {
|
|
3818
|
+
ctx.logger.warn(`diff-approval: skipping unreadable comment file '${file}' (moved to '.corrupt'): ${errorMessage(error)}`);
|
|
3819
|
+
},
|
|
3820
|
+
onPersistError: (message) => {
|
|
3821
|
+
if (message === void 0) return;
|
|
3822
|
+
ctx.logger.error(`diff-approval: writing comments failed, so the threads will not survive a restart: ${message}`);
|
|
3823
|
+
}
|
|
3824
|
+
});
|
|
3825
|
+
const commentAsker = new CommentAsker(ctx, comments);
|
|
3826
|
+
/**
|
|
3827
|
+
* The lines each comment sits on now, resolved from the entry content it hangs off and held
|
|
3828
|
+
* against the version of that content it was resolved at (see `resolveCommentLines` for the rule
|
|
3829
|
+
* and `PendingDiffStore.contentVersion` for the versions).
|
|
3830
|
+
*
|
|
3831
|
+
* The list read is the hot path — every client of the session asks once a second — so a figure
|
|
3832
|
+
* that costs a search of the entry's whole content is computed when the content can have changed
|
|
3833
|
+
* and reused until it has. Nothing is written to the comment record: a resolved line is DERIVED,
|
|
3834
|
+
* and a persisted one would outlive the content it was true of.
|
|
3835
|
+
*/
|
|
3836
|
+
const commentLines = /* @__PURE__ */ new Map();
|
|
3837
|
+
/**
|
|
3838
|
+
* The new-file lines this session's comments sit on now, keyed by comment id.
|
|
3839
|
+
*
|
|
3840
|
+
* This is the whole point of resolving in the host: the list pane, the code view's own card and
|
|
3841
|
+
* the jump that opens it draw ONE figure, and they draw it without anything having been opened —
|
|
3842
|
+
* a comment used to be corrected by the open file's pane and published back to the list, so the
|
|
3843
|
+
* list read `382` while the card read `378` until the reader opened the file.
|
|
3844
|
+
*
|
|
3845
|
+
* A comment whose quote is gone from the content is ABSENT from the map rather than guessed at:
|
|
3846
|
+
* the caller then falls back to `record.anchor`, the line the comment was written on, which is
|
|
3847
|
+
* the same answer an outdated thread shows.
|
|
3848
|
+
* @param sessionId - the session whose comments to resolve.
|
|
3849
|
+
* @returns the resolved lines per comment id; comments that do not resolve are left out.
|
|
3850
|
+
*/
|
|
3851
|
+
function resolvedCommentLines(sessionId) {
|
|
3852
|
+
const resolved = {};
|
|
3853
|
+
for (const comment of comments.list(sessionId)) {
|
|
3854
|
+
const entry = store.get(comment.entryId);
|
|
3855
|
+
if (entry === void 0) continue;
|
|
3856
|
+
const version = store.contentVersion(comment.entryId);
|
|
3857
|
+
const cached = commentLines.get(comment.id);
|
|
3858
|
+
if (cached !== void 0 && cached.entryId === comment.entryId && cached.version === version && cached.quote === comment.quote && cached.context === comment.quoteContext && cached.startLine === comment.anchor.startLine && cached.endLine === comment.anchor.endLine) {
|
|
3859
|
+
if (cached.range !== void 0) resolved[comment.id] = cached.range;
|
|
3860
|
+
continue;
|
|
3861
|
+
}
|
|
3862
|
+
const range = resolveCommentLines(entry.newText, comment);
|
|
3863
|
+
commentLines.set(comment.id, {
|
|
3864
|
+
entryId: comment.entryId,
|
|
3865
|
+
version,
|
|
3866
|
+
quote: comment.quote,
|
|
3867
|
+
context: comment.quoteContext,
|
|
3868
|
+
startLine: comment.anchor.startLine,
|
|
3869
|
+
endLine: comment.anchor.endLine,
|
|
3870
|
+
range
|
|
3871
|
+
});
|
|
3872
|
+
if (range !== void 0) resolved[comment.id] = range;
|
|
3873
|
+
}
|
|
3874
|
+
return resolved;
|
|
3875
|
+
}
|
|
3876
|
+
/**
|
|
3877
|
+
* Drop the resolved lines of every comment that hangs off one entry. They are what an entry takes
|
|
3878
|
+
* with it when it leaves the list (`dropEntry` removes those comments), so their figures go too.
|
|
3879
|
+
* @param entryId - the entry that left the list.
|
|
3880
|
+
*/
|
|
3881
|
+
function forgetCommentLinesForEntry(entryId) {
|
|
3882
|
+
for (const [id, row] of commentLines) if (row.entryId === entryId) commentLines.delete(id);
|
|
3883
|
+
}
|
|
3884
|
+
/** Drop the resolved lines of the comments the store no longer holds (the orphan sweep's leavings). */
|
|
3885
|
+
function forgetOrphanedCommentLines() {
|
|
3886
|
+
for (const id of commentLines.keys()) if (comments.get(id) === void 0) commentLines.delete(id);
|
|
3887
|
+
}
|
|
1985
3888
|
const launchPath = config?.openPath ?? defaultOpenPath;
|
|
1986
|
-
const commentSkill = lazyCommentSkill(ctx,
|
|
3889
|
+
const commentSkill = lazyCommentSkill(ctx, registerRuntimeSkill(ctx, COMMENT_SKILL));
|
|
3890
|
+
registerRuntimeSkill(ctx, ANNOTATE_SKILL);
|
|
1987
3891
|
/** Hydrate the globally-unique store once from the single persistence file. */
|
|
1988
3892
|
let loadPromise;
|
|
1989
3893
|
const ensureLoaded = () => {
|
|
@@ -1995,9 +3899,96 @@ function apply(ctx, config) {
|
|
|
1995
3899
|
} catch (error) {
|
|
1996
3900
|
ctx.logger.error(`diff-approval: loading persisted state failed, so the list starts empty: ${errorMessage(error)}`);
|
|
1997
3901
|
}
|
|
3902
|
+
try {
|
|
3903
|
+
if (await comments.loadAll() > 0 && comments.skippedFiles().length === 0) comments.retain(new Set(store.all().map((entry) => entry.id)));
|
|
3904
|
+
} catch (error) {
|
|
3905
|
+
ctx.logger.error(`diff-approval: loading comment state failed, so the comments start empty: ${errorMessage(error)}`);
|
|
3906
|
+
}
|
|
1998
3907
|
})();
|
|
1999
3908
|
return loadPromise;
|
|
2000
3909
|
};
|
|
3910
|
+
registerAnnotateTool(ctx, {
|
|
3911
|
+
ready: ensureLoaded,
|
|
3912
|
+
entriesOf: (sessionId) => store.list(sessionId),
|
|
3913
|
+
commentsOf: (sessionId) => comments.list(sessionId),
|
|
3914
|
+
addComment: (record) => {
|
|
3915
|
+
comments.add(record);
|
|
3916
|
+
},
|
|
3917
|
+
listFile: (sessionId, asked, signal) => listFileForAnnotation(sessionId, asked, signal),
|
|
3918
|
+
log: (message) => ctx.logger.info(`diff-approval: ${message}`)
|
|
3919
|
+
});
|
|
3920
|
+
/**
|
|
3921
|
+
* Admit one file into a session's list, so an annotation can hang on it.
|
|
3922
|
+
*
|
|
3923
|
+
* The panel lists what the workspace has pending; an agent that wants to explain a file nobody is
|
|
3924
|
+
* reviewing has nowhere to put the card until the file is listed. This is the same admission the
|
|
3925
|
+
* reader's own "add this path" performs (`add-path`), reached by the agent instead of by hand: both
|
|
3926
|
+
* sides of the entry are the file's own text — the "no pending diff" shape a scanned-clean file
|
|
3927
|
+
* already lands with — so the file opens at its own content and the card sits on the lines it names.
|
|
3928
|
+
*
|
|
3929
|
+
* It is deliberately that ROUTE and not a raw insert: a path already listed is answered from the list
|
|
3930
|
+
* (so annotating twice never re-baselines a review in progress), a path outside the workspace is
|
|
3931
|
+
* refused here rather than confined later, and what lands goes through the same fold + persist + undo
|
|
3932
|
+
* bookkeeping as every other admission.
|
|
3933
|
+
*
|
|
3934
|
+
* @param sessionId - the session whose list gains the file.
|
|
3935
|
+
* @param asked - the path the agent named, workspace-relative or absolute.
|
|
3936
|
+
* @param signal - the caller's lifetime, for the read.
|
|
3937
|
+
* @returns the listed entry (and whether this call is what listed it), or why it could not be listed.
|
|
3938
|
+
*/
|
|
3939
|
+
async function listFileForAnnotation(sessionId, asked, signal) {
|
|
3940
|
+
await ensureLoaded();
|
|
3941
|
+
const workspace = workspaceOf(sessionId);
|
|
3942
|
+
if (workspace === void 0) return { kind: "no-workspace" };
|
|
3943
|
+
const absolute = resolveInsideWorkspace(workspace.path, asked);
|
|
3944
|
+
if (absolute === void 0) return { kind: "outside" };
|
|
3945
|
+
const listed = store.list(sessionId).find((entry) => pathIdentity(entry.path) === pathIdentity(absolute));
|
|
3946
|
+
if (listed !== void 0) return {
|
|
3947
|
+
kind: "listed",
|
|
3948
|
+
entry: listed,
|
|
3949
|
+
added: false
|
|
3950
|
+
};
|
|
3951
|
+
const content = await readTextOrNone(absolute, signal ?? new AbortController().signal);
|
|
3952
|
+
if (content === void 0) return { kind: "missing" };
|
|
3953
|
+
await foldBatch(sessionId, [{
|
|
3954
|
+
id: absolute,
|
|
3955
|
+
sessionId,
|
|
3956
|
+
path: absolute,
|
|
3957
|
+
kind: "edit",
|
|
3958
|
+
oldText: content,
|
|
3959
|
+
newText: content,
|
|
3960
|
+
updatedAt: Date.now(),
|
|
3961
|
+
sessionIds: [sessionId]
|
|
3962
|
+
}], true);
|
|
3963
|
+
const landed = store.list(sessionId).find((entry) => pathIdentity(entry.path) === pathIdentity(absolute));
|
|
3964
|
+
return landed === void 0 ? { kind: "missing" } : {
|
|
3965
|
+
kind: "listed",
|
|
3966
|
+
entry: landed,
|
|
3967
|
+
added: true
|
|
3968
|
+
};
|
|
3969
|
+
}
|
|
3970
|
+
/**
|
|
3971
|
+
* Drop one entry from its session's list, ALONG WITH THE COMMENTS it carries.
|
|
3972
|
+
*
|
|
3973
|
+
* The comments go in the same tick as the entry — rather than being swept on some later read —
|
|
3974
|
+
* because a second client polling in between must never be handed a comment naming a file the same
|
|
3975
|
+
* read no longer lists. Synchronous: the comment store applies changes to memory as it goes and
|
|
3976
|
+
* persists on its own chain.
|
|
3977
|
+
*
|
|
3978
|
+
* This call is the DESTRUCTION the undo pair of every dropping action has to be able to reverse, so
|
|
3979
|
+
* each of those actions snapshots the entry's comments into its `before` side first (see
|
|
3980
|
+
* `droppingComments`). The panel still asks before a press that drops an entry carrying comments
|
|
3981
|
+
* (see the `remove-one` / `remove-*` prompts): undo covers the paths that have a pair, and the ask
|
|
3982
|
+
* covers the reader's intent either way.
|
|
3983
|
+
* @param id - the entry id (= path) leaving the list.
|
|
3984
|
+
* @returns whether the entry was there.
|
|
3985
|
+
*/
|
|
3986
|
+
function dropEntry(id) {
|
|
3987
|
+
const removed = store.remove(id);
|
|
3988
|
+
comments.removeForEntry(id);
|
|
3989
|
+
forgetCommentLinesForEntry(id);
|
|
3990
|
+
return removed;
|
|
3991
|
+
}
|
|
2001
3992
|
/** Pre-write bases captured at the intent seams, keyed by the tool call id. */
|
|
2002
3993
|
const editorIntents = /* @__PURE__ */ new Map();
|
|
2003
3994
|
/**
|
|
@@ -2123,8 +4114,8 @@ function apply(ctx, config) {
|
|
|
2123
4114
|
stack.length = 0;
|
|
2124
4115
|
stack.push(...kept);
|
|
2125
4116
|
};
|
|
2126
|
-
clean(
|
|
2127
|
-
clean(
|
|
4117
|
+
for (const stack of undoStacks.values()) clean(stack);
|
|
4118
|
+
for (const stack of redoStacks.values()) clean(stack);
|
|
2128
4119
|
}
|
|
2129
4120
|
/**
|
|
2130
4121
|
* Settle each listed entry against its live file. Existence is decided by the
|
|
@@ -2141,13 +4132,14 @@ function apply(ctx, config) {
|
|
|
2141
4132
|
for (const entry of store.list(sessionId)) {
|
|
2142
4133
|
const live = await liveStateOf(entry.path);
|
|
2143
4134
|
if (live.kind === "deleted") {
|
|
2144
|
-
|
|
2145
|
-
pushUndo(sessionId, {
|
|
4135
|
+
const checkpoint = droppingComments({
|
|
2146
4136
|
id: entry.path,
|
|
2147
4137
|
path: entry.path,
|
|
2148
4138
|
entry,
|
|
2149
4139
|
fileText: entry.newText
|
|
2150
|
-
}
|
|
4140
|
+
});
|
|
4141
|
+
dropEntry(entry.path);
|
|
4142
|
+
pushUndo(sessionId, checkpoint, {
|
|
2151
4143
|
id: entry.path,
|
|
2152
4144
|
path: entry.path,
|
|
2153
4145
|
entry: void 0,
|
|
@@ -2157,7 +4149,7 @@ function apply(ctx, config) {
|
|
|
2157
4149
|
continue;
|
|
2158
4150
|
}
|
|
2159
4151
|
if (live.kind === "unavailable") {
|
|
2160
|
-
|
|
4152
|
+
dropEntry(entry.path);
|
|
2161
4153
|
purgeForEntry(entry.path);
|
|
2162
4154
|
persistSession();
|
|
2163
4155
|
continue;
|
|
@@ -2168,7 +4160,7 @@ function apply(ctx, config) {
|
|
|
2168
4160
|
if (hasContent && content !== entry.newText) {
|
|
2169
4161
|
adopted = content;
|
|
2170
4162
|
const beforeText = entry.newText;
|
|
2171
|
-
const redoWasPresent =
|
|
4163
|
+
const redoWasPresent = redoStackOf(sessionId).length > 0;
|
|
2172
4164
|
store.update(entry.path, { newText: content });
|
|
2173
4165
|
pushUndo(sessionId, {
|
|
2174
4166
|
id: entry.path,
|
|
@@ -2246,19 +4238,7 @@ function apply(ctx, config) {
|
|
|
2246
4238
|
fileText: void 0
|
|
2247
4239
|
});
|
|
2248
4240
|
}
|
|
2249
|
-
if (batchBefore.length > 0)
|
|
2250
|
-
id: batchBefore[0].id,
|
|
2251
|
-
path: batchBefore[0].path,
|
|
2252
|
-
entry: void 0,
|
|
2253
|
-
fileText: void 0,
|
|
2254
|
-
batch: batchBefore
|
|
2255
|
-
}, {
|
|
2256
|
-
id: batchAfter[0].id,
|
|
2257
|
-
path: batchAfter[0].path,
|
|
2258
|
-
entry: void 0,
|
|
2259
|
-
fileText: void 0,
|
|
2260
|
-
batch: batchAfter
|
|
2261
|
-
});
|
|
4241
|
+
if (batchBefore.length > 0) pushBatchUndo(sessionId, batchBefore, batchAfter);
|
|
2262
4242
|
return folded;
|
|
2263
4243
|
}
|
|
2264
4244
|
/**
|
|
@@ -2380,6 +4360,56 @@ function apply(ctx, config) {
|
|
|
2380
4360
|
return policy === void 0 ? ctx.fs.writeText(target, content, void 0, signal) : ctx.fs.writeText(target, content, void 0, signal, policy);
|
|
2381
4361
|
}
|
|
2382
4362
|
/**
|
|
4363
|
+
* Delete one file as a Revert, under the same session sandbox policy the write
|
|
4364
|
+
* path carries.
|
|
4365
|
+
*
|
|
4366
|
+
* A Revert of a created file removes it, and a removal is a mutation like any
|
|
4367
|
+
* other: going through a raw `processPath` + `rm` bypassed the confinement
|
|
4368
|
+
* entirely, so a `workspace-write` session could delete a file the very same
|
|
4369
|
+
* policy would refuse to write. The mode is therefore checked here, against the
|
|
4370
|
+
* target's own path, and the backend that does confine is handed the policy as
|
|
4371
|
+
* well: the check is what a build with no confining backend still enforces, and
|
|
4372
|
+
* passing the policy through is what lets a confining one enforce its own,
|
|
4373
|
+
* stricter rules. The plain `rm` remains the last resort for a deployment whose
|
|
4374
|
+
* `ctx.fs` has no delete verb of its own.
|
|
4375
|
+
* @param target - the resolved target to delete.
|
|
4376
|
+
* @param sessionId - the session whose policy governs the delete.
|
|
4377
|
+
* @param signal - aborts before the delete is issued.
|
|
4378
|
+
*/
|
|
4379
|
+
async function removeRevert(target, sessionId, signal) {
|
|
4380
|
+
const policy = sandboxPolicyOf(sessionId);
|
|
4381
|
+
const osPath = processPathOf(target);
|
|
4382
|
+
if (policy !== void 0 && !withinSandboxRoot(osPath, policy)) throw new Error(`file access denied under ${policy.mode} mode: '${osPath}' is outside the sandbox workspace root '${policy.workspaceRoot}'`);
|
|
4383
|
+
const fs = ctx.fs;
|
|
4384
|
+
if (policy !== void 0 && typeof fs.remove === "function") {
|
|
4385
|
+
await fs.remove(target, policy, signal);
|
|
4386
|
+
return;
|
|
4387
|
+
}
|
|
4388
|
+
await rm(osPath, { force: true });
|
|
4389
|
+
}
|
|
4390
|
+
/** The OS path a resolved target names, as the confinement check reads it. */
|
|
4391
|
+
function processPathOf(target) {
|
|
4392
|
+
return ctx.fs.processPath(target);
|
|
4393
|
+
}
|
|
4394
|
+
/**
|
|
4395
|
+
* Whether a path lies inside the policy's workspace root (or IS it). Paths are
|
|
4396
|
+
* compared case-insensitively on Windows for the same reason the backend's own
|
|
4397
|
+
* containment is: `C:\Repo` and `c:\repo` are one directory there.
|
|
4398
|
+
* @param path - the OS path the delete would touch.
|
|
4399
|
+
* @param policy - the session's resolved policy.
|
|
4400
|
+
* @returns true when the mode permits the delete at that path.
|
|
4401
|
+
*/
|
|
4402
|
+
function withinSandboxRoot(path, policy) {
|
|
4403
|
+
if (policy.mode !== "read-only" && policy.mode !== "workspace-write") return true;
|
|
4404
|
+
if (policy.mode === "read-only") return false;
|
|
4405
|
+
if (policy.workspaceRoot === "") return true;
|
|
4406
|
+
const target = process.platform === "win32" ? path.toLowerCase() : path;
|
|
4407
|
+
const root = process.platform === "win32" ? policy.workspaceRoot.toLowerCase() : policy.workspaceRoot;
|
|
4408
|
+
if (target === root) return true;
|
|
4409
|
+
const rel = relative(root, target);
|
|
4410
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
4411
|
+
}
|
|
4412
|
+
/**
|
|
2383
4413
|
* Revert one entry's file back to its baseline (the shared per-entry logic
|
|
2384
4414
|
* behind both the single `revert` endpoint and the bulk `revert-all`). A
|
|
2385
4415
|
* created file's revert deletes it (not undoable: the file is gone), an edit
|
|
@@ -2392,7 +4422,7 @@ function apply(ctx, config) {
|
|
|
2392
4422
|
async function revertEntryContent(entry, sessionId, signal) {
|
|
2393
4423
|
const resolved = await ctx.fs.resolve(entry.path, { signal });
|
|
2394
4424
|
if (entry.kind === "create") {
|
|
2395
|
-
await
|
|
4425
|
+
await removeRevert(resolved, sessionId, signal);
|
|
2396
4426
|
return;
|
|
2397
4427
|
}
|
|
2398
4428
|
const preWrite = await ctx.fs.readText(resolved, void 0) ?? entry.newText;
|
|
@@ -2413,27 +4443,173 @@ function apply(ctx, config) {
|
|
|
2413
4443
|
}
|
|
2414
4444
|
};
|
|
2415
4445
|
}
|
|
2416
|
-
const
|
|
2417
|
-
const
|
|
4446
|
+
const undoStacks = /* @__PURE__ */ new Map();
|
|
4447
|
+
const redoStacks = /* @__PURE__ */ new Map();
|
|
4448
|
+
/** The undo stack of one session, created on first use. */
|
|
4449
|
+
function undoStackOf(sessionId) {
|
|
4450
|
+
let stack = undoStacks.get(sessionId);
|
|
4451
|
+
if (stack === void 0) {
|
|
4452
|
+
stack = [];
|
|
4453
|
+
undoStacks.set(sessionId, stack);
|
|
4454
|
+
}
|
|
4455
|
+
return stack;
|
|
4456
|
+
}
|
|
4457
|
+
/** The redo stack of one session, created on first use. */
|
|
4458
|
+
function redoStackOf(sessionId) {
|
|
4459
|
+
let stack = redoStacks.get(sessionId);
|
|
4460
|
+
if (stack === void 0) {
|
|
4461
|
+
stack = [];
|
|
4462
|
+
redoStacks.set(sessionId, stack);
|
|
4463
|
+
}
|
|
4464
|
+
return stack;
|
|
4465
|
+
}
|
|
4466
|
+
/** Record one undoable action in its own session's history. A fresh action
|
|
4467
|
+
* invalidates that session's redo history (and only that session's). */
|
|
2418
4468
|
function pushUndo(sessionId, before, after) {
|
|
2419
|
-
|
|
4469
|
+
undoStackOf(sessionId).push({
|
|
2420
4470
|
sessionId,
|
|
2421
4471
|
before,
|
|
2422
4472
|
after
|
|
2423
4473
|
});
|
|
2424
|
-
|
|
4474
|
+
redoStackOf(sessionId).length = 0;
|
|
4475
|
+
}
|
|
4476
|
+
/**
|
|
4477
|
+
* Record several entries' worth of ONE decision as a single undo step.
|
|
4478
|
+
*
|
|
4479
|
+
* The reader asked once — a VCS import, or a keep/revert over a pick of files — so one Ctrl+Z has to
|
|
4480
|
+
* take the whole thing back. A loop of single actions would push one step per entry instead, and undo
|
|
4481
|
+
* would then peel that one decision apart file by file.
|
|
4482
|
+
*
|
|
4483
|
+
* @param sessionId - the session whose history this belongs to.
|
|
4484
|
+
* @param before - one state per entry, in the order the decision touched them.
|
|
4485
|
+
* @param after - the same entries as the decision left them; the two arrays are index-aligned, which
|
|
4486
|
+
* is also what the divergence guard on a restore reads (see `restoreState`).
|
|
4487
|
+
*/
|
|
4488
|
+
function pushBatchUndo(sessionId, before, after) {
|
|
4489
|
+
if (before.length === 0) return;
|
|
4490
|
+
pushUndo(sessionId, {
|
|
4491
|
+
id: before[0].id,
|
|
4492
|
+
path: before[0].path,
|
|
4493
|
+
entry: void 0,
|
|
4494
|
+
fileText: void 0,
|
|
4495
|
+
batch: before
|
|
4496
|
+
}, {
|
|
4497
|
+
id: after[0].id,
|
|
4498
|
+
path: after[0].path,
|
|
4499
|
+
entry: void 0,
|
|
4500
|
+
fileText: void 0,
|
|
4501
|
+
batch: after
|
|
4502
|
+
});
|
|
4503
|
+
}
|
|
4504
|
+
/** Drop the top pair of one stack when it belongs to `sessionId`; otherwise the
|
|
4505
|
+
* stack is left exactly as it was, so another session's action is untouched.
|
|
4506
|
+
* @param stack - the session's stack (undo or redo).
|
|
4507
|
+
* @param sessionId - the session the request is for.
|
|
4508
|
+
* @returns the pair to move, or undefined when this session has nothing to move.
|
|
4509
|
+
*/
|
|
4510
|
+
function popOwnPair(stack, sessionId) {
|
|
4511
|
+
const pair = stack[stack.length - 1];
|
|
4512
|
+
if (pair === void 0 || pair.sessionId !== sessionId) return void 0;
|
|
4513
|
+
stack.pop();
|
|
4514
|
+
return pair;
|
|
4515
|
+
}
|
|
4516
|
+
/**
|
|
4517
|
+
* One side of a comment pair: the records it holds, and the entry they hang off.
|
|
4518
|
+
*
|
|
4519
|
+
* The pair's `id`/`path` are that ENTRY's id (= its path, the store's global identity), which is the
|
|
4520
|
+
* one field that makes a comment pair behave like a file pair everywhere the stack is walked:
|
|
4521
|
+
* `purgeForEntry` filters by paths, so a comment whose file left the review cannot be restored onto
|
|
4522
|
+
* it. A pick of comments can span several files, in which case the pair names its first record's
|
|
4523
|
+
* entry — each record still carries its own `entryId`, which is where the restore puts it back.
|
|
4524
|
+
* @param entryId - the entry the pair's comments hang off.
|
|
4525
|
+
* @param records - the threads this side holds.
|
|
4526
|
+
* @returns the snapshot for `pushUndo`.
|
|
4527
|
+
*/
|
|
4528
|
+
function commentState(entryId, records) {
|
|
4529
|
+
return {
|
|
4530
|
+
id: entryId,
|
|
4531
|
+
path: entryId,
|
|
4532
|
+
entry: void 0,
|
|
4533
|
+
fileText: void 0,
|
|
4534
|
+
kind: "comments",
|
|
4535
|
+
comments: records
|
|
4536
|
+
};
|
|
4537
|
+
}
|
|
4538
|
+
/**
|
|
4539
|
+
* The `before` side of an action that DROPS its entry, with the comments that drop is about to
|
|
4540
|
+
* delete riding along.
|
|
4541
|
+
*
|
|
4542
|
+
* Undo means one thing to the reader — their last action, taken back — so a pair whose action takes
|
|
4543
|
+
* a file out of the list has to bring back the threads that went with it, exactly as the pair for a
|
|
4544
|
+
* closed card brings the card back. Without this, Ctrl+Z gave the row back empty and the same
|
|
4545
|
+
* gesture meant two different things depending on whether the action was about a file or a comment.
|
|
4546
|
+
*
|
|
4547
|
+
* The snapshot goes on the `before` side ONLY: `after` carries none, which is what makes a redo the
|
|
4548
|
+
* same drop again rather than something that reinstalls a thread.
|
|
4549
|
+
*
|
|
4550
|
+
* Only the paths that really drop take one. A keep asked to leave its entry listed never deletes a
|
|
4551
|
+
* comment, and a snapshot there would re-add what the store still holds — and worse, would make the
|
|
4552
|
+
* undo of that keep resurrect comments the reader wrote after it.
|
|
4553
|
+
*
|
|
4554
|
+
* Must be called BEFORE `dropEntry`: the snapshot is of what that call is about to take away.
|
|
4555
|
+
* @param state - the `before` side as the action's own bookkeeping built it.
|
|
4556
|
+
* @returns that side, carrying the entry's records when it has any (absent rather than empty, so
|
|
4557
|
+
* "this pair is about comments too" is one check for the restore).
|
|
4558
|
+
*/
|
|
4559
|
+
function droppingComments(state) {
|
|
4560
|
+
const carried = comments.forEntry(state.path);
|
|
4561
|
+
return carried.length === 0 ? state : {
|
|
4562
|
+
...state,
|
|
4563
|
+
comments: carried
|
|
4564
|
+
};
|
|
4565
|
+
}
|
|
4566
|
+
/**
|
|
4567
|
+
* Install one side of a comment pair: the comments that side holds go back into the store, and the
|
|
4568
|
+
* ones only the other side holds are what this restore removes.
|
|
4569
|
+
*
|
|
4570
|
+
* The records go in VERBATIM (`CommentStore.add` stores a record it has never seen exactly as it
|
|
4571
|
+
* arrived), which is the whole point of snapshotting them: a rebuilt record would come back as a new
|
|
4572
|
+
* comment, with a fresh id, a fresh `createdAt` and no questions, and the reader's thread would be
|
|
4573
|
+
* gone behind a look-alike.
|
|
4574
|
+
*
|
|
4575
|
+
* The resolved lines of the entry are forgotten afterwards, because a range resolved against the
|
|
4576
|
+
* content the comment was written on is not a fact about the content it is being restored onto: the
|
|
4577
|
+
* file may have moved on while the thread was closed, so the next read has to place the quote again.
|
|
4578
|
+
*
|
|
4579
|
+
* Nothing here touches `ctx.fs` or the pending store: a comment is not a change to a file, and a
|
|
4580
|
+
* 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
|
+
* @param state - the side to install.
|
|
4583
|
+
* @param expectedFile - the other side; the comments only it lists are the ones to remove.
|
|
4584
|
+
*/
|
|
4585
|
+
function restoreComments(sessionId, state, expectedFile) {
|
|
4586
|
+
const installing = state.comments ?? [];
|
|
4587
|
+
const installed = new Set(installing.map((record) => record.id));
|
|
4588
|
+
const stale = (expectedFile?.comments ?? []).map((record) => record.id).filter((id) => !installed.has(id));
|
|
4589
|
+
if (stale.length > 0) {
|
|
4590
|
+
comments.removeMany(sessionId, stale);
|
|
4591
|
+
for (const id of stale) commentLines.delete(id);
|
|
4592
|
+
}
|
|
4593
|
+
if (installing.length > 0) comments.addMany(installing);
|
|
4594
|
+
forgetCommentLinesForEntry(state.path);
|
|
2425
4595
|
}
|
|
2426
4596
|
/**
|
|
2427
4597
|
* Restore one snapshot (the before/after side of an undo pair). File writes
|
|
2428
4598
|
* carry the session sandbox policy; a divergence guard refuses to overwrite
|
|
2429
4599
|
* a file an outside writer has since changed. The store change is applied
|
|
2430
|
-
* only after the write succeeds, keeping the restore all-or-nothing
|
|
4600
|
+
* only after the write succeeds, keeping the restore all-or-nothing — and the
|
|
4601
|
+
* comments a dropping action removed are the LAST thing put back, so a refused
|
|
4602
|
+
* restore cannot leave a thread on a row that did not come back.
|
|
2431
4603
|
* @param sessionId - the owning session.
|
|
2432
4604
|
* @param state - the snapshot to restore.
|
|
2433
4605
|
* @param expectedFile - the other side's file content, checked before a write.
|
|
2434
4606
|
* @param signal - aborts before atomic publication takes effect.
|
|
2435
4607
|
*/
|
|
2436
4608
|
async function restoreState(sessionId, state, expectedFile, signal) {
|
|
4609
|
+
if (state.kind === "comments") {
|
|
4610
|
+
restoreComments(sessionId, state, expectedFile);
|
|
4611
|
+
return;
|
|
4612
|
+
}
|
|
2437
4613
|
if (state.fileText !== void 0) {
|
|
2438
4614
|
const resolved = await ctx.fs.resolve(state.path, { signal });
|
|
2439
4615
|
if (expectedFile?.fileText !== void 0) {
|
|
@@ -2442,12 +4618,19 @@ function apply(ctx, config) {
|
|
|
2442
4618
|
await writeRevert(resolved, state.fileText, sessionId, signal);
|
|
2443
4619
|
}
|
|
2444
4620
|
if (state.batch !== void 0) {
|
|
2445
|
-
|
|
2446
|
-
|
|
4621
|
+
const counterpart = expectedFile?.batch;
|
|
4622
|
+
for (const [index, item] of state.batch.entries()) await restoreState(sessionId, {
|
|
4623
|
+
...item,
|
|
4624
|
+
batch: void 0
|
|
4625
|
+
}, counterpart?.[index], signal);
|
|
2447
4626
|
return;
|
|
2448
4627
|
}
|
|
2449
4628
|
if (state.entry !== void 0) store.restore(state.entry);
|
|
2450
|
-
else
|
|
4629
|
+
else dropEntry(state.path);
|
|
4630
|
+
if (state.comments !== void 0 && state.comments.length > 0) {
|
|
4631
|
+
comments.addMany(state.comments);
|
|
4632
|
+
forgetCommentLinesForEntry(state.path);
|
|
4633
|
+
}
|
|
2451
4634
|
}
|
|
2452
4635
|
/**
|
|
2453
4636
|
* Mirror the whole (globally-unique) entry set to disk. A write fault logs a
|
|
@@ -2529,18 +4712,101 @@ function apply(ctx, config) {
|
|
|
2529
4712
|
const sessionId = sessionOf(payload);
|
|
2530
4713
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
2531
4714
|
await ensureLoaded();
|
|
4715
|
+
if (comments.retain(new Set(store.all().map((entry) => entry.id))) > 0) forgetOrphanedCommentLines();
|
|
2532
4716
|
const { files, redoCleared } = await listWithState(sessionId);
|
|
4717
|
+
const commentAnswers = {};
|
|
4718
|
+
if (comments.list(sessionId).length > 0) {
|
|
4719
|
+
const answers = commentAsker.answers(sessionId);
|
|
4720
|
+
for (const [requestId, read] of Object.entries(answers)) if (read.turn !== void 0) comments.recordTurnForRequest(sessionId, requestId, read.turn);
|
|
4721
|
+
for (const turn of commentAsker.endedTurns(sessionId)) comments.markTurnEnded(sessionId, turn);
|
|
4722
|
+
for (const [requestId, read] of Object.entries(answers)) if (read.answer !== void 0) commentAnswers[requestId] = read.answer;
|
|
4723
|
+
}
|
|
2533
4724
|
return {
|
|
2534
4725
|
ok: true,
|
|
2535
4726
|
value: {
|
|
2536
4727
|
files,
|
|
4728
|
+
comments: comments.list(sessionId),
|
|
4729
|
+
commentLines: resolvedCommentLines(sessionId),
|
|
4730
|
+
commentsRevision: comments.commentsRevision(),
|
|
4731
|
+
commentAnswers,
|
|
2537
4732
|
workspacePath: workspaceOf(sessionId)?.path,
|
|
2538
4733
|
redoCleared: redoCleared || void 0,
|
|
2539
4734
|
commentSkill: commentSkill(),
|
|
2540
|
-
persistError: persistFailureReported
|
|
4735
|
+
persistError: persistFailureReported,
|
|
4736
|
+
commentPersistError: comments.persistError()
|
|
2541
4737
|
}
|
|
2542
4738
|
};
|
|
2543
4739
|
}
|
|
4740
|
+
case "comment-add": {
|
|
4741
|
+
const input = commentAddOf(payload);
|
|
4742
|
+
if (input === void 0) return rpcError("sessionId, entryId, anchor, quote and text must be valid");
|
|
4743
|
+
await ensureLoaded();
|
|
4744
|
+
const entry = store.list(input.sessionId).find((candidate) => candidate.id === input.entryId);
|
|
4745
|
+
if (entry === void 0) return {
|
|
4746
|
+
ok: true,
|
|
4747
|
+
value: { outcome: "missing" }
|
|
4748
|
+
};
|
|
4749
|
+
const now = Date.now();
|
|
4750
|
+
const record = {
|
|
4751
|
+
id: input.id ?? randomUUID(),
|
|
4752
|
+
sessionId: input.sessionId,
|
|
4753
|
+
entryId: entry.id,
|
|
4754
|
+
path: entry.path,
|
|
4755
|
+
anchor: input.anchor,
|
|
4756
|
+
quote: input.quote,
|
|
4757
|
+
text: input.text,
|
|
4758
|
+
createdAt: now,
|
|
4759
|
+
updatedAt: now,
|
|
4760
|
+
...input.quoteContext === void 0 ? {} : { quoteContext: input.quoteContext },
|
|
4761
|
+
...input.quoteLines === void 0 ? {} : { quoteLines: input.quoteLines }
|
|
4762
|
+
};
|
|
4763
|
+
comments.add(record);
|
|
4764
|
+
pushUndo(input.sessionId, commentState(entry.path, []), commentState(entry.path, [record]));
|
|
4765
|
+
return {
|
|
4766
|
+
ok: true,
|
|
4767
|
+
value: {
|
|
4768
|
+
outcome: "added",
|
|
4769
|
+
comment: record
|
|
4770
|
+
}
|
|
4771
|
+
};
|
|
4772
|
+
}
|
|
4773
|
+
case "comment-remove": {
|
|
4774
|
+
const target = targetOf(payload);
|
|
4775
|
+
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
4776
|
+
await ensureLoaded();
|
|
4777
|
+
const record = comments.get(target.id);
|
|
4778
|
+
const removed = comments.remove(target.sessionId, target.id);
|
|
4779
|
+
const value = { outcome: removed ? "removed" : "missing" };
|
|
4780
|
+
if (removed && record !== void 0) pushUndo(target.sessionId, commentState(record.entryId, [record]), commentState(record.entryId, []));
|
|
4781
|
+
commentLines.delete(target.id);
|
|
4782
|
+
return {
|
|
4783
|
+
ok: true,
|
|
4784
|
+
value
|
|
4785
|
+
};
|
|
4786
|
+
}
|
|
4787
|
+
case "comment-remove-many": {
|
|
4788
|
+
const request = commentRemoveManyOf(payload);
|
|
4789
|
+
if (request === void 0) return rpcError("sessionId and a non-empty ids array of strings must be given");
|
|
4790
|
+
await ensureLoaded();
|
|
4791
|
+
const snapshot = request.ids.map((id) => comments.get(id)).filter((record) => record !== void 0 && record.sessionId === request.sessionId);
|
|
4792
|
+
const removed = comments.removeMany(request.sessionId, request.ids);
|
|
4793
|
+
for (const id of removed) commentLines.delete(id);
|
|
4794
|
+
const first = snapshot[0];
|
|
4795
|
+
if (first !== void 0) pushUndo(request.sessionId, commentState(first.entryId, snapshot), commentState(first.entryId, []));
|
|
4796
|
+
return {
|
|
4797
|
+
ok: true,
|
|
4798
|
+
value: { removed: removed.length }
|
|
4799
|
+
};
|
|
4800
|
+
}
|
|
4801
|
+
case "comment-ask": {
|
|
4802
|
+
const input = commentAskOf(payload);
|
|
4803
|
+
if (input === void 0) return rpcError("sessionId, id, prompt and text must be valid");
|
|
4804
|
+
await ensureLoaded();
|
|
4805
|
+
return {
|
|
4806
|
+
ok: true,
|
|
4807
|
+
value: await commentAsker.ask(input.sessionId, input.id, input.prompt, input.text, signal)
|
|
4808
|
+
};
|
|
4809
|
+
}
|
|
2544
4810
|
case "keep": {
|
|
2545
4811
|
const target = targetOf(payload);
|
|
2546
4812
|
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
@@ -2580,13 +4846,14 @@ function apply(ctx, config) {
|
|
|
2580
4846
|
}
|
|
2581
4847
|
};
|
|
2582
4848
|
}
|
|
2583
|
-
|
|
2584
|
-
pushUndo(target.sessionId, {
|
|
4849
|
+
const beforeDrop = droppingComments({
|
|
2585
4850
|
id: entry.path,
|
|
2586
4851
|
path: entry.path,
|
|
2587
4852
|
entry,
|
|
2588
4853
|
fileText: void 0
|
|
2589
|
-
}
|
|
4854
|
+
});
|
|
4855
|
+
dropEntry(target.id);
|
|
4856
|
+
pushUndo(target.sessionId, beforeDrop, {
|
|
2590
4857
|
id: entry.path,
|
|
2591
4858
|
path: entry.path,
|
|
2592
4859
|
entry: void 0,
|
|
@@ -2644,8 +4911,12 @@ function apply(ctx, config) {
|
|
|
2644
4911
|
}
|
|
2645
4912
|
};
|
|
2646
4913
|
}
|
|
2647
|
-
|
|
2648
|
-
|
|
4914
|
+
const pair = undo === void 0 ? void 0 : {
|
|
4915
|
+
before: droppingComments(undo.before),
|
|
4916
|
+
after: undo.after
|
|
4917
|
+
};
|
|
4918
|
+
dropEntry(target.id);
|
|
4919
|
+
if (pair !== void 0) pushUndo(target.sessionId, pair.before, pair.after);
|
|
2649
4920
|
persistSession(true);
|
|
2650
4921
|
return {
|
|
2651
4922
|
ok: true,
|
|
@@ -2660,33 +4931,21 @@ function apply(ctx, config) {
|
|
|
2660
4931
|
const before = [];
|
|
2661
4932
|
const after = [];
|
|
2662
4933
|
for (const entry of entries) {
|
|
2663
|
-
before.push({
|
|
4934
|
+
before.push(droppingComments({
|
|
2664
4935
|
id: entry.id,
|
|
2665
4936
|
path: entry.path,
|
|
2666
4937
|
entry,
|
|
2667
4938
|
fileText: void 0
|
|
2668
|
-
});
|
|
4939
|
+
}));
|
|
2669
4940
|
after.push({
|
|
2670
4941
|
id: entry.id,
|
|
2671
4942
|
path: entry.path,
|
|
2672
4943
|
entry: void 0,
|
|
2673
4944
|
fileText: void 0
|
|
2674
4945
|
});
|
|
2675
|
-
|
|
4946
|
+
dropEntry(entry.id);
|
|
2676
4947
|
}
|
|
2677
|
-
if (before.length > 0)
|
|
2678
|
-
id: before[0].id,
|
|
2679
|
-
path: before[0].path,
|
|
2680
|
-
entry: void 0,
|
|
2681
|
-
fileText: void 0,
|
|
2682
|
-
batch: before
|
|
2683
|
-
}, {
|
|
2684
|
-
id: after[0].id,
|
|
2685
|
-
path: after[0].path,
|
|
2686
|
-
entry: void 0,
|
|
2687
|
-
fileText: void 0,
|
|
2688
|
-
batch: after
|
|
2689
|
-
});
|
|
4948
|
+
if (before.length > 0) pushBatchUndo(sessionId, before, after);
|
|
2690
4949
|
persistSession(true);
|
|
2691
4950
|
return {
|
|
2692
4951
|
ok: true,
|
|
@@ -2708,30 +4967,121 @@ function apply(ctx, config) {
|
|
|
2708
4967
|
return rpcError(`revert-all failed for ${entry.path}`);
|
|
2709
4968
|
}
|
|
2710
4969
|
if (undo !== void 0) {
|
|
2711
|
-
batchBefore.push(undo.before);
|
|
4970
|
+
batchBefore.push(droppingComments(undo.before));
|
|
2712
4971
|
batchAfter.push(undo.after);
|
|
2713
4972
|
}
|
|
2714
|
-
|
|
4973
|
+
dropEntry(entry.id);
|
|
2715
4974
|
}
|
|
2716
|
-
if (batchBefore.length > 0)
|
|
2717
|
-
id: batchBefore[0].id,
|
|
2718
|
-
path: batchBefore[0].path,
|
|
2719
|
-
entry: void 0,
|
|
2720
|
-
fileText: void 0,
|
|
2721
|
-
batch: batchBefore
|
|
2722
|
-
}, {
|
|
2723
|
-
id: batchAfter[0].id,
|
|
2724
|
-
path: batchAfter[0].path,
|
|
2725
|
-
entry: void 0,
|
|
2726
|
-
fileText: void 0,
|
|
2727
|
-
batch: batchAfter
|
|
2728
|
-
});
|
|
4975
|
+
if (batchBefore.length > 0) pushBatchUndo(sessionId, batchBefore, batchAfter);
|
|
2729
4976
|
persistSession(true);
|
|
2730
4977
|
return {
|
|
2731
4978
|
ok: true,
|
|
2732
4979
|
value: { affected: entries.length }
|
|
2733
4980
|
};
|
|
2734
4981
|
}
|
|
4982
|
+
case "keep-many": {
|
|
4983
|
+
const request = manyTargetsOf(payload);
|
|
4984
|
+
if (request === void 0) return rpcError("sessionId and a non-empty ids array of strings must be given");
|
|
4985
|
+
await ensureLoaded();
|
|
4986
|
+
const before = [];
|
|
4987
|
+
const after = [];
|
|
4988
|
+
for (const id of request.ids) {
|
|
4989
|
+
const entry = store.get(id);
|
|
4990
|
+
if (entry === void 0) continue;
|
|
4991
|
+
if (request.keepListed === true) {
|
|
4992
|
+
store.update(id, { oldText: entry.newText });
|
|
4993
|
+
before.push({
|
|
4994
|
+
id: entry.id,
|
|
4995
|
+
path: entry.path,
|
|
4996
|
+
entry,
|
|
4997
|
+
fileText: void 0
|
|
4998
|
+
});
|
|
4999
|
+
after.push({
|
|
5000
|
+
id: entry.id,
|
|
5001
|
+
path: entry.path,
|
|
5002
|
+
entry: {
|
|
5003
|
+
...entry,
|
|
5004
|
+
oldText: entry.newText,
|
|
5005
|
+
updatedAt: Date.now()
|
|
5006
|
+
},
|
|
5007
|
+
fileText: void 0
|
|
5008
|
+
});
|
|
5009
|
+
continue;
|
|
5010
|
+
}
|
|
5011
|
+
before.push(droppingComments({
|
|
5012
|
+
id: entry.id,
|
|
5013
|
+
path: entry.path,
|
|
5014
|
+
entry,
|
|
5015
|
+
fileText: void 0
|
|
5016
|
+
}));
|
|
5017
|
+
after.push({
|
|
5018
|
+
id: entry.id,
|
|
5019
|
+
path: entry.path,
|
|
5020
|
+
entry: void 0,
|
|
5021
|
+
fileText: void 0
|
|
5022
|
+
});
|
|
5023
|
+
dropEntry(id);
|
|
5024
|
+
}
|
|
5025
|
+
pushBatchUndo(request.sessionId, before, after);
|
|
5026
|
+
persistSession(true);
|
|
5027
|
+
return {
|
|
5028
|
+
ok: true,
|
|
5029
|
+
value: { affected: before.length }
|
|
5030
|
+
};
|
|
5031
|
+
}
|
|
5032
|
+
case "revert-many": {
|
|
5033
|
+
const request = manyTargetsOf(payload);
|
|
5034
|
+
if (request === void 0) return rpcError("sessionId and a non-empty ids array of strings must be given");
|
|
5035
|
+
await ensureLoaded();
|
|
5036
|
+
const before = [];
|
|
5037
|
+
const after = [];
|
|
5038
|
+
let affected = 0;
|
|
5039
|
+
for (const id of request.ids) {
|
|
5040
|
+
const entry = store.get(id);
|
|
5041
|
+
if (entry === void 0) continue;
|
|
5042
|
+
affected += 1;
|
|
5043
|
+
let undo;
|
|
5044
|
+
try {
|
|
5045
|
+
undo = await revertEntryContent(entry, request.sessionId, signal);
|
|
5046
|
+
} catch {
|
|
5047
|
+
return rpcError(`revert-many failed for ${entry.path}`);
|
|
5048
|
+
}
|
|
5049
|
+
if (request.keepListed === true) {
|
|
5050
|
+
const content = undo?.after.fileText ?? "";
|
|
5051
|
+
store.update(id, { newText: content });
|
|
5052
|
+
if (undo !== void 0) {
|
|
5053
|
+
before.push({
|
|
5054
|
+
id: entry.path,
|
|
5055
|
+
path: entry.path,
|
|
5056
|
+
entry,
|
|
5057
|
+
fileText: undo.before.fileText
|
|
5058
|
+
});
|
|
5059
|
+
after.push({
|
|
5060
|
+
id: entry.path,
|
|
5061
|
+
path: entry.path,
|
|
5062
|
+
entry: {
|
|
5063
|
+
...entry,
|
|
5064
|
+
newText: content,
|
|
5065
|
+
updatedAt: Date.now()
|
|
5066
|
+
},
|
|
5067
|
+
fileText: content
|
|
5068
|
+
});
|
|
5069
|
+
}
|
|
5070
|
+
continue;
|
|
5071
|
+
}
|
|
5072
|
+
if (undo !== void 0) {
|
|
5073
|
+
before.push(droppingComments(undo.before));
|
|
5074
|
+
after.push(undo.after);
|
|
5075
|
+
}
|
|
5076
|
+
dropEntry(id);
|
|
5077
|
+
}
|
|
5078
|
+
pushBatchUndo(request.sessionId, before, after);
|
|
5079
|
+
persistSession(true);
|
|
5080
|
+
return {
|
|
5081
|
+
ok: true,
|
|
5082
|
+
value: { affected }
|
|
5083
|
+
};
|
|
5084
|
+
}
|
|
2735
5085
|
case "block-keep": {
|
|
2736
5086
|
const blockTarget = blockTargetOf(payload);
|
|
2737
5087
|
if (blockTarget === void 0) return rpcError("sessionId, id, and block must be valid");
|
|
@@ -2763,13 +5113,14 @@ function apply(ctx, config) {
|
|
|
2763
5113
|
persistSession();
|
|
2764
5114
|
const fullyResolved = contentEqual(updatedOld, entry.newText);
|
|
2765
5115
|
if (fullyResolved && blockTarget.removeWhenResolved === true) {
|
|
2766
|
-
|
|
2767
|
-
pushUndo(blockTarget.sessionId, {
|
|
5116
|
+
const resolvedDrop = droppingComments({
|
|
2768
5117
|
id: entry.id,
|
|
2769
5118
|
path: entry.path,
|
|
2770
5119
|
entry: afterEntry,
|
|
2771
5120
|
fileText: void 0
|
|
2772
|
-
}
|
|
5121
|
+
});
|
|
5122
|
+
dropEntry(blockTarget.id);
|
|
5123
|
+
pushUndo(blockTarget.sessionId, resolvedDrop, {
|
|
2773
5124
|
id: entry.id,
|
|
2774
5125
|
path: entry.path,
|
|
2775
5126
|
entry: void 0,
|
|
@@ -2806,7 +5157,7 @@ function apply(ctx, config) {
|
|
|
2806
5157
|
let undo;
|
|
2807
5158
|
try {
|
|
2808
5159
|
const target = await ctx.fs.resolve(entry.path, { signal });
|
|
2809
|
-
if (entry.kind === "create" && content === "") await
|
|
5160
|
+
if (entry.kind === "create" && content === "") await removeRevert(target, blockTarget.sessionId, signal);
|
|
2810
5161
|
else {
|
|
2811
5162
|
const preWrite = await ctx.fs.readText(target, void 0) ?? entry.newText;
|
|
2812
5163
|
await writeRevert(target, content, blockTarget.sessionId, signal);
|
|
@@ -2832,13 +5183,14 @@ function apply(ctx, config) {
|
|
|
2832
5183
|
persistSession();
|
|
2833
5184
|
const fullyResolved = contentEqual(updatedNew, entry.oldText);
|
|
2834
5185
|
if (fullyResolved && blockTarget.removeWhenResolved === true) {
|
|
2835
|
-
|
|
2836
|
-
pushUndo(blockTarget.sessionId, {
|
|
5186
|
+
const resolvedDrop = droppingComments({
|
|
2837
5187
|
id: entry.id,
|
|
2838
5188
|
path: entry.path,
|
|
2839
5189
|
entry: afterEntry,
|
|
2840
5190
|
fileText: void 0
|
|
2841
|
-
}
|
|
5191
|
+
});
|
|
5192
|
+
dropEntry(blockTarget.id);
|
|
5193
|
+
pushUndo(blockTarget.sessionId, resolvedDrop, {
|
|
2842
5194
|
id: entry.id,
|
|
2843
5195
|
path: entry.path,
|
|
2844
5196
|
entry: void 0,
|
|
@@ -2857,18 +5209,18 @@ function apply(ctx, config) {
|
|
|
2857
5209
|
case "undo": {
|
|
2858
5210
|
const sessionId = sessionOf(payload);
|
|
2859
5211
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
2860
|
-
const pair =
|
|
5212
|
+
const pair = popOwnPair(undoStackOf(sessionId), sessionId);
|
|
2861
5213
|
if (pair === void 0) return {
|
|
2862
5214
|
ok: true,
|
|
2863
5215
|
value: { outcome: "nothing" }
|
|
2864
5216
|
};
|
|
2865
5217
|
try {
|
|
2866
|
-
await restoreState(sessionId, pair.before, pair.after, signal);
|
|
5218
|
+
await restoreState(pair.sessionId, pair.before, pair.after, signal);
|
|
2867
5219
|
} catch (error) {
|
|
2868
|
-
|
|
5220
|
+
undoStackOf(sessionId).push(pair);
|
|
2869
5221
|
return rpcError(`undo failed: ${errorMessage(error)}`);
|
|
2870
5222
|
}
|
|
2871
|
-
|
|
5223
|
+
redoStackOf(sessionId).push(pair);
|
|
2872
5224
|
persistSession(true);
|
|
2873
5225
|
return {
|
|
2874
5226
|
ok: true,
|
|
@@ -2881,18 +5233,18 @@ function apply(ctx, config) {
|
|
|
2881
5233
|
case "redo": {
|
|
2882
5234
|
const sessionId = sessionOf(payload);
|
|
2883
5235
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
2884
|
-
const pair =
|
|
5236
|
+
const pair = popOwnPair(redoStackOf(sessionId), sessionId);
|
|
2885
5237
|
if (pair === void 0) return {
|
|
2886
5238
|
ok: true,
|
|
2887
5239
|
value: { outcome: "nothing" }
|
|
2888
5240
|
};
|
|
2889
5241
|
try {
|
|
2890
|
-
await restoreState(sessionId, pair.after, pair.before, signal);
|
|
5242
|
+
await restoreState(pair.sessionId, pair.after, pair.before, signal);
|
|
2891
5243
|
} catch (error) {
|
|
2892
|
-
|
|
5244
|
+
redoStackOf(sessionId).push(pair);
|
|
2893
5245
|
return rpcError(`redo failed: ${errorMessage(error)}`);
|
|
2894
5246
|
}
|
|
2895
|
-
|
|
5247
|
+
undoStackOf(sessionId).push(pair);
|
|
2896
5248
|
persistSession(true);
|
|
2897
5249
|
return {
|
|
2898
5250
|
ok: true,
|
|
@@ -3375,6 +5727,136 @@ function targetOf(payload) {
|
|
|
3375
5727
|
id
|
|
3376
5728
|
};
|
|
3377
5729
|
}
|
|
5730
|
+
/**
|
|
5731
|
+
* Narrow a wire payload to one keep/revert over a PICK of files: one session, the files to act on, and
|
|
5732
|
+
* whether the resolved ones stay listed.
|
|
5733
|
+
*
|
|
5734
|
+
* The ids are all-or-nothing, like the pick itself: a payload with one unusable id is not a pick, so the
|
|
5735
|
+
* whole request is refused rather than half-honoured (the ids themselves are still only acted on where
|
|
5736
|
+
* they exist, which is the endpoint's business). A repeated id names one file, so it is kept once.
|
|
5737
|
+
*
|
|
5738
|
+
* @param payload - the wire payload.
|
|
5739
|
+
* @returns the request, or undefined when it is not one.
|
|
5740
|
+
*/
|
|
5741
|
+
function manyTargetsOf(payload) {
|
|
5742
|
+
const sessionId = sessionOf(payload);
|
|
5743
|
+
if (sessionId === void 0) return void 0;
|
|
5744
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return void 0;
|
|
5745
|
+
const record = payload;
|
|
5746
|
+
const ids = record.ids;
|
|
5747
|
+
if (!Array.isArray(ids) || ids.length === 0) return void 0;
|
|
5748
|
+
const wanted = [];
|
|
5749
|
+
for (const id of ids) {
|
|
5750
|
+
if (typeof id !== "string" || id.length === 0) return void 0;
|
|
5751
|
+
if (!wanted.includes(id)) wanted.push(id);
|
|
5752
|
+
}
|
|
5753
|
+
return {
|
|
5754
|
+
sessionId,
|
|
5755
|
+
ids: wanted,
|
|
5756
|
+
keepListed: record.keepListed === true ? true : void 0
|
|
5757
|
+
};
|
|
5758
|
+
}
|
|
5759
|
+
/**
|
|
5760
|
+
* Narrow a wire payload to one batch comment removal: one session, and the comments to drop.
|
|
5761
|
+
*
|
|
5762
|
+
* The ids are all-or-nothing: a payload with one unusable id is not a batch, so the whole request
|
|
5763
|
+
* is refused rather than half-honoured (the ids themselves are still only dropped where they exist,
|
|
5764
|
+
* which is `CommentStore.removeMany`'s business). A repeated id names one comment, so it is kept once.
|
|
5765
|
+
*
|
|
5766
|
+
* @param payload - the wire payload.
|
|
5767
|
+
* @returns the request, or undefined when it is not one.
|
|
5768
|
+
*/
|
|
5769
|
+
function commentRemoveManyOf(payload) {
|
|
5770
|
+
const sessionId = sessionOf(payload);
|
|
5771
|
+
if (sessionId === void 0) return void 0;
|
|
5772
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return void 0;
|
|
5773
|
+
const ids = payload.ids;
|
|
5774
|
+
if (!Array.isArray(ids) || ids.length === 0) return void 0;
|
|
5775
|
+
const wanted = [];
|
|
5776
|
+
for (const id of ids) {
|
|
5777
|
+
if (typeof id !== "string" || id.length === 0) return void 0;
|
|
5778
|
+
if (!wanted.includes(id)) wanted.push(id);
|
|
5779
|
+
}
|
|
5780
|
+
return {
|
|
5781
|
+
sessionId,
|
|
5782
|
+
ids: wanted
|
|
5783
|
+
};
|
|
5784
|
+
}
|
|
5785
|
+
/**
|
|
5786
|
+
* Narrow a wire payload to one comment-ask request: a target, the prompt to send, and the
|
|
5787
|
+
* reader's own words that prompt wraps.
|
|
5788
|
+
*
|
|
5789
|
+
* Both strings are required. The prompt is what the agent is asked; `text` is what the
|
|
5790
|
+
* thread shows as the question, and it cannot be recovered from the prompt (which carries
|
|
5791
|
+
* the marker, the reference and the rules around it) — a request without it would store a
|
|
5792
|
+
* question nobody can read, so it is refused instead.
|
|
5793
|
+
*
|
|
5794
|
+
* @param payload - the wire payload.
|
|
5795
|
+
* @returns the request, or undefined when it is not one.
|
|
5796
|
+
*/
|
|
5797
|
+
function commentAskOf(payload) {
|
|
5798
|
+
const target = targetOf(payload);
|
|
5799
|
+
if (target === void 0) return void 0;
|
|
5800
|
+
const record = payload;
|
|
5801
|
+
const prompt = record.prompt;
|
|
5802
|
+
if (typeof prompt !== "string" || prompt.trim().length === 0) return void 0;
|
|
5803
|
+
const text = record.text;
|
|
5804
|
+
if (typeof text !== "string" || text.trim().length === 0) return void 0;
|
|
5805
|
+
return {
|
|
5806
|
+
...target,
|
|
5807
|
+
prompt,
|
|
5808
|
+
text
|
|
5809
|
+
};
|
|
5810
|
+
}
|
|
5811
|
+
/**
|
|
5812
|
+
* Narrow a wire payload to one comment-add request.
|
|
5813
|
+
*
|
|
5814
|
+
* The display path is deliberately NOT read from the wire: the entry a comment
|
|
5815
|
+
* hangs off is the authority on what that file is called, so a client cannot pin a
|
|
5816
|
+
* comment to a path the list disagrees with.
|
|
5817
|
+
*
|
|
5818
|
+
* @param payload - the request body.
|
|
5819
|
+
* @returns the narrowed request, or `undefined` when a required field is missing.
|
|
5820
|
+
*/
|
|
5821
|
+
function commentAddOf(payload) {
|
|
5822
|
+
const sessionId = sessionOf(payload);
|
|
5823
|
+
if (sessionId === void 0) return void 0;
|
|
5824
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return void 0;
|
|
5825
|
+
const body = payload;
|
|
5826
|
+
const { entryId, anchor, quote, text } = body;
|
|
5827
|
+
if (typeof entryId !== "string" || entryId.length === 0) return void 0;
|
|
5828
|
+
if (typeof anchor !== "object" || anchor === null || Array.isArray(anchor)) return void 0;
|
|
5829
|
+
const { startLine, endLine } = anchor;
|
|
5830
|
+
if (typeof startLine !== "number" || !Number.isFinite(startLine)) return void 0;
|
|
5831
|
+
if (typeof endLine !== "number" || !Number.isFinite(endLine)) return void 0;
|
|
5832
|
+
if (typeof quote !== "string") return void 0;
|
|
5833
|
+
if (typeof text !== "string" || text.trim().length === 0) return void 0;
|
|
5834
|
+
const id = body.id;
|
|
5835
|
+
const quoteContext = body.quoteContext;
|
|
5836
|
+
const quoteLines = [];
|
|
5837
|
+
if (Array.isArray(body.quoteLines)) for (const line of body.quoteLines) {
|
|
5838
|
+
if (typeof line !== "object" || line === null || Array.isArray(line)) continue;
|
|
5839
|
+
const { old, new: side, kind } = line;
|
|
5840
|
+
quoteLines.push({
|
|
5841
|
+
...typeof old === "number" && Number.isFinite(old) ? { old } : {},
|
|
5842
|
+
...typeof side === "number" && Number.isFinite(side) ? { new: side } : {},
|
|
5843
|
+
...kind === "add" || kind === "del" || kind === "context" ? { kind } : {}
|
|
5844
|
+
});
|
|
5845
|
+
}
|
|
5846
|
+
return {
|
|
5847
|
+
sessionId,
|
|
5848
|
+
id: typeof id === "string" && id.length > 0 ? id : void 0,
|
|
5849
|
+
entryId,
|
|
5850
|
+
anchor: {
|
|
5851
|
+
startLine,
|
|
5852
|
+
endLine
|
|
5853
|
+
},
|
|
5854
|
+
quote,
|
|
5855
|
+
quoteContext: typeof quoteContext === "string" && quoteContext !== "" ? quoteContext : void 0,
|
|
5856
|
+
quoteLines: quoteLines.length > 0 ? quoteLines : void 0,
|
|
5857
|
+
text
|
|
5858
|
+
};
|
|
5859
|
+
}
|
|
3378
5860
|
/** Narrow a wire payload to one preview-image target. */
|
|
3379
5861
|
function previewImageTargetOf(payload) {
|
|
3380
5862
|
const sessionId = sessionOf(payload);
|
|
@@ -3534,4 +6016,4 @@ function fontRoute(warn) {
|
|
|
3534
6016
|
};
|
|
3535
6017
|
}
|
|
3536
6018
|
//#endregion
|
|
3537
|
-
export { COMMENT_SKILL_NAME, DIFF_APPROVAL_CHANNEL, PendingDiffStore, PendingPersistence, apply, defaultOpenPath, defaultStorageDir, fontRoute, inject, name, resetFontSlicesForTests, serveFontSlice };
|
|
6019
|
+
export { COMMENT_SKILL_NAME, CommentAsker, CommentStore, DIFF_APPROVAL_CHANNEL, PendingDiffStore, PendingPersistence, answerForRequest, apply, commentsDirFor, defaultOpenPath, defaultStorageDir, fontRoute, inject, name, resetFontSlicesForTests, serveFontSlice };
|