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.
@@ -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
+ }