dsh-plugin-mobile-link 0.6.6 → 0.7.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 +1 -1
- package/cordis.patch.yml +6 -0
- package/lib/dir-listing.js +167 -0
- package/lib/dlp.js +18 -1
- package/lib/enroll.js +2 -2
- package/lib/files.js +118 -12
- package/lib/git.js +20 -5
- package/lib/hello.js +22 -0
- 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 +29 -0
- package/lib/jobs-roster.js +377 -0
- package/lib/link.js +46 -3
- package/lib/logs.js +242 -0
- package/lib/pairing.js +1 -1
- package/lib/router.js +170 -5
- package/lib/session-digest.js +59 -4
- package/lib/state.js +3 -3
- package/lib/unicode.js +116 -0
- package/package.json +1 -1
package/lib/logs.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设备日志落盘(`_link/logs`)。
|
|
3
|
+
*
|
|
4
|
+
* 与 `journal.js` 的分工:journal 记的是**连接器自己**的生命周期事件(几百条、可以随手丢),
|
|
5
|
+
* 这里记的是**手机端**产生的日志行(量级大、需要按时间窗回看)。两者共用"诊断设施不许抛"
|
|
6
|
+
* 这条硬约束,但容量与保留期完全不同。
|
|
7
|
+
*
|
|
8
|
+
* 保留期 **7 天**,清理不靠定时器、也不新增任何进程:每次 `append` 顺手做一次,
|
|
9
|
+
* 且 60 秒内最多一次。理由是不引入定时任务就不会有"任务没跑起来、盘悄悄满了"这种状态。
|
|
10
|
+
*
|
|
11
|
+
* 目录布局:`<root>/<deviceId>/<YYYYMMDD>.jsonl`。按天分文件有三个好处:
|
|
12
|
+
* 清理只需 unlink 整个文件,不必重写;`tail` 只需盯当天的文件;超限时按天淘汰。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
import { defaultStatePath } from './state.js'
|
|
19
|
+
|
|
20
|
+
/** 日志保留期:7 天。 */
|
|
21
|
+
export const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
|
|
22
|
+
/** 单台设备磁盘上限;超了按天淘汰最旧的。 */
|
|
23
|
+
export const DEFAULT_MAX_DEVICE_BYTES = 200 * 1024 * 1024
|
|
24
|
+
/** 单条日志正文上限;超长截断而不是丢弃(截断仍能看出是哪一行)。 */
|
|
25
|
+
export const MAX_TEXT_CHARS = 4000
|
|
26
|
+
/** 每批最多收多少条、多少字节;超了如实报 `accepted`,剩下的由手机下次推。 */
|
|
27
|
+
export const MAX_BATCH_ENTRIES = 500
|
|
28
|
+
export const MAX_BATCH_BYTES = 64 * 1024
|
|
29
|
+
/** 清理的最小间隔:清理一次最多 60 秒一次。 */
|
|
30
|
+
const PRUNE_INTERVAL_MS = 60 * 1000
|
|
31
|
+
|
|
32
|
+
/** 默认落在身份文件旁边:`$DSH_HOME/mobile-link/logs`。测试用临时 DSH_HOME 自动隔离。 */
|
|
33
|
+
export function defaultLogsRoot() {
|
|
34
|
+
return join(dirname(defaultStatePath()), 'logs')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 本地日 `YYYYMMDD`,与 journal 的 `local_day` 口径一致。 */
|
|
38
|
+
export function dayStamp(ms) {
|
|
39
|
+
const date = new Date(ms)
|
|
40
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
41
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 设备 id 会直接进路径:只留安全字符,避免 `../` 之类的路径穿越。 */
|
|
45
|
+
export function safeDeviceId(deviceId) {
|
|
46
|
+
const text = String(deviceId ?? '').trim()
|
|
47
|
+
if (!text) return 'unknown'
|
|
48
|
+
return text.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 120)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 一条上报 → 一行落盘 JSON;非法条目返回 null(调用方计入拒收)。 */
|
|
52
|
+
export function normalizeEntry(raw) {
|
|
53
|
+
if (!raw || typeof raw !== 'object') return null
|
|
54
|
+
const ms = Number(raw.ms)
|
|
55
|
+
const text = typeof raw.text === 'string' ? raw.text : ''
|
|
56
|
+
if (!Number.isFinite(ms) || !text) return null
|
|
57
|
+
const seq = Number(raw.seq)
|
|
58
|
+
return {
|
|
59
|
+
seq: Number.isFinite(seq) ? seq : undefined,
|
|
60
|
+
ms,
|
|
61
|
+
at: typeof raw.at === 'string' && raw.at ? raw.at : new Date(ms).toISOString(),
|
|
62
|
+
level: typeof raw.level === 'string' ? raw.level.slice(0, 16) : 'info',
|
|
63
|
+
source: typeof raw.source === 'string' ? raw.source.slice(0, 32) : 'app',
|
|
64
|
+
text: text.slice(0, MAX_TEXT_CHARS),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class LogSpool {
|
|
69
|
+
/**
|
|
70
|
+
* @param {object} [options]
|
|
71
|
+
* @param {string} [options.root] 目录(默认 `$DSH_HOME/mobile-link/logs`)
|
|
72
|
+
* @param {number} [options.retentionMs] 保留期
|
|
73
|
+
* @param {number} [options.maxDeviceBytes] 单设备上限
|
|
74
|
+
* @param {() => number} [options.now]
|
|
75
|
+
* @param {{ debug?: (message: string) => void }} [options.logger]
|
|
76
|
+
*/
|
|
77
|
+
constructor({
|
|
78
|
+
root, retentionMs = DEFAULT_RETENTION_MS, maxDeviceBytes = DEFAULT_MAX_DEVICE_BYTES,
|
|
79
|
+
now = Date.now, logger,
|
|
80
|
+
} = {}) {
|
|
81
|
+
this.root = typeof root === 'string' && root.trim() ? root : defaultLogsRoot()
|
|
82
|
+
this.retentionMs = Math.max(60_000, Number(retentionMs) || DEFAULT_RETENTION_MS)
|
|
83
|
+
this.maxDeviceBytes = Math.max(1024, Number(maxDeviceBytes) || DEFAULT_MAX_DEVICE_BYTES)
|
|
84
|
+
this.now = now
|
|
85
|
+
this.logger = logger
|
|
86
|
+
/** 每台设备已接受的最高序号(幂等:同批重推不会写两遍)。 */
|
|
87
|
+
this.watermarks = new Map()
|
|
88
|
+
this._lastPruneAt = 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 一台设备当天的文件路径。 */
|
|
92
|
+
pathFor(deviceId, ms = this.now()) {
|
|
93
|
+
return join(this.root, safeDeviceId(deviceId), `${dayStamp(ms)}.jsonl`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 收一批并落盘。
|
|
98
|
+
*
|
|
99
|
+
* @returns {{accepted: number, lastSeq: number, dropped: number}}
|
|
100
|
+
* `accepted` 是真正写下去的条数;被水位挡掉的重复条目不计入。
|
|
101
|
+
*/
|
|
102
|
+
append(deviceId, entries, { full = false } = {}) {
|
|
103
|
+
const result = { accepted: 0, lastSeq: this.watermarks.get(deviceId) ?? 0, dropped: 0 }
|
|
104
|
+
if (!Array.isArray(entries) || entries.length === 0) return result
|
|
105
|
+
let bytes = 0
|
|
106
|
+
const byDay = new Map()
|
|
107
|
+
for (const raw of entries) {
|
|
108
|
+
const entry = normalizeEntry(raw)
|
|
109
|
+
if (!entry) { result.dropped += 1; continue }
|
|
110
|
+
const seq = Number(entry.seq)
|
|
111
|
+
const watermark = this.watermarks.get(deviceId) ?? 0
|
|
112
|
+
// `full` 是"整段重推"(App 冷启动回填或 `dsh://diag export`):
|
|
113
|
+
// 水位不参与判断,但仍然照常推进,避免之后的增量重复。
|
|
114
|
+
if (!full && Number.isFinite(seq) && seq <= watermark) continue
|
|
115
|
+
const line = `${JSON.stringify({ ...entry, deviceId, full: full || undefined })}\n`
|
|
116
|
+
if (result.accepted > 0 && bytes + line.length > MAX_BATCH_BYTES) break
|
|
117
|
+
if (result.accepted >= MAX_BATCH_ENTRIES) break
|
|
118
|
+
const day = dayStamp(entry.ms)
|
|
119
|
+
const bucket = byDay.get(day) ?? []
|
|
120
|
+
bucket.push(line)
|
|
121
|
+
byDay.set(day, bucket)
|
|
122
|
+
bytes += line.length
|
|
123
|
+
result.accepted += 1
|
|
124
|
+
result.lastSeq = Math.max(result.lastSeq, Number.isFinite(seq) ? seq : result.lastSeq)
|
|
125
|
+
}
|
|
126
|
+
if (!byDay.size) return result
|
|
127
|
+
for (const [day, lines] of byDay) this.#write(deviceId, day, lines)
|
|
128
|
+
this.watermarks.set(deviceId, result.lastSeq)
|
|
129
|
+
this.#pruneIfDue()
|
|
130
|
+
return result
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#write(deviceId, day, lines) {
|
|
134
|
+
const path = join(this.root, safeDeviceId(deviceId), `${day}.jsonl`)
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
137
|
+
appendFileSync(path, lines.join(''))
|
|
138
|
+
} catch (error) {
|
|
139
|
+
// 诊断设施不许把链路带崩:写不进去就只报一声。
|
|
140
|
+
this.logger?.debug?.(`mobile-link: logs spool write failed (${error?.message ?? error})`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 60 秒内最多清理一次;清理本身不许抛。 */
|
|
145
|
+
#pruneIfDue() {
|
|
146
|
+
const now = this.now()
|
|
147
|
+
if (now - this._lastPruneAt < PRUNE_INTERVAL_MS) return
|
|
148
|
+
this._lastPruneAt = now
|
|
149
|
+
try {
|
|
150
|
+
this.prune(now)
|
|
151
|
+
} catch (error) {
|
|
152
|
+
this.logger?.debug?.(`mobile-link: logs prune failed (${error?.message ?? error})`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 清理:先按保留期删整天文件,再按单设备上限删最旧的。
|
|
158
|
+
* 返回删掉的字节数(测试用;生产只看副作用)。
|
|
159
|
+
*/
|
|
160
|
+
prune(now = this.now()) {
|
|
161
|
+
let freed = 0
|
|
162
|
+
let devices = []
|
|
163
|
+
try {
|
|
164
|
+
devices = readdirSync(this.root, { withFileTypes: true }).filter((item) => item.isDirectory())
|
|
165
|
+
} catch {
|
|
166
|
+
return 0
|
|
167
|
+
}
|
|
168
|
+
for (const device of devices) {
|
|
169
|
+
const dir = join(this.root, device.name)
|
|
170
|
+
let files = []
|
|
171
|
+
try {
|
|
172
|
+
files = readdirSync(dir).filter((name) => name.endsWith('.jsonl'))
|
|
173
|
+
} catch {
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (!files.length) {
|
|
177
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* 目录删不掉无所谓 */ }
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
const stats = []
|
|
181
|
+
for (const name of files.sort()) {
|
|
182
|
+
const path = join(dir, name)
|
|
183
|
+
try {
|
|
184
|
+
const info = statSync(path)
|
|
185
|
+
stats.push({ path, day: name.slice(0, 8), size: info.size, mtimeMs: info.mtimeMs })
|
|
186
|
+
} catch { /* 文件刚被删掉 */ }
|
|
187
|
+
}
|
|
188
|
+
// 保留期按**文件名里的日**判定,不看 mtime:mtime 会被备份、拷贝、恢复改动,
|
|
189
|
+
// 而"这是哪一天的日志"只写在名字里,判定必须与名字一致。
|
|
190
|
+
const cutoff = dayStamp(now - this.retentionMs)
|
|
191
|
+
for (const file of stats) {
|
|
192
|
+
if (file.day >= cutoff) continue
|
|
193
|
+
freed += this.#unlink(file)
|
|
194
|
+
}
|
|
195
|
+
let kept = stats.filter((file) => file.day >= cutoff)
|
|
196
|
+
let total = kept.reduce((sum, file) => sum + file.size, 0)
|
|
197
|
+
for (const file of kept) {
|
|
198
|
+
if (total <= this.maxDeviceBytes) break
|
|
199
|
+
total -= file.size
|
|
200
|
+
freed += this.#unlink(file)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return freed
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#unlink(file) {
|
|
207
|
+
try {
|
|
208
|
+
unlinkSync(file.path)
|
|
209
|
+
return file.size
|
|
210
|
+
} catch {
|
|
211
|
+
return 0
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 某台设备(或全部)的磁盘占用,给人看的。 */
|
|
216
|
+
usage() {
|
|
217
|
+
const rows = []
|
|
218
|
+
let devices = []
|
|
219
|
+
try {
|
|
220
|
+
devices = readdirSync(this.root, { withFileTypes: true }).filter((item) => item.isDirectory())
|
|
221
|
+
} catch {
|
|
222
|
+
return rows
|
|
223
|
+
}
|
|
224
|
+
for (const device of devices) {
|
|
225
|
+
const dir = join(this.root, device.name)
|
|
226
|
+
let bytes = 0
|
|
227
|
+
let files = 0
|
|
228
|
+
let newest = 0
|
|
229
|
+
try {
|
|
230
|
+
for (const name of readdirSync(dir)) {
|
|
231
|
+
if (!name.endsWith('.jsonl')) continue
|
|
232
|
+
const info = statSync(join(dir, name))
|
|
233
|
+
bytes += info.size
|
|
234
|
+
files += 1
|
|
235
|
+
newest = Math.max(newest, info.mtimeMs)
|
|
236
|
+
}
|
|
237
|
+
} catch { /* 忽略单台设备的读失败 */ }
|
|
238
|
+
rows.push({ deviceId: device.name, bytes, files, newestMs: newest })
|
|
239
|
+
}
|
|
240
|
+
return rows.sort((a, b) => b.bytes - a.bytes)
|
|
241
|
+
}
|
|
242
|
+
}
|
package/lib/pairing.js
CHANGED
|
@@ -17,7 +17,7 @@ import { pairCodeEndpoint, pairCodeRegenerateEndpoint, qrPayload } from './dlp.j
|
|
|
17
17
|
*/
|
|
18
18
|
export async function mintPairCode({ identity, ttlMs = null, fetchImpl = fetch }) {
|
|
19
19
|
// 032:**持久码那条路不能把 `ttlMs` 放进请求体**。中转的判据是 `"ttlMs" not in body`
|
|
20
|
-
// (`relay/
|
|
20
|
+
// (`relay-go/internal/httpapi` 的 pair-code 处理):带一个 `ttlMs: null` 会被读成"限时、但没给时长",
|
|
21
21
|
// 于是落到中转的默认 10 分钟——面板显示倒计时,码十分钟后就废(用户看到的就是这个)。
|
|
22
22
|
const payload = ttlMs == null
|
|
23
23
|
? { agentId: identity.agentId }
|
package/lib/router.js
CHANGED
|
@@ -18,6 +18,7 @@ import { GitBridge, isGitMethod } from './git.js'
|
|
|
18
18
|
import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
|
|
19
19
|
import { isHistoryMethod } from './history.js'
|
|
20
20
|
import { trimRecordsForWire } from './history/records.js'
|
|
21
|
+
import { isJobsMethod } from './jobs-roster.js'
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* 后台大文件上传的桥接帧(relay → 连接器),名字见 `docs/relay-contract.json`
|
|
@@ -26,6 +27,16 @@ import { trimRecordsForWire } from './history/records.js'
|
|
|
26
27
|
export const FSPUT_BEGIN = 'fsPutBegin'
|
|
27
28
|
export const FSPUT_CHUNK = 'fsPutChunk'
|
|
28
29
|
export const FSPUT_END = 'fsPutEnd'
|
|
30
|
+
/**
|
|
31
|
+
* relay 叫停一次后台上传(占位型前向兼容)。
|
|
32
|
+
*
|
|
33
|
+
* 本仓库两个中转**目前都不发这一帧**:手机掐掉 `PUT /files/up` 时,它们在
|
|
34
|
+
* `finally` 里只摘掉本地的桥接,既不发取消帧、也不断开 agent 的 WebSocket。
|
|
35
|
+
* 因此真正兜住句柄的是 `FileInbox` 的空闲清扫与"重传接管暂存路径";这一帧
|
|
36
|
+
* 让将来的/第三方中转能提前释放。按 bid 寻址,可以不带 `deviceId`
|
|
37
|
+
* (与 `fsGetCancel` 同口径,未知 bid 是空操作)。
|
|
38
|
+
*/
|
|
39
|
+
export const FSPUT_CANCEL = 'fsPutCancel'
|
|
29
40
|
|
|
30
41
|
/**
|
|
31
42
|
* 后台大文件下载的桥接帧(relay → 连接器),同一族的另一半:relay 终结
|
|
@@ -96,13 +107,16 @@ export function trimHistoryPageForWire(method, result) {
|
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
import { DeviceTimeline } from './diagnostics.js'
|
|
99
|
-
import { SESSION_DIGEST_METHOD, digestSessionList, encodeDigest } from './session-digest.js'
|
|
110
|
+
import { SESSION_DIGEST_METHOD, digestSessionList, encodeDigest, digestDelta } from './session-digest.js'
|
|
111
|
+
import { DIR_LISTING_METHOD, dirListing } from './dir-listing.js'
|
|
100
112
|
|
|
101
113
|
/** 慢请求阈值默认值(毫秒);0 = 关闭逐条慢请求记录。 */
|
|
102
114
|
export const DEFAULT_SLOW_REQUEST_MS = 2000
|
|
103
115
|
|
|
104
116
|
/** App 把自己那份阶段记录推上来的方法(契约 connector-diagnostics-method.md)。 */
|
|
105
117
|
export const DIAGNOSTICS_METHOD = '_link/diagnostics'
|
|
118
|
+
/** 手机把运行日志推上来的方法;落盘在 `logs.js`,与 journal 分开。 */
|
|
119
|
+
export const LOGS_METHOD = '_link/logs'
|
|
106
120
|
/** 单批上限:超过就截断并如实报 `accepted`,剩下的由 App 下次再推(水位不推进)。 */
|
|
107
121
|
export const DIAGNOSTICS_BATCH_BYTES = 32 * 1024
|
|
108
122
|
/** 单条事件里字符串字段的保留长度。 */
|
|
@@ -142,9 +156,11 @@ export class DeviceRouter {
|
|
|
142
156
|
constructor({
|
|
143
157
|
dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1,
|
|
144
158
|
eventsBacklog = DEFAULT_EVENTS_BACKLOG, notify = () => {}, onEvent = () => {},
|
|
145
|
-
slowRequestMs = DEFAULT_SLOW_REQUEST_MS, diagnostics = true,
|
|
159
|
+
slowRequestMs = DEFAULT_SLOW_REQUEST_MS, diagnostics = true, logs = null,
|
|
146
160
|
} = {}) {
|
|
147
161
|
this.dsh = dsh
|
|
162
|
+
/** 设备日志落盘(见 logs.js);null = 不接日志(老配置或单测)。 */
|
|
163
|
+
this.logs = logs
|
|
148
164
|
this.eventsBacklog = eventsBacklog
|
|
149
165
|
this.slowRequestMs = Number(slowRequestMs) || 0
|
|
150
166
|
this.diagnosticsEnabled = diagnostics !== false
|
|
@@ -185,6 +201,21 @@ export class DeviceRouter {
|
|
|
185
201
|
// 历史窗口/检索(spec 007):由 index.js 注入宿主 `sessionQuery` 后交给它。
|
|
186
202
|
// 没绑上就回 `link/unsupported`,手机端退回 `session/page`。
|
|
187
203
|
this.history = undefined
|
|
204
|
+
// 活动名单与 `link/jobs` 推帧(spec 018)。订阅与去抖都在 roster 里,router 只负责
|
|
205
|
+
// 转交设备生命周期与把帧广播出去。
|
|
206
|
+
this.jobsRoster = undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 挂上活动名单(spec 018)。
|
|
211
|
+
*
|
|
212
|
+
* 注入可能晚于第一台设备连接(`ctx.inject` 的时序),所以这里要对**已经挂着**的
|
|
213
|
+
* 设备补报一遍:只在 attach 时通知的话,这台连接会一直到下一次 attach 才开始推帧。
|
|
214
|
+
*/
|
|
215
|
+
bindJobsRoster(roster) {
|
|
216
|
+
this.jobsRoster = roster
|
|
217
|
+
if (roster === undefined) return
|
|
218
|
+
for (const deviceId of this.devices.keys()) roster.addDevice(deviceId)
|
|
188
219
|
}
|
|
189
220
|
|
|
190
221
|
#changed() {
|
|
@@ -221,6 +252,11 @@ export class DeviceRouter {
|
|
|
221
252
|
this.handleFsGetCancel(frame)
|
|
222
253
|
return
|
|
223
254
|
}
|
|
255
|
+
// 上传的取消帧同样是 bid 寻址,可能不带 `deviceId`;放在守卫之前,理由同上。
|
|
256
|
+
if (frame.t === FSPUT_CANCEL) {
|
|
257
|
+
this.handleFsPutCancel(frame)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
224
260
|
if (!deviceId) return
|
|
225
261
|
const problem = deviceFrameError(frame)
|
|
226
262
|
if (problem) {
|
|
@@ -305,6 +341,17 @@ export class DeviceRouter {
|
|
|
305
341
|
}
|
|
306
342
|
}
|
|
307
343
|
|
|
344
|
+
/**
|
|
345
|
+
* relay 叫停一次上传:立刻关句柄、删 `.part`。
|
|
346
|
+
*
|
|
347
|
+
* 不回帧:取消本身不是一次请求,也没有可报的错(与 `handleFsGetCancel` 同)。
|
|
348
|
+
*/
|
|
349
|
+
handleFsPutCancel(frame) {
|
|
350
|
+
const bid = String(frame?.bid ?? '')
|
|
351
|
+
if (!bid) return
|
|
352
|
+
this.fileInbox.cancel(bid)
|
|
353
|
+
}
|
|
354
|
+
|
|
308
355
|
/**
|
|
309
356
|
* 开一次下载。**不 await**:分片要在 relay 读响应体时陆续发出去,一个 await
|
|
310
357
|
* 会把这条 socket 上其它帧全挡住(与上传那条流水线的理由相同)。
|
|
@@ -404,6 +451,22 @@ export class DeviceRouter {
|
|
|
404
451
|
return { ok: true, value: { accepted, lastSeq } }
|
|
405
452
|
}
|
|
406
453
|
|
|
454
|
+
/**
|
|
455
|
+
* 收下手机推来的日志行。
|
|
456
|
+
*
|
|
457
|
+
* 与 `acceptDiagnostics` 的区别:这里不产生任何生命周期事件(日志是数据,不是状态),
|
|
458
|
+
* 也不做批量解释;`logs.js` 只负责按天落盘与 7 天清理。失败一律当无事:
|
|
459
|
+
* 手机侧把未确认的条目留在自己的环里,下次再推。
|
|
460
|
+
*/
|
|
461
|
+
acceptLogs(deviceId, args = {}) {
|
|
462
|
+
if (!this.logs) {
|
|
463
|
+
return { ok: false, error: errorObject('logs/disabled', 'log intake is disabled on this connector') }
|
|
464
|
+
}
|
|
465
|
+
const entries = Array.isArray(args.entries) ? args.entries : []
|
|
466
|
+
const result = this.logs.append(deviceId, entries, { full: args.full === true })
|
|
467
|
+
return { ok: true, value: result }
|
|
468
|
+
}
|
|
469
|
+
|
|
407
470
|
/** 起一次新的接入计时(回头客也重起:那确实是一次新连接)。 */
|
|
408
471
|
#startTimeline(deviceId) {
|
|
409
472
|
this.timelines.get(deviceId)?.finish()
|
|
@@ -428,6 +491,9 @@ export class DeviceRouter {
|
|
|
428
491
|
async attachDevice(deviceId, info = {}) {
|
|
429
492
|
if (!nonEmptyString(deviceId)) return
|
|
430
493
|
this.#startTimeline(deviceId)
|
|
494
|
+
// 活动名单(018):第一台设备连上才订阅宿主任务事件;回头客这里也要补一次
|
|
495
|
+
// (detach 时它从名单里撤下过)。
|
|
496
|
+
this.jobsRoster?.addDevice(deviceId)
|
|
431
497
|
const existing = this.devices.get(deviceId)
|
|
432
498
|
if (existing) {
|
|
433
499
|
// 回头客:手机又连上了。流和攒下的提问都还在,交给 handleOpen 补发。
|
|
@@ -484,6 +550,9 @@ export class DeviceRouter {
|
|
|
484
550
|
this.#finishTimeline(deviceId)
|
|
485
551
|
const record = this.devices.get(deviceId)
|
|
486
552
|
if (!record) return
|
|
553
|
+
// 活动名单(018):断了就不再往它那儿推任务帧(离线补发旧任务状态没有意义),
|
|
554
|
+
// 最后一台设备离开时 roster 自己会释放宿主订阅。
|
|
555
|
+
this.jobsRoster?.removeDevice(deviceId)
|
|
487
556
|
if (reason === 'revoked') {
|
|
488
557
|
await this.#dropDevice(deviceId)
|
|
489
558
|
this.onEvent('device.revoked', { deviceId })
|
|
@@ -516,6 +585,7 @@ export class DeviceRouter {
|
|
|
516
585
|
async #dropDevice(deviceId) {
|
|
517
586
|
const record = this.devices.get(deviceId)
|
|
518
587
|
if (!record) return
|
|
588
|
+
this.jobsRoster?.removeDevice(deviceId)
|
|
519
589
|
for (const muxId of this.streams.removeDevice(deviceId)) this.dsh.cancelStream(muxId)
|
|
520
590
|
this.devices.delete(deviceId)
|
|
521
591
|
this.logger.info?.(
|
|
@@ -555,6 +625,12 @@ export class DeviceRouter {
|
|
|
555
625
|
// 不该有残留:上面的循环已经逐个收过;这里兜底,防止 attach 之后没进 devices 的路径留下脏项。
|
|
556
626
|
this.timelines.clear()
|
|
557
627
|
this._sentBytes.clear()
|
|
628
|
+
// 链路断了:在传的上传与下载都不可能再完成。上传要关句柄、删 `.part`;
|
|
629
|
+
// 下载要停掉还没读完的窗口。
|
|
630
|
+
this.fileInbox.clear()
|
|
631
|
+
this.fileFetcher.clear()
|
|
632
|
+
// 活动名单(018):设备都没了,宿主任务订阅与排队的帧一起放掉。
|
|
633
|
+
this.jobsRoster?.releaseAll()
|
|
558
634
|
return muxIds
|
|
559
635
|
}
|
|
560
636
|
|
|
@@ -575,19 +651,66 @@ export class DeviceRouter {
|
|
|
575
651
|
this.lastError = messageOf(error)
|
|
576
652
|
this.#changed()
|
|
577
653
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
654
|
+
let payload = result
|
|
655
|
+
if (result?.ok === true) {
|
|
656
|
+
const trimmed = digestSessionList(result)
|
|
657
|
+
const items = Array.isArray(trimmed?.items) ? trimmed.items : []
|
|
658
|
+
const since = typeof args.since === 'string' ? args.since : null
|
|
659
|
+
this.digestCursors ??= new Map()
|
|
660
|
+
const previous = since ? this.digestCursors.get(deviceId) : null
|
|
661
|
+
// 版本对得上才发增量;对不上(首次、连接器重启、设备被清理)发全量。
|
|
662
|
+
const incremental = Boolean(previous && previous.version === since)
|
|
663
|
+
const delta = digestDelta(incremental ? previous.fingerprints : null, items)
|
|
664
|
+
this.digestCursors.set(deviceId, { version: delta.version, fingerprints: delta.fingerprints })
|
|
665
|
+
if (this.digestCursors.size > 32) {
|
|
666
|
+
const oldest = this.digestCursors.keys().next().value
|
|
667
|
+
this.digestCursors.delete(oldest)
|
|
668
|
+
}
|
|
669
|
+
const value = {
|
|
670
|
+
...trimmed,
|
|
671
|
+
items: incremental ? delta.changed : items,
|
|
672
|
+
removed: incremental ? delta.removed : [],
|
|
673
|
+
version: delta.version,
|
|
674
|
+
full: !incremental,
|
|
675
|
+
}
|
|
676
|
+
payload = { ok: true, value: encodeDigest(value, args.encoding) }
|
|
677
|
+
}
|
|
581
678
|
this.send(deviceId, resultFrame(frame.id, payload, deviceId))
|
|
582
679
|
return
|
|
583
680
|
}
|
|
584
681
|
|
|
682
|
+
// 目录清单的本机增强(设计 20261024):连接器读宿主的 `workspaceFiles/list`,
|
|
683
|
+
// 在电脑本机对每个条目 `fs.stat` 补一个 `mtimeMs`——宿主没有时间字段,而
|
|
684
|
+
// `workspaceFiles/stat` 的 `version` 是 opaque token(推不出时间)。
|
|
685
|
+
// 与 sessionDigest 同款:清单的内容仍是宿主的,连接器只加字段;任何一步出问题
|
|
686
|
+
// 都退回未增强的原始清单(装饰永不弄坏清单)。
|
|
687
|
+
if (frame.method === DIR_LISTING_METHOD) {
|
|
688
|
+
let result
|
|
689
|
+
try {
|
|
690
|
+
// `dirListing` 认宿主信封本身(`{ ok, value: { entries } }`)并原样回同形状的值:
|
|
691
|
+
// 宿主报错时**信封逐字透传**,`resultFrame` 照常把它翻成 `ok:false` + 原错误码。
|
|
692
|
+
// 这里不能再包一层 `{ ok: true, value }`——那会把宿主的失败塞进 `value` 里,
|
|
693
|
+
// 帧上反而成了 `ok:true`(手机就会把"目录不存在"读成"空目录")。
|
|
694
|
+
result = await dirListing({ dsh: this.dsh, args: frame.args ?? {}, logger: this.logger })
|
|
695
|
+
} catch (error) {
|
|
696
|
+
result = { ok: false, error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}) }
|
|
697
|
+
}
|
|
698
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
|
|
585
702
|
if (frame.method === DIAGNOSTICS_METHOD) {
|
|
586
703
|
const result = this.acceptDiagnostics(deviceId, frame.args ?? {})
|
|
587
704
|
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
588
705
|
return
|
|
589
706
|
}
|
|
590
707
|
|
|
708
|
+
if (frame.method === LOGS_METHOD) {
|
|
709
|
+
const result = this.acceptLogs(deviceId, frame.args ?? {})
|
|
710
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
|
|
591
714
|
// Files the phone sends are answered here rather than forwarded: the Host
|
|
592
715
|
// has no upload endpoint, and carrying them on the existing unary frame
|
|
593
716
|
// means neither the relay nor the protocol has to change.
|
|
@@ -667,6 +790,22 @@ export class DeviceRouter {
|
|
|
667
790
|
return
|
|
668
791
|
}
|
|
669
792
|
|
|
793
|
+
// 活动名单(spec 018)与 history 同一条短路:连接器自己回答,不转发宿主。
|
|
794
|
+
// 没挂上宿主 `ctx.jobs` 时回 `link/unsupported`——**不**回空名单,否则手机
|
|
795
|
+
// 会把"这台连接器不支持"显示成"没有任务在跑"。
|
|
796
|
+
if (isJobsMethod(frame.method)) {
|
|
797
|
+
let result
|
|
798
|
+
try {
|
|
799
|
+
result = this.jobsRoster === undefined
|
|
800
|
+
? { ok: false, error: errorObject('link/unsupported', 'job roster is unavailable on this connector') }
|
|
801
|
+
: await this.jobsRoster.handle(frame.args ?? {})
|
|
802
|
+
} catch (error) {
|
|
803
|
+
result = { ok: false, error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}) }
|
|
804
|
+
}
|
|
805
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
806
|
+
return
|
|
807
|
+
}
|
|
808
|
+
|
|
670
809
|
let result
|
|
671
810
|
try {
|
|
672
811
|
result = await this.dsh.rpc(frame.method, frame.args ?? {})
|
|
@@ -831,12 +970,38 @@ export class DeviceRouter {
|
|
|
831
970
|
record.readyValue = value
|
|
832
971
|
this.#changed()
|
|
833
972
|
}
|
|
973
|
+
this.#sendEventItem(deviceId, record, value)
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* 向一台设备投递一条 `$events` 形态的项(item,或未开流时的 event 帧)。
|
|
978
|
+
*
|
|
979
|
+
* 抽出来是为了让连接器自造的帧(`link/jobs`,见 `publishEvent`)与宿主事件走**同一段**
|
|
980
|
+
* 投递口径,帧语义不变:开了 `$events` 的按 dlpId 走 item,没开的按 event 广播。
|
|
981
|
+
*/
|
|
982
|
+
#sendEventItem(deviceId, record, value) {
|
|
834
983
|
for (const dlpId of record.dlpIds) this.send(deviceId, { t: 'item', id: dlpId, value })
|
|
835
984
|
// Spec §4.1.5: every $events item is broadcast as an `event` frame until the
|
|
836
985
|
// device opens the stream explicitly, so nothing is delivered twice.
|
|
837
986
|
if (record.dlpIds.size === 0) this.send(deviceId, { t: 'event', value })
|
|
838
987
|
}
|
|
839
988
|
|
|
989
|
+
/**
|
|
990
|
+
* 把一条连接器自造的项广播给**每台在线设备**(活动名单的 `link/jobs`,spec 018)。
|
|
991
|
+
*
|
|
992
|
+
* 与 `forwardEvent` 的区别是来源:那条项来自某台设备自己的 `$events` 流,只回给它;
|
|
993
|
+
* 这条是宿主任务事件的产物,没有归属设备,所以每台在线设备各收一份。
|
|
994
|
+
*
|
|
995
|
+
* **离线设备不推**:`$events` 还替它留着(见 `detachDevice`),但旧任务状态没有补发
|
|
996
|
+
* 意义——重连后手机重新问一次 `_link/jobRoster` 即可(契约 §3)。
|
|
997
|
+
*/
|
|
998
|
+
publishEvent(value) {
|
|
999
|
+
for (const record of this.devices.values()) {
|
|
1000
|
+
if (record.offlineSince !== undefined) continue
|
|
1001
|
+
this.#sendEventItem(record.deviceId, record, value)
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
840
1005
|
/**
|
|
841
1006
|
* 要不要为这条 `$events` 项报一声(就是「提醒手机」)。
|
|
842
1007
|
*
|
package/lib/session-digest.js
CHANGED
|
@@ -12,25 +12,33 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { deflateRawSync } from 'node:zlib'
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
15
16
|
|
|
16
17
|
/** 方法名(设备发起,中转不校验方法名,所以不需要中转改动)。 */
|
|
17
18
|
export const SESSION_DIGEST_METHOD = '_link/sessionDigest'
|
|
18
19
|
|
|
19
20
|
/** 会话项里剔掉的字段:零读取点(`agentAvailable` 1.8%)。 */
|
|
20
|
-
|
|
21
|
+
// `agentAvailable` 曾是"零读取点",018 之后列表行要用它区分"会话是否还有活实例"
|
|
22
|
+
// (`ActivityStore` 与 `session.activity.*`),因此**不再剔除**。留空数组是为了保住
|
|
23
|
+
// 「剔除机制 + 逐字节稳定」这两条不变式;将来要剔字段仍从这里加。
|
|
24
|
+
export const DROPPED_ITEM_KEYS = Object.freeze([])
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
|
-
*
|
|
27
|
+
* 投影键里剔掉的字段(合计约 55%)。
|
|
24
28
|
*
|
|
25
29
|
* 每一条都能在 App/DSHKit 里复查"零读取点":
|
|
26
30
|
* `turnOutline` 33.4%(每轮 prompt/response 原文)、`imageLimits` 6.2%(上传限制)、
|
|
27
31
|
* `sessionStats` 4.2%、`todos` 2.6%、`subagentCatalog` 2.6%、`contextBreakdown` 2.5%、
|
|
28
32
|
* `sessionListMetadata` 1.9%(唯一被读的 `blank` 在顶层已有同名字段)、
|
|
29
|
-
* `
|
|
33
|
+
* `agentPreset` 0.8%(`SessionCreateRequest.agentPreset` 是请求字段)。
|
|
34
|
+
*
|
|
35
|
+
* **2026-10-20 起不再剔除 `subagentTiming`**(原 1.5%):018 的活动可见性靠它判断
|
|
36
|
+
* "某子会话当前是否在跑"与已跑时长;剔除后手机永远看不到「N 智能体」。
|
|
37
|
+
* 同理 `agentAvailable` 也不再剔除。代价是摘要通道多约 1.5% 字节,换来功能可用。
|
|
30
38
|
*/
|
|
31
39
|
export const DROPPED_PROJECTION_KEYS = Object.freeze([
|
|
32
40
|
'turnOutline', 'imageLimits', 'sessionStats', 'todos', 'subagentCatalog',
|
|
33
|
-
'contextBreakdown', 'sessionListMetadata', '
|
|
41
|
+
'contextBreakdown', 'sessionListMetadata', 'agentPreset',
|
|
34
42
|
])
|
|
35
43
|
|
|
36
44
|
/**
|
|
@@ -115,3 +123,50 @@ export function encodeDigest(value, encoding) {
|
|
|
115
123
|
rawBytes: raw.length,
|
|
116
124
|
}
|
|
117
125
|
}
|
|
126
|
+
|
|
127
|
+
/** 单条会话的稳定指纹:内容不变则指纹不变(用于增量比较)。 */
|
|
128
|
+
export function digestFingerprint(item) {
|
|
129
|
+
try {
|
|
130
|
+
return createHash('sha1').update(JSON.stringify(item)).digest('hex').slice(0, 16)
|
|
131
|
+
} catch {
|
|
132
|
+
return 'unhashable'
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 一批会话的指纹表(id → 指纹)。 */
|
|
137
|
+
export function digestFingerprints(items) {
|
|
138
|
+
const map = new Map()
|
|
139
|
+
for (const item of items ?? []) {
|
|
140
|
+
if (item && typeof item.id === 'string') map.set(item.id, digestFingerprint(item))
|
|
141
|
+
}
|
|
142
|
+
return map
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 指纹表的版本:指纹集合不变则版本不变。 */
|
|
146
|
+
export function fingerprintsVersion(map) {
|
|
147
|
+
const keys = [...map.keys()].sort()
|
|
148
|
+
const digest = createHash('sha1')
|
|
149
|
+
for (const key of keys) digest.update(key).update('\u0000').update(map.get(key)).update('\u0001')
|
|
150
|
+
return digest.digest('hex').slice(0, 16)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 相对上一次指纹表的增量。
|
|
155
|
+
*
|
|
156
|
+
* 入参 `previous` 为 Map 或 null;返回 changed(新增或内容变化的项)、
|
|
157
|
+
* removed(消失的 id)与新版本。`previous` 为空时调用方应按全量处理。
|
|
158
|
+
*/
|
|
159
|
+
export function digestDelta(previous, items) {
|
|
160
|
+
const list = Array.isArray(items) ? items : []
|
|
161
|
+
const next = digestFingerprints(list)
|
|
162
|
+
const changed = []
|
|
163
|
+
for (const item of list) {
|
|
164
|
+
if (!item || typeof item.id !== 'string') continue
|
|
165
|
+
if (!previous || previous.get(item.id) !== next.get(item.id)) changed.push(item)
|
|
166
|
+
}
|
|
167
|
+
const removed = []
|
|
168
|
+
if (previous) {
|
|
169
|
+
for (const id of previous.keys()) if (!next.has(id)) removed.push(id)
|
|
170
|
+
}
|
|
171
|
+
return { changed, removed, version: fingerprintsVersion(next), fingerprints: next }
|
|
172
|
+
}
|
package/lib/state.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The file holds the long-lived `agentId` / `agentSecret` for this computer.
|
|
5
5
|
* There are two ways it gets there, and they are deliberately indistinguishable
|
|
6
|
-
* afterwards: the relay operator provisions it by hand (relay
|
|
7
|
-
*
|
|
6
|
+
* afterwards: the relay operator provisions it by hand (`dsh-relay admin
|
|
7
|
+
* agent-register --write-config`), or the person running the computer redeems
|
|
8
8
|
* an invite code themselves (`dsh-mobile-link enroll`, see enroll.js).
|
|
9
9
|
* Environment variables and the plugin config block override the file, which is
|
|
10
10
|
* how the integration test injects a throwaway identity.
|
|
@@ -109,7 +109,7 @@ export async function resolveIdentity({ stateFile, agentId, agentSecret, relayUr
|
|
|
109
109
|
`mobile-link: this computer is not registered yet. Run "dsh-mobile-link enroll`
|
|
110
110
|
+ ` --invite <code> --relay <relay-url>" with the invite code you were given, or set`
|
|
111
111
|
+ ` ${INVITE_ENV} and restart DSH. (Provisioning by hand is still possible:`
|
|
112
|
-
+ ` "
|
|
112
|
+
+ ` "dsh-relay admin agent-register --account <acc> --name <name>`
|
|
113
113
|
+ ` --write-config ${path}".)`,
|
|
114
114
|
{ stateFile: path },
|
|
115
115
|
)
|