@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/LICENSE +201 -0
- package/README.md +71 -0
- package/README.zh-CN.md +71 -0
- package/assets/icon.png +0 -0
- package/assets/screenshots/archived-sessions.png +0 -0
- package/cordis.patch.yml +19 -0
- package/lib/client.js +2903 -0
- package/lib/index.js +16 -0
- package/lib/projcache.js +54 -0
- package/lib/workspace.js +229 -0
- package/package.json +73 -0
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 };
|
package/lib/projcache.js
ADDED
|
@@ -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 };
|
package/lib/workspace.js
ADDED
|
@@ -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
|
+
}
|