@duke-dsh-plugins/dsh-archive-manager 1.3.3 → 1.3.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.
Files changed (4) hide show
  1. package/client.js +530 -515
  2. package/index.js +276 -276
  3. package/package.json +1 -1
  4. package/typert.host.js +224 -224
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",
3
+ "version": "1.3.5",
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",