dsh-plugin-mobile-link 0.6.0 → 0.6.5

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/lib/history.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { HOST_CALL_TIMEOUT_MS, withTimeout, messageOf } from './history/shared.js'
2
- import { createProbeRunner } from './history/probe.js'
3
2
  import {
4
3
  DEFAULT_LIMIT, DEFAULT_MAX_BYTES, WINDOW_CHUNK,
5
- bytesOfRecords, displayRoleOf, roleOf, toRecord,
4
+ bytesOfRecords, displayRoleOf, roleOf, toRecord, trimRecordForWire, trimRecordsForWire,
6
5
  } from './history/records.js'
7
6
  import { pageFrom, scanOlder } from './history/window.js'
7
+ import { filteredScan } from './history/filter-scan.js'
8
+ import { createSnapshotCache, scanSnapshot } from './history/session-snapshot.js'
8
9
 
9
10
  // Re-exported for the caller/tests that read them off this module (single source
10
11
  // is `history/records.js`; the call ceiling lives in `history/shared.js`).
@@ -30,10 +31,6 @@ export { HOST_CALL_TIMEOUT_MS } from './history/shared.js'
30
31
  * Feature detection is the switch, not version comparison: a connector that
31
32
  * lacks the injected service answers `link/unsupported`, and the phone keeps
32
33
  * using `session/page`. Nothing about the existing methods changes.
33
- *
34
- * SPIKE NOTE (spec 007, task T008): `probe()` below is a temporary read-only
35
- * measurement used to pick the exact Host API mapping. It is wired to
36
- * `GET /mobile-link/history-probe` and is removed once the mapping is locked.
37
34
  */
38
35
 
39
36
  export const WINDOW_METHOD = '_link/sessionWindow'
@@ -117,6 +114,13 @@ function failure(code, message, details = {}) {
117
114
  return { ok: false, error: { code, message, details } }
118
115
  }
119
116
 
117
+ /// 搜索命中项按契约只带 `seq`/`role`/`snippet`(不带记录本身),所以今天**没有**
118
+ /// 宿主日志可删。这里仍然逐条过一遍清洗:万一将来命中项带上记录(例如为了跳转
119
+ /// 预取),它就不会成为那条绕过统一口径的旁路。
120
+ function trimHitsForWire(hits) {
121
+ return hits.map((hit) => (hit?.record === undefined ? hit : trimRecordForWire(hit)))
122
+ }
123
+
120
124
  /// Map a thrown Host/gateway error onto the contract's codes.
121
125
  function hostFailure(error) {
122
126
  const message = messageOf(error)
@@ -130,7 +134,11 @@ function hostFailure(error) {
130
134
  */
131
135
  export function createHistoryService({ logger = console } = {}) {
132
136
  let sessionQuery
133
- const probeRunner = createProbeRunner({ getQuery: () => sessionQuery, logger })
137
+ /// 会话日志快照(只给**按角色过滤**的读取用):一个会话读一次整份、本地过滤、短 TTL。
138
+ /// 它把"每条命中一次宿主调用"那道墙去掉——灌库一个会话从十几次往返变成一次读。
139
+ const snapshots = createSnapshotCache({
140
+ readSession: (sessionId) => withTimeout(sessionQuery.readSession(sessionId), HOST_CALL_TIMEOUT_MS, 'readSession'),
141
+ })
134
142
  /// Set to false after the Host has told us its full-text index is closed
135
143
  /// (`openAt: "never"`), so later searches go straight to the scan.
136
144
  let indexSearchable = true
@@ -176,6 +184,22 @@ export function createHistoryService({ logger = console } = {}) {
176
184
  }
177
185
  }
178
186
 
187
+ /**
188
+ * 一个 seq 段的**消息类文档**(`filterEvents`):比 `readEvent` 每块 51 条快约 20 倍/seq,
189
+ * 但只回消息类事件(user/assistant/tool 那一族)——角色过滤要的正是这些。
190
+ *
191
+ * 宿主没挂这个接口时(老宿主)回 undefined,`window()` 会退回原来的分块扫描。
192
+ */
193
+ async function readFilterChunk(sessionId, from, to) {
194
+ if (typeof sessionQuery?.filterEvents !== 'function') return undefined
195
+ const docs = await withTimeout(
196
+ sessionQuery.filterEvents(sessionId, [{ kind: 'seq', from, to }]),
197
+ HOST_CALL_TIMEOUT_MS,
198
+ 'filterEvents(window)',
199
+ )
200
+ return { docs: Array.isArray(docs) ? docs : [] }
201
+ }
202
+
179
203
  /// One page of in-session literal search (spec 007 FR-010…FR-012).
180
204
  ///
181
205
  /// Preferred path is the Host's indexed `searchEvents`; this deployment ships it
@@ -226,7 +250,7 @@ export function createHistoryService({ logger = console } = {}) {
226
250
  return {
227
251
  ok: true,
228
252
  value: {
229
- hits: indexed,
253
+ hits: trimHitsForWire(indexed),
230
254
  cursor: page.hasMore === true && page.cursor !== undefined ? String(page.cursor) : null,
231
255
  hasMore: page.hasMore === true,
232
256
  source: 'index',
@@ -256,6 +280,13 @@ export function createHistoryService({ logger = console } = {}) {
256
280
  // far more often than about something old.
257
281
  for (let index = docs.length - 1; index >= 0; index -= 1) {
258
282
  const doc = docs[index]
283
+ // **已知局限**:这里只能按文档判角色,而宿主的 `filterEvents` 文档不带 `data.source`,
284
+ // 所以 `roles:['me']` 会把 `source.kind = "agent-instructions"` 这类**注入消息**也算进来
285
+ // (真机实测:AGENTS.md 注入那条被判成 me)。窗口路径(`_link/sessionWindow`)已改成
286
+ // 取回原始事件后复判,这里没有原始事件、逐条回读的代价又太大(50 条命中 ≈ 11 秒),
287
+ // 因此保留现状并在 `session-search` 的说明里标注:**角色过滤对注入消息不可靠**。
288
+ // App 侧「只看我的提问」自 T040 起走本地库,不走这条路;要恢复这条能力需先让宿主
289
+ // 在文档里带上 `data.source`。
259
290
  const role = roleOf(doc)
260
291
  if (roles !== undefined && (role === undefined || !roles.has(role))) continue
261
292
  const display = displayRoleOf(doc)
@@ -272,7 +303,7 @@ export function createHistoryService({ logger = console } = {}) {
272
303
  return {
273
304
  ok: true,
274
305
  value: {
275
- hits,
306
+ hits: trimHitsForWire(hits),
276
307
  cursor: hasMore ? String(to) : null,
277
308
  hasMore,
278
309
  scannedChunks: scanned,
@@ -316,8 +347,28 @@ export function createHistoryService({ logger = console } = {}) {
316
347
  if (inFlight.has(key)) return failure('link/busy', 'another window read is in flight for this session')
317
348
  inFlight.add(key)
318
349
  try {
319
- const scan = await scanOlder({ readChunk, sessionId, before, through, limit, maxBytes, roles })
320
- return { ok: true, value: pageFrom(scan, { limit, maxBytes, roles }) }
350
+ // 按角色过滤时走"先定位命中 seq、再定向取完整记录"(spec 016 的"来回次数"一条):
351
+ // 同样一段日志,过滤路径实测从 2.5–4.1 s 降到数百毫秒;未过滤的浏览翻页保持原样
352
+ // (它要的是精确的一段原始事件,`readEvent` 每块 51 条正合适)。
353
+ const useFilterScan = roles !== undefined && typeof sessionQuery?.filterEvents === 'function'
354
+ // 三级:① 角色过滤 + 宿主能整份读 → 从快照出(一次读、之后每页零往返);
355
+ // ② 角色过滤但快照不可用(没有 readSession / 太大 / 读失败)→ A1 的分块定位;
356
+ // ③ 不带角色 → 原来的分块扫描(浏览翻页要的是精确的一段原始事件)。
357
+ const snapshot = roles !== undefined && typeof sessionQuery?.readSession === 'function'
358
+ ? await snapshots.get(sessionId)
359
+ : undefined
360
+ const scan = snapshot !== undefined
361
+ ? scanSnapshot(snapshot, { before, through, limit, maxBytes, roles, roleOf })
362
+ : useFilterScan
363
+ ? await filteredScan({
364
+ readFilterChunk, readRecords: readChunk, newestSeq, sessionId, before, through, limit,
365
+ roles, roleOf,
366
+ })
367
+ : await scanOlder({ readChunk, sessionId, before, through, limit, maxBytes, roles })
368
+ const page = pageFrom(scan, { limit, maxBytes, roles })
369
+ // 016:下发前统一清掉只用于诊断的宿主模型输出日志(口径只有一份,
370
+ // 见 specs/016-history-load-perf/contracts/history-record-trim.md)。
371
+ return { ok: true, value: { ...page, records: trimRecordsForWire(page.records) } }
321
372
  } catch (error) {
322
373
  logger.warn?.(`mobile-link: sessionWindow failed: ${messageOf(error)}`)
323
374
  return hostFailure(error)
@@ -346,10 +397,5 @@ export function createHistoryService({ logger = console } = {}) {
346
397
  return failure('link/unknown-method', String(method))
347
398
  },
348
399
 
349
- /// Exposed for the unit tests and the (gated) measurement route.
350
- /// 只读测量(spec 007 T008/T047):实现见 `history/probe.js`。
351
- async probe(args = {}) {
352
- return probeRunner(args)
353
- },
354
400
  }
355
401
  }
package/lib/index.js CHANGED
@@ -48,6 +48,14 @@ const DEFAULTS = {
48
48
  stateFile: '',
49
49
  dshUrl: '',
50
50
  inviteCode: '',
51
+ // 生命周期流水的落盘位置:留空=默认写在身份文件旁边(`$DSH_HOME/mobile-link/
52
+ // link-events.jsonl`),给字符串=写在那里,`false`=只在内存里留一份。
53
+ journalFile: '',
54
+ // 慢请求阈值(毫秒);0 = 不记单条慢请求(每次接入的 `device.summary` 仍然写)。
55
+ slowRequestMs: 2000,
56
+ // 是否接收手机推上来的阶段记录(`_link/diagnostics`)。false = 回 diagnostics/disabled,
57
+ // 手机静默忽略——这是"记录出问题时的回退闸",关掉不影响连接。
58
+ diagnostics: true,
51
59
  heartbeatMs: 20000,
52
60
  pongTimeoutMs: 60000,
53
61
  maxBackoffMs: 30000,
@@ -96,6 +104,14 @@ function buildAgent(config, logger) {
96
104
  // otherwise the agent reads DSH_MOBILE_LINK_INVITE (see enroll.js).
97
105
  inviteCode: typeof config.inviteCode === 'string' && config.inviteCode ? config.inviteCode : undefined,
98
106
  dshUrl: typeof config.dshUrl === 'string' && config.dshUrl ? config.dshUrl : undefined,
107
+ journalPath: config.journalFile === false
108
+ ? false
109
+ : (typeof config.journalFile === 'string' && config.journalFile ? config.journalFile : undefined),
110
+ // 注意 0 是**有效值**(关闭逐条慢请求),所以不能用 `||` 兜底。
111
+ slowRequestMs: config.slowRequestMs === undefined
112
+ ? DEFAULTS.slowRequestMs
113
+ : Number(config.slowRequestMs) || 0,
114
+ diagnostics: config.diagnostics !== false,
99
115
  heartbeatMs: Number(config.heartbeatMs) || DEFAULTS.heartbeatMs,
100
116
  pongTimeoutMs: Number(config.pongTimeoutMs) || DEFAULTS.pongTimeoutMs,
101
117
  maxBackoffMs: Number(config.maxBackoffMs) || DEFAULTS.maxBackoffMs,
package/lib/journal.js ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * 链路与设备的生命周期流水。
3
+ *
4
+ * 为什么需要它(2026-10-12/13 那次"手机连不上"的教训):连接器的日志走宿主
5
+ * `ctx.logger`,而宿主既不落盘也不保留历史,于是"连接器为什么把这条中转链路拆了"
6
+ * 只能靠中转侧日志反推——`lastError` 只留最后一条,中间发生过几次拆链、每次什么原因,
7
+ * 事后一概查不到。这一族事件就是补这个洞。
8
+ *
9
+ * 两条出口,用途不同:
10
+ * * **内存环**(定长,默认 200 条):随 `/mobile-link/status` 一起读,正在跑的时候看;
11
+ * * **JSONL 文件**(可选,默认写在身份文件旁边,超过上限滚动一次):进程重启后还在,
12
+ * 用于事后复盘——重连风暴的现场往往在重启之后就没了。
13
+ *
14
+ * 硬约束:**这里任何一步都不许抛**。它是诊断设施,不能因为磁盘满、权限错、
15
+ * 路径怪就把链路带崩;写失败只记一条 `journal.error` 然后闭嘴。
16
+ */
17
+
18
+ import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
19
+ import { dirname, join } from 'node:path'
20
+
21
+ import { defaultStatePath } from './state.js'
22
+
23
+ /** 内存环里保留多少条。 */
24
+ export const DEFAULT_LIMIT = 200
25
+ /** 单个 JSONL 文件的上限;超了就滚动成 `<path>.1`,所以磁盘占用上限约两倍。 */
26
+ export const DEFAULT_MAX_BYTES = 256 * 1024
27
+ /** 单条事件序列化后的上限;见 `#shrink` 的两步处理。 */
28
+ export const MAX_EVENT_BYTES = 2048
29
+
30
+ /**
31
+ * 事件目录(`kind` 词表)——**与契约逐字一致**,由 `npm run check:contracts` 机械核对。
32
+ *
33
+ * 契约:`docs/diagnostics-events.md`(§2 既有 + §3 新增 + 内部)。
34
+ * 为什么把词表放进代码:写入侧需要一个能自检的集合(防止手滑写错 kind 后没人发现),
35
+ * 而插件是独立发布的包,不能运行时去读仓库里的 spec 文件。
36
+ */
37
+ export const EVENT_KINDS = Object.freeze([
38
+ // §2 既有(本次之前就有,语义不变)
39
+ 'link.start',
40
+ 'link.open',
41
+ 'link.close',
42
+ 'link.backoff',
43
+ 'link.failed',
44
+ 'link.unregistered',
45
+ 'link.stop',
46
+ 'watchdog.repull',
47
+ 'watchdog.drop',
48
+ 'dsh.ok',
49
+ 'dsh.unreachable',
50
+ 'device.attach',
51
+ 'device.reattach',
52
+ 'device.attach-again',
53
+ 'device.detach',
54
+ 'device.revoked',
55
+ // §3 本次新增
56
+ 'device.phase',
57
+ 'request.slow',
58
+ 'device.summary',
59
+ 'app.dial',
60
+ 'app.hello',
61
+ 'app.first-request',
62
+ 'app.first-response',
63
+ 'app.request',
64
+ 'app.first-screen',
65
+ 'app.first-render',
66
+ 'app.scene',
67
+ 'app.push',
68
+ // 内部:写失败与超大条目自报(不在契约的对外表里,但同属写入侧词表)
69
+ 'journal.error',
70
+ ])
71
+
72
+ /** 信封字段:detail 里的同名键一律忽略(否则会把时刻/事件名冲掉)。 */
73
+ const PROTECTED_KEYS = new Set(['at', 'ms', 'kind'])
74
+
75
+ /** 单条事件里字符串值的最长保留长度(`#shrink` 用)。 */
76
+ const MAX_STRING_CHARS = 200
77
+
78
+
79
+ /** 默认落在身份文件旁边(`$DSH_HOME/mobile-link/`),测试用临时 DSH_HOME 时会自动隔离。 */
80
+ export function defaultJournalPath() {
81
+ return join(dirname(defaultStatePath()), 'link-events.jsonl')
82
+ }
83
+
84
+ /**
85
+ * 把一条事件序列化成一行 JSON。
86
+ *
87
+ * 同时给 `at`(给人看的 ISO 8601)与 `ms`(给脚本比对的整数),因为这两种读者都存在:
88
+ * 人 grep 时想直接看到时刻,脚本算间隔时不想解析日期。
89
+ */
90
+ function toLine(entry) {
91
+ try {
92
+ return `${JSON.stringify(entry)}\n`
93
+ } catch {
94
+ // 循环引用之类的 detail 不该让整条事件丢掉:退化成只留 kind。
95
+ return `${JSON.stringify({ at: entry.at, ms: entry.ms, kind: entry.kind })}\n`
96
+ }
97
+ }
98
+
99
+ export class LinkJournal {
100
+ /**
101
+ * @param {object} [options]
102
+ * @param {string} [options.path] JSONL 路径;不传=只留内存,不碰磁盘
103
+ * @param {number} [options.limit] 内存环容量
104
+ * @param {number} [options.maxBytes] 单个文件上限
105
+ * @param {() => number} [options.now] 取时刻(测试注入)
106
+ * @param {{ debug?: (message: string) => void }} [options.logger]
107
+ */
108
+ constructor({
109
+ path, limit = DEFAULT_LIMIT, maxBytes = DEFAULT_MAX_BYTES,
110
+ now = Date.now, logger,
111
+ } = {}) {
112
+ this._path = typeof path === 'string' && path.trim() ? path : undefined
113
+ this.maxBytes = Math.max(1024, Number(maxBytes) || DEFAULT_MAX_BYTES)
114
+ this.limit = Math.max(1, Number(limit) || DEFAULT_LIMIT)
115
+ this.now = now
116
+ this.logger = logger
117
+ /** @type {Array<Record<string, unknown>>} */
118
+ this._entries = []
119
+ /** 因为环满而被挤掉的条数:读写两侧都不是无损的,得让读者知道。 */
120
+ this.dropped = 0
121
+ /** 落盘写坏了几次(第一次就停手,避免刷屏)。 */
122
+ this.fileErrors = 0
123
+ this._bytes = undefined
124
+ this._fileBroken = false
125
+ /** 写入过的总条数(含被挤掉的)。 */
126
+ this.recorded = 0
127
+ }
128
+
129
+ /** 落盘路径;`null` 表示这份流水只在内存里。 */
130
+ get path() {
131
+ return this._path ?? null
132
+ }
133
+
134
+ /**
135
+ * 记一条。**永不抛**。
136
+ *
137
+ * @param {string} kind 事件名(`link.open` / `device.detach` …),未知取值允许写入
138
+ * (老读者必须忽略不认识的 kind),但单测会盯住目录漂移
139
+ * @param {Record<string, unknown>} [detail] 该事件自带的字段(`undefined` 一律丢弃)
140
+ * @returns {Record<string, unknown>} 写进去的那条(测试要断言,所以返回)
141
+ */
142
+ record(kind, detail) {
143
+ const entry = this.#build(kind, detail)
144
+ const line = toLine(entry)
145
+ // 两条分支必须返回同一形状:小的直接过,大的走两步降级。
146
+ const sized = Buffer.byteLength(line) <= MAX_EVENT_BYTES ? { entry, line } : this.#shrink(entry)
147
+ this.#push(sized.entry)
148
+ this.#append(sized.line)
149
+ return sized.entry
150
+ }
151
+
152
+ /**
153
+ * 组装条目:信封 + 过滤掉 `undefined` 的 detail。
154
+ *
155
+ * **信封字段受保护**:`at`/`ms`/`kind` 不会被 detail 覆盖。这条是 2026-10-13 踩出来的——
156
+ * 当时 `request.slow`/`app.hello` 的 detail 里也有一个 `ms`(含义是**耗时**),
157
+ * 摊平后把事件的**时刻**冲掉了,时间线上那条事件会跑到 1970 年。耗时一律叫 `elapsedMs`。
158
+ */
159
+ #build(kind, detail) {
160
+ const ms = this.now()
161
+ const entry = { at: new Date(ms).toISOString(), ms, kind }
162
+ if (detail && typeof detail === 'object') {
163
+ for (const [key, value] of Object.entries(detail)) {
164
+ if (value === undefined || PROTECTED_KEYS.has(key)) continue
165
+ entry[key] = value
166
+ }
167
+ }
168
+ return entry
169
+ }
170
+
171
+ /**
172
+ * 记一条**别人钟上**的事件(App 推上来的那些):时刻用给定的值,不用本机时钟。
173
+ *
174
+ * 单独一个入口而不是"让 detail 覆盖 ms":覆盖是隐式的,谁都能不小心改掉时刻;
175
+ * 这里只有显式调用才会保留外部时刻。
176
+ */
177
+ recordForeign(kind, { at, ms, ...detail } = {}) {
178
+ const stamp = Number.isFinite(ms) ? ms : this.now()
179
+ const entry = { at: typeof at === 'string' && at ? at : new Date(stamp).toISOString(), ms: stamp, kind }
180
+ for (const [key, value] of Object.entries(detail)) {
181
+ if (value === undefined || PROTECTED_KEYS.has(key)) continue
182
+ entry[key] = value
183
+ }
184
+ const line = toLine(entry)
185
+ const sized = Buffer.byteLength(line) <= MAX_EVENT_BYTES ? { entry, line } : this.#shrink(entry)
186
+ this.#push(sized.entry)
187
+ this.#append(sized.line)
188
+ return sized.entry
189
+ }
190
+
191
+ /** 记进内存环(磁盘那半由 `record` 负责)。 */
192
+ #push(entry) {
193
+ this._entries.push(entry)
194
+ this.recorded += 1
195
+ while (this._entries.length > this.limit) {
196
+ this._entries.shift()
197
+ this.dropped += 1
198
+ }
199
+ }
200
+
201
+ /**
202
+ * 超大条目的两步降级:先把长字符串截短(保住这一条),仍超限则丢掉它并自报一条 `journal.error`。
203
+ *
204
+ * 为什么不在写入侧直接抛/直接丢:诊断设施最怕"因为自己太胖把现场弄没了"。
205
+ * 截断会留 `truncated: true`,读者知道这条不完整;真的截不动(例如深嵌套对象)才丢,且丢也要留痕。
206
+ */
207
+ #shrink(entry) {
208
+ const shortened = { ...entry }
209
+ for (const [key, value] of Object.entries(shortened)) {
210
+ if (typeof value === 'string' && value.length > MAX_STRING_CHARS) {
211
+ shortened[key] = `${value.slice(0, MAX_STRING_CHARS)}…`
212
+ }
213
+ }
214
+ shortened.truncated = true
215
+ const line = toLine(shortened)
216
+ if (Buffer.byteLength(line) <= MAX_EVENT_BYTES) return { entry: shortened, line }
217
+ const fallback = this.#build('journal.error', {
218
+ // 字段名不能叫 `kind`:`#build` 会把 detail 的键盖到信封上,那样这条自报就冒充成原事件了。
219
+ originalKind: entry.kind,
220
+ bytes: Buffer.byteLength(toLine(entry)),
221
+ reason: 'event too large even after truncation',
222
+ })
223
+ return { entry: fallback, line: toLine(fallback) }
224
+ }
225
+
226
+ /** 最近的 `count` 条,最旧在前。 */
227
+ recent(count = this.limit) {
228
+ const size = Math.max(0, Number(count) || 0)
229
+ return this._entries.slice(Math.max(0, this._entries.length - size))
230
+ }
231
+
232
+ /** 内存环里现有的全部条目(副本)。 */
233
+ entries() {
234
+ return this._entries.slice()
235
+ }
236
+
237
+ #append(line) {
238
+ if (!this._path || this._fileBroken) return
239
+ try {
240
+ if (this._bytes === undefined) {
241
+ mkdirSync(dirname(this._path), { recursive: true, mode: 0o700 })
242
+ this._bytes = this.#sizeOf(this._path)
243
+ }
244
+ const bytes = Buffer.byteLength(line)
245
+ if (this._bytes > 0 && this._bytes + bytes > this.maxBytes) this.#rotate()
246
+ appendFileSync(this._path, line, { mode: 0o600 })
247
+ this._bytes += bytes
248
+ } catch (error) {
249
+ this.fileErrors += 1
250
+ this._fileBroken = true
251
+ this.logger?.debug?.(`mobile-link: link journal disabled (${error?.message ?? error})`)
252
+ }
253
+ }
254
+
255
+ #sizeOf(path) {
256
+ try {
257
+ return statSync(path).size
258
+ } catch {
259
+ return 0
260
+ }
261
+ }
262
+
263
+ /** 滚一次:现文件改名成 `<path>.1`,旧的那份直接丢掉(只保两代)。 */
264
+ #rotate() {
265
+ const archived = `${this._path}.1`
266
+ try {
267
+ renameSync(this._path, archived)
268
+ } catch {
269
+ // Windows 上改名到已存在的目标会失败;删掉目标再试一次。
270
+ try {
271
+ unlinkSync(archived)
272
+ renameSync(this._path, archived)
273
+ } catch (error) {
274
+ throw error
275
+ }
276
+ }
277
+ this._bytes = 0
278
+ }
279
+ }