@linyanzhi/dsh-mesh 0.1.0 → 0.2.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.
@@ -0,0 +1,270 @@
1
+ // dsh-mesh-ui — 委托面板数据层(只读)
2
+ //
3
+ // 供设置页「AI 网络」之外的独立面板使用:委托列表 / 单任务状态 / 对端会话正文。
4
+ // 全部只读:
5
+ // - 列表:读本地 ~/.dsh-mesh/delegations.json
6
+ // - 任务状态:跑 `mesh-delegate.mjs status <taskId>`(只读查询,不改变对端)
7
+ // - 会话正文:走对端 session_history 只读工具(解压会话日志,无模型往返)
8
+ //
9
+ // 归宿:reusable(dsh-mesh/plugin/dsh-mesh-ui/lib/)
10
+
11
+ import { readFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { cliJson, meshHome, nodeExec, resolveRepo, run, unwrapPeerCall } from './collect.js'
14
+
15
+ export const NODE_LABELS = {
16
+ home: 'peer-x',
17
+ home1: 'peer-x',
18
+ home2: 'peer-x',
19
+ company: 'peer-x',
20
+ company2: 'peer-x',
21
+ 'company2-lan': 'peer-x(内网)'
22
+ }
23
+
24
+ export function nodeLabel(name) {
25
+ return NODE_LABELS[String(name || '').toLowerCase()] || String(name || '未知节点')
26
+ }
27
+
28
+ function readJson(file, fallback = null) {
29
+ try {
30
+ return JSON.parse(readFileSync(file, 'utf8'))
31
+ } catch {
32
+ return fallback
33
+ }
34
+ }
35
+
36
+ /** 委托列表(本地台账,按更新时间倒序)。
37
+ * - 过滤 selftest 污染记录(selftest 曾把测试任务写进真实台账)
38
+ * - 活跃任务超过阈值未更新 → displayState=stalled(UI 不再显示无限 loading)
39
+ */
40
+ export function listDelegations(limit = 50) {
41
+ const data = readJson(join(meshHome(), 'delegations.json'), { tasks: {} })
42
+ const localNode = readJson(join(meshHome(), 'config.json'), {})?.node?.name || ''
43
+ const ACTIVE_STATES = ['working', 'submitted', 'accepted', 'question', 'provisioning', 'running']
44
+ // 超过这些时间没有 updatedAt 更新,就认为“疑似失联”(不直接改文件,UI 只展示)
45
+ const STALE_AFTER_MS = {
46
+ submitted: 15 * 60 * 1000,
47
+ accepted: 15 * 60 * 1000,
48
+ provisioning: 15 * 60 * 1000,
49
+ working: 60 * 60 * 1000,
50
+ running: 60 * 60 * 1000,
51
+ question: 6 * 60 * 60 * 1000
52
+ }
53
+ const nowMs = Date.now()
54
+ const tasks = Object.values(data?.tasks || {})
55
+ .filter((task) => task && task.from !== 'selftest')
56
+ .map((task) => {
57
+ const start = Date.parse(task.createdAt || '')
58
+ const end = Date.parse(task.updatedAt || '') || nowMs
59
+ const from = task.from || ''
60
+ const state = task.state || 'unknown'
61
+ const isActive = ACTIVE_STATES.indexOf(state) >= 0
62
+ const updatedMs = Date.parse(task.updatedAt || task.createdAt || '')
63
+ const ageMs = Number.isFinite(updatedMs) ? nowMs - updatedMs : Infinity
64
+ const staleAfter = STALE_AFTER_MS[state] ?? 60 * 60 * 1000
65
+ const staleActive = isActive && ageMs > staleAfter
66
+ return {
67
+ taskId: task.taskId || '',
68
+ conversationId: task.conversationId || '',
69
+ sessionId: task.sessionId || '',
70
+ from,
71
+ fromLabel: nodeLabel(from),
72
+ fromIsLocal: Boolean(from) && from === localNode,
73
+ fromInstance: task.fromInstance || '',
74
+ node: task.node || '',
75
+ nodeLabel: nodeLabel(task.node),
76
+ toIsLocal: Boolean(task.node) && task.node === localNode,
77
+ instance: task.instance || '',
78
+ workspace: task.workspace || '',
79
+ dedicated: Boolean(task.dedicated),
80
+ state,
81
+ displayState: staleActive ? 'stalled' : state,
82
+ isActive,
83
+ staleActive,
84
+ staleAfterSec: Math.round(staleAfter / 1000),
85
+ activeFresh: isActive && !staleActive,
86
+ displayAt: task.finishedAt || task.updatedAt || '',
87
+ policy: task.requestedPolicy || task.policy || '',
88
+ goal: String(task.goal || ''),
89
+ createdAt: task.createdAt || '',
90
+ updatedAt: task.updatedAt || '',
91
+ durationSec: (() => {
92
+ if (!Number.isFinite(start)) return null
93
+ const value = Math.max(0, Math.round((end - start) / 1000))
94
+ return value > 12 * 3600 ? null : value // 台账 updatedAt 会被轮询刷新,超长值不可信
95
+ })(),
96
+ stalledReason: task.stalledReason || '',
97
+ lastSummary: task.summary || ''
98
+ }
99
+ })
100
+ tasks.sort((a, b) => {
101
+ if (a.activeFresh !== b.activeFresh) return a.activeFresh ? -1 : 1
102
+ return String(b.displayAt || b.updatedAt || '').localeCompare(String(a.displayAt || a.updatedAt || ''))
103
+ })
104
+ const byState = {}
105
+ for (const task of tasks) byState[task.displayState] = (byState[task.displayState] || 0) + 1
106
+ return { total: tasks.length, byState, localNode, tasks: tasks.slice(0, Math.max(1, Math.min(limit, 200))) }
107
+ }
108
+
109
+ /** 对端是 POSIX 时把 Windows 拼接产生的 \root\Code\... 归一化成 /root/workspace/...。 */
110
+ function posixPath(raw) {
111
+ const s = String(raw || "")
112
+ if (!s) return s
113
+ if (/^[A-Za-z]:[\\/]/.test(s)) return s // 本机盘符路径(Windows 对端)不动
114
+ if (s.startsWith("\\\\")) return s // UNC 路径不动
115
+ if (s.startsWith("\\") || s.includes("\\")) return s.replace(/\\/g, "/") // \root\Code -> /root/workspace
116
+ return s
117
+ }
118
+ /** 读对端某个 JSON 文件(只读 fs_read,路径来自本地台账 task.paths)。 */
119
+ async function readPeerJson(repo, node, filePath) {
120
+ if (!repo || !node || !filePath) return null
121
+ const args = JSON.stringify({ path: String(filePath) })
122
+ const { stdout } = await run(nodeExec(), [join(repo, 'src', 'cli.js'), 'peer', 'call', 'fs_read', '--to', String(node), '--args', args], { cwd: repo, timeout: 15_000, env: { ...process.env } })
123
+ const start = String(stdout || '').indexOf('{')
124
+ if (start < 0) return null
125
+ try {
126
+ const wrapper = JSON.parse(String(stdout).slice(start))
127
+ const payload = unwrapPeerCall(wrapper)
128
+ if (!payload || payload.ok === false) return null
129
+ const content = payload.content ?? payload.text ?? payload
130
+ if (typeof content === 'string') { try { return JSON.parse(content) } catch { return null } }
131
+ return content && typeof content === 'object' ? content : null
132
+ } catch {
133
+ return null
134
+ }
135
+ }
136
+
137
+ /** 从对端产物里算真实时间线(request.requestedAt → status.updatedAt)。 */
138
+ async function artifactTimeline(repo, node, taskId) {
139
+ const data = readJson(join(meshHome(), 'delegations.json'), { tasks: {} })
140
+ const task = data?.tasks?.[taskId]
141
+ const paths = task?.paths || {}
142
+ const requestLocal = paths.requestLocal || paths.request || ''
143
+ const statusRemote = paths.statusRemote || paths.status || ''
144
+ const [request, status] = await Promise.all([
145
+ Promise.resolve(requestLocal && !/^[A-Za-z]:\\\\/.test(requestLocal) === false ? readJson(requestLocal, null) : readJson(requestLocal, null)),
146
+ readPeerJson(repo, node, statusRemote)
147
+ ])
148
+ const requestedAt = request?.requestedAt || task?.createdAt || ''
149
+ const finishedAt = status?.updatedAt || status?.heartbeatAt || ''
150
+ const startMs = Date.parse(requestedAt)
151
+ const endMs = Date.parse(finishedAt)
152
+ const durationSec = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs ? Math.round((endMs - startMs) / 1000) : null
153
+ return { requestedAt, finishedAt, durationSec, state: status?.state || '', summary: status?.summary || '', error: status?.error || '' }
154
+ }
155
+
156
+ /** 单任务状态 + 详情(只读;对端产物 + 会话元信息 + 时间线)。 */
157
+ export async function taskStatus(taskId) {
158
+ const repo = resolveRepo()
159
+ if (!repo) return { ok: false, error: 'dsh-mesh repo not found' }
160
+ if (!/^[A-Za-z0-9._:-]{4,120}$/.test(String(taskId || ''))) return { ok: false, error: 'invalid taskId' }
161
+ const registry = readJson(join(meshHome(), 'delegations.json'), { tasks: {} })
162
+ const entry = registry?.tasks?.[String(taskId)] || null
163
+ const node = entry?.node || ''
164
+ const paths = entry?.paths || {}
165
+
166
+ const { error, stdout, stderr } = await run(nodeExec(), [join(repo, 'scripts', 'mesh-delegate.mjs'), 'status', String(taskId)], {
167
+ cwd: repo,
168
+ timeout: 45_000,
169
+ env: { ...process.env }
170
+ })
171
+ const start = String(stdout || '').indexOf('{')
172
+
173
+ // 对端产物(status.json / result.json)+ 本机 request.json + 对端会话元信息,全部并发
174
+ const [requestJson, statusJson, resultJson, sessionMeta] = node
175
+ ? await Promise.all([
176
+ Promise.resolve(paths.requestLocal ? readJson(paths.requestLocal, null) : null),
177
+ readPeerJson(repo, node, posixPath(paths.statusRemote || paths.status)),
178
+ readPeerJson(repo, node, posixPath(paths.resultRemote || paths.result)),
179
+ entry?.sessionId ? sessionHistory(node, entry.sessionId, 1).then((r) => (r.ok ? { totalMessages: r.payload?.totalMessages ?? 0, frameCount: r.payload?.frameCount ?? 0, bytes: r.payload?.bytes ?? 0, title: r.payload?.title || '' } : null)).catch(() => null) : Promise.resolve(null)
180
+ ])
181
+ : [null, null, null, null]
182
+
183
+ const seconds = (from, to) => {
184
+ const a = Date.parse(from || '')
185
+ const b = Date.parse(to || '')
186
+ return Number.isFinite(a) && Number.isFinite(b) && b >= a ? Math.round((b - a) / 1000) : null
187
+ }
188
+ const requestedAt = requestJson?.requestedAt || entry?.createdAt || ''
189
+ const startedAt = statusJson?.startedAt || statusJson?.heartbeatAt || statusJson?.updatedAt || ''
190
+ const finishedAt = statusJson?.updatedAt || resultJson?.completedAt || ((statusJson?.state === 'done' || statusJson?.state === 'failed' || statusJson?.state === 'cancelled') ? statusJson?.heartbeatAt || '' : '')
191
+ const heartbeatAt = statusJson?.heartbeatAt || ''
192
+ const detail = {
193
+ request: requestJson,
194
+ status: statusJson,
195
+ result: resultJson,
196
+ session: sessionMeta,
197
+ timeline: {
198
+ requestedAt,
199
+ startedAt,
200
+ heartbeatAt,
201
+ finishedAt,
202
+ responseSec: seconds(requestedAt, heartbeatAt || startedAt),
203
+ execSec: seconds(startedAt, finishedAt),
204
+ totalSec: seconds(requestedAt, finishedAt)
205
+ },
206
+ // 对端做过哪些操作 / 权限范围(来自 result.json 与 request.json 的协议字段)
207
+ operations: {
208
+ commandsRun: Array.isArray(resultJson?.commandsRun) ? resultJson.commandsRun : [],
209
+ evidence: Array.isArray(resultJson?.evidence) ? resultJson.evidence : [],
210
+ notes: resultJson?.notes || resultJson?.readonlyEvidence || '',
211
+ readonly: resultJson?.readonly === true,
212
+ scope: requestJson?.scope || '',
213
+ acceptance: requestJson?.acceptance || '',
214
+ sessionPolicy: requestJson?.sessionPolicy || '',
215
+ targetInstance: requestJson?.instance || entry?.instance || '',
216
+ workspace: requestJson?.workspace || entry?.workspace || ''
217
+ },
218
+ artifacts: statusJson && requestJson
219
+ ? { requestedAt, finishedAt, durationSec: seconds(requestedAt, finishedAt), state: statusJson.state || '', summary: statusJson.summary || '', error: statusJson.error || '' }
220
+ : null
221
+ }
222
+
223
+ if (start < 0) {
224
+ return {
225
+ ok: true,
226
+ degraded: true,
227
+ error: error || 'mesh-delegate status 未返回 JSON(对端可能离线或超时)',
228
+ status: entry
229
+ ? { state: entry.state, sessionState: '', staleSeconds: null, error: 'status.json 未取到', summary: entry.goal ? '(台账)' + String(entry.goal).slice(0, 200) : '' }
230
+ : { state: 'unknown', sessionState: '', staleSeconds: null, error: '台账里也没有该任务' },
231
+ artifacts: detail.artifacts,
232
+ detail
233
+ }
234
+ }
235
+ try {
236
+ const status = JSON.parse(String(stdout).slice(start))
237
+ return { ok: true, status, artifacts: detail.artifacts, detail }
238
+ } catch {
239
+ return { ok: false, error: 'task status JSON parse failed', stderr: String(stderr || '').slice(-400) }
240
+ }
241
+ }
242
+
243
+ /** 对端会话正文(只读;走 session_history,最多 200 条消息)。 */
244
+ export async function sessionHistory(node, sessionId, limit = 40) {
245
+ const repo = resolveRepo()
246
+ if (!repo) return { ok: false, error: 'dsh-mesh repo not found' }
247
+ if (!/^[A-Za-z0-9._:-]{1,40}$/.test(String(node || ''))) return { ok: false, error: 'invalid node' }
248
+ if (!/^[A-Za-z0-9._-]{8,120}$/.test(String(sessionId || ''))) return { ok: false, error: 'invalid sessionId' }
249
+ const lines = Math.min(Math.max(Number(limit) || 40, 1), 200)
250
+ const args = JSON.stringify({ sessionId: String(sessionId), limit: lines })
251
+ const { error, stdout, stderr } = await run(
252
+ nodeExec(),
253
+ [join(repo, 'src', 'cli.js'), 'peer', 'call', 'session_history', '--to', String(node), '--args', args],
254
+ { cwd: repo, timeout: 60_000, env: { ...process.env } }
255
+ )
256
+ const start = String(stdout || '').indexOf('{')
257
+ if (start >= 0) {
258
+ try {
259
+ const wrapper = JSON.parse(String(stdout).slice(start))
260
+ const payload = unwrapPeerCall(wrapper)
261
+ if (payload?.ok === true) return { ok: true, payload }
262
+ return { ok: false, error: payload?.message || 'session_history failed', payload }
263
+ } catch {
264
+ /* fall through */
265
+ }
266
+ }
267
+ return { ok: false, error: error || 'session_history returned no JSON', stderr: String(stderr || '').slice(-400) }
268
+ }
269
+
270
+ export { cliJson }