@michengai/dsh-archive-manager 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,16 @@
1
+ //#region lib/types/index.js
2
+ /**
3
+ * @michengai/dsh-archive-manager 根宿主入口。
4
+ *
5
+ * 发布包是单个 DSH 插件。三个运行时模块通过根包子路径导出:
6
+ *
7
+ * - `./workspace`:宿主工作区服务。
8
+ * - `./projcache`:宿主投影缓存服务。
9
+ * - `./client`:已归档会话管理浏览器客户端 bundle。
10
+ *
11
+ * 根入口对应 `ui-workspace-archive-manager` 服务行。浏览器端由 package.json
12
+ * 的 `dsh.client` 声明发现,因此该宿主入口无需额外行为。
13
+ */
14
+ function apply() {}
15
+ //#endregion
16
+ export { apply };
@@ -0,0 +1,54 @@
1
+ import { SessionProjectionCache } from "@deepseek-ai/dsh-session-projection-cache";
2
+ //#region lib/types/index.js
3
+ /**
4
+ * dsh-archive-manager projcache half.
5
+ *
6
+ * `ArchiveProjectionCache` extends the shipped `SessionProjectionCache`
7
+ * (service name `sessionProjectionCache`, same domain, same fail-soft write
8
+ * path) and adds two public methods used by the archive manager's
9
+ * `deleteSession`:
10
+ *
11
+ * - `delete(id)` — permanently remove one session's cached projection row
12
+ * (`table.delete` on the `session_projcache` domain).
13
+ * - `whenIdle()` — resolve once every in-flight fail-soft checkpoint write
14
+ * has settled. Session disposal triggers one final write-behind
15
+ * (`flushSoft(session, "detach")`); the deletion flow must wait for that
16
+ * write to land BEFORE deleting the row, otherwise the row is written back
17
+ * after deletion and resurrects the cache entry.
18
+ *
19
+ * The default export is a Service subclass (same shape as the shipped
20
+ * `@deepseek-ai/dsh-session-projection-cache` package), so the profile patch
21
+ * can substitute this package for the `session-projection-cache` row with no
22
+ * other wiring change.
23
+ */
24
+ var ArchiveProjectionCache = class extends SessionProjectionCache {
25
+ /** Tail of in-flight fail-soft checkpoint writes (settled promises only). */
26
+ writeTail = Promise.resolve();
27
+ constructor(ctx, config) {
28
+ super(ctx, config);
29
+ }
30
+ /** Fail-soft durable checkpoint, tracked so {@link whenIdle} can observe it. */
31
+ flushSoft(session, trigger) {
32
+ const task = super.flushSoft(session, trigger);
33
+ this.writeTail = Promise.allSettled([this.writeTail, task]).then(() => void 0);
34
+ return task;
35
+ }
36
+ /**
37
+ * Resolve once every tracked in-flight write has settled (failures
38
+ * included). Fresh calls during an idle cache resolve immediately.
39
+ * @returns resolution after the tracked writes settle.
40
+ */
41
+ whenIdle() {
42
+ return this.writeTail;
43
+ }
44
+ /**
45
+ * Permanently remove one session's cached projection row.
46
+ * @param id - the session whose cache row to delete.
47
+ * @returns resolution after the row is gone.
48
+ */
49
+ async delete(id) {
50
+ await this.requireTable().delete(id);
51
+ }
52
+ };
53
+ //#endregion
54
+ export { ArchiveProjectionCache, ArchiveProjectionCache as default };
@@ -0,0 +1,229 @@
1
+ import { rm } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import {
4
+ WorkspaceRegistry,
5
+ WorkspaceUnknownSessionError
6
+ } from "@deepseek-ai/dsh-workspace";
7
+ import { bindTypertRemote, Remote } from "@deepseek-ai/dsh-typert-protocol";
8
+ import { sessionDir } from "@deepseek-ai/dsh-spill-local";
9
+ //#region lib/types/index.js
10
+ /**
11
+ * dsh-archive-manager host half — archive session management.
12
+ *
13
+ * `ArchiveWorkspaceRegistry` extends the shipped `WorkspaceRegistry`
14
+ * (service name `workspaceRegistry`, one domain open, same accounting
15
+ * invariants) and adds two durable operations:
16
+ *
17
+ * - `unarchiveSession(sessionId)` — removes the id from the registry-global
18
+ * `archivedSessionIds` set. Archiving never moves the accounting seat, so
19
+ * unarchiving restores the session at its original workspace position.
20
+ * Idempotent: a session that is not archived resolves without writing.
21
+ *
22
+ * - `deleteSession(sessionId)` — PERMANENT deletion, no trace left on disk:
23
+ * 1. validate the session is known (live, header-indexed, or persisted);
24
+ * 2. if live: flush buffered events to disk, then detach the session from
25
+ * the sessions store (`session/disposed` is emitted, so the browser
26
+ * receives `host/session-removed` and greys the composer);
27
+ * 3. wait for the projection cache's dispose write-behind (`whenIdle`) so
28
+ * the final cache row is durable BEFORE it is deleted;
29
+ * 4. remove the transcript directory (the whole `session-<id>` dir under
30
+ * the persistence root, located through `sessionPersistence.locate`);
31
+ * 5. remove the id from `archivedSessionIds` (durable `setState`);
32
+ * 6. remove the id from every workspace record's `sessionIds` and refresh
33
+ * the in-memory entity snapshots (durable `table.update`, so the host
34
+ * stream pushes `host/workspace-changed` to every browser);
35
+ * 7. delete the projection-cache row (`sessionProjectionCache.delete`);
36
+ * 8. best-effort (failures only warn): cascade-delete SUBAGENT child
37
+ * sessions of the deleted session (`header.origin === "subagent"` ONLY —
38
+ * a fork branch carries `parentSession` too but is an independent user
39
+ * session and is never cascade-deleted) and clear its spill data.
40
+ *
41
+ * Core steps throw on failure (no half-delete states: every failing step is
42
+ * idempotent and re-runnable, so a retry heals); cascade/spill are
43
+ * best-effort by design.
44
+ *
45
+ * The two new methods are also exported as Typert Remote endpoints
46
+ * (`workspaceRegistry/unarchiveSession`, `workspaceRegistry/deleteSession`)
47
+ * through the service's `typertRemote` binding + `Remote` markers — the
48
+ * browser reaches them through the standard typert gateway SRC path, keeping
49
+ * the legacy `/api/workspace.*` gateway untouched.
50
+ *
51
+ * The default export is a Service subclass (same shape as the shipped
52
+ * `@deepseek-ai/dsh-workspace` package), so the profile patch can substitute
53
+ * this package for the `workspace` row with no other wiring change.
54
+ */
55
+ function markRemoteMethod(instance, method) {
56
+ // Simulate the TS decorator pipeline `@Remote(method)` for one method:
57
+ // `Remote` returns a standard method decorator; we hand it a decorator
58
+ // context whose addInitializer runs immediately with `this` = instance.
59
+ const context = {
60
+ private: false,
61
+ static: false,
62
+ name: method,
63
+ addInitializer(fn) {
64
+ fn.call(instance);
65
+ }
66
+ };
67
+ Remote(method)(void 0, context);
68
+ }
69
+ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
70
+ static inject = [
71
+ "storageDomain",
72
+ "sessionPersistence",
73
+ "sessionProjectionCache"
74
+ ];
75
+ constructor(ctx) {
76
+ super(ctx);
77
+ this.typertRemote = bindTypertRemote(this, this.name);
78
+ markRemoteMethod(this, "unarchiveSession");
79
+ markRemoteMethod(this, "deleteSession");
80
+ }
81
+ /**
82
+ * Move one session out of the registry-global archive set, restoring its
83
+ * normal visibility (its accounting seat was never moved, so the session
84
+ * reappears at its original workspace position). Idempotent: an
85
+ * already-unarchived session resolves without writing; an unknown session
86
+ * rejects like `archiveSession` does.
87
+ * @param sessionId - the session to unarchive.
88
+ * @returns the full updated archive set.
89
+ */
90
+ async unarchiveSession(sessionId) {
91
+ return this.enqueueOperation(async () => {
92
+ if (!await this.sessionKnown(sessionId)) throw new WorkspaceUnknownSessionError(sessionId);
93
+ const state = this.requireState();
94
+ if (!state.archivedSessionIds.includes(sessionId)) return { archivedSessionIds: [...state.archivedSessionIds] };
95
+ const next = {
96
+ ...state,
97
+ archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId)
98
+ };
99
+ await this.setState(next);
100
+ return { archivedSessionIds: [...next.archivedSessionIds] };
101
+ });
102
+ }
103
+ /**
104
+ * Permanently delete one session and every trace of it (transcript
105
+ * directory, workspace accounting, archive marker, projection cache row).
106
+ * @param sessionId - the session to delete.
107
+ * @returns `{ deleted: true }` after durability.
108
+ * @throws {@link WorkspaceUnknownSessionError} when the session is unknown.
109
+ */
110
+ async deleteSession(sessionId) {
111
+ return this.enqueueOperation(() => this.deleteSessionCore(sessionId));
112
+ }
113
+ /** The serialized core deletion body (also used by the cascade path, which
114
+ * already holds the operation chain — it must never re-enqueue). */
115
+ async deleteSessionCore(sessionId) {
116
+ if (!await this.sessionKnown(sessionId)) throw new WorkspaceUnknownSessionError(sessionId);
117
+ const sessions = this.ctx.get("sessions");
118
+ const live = sessions?.get(sessionId);
119
+ if (live !== void 0) {
120
+ // Durability barrier first: no pending transcript writes may race
121
+ // the directory removal (the persistence backend closes handles per
122
+ // batch, so a flushed session leaves no open file).
123
+ await sessions.flush(live);
124
+ // Detach from the store; `session/disposed` fires synchronously,
125
+ // which drives the browser `host/session-removed` frame and starts
126
+ // the projection cache's final write-behind.
127
+ const entry = sessions.liveEntryFor(live);
128
+ sessions.detachEntered(entry);
129
+ } else if (sessions !== void 0) await this.publishColdSessionRemoval(sessionId, sessions);
130
+ const projCache = this.ctx.get("sessionProjectionCache");
131
+ // The dispose write-behind must land BEFORE the cache row is deleted,
132
+ // otherwise the row is written back after deletion (resurrected).
133
+ await projCache?.whenIdle?.();
134
+ await this.removeTranscriptDirectory(sessionId);
135
+ const state = this.requireState();
136
+ if (state.archivedSessionIds.includes(sessionId)) {
137
+ await this.setState({
138
+ ...state,
139
+ archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId)
140
+ });
141
+ }
142
+ await this.removeFromWorkspaceAccounts(sessionId);
143
+ if (projCache !== void 0) await projCache.delete(sessionId);
144
+ await this.deleteDescendants(sessionId);
145
+ await this.cleanSpill(sessionId);
146
+ return { deleted: true };
147
+ }
148
+ /** 为未处于实时状态的持久化会话发布相同的移除事件。 */
149
+ async publishColdSessionRemoval(sessionId, sessions) {
150
+ const persistence = this.ctx.get("sessionPersistence");
151
+ if (persistence === void 0 || typeof persistence.prepare !== "function") return;
152
+ try {
153
+ const preparation = await persistence.prepare(sessionId);
154
+ const detach = sessions.enter(preparation.session);
155
+ try {
156
+ sessions.announce(preparation.session);
157
+ } finally {
158
+ detach();
159
+ preparation[Symbol.dispose]();
160
+ }
161
+ } catch (error) {
162
+ this.ctx.logger.warn(`archive-manager: could not publish removal for stored session "${sessionId}": ${String(error)}`);
163
+ }
164
+ }
165
+ /** Remove the session's whole transcript directory (throw on failure —
166
+ * the irreversible step runs before any accounting mutation). */
167
+ async removeTranscriptDirectory(sessionId) {
168
+ const persistence = this.ctx.get("sessionPersistence");
169
+ if (persistence === void 0 || typeof persistence.locate !== "function") {
170
+ throw new Error(`cannot delete session "${sessionId}": the session persistence backend does not expose locate() to resolve its transcript directory`);
171
+ }
172
+ const header = await this.readSessionHeader(sessionId);
173
+ const location = persistence.locate(header);
174
+ await rm(dirname(location.path), { recursive: true, force: true });
175
+ }
176
+ /** Drop the id from every workspace record and refresh entity snapshots. */
177
+ async removeFromWorkspaceAccounts(sessionId) {
178
+ const table = this.requireTable();
179
+ const state = this.requireState();
180
+ for (const workspaceId of state.workspaceIds) {
181
+ const record = table.get(workspaceId);
182
+ if (record === void 0 || !record.sessionIds.includes(sessionId)) continue;
183
+ const next = await table.update(workspaceId, (current) => ({
184
+ ...current,
185
+ sessionIds: current.sessionIds.filter((id) => id !== sessionId),
186
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
187
+ }));
188
+ const entity = this.entities.get(workspaceId);
189
+ if (entity !== void 0) entity.record = next;
190
+ }
191
+ }
192
+ /** Best-effort cascade: delete SUBAGENT child sessions of `sessionId`.
193
+ * Only sessions whose header marks `origin: "subagent"` qualify: `parentSession`
194
+ * alone is ambiguous (fork branches also carry it), and a fork branch is an
195
+ * independent user session that must never be cascade-deleted. */
196
+ async deleteDescendants(sessionId) {
197
+ try {
198
+ const descendants = [];
199
+ const sessions = this.ctx.get("sessions");
200
+ if (sessions !== void 0) for (const session of sessions.list()) {
201
+ if (session.header.parentSession === sessionId && session.header.origin === "subagent") descendants.push(session.id);
202
+ }
203
+ for (const header of await this.ctx.sessionPersistence.list()) {
204
+ if (header.parentSession === sessionId && header.origin === "subagent" && !descendants.includes(header.id)) descendants.push(header.id);
205
+ }
206
+ for (const childId of descendants) {
207
+ try {
208
+ await this.deleteSessionCore(childId);
209
+ } catch (error) {
210
+ this.ctx.logger.warn(`archive-manager: cascade delete of subagent session "${childId}" (child of "${sessionId}") failed: ${String(error)}`);
211
+ }
212
+ }
213
+ } catch (error) {
214
+ this.ctx.logger.warn(`archive-manager: descendant enumeration for deleted session "${sessionId}" failed: ${String(error)}`);
215
+ }
216
+ }
217
+ /** Best-effort spill cleanup: remove the session-scoped spill directory. */
218
+ async cleanSpill(sessionId) {
219
+ try {
220
+ const spill = this.ctx.get("spillStore");
221
+ if (spill === void 0 || typeof spill.root !== "string") return;
222
+ await rm(sessionDir(spill.root, sessionId), { recursive: true, force: true });
223
+ } catch (error) {
224
+ this.ctx.logger.warn(`archive-manager: spill cleanup for deleted session "${sessionId}" failed: ${String(error)}`);
225
+ }
226
+ }
227
+ };
228
+ //#endregion
229
+ export { ArchiveWorkspaceRegistry, ArchiveWorkspaceRegistry as default };
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@michengai/dsh-archive-manager",
3
+ "version": "0.1.0",
4
+ "description": "NPM-installable DSH Web plugin for managing archived sessions.",
5
+ "type": "module",
6
+ "packageManager": "pnpm@11.21.0",
7
+ "engines": {
8
+ "node": "^22.19.0 || >=24.0.0"
9
+ },
10
+ "main": "lib/index.js",
11
+ "exports": {
12
+ ".": "./lib/index.js",
13
+ "./workspace": "./lib/workspace.js",
14
+ "./projcache": "./lib/projcache.js",
15
+ "./client": "./lib/client.js",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "lib",
20
+ "assets",
21
+ "cordis.patch.yml",
22
+ "README.md",
23
+ "README.zh-CN.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "build": "node scripts/check-package.mjs",
28
+ "test": "node --test test/*.test.mjs",
29
+ "pack:check": "pnpm pack --dry-run",
30
+ "prepublishOnly": "pnpm build && pnpm test"
31
+ },
32
+ "license": "Apache-2.0",
33
+ "dsh": {
34
+ "bundle": {
35
+ "patch": "./cordis.patch.yml"
36
+ },
37
+ "client": {
38
+ "inject": [
39
+ "@deepseek-ai/dsh-client-locale",
40
+ "@deepseek-ai/dsh-client-runtime",
41
+ "@deepseek-ai/dsh-client-ui-conversation",
42
+ "@deepseek-ai/dsh-client-ui-sidebar",
43
+ "@deepseek-ai/dsh-typert-registry",
44
+ "@deepseek-ai/dsh-client-connection"
45
+ ],
46
+ "platform": "web"
47
+ }
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/MichengAI/dsh-archive-manager.git"
52
+ },
53
+ "homepage": "https://github.com/MichengAI/dsh-archive-manager#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/MichengAI/dsh-archive-manager/issues"
56
+ },
57
+ "keywords": [
58
+ "deepseek",
59
+ "dsh",
60
+ "dsh-plugin",
61
+ "archive",
62
+ "session-management",
63
+ "cordis",
64
+ "typert",
65
+ "workspace",
66
+ "nodejs",
67
+ "esm"
68
+ ],
69
+ "publishConfig": {
70
+ "access": "public",
71
+ "registry": "https://registry.npmjs.org/"
72
+ }
73
+ }