dsh-vscode-mode 0.14.2 → 0.14.4
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 +12 -9
- package/lib/client.js +906 -395
- package/lib/client.js.map +1 -1
- package/lib/index.js +1594 -607
- package/lib/index.js.map +1 -1
- package/package.json +7 -7
- package/skills/dsh-vscodemode-mcp/SKILL.md +9 -3
- package/src/archiveDb.ts +476 -0
- package/src/capture.ts +8 -1
- package/src/client/diffDockStore.ts +18 -0
- package/src/client/mcpForm.ts +79 -0
- package/src/client/mcpState.ts +179 -0
- package/src/client/monaco/diffRender.ts +3 -0
- package/src/client/state/regions.ts +21 -1
- package/src/client/styles/editor.css +12 -0
- package/src/client/styles/mcp.css +6 -3
- package/src/client/ui/ConversationDiffDock.ts +2 -2
- package/src/client/ui/DiffLauncher.ts +76 -17
- package/src/client/ui/EditorView.ts +90 -7
- package/src/client/ui/McpSettings.ts +174 -137
- package/src/dshVersion.ts +4 -3
- package/src/index.ts +2 -0
- package/src/mcp.ts +131 -23
- package/src/mcpIsolation.ts +2 -1
- package/src/mcpProject.ts +74 -14
- package/src/mcpRuntime.ts +18 -3
- package/src/paths.ts +11 -0
- package/src/perf.ts +17 -24
- package/src/revert.ts +93 -1
- package/src/rpc.ts +243 -68
- package/src/shared/diff.ts +69 -20
- package/src/shared/mcpStatus.ts +30 -0
- package/src/shared/rpc.ts +10 -1
- package/src/store.ts +154 -55
package/lib/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync,
|
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
9
|
import { Buffer as Buffer$1 } from "node:buffer";
|
|
9
10
|
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
10
11
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -107,6 +108,7 @@ function guardMcp(ctx, exec) {
|
|
|
107
108
|
const server = callServer(exec);
|
|
108
109
|
const owners = serverOwners(ctx);
|
|
109
110
|
const current = runtimeOf(ctx).workspace(exec.agent);
|
|
111
|
+
if (!RESOURCE_TOOLS.has(name) && [...mcpNames(ctx)].filter((key) => name.startsWith("mcp__" + key + "__")).length > 1) return "已拒绝:MCP 命名空间冲突,无法确认工具归属";
|
|
110
112
|
if (!(RESOURCE_TOOLS.has(name) ? [...owners].filter(([key]) => key === server) : [...owners].filter(([key]) => name.startsWith("mcp__" + key + "__"))).some(([, owner]) => owner !== current)) return void 0;
|
|
111
113
|
return current === void 0 ? "项目 MCP 需要在已注册工作区的对话中使用" : "已拒绝:当前对话工作区不能使用其他项目的 MCP";
|
|
112
114
|
}
|
|
@@ -393,7 +395,7 @@ var McpRuntime = class {
|
|
|
393
395
|
const update = async () => {
|
|
394
396
|
this.projects.set(path, defs);
|
|
395
397
|
this.changed();
|
|
396
|
-
for (const [agent, record] of this.agents) await this.syncAgent(agent, record, refresh);
|
|
398
|
+
for (const [agent, record] of this.agents) await this.syncAgent(agent, record, this.workspace(agent) === path ? refresh : void 0);
|
|
397
399
|
this.changed();
|
|
398
400
|
};
|
|
399
401
|
return this.enqueue(update);
|
|
@@ -476,7 +478,7 @@ var McpRuntime = class {
|
|
|
476
478
|
const defs = this.projects.get(path ?? "") ?? /* @__PURE__ */ new Map();
|
|
477
479
|
for (const [name, instance] of record.instances) {
|
|
478
480
|
const def = defs.get(name);
|
|
479
|
-
if (def?.enabled && instance.key === JSON.stringify(def.config) && name !== refresh) continue;
|
|
481
|
+
if (def?.enabled && instance.path === path && instance.key === JSON.stringify(def.config) && name !== refresh) continue;
|
|
480
482
|
await this.stopOne(record, name);
|
|
481
483
|
}
|
|
482
484
|
for (const [name, def] of defs) {
|
|
@@ -486,6 +488,20 @@ var McpRuntime = class {
|
|
|
486
488
|
}
|
|
487
489
|
}
|
|
488
490
|
/**
|
|
491
|
+
* Cache successful module loads while allowing explicit refresh after transient failures.
|
|
492
|
+
* @private @author ddj 2026年10月08号
|
|
493
|
+
* @returns Official plugin module; load failures propagate to the instance diagnostic.
|
|
494
|
+
*/
|
|
495
|
+
async getModule() {
|
|
496
|
+
const loading = this.module ??= this.load();
|
|
497
|
+
try {
|
|
498
|
+
return await loading;
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (this.module === loading) this.module = void 0;
|
|
501
|
+
throw error;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
489
505
|
* Mount the official plugin through agent.ctx; ACTIVE is not connection evidence.
|
|
490
506
|
* @private @author ddj 2026年09月28号
|
|
491
507
|
* @param agent Scoped context owner.
|
|
@@ -501,7 +517,7 @@ var McpRuntime = class {
|
|
|
501
517
|
};
|
|
502
518
|
record.instances.set(config.serverName, instance);
|
|
503
519
|
try {
|
|
504
|
-
const officialMcp = await
|
|
520
|
+
const officialMcp = await this.getModule();
|
|
505
521
|
if (!record.live || this.stopped) return;
|
|
506
522
|
if (entriesOf(this.ctx).find((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? "")) && entry.options?.config?.serverName === config.serverName)) throw new Error("serverName 已被全局 MCP 使用:" + config.serverName);
|
|
507
523
|
const options = config.transport === "stdio" ? {
|
|
@@ -629,6 +645,7 @@ async function installMcpRuntime(ctx) {
|
|
|
629
645
|
//#region src/mcpProject.ts
|
|
630
646
|
/** Project MCP configuration; .mcp.json is the source of truth. @author ddj 2026年09月28号 */
|
|
631
647
|
const FILE_NAME = ".mcp.json";
|
|
648
|
+
const projectStates = /* @__PURE__ */ new WeakMap();
|
|
632
649
|
/**
|
|
633
650
|
* Require an exact registered workspace before any project file mutation.
|
|
634
651
|
* @private @author ddj 2026年09月28号
|
|
@@ -670,7 +687,17 @@ function projectEntryId(workspacePath, serverName) {
|
|
|
670
687
|
return PROJECT_ENTRY_PREFIX + hashWorkspace(workspacePath) + "." + serverName;
|
|
671
688
|
}
|
|
672
689
|
/**
|
|
673
|
-
*
|
|
690
|
+
* Identify an absent target using official host and legacy Node filesystem codes.
|
|
691
|
+
* @private @author ddj 2026年10月08号
|
|
692
|
+
* @param error Filesystem failure; messages and class identity are not authoritative.
|
|
693
|
+
* @returns Whether the backend explicitly reports a missing target.
|
|
694
|
+
*/
|
|
695
|
+
function isMissingFile(error) {
|
|
696
|
+
const code = error?.code;
|
|
697
|
+
return code === "FS_NOT_FOUND" || code === "ENOENT";
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Read the complete source document; only missing-target codes denote an absent configuration.
|
|
674
701
|
* @private @author ddj 2026年09月28号
|
|
675
702
|
* @param ctx Host filesystem.
|
|
676
703
|
* @param workspacePath Registered workspace path.
|
|
@@ -684,7 +711,7 @@ async function readProjectJson(ctx, workspacePath) {
|
|
|
684
711
|
try {
|
|
685
712
|
text = await fs.readText(target);
|
|
686
713
|
} catch (error) {
|
|
687
|
-
if (error
|
|
714
|
+
if (isMissingFile(error)) return {};
|
|
688
715
|
throw new Error(".mcp.json 读取失败:" + String(error));
|
|
689
716
|
}
|
|
690
717
|
let data;
|
|
@@ -722,7 +749,17 @@ function serversOf(data) {
|
|
|
722
749
|
*/
|
|
723
750
|
function configFromDef(def, serverName) {
|
|
724
751
|
if (!def) return null;
|
|
725
|
-
|
|
752
|
+
if (def.transport !== void 0 && !["stdio", "streamable-http"].includes(def.transport)) throw new Error("不支持的 MCP transport:" + serverName);
|
|
753
|
+
if (def.args !== void 0 && (!Array.isArray(def.args) || def.args.some((arg) => typeof arg !== "string"))) throw new Error("MCP args 必须是字符串数组:" + serverName);
|
|
754
|
+
for (const field of ["env", "headers"]) {
|
|
755
|
+
const values = def[field];
|
|
756
|
+
if (values !== void 0 && (!values || typeof values !== "object" || Array.isArray(values) || Object.values(values).some((value) => value === null || ![
|
|
757
|
+
"string",
|
|
758
|
+
"number",
|
|
759
|
+
"boolean"
|
|
760
|
+
].includes(typeof value)))) throw new Error("MCP " + field + " 必须是标量字典:" + serverName);
|
|
761
|
+
}
|
|
762
|
+
const transport = def.transport ?? (typeof def.url === "string" ? "streamable-http" : "stdio");
|
|
726
763
|
const config = {
|
|
727
764
|
serverName,
|
|
728
765
|
transport
|
|
@@ -765,15 +802,19 @@ async function writeProjectJson(ctx, workspacePath, data) {
|
|
|
765
802
|
* @returns Completion; throws on collision or an unreadable competing source document.
|
|
766
803
|
*/
|
|
767
804
|
async function checkMcpName(ctx, serverName, workspacePath) {
|
|
768
|
-
|
|
805
|
+
/** @private @author ddj 2026年10月08号 @param other Existing name. @param sameOwner Whether updates share ownership. @throws Error for ambiguous namespaces. */
|
|
806
|
+
const checkName = (other, sameOwner) => {
|
|
807
|
+
if (nameOverlap(serverName, other)) throw new Error("MCP 命名空间冲突:" + serverName + " / " + other + ",请避免 __ 前缀交叠");
|
|
808
|
+
if (other === serverName && !sameOwner) throw new Error("serverName \"" + serverName + "\" 已被另一个 MCP 使用(全局或其他项目),请换一个名称");
|
|
809
|
+
};
|
|
769
810
|
for (const entry of entriesOf(ctx)) {
|
|
770
|
-
if (isProjectEntryId(String(entry.id ?? entry.options?.id ?? ""))
|
|
771
|
-
|
|
811
|
+
if (isProjectEntryId(String(entry.id ?? entry.options?.id ?? ""))) continue;
|
|
812
|
+
const name = entry.options?.config?.serverName;
|
|
813
|
+
if (typeof name === "string") checkName(name, workspacePath === void 0);
|
|
772
814
|
}
|
|
773
815
|
for (const ws of ctx.get("workspaceRegistry")?.list?.() ?? []) {
|
|
774
|
-
if (ws.path === workspacePath) continue;
|
|
775
816
|
const servers = serversOf(await readProjectJson(ctx, ws.path));
|
|
776
|
-
|
|
817
|
+
for (const name of Object.keys(servers)) checkName(name, ws.path === workspacePath);
|
|
777
818
|
}
|
|
778
819
|
}
|
|
779
820
|
/**
|
|
@@ -817,9 +858,16 @@ async function projectOf(ctx, workspacePath, title) {
|
|
|
817
858
|
missingDir = !info || info.type !== "directory";
|
|
818
859
|
if (!missingDir) await reconcileProject(ctx, workspacePath);
|
|
819
860
|
} catch (error) {
|
|
820
|
-
missingDir = error
|
|
861
|
+
missingDir = isMissingFile(error) || void 0;
|
|
821
862
|
fileError = String(error);
|
|
822
863
|
}
|
|
864
|
+
const owner = ctx.root ?? ctx;
|
|
865
|
+
const states = projectStates.get(owner) ?? /* @__PURE__ */ new Map();
|
|
866
|
+
states.set(workspacePath, {
|
|
867
|
+
missingDir,
|
|
868
|
+
fileError
|
|
869
|
+
});
|
|
870
|
+
projectStates.set(owner, states);
|
|
823
871
|
return {
|
|
824
872
|
workspacePath,
|
|
825
873
|
title,
|
|
@@ -841,6 +889,26 @@ async function listProjects(ctx) {
|
|
|
841
889
|
return { projects };
|
|
842
890
|
}
|
|
843
891
|
/**
|
|
892
|
+
* Read the current ledger only; polling never reconciles files or starts connections.
|
|
893
|
+
* @public @author ddj 2026年10月08号
|
|
894
|
+
* @param ctx Host services.
|
|
895
|
+
* @returns Masked global/project snapshots with last configuration diagnostics.
|
|
896
|
+
*/
|
|
897
|
+
function mcpSnapshot(ctx) {
|
|
898
|
+
const states = projectStates.get(ctx.root ?? ctx);
|
|
899
|
+
const projects = (ctx.get("workspaceRegistry")?.list?.() ?? []).map((ws) => ({
|
|
900
|
+
workspacePath: ws.path,
|
|
901
|
+
title: ws.title ?? "",
|
|
902
|
+
source: "project",
|
|
903
|
+
servers: runtimeOf(ctx).views(ws.path),
|
|
904
|
+
...states?.get(ws.path)
|
|
905
|
+
}));
|
|
906
|
+
return {
|
|
907
|
+
...listMcp(ctx),
|
|
908
|
+
projects
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
844
912
|
* Merge editable fields while retaining advanced fields and unchanged masked credentials.
|
|
845
913
|
* @private @author ddj 2026年09月28号
|
|
846
914
|
* @param previous Persisted server definition.
|
|
@@ -848,20 +916,10 @@ async function listProjects(ctx) {
|
|
|
848
916
|
* @returns Enabled source definition without runtime-only keys or stale transport fields.
|
|
849
917
|
*/
|
|
850
918
|
function mergeDefinition(previous, config) {
|
|
851
|
-
const next =
|
|
852
|
-
...previous,
|
|
853
|
-
...config
|
|
854
|
-
}, previous);
|
|
919
|
+
const next = mergeMcp(previous, config);
|
|
855
920
|
delete next.serverName;
|
|
856
921
|
delete next.transport;
|
|
857
922
|
delete next.disabled;
|
|
858
|
-
const stale = config.transport === "stdio" ? ["url", "headers"] : [
|
|
859
|
-
"command",
|
|
860
|
-
"args",
|
|
861
|
-
"cwd",
|
|
862
|
-
"env"
|
|
863
|
-
];
|
|
864
|
-
for (const key of stale) delete next[key];
|
|
865
923
|
return next;
|
|
866
924
|
}
|
|
867
925
|
/**
|
|
@@ -874,6 +932,10 @@ function mergeDefinition(previous, config) {
|
|
|
874
932
|
* @returns Updated project view; invalid sources or conflicts reject before writing.
|
|
875
933
|
*/
|
|
876
934
|
async function projectSave(ctx, workspacePath, serverName, config) {
|
|
935
|
+
return mcpEdit(ctx, () => saveProject(ctx, workspacePath, serverName, config));
|
|
936
|
+
}
|
|
937
|
+
/** @private @author ddj 2026年10月08号 @param ctx Host services. @param workspacePath Owner. @param serverName Namespace. @param config Editable fields. @returns Persisted project status. */
|
|
938
|
+
async function saveProject(ctx, workspacePath, serverName, config) {
|
|
877
939
|
const workspace = requireWorkspace$2(ctx, workspacePath);
|
|
878
940
|
validateConfig({
|
|
879
941
|
...config,
|
|
@@ -902,6 +964,10 @@ async function projectSave(ctx, workspacePath, serverName, config) {
|
|
|
902
964
|
* @returns Updated project view; write failures leave active configuration intact.
|
|
903
965
|
*/
|
|
904
966
|
async function projectRemove(ctx, workspacePath, serverName) {
|
|
967
|
+
return mcpEdit(ctx, () => removeProject(ctx, workspacePath, serverName));
|
|
968
|
+
}
|
|
969
|
+
/** @private @author ddj 2026年10月08号 @param ctx Host services. @param workspacePath Owner. @param serverName Namespace. @returns Project after persisted removal. */
|
|
970
|
+
async function removeProject(ctx, workspacePath, serverName) {
|
|
905
971
|
const workspace = requireWorkspace$2(ctx, workspacePath);
|
|
906
972
|
const data = await readProjectJson(ctx, workspacePath);
|
|
907
973
|
const servers = serversOf(data);
|
|
@@ -923,6 +989,10 @@ async function projectRemove(ctx, workspacePath, serverName) {
|
|
|
923
989
|
* @returns Updated project view, including retained disabled definitions.
|
|
924
990
|
*/
|
|
925
991
|
async function projectToggle(ctx, workspacePath, serverName, enabled) {
|
|
992
|
+
return mcpEdit(ctx, () => toggleProject(ctx, workspacePath, serverName, enabled));
|
|
993
|
+
}
|
|
994
|
+
/** @private @author ddj 2026年10月08号 @param ctx Host services. @param workspacePath Owner. @param serverName Namespace. @param enabled Desired state. @returns Project after persisted toggle. */
|
|
995
|
+
async function toggleProject(ctx, workspacePath, serverName, enabled) {
|
|
926
996
|
const workspace = requireWorkspace$2(ctx, workspacePath);
|
|
927
997
|
const data = await readProjectJson(ctx, workspacePath);
|
|
928
998
|
const servers = serversOf(data);
|
|
@@ -946,6 +1016,10 @@ async function projectToggle(ctx, workspacePath, serverName, enabled) {
|
|
|
946
1016
|
* @returns Updated project view; disabled or idle definitions stay connection-free.
|
|
947
1017
|
*/
|
|
948
1018
|
async function projectRefresh(ctx, workspacePath, serverName) {
|
|
1019
|
+
return mcpEdit(ctx, () => refreshProject(ctx, workspacePath, serverName));
|
|
1020
|
+
}
|
|
1021
|
+
/** @private @author ddj 2026年10月08号 @param ctx Host services. @param workspacePath Owner. @param serverName Namespace. @returns Refreshed project status without racing edits. */
|
|
1022
|
+
async function refreshProject(ctx, workspacePath, serverName) {
|
|
949
1023
|
const workspace = requireWorkspace$2(ctx, workspacePath);
|
|
950
1024
|
const data = await readProjectJson(ctx, workspacePath);
|
|
951
1025
|
if (!Object.hasOwn(serversOf(data), serverName)) throw new Error("项目中不存在该 MCP");
|
|
@@ -960,6 +1034,44 @@ async function projectRefresh(ctx, workspacePath, serverName) {
|
|
|
960
1034
|
* 身份常量(包名/条目前缀/新旧格式判定)统一由 compat 层持有,本模块再导出保持既有 import 面。
|
|
961
1035
|
* 作者 ddj 2026年08月22号
|
|
962
1036
|
*/
|
|
1037
|
+
/**
|
|
1038
|
+
* Compare the official tool-name namespaces without guessing the longest prefix.
|
|
1039
|
+
* @public @author ddj 2026年10月08号
|
|
1040
|
+
* @param left First server name.
|
|
1041
|
+
* @param right Second server name.
|
|
1042
|
+
* @returns Whether distinct names generate overlapping public tool names.
|
|
1043
|
+
*/
|
|
1044
|
+
function nameOverlap(left, right) {
|
|
1045
|
+
return left !== right && (left.startsWith(right + "__") || right.startsWith(left + "__"));
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Collect known global and project namespaces without opening connections.
|
|
1049
|
+
* @public @author ddj 2026年10月08号
|
|
1050
|
+
* @param ctx Host context.
|
|
1051
|
+
* @returns Distinct server names used for fail-closed attribution.
|
|
1052
|
+
*/
|
|
1053
|
+
function mcpNames(ctx) {
|
|
1054
|
+
const names = new Set(runtimeOf(ctx).owners().keys());
|
|
1055
|
+
for (const entry of entriesOf(ctx)) {
|
|
1056
|
+
const name = entry.options?.config?.serverName;
|
|
1057
|
+
if (typeof name === "string") names.add(name);
|
|
1058
|
+
}
|
|
1059
|
+
return names;
|
|
1060
|
+
}
|
|
1061
|
+
const edits = /* @__PURE__ */ new WeakMap();
|
|
1062
|
+
/**
|
|
1063
|
+
* Serialize profile/project edits together to preserve files and cross-scope name reservations.
|
|
1064
|
+
* @public @author ddj 2026年10月08号
|
|
1065
|
+
* @param ctx Host owner.
|
|
1066
|
+
* @param action Validated configuration operation.
|
|
1067
|
+
* @returns Operation result; a rejection never poisons the next edit.
|
|
1068
|
+
*/
|
|
1069
|
+
function mcpEdit(ctx, action) {
|
|
1070
|
+
const owner = ctx.root ?? ctx;
|
|
1071
|
+
const next = (edits.get(owner) ?? Promise.resolve()).then(action, action);
|
|
1072
|
+
edits.set(owner, next);
|
|
1073
|
+
return next;
|
|
1074
|
+
}
|
|
963
1075
|
/** 列出当前 loader 中的 MCP entry。 */
|
|
964
1076
|
function entriesOf(ctx) {
|
|
965
1077
|
const loader = ctx.get("loader");
|
|
@@ -985,7 +1097,10 @@ function toolsOf(ctx, serverName, agent) {
|
|
|
985
1097
|
const visible = (ctx.get("tools")?.view?.(agent))?.visible;
|
|
986
1098
|
if (!(visible instanceof Map)) return [];
|
|
987
1099
|
const prefix = `mcp__${serverName}__`;
|
|
988
|
-
|
|
1100
|
+
const namespaces = mcpNames(ctx);
|
|
1101
|
+
namespaces.add(serverName);
|
|
1102
|
+
const belongs = (name) => name.startsWith(prefix) && [...namespaces].filter((server) => name.startsWith("mcp__" + server + "__")).length === 1;
|
|
1103
|
+
return [...visible.entries()].filter(([name]) => belongs(String(name))).map(([name, definition]) => ({
|
|
989
1104
|
name: String(name).slice(prefix.length),
|
|
990
1105
|
description: typeof definition?.description === "string" ? definition.description : void 0
|
|
991
1106
|
}));
|
|
@@ -1017,6 +1132,27 @@ function mergeSecrets(next, previous) {
|
|
|
1017
1132
|
return merged;
|
|
1018
1133
|
}
|
|
1019
1134
|
/**
|
|
1135
|
+
* Merge editable transport fields while retaining advanced settings and secret values.
|
|
1136
|
+
* @public @author ddj 2026年10月08号
|
|
1137
|
+
* @param previous Last saved configuration.
|
|
1138
|
+
* @param config Submitted transport configuration.
|
|
1139
|
+
* @returns Detached configuration without fields belonging to the old transport.
|
|
1140
|
+
*/
|
|
1141
|
+
function mergeMcp(previous, config) {
|
|
1142
|
+
const next = mergeSecrets({
|
|
1143
|
+
...previous,
|
|
1144
|
+
...config
|
|
1145
|
+
}, previous);
|
|
1146
|
+
const stale = config.transport === "stdio" ? ["url", "headers"] : [
|
|
1147
|
+
"command",
|
|
1148
|
+
"args",
|
|
1149
|
+
"cwd",
|
|
1150
|
+
"env"
|
|
1151
|
+
];
|
|
1152
|
+
for (const key of stale) delete next[key];
|
|
1153
|
+
return next;
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1020
1156
|
* Summarize a global entry without treating plugin activation as a successful handshake.
|
|
1021
1157
|
* @public @author ddj 2026年09月28号
|
|
1022
1158
|
* @param ctx Host services.
|
|
@@ -1041,11 +1177,42 @@ function serverOf(ctx, entry) {
|
|
|
1041
1177
|
error: state === 3 ? "MCP 插件未正常运行" : void 0
|
|
1042
1178
|
};
|
|
1043
1179
|
}
|
|
1044
|
-
/**
|
|
1180
|
+
/**
|
|
1181
|
+
* Validate transport fields before any loader or source mutation.
|
|
1182
|
+
* @public @author ddj 2026年10月08号
|
|
1183
|
+
* @param config Submitted configuration.
|
|
1184
|
+
* @throws Error for unsupported transport or malformed endpoint/arguments/maps.
|
|
1185
|
+
*/
|
|
1045
1186
|
function validateConfig(config) {
|
|
1046
|
-
if (
|
|
1047
|
-
if (config.
|
|
1048
|
-
if (
|
|
1187
|
+
if (!config || typeof config !== "object") throw new Error("MCP 配置必须是对象");
|
|
1188
|
+
if (typeof config.serverName !== "string" || !/^[A-Za-z0-9_-]{1,32}$/.test(config.serverName)) throw new Error("serverName 只能包含字母、数字、下划线和连字符(最多 32 位)");
|
|
1189
|
+
if (!["stdio", "streamable-http"].includes(config.transport)) throw new Error("不支持的 MCP transport");
|
|
1190
|
+
if (config.transport === "stdio") {
|
|
1191
|
+
if (typeof config.command !== "string" || !config.command.trim()) throw new Error("stdio MCP 必须填写 command");
|
|
1192
|
+
if (config.args !== void 0 && (!Array.isArray(config.args) || config.args.some((arg) => typeof arg !== "string"))) throw new Error("MCP args 必须是字符串数组");
|
|
1193
|
+
if (config.cwd !== void 0 && typeof config.cwd !== "string") throw new Error("MCP cwd 必须是字符串");
|
|
1194
|
+
validatePairs(config.env, "env");
|
|
1195
|
+
} else {
|
|
1196
|
+
let url;
|
|
1197
|
+
try {
|
|
1198
|
+
url = new URL(config.url ?? "");
|
|
1199
|
+
} catch {
|
|
1200
|
+
throw new Error("HTTP MCP 必须填写有效 http(s) URL");
|
|
1201
|
+
}
|
|
1202
|
+
if (!["http:", "https:"].includes(url.protocol) || !url.hostname || url.username || url.password) throw new Error("HTTP MCP 必须填写有效 http(s) URL,凭据请使用请求头");
|
|
1203
|
+
validatePairs(config.headers, "headers");
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Validate an optional string-valued map without coercing malformed input.
|
|
1208
|
+
* @private @author ddj 2026年10月08号
|
|
1209
|
+
* @param values Submitted map.
|
|
1210
|
+
* @param field Field used in the diagnostic.
|
|
1211
|
+
* @throws Error for non-object or non-string values.
|
|
1212
|
+
*/
|
|
1213
|
+
function validatePairs(values, field) {
|
|
1214
|
+
if (values === void 0) return;
|
|
1215
|
+
if (!values || typeof values !== "object" || Array.isArray(values) || Object.entries(values).some(([key, value]) => !key || typeof value !== "string")) throw new Error("MCP " + field + " 必须是字符串字典");
|
|
1049
1216
|
}
|
|
1050
1217
|
/** 列出全局(profile)MCP 服务,过滤掉新旧格式的项目级条目。 */
|
|
1051
1218
|
function listMcp(ctx) {
|
|
@@ -1059,16 +1226,16 @@ function listMcp(ctx) {
|
|
|
1059
1226
|
* @returns Masked server status; project name collisions reject before writing.
|
|
1060
1227
|
*/
|
|
1061
1228
|
async function saveMcp(ctx, config) {
|
|
1229
|
+
return mcpEdit(ctx, () => saveGlobal(ctx, config));
|
|
1230
|
+
}
|
|
1231
|
+
/** @private @author ddj 2026年10月08号 @param ctx Host services. @param config Configuration. @returns Saved status; validation failures do not write. */
|
|
1232
|
+
async function saveGlobal(ctx, config) {
|
|
1062
1233
|
validateConfig(config);
|
|
1063
1234
|
await checkMcpName(ctx, config.serverName);
|
|
1064
1235
|
const loader = ctx.get("loader");
|
|
1065
1236
|
if (!loader) throw new Error("缺少 loader 服务");
|
|
1066
1237
|
const existing = entriesOf(ctx).find((entry) => !isProjectEntryId(String(entry.id ?? entry.options?.id ?? "")) && entry.options?.config?.serverName === config.serverName);
|
|
1067
|
-
const
|
|
1068
|
-
const next = mergeSecrets({
|
|
1069
|
-
...previous,
|
|
1070
|
-
...config
|
|
1071
|
-
}, previous);
|
|
1238
|
+
const next = mergeMcp(existing?.options?.config ?? {}, config);
|
|
1072
1239
|
if (existing) {
|
|
1073
1240
|
await loader.update(existing.id, { config: next });
|
|
1074
1241
|
return serverOf(ctx, loader.resolve(existing.id));
|
|
@@ -1079,31 +1246,46 @@ async function saveMcp(ctx, config) {
|
|
|
1079
1246
|
});
|
|
1080
1247
|
return serverOf(ctx, loader.resolve(id));
|
|
1081
1248
|
}
|
|
1082
|
-
/**
|
|
1083
|
-
|
|
1249
|
+
/** @private @author ddj 2026年10月08号 @param ctx Host services. @param id Global identity. @returns Loader and verified global entry. @throws Missing service, entry or wrong scope. */
|
|
1250
|
+
function globalEntry(ctx, id) {
|
|
1084
1251
|
const loader = ctx.get("loader");
|
|
1085
1252
|
if (!loader) throw new Error("缺少 loader 服务");
|
|
1086
1253
|
const entry = entriesOf(ctx).find((candidate) => String(candidate.id) === id);
|
|
1087
1254
|
if (!entry) throw new Error("MCP 服务不存在");
|
|
1088
|
-
|
|
1255
|
+
if (isProjectEntryId(String(entry.id ?? entry.options?.id ?? ""))) throw new Error("项目 MCP 必须通过项目入口操作");
|
|
1256
|
+
return {
|
|
1257
|
+
loader,
|
|
1258
|
+
entry
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
/** @public @author ddj 2026年10月08号 @param ctx Host services. @param id Global identity. @returns Persisted removal, serialized with all settings writes. */
|
|
1262
|
+
async function removeMcp(ctx, id) {
|
|
1263
|
+
/** @private @author ddj 2026年10月08号 @returns Loader removal after scope validation. */
|
|
1264
|
+
const remove = async () => {
|
|
1265
|
+
const { loader, entry } = globalEntry(ctx, id);
|
|
1266
|
+
await loader.remove(entry.id);
|
|
1267
|
+
};
|
|
1268
|
+
return mcpEdit(ctx, remove);
|
|
1089
1269
|
}
|
|
1090
|
-
/**
|
|
1270
|
+
/** @public @author ddj 2026年10月08号 @param ctx Host services. @param id Global identity. @param enabled Desired state. @returns Persisted status after serialized toggle. */
|
|
1091
1271
|
async function toggleMcp(ctx, id, enabled) {
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1272
|
+
/** @private @author ddj 2026年10月08号 @returns Loader status after toggle. */
|
|
1273
|
+
const toggle = async () => {
|
|
1274
|
+
const { loader, entry } = globalEntry(ctx, id);
|
|
1275
|
+
await loader.update(entry.id, { disabled: !enabled });
|
|
1276
|
+
return serverOf(ctx, loader.resolve(entry.id));
|
|
1277
|
+
};
|
|
1278
|
+
return mcpEdit(ctx, toggle);
|
|
1098
1279
|
}
|
|
1099
|
-
/**
|
|
1280
|
+
/** @public @author ddj 2026年10月08号 @param ctx Host services. @param id Global identity. @returns Loader status after serialized refresh. */
|
|
1100
1281
|
async function refreshMcp(ctx, id) {
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1282
|
+
/** @private @author ddj 2026年10月08号 @returns Loader status after refresh. */
|
|
1283
|
+
const refresh = async () => {
|
|
1284
|
+
const { loader, entry } = globalEntry(ctx, id);
|
|
1285
|
+
await loader.update(entry.id, { config: { ...entry.options?.config ?? {} } });
|
|
1286
|
+
return serverOf(ctx, loader.resolve(entry.id));
|
|
1287
|
+
};
|
|
1288
|
+
return mcpEdit(ctx, refresh);
|
|
1107
1289
|
}
|
|
1108
1290
|
//#endregion
|
|
1109
1291
|
//#region src/shared/keybindings.ts
|
|
@@ -2241,8 +2423,6 @@ const DSH_HOME_ENV = "DSH_HOME";
|
|
|
2241
2423
|
const CACHE_TOTAL_CAP = 209715200;
|
|
2242
2424
|
/** 工作区根侧车:审查记录(用户数据,随项目走,不迁移)。 */
|
|
2243
2425
|
const SIDECAR = ".dsh-edit-review.json";
|
|
2244
|
-
/** 工作区根侧车:归档(用户数据)。 */
|
|
2245
|
-
const SIDECAR_ARCHIVE = ".dsh-edit-review-archive.json";
|
|
2246
2426
|
/** 旧 debug 日志文件名(工作区,迁移后由插件清理)。 */
|
|
2247
2427
|
const DEBUG_LOG = ".dsh-edit-review-debug.log";
|
|
2248
2428
|
/** 旧树缓存 sidecar 名(工作区,迁移后由插件清理;缓存可重建,删除无损)。 */
|
|
@@ -2376,6 +2556,16 @@ function uiGlobalsFile(home = dshHome(), schema = 1) {
|
|
|
2376
2556
|
function debugLogFile(cwd, home = dshHome()) {
|
|
2377
2557
|
return join(pluginLogRoot(home), "debug." + hashOf(cwd) + ".log");
|
|
2378
2558
|
}
|
|
2559
|
+
/**
|
|
2560
|
+
* 每工作区归档数据库(用户数据,不受缓存清理预算约束)。
|
|
2561
|
+
* @author ddj 2026年09月30号
|
|
2562
|
+
* @param cwd 工作区绝对路径
|
|
2563
|
+
* @param home DSH home
|
|
2564
|
+
* @returns 归档数据库绝对路径
|
|
2565
|
+
*/
|
|
2566
|
+
function archiveDbFile(cwd, home = dshHome()) {
|
|
2567
|
+
return join(home, PLUGIN_ID, "data", "archive", hashOf(cwd) + ".sqlite");
|
|
2568
|
+
}
|
|
2379
2569
|
/** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
|
|
2380
2570
|
function assetsDirOf(moduleUrl) {
|
|
2381
2571
|
return join(dirname(fileURLToPath(moduleUrl)), "..", "assets");
|
|
@@ -2933,19 +3123,23 @@ function inDshRange(version, range) {
|
|
|
2933
3123
|
}
|
|
2934
3124
|
/**
|
|
2935
3125
|
* 版本线标签:报告与文档展示版本归属;不可解析返回 '未知'。
|
|
2936
|
-
*
|
|
2937
|
-
* @author ddj 2026年09月02号 / 2026年09月18号 / 2026年09月22号 / 2026年09月29号
|
|
3126
|
+
* 逐线自新到旧匹配,先命中先返回;仅已静态审计的 0.2.0-rc.1/rc.2 精确标记。
|
|
3127
|
+
* @author ddj 2026年09月02号 / 2026年09月18号 / 2026年09月22号 / 2026年09月29号 / 2026年10月08号
|
|
2938
3128
|
* @param input 版本串(如 '0.1.6-alpha.2')
|
|
2939
3129
|
* @returns 版本线标签
|
|
2940
3130
|
*/
|
|
2941
3131
|
function familyLabel(input) {
|
|
2942
3132
|
const version = parseDshVersion(input);
|
|
2943
3133
|
if (!version) return "未知";
|
|
3134
|
+
if (inDshRange(version, {
|
|
3135
|
+
from: "0.2.0-rc.2",
|
|
3136
|
+
to: "0.2.0-rc.2"
|
|
3137
|
+
})) return "0.2.0-rc.2(设置=profile Config;按能力探测)";
|
|
2944
3138
|
if (inDshRange(version, {
|
|
2945
3139
|
from: "0.2.0-rc.1",
|
|
2946
3140
|
to: "0.2.0-rc.1"
|
|
2947
3141
|
})) return "0.2.0-rc.1(设置=profile Config;按能力探测)";
|
|
2948
|
-
if (inDshRange(version, { from: "0.2.0-0" })) return "0.2.0 及更新(除 rc.1 外未经逐版适配;按能力探测)";
|
|
3142
|
+
if (inDshRange(version, { from: "0.2.0-0" })) return "0.2.0 及更新(除 rc.1/rc.2 外未经逐版适配;按能力探测)";
|
|
2949
3143
|
if (inDshRange(version, { from: "0.1.7-alpha.1" })) return "0.1.7-alpha.1 及更新(设置=profile Config + configForms 客户端面 + 会话 V4)";
|
|
2950
3144
|
if (inDshRange(version, { from: "0.1.6-alpha.2" })) return "0.1.6-alpha.2 及更新(会话多实例共存 + 回合改动卡片 + Office 侧栏预览 + 侧栏浏览器)";
|
|
2951
3145
|
if (inDshRange(version, { from: "0.1.6-alpha.1" })) return "0.1.6-alpha 及更新(MCP SDK v2 + Web 侧边栏终端 + 文件链接默认侧栏预览)";
|
|
@@ -3735,21 +3929,42 @@ function commonSuffix(left, right, prefix) {
|
|
|
3735
3929
|
while (i < limit && left.charCodeAt(left.length - 1 - i) === right.charCodeAt(right.length - 1 - i)) i++;
|
|
3736
3930
|
return i;
|
|
3737
3931
|
}
|
|
3738
|
-
/**
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3932
|
+
/**
|
|
3933
|
+
* 懒扫描候选:先找 cursor 后未占用项,找不到再回退至首个未占用项。
|
|
3934
|
+
* @author ddj 2026年09月29号
|
|
3935
|
+
* @param content 当前内容
|
|
3936
|
+
* @param needle 搜索串
|
|
3937
|
+
* @param cursor 前一块的结束位置
|
|
3938
|
+
* @param occupied 已选区间
|
|
3939
|
+
* @param state 此搜索串已扫描的候选
|
|
3940
|
+
* @returns 与完整候选列表双轮查找相同的匹配
|
|
3941
|
+
*/
|
|
3942
|
+
function findCandidate(content, needle, cursor, occupied, state) {
|
|
3943
|
+
if (!needle.length) return void 0;
|
|
3944
|
+
let fallback;
|
|
3945
|
+
for (let i = 0; i < state.ranges.length; i++) {
|
|
3946
|
+
const range = state.ranges[i];
|
|
3947
|
+
if (occupied.some((used) => overlaps(range, used))) continue;
|
|
3948
|
+
if (range.start >= cursor) return range;
|
|
3949
|
+
if (!fallback) fallback = range;
|
|
3950
|
+
}
|
|
3951
|
+
while (!state.done) {
|
|
3952
|
+
const at = content.indexOf(needle, state.next);
|
|
3953
|
+
if (at < 0) {
|
|
3954
|
+
state.done = true;
|
|
3955
|
+
break;
|
|
3956
|
+
}
|
|
3957
|
+
const range = {
|
|
3747
3958
|
start: at,
|
|
3748
3959
|
end: at + needle.length
|
|
3749
|
-
}
|
|
3750
|
-
|
|
3960
|
+
};
|
|
3961
|
+
state.ranges.push(range);
|
|
3962
|
+
state.next = range.end;
|
|
3963
|
+
if (occupied.some((used) => overlaps(range, used))) continue;
|
|
3964
|
+
if (range.start >= cursor) return range;
|
|
3965
|
+
if (!fallback) fallback = range;
|
|
3751
3966
|
}
|
|
3752
|
-
return
|
|
3967
|
+
return fallback;
|
|
3753
3968
|
}
|
|
3754
3969
|
/**
|
|
3755
3970
|
* 从 after 快照定位所有 hunk,按 hunk 顺序优先选未占用且靠近前一块的候选。
|
|
@@ -3763,6 +3978,7 @@ function locateHunks(content, hunks) {
|
|
|
3763
3978
|
const locations = [];
|
|
3764
3979
|
let cursor = 0;
|
|
3765
3980
|
const occupied = [];
|
|
3981
|
+
const matches = /* @__PURE__ */ new Map();
|
|
3766
3982
|
for (let idx = 0; idx < hunks.length; idx++) {
|
|
3767
3983
|
const hunk = hunks[idx];
|
|
3768
3984
|
const newText = hunk.newText;
|
|
@@ -3771,8 +3987,21 @@ function locateHunks(content, hunks) {
|
|
|
3771
3987
|
end: hunk.afterEnd
|
|
3772
3988
|
} : null;
|
|
3773
3989
|
const snapshotMatches = snapshot && snapshot.start >= 0 && snapshot.end >= snapshot.start && (newText.length === 0 || content.slice(snapshot.start, snapshot.end) === newText) ? snapshot : null;
|
|
3774
|
-
|
|
3775
|
-
|
|
3990
|
+
let candidate;
|
|
3991
|
+
if (snapshotMatches) {
|
|
3992
|
+
if (!occupied.some((used) => overlaps(snapshotMatches, used))) candidate = snapshotMatches;
|
|
3993
|
+
} else if (newText.length) {
|
|
3994
|
+
let state = matches.get(newText);
|
|
3995
|
+
if (!state) {
|
|
3996
|
+
state = {
|
|
3997
|
+
ranges: [],
|
|
3998
|
+
next: 0,
|
|
3999
|
+
done: false
|
|
4000
|
+
};
|
|
4001
|
+
matches.set(newText, state);
|
|
4002
|
+
}
|
|
4003
|
+
candidate = findCandidate(content, newText, cursor, occupied, state);
|
|
4004
|
+
}
|
|
3776
4005
|
if (candidate) {
|
|
3777
4006
|
locations.push({
|
|
3778
4007
|
idx,
|
|
@@ -3832,82 +4061,43 @@ function replaceRange(content, location, reverse = false) {
|
|
|
3832
4061
|
const replacement = reverse ? location.hunk.oldText ?? "" : location.hunk.newText;
|
|
3833
4062
|
return content.slice(0, location.start) + replacement + content.slice(location.end);
|
|
3834
4063
|
}
|
|
3835
|
-
/**
|
|
4064
|
+
/**
|
|
4065
|
+
* 按位置倒序收集片段,最后一次拼接;非互斥区间退回原替换语义。
|
|
4066
|
+
* @author ddj 2026年09月29号
|
|
4067
|
+
* @param content 当前文件内容
|
|
4068
|
+
* @param locations 已定位区间
|
|
4069
|
+
* @param reverse 是否反向应用
|
|
4070
|
+
* @returns 合成内容及失效块索引
|
|
4071
|
+
*/
|
|
3836
4072
|
function applyLocations(content, locations, reverse = false) {
|
|
3837
4073
|
const stale = locations.filter((location) => !location.matched).map((location) => location.idx);
|
|
3838
|
-
let out = content;
|
|
3839
4074
|
const matched = locations.filter((location) => location.matched).sort((a, b) => b.start - a.start);
|
|
4075
|
+
const parts = [];
|
|
4076
|
+
let end = content.length;
|
|
3840
4077
|
for (const location of matched) {
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
4078
|
+
if (location.start < 0 || location.end > end) {
|
|
4079
|
+
let out = content;
|
|
4080
|
+
for (const item of matched) {
|
|
4081
|
+
const next = replaceRange(out, item, reverse);
|
|
4082
|
+
if (next === null) stale.push(item.idx);
|
|
4083
|
+
else out = next;
|
|
4084
|
+
}
|
|
4085
|
+
return {
|
|
4086
|
+
content: out,
|
|
4087
|
+
stale: stale.sort((a, b) => a - b)
|
|
4088
|
+
};
|
|
4089
|
+
}
|
|
4090
|
+
parts.push(content.slice(location.end, end), reverse ? location.hunk.oldText ?? "" : location.hunk.newText);
|
|
4091
|
+
end = location.start;
|
|
3844
4092
|
}
|
|
4093
|
+
parts.push(content.slice(0, end));
|
|
4094
|
+
parts.reverse();
|
|
3845
4095
|
return {
|
|
3846
|
-
content:
|
|
4096
|
+
content: parts.join(""),
|
|
3847
4097
|
stale: stale.sort((a, b) => a - b)
|
|
3848
4098
|
};
|
|
3849
4099
|
}
|
|
3850
4100
|
/**
|
|
3851
|
-
* 归一化一条持久化记录(容忍旧版/损坏字段)。
|
|
3852
|
-
* @author ddj 2026年08月20号
|
|
3853
|
-
* @param raw 反序列化的原始记录
|
|
3854
|
-
* @returns 合法记录或 null
|
|
3855
|
-
*/
|
|
3856
|
-
function normalizeRecord(raw) {
|
|
3857
|
-
if (!raw || typeof raw !== "object") return null;
|
|
3858
|
-
const r = raw;
|
|
3859
|
-
if (typeof r.callId !== "string" || typeof r.path !== "string") return null;
|
|
3860
|
-
const hunks = Array.isArray(r.hunks) ? r.hunks.filter((h) => h && typeof h.newText === "string").map((h) => {
|
|
3861
|
-
const item = h;
|
|
3862
|
-
return {
|
|
3863
|
-
oldText: typeof item.oldText === "string" ? item.oldText : null,
|
|
3864
|
-
newText: item.newText,
|
|
3865
|
-
...Number.isInteger(item.afterStart) ? { afterStart: item.afterStart } : {},
|
|
3866
|
-
...Number.isInteger(item.afterEnd) ? { afterEnd: item.afterEnd } : {},
|
|
3867
|
-
...Number.isInteger(item.beforeStart) ? { beforeStart: item.beforeStart } : {},
|
|
3868
|
-
...Number.isInteger(item.beforeEnd) ? { beforeEnd: item.beforeEnd } : {}
|
|
3869
|
-
};
|
|
3870
|
-
}) : [];
|
|
3871
|
-
const toolName = r.toolName === "write" || r.toolName === "remote_ssh_write" ? r.toolName : "edit";
|
|
3872
|
-
const rawDecisions = r.decisions && typeof r.decisions === "object" ? r.decisions : {};
|
|
3873
|
-
const call = rawDecisions.call === "accepted" || rawDecisions.call === "rejected" ? rawDecisions.call : "pending";
|
|
3874
|
-
const rawPerHunk = Array.isArray(rawDecisions.perHunk) ? rawDecisions.perHunk : [];
|
|
3875
|
-
const perHunk = hunks.map((_, i) => {
|
|
3876
|
-
const value = rawPerHunk[i];
|
|
3877
|
-
if (value === "accepted" || value === "rejected") return value;
|
|
3878
|
-
return call === "accepted" || call === "rejected" ? call : "pending";
|
|
3879
|
-
});
|
|
3880
|
-
const before = typeof r.before === "string" ? r.before : null;
|
|
3881
|
-
const hasAfter = Object.prototype.hasOwnProperty.call(r, "after");
|
|
3882
|
-
const after = typeof r.after === "string" ? r.after : null;
|
|
3883
|
-
return {
|
|
3884
|
-
callId: r.callId,
|
|
3885
|
-
toolName,
|
|
3886
|
-
path: r.path,
|
|
3887
|
-
before,
|
|
3888
|
-
after,
|
|
3889
|
-
baseFingerprint: typeof r.baseFingerprint === "string" ? r.baseFingerprint : fingerprint(before),
|
|
3890
|
-
afterFingerprint: typeof r.afterFingerprint === "string" ? r.afterFingerprint : fingerprint(after),
|
|
3891
|
-
legacy: !hasAfter,
|
|
3892
|
-
conflict: r.conflict === true,
|
|
3893
|
-
create: r.create === true,
|
|
3894
|
-
callHunk: r.callHunk && typeof r.callHunk.oldText === "string" && typeof r.callHunk.newText === "string" ? {
|
|
3895
|
-
oldText: r.callHunk.oldText,
|
|
3896
|
-
newText: r.callHunk.newText
|
|
3897
|
-
} : null,
|
|
3898
|
-
hunks,
|
|
3899
|
-
decisions: {
|
|
3900
|
-
call,
|
|
3901
|
-
perHunk
|
|
3902
|
-
},
|
|
3903
|
-
note: typeof r.note === "string" ? r.note : null,
|
|
3904
|
-
superseded: r.superseded === true,
|
|
3905
|
-
archived: r.archived === true,
|
|
3906
|
-
batch: Number.isInteger(r.batch) ? r.batch : 0,
|
|
3907
|
-
at: typeof r.at === "string" ? r.at : (/* @__PURE__ */ new Date()).toISOString()
|
|
3908
|
-
};
|
|
3909
|
-
}
|
|
3910
|
-
/**
|
|
3911
4101
|
* 写决策到记录(scope=call 整体 / hunk 单个块)。
|
|
3912
4102
|
* @author ddj 2026年08月20号
|
|
3913
4103
|
* @param record 记录
|
|
@@ -4012,16 +4202,6 @@ function prune(map, max = 200) {
|
|
|
4012
4202
|
const sorted = [...map.values()].sort((a, b) => a.at < b.at ? -1 : 1);
|
|
4013
4203
|
for (let i = 0; i < sorted.length - max; i++) map.delete(sorted[i].callId);
|
|
4014
4204
|
}
|
|
4015
|
-
/** 按批次分组记录。 */
|
|
4016
|
-
function groupByBatch(recs) {
|
|
4017
|
-
const map = /* @__PURE__ */ new Map();
|
|
4018
|
-
for (const r of recs) {
|
|
4019
|
-
const k = Number.isInteger(r.batch) ? r.batch : null;
|
|
4020
|
-
if (!map.has(k)) map.set(k, []);
|
|
4021
|
-
map.get(k).push(r);
|
|
4022
|
-
}
|
|
4023
|
-
return map;
|
|
4024
|
-
}
|
|
4025
4205
|
/**
|
|
4026
4206
|
* 归档条目构造(一条批次一条)。
|
|
4027
4207
|
* @author ddj 2026年08月20号
|
|
@@ -4060,102 +4240,739 @@ function archiveEntryFor(recs, cwd, reason) {
|
|
|
4060
4240
|
};
|
|
4061
4241
|
}
|
|
4062
4242
|
//#endregion
|
|
4063
|
-
//#region src/
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
/**
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
/**
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4243
|
+
//#region src/debugLog.ts
|
|
4244
|
+
/**
|
|
4245
|
+
* dsh-vscode-mode host — 诊断文件通道(client 上报 → 缓冲批量落盘)+ 日志文件管理。
|
|
4246
|
+
* 通道:edrv.debug RPC → 内存缓冲(满/空闲触发)→ 串行读改写
|
|
4247
|
+
* `~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log`(512KB 上限截断保留尾部)。
|
|
4248
|
+
* 设计:console 不一定落盘,文件可靠;写失败静默忽略(调试日志不阻塞业务)。
|
|
4249
|
+
* 管理:edrv.dlog.* RPC 用的清单/尾部读取/清空三能力;file 参数一律先过
|
|
4250
|
+
* isDebugLogName 白名单(纯文件名、无路径分隔),杜绝穿越出日志根。
|
|
4251
|
+
* 作者 ddj 2026-09-08 / 2026-09-17
|
|
4252
|
+
*/
|
|
4253
|
+
/** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
|
|
4254
|
+
const debugBuffers = /* @__PURE__ */ new Map();
|
|
4255
|
+
/** cwd → Promise 链:串行化 debug 日志落盘(fs read+write 非原子,避免并发丢行)。 */
|
|
4256
|
+
const debugWriteQueues = /* @__PURE__ */ new Map();
|
|
4257
|
+
/** debug 日志单文件上限:超限截断保留尾部,防文件无限增长拖慢每次追加。 */
|
|
4258
|
+
const DEBUG_LOG_CAP = 524288;
|
|
4259
|
+
/** debug 日志批量缓冲上限:攒满即落盘(一次读改写),上限内不逐条写文件。 */
|
|
4260
|
+
const DEBUG_BUF_CAP = 32768;
|
|
4261
|
+
/** debug 日志空闲 flush 延迟:缓冲未满时,静默一段时间后落盘一次。 */
|
|
4262
|
+
const DEBUG_FLUSH_IDLE_MS = 1e3;
|
|
4263
|
+
/** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
|
|
4264
|
+
const debugLegacyCleaned = /* @__PURE__ */ new Set();
|
|
4265
|
+
/** 诊断回显日志器(终端回显统一带 `[dsh-vscode-mode:debug]` 前缀,级别跟随上报级别)。 */
|
|
4266
|
+
const debugLog = log.child("debug");
|
|
4267
|
+
/** 诊断日志文件名白名单(cwd hash 命名;请求回传的 file 参数必须整名匹配,防路径穿越)。 */
|
|
4268
|
+
const DEBUG_LOG_NAME = /^debug\.[0-9a-f]{16}\.log$/;
|
|
4269
|
+
/**
|
|
4270
|
+
* 文件名是否为合法诊断日志名(白名单整名匹配,杜绝路径穿越)。
|
|
4271
|
+
* @author ddj 2026年09月17号
|
|
4272
|
+
* @param name 请求回传的文件名(可为空)
|
|
4273
|
+
* @returns 是否合法
|
|
4274
|
+
*/
|
|
4275
|
+
function isDebugLogName(name) {
|
|
4276
|
+
return typeof name === "string" && DEBUG_LOG_NAME.test(name);
|
|
4088
4277
|
}
|
|
4089
4278
|
/**
|
|
4090
|
-
*
|
|
4091
|
-
*
|
|
4092
|
-
* @
|
|
4093
|
-
* @
|
|
4279
|
+
* 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
|
|
4280
|
+
* 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
|
|
4281
|
+
* @author ddj 2026年09月08号
|
|
4282
|
+
* @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
|
|
4283
|
+
* @param batch 本批日志行
|
|
4094
4284
|
*/
|
|
4095
|
-
function
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
const
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
records: b.records
|
|
4107
|
-
};
|
|
4285
|
+
function flushDebug(cwd, batch) {
|
|
4286
|
+
const task = (debugWriteQueues.get(cwd) ?? Promise.resolve()).then(async () => {
|
|
4287
|
+
try {
|
|
4288
|
+
await mkdir(pluginLogRoot(), { recursive: true });
|
|
4289
|
+
const target = debugLogFile(cwd);
|
|
4290
|
+
const appended = await readFile(target, "utf8").catch(() => "") + batch.map((l) => (/* @__PURE__ */ new Date()).toISOString() + " " + l + "\n").join("");
|
|
4291
|
+
const next = appended.length > DEBUG_LOG_CAP ? appended.slice(-Math.floor(DEBUG_LOG_CAP / 2)) : appended;
|
|
4292
|
+
await writeFile(target, next, "utf8");
|
|
4293
|
+
if (!debugLegacyCleaned.has(cwd)) {
|
|
4294
|
+
debugLegacyCleaned.add(cwd);
|
|
4295
|
+
await rm(join(cwd, DEBUG_LOG), { force: true }).catch(() => {});
|
|
4108
4296
|
}
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
};
|
|
4114
|
-
}
|
|
4115
|
-
return null;
|
|
4116
|
-
} catch (error) {
|
|
4117
|
-
return null;
|
|
4118
|
-
}
|
|
4119
|
-
}
|
|
4120
|
-
/** 加载某工作区的记录桶(内存 Map;缺失 → 空)。 */
|
|
4121
|
-
async function loadBucket(ctx, cwd) {
|
|
4122
|
-
const data = parseSidecar(await readSidecarText(ctx, cwd));
|
|
4123
|
-
const map = /* @__PURE__ */ new Map();
|
|
4124
|
-
if (data && data.workspaces[cwd] && typeof data.workspaces[cwd].records === "object") for (const rec of Object.values(data.workspaces[cwd].records)) {
|
|
4125
|
-
const n = normalizeRecord(rec);
|
|
4126
|
-
if (n) map.set(n.callId, n);
|
|
4127
|
-
}
|
|
4128
|
-
return map;
|
|
4129
|
-
}
|
|
4130
|
-
/** 保存某工作区的记录桶(写前合并,不覆盖其他工作区)。 */
|
|
4131
|
-
async function saveBucket(ctx, cwd, recsMap, session) {
|
|
4132
|
-
const fs = ctx.get("fs");
|
|
4133
|
-
if (!fs || !cwd) return;
|
|
4134
|
-
try {
|
|
4135
|
-
const target = await fs.resolve(SIDECAR, { cwd });
|
|
4136
|
-
const existing = parseSidecar(await readSidecarText(ctx, cwd)) ?? {
|
|
4137
|
-
version: 2,
|
|
4138
|
-
updatedAt: "",
|
|
4139
|
-
workspaces: {}
|
|
4140
|
-
};
|
|
4141
|
-
existing.workspaces[cwd] = {
|
|
4142
|
-
at: Date.now(),
|
|
4143
|
-
records: Object.fromEntries(recsMap)
|
|
4144
|
-
};
|
|
4145
|
-
existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4146
|
-
await fs.writeText(target, JSON.stringify(existing), void 0, void 0, policyOf(ctx, session));
|
|
4147
|
-
} catch (error) {
|
|
4148
|
-
log.error("saveBucket failed: " + String(error));
|
|
4149
|
-
}
|
|
4297
|
+
} catch (e) {}
|
|
4298
|
+
});
|
|
4299
|
+
debugWriteQueues.set(cwd, task);
|
|
4300
|
+
return task;
|
|
4150
4301
|
}
|
|
4151
4302
|
/**
|
|
4152
|
-
*
|
|
4153
|
-
*
|
|
4154
|
-
* @
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4303
|
+
* 记录一条 client 上报的诊断日志:入队缓冲批量落盘 + 终端回显(级别跟随,缺省 debug)。
|
|
4304
|
+
* 落盘行格式 `<iso> [level] text`;级别标记在入队时补齐,flush 侧无感。
|
|
4305
|
+
* @author ddj 2026年09月08号 / 2026年09月17号
|
|
4306
|
+
* @param _ctx DSH 上下文(入参保留:诊断通道按 cwd 隔离,当前不需要 ctx,未来过滤用)
|
|
4307
|
+
* @param cwd 工作区(日志文件按 cwd hash 隔离)
|
|
4308
|
+
* @param text 单条日志文本(不含换行)
|
|
4309
|
+
* @param level 日志级别(缺省 debug;落盘行与终端回显均带此级别)
|
|
4310
|
+
*/
|
|
4311
|
+
function debugRecord(_ctx, cwd, text, level = "debug") {
|
|
4312
|
+
const tagged = "[" + level + "] " + text;
|
|
4313
|
+
const st = debugBuffers.get(cwd) ?? {
|
|
4314
|
+
lines: [],
|
|
4315
|
+
len: 0,
|
|
4316
|
+
timer: null
|
|
4317
|
+
};
|
|
4318
|
+
st.lines.push(tagged);
|
|
4319
|
+
st.len += tagged.length;
|
|
4320
|
+
const flush = () => {
|
|
4321
|
+
st.timer = null;
|
|
4322
|
+
const batch = st.lines;
|
|
4323
|
+
st.lines = [];
|
|
4324
|
+
st.len = 0;
|
|
4325
|
+
flushDebug(cwd, batch);
|
|
4326
|
+
};
|
|
4327
|
+
if (st.len >= DEBUG_BUF_CAP) {
|
|
4328
|
+
if (st.timer) clearTimeout(st.timer);
|
|
4329
|
+
flush();
|
|
4330
|
+
} else if (!st.timer) st.timer = setTimeout(flush, DEBUG_FLUSH_IDLE_MS);
|
|
4331
|
+
debugBuffers.set(cwd, st);
|
|
4332
|
+
debugLog[level](text);
|
|
4333
|
+
}
|
|
4334
|
+
/**
|
|
4335
|
+
* 清单:日志根下全部诊断日志文件(名字/字节/mtime;非法名跳过),mtime 新的在前。
|
|
4336
|
+
* @author ddj 2026年09月17号
|
|
4337
|
+
* @param home DSH home(测试可注入;缺省按环境解析)
|
|
4338
|
+
* @returns 清单项数组
|
|
4339
|
+
*/
|
|
4340
|
+
async function listDebugLogs(home = dshHome()) {
|
|
4341
|
+
const names = await readdir(pluginLogRoot(home)).catch(() => []);
|
|
4342
|
+
const items = [];
|
|
4343
|
+
for (const name of names) {
|
|
4344
|
+
if (!isDebugLogName(name)) continue;
|
|
4345
|
+
const info = await stat(join(pluginLogRoot(home), name)).catch(() => null);
|
|
4346
|
+
if (!info?.isFile()) continue;
|
|
4347
|
+
items.push({
|
|
4348
|
+
name,
|
|
4349
|
+
bytes: info.size,
|
|
4350
|
+
mtimeMs: info.mtimeMs
|
|
4351
|
+
});
|
|
4352
|
+
}
|
|
4353
|
+
items.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
4354
|
+
return items;
|
|
4355
|
+
}
|
|
4356
|
+
/**
|
|
4357
|
+
* 尾部读取一份诊断日志:保留文件尾 maxBytes 字节(缺省 256KB),truncated 标记截头。
|
|
4358
|
+
* 单文件本体受 DEBUG_LOG_CAP=512KB 约束,整读为字符串的成本可接受。
|
|
4359
|
+
* @author ddj 2026年09月17号
|
|
4360
|
+
* @param name 日志文件名(isDebugLogName 白名单校验,非法直接 null)
|
|
4361
|
+
* @param maxBytes 尾部保留字节数(缺省 256KB)
|
|
4362
|
+
* @param home DSH home(测试可注入)
|
|
4363
|
+
* @returns 读取结果;文件不存在或名字非法返回 null
|
|
4364
|
+
*/
|
|
4365
|
+
async function readDebugLog(name, maxBytes = 262144, home = dshHome()) {
|
|
4366
|
+
if (!isDebugLogName(name)) return null;
|
|
4367
|
+
const target = join(pluginLogRoot(home), name);
|
|
4368
|
+
const info = await stat(target).catch(() => null);
|
|
4369
|
+
if (!info?.isFile()) return null;
|
|
4370
|
+
const text = await readFile(target, "utf8").catch(() => null);
|
|
4371
|
+
if (text === null) return null;
|
|
4372
|
+
const cut = Math.max(0, text.length - maxBytes);
|
|
4373
|
+
return {
|
|
4374
|
+
name,
|
|
4375
|
+
path: target,
|
|
4376
|
+
content: cut > 0 ? text.slice(cut) : text,
|
|
4377
|
+
size: info.size,
|
|
4378
|
+
truncated: cut > 0
|
|
4379
|
+
};
|
|
4380
|
+
}
|
|
4381
|
+
/**
|
|
4382
|
+
* 清空一份诊断日志(truncate 到 0,保留文件本身)。
|
|
4383
|
+
* @author ddj 2026年09月17号
|
|
4384
|
+
* @param name 日志文件名(isDebugLogName 白名单校验)
|
|
4385
|
+
* @param home DSH home(测试可注入)
|
|
4386
|
+
* @returns 是否清空成功(文件不存在视为未成功,文案由调用方给)
|
|
4387
|
+
*/
|
|
4388
|
+
async function clearDebugLog(name, home = dshHome()) {
|
|
4389
|
+
if (!isDebugLogName(name)) return false;
|
|
4390
|
+
return await truncate(join(pluginLogRoot(home), name)).then(() => true).catch(() => false);
|
|
4391
|
+
}
|
|
4392
|
+
//#endregion
|
|
4393
|
+
//#region src/archiveDb.ts
|
|
4394
|
+
/**
|
|
4395
|
+
* 工作区差异归档 V2:SQLite 事务追加与按需读取;旧 JSON 只读留存,不参与热路径。
|
|
4396
|
+
* 数据库位于 DSH home 的 data/,不会被缓存清理器删除。
|
|
4397
|
+
* 作者 ddj 2026-09-30
|
|
4398
|
+
*/
|
|
4399
|
+
/**
|
|
4400
|
+
* 数据库行中 null 批次用独立字符串键,避免 SQLite UNIQUE 对 NULL 不去重。
|
|
4401
|
+
* @author ddj 2026年09月30号
|
|
4402
|
+
* @param batch 原始批次
|
|
4403
|
+
* @returns 可用于唯一约束的字符串键
|
|
4404
|
+
*/
|
|
4405
|
+
const batchKey = (batch) => batch === null ? "null" : "n" + batch;
|
|
4406
|
+
/** 仅由本插件打开的工作区连接;插件卸载时 closeArchiveDbs 关闭。 */
|
|
4407
|
+
const connections = /* @__PURE__ */ new Map();
|
|
4408
|
+
/**
|
|
4409
|
+
* 打开且核验单工作区数据库;已有异常库绝不重置成空库。
|
|
4410
|
+
* @author ddj 2026年09月30号
|
|
4411
|
+
* @param cwd 工作区绝对路径
|
|
4412
|
+
* @returns 已缓存连接
|
|
4413
|
+
*/
|
|
4414
|
+
function dbFor(cwd) {
|
|
4415
|
+
const existing = connections.get(cwd);
|
|
4416
|
+
if (existing) return existing;
|
|
4417
|
+
const file = archiveDbFile(cwd);
|
|
4418
|
+
mkdirSync(dirname(file), {
|
|
4419
|
+
recursive: true,
|
|
4420
|
+
mode: 448
|
|
4421
|
+
});
|
|
4422
|
+
const db = new DatabaseSync(file, { timeout: 2e3 });
|
|
4423
|
+
try {
|
|
4424
|
+
db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=2000");
|
|
4425
|
+
db.exec("CREATE TABLE IF NOT EXISTS metadata (name TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
4426
|
+
const version = db.prepare("SELECT value FROM metadata WHERE name='schema'").get();
|
|
4427
|
+
const workspace = db.prepare("SELECT value FROM metadata WHERE name='cwd'").get();
|
|
4428
|
+
if (version && version.value !== "2" && version.value !== "3" || workspace && workspace.value !== cwd) throw new Error("归档数据库 schema/工作区不匹配");
|
|
4429
|
+
if (!!version !== !!workspace) throw new Error("归档数据库元数据不完整");
|
|
4430
|
+
if (!version) {
|
|
4431
|
+
if (db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name<>'metadata' LIMIT 1").get()) throw new Error("归档数据库缺失元数据且已有数据表");
|
|
4432
|
+
}
|
|
4433
|
+
db.exec(`CREATE TABLE IF NOT EXISTS batches (
|
|
4434
|
+
id INTEGER PRIMARY KEY, cwd TEXT NOT NULL, path TEXT NOT NULL, batch_key TEXT NOT NULL,
|
|
4435
|
+
batch INTEGER, at TEXT NOT NULL, last_at TEXT, reason TEXT,
|
|
4436
|
+
accepted INTEGER NOT NULL DEFAULT 0, rejected INTEGER NOT NULL DEFAULT 0,
|
|
4437
|
+
pending INTEGER NOT NULL DEFAULT 0, superseded INTEGER NOT NULL DEFAULT 0,
|
|
4438
|
+
n_records INTEGER NOT NULL DEFAULT 0, UNIQUE(cwd,path,batch_key)
|
|
4439
|
+
);
|
|
4440
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
4441
|
+
batch_id INTEGER NOT NULL REFERENCES batches(id), call_id TEXT NOT NULL,
|
|
4442
|
+
payload TEXT NOT NULL, accepted INTEGER NOT NULL, rejected INTEGER NOT NULL,
|
|
4443
|
+
pending INTEGER NOT NULL, superseded INTEGER NOT NULL,
|
|
4444
|
+
PRIMARY KEY(batch_id,call_id)
|
|
4445
|
+
);
|
|
4446
|
+
CREATE INDEX IF NOT EXISTS archive_by_path ON batches(cwd,path,batch);
|
|
4447
|
+
CREATE INDEX IF NOT EXISTS archive_by_call ON records(call_id);
|
|
4448
|
+
CREATE TABLE IF NOT EXISTS revert_intents (
|
|
4449
|
+
call_id TEXT NOT NULL, scope TEXT NOT NULL, hunk_index INTEGER NOT NULL,
|
|
4450
|
+
state TEXT NOT NULL CHECK(state IN ('started','applied')),
|
|
4451
|
+
PRIMARY KEY(call_id,scope,hunk_index)
|
|
4452
|
+
)`);
|
|
4453
|
+
if (!version) {
|
|
4454
|
+
db.exec("BEGIN IMMEDIATE");
|
|
4455
|
+
try {
|
|
4456
|
+
db.prepare("INSERT INTO metadata(name,value) VALUES('schema','2')").run();
|
|
4457
|
+
db.prepare("INSERT INTO metadata(name,value) VALUES('cwd',?)").run(cwd);
|
|
4458
|
+
db.exec("COMMIT");
|
|
4459
|
+
} catch (error) {
|
|
4460
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
4461
|
+
throw error;
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
4464
|
+
if (version?.value === "3") {
|
|
4465
|
+
const active = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='active_records'").get();
|
|
4466
|
+
const source = db.prepare("SELECT value FROM metadata WHERE name='active_source'").get();
|
|
4467
|
+
if (!active || !source) throw new Error("活跃记录数据库元数据不完整");
|
|
4468
|
+
}
|
|
4469
|
+
connections.set(cwd, db);
|
|
4470
|
+
return db;
|
|
4471
|
+
} catch (error) {
|
|
4472
|
+
db.close();
|
|
4473
|
+
throw error;
|
|
4474
|
+
}
|
|
4475
|
+
}
|
|
4476
|
+
/**
|
|
4477
|
+
* 只在隔离的切换窗口导入旧 sidecar;不接受模糊空库或解析损坏的输入。
|
|
4478
|
+
* @author ddj 2026年09月30号
|
|
4479
|
+
* @param cwd 工作区
|
|
4480
|
+
* @param source 旧文件原文(null 仅表示明确不存在)
|
|
4481
|
+
* @param confirm 再读源文件原文,防止迁移途中旧 Host 改写
|
|
4482
|
+
* @returns 活跃记录 Map
|
|
4483
|
+
*/
|
|
4484
|
+
async function migrateActive(cwd, source, confirm) {
|
|
4485
|
+
const db = dbFor(cwd);
|
|
4486
|
+
if (String(db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value) === "3") return readActive(cwd);
|
|
4487
|
+
const digest = source === null ? "absent" : createHash("sha256").update(source).digest("hex");
|
|
4488
|
+
let recs = {};
|
|
4489
|
+
if (source !== null) {
|
|
4490
|
+
let parsed;
|
|
4491
|
+
try {
|
|
4492
|
+
parsed = JSON.parse(source);
|
|
4493
|
+
} catch {
|
|
4494
|
+
throw new Error("旧活跃 sidecar 损坏,停止迁移");
|
|
4495
|
+
}
|
|
4496
|
+
if (parsed?.version !== 2 || !parsed.workspaces || typeof parsed.workspaces !== "object" || Array.isArray(parsed.workspaces)) throw new Error("旧活跃 sidecar 格式无效");
|
|
4497
|
+
const workspace = parsed.workspaces[cwd];
|
|
4498
|
+
if (workspace && (!workspace.records || typeof workspace.records !== "object" || Array.isArray(workspace.records))) throw new Error("旧工作区记录格式无效");
|
|
4499
|
+
recs = workspace?.records ?? {};
|
|
4500
|
+
for (const [key, rec] of Object.entries(recs)) if (!rec || rec.callId !== key || typeof rec.path !== "string" || !Array.isArray(rec.hunks) || !rec.decisions || !Array.isArray(rec.decisions.perHunk) || !Number.isInteger(rec.batch) || typeof rec.at !== "string") throw new Error("旧活跃记录损坏:" + key);
|
|
4501
|
+
}
|
|
4502
|
+
if (await confirm() !== source) throw new Error("迁移期间旧活跃 sidecar 被修改");
|
|
4503
|
+
if (activeReady(cwd)) return readActive(cwd);
|
|
4504
|
+
db.exec("BEGIN IMMEDIATE");
|
|
4505
|
+
try {
|
|
4506
|
+
db.exec(`CREATE TABLE active_records(call_id TEXT PRIMARY KEY, payload TEXT NOT NULL,
|
|
4507
|
+
path TEXT NOT NULL, archived INTEGER NOT NULL, pending INTEGER NOT NULL, at TEXT NOT NULL);
|
|
4508
|
+
CREATE INDEX active_by_path ON active_records(path,archived)`);
|
|
4509
|
+
const add = db.prepare("INSERT INTO active_records(call_id,payload,path,archived,pending,at) VALUES(?,?,?,?,?,?)");
|
|
4510
|
+
for (const rec of Object.values(recs)) add.run(rec.callId, JSON.stringify(rec), rec.path, rec.archived ? 1 : 0, rec.decisions?.perHunk?.filter((d) => d === "pending").length ?? 0, rec.at);
|
|
4511
|
+
if (await confirm() !== source) throw new Error("迁移期间旧活跃 sidecar 被修改");
|
|
4512
|
+
db.prepare("INSERT INTO metadata(name,value) VALUES('active_source',?)").run(digest);
|
|
4513
|
+
db.prepare("UPDATE metadata SET value='3' WHERE name='schema'").run();
|
|
4514
|
+
db.exec("COMMIT");
|
|
4515
|
+
return new Map(Object.values(recs).map((rec) => [rec.callId, rec]));
|
|
4516
|
+
} catch (error) {
|
|
4517
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
4518
|
+
throw error;
|
|
4519
|
+
}
|
|
4520
|
+
}
|
|
4521
|
+
/**
|
|
4522
|
+
* 判断已核验的工作区是否完成活跃表迁移,不写入工作区记录。
|
|
4523
|
+
* @author ddj 2026年09月30号
|
|
4524
|
+
* @param cwd 工作区
|
|
4525
|
+
* @returns 迁移完成返回 true
|
|
4526
|
+
*/
|
|
4527
|
+
function activeReady(cwd) {
|
|
4528
|
+
return dbFor(cwd).prepare("SELECT value FROM metadata WHERE name='schema'").get().value === "3";
|
|
4529
|
+
}
|
|
4530
|
+
/**
|
|
4531
|
+
* 对旧文件原文核对迁移标记,旧 Host 若继续写旧 sidecar 则拒绝启用新活跃库。
|
|
4532
|
+
* @author ddj 2026年09月30号
|
|
4533
|
+
* @param cwd 工作区
|
|
4534
|
+
* @param source 当前遗留文件原文
|
|
4535
|
+
*/
|
|
4536
|
+
function checkActiveSource(cwd, source) {
|
|
4537
|
+
const row = dbFor(cwd).prepare("SELECT value FROM metadata WHERE name='active_source'").get();
|
|
4538
|
+
if (!row) throw new Error("活跃记录缺少迁移标记");
|
|
4539
|
+
const digest = source === null ? "absent" : createHash("sha256").update(source).digest("hex");
|
|
4540
|
+
if (row.value !== digest) throw new Error("旧活跃 sidecar 在迁移后发生变化,请停止操作并人工核对");
|
|
4541
|
+
}
|
|
4542
|
+
/**
|
|
4543
|
+
* 只读当前工作区活跃表;未迁移时禁止回退到空库。
|
|
4544
|
+
* @author ddj 2026年09月30号
|
|
4545
|
+
* @param cwd 工作区
|
|
4546
|
+
* @returns 完整有限记录桶
|
|
4547
|
+
*/
|
|
4548
|
+
function readActive(cwd) {
|
|
4549
|
+
const db = dbFor(cwd);
|
|
4550
|
+
if (db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value !== "3") throw new Error("工作区活跃记录未迁移");
|
|
4551
|
+
const recs = db.prepare("SELECT payload FROM active_records ORDER BY rowid").all().map((row) => JSON.parse(String(row.payload)));
|
|
4552
|
+
return new Map(recs.map((rec) => [rec.callId, rec]));
|
|
4553
|
+
}
|
|
4554
|
+
/**
|
|
4555
|
+
* 单次 SQLite 事务提交活跃行变化及可选归档,绝不重写整个记录桶。
|
|
4556
|
+
* @author ddj 2026年09月30号
|
|
4557
|
+
* @param cwd 工作区
|
|
4558
|
+
* @param changes 明确新增、更新和移除的记录
|
|
4559
|
+
* @param entries 可选本次归档批次
|
|
4560
|
+
* @returns 新增归档批次数
|
|
4561
|
+
*/
|
|
4562
|
+
function commitActive(cwd, changes, entries = []) {
|
|
4563
|
+
const db = dbFor(cwd);
|
|
4564
|
+
if (db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value !== "3") throw new Error("工作区活跃记录未迁移");
|
|
4565
|
+
const upsert = db.prepare(`INSERT INTO active_records(call_id,payload,path,archived,pending,at) VALUES(?,?,?,?,?,?)
|
|
4566
|
+
ON CONFLICT(call_id) DO UPDATE SET payload=excluded.payload,path=excluded.path,
|
|
4567
|
+
archived=excluded.archived,pending=excluded.pending,at=excluded.at`);
|
|
4568
|
+
const remove = db.prepare("DELETE FROM active_records WHERE call_id=?");
|
|
4569
|
+
db.exec("BEGIN IMMEDIATE");
|
|
4570
|
+
try {
|
|
4571
|
+
const added = entries.length ? insertArchive(db, cwd, entries) : 0;
|
|
4572
|
+
for (const rec of changes.upserts) {
|
|
4573
|
+
const pending = rec.decisions?.perHunk?.filter((decision) => decision === "pending").length ?? 0;
|
|
4574
|
+
upsert.run(rec.callId, JSON.stringify(rec), rec.path, rec.archived ? 1 : 0, pending, rec.at);
|
|
4575
|
+
}
|
|
4576
|
+
for (const callId of changes.removed) remove.run(callId);
|
|
4577
|
+
db.exec("COMMIT");
|
|
4578
|
+
return added;
|
|
4579
|
+
} catch (error) {
|
|
4580
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
4581
|
+
throw error;
|
|
4582
|
+
}
|
|
4583
|
+
}
|
|
4584
|
+
/**
|
|
4585
|
+
* 轻量活跃摘要;旧 JSON 保留为迁移前快照而不参与统计。
|
|
4586
|
+
* @author ddj 2026年09月30号
|
|
4587
|
+
* @param cwd 工作区
|
|
4588
|
+
* @returns 活跃条数和每文件待处理计数
|
|
4589
|
+
*/
|
|
4590
|
+
function activeSummary(cwd) {
|
|
4591
|
+
const db = dbFor(cwd);
|
|
4592
|
+
if (db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value !== "3") throw new Error("工作区活跃记录未迁移");
|
|
4593
|
+
return {
|
|
4594
|
+
active: Number(db.prepare("SELECT count(*) AS n FROM active_records WHERE archived=0").get().n),
|
|
4595
|
+
pendingByFile: db.prepare("SELECT path, sum(pending) AS pending FROM active_records WHERE archived=0 AND pending>0 GROUP BY path ORDER BY pending DESC").all().map((row) => ({
|
|
4596
|
+
path: String(row.path),
|
|
4597
|
+
pending: Number(row.pending)
|
|
4598
|
+
}))
|
|
4599
|
+
};
|
|
4600
|
+
}
|
|
4601
|
+
/**
|
|
4602
|
+
* 对一批同路径记录做事务内去重追加,返回成功插入的批次数。
|
|
4603
|
+
* @author ddj 2026年09月30号
|
|
4604
|
+
* @param cwd 工作区
|
|
4605
|
+
* @param entries 已按旧语义构造的归档批次
|
|
4606
|
+
* @returns 新增批次数
|
|
4607
|
+
*/
|
|
4608
|
+
function appendArchive(cwd, entries) {
|
|
4609
|
+
if (!entries.length) return 0;
|
|
4610
|
+
const db = dbFor(cwd);
|
|
4611
|
+
db.exec("BEGIN IMMEDIATE");
|
|
4612
|
+
try {
|
|
4613
|
+
const added = insertArchive(db, cwd, entries);
|
|
4614
|
+
db.exec("COMMIT");
|
|
4615
|
+
return added;
|
|
4616
|
+
} catch (error) {
|
|
4617
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
4618
|
+
throw error;
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
/**
|
|
4622
|
+
* 在调用方事务内追加归档批次;归档与活跃行可共用一次提交。
|
|
4623
|
+
* @author ddj 2026年09月30号
|
|
4624
|
+
* @param db 当前工作区连接
|
|
4625
|
+
* @param cwd 工作区
|
|
4626
|
+
* @param entries 待归档条目
|
|
4627
|
+
* @returns 新增批次数
|
|
4628
|
+
*/
|
|
4629
|
+
function insertArchive(db, cwd, entries) {
|
|
4630
|
+
const get = db.prepare("SELECT id FROM batches WHERE cwd=? AND path=? AND batch_key=?");
|
|
4631
|
+
const add = db.prepare("INSERT INTO batches(cwd,path,batch_key,batch,at,last_at,reason) VALUES(?,?,?,?,?,?,?)");
|
|
4632
|
+
const update = db.prepare("UPDATE batches SET last_at=?, reason=? WHERE id=?");
|
|
4633
|
+
const insert = db.prepare("INSERT OR IGNORE INTO records(batch_id,call_id,payload,accepted,rejected,pending,superseded) VALUES(?,?,?,?,?,?,?)");
|
|
4634
|
+
const tally = db.prepare(`UPDATE batches SET n_records=n_records+1, accepted=accepted+?, rejected=rejected+?,
|
|
4635
|
+
pending=pending+?, superseded=superseded+? WHERE id=?`);
|
|
4636
|
+
let added = 0;
|
|
4637
|
+
for (const entry of entries) {
|
|
4638
|
+
if (entry.cwd !== cwd) throw new Error("归档批次工作区不匹配");
|
|
4639
|
+
const key = batchKey(entry.batch);
|
|
4640
|
+
const found = get.get(cwd, entry.path, key);
|
|
4641
|
+
const id = found ? Number(found.id) : Number(add.run(cwd, entry.path, key, entry.batch, entry.at, entry.lastAt ?? null, entry.reason).lastInsertRowid);
|
|
4642
|
+
if (found) update.run(entry.at, entry.reason, id);
|
|
4643
|
+
else added++;
|
|
4644
|
+
for (const record of entry.records) {
|
|
4645
|
+
const sum = record.summary;
|
|
4646
|
+
if (insert.run(id, record.callId, JSON.stringify(record), sum.accepted, sum.rejected, sum.pending, sum.superseded ? 1 : 0).changes) tally.run(sum.accepted, sum.rejected, sum.pending, sum.superseded ? 1 : 0, id);
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
const clear = db.prepare("DELETE FROM revert_intents WHERE call_id=?");
|
|
4650
|
+
for (const entry of entries) for (const record of entry.records) clear.run(record.callId);
|
|
4651
|
+
return added;
|
|
4652
|
+
}
|
|
4653
|
+
/**
|
|
4654
|
+
* 返回某工作区所有归档批次的轻量摘要,不取记录 JSON。
|
|
4655
|
+
* @author ddj 2026年09月30号
|
|
4656
|
+
* @param cwd 工作区
|
|
4657
|
+
* @returns 归档列表
|
|
4658
|
+
*/
|
|
4659
|
+
function listArchives(cwd) {
|
|
4660
|
+
return dbFor(cwd).prepare(`SELECT path,batch,at,last_at,reason,n_records,accepted,rejected,pending,superseded
|
|
4661
|
+
FROM batches WHERE cwd=? ORDER BY COALESCE(batch,-1) DESC, at DESC`).all(cwd).map((row) => ({
|
|
4662
|
+
path: String(row.path),
|
|
4663
|
+
batch: row.batch === null ? null : Number(row.batch),
|
|
4664
|
+
at: String(row.at),
|
|
4665
|
+
lastAt: String(row.last_at ?? row.at),
|
|
4666
|
+
reason: row.reason === null ? null : String(row.reason),
|
|
4667
|
+
nRecords: Number(row.n_records),
|
|
4668
|
+
summary: {
|
|
4669
|
+
accepted: Number(row.accepted),
|
|
4670
|
+
rejected: Number(row.rejected),
|
|
4671
|
+
pending: Number(row.pending),
|
|
4672
|
+
superseded: Number(row.superseded)
|
|
4673
|
+
}
|
|
4674
|
+
}));
|
|
4675
|
+
}
|
|
4676
|
+
/**
|
|
4677
|
+
* 固定快照上界的 keyset 分页,新增批次不挤动后续页面。
|
|
4678
|
+
* @author ddj 2026年09月30号
|
|
4679
|
+
* @param cwd 工作区
|
|
4680
|
+
* @param cursor 上页返回的不透明游标
|
|
4681
|
+
* @param limit 每页条数,缺省 50,最多 100
|
|
4682
|
+
* @returns 摘要页和下一页游标
|
|
4683
|
+
*/
|
|
4684
|
+
function pageArchives(cwd, cursor, limit = 50) {
|
|
4685
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error("归档分页大小不合法");
|
|
4686
|
+
const db = dbFor(cwd);
|
|
4687
|
+
const newest = Number(db.prepare("SELECT COALESCE(MAX(id),0) AS id FROM batches WHERE cwd=?").get(cwd).id);
|
|
4688
|
+
let snapshot = newest;
|
|
4689
|
+
let last = null;
|
|
4690
|
+
if (cursor) try {
|
|
4691
|
+
const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
4692
|
+
if (value.cwd !== cwd || !Number.isSafeInteger(value.snapshot) || value.snapshot < 0 || value.snapshot > newest || !Array.isArray(value.last) || value.last.length !== 3 || !Number.isSafeInteger(value.last[0]) || typeof value.last[1] !== "string" || !Number.isSafeInteger(value.last[2]) || value.last[2] > value.snapshot) throw new Error("invalid");
|
|
4693
|
+
snapshot = value.snapshot;
|
|
4694
|
+
last = value.last;
|
|
4695
|
+
} catch {
|
|
4696
|
+
throw new Error("归档分页游标无效");
|
|
4697
|
+
}
|
|
4698
|
+
const args = [cwd, snapshot];
|
|
4699
|
+
let where = "WHERE cwd=? AND id<=?";
|
|
4700
|
+
if (last) {
|
|
4701
|
+
where += " AND (COALESCE(batch,-1),at,id) < (?,?,?)";
|
|
4702
|
+
args.push(...last);
|
|
4703
|
+
}
|
|
4704
|
+
const rows = db.prepare(`SELECT id,path,batch,at,last_at,reason,n_records,accepted,rejected,pending,superseded
|
|
4705
|
+
FROM batches ${where} ORDER BY COALESCE(batch,-1) DESC, at DESC, id DESC LIMIT ?`).all(...args, limit + 1);
|
|
4706
|
+
const entries = rows.slice(0, limit).map((row) => ({
|
|
4707
|
+
path: String(row.path),
|
|
4708
|
+
batch: row.batch === null ? null : Number(row.batch),
|
|
4709
|
+
at: String(row.at),
|
|
4710
|
+
lastAt: String(row.last_at ?? row.at),
|
|
4711
|
+
reason: row.reason === null ? null : String(row.reason),
|
|
4712
|
+
nRecords: Number(row.n_records),
|
|
4713
|
+
summary: {
|
|
4714
|
+
accepted: Number(row.accepted),
|
|
4715
|
+
rejected: Number(row.rejected),
|
|
4716
|
+
pending: Number(row.pending),
|
|
4717
|
+
superseded: Number(row.superseded)
|
|
4718
|
+
}
|
|
4719
|
+
}));
|
|
4720
|
+
const tail = rows[limit - 1];
|
|
4721
|
+
return {
|
|
4722
|
+
entries,
|
|
4723
|
+
nextCursor: rows.length > limit && tail ? Buffer.from(JSON.stringify({
|
|
4724
|
+
cwd,
|
|
4725
|
+
snapshot,
|
|
4726
|
+
last: [
|
|
4727
|
+
tail.batch === null ? -1 : Number(tail.batch),
|
|
4728
|
+
tail.at,
|
|
4729
|
+
tail.id
|
|
4730
|
+
]
|
|
4731
|
+
})).toString("base64url") : null
|
|
4732
|
+
};
|
|
4733
|
+
}
|
|
4734
|
+
/**
|
|
4735
|
+
* 仅查询需要展开的路径/批次(无 path 时维持 RPC 返回全库语义)。
|
|
4736
|
+
* @author ddj 2026年09月30号
|
|
4737
|
+
* @param cwd 工作区
|
|
4738
|
+
* @param path 可选目标路径
|
|
4739
|
+
* @param batch 可选批次
|
|
4740
|
+
* @returns 原有 ArchiveBatch 格式
|
|
4741
|
+
*/
|
|
4742
|
+
function readArchives(cwd, path, batch) {
|
|
4743
|
+
const db = dbFor(cwd);
|
|
4744
|
+
let where = "WHERE cwd=?";
|
|
4745
|
+
const args = [cwd];
|
|
4746
|
+
if (path) {
|
|
4747
|
+
where += " AND path=?";
|
|
4748
|
+
args.push(path);
|
|
4749
|
+
}
|
|
4750
|
+
if (batch !== void 0) {
|
|
4751
|
+
where += " AND batch_key=?";
|
|
4752
|
+
args.push(batchKey(batch));
|
|
4753
|
+
}
|
|
4754
|
+
const rows = db.prepare(`SELECT id,cwd,path,batch,at,last_at,reason FROM batches ${where} ORDER BY id`).all(...args);
|
|
4755
|
+
const records = db.prepare("SELECT payload FROM records WHERE batch_id=? ORDER BY rowid");
|
|
4756
|
+
return rows.map((row) => ({
|
|
4757
|
+
cwd: String(row.cwd),
|
|
4758
|
+
path: String(row.path),
|
|
4759
|
+
batch: row.batch === null ? null : Number(row.batch),
|
|
4760
|
+
at: String(row.at),
|
|
4761
|
+
...row.last_at === null ? {} : { lastAt: String(row.last_at) },
|
|
4762
|
+
reason: String(row.reason ?? ""),
|
|
4763
|
+
records: records.all(row.id).map((record) => JSON.parse(String(record.payload)))
|
|
4764
|
+
}));
|
|
4765
|
+
}
|
|
4766
|
+
/**
|
|
4767
|
+
* 查询已提交的归档记录,用于数据库成功但活跃 sidecar 写入失败后的幂等补偿。
|
|
4768
|
+
* @author ddj 2026年09月30号
|
|
4769
|
+
* @param cwd 工作区
|
|
4770
|
+
* @param callId 调用标识
|
|
4771
|
+
* @param path 目标文件路径
|
|
4772
|
+
* @param batch 对应文件批次
|
|
4773
|
+
* @returns 已归档快照或 null
|
|
4774
|
+
*/
|
|
4775
|
+
function committedRecord(cwd, callId, path, batch) {
|
|
4776
|
+
const row = dbFor(cwd).prepare(`SELECT r.payload FROM records r JOIN batches b ON b.id=r.batch_id
|
|
4777
|
+
WHERE b.cwd=? AND b.path=? AND b.batch_key=? AND r.call_id=? LIMIT 1`).get(cwd, path, batchKey(batch), callId);
|
|
4778
|
+
return row ? JSON.parse(String(row.payload)) : null;
|
|
4779
|
+
}
|
|
4780
|
+
/**
|
|
4781
|
+
* 查询一次 Undo 的持久化阶段;started 表示写入结果无法仅凭 sidecar 判断。
|
|
4782
|
+
* @author ddj 2026年09月30号
|
|
4783
|
+
* @param cwd 工作区
|
|
4784
|
+
* @param callId 记录标识
|
|
4785
|
+
* @param scope 决策范围
|
|
4786
|
+
* @param hunkIndex 块索引(整调用为 -1)
|
|
4787
|
+
* @returns 阶段或 null
|
|
4788
|
+
*/
|
|
4789
|
+
function revertState(cwd, callId, scope, hunkIndex) {
|
|
4790
|
+
const row = dbFor(cwd).prepare("SELECT state FROM revert_intents WHERE call_id=? AND scope=? AND hunk_index=?").get(callId, scope, hunkIndex);
|
|
4791
|
+
return row ? String(row.state) : null;
|
|
4792
|
+
}
|
|
4793
|
+
/**
|
|
4794
|
+
* 持久化 Undo 阶段(started 在写文件前,applied 在写文件后)。
|
|
4795
|
+
* @author ddj 2026年09月30号
|
|
4796
|
+
* @param cwd 工作区
|
|
4797
|
+
* @param callId 记录标识
|
|
4798
|
+
* @param scope 决策范围
|
|
4799
|
+
* @param hunkIndex 块索引
|
|
4800
|
+
* @param state 阶段
|
|
4801
|
+
*/
|
|
4802
|
+
function setRevertState(cwd, callId, scope, hunkIndex, state) {
|
|
4803
|
+
dbFor(cwd).prepare(`INSERT INTO revert_intents(call_id,scope,hunk_index,state) VALUES(?,?,?,?)
|
|
4804
|
+
ON CONFLICT(call_id,scope,hunk_index) DO UPDATE SET state=excluded.state`).run(callId, scope, hunkIndex, state);
|
|
4805
|
+
}
|
|
4806
|
+
/**
|
|
4807
|
+
* 一次事务登记同记录的所有批量 Undo 意图,登记不完整时不写任何文件。
|
|
4808
|
+
* @author ddj 2026年09月30号
|
|
4809
|
+
* @param cwd 工作区
|
|
4810
|
+
* @param callId 记录标识
|
|
4811
|
+
* @param indices 块索引
|
|
4812
|
+
*/
|
|
4813
|
+
function startReverts(cwd, callId, indices) {
|
|
4814
|
+
const db = dbFor(cwd);
|
|
4815
|
+
const insert = db.prepare(`INSERT INTO revert_intents(call_id,scope,hunk_index,state) VALUES(?,'hunk',?,'started')`);
|
|
4816
|
+
db.exec("BEGIN IMMEDIATE");
|
|
4817
|
+
try {
|
|
4818
|
+
for (const index of indices) insert.run(callId, index);
|
|
4819
|
+
db.exec("COMMIT");
|
|
4820
|
+
} catch (error) {
|
|
4821
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
4822
|
+
throw error;
|
|
4823
|
+
}
|
|
4824
|
+
}
|
|
4825
|
+
/**
|
|
4826
|
+
* 撤销尚未执行成功的 Undo 意图,保留成功写入后的阶段供补偿。
|
|
4827
|
+
* @author ddj 2026年09月30号
|
|
4828
|
+
* @param cwd 工作区
|
|
4829
|
+
* @param callId 记录标识
|
|
4830
|
+
* @param scope 决策范围
|
|
4831
|
+
* @param hunkIndex 块索引
|
|
4832
|
+
*/
|
|
4833
|
+
function clearRevertState(cwd, callId, scope, hunkIndex) {
|
|
4834
|
+
dbFor(cwd).prepare("DELETE FROM revert_intents WHERE call_id=? AND scope=? AND hunk_index=?").run(callId, scope, hunkIndex);
|
|
4835
|
+
}
|
|
4836
|
+
/**
|
|
4837
|
+
* 已执行但 sidecar 未更新的 Undo,从磁盘恢复决定而不重写文件。
|
|
4838
|
+
* @author ddj 2026年09月30号
|
|
4839
|
+
* @param cwd 工作区
|
|
4840
|
+
* @param record 活跃记录
|
|
4841
|
+
* @returns 是否应用了持久化决定
|
|
4842
|
+
*/
|
|
4843
|
+
function restoreReverts(cwd, record) {
|
|
4844
|
+
const rows = dbFor(cwd).prepare("SELECT scope,hunk_index,state FROM revert_intents WHERE call_id=?").all(record.callId);
|
|
4845
|
+
let changed = false;
|
|
4846
|
+
for (const row of rows) {
|
|
4847
|
+
if (row.state !== "applied") continue;
|
|
4848
|
+
if (row.scope === "call") {
|
|
4849
|
+
record.decisions.call = "rejected";
|
|
4850
|
+
record.decisions.perHunk = record.decisions.perHunk.map(() => "rejected");
|
|
4851
|
+
} else if (row.scope === "hunk" && Number(row.hunk_index) >= 0 && Number(row.hunk_index) < record.hunks.length) record.decisions.perHunk[Number(row.hunk_index)] = "rejected";
|
|
4852
|
+
else continue;
|
|
4853
|
+
changed = true;
|
|
4854
|
+
}
|
|
4855
|
+
return changed;
|
|
4856
|
+
}
|
|
4857
|
+
/**
|
|
4858
|
+
* 关闭所有工作区连接,供 Host 卸载以及测试释放 SQLite 句柄。
|
|
4859
|
+
* @author ddj 2026年09月30号
|
|
4860
|
+
*/
|
|
4861
|
+
function closeArchiveDbs() {
|
|
4862
|
+
for (const db of connections.values()) db.close();
|
|
4863
|
+
connections.clear();
|
|
4864
|
+
}
|
|
4865
|
+
//#endregion
|
|
4866
|
+
//#region src/store.ts
|
|
4867
|
+
const READ_CAP$1 = 8388608;
|
|
4868
|
+
/** 二进制预览/写回上限(PDF 等整文件 base64 传输;文本侧仍用 READ_CAP)。 */
|
|
4869
|
+
const BINARY_READ_CAP = 33554432;
|
|
4870
|
+
/** 旧文件首次全量指纹核验后,每次操作优先检查 DSH fs stat 身份。 */
|
|
4871
|
+
const sourceStats = /* @__PURE__ */ new Map();
|
|
4872
|
+
/** 会话沙箱策略(policyOf):可能不存在,调用方需容忍 undefined。 */
|
|
4873
|
+
function policyOf(ctx, session) {
|
|
4874
|
+
const svc = ctx.get("sandboxPolicy");
|
|
4875
|
+
if (!svc) return void 0;
|
|
4876
|
+
return session ? svc.resolve({ session }) : svc.resolve();
|
|
4877
|
+
}
|
|
4878
|
+
/** 按会话 cwd 解析目标路径。 */
|
|
4879
|
+
async function resolveTarget(ctx, session, path) {
|
|
4880
|
+
const fs = ctx.get("fs");
|
|
4881
|
+
const cwd = session?.header?.cwd;
|
|
4882
|
+
return fs.resolve(path, cwd ? { cwd } : {});
|
|
4883
|
+
}
|
|
4884
|
+
/**
|
|
4885
|
+
* 严格读取旧 sidecar:仅确认确实不存在时才允许从空记录迁移。
|
|
4886
|
+
* @author ddj 2026年09月30号
|
|
4887
|
+
* @param ctx DSH 上下文
|
|
4888
|
+
* @param cwd 工作区
|
|
4889
|
+
* @returns 旧文件内容或确认缺失
|
|
4890
|
+
*/
|
|
4891
|
+
async function migrationText(ctx, cwd) {
|
|
4892
|
+
const fs = ctx.get("fs");
|
|
4893
|
+
if (!fs) throw new Error("无法迁移差异记录:缺少 fs");
|
|
4894
|
+
const target = await fs.resolve(SIDECAR, { cwd });
|
|
4895
|
+
const info = await fs.stat(target);
|
|
4896
|
+
if (!info) return null;
|
|
4897
|
+
if (info.type !== "file") throw new Error("旧活跃 sidecar 非文件");
|
|
4898
|
+
return fs.readText(target);
|
|
4899
|
+
}
|
|
4900
|
+
/**
|
|
4901
|
+
* 读取旧 sidecar 文件的版本身份;缺少可靠版本令牌时返回 null 走全文核验。
|
|
4902
|
+
* @author ddj 2026年09月30号
|
|
4903
|
+
* @param ctx DSH 上下文
|
|
4904
|
+
* @param cwd 工作区
|
|
4905
|
+
* @returns 文件身份或 null
|
|
4906
|
+
*/
|
|
4907
|
+
async function sourceStat(ctx, cwd) {
|
|
4908
|
+
const fs = ctx.get("fs");
|
|
4909
|
+
if (!fs) throw new Error("无法核验旧 sidecar:缺少 fs");
|
|
4910
|
+
const info = await fs.stat(await fs.resolve(SIDECAR, { cwd }));
|
|
4911
|
+
if (!info) return "absent";
|
|
4912
|
+
const version = info.version ?? info.rev;
|
|
4913
|
+
return typeof version === "string" && version.length ? version : null;
|
|
4914
|
+
}
|
|
4915
|
+
/**
|
|
4916
|
+
* 首次读取旧文件全文确认数据库来源,后续只在 stat 版本变动时重新核验。
|
|
4917
|
+
* @author ddj 2026年09月30号
|
|
4918
|
+
* @param ctx DSH 上下文
|
|
4919
|
+
* @param cwd 工作区
|
|
4920
|
+
*/
|
|
4921
|
+
async function checkSource(ctx, cwd) {
|
|
4922
|
+
const now = await sourceStat(ctx, cwd);
|
|
4923
|
+
if (now !== null && sourceStats.get(cwd) === now) return;
|
|
4924
|
+
checkActiveSource(cwd, await migrationText(ctx, cwd));
|
|
4925
|
+
if (now !== null) sourceStats.set(cwd, now);
|
|
4926
|
+
}
|
|
4927
|
+
/** 加载某工作区的记录桶(内存 Map;首次在隔离切换窗口严格迁移)。 */
|
|
4928
|
+
async function loadBucket(ctx, cwd) {
|
|
4929
|
+
const map = await migrateActive(cwd, await migrationText(ctx, cwd), () => migrationText(ctx, cwd));
|
|
4930
|
+
await checkSource(ctx, cwd);
|
|
4931
|
+
for (const rec of map.values()) {
|
|
4932
|
+
if (rec.archived) continue;
|
|
4933
|
+
const previous = JSON.stringify(rec);
|
|
4934
|
+
restoreReverts(cwd, rec);
|
|
4935
|
+
const committed = committedRecord(cwd, rec.callId, rec.path, rec.batch ?? null);
|
|
4936
|
+
if (committed) {
|
|
4937
|
+
rec.decisions = committed.decisions;
|
|
4938
|
+
rec.superseded = committed.superseded;
|
|
4939
|
+
rec.archived = true;
|
|
4940
|
+
}
|
|
4941
|
+
if (JSON.stringify(rec) !== previous) commitActive(cwd, {
|
|
4942
|
+
upserts: [rec],
|
|
4943
|
+
removed: []
|
|
4944
|
+
});
|
|
4945
|
+
}
|
|
4946
|
+
return map;
|
|
4947
|
+
}
|
|
4948
|
+
/**
|
|
4949
|
+
* 仅把明确变动的记录行写入 SQLite;旧 sidecar 不再写入。
|
|
4950
|
+
* @author ddj 2026年09月30号
|
|
4951
|
+
* @param ctx DSH 上下文
|
|
4952
|
+
* @param cwd 工作区
|
|
4953
|
+
* @param recsMap 供当前调用点核对的记录桶
|
|
4954
|
+
* @param session 会话(保留旧 API)
|
|
4955
|
+
* @param changes 明确的更新和删除集合
|
|
4956
|
+
*/
|
|
4957
|
+
async function saveBucket(ctx, cwd, recsMap, session, changes) {
|
|
4958
|
+
if (!changes) throw new Error("增量保存缺少变更集合");
|
|
4959
|
+
for (const rec of changes.upserts) if (recsMap.get(rec.callId) !== rec) throw new Error("增量记录不在工作区桶内");
|
|
4960
|
+
try {
|
|
4961
|
+
await checkSource(ctx, cwd);
|
|
4962
|
+
commitActive(cwd, changes);
|
|
4963
|
+
} catch (error) {
|
|
4964
|
+
log.error("saveBucket failed: " + String(error));
|
|
4965
|
+
throw error;
|
|
4966
|
+
}
|
|
4967
|
+
}
|
|
4968
|
+
/**
|
|
4969
|
+
* 读取记录目标文件内容(按 path 缓存一次;缺失/过大/失败 → null/'' 占位)。
|
|
4970
|
+
* @author ddj 2026年08月20号
|
|
4971
|
+
* @param cache path → 内容(null=缺失/读取失败,''=过大跳过)
|
|
4972
|
+
*/
|
|
4973
|
+
async function readForCache(ctx, session, cache, path) {
|
|
4974
|
+
const cached = cache.get(path);
|
|
4975
|
+
if (cached) return cached;
|
|
4159
4976
|
const fs = ctx.get("fs");
|
|
4160
4977
|
if (!fs) {
|
|
4161
4978
|
const state = { kind: "unavailable" };
|
|
@@ -4237,74 +5054,96 @@ async function autoArchiveStale(ctx, session, cwd, bucket) {
|
|
|
4237
5054
|
if (await recordIsStale(ctx, session, cache, rec)) stale.push(rec);
|
|
4238
5055
|
}
|
|
4239
5056
|
if (!stale.length) return 0;
|
|
5057
|
+
const original = stale.map((rec) => ({
|
|
5058
|
+
rec,
|
|
5059
|
+
at: rec.at,
|
|
5060
|
+
superseded: rec.superseded
|
|
5061
|
+
}));
|
|
4240
5062
|
for (const r of stale) {
|
|
4241
5063
|
r.superseded = true;
|
|
4242
5064
|
r.at = (/* @__PURE__ */ new Date()).toISOString();
|
|
4243
5065
|
}
|
|
4244
|
-
await archiveRecords(ctx, session, cwd, bucket, stale, "差异无法定位(已被后续修改覆盖),自动归档");
|
|
4245
|
-
return stale.length;
|
|
4246
|
-
}
|
|
4247
|
-
/** 读取归档 sidecar 文本(缺失/失败 → null)。 */
|
|
4248
|
-
async function readArchiveText(ctx, cwd) {
|
|
4249
|
-
const fs = ctx.get("fs");
|
|
4250
|
-
if (!fs || !cwd) return null;
|
|
4251
5066
|
try {
|
|
4252
|
-
|
|
5067
|
+
await archiveRecords(ctx, session, cwd, bucket, stale, "差异无法定位(已被后续修改覆盖),自动归档");
|
|
4253
5068
|
} catch (error) {
|
|
4254
|
-
|
|
5069
|
+
for (const item of original) {
|
|
5070
|
+
if (item.rec.archived) continue;
|
|
5071
|
+
item.rec.superseded = item.superseded;
|
|
5072
|
+
item.rec.at = item.at;
|
|
5073
|
+
}
|
|
5074
|
+
throw error;
|
|
4255
5075
|
}
|
|
5076
|
+
return stale.length;
|
|
4256
5077
|
}
|
|
4257
|
-
/**
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
const
|
|
4269
|
-
if (!fs || !cwd) return;
|
|
5078
|
+
/**
|
|
5079
|
+
* 追加归档条目(按 cwd+path+batch 合并,同 callId 去重)。
|
|
5080
|
+
* @author ddj 2026年09月30号
|
|
5081
|
+
* @param ctx DSH 上下文
|
|
5082
|
+
* @param cwd 工作区
|
|
5083
|
+
* @param entries 待归档批次
|
|
5084
|
+
* @param session 会话
|
|
5085
|
+
* @param traceId 可选诊断追踪标识;有值时输出分段耗时
|
|
5086
|
+
*/
|
|
5087
|
+
async function appendArchiveEntries(ctx, cwd, entries, session, traceId) {
|
|
5088
|
+
if (!cwd || !entries.length) return;
|
|
5089
|
+
const started = traceId ? performance.now() : 0;
|
|
4270
5090
|
try {
|
|
4271
|
-
|
|
4272
|
-
const
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
const exist = existing[idx];
|
|
4277
|
-
const seen = new Set((exist.records || []).map((r) => r.callId));
|
|
4278
|
-
for (const rec of e.records) if (!seen.has(String(rec.callId))) {
|
|
4279
|
-
exist.records.push(rec);
|
|
4280
|
-
seen.add(String(rec.callId));
|
|
4281
|
-
}
|
|
4282
|
-
exist.lastAt = e.at;
|
|
4283
|
-
exist.reason = e.reason;
|
|
4284
|
-
} else existing.push(e);
|
|
4285
|
-
}
|
|
4286
|
-
const data = {
|
|
4287
|
-
version: 1,
|
|
4288
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4289
|
-
batches: existing
|
|
4290
|
-
};
|
|
4291
|
-
await fs.writeText(target, JSON.stringify(data), void 0, void 0, policyOf(ctx, session));
|
|
5091
|
+
await checkSource(ctx, cwd);
|
|
5092
|
+
const added = appendArchive(cwd, entries);
|
|
5093
|
+
try {
|
|
5094
|
+
if (traceId) debugRecord(ctx, cwd, `[DEBUG diffArchive] trace=${traceId} stage=done mode=sqlite entries=${entries.length} insertedBatches=${added} dbMs=${(performance.now() - started).toFixed(1)} totalMs=${(performance.now() - started).toFixed(1)}`);
|
|
5095
|
+
} catch {}
|
|
4292
5096
|
} catch (error) {
|
|
5097
|
+
try {
|
|
5098
|
+
if (traceId) debugRecord(ctx, cwd, `[DEBUG diffArchive] trace=${traceId} stage=error mode=sqlite totalMs=${(performance.now() - started).toFixed(1)}`);
|
|
5099
|
+
} catch {}
|
|
4293
5100
|
log.error("appendArchiveEntries failed: " + String(error));
|
|
5101
|
+
throw error;
|
|
4294
5102
|
}
|
|
4295
5103
|
}
|
|
4296
5104
|
/**
|
|
4297
5105
|
* 归档记录:标记 archived、按批次写入归档、落盘工作区桶。
|
|
4298
|
-
* @
|
|
5106
|
+
* @author ddj 2026年09月30号
|
|
5107
|
+
* @param ctx DSH 上下文
|
|
5108
|
+
* @param session 会话
|
|
5109
|
+
* @param cwd 工作区
|
|
5110
|
+
* @param bucket 当前工作区记录桶
|
|
5111
|
+
* @param recs 待归档记录
|
|
5112
|
+
* @param reason 归档原因
|
|
5113
|
+
* @param opts deferSave 为 true 时只写归档、不落盘桶;traceId 用于可选分段计时
|
|
4299
5114
|
*/
|
|
4300
5115
|
async function archiveRecords(ctx, session, cwd, bucket, recs, reason, opts) {
|
|
4301
5116
|
if (!recs.length) return;
|
|
4302
5117
|
const fresh = recs.filter((r) => !r.archived);
|
|
4303
|
-
|
|
4304
|
-
const
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
5118
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
5119
|
+
for (const rec of recs) {
|
|
5120
|
+
const key = JSON.stringify([rec.path, Number.isInteger(rec.batch) ? rec.batch : null]);
|
|
5121
|
+
const list = byFile.get(key) ?? [];
|
|
5122
|
+
list.push(rec);
|
|
5123
|
+
byFile.set(key, list);
|
|
5124
|
+
}
|
|
5125
|
+
const entries = [...byFile.values()].map((list) => archiveEntryFor(list, cwd, reason));
|
|
5126
|
+
const changed = new Map((opts?.changed ?? []).map((rec) => [rec.callId, rec]));
|
|
5127
|
+
for (const rec of recs) changed.set(rec.callId, rec);
|
|
5128
|
+
const start = opts?.traceId ? performance.now() : 0;
|
|
5129
|
+
await checkSource(ctx, cwd);
|
|
5130
|
+
const original = recs.map((rec) => rec.archived);
|
|
5131
|
+
for (const rec of recs) rec.archived = true;
|
|
5132
|
+
try {
|
|
5133
|
+
const added = commitActive(cwd, {
|
|
5134
|
+
upserts: [...changed.values()],
|
|
5135
|
+
removed: []
|
|
5136
|
+
}, entries);
|
|
5137
|
+
try {
|
|
5138
|
+
if (opts?.traceId) debugRecord(ctx, cwd, `[DEBUG diffArchive] trace=${opts.traceId} stage=done mode=sqlite entries=${entries.length} insertedBatches=${added} dbMs=${(performance.now() - start).toFixed(1)} totalMs=${(performance.now() - start).toFixed(1)}`);
|
|
5139
|
+
} catch {}
|
|
5140
|
+
} catch (error) {
|
|
5141
|
+
recs.forEach((rec, idx) => {
|
|
5142
|
+
rec.archived = original[idx];
|
|
5143
|
+
});
|
|
5144
|
+
throw error;
|
|
5145
|
+
}
|
|
5146
|
+
if (fresh.length && !opts?.deferSave) return;
|
|
4308
5147
|
}
|
|
4309
5148
|
//#endregion
|
|
4310
5149
|
//#region src/httpGuard.ts
|
|
@@ -5480,10 +6319,21 @@ async function persistRecord(ctx, registry, session, cwd, record) {
|
|
|
5480
6319
|
bucket = await loadBucket(ctx, cwd);
|
|
5481
6320
|
registry.set(cwd, bucket);
|
|
5482
6321
|
}
|
|
6322
|
+
const prior = new Map(bucket);
|
|
5483
6323
|
record.batch = fileMaxBatch(bucket, record.path) + 1;
|
|
5484
6324
|
bucket.set(record.callId, record);
|
|
5485
6325
|
prune(bucket);
|
|
5486
|
-
|
|
6326
|
+
const removed = [...prior.keys()].filter((callId) => !bucket.has(callId));
|
|
6327
|
+
try {
|
|
6328
|
+
await saveBucket(ctx, cwd, bucket, session, {
|
|
6329
|
+
upserts: [record],
|
|
6330
|
+
removed
|
|
6331
|
+
});
|
|
6332
|
+
} catch (error) {
|
|
6333
|
+
bucket.clear();
|
|
6334
|
+
for (const [callId, rec] of prior) bucket.set(callId, rec);
|
|
6335
|
+
throw error;
|
|
6336
|
+
}
|
|
5487
6337
|
}
|
|
5488
6338
|
/**
|
|
5489
6339
|
* 捕获一次工具执行结果(tools/result):edit/write/remote_ssh_write 成功时
|
|
@@ -6761,7 +7611,7 @@ async function revertCall(ctx, session, record) {
|
|
|
6761
7611
|
}
|
|
6762
7612
|
}
|
|
6763
7613
|
/** 单 hunk 回滚:用 callHunk(edit 单 hunk 精确串)经 fs.editText 反替换。 */
|
|
6764
|
-
async function revertHunk(ctx, session, record, idx) {
|
|
7614
|
+
async function revertHunk(ctx, session, record, idx, traceId) {
|
|
6765
7615
|
const fs = ctx.get("fs");
|
|
6766
7616
|
if (!fs) return {
|
|
6767
7617
|
ok: false,
|
|
@@ -6772,8 +7622,13 @@ async function revertHunk(ctx, session, record, idx) {
|
|
|
6772
7622
|
ok: false,
|
|
6773
7623
|
error: "找不到该差异块"
|
|
6774
7624
|
};
|
|
7625
|
+
const started = traceId ? performance.now() : 0;
|
|
7626
|
+
let readMs = 0;
|
|
7627
|
+
let locateMs = 0;
|
|
7628
|
+
let stage = "resolve";
|
|
6775
7629
|
try {
|
|
6776
7630
|
const target = await resolveTarget(ctx, session, record.path);
|
|
7631
|
+
stage = "stat";
|
|
6777
7632
|
const info = await fs.stat(target);
|
|
6778
7633
|
if (!info || info.type !== "file") return {
|
|
6779
7634
|
ok: false,
|
|
@@ -6783,14 +7638,20 @@ async function revertHunk(ctx, session, record, idx) {
|
|
|
6783
7638
|
ok: false,
|
|
6784
7639
|
error: "回滚失败:文件过大,无法安全定位"
|
|
6785
7640
|
};
|
|
7641
|
+
stage = "read";
|
|
6786
7642
|
const content = await fs.readText(target);
|
|
7643
|
+
if (traceId) readMs = performance.now() - started;
|
|
7644
|
+
const locateStart = traceId ? performance.now() : 0;
|
|
7645
|
+
stage = "locate";
|
|
6787
7646
|
const location = locateHunks(content, [precise])[0];
|
|
7647
|
+
if (traceId) locateMs = performance.now() - locateStart;
|
|
6788
7648
|
if (!location?.matched) return {
|
|
6789
7649
|
ok: false,
|
|
6790
7650
|
stale: true,
|
|
6791
7651
|
error: "回滚失败:该区域在文件中已不存在(可能已被后续修改覆盖)"
|
|
6792
7652
|
};
|
|
6793
7653
|
const result = applyLocations(content, [location], true);
|
|
7654
|
+
stage = "write";
|
|
6794
7655
|
await fs.writeText(target, result.content, void 0, void 0, policyOf(ctx, session));
|
|
6795
7656
|
return { ok: true };
|
|
6796
7657
|
} catch (error) {
|
|
@@ -6798,6 +7659,88 @@ async function revertHunk(ctx, session, record, idx) {
|
|
|
6798
7659
|
ok: false,
|
|
6799
7660
|
error: "回滚失败:该区域可能已被后续修改影响(" + String(error) + ")"
|
|
6800
7661
|
};
|
|
7662
|
+
} finally {
|
|
7663
|
+
if (traceId) try {
|
|
7664
|
+
debugRecord(ctx, session.header?.cwd ?? "", `[DEBUG diffRevert] trace=${traceId} mode=single stage=${stage} readMs=${readMs.toFixed(1)} locateMs=${locateMs.toFixed(1)} totalMs=${(performance.now() - started).toFixed(1)}`);
|
|
7665
|
+
} catch {}
|
|
7666
|
+
}
|
|
7667
|
+
}
|
|
7668
|
+
/**
|
|
7669
|
+
* 同一记录的连续 hunk 在内存里逐项回滚,只对文件读写各一次。
|
|
7670
|
+
* 不替代单条回滚;调用方只传确定同路径且非远端的连续项。
|
|
7671
|
+
* @author ddj 2026年09月29号
|
|
7672
|
+
* @param ctx DSH 上下文
|
|
7673
|
+
* @param session 会话
|
|
7674
|
+
* @param record 差异记录
|
|
7675
|
+
* @param indices 按决策顺序排列的 hunk 索引
|
|
7676
|
+
* @param traceId 可选的诊断追踪号,仅开启诊断时传入
|
|
7677
|
+
* @returns 每个 hunk 对应的独立执行结果
|
|
7678
|
+
*/
|
|
7679
|
+
async function revertHunks(ctx, session, record, indices, traceId) {
|
|
7680
|
+
const fs = ctx.get("fs");
|
|
7681
|
+
if (!fs) return indices.map(() => ({
|
|
7682
|
+
ok: false,
|
|
7683
|
+
error: "回滚不可用:缺少 fs"
|
|
7684
|
+
}));
|
|
7685
|
+
const failAll = (error) => indices.map((idx) => preciseHunk(record, idx) ? {
|
|
7686
|
+
ok: false,
|
|
7687
|
+
error
|
|
7688
|
+
} : {
|
|
7689
|
+
ok: false,
|
|
7690
|
+
error: "找不到该差异块"
|
|
7691
|
+
});
|
|
7692
|
+
try {
|
|
7693
|
+
const started = traceId ? performance.now() : 0;
|
|
7694
|
+
const target = await resolveTarget(ctx, session, record.path);
|
|
7695
|
+
const info = await fs.stat(target);
|
|
7696
|
+
if (!info || info.type !== "file") return failAll("回滚失败:文件不存在");
|
|
7697
|
+
if ((info.size ?? 0) > 8388608) return failAll("回滚失败:文件过大,无法安全定位");
|
|
7698
|
+
let content = await fs.readText(target);
|
|
7699
|
+
const readMs = traceId ? performance.now() - started : 0;
|
|
7700
|
+
const locateStart = traceId ? performance.now() : 0;
|
|
7701
|
+
let size = info.size ?? Buffer.byteLength(content);
|
|
7702
|
+
const results = [];
|
|
7703
|
+
for (const idx of indices) {
|
|
7704
|
+
const hunk = preciseHunk(record, idx);
|
|
7705
|
+
if (!hunk) {
|
|
7706
|
+
results.push({
|
|
7707
|
+
ok: false,
|
|
7708
|
+
error: "找不到该差异块"
|
|
7709
|
+
});
|
|
7710
|
+
continue;
|
|
7711
|
+
}
|
|
7712
|
+
if (size > 8388608) {
|
|
7713
|
+
results.push({
|
|
7714
|
+
ok: false,
|
|
7715
|
+
error: "回滚失败:文件过大,无法安全定位"
|
|
7716
|
+
});
|
|
7717
|
+
continue;
|
|
7718
|
+
}
|
|
7719
|
+
const location = locateHunks(content, [hunk])[0];
|
|
7720
|
+
if (!location?.matched) {
|
|
7721
|
+
results.push({
|
|
7722
|
+
ok: false,
|
|
7723
|
+
stale: true,
|
|
7724
|
+
error: "回滚失败:该区域在文件中已不存在(可能已被后续修改覆盖)"
|
|
7725
|
+
});
|
|
7726
|
+
continue;
|
|
7727
|
+
}
|
|
7728
|
+
size += Buffer.byteLength(hunk.oldText ?? "") - Buffer.byteLength(content.slice(location.start, location.end));
|
|
7729
|
+
content = applyLocations(content, [location], true).content;
|
|
7730
|
+
results.push({ ok: true });
|
|
7731
|
+
}
|
|
7732
|
+
const locateMs = traceId ? performance.now() - locateStart : 0;
|
|
7733
|
+
const writeStart = traceId ? performance.now() : 0;
|
|
7734
|
+
if (results.some((result) => result.ok)) await fs.writeText(target, content, void 0, void 0, policyOf(ctx, session));
|
|
7735
|
+
try {
|
|
7736
|
+
if (traceId) {
|
|
7737
|
+
const totalMs = performance.now() - started;
|
|
7738
|
+
debugRecord(ctx, session.header?.cwd ?? "", `[DEBUG diffRevert] trace=${traceId} mode=batch hunks=${indices.length} bytes=${info.size ?? 0} readMs=${readMs.toFixed(1)} locateMs=${locateMs.toFixed(1)} writeMs=${(performance.now() - writeStart).toFixed(1)} totalMs=${totalMs.toFixed(1)}`);
|
|
7739
|
+
}
|
|
7740
|
+
} catch {}
|
|
7741
|
+
return results;
|
|
7742
|
+
} catch (error) {
|
|
7743
|
+
return failAll("回滚失败:该区域可能已被后续修改影响(" + String(error) + ")");
|
|
6801
7744
|
}
|
|
6802
7745
|
}
|
|
6803
7746
|
/**
|
|
@@ -9307,233 +10250,83 @@ function parseUiState(text) {
|
|
|
9307
10250
|
}
|
|
9308
10251
|
return {
|
|
9309
10252
|
version: 1,
|
|
9310
|
-
updatedAt: typeof doc.updatedAt === "string" ? doc.updatedAt : "",
|
|
9311
|
-
keys
|
|
9312
|
-
};
|
|
9313
|
-
}
|
|
9314
|
-
/**
|
|
9315
|
-
* 序列化为镜像文件文本。
|
|
9316
|
-
* @author ddj 2026年09月28号
|
|
9317
|
-
* @param keys 键值表
|
|
9318
|
-
* @param updatedAt 更新时间(缺省当前时间)
|
|
9319
|
-
* @returns 文件文本
|
|
9320
|
-
*/
|
|
9321
|
-
function serializeUiState(keys, updatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
9322
|
-
return JSON.stringify({
|
|
9323
|
-
version: 1,
|
|
9324
|
-
updatedAt,
|
|
9325
|
-
keys
|
|
9326
|
-
});
|
|
9327
|
-
}
|
|
9328
|
-
/** 同文件写队列尾(串行化并发写入;队列清空即移除条目)。 */
|
|
9329
|
-
const writeChains = /* @__PURE__ */ new Map();
|
|
9330
|
-
/**
|
|
9331
|
-
* 把写任务挂到该文件的队尾串行执行(前序失败不影响后续)。
|
|
9332
|
-
* @author ddj 2026年09月28号
|
|
9333
|
-
* @param file 目标文件绝对路径
|
|
9334
|
-
* @param run 实际写操作
|
|
9335
|
-
* @returns 本次写操作结果
|
|
9336
|
-
*/
|
|
9337
|
-
function chainWrite(file, run) {
|
|
9338
|
-
const next = (writeChains.get(file) ?? Promise.resolve()).then(run, run);
|
|
9339
|
-
const tail = next.then(() => void 0, () => void 0);
|
|
9340
|
-
writeChains.set(file, tail);
|
|
9341
|
-
tail.then(() => {
|
|
9342
|
-
if (writeChains.get(file) === tail) writeChains.delete(file);
|
|
9343
|
-
});
|
|
9344
|
-
return next;
|
|
9345
|
-
}
|
|
9346
|
-
/**
|
|
9347
|
-
* 读取镜像文件的键值表(文件缺失/损坏/不可读 → 空表,静默)。
|
|
9348
|
-
* @author ddj 2026年09月28号
|
|
9349
|
-
* @param file 镜像文件绝对路径
|
|
9350
|
-
* @returns 键值表(localStorage 键 → 字符串原值)
|
|
9351
|
-
*/
|
|
9352
|
-
async function readUiState(file) {
|
|
9353
|
-
try {
|
|
9354
|
-
const doc = parseUiState(await readFile(file, "utf8"));
|
|
9355
|
-
return doc ? doc.keys : {};
|
|
9356
|
-
} catch (error) {
|
|
9357
|
-
return {};
|
|
9358
|
-
}
|
|
9359
|
-
}
|
|
9360
|
-
/**
|
|
9361
|
-
* 合并写入镜像文件(读旧 → 合并补丁 → 容量校验 → 原子替换),同文件写操作串行。
|
|
9362
|
-
* @author ddj 2026年09月28号
|
|
9363
|
-
* @param file 镜像文件绝对路径
|
|
9364
|
-
* @param patch 增量补丁(null/undefined 值删除该键)
|
|
9365
|
-
* @returns ok;超限或写入失败时 ok:false + 中文错误文案
|
|
9366
|
-
*/
|
|
9367
|
-
async function writeUiState(file, patch) {
|
|
9368
|
-
return chainWrite(file, async () => {
|
|
9369
|
-
try {
|
|
9370
|
-
const json = serializeUiState(mergeUiStateKeys(await readUiState(file), patch));
|
|
9371
|
-
if (Buffer$1.byteLength(json, "utf8") > 524288) return {
|
|
9372
|
-
ok: false,
|
|
9373
|
-
error: "界面状态镜像超出容量上限(524288 字节)"
|
|
9374
|
-
};
|
|
9375
|
-
await mkdir(dirname(file), { recursive: true });
|
|
9376
|
-
const tmp = file + ".tmp";
|
|
9377
|
-
await writeFile(tmp, json, "utf8");
|
|
9378
|
-
await rename(tmp, file);
|
|
9379
|
-
return { ok: true };
|
|
9380
|
-
} catch (error) {
|
|
9381
|
-
return {
|
|
9382
|
-
ok: false,
|
|
9383
|
-
error: "界面状态写入失败: " + (error instanceof Error ? error.message : String(error))
|
|
9384
|
-
};
|
|
9385
|
-
}
|
|
9386
|
-
});
|
|
9387
|
-
}
|
|
9388
|
-
//#endregion
|
|
9389
|
-
//#region src/debugLog.ts
|
|
9390
|
-
/**
|
|
9391
|
-
* dsh-vscode-mode host — 诊断文件通道(client 上报 → 缓冲批量落盘)+ 日志文件管理。
|
|
9392
|
-
* 通道:edrv.debug RPC → 内存缓冲(满/空闲触发)→ 串行读改写
|
|
9393
|
-
* `~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log`(512KB 上限截断保留尾部)。
|
|
9394
|
-
* 设计:console 不一定落盘,文件可靠;写失败静默忽略(调试日志不阻塞业务)。
|
|
9395
|
-
* 管理:edrv.dlog.* RPC 用的清单/尾部读取/清空三能力;file 参数一律先过
|
|
9396
|
-
* isDebugLogName 白名单(纯文件名、无路径分隔),杜绝穿越出日志根。
|
|
9397
|
-
* 作者 ddj 2026-09-08 / 2026-09-17
|
|
9398
|
-
*/
|
|
9399
|
-
/** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
|
|
9400
|
-
const debugBuffers = /* @__PURE__ */ new Map();
|
|
9401
|
-
/** cwd → Promise 链:串行化 debug 日志落盘(fs read+write 非原子,避免并发丢行)。 */
|
|
9402
|
-
const debugWriteQueues = /* @__PURE__ */ new Map();
|
|
9403
|
-
/** debug 日志单文件上限:超限截断保留尾部,防文件无限增长拖慢每次追加。 */
|
|
9404
|
-
const DEBUG_LOG_CAP = 524288;
|
|
9405
|
-
/** debug 日志批量缓冲上限:攒满即落盘(一次读改写),上限内不逐条写文件。 */
|
|
9406
|
-
const DEBUG_BUF_CAP = 32768;
|
|
9407
|
-
/** debug 日志空闲 flush 延迟:缓冲未满时,静默一段时间后落盘一次。 */
|
|
9408
|
-
const DEBUG_FLUSH_IDLE_MS = 1e3;
|
|
9409
|
-
/** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
|
|
9410
|
-
const debugLegacyCleaned = /* @__PURE__ */ new Set();
|
|
9411
|
-
/** 诊断回显日志器(终端回显统一带 `[dsh-vscode-mode:debug]` 前缀,级别跟随上报级别)。 */
|
|
9412
|
-
const debugLog = log.child("debug");
|
|
9413
|
-
/** 诊断日志文件名白名单(cwd hash 命名;请求回传的 file 参数必须整名匹配,防路径穿越)。 */
|
|
9414
|
-
const DEBUG_LOG_NAME = /^debug\.[0-9a-f]{16}\.log$/;
|
|
9415
|
-
/**
|
|
9416
|
-
* 文件名是否为合法诊断日志名(白名单整名匹配,杜绝路径穿越)。
|
|
9417
|
-
* @author ddj 2026年09月17号
|
|
9418
|
-
* @param name 请求回传的文件名(可为空)
|
|
9419
|
-
* @returns 是否合法
|
|
9420
|
-
*/
|
|
9421
|
-
function isDebugLogName(name) {
|
|
9422
|
-
return typeof name === "string" && DEBUG_LOG_NAME.test(name);
|
|
9423
|
-
}
|
|
9424
|
-
/**
|
|
9425
|
-
* 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
|
|
9426
|
-
* 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
|
|
9427
|
-
* @author ddj 2026年09月08号
|
|
9428
|
-
* @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
|
|
9429
|
-
* @param batch 本批日志行
|
|
9430
|
-
*/
|
|
9431
|
-
function flushDebug(cwd, batch) {
|
|
9432
|
-
const task = (debugWriteQueues.get(cwd) ?? Promise.resolve()).then(async () => {
|
|
9433
|
-
try {
|
|
9434
|
-
await mkdir(pluginLogRoot(), { recursive: true });
|
|
9435
|
-
const target = debugLogFile(cwd);
|
|
9436
|
-
const appended = await readFile(target, "utf8").catch(() => "") + batch.map((l) => (/* @__PURE__ */ new Date()).toISOString() + " " + l + "\n").join("");
|
|
9437
|
-
const next = appended.length > DEBUG_LOG_CAP ? appended.slice(-Math.floor(DEBUG_LOG_CAP / 2)) : appended;
|
|
9438
|
-
await writeFile(target, next, "utf8");
|
|
9439
|
-
if (!debugLegacyCleaned.has(cwd)) {
|
|
9440
|
-
debugLegacyCleaned.add(cwd);
|
|
9441
|
-
await rm(join(cwd, DEBUG_LOG), { force: true }).catch(() => {});
|
|
9442
|
-
}
|
|
9443
|
-
} catch (e) {}
|
|
9444
|
-
});
|
|
9445
|
-
debugWriteQueues.set(cwd, task);
|
|
9446
|
-
return task;
|
|
9447
|
-
}
|
|
9448
|
-
/**
|
|
9449
|
-
* 记录一条 client 上报的诊断日志:入队缓冲批量落盘 + 终端回显(级别跟随,缺省 debug)。
|
|
9450
|
-
* 落盘行格式 `<iso> [level] text`;级别标记在入队时补齐,flush 侧无感。
|
|
9451
|
-
* @author ddj 2026年09月08号 / 2026年09月17号
|
|
9452
|
-
* @param _ctx DSH 上下文(入参保留:诊断通道按 cwd 隔离,当前不需要 ctx,未来过滤用)
|
|
9453
|
-
* @param cwd 工作区(日志文件按 cwd hash 隔离)
|
|
9454
|
-
* @param text 单条日志文本(不含换行)
|
|
9455
|
-
* @param level 日志级别(缺省 debug;落盘行与终端回显均带此级别)
|
|
9456
|
-
*/
|
|
9457
|
-
function debugRecord(_ctx, cwd, text, level = "debug") {
|
|
9458
|
-
const tagged = "[" + level + "] " + text;
|
|
9459
|
-
const st = debugBuffers.get(cwd) ?? {
|
|
9460
|
-
lines: [],
|
|
9461
|
-
len: 0,
|
|
9462
|
-
timer: null
|
|
9463
|
-
};
|
|
9464
|
-
st.lines.push(tagged);
|
|
9465
|
-
st.len += tagged.length;
|
|
9466
|
-
const flush = () => {
|
|
9467
|
-
st.timer = null;
|
|
9468
|
-
const batch = st.lines;
|
|
9469
|
-
st.lines = [];
|
|
9470
|
-
st.len = 0;
|
|
9471
|
-
flushDebug(cwd, batch);
|
|
9472
|
-
};
|
|
9473
|
-
if (st.len >= DEBUG_BUF_CAP) {
|
|
9474
|
-
if (st.timer) clearTimeout(st.timer);
|
|
9475
|
-
flush();
|
|
9476
|
-
} else if (!st.timer) st.timer = setTimeout(flush, DEBUG_FLUSH_IDLE_MS);
|
|
9477
|
-
debugBuffers.set(cwd, st);
|
|
9478
|
-
debugLog[level](text);
|
|
10253
|
+
updatedAt: typeof doc.updatedAt === "string" ? doc.updatedAt : "",
|
|
10254
|
+
keys
|
|
10255
|
+
};
|
|
9479
10256
|
}
|
|
9480
10257
|
/**
|
|
9481
|
-
*
|
|
9482
|
-
* @author ddj 2026年09月
|
|
9483
|
-
* @param
|
|
9484
|
-
* @
|
|
10258
|
+
* 序列化为镜像文件文本。
|
|
10259
|
+
* @author ddj 2026年09月28号
|
|
10260
|
+
* @param keys 键值表
|
|
10261
|
+
* @param updatedAt 更新时间(缺省当前时间)
|
|
10262
|
+
* @returns 文件文本
|
|
9485
10263
|
*/
|
|
9486
|
-
|
|
9487
|
-
|
|
9488
|
-
|
|
9489
|
-
|
|
9490
|
-
|
|
9491
|
-
|
|
9492
|
-
if (!info?.isFile()) continue;
|
|
9493
|
-
items.push({
|
|
9494
|
-
name,
|
|
9495
|
-
bytes: info.size,
|
|
9496
|
-
mtimeMs: info.mtimeMs
|
|
9497
|
-
});
|
|
9498
|
-
}
|
|
9499
|
-
items.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
9500
|
-
return items;
|
|
10264
|
+
function serializeUiState(keys, updatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
10265
|
+
return JSON.stringify({
|
|
10266
|
+
version: 1,
|
|
10267
|
+
updatedAt,
|
|
10268
|
+
keys
|
|
10269
|
+
});
|
|
9501
10270
|
}
|
|
10271
|
+
/** 同文件写队列尾(串行化并发写入;队列清空即移除条目)。 */
|
|
10272
|
+
const writeChains = /* @__PURE__ */ new Map();
|
|
9502
10273
|
/**
|
|
9503
|
-
*
|
|
9504
|
-
*
|
|
9505
|
-
* @
|
|
9506
|
-
* @param
|
|
9507
|
-
* @
|
|
9508
|
-
* @param home DSH home(测试可注入)
|
|
9509
|
-
* @returns 读取结果;文件不存在或名字非法返回 null
|
|
10274
|
+
* 把写任务挂到该文件的队尾串行执行(前序失败不影响后续)。
|
|
10275
|
+
* @author ddj 2026年09月28号
|
|
10276
|
+
* @param file 目标文件绝对路径
|
|
10277
|
+
* @param run 实际写操作
|
|
10278
|
+
* @returns 本次写操作结果
|
|
9510
10279
|
*/
|
|
9511
|
-
|
|
9512
|
-
|
|
9513
|
-
const
|
|
9514
|
-
|
|
9515
|
-
|
|
9516
|
-
|
|
9517
|
-
|
|
9518
|
-
|
|
9519
|
-
return {
|
|
9520
|
-
name,
|
|
9521
|
-
path: target,
|
|
9522
|
-
content: cut > 0 ? text.slice(cut) : text,
|
|
9523
|
-
size: info.size,
|
|
9524
|
-
truncated: cut > 0
|
|
9525
|
-
};
|
|
10280
|
+
function chainWrite(file, run) {
|
|
10281
|
+
const next = (writeChains.get(file) ?? Promise.resolve()).then(run, run);
|
|
10282
|
+
const tail = next.then(() => void 0, () => void 0);
|
|
10283
|
+
writeChains.set(file, tail);
|
|
10284
|
+
tail.then(() => {
|
|
10285
|
+
if (writeChains.get(file) === tail) writeChains.delete(file);
|
|
10286
|
+
});
|
|
10287
|
+
return next;
|
|
9526
10288
|
}
|
|
9527
10289
|
/**
|
|
9528
|
-
*
|
|
9529
|
-
* @author ddj 2026年09月
|
|
9530
|
-
* @param
|
|
9531
|
-
* @
|
|
9532
|
-
* @returns 是否清空成功(文件不存在视为未成功,文案由调用方给)
|
|
10290
|
+
* 读取镜像文件的键值表(文件缺失/损坏/不可读 → 空表,静默)。
|
|
10291
|
+
* @author ddj 2026年09月28号
|
|
10292
|
+
* @param file 镜像文件绝对路径
|
|
10293
|
+
* @returns 键值表(localStorage 键 → 字符串原值)
|
|
9533
10294
|
*/
|
|
9534
|
-
async function
|
|
9535
|
-
|
|
9536
|
-
|
|
10295
|
+
async function readUiState(file) {
|
|
10296
|
+
try {
|
|
10297
|
+
const doc = parseUiState(await readFile(file, "utf8"));
|
|
10298
|
+
return doc ? doc.keys : {};
|
|
10299
|
+
} catch (error) {
|
|
10300
|
+
return {};
|
|
10301
|
+
}
|
|
10302
|
+
}
|
|
10303
|
+
/**
|
|
10304
|
+
* 合并写入镜像文件(读旧 → 合并补丁 → 容量校验 → 原子替换),同文件写操作串行。
|
|
10305
|
+
* @author ddj 2026年09月28号
|
|
10306
|
+
* @param file 镜像文件绝对路径
|
|
10307
|
+
* @param patch 增量补丁(null/undefined 值删除该键)
|
|
10308
|
+
* @returns ok;超限或写入失败时 ok:false + 中文错误文案
|
|
10309
|
+
*/
|
|
10310
|
+
async function writeUiState(file, patch) {
|
|
10311
|
+
return chainWrite(file, async () => {
|
|
10312
|
+
try {
|
|
10313
|
+
const json = serializeUiState(mergeUiStateKeys(await readUiState(file), patch));
|
|
10314
|
+
if (Buffer$1.byteLength(json, "utf8") > 524288) return {
|
|
10315
|
+
ok: false,
|
|
10316
|
+
error: "界面状态镜像超出容量上限(524288 字节)"
|
|
10317
|
+
};
|
|
10318
|
+
await mkdir(dirname(file), { recursive: true });
|
|
10319
|
+
const tmp = file + ".tmp";
|
|
10320
|
+
await writeFile(tmp, json, "utf8");
|
|
10321
|
+
await rename(tmp, file);
|
|
10322
|
+
return { ok: true };
|
|
10323
|
+
} catch (error) {
|
|
10324
|
+
return {
|
|
10325
|
+
ok: false,
|
|
10326
|
+
error: "界面状态写入失败: " + (error instanceof Error ? error.message : String(error))
|
|
10327
|
+
};
|
|
10328
|
+
}
|
|
10329
|
+
});
|
|
9537
10330
|
}
|
|
9538
10331
|
//#endregion
|
|
9539
10332
|
//#region src/perf.ts
|
|
@@ -9940,30 +10733,28 @@ async function sidecarSummaryOf(ctx, cwd) {
|
|
|
9940
10733
|
const summary = {
|
|
9941
10734
|
active: 0,
|
|
9942
10735
|
pendingByFile: [],
|
|
9943
|
-
archiveBytes: 0
|
|
10736
|
+
archiveBytes: 0,
|
|
10737
|
+
legacyArchiveBytes: 0,
|
|
10738
|
+
legacyActiveBytes: 0
|
|
9944
10739
|
};
|
|
9945
10740
|
if (!fs || !cwd) return summary;
|
|
10741
|
+
const current = activeSummary(cwd);
|
|
10742
|
+
summary.active = current.active;
|
|
10743
|
+
summary.pendingByFile = current.pendingByFile;
|
|
9946
10744
|
try {
|
|
9947
|
-
|
|
9948
|
-
|
|
9949
|
-
|
|
9950
|
-
|
|
9951
|
-
|
|
9952
|
-
|
|
9953
|
-
|
|
9954
|
-
|
|
9955
|
-
|
|
9956
|
-
|
|
9957
|
-
|
|
9958
|
-
|
|
9959
|
-
|
|
9960
|
-
pending
|
|
9961
|
-
})).sort((a, b) => b.pending - a.pending);
|
|
9962
|
-
}
|
|
9963
|
-
try {
|
|
9964
|
-
summary.archiveBytes = (await fs.stat(await fs.resolve(".dsh-edit-review-archive.json", { cwd })))?.size ?? 0;
|
|
9965
|
-
} catch (error) {}
|
|
9966
|
-
} catch (error) {}
|
|
10745
|
+
summary.legacyArchiveBytes = (await fs.stat(await fs.resolve(".dsh-edit-review-archive.json", { cwd })))?.size ?? 0;
|
|
10746
|
+
} catch {}
|
|
10747
|
+
try {
|
|
10748
|
+
summary.legacyActiveBytes = (await fs.stat(await fs.resolve(".dsh-edit-review.json", { cwd })))?.size ?? 0;
|
|
10749
|
+
} catch {}
|
|
10750
|
+
const dbPath = archiveDbFile(cwd);
|
|
10751
|
+
for (const file of [
|
|
10752
|
+
dbPath,
|
|
10753
|
+
dbPath + "-wal",
|
|
10754
|
+
dbPath + "-shm"
|
|
10755
|
+
]) try {
|
|
10756
|
+
summary.archiveBytes += (await stat(file)).size;
|
|
10757
|
+
} catch {}
|
|
9967
10758
|
return summary;
|
|
9968
10759
|
}
|
|
9969
10760
|
/** 解析归档 manifest(损坏/缺失 → 空)。 */
|
|
@@ -10102,6 +10893,8 @@ function settingsDocPath(configuredPath, requestedPath, platform = process.platf
|
|
|
10102
10893
|
const staleCheckedAt = /* @__PURE__ */ new Map();
|
|
10103
10894
|
/** stale 自动清理最小间隔。 */
|
|
10104
10895
|
const STALE_CHECK_MIN_MS = 1e4;
|
|
10896
|
+
/** 诊断由 client 的 edrv.debug 激活,过期后不再产生 Host 耗时日志。 */
|
|
10897
|
+
const diffTraceUntil = /* @__PURE__ */ new Map();
|
|
10105
10898
|
/**
|
|
10106
10899
|
* 无会话期诊断的伪 cwd:冷启动早期本插件客户端就会上报界面状态镜像事件
|
|
10107
10900
|
* (此时 `sessions` 可能尚未就绪,`requireSession` 会失败),退回本固定值落盘
|
|
@@ -10256,7 +11049,10 @@ async function afterManualSave(ctx, registry, sc, path) {
|
|
|
10256
11049
|
const done = [];
|
|
10257
11050
|
for (const rec of bucket.values()) if (rec.path === path && rec.superseded) done.push(rec);
|
|
10258
11051
|
if (done.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, done, "被手动编辑覆盖");
|
|
10259
|
-
else await saveBucket(ctx, sc.cwd, bucket, sc.session
|
|
11052
|
+
else await saveBucket(ctx, sc.cwd, bucket, sc.session, {
|
|
11053
|
+
upserts: [...bucket.values()].filter((rec) => rec.path === path),
|
|
11054
|
+
removed: []
|
|
11055
|
+
});
|
|
10260
11056
|
}
|
|
10261
11057
|
}
|
|
10262
11058
|
/**
|
|
@@ -10271,9 +11067,17 @@ async function afterManualSave(ctx, registry, sc, path) {
|
|
|
10271
11067
|
async function applyDecisions(ctx, session, cwd, bucket, items) {
|
|
10272
11068
|
const results = [];
|
|
10273
11069
|
const resolved = [];
|
|
11070
|
+
const touched = /* @__PURE__ */ new Map();
|
|
10274
11071
|
const ws = findRemoteWs(cwd);
|
|
10275
11072
|
let changed = false;
|
|
10276
|
-
|
|
11073
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
11074
|
+
const trace = diffTraceUntil.get(cwd);
|
|
11075
|
+
const traceId = trace && trace.until > Date.now() ? trace.id : null;
|
|
11076
|
+
const started = traceId ? performance.now() : 0;
|
|
11077
|
+
let revertMs = 0;
|
|
11078
|
+
let revertGroups = 0;
|
|
11079
|
+
for (let index = 0; index < items.length; index++) {
|
|
11080
|
+
const item = items[index];
|
|
10277
11081
|
const record = bucket.get(item.callId);
|
|
10278
11082
|
if (!record) {
|
|
10279
11083
|
results.push({
|
|
@@ -10283,10 +11087,100 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
|
|
|
10283
11087
|
});
|
|
10284
11088
|
continue;
|
|
10285
11089
|
}
|
|
11090
|
+
if (record.archived) {
|
|
11091
|
+
results.push({
|
|
11092
|
+
callId: item.callId,
|
|
11093
|
+
ok: false,
|
|
11094
|
+
error: "记录已归档,无法重复决策"
|
|
11095
|
+
});
|
|
11096
|
+
continue;
|
|
11097
|
+
}
|
|
11098
|
+
const alreadyDone = (item.scope === "hunk" ? record.decisions.perHunk[item.hunkIndex ?? -1] : record.decisions.call) === item.decision;
|
|
11099
|
+
if (alreadyDone) {
|
|
11100
|
+
if (recordResolved(record) && !record.archived) resolved.push(record);
|
|
11101
|
+
changed = true;
|
|
11102
|
+
touched.set(record.callId, record);
|
|
11103
|
+
results.push({
|
|
11104
|
+
callId: item.callId,
|
|
11105
|
+
ok: true,
|
|
11106
|
+
record: recView(record, ws)
|
|
11107
|
+
});
|
|
11108
|
+
continue;
|
|
11109
|
+
}
|
|
10286
11110
|
let revertedStale = false;
|
|
10287
|
-
if (item.decision === "rejected") {
|
|
11111
|
+
if (item.decision === "rejected" && !alreadyDone) {
|
|
10288
11112
|
const scope = item.scope ?? "call";
|
|
10289
|
-
const
|
|
11113
|
+
const hunkIndex = scope === "hunk" ? item.hunkIndex ?? -1 : -1;
|
|
11114
|
+
let existingIntent;
|
|
11115
|
+
try {
|
|
11116
|
+
existingIntent = grouped.has(index) ? null : revertState(cwd, record.callId, scope, hunkIndex);
|
|
11117
|
+
} catch (error) {
|
|
11118
|
+
results.push({
|
|
11119
|
+
callId: item.callId,
|
|
11120
|
+
ok: false,
|
|
11121
|
+
error: "回滚状态读取失败,未修改文件:" + String(error)
|
|
11122
|
+
});
|
|
11123
|
+
continue;
|
|
11124
|
+
}
|
|
11125
|
+
if (existingIntent === "started") {
|
|
11126
|
+
results.push({
|
|
11127
|
+
callId: item.callId,
|
|
11128
|
+
ok: false,
|
|
11129
|
+
error: "回滚结果不确定,请人工核对文件后处理;已阻止再次修改文件"
|
|
11130
|
+
});
|
|
11131
|
+
continue;
|
|
11132
|
+
}
|
|
11133
|
+
if (existingIntent === "applied") {
|
|
11134
|
+
markDecision(record, scope, item.hunkIndex, item.decision);
|
|
11135
|
+
changed = true;
|
|
11136
|
+
touched.set(record.callId, record);
|
|
11137
|
+
if (recordResolved(record)) resolved.push(record);
|
|
11138
|
+
results.push({
|
|
11139
|
+
callId: item.callId,
|
|
11140
|
+
ok: true,
|
|
11141
|
+
record: recView(record, ws)
|
|
11142
|
+
});
|
|
11143
|
+
continue;
|
|
11144
|
+
}
|
|
11145
|
+
if (!grouped.has(index)) {
|
|
11146
|
+
let last = index + 1;
|
|
11147
|
+
if (scope === "hunk" && !ws && record.toolName !== "remote_ssh_write") while (last < items.length && items[last].callId === item.callId && items[last].decision === "rejected" && items[last].scope === "hunk") last++;
|
|
11148
|
+
let groupReady = false;
|
|
11149
|
+
try {
|
|
11150
|
+
groupReady = last > index + 1 && items.slice(index + 1, last).every((next) => revertState(cwd, record.callId, "hunk", next.hunkIndex ?? -1) === null);
|
|
11151
|
+
if (groupReady) startReverts(cwd, record.callId, items.slice(index, last).map((next) => next.hunkIndex ?? -1));
|
|
11152
|
+
else setRevertState(cwd, record.callId, scope, hunkIndex, "started");
|
|
11153
|
+
} catch (error) {
|
|
11154
|
+
results.push({
|
|
11155
|
+
callId: item.callId,
|
|
11156
|
+
ok: false,
|
|
11157
|
+
error: "回滚状态无法登记,未修改文件:" + String(error)
|
|
11158
|
+
});
|
|
11159
|
+
continue;
|
|
11160
|
+
}
|
|
11161
|
+
if (groupReady) {
|
|
11162
|
+
const revertStart = traceId ? performance.now() : 0;
|
|
11163
|
+
const outcomes = await revertHunks(ctx, session, record, items.slice(index, last).map((next) => next.hunkIndex ?? -1), traceId ?? void 0);
|
|
11164
|
+
if (traceId) revertMs += performance.now() - revertStart;
|
|
11165
|
+
revertGroups++;
|
|
11166
|
+
outcomes.forEach((outcome, offset) => grouped.set(index + offset, outcome));
|
|
11167
|
+
}
|
|
11168
|
+
}
|
|
11169
|
+
const revertStart = traceId ? performance.now() : 0;
|
|
11170
|
+
const outcome = grouped.get(index) ?? (scope === "hunk" ? await revertHunk(ctx, session, record, item.hunkIndex ?? -1, traceId ?? void 0) : await revertCall(ctx, session, record));
|
|
11171
|
+
if (traceId && !grouped.has(index)) revertMs += performance.now() - revertStart;
|
|
11172
|
+
grouped.delete(index);
|
|
11173
|
+
try {
|
|
11174
|
+
if (outcome.ok || outcome.stale === true) setRevertState(cwd, record.callId, scope, hunkIndex, "applied");
|
|
11175
|
+
else if (outcome.error === "找不到该差异块" || outcome.error === "回滚失败:文件不存在" || outcome.error === "回滚失败:文件过大,无法安全定位") clearRevertState(cwd, record.callId, scope, hunkIndex);
|
|
11176
|
+
} catch (error) {
|
|
11177
|
+
results.push({
|
|
11178
|
+
callId: item.callId,
|
|
11179
|
+
ok: false,
|
|
11180
|
+
error: "回滚状态保存失败,请人工核对文件:" + String(error)
|
|
11181
|
+
});
|
|
11182
|
+
continue;
|
|
11183
|
+
}
|
|
10290
11184
|
if (!outcome.ok) {
|
|
10291
11185
|
if (scope !== "hunk" || outcome.stale !== true) {
|
|
10292
11186
|
results.push({
|
|
@@ -10302,6 +11196,7 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
|
|
|
10302
11196
|
const wasResolved = recordResolved(record);
|
|
10303
11197
|
markDecision(record, item.scope ?? "call", item.hunkIndex, item.decision);
|
|
10304
11198
|
changed = true;
|
|
11199
|
+
touched.set(record.callId, record);
|
|
10305
11200
|
if (!wasResolved && recordResolved(record)) resolved.push(record);
|
|
10306
11201
|
const result = {
|
|
10307
11202
|
callId: item.callId,
|
|
@@ -10311,8 +11206,58 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
|
|
|
10311
11206
|
if (revertedStale) result.stale = true;
|
|
10312
11207
|
results.push(result);
|
|
10313
11208
|
}
|
|
10314
|
-
|
|
10315
|
-
|
|
11209
|
+
const decisionMs = traceId ? performance.now() - started : 0;
|
|
11210
|
+
const archiveStart = traceId ? performance.now() : 0;
|
|
11211
|
+
if (resolved.length) {
|
|
11212
|
+
const reason = items.some((item) => item.decision === "rejected") ? "已处理(回滚)" : "已处理";
|
|
11213
|
+
try {
|
|
11214
|
+
await archiveRecords(ctx, session, cwd, bucket, resolved, reason, {
|
|
11215
|
+
deferSave: true,
|
|
11216
|
+
traceId: traceId ?? void 0,
|
|
11217
|
+
changed: [...touched.values()]
|
|
11218
|
+
});
|
|
11219
|
+
} catch (error) {
|
|
11220
|
+
const failed = new Set(resolved.map((record) => record.callId));
|
|
11221
|
+
for (const result of results) {
|
|
11222
|
+
if (!failed.has(result.callId) || !result.ok) continue;
|
|
11223
|
+
result.ok = false;
|
|
11224
|
+
result.error = "归档失败:" + String(error);
|
|
11225
|
+
delete result.record;
|
|
11226
|
+
}
|
|
11227
|
+
if (changed) try {
|
|
11228
|
+
await saveBucket(ctx, cwd, bucket, session, {
|
|
11229
|
+
upserts: [...touched.values()],
|
|
11230
|
+
removed: []
|
|
11231
|
+
});
|
|
11232
|
+
} catch (saveError) {
|
|
11233
|
+
log.error("归档失败后的 SQLite 补写失败:" + String(saveError));
|
|
11234
|
+
}
|
|
11235
|
+
return results;
|
|
11236
|
+
}
|
|
11237
|
+
}
|
|
11238
|
+
const archiveMs = traceId ? performance.now() - archiveStart : 0;
|
|
11239
|
+
const saveStart = traceId ? performance.now() : 0;
|
|
11240
|
+
if (changed && !resolved.length) try {
|
|
11241
|
+
await saveBucket(ctx, cwd, bucket, session, {
|
|
11242
|
+
upserts: [...touched.values()],
|
|
11243
|
+
removed: []
|
|
11244
|
+
});
|
|
11245
|
+
} catch (error) {
|
|
11246
|
+
for (const result of results) {
|
|
11247
|
+
if (!result.ok) continue;
|
|
11248
|
+
result.ok = false;
|
|
11249
|
+
result.error = "决策记录保存失败:" + String(error);
|
|
11250
|
+
delete result.record;
|
|
11251
|
+
}
|
|
11252
|
+
return results;
|
|
11253
|
+
}
|
|
11254
|
+
try {
|
|
11255
|
+
if (traceId) {
|
|
11256
|
+
const saveMs = performance.now() - saveStart;
|
|
11257
|
+
const totalMs = performance.now() - started;
|
|
11258
|
+
debugRecord(ctx, cwd, `[DEBUG diffDecision] trace=${traceId} items=${items.length} rejected=${items.filter((item) => item.decision === "rejected").length} ok=${results.filter((result) => result.ok).length} stale=${results.filter((result) => result.stale === true).length} grouped=${revertGroups} loopMs=${decisionMs.toFixed(1)} revertMs=${revertMs.toFixed(1)} archiveMs=${archiveMs.toFixed(1)} saveMs=${saveMs.toFixed(1)} totalMs=${totalMs.toFixed(1)}`);
|
|
11259
|
+
}
|
|
11260
|
+
} catch {}
|
|
10316
11261
|
return results;
|
|
10317
11262
|
}
|
|
10318
11263
|
/** 当前活跃会话 id 集合(live sessions,用于移出/恢复护栏)。 */
|
|
@@ -10446,18 +11391,25 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
10446
11391
|
...svnHandlers ?? {},
|
|
10447
11392
|
...dapHandlers ?? {},
|
|
10448
11393
|
"edrv.list": async (args) => {
|
|
11394
|
+
const start = performance.now();
|
|
11395
|
+
let staleMs = 0;
|
|
11396
|
+
let staleChecked = false;
|
|
10449
11397
|
const sc = await requireSession(ctx, args.sessionId);
|
|
10450
11398
|
if ("err" in sc) return {
|
|
10451
11399
|
ok: false,
|
|
10452
11400
|
error: sc.err
|
|
10453
11401
|
};
|
|
10454
11402
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
11403
|
+
const loadMs = performance.now() - start;
|
|
10455
11404
|
const want = Array.isArray(args.callIds) ? new Set(args.callIds) : null;
|
|
10456
11405
|
if (!want && args.skipStale !== true) {
|
|
10457
11406
|
const now = Date.now();
|
|
10458
11407
|
if (now - (staleCheckedAt.get(sc.cwd) ?? 0) > STALE_CHECK_MIN_MS) {
|
|
10459
11408
|
staleCheckedAt.set(sc.cwd, now);
|
|
11409
|
+
staleChecked = true;
|
|
11410
|
+
const staleStart = performance.now();
|
|
10460
11411
|
await autoArchiveStale(ctx, sc.session, sc.cwd, bucket);
|
|
11412
|
+
staleMs = performance.now() - staleStart;
|
|
10461
11413
|
}
|
|
10462
11414
|
}
|
|
10463
11415
|
const out = [];
|
|
@@ -10468,6 +11420,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
10468
11420
|
out.push(recView(rec, ws));
|
|
10469
11421
|
}
|
|
10470
11422
|
out.sort((a, b) => a.at < b.at ? -1 : 1);
|
|
11423
|
+
try {
|
|
11424
|
+
const totalMs = performance.now() - start;
|
|
11425
|
+
const trace = diffTraceUntil.get(sc.cwd);
|
|
11426
|
+
if (trace && trace.until > Date.now() && (totalMs >= 20 || staleMs >= 10)) debugRecord(ctx, sc.cwd, `[DEBUG diffListHost] trace=${trace.id} count=${out.length} bucket=${bucket.size} checked=${staleChecked} loadMs=${loadMs.toFixed(1)} staleMs=${staleMs.toFixed(1)} totalMs=${totalMs.toFixed(1)}`);
|
|
11427
|
+
} catch {}
|
|
10471
11428
|
return {
|
|
10472
11429
|
ok: true,
|
|
10473
11430
|
records: out
|
|
@@ -10898,44 +11855,45 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
10898
11855
|
ok: false,
|
|
10899
11856
|
error: sc.err
|
|
10900
11857
|
};
|
|
10901
|
-
|
|
10902
|
-
|
|
10903
|
-
const entries = batches.map((b) => {
|
|
10904
|
-
const recs = Array.isArray(b.records) ? b.records : [];
|
|
10905
|
-
const sum = recs.reduce((s, r) => {
|
|
10906
|
-
const sm = r.summary || {
|
|
10907
|
-
accepted: 0,
|
|
10908
|
-
rejected: 0,
|
|
10909
|
-
pending: 0,
|
|
10910
|
-
superseded: false
|
|
10911
|
-
};
|
|
10912
|
-
s.accepted += sm.accepted || 0;
|
|
10913
|
-
s.rejected += sm.rejected || 0;
|
|
10914
|
-
s.pending += sm.pending || 0;
|
|
10915
|
-
if (sm.superseded) s.superseded++;
|
|
10916
|
-
return s;
|
|
10917
|
-
}, {
|
|
10918
|
-
accepted: 0,
|
|
10919
|
-
rejected: 0,
|
|
10920
|
-
pending: 0,
|
|
10921
|
-
superseded: 0
|
|
10922
|
-
});
|
|
11858
|
+
try {
|
|
11859
|
+
const ws = findRemoteWs(sc.cwd);
|
|
10923
11860
|
return {
|
|
10924
|
-
|
|
10925
|
-
|
|
10926
|
-
|
|
10927
|
-
|
|
10928
|
-
|
|
10929
|
-
reason: b.reason ?? null,
|
|
10930
|
-
nRecords: recs.length,
|
|
10931
|
-
summary: sum
|
|
11861
|
+
ok: true,
|
|
11862
|
+
entries: listArchives(sc.cwd).map((item) => ({
|
|
11863
|
+
...item,
|
|
11864
|
+
...ws ? { displayPath: remoteDisplayOf(item.path, ws) ?? void 0 } : {}
|
|
11865
|
+
}))
|
|
10932
11866
|
};
|
|
10933
|
-
})
|
|
10934
|
-
|
|
10935
|
-
|
|
10936
|
-
|
|
10937
|
-
|
|
11867
|
+
} catch (error) {
|
|
11868
|
+
return {
|
|
11869
|
+
ok: false,
|
|
11870
|
+
error: "归档列表读取失败:" + String(error)
|
|
11871
|
+
};
|
|
11872
|
+
}
|
|
11873
|
+
},
|
|
11874
|
+
"edrv.archivePage": async (args) => {
|
|
11875
|
+
const sc = await requireSession(ctx, args.sessionId);
|
|
11876
|
+
if ("err" in sc) return {
|
|
11877
|
+
ok: false,
|
|
11878
|
+
error: sc.err
|
|
10938
11879
|
};
|
|
11880
|
+
try {
|
|
11881
|
+
const ws = findRemoteWs(sc.cwd);
|
|
11882
|
+
const page = pageArchives(sc.cwd, args.cursor, args.limit);
|
|
11883
|
+
return {
|
|
11884
|
+
ok: true,
|
|
11885
|
+
entries: page.entries.map((item) => ({
|
|
11886
|
+
...item,
|
|
11887
|
+
...ws ? { displayPath: remoteDisplayOf(item.path, ws) ?? void 0 } : {}
|
|
11888
|
+
})),
|
|
11889
|
+
nextCursor: page.nextCursor
|
|
11890
|
+
};
|
|
11891
|
+
} catch (error) {
|
|
11892
|
+
return {
|
|
11893
|
+
ok: false,
|
|
11894
|
+
error: "归档分页读取失败:" + String(error)
|
|
11895
|
+
};
|
|
11896
|
+
}
|
|
10939
11897
|
},
|
|
10940
11898
|
"edrv.archiveRead": async (args) => {
|
|
10941
11899
|
const sc = await requireSession(ctx, args.sessionId);
|
|
@@ -10943,11 +11901,21 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
10943
11901
|
ok: false,
|
|
10944
11902
|
error: sc.err
|
|
10945
11903
|
};
|
|
10946
|
-
|
|
10947
|
-
|
|
10948
|
-
|
|
10949
|
-
|
|
10950
|
-
|
|
11904
|
+
try {
|
|
11905
|
+
if (args.batch !== void 0 && (!args.path || args.batch !== null && !Number.isInteger(args.batch))) return {
|
|
11906
|
+
ok: false,
|
|
11907
|
+
error: "归档批次参数无效"
|
|
11908
|
+
};
|
|
11909
|
+
return {
|
|
11910
|
+
ok: true,
|
|
11911
|
+
batches: readArchives(sc.cwd, args.path, args.batch)
|
|
11912
|
+
};
|
|
11913
|
+
} catch (error) {
|
|
11914
|
+
return {
|
|
11915
|
+
ok: false,
|
|
11916
|
+
error: "归档详情读取失败:" + String(error)
|
|
11917
|
+
};
|
|
11918
|
+
}
|
|
10951
11919
|
},
|
|
10952
11920
|
"edrv.rollback": async (args) => {
|
|
10953
11921
|
const sc = await requireSession(ctx, args.sessionId);
|
|
@@ -10961,47 +11929,54 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
10961
11929
|
};
|
|
10962
11930
|
const batch = args.batch;
|
|
10963
11931
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
10964
|
-
|
|
10965
|
-
|
|
10966
|
-
|
|
10967
|
-
|
|
10968
|
-
|
|
10969
|
-
|
|
10970
|
-
|
|
11932
|
+
try {
|
|
11933
|
+
let affected = [];
|
|
11934
|
+
let beforeRec = null;
|
|
11935
|
+
if (batch !== void 0) {
|
|
11936
|
+
const all = readArchives(sc.cwd, args.path, batch);
|
|
11937
|
+
const recs = [];
|
|
11938
|
+
for (const b of all) if (b.batch === batch) for (const r of b.records || []) recs.push(r);
|
|
11939
|
+
if (!recs.length) return {
|
|
11940
|
+
ok: false,
|
|
11941
|
+
error: "归档中找不到该批次"
|
|
11942
|
+
};
|
|
11943
|
+
recs.sort((a, b2) => a.at < b2.at ? -1 : 1);
|
|
11944
|
+
beforeRec = recs[0];
|
|
11945
|
+
affected = recs;
|
|
11946
|
+
} else {
|
|
11947
|
+
for (const r of bucket.values()) if (r.path === args.path && !r.archived) affected.push(r);
|
|
11948
|
+
if (!affected.length) return {
|
|
11949
|
+
ok: false,
|
|
11950
|
+
error: "该文件没有可回滚的差异"
|
|
11951
|
+
};
|
|
11952
|
+
affected.sort((a, b2) => a.at < b2.at ? -1 : 1);
|
|
11953
|
+
beforeRec = affected[0];
|
|
11954
|
+
}
|
|
11955
|
+
const rerr = await restoreFile(ctx, sc.session, beforeRec);
|
|
11956
|
+
if (rerr) return {
|
|
10971
11957
|
ok: false,
|
|
10972
|
-
error:
|
|
11958
|
+
error: rerr
|
|
10973
11959
|
};
|
|
10974
|
-
|
|
10975
|
-
|
|
10976
|
-
|
|
10977
|
-
|
|
10978
|
-
|
|
10979
|
-
|
|
11960
|
+
if (batch === void 0) await archiveRecords(ctx, sc.session, sc.cwd, bucket, affected, "已回滚");
|
|
11961
|
+
else {
|
|
11962
|
+
const sumRecs = readArchives(sc.cwd, args.path, batch).reduce((s, b) => s + (b.records || []).length, 0);
|
|
11963
|
+
const logRec = Object.assign({}, affected[0], { note: (affected[0].note ? affected[0].note + ";" : "") + "回滚至本批次前(批次 " + batch + ")" });
|
|
11964
|
+
await appendArchiveEntries(ctx, sc.cwd, [archiveEntryFor([logRec], sc.cwd, "已回滚(批次 " + batch + "," + sumRecs + " 条)")], sc.session);
|
|
11965
|
+
const activeRecs = [];
|
|
11966
|
+
for (const r of bucket.values()) if (r.path === args.path && !r.archived) activeRecs.push(r);
|
|
11967
|
+
if (activeRecs.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, activeRecs, "已回滚(批次回滚覆盖)");
|
|
11968
|
+
}
|
|
11969
|
+
return {
|
|
11970
|
+
ok: true,
|
|
11971
|
+
path: args.path,
|
|
11972
|
+
batch: batch ?? null
|
|
11973
|
+
};
|
|
11974
|
+
} catch (error) {
|
|
11975
|
+
return {
|
|
10980
11976
|
ok: false,
|
|
10981
|
-
error: "
|
|
11977
|
+
error: "回滚/归档失败:" + String(error)
|
|
10982
11978
|
};
|
|
10983
|
-
affected.sort((a, b2) => a.at < b2.at ? -1 : 1);
|
|
10984
|
-
beforeRec = affected[0];
|
|
10985
|
-
}
|
|
10986
|
-
const rerr = await restoreFile(ctx, sc.session, beforeRec);
|
|
10987
|
-
if (rerr) return {
|
|
10988
|
-
ok: false,
|
|
10989
|
-
error: rerr
|
|
10990
|
-
};
|
|
10991
|
-
if (batch === void 0) await archiveRecords(ctx, sc.session, sc.cwd, bucket, affected, "已回滚");
|
|
10992
|
-
else {
|
|
10993
|
-
const sumRecs = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd && b.path === args.path && b.batch === batch).reduce((s, b) => s + (b.records || []).length, 0);
|
|
10994
|
-
const logRec = Object.assign({}, affected[0], { note: (affected[0].note ? affected[0].note + ";" : "") + "回滚至本批次前(批次 " + batch + ")" });
|
|
10995
|
-
await appendArchiveEntries(ctx, sc.cwd, [archiveEntryFor([logRec], sc.cwd, "已回滚(批次 " + batch + "," + sumRecs + " 条)")], sc.session);
|
|
10996
|
-
const activeRecs = [];
|
|
10997
|
-
for (const r of bucket.values()) if (r.path === args.path && !r.archived) activeRecs.push(r);
|
|
10998
|
-
if (activeRecs.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, activeRecs, "已回滚(批次回滚覆盖)");
|
|
10999
11979
|
}
|
|
11000
|
-
return {
|
|
11001
|
-
ok: true,
|
|
11002
|
-
path: args.path,
|
|
11003
|
-
batch: batch ?? null
|
|
11004
|
-
};
|
|
11005
11980
|
},
|
|
11006
11981
|
"edrv.debug": async (args) => {
|
|
11007
11982
|
const text = String(args.text ?? "");
|
|
@@ -11010,6 +11985,13 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
11010
11985
|
debugRecord(ctx, UI_AUDIT_CWD, text, args.level);
|
|
11011
11986
|
return { ok: true };
|
|
11012
11987
|
}
|
|
11988
|
+
try {
|
|
11989
|
+
const match = /^\[DEBUG diffAction\] stage=start trace=(\d+-\d+) /.exec(text);
|
|
11990
|
+
if (match) diffTraceUntil.set(sc.cwd, {
|
|
11991
|
+
until: Date.now() + 15e3,
|
|
11992
|
+
id: match[1]
|
|
11993
|
+
});
|
|
11994
|
+
} catch {}
|
|
11013
11995
|
debugRecord(ctx, sc.cwd, text, args.level);
|
|
11014
11996
|
return { ok: true };
|
|
11015
11997
|
},
|
|
@@ -11365,6 +12347,10 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
11365
12347
|
ok: true,
|
|
11366
12348
|
...listMcp(ctx)
|
|
11367
12349
|
}),
|
|
12350
|
+
"mcp.snapshot": async () => ({
|
|
12351
|
+
ok: true,
|
|
12352
|
+
...mcpSnapshot(ctx)
|
|
12353
|
+
}),
|
|
11368
12354
|
"mcp.save": async (args) => {
|
|
11369
12355
|
try {
|
|
11370
12356
|
return {
|
|
@@ -21162,6 +22148,7 @@ function attachRuntime(ctx, runtime) {
|
|
|
21162
22148
|
/** @author ddj 2026年09月28号 Release every manager even if another cleanup rejects. */
|
|
21163
22149
|
async function cleanup() {
|
|
21164
22150
|
runtime.fileVersions.dispose();
|
|
22151
|
+
closeArchiveDbs();
|
|
21165
22152
|
disposeAllServers();
|
|
21166
22153
|
await Promise.allSettled([
|
|
21167
22154
|
() => runtime.lspManager.disposeAll(),
|