dsh-session-manager 0.7.2 → 0.7.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/lib/index.js CHANGED
@@ -22,12 +22,13 @@
22
22
  * The compatibility adapter normalizes service access and list snapshots.
23
23
  * Optional live-writer/coordinator internals are guarded before mutation.
24
24
  */
25
+ import { existsSync, readFileSync, writeFileSync, rmSync, renameSync } from "node:fs";
25
26
  import { lstat, mkdir, readdir, realpath, rename, rm } from "node:fs/promises";
26
27
  import { basename, dirname, join } from "node:path";
27
28
  import { randomBytes } from "node:crypto";
28
29
  import { createAnnotationStore } from "./annotation-store.js";
29
30
  import { createWorkspaceArchiveStore, assertWorkspaceId } from "./workspace-archive-store.js";
30
- import { createDshAdapter, normalizeSessionHeader, compareSemver } from "./compat/dsh-adapter.js";
31
+ import { createDshAdapter, normalizeSessionHeader, compareSemver, detectPluginSource, isHmrActive, resolveProfileDir } from "./compat/dsh-adapter.js";
31
32
  import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, candidateSessionDirs, encodeFsSessionSegment, decodeFsSessionSegment, encodeProjectSegment, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
32
33
 
33
34
  export const name = "dsh-session-manager";
@@ -100,6 +101,12 @@ function rebindLiveWriter(persistence, sessionId, newHeader) {
100
101
  * disk fallback semantics are preserved -- the orphan is the whole
101
102
  * reason this loop exists.
102
103
  *
104
+ * The result carries two completeness flags: "persisted" reports whether the
105
+ * persistence index itself listed successfully (its rows are authoritative),
106
+ * while "complete" additionally requires the disk sweep to finish without
107
+ * errors. Callers that only add validated memberships may run on "persisted";
108
+ * callers that remove records must wait for "complete".
109
+ *
103
110
  * @param {object|undefined} persistence DSH's sessionPersistence service
104
111
  * @param {((segment: string) => string)|undefined} dshHome Resolver for
105
112
  * DSH home sub-paths; only "sessions" is read.
@@ -109,6 +116,7 @@ const scanSessionHeaders = async (persistence, dshHome) => {
109
116
  const headers = [];
110
117
  const errors = [];
111
118
  let listed = false;
119
+ let persisted = false;
112
120
  if (typeof persistence?.list === "function") {
113
121
  try {
114
122
  const rows = await persistence.list();
@@ -121,9 +129,10 @@ const scanSessionHeaders = async (persistence, dshHome) => {
121
129
  if (!seen.has(header.id)) { seen.add(header.id); headers.push(header); }
122
130
  }
123
131
  listed = true;
132
+ persisted = true;
124
133
  } catch (error) { errors.push(error); }
125
134
  }
126
- const result = () => ({ headers, complete: listed && errors.length === 0, errors });
135
+ const result = () => ({ headers, persisted, complete: listed && errors.length === 0, errors });
127
136
  // Skip known files before fopen; retain the orphan/raw-id compatibility path.
128
137
  // Only add canonical session directories that do not start and end with "--"
129
138
  // to avoid colliding with legacy projectKey candidates (e.g. "--s1--").
@@ -174,9 +183,209 @@ const scanSessionHeaders = async (persistence, dshHome) => {
174
183
 
175
184
  const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHeaders(persistence, dshHome)).headers;
176
185
 
186
+ /** Canonicalize a directory path the way DSH's workspace registry does
187
+ * (fs.realpath on a fully qualified path). Returns undefined when the path
188
+ * cannot be resolved so callers skip instead of guessing; DSH stores
189
+ * workspace paths canonicalized, so canonical equality is the only safe
190
+ * membership comparison on Windows (case, separators, 8.3 names, junctions). */
191
+ const canonicalizeDirectory = async (path) => {
192
+ if (typeof path !== "string" || path === "") return undefined;
193
+ try { return await realpath(path); } catch { return undefined; }
194
+ };
195
+
196
+ const UPDATE_STATUS_FILE = ".dsh-session-manager-update.json";
197
+ const STALE_EXTERNAL_TASK_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes fallback for dead external processes
198
+ const HEARTBEAT_STALE_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes without heartbeat for external processes
199
+
200
+ const TRUSTED_REGISTRIES = new Set([
201
+ "https://registry.npmjs.org",
202
+ "https://registry.npmmirror.com",
203
+ "http://127.0.0.1:4873",
204
+ "http://localhost:4873"
205
+ ]);
206
+
207
+ function normalizeRegistryUrl(url) {
208
+ if (!url || typeof url !== "string") return "";
209
+ const trimmed = url.trim();
210
+ if (!trimmed) return "";
211
+ return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
212
+ }
213
+
214
+ function resolveTrustedRegistry(requested, envRegistry) {
215
+ const normEnv = normalizeRegistryUrl(envRegistry);
216
+ const normReq = normalizeRegistryUrl(requested);
217
+
218
+ if (normReq) {
219
+ if (TRUSTED_REGISTRIES.has(normReq) || (normEnv && normReq === normEnv)) {
220
+ return normReq;
221
+ }
222
+ return null;
223
+ }
224
+
225
+ if (normEnv && (TRUSTED_REGISTRIES.has(normEnv) || normEnv.length > 0)) {
226
+ return normEnv;
227
+ }
228
+
229
+ return "https://registry.npmjs.org";
230
+ }
231
+
232
+ const ACTIVE_UPDATE_TASK_KEY = Symbol.for("dsh-session-manager.activeUpdateTask");
233
+
234
+ function getActiveTask() {
235
+ return globalThis[ACTIVE_UPDATE_TASK_KEY] || null;
236
+ }
237
+
238
+ function setActiveTask(task) {
239
+ if (task) {
240
+ globalThis[ACTIVE_UPDATE_TASK_KEY] = task;
241
+ } else {
242
+ delete globalThis[ACTIVE_UPDATE_TASK_KEY];
243
+ }
244
+ }
245
+
246
+ function releaseActiveTask(taskId) {
247
+ const cur = getActiveTask();
248
+ if (cur && (!taskId || cur.taskId === taskId)) {
249
+ setActiveTask(null);
250
+ }
251
+ }
252
+
253
+ function isPidAlive(pid) {
254
+ if (typeof pid !== "number" || pid <= 0) return false;
255
+ try {
256
+ process.kill(pid, 0);
257
+ return true;
258
+ } catch (err) {
259
+ return err.code === "EPERM";
260
+ }
261
+ }
262
+
263
+ function getUpdateStatusFilePath(profileDir) {
264
+ return profileDir ? join(profileDir, UPDATE_STATUS_FILE) : null;
265
+ }
266
+
267
+ function writeUpdateStatus(profileDir, statusObj) {
268
+ const filePath = getUpdateStatusFilePath(profileDir);
269
+ if (!filePath || !statusObj || typeof statusObj !== "object") return;
270
+ statusObj.updatedAt = Date.now();
271
+ try {
272
+ if (existsSync(filePath)) {
273
+ const existing = JSON.parse(readFileSync(filePath, "utf8"));
274
+ if (existing && existing.taskId && statusObj.taskId && existing.taskId !== statusObj.taskId) {
275
+ if (isDiskTaskActive(existing) && (existing.startedAt || 0) > (statusObj.startedAt || 0)) {
276
+ return;
277
+ }
278
+ }
279
+ }
280
+ } catch (_) {}
281
+ const tmpPath = filePath + ".tmp." + Date.now();
282
+ try {
283
+ writeFileSync(tmpPath, JSON.stringify(statusObj, null, 2), "utf8");
284
+ try {
285
+ renameSync(tmpPath, filePath);
286
+ } catch {
287
+ try { rmSync(filePath, { force: true }); } catch (_) {}
288
+ renameSync(tmpPath, filePath);
289
+ }
290
+ } catch (_) {
291
+ try { if (existsSync(tmpPath)) rmSync(tmpPath, { force: true }); } catch (_) {}
292
+ }
293
+ }
294
+
295
+ function readUpdateStatus(profileDir) {
296
+ const filePath = getUpdateStatusFilePath(profileDir);
297
+ if (!filePath) return null;
298
+ try {
299
+ if (existsSync(filePath)) {
300
+ return JSON.parse(readFileSync(filePath, "utf8"));
301
+ }
302
+ } catch (_) { /* best-effort */ }
303
+ return null;
304
+ }
305
+
306
+ const ACTIVE_TASK_STATUSES = new Set([
307
+ "preparing",
308
+ "removing",
309
+ "removed",
310
+ "installing",
311
+ "verifying",
312
+ "rolling-back"
313
+ ]);
314
+
315
+ function isDiskTaskActive(statusObj) {
316
+ if (!statusObj || typeof statusObj !== "object") return false;
317
+ if (!ACTIVE_TASK_STATUSES.has(statusObj.status)) return false;
318
+
319
+ // If status was recorded by the current process, consult in-memory task
320
+ if (statusObj.pid === process.pid) {
321
+ const inMem = getActiveTask();
322
+ if (!inMem || inMem.taskId !== statusObj.taskId) {
323
+ return false;
324
+ }
325
+ return true;
326
+ }
327
+
328
+ // If status was recorded by another process, check if PID is alive
329
+ if (typeof statusObj.pid === "number") {
330
+ if (!isPidAlive(statusObj.pid)) {
331
+ return false;
332
+ }
333
+ }
334
+
335
+ const lastActive = typeof statusObj.updatedAt === "number"
336
+ ? statusObj.updatedAt
337
+ : (typeof statusObj.startedAt === "number" ? statusObj.startedAt : 0);
338
+ if (Date.now() - lastActive > HEARTBEAT_STALE_TIMEOUT_MS) return false;
339
+ return true;
340
+ }
341
+
342
+ function isTaskActive(statusObj) {
343
+ return isDiskTaskActive(statusObj);
344
+ }
345
+
346
+ function verifyInstalledPackage(profileDir, expectedVersion) {
347
+ if (!profileDir) {
348
+ return { ok: false, error: "无法定位插件运行 profile 目录,无法进行安装校验" };
349
+ }
350
+ const pkgDir = join(profileDir, "node_modules", "dsh-session-manager");
351
+ const pkgFile = join(pkgDir, "package.json");
352
+ const entryIndex = join(pkgDir, "lib", "index.js");
353
+ const entryClient = join(pkgDir, "lib", "client.js");
354
+
355
+ if (!existsSync(pkgFile)) {
356
+ return { ok: false, error: "未找到安装目录或 package.json: " + pkgFile };
357
+ }
358
+ let pkg;
359
+ try {
360
+ pkg = JSON.parse(readFileSync(pkgFile, "utf8"));
361
+ } catch (err) {
362
+ return { ok: false, error: "package.json 解析失败: " + safeErrorMessage(err) };
363
+ }
364
+ if (!pkg || typeof pkg !== "object") {
365
+ return { ok: false, error: "package.json 内容无效" };
366
+ }
367
+ if (pkg.name !== "dsh-session-manager") {
368
+ return { ok: false, error: "安装包名称不符: 预期 dsh-session-manager,实际 " + pkg.name };
369
+ }
370
+ if (expectedVersion && expectedVersion !== "latest" && pkg.version !== expectedVersion) {
371
+ return {
372
+ ok: false,
373
+ version: pkg.version,
374
+ error: "安装版本不匹配: 预期 " + expectedVersion + ",实际安装 " + pkg.version
375
+ };
376
+ }
377
+ if (!existsSync(entryIndex)) {
378
+ return { ok: false, version: pkg.version, error: "缺少服务端入口文件: lib/index.js" };
379
+ }
380
+ if (!existsSync(entryClient)) {
381
+ return { ok: false, version: pkg.version, error: "缺少客户端入口文件: lib/client.js" };
382
+ }
383
+ return { ok: true, version: pkg.version };
384
+ }
385
+
177
386
  export function apply(ctx) {
178
387
  try {
179
- const buildId = "dsh-session-manager v0.7.2 (workspace-archive)";
388
+ const buildId = "dsh-session-manager v0.7.4 (workspace-archive)";
180
389
  if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
181
390
  else if (typeof console !== "undefined" && console?.info) console.info(buildId);
182
391
  } catch (_) { /* ignore logging failures */ }
@@ -208,10 +417,26 @@ export function apply(ctx) {
208
417
  const safeGetAgent = (sessionId) => {
209
418
  try { return ctx.agents?.get?.(sessionId); } catch { return undefined; }
210
419
  };
420
+ /** Durable workspace membership for one entity. The record account is the
421
+ * authoritative persisted list on DSH 0.2.x (registry entities expose it as
422
+ * the private `record` field); the runtime `sessionIds` getter is filtered
423
+ * by the canonical-cwd index and may hide members whose index entry is
424
+ * stale, so it is only a fallback for hosts without a readable record. */
425
+ const workspaceMemberIds = (entity) => {
426
+ if (entity === null || entity === undefined) return [];
427
+ const record = entity.record;
428
+ if (record !== null && record !== undefined && Array.isArray(record.sessionIds)) return record.sessionIds;
429
+ const ids = entity.sessionIds;
430
+ if (Array.isArray(ids)) return ids;
431
+ if (typeof ids === "function") {
432
+ try { const value = ids(); if (Array.isArray(value)) return value; } catch { /* ignore */ }
433
+ }
434
+ return [];
435
+ };
211
436
  const assertAnnotatable = id => {
212
437
  const registry = adapter.workspaceRegistry;
213
438
  if (adapter.getSession(id) !== undefined || registry?.headers?.has?.(id)
214
- || adapter.workspaceList().some(ws => (ws.record?.sessionIds ?? ws.sessionIds ?? []).includes(id))) return;
439
+ || adapter.workspaceList().some(ws => workspaceMemberIds(ws).includes(id))) return;
215
440
  throw Object.assign(new Error("会话不存在或尚未加载,请先打开该会话后重试"), { code: "session-not-found" });
216
441
  };
217
442
  const operations = new Map();
@@ -376,7 +601,9 @@ export function apply(ctx) {
376
601
  const detachFromWorkspaces = async (sessionId) => {
377
602
  const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
378
603
  for (const entity of registry.list()) {
379
- if (entity.sessionIds.includes(sessionId)) {
604
+ // Durable record, not the runtime-filtered getter: deleting a session
605
+ // must purge its account even when the cwd index is stale.
606
+ if (workspaceMemberIds(entity).includes(sessionId)) {
380
607
  await entity.detachSession(sessionId);
381
608
  }
382
609
  }
@@ -385,9 +612,7 @@ export function apply(ctx) {
385
612
  /** Ordered workspace projection for the client. */
386
613
  const listWorkspaces = () => {
387
614
  return ctx.workspaceRegistry.list().map((entity) => {
388
- const rawIds = Array.isArray(entity.record?.sessionIds)
389
- ? [...entity.record.sessionIds]
390
- : [...entity.sessionIds];
615
+ const rawIds = [...workspaceMemberIds(entity)];
391
616
  const selfUpdatedAt = entity.updatedAt ?? entity.record?.updatedAt ?? null;
392
617
  return {
393
618
  id: entity.id,
@@ -643,6 +868,182 @@ ${rest}`, "utf8");
643
868
  return false;
644
869
  };
645
870
 
871
+ /**
872
+ * Attach one session to the registered workspace that owns its cwd.
873
+ * Returns true only when a new durable membership was written.
874
+ *
875
+ * DSH's own create/fork flow attaches through workspaceRegistry, but a
876
+ * session created outside that flow (cwd-only create, an attach the host
877
+ * declined, or a session that predates this plugin load) stays
878
+ * unaccounted even though its cwd names a registered workspace. The
879
+ * official entity API re-validates the stored header cwd against the
880
+ * canonical workspace path and persists through the domain table, so a
881
+ * wrong match can never be written. Subagent sessions never join a
882
+ * workspace account, and a session any workspace already accounts for is
883
+ * left untouched (no session is ever registered twice).
884
+ */
885
+ const attachSessionToOwningWorkspace = async (header) => {
886
+ const registry = adapter.workspaceRegistry;
887
+ if (registry === void 0 || typeof registry.list !== "function") return false;
888
+ if (header === void 0 || typeof header.id !== "string") return false;
889
+ if (header.origin === "subagent") return false;
890
+ if (typeof header.cwd !== "string" || header.cwd === "") return false;
891
+ const run = async () => {
892
+ const entities = registry.list();
893
+ for (const entity of entities) {
894
+ if (workspaceMemberIds(entity).includes(header.id)) return false;
895
+ }
896
+ const byPath = new Map();
897
+ for (const entity of entities) {
898
+ if (typeof entity.path === "string" && !byPath.has(entity.path)) byPath.set(entity.path, entity);
899
+ }
900
+ let target = byPath.get(header.cwd);
901
+ if (target === void 0 && typeof registry.resolveByPath === "function") {
902
+ try { target = await registry.resolveByPath(header.cwd); } catch { target = void 0; }
903
+ }
904
+ if (target === void 0) {
905
+ const canonical = await canonicalizeDirectory(header.cwd);
906
+ if (canonical !== void 0) target = byPath.get(canonical);
907
+ }
908
+ if (target === void 0 || typeof target.attachSession !== "function") return false;
909
+ await target.attachSession(header.id);
910
+ return true;
911
+ };
912
+ if (typeof registry.enqueueOperation === "function") return await registry.enqueueOperation(run);
913
+ return await run();
914
+ };
915
+
916
+ /**
917
+ * Resolve the header the official attachSession() will validate against.
918
+ * WorkspaceEntity.readSessionHeader prefers the live session, then the
919
+ * registry's own header index, and only then a fresh persistence listing.
920
+ * Mirroring that order lets the repair predict a rejection instead of
921
+ * attempting an attach that cannot succeed.
922
+ */
923
+ const effectiveSessionHeader = (sessionId, storedHeader) => {
924
+ try {
925
+ const live = ctx.sessions?.get?.(sessionId);
926
+ if (live !== undefined && live !== null && live.header !== undefined) return live.header;
927
+ } catch { /* fall through to the index */ }
928
+ try {
929
+ const indexed = adapter.workspaceRegistry?.headers?.get?.(sessionId);
930
+ if (indexed !== undefined) return indexed;
931
+ } catch { /* fall through to the stored header */ }
932
+ return storedHeader;
933
+ };
934
+
935
+ /**
936
+ * Complete the durable workspace membership of a session whose artifact
937
+ * already lives under the target workspace path (same-path move). DSH 0.2.x
938
+ * keeps "cwd matches the workspace path" and "the workspace accounts for
939
+ * the session" as separate facts, so a matching cwd alone must not be
940
+ * reported as success.
941
+ *
942
+ * Conservative policy. The official domain offers no cross-record
943
+ * transaction (single-record updates on one serialized storage unit), and
944
+ * attachSession() validates the session cwd against the workspace path --
945
+ * so an account held by a DIFFERENT workspace can never be restored
946
+ * through the official API once it is removed. Automatic migration would
947
+ * therefore risk destroying the only remaining account if the target
948
+ * attach then failed. Hence:
949
+ * - no workspace accounts for the session: complete the target
950
+ * membership through attachSession(), after pre-checking every
951
+ * condition it validates so a predictable rejection fails before any
952
+ * write;
953
+ * - already accounted by the target and by nobody else: idempotent
954
+ * no-op, no write;
955
+ * - accounted by another workspace: refuse with a membership-conflict
956
+ * error; no automatic cross-record migration;
957
+ * - membership state cannot be confirmed: refuse.
958
+ *
959
+ * No artifact is rewritten, no cwd is touched, and no live session is
960
+ * restarted. A true cross-workspace move re-homes the artifact first and
961
+ * is a separate, unchanged path.
962
+ *
963
+ * @returns {status: "already-member"|"attached", attached: boolean}
964
+ * @throws with code workspace-membership-unknown | workspace-membership-conflict |
965
+ * workspace-attach-unavailable | workspace-repair-unsafe | workspace-repair-failed.
966
+ */
967
+ const repairWorkspaceMembership = async (target, sessionId, storedHeader) => {
968
+ const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
969
+ const run = async () => {
970
+ // Confirm the current accounting from a fresh, readable listing. An
971
+ // unreadable registry must never be guessed at.
972
+ let entities;
973
+ let inTarget;
974
+ let stale;
975
+ try {
976
+ if (typeof registry?.list !== "function") throw new Error("workspace registry 不可用");
977
+ const listed = registry.list();
978
+ if (!Array.isArray(listed)) throw new Error("workspace registry 返回了无效数据");
979
+ entities = listed;
980
+ inTarget = workspaceMemberIds(target).includes(sessionId);
981
+ stale = entities.filter((entity) =>
982
+ entity !== target && entity.id !== target.id && workspaceMemberIds(entity).includes(sessionId));
983
+ } catch (cause) {
984
+ const error = new Error("无法确认会话当前的工作区归属状态(" + safeErrorMessage(cause) + "),已拒绝修复操作");
985
+ error.code = "workspace-membership-unknown";
986
+ throw error;
987
+ }
988
+
989
+ if (stale.length > 0) {
990
+ // Membership conflict: the session is accounted by a workspace whose
991
+ // path differs from its cwd. That account cannot be restored through
992
+ // the official API once removed, so an automatic migration could
993
+ // destroy the only remaining account if the target attach failed.
994
+ // Refuse and let the operator resolve the conflict.
995
+ const names = stale.map((entity) => entity.title || entity.id).join("、");
996
+ const error = new Error(
997
+ "会话当前登记在其他工作区(" + names + "),与其 cwd 指向的目标工作区不一致。"
998
+ + "为避免修复失败造成数据损失,已拒绝自动迁移;请先处理该归属冲突,或使用真正的跨工作区移动"
999
+ );
1000
+ error.code = "workspace-membership-conflict";
1001
+ throw error;
1002
+ }
1003
+ if (inTarget) return { status: "already-member", attached: false };
1004
+ if (typeof target.attachSession !== "function") {
1005
+ const error = new Error("当前 DSH 宿主未提供官方工作区归属接口(attachSession),无法补全会话归属");
1006
+ error.code = "workspace-attach-unavailable";
1007
+ throw error;
1008
+ }
1009
+
1010
+ // Pre-check every condition attachSession() validates so a predictable
1011
+ // rejection fails before any write. The header it reads is the live
1012
+ // session first, then the registry index, then a fresh persistence
1013
+ // listing -- not necessarily the header this request was validated
1014
+ // against.
1015
+ const effective = effectiveSessionHeader(sessionId, storedHeader);
1016
+ if (effective === void 0 || typeof effective.cwd !== "string" || effective.cwd === "") {
1017
+ const error = new Error("拒绝补全归属:会话没有可校验的 cwd,官方接口无法登记");
1018
+ error.code = "workspace-repair-unsafe";
1019
+ throw error;
1020
+ }
1021
+ const canonical = await canonicalizeDirectory(effective.cwd);
1022
+ if (canonical === void 0 || canonical !== target.path) {
1023
+ const error = new Error(
1024
+ "拒绝补全归属:会话当前 cwd(" + effective.cwd + ")解析后与目标工作区(" + target.path
1025
+ + ")不一致,官方接口会拒绝登记;未做任何修改"
1026
+ );
1027
+ error.code = "workspace-repair-unsafe";
1028
+ throw error;
1029
+ }
1030
+ try {
1031
+ await target.attachSession(sessionId);
1032
+ } catch (cause) {
1033
+ // Nothing has been written yet, so the previous state (no account) is
1034
+ // intact; surface the failure with its cause.
1035
+ const error = new Error("补全归属失败:" + safeErrorMessage(cause));
1036
+ error.code = "workspace-repair-failed";
1037
+ throw error;
1038
+ }
1039
+ return { status: "attached", attached: true };
1040
+ };
1041
+ if (registry !== void 0 && typeof registry.enqueueOperation === "function") {
1042
+ return await registry.enqueueOperation(run);
1043
+ }
1044
+ return await run();
1045
+ };
1046
+
646
1047
  /**
647
1048
  * TRUE cross-workspace move. See the module docstring for the full design:
648
1049
  * re-home the stored cwd, migrate the artifact, then swap accounting.
@@ -705,7 +1106,25 @@ ${rest}`, "utf8");
705
1106
  let currentCanonical;
706
1107
  try { currentCanonical = await realpath(storedHeader.cwd); } catch { /* re-home below */ }
707
1108
  if (currentCanonical === targetPath) {
708
- return { ok: true, sessionId, moved: false, message: "会话已属于目标工作区" };
1109
+ // The artifact already lives under the target workspace path, but on
1110
+ // DSH 0.2.x "cwd matches" and "the workspace accounts for the
1111
+ // session" are separate facts: a matching cwd with a missing
1112
+ // registration used to return a silent no-op success. Repair the
1113
+ // durable membership through the official entity API (idempotent,
1114
+ // no artifact rewrite, no live-session restart) and report what
1115
+ // actually happened. A repair that cannot be completed fails loudly
1116
+ // instead of reporting success.
1117
+ const repair = await repairWorkspaceMembership(target, sessionId, storedHeader);
1118
+ return {
1119
+ ok: true,
1120
+ sessionId,
1121
+ moved: false,
1122
+ attached: repair.attached,
1123
+ membership: repair.status,
1124
+ message: repair.attached
1125
+ ? "会话已归属目标工作区(已补全归属记录)"
1126
+ : "会话已属于目标工作区"
1127
+ };
709
1128
  }
710
1129
  }
711
1130
 
@@ -812,7 +1231,7 @@ ${rest}`, "utf8");
812
1231
  const oldIndexedPath = registry.sessionPaths?.get?.(sessionId);
813
1232
  const oldInvalidPath = registry.invalidSessionPaths?.get?.(sessionId);
814
1233
  const fromWorkspaceIds = registry.list()
815
- .filter((entity) => entity.id !== target.id && (entity.record?.sessionIds ?? entity.sessionIds).includes(sessionId))
1234
+ .filter((entity) => entity.id !== target.id && workspaceMemberIds(entity).includes(sessionId))
816
1235
  .map((entity) => entity.id);
817
1236
  let targetAttached = false;
818
1237
  const detachedFrom = [];
@@ -977,8 +1396,7 @@ ${rest}`, "utf8");
977
1396
  throw error;
978
1397
  }
979
1398
  allowedIds = new Set();
980
- const ids = typeof ws.sessionIds === "function" ? ws.sessionIds() : ws.sessionIds;
981
- if (Array.isArray(ids)) for (const id of ids) allowedIds.add(id);
1399
+ for (const id of workspaceMemberIds(ws)) allowedIds.add(id);
982
1400
  }
983
1401
  }
984
1402
  const requestedSessionId = filter !== void 0 && typeof filter === "object" && typeof filter.sessionId === "string"
@@ -1627,43 +2045,103 @@ ${rest}`, "utf8");
1627
2045
  if (typeof persistence?.list !== "function" || typeof registry?.list !== "function") return;
1628
2046
 
1629
2047
  try {
1630
- // Remember only ids that existed before the async scan. Concurrently
1631
- // attached ids must never be removed using this older disk snapshot.
1632
- const initialIds = new Map(registry.list().map(ws => [ws.id, new Set(ws.record?.sessionIds ?? [])]));
1633
- const scan = await scanSessionHeaders(persistence, adapter.homePath);
1634
- if (!scan.complete) {
1635
- ctx.logger.warn("session-manager: incomplete session scan; workspace reconciliation skipped: " + scan.errors.map(safeErrorMessage).join("; "));
2048
+ // Snapshot durable membership BEFORE the async scan. Only ids that
2049
+ // already existed here may ever be pruned later; ids attached
2050
+ // concurrently must never be removed using an older disk snapshot.
2051
+ let entities;
2052
+ try { entities = registry.list(); }
2053
+ catch (error) {
2054
+ ctx.logger.warn("session-manager: workspace registry unavailable; workspace reconciliation skipped: " + safeErrorMessage(error));
1636
2055
  return;
1637
2056
  }
1638
- const headers = scan.headers;
1639
- const validIds = new Set(headers.map(header => header.id));
2057
+ const initialIds = new Map(entities.map(ws => [ws.id, new Set(workspaceMemberIds(ws))]));
2058
+ const scan = await scanSessionHeaders(persistence, adapter.homePath);
2059
+ const headers = Array.isArray(scan.headers) ? scan.headers : [];
2060
+
2061
+ // Step 1a: attach sessions whose cwd names a registered workspace but
2062
+ // that no workspace accounts for (issue #41). Every candidate is
2063
+ // re-validated by the official entity API, which persists through the
2064
+ // domain table and is idempotent for already-accounted ids -- so this
2065
+ // pass can only add well-formed memberships, never corrupt records.
2066
+ // It runs whenever the persistence index listed successfully
2067
+ // (scan.persisted); a partial disk sweep cannot make a listed header
2068
+ // invalid.
2069
+ let added = 0;
2070
+ if (scan.persisted) {
2071
+ const assigned = new Set();
2072
+ for (const ws of entities) for (const id of initialIds.get(ws.id)) assigned.add(id);
2073
+ const candidates = headers.filter(header =>
2074
+ typeof header.cwd === "string" && header.cwd !== ""
2075
+ && header.origin !== "subagent"
2076
+ && !assigned.has(header.id));
2077
+ if (candidates.length > 0) {
2078
+ const byPath = new Map();
2079
+ for (const ws of entities) {
2080
+ if (typeof ws.path === "string" && !byPath.has(ws.path)) byPath.set(ws.path, ws);
2081
+ }
2082
+ const attach = async () => {
2083
+ // Re-read membership inside the registry write queue: a concurrent
2084
+ // move may have accounted an id after the snapshot above.
2085
+ const current = typeof registry.list === "function" ? registry.list() : entities;
2086
+ const assignedNow = new Set();
2087
+ for (const ws of current) for (const id of workspaceMemberIds(ws)) assignedNow.add(id);
2088
+ for (const header of candidates) {
2089
+ if (assignedNow.has(header.id)) continue;
2090
+ let target = byPath.get(header.cwd);
2091
+ if (target === void 0) {
2092
+ const canonical = await canonicalizeDirectory(header.cwd);
2093
+ if (canonical !== void 0) target = byPath.get(canonical);
2094
+ }
2095
+ if (target === void 0 || typeof target.attachSession !== "function") continue;
2096
+ try {
2097
+ await target.attachSession(header.id);
2098
+ assignedNow.add(header.id);
2099
+ added += 1;
2100
+ } catch (error) {
2101
+ ctx.logger.warn("session-manager: boot attach of session '" + header.id + "' to workspace '" + target.id + "' failed: " + safeErrorMessage(error));
2102
+ }
2103
+ }
2104
+ };
2105
+ if (typeof registry.enqueueOperation === "function") await registry.enqueueOperation(attach);
2106
+ else await attach();
2107
+ }
2108
+ }
1640
2109
 
1641
- // Step 1: Immediate Workspace Member Reconciliation (isolated from projection rebuild)
2110
+ // Step 1b: prune record entries whose session no longer exists. Only a
2111
+ // fully complete scan (persistence index plus disk sweep, no errors)
2112
+ // may remove anything; live sessions and ids attached after the
2113
+ // snapshot are always kept. Durable records are read through
2114
+ // workspaceMemberIds so a stale runtime index cannot hide a dead entry.
1642
2115
  let removed = 0;
1643
- let added = 0;
1644
- await registry.enqueueOperation(async () => {
1645
- const state = registry.requireState();
1646
- const pathById = new Map(registry.list().map(ws => [ws.id, ws.path]));
1647
- const assignedIds = new Set((state.workspaces ?? []).flatMap(ws => ws.sessionIds ?? []));
1648
- const workspaces = (state.workspaces ?? []).map(ws => {
1649
- const original = ws.sessionIds ?? [];
1650
- const sessionIds = original.filter(id => validIds.has(id)
1651
- || !initialIds.get(ws.id)?.has(id) || adapter.getSession(id) !== undefined);
1652
- const removedHere = original.length - sessionIds.length;
1653
- removed += removedHere;
1654
- for (const header of headers) {
1655
- if (header.cwd === undefined || header.cwd !== pathById.get(ws.id) || assignedIds.has(header.id)) continue;
1656
- sessionIds.unshift(header.id);
1657
- assignedIds.add(header.id);
1658
- added += 1;
2116
+ if (scan.complete) {
2117
+ const validIds = new Set(headers.map(header => header.id));
2118
+ const prune = async () => {
2119
+ for (const ws of (typeof registry.list === "function" ? registry.list() : entities)) {
2120
+ const initial = initialIds.get(ws.id);
2121
+ // Snapshot: detachSession replaces the record array while we walk it.
2122
+ for (const id of [...workspaceMemberIds(ws)]) {
2123
+ if (validIds.has(id)) continue;
2124
+ if (initial === void 0 || !initial.has(id)) continue;
2125
+ if (adapter.getSession(id) !== undefined) continue;
2126
+ if (typeof ws.detachSession !== "function") continue;
2127
+ await ws.detachSession(id);
2128
+ removed += 1;
2129
+ }
1659
2130
  }
1660
- if (sessionIds.length === original.length && sessionIds.every((id, i) => id === original[i])) return ws;
1661
- return { ...ws, sessionIds, updatedAt: new Date().toISOString() };
1662
- });
1663
- if (removed || added) await registry.setState({ ...state, workspaces });
1664
- });
1665
- if (removed || added) {
1666
- ctx.logger.info('session-manager: post-boot workspace reconcile removed ' + removed + ' orphans, attached ' + added + ' disk-only sessions');
2131
+ };
2132
+ if (typeof registry.enqueueOperation === "function") await registry.enqueueOperation(prune);
2133
+ else await prune();
2134
+ }
2135
+ if (added || removed) {
2136
+ ctx.logger.info('session-manager: post-boot workspace reconcile attached ' + added + ' unaccounted sessions, removed ' + removed + ' orphans');
2137
+ }
2138
+ if (!scan.persisted) {
2139
+ ctx.logger.warn("session-manager: incomplete session scan; workspace reconciliation skipped: " + scan.errors.map(safeErrorMessage).join("; "));
2140
+ return;
2141
+ }
2142
+ if (!scan.complete) {
2143
+ ctx.logger.warn("session-manager: incomplete disk sweep; workspace membership pruning skipped: " + scan.errors.map(safeErrorMessage).join("; "));
2144
+ return;
1667
2145
  }
1668
2146
 
1669
2147
  // Step 2: Budgeted Background Projection Cache Rebuild
@@ -1727,6 +2205,32 @@ ${rest}`, "utf8");
1727
2205
  }
1728
2206
  }, "session-manager: post-boot re-fold stale projection cache rows");
1729
2207
 
2208
+ /**
2209
+ * Runtime membership repair (issue #41): DSH's own create/fork flow attaches
2210
+ * a session to its workspace, but a session created outside that flow -- or
2211
+ * one whose host-side attach failed -- stays unaccounted even though its
2212
+ * cwd names a registered workspace, and would otherwise only be repaired on
2213
+ * the next DSH restart. Repair it as soon as the session is announced.
2214
+ *
2215
+ * The listener must never throw synchronously: a throwing session/created
2216
+ * listener rolls back session creation in DSH. Everything is contained and
2217
+ * fire-and-forget; failures are logged only.
2218
+ */
2219
+ if (typeof ctx.on === "function") ctx.on("session/created", (session) => {
2220
+ void (async () => {
2221
+ try {
2222
+ const header = session?.header ?? session;
2223
+ if (header === void 0 || typeof header.id !== "string") return;
2224
+ const attached = await attachSessionToOwningWorkspace(header);
2225
+ if (attached) {
2226
+ ctx.logger.info("session-manager: attached new session '" + header.id + "' to its cwd workspace");
2227
+ }
2228
+ } catch (error) {
2229
+ ctx.logger.warn("session-manager: session/created membership repair failed: " + safeErrorMessage(error));
2230
+ }
2231
+ })();
2232
+ });
2233
+
1730
2234
  /** Build a normalized annotation patch from one bulk action + payload.
1731
2235
  * Returns null when the payload is missing or malformed so the caller can
1732
2236
  * surface a 400 instead of letting the store throw on a bad value.
@@ -2024,14 +2528,91 @@ ${rest}`, "utf8");
2024
2528
  return send(res, 200, { ok: true, result });
2025
2529
  }
2026
2530
  if (req.method === "GET" && path === "/update/check") {
2027
- const currentVersion = adapter.pluginVersion() || "0.7.2";
2028
- let latestVersion = currentVersion;
2531
+ const currentVersion = adapter.pluginVersion() || "0.7.4";
2532
+ const runningVersion = currentVersion;
2533
+ let installedVersion = runningVersion;
2534
+ const profileDir = resolveProfileDir(adapter, ctx);
2535
+ if (profileDir) {
2536
+ const diskCheck = verifyInstalledPackage(profileDir);
2537
+ if (diskCheck.ok && diskCheck.version) {
2538
+ installedVersion = diskCheck.version;
2539
+ }
2540
+ }
2541
+ const pm = adapter.pluginManager;
2542
+ let bundles = [];
2543
+ if (typeof pm?.listBundles === "function") {
2544
+ try { bundles = await pm.listBundles(); } catch (_) {}
2545
+ }
2546
+ const source = detectPluginSource(adapter, ctx, bundles);
2547
+ if (source === "linked") {
2548
+ return send(res, 200, {
2549
+ ok: true,
2550
+ result: {
2551
+ runningVersion,
2552
+ currentVersion: runningVersion,
2553
+ installedVersion,
2554
+ latestVersion: runningVersion,
2555
+ hasUpdate: false,
2556
+ source: "linked",
2557
+ message: "开发模式(本地链接),请手动修改或拉取代码"
2558
+ }
2559
+ });
2560
+ }
2561
+ if (source === "github") {
2562
+ return send(res, 200, {
2563
+ ok: true,
2564
+ result: {
2565
+ runningVersion,
2566
+ currentVersion: runningVersion,
2567
+ installedVersion,
2568
+ latestVersion: runningVersion,
2569
+ hasUpdate: false,
2570
+ source: "github",
2571
+ message: "通过 Git/GitHub 安装,请通过 DSH 插件市场或 git 更新"
2572
+ }
2573
+ });
2574
+ }
2575
+ if (source === "installation") {
2576
+ return send(res, 200, {
2577
+ ok: true,
2578
+ result: {
2579
+ runningVersion,
2580
+ currentVersion: runningVersion,
2581
+ installedVersion,
2582
+ latestVersion: runningVersion,
2583
+ hasUpdate: false,
2584
+ source: "installation",
2585
+ message: "系统内置插件,无法独立更新"
2586
+ }
2587
+ });
2588
+ }
2589
+ if (source === "unknown") {
2590
+ return send(res, 200, {
2591
+ ok: true,
2592
+ result: {
2593
+ runningVersion,
2594
+ currentVersion: runningVersion,
2595
+ installedVersion,
2596
+ latestVersion: runningVersion,
2597
+ hasUpdate: false,
2598
+ source: "unknown",
2599
+ message: "无法确认插件安装来源,为保证安全已禁止自动更新"
2600
+ }
2601
+ });
2602
+ }
2603
+ let latestVersion = installedVersion;
2029
2604
  let hasUpdate = false;
2030
2605
  try {
2606
+ const envRegistry = process.env.NPM_CONFIG_REGISTRY?.trim();
2031
2607
  const requestedRegistry = url.searchParams.get("registry")?.trim();
2032
- const registry = (requestedRegistry && /^https?:\/\//i.test(requestedRegistry))
2033
- ? requestedRegistry
2034
- : (process.env.NPM_CONFIG_REGISTRY || "https://registry.npmjs.org");
2608
+ const registry = resolveTrustedRegistry(requestedRegistry, envRegistry);
2609
+ if (!registry) {
2610
+ return send(res, 400, {
2611
+ ok: false,
2612
+ code: "untrusted-registry",
2613
+ error: "不受信任的 npm 安装源: " + requestedRegistry
2614
+ });
2615
+ }
2035
2616
  const base = registry.endsWith("/") ? registry : registry + "/";
2036
2617
  const pkgUrl = new URL("dsh-session-manager/latest", base).toString();
2037
2618
  const controller = new AbortController();
@@ -2050,17 +2631,33 @@ ${rest}`, "utf8");
2050
2631
  } finally {
2051
2632
  clearTimeout(timer);
2052
2633
  }
2053
- hasUpdate = compareSemver(latestVersion, currentVersion) > 0;
2634
+ hasUpdate = compareSemver(latestVersion, installedVersion) > 0;
2635
+ const restartRequired = compareSemver(installedVersion, runningVersion) > 0;
2054
2636
  return send(res, 200, {
2055
2637
  ok: true,
2056
- result: { currentVersion, latestVersion, hasUpdate }
2638
+ result: {
2639
+ runningVersion,
2640
+ currentVersion: runningVersion,
2641
+ installedVersion,
2642
+ latestVersion,
2643
+ hasUpdate,
2644
+ restartRequired,
2645
+ source: "npm"
2646
+ }
2057
2647
  });
2058
2648
  } catch (err) {
2059
2649
  return send(res, 200, {
2060
2650
  ok: false,
2061
2651
  code: "update-check-failed",
2062
2652
  error: "检查更新失败: " + safeErrorMessage(err),
2063
- result: { currentVersion, latestVersion: currentVersion, hasUpdate: false }
2653
+ result: {
2654
+ runningVersion,
2655
+ currentVersion: runningVersion,
2656
+ installedVersion,
2657
+ latestVersion: installedVersion,
2658
+ hasUpdate: false,
2659
+ source: "npm"
2660
+ }
2064
2661
  });
2065
2662
  }
2066
2663
  }
@@ -2069,38 +2666,650 @@ ${rest}`, "utf8");
2069
2666
  if (rawVersion && !/^[a-zA-Z0-9_.-]+$/.test(rawVersion)) {
2070
2667
  return send(res, 400, { ok: false, code: "bad-request", error: "Invalid version format" });
2071
2668
  }
2072
- const targetVersion = rawVersion || "latest";
2073
- const spec = "dsh-session-manager@" + targetVersion;
2074
- const registry = typeof body?.registry === "string" && /^https?:\/\//i.test(body.registry.trim()) ? body.registry.trim() : "";
2669
+
2075
2670
  const pm = adapter.pluginManager;
2076
- if (pm && typeof pm.installBundle === "function") {
2671
+ if (!pm || typeof pm.installBundle !== "function") {
2672
+ return send(res, 501, {
2673
+ ok: false,
2674
+ code: "plugin-manager-unavailable",
2675
+ error: "当前宿主环境未提供 PluginManager 安装服务,请通过 DSH 插件市场或命令行更新"
2676
+ });
2677
+ }
2678
+
2679
+ const profileDir = resolveProfileDir(adapter, ctx);
2680
+
2681
+ // P1-3: Concurrency Mutex check & immediate synchronous acquisition
2682
+ const inMemoryTask = getActiveTask();
2683
+ if (inMemoryTask) {
2684
+ return send(res, 409, {
2685
+ ok: false,
2686
+ code: "update-in-progress",
2687
+ error: "已有正在执行的更新任务 (" + inMemoryTask.taskId + "),请勿重复操作",
2688
+ taskId: inMemoryTask.taskId
2689
+ });
2690
+ }
2691
+ const diskStatus = readUpdateStatus(profileDir);
2692
+ if (isDiskTaskActive(diskStatus)) {
2693
+ return send(res, 409, {
2694
+ ok: false,
2695
+ code: "update-in-progress",
2696
+ error: "已有正在执行的更新任务 (" + diskStatus.taskId + "),请勿重复操作",
2697
+ taskId: diskStatus.taskId
2698
+ });
2699
+ }
2700
+
2701
+ const taskId = (typeof body?.taskId === "string" && body.taskId.trim())
2702
+ ? body.taskId.trim()
2703
+ : ("update-" + Date.now() + "-" + randomBytes(4).toString("hex"));
2704
+
2705
+ const taskStartedAt = Date.now();
2706
+ const taskRecord = {
2707
+ taskId,
2708
+ startedAt: taskStartedAt,
2709
+ profileDir,
2710
+ pid: process.pid,
2711
+ targetVersion: rawVersion || "latest"
2712
+ };
2713
+ setActiveTask(taskRecord);
2714
+
2715
+ const currentVersion = adapter.pluginVersion() || "0.7.4";
2716
+ let installedVersion = currentVersion;
2717
+ if (profileDir) {
2718
+ const pkgFile = join(profileDir, "node_modules", "dsh-session-manager", "package.json");
2719
+ if (existsSync(pkgFile)) {
2720
+ const diskCheck = verifyInstalledPackage(profileDir);
2721
+ if (diskCheck.ok && diskCheck.version) {
2722
+ installedVersion = diskCheck.version;
2723
+ } else {
2724
+ releaseActiveTask(taskId);
2725
+ return send(res, 500, {
2726
+ ok: false,
2727
+ code: "disk-verify-failed",
2728
+ error: "无法确认已安装版本,磁盘插件目录异常: " + (diskCheck?.error || "package.json 校验失败"),
2729
+ taskId
2730
+ });
2731
+ }
2732
+ }
2733
+ }
2734
+
2735
+ const targetVersion = rawVersion || "latest";
2736
+ let resolvedTargetVersion = (targetVersion !== "latest") ? targetVersion : null;
2737
+ const envRegistry = process.env.NPM_CONFIG_REGISTRY?.trim();
2738
+ const requestedRegistry = typeof body?.registry === "string" ? body.registry.trim() : "";
2739
+ const registry = resolveTrustedRegistry(requestedRegistry, envRegistry);
2740
+ if (!registry) {
2741
+ releaseActiveTask(taskId);
2742
+ return send(res, 400, {
2743
+ ok: false,
2744
+ code: "untrusted-registry",
2745
+ error: "不受信任的 npm 安装源: " + requestedRegistry
2746
+ });
2747
+ }
2748
+ const forwardRegistry = (requestedRegistry && requestedRegistry !== "https://registry.npmjs.org")
2749
+ ? registry
2750
+ : (envRegistry ? registry : "");
2751
+ const isHmr = isHmrActive(adapter, ctx);
2752
+ const prevRegistry = process.env.NPM_CONFIG_REGISTRY;
2753
+
2754
+ let heartbeatTimer = setInterval(() => {
2077
2755
  try {
2078
- const prevRegistry = process.env.NPM_CONFIG_REGISTRY;
2079
- if (registry) {
2080
- process.env.NPM_CONFIG_REGISTRY = registry;
2756
+ const curStatus = readUpdateStatus(profileDir);
2757
+ if (curStatus && curStatus.taskId === taskId && ACTIVE_TASK_STATUSES.has(curStatus.status)) {
2758
+ curStatus.updatedAt = Date.now();
2759
+ writeUpdateStatus(profileDir, curStatus);
2760
+ }
2761
+ } catch (_) {}
2762
+ }, 10000);
2763
+ if (typeof heartbeatTimer?.unref === "function") {
2764
+ heartbeatTimer.unref();
2765
+ }
2766
+
2767
+ try {
2768
+ // P1-4: Source check
2769
+ let bundles = [];
2770
+ if (typeof pm?.listBundles === "function") {
2771
+ try { bundles = await pm.listBundles(); } catch (_) {}
2772
+ }
2773
+ const source = detectPluginSource(adapter, ctx, bundles);
2774
+ if (source === "linked" || source === "github" || source === "installation") {
2775
+ releaseActiveTask(taskId);
2776
+ return send(res, 400, {
2777
+ ok: false,
2778
+ code: "source-protected",
2779
+ error: "当前插件为 " + source + " 安装来源,无法通过 npm 覆盖更新"
2780
+ });
2781
+ }
2782
+ if (source === "unknown") {
2783
+ releaseActiveTask(taskId);
2784
+ return send(res, 400, {
2785
+ ok: false,
2786
+ code: "source-unknown",
2787
+ error: "无法确认插件安装来源,已禁止自动更新以防破坏环境"
2788
+ });
2789
+ }
2790
+
2791
+ if (!resolvedTargetVersion) {
2792
+ try {
2793
+ const regUrl = registry || process.env.NPM_CONFIG_REGISTRY || "https://registry.npmjs.org";
2794
+ const base = regUrl.endsWith("/") ? regUrl : regUrl + "/";
2795
+ const pkgUrl = new URL("dsh-session-manager/latest", base).toString();
2796
+ const ctrl = new AbortController();
2797
+ const timer = setTimeout(() => ctrl.abort(), 4000);
2798
+ try {
2799
+ const resp = await fetch(pkgUrl, { signal: ctrl.signal, headers: { accept: "application/json" } });
2800
+ if (resp.ok) {
2801
+ const d = await resp.json();
2802
+ if (d && typeof d.version === "string") {
2803
+ resolvedTargetVersion = d.version;
2804
+ }
2805
+ }
2806
+ } finally {
2807
+ clearTimeout(timer);
2808
+ }
2809
+ } catch (_) { /* fallback to outcome.version after install */ }
2810
+ }
2811
+
2812
+ const baselineVersion = installedVersion || currentVersion;
2813
+
2814
+ if (resolvedTargetVersion && baselineVersion && baselineVersion !== "unknown") {
2815
+ const cmp = compareSemver(resolvedTargetVersion, baselineVersion);
2816
+ if (cmp === 0) {
2817
+ // If target equals baseline:
2818
+ // Check if already installed on disk pending restart or refresh
2819
+ if (compareSemver(installedVersion, currentVersion) > 0) {
2820
+ releaseActiveTask(taskId);
2821
+ const application = isHmr ? "refresh-required" : "restart-required";
2822
+ writeUpdateStatus(profileDir, {
2823
+ taskId,
2824
+ status: "completed",
2825
+ targetVersion: installedVersion,
2826
+ currentVersion,
2827
+ application,
2828
+ alreadyInstalled: true,
2829
+ pid: process.pid,
2830
+ completedAt: Date.now()
2831
+ });
2832
+ return send(res, 200, {
2833
+ ok: true,
2834
+ result: {
2835
+ application,
2836
+ spec: "dsh-session-manager@" + installedVersion,
2837
+ version: installedVersion,
2838
+ taskId,
2839
+ alreadyInstalled: true
2840
+ }
2841
+ });
2842
+ }
2843
+ // Target is equal to running & installed version -> not an upgrade
2844
+ releaseActiveTask(taskId);
2845
+ return send(res, 400, {
2846
+ ok: false,
2847
+ code: "not-upgrade",
2848
+ error: "目标版本 (" + resolvedTargetVersion + ") 不高于当前已安装版本 (" + baselineVersion + ")"
2849
+ });
2850
+ } else if (cmp < 0) {
2851
+ releaseActiveTask(taskId);
2852
+ return send(res, 400, {
2853
+ ok: false,
2854
+ code: "not-upgrade",
2855
+ error: "目标版本 (" + resolvedTargetVersion + ") 低于当前已安装版本 (" + baselineVersion + ")"
2856
+ });
2857
+ }
2858
+ }
2859
+ const spec = "dsh-session-manager@" + (resolvedTargetVersion || targetVersion);
2860
+ const oldSpec = "dsh-session-manager@" + currentVersion;
2861
+
2862
+ writeUpdateStatus(profileDir, {
2863
+ taskId,
2864
+ status: "preparing",
2865
+ targetVersion: resolvedTargetVersion || targetVersion,
2866
+ currentVersion,
2867
+ source: "npm",
2868
+ pid: process.pid,
2869
+ startedAt: taskStartedAt
2870
+ });
2871
+
2872
+ if (forwardRegistry) {
2873
+ process.env.NPM_CONFIG_REGISTRY = forwardRegistry;
2874
+ }
2875
+
2876
+ // Capture locals before any potential scope teardown
2877
+ const localProfileDir = profileDir;
2878
+ const localSpec = spec;
2879
+ const localOldSpec = oldSpec;
2880
+ const localPm = pm;
2881
+ const localTaskId = taskId;
2882
+ const localTargetVersion = targetVersion;
2883
+ const localCurrentVersion = currentVersion;
2884
+ const localRegistry = forwardRegistry;
2885
+ let localResolvedTargetVersion = resolvedTargetVersion;
2886
+
2887
+ // Helper: perform rollback and record state
2888
+ const performRollback = async (triggerError, { newVersionInstalled = false } = {}) => {
2889
+ // Restore registry environment first so rollback is not blocked by custom registry failure
2890
+ if (forwardRegistry) {
2891
+ if (prevRegistry !== undefined) {
2892
+ process.env.NPM_CONFIG_REGISTRY = prevRegistry;
2893
+ } else {
2894
+ delete process.env.NPM_CONFIG_REGISTRY;
2895
+ }
2896
+ }
2897
+
2898
+ writeUpdateStatus(localProfileDir, {
2899
+ taskId: localTaskId,
2900
+ status: "rolling-back",
2901
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2902
+ currentVersion: localCurrentVersion,
2903
+ error: triggerError,
2904
+ pid: process.pid,
2905
+ rollingBackAt: Date.now()
2906
+ });
2907
+
2908
+ // P0-2: In HMR mode, if new version was already installed into profile,
2909
+ // calling installBundle(oldSpec) directly throws "already-installed".
2910
+ // Therefore, we must remove the new version first.
2911
+ let cleanError = null;
2912
+ if (isHmr && newVersionInstalled) {
2913
+ if (typeof localPm.removeBundle === "function") {
2914
+ try {
2915
+ const rmOutcome = await localPm.removeBundle("dsh-session-manager");
2916
+ if (rmOutcome && (rmOutcome.application === "failed" || rmOutcome.application === "cancelled" || rmOutcome.ok === false)) {
2917
+ cleanError = rmOutcome.error?.message || rmOutcome.error?.code || safeErrorMessage(rmOutcome.error) || ("application: " + rmOutcome.application);
2918
+ }
2919
+ } catch (rmErr) {
2920
+ cleanError = safeErrorMessage(rmErr);
2921
+ }
2922
+ }
2923
+ }
2924
+
2925
+ if (cleanError) {
2926
+ const rbDetail = "清理安装失败的新版本失败: " + cleanError;
2927
+ releaseActiveTask(localTaskId);
2928
+ const actualCheck = verifyInstalledPackage(localProfileDir);
2929
+ const installedState = actualCheck.ok ? (actualCheck.version || "unknown") : "unverified";
2930
+ writeUpdateStatus(localProfileDir, {
2931
+ taskId: localTaskId,
2932
+ status: "recovery-required",
2933
+ stage: "cleanup-new-version",
2934
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2935
+ currentVersion: localCurrentVersion,
2936
+ rolledBack: false,
2937
+ error: triggerError,
2938
+ rollbackError: rbDetail,
2939
+ installedState,
2940
+ pid: process.pid,
2941
+ failedAt: Date.now()
2942
+ });
2943
+ if (!res.writableEnded) {
2944
+ return send(res, 500, {
2945
+ ok: false,
2946
+ code: "recovery-required",
2947
+ stage: "cleanup-new-version",
2948
+ error: "安装更新失败: " + triggerError + "(自动恢复旧版本失败: " + rbDetail + ",请手动重新安装插件)",
2949
+ rolledBack: false,
2950
+ rollbackError: rbDetail,
2951
+ originalVersion: localCurrentVersion,
2952
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2953
+ installedState,
2954
+ taskId: localTaskId
2955
+ });
2956
+ }
2957
+ return;
2081
2958
  }
2082
- let outcome;
2959
+
2960
+ let rbOutcome = null;
2961
+ let rbError = null;
2083
2962
  try {
2084
- outcome = await (registry ? pm.installBundle(spec, { registry }) : pm.installBundle(spec));
2085
- } finally {
2086
- if (registry) {
2087
- if (prevRegistry !== undefined) {
2088
- process.env.NPM_CONFIG_REGISTRY = prevRegistry;
2089
- } else {
2090
- delete process.env.NPM_CONFIG_REGISTRY;
2963
+ rbOutcome = await (localRegistry ? localPm.installBundle(localOldSpec, { registry: localRegistry }) : localPm.installBundle(localOldSpec));
2964
+ } catch (err) {
2965
+ rbError = safeErrorMessage(err);
2966
+ // Fallback to default installBundle without registry option if custom registry fails
2967
+ if (localRegistry) {
2968
+ try {
2969
+ rbOutcome = await localPm.installBundle(localOldSpec);
2970
+ rbError = null;
2971
+ } catch (err2) {
2972
+ rbError = safeErrorMessage(err2);
2091
2973
  }
2092
2974
  }
2093
2975
  }
2094
- const application = outcome?.application || "restart-required";
2095
- return send(res, 200, { ok: true, result: { application, spec } });
2976
+ let installOk = rbOutcome && rbOutcome.application !== "failed" && rbOutcome.application !== "cancelled" && rbOutcome.ok !== false && !rbError;
2977
+
2978
+ if (installOk && typeof localPm.selectBundle === "function") {
2979
+ try { await localPm.selectBundle("dsh-session-manager", true); } catch (_) {}
2980
+ }
2981
+
2982
+ let verifiedOk = false;
2983
+ let verifyError = null;
2984
+ if (installOk) {
2985
+ const v = verifyInstalledPackage(localProfileDir, localCurrentVersion);
2986
+ verifiedOk = v.ok;
2987
+ if (!v.ok) verifyError = v.error;
2988
+ }
2989
+ releaseActiveTask(localTaskId);
2990
+ if (installOk && verifiedOk) {
2991
+ writeUpdateStatus(localProfileDir, {
2992
+ taskId: localTaskId,
2993
+ status: "rolled-back",
2994
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
2995
+ currentVersion: localCurrentVersion,
2996
+ rolledBack: true,
2997
+ error: triggerError,
2998
+ pid: process.pid,
2999
+ failedAt: Date.now()
3000
+ });
3001
+ if (!res.writableEnded) {
3002
+ return send(res, 500, {
3003
+ ok: false,
3004
+ code: "install-failed",
3005
+ error: "安装更新失败: " + triggerError + "(已自动恢复旧版本 v" + localCurrentVersion + ")",
3006
+ rolledBack: true,
3007
+ originalVersion: localCurrentVersion,
3008
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
3009
+ taskId: localTaskId
3010
+ });
3011
+ }
3012
+ } else {
3013
+ const failureStage = !installOk ? "reinstall-old-version" : "verify-rollback";
3014
+ const rbDetail = rbError || verifyError || (rbOutcome?.error ? safeErrorMessage(rbOutcome.error) : "回滚安装未成功");
3015
+ const actualCheck = verifyInstalledPackage(localProfileDir);
3016
+ const installedState = actualCheck.ok ? (actualCheck.version || "unknown") : "unverified";
3017
+ writeUpdateStatus(localProfileDir, {
3018
+ taskId: localTaskId,
3019
+ status: "recovery-required",
3020
+ stage: failureStage,
3021
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
3022
+ currentVersion: localCurrentVersion,
3023
+ rolledBack: false,
3024
+ error: triggerError,
3025
+ rollbackError: rbDetail,
3026
+ installedState,
3027
+ pid: process.pid,
3028
+ failedAt: Date.now()
3029
+ });
3030
+ if (!res.writableEnded) {
3031
+ return send(res, 500, {
3032
+ ok: false,
3033
+ code: "recovery-required",
3034
+ stage: failureStage,
3035
+ error: "安装更新失败: " + triggerError + "(自动恢复旧版本失败: " + rbDetail + ",请手动重新安装插件)",
3036
+ rolledBack: false,
3037
+ rollbackError: rbDetail,
3038
+ originalVersion: localCurrentVersion,
3039
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
3040
+ installedState,
3041
+ taskId: localTaskId
3042
+ });
3043
+ }
3044
+ }
3045
+ };
3046
+
3047
+ if (isHmr && typeof localPm.removeBundle === "function") {
3048
+ // P0-1 & P0-2: Remove old bundle first in HMR
3049
+ writeUpdateStatus(localProfileDir, {
3050
+ taskId: localTaskId,
3051
+ status: "removing",
3052
+ targetVersion: localTargetVersion,
3053
+ currentVersion: localCurrentVersion,
3054
+ source: "npm",
3055
+ pid: process.pid,
3056
+ startedAt: taskStartedAt
3057
+ });
3058
+
3059
+ let removeResult;
3060
+ try {
3061
+ removeResult = await localPm.removeBundle("dsh-session-manager");
3062
+ } catch (rmErr) {
3063
+ const failReason = safeErrorMessage(rmErr);
3064
+ releaseActiveTask(localTaskId);
3065
+ writeUpdateStatus(localProfileDir, {
3066
+ taskId: localTaskId,
3067
+ status: "failed",
3068
+ stage: "removing",
3069
+ targetVersion: localTargetVersion,
3070
+ currentVersion: localCurrentVersion,
3071
+ error: "卸载旧版本失败: " + failReason,
3072
+ pid: process.pid,
3073
+ failedAt: Date.now()
3074
+ });
3075
+ return send(res, 500, {
3076
+ ok: false,
3077
+ code: "remove-failed",
3078
+ error: "卸载旧版本失败: " + failReason,
3079
+ taskId: localTaskId
3080
+ });
3081
+ }
3082
+
3083
+ // P0-2: Strictly check removeBundle return value
3084
+ if (removeResult && (removeResult.application === "failed" || removeResult.application === "cancelled" || removeResult.ok === false)) {
3085
+ const failReason = removeResult.error?.message || removeResult.error?.code || safeErrorMessage(removeResult.error) || ("application: " + removeResult.application);
3086
+ if (removeResult.changed && typeof localPm.selectBundle === "function") {
3087
+ try { await localPm.selectBundle("dsh-session-manager", true); } catch (_) {}
3088
+ }
3089
+ releaseActiveTask(localTaskId);
3090
+ writeUpdateStatus(localProfileDir, {
3091
+ taskId: localTaskId,
3092
+ status: "failed",
3093
+ stage: "removing",
3094
+ targetVersion: localTargetVersion,
3095
+ currentVersion: localCurrentVersion,
3096
+ error: "卸载旧版本失败: " + failReason,
3097
+ pid: process.pid,
3098
+ failedAt: Date.now()
3099
+ });
3100
+ return send(res, 500, {
3101
+ ok: false,
3102
+ code: "remove-failed",
3103
+ error: "卸载旧版本失败: " + failReason,
3104
+ taskId: localTaskId
3105
+ });
3106
+ }
3107
+
3108
+ writeUpdateStatus(localProfileDir, {
3109
+ taskId: localTaskId,
3110
+ status: "removed",
3111
+ targetVersion: localTargetVersion,
3112
+ currentVersion: localCurrentVersion,
3113
+ source: "npm",
3114
+ pid: process.pid,
3115
+ startedAt: taskStartedAt
3116
+ });
3117
+ }
3118
+
3119
+ // Install target bundle
3120
+ writeUpdateStatus(localProfileDir, {
3121
+ taskId: localTaskId,
3122
+ status: "installing",
3123
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
3124
+ currentVersion: localCurrentVersion,
3125
+ source: "npm",
3126
+ pid: process.pid,
3127
+ startedAt: taskStartedAt
3128
+ });
3129
+
3130
+ let outcome;
3131
+ try {
3132
+ outcome = await (localRegistry ? localPm.installBundle(localSpec, { registry: localRegistry }) : localPm.installBundle(localSpec));
2096
3133
  } catch (err) {
2097
- return send(res, 500, { ok: false, code: "install-failed", error: safeErrorMessage(err) });
3134
+ const errMsg = safeErrorMessage(err);
3135
+ if (isHmr) {
3136
+ // In HMR, old bundle was removed -> execute rollback (new version was not installed)
3137
+ return await performRollback(errMsg, { newVersionInstalled: false });
3138
+ } else {
3139
+ // In non-HMR, old bundle was not removed -> fail directly
3140
+ releaseActiveTask(localTaskId);
3141
+ writeUpdateStatus(localProfileDir, {
3142
+ taskId: localTaskId,
3143
+ status: "failed",
3144
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
3145
+ currentVersion: localCurrentVersion,
3146
+ error: errMsg,
3147
+ pid: process.pid,
3148
+ failedAt: Date.now()
3149
+ });
3150
+ return send(res, 500, {
3151
+ ok: false,
3152
+ code: "install-failed",
3153
+ error: "安装更新失败: " + errMsg,
3154
+ taskId: localTaskId
3155
+ });
3156
+ }
3157
+ }
3158
+
3159
+ // Check outcome application
3160
+ if (outcome && (outcome.application === "failed" || outcome.application === "cancelled" || outcome.ok === false)) {
3161
+ const failReason = outcome.error?.message || outcome.error || ("application: " + outcome.application);
3162
+ if (isHmr) {
3163
+ return await performRollback(failReason, { newVersionInstalled: false });
3164
+ } else {
3165
+ releaseActiveTask(localTaskId);
3166
+ writeUpdateStatus(localProfileDir, {
3167
+ taskId: localTaskId,
3168
+ status: "failed",
3169
+ targetVersion: localResolvedTargetVersion || localTargetVersion,
3170
+ currentVersion: localCurrentVersion,
3171
+ error: failReason,
3172
+ pid: process.pid,
3173
+ failedAt: Date.now()
3174
+ });
3175
+ return send(res, 500, {
3176
+ ok: false,
3177
+ code: "install-failed",
3178
+ error: "安装更新失败: " + failReason,
3179
+ taskId: localTaskId
3180
+ });
3181
+ }
3182
+ }
3183
+
3184
+ if (!localResolvedTargetVersion && typeof outcome?.version === "string") {
3185
+ localResolvedTargetVersion = outcome.version;
3186
+ }
3187
+ const concreteTargetVersion = localResolvedTargetVersion || (localTargetVersion !== "latest" ? localTargetVersion : null);
3188
+
3189
+ // P1-2: Strict post-install verification
3190
+ writeUpdateStatus(localProfileDir, {
3191
+ taskId: localTaskId,
3192
+ status: "verifying",
3193
+ targetVersion: concreteTargetVersion || localTargetVersion,
3194
+ currentVersion: localCurrentVersion,
3195
+ source: "npm",
3196
+ pid: process.pid,
3197
+ startedAt: taskStartedAt
3198
+ });
3199
+
3200
+ const verifyResult = verifyInstalledPackage(localProfileDir, concreteTargetVersion);
3201
+ if (!verifyResult.ok) {
3202
+ const vError = "安装后校验失败: " + verifyResult.error;
3203
+ return await performRollback(vError, { newVersionInstalled: true });
3204
+ }
3205
+
3206
+ // Completed!
3207
+ const application = outcome?.application || (isHmr ? "refresh-required" : "restart-required");
3208
+ releaseActiveTask(localTaskId);
3209
+ const finalInstalledVersion = verifyResult.version || concreteTargetVersion || localTargetVersion;
3210
+ writeUpdateStatus(localProfileDir, {
3211
+ taskId: localTaskId,
3212
+ status: "completed",
3213
+ targetVersion: finalInstalledVersion,
3214
+ currentVersion: localCurrentVersion,
3215
+ application,
3216
+ pid: process.pid,
3217
+ completedAt: Date.now()
3218
+ });
3219
+
3220
+ if (!res.writableEnded) {
3221
+ return send(res, 200, {
3222
+ ok: true,
3223
+ result: {
3224
+ application,
3225
+ spec: localSpec,
3226
+ version: finalInstalledVersion,
3227
+ taskId: localTaskId
3228
+ }
3229
+ });
3230
+ }
3231
+ } finally {
3232
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
3233
+ releaseActiveTask(taskId);
3234
+ if (forwardRegistry) {
3235
+ if (prevRegistry !== undefined) {
3236
+ process.env.NPM_CONFIG_REGISTRY = prevRegistry;
3237
+ } else {
3238
+ delete process.env.NPM_CONFIG_REGISTRY;
3239
+ }
3240
+ }
3241
+ }
3242
+ }
3243
+ if (req.method === "GET" && path === "/update/status") {
3244
+ const profileDir = resolveProfileDir(adapter, ctx);
3245
+ const currentVersion = adapter.pluginVersion() || "0.7.4";
3246
+ let installedVersion = currentVersion;
3247
+ if (profileDir) {
3248
+ const diskCheck = verifyInstalledPackage(profileDir);
3249
+ if (diskCheck.ok && diskCheck.version) {
3250
+ installedVersion = diskCheck.version;
2098
3251
  }
2099
3252
  }
2100
- return send(res, 501, {
2101
- ok: false,
2102
- code: "plugin-manager-unavailable",
2103
- error: "当前宿主环境未提供 PluginManager 安装服务,请通过 DSH 插件市场或命令行更新"
3253
+ const updateStatus = readUpdateStatus(profileDir);
3254
+ const queryTaskId = url.searchParams.get("taskId")?.trim();
3255
+
3256
+ if (queryTaskId) {
3257
+ if (updateStatus && updateStatus.taskId === queryTaskId) {
3258
+ return send(res, 200, {
3259
+ ok: true,
3260
+ result: {
3261
+ ...updateStatus,
3262
+ runningVersion: currentVersion,
3263
+ currentVersion,
3264
+ installedVersion
3265
+ }
3266
+ });
3267
+ }
3268
+ if (updateStatus && updateStatus.taskId && updateStatus.taskId !== queryTaskId) {
3269
+ return send(res, 200, {
3270
+ ok: true,
3271
+ result: {
3272
+ status: "superseded",
3273
+ taskId: queryTaskId,
3274
+ activeTaskId: updateStatus.taskId,
3275
+ runningVersion: currentVersion,
3276
+ currentVersion,
3277
+ installedVersion
3278
+ }
3279
+ });
3280
+ }
3281
+ return send(res, 200, {
3282
+ ok: true,
3283
+ result: {
3284
+ status: "idle",
3285
+ taskId: queryTaskId,
3286
+ runningVersion: currentVersion,
3287
+ currentVersion,
3288
+ installedVersion
3289
+ }
3290
+ });
3291
+ }
3292
+
3293
+ const now = Date.now();
3294
+ if (updateStatus && (now - (updateStatus.completedAt || updateStatus.failedAt || updateStatus.startedAt || 0) < 10 * 60 * 1000)) {
3295
+ return send(res, 200, {
3296
+ ok: true,
3297
+ result: {
3298
+ ...updateStatus,
3299
+ runningVersion: currentVersion,
3300
+ currentVersion,
3301
+ installedVersion
3302
+ }
3303
+ });
3304
+ }
3305
+ return send(res, 200, {
3306
+ ok: true,
3307
+ result: {
3308
+ status: "idle",
3309
+ runningVersion: currentVersion,
3310
+ currentVersion,
3311
+ installedVersion
3312
+ }
2104
3313
  });
2105
3314
  }
2106
3315
  return send(res, 404, { ok: false, error: "not found: " + req.method + " " + path });
@@ -2108,7 +3317,9 @@ ${rest}`, "utf8");
2108
3317
  ctx.logger.warn("session-manager: api error: " + safeErrorMessage(error) + "; json=" + safeJson(error));
2109
3318
  const status = error?.code === "bad-request" ? 400 : error?.code === "body-too-large" ? 413
2110
3319
  : error?.code === "annotation-conflict" || error?.code === "annotations-busy" ? 409
2111
- : error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503 : 500;
3320
+ : error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503
3321
+ : error?.code === "workspace-membership-conflict" ? 409
3322
+ : error?.code === "workspace-attach-unavailable" ? 501 : 500;
2112
3323
  return send(res, status, {
2113
3324
  ok: false,
2114
3325
  error: safeErrorMessage(error),