@wjj-8283/dsh-temp-workspace 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 +21 -0
- package/README.md +263 -0
- package/README.zh.md +207 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +827 -0
- package/lib/index.js +1446 -0
- package/package.json +68 -0
- package/src/client.js +816 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1446 @@
|
|
|
1
|
+
// @wjj-8283/dsh-temp-workspace — node half.
|
|
2
|
+
//
|
|
3
|
+
// Adds a "temporary workspace" (临时工作区) feature to the DeepSeek Harness web
|
|
4
|
+
// UI. A temporary workspace is an ordinary registry Workspace over a throwaway
|
|
5
|
+
// directory; every Session it owns is also temporary. On the next Harness
|
|
6
|
+
// start this plugin deletes those conversations (their session logs on disk)
|
|
7
|
+
// AND the workspace record itself, leaving no trace.
|
|
8
|
+
//
|
|
9
|
+
// Two halves:
|
|
10
|
+
// - Host half (this file): registers a browser-trust-fenced /temp-workspace/api
|
|
11
|
+
// route (create/delete/list + the settings config/pending/confirm/keep
|
|
12
|
+
// surfacing) and, at boot, cleans up the workspaces recorded as temporary.
|
|
13
|
+
// - Browser half (./client.js): injects a small icon button beside the
|
|
14
|
+
// sidebar's "Add workspace" (+) button. Clicking it asks this route to
|
|
15
|
+
// create a temporary workspace and opens a new Session in it. It also
|
|
16
|
+
// renders a Settings -> Plugins card and, when confirmBeforeDelete is on,
|
|
17
|
+
// a popup on boot before removing held workspaces.
|
|
18
|
+
//
|
|
19
|
+
// There is no public "delete session log" API in DSH (the persistence seam is
|
|
20
|
+
// append-only), so cleanup deletes the session directories on disk directly
|
|
21
|
+
// (computed through ctx.sessionPersistence.locate) before removing the
|
|
22
|
+
// workspace registration (workspaceRegistry.delete). Deleting a workspace
|
|
23
|
+
// registration retains the directory and session logs, so this plugin removes
|
|
24
|
+
// both explicitly. Deletion is driven by a durable marker (path + id) so a
|
|
25
|
+
// workspace whose registry record was already removed via the UI before a
|
|
26
|
+
// restart still reaps its leftover directory.
|
|
27
|
+
|
|
28
|
+
import { mkdir, readFile, writeFile, rename, rm, cp, stat, readdir } from 'node:fs/promises'
|
|
29
|
+
import { spawn } from 'node:child_process'
|
|
30
|
+
import { existsSync } from 'node:fs'
|
|
31
|
+
import { join, dirname, resolve, isAbsolute } from 'node:path'
|
|
32
|
+
import { homedir, tmpdir } from 'node:os'
|
|
33
|
+
import { randomUUID } from 'node:crypto'
|
|
34
|
+
import { promisify } from 'node:util'
|
|
35
|
+
import { zstdCompress, zstdDecompress } from 'node:zlib'
|
|
36
|
+
|
|
37
|
+
const zstdCompressAsync = promisify(zstdCompress)
|
|
38
|
+
const zstdDecompressAsync = promisify(zstdDecompress)
|
|
39
|
+
|
|
40
|
+
const PLUGIN_ID = 'dsh-temp-workspace'
|
|
41
|
+
const TEMP_TITLE = '临时工作区'
|
|
42
|
+
const SETTINGS_NS = 'dsh-temp-workspace'
|
|
43
|
+
// Default behavior when the user has not configured anything: delete on the
|
|
44
|
+
// next boot immediately, but confirm with the user before doing so.
|
|
45
|
+
const DEFAULT_SETTINGS = Object.freeze({
|
|
46
|
+
deleteMode: 'immediate', // 'immediate' | 'delayed'
|
|
47
|
+
deleteDelay: 3600, // seconds to wait after boot when deleteMode === 'delayed'
|
|
48
|
+
confirmBeforeDelete: true,
|
|
49
|
+
})
|
|
50
|
+
// A marker file under the DSH home listing every workspace currently marked
|
|
51
|
+
// temporary. Keep it next to the throwaway workspace dirs so a single recursive
|
|
52
|
+
// removal of the root dir never nukes the marker while a workspace exists.
|
|
53
|
+
const root = () => join(dshHome(), 'temp-workspaces')
|
|
54
|
+
const statePath = () => join(root(), 'state.json')
|
|
55
|
+
|
|
56
|
+
// The original (unwrapped) workspace registry `delete` method, captured in
|
|
57
|
+
// `apply`. The plugin's own cleanup path calls this so it never re-enters the
|
|
58
|
+
// temp-cleanup hook, while external (native sidebar) deletes go through the
|
|
59
|
+
// wrapped version that reaps the temp workspace's files first.
|
|
60
|
+
let registryDeleteUnwrapped = null
|
|
61
|
+
|
|
62
|
+
/** The DSH home: $DSH_HOME, else ~/.dsh (mirror of @deepseek-ai/dsh-home-paths). */
|
|
63
|
+
function dshHome() {
|
|
64
|
+
const env = process.env.DSH_HOME
|
|
65
|
+
if (typeof env === 'string' && env.trim() !== '') return env.trim()
|
|
66
|
+
return join(homedir(), '.dsh')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mirror of the JSONL persistence store's `projectKey(cwd)` (kept in
|
|
71
|
+
* @deepseek-ai/dsh-session-persistence-jsonl). Returns the single filesystem-safe
|
|
72
|
+
* project directory name for a workspace cwd, e.g. a temp workspace at
|
|
73
|
+
* `/Users/wjj/.dsh/temp-workspaces/<uuid>` becoming
|
|
74
|
+
* `--Users-wjj-.dsh-temp-workspaces-<uuid>--`. Replicating the exact encoding
|
|
75
|
+
* is what lets us identify the session project dirs a temp workspace owns.
|
|
76
|
+
*/
|
|
77
|
+
function projectKey(cwd) {
|
|
78
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return '_no-cwd'
|
|
79
|
+
let readable = ''
|
|
80
|
+
let separatorRun = false
|
|
81
|
+
for (let i = 0; i < cwd.length; i++) {
|
|
82
|
+
const code = cwd.charCodeAt(i)
|
|
83
|
+
const ch = String.fromCharCode(code)
|
|
84
|
+
if (ch === '/' || ch === '\\' || ch === ':') {
|
|
85
|
+
if (!separatorRun) readable += '-'
|
|
86
|
+
separatorRun = true
|
|
87
|
+
} else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
88
|
+
readable += ch
|
|
89
|
+
separatorRun = false
|
|
90
|
+
} else {
|
|
91
|
+
readable += '~' + code.toString(16).toUpperCase().padStart(4, '0')
|
|
92
|
+
separatorRun = false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The session project directory name that `cwd`'s sessions live under. */
|
|
99
|
+
function projectDirName(cwd) {
|
|
100
|
+
return projectKey(cwd)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Remove a session project directory once it holds nothing but junk (a leftover
|
|
105
|
+
* `.DS_Store`) — i.e. after every real session dir inside it is gone. This is
|
|
106
|
+
* what prevents the empty `--<cwd>--` directories from accumulating after a
|
|
107
|
+
* temp workspace's logs are cleaned or migrated. Only fires when there is no
|
|
108
|
+
* session content left, so it can never drop a live conversation.
|
|
109
|
+
*/
|
|
110
|
+
async function pruneEmptyProjectDir(projectDir) {
|
|
111
|
+
try {
|
|
112
|
+
const entries = await readdir(projectDir)
|
|
113
|
+
if (entries.length === 0) {
|
|
114
|
+
await rm(projectDir, { recursive: true, force: true }).catch(() => {})
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
// macOS Finder often leaves a stray .DS_Store; treat that alone as empty.
|
|
118
|
+
if (entries.every((name) => name === '.DS_Store')) {
|
|
119
|
+
await rm(projectDir, { recursive: true, force: true }).catch(() => {})
|
|
120
|
+
}
|
|
121
|
+
} catch { /* already absent */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── durable marker state ────────────────────────────────────────────────────
|
|
125
|
+
/** Read the temp-workspace marker list (missing/corrupt file => []). */
|
|
126
|
+
async function readState() {
|
|
127
|
+
try {
|
|
128
|
+
const raw = await readFile(statePath(), 'utf8')
|
|
129
|
+
const parsed = JSON.parse(raw)
|
|
130
|
+
if (parsed && Array.isArray(parsed.tempWorkspaces)) return parsed.tempWorkspaces
|
|
131
|
+
} catch { /* first boot / absent / corrupt — start empty */ }
|
|
132
|
+
return []
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Atomically persist the temp-workspace marker list. */
|
|
136
|
+
async function writeState(list) {
|
|
137
|
+
const dir = dirname(statePath())
|
|
138
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
139
|
+
const tmp = `${statePath()}.tmp`
|
|
140
|
+
await writeFile(tmp, JSON.stringify({ tempWorkspaces: list }, null, 2), { mode: 0o600 })
|
|
141
|
+
await rename(tmp, statePath())
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── pending session re-attach (survives the restart the client prompts) ─────
|
|
145
|
+
// The registry's session→workspace grouping (bootstrap) runs only once on the
|
|
146
|
+
// first boot. Migrated sessions are moved + their cwd rewritten on disk, but the
|
|
147
|
+
// already-initialized registry never adds them to the new workspace's sessionIds.
|
|
148
|
+
// So we persist a "pending attach" list and re-attach on the next boot, when
|
|
149
|
+
// `replaceHeaderIndex` has indexed the migrated headers with the fresh cwd.
|
|
150
|
+
const pendingAttachPath = () => join(root(), 'pending-attach.json')
|
|
151
|
+
|
|
152
|
+
/** Read pending session-attach jobs (missing/corrupt file => []). */
|
|
153
|
+
async function readPendingAttach() {
|
|
154
|
+
try {
|
|
155
|
+
const raw = await readFile(pendingAttachPath(), 'utf8')
|
|
156
|
+
const parsed = JSON.parse(raw)
|
|
157
|
+
if (parsed && Array.isArray(parsed.jobs)) return parsed.jobs
|
|
158
|
+
} catch { /* first boot / absent / corrupt */ }
|
|
159
|
+
return []
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Atomically persist the pending session-attach jobs. */
|
|
163
|
+
async function writePendingAttach(jobs) {
|
|
164
|
+
const dir = dirname(pendingAttachPath())
|
|
165
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
166
|
+
const tmp = `${pendingAttachPath()}.tmp`
|
|
167
|
+
await writeFile(tmp, JSON.stringify({ jobs }, null, 2), { mode: 0o600 })
|
|
168
|
+
await rename(tmp, pendingAttachPath())
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Append one pending-attach job (workspaceId + migrated session ids). */
|
|
172
|
+
async function addPendingAttach(workspaceId, path, sessionIds) {
|
|
173
|
+
if (sessionIds.length === 0) return
|
|
174
|
+
const jobs = await readPendingAttach()
|
|
175
|
+
jobs.push({ workspaceId, path, sessionIds, at: new Date().toISOString() })
|
|
176
|
+
await writePendingAttach(jobs)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Re-attach every persisted migrated session to its new workspace. Returns the
|
|
181
|
+
* number of sessions attached; clears the pending list even for jobs that could
|
|
182
|
+
* not be resolved (so a stale record never blocks later ones).
|
|
183
|
+
*/
|
|
184
|
+
async function reattachPendingSessions(ctx) {
|
|
185
|
+
const jobs = await readPendingAttach()
|
|
186
|
+
if (jobs.length === 0) return 0
|
|
187
|
+
let attached = 0
|
|
188
|
+
for (const job of jobs) {
|
|
189
|
+
if (job === null || typeof job !== 'object' || !Array.isArray(job.sessionIds)) continue
|
|
190
|
+
const ws = ctx.workspaceRegistry.get(job.workspaceId)
|
|
191
|
+
if (ws === undefined) {
|
|
192
|
+
console.warn(`[${PLUGIN_ID}] pending re-attach: workspace ${job.workspaceId} no longer exists; dropping`)
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
for (const sessionId of job.sessionIds) {
|
|
196
|
+
if (typeof sessionId !== 'string') continue
|
|
197
|
+
try {
|
|
198
|
+
await ws.attachSession(sessionId)
|
|
199
|
+
attached += 1
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.error(`[${PLUGIN_ID}] re-attach ${sessionId} failed`, error)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
await writePendingAttach([])
|
|
206
|
+
return attached
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── workspace helpers ───────────────────────────────────────────────────────
|
|
210
|
+
/** Project a registry Workspace into the wire WorkspaceView shape. */
|
|
211
|
+
function toView(ws) {
|
|
212
|
+
return {
|
|
213
|
+
workspaceId: ws.id,
|
|
214
|
+
path: ws.path,
|
|
215
|
+
title: ws.title,
|
|
216
|
+
sessionIds: [...ws.sessionIds],
|
|
217
|
+
createdAt: ws.createdAt,
|
|
218
|
+
updatedAt: ws.updatedAt,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Remove from disk every session log living under `cwd` (the temp workspace dir). */
|
|
223
|
+
async function deleteSessionsUnderPath(ctx, cwd) {
|
|
224
|
+
// Remove the whole cwd project directory rather than matching individual
|
|
225
|
+
// headers. The JSONL store groups a workspace's session logs under
|
|
226
|
+
// `<sessionsRoot>/--<projectKey(cwd)>--`, so deleting that directory clears
|
|
227
|
+
// every conversation for this temp workspace regardless of header state (a
|
|
228
|
+
// blank session may not be materialized as a header yet, and a stale header
|
|
229
|
+
// cache could otherwise make the cwd match miss a live log). Because the temp
|
|
230
|
+
// workspace's throwaway dir is unique (under ~/.dsh/temp-workspaces/<uuid>),
|
|
231
|
+
// its projectKey can never collide with a real workspace's project dir.
|
|
232
|
+
let sessionsRoot
|
|
233
|
+
try { sessionsRoot = ctx.sessionPersistence.root } catch { return }
|
|
234
|
+
if (typeof sessionsRoot !== 'string' || sessionsRoot === '') return
|
|
235
|
+
const projectDir = join(sessionsRoot, projectKey(cwd))
|
|
236
|
+
await rm(projectDir, { recursive: true, force: true }).catch(() => {})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Remove from disk every session log a workspace record still claims. */
|
|
240
|
+
async function deleteWorkspaceSessions(ctx, ws) {
|
|
241
|
+
// Same project-dir strategy as deleteSessionsUnderPath: this workspace's logs
|
|
242
|
+
// all live under its cwd's project directory, so remove it wholesale (more
|
|
243
|
+
// robust than matching headers by id, which misses blank/unmaterialized ones).
|
|
244
|
+
await deleteSessionsUnderPath(ctx, ws.path)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Best-effort removal of LIVE sessions owned by a temp workspace, so a delete
|
|
249
|
+
* that runs while the host still holds those sessions actually drops the
|
|
250
|
+
* conversations from the UI — not just from disk. This is the gap behind the
|
|
251
|
+
* "cleared the temp workspace but the conversations are still there" bug:
|
|
252
|
+
* the boot confirm dialog / delayed auto-delete / sidebar delete can all run
|
|
253
|
+
* in a host process that never truly restarted (a page reload keeps the host
|
|
254
|
+
* and its in-memory sessions alive), and DSH's own workspace delete only
|
|
255
|
+
* removes the registration.
|
|
256
|
+
*
|
|
257
|
+
* Order matters for workspaces with MORE THAN ONE live session. The detach
|
|
258
|
+
* below emits `session/disposed`, and the persistence coordinator answers
|
|
259
|
+
* that with a retirement flush (`retire` → `flush` → `initFor`), which
|
|
260
|
+
* MATERIALIZES the session's log file when it is not on disk yet. If that
|
|
261
|
+
* flush races the directory deletion (the caller removes the project dir
|
|
262
|
+
* right after this, and a late flush re-materializes a log in its place),
|
|
263
|
+
* the conversations turn into unreadable Ungrouped leftovers. So before
|
|
264
|
+
* detaching we:
|
|
265
|
+
* 1. cancel the agent and wait for the aborted turn to settle
|
|
266
|
+
* (`whenIdle`, mirroring the agent loop's own dispose: the cancelled
|
|
267
|
+
* turn appends `turn/end` before going idle);
|
|
268
|
+
* 2. flush the session (`sessions.flush`) so every pending write — the
|
|
269
|
+
* materialized header included — is durably on disk NOW;
|
|
270
|
+
* then detach, whose retirement flush has nothing left to write and cannot
|
|
271
|
+
* re-create the log after the directory is removed.
|
|
272
|
+
*
|
|
273
|
+
* After the detach we also prune the session's projection-cache row (the
|
|
274
|
+
* detach checkpoints one last time, so the prune retries briefly) and the
|
|
275
|
+
* legacy per-session cache file — the "no trace" half of this function; the
|
|
276
|
+
* boot-time orphan sweep is the authoritative net for anything that slips
|
|
277
|
+
* through.
|
|
278
|
+
*
|
|
279
|
+
* Every step is optional and fail-soft:
|
|
280
|
+
* - the live-store detach (the primary removal) emits `session/disposed`
|
|
281
|
+
* so the client drops the conversation at once;
|
|
282
|
+
* - only when that internal detach is unavailable do we fall back to
|
|
283
|
+
* `archiveSession`, the registry's supported "hidden from every grouping
|
|
284
|
+
* surface" mechanism (the sidebar groups, flat list, and search all
|
|
285
|
+
* filter archived ids) — the conversation disappears anyway, at the
|
|
286
|
+
* cost of a durable archived-id entry the orphan sweep later reclaims.
|
|
287
|
+
* @returns the number of live sessions removed.
|
|
288
|
+
*/
|
|
289
|
+
async function removeLiveSessions(ctx, cwd) {
|
|
290
|
+
if (typeof cwd !== 'string' || cwd === '') return 0
|
|
291
|
+
const sessions = (typeof ctx.get === 'function' && ctx.get('sessions')) || ctx.sessions
|
|
292
|
+
const agents = (typeof ctx.get === 'function' && ctx.get('agents')) || ctx.agents
|
|
293
|
+
if (!sessions || typeof sessions.list !== 'function') return 0
|
|
294
|
+
const owned = []
|
|
295
|
+
for (const session of sessions.list()) {
|
|
296
|
+
if (session && typeof session === 'object' && session.header && session.header.cwd === cwd) owned.push(session)
|
|
297
|
+
}
|
|
298
|
+
let removed = 0
|
|
299
|
+
const detachedIds = []
|
|
300
|
+
for (const session of owned) {
|
|
301
|
+
try {
|
|
302
|
+
const id = session.id
|
|
303
|
+
// 1. Stop any running turn and wait for it to settle, so its final
|
|
304
|
+
// `turn/end` append lands before we flush (no log resurrection).
|
|
305
|
+
try {
|
|
306
|
+
const agent = agents && typeof agents.get === 'function' ? agents.get(id) : undefined
|
|
307
|
+
if (agent && typeof agent.cancel === 'function') {
|
|
308
|
+
agent.cancel({ kind: 'disposed' })
|
|
309
|
+
if (typeof agent.whenIdle === 'function') {
|
|
310
|
+
await Promise.race([agent.whenIdle(), settleTimeout(2000)])
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.error(`[${PLUGIN_ID}] cancel live agent ${id} failed`, error)
|
|
315
|
+
}
|
|
316
|
+
// 2. Drain every pending write to disk NOW, so the retirement flush
|
|
317
|
+
// triggered by the detach below has nothing to write and cannot
|
|
318
|
+
// re-materialize the log after the directory is deleted.
|
|
319
|
+
try {
|
|
320
|
+
if (typeof sessions.flush === 'function') await sessions.flush(session)
|
|
321
|
+
} catch { /* best-effort */ }
|
|
322
|
+
// 3. Detach — the primary removal (emits session/disposed).
|
|
323
|
+
let detached = false
|
|
324
|
+
try {
|
|
325
|
+
const store = sessions.store
|
|
326
|
+
const entry = store && typeof store.get === 'function' ? store.get(id) : undefined
|
|
327
|
+
if (entry !== undefined && typeof sessions.detachEntered === 'function') {
|
|
328
|
+
sessions.detachEntered(entry)
|
|
329
|
+
detached = true
|
|
330
|
+
removed += 1
|
|
331
|
+
detachedIds.push(id)
|
|
332
|
+
}
|
|
333
|
+
} catch { /* fall through to the archive fallback */ }
|
|
334
|
+
// 4. Fallback when the live-store detach is unavailable: archive, so
|
|
335
|
+
// the conversation is hidden from every grouping surface anyway.
|
|
336
|
+
if (!detached) {
|
|
337
|
+
try {
|
|
338
|
+
if (ctx.workspaceRegistry && typeof ctx.workspaceRegistry.archiveSession === 'function') {
|
|
339
|
+
await ctx.workspaceRegistry.archiveSession(id)
|
|
340
|
+
}
|
|
341
|
+
} catch { /* best-effort */ }
|
|
342
|
+
}
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.error(`[${PLUGIN_ID}] live-session cleanup failed for ${session?.id}`, error)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Let the detach-induced retirement flushes and projection-cache detach
|
|
348
|
+
// checkpoints settle on the microtask queue so the caller's directory
|
|
349
|
+
// deletion never races a late write.
|
|
350
|
+
await settleTimeout(0)
|
|
351
|
+
// Prune the durable projection-cache rows. The detach checkpoints the
|
|
352
|
+
// session one last time (flushSoft on session/disposed), so retry briefly
|
|
353
|
+
// until the row stays gone; the boot-time orphan sweep is the net.
|
|
354
|
+
for (const id of detachedIds) {
|
|
355
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
356
|
+
await settleTimeout(80)
|
|
357
|
+
await pruneSessionResidue(ctx, id)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return removed
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** A promise that resolves after `ms`, never rejects — for bounded best-effort waits. */
|
|
364
|
+
function settleTimeout(ms) {
|
|
365
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The legacy per-session projection-cache file path for one session id (older storage layout). */
|
|
369
|
+
function legacyProjectionCacheFile(id) {
|
|
370
|
+
return join(dshHome(), 'storages', 'session_projcache', 'sessions', `${id}.json`)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Remove one session's durable projection-cache residue (row + legacy file), fail-soft. */
|
|
374
|
+
async function pruneSessionResidue(ctx, id) {
|
|
375
|
+
if (typeof id !== 'string' || id === '') return
|
|
376
|
+
try {
|
|
377
|
+
const cache = (typeof ctx.get === 'function' && ctx.get('sessionProjectionCache')) || ctx.sessionProjectionCache
|
|
378
|
+
if (cache && cache.table && typeof cache.table.delete === 'function') {
|
|
379
|
+
await cache.table.delete(id)
|
|
380
|
+
}
|
|
381
|
+
} catch { /* best-effort */ }
|
|
382
|
+
await rm(legacyProjectionCacheFile(id), { force: true }).catch(() => {})
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Hide the COLD (persisted-but-not-attached) sessions of a temp workspace from
|
|
387
|
+
* every grouping surface by archiving them. Deleting their logs alone leaves
|
|
388
|
+
* a stale entry in an already-open browser: cold sessions are not in the live
|
|
389
|
+
* session store, so the detach in `removeLiveSessions` cannot emit the
|
|
390
|
+
* `session/disposed` → `host/session-removed` event that would drop them —
|
|
391
|
+
* they linger in the sidebar as unreadable Ungrouped conversations until a
|
|
392
|
+
* reload. Archiving is the supported counterpart: the archive-set change
|
|
393
|
+
* pushes `host/archived-sessions-changed`, and the sidebar (groups, flat
|
|
394
|
+
* list, and search) filters archived ids, so the stale entries disappear
|
|
395
|
+
* immediately. The durable archived-id entry is reclaimed by the boot-time
|
|
396
|
+
* orphan sweep (`pruneOrphanTempResidue`), which finds the session's
|
|
397
|
+
* projection-cache row.
|
|
398
|
+
*
|
|
399
|
+
* Runs BEFORE the logs are deleted (the session catalog is read from disk).
|
|
400
|
+
* @returns the number of sessions archived.
|
|
401
|
+
*/
|
|
402
|
+
async function archiveColdSessions(ctx, cwd) {
|
|
403
|
+
if (typeof cwd !== 'string' || cwd === '') return 0
|
|
404
|
+
const sessions = (typeof ctx.get === 'function' && ctx.get('sessions')) || ctx.sessions
|
|
405
|
+
const live = new Set()
|
|
406
|
+
if (sessions && typeof sessions.list === 'function') {
|
|
407
|
+
for (const session of sessions.list()) {
|
|
408
|
+
if (session && session.header && session.header.cwd === cwd) live.add(session.id)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
let archived = 0
|
|
412
|
+
let headers
|
|
413
|
+
try { headers = await ctx.sessionPersistence.list() } catch { return 0 }
|
|
414
|
+
for (const meta of headers) {
|
|
415
|
+
if (meta.cwd !== cwd) continue
|
|
416
|
+
if (live.has(meta.id)) continue // live sessions are detached, not archived
|
|
417
|
+
try {
|
|
418
|
+
if (ctx.workspaceRegistry && typeof ctx.workspaceRegistry.archiveSession === 'function') {
|
|
419
|
+
await ctx.workspaceRegistry.archiveSession(meta.id)
|
|
420
|
+
archived += 1
|
|
421
|
+
}
|
|
422
|
+
} catch { /* best-effort */ }
|
|
423
|
+
}
|
|
424
|
+
return archived
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Remove a temp workspace's on-disk footprints WITHOUT touching the workspace
|
|
429
|
+
* registry: its session logs under its cwd project dir, its throwaway dir, and
|
|
430
|
+
* its marker entry. This is the file-reaping half shared by `deleteTempWorkspace`
|
|
431
|
+
* and by the registry-delete hook (which runs when a temp workspace is removed
|
|
432
|
+
* via the native sidebar delete — DSH's own registry delete retains the
|
|
433
|
+
* directory and every session log, so without this the conversations would
|
|
434
|
+
* survive). Safe to call for an already-unregistered temp workspace.
|
|
435
|
+
*/
|
|
436
|
+
async function reapTempWorkspaceFiles(ctx, entry) {
|
|
437
|
+
const { workspaceId, path } = entry ?? {}
|
|
438
|
+
if (typeof path === 'string' && path !== '') {
|
|
439
|
+
await removeLiveSessions(ctx, path)
|
|
440
|
+
await archiveColdSessions(ctx, path)
|
|
441
|
+
await deleteSessionsUnderPath(ctx, path)
|
|
442
|
+
await rm(path, { recursive: true, force: true }).catch(() => {})
|
|
443
|
+
}
|
|
444
|
+
if (typeof workspaceId === 'string' && workspaceId !== '') {
|
|
445
|
+
const list = (await readState()).filter((item) => item.workspaceId !== workspaceId)
|
|
446
|
+
await writeState(list)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Delete one temporary workspace. `entry` comes from the marker and carries the
|
|
452
|
+
* recorded `path`, which is the single source of truth for the throwaway dir —
|
|
453
|
+
* deleting it must NOT depend on the registry record still existing. When a
|
|
454
|
+
* user removes the temp workspace via the UI before restart, the registry
|
|
455
|
+
* record is already gone but the folder (and any sessions it owned) remain;
|
|
456
|
+
* this path-driven deletion is what reaps that leftover.
|
|
457
|
+
*/
|
|
458
|
+
async function deleteTempWorkspace(ctx, entry) {
|
|
459
|
+
const { workspaceId, path } = entry
|
|
460
|
+
const ws = ctx.workspaceRegistry.get(workspaceId)
|
|
461
|
+
if (ws !== undefined) {
|
|
462
|
+
// Tear down live sessions first: a running agent must be stopped before
|
|
463
|
+
// its log directory is removed, or it would re-materialize the log and
|
|
464
|
+
// the conversation would survive the delete. Then archive the cold
|
|
465
|
+
// (unattached) sessions so an already-open browser drops them too.
|
|
466
|
+
await removeLiveSessions(ctx, ws.path)
|
|
467
|
+
await archiveColdSessions(ctx, ws.path)
|
|
468
|
+
await deleteWorkspaceSessions(ctx, ws)
|
|
469
|
+
// Use the unwrapped delete so the temp-cleanup hook does not re-enter.
|
|
470
|
+
const del = registryDeleteUnwrapped ?? ctx.workspaceRegistry.delete.bind(ctx.workspaceRegistry)
|
|
471
|
+
await del(workspaceId)
|
|
472
|
+
}
|
|
473
|
+
await reapTempWorkspaceFiles(ctx, { workspaceId, path: path || ws?.path })
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Permanently keep a temp workspace: move its files INTO a user-chosen folder
|
|
478
|
+
* (used directly — no extra subfolder is created) and re-register that folder as
|
|
479
|
+
* an ordinary (permanent) workspace named after the folder. This de-temps the
|
|
480
|
+
* workspace — it is no longer in the temp marker and so is never auto-deleted.
|
|
481
|
+
*
|
|
482
|
+
* The workspace's conversations are migrated too: each session log's header
|
|
483
|
+
* frame `cwd` is rewritten to the folder path and its log directory is moved
|
|
484
|
+
* into the new cwd's projectKey slot. Session logs are zstd-compressed, so only
|
|
485
|
+
* the independent header frame is rewritten; the event frames are preserved
|
|
486
|
+
* byte-for-byte. The session is not attached in-session (the registry caches
|
|
487
|
+
* headers from boot); it re-attaches on the next restart, which the client
|
|
488
|
+
* prompts for.
|
|
489
|
+
*
|
|
490
|
+
* @returns the new permanent workspace view.
|
|
491
|
+
*/
|
|
492
|
+
async function moveTempWorkspace(ctx, entry, targetDir) {
|
|
493
|
+
const { workspaceId, path: sourcePath } = entry
|
|
494
|
+
const ws = ctx.workspaceRegistry.get(workspaceId)
|
|
495
|
+
const from = sourcePath || ws?.path || ''
|
|
496
|
+
if (from === '') throw new TempWorkspaceError('bad-path', 'no source directory recorded for this temp workspace', 400)
|
|
497
|
+
const target = resolve(targetDir)
|
|
498
|
+
if (target === from) throw new TempWorkspaceError('bad-path', 'source and destination are the same directory', 400)
|
|
499
|
+
// The user-chosen folder becomes the workspace, so its name is the title.
|
|
500
|
+
const title = describeTitle(target)
|
|
501
|
+
|
|
502
|
+
// Validate the destination: it must be an existing directory, and not already
|
|
503
|
+
// owned by a workspace (we never merge into an existing workspace's folder).
|
|
504
|
+
let targetStat
|
|
505
|
+
try { targetStat = await stat(target) } catch { targetStat = undefined }
|
|
506
|
+
if (targetStat === undefined || !targetStat.isDirectory()) {
|
|
507
|
+
throw new TempWorkspaceError('bad-path', `destination folder "${target}" is not an existing directory`, 400)
|
|
508
|
+
}
|
|
509
|
+
const existing = await ctx.workspaceRegistry.resolveByPath(target).catch(() => undefined)
|
|
510
|
+
if (existing !== undefined) {
|
|
511
|
+
throw new TempWorkspaceError('path-in-use', `destination folder "${target}" is already a workspace`, 409)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Sessions owned by the temp workspace (header cwd === from). Capture BEFORE
|
|
515
|
+
// the directory move so the registry membership stays readable.
|
|
516
|
+
const oldSessionIds = await workspaceSessionIds(ctx, ws, from)
|
|
517
|
+
|
|
518
|
+
// Move the temp workspace's CONTENTS into the user-chosen folder. The folder
|
|
519
|
+
// already exists, so we copy its children into it, then remove the source.
|
|
520
|
+
const children = await readdir(from)
|
|
521
|
+
for (const child of children) {
|
|
522
|
+
await cp(join(from, child), join(target, child), { recursive: true, force: true })
|
|
523
|
+
}
|
|
524
|
+
await rm(from, { recursive: true, force: true }).catch(() => {})
|
|
525
|
+
|
|
526
|
+
// Re-register as a permanent workspace at the user-chosen folder.
|
|
527
|
+
let created
|
|
528
|
+
try {
|
|
529
|
+
created = await ctx.workspaceRegistry.create(target, title)
|
|
530
|
+
} catch (error) {
|
|
531
|
+
// Roll back the move so a failed registration does not strand the folder.
|
|
532
|
+
await rm(from, { recursive: true, force: true }).catch(() => {})
|
|
533
|
+
throw new TempWorkspaceError('move-failed', `moved "${from}" but could not register it at "${target}": ${error?.message ?? String(error)}`, 500)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Migrate the conversations: rewrite each session log's header cwd and move
|
|
537
|
+
// its log dir into the new cwd's projectKey slot. The session is NOT attached
|
|
538
|
+
// here — the registry's header index was captured at boot (with the old cwd),
|
|
539
|
+
// so an in-session attach would validate against the old (now-deleted) temp
|
|
540
|
+
// path and fail. The registry's session→workspace grouping (bootstrap) only
|
|
541
|
+
// runs once on the first boot; on later boots it only rebuilds the id→cwd
|
|
542
|
+
// index, so it does NOT add the migrated session to the new workspace's
|
|
543
|
+
// sessionIds. We therefore record a durable "pending attach" here and re-attach
|
|
544
|
+
// on the next boot (when the index has the fresh cwd), which the client already
|
|
545
|
+
// prompts the user to do.
|
|
546
|
+
let migrated = 0
|
|
547
|
+
const migratedIds = []
|
|
548
|
+
for (const sessionId of oldSessionIds) {
|
|
549
|
+
try {
|
|
550
|
+
const ok = await migrateSession(ctx, sessionId, from, target, 'zstd')
|
|
551
|
+
if (ok) {
|
|
552
|
+
migrated += 1
|
|
553
|
+
migratedIds.push(sessionId)
|
|
554
|
+
console.log(`[${PLUGIN_ID}] migrated session ${sessionId}; it will attach to "${target}" after the next restart`)
|
|
555
|
+
}
|
|
556
|
+
} catch (error) {
|
|
557
|
+
console.error(`[${PLUGIN_ID}] migrate session ${sessionId} failed`, error)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Drop the temp marker entry (this workspace is no longer temporary).
|
|
562
|
+
const list = (await readState()).filter((item) => item.workspaceId !== workspaceId)
|
|
563
|
+
await writeState(list)
|
|
564
|
+
|
|
565
|
+
// Delete the OLD registry record if it still exists.
|
|
566
|
+
if (ws !== undefined && ws.id !== created.id) {
|
|
567
|
+
try { await ctx.workspaceRegistry.delete(workspaceId) } catch { /* best-effort */ }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Record a pending attach so the next boot re-attaches these migrated sessions
|
|
571
|
+
// to the newly registered workspace (the registry does not auto-group them on
|
|
572
|
+
// an already-initialized boot).
|
|
573
|
+
if (migratedIds.length > 0) {
|
|
574
|
+
await addPendingAttach(created.id, target, migratedIds)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return { workspace: toView(created), from, target, migratedSessions: migrated }
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** A short display title from a path's base name. */
|
|
581
|
+
function describeTitle(p) {
|
|
582
|
+
const base = p.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
|
583
|
+
return base && base !== '' ? base : '临时工作区'
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ── session log migration (zstd-aware) ──────────────────────────────────────
|
|
587
|
+
// A DSH session log is a concatenation of independently-decodable Zstandard
|
|
588
|
+
// frames: frame 0 is the header line (a single newline-terminated JSON object
|
|
589
|
+
// carrying the immutable `cwd`), and the remaining frames are the event
|
|
590
|
+
// batches. To migrate a session to a new workspace we only need to rewrite
|
|
591
|
+
// frame 0's `cwd` and leave every event frame byte-for-byte intact. The frame
|
|
592
|
+
// scanner below only locates the FIRST complete frame (the header) — it never
|
|
593
|
+
// decodes the event frames.
|
|
594
|
+
|
|
595
|
+
const ZSTD_MAGIC_LE = 0xFD2FB528 // 0x28 0xB5 0x2F 0xFD stored little-endian
|
|
596
|
+
|
|
597
|
+
/** Locate the byte range [start, end) of the first complete zstd frame. */
|
|
598
|
+
function firstZstdFrame(buffer) {
|
|
599
|
+
const len = buffer.length
|
|
600
|
+
if (len < 4 || buffer.readUInt32LE(0) !== ZSTD_MAGIC_LE) {
|
|
601
|
+
throw new Error('session log is not a zstandard frame (invalid magic)')
|
|
602
|
+
}
|
|
603
|
+
let offset = 4
|
|
604
|
+
if (offset === len) throw new Error('session log zstd frame truncated in frame header')
|
|
605
|
+
const descriptor = buffer.readUInt8(offset)
|
|
606
|
+
offset += 1
|
|
607
|
+
if ((descriptor & 24) !== 0) throw new Error('session log zstd frame reserved header bit set')
|
|
608
|
+
const contentSizeFlag = descriptor >>> 6
|
|
609
|
+
const singleSegment = (descriptor & 32) !== 0
|
|
610
|
+
const checksum = (descriptor & 4) !== 0
|
|
611
|
+
const dictionaryFlag = descriptor & 3
|
|
612
|
+
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
|
|
613
|
+
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
|
|
614
|
+
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
|
|
615
|
+
if (len - offset < remainingHeaderBytes) throw new Error('session log zstd frame truncated in frame header')
|
|
616
|
+
offset += remainingHeaderBytes
|
|
617
|
+
for (;;) {
|
|
618
|
+
if (len - offset < 3) throw new Error('session log zstd frame truncated in blocks')
|
|
619
|
+
const blockHeader = buffer.readUIntLE(offset, 3)
|
|
620
|
+
offset += 3
|
|
621
|
+
const lastBlock = (blockHeader & 1) !== 0
|
|
622
|
+
const blockType = blockHeader >>> 1 & 3
|
|
623
|
+
const blockSize = blockHeader >>> 3
|
|
624
|
+
if (blockType === 3) throw new Error('session log zstd frame reserved block type')
|
|
625
|
+
offset += blockType === 1 ? 1 : blockSize
|
|
626
|
+
if (lastBlock) break
|
|
627
|
+
}
|
|
628
|
+
if (checksum) {
|
|
629
|
+
if (len - offset < 4) throw new Error('session log zstd frame truncated in checksum')
|
|
630
|
+
offset += 4
|
|
631
|
+
}
|
|
632
|
+
return { start: 0, end: offset }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** zstd frame compression options (checksummed, matching the backend). */
|
|
636
|
+
function zstdOptions() {
|
|
637
|
+
// The backend passes its checksum options; a conservative default is fine too.
|
|
638
|
+
return { level: 3 }
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Rewrite the `cwd` field in the header line of a session log and return the
|
|
643
|
+
* new raw artifact bytes. `buf` is the original file bytes; `artifactName`
|
|
644
|
+
* tells whether it is zstd or plaintext. `fromCwd`, when given, must match the
|
|
645
|
+
* current header cwd (aborts on mismatch). Returns null when nothing changed.
|
|
646
|
+
*/
|
|
647
|
+
async function rewriteSessionCwdBytes(buf, artifactName, fromCwd, toCwd) {
|
|
648
|
+
const isZstd = artifactName.endsWith('.jsonl.zstd')
|
|
649
|
+
let plain
|
|
650
|
+
let tail = Buffer.alloc(0)
|
|
651
|
+
if (isZstd) {
|
|
652
|
+
let frame
|
|
653
|
+
try {
|
|
654
|
+
frame = firstZstdFrame(buf)
|
|
655
|
+
plain = (await zstdDecompressAsync(buf.subarray(frame.start, frame.end))).toString('utf8')
|
|
656
|
+
tail = buf.subarray(frame.end)
|
|
657
|
+
} catch {
|
|
658
|
+
// Fall back to treating the whole file as a single frame.
|
|
659
|
+
plain = (await zstdDecompressAsync(buf)).toString('utf8')
|
|
660
|
+
tail = Buffer.alloc(0)
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
plain = buf.toString('utf8')
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const nl = plain.indexOf('\n')
|
|
667
|
+
if (nl === -1) return null
|
|
668
|
+
const headerText = plain.slice(0, nl)
|
|
669
|
+
const rest = plain.slice(nl)
|
|
670
|
+
let parsed
|
|
671
|
+
try { parsed = JSON.parse(headerText) } catch { return null }
|
|
672
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
673
|
+
if (fromCwd !== undefined && parsed.cwd !== fromCwd) return null
|
|
674
|
+
if (parsed.cwd === toCwd) return null
|
|
675
|
+
parsed.cwd = toCwd
|
|
676
|
+
|
|
677
|
+
const newHeader = JSON.stringify(parsed) + rest
|
|
678
|
+
if (isZstd) {
|
|
679
|
+
const newFrame = await zstdCompressAsync(Buffer.from(newHeader, 'utf8'), zstdOptions())
|
|
680
|
+
return Buffer.concat([newFrame, tail])
|
|
681
|
+
}
|
|
682
|
+
return Buffer.from(newHeader, 'utf8')
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The physical artifact filename a session log uses for a given compression. */
|
|
686
|
+
function sessionArtifactName(compression) {
|
|
687
|
+
return compression === 'zstd' ? 'session.jsonl.zstd' : 'session.jsonl'
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* List the session ids owned by a workspace: its registry `sessionIds`
|
|
692
|
+
* (canonical-cwd filtered) plus any materialized header whose cwd equals the
|
|
693
|
+
* workspace path (defensive for a record the registry no longer claims).
|
|
694
|
+
*/
|
|
695
|
+
async function workspaceSessionIds(ctx, ws, path) {
|
|
696
|
+
const ids = new Set(ws?.sessionIds ?? [])
|
|
697
|
+
try {
|
|
698
|
+
const headers = await ctx.sessionPersistence.list()
|
|
699
|
+
for (const meta of headers) if (meta.cwd === path) ids.add(meta.id)
|
|
700
|
+
} catch { /* ignore */ }
|
|
701
|
+
return [...ids]
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Migrate one session to a new cwd: write a rewritten copy of its log into the
|
|
706
|
+
* new cwd's projectKey directory (header frame `cwd` updated, every event
|
|
707
|
+
* frame unchanged), then remove the old session directory. Non-destructive:
|
|
708
|
+
* the destination is written first; the old dir is only removed after success.
|
|
709
|
+
* @returns true on success.
|
|
710
|
+
*/
|
|
711
|
+
async function migrateSession(ctx, sessionId, oldCwd, newCwd, compression) {
|
|
712
|
+
const oldLoc = ctx.sessionPersistence.locate({ id: sessionId, cwd: oldCwd })
|
|
713
|
+
const newLoc = ctx.sessionPersistence.locate({ id: sessionId, cwd: newCwd })
|
|
714
|
+
if (!oldLoc?.path || !newLoc?.path) return false
|
|
715
|
+
|
|
716
|
+
const oldDir = dirname(oldLoc.path)
|
|
717
|
+
const newDir = dirname(newLoc.path)
|
|
718
|
+
|
|
719
|
+
// Detect the physical artifact: the backend defaults to zstd, but a session
|
|
720
|
+
// may be stored plaintext (.jsonl). Try the given compression, then the other.
|
|
721
|
+
const preferred = sessionArtifactName(compression)
|
|
722
|
+
const alternate = compression === 'zstd' ? 'session.jsonl' : 'session.jsonl.zstd'
|
|
723
|
+
let artifactName = preferred
|
|
724
|
+
let exists = await stat(join(oldDir, preferred)).then(() => true, () => false)
|
|
725
|
+
if (!exists) {
|
|
726
|
+
const alt = await stat(join(oldDir, alternate)).then(() => true, () => false)
|
|
727
|
+
if (alt) artifactName = alternate
|
|
728
|
+
else return false // nothing materialized on disk — nothing to migrate
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const oldArtifact = join(oldDir, artifactName)
|
|
732
|
+
const newArtifact = join(newDir, artifactName)
|
|
733
|
+
|
|
734
|
+
const buf = await readFile(oldArtifact).catch((error) => {
|
|
735
|
+
console.error(`[${PLUGIN_ID}] migrate read failed for ${sessionId}`, error)
|
|
736
|
+
return null
|
|
737
|
+
})
|
|
738
|
+
if (buf === null) return false
|
|
739
|
+
const newBytes = await rewriteSessionCwdBytes(buf, oldArtifact, oldCwd, newCwd)
|
|
740
|
+
if (newBytes === null) return false
|
|
741
|
+
|
|
742
|
+
try {
|
|
743
|
+
await mkdir(newDir, { recursive: true, mode: 0o700 })
|
|
744
|
+
await rm(newDir, { recursive: true, force: true }).catch(() => {})
|
|
745
|
+
await mkdir(newDir, { recursive: true, mode: 0o700 })
|
|
746
|
+
await writeFile(newArtifact, newBytes, { mode: 0o600 })
|
|
747
|
+
// Move any other session-owned artifacts (non-header) along verbatim.
|
|
748
|
+
await rm(oldDir, { recursive: true, force: true }).catch(() => {})
|
|
749
|
+
await pruneEmptyProjectDir(dirname(oldDir))
|
|
750
|
+
return true
|
|
751
|
+
} catch (error) {
|
|
752
|
+
console.error(`[${PLUGIN_ID}] migrate write failed for ${sessionId}`, error)
|
|
753
|
+
return false
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ── create / delete / list ──────────────────────────────────────────────────
|
|
758
|
+
/** Create a temporary workspace (throwaway dir + registry record + marker). */
|
|
759
|
+
async function createTempWorkspace(ctx) {
|
|
760
|
+
const dir = join(root(), randomUUID())
|
|
761
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
762
|
+
const ws = await ctx.workspaceRegistry.create(dir, TEMP_TITLE)
|
|
763
|
+
|
|
764
|
+
const list = await readState()
|
|
765
|
+
list.push({ workspaceId: ws.id, path: ws.path, createdAt: ws.createdAt })
|
|
766
|
+
await writeState(list)
|
|
767
|
+
|
|
768
|
+
return { workspace: toView(ws), created: true }
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** The temp-workspace marker entries (id + path + createdAt) for the browser. */
|
|
772
|
+
async function listTempWorkspaces() {
|
|
773
|
+
return (await readState()).filter((entry) => entry && typeof entry === 'object' && entry.workspaceId)
|
|
774
|
+
.map((entry) => ({ workspaceId: entry.workspaceId, path: entry.path, createdAt: entry.createdAt }))
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Boot cleanup: remove every workspace (sessions + registration) recorded as temporary. */
|
|
778
|
+
async function cleanupTempWorkspaces(ctx) {
|
|
779
|
+
const list = await readState()
|
|
780
|
+
if (list.length === 0) return 0
|
|
781
|
+
let removed = 0
|
|
782
|
+
for (const entry of list) {
|
|
783
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
784
|
+
try {
|
|
785
|
+
await deleteTempWorkspace(ctx, entry)
|
|
786
|
+
removed += 1
|
|
787
|
+
} catch (error) {
|
|
788
|
+
console.error(`[${PLUGIN_ID}] cleanup failed for ${entry.workspaceId}`, error)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
// Clear the marker for every entry we processed, regardless of per-entry
|
|
792
|
+
// errors, so a permanently-broken record never blocks later cleanups.
|
|
793
|
+
const processed = new Set(list.map((entry) => entry.workspaceId).filter(Boolean))
|
|
794
|
+
const remaining = (await readState()).filter((entry) => !(entry.workspaceId !== undefined && processed.has(entry.workspaceId)))
|
|
795
|
+
await writeState(remaining)
|
|
796
|
+
return removed
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* One-time boot sweep: remove session project directories left behind by temp
|
|
801
|
+
* workspaces that no longer exist. The JSONL store removes individual session
|
|
802
|
+
* dirs and (before this fix) never reaped the now-empty `--<cwd>--` parent, so
|
|
803
|
+
* after repeated create/delete cycles `~/.dsh/sessions` fills with orphaned
|
|
804
|
+
* `--*.dsh-temp-workspaces-*--` folders. Any such project dir whose cwd is NOT
|
|
805
|
+
* still referenced by the temp marker is provably orphaned — its workspace's
|
|
806
|
+
* throwaway directory is already gone — so the whole project dir (sessions
|
|
807
|
+
* included) can be removed. Active temp workspaces (still in the marker) are
|
|
808
|
+
* untouched.
|
|
809
|
+
* @returns the number of orphaned project directories removed.
|
|
810
|
+
*/
|
|
811
|
+
async function cleanupOrphanTempProjectDirs(ctx) {
|
|
812
|
+
let sessionsRoot
|
|
813
|
+
try { sessionsRoot = ctx.sessionPersistence.root } catch { return 0 }
|
|
814
|
+
if (typeof sessionsRoot !== 'string' || sessionsRoot === '') return 0
|
|
815
|
+
|
|
816
|
+
const active = new Set((await readState())
|
|
817
|
+
.filter((entry) => entry && typeof entry === 'object' && entry.path)
|
|
818
|
+
.map((entry) => projectDirName(entry.path)))
|
|
819
|
+
|
|
820
|
+
let removed = 0
|
|
821
|
+
let entries
|
|
822
|
+
try { entries = await readdir(sessionsRoot, { withFileTypes: true }) } catch { return 0 }
|
|
823
|
+
for (const entry of entries) {
|
|
824
|
+
if (!entry.isDirectory()) continue
|
|
825
|
+
const name = entry.name
|
|
826
|
+
// Only temp-workspace project dirs: `--...dsh-temp-workspaces-...--`. This
|
|
827
|
+
// filter deliberately skips ordinary project dirs (e.g. the user's real cwd,
|
|
828
|
+
// or the permanent-keep destination folders created while testing) so they
|
|
829
|
+
// are never touched.
|
|
830
|
+
if (!name.startsWith('--') || !name.endsWith('--')) continue
|
|
831
|
+
if (!name.includes('dsh-temp-workspaces-')) continue
|
|
832
|
+
if (active.has(name)) continue // a live temp workspace still owns this dir
|
|
833
|
+
const dir = join(sessionsRoot, name)
|
|
834
|
+
try {
|
|
835
|
+
await rm(dir, { recursive: true, force: true })
|
|
836
|
+
removed += 1
|
|
837
|
+
console.log(`[${PLUGIN_ID}] cleared orphaned temp-workspace session dir ${name}`)
|
|
838
|
+
} catch (error) {
|
|
839
|
+
console.error(`[${PLUGIN_ID}] could not clear orphaned dir ${name}`, error)
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Also reap orphaned throwaway directories directly under the temp root: the
|
|
844
|
+
// durable state.json is the source of truth for which throwaway dirs are live,
|
|
845
|
+
// so any `<uuid>` dir there that is no longer referenced is provably a leftover
|
|
846
|
+
// (its marker entry was already cleared by a prior delete). These are the
|
|
847
|
+
// actual "临时工作区" folders and would otherwise accumulate beside state.json.
|
|
848
|
+
const troot = root()
|
|
849
|
+
let tentries
|
|
850
|
+
try { tentries = await readdir(troot, { withFileTypes: true }) } catch { return removed }
|
|
851
|
+
const activePaths = new Set((await readState()).filter((e) => e && typeof e === 'object' && e.path).map((e) => e.path))
|
|
852
|
+
for (const entry of tentries) {
|
|
853
|
+
if (!entry.isDirectory()) continue
|
|
854
|
+
const dir = join(troot, entry.name)
|
|
855
|
+
if (activePaths.has(dir)) continue
|
|
856
|
+
// Never touch our own infra files if a directory somehow collides by name.
|
|
857
|
+
if (entry.name === 'state.json' || entry.name === 'pending-attach.json') continue
|
|
858
|
+
try {
|
|
859
|
+
await rm(dir, { recursive: true, force: true })
|
|
860
|
+
removed += 1
|
|
861
|
+
console.log(`[${PLUGIN_ID}] cleared orphaned throwaway dir ${entry.name}`)
|
|
862
|
+
} catch (error) {
|
|
863
|
+
console.error(`[${PLUGIN_ID}] could not clear orphaned throwaway dir ${entry.name}`, error)
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return removed
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* One-time boot sweep that reclaims the DURABLE metadata residue a temp
|
|
871
|
+
* workspace leaves behind after its session dirs are gone: the projection
|
|
872
|
+
* cache rows (`session_projcache` domain), the archived-id entries the
|
|
873
|
+
* archive fallback adds, and the legacy per-session cache files from an
|
|
874
|
+
* older storage layout. These rows are identity-checked (so they never
|
|
875
|
+
* surface a ghost conversation by themselves), but they ARE a trace, and
|
|
876
|
+
* the plugin's contract is "no trace".
|
|
877
|
+
*
|
|
878
|
+
* A row is provably orphaned when its recorded `cwd` lives under the temp
|
|
879
|
+
* root (`~/.dsh/temp-workspaces`) and is NOT still referenced by the marker —
|
|
880
|
+
* its workspace's throwaway dir is already gone. Active temp workspaces
|
|
881
|
+
* (still in the marker) are untouched, and rows for real (non-temp) cwds
|
|
882
|
+
* are never considered. The archived-id removal is scoped the same way, so
|
|
883
|
+
* the user's intentional archives of real conversations are never touched.
|
|
884
|
+
* @returns the number of projection-cache rows pruned.
|
|
885
|
+
*/
|
|
886
|
+
async function pruneOrphanTempResidue(ctx) {
|
|
887
|
+
const cache = (typeof ctx.get === 'function' && ctx.get('sessionProjectionCache')) || ctx.sessionProjectionCache
|
|
888
|
+
if (!cache || !cache.table || typeof cache.table.keys !== 'function' || typeof cache.table.get !== 'function') return 0
|
|
889
|
+
const active = new Set((await readState())
|
|
890
|
+
.filter((entry) => entry && typeof entry === 'object' && entry.path)
|
|
891
|
+
.map((entry) => entry.path))
|
|
892
|
+
const troot = root()
|
|
893
|
+
|
|
894
|
+
const orphanIds = []
|
|
895
|
+
for (const id of cache.table.keys()) {
|
|
896
|
+
const record = cache.table.get(id)
|
|
897
|
+
const cwd = record && record.identity ? record.identity.cwd : undefined
|
|
898
|
+
if (typeof cwd !== 'string') continue
|
|
899
|
+
if (cwd !== troot && !cwd.startsWith(troot + '/')) continue // not a temp-workspace cwd
|
|
900
|
+
if (active.has(cwd)) continue // a live temp workspace still owns this row
|
|
901
|
+
orphanIds.push(id)
|
|
902
|
+
}
|
|
903
|
+
if (orphanIds.length === 0) return 0
|
|
904
|
+
|
|
905
|
+
// Un-archive the orphaned temp sessions so the registry's durable archive
|
|
906
|
+
// set stops accumulating temp residue (scoped to exactly the ids above).
|
|
907
|
+
try {
|
|
908
|
+
const registry = ctx.workspaceRegistry
|
|
909
|
+
if (registry && registry.global && typeof registry.global.get === 'function' && typeof registry.global.set === 'function') {
|
|
910
|
+
const state = registry.global.get()
|
|
911
|
+
const removed = new Set(orphanIds)
|
|
912
|
+
const kept = state.archivedSessionIds.filter((id) => !removed.has(id))
|
|
913
|
+
if (kept.length !== state.archivedSessionIds.length) {
|
|
914
|
+
state.archivedSessionIds = kept
|
|
915
|
+
await registry.global.set(state)
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
} catch (error) {
|
|
919
|
+
console.error(`[${PLUGIN_ID}] could not un-archive orphaned temp sessions`, error)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
let pruned = 0
|
|
923
|
+
for (const id of orphanIds) {
|
|
924
|
+
try {
|
|
925
|
+
if (typeof cache.table.delete === 'function') {
|
|
926
|
+
await cache.table.delete(id)
|
|
927
|
+
pruned += 1
|
|
928
|
+
}
|
|
929
|
+
} catch { /* best-effort */ }
|
|
930
|
+
await rm(legacyProjectionCacheFile(id), { force: true }).catch(() => {})
|
|
931
|
+
}
|
|
932
|
+
if (pruned > 0) console.log(`[${PLUGIN_ID}] pruned ${pruned} orphaned temp-workspace projection-cache row(s)`)
|
|
933
|
+
return pruned
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ── wire helpers (mirror dsh-better-sidebar / dsh-git-graph) ────────────────
|
|
937
|
+
export class TempWorkspaceError extends Error {
|
|
938
|
+
constructor(code, message, status = 400) {
|
|
939
|
+
super(message)
|
|
940
|
+
this.code = code
|
|
941
|
+
this.status = status
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function readJsonBody(req) {
|
|
946
|
+
return new Promise((resolve, reject) => {
|
|
947
|
+
let body = ''
|
|
948
|
+
req.on('data', (chunk) => {
|
|
949
|
+
body += chunk
|
|
950
|
+
if (body.length > 4 * 1024 * 1024) {
|
|
951
|
+
req.destroy()
|
|
952
|
+
reject(new TempWorkspaceError('payload-too-large', 'request body too large', 413))
|
|
953
|
+
}
|
|
954
|
+
})
|
|
955
|
+
req.on('end', () => {
|
|
956
|
+
try { resolve(body === '' ? {} : JSON.parse(body)) } catch { reject(new TempWorkspaceError('bad-json', 'invalid JSON body', 400)) }
|
|
957
|
+
})
|
|
958
|
+
req.on('error', reject)
|
|
959
|
+
})
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function writeJson(res, status, body) {
|
|
963
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
964
|
+
res.end(JSON.stringify(body))
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function writeOk(res, value) {
|
|
968
|
+
writeJson(res, 200, { ok: true, value })
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function writeError(res, error) {
|
|
972
|
+
const err = error instanceof Error ? error : new Error(String(error))
|
|
973
|
+
const status = err instanceof TempWorkspaceError ? err.status : 500
|
|
974
|
+
writeJson(res, status, { ok: false, error: { code: err.code ?? 'error', message: err.message } })
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** Same-origin / trusted-host check, mirroring the git-graph/better-sidebar fence. */
|
|
978
|
+
function isLoopback(hostname) {
|
|
979
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]'
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// ── self-restart (ported from dsh-market's proven restart.js) ────────────────
|
|
983
|
+
// The "Restart now" button triggers a real host-level restart, not just a page
|
|
984
|
+
// reload: a detached helper respawns the exact DSH invocation once this host
|
|
985
|
+
// releases its port, then this process terminates itself. Safety model mirrors
|
|
986
|
+
// dsh-market: direct same-origin loopback only, no forwarding headers, and the
|
|
987
|
+
// port is read off the request so the replacement takes over the same one.
|
|
988
|
+
|
|
989
|
+
/** The Node binary running this process (prefer argv0 when it is an absolute exe). */
|
|
990
|
+
function nodeExecutable(argv0 = process.argv0, execPath = process.execPath) {
|
|
991
|
+
if (argv0 !== undefined && argv0 !== '' && isAbsolute(argv0) && existsSync(argv0)) return argv0
|
|
992
|
+
return execPath
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** The exact DSH entry this process booted with (mirrors dsh-market's dshArgv). */
|
|
996
|
+
function dshArgv() {
|
|
997
|
+
const entry = process.argv[1]
|
|
998
|
+
if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
|
|
999
|
+
const abs = resolve(entry)
|
|
1000
|
+
return { file: nodeExecutable(), args: [...process.execArgv, abs], cwd: dirname(abs), viaShell: false }
|
|
1001
|
+
}
|
|
1002
|
+
return { file: 'dsh', args: [], cwd: undefined, viaShell: process.platform === 'win32' }
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** The exact boot invocation the detached restart helper replays. */
|
|
1006
|
+
function restartLaunch() {
|
|
1007
|
+
const launch = dshArgv()
|
|
1008
|
+
return {
|
|
1009
|
+
...launch,
|
|
1010
|
+
args: [...launch.args, ...process.argv.slice(2)],
|
|
1011
|
+
cwd: launch.cwd ?? process.cwd(),
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Platform-correct spawn invocation (Windows gets a hidden console). */
|
|
1016
|
+
function respawnInvocation(launch, platform = process.platform) {
|
|
1017
|
+
if (platform !== 'win32') return { file: launch.file, args: launch.args, viaShell: launch.viaShell, detached: true }
|
|
1018
|
+
const quote = (p) => `'${p.replace(/'/g, "''")}'`
|
|
1019
|
+
return {
|
|
1020
|
+
file: 'powershell.exe',
|
|
1021
|
+
args: ['-NoProfile', '-WindowStyle', 'Hidden', '-Command', [`& ${quote(launch.file)}`, ...launch.args.map(quote)].join(' ')],
|
|
1022
|
+
viaShell: false,
|
|
1023
|
+
detached: false,
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** Source for the detached helper that outlives this process and brings the replacement up. */
|
|
1028
|
+
function restartHelperSource(spawned, launch, logs, port) {
|
|
1029
|
+
return [
|
|
1030
|
+
"const { spawn } = require('node:child_process')",
|
|
1031
|
+
"const fs = require('node:fs')",
|
|
1032
|
+
"const net = require('node:net')",
|
|
1033
|
+
`const file = ${JSON.stringify(spawned.file)}`,
|
|
1034
|
+
`const args = ${JSON.stringify(spawned.args)}`,
|
|
1035
|
+
`const cwd = ${JSON.stringify(launch.cwd)}`,
|
|
1036
|
+
`const viaShell = ${JSON.stringify(spawned.viaShell)}`,
|
|
1037
|
+
`const detached = ${JSON.stringify(spawned.detached)}`,
|
|
1038
|
+
`const logOut = ${JSON.stringify(logs.out)}`,
|
|
1039
|
+
`const logErr = ${JSON.stringify(logs.err)}`,
|
|
1040
|
+
`const port = ${JSON.stringify(port)}`,
|
|
1041
|
+
'const sleep = (ms) => new Promise((r) => setTimeout(r, ms))',
|
|
1042
|
+
'const note = (line) => { try { fs.appendFileSync(logErr, `[temp-workspace] ${line}\\n`) } catch {} }',
|
|
1043
|
+
'const listening = () => new Promise((resolve) => {',
|
|
1044
|
+
' const probe = net.connect({ host: "127.0.0.1", port })',
|
|
1045
|
+
' const done = (value) => { probe.destroy(); resolve(value) }',
|
|
1046
|
+
' probe.on("connect", () => done(true))',
|
|
1047
|
+
' probe.on("error", () => done(false))',
|
|
1048
|
+
' setTimeout(() => done(false), 500)',
|
|
1049
|
+
'})',
|
|
1050
|
+
'const main = async () => {',
|
|
1051
|
+
' if (port) {',
|
|
1052
|
+
' const until = Date.now() + 30000',
|
|
1053
|
+
' while (Date.now() < until && await listening()) await sleep(250)',
|
|
1054
|
+
' if (await listening()) note(`port ${port} was still in use after 30s; starting anyway`)',
|
|
1055
|
+
' await sleep(300)',
|
|
1056
|
+
' } else { await sleep(1500) }',
|
|
1057
|
+
' let child',
|
|
1058
|
+
' try {',
|
|
1059
|
+
' const out = fs.openSync(logOut, "a")',
|
|
1060
|
+
' const err = fs.openSync(logErr, "a")',
|
|
1061
|
+
' child = spawn(file, args, { cwd, detached, stdio: ["ignore", out, err], env: process.env, shell: viaShell })',
|
|
1062
|
+
' child.on("error", (error) => note(`could not start the replacement: ${error && error.message ? error.message : error}`))',
|
|
1063
|
+
' child.unref()',
|
|
1064
|
+
' } catch (error) {',
|
|
1065
|
+
' note(`could not start the replacement: ${error && error.message ? error.message : error}`)',
|
|
1066
|
+
' return',
|
|
1067
|
+
' }',
|
|
1068
|
+
' if (!port) { await sleep(3000); return }',
|
|
1069
|
+
' const upBy = Date.now() + 20000',
|
|
1070
|
+
' while (Date.now() < upBy && !(await listening())) await sleep(500)',
|
|
1071
|
+
' if (!(await listening())) note(`the replacement did not bind port ${port} within 20s — see the output log beside this one`)',
|
|
1072
|
+
'}',
|
|
1073
|
+
'main()',
|
|
1074
|
+
].join('\n')
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** The port this process serves on, read off the request that asked for the restart. */
|
|
1078
|
+
function servingPort(request) {
|
|
1079
|
+
const host = request.headers.host
|
|
1080
|
+
if (host === undefined) return null
|
|
1081
|
+
const match = /:(\d{1,5})$/u.exec(host)
|
|
1082
|
+
if (match === null) return null
|
|
1083
|
+
const port = Number(match[1])
|
|
1084
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Whether a process-control request came from this Web host on loopback. */
|
|
1088
|
+
function trustedRestartRequest(request) {
|
|
1089
|
+
const address = request.socket.remoteAddress
|
|
1090
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
1091
|
+
if (request.headers.forwarded !== undefined || request.headers['x-forwarded-for'] !== undefined || request.headers['x-real-ip'] !== undefined) return false
|
|
1092
|
+
const origin = request.headers.origin
|
|
1093
|
+
const host = request.headers.host
|
|
1094
|
+
if (origin === undefined || host === undefined) return false
|
|
1095
|
+
try {
|
|
1096
|
+
const parsed = new URL(origin)
|
|
1097
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host
|
|
1098
|
+
} catch {
|
|
1099
|
+
return false
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** Relaunch the exact DSH entry, then stop this process. */
|
|
1104
|
+
async function scheduleRestart(port) {
|
|
1105
|
+
const launch = restartLaunch()
|
|
1106
|
+
const spawned = respawnInvocation(launch)
|
|
1107
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
1108
|
+
const logOut = join(tmpdir(), `temp-workspace-restart-${stamp}.out.log`)
|
|
1109
|
+
const logErr = join(tmpdir(), `temp-workspace-restart-${stamp}.err.log`)
|
|
1110
|
+
const helper = spawn(nodeExecutable(), ['-e', restartHelperSource(spawned, launch, { out: logOut, err: logErr }, port)], {
|
|
1111
|
+
detached: true,
|
|
1112
|
+
stdio: 'ignore',
|
|
1113
|
+
env: process.env,
|
|
1114
|
+
})
|
|
1115
|
+
helper.unref()
|
|
1116
|
+
// Give the response a moment to reach the browser, then terminate this host.
|
|
1117
|
+
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 500)
|
|
1118
|
+
return { pid: process.pid, helperPid: helper.pid, logOut, logErr }
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
export const inject = ['webServer', 'webRuntime', 'workspaceRegistry', 'sessionPersistence', 'settings', 'loader']
|
|
1122
|
+
|
|
1123
|
+
export async function apply(ctx) {
|
|
1124
|
+
const trustedHosts = ctx.webRuntime?.trustedHosts ?? []
|
|
1125
|
+
|
|
1126
|
+
// ── settings ─────────────────────────────────────────────────────────────
|
|
1127
|
+
// Register this plugin's settings namespace host-side (defaults, schema, and
|
|
1128
|
+
// the user-editable section in ~/.dsh/settings.yaml). The browser card reads
|
|
1129
|
+
// and writes the same namespace (via its own /temp-workspace/api/config
|
|
1130
|
+
// route); the boot cleanup below reads it to decide delete timing and whether
|
|
1131
|
+
// to confirm first. Mirrors dsh-workspace-auto-approval's use of the loader
|
|
1132
|
+
// for the schemastery builder.
|
|
1133
|
+
let settingsScope
|
|
1134
|
+
try {
|
|
1135
|
+
const schemaModule = await ctx.loader.import('@deepseek-ai/schemastery')
|
|
1136
|
+
const z = schemaModule?.default ?? schemaModule
|
|
1137
|
+
const schema = z.object({
|
|
1138
|
+
deleteMode: z.union([z.const('immediate'), z.const('delayed')]).default('immediate'),
|
|
1139
|
+
deleteDelay: z.number().min(0).default(3600),
|
|
1140
|
+
confirmBeforeDelete: z.boolean().default(true),
|
|
1141
|
+
})
|
|
1142
|
+
settingsScope = ctx.settings.register(SETTINGS_NS, schema)
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
console.error(`[${PLUGIN_ID}] settings registration failed; using defaults`, error)
|
|
1145
|
+
settingsScope = undefined
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** Read the resolved settings, merging schema defaults over any missing key. */
|
|
1149
|
+
function readSettings() {
|
|
1150
|
+
const value = (() => {
|
|
1151
|
+
try { return settingsScope === undefined ? undefined : settingsScope.get() } catch { return undefined }
|
|
1152
|
+
})()
|
|
1153
|
+
return {
|
|
1154
|
+
deleteMode: value?.deleteMode ?? DEFAULT_SETTINGS.deleteMode,
|
|
1155
|
+
deleteDelay: Number(value?.deleteDelay ?? DEFAULT_SETTINGS.deleteDelay),
|
|
1156
|
+
confirmBeforeDelete: value?.confirmBeforeDelete ?? DEFAULT_SETTINGS.confirmBeforeDelete,
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// ── native-delete hook ───────────────────────────────────────────────────
|
|
1161
|
+
// DSH's workspace registry delete retains the workspace directory AND every
|
|
1162
|
+
// session log (it only removes the registration). So deleting a temp
|
|
1163
|
+
// workspace via the sidebar would leave its conversations behind until a
|
|
1164
|
+
// boot-time cleanup. Wrap the registry delete so that, whenever the deleted
|
|
1165
|
+
// workspace is one recorded in the temp marker, we immediately reap its
|
|
1166
|
+
// conversations + throwaway dir + marker entry. Non-temp workspaces (not in
|
|
1167
|
+
// the marker) are unaffected, and the plugin's own cleanup path calls the
|
|
1168
|
+
// unwrapped delete to avoid re-entering this hook.
|
|
1169
|
+
{
|
|
1170
|
+
const nativeDelete = ctx.workspaceRegistry.delete.bind(ctx.workspaceRegistry)
|
|
1171
|
+
registryDeleteUnwrapped = nativeDelete
|
|
1172
|
+
ctx.workspaceRegistry.delete = async (id) => {
|
|
1173
|
+
const result = await nativeDelete(id)
|
|
1174
|
+
try {
|
|
1175
|
+
const entry = (await readState()).find((item) => item?.workspaceId === id)
|
|
1176
|
+
if (entry !== undefined) await reapTempWorkspaceFiles(ctx, entry)
|
|
1177
|
+
} catch (error) {
|
|
1178
|
+
console.error(`[${PLUGIN_ID}] temp-workspace cleanup after delete failed for ${id}`, error)
|
|
1179
|
+
}
|
|
1180
|
+
return result
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const fence = (req) => {
|
|
1185
|
+
try {
|
|
1186
|
+
const authority = (req.headers?.host ?? '')
|
|
1187
|
+
const hostname = authority.replace(/:\d+$/, '')
|
|
1188
|
+
if (isLoopback(hostname)) return true
|
|
1189
|
+
return trustedHosts.some((entry) => (entry ?? '') === hostname || (entry ?? '').replace(/:\d+$/, '') === hostname)
|
|
1190
|
+
} catch {
|
|
1191
|
+
return false
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// ── boot cleanup (settings-driven) ───────────────────────────────────────
|
|
1196
|
+
// Read the marker + resolved settings and decide what to do on this boot:
|
|
1197
|
+
// - confirmBeforeDelete ON : never auto-delete; expose the pending set and
|
|
1198
|
+
// wait for the browser to confirm (delete) or keep it. Nothing is removed
|
|
1199
|
+
// until the user answers, so an unattended boot never loses work.
|
|
1200
|
+
// - confirmBeforeDelete OFF : delete automatically — immediately, or after
|
|
1201
|
+
// deleteDelay seconds when deleteMode === 'delayed'.
|
|
1202
|
+
// The pending list + timing are captured so the /pending route can drive the
|
|
1203
|
+
// browser popup without re-reading the marker on every poll.
|
|
1204
|
+
const pending = new Map() // workspaceId -> marker entry awaiting confirmation
|
|
1205
|
+
// Whether the user already answered the boot prompt this host-lifetime
|
|
1206
|
+
// (keep / delete / permanent-keep). Once answered, /pending reports nothing
|
|
1207
|
+
// pending so the dialog never re-appears for the same boot, even though a
|
|
1208
|
+
// temp-keep keeps the workspaces in the marker for a FUTURE boot.
|
|
1209
|
+
let bootAnswered = false
|
|
1210
|
+
|
|
1211
|
+
/** When to run the deletion confirm/action, as an epoch-ms timestamp. */
|
|
1212
|
+
function deleteAtMs(settings) {
|
|
1213
|
+
return Date.now() + (settings.deleteMode === 'delayed' ? (settings.deleteDelay * 1000) : 0)
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// A fixed boot-relative deadline (epoch ms) so a delayed confirm doesn't
|
|
1217
|
+
// slide forever on every /pending poll. Captured once, at boot.
|
|
1218
|
+
const bootDeadline = deleteAtMs(readSettings())
|
|
1219
|
+
|
|
1220
|
+
async function runBootCleanup(settings) {
|
|
1221
|
+
const list = await readState()
|
|
1222
|
+
if (list.length === 0) return
|
|
1223
|
+
if (settings.confirmBeforeDelete) {
|
|
1224
|
+
// Hold the set for the browser to confirm. Keep the marker intact so a
|
|
1225
|
+
// user who never answers still has the workspaces next boot.
|
|
1226
|
+
bootAnswered = false
|
|
1227
|
+
for (const entry of list) if (entry?.workspaceId) pending.set(entry.workspaceId, entry)
|
|
1228
|
+
console.log(`[${PLUGIN_ID}] awaiting confirmation to delete ${list.length} temporary workspace(s)`)
|
|
1229
|
+
return
|
|
1230
|
+
}
|
|
1231
|
+
// No confirmation required: remove now or schedule the removal.
|
|
1232
|
+
if (settings.deleteMode === 'delayed' && settings.deleteDelay > 0) {
|
|
1233
|
+
const ms = settings.deleteDelay * 1000
|
|
1234
|
+
console.log(`[${PLUGIN_ID}] scheduling deletion of ${list.length} temporary workspace(s) in ${ms}ms`)
|
|
1235
|
+
const timer = setTimeout(() => {
|
|
1236
|
+
void cleanupTempWorkspaces(ctx)
|
|
1237
|
+
.then((removed) => { if (removed > 0) console.log(`[${PLUGIN_ID}] removed ${removed} temporary workspace(s) after delay`) })
|
|
1238
|
+
.catch((error) => console.error(`[${PLUGIN_ID}] delayed boot cleanup failed`, error))
|
|
1239
|
+
}, ms)
|
|
1240
|
+
timer.unref?.()
|
|
1241
|
+
return
|
|
1242
|
+
}
|
|
1243
|
+
const removed = await cleanupTempWorkspaces(ctx)
|
|
1244
|
+
if (removed > 0) console.log(`[${PLUGIN_ID}] removed ${removed} temporary workspace(s) on boot`)
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** The marker entries currently held for confirmation, with a marker fallback. */
|
|
1248
|
+
async function heldEntries() {
|
|
1249
|
+
if (pending.size > 0) return [...pending.values()]
|
|
1250
|
+
// The in-memory cache may not be populated yet (boot cleanup is async, and
|
|
1251
|
+
// the browser can poll before it settles). Fall back to the durable marker
|
|
1252
|
+
// so a confirm decision never silently loses workspaces.
|
|
1253
|
+
const settings = readSettings()
|
|
1254
|
+
if (!settings.confirmBeforeDelete) return []
|
|
1255
|
+
return (await readState()).filter((entry) => entry?.workspaceId)
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
void runBootCleanup(readSettings())
|
|
1259
|
+
.catch((error) => console.error(`[${PLUGIN_ID}] boot cleanup failed`, error))
|
|
1260
|
+
|
|
1261
|
+
// Re-attach migrated sessions to their new permanent workspace. This runs at
|
|
1262
|
+
// boot, after the workspace registry initialized and indexed the migrated
|
|
1263
|
+
// headers with the new cwd — so `attachSession` validates against the new path
|
|
1264
|
+
// and succeeds. Without this, an already-initialized registry never groups the
|
|
1265
|
+
// migrated sessions and they end up ungrouped.
|
|
1266
|
+
void reattachPendingSessions(ctx)
|
|
1267
|
+
.then((n) => { if (n > 0) console.log(`[${PLUGIN_ID}] re-attached ${n} migrated session(s) after restart`) })
|
|
1268
|
+
.catch((error) => console.error(`[${PLUGIN_ID}] pending re-attach failed`, error))
|
|
1269
|
+
|
|
1270
|
+
// Reap leftover temp-workspace session project dirs from prior create/delete
|
|
1271
|
+
// cycles (the store never pruned the empty `--<cwd>--` parent). Runs alone so
|
|
1272
|
+
// it also fixes dirs orphaned before this version.
|
|
1273
|
+
void cleanupOrphanTempProjectDirs(ctx)
|
|
1274
|
+
.then((removed) => { if (removed > 0) console.log(`[${PLUGIN_ID}] swept ${removed} orphaned temp-workspace session dir(s)`) })
|
|
1275
|
+
.catch((error) => console.error(`[${PLUGIN_ID}] orphaned-dir sweep failed`, error))
|
|
1276
|
+
|
|
1277
|
+
// Reclaim the durable metadata residue of already-deleted temp workspaces:
|
|
1278
|
+
// projection-cache rows, archived-id entries, and legacy per-session cache
|
|
1279
|
+
// files. Identity-checked reads never surface them as conversations, but
|
|
1280
|
+
// "no trace" means no trace — this is the authoritative net behind the
|
|
1281
|
+
// delete-time pruning in `removeLiveSessions`. The projection cache may
|
|
1282
|
+
// still be initializing when `apply` runs (this plugin does not inject it),
|
|
1283
|
+
// so retry until its table is ready.
|
|
1284
|
+
const sweepResidue = (attempt) => {
|
|
1285
|
+
const cache = (typeof ctx.get === 'function' && ctx.get('sessionProjectionCache')) || ctx.sessionProjectionCache
|
|
1286
|
+
if (!cache || !cache.table) {
|
|
1287
|
+
if (attempt < 10) setTimeout(() => sweepResidue(attempt + 1), 2000)
|
|
1288
|
+
return
|
|
1289
|
+
}
|
|
1290
|
+
void pruneOrphanTempResidue(ctx)
|
|
1291
|
+
.catch((error) => console.error(`[${PLUGIN_ID}] orphaned projection-cache sweep failed`, error))
|
|
1292
|
+
}
|
|
1293
|
+
sweepResidue(0)
|
|
1294
|
+
|
|
1295
|
+
// ── dispatch ─────────────────────────────────────────────────────────────
|
|
1296
|
+
const dispatch = {
|
|
1297
|
+
create: async () => createTempWorkspace(ctx),
|
|
1298
|
+
list: async () => ({ entries: await listTempWorkspaces() }),
|
|
1299
|
+
delete: async (payload) => {
|
|
1300
|
+
if (typeof payload?.workspaceId !== 'string' || payload.workspaceId === '') {
|
|
1301
|
+
throw new TempWorkspaceError('bad-arg', 'missing string "workspaceId"', 400)
|
|
1302
|
+
}
|
|
1303
|
+
// Use the marker entry (which carries the durable path) so a workspace
|
|
1304
|
+
// whose registry record is already gone still reaps its folder.
|
|
1305
|
+
const entry = (await readState()).find((item) => item.workspaceId === payload.workspaceId)
|
|
1306
|
+
await deleteTempWorkspace(ctx, entry ?? { workspaceId: payload.workspaceId, path: undefined })
|
|
1307
|
+
return { ok: true }
|
|
1308
|
+
},
|
|
1309
|
+
// Settings read/write for the Settings -> Plugins card (mirrors the
|
|
1310
|
+
// workspace-auto-approval /config contract).
|
|
1311
|
+
config: async (payload) => {
|
|
1312
|
+
const settings = readSettings()
|
|
1313
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload) && Object.keys(payload).length > 0) {
|
|
1314
|
+
await writeSettings(payload)
|
|
1315
|
+
return readSettings()
|
|
1316
|
+
}
|
|
1317
|
+
return settings
|
|
1318
|
+
},
|
|
1319
|
+
// Restore every settings field to its default (empties the user layer).
|
|
1320
|
+
configReset: async () => {
|
|
1321
|
+
await writeSettings(null)
|
|
1322
|
+
return readSettings()
|
|
1323
|
+
},
|
|
1324
|
+
// Pending-confirmation state for the boot popup.
|
|
1325
|
+
pending: async () => {
|
|
1326
|
+
const settings = readSettings()
|
|
1327
|
+
if (bootAnswered) return { pending: [], confirmBeforeDelete: settings.confirmBeforeDelete, deleteMode: settings.deleteMode, deleteAt: bootDeadline, pendingCount: 0 }
|
|
1328
|
+
const entries = await heldEntries()
|
|
1329
|
+
return {
|
|
1330
|
+
pending: entries.map((entry) => ({ workspaceId: entry.workspaceId, path: entry.path, createdAt: entry.createdAt })),
|
|
1331
|
+
confirmBeforeDelete: settings.confirmBeforeDelete,
|
|
1332
|
+
deleteMode: settings.deleteMode,
|
|
1333
|
+
deleteAt: bootDeadline,
|
|
1334
|
+
pendingCount: entries.length,
|
|
1335
|
+
}
|
|
1336
|
+
},
|
|
1337
|
+
// User confirmed: delete the held set now.
|
|
1338
|
+
confirm: async () => {
|
|
1339
|
+
bootAnswered = true
|
|
1340
|
+
const entries = await heldEntries()
|
|
1341
|
+
pending.clear()
|
|
1342
|
+
for (const entry of entries) {
|
|
1343
|
+
try { await deleteTempWorkspace(ctx, entry) } catch (error) { console.error(`[${PLUGIN_ID}] confirmed delete failed for ${entry?.workspaceId}`, error) }
|
|
1344
|
+
}
|
|
1345
|
+
return { deleted: entries.length }
|
|
1346
|
+
},
|
|
1347
|
+
// User chose "temporary keep": preserve these workspaces but keep them
|
|
1348
|
+
// temporary (they stay in the marker and are subject to cleanup again on a
|
|
1349
|
+
// later boot). Only the in-memory hold is released.
|
|
1350
|
+
keep: async () => {
|
|
1351
|
+
bootAnswered = true
|
|
1352
|
+
const entries = await heldEntries()
|
|
1353
|
+
pending.clear()
|
|
1354
|
+
return { kept: entries.length }
|
|
1355
|
+
},
|
|
1356
|
+
// Permanent keep: use the user-chosen folder directly as the permanent
|
|
1357
|
+
// workspace (move the temp files in, name it after the folder). With an
|
|
1358
|
+
// explicit workspaceId (from the per-row button) only that temp workspace is
|
|
1359
|
+
// moved; otherwise every held temp workspace is moved.
|
|
1360
|
+
permanentKeep: async (payload) => {
|
|
1361
|
+
const target = typeof payload?.target === 'string' && payload.target !== '' ? payload.target : undefined
|
|
1362
|
+
if (target === undefined) throw new TempWorkspaceError('bad-arg', 'missing string "target"', 400)
|
|
1363
|
+
const explicitId = typeof payload?.workspaceId === 'string' && payload.workspaceId !== '' ? payload.workspaceId : undefined
|
|
1364
|
+
let entries
|
|
1365
|
+
if (explicitId !== undefined) {
|
|
1366
|
+
bootAnswered = true
|
|
1367
|
+
const marker = await readState()
|
|
1368
|
+
const entry = marker.find((item) => item?.workspaceId === explicitId)
|
|
1369
|
+
entries = entry === undefined ? [] : [entry]
|
|
1370
|
+
} else {
|
|
1371
|
+
bootAnswered = true
|
|
1372
|
+
entries = await heldEntries()
|
|
1373
|
+
}
|
|
1374
|
+
if (entries.length === 0) return { moved: [] }
|
|
1375
|
+
const moved = []
|
|
1376
|
+
for (const entry of entries) {
|
|
1377
|
+
try {
|
|
1378
|
+
moved.push(await moveTempWorkspace(ctx, entry, target))
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
console.error(`[${PLUGIN_ID}] permanent-keep move failed for ${entry?.workspaceId}`, error)
|
|
1381
|
+
throw error
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
return { moved }
|
|
1385
|
+
},
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
async function writeSettings(patch) {
|
|
1389
|
+
if (settingsScope === undefined) return
|
|
1390
|
+
// `null` (or an empty patch) resets the whole user layer back to defaults.
|
|
1391
|
+
if (patch === null) {
|
|
1392
|
+
await settingsScope.replace({})
|
|
1393
|
+
return
|
|
1394
|
+
}
|
|
1395
|
+
// Normalize a partial patch over the defaulted current value, validating
|
|
1396
|
+
// deleteMode against its literal union and coercing numerics.
|
|
1397
|
+
const next = { ...readSettings(), ...(patch ?? {}) }
|
|
1398
|
+
if (next.deleteMode !== 'immediate' && next.deleteMode !== 'delayed') {
|
|
1399
|
+
throw new TempWorkspaceError('bad-arg', 'deleteMode must be "immediate" or "delayed"', 400)
|
|
1400
|
+
}
|
|
1401
|
+
const delay = Number(next.deleteDelay)
|
|
1402
|
+
if (!Number.isFinite(delay) || delay < 0) {
|
|
1403
|
+
throw new TempWorkspaceError('bad-arg', 'deleteDelay must be a non-negative number', 400)
|
|
1404
|
+
}
|
|
1405
|
+
await settingsScope.update({
|
|
1406
|
+
deleteMode: next.deleteMode,
|
|
1407
|
+
deleteDelay: Math.floor(delay),
|
|
1408
|
+
confirmBeforeDelete: Boolean(next.confirmBeforeDelete),
|
|
1409
|
+
})
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1413
|
+
kind: 'prefix',
|
|
1414
|
+
path: '/temp-workspace/api',
|
|
1415
|
+
handler: async (req, res) => {
|
|
1416
|
+
if (!fence(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } }); return }
|
|
1417
|
+
// Reads (config GET, pending GET) use GET; mutations use POST.
|
|
1418
|
+
const isRead = req.method === 'GET'
|
|
1419
|
+
if (!isRead && req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } }); return }
|
|
1420
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
1421
|
+
const method = pathname.startsWith('/temp-workspace/api/') ? pathname.slice('/temp-workspace/api/'.length) : undefined
|
|
1422
|
+
if (method === undefined || method.includes('/')) { writeError(res, new TempWorkspaceError('not-found', 'unknown temp-workspace method', 404)); return }
|
|
1423
|
+
|
|
1424
|
+
// Restart needs the raw request (port, socket, Origin), so handle it
|
|
1425
|
+
// before the payload-dispatch path. Same guard as dsh-market: direct
|
|
1426
|
+
// same-origin loopback, no forwarding headers.
|
|
1427
|
+
if (method === 'restart') {
|
|
1428
|
+
if (!trustedRestartRequest(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'restart requires a same-origin loopback request' } }); return }
|
|
1429
|
+
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } }); return }
|
|
1430
|
+
const port = servingPort(req)
|
|
1431
|
+
const result = await scheduleRestart(port)
|
|
1432
|
+
writeOk(res, { restarting: true, ...result })
|
|
1433
|
+
return
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
try {
|
|
1437
|
+
const payload = isRead ? {} : await readJsonBody(req)
|
|
1438
|
+
const handler = dispatch[method]
|
|
1439
|
+
if (handler === undefined) throw new TempWorkspaceError('not-found', `unknown temp-workspace method "${method}"`, 404)
|
|
1440
|
+
writeOk(res, await handler(payload))
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
writeError(res, error)
|
|
1443
|
+
}
|
|
1444
|
+
},
|
|
1445
|
+
}), `${PLUGIN_ID}: /temp-workspace/api routes`)
|
|
1446
|
+
}
|