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/LICENSE +21 -0
- package/README.md +144 -2
- package/cordis.patch.yml +4 -0
- package/docs/DETAIL.zh.md +29 -0
- package/index.js +87 -0
- package/lib/client.js +256 -0
- package/lib/command.js +110 -0
- package/lib/gc.js +221 -0
- package/lib/isolate.js +164 -0
- package/lib/patch.js +660 -0
- package/lib/state.js +127 -0
- package/lib/tool.js +118 -0
- package/lib/web.js +116 -0
- package/locale/en.json +9 -0
- package/locale/zh-CN.json +9 -0
- package/locale/zh.json +9 -0
- package/package.json +57 -4
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
|
+
}
|