dsh-scratch-workspace 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/gc.js ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Disk Cleanliness (磁盘常态洁净): Lifecycle garbage collection for ephemeral
3
+ * session directories, empty abandoned folders, and orphan directories.
4
+ * @module dsh-scratch-workspace/lib/gc
5
+ */
6
+
7
+ import { readdir, rename, rm, stat } from 'node:fs/promises'
8
+ import { join } from 'node:path'
9
+ import { SESSION_DIR_PATTERN } from './isolate.js'
10
+
11
+ /** Default TTL for orphan session directories: 24 hours. */
12
+ export const DEFAULT_ORPHAN_TTL_MS = 24 * 60 * 60 * 1000
13
+
14
+ /** Grace period for empty abandoned session folders: 5 minutes. */
15
+ export const EMPTY_SESSION_GRACE_MS = 5 * 60 * 1000
16
+
17
+ /**
18
+ * Check whether a directory is empty (ignoring system artifacts like .DS_Store).
19
+ * @param dir - absolute directory path.
20
+ * @returns true if directory contains no meaningful files.
21
+ */
22
+ export async function isDirectoryEmpty(dir) {
23
+ try {
24
+ const entries = await readdir(dir, { withFileTypes: true })
25
+ const meaningful = entries.filter(e => e.name !== '.DS_Store' && e.name !== 'Thumbs.db')
26
+ return meaningful.length === 0
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Remove one session directory safely.
34
+ * @param sessionDir - absolute path of session directory.
35
+ */
36
+ export async function removeSessionDir(sessionDir) {
37
+ try {
38
+ await rm(sessionDir, { recursive: true, force: true })
39
+ return true
40
+ } catch {
41
+ return false
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Normalize session ID to its 8-char short prefix for directory matching.
47
+ * @param id - full session ID (e.g. "session-de59473b-0408-...")
48
+ * @returns 8-character hex prefix
49
+ */
50
+ export function shortSessionId(id) {
51
+ const flattened = String(id).replace(/^session-/, '').replaceAll('-', '')
52
+ return flattened.slice(0, 8).toLowerCase()
53
+ }
54
+
55
+ /**
56
+ * Scan isolating roots and clean up orphan and empty abandoned session folders.
57
+ * @param options - execution parameters.
58
+ * @returns summary of cleaned directories.
59
+ */
60
+ export async function cleanOrphans({
61
+ roots = [],
62
+ knownSessionIds = new Set(),
63
+ orphanTtlMs = DEFAULT_ORPHAN_TTL_MS,
64
+ emptyGraceMs = EMPTY_SESSION_GRACE_MS,
65
+ now = Date.now(),
66
+ logger,
67
+ } = {}) {
68
+ const cleaned = []
69
+ const knownShortIds = new Set([...knownSessionIds].map(shortSessionId))
70
+ let scannedCount = 0
71
+
72
+ for (const root of roots) {
73
+ let entries = []
74
+ try {
75
+ entries = await readdir(root, { withFileTypes: true })
76
+ } catch {
77
+ continue
78
+ }
79
+ scannedCount += entries.length
80
+
81
+ // Index titled folders in this root: shortId -> titledDirPath
82
+ const titledFolders = new Map()
83
+ for (const entry of entries) {
84
+ if (!entry.isDirectory()) continue
85
+ const match = entry.name.match(SESSION_DIR_PATTERN)
86
+ if (match && match[1]) {
87
+ const short = match[2].split('-')[0].toLowerCase()
88
+ titledFolders.set(short, join(root, entry.name))
89
+ }
90
+ }
91
+
92
+ for (const entry of entries) {
93
+ if (entry.isSymbolicLink()) {
94
+ const match = entry.name.match(SESSION_DIR_PATTERN)
95
+ if (match) {
96
+ const linkPath = join(root, entry.name)
97
+ await rm(linkPath, { force: true }).catch(() => {})
98
+ cleaned.push({ path: linkPath, reason: 'legacy-symlink' })
99
+ logger?.info?.(`dsh-scratch-workspace gc: removed legacy symlink ${linkPath}`)
100
+ continue
101
+ }
102
+ }
103
+ if (!entry.isDirectory()) continue
104
+ const match = entry.name.match(SESSION_DIR_PATTERN)
105
+ if (!match) continue
106
+
107
+ const dirPath = join(root, entry.name)
108
+ const folderShort = (match[2] ?? match[1] ?? '').split('-')[0].toLowerCase()
109
+
110
+ try {
111
+ // Case 0: Pre-rename duplicate leftover whose titled folder already exists
112
+ if (!match[1] && titledFolders.has(folderShort)) {
113
+ const titledDir = titledFolders.get(folderShort)
114
+ try {
115
+ const subEntries = await readdir(dirPath)
116
+ for (const sub of subEntries) {
117
+ if (sub === '.DS_Store' || sub === 'Thumbs.db') continue
118
+ await rename(join(dirPath, sub), join(titledDir, sub)).catch(() => {})
119
+ }
120
+ } catch {}
121
+ await removeSessionDir(dirPath)
122
+ cleaned.push({ path: dirPath, reason: 'pre-rename-duplicate' })
123
+ logger?.info?.(`dsh-scratch-workspace gc: removed pre-rename duplicate dir ${dirPath}`)
124
+ continue
125
+ }
126
+
127
+ const info = await stat(dirPath)
128
+ const ageMs = now - info.mtimeMs
129
+
130
+ // Case 1: Empty abandoned session folder past grace period
131
+ const empty = await isDirectoryEmpty(dirPath)
132
+ if (empty && ageMs >= emptyGraceMs) {
133
+ await removeSessionDir(dirPath)
134
+ cleaned.push({ path: dirPath, reason: 'empty-abandoned' })
135
+ logger?.info?.(`dsh-scratch-workspace gc: removed empty session dir ${dirPath}`)
136
+ continue
137
+ }
138
+
139
+ // Case 2: Orphan session directory no longer associated with any session
140
+ const hasSession = knownShortIds.has(folderShort)
141
+ if (!hasSession && ageMs > orphanTtlMs) {
142
+ await removeSessionDir(dirPath)
143
+ cleaned.push({ path: dirPath, reason: 'orphan-ttl' })
144
+ logger?.info?.(`dsh-scratch-workspace gc: removed orphan session dir ${dirPath}`)
145
+ }
146
+ } catch {
147
+ // Ignore races with active filesystem operations
148
+ }
149
+ }
150
+ }
151
+
152
+ cleaned.cleanedCount = cleaned.length
153
+ cleaned.scannedCount = scannedCount
154
+ return cleaned
155
+ }
156
+
157
+ /**
158
+ * Setup automatic periodic GC.
159
+ * @param ctx - Cordis context.
160
+ * @param iso - shared isolation state.
161
+ * @param options - GC interval and TTL configuration.
162
+ * @returns disposer function.
163
+ */
164
+ export function setupAutoGc(ctx, iso, {
165
+ intervalMs = 30 * 60 * 1000,
166
+ orphanTtlMs = DEFAULT_ORPHAN_TTL_MS,
167
+ } = {}) {
168
+ let timer = null
169
+
170
+ async function collectKnownSessions() {
171
+ const ids = new Set()
172
+ // Live sessions from registry
173
+ const registry = ctx.root?.workspaceRegistry ?? ctx.workspaceRegistry
174
+ const workspaces = registry?.list?.() ?? []
175
+ for (const ws of workspaces) {
176
+ for (const id of ws.sessionIds ?? []) ids.add(id)
177
+ }
178
+ // Live agents
179
+ const agents = ctx.agents ?? ctx.root?.agents
180
+ if (typeof agents?.list === 'function') {
181
+ for (const ag of agents.list() ?? []) if (ag?.id) ids.add(ag.id)
182
+ }
183
+ // Stored headers if available
184
+ try {
185
+ const stored = await registry?.listStoredHeaders?.()
186
+ if (Array.isArray(stored)) {
187
+ for (const h of stored) if (h?.id) ids.add(h.id)
188
+ }
189
+ } catch {}
190
+
191
+ return ids
192
+ }
193
+
194
+ async function triggerGc() {
195
+ try {
196
+ const known = await collectKnownSessions()
197
+ const roots = iso.roots()
198
+ await cleanOrphans({
199
+ roots,
200
+ knownSessionIds: known,
201
+ orphanTtlMs,
202
+ logger: ctx.logger,
203
+ })
204
+ } catch (error) {
205
+ ctx.logger?.warn?.(`dsh-scratch-workspace gc failed: ${error?.message ?? error}`)
206
+ }
207
+ }
208
+
209
+ // Initial GC after host settles (10 seconds)
210
+ const initialTimeout = setTimeout(triggerGc, 10_000)
211
+ initialTimeout?.unref?.()
212
+
213
+ // Periodic GC
214
+ timer = setInterval(triggerGc, intervalMs)
215
+ timer?.unref?.()
216
+
217
+ return () => {
218
+ clearTimeout(initialTimeout)
219
+ if (timer) clearInterval(timer)
220
+ }
221
+ }
package/lib/isolate.js ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Isolation policy: which directories isolate their sessions, and where one
3
+ * session's private directory lands.
4
+ *
5
+ * The durable marker is a per-directory `.dsh-isolate` file. In addition,
6
+ * default-workspace can be automatically isolated to keep ad-hoc explorations
7
+ * clean without manual intervention.
8
+ * @module dsh-scratch-workspace/lib/isolate
9
+ */
10
+
11
+ import { mkdir, rm, stat, writeFile } from 'node:fs/promises'
12
+ import { join, resolve, sep } from 'node:path'
13
+
14
+ /** Marker file that switches one directory to per-session working folders. */
15
+ export const MARKER_NAME = '.dsh-isolate'
16
+
17
+ /** Prefix of every per-session working directory. */
18
+ export const SESSION_DIR_PREFIX = 'session-'
19
+
20
+ /** Regex matching session directory names, supporting both raw shortIds and titled formats. */
21
+ export const SESSION_DIR_PATTERN = /^session-(?:(.*)-)?([0-9a-fA-F]{8,36}(?:-\d+)?)$/
22
+
23
+ /**
24
+ * Sanitize a human session title for filesystem folder naming.
25
+ * Strips path separators, illegal filename characters, and limits length.
26
+ * @param {string} title
27
+ * @param {number} [maxChars=30]
28
+ * @returns {string}
29
+ */
30
+ export function sanitizeTitleForPath(title, maxChars = 30) {
31
+ if (typeof title !== 'string') return ''
32
+ const cleaned = title
33
+ .replace(/[\/\\:*?"<>|#\x00-\x1f]/gu, '')
34
+ .replace(/\s+/gu, '-')
35
+ .replace(/-+/gu, '-')
36
+ .trim()
37
+ return cleaned.slice(0, maxChars).replace(/^-+|-+$/g, '')
38
+ }
39
+
40
+ /**
41
+ * Whether a session title is meaningful (not a default placeholder).
42
+ * @param {string} title
43
+ * @returns {boolean}
44
+ */
45
+ export function isMeaningfulTitle(title) {
46
+ if (typeof title !== 'string') return false
47
+ const trimmed = title.trim().toLowerCase()
48
+ if (!trimmed) return false
49
+ const placeholders = new Set([
50
+ '新会话', '新建会话', 'new session', 'new chat', 'untitled', '新任务', 'new task'
51
+ ])
52
+ return !placeholders.has(trimmed)
53
+ }
54
+
55
+ /** The one separator that follows a root spelling in a prefix test. */
56
+ const SEPARATORS = new Set(['/', '\\'])
57
+
58
+ /** Whether `child` is `root` itself or lies inside it (both canonical absolute paths). */
59
+ export function sameOrInside(root, child) {
60
+ if (child === root) return true
61
+ const prefix = root !== '' && SEPARATORS.has(root.at(-1)) ? root : root + sep
62
+ return child.startsWith(prefix)
63
+ }
64
+
65
+ /**
66
+ * Whether one directory currently isolates its sessions.
67
+ * @param root - absolute directory path.
68
+ * @param defaultPath - optional default workspace path to auto-isolate.
69
+ * @returns true when marked or when matching the default workspace.
70
+ */
71
+ export async function isIsolated(root, defaultPath) {
72
+ if (typeof root !== 'string' || root === '') return false
73
+ if (defaultPath && root === defaultPath) return true
74
+ try {
75
+ return (await stat(join(root, MARKER_NAME))).isFile()
76
+ } catch {
77
+ return false
78
+ }
79
+ }
80
+
81
+ /** The comment a freshly written marker carries. */
82
+ const MARKER_TEXT = [
83
+ '# DeepSeek Harness per-session working directories',
84
+ '#',
85
+ '# While this file exists, every new session created in this directory works in',
86
+ '# its own "session-<id>" subfolder instead of the directory itself, so',
87
+ '# concurrent sessions cannot overwrite each other\'s files.',
88
+ '#',
89
+ '# Delete this file to share one working directory again. Sessions already',
90
+ '# created keep the folder they were created in.',
91
+ '',
92
+ ].join('\n')
93
+
94
+ /**
95
+ * Enable or disable isolation for one directory by writing or removing its marker.
96
+ * @param root - absolute directory path.
97
+ * @param enabled - target state.
98
+ * @returns the state that is now on disk.
99
+ */
100
+ export async function setIsolated(root, enabled) {
101
+ const marker = join(root, MARKER_NAME)
102
+ if (!enabled) {
103
+ await rm(marker, { force: true })
104
+ return false
105
+ }
106
+ await mkdir(root, { recursive: true })
107
+ await writeFile(marker, MARKER_TEXT, 'utf8')
108
+ return true
109
+ }
110
+
111
+ /**
112
+ * Every marked directory that is an ancestor of `path`, nearest first.
113
+ * @param roots - candidate marked directories.
114
+ * @param path - absolute directory to test.
115
+ * @returns matching roots in nearest-first order.
116
+ */
117
+ export function containingRoots(roots, path) {
118
+ return [...roots]
119
+ .filter(root => sameOrInside(root, path))
120
+ .sort((left, right) => right.length - left.length)
121
+ }
122
+
123
+ /**
124
+ * The nearest marked ancestor of `path`, if any.
125
+ * @param roots - candidate marked directories.
126
+ * @param path - absolute directory to test.
127
+ * @returns the owning marked directory, or undefined.
128
+ */
129
+ export function owningRoot(roots, path) {
130
+ return containingRoots(roots, path)[0]
131
+ }
132
+
133
+ /**
134
+ * Allocate one private directory for a session under an isolating directory.
135
+ *
136
+ * The name keeps the `session-<id>` shape: the first eight characters of
137
+ * the session id, with `-2`, `-3`, … on collision.
138
+ * If a collided candidate is completely empty (abandoned draft), it is reused.
139
+ * @param root - isolating directory.
140
+ * @param sessionId - the session's id.
141
+ * @param isDirectoryEmptyFn - optional predicate to check if an existing folder is empty.
142
+ * @returns the absolute directory path, created on disk.
143
+ */
144
+ export async function allocateSessionDir(root, sessionId, isDirectoryEmptyFn) {
145
+ const flattened = String(sessionId).replace(/^session-/, '').replaceAll('-', '')
146
+ const short = flattened.slice(0, 8) === '' ? 'unknown' : flattened.slice(0, 8)
147
+ for (let attempt = 1; ; attempt += 1) {
148
+ const suffix = attempt === 1 ? '' : `-${attempt}`
149
+ const candidate = resolve(join(root, `${SESSION_DIR_PREFIX}${short}${suffix}`))
150
+ try {
151
+ await mkdir(candidate)
152
+ return candidate
153
+ } catch (error) {
154
+ if (error !== null && typeof error === 'object' && error.code === 'EEXIST') {
155
+ if (typeof isDirectoryEmptyFn === 'function') {
156
+ const empty = await isDirectoryEmptyFn(candidate).catch(() => false)
157
+ if (empty) return candidate
158
+ }
159
+ continue
160
+ }
161
+ throw error
162
+ }
163
+ }
164
+ }