dsh-long-archive 2.0.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,327 @@
1
+ // Archived-session manager — host half (v2.1).
2
+ //
3
+ // Transport: the official connection RPC channel (`/arcv`, loopback
4
+ // authority) — the platform fence (loopback + Origin/Host + sec-fetch-site)
5
+ // replaces the hand-rolled /__arcv route and its three CSRF layers.
6
+ //
7
+ // Delete pipeline. Common (idle/no-agent) path, fully synchronous in the
8
+ // request: bounded agent teardown → durable log rm → workspace detach →
9
+ // archive-list removal. The log rm runs BEFORE any accounting change, so the
10
+ // session can never surface as a live "ungrouped" row — it vanishes
11
+ // atomically. Busy-agent path: the request returns ok once the teardown has
12
+ // been started (bounded), and a background chain finishes the same order —
13
+ // rm first, de-archive LAST — after the agent's final write-behind events
14
+ // flush (settle + grace), so the persistence coordinator's retirement runs
15
+ // against a live file instead of ENOENT-ing into a permanent leak. Until the
16
+ // chain completes the session stays in archivedSessionIds (hidden), never
17
+ // visible as active.
18
+ import { rm } from 'node:fs/promises'
19
+ import { dirname, parse, resolve } from 'node:path'
20
+
21
+ export const name = 'archive-manager'
22
+
23
+ export const inject = ['connection', 'agents', 'sessionPersistence', 'workspaceRegistry', 'storageDomain', 'sessions']
24
+
25
+ export function apply(ctx) {
26
+ // 防崩:任何加载期异常都只记录日志,绝不抛出——避免 DSH 起不来。
27
+ try {
28
+ const agentDisposers = new Map()
29
+ const agents = ctx.agents
30
+
31
+ // Teardown budget for a busy agent (whenIdle waits for its current turn to
32
+ // settle — unbounded by nature). An idle agent disposes in ms, so the race
33
+ // rarely binds; after the budget the delete switches to the background
34
+ // chain instead of blocking the request.
35
+ const TEARDOWN_WAIT_MS = 1000
36
+ // Grace after a settled teardown before the log rm: the persistence
37
+ // coordinator retires (flushing the agent's final events) shortly AFTER
38
+ // the disposer resolves; the write-behind drain is immediate once
39
+ // barrier-flushed. Half a second covers dispatch + flush with margin.
40
+ const RM_GRACE_MS = 500
41
+
42
+ // ── AgentHandle.dispose capture ──
43
+ // rc.6 has no agents.stop and agent/created carries no disposal capability,
44
+ // so wrapping the registry entry points is the only capture surface. Covers
45
+ // every agent created or resumed through ctx.agents; config-declared agents
46
+ // (agent-loop's internal create path) bypass this — their deletion reports
47
+ // no-captured-disposer and needs a restart, by design.
48
+ const origCreate = agents.create.bind(agents)
49
+ const origResume = agents.resume.bind(agents)
50
+ const disposeWrapper = ctx.effect(() => {
51
+ agents.create = async (options) => {
52
+ const handle = await origCreate(options)
53
+ try { agentDisposers.set(handle.agent.id, handle.dispose) } catch (e) {}
54
+ return handle
55
+ }
56
+ agents.resume = async (options) => {
57
+ const handle = await origResume(options)
58
+ try { agentDisposers.set(handle.agent.id, handle.dispose) } catch (e) {}
59
+ return handle
60
+ }
61
+ return () => {
62
+ agents.create = origCreate
63
+ agents.resume = origResume
64
+ }
65
+ })
66
+
67
+ function archivedIds() {
68
+ const state = ctx.workspaceRegistry.state
69
+ const ids = state !== undefined ? state.archivedSessionIds : undefined
70
+ return Array.isArray(ids) ? ids : []
71
+ }
72
+
73
+ // Serialized read-modify-write on the registry's own operation chain: the
74
+ // registry's canonical mutations (create/delete/order) are also non-atomic
75
+ // multi-step domain writes, so an independent plugin-side chain could
76
+ // interleave and silently roll archivedSessionIds back. enqueueOperation
77
+ // puts this mutation on the same mutual-exclusion chain as every official
78
+ // write; the mirror write reuses the registry's own state reference.
79
+ function mutateArchived(update) {
80
+ return ctx.workspaceRegistry.enqueueOperation(async () => {
81
+ const domain = ctx.storageDomain.get('workspace')
82
+ const state = domain.global.get()
83
+ const archived = Array.isArray(state.archivedSessionIds) ? state.archivedSessionIds : []
84
+ const nextIds = update(archived)
85
+ if (nextIds === archived) return
86
+ await domain.global.set({ ...state, archivedSessionIds: nextIds })
87
+ const registry = ctx.workspaceRegistry
88
+ if (registry.state !== undefined) registry.state.archivedSessionIds = nextIds
89
+ })
90
+ }
91
+
92
+ async function handleUnarchive(args) {
93
+ const id = args.sessionId
94
+ if (typeof id !== 'string' || id === '') return { ok: false, code: 'bad-session-id' }
95
+ if (!archivedIds().includes(id)) return { ok: false, code: 'not-archived' }
96
+ // A delete whose removal is in flight — the sync pipeline (from its first
97
+ // step until finalize lands) or the busy-agent background chain — must
98
+ // finish unconditionally, so an unarchive here would resurrect a session
99
+ // the delete then silently destroys. Refuse until the entry is cleared.
100
+ if (pendingFinalize.has(id)) return { ok: false, code: 'delete-pending' }
101
+ await mutateArchived((archived) => (archived.includes(id) ? archived.filter((x) => x !== id) : archived))
102
+ return { ok: true }
103
+ }
104
+
105
+ // ── durable log removal ──
106
+ // findLog (async!) probes each project directory with existence checks
107
+ // only (no header parsing), including the cwd-less `_no-cwd` bucket. It is
108
+ // a jsonl-backend member, so guard with typeof. Resolves undefined when no
109
+ // log exists — already gone counts as removed.
110
+ //
111
+ // Windows transient-lock retry: `force: true` only suppresses ENOENT — it
112
+ // never hides EBUSY/EPERM. On Windows those surface when the tree is
113
+ // momentarily held (just-closed handle teardown, Defender scan, SMB/9p
114
+ // remaps like \\wsl.localhost), so a single-shot rm used to fail the whole
115
+ // delete with `log-removal-failed`. The short retry ladder (~1s total)
116
+ // absorbs those windows; anything else still fails fast.
117
+ const REMOVE_RETRY_DELAYS_MS = [0, 120, 300, 600]
118
+ const REMOVE_RETRYABLE_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'UNKNOWN', 'ETXTBSY'])
119
+ async function removeLogDir(id) {
120
+ const persistence = ctx.sessionPersistence
121
+ if (persistence === undefined || typeof persistence.findLog !== 'function') return false
122
+ let path
123
+ try { path = await persistence.findLog(id) } catch (e) { return false }
124
+ if (typeof path !== 'string' || path === '') return true
125
+ const dir = resolve(dirname(path))
126
+ // Root guard must be cross-platform: POSIX `/`, Windows drive roots
127
+ // (`C:\`), and UNC share roots all identify themselves as the path root.
128
+ if (parse(dir).root === dir) return false
129
+ for (let attempt = 0; ; attempt++) {
130
+ try {
131
+ await rm(dir, { recursive: true, force: true })
132
+ return true
133
+ } catch (error) {
134
+ const code = error !== null && typeof error === 'object' && typeof error.code === 'string'
135
+ ? error.code
136
+ : undefined
137
+ const retriable = code !== undefined && REMOVE_RETRYABLE_CODES.has(code)
138
+ if (!retriable || attempt === REMOVE_RETRY_DELAYS_MS.length - 1) {
139
+ ctx.logger?.warn(`archive-manager: log removal for ${id} failed${code !== undefined ? ` (${code})` : ''}: ${error instanceof Error ? error.message : String(error)}`)
140
+ return false
141
+ }
142
+ await new Promise((resolve2) => setTimeout(resolve2, REMOVE_RETRY_DELAYS_MS[attempt + 1]))
143
+ }
144
+ }
145
+ }
146
+
147
+ // Accounting AFTER a successful rm: detach from every workspace entity
148
+ // (detachSession is idempotent; calling it unconditionally also cleans
149
+ // durable records the visible sessionIds getter would filter out) and
150
+ // remove the archive flag LAST — the moment the id leaves the archive set
151
+ // it must already be gone from disk, or it would surface as an ungrouped
152
+ // live session.
153
+ async function finalizeRemoval(id) {
154
+ const registry = ctx.workspaceRegistry
155
+ if (registry !== undefined && registry.entities !== undefined && typeof registry.entities.values === 'function') {
156
+ for (const entity of registry.entities.values()) {
157
+ try { await entity.detachSession(id) } catch (e) {}
158
+ }
159
+ }
160
+ await mutateArchived((archived) => (archived.includes(id) ? archived.filter((x) => x !== id) : archived))
161
+ }
162
+
163
+ // Bounded best-effort agent teardown. Both handlers retire the captured
164
+ // entry, so nothing rejects unhandled; a synchronous throw from the
165
+ // disposal call counts as a failed teardown (entry retired, delete
166
+ // proceeds). Returns the settled-teardown promise and whether it settled
167
+ // within the budget, so the caller picks the sync or background pipeline.
168
+ async function teardownAgent(id) {
169
+ const disposer = agentDisposers.get(id)
170
+ if (disposer === undefined) {
171
+ return { state: ctx.agents.get(id) !== undefined ? 'no-captured-disposer' : 'idle', teardown: undefined, settled: true }
172
+ }
173
+ let teardown
174
+ try {
175
+ teardown = disposer()
176
+ } catch (error) {
177
+ agentDisposers.delete(id)
178
+ return { state: 'ok', teardown: undefined, settled: true }
179
+ }
180
+ const settled = teardown.then(
181
+ () => { agentDisposers.delete(id) },
182
+ () => { agentDisposers.delete(id) },
183
+ )
184
+ let settledWithinBudget = false
185
+ let timer
186
+ try {
187
+ await Promise.race([
188
+ settled.then(() => { settledWithinBudget = true }),
189
+ new Promise((resolve2) => { timer = setTimeout(resolve2, TEARDOWN_WAIT_MS) }),
190
+ ])
191
+ } finally {
192
+ if (timer !== undefined) clearTimeout(timer)
193
+ }
194
+ return { state: 'ok', teardown: settled, settled: settledWithinBudget }
195
+ }
196
+
197
+ // ── background finalization for busy agents ──
198
+ // One chain per id. On any step's failure the session simply stays archived
199
+ // (hidden) with its log on disk — the row may reappear in the archive panel
200
+ // after a refresh, which is the honest state for an unfinished delete.
201
+ const pendingFinalize = new Map()
202
+ function scheduleFinalization(id, teardown) {
203
+ if (pendingFinalize.has(id)) return
204
+ const entry = { done: false }
205
+ pendingFinalize.set(id, entry)
206
+ const finish = () => {
207
+ if (entry.done) return
208
+ entry.done = true
209
+ pendingFinalize.delete(id)
210
+ removeLogDir(id).then((removed) => {
211
+ if (removed) return finalizeRemoval(id)
212
+ ctx.logger?.warn(`archive-manager: deferred log removal for ${id} failed; session stays archived`)
213
+ }).catch(() => {})
214
+ }
215
+ // The teardown promise never rejects (both handlers attached in
216
+ // teardownAgent); settle → grace → finish.
217
+ teardown.then(() => { setTimeout(finish, RM_GRACE_MS) })
218
+ }
219
+
220
+ async function handleDelete(args) {
221
+ const id = args.sessionId
222
+ if (typeof id !== 'string' || id === '') return { ok: false, code: 'bad-session-id' }
223
+ // Route-level authorization: only archived members may be deleted (the
224
+ // panel lists exactly these; anything else is a forged request).
225
+ if (!archivedIds().includes(id)) return { ok: false, code: 'not-archived' }
226
+ // Durable removal needs the jsonl findLog surface; without it the delete
227
+ // would strip the archive/workspace accounting and leave the log on disk
228
+ // — fail fast instead of reporting a fake success.
229
+ if (ctx.sessionPersistence === undefined || typeof ctx.sessionPersistence.findLog !== 'function') {
230
+ return { ok: false, code: 'persistence-unavailable' }
231
+ }
232
+ // Live agent: bounded teardown. An agent whose disposer was lost to a
233
+ // host reload is the single hard refusal — only a restart can release it.
234
+ let agentWasLive = false
235
+ let teardown
236
+ let settled = true
237
+ if (ctx.agents.get(id) !== undefined) {
238
+ agentWasLive = true
239
+ const result = await teardownAgent(id)
240
+ if (result.state === 'no-captured-disposer') {
241
+ return { ok: false, code: 'no-captured-disposer' }
242
+ }
243
+ teardown = result.teardown
244
+ settled = result.settled
245
+ }
246
+ // Page-open protection: the client checks its own current session; this
247
+ // host-side recheck covers the gap where the session record is live in
248
+ // another tab/page but its agent already settled here (v1 refused this).
249
+ if (ctx.sessions?.get(id) !== undefined) {
250
+ return { ok: false, code: 'still-live' }
251
+ }
252
+ if (!agentWasLive || settled) {
253
+ // Synchronous pipeline (idle agent or none): the response waits for the
254
+ // real removal, exactly like v1. The grace only covers a settled
255
+ // teardown's in-flight retirement flush. The pipeline registers a
256
+ // pendingFinalize entry for its whole span so a concurrent unarchive in
257
+ // another tab cannot resurrect a session whose log is already gone (the
258
+ // handler refuses delete-pending while it is set).
259
+ const entry = { done: false }
260
+ pendingFinalize.set(id, entry)
261
+ try {
262
+ if (agentWasLive) {
263
+ await new Promise((resolve2) => setTimeout(resolve2, RM_GRACE_MS))
264
+ }
265
+ const removed = await removeLogDir(id)
266
+ if (!removed) return { ok: false, code: 'log-removal-failed' }
267
+ await finalizeRemoval(id)
268
+ return { ok: true }
269
+ } finally {
270
+ pendingFinalize.delete(id)
271
+ }
272
+ }
273
+ // Busy agent: ok now, chain finishes after the turn ends. The session
274
+ // stays archived (hidden) until the log is actually gone.
275
+ scheduleFinalization(id, teardown)
276
+ return { ok: true }
277
+ }
278
+
279
+ const dispatch = {
280
+ unarchive: handleUnarchive,
281
+ delete: handleDelete,
282
+ diag: async (args) => {
283
+ const id = args.sessionId
284
+ if (typeof id !== 'string' || !archivedIds().includes(id)) return { ok: false, code: 'not-archived' }
285
+ return {
286
+ agentLive: ctx.agents.get(id) !== undefined,
287
+ sessionLive: ctx.sessions?.get(id) !== undefined,
288
+ captured: agentDisposers.has(id),
289
+ }
290
+ },
291
+ }
292
+
293
+ // 0.1.5: connection.rpc 对插件不可用(内部 owner.webServer 取不到注入)→ 直接挂 HTTP 路由
294
+ const disposeRoute = ctx.webServer.register({
295
+ kind: 'exact',
296
+ path: '/api/dsh-archive-rpc',
297
+ handler: async (req, res) => {
298
+ try {
299
+ if (req.method !== 'POST') { res.writeHead(405); res.end('method not allowed'); return }
300
+ const chunks = []
301
+ for await (const chunk of req) chunks.push(chunk)
302
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
303
+ const endpoint = String(body.endpoint ?? '')
304
+ const handler = Object.prototype.hasOwnProperty.call(dispatch, endpoint) ? dispatch[endpoint] : undefined
305
+ if (handler === undefined) throw new Error(`unknown endpoint: ${endpoint}`)
306
+ const result = await handler(body.payload ?? {})
307
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
308
+ res.end(JSON.stringify(result ?? {}))
309
+ } catch (error) {
310
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
311
+ res.end(JSON.stringify({ ok: false, code: 'bad-request', message: String((error && error.message) || error) }))
312
+ }
313
+ },
314
+ })
315
+
316
+ return () => {
317
+ disposeRoute()
318
+ disposeWrapper()
319
+ for (const entry of pendingFinalize.values()) entry.done = true
320
+ pendingFinalize.clear()
321
+ }
322
+ } catch (error) {
323
+ try { ctx.logger?.warn?.(`archive-manager: apply failed (skipped, DSH unaffected): ${String((error && error.message) || error)}`) } catch (e) {}
324
+ }
325
+ }
326
+
327
+ export default { name, inject, apply }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "dsh-long-archive",
3
+ "description": "Archived-session manager for DSH Web: sidebar footer entry, grouped archive panel, restore/delete with full agent teardown (DSH 0.1.5-rc.2 适配版)",
4
+ "version": "2.0.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "dsh": {
13
+ "client": {
14
+ "platform": "web",
15
+ "inject": [
16
+ "@deepseek-ai/dsh-client-runtime",
17
+ "@deepseek-ai/dsh-client-locale",
18
+ "@deepseek-ai/dsh-client-connection",
19
+ "@deepseek-ai/dsh-client-ui-slots",
20
+ "@deepseek-ai/dsh-client-ui-primitives",
21
+ "@deepseek-ai/dsh-client-ui-conversation",
22
+ "@deepseek-ai/dsh-client-ui-layout"
23
+ ]
24
+ },
25
+ "bundle": {
26
+ "patch": "./cordis.patch.yml"
27
+ }
28
+ },
29
+ "peerDependencies": {
30
+ "@deepseek-ai/cordis": "^4.0.1",
31
+ "react": "^18.2.0",
32
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
33
+ "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
34
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
35
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
36
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
37
+ "@deepseek-ai/dsh-client-ui-workspace": "^0.1.0-rc.6",
38
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
39
+ "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6",
40
+ "@deepseek-ai/dsh-storage-domain": "^0.1.0-rc.6",
41
+ "@deepseek-ai/dsh-workspace": "^0.1.0-rc.6"
42
+ },
43
+ "files": [
44
+ "lib/index.js",
45
+ "lib/client.js",
46
+ "cordis.patch.yml",
47
+ "README.md",
48
+ "LICENSE"
49
+ ],
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/Jasonrale/dsh-archive-manager.git"
53
+ },
54
+ "keywords": [
55
+ "dsh",
56
+ "deepseek-harness",
57
+ "cordis",
58
+ "plugin",
59
+ "archive"
60
+ ],
61
+ "license": "MIT",
62
+ "engines": {
63
+ "node": ">=22"
64
+ }
65
+ }