@duke-dsh-plugins/dsh-archive-manager 1.3.1 → 1.3.3
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/cordis.patch.yml +1 -1
- package/index.js +276 -276
- package/package.json +1 -1
- package/typert.host.js +1 -1
package/cordis.patch.yml
CHANGED
package/index.js
CHANGED
|
@@ -1,276 +1,276 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-archive-manager — Host half.
|
|
3
|
-
*
|
|
4
|
-
* A Cordis "class plugin": this module exports an `ArchiveManagerService`
|
|
5
|
-
* extending `TypertRemoteService`. The DSH loader instantiates the class and
|
|
6
|
-
* registers it as the `archiveManager` service; the Typert Gateway exposes its
|
|
7
|
-
* `@Remote`-marked methods to the browser Client half under the
|
|
8
|
-
* `archiveManager` Remote namespace.
|
|
9
|
-
*
|
|
10
|
-
* Responsibilities:
|
|
11
|
-
* - `restore`: remove a session from the workspace registry's archive set
|
|
12
|
-
* (durable write through the registry's own state writer, which keeps the
|
|
13
|
-
* registry cache, the domain store and the `host/archived-sessions-changed`
|
|
14
|
-
* client frame in sync).
|
|
15
|
-
* - `delete`: refuse sessions whose agent is actually running, remove the
|
|
16
|
-
* session's durable log directory with plain `node:fs` calls (fully
|
|
17
|
-
* cross-platform: Windows, macOS, Linux), then drop the archive
|
|
18
|
-
* entry. Live-but-idle sessions keep their archive entry so they do not
|
|
19
|
-
* "revive" in the sidebar; the `state` call marks them as ghost.
|
|
20
|
-
* - `state`: report which archived sessions are still live in memory while
|
|
21
|
-
* their log files no longer exist (ghost records the Client hides).
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
25
|
-
import { Service } from "@deepseek-ai/cordis";
|
|
26
|
-
import { existsSync } from "node:fs";
|
|
27
|
-
import { rm } from "node:fs/promises";
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Mark one instance method as a Remote export without relying on decorator
|
|
31
|
-
* syntax (Node ESM does not support the proposal decorators here). We drive
|
|
32
|
-
* the same `Remote(name)` decorator manually through a synthetic decorator
|
|
33
|
-
* context and run the registered initializers against the instance.
|
|
34
|
-
*
|
|
35
|
-
* @param {object} instance - live service instance whose prototype is marked.
|
|
36
|
-
* @param {string} method - public instance method name.
|
|
37
|
-
* @param {string} [exportName] - wire export name; defaults to the method name.
|
|
38
|
-
*/
|
|
39
|
-
function markRemoteMethod(instance, method, exportName) {
|
|
40
|
-
const decorator = Remote(method, undefined);
|
|
41
|
-
const initializers = [];
|
|
42
|
-
decorator(undefined, {
|
|
43
|
-
kind: "method",
|
|
44
|
-
name: method,
|
|
45
|
-
static: false,
|
|
46
|
-
private: false,
|
|
47
|
-
addInitializer: (fn) => initializers.push(fn),
|
|
48
|
-
});
|
|
49
|
-
for (const fn of initializers) fn.call(instance);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Read the current archive set from the authoritative workspace domain, with a
|
|
54
|
-
* fallback to the registry's own getter when the domain is unavailable.
|
|
55
|
-
*/
|
|
56
|
-
function readArchived(domain) {
|
|
57
|
-
if (domain) {
|
|
58
|
-
try {
|
|
59
|
-
const state = domain.global.get();
|
|
60
|
-
if (state && Array.isArray(state.archivedSessionIds)) {
|
|
61
|
-
return [...state.archivedSessionIds];
|
|
62
|
-
}
|
|
63
|
-
} catch {
|
|
64
|
-
/* fall through to registry getter */
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
const registry = this?.ctx?.get?.("workspaceRegistry");
|
|
68
|
-
if (registry && Array.isArray(registry.archivedSessionIds)) {
|
|
69
|
-
return [...registry.archivedSessionIds];
|
|
70
|
-
}
|
|
71
|
-
return [];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export class ArchiveManagerService extends TypertRemoteService {
|
|
75
|
-
static inject = [
|
|
76
|
-
"workspaceRegistry",
|
|
77
|
-
"storageDomain",
|
|
78
|
-
"sessionPersistence",
|
|
79
|
-
"sessionQuery",
|
|
80
|
-
"sessions",
|
|
81
|
-
"agents",
|
|
82
|
-
];
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Cordis instantiates class plugins with `new Callback(ctx, config)` — the
|
|
86
|
-
* second argument is the plugin config, NOT the service key. `TypertRemoteService`
|
|
87
|
-
* has a `(ctx, serviceKey, options)` constructor that validates the key; without
|
|
88
|
-
* an explicit constructor here, `config` is passed as `serviceKey` and the
|
|
89
|
-
* `validateName` check throws during construction. As a result the `archiveManager`
|
|
90
|
-
* service was never registered, so the client stayed pending forever on
|
|
91
|
-
* `remote.archiveManager` (web boot: 1 entry did not activate). Match the official
|
|
92
|
-
* `MessageFeedbackService` pattern: pass the exact service key to `super()`.
|
|
93
|
-
*/
|
|
94
|
-
constructor(ctx, config) {
|
|
95
|
-
super(ctx, "archiveManager");
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Cordis class-plugin initializer: runs right after construction, before the
|
|
100
|
-
* service is published. Mark every Remote method and cache resolved config.
|
|
101
|
-
*/
|
|
102
|
-
[Service.init]() {
|
|
103
|
-
markRemoteMethod(this, "restore", "restore");
|
|
104
|
-
markRemoteMethod(this, "delete", "delete");
|
|
105
|
-
markRemoteMethod(this, "state", "state");
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Resolve the workspace domain (the domain the registry opened). */
|
|
109
|
-
workspaceDomain() {
|
|
110
|
-
const domain = this.ctx.get("storageDomain");
|
|
111
|
-
return domain ? domain.get("workspace") : undefined;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Remove a session from the archive set using the registry's own state
|
|
116
|
-
* writer: durable write + registry cache sync + `domain/changed` emission.
|
|
117
|
-
*/
|
|
118
|
-
async writeArchived(next) {
|
|
119
|
-
const domain = this.workspaceDomain();
|
|
120
|
-
if (!domain) throw new Error("workspace domain unavailable");
|
|
121
|
-
const state = domain.global.get();
|
|
122
|
-
await this.ctx.workspaceRegistry.setState({
|
|
123
|
-
...state,
|
|
124
|
-
archivedSessionIds: next,
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Resolve a session header (live-preferred, then persisted). */
|
|
129
|
-
async findHeader(sessionId) {
|
|
130
|
-
const sessionQuery = this.ctx.get("sessionQuery");
|
|
131
|
-
if (sessionQuery && typeof sessionQuery.listSessions === "function") {
|
|
132
|
-
try {
|
|
133
|
-
const records = await sessionQuery.listSessions();
|
|
134
|
-
const rec = records.find((r) => String(r.header.id) === sessionId);
|
|
135
|
-
if (rec) return rec.header;
|
|
136
|
-
} catch {
|
|
137
|
-
/* fall through */
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
const sessionPersistence = this.ctx.get("sessionPersistence");
|
|
141
|
-
if (sessionPersistence && typeof sessionPersistence.list === "function") {
|
|
142
|
-
try {
|
|
143
|
-
const headers = await sessionPersistence.list();
|
|
144
|
-
return headers.find((h) => String(h.id) === sessionId);
|
|
145
|
-
} catch {
|
|
146
|
-
/* fall through */
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Restore: remove `sessionId` from the archive set so the session returns to
|
|
154
|
-
* its project slot in the sidebar.
|
|
155
|
-
*/
|
|
156
|
-
async restore(request) {
|
|
157
|
-
const sessionId = request && request.sessionId;
|
|
158
|
-
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
159
|
-
return { ok: false, error: { code: "invalid-session" } };
|
|
160
|
-
}
|
|
161
|
-
const ids = readArchived.call(this, this.workspaceDomain());
|
|
162
|
-
if (!ids.includes(sessionId)) {
|
|
163
|
-
return { ok: false, error: { code: "not-archived" } };
|
|
164
|
-
}
|
|
165
|
-
try {
|
|
166
|
-
await this.writeArchived(ids.filter((id) => id !== sessionId));
|
|
167
|
-
return { ok: true, value: { restored: true } };
|
|
168
|
-
} catch (error) {
|
|
169
|
-
return {
|
|
170
|
-
ok: false,
|
|
171
|
-
error: { code: "write-failed", message: String(error && error.message ? error.message : error) },
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Delete: refuse a genuinely running agent, remove the session log directory
|
|
178
|
-
* durably, then clear the archive entry. Live-but-idle sessions keep the
|
|
179
|
-
* entry (ghost); non-live sessions are fully removed.
|
|
180
|
-
*/
|
|
181
|
-
async delete(request) {
|
|
182
|
-
const sessionId = request && request.sessionId;
|
|
183
|
-
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
184
|
-
return { ok: false, error: { code: "invalid-session" } };
|
|
185
|
-
}
|
|
186
|
-
const agents = this.ctx.get("agents");
|
|
187
|
-
let agent;
|
|
188
|
-
try {
|
|
189
|
-
agent = agents ? agents.get(sessionId) : undefined;
|
|
190
|
-
} catch {
|
|
191
|
-
agent = undefined;
|
|
192
|
-
}
|
|
193
|
-
if (agent !== undefined && agent.status === "running") {
|
|
194
|
-
return { ok: false, error: { code: "agent-running", message: "该会话的智能体正在运行中,请等待其完成或停止后再删除" } };
|
|
195
|
-
}
|
|
196
|
-
const header = await this.findHeader(sessionId);
|
|
197
|
-
let location;
|
|
198
|
-
if (header) {
|
|
199
|
-
const sessionPersistence = this.ctx.get("sessionPersistence");
|
|
200
|
-
try {
|
|
201
|
-
location = sessionPersistence.locate(header);
|
|
202
|
-
} catch {
|
|
203
|
-
location = undefined;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
let removed = false;
|
|
207
|
-
let fileError;
|
|
208
|
-
if (location && location.path) {
|
|
209
|
-
const target = String(location.path);
|
|
210
|
-
try {
|
|
211
|
-
// Delete straight from the host process via node:fs — no shell, no
|
|
212
|
-
// PowerShell, no sandbox escalation, so this behaves identically on
|
|
213
|
-
// Windows, macOS and Linux. Composition plugins run in-process with
|
|
214
|
-
// the host's own privileges, which makes the sandbox ACL dance the
|
|
215
|
-
// previous shell-based implementation needed unnecessary. maxRetries
|
|
216
|
-
// absorbs transient Windows file locks (antivirus / indexers) and is
|
|
217
|
-
// a no-op elsewhere.
|
|
218
|
-
await rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
|
219
|
-
removed = !existsSync(target);
|
|
220
|
-
if (!removed) fileError = "path-still-exists";
|
|
221
|
-
} catch (error) {
|
|
222
|
-
fileError = String(error && error.message ? error.message : error);
|
|
223
|
-
}
|
|
224
|
-
} else {
|
|
225
|
-
removed = true; // no located artifact → already gone
|
|
226
|
-
}
|
|
227
|
-
if (!removed) {
|
|
228
|
-
return {
|
|
229
|
-
ok: false,
|
|
230
|
-
error: { code: "delete-failed", message: "删除会话文件失败(" + (fileError || "未知错误") + "),已保留归档条目" },
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
const sessions = this.ctx.get("sessions");
|
|
234
|
-
const live = !!(sessions && sessions.get(sessionId) !== undefined);
|
|
235
|
-
if (!live) {
|
|
236
|
-
const ids = readArchived.call(this, this.workspaceDomain());
|
|
237
|
-
if (ids.includes(sessionId)) {
|
|
238
|
-
try {
|
|
239
|
-
await this.writeArchived(ids.filter((id) => id !== sessionId));
|
|
240
|
-
} catch {
|
|
241
|
-
/* log already gone; entry cleanup best-effort */
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return { ok: true, value: { fileRemoved: !!(location && location.path), live } };
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* State: report archived sessions that are still live in memory while their
|
|
250
|
-
* log files no longer exist. The Client hides these (ghost) and tells the
|
|
251
|
-
* user they clear on restart.
|
|
252
|
-
*/
|
|
253
|
-
async state() {
|
|
254
|
-
const sessions = this.ctx.get("sessions");
|
|
255
|
-
const sessionPersistence = this.ctx.get("sessionPersistence");
|
|
256
|
-
if (!sessions || !sessionPersistence || typeof sessionPersistence.locate !== "function") {
|
|
257
|
-
return { ok: true, value: { ghostIds: [] } };
|
|
258
|
-
}
|
|
259
|
-
const ids = readArchived.call(this, this.workspaceDomain());
|
|
260
|
-
const ghostIds = [];
|
|
261
|
-
for (const id of ids) {
|
|
262
|
-
try {
|
|
263
|
-
const session = sessions.get(id);
|
|
264
|
-
if (session === undefined) continue; // not live in memory → not a ghost
|
|
265
|
-
const location = sessionPersistence.locate(session.header);
|
|
266
|
-
// Live but the log directory is gone → ghost the client hides.
|
|
267
|
-
if (location && location.path && !existsSync(String(location.path))) ghostIds.push(id);
|
|
268
|
-
} catch {
|
|
269
|
-
/* skip */
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
return { ok: true, value: { ghostIds } };
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
export default ArchiveManagerService;
|
|
1
|
+
/**
|
|
2
|
+
* dsh-archive-manager — Host half.
|
|
3
|
+
*
|
|
4
|
+
* A Cordis "class plugin": this module exports an `ArchiveManagerService`
|
|
5
|
+
* extending `TypertRemoteService`. The DSH loader instantiates the class and
|
|
6
|
+
* registers it as the `archiveManager` service; the Typert Gateway exposes its
|
|
7
|
+
* `@Remote`-marked methods to the browser Client half under the
|
|
8
|
+
* `archiveManager` Remote namespace.
|
|
9
|
+
*
|
|
10
|
+
* Responsibilities:
|
|
11
|
+
* - `restore`: remove a session from the workspace registry's archive set
|
|
12
|
+
* (durable write through the registry's own state writer, which keeps the
|
|
13
|
+
* registry cache, the domain store and the `host/archived-sessions-changed`
|
|
14
|
+
* client frame in sync).
|
|
15
|
+
* - `delete`: refuse sessions whose agent is actually running, remove the
|
|
16
|
+
* session's durable log directory with plain `node:fs` calls (fully
|
|
17
|
+
* cross-platform: Windows, macOS, Linux), then drop the archive
|
|
18
|
+
* entry. Live-but-idle sessions keep their archive entry so they do not
|
|
19
|
+
* "revive" in the sidebar; the `state` call marks them as ghost.
|
|
20
|
+
* - `state`: report which archived sessions are still live in memory while
|
|
21
|
+
* their log files no longer exist (ghost records the Client hides).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
25
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
import { rm } from "node:fs/promises";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Mark one instance method as a Remote export without relying on decorator
|
|
31
|
+
* syntax (Node ESM does not support the proposal decorators here). We drive
|
|
32
|
+
* the same `Remote(name)` decorator manually through a synthetic decorator
|
|
33
|
+
* context and run the registered initializers against the instance.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} instance - live service instance whose prototype is marked.
|
|
36
|
+
* @param {string} method - public instance method name.
|
|
37
|
+
* @param {string} [exportName] - wire export name; defaults to the method name.
|
|
38
|
+
*/
|
|
39
|
+
function markRemoteMethod(instance, method, exportName) {
|
|
40
|
+
const decorator = Remote(method, undefined);
|
|
41
|
+
const initializers = [];
|
|
42
|
+
decorator(undefined, {
|
|
43
|
+
kind: "method",
|
|
44
|
+
name: method,
|
|
45
|
+
static: false,
|
|
46
|
+
private: false,
|
|
47
|
+
addInitializer: (fn) => initializers.push(fn),
|
|
48
|
+
});
|
|
49
|
+
for (const fn of initializers) fn.call(instance);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the current archive set from the authoritative workspace domain, with a
|
|
54
|
+
* fallback to the registry's own getter when the domain is unavailable.
|
|
55
|
+
*/
|
|
56
|
+
function readArchived(domain) {
|
|
57
|
+
if (domain) {
|
|
58
|
+
try {
|
|
59
|
+
const state = domain.global.get();
|
|
60
|
+
if (state && Array.isArray(state.archivedSessionIds)) {
|
|
61
|
+
return [...state.archivedSessionIds];
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
/* fall through to registry getter */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const registry = this?.ctx?.get?.("workspaceRegistry");
|
|
68
|
+
if (registry && Array.isArray(registry.archivedSessionIds)) {
|
|
69
|
+
return [...registry.archivedSessionIds];
|
|
70
|
+
}
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class ArchiveManagerService extends TypertRemoteService {
|
|
75
|
+
static inject = [
|
|
76
|
+
"workspaceRegistry",
|
|
77
|
+
"storageDomain",
|
|
78
|
+
"sessionPersistence",
|
|
79
|
+
"sessionQuery",
|
|
80
|
+
"sessions",
|
|
81
|
+
"agents",
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Cordis instantiates class plugins with `new Callback(ctx, config)` — the
|
|
86
|
+
* second argument is the plugin config, NOT the service key. `TypertRemoteService`
|
|
87
|
+
* has a `(ctx, serviceKey, options)` constructor that validates the key; without
|
|
88
|
+
* an explicit constructor here, `config` is passed as `serviceKey` and the
|
|
89
|
+
* `validateName` check throws during construction. As a result the `archiveManager`
|
|
90
|
+
* service was never registered, so the client stayed pending forever on
|
|
91
|
+
* `remote.archiveManager` (web boot: 1 entry did not activate). Match the official
|
|
92
|
+
* `MessageFeedbackService` pattern: pass the exact service key to `super()`.
|
|
93
|
+
*/
|
|
94
|
+
constructor(ctx, config) {
|
|
95
|
+
super(ctx, "archiveManager");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Cordis class-plugin initializer: runs right after construction, before the
|
|
100
|
+
* service is published. Mark every Remote method and cache resolved config.
|
|
101
|
+
*/
|
|
102
|
+
[Service.init]() {
|
|
103
|
+
markRemoteMethod(this, "restore", "restore");
|
|
104
|
+
markRemoteMethod(this, "delete", "delete");
|
|
105
|
+
markRemoteMethod(this, "state", "state");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Resolve the workspace domain (the domain the registry opened). */
|
|
109
|
+
workspaceDomain() {
|
|
110
|
+
const domain = this.ctx.get("storageDomain");
|
|
111
|
+
return domain ? domain.get("workspace") : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remove a session from the archive set using the registry's own state
|
|
116
|
+
* writer: durable write + registry cache sync + `domain/changed` emission.
|
|
117
|
+
*/
|
|
118
|
+
async writeArchived(next) {
|
|
119
|
+
const domain = this.workspaceDomain();
|
|
120
|
+
if (!domain) throw new Error("workspace domain unavailable");
|
|
121
|
+
const state = domain.global.get();
|
|
122
|
+
await this.ctx.workspaceRegistry.setState({
|
|
123
|
+
...state,
|
|
124
|
+
archivedSessionIds: next,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Resolve a session header (live-preferred, then persisted). */
|
|
129
|
+
async findHeader(sessionId) {
|
|
130
|
+
const sessionQuery = this.ctx.get("sessionQuery");
|
|
131
|
+
if (sessionQuery && typeof sessionQuery.listSessions === "function") {
|
|
132
|
+
try {
|
|
133
|
+
const records = await sessionQuery.listSessions();
|
|
134
|
+
const rec = records.find((r) => String(r.header.id) === sessionId);
|
|
135
|
+
if (rec) return rec.header;
|
|
136
|
+
} catch {
|
|
137
|
+
/* fall through */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const sessionPersistence = this.ctx.get("sessionPersistence");
|
|
141
|
+
if (sessionPersistence && typeof sessionPersistence.list === "function") {
|
|
142
|
+
try {
|
|
143
|
+
const headers = await sessionPersistence.list();
|
|
144
|
+
return headers.find((h) => String(h.id) === sessionId);
|
|
145
|
+
} catch {
|
|
146
|
+
/* fall through */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Restore: remove `sessionId` from the archive set so the session returns to
|
|
154
|
+
* its project slot in the sidebar.
|
|
155
|
+
*/
|
|
156
|
+
async restore(request) {
|
|
157
|
+
const sessionId = request && request.sessionId;
|
|
158
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
159
|
+
return { ok: false, error: { code: "invalid-session" } };
|
|
160
|
+
}
|
|
161
|
+
const ids = readArchived.call(this, this.workspaceDomain());
|
|
162
|
+
if (!ids.includes(sessionId)) {
|
|
163
|
+
return { ok: false, error: { code: "not-archived" } };
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
await this.writeArchived(ids.filter((id) => id !== sessionId));
|
|
167
|
+
return { ok: true, value: { restored: true } };
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
error: { code: "write-failed", message: String(error && error.message ? error.message : error) },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Delete: refuse a genuinely running agent, remove the session log directory
|
|
178
|
+
* durably, then clear the archive entry. Live-but-idle sessions keep the
|
|
179
|
+
* entry (ghost); non-live sessions are fully removed.
|
|
180
|
+
*/
|
|
181
|
+
async delete(request) {
|
|
182
|
+
const sessionId = request && request.sessionId;
|
|
183
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
184
|
+
return { ok: false, error: { code: "invalid-session" } };
|
|
185
|
+
}
|
|
186
|
+
const agents = this.ctx.get("agents");
|
|
187
|
+
let agent;
|
|
188
|
+
try {
|
|
189
|
+
agent = agents ? agents.get(sessionId) : undefined;
|
|
190
|
+
} catch {
|
|
191
|
+
agent = undefined;
|
|
192
|
+
}
|
|
193
|
+
if (agent !== undefined && agent.status === "running") {
|
|
194
|
+
return { ok: false, error: { code: "agent-running", message: "该会话的智能体正在运行中,请等待其完成或停止后再删除" } };
|
|
195
|
+
}
|
|
196
|
+
const header = await this.findHeader(sessionId);
|
|
197
|
+
let location;
|
|
198
|
+
if (header) {
|
|
199
|
+
const sessionPersistence = this.ctx.get("sessionPersistence");
|
|
200
|
+
try {
|
|
201
|
+
location = sessionPersistence.locate(header);
|
|
202
|
+
} catch {
|
|
203
|
+
location = undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let removed = false;
|
|
207
|
+
let fileError;
|
|
208
|
+
if (location && location.path) {
|
|
209
|
+
const target = String(location.path);
|
|
210
|
+
try {
|
|
211
|
+
// Delete straight from the host process via node:fs — no shell, no
|
|
212
|
+
// PowerShell, no sandbox escalation, so this behaves identically on
|
|
213
|
+
// Windows, macOS and Linux. Composition plugins run in-process with
|
|
214
|
+
// the host's own privileges, which makes the sandbox ACL dance the
|
|
215
|
+
// previous shell-based implementation needed unnecessary. maxRetries
|
|
216
|
+
// absorbs transient Windows file locks (antivirus / indexers) and is
|
|
217
|
+
// a no-op elsewhere.
|
|
218
|
+
await rm(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
|
219
|
+
removed = !existsSync(target);
|
|
220
|
+
if (!removed) fileError = "path-still-exists";
|
|
221
|
+
} catch (error) {
|
|
222
|
+
fileError = String(error && error.message ? error.message : error);
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
removed = true; // no located artifact → already gone
|
|
226
|
+
}
|
|
227
|
+
if (!removed) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
error: { code: "delete-failed", message: "删除会话文件失败(" + (fileError || "未知错误") + "),已保留归档条目" },
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const sessions = this.ctx.get("sessions");
|
|
234
|
+
const live = !!(sessions && sessions.get(sessionId) !== undefined);
|
|
235
|
+
if (!live) {
|
|
236
|
+
const ids = readArchived.call(this, this.workspaceDomain());
|
|
237
|
+
if (ids.includes(sessionId)) {
|
|
238
|
+
try {
|
|
239
|
+
await this.writeArchived(ids.filter((id) => id !== sessionId));
|
|
240
|
+
} catch {
|
|
241
|
+
/* log already gone; entry cleanup best-effort */
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { ok: true, value: { fileRemoved: !!(location && location.path), live } };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* State: report archived sessions that are still live in memory while their
|
|
250
|
+
* log files no longer exist. The Client hides these (ghost) and tells the
|
|
251
|
+
* user they clear on restart.
|
|
252
|
+
*/
|
|
253
|
+
async state() {
|
|
254
|
+
const sessions = this.ctx.get("sessions");
|
|
255
|
+
const sessionPersistence = this.ctx.get("sessionPersistence");
|
|
256
|
+
if (!sessions || !sessionPersistence || typeof sessionPersistence.locate !== "function") {
|
|
257
|
+
return { ok: true, value: { ghostIds: [] } };
|
|
258
|
+
}
|
|
259
|
+
const ids = readArchived.call(this, this.workspaceDomain());
|
|
260
|
+
const ghostIds = [];
|
|
261
|
+
for (const id of ids) {
|
|
262
|
+
try {
|
|
263
|
+
const session = sessions.get(id);
|
|
264
|
+
if (session === undefined) continue; // not live in memory → not a ghost
|
|
265
|
+
const location = sessionPersistence.locate(session.header);
|
|
266
|
+
// Live but the log directory is gone → ghost the client hides.
|
|
267
|
+
if (location && location.path && !existsSync(String(location.path))) ghostIds.push(id);
|
|
268
|
+
} catch {
|
|
269
|
+
/* skip */
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { ok: true, value: { ghostIds } };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export default ArchiveManagerService;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duke-dsh-plugins/dsh-archive-manager",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"description": "Archive manager for DeepSeek Harness: group archived sessions by project, newest-first, with search, restore and delete.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
package/typert.host.js
CHANGED