dsh-session-manager 0.7.3 → 0.7.5
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 +20 -0
- package/README.md +6 -5
- package/README.zh.md +5 -4
- package/lib/client.js +225 -27
- package/lib/compat/dsh-adapter.js +36 -2
- package/lib/index.js +584 -53
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -28,7 +28,7 @@ import { basename, dirname, join } from "node:path";
|
|
|
28
28
|
import { randomBytes } from "node:crypto";
|
|
29
29
|
import { createAnnotationStore } from "./annotation-store.js";
|
|
30
30
|
import { createWorkspaceArchiveStore, assertWorkspaceId } from "./workspace-archive-store.js";
|
|
31
|
-
import { createDshAdapter, normalizeSessionHeader, compareSemver, detectPluginSource, isHmrActive, resolveProfileDir } from "./compat/dsh-adapter.js";
|
|
31
|
+
import { createDshAdapter, getService, normalizeSessionHeader, compareSemver, detectPluginSource, isHmrActive, resolveProfileDir } from "./compat/dsh-adapter.js";
|
|
32
32
|
import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, candidateSessionDirs, encodeFsSessionSegment, decodeFsSessionSegment, encodeProjectSegment, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
|
|
33
33
|
|
|
34
34
|
export const name = "dsh-session-manager";
|
|
@@ -39,7 +39,8 @@ export const inject = [
|
|
|
39
39
|
"sessions",
|
|
40
40
|
"agents",
|
|
41
41
|
"sessionPersistence",
|
|
42
|
-
"agentPresets"
|
|
42
|
+
"agentPresets",
|
|
43
|
+
"connection"
|
|
43
44
|
];
|
|
44
45
|
|
|
45
46
|
/** Like JSON.stringify but swallows circular-reference/BigInt errors so the
|
|
@@ -101,6 +102,12 @@ function rebindLiveWriter(persistence, sessionId, newHeader) {
|
|
|
101
102
|
* disk fallback semantics are preserved -- the orphan is the whole
|
|
102
103
|
* reason this loop exists.
|
|
103
104
|
*
|
|
105
|
+
* The result carries two completeness flags: "persisted" reports whether the
|
|
106
|
+
* persistence index itself listed successfully (its rows are authoritative),
|
|
107
|
+
* while "complete" additionally requires the disk sweep to finish without
|
|
108
|
+
* errors. Callers that only add validated memberships may run on "persisted";
|
|
109
|
+
* callers that remove records must wait for "complete".
|
|
110
|
+
*
|
|
104
111
|
* @param {object|undefined} persistence DSH's sessionPersistence service
|
|
105
112
|
* @param {((segment: string) => string)|undefined} dshHome Resolver for
|
|
106
113
|
* DSH home sub-paths; only "sessions" is read.
|
|
@@ -110,6 +117,7 @@ const scanSessionHeaders = async (persistence, dshHome) => {
|
|
|
110
117
|
const headers = [];
|
|
111
118
|
const errors = [];
|
|
112
119
|
let listed = false;
|
|
120
|
+
let persisted = false;
|
|
113
121
|
if (typeof persistence?.list === "function") {
|
|
114
122
|
try {
|
|
115
123
|
const rows = await persistence.list();
|
|
@@ -122,9 +130,10 @@ const scanSessionHeaders = async (persistence, dshHome) => {
|
|
|
122
130
|
if (!seen.has(header.id)) { seen.add(header.id); headers.push(header); }
|
|
123
131
|
}
|
|
124
132
|
listed = true;
|
|
133
|
+
persisted = true;
|
|
125
134
|
} catch (error) { errors.push(error); }
|
|
126
135
|
}
|
|
127
|
-
const result = () => ({ headers, complete: listed && errors.length === 0, errors });
|
|
136
|
+
const result = () => ({ headers, persisted, complete: listed && errors.length === 0, errors });
|
|
128
137
|
// Skip known files before fopen; retain the orphan/raw-id compatibility path.
|
|
129
138
|
// Only add canonical session directories that do not start and end with "--"
|
|
130
139
|
// to avoid colliding with legacy projectKey candidates (e.g. "--s1--").
|
|
@@ -175,6 +184,16 @@ const scanSessionHeaders = async (persistence, dshHome) => {
|
|
|
175
184
|
|
|
176
185
|
const listSessionHeaders = async (persistence, dshHome) => (await scanSessionHeaders(persistence, dshHome)).headers;
|
|
177
186
|
|
|
187
|
+
/** Canonicalize a directory path the way DSH's workspace registry does
|
|
188
|
+
* (fs.realpath on a fully qualified path). Returns undefined when the path
|
|
189
|
+
* cannot be resolved so callers skip instead of guessing; DSH stores
|
|
190
|
+
* workspace paths canonicalized, so canonical equality is the only safe
|
|
191
|
+
* membership comparison on Windows (case, separators, 8.3 names, junctions). */
|
|
192
|
+
const canonicalizeDirectory = async (path) => {
|
|
193
|
+
if (typeof path !== "string" || path === "") return undefined;
|
|
194
|
+
try { return await realpath(path); } catch { return undefined; }
|
|
195
|
+
};
|
|
196
|
+
|
|
178
197
|
const UPDATE_STATUS_FILE = ".dsh-session-manager-update.json";
|
|
179
198
|
const STALE_EXTERNAL_TASK_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes fallback for dead external processes
|
|
180
199
|
const HEARTBEAT_STALE_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes without heartbeat for external processes
|
|
@@ -367,7 +386,7 @@ function verifyInstalledPackage(profileDir, expectedVersion) {
|
|
|
367
386
|
|
|
368
387
|
export function apply(ctx) {
|
|
369
388
|
try {
|
|
370
|
-
const buildId = "dsh-session-manager v0.7.
|
|
389
|
+
const buildId = "dsh-session-manager v0.7.5 (workspace-archive)";
|
|
371
390
|
if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
|
|
372
391
|
else if (typeof console !== "undefined" && console?.info) console.info(buildId);
|
|
373
392
|
} catch (_) { /* ignore logging failures */ }
|
|
@@ -399,10 +418,26 @@ export function apply(ctx) {
|
|
|
399
418
|
const safeGetAgent = (sessionId) => {
|
|
400
419
|
try { return ctx.agents?.get?.(sessionId); } catch { return undefined; }
|
|
401
420
|
};
|
|
421
|
+
/** Durable workspace membership for one entity. The record account is the
|
|
422
|
+
* authoritative persisted list on DSH 0.2.x (registry entities expose it as
|
|
423
|
+
* the private `record` field); the runtime `sessionIds` getter is filtered
|
|
424
|
+
* by the canonical-cwd index and may hide members whose index entry is
|
|
425
|
+
* stale, so it is only a fallback for hosts without a readable record. */
|
|
426
|
+
const workspaceMemberIds = (entity) => {
|
|
427
|
+
if (entity === null || entity === undefined) return [];
|
|
428
|
+
const record = entity.record;
|
|
429
|
+
if (record !== null && record !== undefined && Array.isArray(record.sessionIds)) return record.sessionIds;
|
|
430
|
+
const ids = entity.sessionIds;
|
|
431
|
+
if (Array.isArray(ids)) return ids;
|
|
432
|
+
if (typeof ids === "function") {
|
|
433
|
+
try { const value = ids(); if (Array.isArray(value)) return value; } catch { /* ignore */ }
|
|
434
|
+
}
|
|
435
|
+
return [];
|
|
436
|
+
};
|
|
402
437
|
const assertAnnotatable = id => {
|
|
403
438
|
const registry = adapter.workspaceRegistry;
|
|
404
439
|
if (adapter.getSession(id) !== undefined || registry?.headers?.has?.(id)
|
|
405
|
-
|| adapter.workspaceList().some(ws => (ws
|
|
440
|
+
|| adapter.workspaceList().some(ws => workspaceMemberIds(ws).includes(id))) return;
|
|
406
441
|
throw Object.assign(new Error("会话不存在或尚未加载,请先打开该会话后重试"), { code: "session-not-found" });
|
|
407
442
|
};
|
|
408
443
|
const operations = new Map();
|
|
@@ -567,7 +602,9 @@ export function apply(ctx) {
|
|
|
567
602
|
const detachFromWorkspaces = async (sessionId) => {
|
|
568
603
|
const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
|
|
569
604
|
for (const entity of registry.list()) {
|
|
570
|
-
|
|
605
|
+
// Durable record, not the runtime-filtered getter: deleting a session
|
|
606
|
+
// must purge its account even when the cwd index is stale.
|
|
607
|
+
if (workspaceMemberIds(entity).includes(sessionId)) {
|
|
571
608
|
await entity.detachSession(sessionId);
|
|
572
609
|
}
|
|
573
610
|
}
|
|
@@ -576,9 +613,7 @@ export function apply(ctx) {
|
|
|
576
613
|
/** Ordered workspace projection for the client. */
|
|
577
614
|
const listWorkspaces = () => {
|
|
578
615
|
return ctx.workspaceRegistry.list().map((entity) => {
|
|
579
|
-
const rawIds =
|
|
580
|
-
? [...entity.record.sessionIds]
|
|
581
|
-
: [...entity.sessionIds];
|
|
616
|
+
const rawIds = [...workspaceMemberIds(entity)];
|
|
582
617
|
const selfUpdatedAt = entity.updatedAt ?? entity.record?.updatedAt ?? null;
|
|
583
618
|
return {
|
|
584
619
|
id: entity.id,
|
|
@@ -834,6 +869,291 @@ ${rest}`, "utf8");
|
|
|
834
869
|
return false;
|
|
835
870
|
};
|
|
836
871
|
|
|
872
|
+
/**
|
|
873
|
+
* Attach one session to the registered workspace that owns its cwd.
|
|
874
|
+
* Returns true only when a new durable membership was written.
|
|
875
|
+
*
|
|
876
|
+
* DSH's own create/fork flow attaches through workspaceRegistry, but a
|
|
877
|
+
* session created outside that flow (cwd-only create, an attach the host
|
|
878
|
+
* declined, or a session that predates this plugin load) stays
|
|
879
|
+
* unaccounted even though its cwd names a registered workspace. The
|
|
880
|
+
* official entity API re-validates the stored header cwd against the
|
|
881
|
+
* canonical workspace path and persists through the domain table, so a
|
|
882
|
+
* wrong match can never be written. Subagent sessions never join a
|
|
883
|
+
* workspace account, and a session any workspace already accounts for is
|
|
884
|
+
* left untouched (no session is ever registered twice).
|
|
885
|
+
*/
|
|
886
|
+
const attachSessionToOwningWorkspace = async (header) => {
|
|
887
|
+
const registry = adapter.workspaceRegistry;
|
|
888
|
+
if (registry === void 0 || typeof registry.list !== "function") return false;
|
|
889
|
+
if (header === void 0 || typeof header.id !== "string") return false;
|
|
890
|
+
if (header.origin === "subagent") return false;
|
|
891
|
+
if (typeof header.cwd !== "string" || header.cwd === "") return false;
|
|
892
|
+
const run = async () => {
|
|
893
|
+
const entities = registry.list();
|
|
894
|
+
for (const entity of entities) {
|
|
895
|
+
if (workspaceMemberIds(entity).includes(header.id)) return false;
|
|
896
|
+
}
|
|
897
|
+
const byPath = new Map();
|
|
898
|
+
for (const entity of entities) {
|
|
899
|
+
if (typeof entity.path === "string" && !byPath.has(entity.path)) byPath.set(entity.path, entity);
|
|
900
|
+
}
|
|
901
|
+
let target = byPath.get(header.cwd);
|
|
902
|
+
if (target === void 0 && typeof registry.resolveByPath === "function") {
|
|
903
|
+
try { target = await registry.resolveByPath(header.cwd); } catch { target = void 0; }
|
|
904
|
+
}
|
|
905
|
+
if (target === void 0) {
|
|
906
|
+
const canonical = await canonicalizeDirectory(header.cwd);
|
|
907
|
+
if (canonical !== void 0) target = byPath.get(canonical);
|
|
908
|
+
}
|
|
909
|
+
if (target === void 0 || typeof target.attachSession !== "function") return false;
|
|
910
|
+
await target.attachSession(header.id);
|
|
911
|
+
return true;
|
|
912
|
+
};
|
|
913
|
+
if (typeof registry.enqueueOperation === "function") return await registry.enqueueOperation(run);
|
|
914
|
+
return await run();
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Resolve the header the official attachSession() will validate against.
|
|
919
|
+
* WorkspaceEntity.readSessionHeader prefers the live session, then the
|
|
920
|
+
* registry's own header index, and only then a fresh persistence listing.
|
|
921
|
+
* Mirroring that order lets the repair predict a rejection instead of
|
|
922
|
+
* attempting an attach that cannot succeed.
|
|
923
|
+
*/
|
|
924
|
+
const effectiveSessionHeader = (sessionId, storedHeader) => {
|
|
925
|
+
try {
|
|
926
|
+
const live = ctx.sessions?.get?.(sessionId);
|
|
927
|
+
if (live !== undefined && live !== null && live.header !== undefined) return live.header;
|
|
928
|
+
} catch { /* fall through to the index */ }
|
|
929
|
+
try {
|
|
930
|
+
const indexed = adapter.workspaceRegistry?.headers?.get?.(sessionId);
|
|
931
|
+
if (indexed !== undefined) return indexed;
|
|
932
|
+
} catch { /* fall through to the stored header */ }
|
|
933
|
+
return storedHeader;
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Complete the durable workspace membership of a session whose artifact
|
|
938
|
+
* already lives under the target workspace path (same-path move). DSH 0.2.x
|
|
939
|
+
* keeps "cwd matches the workspace path" and "the workspace accounts for
|
|
940
|
+
* the session" as separate facts, so a matching cwd alone must not be
|
|
941
|
+
* reported as success.
|
|
942
|
+
*
|
|
943
|
+
* Conservative policy. The official domain offers no cross-record
|
|
944
|
+
* transaction (single-record updates on one serialized storage unit), and
|
|
945
|
+
* attachSession() validates the session cwd against the workspace path --
|
|
946
|
+
* so an account held by a DIFFERENT workspace can never be restored
|
|
947
|
+
* through the official API once it is removed. Automatic migration would
|
|
948
|
+
* therefore risk destroying the only remaining account if the target
|
|
949
|
+
* attach then failed. Hence:
|
|
950
|
+
* - no workspace accounts for the session: complete the target
|
|
951
|
+
* membership through attachSession(), after pre-checking every
|
|
952
|
+
* condition it validates so a predictable rejection fails before any
|
|
953
|
+
* write;
|
|
954
|
+
* - already accounted by the target and by nobody else: idempotent
|
|
955
|
+
* no-op, no write;
|
|
956
|
+
* - accounted by another workspace: refuse with a membership-conflict
|
|
957
|
+
* error; no automatic cross-record migration;
|
|
958
|
+
* - membership state cannot be confirmed: refuse.
|
|
959
|
+
*
|
|
960
|
+
* No artifact is rewritten, no cwd is touched, and no live session is
|
|
961
|
+
* restarted. A true cross-workspace move re-homes the artifact first and
|
|
962
|
+
* is a separate, unchanged path.
|
|
963
|
+
*
|
|
964
|
+
* @returns {status: "already-member"|"attached", attached: boolean}
|
|
965
|
+
* @throws with code workspace-membership-unknown | workspace-membership-conflict |
|
|
966
|
+
* workspace-attach-unavailable | workspace-repair-unsafe | workspace-repair-failed.
|
|
967
|
+
*/
|
|
968
|
+
const repairWorkspaceMembership = async (target, sessionId, storedHeader) => {
|
|
969
|
+
const registry = ctx.get("workspaceRegistry") ?? ctx.workspaceRegistry ?? adapter.workspaceRegistry;
|
|
970
|
+
const run = async () => {
|
|
971
|
+
// Confirm the current accounting from a fresh, readable listing. An
|
|
972
|
+
// unreadable registry must never be guessed at.
|
|
973
|
+
let entities;
|
|
974
|
+
let inTarget;
|
|
975
|
+
let stale;
|
|
976
|
+
try {
|
|
977
|
+
if (typeof registry?.list !== "function") throw new Error("workspace registry 不可用");
|
|
978
|
+
const listed = registry.list();
|
|
979
|
+
if (!Array.isArray(listed)) throw new Error("workspace registry 返回了无效数据");
|
|
980
|
+
entities = listed;
|
|
981
|
+
inTarget = workspaceMemberIds(target).includes(sessionId);
|
|
982
|
+
stale = entities.filter((entity) =>
|
|
983
|
+
entity !== target && entity.id !== target.id && workspaceMemberIds(entity).includes(sessionId));
|
|
984
|
+
} catch (cause) {
|
|
985
|
+
const error = new Error("无法确认会话当前的工作区归属状态(" + safeErrorMessage(cause) + "),已拒绝修复操作");
|
|
986
|
+
error.code = "workspace-membership-unknown";
|
|
987
|
+
throw error;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (stale.length > 0) {
|
|
991
|
+
// Membership conflict: the session is accounted by a workspace whose
|
|
992
|
+
// path differs from its cwd. That account cannot be restored through
|
|
993
|
+
// the official API once removed, so an automatic migration could
|
|
994
|
+
// destroy the only remaining account if the target attach failed.
|
|
995
|
+
// Refuse and let the operator resolve the conflict.
|
|
996
|
+
const names = stale.map((entity) => entity.title || entity.id).join("、");
|
|
997
|
+
const error = new Error(
|
|
998
|
+
"会话当前登记在其他工作区(" + names + "),与其 cwd 指向的目标工作区不一致。"
|
|
999
|
+
+ "为避免修复失败造成数据损失,已拒绝自动迁移;请先处理该归属冲突,或使用真正的跨工作区移动"
|
|
1000
|
+
);
|
|
1001
|
+
error.code = "workspace-membership-conflict";
|
|
1002
|
+
throw error;
|
|
1003
|
+
}
|
|
1004
|
+
if (inTarget) return { status: "already-member", attached: false };
|
|
1005
|
+
if (typeof target.attachSession !== "function") {
|
|
1006
|
+
const error = new Error("当前 DSH 宿主未提供官方工作区归属接口(attachSession),无法补全会话归属");
|
|
1007
|
+
error.code = "workspace-attach-unavailable";
|
|
1008
|
+
throw error;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Pre-check every condition attachSession() validates so a predictable
|
|
1012
|
+
// rejection fails before any write. The header it reads is the live
|
|
1013
|
+
// session first, then the registry index, then a fresh persistence
|
|
1014
|
+
// listing -- not necessarily the header this request was validated
|
|
1015
|
+
// against.
|
|
1016
|
+
const effective = effectiveSessionHeader(sessionId, storedHeader);
|
|
1017
|
+
if (effective === void 0 || typeof effective.cwd !== "string" || effective.cwd === "") {
|
|
1018
|
+
const error = new Error("拒绝补全归属:会话没有可校验的 cwd,官方接口无法登记");
|
|
1019
|
+
error.code = "workspace-repair-unsafe";
|
|
1020
|
+
throw error;
|
|
1021
|
+
}
|
|
1022
|
+
const canonical = await canonicalizeDirectory(effective.cwd);
|
|
1023
|
+
if (canonical === void 0 || canonical !== target.path) {
|
|
1024
|
+
const error = new Error(
|
|
1025
|
+
"拒绝补全归属:会话当前 cwd(" + effective.cwd + ")解析后与目标工作区(" + target.path
|
|
1026
|
+
+ ")不一致,官方接口会拒绝登记;未做任何修改"
|
|
1027
|
+
);
|
|
1028
|
+
error.code = "workspace-repair-unsafe";
|
|
1029
|
+
throw error;
|
|
1030
|
+
}
|
|
1031
|
+
try {
|
|
1032
|
+
await target.attachSession(sessionId);
|
|
1033
|
+
} catch (cause) {
|
|
1034
|
+
// Nothing has been written yet, so the previous state (no account) is
|
|
1035
|
+
// intact; surface the failure with its cause.
|
|
1036
|
+
const error = new Error("补全归属失败:" + safeErrorMessage(cause));
|
|
1037
|
+
error.code = "workspace-repair-failed";
|
|
1038
|
+
throw error;
|
|
1039
|
+
}
|
|
1040
|
+
return { status: "attached", attached: true };
|
|
1041
|
+
};
|
|
1042
|
+
if (registry !== void 0 && typeof registry.enqueueOperation === "function") {
|
|
1043
|
+
return await registry.enqueueOperation(run);
|
|
1044
|
+
}
|
|
1045
|
+
return await run();
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Issue #42: record the working-directory change a move implies.
|
|
1050
|
+
*
|
|
1051
|
+
* A cross-workspace move rewrites the artifact header's `cwd`, but DSH
|
|
1052
|
+
* folds the `workingDirectory` projection from the session log: the unit
|
|
1053
|
+
* initializes from `header.cwd` only while it has no checkpoint, and both
|
|
1054
|
+
* the projection cache and every `working-directory/change` event
|
|
1055
|
+
* supersede that initial value. A live session therefore keeps reporting
|
|
1056
|
+
* its pre-move directory -- the projection state is never re-initialized --
|
|
1057
|
+
* and the cache checkpoints that stale state under the NEW header identity,
|
|
1058
|
+
* so a rebuild cannot recover the target either. Route the change through
|
|
1059
|
+
* the host's own path (the `workingDirectory` service is what a user `cd`
|
|
1060
|
+
* uses) so the log gains the event the projection folds from; without that
|
|
1061
|
+
* service, append the equivalent event directly, which is the same shape
|
|
1062
|
+
* the host's `commit()` writes.
|
|
1063
|
+
*
|
|
1064
|
+
* @param agent - the live Agent owning the moved session, when one exists.
|
|
1065
|
+
* @param session - the live Session owning the moved log.
|
|
1066
|
+
* @param targetPath - the destination workspace directory.
|
|
1067
|
+
* @returns `{status}`: "synced" (host service committed the change),
|
|
1068
|
+
* "recorded" (event appended directly) or "restart-required" (the host
|
|
1069
|
+
* rejected the change; the move itself has already succeeded).
|
|
1070
|
+
*/
|
|
1071
|
+
const rehomeLiveWorkingDirectory = async ({ agent, session, targetPath }) => {
|
|
1072
|
+
const service = getService(ctx, "workingDirectory");
|
|
1073
|
+
if (service !== undefined && typeof service.set === "function" && agent !== undefined) {
|
|
1074
|
+
try {
|
|
1075
|
+
const cwd = await service.set(agent, targetPath);
|
|
1076
|
+
return { status: "synced", cwd: typeof cwd === "string" && cwd !== "" ? cwd : targetPath };
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
const isFiberError = error?.code === "INACTIVE_EFFECT" ||
|
|
1079
|
+
String(error?.message || error).toLowerCase().includes("inactive") ||
|
|
1080
|
+
String(error?.message || error).toLowerCase().includes("fiber");
|
|
1081
|
+
if (!isFiberError) {
|
|
1082
|
+
ctx.logger.warn(`session-manager: moved "${safeJson(session?.id)}" but the host rejected the working-directory change: ${safeErrorMessage(error)}`);
|
|
1083
|
+
return { status: "restart-required", reason: safeErrorMessage(error) };
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
if (session === undefined || session === null || typeof session.append !== "function") {
|
|
1088
|
+
return { status: "restart-required", reason: "no live session to record the working-directory change" };
|
|
1089
|
+
}
|
|
1090
|
+
try {
|
|
1091
|
+
session.append("working-directory/change", { cwd: targetPath });
|
|
1092
|
+
return { status: "recorded", cwd: targetPath };
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
ctx.logger.warn(`session-manager: moved "${safeJson(session.id)}" but could not record the working-directory change: ${safeErrorMessage(error)}`);
|
|
1095
|
+
return { status: "restart-required", reason: safeErrorMessage(error) };
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Issue #42 (cold sessions): append the `working-directory/change` event a
|
|
1101
|
+
* live move records, but only when the stored log already carries one --
|
|
1102
|
+
* otherwise the rewritten `header.cwd` alone initializes the projection on
|
|
1103
|
+
* the next resume and touching the log would be gratuitous. A stale change
|
|
1104
|
+
* event would otherwise fold the pre-move directory back in. Rows whose
|
|
1105
|
+
* `seq` does not match their position (packed physical rows) cannot be
|
|
1106
|
+
* extended without the persistence codec, so the body is left untouched and
|
|
1107
|
+
* the caller reports that a restart is required.
|
|
1108
|
+
*
|
|
1109
|
+
* @param rest - the artifact body (everything after the header line).
|
|
1110
|
+
* @param targetPath - the destination workspace directory.
|
|
1111
|
+
* @returns `{rest, status}` with the body to publish.
|
|
1112
|
+
*/
|
|
1113
|
+
const rehomeColdWorkingDirectory = (rest, targetPath) => {
|
|
1114
|
+
const nl = String.fromCharCode(10);
|
|
1115
|
+
const terminated = rest.endsWith(nl);
|
|
1116
|
+
const body = terminated ? rest.slice(0, -1) : rest;
|
|
1117
|
+
if (body === "") return { rest, status: "header-only" };
|
|
1118
|
+
const lines = body.split(nl);
|
|
1119
|
+
let lastChange;
|
|
1120
|
+
let hasPackedOrUnparseableRows = false;
|
|
1121
|
+
let hasNonSequentialSeq = false;
|
|
1122
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1123
|
+
let event;
|
|
1124
|
+
try {
|
|
1125
|
+
event = JSON.parse(lines[index]);
|
|
1126
|
+
} catch {
|
|
1127
|
+
hasPackedOrUnparseableRows = true;
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
if (event === null || typeof event !== "object") {
|
|
1131
|
+
hasPackedOrUnparseableRows = true;
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
if (event.seq !== index) {
|
|
1135
|
+
hasNonSequentialSeq = true;
|
|
1136
|
+
}
|
|
1137
|
+
if (event.type === "working-directory/change") {
|
|
1138
|
+
lastChange = event.data?.cwd;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
if (lastChange === undefined) return { rest, status: "header-only" };
|
|
1142
|
+
if (lastChange === targetPath) return { rest, status: "already-target" };
|
|
1143
|
+
if (hasPackedOrUnparseableRows || hasNonSequentialSeq) {
|
|
1144
|
+
const error = new Error("无法安全同步会话工作目录(会话日志包含压缩行且已有工作目录变更历史),为防止工作目录不一致,已取消移动。原始会话保持原状");
|
|
1145
|
+
error.code = "working-directory-sync-unsafe";
|
|
1146
|
+
throw error;
|
|
1147
|
+
}
|
|
1148
|
+
const appended = JSON.stringify({
|
|
1149
|
+
type: "working-directory/change",
|
|
1150
|
+
seq: lines.length,
|
|
1151
|
+
time: Date.now(),
|
|
1152
|
+
data: { cwd: targetPath }
|
|
1153
|
+
});
|
|
1154
|
+
return { rest: `${body}${nl}${appended}${nl}`, status: "recorded" };
|
|
1155
|
+
};
|
|
1156
|
+
|
|
837
1157
|
/**
|
|
838
1158
|
* TRUE cross-workspace move. See the module docstring for the full design:
|
|
839
1159
|
* re-home the stored cwd, migrate the artifact, then swap accounting.
|
|
@@ -896,7 +1216,25 @@ ${rest}`, "utf8");
|
|
|
896
1216
|
let currentCanonical;
|
|
897
1217
|
try { currentCanonical = await realpath(storedHeader.cwd); } catch { /* re-home below */ }
|
|
898
1218
|
if (currentCanonical === targetPath) {
|
|
899
|
-
|
|
1219
|
+
// The artifact already lives under the target workspace path, but on
|
|
1220
|
+
// DSH 0.2.x "cwd matches" and "the workspace accounts for the
|
|
1221
|
+
// session" are separate facts: a matching cwd with a missing
|
|
1222
|
+
// registration used to return a silent no-op success. Repair the
|
|
1223
|
+
// durable membership through the official entity API (idempotent,
|
|
1224
|
+
// no artifact rewrite, no live-session restart) and report what
|
|
1225
|
+
// actually happened. A repair that cannot be completed fails loudly
|
|
1226
|
+
// instead of reporting success.
|
|
1227
|
+
const repair = await repairWorkspaceMembership(target, sessionId, storedHeader);
|
|
1228
|
+
return {
|
|
1229
|
+
ok: true,
|
|
1230
|
+
sessionId,
|
|
1231
|
+
moved: false,
|
|
1232
|
+
attached: repair.attached,
|
|
1233
|
+
membership: repair.status,
|
|
1234
|
+
message: repair.attached
|
|
1235
|
+
? "会话已归属目标工作区(已补全归属记录)"
|
|
1236
|
+
: "会话已属于目标工作区"
|
|
1237
|
+
};
|
|
900
1238
|
}
|
|
901
1239
|
}
|
|
902
1240
|
|
|
@@ -968,7 +1306,14 @@ ${rest}`, "utf8");
|
|
|
968
1306
|
throw new Error("目标位置已有会话工件,拒绝覆盖");
|
|
969
1307
|
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
970
1308
|
if (oldPath === newPath) throw new Error("会话源路径与目标路径相同,但 cwd 不一致,拒绝覆盖");
|
|
971
|
-
|
|
1309
|
+
// Issue #42: a cold log is the only authority its working-directory
|
|
1310
|
+
// projection folds from on the next resume, so re-home it together
|
|
1311
|
+
// with the artifact header. Live sessions get the equivalent event
|
|
1312
|
+
// through the host path once the artifact has been published.
|
|
1313
|
+
const bodyRehome = liveSession === void 0
|
|
1314
|
+
? rehomeColdWorkingDirectory(rest, targetPath)
|
|
1315
|
+
: { rest, status: "live" };
|
|
1316
|
+
const bytes = await encodeArtifact(JSON.stringify(newHeader), bodyRehome.rest, newPath.endsWith(".zstd"));
|
|
972
1317
|
await mkdir(dirname(newPath), { recursive: true });
|
|
973
1318
|
const tempNew = await writeTempFile(newPath, bytes);
|
|
974
1319
|
const oldHidden = `${oldPath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
@@ -1003,7 +1348,7 @@ ${rest}`, "utf8");
|
|
|
1003
1348
|
const oldIndexedPath = registry.sessionPaths?.get?.(sessionId);
|
|
1004
1349
|
const oldInvalidPath = registry.invalidSessionPaths?.get?.(sessionId);
|
|
1005
1350
|
const fromWorkspaceIds = registry.list()
|
|
1006
|
-
.filter((entity) => entity.id !== target.id && (entity
|
|
1351
|
+
.filter((entity) => entity.id !== target.id && workspaceMemberIds(entity).includes(sessionId))
|
|
1007
1352
|
.map((entity) => entity.id);
|
|
1008
1353
|
let targetAttached = false;
|
|
1009
1354
|
const detachedFrom = [];
|
|
@@ -1069,7 +1414,7 @@ ${rest}`, "utf8");
|
|
|
1069
1414
|
await rm(dirname(oldPath), { recursive: true, force: true });
|
|
1070
1415
|
} catch (error) { ctx.logger.warn(`session-manager: old directory retained: ${String(error)}`); }
|
|
1071
1416
|
|
|
1072
|
-
movedArtifact = { header: newHeader, rest };
|
|
1417
|
+
movedArtifact = { header: newHeader, rest: bodyRehome.rest, workingDirectory: bodyRehome.status };
|
|
1073
1418
|
|
|
1074
1419
|
return {
|
|
1075
1420
|
ok: true,
|
|
@@ -1101,7 +1446,34 @@ ${rest}`, "utf8");
|
|
|
1101
1446
|
ctx.logger.warn(`session-manager: post-move cache refresh failed for "${sessionId}": ${String(error)}`);
|
|
1102
1447
|
}
|
|
1103
1448
|
}
|
|
1104
|
-
|
|
1449
|
+
// Issue #42: the artifact header was rewritten, but the folded
|
|
1450
|
+
// `workingDirectory` projection was not. Align it now -- after the
|
|
1451
|
+
// artifact has been published (a live session's change event must land in
|
|
1452
|
+
// the moved log) and after the registry swap.
|
|
1453
|
+
let workingDirectory = movedArtifact?.workingDirectory ?? "not-needed";
|
|
1454
|
+
let restartRequired = false;
|
|
1455
|
+
if (liveSession !== void 0) {
|
|
1456
|
+
const rehomed = await rehomeLiveWorkingDirectory({ agent: liveAgent, session: liveSession, targetPath });
|
|
1457
|
+
workingDirectory = rehomed.status;
|
|
1458
|
+
if (rehomed.status === "restart-required") restartRequired = true;
|
|
1459
|
+
if (rehomed.status === "synced" || rehomed.status === "recorded") {
|
|
1460
|
+
// The refold above seeds from the projection cache, so it cannot see
|
|
1461
|
+
// a change committed after the move: checkpoint what the live
|
|
1462
|
+
// session now actually folds (this flushes the new event first).
|
|
1463
|
+
if (cache !== void 0 && typeof cache.write === "function") {
|
|
1464
|
+
try {
|
|
1465
|
+
await cache.write(liveSession);
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
ctx.logger.warn(`session-manager: post-move working-directory checkpoint failed for "${sessionId}": ${safeErrorMessage(error)}`);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
} else if (workingDirectory === "restart-required") {
|
|
1472
|
+
restartRequired = true;
|
|
1473
|
+
}
|
|
1474
|
+
result.workingDirectory = workingDirectory;
|
|
1475
|
+
result.restartRequired = restartRequired;
|
|
1476
|
+
// The agent is still live (no teardown). The session's in-memory
|
|
1105
1477
|
// header was rewritten above and the workspace registry updated;
|
|
1106
1478
|
// future writes go through persistence.locate() against the new
|
|
1107
1479
|
// cwd, so the file at the new location accumulates new events.
|
|
@@ -1168,8 +1540,7 @@ ${rest}`, "utf8");
|
|
|
1168
1540
|
throw error;
|
|
1169
1541
|
}
|
|
1170
1542
|
allowedIds = new Set();
|
|
1171
|
-
const
|
|
1172
|
-
if (Array.isArray(ids)) for (const id of ids) allowedIds.add(id);
|
|
1543
|
+
for (const id of workspaceMemberIds(ws)) allowedIds.add(id);
|
|
1173
1544
|
}
|
|
1174
1545
|
}
|
|
1175
1546
|
const requestedSessionId = filter !== void 0 && typeof filter === "object" && typeof filter.sessionId === "string"
|
|
@@ -1818,43 +2189,103 @@ ${rest}`, "utf8");
|
|
|
1818
2189
|
if (typeof persistence?.list !== "function" || typeof registry?.list !== "function") return;
|
|
1819
2190
|
|
|
1820
2191
|
try {
|
|
1821
|
-
//
|
|
1822
|
-
//
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
2192
|
+
// Snapshot durable membership BEFORE the async scan. Only ids that
|
|
2193
|
+
// already existed here may ever be pruned later; ids attached
|
|
2194
|
+
// concurrently must never be removed using an older disk snapshot.
|
|
2195
|
+
let entities;
|
|
2196
|
+
try { entities = registry.list(); }
|
|
2197
|
+
catch (error) {
|
|
2198
|
+
ctx.logger.warn("session-manager: workspace registry unavailable; workspace reconciliation skipped: " + safeErrorMessage(error));
|
|
1827
2199
|
return;
|
|
1828
2200
|
}
|
|
1829
|
-
const
|
|
1830
|
-
const
|
|
2201
|
+
const initialIds = new Map(entities.map(ws => [ws.id, new Set(workspaceMemberIds(ws))]));
|
|
2202
|
+
const scan = await scanSessionHeaders(persistence, adapter.homePath);
|
|
2203
|
+
const headers = Array.isArray(scan.headers) ? scan.headers : [];
|
|
2204
|
+
|
|
2205
|
+
// Step 1a: attach sessions whose cwd names a registered workspace but
|
|
2206
|
+
// that no workspace accounts for (issue #41). Every candidate is
|
|
2207
|
+
// re-validated by the official entity API, which persists through the
|
|
2208
|
+
// domain table and is idempotent for already-accounted ids -- so this
|
|
2209
|
+
// pass can only add well-formed memberships, never corrupt records.
|
|
2210
|
+
// It runs whenever the persistence index listed successfully
|
|
2211
|
+
// (scan.persisted); a partial disk sweep cannot make a listed header
|
|
2212
|
+
// invalid.
|
|
2213
|
+
let added = 0;
|
|
2214
|
+
if (scan.persisted) {
|
|
2215
|
+
const assigned = new Set();
|
|
2216
|
+
for (const ws of entities) for (const id of initialIds.get(ws.id)) assigned.add(id);
|
|
2217
|
+
const candidates = headers.filter(header =>
|
|
2218
|
+
typeof header.cwd === "string" && header.cwd !== ""
|
|
2219
|
+
&& header.origin !== "subagent"
|
|
2220
|
+
&& !assigned.has(header.id));
|
|
2221
|
+
if (candidates.length > 0) {
|
|
2222
|
+
const byPath = new Map();
|
|
2223
|
+
for (const ws of entities) {
|
|
2224
|
+
if (typeof ws.path === "string" && !byPath.has(ws.path)) byPath.set(ws.path, ws);
|
|
2225
|
+
}
|
|
2226
|
+
const attach = async () => {
|
|
2227
|
+
// Re-read membership inside the registry write queue: a concurrent
|
|
2228
|
+
// move may have accounted an id after the snapshot above.
|
|
2229
|
+
const current = typeof registry.list === "function" ? registry.list() : entities;
|
|
2230
|
+
const assignedNow = new Set();
|
|
2231
|
+
for (const ws of current) for (const id of workspaceMemberIds(ws)) assignedNow.add(id);
|
|
2232
|
+
for (const header of candidates) {
|
|
2233
|
+
if (assignedNow.has(header.id)) continue;
|
|
2234
|
+
let target = byPath.get(header.cwd);
|
|
2235
|
+
if (target === void 0) {
|
|
2236
|
+
const canonical = await canonicalizeDirectory(header.cwd);
|
|
2237
|
+
if (canonical !== void 0) target = byPath.get(canonical);
|
|
2238
|
+
}
|
|
2239
|
+
if (target === void 0 || typeof target.attachSession !== "function") continue;
|
|
2240
|
+
try {
|
|
2241
|
+
await target.attachSession(header.id);
|
|
2242
|
+
assignedNow.add(header.id);
|
|
2243
|
+
added += 1;
|
|
2244
|
+
} catch (error) {
|
|
2245
|
+
ctx.logger.warn("session-manager: boot attach of session '" + header.id + "' to workspace '" + target.id + "' failed: " + safeErrorMessage(error));
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
};
|
|
2249
|
+
if (typeof registry.enqueueOperation === "function") await registry.enqueueOperation(attach);
|
|
2250
|
+
else await attach();
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
1831
2253
|
|
|
1832
|
-
// Step
|
|
2254
|
+
// Step 1b: prune record entries whose session no longer exists. Only a
|
|
2255
|
+
// fully complete scan (persistence index plus disk sweep, no errors)
|
|
2256
|
+
// may remove anything; live sessions and ids attached after the
|
|
2257
|
+
// snapshot are always kept. Durable records are read through
|
|
2258
|
+
// workspaceMemberIds so a stale runtime index cannot hide a dead entry.
|
|
1833
2259
|
let removed = 0;
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
const
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
assignedIds.add(header.id);
|
|
1849
|
-
added += 1;
|
|
2260
|
+
if (scan.complete) {
|
|
2261
|
+
const validIds = new Set(headers.map(header => header.id));
|
|
2262
|
+
const prune = async () => {
|
|
2263
|
+
for (const ws of (typeof registry.list === "function" ? registry.list() : entities)) {
|
|
2264
|
+
const initial = initialIds.get(ws.id);
|
|
2265
|
+
// Snapshot: detachSession replaces the record array while we walk it.
|
|
2266
|
+
for (const id of [...workspaceMemberIds(ws)]) {
|
|
2267
|
+
if (validIds.has(id)) continue;
|
|
2268
|
+
if (initial === void 0 || !initial.has(id)) continue;
|
|
2269
|
+
if (adapter.getSession(id) !== undefined) continue;
|
|
2270
|
+
if (typeof ws.detachSession !== "function") continue;
|
|
2271
|
+
await ws.detachSession(id);
|
|
2272
|
+
removed += 1;
|
|
2273
|
+
}
|
|
1850
2274
|
}
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
2275
|
+
};
|
|
2276
|
+
if (typeof registry.enqueueOperation === "function") await registry.enqueueOperation(prune);
|
|
2277
|
+
else await prune();
|
|
2278
|
+
}
|
|
2279
|
+
if (added || removed) {
|
|
2280
|
+
ctx.logger.info('session-manager: post-boot workspace reconcile attached ' + added + ' unaccounted sessions, removed ' + removed + ' orphans');
|
|
2281
|
+
}
|
|
2282
|
+
if (!scan.persisted) {
|
|
2283
|
+
ctx.logger.warn("session-manager: incomplete session scan; workspace reconciliation skipped: " + scan.errors.map(safeErrorMessage).join("; "));
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
if (!scan.complete) {
|
|
2287
|
+
ctx.logger.warn("session-manager: incomplete disk sweep; workspace membership pruning skipped: " + scan.errors.map(safeErrorMessage).join("; "));
|
|
2288
|
+
return;
|
|
1858
2289
|
}
|
|
1859
2290
|
|
|
1860
2291
|
// Step 2: Budgeted Background Projection Cache Rebuild
|
|
@@ -1918,6 +2349,32 @@ ${rest}`, "utf8");
|
|
|
1918
2349
|
}
|
|
1919
2350
|
}, "session-manager: post-boot re-fold stale projection cache rows");
|
|
1920
2351
|
|
|
2352
|
+
/**
|
|
2353
|
+
* Runtime membership repair (issue #41): DSH's own create/fork flow attaches
|
|
2354
|
+
* a session to its workspace, but a session created outside that flow -- or
|
|
2355
|
+
* one whose host-side attach failed -- stays unaccounted even though its
|
|
2356
|
+
* cwd names a registered workspace, and would otherwise only be repaired on
|
|
2357
|
+
* the next DSH restart. Repair it as soon as the session is announced.
|
|
2358
|
+
*
|
|
2359
|
+
* The listener must never throw synchronously: a throwing session/created
|
|
2360
|
+
* listener rolls back session creation in DSH. Everything is contained and
|
|
2361
|
+
* fire-and-forget; failures are logged only.
|
|
2362
|
+
*/
|
|
2363
|
+
if (typeof ctx.on === "function") ctx.on("session/created", (session) => {
|
|
2364
|
+
void (async () => {
|
|
2365
|
+
try {
|
|
2366
|
+
const header = session?.header ?? session;
|
|
2367
|
+
if (header === void 0 || typeof header.id !== "string") return;
|
|
2368
|
+
const attached = await attachSessionToOwningWorkspace(header);
|
|
2369
|
+
if (attached) {
|
|
2370
|
+
ctx.logger.info("session-manager: attached new session '" + header.id + "' to its cwd workspace");
|
|
2371
|
+
}
|
|
2372
|
+
} catch (error) {
|
|
2373
|
+
ctx.logger.warn("session-manager: session/created membership repair failed: " + safeErrorMessage(error));
|
|
2374
|
+
}
|
|
2375
|
+
})();
|
|
2376
|
+
});
|
|
2377
|
+
|
|
1921
2378
|
/** Build a normalized annotation patch from one bulk action + payload.
|
|
1922
2379
|
* Returns null when the payload is missing or malformed so the caller can
|
|
1923
2380
|
* surface a 400 instead of letting the store throw on a bad value.
|
|
@@ -1982,9 +2439,80 @@ ${rest}`, "utf8");
|
|
|
1982
2439
|
if (isBlank) { const e = new Error("blank"); e.code = "blank-skip"; throw e; }
|
|
1983
2440
|
};
|
|
1984
2441
|
|
|
1985
|
-
|
|
2442
|
+
/**
|
|
2443
|
+
* Issue #43: Validate incoming request with DSH connection authentication.
|
|
2444
|
+
* Reuses DSH official Connection/Gateway authentication and authorization mechanisms.
|
|
2445
|
+
* Strict fail-closed: requires connection.admit() to succeed with a valid Owner peer.
|
|
2446
|
+
* Must execute before request body reading, JSON parsing, or business operations.
|
|
2447
|
+
*/
|
|
2448
|
+
const authenticateApiRequest = (req) => {
|
|
2449
|
+
const connection = getService(ctx, "connection");
|
|
2450
|
+
if (!connection || typeof connection.admit !== "function") {
|
|
2451
|
+
return {
|
|
2452
|
+
ok: false,
|
|
2453
|
+
status: 503,
|
|
2454
|
+
code: "auth-unavailable",
|
|
2455
|
+
error: "DSH 认证服务未就绪或不可用,为保护数据安全已拒绝请求"
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
try {
|
|
2459
|
+
const admission = connection.admit(req);
|
|
2460
|
+
if (!admission || typeof admission !== "object") {
|
|
2461
|
+
return {
|
|
2462
|
+
ok: false,
|
|
2463
|
+
status: 401,
|
|
2464
|
+
code: "unauthorized",
|
|
2465
|
+
error: "未授权:需要有效的 DSH 会话凭证"
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
if ("rejection" in admission) {
|
|
2469
|
+
const status = typeof admission.rejection === "number" ? admission.rejection : 401;
|
|
2470
|
+
const error = status === 403
|
|
2471
|
+
? "禁止访问:请求未通过 DSH 信任检查或跨站防护"
|
|
2472
|
+
: "未授权:需要有效的 DSH 会话凭证";
|
|
2473
|
+
return { ok: false, status, code: status === 403 ? "forbidden" : "unauthorized", error };
|
|
2474
|
+
}
|
|
2475
|
+
const peer = admission.peer;
|
|
2476
|
+
if (!peer || typeof peer !== "object") {
|
|
2477
|
+
return {
|
|
2478
|
+
ok: false,
|
|
2479
|
+
status: 401,
|
|
2480
|
+
code: "unauthorized",
|
|
2481
|
+
error: "未授权:缺少有效 Peer 凭证"
|
|
2482
|
+
};
|
|
2483
|
+
}
|
|
2484
|
+
const principalKind = peer.principal?.kind;
|
|
2485
|
+
if (principalKind !== "owner") {
|
|
2486
|
+
return {
|
|
2487
|
+
ok: false,
|
|
2488
|
+
status: 403,
|
|
2489
|
+
code: "forbidden",
|
|
2490
|
+
error: "禁止访问:会话管理属于特权操作,需要 owner 权限"
|
|
2491
|
+
};
|
|
2492
|
+
}
|
|
2493
|
+
return { ok: true, peer };
|
|
2494
|
+
} catch (err) {
|
|
2495
|
+
return {
|
|
2496
|
+
ok: false,
|
|
2497
|
+
status: 403,
|
|
2498
|
+
code: "auth-error",
|
|
2499
|
+
error: "认证检查失败: " + safeErrorMessage(err)
|
|
2500
|
+
};
|
|
2501
|
+
}
|
|
2502
|
+
};
|
|
2503
|
+
|
|
2504
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2505
|
+
kind: "prefix",
|
|
1986
2506
|
path: API_PREFIX,
|
|
1987
2507
|
handler: async (req, res) => {
|
|
2508
|
+
const auth = authenticateApiRequest(req);
|
|
2509
|
+
if (!auth.ok) {
|
|
2510
|
+
return send(res, auth.status, {
|
|
2511
|
+
ok: false,
|
|
2512
|
+
code: auth.code,
|
|
2513
|
+
error: auth.error
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
1988
2516
|
try {
|
|
1989
2517
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1990
2518
|
const path = url.pathname.startsWith(API_PREFIX) ? url.pathname.slice(API_PREFIX.length) || "/" : "/";
|
|
@@ -2215,7 +2743,7 @@ ${rest}`, "utf8");
|
|
|
2215
2743
|
return send(res, 200, { ok: true, result });
|
|
2216
2744
|
}
|
|
2217
2745
|
if (req.method === "GET" && path === "/update/check") {
|
|
2218
|
-
const currentVersion = adapter.pluginVersion() || "0.7.
|
|
2746
|
+
const currentVersion = adapter.pluginVersion() || "0.7.5";
|
|
2219
2747
|
const runningVersion = currentVersion;
|
|
2220
2748
|
let installedVersion = runningVersion;
|
|
2221
2749
|
const profileDir = resolveProfileDir(adapter, ctx);
|
|
@@ -2399,7 +2927,7 @@ ${rest}`, "utf8");
|
|
|
2399
2927
|
};
|
|
2400
2928
|
setActiveTask(taskRecord);
|
|
2401
2929
|
|
|
2402
|
-
const currentVersion = adapter.pluginVersion() || "0.7.
|
|
2930
|
+
const currentVersion = adapter.pluginVersion() || "0.7.5";
|
|
2403
2931
|
let installedVersion = currentVersion;
|
|
2404
2932
|
if (profileDir) {
|
|
2405
2933
|
const pkgFile = join(profileDir, "node_modules", "dsh-session-manager", "package.json");
|
|
@@ -2929,7 +3457,7 @@ ${rest}`, "utf8");
|
|
|
2929
3457
|
}
|
|
2930
3458
|
if (req.method === "GET" && path === "/update/status") {
|
|
2931
3459
|
const profileDir = resolveProfileDir(adapter, ctx);
|
|
2932
|
-
const currentVersion = adapter.pluginVersion() || "0.7.
|
|
3460
|
+
const currentVersion = adapter.pluginVersion() || "0.7.5";
|
|
2933
3461
|
let installedVersion = currentVersion;
|
|
2934
3462
|
if (profileDir) {
|
|
2935
3463
|
const diskCheck = verifyInstalledPackage(profileDir);
|
|
@@ -3004,7 +3532,10 @@ ${rest}`, "utf8");
|
|
|
3004
3532
|
ctx.logger.warn("session-manager: api error: " + safeErrorMessage(error) + "; json=" + safeJson(error));
|
|
3005
3533
|
const status = error?.code === "bad-request" ? 400 : error?.code === "body-too-large" ? 413
|
|
3006
3534
|
: error?.code === "annotation-conflict" || error?.code === "annotations-busy" ? 409
|
|
3007
|
-
: error?.code === "
|
|
3535
|
+
: error?.code === "working-directory-sync-unsafe" ? 409
|
|
3536
|
+
: error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503
|
|
3537
|
+
: error?.code === "workspace-membership-conflict" ? 409
|
|
3538
|
+
: error?.code === "workspace-attach-unavailable" ? 501 : 500;
|
|
3008
3539
|
return send(res, status, {
|
|
3009
3540
|
ok: false,
|
|
3010
3541
|
error: safeErrorMessage(error),
|