@michengai/dsh-codex-ui 0.2.101 → 0.2.102
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/CHANGELOG.md +28 -0
- package/CHANGELOG.zh-CN.md +28 -0
- package/README.md +1 -2
- package/README.zh-CN.md +1 -2
- package/{dist → lib}/client.js +2514 -749
- package/{dist → lib}/index.mjs +556 -19
- package/package.json +45 -27
- /package/{dist → lib}/index.d.mts +0 -0
package/{dist → lib}/index.mjs
RENAMED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { lstat, mkdir, open, readFile, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { basename, dirname, isAbsolute, join, resolve, sep, win32 } from "node:path";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
8
8
|
//#region src/dependencies.ts
|
|
9
9
|
const SUITE_PACKAGE = "@michengai/dsh-codex-suite";
|
|
10
10
|
const SUITE_MEMBER_PACKAGES = [
|
|
@@ -820,11 +820,26 @@ function requireService(ctx, key, method) {
|
|
|
820
820
|
}
|
|
821
821
|
/** 在唯一的宿主边界校验服务能力;宿主 API 变更时立即失败,不会静默返回 503。 */
|
|
822
822
|
function hostServices(ctx) {
|
|
823
|
+
const emit = ctx.emit;
|
|
823
824
|
return {
|
|
824
825
|
webServer: requireService(ctx, "webServer", "register"),
|
|
825
826
|
agents: requireService(ctx, "agents", "get"),
|
|
827
|
+
sessions: requireService(ctx, "sessions", "get"),
|
|
828
|
+
sessionPersistence: requireService(ctx, "sessionPersistence", "list"),
|
|
826
829
|
tools: requireService(ctx, "tools", "schemas"),
|
|
827
|
-
workspaceRegistry: requireService(ctx, "workspaceRegistry", "list")
|
|
830
|
+
workspaceRegistry: requireService(ctx, "workspaceRegistry", "list"),
|
|
831
|
+
sessionProjectionCache: ctx.get("sessionProjectionCache"),
|
|
832
|
+
emit: (event, ...args) => {
|
|
833
|
+
emit.call(ctx, event, ...args);
|
|
834
|
+
},
|
|
835
|
+
logger: {
|
|
836
|
+
warn: (message) => {
|
|
837
|
+
ctx.logger.warn("%s", message);
|
|
838
|
+
},
|
|
839
|
+
info: (message) => {
|
|
840
|
+
ctx.logger.info("%s", message);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
828
843
|
};
|
|
829
844
|
}
|
|
830
845
|
//#endregion
|
|
@@ -1046,6 +1061,409 @@ var ForegroundExplorer = class {
|
|
|
1046
1061
|
}
|
|
1047
1062
|
};
|
|
1048
1063
|
//#endregion
|
|
1064
|
+
//#region src/session-migration.ts
|
|
1065
|
+
var SessionMoveError = class extends Error {
|
|
1066
|
+
code;
|
|
1067
|
+
constructor(code, message, options) {
|
|
1068
|
+
super(message, options);
|
|
1069
|
+
this.code = code;
|
|
1070
|
+
this.name = "SessionMoveError";
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
const movingSessions = /* @__PURE__ */ new Set();
|
|
1074
|
+
function rawSessionIds(workspace) {
|
|
1075
|
+
return Array.isArray(workspace.record?.sessionIds) ? workspace.record.sessionIds : workspace.sessionIds;
|
|
1076
|
+
}
|
|
1077
|
+
function validIdentifier(value) {
|
|
1078
|
+
return value.length > 0 && value.length <= 512 && !/[\u0000-\u001f\u007f]/.test(value);
|
|
1079
|
+
}
|
|
1080
|
+
async function pathExists(path) {
|
|
1081
|
+
try {
|
|
1082
|
+
await lstat(path);
|
|
1083
|
+
return true;
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
if (error.code === "ENOENT") return false;
|
|
1086
|
+
throw error;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
async function writeTemporaryFile(finalPath, data) {
|
|
1090
|
+
const temporaryPath = `${finalPath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
1091
|
+
const handle = await open(temporaryPath, "wx", 384);
|
|
1092
|
+
try {
|
|
1093
|
+
await handle.writeFile(data);
|
|
1094
|
+
await handle.sync();
|
|
1095
|
+
} finally {
|
|
1096
|
+
await handle.close();
|
|
1097
|
+
}
|
|
1098
|
+
return temporaryPath;
|
|
1099
|
+
}
|
|
1100
|
+
async function encodeSessionArtifact(headerLine, body, zstd) {
|
|
1101
|
+
if (!zstd) return Buffer.from(`${headerLine}\n${body}`, "utf8");
|
|
1102
|
+
const { zstdCompress } = await import("node:zlib");
|
|
1103
|
+
if (typeof zstdCompress !== "function") throw new SessionMoveError("session-move/zstd-unavailable", "当前 Node.js 运行时不支持 Zstd 会话迁移。");
|
|
1104
|
+
const compress = (data) => new Promise((resolvePromise, rejectPromise) => {
|
|
1105
|
+
zstdCompress(data, (error, output) => {
|
|
1106
|
+
if (error === null) resolvePromise(output);
|
|
1107
|
+
else rejectPromise(error);
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
const headerFrame = await compress(Buffer.from(`${headerLine}\n`, "utf8"));
|
|
1111
|
+
if (body === "") return headerFrame;
|
|
1112
|
+
const bodyFrame = await compress(Buffer.from(body, "utf8"));
|
|
1113
|
+
return Buffer.concat([headerFrame, bodyFrame]);
|
|
1114
|
+
}
|
|
1115
|
+
var ArtifactDirectoryMove = class {
|
|
1116
|
+
oldArtifact;
|
|
1117
|
+
newArtifact;
|
|
1118
|
+
directoryMoved = false;
|
|
1119
|
+
backupCreated = false;
|
|
1120
|
+
published = false;
|
|
1121
|
+
temporaryPath;
|
|
1122
|
+
oldDirectory;
|
|
1123
|
+
newDirectory;
|
|
1124
|
+
movedOldArtifact;
|
|
1125
|
+
backupArtifact;
|
|
1126
|
+
constructor(oldArtifact, newArtifact) {
|
|
1127
|
+
this.oldArtifact = oldArtifact;
|
|
1128
|
+
this.newArtifact = newArtifact;
|
|
1129
|
+
this.oldDirectory = dirname(oldArtifact);
|
|
1130
|
+
this.newDirectory = dirname(newArtifact);
|
|
1131
|
+
const relativeArtifact = relative(this.oldDirectory, oldArtifact);
|
|
1132
|
+
if (relativeArtifact.startsWith("..") || resolve(this.oldDirectory, relativeArtifact) !== resolve(oldArtifact)) throw new SessionMoveError("session-move/path-invalid", "会话工件路径无效。");
|
|
1133
|
+
this.movedOldArtifact = join(this.newDirectory, relativeArtifact);
|
|
1134
|
+
this.backupArtifact = join(this.newDirectory, `${basename(oldArtifact)}.${randomBytes(6).toString("hex")}.dcu-backup`);
|
|
1135
|
+
}
|
|
1136
|
+
async publish(bytes) {
|
|
1137
|
+
if (resolve(this.oldDirectory).toLowerCase() === resolve(this.newDirectory).toLowerCase()) throw new SessionMoveError("session-move/path-conflict", "源项目和目标项目使用了相同的会话目录。");
|
|
1138
|
+
if (await pathExists(this.newDirectory)) throw new SessionMoveError("session-move/destination-occupied", "目标项目已经存在同名会话工件。");
|
|
1139
|
+
try {
|
|
1140
|
+
await mkdir(dirname(this.newDirectory), { recursive: true });
|
|
1141
|
+
await rename(this.oldDirectory, this.newDirectory);
|
|
1142
|
+
this.directoryMoved = true;
|
|
1143
|
+
await rename(this.movedOldArtifact, this.backupArtifact);
|
|
1144
|
+
this.backupCreated = true;
|
|
1145
|
+
this.temporaryPath = await writeTemporaryFile(this.newArtifact, bytes);
|
|
1146
|
+
await rename(this.temporaryPath, this.newArtifact);
|
|
1147
|
+
this.temporaryPath = void 0;
|
|
1148
|
+
this.published = true;
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
try {
|
|
1151
|
+
await this.rollback();
|
|
1152
|
+
} catch (rollbackError) {
|
|
1153
|
+
throw new SessionMoveError("session-move/rollback-failed", "迁移会话工件失败,且自动回滚未完整完成。", { cause: rollbackError });
|
|
1154
|
+
}
|
|
1155
|
+
if (error instanceof SessionMoveError) throw error;
|
|
1156
|
+
throw new SessionMoveError("session-move/artifact-failed", "迁移会话工件失败,原会话已恢复。", { cause: error });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
async rollback() {
|
|
1160
|
+
const failures = [];
|
|
1161
|
+
if (this.temporaryPath !== void 0) {
|
|
1162
|
+
try {
|
|
1163
|
+
await rm(this.temporaryPath, { force: true });
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
failures.push(error);
|
|
1166
|
+
}
|
|
1167
|
+
this.temporaryPath = void 0;
|
|
1168
|
+
}
|
|
1169
|
+
if (this.published) try {
|
|
1170
|
+
await rm(this.newArtifact, { force: true });
|
|
1171
|
+
this.published = false;
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
failures.push(error);
|
|
1174
|
+
}
|
|
1175
|
+
if (this.backupCreated) try {
|
|
1176
|
+
await rename(this.backupArtifact, this.movedOldArtifact);
|
|
1177
|
+
this.backupCreated = false;
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
failures.push(error);
|
|
1180
|
+
}
|
|
1181
|
+
if (this.directoryMoved) try {
|
|
1182
|
+
await mkdir(dirname(this.oldDirectory), { recursive: true });
|
|
1183
|
+
await rename(this.newDirectory, this.oldDirectory);
|
|
1184
|
+
this.directoryMoved = false;
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
failures.push(error);
|
|
1187
|
+
}
|
|
1188
|
+
if (failures.length > 0) throw new AggregateError(failures, "会话工件回滚失败");
|
|
1189
|
+
}
|
|
1190
|
+
async commit(logger) {
|
|
1191
|
+
if (!this.backupCreated) return;
|
|
1192
|
+
try {
|
|
1193
|
+
await rm(this.backupArtifact, { force: true });
|
|
1194
|
+
this.backupCreated = false;
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
logger?.warn(`会话迁移成功,但旧工件备份清理失败:${String(error)}`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
function workspaceSnapshots(workspaces, sessionId) {
|
|
1201
|
+
return workspaces.map((workspace) => {
|
|
1202
|
+
const ids = [...rawSessionIds(workspace)];
|
|
1203
|
+
const index = ids.indexOf(sessionId);
|
|
1204
|
+
return {
|
|
1205
|
+
workspace,
|
|
1206
|
+
contained: index >= 0,
|
|
1207
|
+
beforeId: index >= 0 ? ids[index + 1] : void 0
|
|
1208
|
+
};
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
async function restoreWorkspaceSnapshots(snapshots, sessionId) {
|
|
1212
|
+
for (const snapshot of snapshots) if (!snapshot.contained && rawSessionIds(snapshot.workspace).includes(sessionId)) await snapshot.workspace.detachSession(sessionId);
|
|
1213
|
+
for (const snapshot of snapshots) {
|
|
1214
|
+
if (!snapshot.contained) continue;
|
|
1215
|
+
if (!rawSessionIds(snapshot.workspace).includes(sessionId)) await snapshot.workspace.attachSession(sessionId);
|
|
1216
|
+
const currentIds = rawSessionIds(snapshot.workspace);
|
|
1217
|
+
const beforeId = snapshot.beforeId !== void 0 && currentIds.includes(snapshot.beforeId) ? snapshot.beforeId : void 0;
|
|
1218
|
+
await snapshot.workspace.insertSessionBefore(sessionId, beforeId);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
async function enterStoredSession(services, sessionId, stored) {
|
|
1222
|
+
const preparation = services.sessionPersistence.prepare === void 0 ? void 0 : await services.sessionPersistence.prepare(sessionId);
|
|
1223
|
+
const session = preparation?.session ?? services.sessions.prepare(sessionId, {
|
|
1224
|
+
seedSource: "persistence",
|
|
1225
|
+
seed: stored.events,
|
|
1226
|
+
meta: stored.meta,
|
|
1227
|
+
inheritedEventCount: stored.inheritedEventCount
|
|
1228
|
+
});
|
|
1229
|
+
const detach = services.sessions.enter(session);
|
|
1230
|
+
try {
|
|
1231
|
+
services.sessions.announce?.(session);
|
|
1232
|
+
} catch (error) {
|
|
1233
|
+
detach();
|
|
1234
|
+
preparation?.[Symbol.dispose]();
|
|
1235
|
+
throw error;
|
|
1236
|
+
}
|
|
1237
|
+
return {
|
|
1238
|
+
detach,
|
|
1239
|
+
releasePreparation: () => {
|
|
1240
|
+
preparation?.[Symbol.dispose]();
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
function detachOriginalEntry(store, sessionId, entry) {
|
|
1245
|
+
if (store === void 0 || entry === void 0 || store.get(sessionId) !== entry) return;
|
|
1246
|
+
if (entry.detach === void 0) throw new SessionMoveError("session-move/service-unavailable", "宿主无法安全释放原会话入口。");
|
|
1247
|
+
entry.detach();
|
|
1248
|
+
if (store.get(sessionId) === entry) throw new SessionMoveError("session-move/quiesce-failed", "原会话入口未能完整释放。");
|
|
1249
|
+
}
|
|
1250
|
+
async function rollbackMove(services, sessionId, transaction, snapshots, originalStored, originalEntry, enteredPlaceholder) {
|
|
1251
|
+
const failures = [];
|
|
1252
|
+
try {
|
|
1253
|
+
enteredPlaceholder?.detach();
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
failures.push(error);
|
|
1256
|
+
}
|
|
1257
|
+
try {
|
|
1258
|
+
enteredPlaceholder?.releasePreparation();
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
failures.push(error);
|
|
1261
|
+
}
|
|
1262
|
+
try {
|
|
1263
|
+
await transaction.rollback();
|
|
1264
|
+
} catch (error) {
|
|
1265
|
+
failures.push(error);
|
|
1266
|
+
}
|
|
1267
|
+
let restored;
|
|
1268
|
+
try {
|
|
1269
|
+
if (services.sessions.get(sessionId) === void 0) restored = await enterStoredSession(services, sessionId, originalStored);
|
|
1270
|
+
await restoreWorkspaceSnapshots(snapshots, sessionId);
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
failures.push(error);
|
|
1273
|
+
} finally {
|
|
1274
|
+
try {
|
|
1275
|
+
if (originalEntry === void 0) restored?.detach();
|
|
1276
|
+
restored?.releasePreparation();
|
|
1277
|
+
} catch (error) {
|
|
1278
|
+
failures.push(error);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
if (failures.length > 0) throw new AggregateError(failures, "会话迁移回滚失败");
|
|
1282
|
+
}
|
|
1283
|
+
async function moveAccounting(target, snapshots, sessionId) {
|
|
1284
|
+
for (const snapshot of snapshots) if (snapshot.workspace.id !== target.id && snapshot.contained) await snapshot.workspace.detachSession(sessionId);
|
|
1285
|
+
await target.attachSession(sessionId);
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* 把持久化会话完整迁移到目标项目。整个会话目录一起移动,任何提交前失败都会恢复原工件和项目顺序。
|
|
1289
|
+
*/
|
|
1290
|
+
async function moveSessionToWorkspace(services, sessionId, targetWorkspaceId, options = {}) {
|
|
1291
|
+
if (!validIdentifier(sessionId) || !validIdentifier(targetWorkspaceId)) throw new SessionMoveError("session-move/invalid-request", "会话或目标项目标识无效。");
|
|
1292
|
+
if (movingSessions.has(sessionId)) throw new SessionMoveError("session-move/busy", "该会话正在移动,请稍后重试。");
|
|
1293
|
+
movingSessions.add(sessionId);
|
|
1294
|
+
try {
|
|
1295
|
+
const workspaces = services.workspaceRegistry.list();
|
|
1296
|
+
const target = workspaces.find((workspace) => workspace.id === targetWorkspaceId);
|
|
1297
|
+
if (target === void 0) throw new SessionMoveError("session-move/workspace-not-found", "目标项目不存在。");
|
|
1298
|
+
const snapshots = workspaceSnapshots(workspaces, sessionId);
|
|
1299
|
+
const targetSnapshot = snapshots.find((snapshot) => snapshot.workspace.id === target.id);
|
|
1300
|
+
const sources = snapshots.filter((snapshot) => snapshot.contained && snapshot.workspace.id !== target.id);
|
|
1301
|
+
if (sources.length > 1 || targetSnapshot?.contained === true && sources.length > 0) throw new SessionMoveError("session-move/accounting-invalid", "会话当前的项目归属不一致,无法安全移动。");
|
|
1302
|
+
const persistedHeader = (await services.sessionPersistence.list()).find((header) => header.id === sessionId);
|
|
1303
|
+
if (persistedHeader === void 0) throw new SessionMoveError("session-move/session-not-found", "该会话没有可迁移的持久化记录。");
|
|
1304
|
+
if (persistedHeader.origin === "subagent") throw new SessionMoveError("session-move/subagent-unsupported", "子代理会话不能移动到其他项目。");
|
|
1305
|
+
const targetPath = await realpath(target.path);
|
|
1306
|
+
let currentPath;
|
|
1307
|
+
if (persistedHeader.cwd !== void 0) try {
|
|
1308
|
+
currentPath = await realpath(persistedHeader.cwd);
|
|
1309
|
+
} catch {
|
|
1310
|
+
currentPath = void 0;
|
|
1311
|
+
}
|
|
1312
|
+
if (currentPath === targetPath && targetSnapshot?.contained === true && sources.length === 0) return {
|
|
1313
|
+
sessionId,
|
|
1314
|
+
moved: false,
|
|
1315
|
+
fromWorkspaceIds: [target.id],
|
|
1316
|
+
toWorkspaceId: target.id,
|
|
1317
|
+
toWorkspaceTitle: target.title || target.id
|
|
1318
|
+
};
|
|
1319
|
+
const liveSession = services.sessions.get(sessionId);
|
|
1320
|
+
const originalEntry = services.sessions.store?.get(sessionId);
|
|
1321
|
+
if (liveSession !== void 0 && (services.sessions.store === void 0 || originalEntry === void 0)) throw new SessionMoveError("session-move/service-unavailable", "宿主无法提供可恢复的会话入口,暂时不能移动活跃会话。");
|
|
1322
|
+
if (currentPath === targetPath) {
|
|
1323
|
+
const stored = liveSession === void 0 ? await services.sessionPersistence.loadStored(sessionId) : void 0;
|
|
1324
|
+
if (liveSession === void 0 && stored === void 0) throw new SessionMoveError("session-move/session-not-found", "读取会话持久化记录失败。");
|
|
1325
|
+
let enteredPlaceholder;
|
|
1326
|
+
try {
|
|
1327
|
+
if (stored !== void 0) enteredPlaceholder = await enterStoredSession(services, sessionId, stored);
|
|
1328
|
+
await moveAccounting(target, snapshots, sessionId);
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
try {
|
|
1331
|
+
await restoreWorkspaceSnapshots(snapshots, sessionId);
|
|
1332
|
+
} catch (rollbackError) {
|
|
1333
|
+
throw new SessionMoveError("session-move/rollback-failed", "恢复项目归属失败。", { cause: rollbackError });
|
|
1334
|
+
}
|
|
1335
|
+
throw new SessionMoveError("session-move/accounting-failed", "更新项目归属失败,原会话已恢复。", { cause: error });
|
|
1336
|
+
} finally {
|
|
1337
|
+
enteredPlaceholder?.detach();
|
|
1338
|
+
enteredPlaceholder?.releasePreparation();
|
|
1339
|
+
}
|
|
1340
|
+
return {
|
|
1341
|
+
sessionId,
|
|
1342
|
+
moved: true,
|
|
1343
|
+
fromWorkspaceIds: sources.map((snapshot) => snapshot.workspace.id),
|
|
1344
|
+
toWorkspaceId: target.id,
|
|
1345
|
+
toWorkspaceTitle: target.title || target.id
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
const agent = services.agents.get(sessionId);
|
|
1349
|
+
try {
|
|
1350
|
+
if (agent !== void 0) {
|
|
1351
|
+
agent.cancel({ kind: "disposed" });
|
|
1352
|
+
await agent.whenIdle?.();
|
|
1353
|
+
}
|
|
1354
|
+
if (liveSession !== void 0) await services.sessions.flush(liveSession);
|
|
1355
|
+
} catch (error) {
|
|
1356
|
+
throw new SessionMoveError("session-move/quiesce-failed", "会话仍在运行,暂时无法移动。", { cause: error });
|
|
1357
|
+
}
|
|
1358
|
+
const raw = await services.sessionPersistence.readRaw(sessionId);
|
|
1359
|
+
const originalStored = await services.sessionPersistence.loadStored(sessionId);
|
|
1360
|
+
if (raw === void 0 || originalStored === void 0) throw new SessionMoveError("session-move/session-not-found", "读取会话持久化记录失败。");
|
|
1361
|
+
const newlineIndex = raw.content.indexOf("\n");
|
|
1362
|
+
const headerText = newlineIndex < 0 ? raw.content : raw.content.slice(0, newlineIndex);
|
|
1363
|
+
const body = newlineIndex < 0 ? "" : raw.content.slice(newlineIndex + 1);
|
|
1364
|
+
let header;
|
|
1365
|
+
try {
|
|
1366
|
+
header = JSON.parse(headerText);
|
|
1367
|
+
} catch (error) {
|
|
1368
|
+
throw new SessionMoveError("session-move/artifact-invalid", "会话工件头部无法解析。", { cause: error });
|
|
1369
|
+
}
|
|
1370
|
+
if (header.id !== sessionId) throw new SessionMoveError("session-move/artifact-invalid", "会话工件标识与请求不一致。");
|
|
1371
|
+
const targetHeader = {
|
|
1372
|
+
...header,
|
|
1373
|
+
cwd: targetPath
|
|
1374
|
+
};
|
|
1375
|
+
const targetMeta = {
|
|
1376
|
+
...raw.meta,
|
|
1377
|
+
cwd: targetPath
|
|
1378
|
+
};
|
|
1379
|
+
const oldLocation = services.sessionPersistence.locate(raw.meta);
|
|
1380
|
+
const newLocation = services.sessionPersistence.locate(targetMeta);
|
|
1381
|
+
if (oldLocation === void 0 || newLocation === void 0) throw new SessionMoveError("session-move/path-invalid", "宿主无法定位会话工件。");
|
|
1382
|
+
const bytes = await (options.encodeArtifact ?? encodeSessionArtifact)(JSON.stringify(targetHeader), body, newLocation.path.endsWith(".zstd"));
|
|
1383
|
+
const transaction = new ArtifactDirectoryMove(oldLocation.path, newLocation.path);
|
|
1384
|
+
try {
|
|
1385
|
+
await agent?.scope?.dispose?.();
|
|
1386
|
+
services.agents.store?.delete(sessionId);
|
|
1387
|
+
detachOriginalEntry(services.sessions.store, sessionId, originalEntry);
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
if (originalEntry !== void 0 && services.sessions.get(sessionId) === void 0) try {
|
|
1390
|
+
(await enterStoredSession(services, sessionId, originalStored)).releasePreparation();
|
|
1391
|
+
} catch (restoreError) {
|
|
1392
|
+
throw new SessionMoveError("session-move/rollback-failed", "恢复原会话入口失败。", { cause: restoreError });
|
|
1393
|
+
}
|
|
1394
|
+
throw new SessionMoveError("session-move/quiesce-failed", "会话仍在运行,暂时无法移动。", { cause: error });
|
|
1395
|
+
}
|
|
1396
|
+
let enteredPlaceholder;
|
|
1397
|
+
try {
|
|
1398
|
+
await transaction.publish(bytes);
|
|
1399
|
+
const movedStored = await services.sessionPersistence.loadStored(sessionId);
|
|
1400
|
+
if (movedStored === void 0 || movedStored.meta.cwd !== targetPath) throw new SessionMoveError("session-move/validation-failed", "迁移后的会话工件校验失败。");
|
|
1401
|
+
enteredPlaceholder = await enterStoredSession(services, sessionId, movedStored);
|
|
1402
|
+
try {
|
|
1403
|
+
await moveAccounting(target, snapshots, sessionId);
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
throw new SessionMoveError("session-move/accounting-failed", "更新项目归属失败。", { cause: error });
|
|
1406
|
+
}
|
|
1407
|
+
enteredPlaceholder.detach();
|
|
1408
|
+
enteredPlaceholder.releasePreparation();
|
|
1409
|
+
enteredPlaceholder = void 0;
|
|
1410
|
+
await transaction.commit(services.logger);
|
|
1411
|
+
} catch (error) {
|
|
1412
|
+
try {
|
|
1413
|
+
await rollbackMove(services, sessionId, transaction, snapshots, originalStored, originalEntry, enteredPlaceholder);
|
|
1414
|
+
} catch (rollbackError) {
|
|
1415
|
+
throw new SessionMoveError("session-move/rollback-failed", "会话移动失败,且自动回滚未完整完成。", { cause: rollbackError });
|
|
1416
|
+
}
|
|
1417
|
+
if (error instanceof SessionMoveError) throw error;
|
|
1418
|
+
throw new SessionMoveError("session-move/failed", "会话移动失败,原会话已恢复。", { cause: error });
|
|
1419
|
+
}
|
|
1420
|
+
try {
|
|
1421
|
+
await services.sessionProjectionCache?.coldSnapshot?.(sessionId);
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
services.logger?.warn(`会话已移动,但投影缓存刷新失败:${String(error)}`);
|
|
1424
|
+
}
|
|
1425
|
+
services.logger?.info?.(`会话 ${sessionId} 已移动到项目 ${target.id}`);
|
|
1426
|
+
return {
|
|
1427
|
+
sessionId,
|
|
1428
|
+
moved: true,
|
|
1429
|
+
fromWorkspaceIds: sources.map((snapshot) => snapshot.workspace.id),
|
|
1430
|
+
toWorkspaceId: target.id,
|
|
1431
|
+
toWorkspaceTitle: target.title || target.id
|
|
1432
|
+
};
|
|
1433
|
+
} finally {
|
|
1434
|
+
movingSessions.delete(sessionId);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
/** 对持久化分组做严格校验,避免一个项目同时出现在多个分组。 */
|
|
1438
|
+
function parseWorkspaceGroups(value) {
|
|
1439
|
+
if (!Array.isArray(value) || value.length > 100) return void 0;
|
|
1440
|
+
const groupIds = /* @__PURE__ */ new Set();
|
|
1441
|
+
const groupTitles = /* @__PURE__ */ new Set();
|
|
1442
|
+
const workspaceIds = /* @__PURE__ */ new Set();
|
|
1443
|
+
const groups = [];
|
|
1444
|
+
for (const valueGroup of value) {
|
|
1445
|
+
if (valueGroup === null || typeof valueGroup !== "object") return void 0;
|
|
1446
|
+
const group = valueGroup;
|
|
1447
|
+
const id = typeof group.id === "string" ? group.id.trim() : "";
|
|
1448
|
+
const title = typeof group.title === "string" ? group.title.trim() : "";
|
|
1449
|
+
if (id === "" || id.length > 128 || title === "" || title.length > 80) return void 0;
|
|
1450
|
+
if (groupIds.has(id) || groupTitles.has(title.toLocaleLowerCase())) return void 0;
|
|
1451
|
+
if (!Array.isArray(group.workspaceIds) || group.workspaceIds.length > 1e3) return void 0;
|
|
1452
|
+
const normalizedIds = [...new Set(group.workspaceIds)];
|
|
1453
|
+
if (!normalizedIds.every((workspaceId) => typeof workspaceId === "string" && workspaceId.trim() !== "" && workspaceId.length <= 256)) return void 0;
|
|
1454
|
+
if (normalizedIds.some((workspaceId) => workspaceIds.has(workspaceId))) return void 0;
|
|
1455
|
+
groupIds.add(id);
|
|
1456
|
+
groupTitles.add(title.toLocaleLowerCase());
|
|
1457
|
+
normalizedIds.forEach((workspaceId) => workspaceIds.add(workspaceId));
|
|
1458
|
+
groups.push({
|
|
1459
|
+
id,
|
|
1460
|
+
title,
|
|
1461
|
+
workspaceIds: normalizedIds
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
return groups;
|
|
1465
|
+
}
|
|
1466
|
+
//#endregion
|
|
1049
1467
|
//#region src/workspace-preferences.ts
|
|
1050
1468
|
const WORKSPACE_PREFERENCES_FILE = ".dsh-codex-ui-preferences.json";
|
|
1051
1469
|
/** Desktop 和普通 DSH Web 共用 Profile;服务换端口或重启后目录仍保持稳定。 */
|
|
@@ -1061,11 +1479,19 @@ function parsePinnedWorkspaceIds(value) {
|
|
|
1061
1479
|
function parseWorkspacePreferences(value) {
|
|
1062
1480
|
if (value === null || typeof value !== "object") return void 0;
|
|
1063
1481
|
const record = value;
|
|
1064
|
-
if (record.version !== 1) return void 0;
|
|
1065
1482
|
const pinnedWorkspaceIds = parsePinnedWorkspaceIds(record.pinnedWorkspaceIds);
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1483
|
+
if (pinnedWorkspaceIds === void 0) return void 0;
|
|
1484
|
+
if (record.version === 1) return {
|
|
1485
|
+
version: 2,
|
|
1486
|
+
pinnedWorkspaceIds,
|
|
1487
|
+
workspaceGroups: []
|
|
1488
|
+
};
|
|
1489
|
+
if (record.version !== 2) return void 0;
|
|
1490
|
+
const workspaceGroups = parseWorkspaceGroups(record.workspaceGroups);
|
|
1491
|
+
return workspaceGroups === void 0 ? void 0 : {
|
|
1492
|
+
version: 2,
|
|
1493
|
+
pinnedWorkspaceIds,
|
|
1494
|
+
workspaceGroups
|
|
1069
1495
|
};
|
|
1070
1496
|
}
|
|
1071
1497
|
async function readWorkspacePreferences(path = workspacePreferencesPath()) {
|
|
@@ -1078,8 +1504,9 @@ async function readWorkspacePreferences(path = workspacePreferencesPath()) {
|
|
|
1078
1504
|
};
|
|
1079
1505
|
} catch (error) {
|
|
1080
1506
|
if (error.code === "ENOENT") return {
|
|
1081
|
-
version:
|
|
1507
|
+
version: 2,
|
|
1082
1508
|
pinnedWorkspaceIds: [],
|
|
1509
|
+
workspaceGroups: [],
|
|
1083
1510
|
exists: false
|
|
1084
1511
|
};
|
|
1085
1512
|
throw error;
|
|
@@ -1090,16 +1517,18 @@ function temporaryPath(path) {
|
|
|
1090
1517
|
}
|
|
1091
1518
|
let writeQueue = Promise.resolve();
|
|
1092
1519
|
/** 串行、原子保存,避免快速拖动排序产生乱序或半截 JSON。 */
|
|
1093
|
-
function writeWorkspacePreferences(pinnedWorkspaceIds, path = workspacePreferencesPath()) {
|
|
1520
|
+
function writeWorkspacePreferences(pinnedWorkspaceIds, workspaceGroups = [], path = workspacePreferencesPath()) {
|
|
1094
1521
|
const normalized = parsePinnedWorkspaceIds([...pinnedWorkspaceIds]);
|
|
1095
|
-
|
|
1522
|
+
const normalizedGroups = parseWorkspaceGroups([...workspaceGroups]);
|
|
1523
|
+
if (normalized === void 0 || normalizedGroups === void 0) return Promise.reject(/* @__PURE__ */ new Error("工作区偏好数据无效。"));
|
|
1096
1524
|
const task = writeQueue.catch(() => void 0).then(async () => {
|
|
1097
1525
|
await mkdir(dirname(path), { recursive: true });
|
|
1098
1526
|
const temporary = temporaryPath(path);
|
|
1099
1527
|
try {
|
|
1100
1528
|
await writeFile(temporary, `${JSON.stringify({
|
|
1101
|
-
version:
|
|
1102
|
-
pinnedWorkspaceIds: normalized
|
|
1529
|
+
version: 2,
|
|
1530
|
+
pinnedWorkspaceIds: normalized,
|
|
1531
|
+
workspaceGroups: normalizedGroups
|
|
1103
1532
|
}, void 0, 2)}\n`, "utf8");
|
|
1104
1533
|
await rename(temporary, path);
|
|
1105
1534
|
} finally {
|
|
@@ -1115,6 +1544,7 @@ const connectorsEndpoint = "/api/michengai/codex-ui/connectors";
|
|
|
1115
1544
|
const dependenciesEndpoint = "/api/michengai/codex-ui/dependencies";
|
|
1116
1545
|
const explorerEndpoint = "/api/michengai/codex-ui/open-in-explorer";
|
|
1117
1546
|
const preferencesEndpoint = "/api/michengai/codex-ui/preferences";
|
|
1547
|
+
const sessionMoveEndpoint = "/api/michengai/codex-ui/session-move";
|
|
1118
1548
|
const maxPreferencesBodyBytes = 32768;
|
|
1119
1549
|
function headerValue(headers, name) {
|
|
1120
1550
|
const value = headers[name];
|
|
@@ -1169,8 +1599,37 @@ const inject = [
|
|
|
1169
1599
|
"webServer",
|
|
1170
1600
|
"agents",
|
|
1171
1601
|
"tools",
|
|
1172
|
-
"workspaceRegistry"
|
|
1602
|
+
"workspaceRegistry",
|
|
1603
|
+
"sessions",
|
|
1604
|
+
"sessionPersistence"
|
|
1173
1605
|
];
|
|
1606
|
+
function sessionMoveStatus(error) {
|
|
1607
|
+
if (!(error instanceof SessionMoveError)) return 500;
|
|
1608
|
+
if (error.code === "session-move/invalid-request") return 400;
|
|
1609
|
+
if (error.code === "session-move/session-not-found" || error.code === "session-move/workspace-not-found") return 404;
|
|
1610
|
+
if (error.code === "session-move/service-unavailable") return 503;
|
|
1611
|
+
if (error.code === "session-move/zstd-unavailable") return 501;
|
|
1612
|
+
if (error.code === "session-move/busy" || error.code === "session-move/subagent-unsupported" || error.code === "session-move/accounting-invalid" || error.code === "session-move/destination-occupied") return 409;
|
|
1613
|
+
return 500;
|
|
1614
|
+
}
|
|
1615
|
+
function publicSessionMoveError(error) {
|
|
1616
|
+
const code = error instanceof SessionMoveError ? error.code : "session-move/failed";
|
|
1617
|
+
return {
|
|
1618
|
+
code,
|
|
1619
|
+
error: {
|
|
1620
|
+
"session-move/invalid-request": "会话或目标项目标识无效。",
|
|
1621
|
+
"session-move/session-not-found": "该会话没有可迁移的持久化记录。",
|
|
1622
|
+
"session-move/workspace-not-found": "目标项目不存在。",
|
|
1623
|
+
"session-move/service-unavailable": "宿主暂时无法安全移动活跃会话。",
|
|
1624
|
+
"session-move/subagent-unsupported": "子代理会话不能移动到其他项目。",
|
|
1625
|
+
"session-move/busy": "该会话正在移动,请稍后重试。",
|
|
1626
|
+
"session-move/accounting-invalid": "会话当前的项目归属不一致,无法安全移动。",
|
|
1627
|
+
"session-move/destination-occupied": "目标项目已经存在同名会话工件。",
|
|
1628
|
+
"session-move/zstd-unavailable": "当前运行环境不支持该会话的存储格式。",
|
|
1629
|
+
"session-move/rollback-failed": "移动失败,自动恢复未完整完成,请查看服务端日志。"
|
|
1630
|
+
}[code] ?? "暂时无法移动该会话,请稍后重试。"
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1174
1633
|
/** 提供不泄露地址、命令和凭证的连接器目录。 */
|
|
1175
1634
|
function apply(ctx) {
|
|
1176
1635
|
const host = hostServices(ctx);
|
|
@@ -1337,23 +1796,27 @@ function apply(ctx) {
|
|
|
1337
1796
|
return;
|
|
1338
1797
|
}
|
|
1339
1798
|
const body = JSON.parse(await readRequestBody(request));
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1799
|
+
const record = body !== null && typeof body === "object" ? body : void 0;
|
|
1800
|
+
const pinnedWorkspaceIds = record === void 0 ? void 0 : parsePinnedWorkspaceIds(record.pinnedWorkspaceIds);
|
|
1801
|
+
const existing = await readWorkspacePreferences();
|
|
1802
|
+
const workspaceGroups = record === void 0 ? void 0 : "workspaceGroups" in record ? parseWorkspaceGroups(record.workspaceGroups) : existing.workspaceGroups;
|
|
1803
|
+
if (pinnedWorkspaceIds === void 0 || workspaceGroups === void 0) {
|
|
1342
1804
|
response.writeHead(400, {
|
|
1343
1805
|
"content-type": "application/json; charset=utf-8",
|
|
1344
1806
|
"cache-control": "no-store"
|
|
1345
1807
|
});
|
|
1346
|
-
response.end(JSON.stringify({ error: "
|
|
1808
|
+
response.end(JSON.stringify({ error: "工作区偏好格式无效。" }));
|
|
1347
1809
|
return;
|
|
1348
1810
|
}
|
|
1349
|
-
await writeWorkspacePreferences(pinnedWorkspaceIds);
|
|
1811
|
+
await writeWorkspacePreferences(pinnedWorkspaceIds, workspaceGroups);
|
|
1350
1812
|
response.writeHead(200, {
|
|
1351
1813
|
"content-type": "application/json; charset=utf-8",
|
|
1352
1814
|
"cache-control": "no-store"
|
|
1353
1815
|
});
|
|
1354
1816
|
response.end(JSON.stringify({
|
|
1355
|
-
version:
|
|
1817
|
+
version: 2,
|
|
1356
1818
|
pinnedWorkspaceIds,
|
|
1819
|
+
workspaceGroups,
|
|
1357
1820
|
exists: true
|
|
1358
1821
|
}));
|
|
1359
1822
|
return;
|
|
@@ -1386,6 +1849,79 @@ function apply(ctx) {
|
|
|
1386
1849
|
}
|
|
1387
1850
|
}
|
|
1388
1851
|
});
|
|
1852
|
+
const disposeSessionMove = host.webServer.register({
|
|
1853
|
+
kind: "exact",
|
|
1854
|
+
path: sessionMoveEndpoint,
|
|
1855
|
+
handler: async (request, response) => {
|
|
1856
|
+
if (request.method !== "POST") {
|
|
1857
|
+
response.writeHead(405, { allow: "POST" });
|
|
1858
|
+
response.end();
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
if (crossSiteRequest(request)) {
|
|
1862
|
+
response.writeHead(403, {
|
|
1863
|
+
"content-type": "application/json; charset=utf-8",
|
|
1864
|
+
"cache-control": "no-store"
|
|
1865
|
+
});
|
|
1866
|
+
response.end(JSON.stringify({
|
|
1867
|
+
ok: false,
|
|
1868
|
+
code: "session-move/cross-site",
|
|
1869
|
+
error: "已拒绝跨站请求。"
|
|
1870
|
+
}));
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
try {
|
|
1874
|
+
const body = JSON.parse(await readRequestBody(request));
|
|
1875
|
+
const record = body !== null && typeof body === "object" ? body : void 0;
|
|
1876
|
+
const sessionId = typeof record?.sessionId === "string" ? record.sessionId.trim() : "";
|
|
1877
|
+
const targetWorkspaceId = typeof record?.targetWorkspaceId === "string" ? record.targetWorkspaceId.trim() : "";
|
|
1878
|
+
const result = await moveSessionToWorkspace(host, sessionId, targetWorkspaceId);
|
|
1879
|
+
response.writeHead(200, {
|
|
1880
|
+
"content-type": "application/json; charset=utf-8",
|
|
1881
|
+
"cache-control": "no-store"
|
|
1882
|
+
});
|
|
1883
|
+
response.end(JSON.stringify({
|
|
1884
|
+
ok: true,
|
|
1885
|
+
result
|
|
1886
|
+
}));
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
if (error instanceof RequestBodyTooLargeError) {
|
|
1889
|
+
response.writeHead(413, {
|
|
1890
|
+
"content-type": "application/json; charset=utf-8",
|
|
1891
|
+
"cache-control": "no-store"
|
|
1892
|
+
});
|
|
1893
|
+
response.end(JSON.stringify({
|
|
1894
|
+
ok: false,
|
|
1895
|
+
code: "session-move/invalid-request",
|
|
1896
|
+
error: "请求体过大。"
|
|
1897
|
+
}));
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
if (error instanceof SyntaxError) {
|
|
1901
|
+
response.writeHead(400, {
|
|
1902
|
+
"content-type": "application/json; charset=utf-8",
|
|
1903
|
+
"cache-control": "no-store"
|
|
1904
|
+
});
|
|
1905
|
+
response.end(JSON.stringify({
|
|
1906
|
+
ok: false,
|
|
1907
|
+
code: "session-move/invalid-request",
|
|
1908
|
+
error: "请求格式无效。"
|
|
1909
|
+
}));
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
ctx.logger.warn("session move failed: %s", error);
|
|
1913
|
+
const payload = publicSessionMoveError(error);
|
|
1914
|
+
response.writeHead(sessionMoveStatus(error), {
|
|
1915
|
+
"content-type": "application/json; charset=utf-8",
|
|
1916
|
+
"cache-control": "no-store"
|
|
1917
|
+
});
|
|
1918
|
+
response.end(JSON.stringify({
|
|
1919
|
+
ok: false,
|
|
1920
|
+
...payload
|
|
1921
|
+
}));
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1389
1925
|
const disposeExplorer = host.webServer.register({
|
|
1390
1926
|
kind: "exact",
|
|
1391
1927
|
path: explorerEndpoint,
|
|
@@ -1456,6 +1992,7 @@ function apply(ctx) {
|
|
|
1456
1992
|
disposeDependencies();
|
|
1457
1993
|
disposeExplorer();
|
|
1458
1994
|
disposePreferences();
|
|
1995
|
+
disposeSessionMove();
|
|
1459
1996
|
};
|
|
1460
1997
|
}, "michengai-codex-ui: catalogs");
|
|
1461
1998
|
}
|