dsh-mobile-plugin 0.1.92 → 0.1.94

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.
Files changed (38) hide show
  1. package/README.md +3 -1
  2. package/config/agent-presets/standard/agent.cordis.yml +7 -4
  3. package/lib/desktop-view.cjs +20 -0
  4. package/lib/impl.js +7 -2
  5. package/lib/mirror/pusher.cjs +40 -0
  6. package/lib/mobile/archive-clean.js +17 -18
  7. package/lib/mobile/attachments.js +23 -6
  8. package/lib/mobile/events.js +28 -3
  9. package/lib/mobile/history-derive.js +53 -7
  10. package/lib/mobile/host-bridge/endpoints.js +0 -1
  11. package/lib/mobile/host-bridge/event-synthesis.js +33 -3
  12. package/lib/mobile/host-bridge/shims.js +28 -4
  13. package/lib/mobile/monitor.js +135 -0
  14. package/lib/mobile/session-flags.js +63 -0
  15. package/lib/mobile/session-models.js +6 -0
  16. package/lib/mobile/session-preview.js +70 -0
  17. package/lib/mobile/session-send.js +8 -2
  18. package/lib/mobile/session-snapshot.js +13 -19
  19. package/lib/mobile/sessions.js +48 -91
  20. package/lib/mobile/stream.js +113 -16
  21. package/lib/mobile/uploads.js +14 -7
  22. package/lib/mobile/workspaces.js +17 -3
  23. package/lib/panel/index.html +7 -7
  24. package/lib/reconnect-scheduler.cjs +31 -4
  25. package/lib/relay/lan-network.cjs +156 -12
  26. package/lib/relay/lan-proxy.js +85 -7
  27. package/lib/relay/protocol.js +3 -3
  28. package/lib/relay/tunnel.js +17 -4
  29. package/lib/routes/control-plane.js +11 -5
  30. package/lib/routes/mobile-api.js +22 -1
  31. package/lib/session-cleanup.cjs +160 -27
  32. package/lib/shared/desktop-view-signal.js +66 -0
  33. package/lib/shared/http-util.js +50 -4
  34. package/lib/shared/list-revision.js +20 -1
  35. package/lib/shared/sse-write.js +52 -2
  36. package/lib/sidecar-facade-worker.cjs +16 -2
  37. package/lib/update-install.cjs +71 -13
  38. package/package.json +2 -2
@@ -4,7 +4,13 @@
4
4
  * dsh-mobile 流式:SSE 转发目标会话的增量事件。
5
5
  *
6
6
  * 订阅宿主事件流,仅转发目标会话的 session/event 帧(增量事件),
7
- * 每 15s 发一行注释保持连接;客户端断开即取消订阅。
7
+ * 每 15s 发一行注释 + 一帧 `session/heartbeat` 数据帧;客户端断开即取消订阅。
8
+ *
9
+ * 为什么要那帧心跳:注释行只是保活,客户端一个事件都收不到。而「流被正常收尾」
10
+ * (中转重启、隧道掉了、宿主自己结束响应)在 App 那边看起来和「这条流还开着」一模一样
11
+ * —— react-native-sse 在 readyState 走到 DONE 时既不派发 error 也不派发 close。所以
12
+ * App 需要一个**自己数得到**的活口,两拍没到就主动断开重连(见 app 侧
13
+ * services/viewmodels/stream-liveness.ts)。这帧不带 seq、不进消息模型、不写缓存。
8
14
  *
9
15
  * 断线恢复:SSE 客户端重连时携带 `Last-Event-ID: <sessionId>:<seq>`(或查询参数
10
16
  * `lastSeq=<seq>`)。服务端先在 history 中回放 `seq > lastSeq` 的事件(收敛状态),
@@ -32,11 +38,30 @@ const approvals = require('./approvals.js');
32
38
  const questions = require('./questions.js');
33
39
 
34
40
  // Android React Native's XHR may buffer tiny chunked responses until its
35
- // internal progress threshold. Pad each SSE record so onprogress exposes it
36
- // promptly; the padding is an SSE comment and is ignored by clients.
41
+ // internal progress threshold. Keep each window of records padded past that
42
+ // threshold so onprogress exposes them promptly; the padding is an SSE comment
43
+ // and is ignored by clients.
37
44
  const SSE_FLUSH_PADDING_BYTES = 2048;
45
+ /**
46
+ * 草稿增量的补齐窗口。
47
+ *
48
+ * **只对 `assistant/chunk` 生效**:一次流式回复每秒有几十条增量,每条都补 2048 字节
49
+ * 等于把正文放大十几倍,而这些字节两条腿(宿主→中转、中转→手机)都是实打实要走的。
50
+ * 按 120ms 一个窗口补齐,界面出字节奏不变(客户端本来就攒够一个阈值才渲染一次)。
51
+ * 带 seq 的事件、审批卡、排队/任务帧、心跳照旧补满整份 —— 它们慢一拍就是「消息不落地」
52
+ * 「卡不出现」。实现见 lib/shared/sse-write.js 的 takePadding。
53
+ */
54
+ const SSE_PAD_MIN_INTERVAL_MS = 120;
38
55
  const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
39
56
 
57
+ /**
58
+ * keepalive 节奏,同时也是 `session/heartbeat` 的间隔。
59
+ *
60
+ * 必须与 App 的 `STREAM_HEARTBEAT_MS` 一致(`app/src/services/viewmodels/stream-liveness.ts`,
61
+ * 由 `test/stream-liveness.test.cjs` 的跨仓断言钉着)。
62
+ */
63
+ const HEARTBEAT_MS = 15_000;
64
+
40
65
  /**
41
66
  * 可回答的审批请求帧类型。宿主 apiProxy 的 `requestedFrame()` 发的是 mux 帧
42
67
  * `approval/requested`(`rpcId` 在帧级、`approvalId` 在 payload);会话事件
@@ -61,26 +86,56 @@ function logStream(sessionId, message, error) {
61
86
  } catch { /* ignore */ }
62
87
  }
63
88
 
89
+ /**
90
+ * 这一帧是不是「流式回复里的草稿增量」——唯一可以按窗口补齐的那一类。
91
+ *
92
+ * 判据与丢帧口径同源:没有 seq 的 `assistant/chunk` 是草稿,同一轮收尾的
93
+ * `assistant/message` 带完整正文、重连回放也能补齐;所以把它按窗口补齐最坏只是慢一拍,
94
+ * 而其它帧慢一拍就可能表现为「消息不落地」「审批卡不出现」。
95
+ */
96
+ function isStreamDraft(payload) {
97
+ if (!payload) return false;
98
+ // 实时帧写的是内层事件(`{type:'assistant/chunk', data}`),回放路径偶尔仍带一层
99
+ // `session/event` 外壳,两种形状都认。
100
+ const type = payload.type === 'session/event' && payload.event ? payload.event.type : payload.type;
101
+ return type === 'assistant/chunk';
102
+ }
103
+
64
104
  /**
65
105
  * 写一条 SSE 记录(策略声明,实现见 `lib/shared/sse-write.js`)。
66
106
  *
67
107
  * 这条流的策略:软水位只丢**没有 seq** 的增量(`assistant/chunk` 是可丢弃草稿,同一轮
68
- * 收尾的 `assistant/message` 带完整正文且有 seq,重连回放能补齐);每条记录补 2048 字节
69
- * 注释,让 RN 的 XHR 尽快吐出来。
108
+ * 收尾的 `assistant/message` 带完整正文且有 seq,重连回放能补齐);草稿增量按 120ms
109
+ * 一个窗口补到 2048 字节以上,**成批灌入的回放**同样按窗口补齐,其余帧补满整份,
110
+ * 让 RN 的 XHR 尽快吐出来(见 `SSE_PAD_MIN_INTERVAL_MS`)。
111
+ *
112
+ * `options.burst` = 成批灌入(断线回放)。回放是几百到几千条连着写出去的:客户端本来
113
+ * 就在持续收字节、进度事件照常触发,而逐条补满分摊到一次重连就是几 MB 白字节(实测一次
114
+ * 903 帧的回放共 2.06MB,其中约 1.85MB 是注释)。窗口机制照样保证「每 120ms 至少一窗
115
+ * 2048 字节」,而且这条流的**第一条**记录总是落在空窗口上、拿到整份填充,所以「重连后
116
+ * 界面立刻出内容」不受影响。实时流里那条「带 seq 的帧补满整份」的理由(它可能夹在一串
117
+ * 慢草稿后面单独到达,慢一拍就是「消息不落地」)在成批灌入里不成立,别把它抄到回放上。
70
118
  *
119
+ * @param {{burst?: boolean}} [options] 成批灌入(回放)标记,见上。
71
120
  * @returns {{ok: boolean, shed?: boolean, needDrain?: boolean}}
72
121
  * `ok:false` → 这条流该收尾(客户端走了,或积压超过硬水位);
73
122
  * `shed:true` → 这条**增量**帧被丢掉、连接还活着;
74
123
  * `needDrain:true` → 写成功了,但底下已经写不动,调用方要先 await 一次 drain 再继续。
75
124
  */
76
- function writeSse(res, payload, frame) {
125
+ function writeSse(res, payload, frame, options = {}) {
126
+ // 两条路走窗口补齐:可丢弃的草稿增量,与成批灌入的回放(见上面 options.burst)。
127
+ // 其余帧一律补满整份 —— 带 seq 的事件、审批卡、排队/任务帧、心跳是一次回复里真正
128
+ // 要落地的东西,宁可多花 2KB 也不让它们卡在客户端的缓冲里等下一次写入。
77
129
  return sharedWriteSse(res, payload, {
78
130
  frame,
79
- padding: ssePadding(SSE_FLUSH_PADDING_BYTES),
131
+ ...(options.burst === true || isStreamDraft(payload)
132
+ ? { paddingBytes: SSE_FLUSH_PADDING_BYTES, padEveryMs: SSE_PAD_MIN_INTERVAL_MS }
133
+ : { padding: ssePadding(SSE_FLUSH_PADDING_BYTES) }),
80
134
  // 审批帧没有 seq,按默认口径会在软水位上被丢掉(见上面那条策略)。但它是「人工决策
81
135
  // 的入口」不是草稿:丢一张,手机就再也看不到那张卡(软水位只在客户端跟不上时到,
82
136
  // 回放也不覆盖这一段)。它一次审批也就一两帧,不是撑爆写队列的那类帧。
83
- shedWhenBuffered: (value) => !isApprovalPayload(value) && shedWithoutSeq(value),
137
+ // 心跳同理必须保住:丢了它,App 会把一条健康的流判成死链(见 isLivenessHeartbeat)。
138
+ shedWhenBuffered: (value) => !isApprovalPayload(value) && !isLivenessHeartbeat(value) && shedWithoutSeq(value),
84
139
  });
85
140
  }
86
141
 
@@ -94,6 +149,19 @@ function isApprovalPayload(payload) {
94
149
  || type === 'question/requested' || type === 'question/resolved';
95
150
  }
96
151
 
152
+ /**
153
+ * 这一帧是「App 用来数活口」的心跳,不是聊天内容。
154
+ *
155
+ * 它走和正文一样的 writeSse(所以同样受背压闸门管),但**必须**在软水位上被保住:
156
+ * 心跳被丢掉就等于这条流没说话,App 会在 35s 后把一条完全健康的连接判死并重连 ——
157
+ * 那是比「丢一张草稿」贵得多的代价。它一帧一百多字节,不是撑爆写队列的那类帧。
158
+ *
159
+ * 它不带 seq,所以**绝不能**推进 `lastDeliveredSeq`(那会让回放跳过一个真事件)。
160
+ */
161
+ function isLivenessHeartbeat(payload) {
162
+ return Boolean(payload) && payload.type === 'session/heartbeat';
163
+ }
164
+
97
165
  /**
98
166
  * 解析断线续传位置:
99
167
  * - `Last-Event-ID: <sessionId>:<seq>`(标准 SSE 头);
@@ -127,6 +195,13 @@ async function handleStream(ctx, req, res) {
127
195
  const url = new URL(req.url, 'http://x');
128
196
  const sessionId = url.searchParams.get('sessionId');
129
197
  if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
198
+ // 子会话地址:App 打开子会话的流时带上父会话与模式(见 app/src/sse.ts 的
199
+ // openSessionStream 传参)。缺这两个字段就是普通会话,与历史那条同一套判定。
200
+ const parentSessionId = url.searchParams.get('parentSessionId');
201
+ const mode = url.searchParams.get('mode');
202
+ const streamAddress = typeof parentSessionId === 'string' && parentSessionId
203
+ ? { sessionId, parentSessionId, ...(mode === 'one-shot' || mode === 'continuable' ? { mode } : {}) }
204
+ : { sessionId };
130
205
  try {
131
206
  res.writeHead(200, {
132
207
  'content-type': 'text/event-stream; charset=utf-8',
@@ -152,8 +227,18 @@ async function handleStream(ctx, req, res) {
152
227
  clearInterval(keepAlive);
153
228
  ac.abort();
154
229
  try { res.end(); } catch { /* ignore */ }
230
+ return;
231
+ }
232
+ // 注释行客户端看不见,所以每一拍再补一帧数据心跳:App 靠「最近真的收到过帧」判断
233
+ // 这条流还在说话(见 app 侧 services/viewmodels/stream-liveness.ts)。写不出去同样
234
+ // 说明客户端已经不在,按上面那条路径收尾。
235
+ if (!writeSse(res, { type: 'session/heartbeat', sessionId, at: Date.now() }).ok) {
236
+ logStream(sessionId, 'heartbeat write failed', null);
237
+ clearInterval(keepAlive);
238
+ ac.abort();
239
+ try { res.end(); } catch { /* ignore */ }
155
240
  }
156
- }, 15_000);
241
+ }, HEARTBEAT_MS);
157
242
  // 定时器不该拖住进程退出:宿主/worker 关停时若这条 SSE 还挂着,事件循环会被它按在
158
243
  // 15s 一拍上,收尾要等到下一次 tick 才发生(lib/sidecar-facade-worker.cjs 的
159
244
  // shutdown 就是这么做的)。测试里的 setInterval 桩没有 unref,所以先判类型。
@@ -180,7 +265,7 @@ async function handleStream(ctx, req, res) {
180
265
  try {
181
266
  // 断线续传:先回放 history 中 seq > resumeSeq 的事件(收敛状态)。
182
267
  if (resumeSeq != null && resumeSeq > 0) {
183
- const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); });
268
+ const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); }, streamAddress);
184
269
  if (replay.gap && !ac.signal.aborted) {
185
270
  const value = { type: 'session/resync-required', sessionId, resumeSeq, oldestSeq: replay.oldestSeq };
186
271
  // 旧实现在这里直接 `return`:既不清 keepAlive 也不 end,漏出一条永不收尾的连接。
@@ -192,7 +277,10 @@ async function handleStream(ctx, req, res) {
192
277
  // payload 里带 sessionId:0.2.0 的宿主没有设备级事件流了,兼容桥据此收窄成
193
278
  // 这一条会话的 follow + 投影 + 任务三条流(见 mobile/host-bridge.js 的 muxFrames)。
194
279
  // 不带就是设备级视角,只会给工作区基线与全局投影 —— 这里反正只要单会话。
195
- const frames = ctx.apiProxy.events.mux({ rpcId: 'mobile-stream', payload: { sessionId } }, ac.signal);
280
+ // 子会话必须带上父会话(`parentSessionId` + `mode`):宿主对 origin==='subagent'
281
+ // 的会话拿普通地址订阅一律回 agent-busy,表现是「页面打得开、内容不更新」。
282
+ // 地址的拼装与历史那条同源,见 host-bridge/event-synthesis.js 的 sessionAddress。
283
+ const frames = ctx.apiProxy.events.mux({ rpcId: 'mobile-stream', payload: streamAddress }, ac.signal);
196
284
  // 写一帧并把三种结果统一收口:丢增量、需要等 drain、该收尾。
197
285
  // 「需要等 drain」这一步是这次 OOM 的正解 —— 写不动就停手,等底层消化完再继续,
198
286
  // 而不是把帧继续往内存里塞。
@@ -214,7 +302,8 @@ async function handleStream(ctx, req, res) {
214
302
  const seq = p.event && Number.isFinite(p.event.seq) ? p.event.seq : null;
215
303
  // 回放已覆盖该 seq(回放与实时交界处)→ 跳过,避免重复交付。
216
304
  if (seq !== null && seq <= lastDeliveredSeq) continue;
217
- const event = await resolveEventAttachments(ctx, sessionId, p.event);
305
+ // 引用模式:图片只下发 attachmentId,不取字节(见 attachments.js)。
306
+ const event = await resolveEventAttachments(ctx, sessionId, p.event, { referencesOnly: true });
218
307
  // 被 shed 的只可能是没有 seq 的增量(见 writeSse 的软水位闸);它不推进水位,
219
308
  // 同一轮收尾的 assistant/message 会把完整正文补上。
220
309
  const result = await push(event, { ...frame, payload: { ...p, event } });
@@ -273,9 +362,15 @@ async function handleStream(ctx, req, res) {
273
362
  * 从会话 history 回放 seq > sinceSeq 的事件(升序),逐个经附件解析后写 SSE。
274
363
  * 通过 onDelivered(seq) 回报已交付的最大 seq,供上层去重。
275
364
  */
276
- async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered) {
365
+ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, address) {
277
366
  try {
278
- const { value } = unwrap(await callSessions(ctx, 'history', { sessionId, maxMessages: REPLAY_WINDOW }));
367
+ // 子会话的重放也要带父会话(同 historyAddress 的口径),否则宿主回 agent-busy,
368
+ // 这条断线续传静默退化成「后面缺一段」。
369
+ const { value } = unwrap(await callSessions(ctx, 'history', {
370
+ sessionId,
371
+ maxMessages: REPLAY_WINDOW,
372
+ ...(address && address.parentSessionId ? { parentSessionId: address.parentSessionId, mode: address.mode || 'continuable' } : {}),
373
+ }));
279
374
  const events = (value && Array.isArray(value.events)) ? value.events : [];
280
375
  const seqs = events
281
376
  .map((entry) => (entry?.event || entry)?.seq)
@@ -293,11 +388,13 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered) {
293
388
  for (const entry of candidates) {
294
389
  if (signal.aborted) break;
295
390
  const e = entry?.event || entry;
296
- const event = await resolveEventAttachments(ctx, sessionId, e);
391
+ const event = await resolveEventAttachments(ctx, sessionId, e, { referencesOnly: true });
297
392
  // Keep replay data identical to live SSE data: the client reducer consumes
298
393
  // the event's own type (turn/start, assistant/message, etc.).
299
394
  const payload = { type: 'session/event', sessionId, event };
300
- const sent = writeSse(res, event, { payload });
395
+ // 成批灌入:这一节几百条连着写,逐条补满 2048 字节注释等于给一次重连凭空加几 MB
396
+ // (见 writeSse 的 options.burst)。
397
+ const sent = writeSse(res, event, { payload }, { burst: true });
301
398
  if (!sent.ok) break;
302
399
  // 回放是「一次性灌几千条」,最容易把写队列撑爆:写不动就在这里等,别一口气塞完。
303
400
  if (sent.needDrain && !(await waitForDrain(res, signal))) break;
@@ -9,13 +9,13 @@
9
9
  * 为什么分片,而不是一次 POST 传完:
10
10
  * - 一次传完会把「能传多大的文件」变成一条运维配置 —— 中转的
11
11
  * `MAX_BODY_BYTES`(dsh-mobile-server/lib/config.js)默认 50MB,插件自己的
12
- * readJsonBody 默认只有 256KB。分片后单请求只有 1MB,与这些上限无关。
12
+ * readJsonBody 默认只有 256KB。分片后单请求最大只有 2MB,与这些上限无关。
13
13
  * - 手机侧网络不稳,分片能只重传坏掉的那一片,而不是整个文件。
14
14
  *
15
15
  * 为什么片内容走 base64 而不是原始字节:隧道协议本身支持原始字节
16
16
  * (lib/relay/protocol.js 的 httpBody 帧),但 App 侧没有任何「发二进制 body」的
17
17
  * 先例,RN 的 fetch 对 ArrayBuffer body 的支持也随版本变;图片通道本来就以 base64
18
- * 走 JSON,这里沿用同一条确定能用的路。代价是 33% 膨胀(单片 1MB → 约 1.37MB),
18
+ * 走 JSON,这里沿用同一条确定能用的路。代价是 33% 膨胀(单片 2MB → 约 2.73MB),
19
19
  * 仍远低于任何一层上限。
20
20
  *
21
21
  * 顺序与幂等:chunk 只接受「下一片」,重复送达同一片按重传处理(回当前 received,
@@ -32,11 +32,16 @@ const crypto = require('node:crypto');
32
32
 
33
33
  const { sendJson, readJsonBody } = require('../shared/http-util');
34
34
 
35
- // 片大小 256KB(base64 后约 341KB):客户端一次请求的硬预算是 8s(`app/src/api/core.ts`
36
- // 的 REQUEST_TIMEOUT_MS),1MB 片在慢速上行网络上会撞超时;片越小越稳,代价只是多几次往返。
35
+ // 片大小的**起步值** 256KB(base64 后约 341KB):客户端一次请求的硬预算是 8s
36
+ // (`app/src/api/core.ts` 的 REQUEST_TIMEOUT_MS),一上来就给大片的机器会在慢速上行
37
+ // 网络上撞超时。客户端从这一档起步、按实测周期自己往上爬,爬到 `CHUNK_MAX_BYTES` 为止 ——
38
+ // 局域网里能一路放大,公网上爬两下就停住。所以 begin 回的是「起步值 + 上限」两个数,
39
+ // 不是「就传这么大」。
37
40
  const CHUNK_BYTES = 256 * 1024;
38
- /** 单片请求体上限:1MB base64 后约 1.37MB,留出信封与转义余量。 */
39
- const CHUNK_MAX_BODY = 4 * 1024 * 1024;
41
+ // 片大小的上限(原始字节):base64 后约 2.73MB,仍在下面 8MB 的请求体预算内。
42
+ const CHUNK_MAX_BYTES = 2 * 1024 * 1024;
43
+ /** 单片请求体上限:4MB base64 后约 5.33MB,留出信封与转义余量。 */
44
+ const CHUNK_MAX_BODY = 8 * 1024 * 1024;
40
45
  /** 文件名上限(按字符算,避开各文件系统的字节上限差异)。 */
41
46
  const NAME_MAX_CHARS = 120;
42
47
  /** 暂存残片的最长寿命:手机上传中断后没人来 abort 的清理依据。 */
@@ -182,7 +187,8 @@ async function handleUploadBegin(_ctx, req, res) {
182
187
  };
183
188
  uploads.set(uploadId, job);
184
189
  scheduleUploadExpiry(uploadId, job);
185
- sendJson(res, 200, { ok: true, uploadId, chunkSize: CHUNK_BYTES });
190
+ // chunkSize 是起步值、maxChunkSize 是天花板:客户端从前者开始按实测周期往上爬。
191
+ sendJson(res, 200, { ok: true, uploadId, chunkSize: CHUNK_BYTES, maxChunkSize: CHUNK_MAX_BYTES });
186
192
  }
187
193
 
188
194
  /** POST /api/mobile/v1/upload/chunk { uploadId, index, data } → { ok, received }。 */
@@ -259,4 +265,5 @@ module.exports = {
259
265
  inboxRoot,
260
266
  sweepExpiredUploads,
261
267
  CHUNK_BYTES,
268
+ CHUNK_MAX_BYTES,
262
269
  };
@@ -15,7 +15,8 @@
15
15
  */
16
16
 
17
17
  const { sendJson, readJsonBody } = require('../shared/http-util');
18
- const { stableRevision } = require('../shared/list-revision.js');
18
+ const { isFreshRequest } = require('../shared/fresh-request');
19
+ const { stableRevision, unchangedListBody } = require('../shared/list-revision.js');
19
20
  // 会话列表缓存归 sessions.js 管。归档/移动会话会改变列表行本身,必须让那边立刻失效,
20
21
  // 否则手机端按完「归档会话」,那一行最长 10 秒还留在侧栏里,看着像没归档。
21
22
  const { invalidateSessionList } = require('./sessions.js');
@@ -80,8 +81,21 @@ async function buildWorkspacesBody(ctx) {
80
81
  };
81
82
  }
82
83
 
83
- async function handleWorkspaces(ctx, _req, res) {
84
- sendJson(res, 200, await buildWorkspacesBody(ctx));
84
+ /**
85
+ * GET /api/mobile/v1/workspaces?rev=<上次的 revision>(token)
86
+ *
87
+ * 与 `/sessions` 同一条条件刷新契约(形状见 `lib/shared/list-revision.js` 的
88
+ * `unchangedListBody`):`rev` 与最终 body 的 revision **恰好相等**时只回一句
89
+ * `{ok, unchanged:true, revision, generatedAt}`,不带 items。这条路由没有响应缓存,
90
+ * 所以只判「这份刚构建出来的 body」。
91
+ * `?fresh=1` 是写后刷新,必须拿到真数据,一律不走这条。
92
+ */
93
+ async function handleWorkspaces(ctx, req, res) {
94
+ const url = new URL(req.url, 'http://x');
95
+ const rev = (url.searchParams.get('rev') || '').trim();
96
+ const body = await buildWorkspacesBody(ctx);
97
+ const unchanged = isFreshRequest(req.url) ? null : unchangedListBody(body, rev);
98
+ sendJson(res, 200, unchanged || body);
85
99
  }
86
100
 
87
101
  /**