dsh-plugin-mobile-link 0.6.6 → 0.8.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/README.md +28 -13
- package/cordis.patch.yml +11 -0
- package/lib/cli.js +3 -4
- package/lib/client.js +560 -75
- package/lib/dir-listing.js +167 -0
- package/lib/dlp.js +33 -1
- package/lib/enroll.js +249 -35
- package/lib/files.js +118 -12
- package/lib/git.js +20 -5
- package/lib/hello.js +23 -1
- package/lib/history/compress.js +40 -0
- package/lib/history/coverage.js +21 -0
- package/lib/history/fold-summary.js +472 -0
- package/lib/history/fold.js +184 -0
- package/lib/history/session-snapshot.js +75 -24
- package/lib/history/summary-window.js +168 -0
- package/lib/history.js +171 -9
- package/lib/index.js +38 -2
- package/lib/jobs-roster.js +377 -0
- package/lib/link.js +238 -11
- package/lib/logs.js +242 -0
- package/lib/pairing.js +1 -1
- package/lib/router.js +171 -5
- package/lib/routes.js +147 -0
- package/lib/session-digest.js +73 -4
- package/lib/state.js +6 -6
- package/lib/status.js +30 -2
- package/lib/unicode.js +116 -0
- package/package.json +1 -1
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 活动名单:本地方法 `_link/jobRoster` 与推帧 `link/jobs`(spec 018)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么由连接器聚合:宿主没有 host-wide 的任务读法——`JobRegistry.list(caller)`
|
|
5
|
+
* 只回该会话自己的 + 所有无主任务,`session/control` 的基线在本版本也只带
|
|
6
|
+
* projections。连接器与宿主同进程,逐会话读是内存过滤,既没有网络往返,也不需要
|
|
7
|
+
* 手机为每个会话各开一条 `job/list` 流。
|
|
8
|
+
*
|
|
9
|
+
* 契约:`specs/018-mobile-activity-visibility/contracts/link-job-roster.md`。
|
|
10
|
+
* 与 `_link/sessionWindow` / `_link/sessionFind` 同族:连接器自己回答,不转发宿主。
|
|
11
|
+
* 特性检测而不是版本比较——拿不到 `ctx.jobs` 时回 `link/unsupported`,手机整块不显示。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 连接器自答的方法名(`_link/` 前缀表示"不转发宿主",与 history 一族一致)。 */
|
|
15
|
+
export const ROSTER_METHOD = '_link/jobRoster'
|
|
16
|
+
|
|
17
|
+
/** 推帧的 `type`。包在 per-device `$events` 的 item 里,旧 App 当未知类型忽略。 */
|
|
18
|
+
export const JOBS_FRAME = 'link/jobs'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 还算"活着"的状态。其余(`completed` / `killed` / `failed`,以及宿主将来可能
|
|
22
|
+
* 新增的终态)一律只计入 `settled`,不下发——否则每个会话的历史任务都会跟着名单
|
|
23
|
+
* 一起上手机。
|
|
24
|
+
*/
|
|
25
|
+
export const LIVE_STATUSES = Object.freeze(['running', 'stopping'])
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 合并窗口(毫秒)。一次任务生命周期里宿主会在毫秒级连发 registered / progress /
|
|
29
|
+
* stopping / settled 几个事件;按会话合并成最后一帧,手机只看到稳定后的那一格。
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_DEBOUNCE_MS = 120
|
|
32
|
+
|
|
33
|
+
const LIVE = new Set(LIVE_STATUSES)
|
|
34
|
+
|
|
35
|
+
/** 会改变名单的宿主事件。`output` 只影响输出流,不影响这一格。 */
|
|
36
|
+
const LIFECYCLE_TYPES = new Set(['registered', 'progress', 'stopping', 'settled', 'removed'])
|
|
37
|
+
|
|
38
|
+
function messageOf(error) {
|
|
39
|
+
return error instanceof Error ? error.message : String(error)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function failure(code, message, details = {}) {
|
|
43
|
+
return { ok: false, error: { code, message, details } }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 默认调度器:`unref` 掉,去抖窗口里没有别的事情时不该把进程按住不让退出。 */
|
|
47
|
+
function defaultSchedule(fn, ms) {
|
|
48
|
+
const handle = setTimeout(fn, ms)
|
|
49
|
+
handle.unref?.()
|
|
50
|
+
return handle
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function defaultCancel(handle) {
|
|
54
|
+
clearTimeout(handle)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 请求参数只允许空对象(契约 §2)。未知字段一律拒绝:放宽了,将来某个字段改名
|
|
59
|
+
* 就会静默变成"没有过滤条件",而不是一条能看到的 bad-request。
|
|
60
|
+
* @returns {object|undefined} 失败时的回包,成功时 undefined
|
|
61
|
+
*/
|
|
62
|
+
function checkArgs(args) {
|
|
63
|
+
if (args === undefined || args === null) return undefined
|
|
64
|
+
if (typeof args !== 'object' || Array.isArray(args)) {
|
|
65
|
+
return failure('gateway/bad-request', 'arguments must be an empty object')
|
|
66
|
+
}
|
|
67
|
+
const keys = Object.keys(args)
|
|
68
|
+
if (keys.length > 0) return failure('gateway/bad-request', `unknown field "${keys[0]}"`)
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 宿主 `JobView` → 契约的 `JobLite`。
|
|
74
|
+
*
|
|
75
|
+
* 只减不增:手机不读 `outputLimitBytes` / `spillPaths`,就不下发。`progress` /
|
|
76
|
+
* `detail` / `finishedAt` 按宿主现值原样带上,缺省即不带(不补 null,手机按"没有"
|
|
77
|
+
* 处理)。`outputLost` 是唯一重命名:`output.earliest > 0` 表示输出环丢过头部。
|
|
78
|
+
*/
|
|
79
|
+
export function jobLite(job) {
|
|
80
|
+
const output = job?.output ?? {}
|
|
81
|
+
const lite = {
|
|
82
|
+
id: job?.id,
|
|
83
|
+
kind: job?.kind,
|
|
84
|
+
label: job?.label,
|
|
85
|
+
status: job?.status,
|
|
86
|
+
startedAt: job?.startedAt,
|
|
87
|
+
outputTotal: Number.isFinite(output.total) ? output.total : 0,
|
|
88
|
+
}
|
|
89
|
+
if (typeof job?.owner === 'string' && job.owner) lite.owner = job.owner
|
|
90
|
+
if (typeof job?.progress === 'string') lite.progress = job.progress
|
|
91
|
+
if (typeof job?.detail === 'string') lite.detail = job.detail
|
|
92
|
+
if (Number.isFinite(job?.finishedAt)) lite.finishedAt = job.finishedAt
|
|
93
|
+
if (Number.isFinite(output.earliest) && output.earliest > 0) lite.outputLost = true
|
|
94
|
+
return lite
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 会话对象 → id。宿主的 `Session` 把 id 放在 `id` 上;裸 id 只在测试与将来的读法里出现。 */
|
|
98
|
+
function sessionIdOf(entry) {
|
|
99
|
+
const id = typeof entry === 'string' ? entry : entry?.id
|
|
100
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {{
|
|
105
|
+
* getJobs?: () => object | undefined,
|
|
106
|
+
* getSessions?: () => object | undefined,
|
|
107
|
+
* deliver?: (value: object) => void,
|
|
108
|
+
* logger?: { debug?: Function, info?: Function, warn?: Function },
|
|
109
|
+
* now?: () => number,
|
|
110
|
+
* debounceMs?: number,
|
|
111
|
+
* schedule?: (fn: Function, ms: number) => unknown,
|
|
112
|
+
* cancel?: (handle: unknown) => void,
|
|
113
|
+
* }} deps
|
|
114
|
+
*/
|
|
115
|
+
export function createJobsRoster({
|
|
116
|
+
getJobs = () => undefined,
|
|
117
|
+
getSessions = () => undefined,
|
|
118
|
+
deliver = () => {},
|
|
119
|
+
logger = console,
|
|
120
|
+
now = Date.now,
|
|
121
|
+
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
122
|
+
schedule = defaultSchedule,
|
|
123
|
+
cancel = defaultCancel,
|
|
124
|
+
} = {}) {
|
|
125
|
+
/**
|
|
126
|
+
* 服务来自两处:构造参数是读取函数(`link.js` 用它把 roster 接在 agent 上),
|
|
127
|
+
* `bindServices` 是插件注入时直接把服务实例交进来。**后者优先**:注入晚于第一台
|
|
128
|
+
* 设备连接时,正是它让订阅能补上。
|
|
129
|
+
*/
|
|
130
|
+
const bound = { jobs: undefined, sessions: undefined }
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 已在线、且需要收 `link/jobs` 的设备。**不**包含"断了但 `$events` 还替它值班"的
|
|
134
|
+
* 设备:离线期间的旧任务状态没有补发意义(契约 §3),重连后手机重新问一次名单。
|
|
135
|
+
*/
|
|
136
|
+
const devices = new Set()
|
|
137
|
+
/** 宿主 `ctx.jobs.events.subscribe(...)` 的 disposer;undefined = 现在没订阅。 */
|
|
138
|
+
let unsubscribe
|
|
139
|
+
/** 去抖定时器的句柄;undefined = 没有排队的帧。 */
|
|
140
|
+
let timer
|
|
141
|
+
/** 窗口内变过的会话;flush 时逐个重算那一格。 */
|
|
142
|
+
const pending = new Set()
|
|
143
|
+
/** 已发出的最大 `at`:同一连接内单调不减,手机据此丢弃乱序帧(契约 §4.5)。 */
|
|
144
|
+
let lastAt = 0
|
|
145
|
+
|
|
146
|
+
function jobs() {
|
|
147
|
+
return bound.jobs ?? getJobs()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sessions() {
|
|
151
|
+
return bound.sessions ?? getSessions()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function available() {
|
|
155
|
+
return jobs() !== undefined && sessions() !== undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 挂上(或摘下)宿主服务。注入可能晚于第一台设备连上,所以绑定后要补一次订阅:
|
|
160
|
+
* 只在 attach 时订阅的话,这台连接会一直到下一次设备 attach 才开始推帧。
|
|
161
|
+
*/
|
|
162
|
+
function bindServices(next = {}) {
|
|
163
|
+
bound.jobs = next.jobs
|
|
164
|
+
bound.sessions = next.sessions
|
|
165
|
+
if (devices.size > 0) {
|
|
166
|
+
if (available()) subscribe()
|
|
167
|
+
else releaseSubscription()
|
|
168
|
+
}
|
|
169
|
+
return available()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function nextAt() {
|
|
173
|
+
const value = Number(now())
|
|
174
|
+
if (Number.isFinite(value) && value > lastAt) lastAt = value
|
|
175
|
+
return lastAt
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 一个会话那一格:只留活的任务,其余计数。 */
|
|
179
|
+
function cellOf(sessionId) {
|
|
180
|
+
const running = []
|
|
181
|
+
let settled = 0
|
|
182
|
+
for (const job of jobs().list(sessionId) ?? []) {
|
|
183
|
+
if (LIVE.has(job?.status)) running.push(jobLite(job))
|
|
184
|
+
else settled += 1
|
|
185
|
+
}
|
|
186
|
+
return { running, settled }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 宿主活会话列表(`ctx.sessions.list()`)。
|
|
191
|
+
*
|
|
192
|
+
* 形状不可信:`undefined` / 非数组 / `list` 不是函数(宿主换实现、依赖没装全时都可能)
|
|
193
|
+
* 一律当空名单。名单只是一份旁路读数,为它把整个 `_link/jobRoster` 变成
|
|
194
|
+
* `link/failed`,等于跟手机说"这台连接器不支持活动名单"(契约 §2 的失败口径只留给
|
|
195
|
+
* **真的读不出来**的情形)。读的时候**抛错**仍然向上抛,由 `handle` 转成 `link/failed`。
|
|
196
|
+
* 元素缺 `id` 不是异常形状,在 `sample` 里逐条跳过。
|
|
197
|
+
*/
|
|
198
|
+
function sessionEntries() {
|
|
199
|
+
const store = sessions()
|
|
200
|
+
if (typeof store?.list !== 'function') return []
|
|
201
|
+
const listed = store.list()
|
|
202
|
+
return Array.isArray(listed) ? listed : []
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 整份名单(`_link/jobRoster` 的 `value`)。 */
|
|
206
|
+
function sample() {
|
|
207
|
+
const at = nextAt()
|
|
208
|
+
const out = {}
|
|
209
|
+
for (const entry of sessionEntries()) {
|
|
210
|
+
const sessionId = sessionIdOf(entry)
|
|
211
|
+
if (sessionId === undefined || out[sessionId] !== undefined) continue
|
|
212
|
+
let cell
|
|
213
|
+
try {
|
|
214
|
+
cell = cellOf(sessionId)
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// 会话在枚举与读取之间消失,或宿主拒绝这次读:少一格好过整份名单失败。
|
|
217
|
+
logger.debug?.(`mobile-link: job roster skipped ${sessionId}: ${messageOf(error)}`)
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
// 一件任务都没有的会话不占位:手机把"缺席"读成"没有活"(契约 §2)。
|
|
221
|
+
// 有已结算任务的会话仍然占位——`settled` 是面板「已结束 N 个」的口径。
|
|
222
|
+
if (cell.running.length === 0 && cell.settled === 0) continue
|
|
223
|
+
out[sessionId] = cell
|
|
224
|
+
}
|
|
225
|
+
return { at, sessions: out }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function subscribe() {
|
|
229
|
+
if (unsubscribe !== undefined) return
|
|
230
|
+
const events = jobs()?.events
|
|
231
|
+
if (typeof events?.subscribe !== 'function') {
|
|
232
|
+
logger.debug?.('mobile-link: this Host has no jobs event stream; link/jobs frames stay off')
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
// `{owners:'scope'}`:连接器挂在宿主全局层,作用域就是整个进程,等价于"全部会话"。
|
|
237
|
+
//
|
|
238
|
+
// 不用 `{owners:'all'}`:本机实测(0.2.0-rc.2 桌面版宿主)该过滤器的订阅装上了
|
|
239
|
+
// 但**一条事件都收不到**(subscribe 成功、onChange 从不触发),于是 `link/jobs`
|
|
240
|
+
// 永远不推、手机只能显示建连那一刻的名单(2026-10-17 owner 报"任务结束了手机还显示在跑")。
|
|
241
|
+
// `scope` 在同一宿主上按预期收到 registered/settled/removed,因此改用它。
|
|
242
|
+
unsubscribe = events.subscribe({ owners: 'scope' }, onChange)
|
|
243
|
+
} catch (error) {
|
|
244
|
+
logger.warn?.(`mobile-link: subscribing to jobs events failed: ${messageOf(error)}`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function releaseSubscription() {
|
|
249
|
+
const dispose = unsubscribe
|
|
250
|
+
unsubscribe = undefined
|
|
251
|
+
if (typeof dispose !== 'function') return
|
|
252
|
+
try {
|
|
253
|
+
dispose()
|
|
254
|
+
} catch (error) {
|
|
255
|
+
logger.debug?.(`mobile-link: releasing jobs events failed: ${messageOf(error)}`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function onChange(event) {
|
|
260
|
+
if (!LIFECYCLE_TYPES.has(event?.type)) return
|
|
261
|
+
// 帧只描述"一个会话的那一格";无主任务没有归属会话,放不进任何一格。
|
|
262
|
+
// 它仍然会在 `_link/jobRoster` 的首答里出现(宿主对每个会话都可见)。
|
|
263
|
+
const sessionId = event?.job?.owner
|
|
264
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return
|
|
265
|
+
pending.add(sessionId)
|
|
266
|
+
arm()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function arm() {
|
|
270
|
+
if (timer !== undefined) return
|
|
271
|
+
timer = schedule(flush, debounceMs)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function flush() {
|
|
275
|
+
timer = undefined
|
|
276
|
+
const dirty = [...pending]
|
|
277
|
+
pending.clear()
|
|
278
|
+
// 没有在线设备:不产帧、也不把变化攒着(契约 §3「设备未连接时不产生帧,不缓存」)。
|
|
279
|
+
if (devices.size === 0 || dirty.length === 0) return
|
|
280
|
+
const at = nextAt()
|
|
281
|
+
for (const sessionId of dirty) {
|
|
282
|
+
let cell
|
|
283
|
+
try {
|
|
284
|
+
cell = cellOf(sessionId)
|
|
285
|
+
} catch (error) {
|
|
286
|
+
// 读不到就不推:推一份空的 `running` 会把手机上正在跑的任务擦掉(比漏一帧更糟)。
|
|
287
|
+
// 记 warn——这是一次手机可见的漏帧,不是无关噪音;这一格在下一轮事件到来时重算。
|
|
288
|
+
logger.warn?.(`mobile-link: link/jobs skipped ${sessionId}: ${messageOf(error)}`)
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
deliver({
|
|
293
|
+
type: JOBS_FRAME,
|
|
294
|
+
value: { at, sessionId, running: cell.running, settled: cell.settled },
|
|
295
|
+
})
|
|
296
|
+
} catch (error) {
|
|
297
|
+
// 投递失败只算这一帧丢了:若让异常掀掉整个 flush,同一窗口里其它会话的帧会一起
|
|
298
|
+
// 发不出去,而且它会冲进定时器回调,把进程级异常变成无人接的错误。
|
|
299
|
+
logger.warn?.(`mobile-link: link/jobs delivery failed: ${messageOf(error)}`)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 帧处理(`_link/jobRoster`)。回包形状与 history 一族一致:`{ok:true,value}` 或
|
|
306
|
+
* `{ok:false,error:{code,message,details}}`。
|
|
307
|
+
*/
|
|
308
|
+
async function handle(args) {
|
|
309
|
+
const rejected = checkArgs(args)
|
|
310
|
+
if (rejected !== undefined) return rejected
|
|
311
|
+
// 没有 ctx.jobs 时**不能**回空名单:空名单会被手机读成"没有活",
|
|
312
|
+
// 把"这台连接器不支持"说成了事实(契约 §4.3)。
|
|
313
|
+
if (!available()) return failure('link/unsupported', 'this Host does not expose the jobs service')
|
|
314
|
+
try {
|
|
315
|
+
return { ok: true, value: sample() }
|
|
316
|
+
} catch (error) {
|
|
317
|
+
logger.warn?.(`mobile-link: jobRoster failed: ${messageOf(error)}`)
|
|
318
|
+
return failure('link/failed', messageOf(error))
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function addDevice(deviceId) {
|
|
323
|
+
if (typeof deviceId !== 'string' || deviceId.length === 0) return false
|
|
324
|
+
if (devices.has(deviceId)) return false
|
|
325
|
+
devices.add(deviceId)
|
|
326
|
+
// 第一台设备连上才订阅:没人收的帧不值得算(契约 §3)。
|
|
327
|
+
if (devices.size === 1) subscribe()
|
|
328
|
+
return true
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function removeDevice(deviceId) {
|
|
332
|
+
if (!devices.delete(deviceId)) return false
|
|
333
|
+
if (devices.size === 0) {
|
|
334
|
+
// 最后一台设备走了:订阅与服务一起放掉,窗口里的变化也不留。
|
|
335
|
+
releaseSubscription()
|
|
336
|
+
pending.clear()
|
|
337
|
+
if (timer !== undefined) {
|
|
338
|
+
cancel(timer)
|
|
339
|
+
timer = undefined
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return true
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 进程收尾(`router.teardownAll`):全部设备、订阅、定时器都不留。 */
|
|
346
|
+
function releaseAll() {
|
|
347
|
+
devices.clear()
|
|
348
|
+
releaseSubscription()
|
|
349
|
+
pending.clear()
|
|
350
|
+
if (timer !== undefined) {
|
|
351
|
+
cancel(timer)
|
|
352
|
+
timer = undefined
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
bindServices,
|
|
358
|
+
handle,
|
|
359
|
+
addDevice,
|
|
360
|
+
removeDevice,
|
|
361
|
+
releaseAll,
|
|
362
|
+
dispose: releaseAll,
|
|
363
|
+
get available() {
|
|
364
|
+
return available()
|
|
365
|
+
},
|
|
366
|
+
get subscribed() {
|
|
367
|
+
return unsubscribe !== undefined
|
|
368
|
+
},
|
|
369
|
+
get deviceCount() {
|
|
370
|
+
return devices.size
|
|
371
|
+
},
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function isJobsMethod(method) {
|
|
376
|
+
return method === ROSTER_METHOD
|
|
377
|
+
}
|