@gehennawu/dsh-service 0.11.1
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.en.md +136 -0
- package/README.md +136 -0
- package/client.js +1251 -0
- package/cordis.patch.yml +6 -0
- package/index.js +1128 -0
- package/package.json +56 -0
package/index.js
ADDED
|
@@ -0,0 +1,1128 @@
|
|
|
1
|
+
// Host half of @gehennawu/dsh-service
|
|
2
|
+
// 「服务控制」:版本信息 + 检查更新 + 一键重启 dsh web
|
|
3
|
+
import { createHmac, randomBytes, randomUUID } from 'node:crypto'
|
|
4
|
+
import { Buffer } from 'node:buffer'
|
|
5
|
+
import { constants as fsConstants } from 'node:fs'
|
|
6
|
+
import { access, cp, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { basename, join, relative, resolve, sep } from 'node:path'
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
10
|
+
import https from 'node:https'
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url)
|
|
13
|
+
const name = 'dsh-service'
|
|
14
|
+
const inject = ['connection']
|
|
15
|
+
const DSH_PACKAGE = '@deepseek-ai/dsh'
|
|
16
|
+
const PLUGIN_PACKAGE = '@gehennawu/dsh-service'
|
|
17
|
+
const NPM_REGISTRY = 'https://registry.npmjs.org/'
|
|
18
|
+
const MAX_NPM_RESPONSE_BYTES = 256 * 1024
|
|
19
|
+
const MAX_BACKUP_TRANSFER_BYTES = 128 * 1024 * 1024
|
|
20
|
+
const instanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
21
|
+
const backupIdSecret = randomBytes(32)
|
|
22
|
+
const BACKUP_NAME = /^dsh-backup-\d{8}-\d{6}\.tar\.gz$/
|
|
23
|
+
const USAGE_INDEX_VERSION = 4
|
|
24
|
+
const USAGE_INDEX_FILE = 'dsh-service-usage-index.json'
|
|
25
|
+
|
|
26
|
+
// 读取当前 dsh 版本。DSH 包由宿主安装,不作为插件依赖打包进来。
|
|
27
|
+
let dshVersion = 'unknown'
|
|
28
|
+
let pluginVersion = 'unknown'
|
|
29
|
+
try { pluginVersion = require('./package.json').version } catch (_) {}
|
|
30
|
+
try {
|
|
31
|
+
dshVersion = require(`${DSH_PACKAGE}/package.json`).version
|
|
32
|
+
} catch (_) {
|
|
33
|
+
try {
|
|
34
|
+
dshVersion = require('/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json').version
|
|
35
|
+
} catch (__) {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isSemverIdentifier(value) {
|
|
39
|
+
if (value.length === 0) return false
|
|
40
|
+
return [...value].every((char) => (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char === '-')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isNumericSemverIdentifier(value) {
|
|
44
|
+
return value.length > 0 && [...value].every((char) => char >= '0' && char <= '9')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseSemver(value) {
|
|
48
|
+
if (typeof value !== 'string') return null
|
|
49
|
+
const trimmed = value.trim()
|
|
50
|
+
const buildParts = trimmed.split('+')
|
|
51
|
+
if (buildParts.length > 2 || (buildParts.length === 2 && !buildParts[1].split('.').every((part) => isSemverIdentifier(part)))) return null
|
|
52
|
+
const versionPart = buildParts[0]
|
|
53
|
+
const dashIndex = versionPart.indexOf('-')
|
|
54
|
+
const corePart = dashIndex === -1 ? versionPart : versionPart.slice(0, dashIndex)
|
|
55
|
+
const prereleasePart = dashIndex === -1 ? '' : versionPart.slice(dashIndex + 1)
|
|
56
|
+
if (dashIndex !== -1 && prereleasePart.length === 0) return null
|
|
57
|
+
const core = corePart.split('.')
|
|
58
|
+
if (core.length !== 3 || !core.every((part) => isNumericSemverIdentifier(part) && (part.length === 1 || !part.startsWith('0')))) return null
|
|
59
|
+
const prerelease = prereleasePart === '' ? [] : prereleasePart.split('.')
|
|
60
|
+
if (!prerelease.every((part) => isSemverIdentifier(part) && !(isNumericSemverIdentifier(part) && part.length > 1 && part.startsWith('0')))) return null
|
|
61
|
+
return { major: Number(core[0]), minor: Number(core[1]), patch: Number(core[2]), prerelease }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function compareSemver(left, right) {
|
|
65
|
+
const a = parseSemver(left)
|
|
66
|
+
const b = parseSemver(right)
|
|
67
|
+
if (!a || !b) return 0
|
|
68
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
69
|
+
if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1
|
|
70
|
+
}
|
|
71
|
+
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
|
|
72
|
+
if (a.prerelease.length === 0) return 1
|
|
73
|
+
if (b.prerelease.length === 0) return -1
|
|
74
|
+
const length = Math.max(a.prerelease.length, b.prerelease.length)
|
|
75
|
+
for (let index = 0; index < length; index += 1) {
|
|
76
|
+
if (index >= a.prerelease.length) return -1
|
|
77
|
+
if (index >= b.prerelease.length) return 1
|
|
78
|
+
const leftPart = a.prerelease[index]
|
|
79
|
+
const rightPart = b.prerelease[index]
|
|
80
|
+
if (leftPart === rightPart) continue
|
|
81
|
+
const leftNumeric = /^[0-9]+$/.test(leftPart)
|
|
82
|
+
const rightNumeric = /^[0-9]+$/.test(rightPart)
|
|
83
|
+
if (leftNumeric && rightNumeric) return Number(leftPart) > Number(rightPart) ? 1 : -1
|
|
84
|
+
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
|
|
85
|
+
return leftPart > rightPart ? 1 : -1
|
|
86
|
+
}
|
|
87
|
+
return 0
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function atLeastSemver(current, target) {
|
|
91
|
+
if (parseSemver(current) && parseSemver(target)) return compareSemver(current, target) >= 0
|
|
92
|
+
return current === target
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 只请求固定的 npm registry 包元数据:不接受来自浏览器的 URL 或包名,避免 SSRF。
|
|
96
|
+
function fetchPublishedVersions(packageName) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
let settled = false
|
|
99
|
+
const fail = (error) => {
|
|
100
|
+
if (settled) return
|
|
101
|
+
settled = true
|
|
102
|
+
reject(error)
|
|
103
|
+
}
|
|
104
|
+
const succeed = (value) => {
|
|
105
|
+
if (settled) return
|
|
106
|
+
settled = true
|
|
107
|
+
resolve(value)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const url = NPM_REGISTRY + encodeURIComponent(packageName)
|
|
111
|
+
const request = https.get(url, {
|
|
112
|
+
timeout: 10000,
|
|
113
|
+
headers: {
|
|
114
|
+
Accept: 'application/json',
|
|
115
|
+
'User-Agent': 'dsh-service',
|
|
116
|
+
},
|
|
117
|
+
}, (response) => {
|
|
118
|
+
const status = response.statusCode || 0
|
|
119
|
+
if (status < 200 || status >= 300) {
|
|
120
|
+
response.resume()
|
|
121
|
+
fail(new Error(`npm registry 返回 HTTP ${status}`))
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let body = ''
|
|
126
|
+
let bytes = 0
|
|
127
|
+
response.setEncoding('utf8')
|
|
128
|
+
response.on('data', (chunk) => {
|
|
129
|
+
bytes += Buffer.byteLength(chunk)
|
|
130
|
+
if (bytes > MAX_NPM_RESPONSE_BYTES) {
|
|
131
|
+
fail(new Error('npm registry 响应过大'))
|
|
132
|
+
request.destroy()
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
body += chunk
|
|
136
|
+
})
|
|
137
|
+
response.on('error', fail)
|
|
138
|
+
response.on('end', () => {
|
|
139
|
+
if (settled) return
|
|
140
|
+
try {
|
|
141
|
+
const data = JSON.parse(body)
|
|
142
|
+
const distTags = data?.['dist-tags'] || {}
|
|
143
|
+
const normalizeTag = (value) => {
|
|
144
|
+
const version = typeof value === 'string' ? value.trim() : ''
|
|
145
|
+
return parseSemver(version) === null ? null : version
|
|
146
|
+
}
|
|
147
|
+
const tags = {
|
|
148
|
+
latest: normalizeTag(distTags.latest),
|
|
149
|
+
next: normalizeTag(distTags.next),
|
|
150
|
+
}
|
|
151
|
+
const versions = [tags.latest, tags.next].filter((version) => parseSemver(version) !== null)
|
|
152
|
+
if (versions.length === 0) {
|
|
153
|
+
fail(new Error('npm 响应中没有有效的 latest 或 next 版本'))
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
const latest = versions.reduce((selected, version) => compareSemver(version, selected) > 0 ? version : selected)
|
|
157
|
+
succeed({ latest, tags })
|
|
158
|
+
} catch (_) {
|
|
159
|
+
fail(new Error('解析 npm 响应失败'))
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
request.on('error', fail)
|
|
164
|
+
request.on('timeout', () => {
|
|
165
|
+
request.destroy()
|
|
166
|
+
fail(new Error('请求 npm registry 超时'))
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resolveDshHome() {
|
|
172
|
+
const configured = process.env.DSH_HOME?.trim()
|
|
173
|
+
return configured ? configured : join(homedir(), '.dsh')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function formatBackupTimestamp(date) {
|
|
177
|
+
const digits = (value) => String(value).padStart(2, '0')
|
|
178
|
+
return `${date.getFullYear()}${digits(date.getMonth() + 1)}${digits(date.getDate())}-${digits(date.getHours())}${digits(date.getMinutes())}${digits(date.getSeconds())}`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function backupId(name) {
|
|
182
|
+
return createHmac('sha256', backupIdSecret).update(name).digest('base64url')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function pathExists(path) {
|
|
186
|
+
try {
|
|
187
|
+
await stat(path)
|
|
188
|
+
return true
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error?.code === 'ENOENT') return false
|
|
191
|
+
throw error
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function listBackups(dshHome) {
|
|
196
|
+
const backupDir = join(dshHome, 'backups')
|
|
197
|
+
await mkdir(backupDir, { recursive: true, mode: 0o700 })
|
|
198
|
+
const entries = await readdir(backupDir, { withFileTypes: true })
|
|
199
|
+
const items = []
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
if (!entry.isFile() || !BACKUP_NAME.test(entry.name)) continue
|
|
202
|
+
const info = await stat(join(backupDir, entry.name))
|
|
203
|
+
items.push({
|
|
204
|
+
id: backupId(entry.name),
|
|
205
|
+
name: entry.name,
|
|
206
|
+
sizeBytes: info.size,
|
|
207
|
+
createdAt: info.mtime.toISOString(),
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
items.sort((a, b) => b.name.localeCompare(a.name))
|
|
211
|
+
return { items, totalBytes: items.reduce((total, item) => total + item.sizeBytes, 0) }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function runTar(ctx, cwd, argv) {
|
|
215
|
+
const subprocess = ctx.get('subprocess')
|
|
216
|
+
if (subprocess === undefined) throw new Error('subprocess-unavailable')
|
|
217
|
+
const executable = await subprocess.resolveExecutable('tar')
|
|
218
|
+
const handle = subprocess.spawn({
|
|
219
|
+
argv: [executable, ...argv],
|
|
220
|
+
cwd,
|
|
221
|
+
stdio: {
|
|
222
|
+
stdin: 'ignore',
|
|
223
|
+
stdout: { maxBytes: 16 * 1024 },
|
|
224
|
+
stderr: { maxBytes: 64 * 1024 },
|
|
225
|
+
},
|
|
226
|
+
graceMs: 5000,
|
|
227
|
+
})
|
|
228
|
+
const outcome = await handle.done
|
|
229
|
+
const stderr = handle.collected.stderr?.readFrom(0).text || ''
|
|
230
|
+
if (outcome.exitCode !== 0 || outcome.signal !== null) {
|
|
231
|
+
throw new Error(`tar-failed: ${stderr.trim() || outcome.signal || outcome.exitCode}`)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function createBackup(ctx, dshHome) {
|
|
236
|
+
const backupDir = join(dshHome, 'backups')
|
|
237
|
+
await mkdir(backupDir, { recursive: true, mode: 0o700 })
|
|
238
|
+
const workspace = join(backupDir, `.staging-${randomUUID()}`)
|
|
239
|
+
await mkdir(workspace, { recursive: true, mode: 0o700 })
|
|
240
|
+
try {
|
|
241
|
+
const sessions = join(dshHome, 'sessions')
|
|
242
|
+
if (await pathExists(sessions)) await cp(sessions, join(workspace, 'sessions'), { recursive: true })
|
|
243
|
+
else await mkdir(join(workspace, 'sessions'), { recursive: true })
|
|
244
|
+
|
|
245
|
+
const configDir = join(workspace, 'config')
|
|
246
|
+
await mkdir(configDir, { recursive: true })
|
|
247
|
+
for (const file of ['settings.yaml', 'cordis.patch.yml', 'AGENTS.md']) {
|
|
248
|
+
const source = join(dshHome, file)
|
|
249
|
+
if (await pathExists(source)) await cp(source, join(configDir, file))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const profilesSource = join(dshHome, 'profiles')
|
|
253
|
+
const profilesTarget = join(workspace, 'profiles')
|
|
254
|
+
await mkdir(profilesTarget, { recursive: true })
|
|
255
|
+
if (await pathExists(profilesSource)) {
|
|
256
|
+
for (const entry of await readdir(profilesSource, { withFileTypes: true })) {
|
|
257
|
+
if (!entry.isDirectory()) continue
|
|
258
|
+
const manifest = join(profilesSource, entry.name, 'package.json')
|
|
259
|
+
if (!(await pathExists(manifest))) continue
|
|
260
|
+
const target = join(profilesTarget, entry.name)
|
|
261
|
+
await mkdir(target, { recursive: true })
|
|
262
|
+
await cp(manifest, join(target, 'package.json'))
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const name = `dsh-backup-${formatBackupTimestamp(new Date())}.tar.gz`
|
|
267
|
+
if (await pathExists(join(backupDir, name))) throw new Error('backup-name-collision')
|
|
268
|
+
const temporary = join(backupDir, `.${name}.${randomUUID()}.tmp`)
|
|
269
|
+
try {
|
|
270
|
+
await runTar(ctx, workspace, ['-czf', temporary, 'sessions', 'config', 'profiles'])
|
|
271
|
+
await rename(temporary, join(backupDir, name))
|
|
272
|
+
} finally {
|
|
273
|
+
await rm(temporary, { force: true })
|
|
274
|
+
}
|
|
275
|
+
const snapshot = await listBackups(dshHome)
|
|
276
|
+
return { item: snapshot.items.find((item) => item.name === name), ...snapshot }
|
|
277
|
+
} finally {
|
|
278
|
+
await rm(workspace, { recursive: true, force: true })
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function deleteBackup(dshHome, id) {
|
|
283
|
+
if (typeof id !== 'string' || id.length === 0) return undefined
|
|
284
|
+
const snapshot = await listBackups(dshHome)
|
|
285
|
+
const item = snapshot.items.find((candidate) => candidate.id === id)
|
|
286
|
+
if (item === undefined) return undefined
|
|
287
|
+
await unlink(join(dshHome, 'backups', basename(item.name)))
|
|
288
|
+
return listBackups(dshHome)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function importBackup(dshHome, name, encoded) {
|
|
292
|
+
if (typeof name !== 'string' || !BACKUP_NAME.test(name) || typeof encoded !== 'string' || encoded.length === 0) return undefined
|
|
293
|
+
const data = Buffer.from(encoded, 'base64')
|
|
294
|
+
if (data.length === 0 || data.length > MAX_BACKUP_TRANSFER_BYTES) return undefined
|
|
295
|
+
const backupDir = join(dshHome, 'backups')
|
|
296
|
+
await mkdir(backupDir, { recursive: true, mode: 0o700 })
|
|
297
|
+
const target = join(backupDir, basename(name))
|
|
298
|
+
if (basename(name) !== name || await pathExists(target)) return undefined
|
|
299
|
+
const temporary = join(backupDir, `.${name}.${randomUUID()}.import`)
|
|
300
|
+
try {
|
|
301
|
+
await writeFile(temporary, data, { mode: 0o600 })
|
|
302
|
+
await rename(temporary, target)
|
|
303
|
+
} finally {
|
|
304
|
+
await rm(temporary, { force: true })
|
|
305
|
+
}
|
|
306
|
+
return listBackups(dshHome)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function usageHour(time) {
|
|
310
|
+
return new Date(time).toISOString().slice(0, 13)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function localDayForHour(hour, timezoneOffsetMinutes = 0) {
|
|
314
|
+
const offset = Number.isFinite(Number(timezoneOffsetMinutes)) ? Math.max(-840, Math.min(840, Number(timezoneOffsetMinutes))) : 0
|
|
315
|
+
const utcTime = Date.parse(`${hour}:00:00.000Z`)
|
|
316
|
+
return new Date(utcTime - offset * 60 * 1000).toISOString().slice(0, 10)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function emptyUsageTotals() {
|
|
320
|
+
return { steps: 0, missingUsage: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function addUsageTotals(target, source) {
|
|
324
|
+
target.steps += source.steps || 0
|
|
325
|
+
target.missingUsage += source.missingUsage || 0
|
|
326
|
+
target.inputTokens += source.inputTokens || 0
|
|
327
|
+
target.outputTokens += source.outputTokens || 0
|
|
328
|
+
target.cacheReadTokens += source.cacheReadTokens || 0
|
|
329
|
+
target.cacheWriteTokens += source.cacheWriteTokens || 0
|
|
330
|
+
return target
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function cacheHitRate(totals) {
|
|
334
|
+
const denominator = totals.inputTokens + totals.cacheReadTokens + totals.cacheWriteTokens
|
|
335
|
+
return denominator === 0 ? 0 : totals.cacheReadTokens / denominator
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function projectForCwd(ctx, cwd) {
|
|
339
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return { id: 'ungrouped', title: 'Ungrouped', path: null }
|
|
340
|
+
const registry = ctx.get('workspaceRegistry')
|
|
341
|
+
let workspaces = []
|
|
342
|
+
if (registry !== undefined) {
|
|
343
|
+
try {
|
|
344
|
+
const listed = registry.list()
|
|
345
|
+
workspaces = Array.isArray(listed) ? listed : [...listed]
|
|
346
|
+
} catch (_) {}
|
|
347
|
+
}
|
|
348
|
+
const absoluteCwd = resolve(cwd)
|
|
349
|
+
let selected
|
|
350
|
+
for (const workspace of workspaces) {
|
|
351
|
+
const workspacePath = resolve(String(workspace.path))
|
|
352
|
+
const child = relative(workspacePath, absoluteCwd)
|
|
353
|
+
if (child === '..' || child.startsWith(`..${sep}`) || resolve(workspacePath, child) !== absoluteCwd) continue
|
|
354
|
+
if (selected === undefined || workspacePath.length > selected.path.length) {
|
|
355
|
+
selected = { id: String(workspace.id), title: String(workspace.title || workspace.id), path: workspacePath }
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return selected || { id: `cwd:${absoluteCwd}`, title: basename(absoluteCwd) || absoluteCwd, path: absoluteCwd }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function revisionKey(revision) {
|
|
362
|
+
return typeof revision === 'string' ? revision : JSON.stringify(revision)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function createUsageIndex() {
|
|
366
|
+
return { version: USAGE_INDEX_VERSION, updatedAt: 0, sessions: {} }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function loadUsageIndex(dshHome) {
|
|
370
|
+
try {
|
|
371
|
+
const parsed = JSON.parse(await readFile(join(dshHome, USAGE_INDEX_FILE), 'utf8'))
|
|
372
|
+
if (parsed?.version !== USAGE_INDEX_VERSION || typeof parsed.sessions !== 'object' || parsed.sessions === null) return createUsageIndex()
|
|
373
|
+
return parsed
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if (error?.code === 'ENOENT') return createUsageIndex()
|
|
376
|
+
return createUsageIndex()
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function saveUsageIndex(dshHome, index) {
|
|
381
|
+
await mkdir(dshHome, { recursive: true })
|
|
382
|
+
const target = join(dshHome, USAGE_INDEX_FILE)
|
|
383
|
+
const temporary = `${target}.${randomUUID()}.tmp`
|
|
384
|
+
try {
|
|
385
|
+
await writeFile(temporary, JSON.stringify(index), { mode: 0o600 })
|
|
386
|
+
await rename(temporary, target)
|
|
387
|
+
} finally {
|
|
388
|
+
await rm(temporary, { force: true })
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function usageFailure(event) {
|
|
393
|
+
if (event.type === 'llm/retry') return event.data?.failure
|
|
394
|
+
if (event.type === 'turn/end' && event.data?.reason?.kind === 'error') return event.data.reason.error
|
|
395
|
+
return undefined
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function recentErrorTime(time) {
|
|
399
|
+
return Number.isFinite(time) && time >= Date.now() - 24 * 60 * 60 * 1000
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function addUsageError(session, event, model) {
|
|
403
|
+
const failure = usageFailure(event)
|
|
404
|
+
if (failure === undefined) return false
|
|
405
|
+
if (!recentErrorTime(event.time)) return true
|
|
406
|
+
if (session.modelErrors === undefined) session.modelErrors = {}
|
|
407
|
+
const provider = event.type === 'llm/retry' && typeof event.data?.provider === 'string' ? event.data.provider : model.provider
|
|
408
|
+
const errorModel = provider === model.provider ? model.model : 'unknown'
|
|
409
|
+
const code = typeof failure.code === 'string' && failure.code.length > 0 ? failure.code : 'UNKNOWN'
|
|
410
|
+
const status = Number.isSafeInteger(failure.status) ? failure.status : null
|
|
411
|
+
const key = `${provider}/${errorModel}|${code}|${status === null ? '-' : status}`
|
|
412
|
+
const current = session.modelErrors[key] || { key, provider, model: errorModel, code, status, message: String(failure.message || code), recentTimes: [] }
|
|
413
|
+
current.recentTimes.push(event.time)
|
|
414
|
+
session.modelErrors[key] = current
|
|
415
|
+
return true
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function toolResultBlock(event) {
|
|
419
|
+
if (event.type !== 'tool/result') return undefined
|
|
420
|
+
const content = event.data?.message?.content
|
|
421
|
+
return Array.isArray(content) ? content.find((block) => block?.type === 'tool-result') : undefined
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function toolFailureCode(tool, code, message) {
|
|
425
|
+
const value = String(code || '').trim()
|
|
426
|
+
if (/ABORT|CANCEL/i.test(value) || /aborted|cancelled|canceled/i.test(message)) return undefined
|
|
427
|
+
if (value && value !== 'UNKNOWN' && value !== 'Error') return value
|
|
428
|
+
if (/requires reading\b/i.test(message)) return 'FS_NOT_OBSERVED'
|
|
429
|
+
if (/old_string was not found/i.test(message)) return 'OLD_STRING_NOT_FOUND'
|
|
430
|
+
if (/no such file or directory|path[^\n]*not found/i.test(message)) return 'PATH_NOT_FOUND'
|
|
431
|
+
if (/file access denied|permission denied|EACCES/i.test(message)) return 'PERMISSION_DENIED'
|
|
432
|
+
if (/timed out|timeout/i.test(message)) return 'TOOL_TIMEOUT'
|
|
433
|
+
const exit = message.match(/\[exit code:\s*(-?\d+)\]/i)
|
|
434
|
+
if (tool === 'bash' && exit) return `EXIT_${exit[1]}`
|
|
435
|
+
return 'TOOL_ERROR'
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function toolFailureMessage(tool, code) {
|
|
439
|
+
if (code === 'FS_NOT_OBSERVED') return `${tool} requires reading <path> first — read the file, then retry`
|
|
440
|
+
if (code === 'OLD_STRING_NOT_FOUND') return `${tool}: old_string was not found in <path>`
|
|
441
|
+
if (code === 'PATH_NOT_FOUND') return `${tool} search failed: <path> not found`
|
|
442
|
+
if (code === 'PERMISSION_DENIED') return `${tool} failed: permission denied for <path>`
|
|
443
|
+
if (code === 'TOOL_TIMEOUT') return `${tool} timed out`
|
|
444
|
+
if (code.startsWith('EXIT_')) return `${tool} command exited with code ${code.slice(5)}`
|
|
445
|
+
return `${tool} failed (${code})`
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function addToolError(session, event) {
|
|
449
|
+
let tool
|
|
450
|
+
let code
|
|
451
|
+
let message
|
|
452
|
+
if (event.type === 'tool/call') {
|
|
453
|
+
if (session.toolCalls === undefined) session.toolCalls = {}
|
|
454
|
+
session.toolCalls[String(event.data?.callId)] = String(event.data?.name || 'unknown')
|
|
455
|
+
return true
|
|
456
|
+
}
|
|
457
|
+
if (event.type === 'tool/result') {
|
|
458
|
+
if (event.surfaceOp !== 'append') return true
|
|
459
|
+
const block = toolResultBlock(event)
|
|
460
|
+
const callId = String(block?.toolCallId || '')
|
|
461
|
+
tool = session.toolCalls?.[callId] || 'unknown'
|
|
462
|
+
if (session.toolCalls !== undefined) delete session.toolCalls[callId]
|
|
463
|
+
const textBlock = Array.isArray(block?.content) ? block.content.find((item) => item?.type === 'text') : undefined
|
|
464
|
+
message = String(textBlock?.text || '')
|
|
465
|
+
const exitFailure = tool === 'bash' && /\[exit code:\s*-?\d+\]/i.test(message)
|
|
466
|
+
if (block?.isError !== true && !exitFailure) return true
|
|
467
|
+
code = toolFailureCode(tool, event.data?.error?.code, message)
|
|
468
|
+
} else if (event.type === 'tool/code-dispatch') {
|
|
469
|
+
tool = String(event.data?.name || 'unknown')
|
|
470
|
+
const textBlock = Array.isArray(event.data?.content) ? event.data.content.find((item) => item?.type === 'text') : undefined
|
|
471
|
+
message = String(textBlock?.text || '')
|
|
472
|
+
const exitFailure = tool === 'bash' && /\[exit code:\s*-?\d+\]/i.test(message)
|
|
473
|
+
if (event.data?.isError !== true && !exitFailure) return true
|
|
474
|
+
code = toolFailureCode(tool, undefined, message)
|
|
475
|
+
} else {
|
|
476
|
+
return false
|
|
477
|
+
}
|
|
478
|
+
if (code === undefined || !recentErrorTime(event.time)) return true
|
|
479
|
+
if (session.toolErrors === undefined) session.toolErrors = {}
|
|
480
|
+
const key = `${tool}|${code}`
|
|
481
|
+
const current = session.toolErrors[key] || { key, tool, code, message: toolFailureMessage(tool, code), recentTimes: [] }
|
|
482
|
+
current.recentTimes.push(event.time)
|
|
483
|
+
session.toolErrors[key] = current
|
|
484
|
+
return true
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function pruneSessionErrors(session, cutoff) {
|
|
488
|
+
for (const field of ['modelErrors', 'toolErrors']) {
|
|
489
|
+
for (const [key, error] of Object.entries(session[field] || {})) {
|
|
490
|
+
error.recentTimes = (error.recentTimes || []).filter((time) => time >= cutoff)
|
|
491
|
+
if (error.recentTimes.length === 0) delete session[field][key]
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function foldUsageEvents(ctx, record, previous, events) {
|
|
497
|
+
const project = projectForCwd(ctx, record.header.cwd)
|
|
498
|
+
const session = previous || { revision: '', lastSeq: Math.max(0, record.header.seedLength || 0) - 1, project, currentModel: null, hours: {} }
|
|
499
|
+
session.project = project
|
|
500
|
+
for (const event of events) {
|
|
501
|
+
if (event.seq < (record.header.seedLength || 0)) continue
|
|
502
|
+
session.lastSeq = Math.max(session.lastSeq, event.seq)
|
|
503
|
+
if (event.type === 'request/header') {
|
|
504
|
+
const provider = event.data?.header?.config?.provider
|
|
505
|
+
const model = event.data?.header?.config?.model
|
|
506
|
+
if (typeof provider === 'string' && typeof model === 'string') session.currentModel = { provider, model, id: `${provider}/${model}` }
|
|
507
|
+
continue
|
|
508
|
+
}
|
|
509
|
+
const model = session.currentModel || { provider: 'unknown', model: 'unknown', id: 'unknown/unknown' }
|
|
510
|
+
if (addToolError(session, event)) continue
|
|
511
|
+
if (addUsageError(session, event, model)) continue
|
|
512
|
+
if (event.type !== 'assistant/message') continue
|
|
513
|
+
const hour = usageHour(event.time)
|
|
514
|
+
const bucket = session.hours[hour] || (session.hours[hour] = { totals: emptyUsageTotals(), models: {} })
|
|
515
|
+
const modelBucket = bucket.models[model.id] || (bucket.models[model.id] = { id: model.id, provider: model.provider, model: model.model, totals: emptyUsageTotals() })
|
|
516
|
+
const usage = event.data?.usage
|
|
517
|
+
const delta = emptyUsageTotals()
|
|
518
|
+
delta.steps = 1
|
|
519
|
+
if (usage === undefined) delta.missingUsage = 1
|
|
520
|
+
else {
|
|
521
|
+
delta.inputTokens = Number(usage.inputTokens) || 0
|
|
522
|
+
delta.outputTokens = Number(usage.outputTokens) || 0
|
|
523
|
+
delta.cacheReadTokens = Number(usage.cacheReadTokens) || 0
|
|
524
|
+
delta.cacheWriteTokens = Number(usage.cacheWriteTokens) || 0
|
|
525
|
+
}
|
|
526
|
+
addUsageTotals(bucket.totals, delta)
|
|
527
|
+
addUsageTotals(modelBucket.totals, delta)
|
|
528
|
+
}
|
|
529
|
+
return session
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function publicUsage(index, timezoneOffsetMinutes = 0) {
|
|
533
|
+
const result = { updatedAt: index.updatedAt, indexedSessions: Object.keys(index.sessions).length, totals: emptyUsageTotals(), days: {}, errors: { models: [], tools: [] } }
|
|
534
|
+
const projects = new Map()
|
|
535
|
+
const modelErrors = new Map()
|
|
536
|
+
const toolErrors = new Map()
|
|
537
|
+
const recentCutoff = Date.now() - 24 * 60 * 60 * 1000
|
|
538
|
+
for (const session of Object.values(index.sessions)) {
|
|
539
|
+
projects.set(session.project.id, session.project)
|
|
540
|
+
pruneSessionErrors(session, recentCutoff)
|
|
541
|
+
for (const error of Object.values(session.modelErrors || {})) {
|
|
542
|
+
const count = error.recentTimes.length
|
|
543
|
+
const key = `${session.project.id}|${error.key}`
|
|
544
|
+
const aggregate = modelErrors.get(key) || { key: error.key, provider: error.provider, model: error.model, code: error.code, status: error.status, message: error.message, count: 0, projectId: session.project.id, projectTitle: session.project.title }
|
|
545
|
+
aggregate.count += count
|
|
546
|
+
modelErrors.set(key, aggregate)
|
|
547
|
+
}
|
|
548
|
+
for (const error of Object.values(session.toolErrors || {})) {
|
|
549
|
+
const count = error.recentTimes.length
|
|
550
|
+
const key = `${session.project.id}|${error.key}`
|
|
551
|
+
const aggregate = toolErrors.get(key) || { key: error.key, tool: error.tool, code: error.code, message: error.message, count: 0, projectId: session.project.id, projectTitle: session.project.title }
|
|
552
|
+
aggregate.count += count
|
|
553
|
+
toolErrors.set(key, aggregate)
|
|
554
|
+
}
|
|
555
|
+
for (const [hour, source] of Object.entries(session.hours || {})) {
|
|
556
|
+
const day = localDayForHour(hour, timezoneOffsetMinutes)
|
|
557
|
+
const dayBucket = result.days[day] || (result.days[day] = { totals: emptyUsageTotals(), projects: new Map() })
|
|
558
|
+
addUsageTotals(dayBucket.totals, source.totals)
|
|
559
|
+
addUsageTotals(result.totals, source.totals)
|
|
560
|
+
const projectBucket = dayBucket.projects.get(session.project.id) || { ...session.project, totals: emptyUsageTotals(), models: new Map() }
|
|
561
|
+
addUsageTotals(projectBucket.totals, source.totals)
|
|
562
|
+
for (const model of Object.values(source.models)) {
|
|
563
|
+
const modelBucket = projectBucket.models.get(model.id) || { id: model.id, provider: model.provider, model: model.model, totals: emptyUsageTotals() }
|
|
564
|
+
addUsageTotals(modelBucket.totals, model.totals)
|
|
565
|
+
projectBucket.models.set(model.id, modelBucket)
|
|
566
|
+
}
|
|
567
|
+
dayBucket.projects.set(session.project.id, projectBucket)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const errorSort = (a, b) => b.count - a.count || a.key.localeCompare(b.key) || a.projectId.localeCompare(b.projectId)
|
|
571
|
+
result.errors.models = [...modelErrors.values()].sort(errorSort)
|
|
572
|
+
result.errors.tools = [...toolErrors.values()].sort(errorSort)
|
|
573
|
+
const finishTotals = (totals) => ({ ...totals, cacheHitRate: cacheHitRate(totals) })
|
|
574
|
+
result.totals = finishTotals(result.totals)
|
|
575
|
+
result.projects = [...projects.values()].sort((a, b) => a.title.localeCompare(b.title))
|
|
576
|
+
for (const bucket of Object.values(result.days)) {
|
|
577
|
+
bucket.totals = finishTotals(bucket.totals)
|
|
578
|
+
bucket.projects = [...bucket.projects.values()].map((project) => ({
|
|
579
|
+
...project,
|
|
580
|
+
totals: finishTotals(project.totals),
|
|
581
|
+
models: [...project.models.values()].map((model) => ({ ...model, totals: finishTotals(model.totals) })).sort((a, b) => a.id.localeCompare(b.id)),
|
|
582
|
+
})).sort((a, b) => a.title.localeCompare(b.title))
|
|
583
|
+
}
|
|
584
|
+
return result
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function refreshUsageIndex(ctx, dshHome, index) {
|
|
588
|
+
const persistence = ctx.get('sessionPersistence')
|
|
589
|
+
if (persistence === undefined) throw new Error('session-persistence-unavailable')
|
|
590
|
+
const snapshots = await persistence.listSnapshots()
|
|
591
|
+
const liveIds = new Set(snapshots.map((record) => String(record.header.id)))
|
|
592
|
+
for (const id of Object.keys(index.sessions)) if (!liveIds.has(id)) delete index.sessions[id]
|
|
593
|
+
const recentCutoff = Date.now() - 24 * 60 * 60 * 1000
|
|
594
|
+
for (const session of Object.values(index.sessions)) pruneSessionErrors(session, recentCutoff)
|
|
595
|
+
for (const record of snapshots) {
|
|
596
|
+
const id = String(record.header.id)
|
|
597
|
+
const revision = revisionKey(record.revision)
|
|
598
|
+
const previous = index.sessions[id]
|
|
599
|
+
if (previous?.revision === revision) continue
|
|
600
|
+
const fromSeq = previous === undefined ? Math.max(0, record.header.seedLength || 0) : previous.lastSeq + 1
|
|
601
|
+
const read = await persistence.readFrom(record.header.id, fromSeq)
|
|
602
|
+
const next = foldUsageEvents(ctx, record, previous, read.events)
|
|
603
|
+
next.revision = revision
|
|
604
|
+
index.sessions[id] = next
|
|
605
|
+
}
|
|
606
|
+
index.updatedAt = Date.now()
|
|
607
|
+
await saveUsageIndex(dshHome, index)
|
|
608
|
+
return index
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function modeString(mode) {
|
|
612
|
+
return '0' + (mode & 0o777).toString(8).padStart(3, '0')
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function runFixedCommand(ctx, argv) {
|
|
616
|
+
const subprocess = ctx.get('subprocess')
|
|
617
|
+
if (subprocess === undefined) throw new Error('subprocess-unavailable')
|
|
618
|
+
const executable = await subprocess.resolveExecutable(argv[0])
|
|
619
|
+
const handle = subprocess.spawn({
|
|
620
|
+
argv: [executable, ...argv.slice(1)],
|
|
621
|
+
cwd: '/',
|
|
622
|
+
stdio: {
|
|
623
|
+
stdin: 'ignore',
|
|
624
|
+
stdout: { maxBytes: 16 * 1024 },
|
|
625
|
+
stderr: { maxBytes: 64 * 1024 },
|
|
626
|
+
},
|
|
627
|
+
graceMs: 5000,
|
|
628
|
+
})
|
|
629
|
+
const outcome = await handle.done
|
|
630
|
+
const stderr = handle.collected.stderr?.readFrom(0).text || ''
|
|
631
|
+
if (outcome.exitCode !== 0 || outcome.signal !== null) {
|
|
632
|
+
throw new Error(`${argv[0]}-failed: ${stderr.trim() || outcome.signal || outcome.exitCode}`)
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function permissionSnapshot(ctx, dshHome, plans) {
|
|
637
|
+
if (process.platform !== 'linux' || typeof process.getuid !== 'function' || typeof process.getgid !== 'function') {
|
|
638
|
+
return { supported: false }
|
|
639
|
+
}
|
|
640
|
+
const workspaceRegistry = ctx.get('workspaceRegistry')
|
|
641
|
+
const entries = [{ label: 'DSH_HOME', path: dshHome }]
|
|
642
|
+
if (workspaceRegistry !== undefined) {
|
|
643
|
+
for (const workspace of workspaceRegistry.list()) {
|
|
644
|
+
if (entries.some((entry) => entry.path === workspace.path)) continue
|
|
645
|
+
entries.push({ label: String(workspace.title || workspace.id), path: String(workspace.path) })
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const items = []
|
|
649
|
+
for (const entry of entries) {
|
|
650
|
+
try {
|
|
651
|
+
const info = await stat(entry.path)
|
|
652
|
+
if (!info.isDirectory()) continue
|
|
653
|
+
items.push({
|
|
654
|
+
label: entry.label,
|
|
655
|
+
path: entry.path,
|
|
656
|
+
owner: `${info.uid}:${info.gid}`,
|
|
657
|
+
mode: modeString(info.mode),
|
|
658
|
+
writable: await hasAgentAccess(entry.path, true),
|
|
659
|
+
})
|
|
660
|
+
} catch (error) {
|
|
661
|
+
items.push({
|
|
662
|
+
label: entry.label,
|
|
663
|
+
path: entry.path,
|
|
664
|
+
owner: 'unavailable',
|
|
665
|
+
mode: '----',
|
|
666
|
+
writable: false,
|
|
667
|
+
error: error?.code || error?.message || String(error),
|
|
668
|
+
})
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const planId = randomUUID()
|
|
672
|
+
plans.set(planId, items.filter((item) => item.error === undefined).map((item) => item.path))
|
|
673
|
+
return {
|
|
674
|
+
supported: true,
|
|
675
|
+
planId,
|
|
676
|
+
targetOwner: `${process.getuid()}:${process.getgid()}`,
|
|
677
|
+
items,
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function isAtOrUnderPath(ancestor, path) {
|
|
682
|
+
const child = relative(ancestor, path)
|
|
683
|
+
return child === '' || (child !== '..' && !child.startsWith(`..${sep}`))
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// 只有宿主自己的凭据文档使用 owner-only 契约;工作区里的同名文件按 Agent 可写性检查。
|
|
687
|
+
function isCredentialsDocument(path, dshHome) {
|
|
688
|
+
return path === join(dshHome, '.credentials.yaml')
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function hasAgentAccess(path, directory) {
|
|
692
|
+
try {
|
|
693
|
+
await access(path, directory ? fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK : fsConstants.R_OK | fsConstants.W_OK)
|
|
694
|
+
return true
|
|
695
|
+
} catch (_) {
|
|
696
|
+
return false
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function deepCheckPermissions(dshHome, plans, planId) {
|
|
701
|
+
if (typeof planId !== 'string') return undefined
|
|
702
|
+
const paths = plans.get(planId)
|
|
703
|
+
if (paths === undefined) return undefined
|
|
704
|
+
const startedAt = Date.now()
|
|
705
|
+
const result = { scanned: 0, ownerIssues: 0, directoryModeIssues: 0, fileModeIssues: 0, unreadable: 0, samples: [] }
|
|
706
|
+
const visit = async (path) => {
|
|
707
|
+
let info
|
|
708
|
+
try {
|
|
709
|
+
info = await lstat(path)
|
|
710
|
+
result.scanned += 1
|
|
711
|
+
} catch (error) {
|
|
712
|
+
result.unreadable += 1
|
|
713
|
+
if (result.samples.length < 50) result.samples.push({ path, issue: 'unreadable', detail: error?.code || error?.message || String(error) })
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
const issues = []
|
|
717
|
+
const mode = info.mode & 0o777
|
|
718
|
+
if (isCredentialsDocument(path, dshHome)) {
|
|
719
|
+
const accessible = await hasAgentAccess(path, false)
|
|
720
|
+
if ((mode & 0o077) !== 0 || !accessible) { result.fileModeIssues += 1; issues.push('file-access') }
|
|
721
|
+
if (issues.length > 0 && result.samples.length < 50) result.samples.push({ path, issue: issues.join(','), detail: modeString(info.mode) })
|
|
722
|
+
return
|
|
723
|
+
}
|
|
724
|
+
if (info.isDirectory()) {
|
|
725
|
+
if (!(await hasAgentAccess(path, true))) { result.directoryModeIssues += 1; issues.push('directory-access') }
|
|
726
|
+
} else if (info.isFile() && !(await hasAgentAccess(path, false))) {
|
|
727
|
+
result.fileModeIssues += 1
|
|
728
|
+
issues.push('file-access')
|
|
729
|
+
}
|
|
730
|
+
if (issues.length > 0 && result.samples.length < 50) result.samples.push({ path, issue: issues.join(','), detail: modeString(info.mode) })
|
|
731
|
+
if (!info.isDirectory()) return
|
|
732
|
+
let entries
|
|
733
|
+
try { entries = await readdir(path, { withFileTypes: true }) } catch (error) {
|
|
734
|
+
result.unreadable += 1
|
|
735
|
+
if (result.samples.length < 50) result.samples.push({ path, issue: 'unreadable', detail: error?.code || error?.message || String(error) })
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
for (const entry of entries) {
|
|
739
|
+
if (entry.name === '.git') continue
|
|
740
|
+
await visit(join(path, entry.name))
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// 工作区注册表常含嵌套路径(如 /workspace 与 /workspace/projects/<x>):被其他根
|
|
744
|
+
// 覆盖的路径只随外层根扫描一次,否则同一批文件会被 stat 两到三次,异常也被重复计数。
|
|
745
|
+
const roots = []
|
|
746
|
+
for (const path of [...paths].sort((a, b) => a.length - b.length)) {
|
|
747
|
+
if (roots.some((root) => isAtOrUnderPath(root, path))) continue
|
|
748
|
+
roots.push(path)
|
|
749
|
+
}
|
|
750
|
+
for (const path of roots) await visit(path)
|
|
751
|
+
result.durationMs = Date.now() - startedAt
|
|
752
|
+
return result
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function repairPermissions(ctx, dshHome, plans, planId) {
|
|
756
|
+
if (typeof planId !== 'string') return undefined
|
|
757
|
+
const paths = plans.get(planId)
|
|
758
|
+
if (paths === undefined) return undefined
|
|
759
|
+
plans.delete(planId)
|
|
760
|
+
const owner = `${process.getuid()}:${process.getgid()}`
|
|
761
|
+
const roots = []
|
|
762
|
+
for (const path of [...paths].sort((a, b) => a.length - b.length)) {
|
|
763
|
+
if (roots.some((root) => isAtOrUnderPath(root, path))) continue
|
|
764
|
+
roots.push(path)
|
|
765
|
+
}
|
|
766
|
+
for (const path of roots) {
|
|
767
|
+
// 先逐个恢复目录遍历能力;使用 `{} ;` 让 find 在进入子目录前立即 chmod,且完整跳过 .git。
|
|
768
|
+
await runFixedCommand(ctx, ['find', path, '-name', '.git', '-prune', '-o', '-type', 'd', '-exec', 'chmod', 'u+rwx', '{}', ';'])
|
|
769
|
+
await runFixedCommand(ctx, ['find', path, '-name', '.git', '-prune', '-o', '-exec', 'chown', '-h', owner, '--', '{}', '+'])
|
|
770
|
+
await runFixedCommand(ctx, ['find', path, '-name', '.git', '-prune', '-o', '-type', 'f', '-exec', 'chmod', 'u+rw', '{}', '+'])
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
const credentials = join(dshHome, '.credentials.yaml')
|
|
774
|
+
const info = await lstat(credentials)
|
|
775
|
+
if (info.isFile()) await runFixedCommand(ctx, ['chmod', '600', '--', credentials])
|
|
776
|
+
} catch (error) {
|
|
777
|
+
if (error?.code !== 'ENOENT') throw error
|
|
778
|
+
}
|
|
779
|
+
return permissionSnapshot(ctx, dshHome, plans)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async function collectDiagnostics(ctx, dshHome) {
|
|
783
|
+
const checks = []
|
|
784
|
+
const add = (id, status, detail) => checks.push({ id, status, ...(detail === undefined ? {} : { detail: String(detail) }) })
|
|
785
|
+
|
|
786
|
+
const persistence = ctx.get('sessionPersistence')
|
|
787
|
+
if (persistence === undefined) add('session-storage', 'error', 'unavailable')
|
|
788
|
+
else {
|
|
789
|
+
try { add('session-storage', 'ok', (await persistence.listSnapshots()).length) } catch (error) { add('session-storage', 'error', error?.message || error) }
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const registry = ctx.get('workspaceRegistry')
|
|
793
|
+
let workspaceCount = 0
|
|
794
|
+
if (registry === undefined) add('workspace-registry', 'warning', 'unavailable')
|
|
795
|
+
else {
|
|
796
|
+
try {
|
|
797
|
+
const listed = registry.list()
|
|
798
|
+
workspaceCount = Array.isArray(listed) ? listed.length : [...listed].length
|
|
799
|
+
add('workspace-registry', 'ok', workspaceCount)
|
|
800
|
+
} catch (error) { add('workspace-registry', 'error', error?.message || error) }
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
try {
|
|
804
|
+
const info = await stat(dshHome)
|
|
805
|
+
add('dsh-home', info.isDirectory() ? 'ok' : 'error', modeString(info.mode))
|
|
806
|
+
} catch (error) { add('dsh-home', 'error', error?.code || error?.message || error) }
|
|
807
|
+
|
|
808
|
+
try {
|
|
809
|
+
const backups = await listBackups(dshHome)
|
|
810
|
+
add('backup-storage', backups.items.length === 0 ? 'warning' : 'ok', `${backups.items.length}:${backups.totalBytes}`)
|
|
811
|
+
} catch (error) { add('backup-storage', 'error', error?.code || error?.message || error) }
|
|
812
|
+
|
|
813
|
+
const subprocess = ctx.get('subprocess')
|
|
814
|
+
if (subprocess === undefined) add('tar', 'error', 'subprocess-unavailable')
|
|
815
|
+
else {
|
|
816
|
+
try { add('tar', 'ok', await subprocess.resolveExecutable('tar')) } catch (error) { add('tar', 'error', error?.message || error) }
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
try {
|
|
820
|
+
const snapshot = await permissionSnapshot(ctx, dshHome, new Map())
|
|
821
|
+
if (snapshot.supported !== true) add('permissions', 'info', 'unsupported')
|
|
822
|
+
else {
|
|
823
|
+
const abnormal = snapshot.items.filter((item) => item.writable === false).length
|
|
824
|
+
add('permissions', abnormal === 0 ? 'ok' : 'warning', abnormal)
|
|
825
|
+
}
|
|
826
|
+
} catch (error) { add('permissions', 'warning', error?.message || error) }
|
|
827
|
+
|
|
828
|
+
let status = 'ok'
|
|
829
|
+
if (checks.some((check) => check.status === 'error')) status = 'error'
|
|
830
|
+
else if (checks.some((check) => check.status === 'warning')) status = 'warning'
|
|
831
|
+
return { status, checkedAt: Date.now(), checks }
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function collectHealth(ctx) {
|
|
835
|
+
const sessionsService = ctx.get('sessions')
|
|
836
|
+
const sessionQueryService = ctx.get('sessionQuery')
|
|
837
|
+
const activity = collectActiveWork(ctx)
|
|
838
|
+
const sessionRecords = sessionQueryService === undefined ? [] : await sessionQueryService.listSessions()
|
|
839
|
+
const memory = process.memoryUsage()
|
|
840
|
+
|
|
841
|
+
return {
|
|
842
|
+
uptimeSeconds: process.uptime(),
|
|
843
|
+
rssBytes: memory.rss,
|
|
844
|
+
liveSessions: sessionsService === undefined ? 0 : sessionsService.list().length,
|
|
845
|
+
persistedSessions: sessionRecords.filter((record) => record.persisted === true).length,
|
|
846
|
+
activeAgents: activity.items.filter((item) => item.type === 'agent').length,
|
|
847
|
+
activeJobs: activity.items.filter((item) => item.type === 'job').length,
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function collectActiveWork(ctx) {
|
|
852
|
+
const agentsService = ctx.get('agents')
|
|
853
|
+
const jobsService = ctx.get('jobs')
|
|
854
|
+
const sharedTerminalsService = ctx.get('terminals')
|
|
855
|
+
const agents = agentsService === undefined ? [] : agentsService.list()
|
|
856
|
+
const items = []
|
|
857
|
+
|
|
858
|
+
for (const agent of agents) {
|
|
859
|
+
if (agent.status !== 'running') continue
|
|
860
|
+
const id = String(agent.id)
|
|
861
|
+
items.push({ type: 'agent', id, label: id, status: 'running' })
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (jobsService !== undefined) {
|
|
865
|
+
const jobsById = new Map()
|
|
866
|
+
for (const caller of [undefined, ...agents]) {
|
|
867
|
+
for (const job of jobsService.list(caller)) {
|
|
868
|
+
if (job.status !== 'running' && job.status !== 'stopping') continue
|
|
869
|
+
jobsById.set(String(job.id), job)
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
for (const job of jobsById.values()) {
|
|
873
|
+
items.push({
|
|
874
|
+
type: 'job',
|
|
875
|
+
id: String(job.id),
|
|
876
|
+
label: String(job.label || job.id),
|
|
877
|
+
status: job.status,
|
|
878
|
+
...(job.ownerSession === undefined ? {} : { ownerSession: String(job.ownerSession) }),
|
|
879
|
+
})
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const terminalsById = new Map()
|
|
884
|
+
for (const owner of agents) {
|
|
885
|
+
let terminalsService = sharedTerminalsService
|
|
886
|
+
try {
|
|
887
|
+
terminalsService = owner.ctx?.get('terminals') ?? sharedTerminalsService
|
|
888
|
+
} catch (_) {}
|
|
889
|
+
if (terminalsService === undefined) continue
|
|
890
|
+
try {
|
|
891
|
+
for (const terminal of terminalsService.list(owner)) {
|
|
892
|
+
if (terminal.status?.kind !== 'running') continue
|
|
893
|
+
const id = String(terminal.sessionId)
|
|
894
|
+
terminalsById.set(id, { terminal, owner })
|
|
895
|
+
}
|
|
896
|
+
} catch (_) {}
|
|
897
|
+
}
|
|
898
|
+
for (const { terminal, owner } of terminalsById.values()) {
|
|
899
|
+
items.push({
|
|
900
|
+
type: 'terminal',
|
|
901
|
+
id: String(terminal.sessionId),
|
|
902
|
+
label: String(terminal.name || `${terminal.type} terminal`),
|
|
903
|
+
status: 'running',
|
|
904
|
+
ownerSession: String(owner.id),
|
|
905
|
+
})
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
return { hasActive: items.length > 0, items }
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function scheduleRestart(ctx) {
|
|
912
|
+
const doExit = () => {
|
|
913
|
+
try {
|
|
914
|
+
// 退出码 42 交给 Docker/systemd/pm2 的重启策略处理。
|
|
915
|
+
process.exit(42)
|
|
916
|
+
} catch (error) {
|
|
917
|
+
console.error('dsh-service: exit failed', error?.message || error)
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const timer = ctx.get('timer')
|
|
921
|
+
if (timer !== undefined) return timer.timeout(doExit, 500)
|
|
922
|
+
return doExit()
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function apply(ctx) {
|
|
926
|
+
const dshHome = resolveDshHome()
|
|
927
|
+
const permissionPlans = new Map()
|
|
928
|
+
let usageIndexPromise = loadUsageIndex(dshHome)
|
|
929
|
+
let usageRefreshPromise
|
|
930
|
+
let updateCache
|
|
931
|
+
let updatePromise
|
|
932
|
+
ctx.effect(() => () => permissionPlans.clear(), 'dsh-service permission plans')
|
|
933
|
+
const commands = ctx.get('commands')
|
|
934
|
+
if (commands !== undefined) {
|
|
935
|
+
ctx.effect(() => commands.register({
|
|
936
|
+
name: 'restart',
|
|
937
|
+
description: 'Restart the DSH Web process after checking active work',
|
|
938
|
+
handler: async (invocation) => {
|
|
939
|
+
if (invocation.rawInput.trim() !== '') return { kind: 'error', text: '/restart does not accept arguments.' }
|
|
940
|
+
const activity = collectActiveWork(ctx)
|
|
941
|
+
if (activity.hasActive) return { kind: 'error', text: `Restart refused: ${activity.items.length} active item(s) detected. Use the Service Control restart tab to review them.` }
|
|
942
|
+
scheduleRestart(ctx)
|
|
943
|
+
return { kind: 'success', text: 'Restart scheduled. The DSH Web process will exit in 0.5 seconds.' }
|
|
944
|
+
},
|
|
945
|
+
}), 'dsh-service restart command')
|
|
946
|
+
}
|
|
947
|
+
const webServer = ctx.get('webServer')
|
|
948
|
+
if (webServer !== undefined) {
|
|
949
|
+
ctx.effect(() => webServer.register({
|
|
950
|
+
kind: 'exact',
|
|
951
|
+
path: '/healthz',
|
|
952
|
+
handler: (req, res) => {
|
|
953
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
954
|
+
res.writeHead(405)
|
|
955
|
+
res.end()
|
|
956
|
+
return
|
|
957
|
+
}
|
|
958
|
+
res.writeHead(200)
|
|
959
|
+
res.end()
|
|
960
|
+
},
|
|
961
|
+
}), 'dsh-service healthz route')
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// DSH 的 Connection RPC channel 只能是单层绝对路径;子功能放在 endpoint 中。
|
|
965
|
+
// 合法示例:channel=/dsh-service,endpoint=version/check-update/activity/web。
|
|
966
|
+
ctx.connection.rpc.handle('/dsh-service', async (endpoint, payload) => {
|
|
967
|
+
if (endpoint === 'version') {
|
|
968
|
+
return { ok: true, value: { current: dshVersion, pluginVersion, instanceId } }
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (endpoint === 'check-update') {
|
|
972
|
+
const now = Date.now()
|
|
973
|
+
if (updateCache && now - updateCache.checkedAt < updateCache.ttl) {
|
|
974
|
+
return updateCache.ok
|
|
975
|
+
? { ok: true, value: Object.assign({}, updateCache.value, { cached: true }) }
|
|
976
|
+
: { ok: false, error: updateCache.error, cached: true }
|
|
977
|
+
}
|
|
978
|
+
try {
|
|
979
|
+
if (updatePromise === undefined) {
|
|
980
|
+
updatePromise = Promise.allSettled([
|
|
981
|
+
fetchPublishedVersions(DSH_PACKAGE),
|
|
982
|
+
fetchPublishedVersions(PLUGIN_PACKAGE),
|
|
983
|
+
]).finally(() => { updatePromise = undefined })
|
|
984
|
+
}
|
|
985
|
+
const [dshResult, pluginResult] = await updatePromise
|
|
986
|
+
const dsh = dshResult.status === 'fulfilled'
|
|
987
|
+
? { current: dshVersion, latest: dshResult.value.latest, tags: dshResult.value.tags, upToDate: atLeastSemver(dshVersion, dshResult.value.latest), status: 'available', url: 'https://github.com/deepseek-ai/DeepSeek-Harness/releases' }
|
|
988
|
+
: { current: dshVersion, latest: null, tags: { latest: null, next: null }, upToDate: null, status: 'unavailable', url: 'https://github.com/deepseek-ai/DeepSeek-Harness/releases' }
|
|
989
|
+
const pluginError = pluginResult.status === 'rejected' ? String(pluginResult.reason?.message || pluginResult.reason) : ''
|
|
990
|
+
const plugin = pluginResult.status === 'fulfilled'
|
|
991
|
+
? { current: pluginVersion, latest: pluginResult.value.latest, tags: pluginResult.value.tags, upToDate: atLeastSemver(pluginVersion, pluginResult.value.latest), status: 'available', url: 'https://github.com/gehennawu/dsh-service/releases' }
|
|
992
|
+
: { current: pluginVersion, latest: null, tags: { latest: null, next: null }, upToDate: null, status: pluginError.includes('HTTP 404') ? 'unpublished' : 'unavailable', url: 'https://github.com/gehennawu/dsh-service/releases' }
|
|
993
|
+
if (dsh.status === 'unavailable' && plugin.status === 'unavailable') throw dshResult.reason
|
|
994
|
+
const value = { checkedAt: now, cached: false, dsh, plugin }
|
|
995
|
+
updateCache = { ok: true, value, checkedAt: now, ttl: 10 * 60 * 1000 }
|
|
996
|
+
return { ok: true, value }
|
|
997
|
+
} catch (error) {
|
|
998
|
+
const message = error?.message || String(error)
|
|
999
|
+
updateCache = { ok: false, error: message, checkedAt: now, ttl: 60 * 1000 }
|
|
1000
|
+
return { ok: false, error: message, cached: false }
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (endpoint === 'activity') {
|
|
1005
|
+
return { ok: true, value: collectActiveWork(ctx) }
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (endpoint === 'health') {
|
|
1009
|
+
try {
|
|
1010
|
+
return { ok: true, value: await collectHealth(ctx) }
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (endpoint === 'diagnostics') {
|
|
1017
|
+
try {
|
|
1018
|
+
return { ok: true, value: await collectDiagnostics(ctx, dshHome) }
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
if (endpoint === 'usage') {
|
|
1025
|
+
try {
|
|
1026
|
+
return { ok: true, value: publicUsage(await usageIndexPromise, payload?.timezoneOffsetMinutes) }
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (endpoint === 'usage-refresh') {
|
|
1033
|
+
try {
|
|
1034
|
+
if (usageRefreshPromise === undefined) {
|
|
1035
|
+
usageRefreshPromise = usageIndexPromise.then((index) => refreshUsageIndex(ctx, dshHome, index)).finally(() => { usageRefreshPromise = undefined })
|
|
1036
|
+
}
|
|
1037
|
+
return { ok: true, value: publicUsage(await usageRefreshPromise, payload?.timezoneOffsetMinutes) }
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (endpoint === 'permissions-plan') {
|
|
1044
|
+
try {
|
|
1045
|
+
return { ok: true, value: await permissionSnapshot(ctx, dshHome, permissionPlans) }
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (endpoint === 'permissions-deep') {
|
|
1052
|
+
try {
|
|
1053
|
+
const value = await deepCheckPermissions(dshHome, permissionPlans, payload?.planId)
|
|
1054
|
+
if (value === undefined) return { ok: false, error: 'unknown-permission-plan' }
|
|
1055
|
+
return { ok: true, value }
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (endpoint === 'permissions-repair') {
|
|
1062
|
+
try {
|
|
1063
|
+
const value = await repairPermissions(ctx, dshHome, permissionPlans, payload?.planId)
|
|
1064
|
+
if (value === undefined) return { ok: false, error: 'unknown-permission-plan' }
|
|
1065
|
+
return { ok: true, value }
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (endpoint === 'backup-list') {
|
|
1072
|
+
try {
|
|
1073
|
+
return { ok: true, value: await listBackups(dshHome) }
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (endpoint === 'backup-create') {
|
|
1080
|
+
try {
|
|
1081
|
+
return { ok: true, value: await createBackup(ctx, dshHome) }
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (endpoint === 'backup-delete') {
|
|
1088
|
+
try {
|
|
1089
|
+
const value = await deleteBackup(dshHome, payload?.id)
|
|
1090
|
+
if (value === undefined) return { ok: false, error: 'unknown-backup' }
|
|
1091
|
+
return { ok: true, value }
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (endpoint === 'backup-import') {
|
|
1098
|
+
try {
|
|
1099
|
+
const value = await importBackup(dshHome, payload?.name, payload?.data)
|
|
1100
|
+
if (value === undefined) return { ok: false, error: 'invalid-backup' }
|
|
1101
|
+
return { ok: true, value }
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (endpoint === 'web') {
|
|
1108
|
+
const activity = collectActiveWork(ctx)
|
|
1109
|
+
if (activity.hasActive && payload?.force !== true) {
|
|
1110
|
+
return { ok: false, error: 'active-work', value: activity }
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
scheduleRestart(ctx)
|
|
1114
|
+
return {
|
|
1115
|
+
ok: true,
|
|
1116
|
+
value: {
|
|
1117
|
+
message: '重启指令已发出,进程将在 0.5 秒后退出',
|
|
1118
|
+
instanceId,
|
|
1119
|
+
},
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return { ok: false, error: 'unknown endpoint: ' + String(endpoint) }
|
|
1124
|
+
}, { authority: 'loopback' })
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
export { apply, inject, name }
|
|
1128
|
+
export default { apply, inject, name }
|