dsh-mobile-plugin 0.1.93 → 0.1.95
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/lib/desktop-view.cjs +20 -0
- package/lib/impl.js +1 -1
- package/lib/mirror/pusher.cjs +40 -0
- package/lib/mobile/archive-clean.js +17 -18
- package/lib/mobile/completion.js +33 -25
- package/lib/mobile/events.js +28 -3
- package/lib/mobile/host-bridge/endpoints.js +0 -1
- package/lib/mobile/host-bridge/event-synthesis.js +33 -3
- package/lib/mobile/host-bridge/shims.js +25 -2
- package/lib/mobile/monitor.js +135 -0
- package/lib/mobile/plugin-update.js +199 -0
- package/lib/mobile/session-cache.js +18 -0
- package/lib/mobile/session-flags.js +63 -0
- package/lib/mobile/session-last-turn.js +106 -0
- package/lib/mobile/session-send.js +4 -0
- package/lib/mobile/session-snapshot.js +9 -17
- package/lib/mobile/sessions.js +16 -39
- package/lib/mobile/stream.js +69 -11
- package/lib/panel/index.html +1 -1
- package/lib/reconnect-scheduler.cjs +31 -4
- package/lib/relay/lan-proxy.js +10 -2
- package/lib/relay/protocol.js +3 -3
- package/lib/relay/tunnel.js +17 -4
- package/lib/routes/control-plane.js +15 -4
- package/lib/routes/mobile-api.js +27 -1
- package/lib/session-cleanup.cjs +160 -27
- package/lib/shared/desktop-view-signal.js +66 -0
- package/lib/shared/http-util.js +50 -4
- package/lib/shared/sse-write.js +52 -2
- package/lib/sidecar-facade-worker.cjs +16 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ curl http://127.0.0.1:3080/api/relay/pairing
|
|
|
56
56
|
|
|
57
57
|
移动 API 需要 Bearer token。设备事件流 `GET /api/mobile/v1/events/stream` 使用 SSE 转发宿主事件。创建会话时可传 `workspaceId` 或 `cwd`;前者优先,并将会话归入指定工作区。移动端排序也会同步到宿主工作区。
|
|
58
58
|
|
|
59
|
-
配对策略默认为 `code`(凭配对码放行);设为 `deny` 时拒绝所有新配对。自动归档只隐藏符合条件的闲置会话,不删除日志。`session_cleanup`
|
|
59
|
+
配对策略默认为 `code`(凭配对码放行);设为 `deny` 时拒绝所有新配对。自动归档只隐藏符合条件的闲置会话,不删除日志。`session_cleanup` 工具可归档会话,也可永久删除已归档会话;删除不可恢复,需要显式确认。删除是「排进待删队列」:会话立刻从列表里消失,日志与投影缓存等宿主下次启动时才从磁盘上删掉 —— 宿主没有「删除会话」的接口,进程活着的时候从它脚底下删文件会让它读到不存在的日志,那是未捕获异常,会带走整个宿主进程。
|
|
60
60
|
|
|
61
61
|
## 无头部署
|
|
62
62
|
|
package/lib/desktop-view.cjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
const fs = require('node:fs');
|
|
20
20
|
const path = require('node:path');
|
|
21
21
|
const paths = require('./shared/paths');
|
|
22
|
+
const { desktopViewSignal } = require('./shared/desktop-view-signal.js');
|
|
22
23
|
|
|
23
24
|
/** 单个键(workspaceId)长度上限;超长的一律丢弃(真实 id 只有十几字节)。 */
|
|
24
25
|
const MAX_KEY_LENGTH = 200;
|
|
@@ -36,6 +37,17 @@ function stateFile() {
|
|
|
36
37
|
let cached = null;
|
|
37
38
|
let loaded = false;
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* 折叠态内容指纹(键排序后序列化)。
|
|
42
|
+
*
|
|
43
|
+
* 只用来回答「这次上报真的改了折叠态吗」:两次 JSON.stringify 的键序不保证一致,
|
|
44
|
+
* 直接比会让同一份状态被认成两次变更。内容没变就既不重写文件、也不给手机推帧。
|
|
45
|
+
*/
|
|
46
|
+
function expansionFingerprint(groupExpansion) {
|
|
47
|
+
const keys = Object.keys(groupExpansion).sort();
|
|
48
|
+
return JSON.stringify(keys.map((key) => [key, groupExpansion[key]]));
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
/** 归一化上报的 groupExpansion:只保留布尔值、限长限量;形状不对返回 null。 */
|
|
40
52
|
function normalizeGroupExpansion(input) {
|
|
41
53
|
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
@@ -91,11 +103,16 @@ function update(input, now = Date.now()) {
|
|
|
91
103
|
const groupExpansion = normalizeGroupExpansion(input && input.groupExpansion);
|
|
92
104
|
if (!groupExpansion) throw new Error('groupExpansion 必须是对象');
|
|
93
105
|
const rawSource = input && (typeof input.source === 'string' ? input.source : input.storageKey);
|
|
106
|
+
const previous = load();
|
|
94
107
|
const next = {
|
|
95
108
|
groupExpansion,
|
|
96
109
|
receivedAt: now,
|
|
97
110
|
source: typeof rawSource === 'string' ? rawSource.slice(0, MAX_SOURCE_LENGTH) : '',
|
|
98
111
|
};
|
|
112
|
+
// 折叠态没变就不落盘、不广播:客户端半边在页面重载 / 切窗口时会重发同一份。
|
|
113
|
+
// 什么都没变却写一次文件、给每台手机推一帧,等于把白问的轮询换成白推的帧。
|
|
114
|
+
const changed = !previous || expansionFingerprint(previous.groupExpansion) !== expansionFingerprint(groupExpansion);
|
|
115
|
+
if (!changed) return snapshot();
|
|
99
116
|
const file = stateFile();
|
|
100
117
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
101
118
|
const tmp = `${file}.tmp`;
|
|
@@ -103,6 +120,9 @@ function update(input, now = Date.now()) {
|
|
|
103
120
|
fs.renameSync(tmp, file);
|
|
104
121
|
cached = next;
|
|
105
122
|
loaded = true;
|
|
123
|
+
// 落盘之后再广播:订阅方收到帧会立刻回来读这条路(GET /desktop-view 读的是这份
|
|
124
|
+
// cached),先通知后落盘会把它引到上一份内容上。
|
|
125
|
+
desktopViewSignal.bump(next.receivedAt);
|
|
106
126
|
return snapshot();
|
|
107
127
|
}
|
|
108
128
|
|
package/lib/impl.js
CHANGED
|
@@ -295,7 +295,7 @@ function apply(ctx) {
|
|
|
295
295
|
const { sidecarToken, syncSidecar, restartSidecar, tunnelConnected } = sidecarLifecycle
|
|
296
296
|
|
|
297
297
|
// 镜像推送调度器:会话事件立即推,后台循环每个 tick 补推一个冷会话。
|
|
298
|
-
const mirror = createMirrorPusher({ ctx, tunnelConnected, getSidecar: sidecarLifecycle.getSidecar })
|
|
298
|
+
const mirror = createMirrorPusher({ ctx, tunnelConnected, getSidecar: sidecarLifecycle.getSidecar, lastMobileAt: activity.snapshot })
|
|
299
299
|
// 隧道刚连上时补推一次全量:未连通期间每轮推送都被 worker 以 503 拒绝。
|
|
300
300
|
sidecarLifecycle.onTunnelUp(() => { void mirror.pushMirror(); })
|
|
301
301
|
|
package/lib/mirror/pusher.cjs
CHANGED
|
@@ -39,6 +39,25 @@ const { observeCompletionEvent } = require('../mobile/completion.js');
|
|
|
39
39
|
/** 后台循环每个 tick 补推一个冷会话,保证没人动的会话镜像也不长期不更新。 */
|
|
40
40
|
const HIGH_PRIORITY_WINDOW_MS = 5 * 60 * 1000;
|
|
41
41
|
const BG_TICK_INTERVAL_MS = 500;
|
|
42
|
+
/**
|
|
43
|
+
* 没人看的时候,后台 tick 降到这个节拍。
|
|
44
|
+
*
|
|
45
|
+
* 500ms 那条节奏是给「手机正在用」准备的:清单或工作区一变,手机半秒内就该看到。
|
|
46
|
+
* 但宿主一天里大部分时间没人看 —— 那时每 500ms 整轮重建一次清单/工作区,走中转的
|
|
47
|
+
* 还要把快照推过一条隧道,纯属空转。
|
|
48
|
+
*
|
|
49
|
+
* 上限由 sidecar 的快照 TTL 卡着:镜像快照 15s 过期,降频必须明显小于它,
|
|
50
|
+
* 取 5s(= TTL/3,`test/mirror-tick-budget.test.cjs` 钉着这条比例)。
|
|
51
|
+
*/
|
|
52
|
+
const IDLE_TICK_INTERVAL_MS = 5_000;
|
|
53
|
+
/**
|
|
54
|
+
* 多久没有手机请求就算「没人看」。
|
|
55
|
+
*
|
|
56
|
+
* 走中转的手机每个请求都会记一次 `mobile/activity.cjs` 的 `note()`;局域网直连的手机
|
|
57
|
+
* 不走那道守卫所以记不到,那种情况另有 `listSignal.watching()` 兜着(有人订阅清单变化时
|
|
58
|
+
* 它为真)。两个判据都不成立时才降频。
|
|
59
|
+
*/
|
|
60
|
+
const AUDIENCE_IDLE_MS = 30_000;
|
|
42
61
|
/**
|
|
43
62
|
* 冷会话轮转的最小间隔(清单/工作区都没变时才生效)。
|
|
44
63
|
*
|
|
@@ -64,6 +83,19 @@ const MAX_CONSECUTIVE_EVENT_BUILDS = 3;
|
|
|
64
83
|
|
|
65
84
|
function createMirrorPusher(deps) {
|
|
66
85
|
const { ctx, tunnelConnected, getSidecar, listSignal = sharedListSignal, sessionSignal = sharedSessionSignal } = deps;
|
|
86
|
+
/**
|
|
87
|
+
* 「手机最近来过没有」的取值器(`mobile/activity.cjs` 的 `snapshot` 形态)。
|
|
88
|
+
*
|
|
89
|
+
* 不传就按「有人在看」处理、维持原来 500ms 的节拍:降频是个优化,不是契约,
|
|
90
|
+
* 别让一处漏接线的装配把镜像悄悄降成 5s 一份。
|
|
91
|
+
*/
|
|
92
|
+
const lastMobileAt = typeof deps.lastMobileAt === 'function' ? deps.lastMobileAt : null;
|
|
93
|
+
const hasAudience = () => {
|
|
94
|
+
if (listSignal.watching()) return true;
|
|
95
|
+
if (!lastMobileAt) return true;
|
|
96
|
+
const at = Number(lastMobileAt()?.lastMobileAt) || 0;
|
|
97
|
+
return at > 0 && Date.now() - at <= AUDIENCE_IDLE_MS;
|
|
98
|
+
};
|
|
67
99
|
|
|
68
100
|
// 移动镜像推送状态(供诊断/状态面板观测:最近推送时间 + 结果)
|
|
69
101
|
let mirrorPushAt = 0
|
|
@@ -565,9 +597,17 @@ function createMirrorPusher(deps) {
|
|
|
565
597
|
let bgQueueBusy = false;
|
|
566
598
|
// 上一次真的跑过冷会话轮转的时刻(见 BG_COLD_ROTATE_MIN_INTERVAL_MS)。
|
|
567
599
|
let lastColdRotateAt = 0;
|
|
600
|
+
// 上一轮后台扫描真正跑起来的时刻(见 IDLE_TICK_INTERVAL_MS 的降频)。
|
|
601
|
+
let lastBgScanAt = 0;
|
|
568
602
|
|
|
569
603
|
const processBgQueue = async () => {
|
|
570
604
|
if (bgQueueBusy) return;
|
|
605
|
+
// 降频:没人看的时候不必每 500ms 重建一轮清单。判据用「手机最近有没有请求」,
|
|
606
|
+
// 而不是「隧道连没连上」—— 隧道连着但人没在用,一样是空转。
|
|
607
|
+
// 有人在看时不加额外下限,节奏仍由 500ms 的定时器自己决定。
|
|
608
|
+
// 事件驱动那条路径(pushSessionImmediately)不受这里影响,会话真的变了仍是立即推。
|
|
609
|
+
if (!hasAudience() && lastBgScanAt && Date.now() - lastBgScanAt < IDLE_TICK_INTERVAL_MS) return;
|
|
610
|
+
lastBgScanAt = Date.now();
|
|
571
611
|
const online = tunnelConnected();
|
|
572
612
|
// 隧道未连接时这个 500ms 的 tick 只在**有人听**的时候才有意义:构建出来的快照没有
|
|
573
613
|
// 任何去处(pushMirrorPayload 会短路),但「清单变了」这件事仍然要说给手机听 ——
|
|
@@ -1,30 +1,33 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 移动端「清理所有已归档的对话」:按宿主归档集合确定清理范围,把它们排进待删队列。
|
|
5
5
|
*
|
|
6
6
|
* 以 workspace.list 的 archivedSessionIds 为清理范围,不用可能过时的列表状态缩小范围;
|
|
7
7
|
* 请求必须显式带 confirm: true —— 磁盘删除不可恢复,手机端「少点一次」不值得把它变成滑一下就没了的动作。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* **这里不删磁盘**:只把 id 排进 session-cleanup.cjs 的待删队列,日志留到宿主下次启动时删
|
|
10
|
+
* (见那个文件的 deleteQueuedArchivedSessions)。宿主没有「删除会话」接口,进程活着的时候
|
|
11
|
+
* 它随时可能去打开那些文件 —— 文件不在就是一次未捕获的 ENOENT,宿主整个退出、桌面端闪退
|
|
12
|
+
* (2026-10-20 真机现场)。本进程只把 id 压进删除集,让手机列表立刻看不到它们。
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 正常列表——桌面端用户看到的就是「清理完它们又回来了,重启 DSH 才真的消失」。归档集合
|
|
15
|
-
* 的收尾放在宿主启动时做,见 session-cleanup.cjs 的 pruneOrphanedArchivedSessionsOnce。
|
|
14
|
+
* 也**不擦宿主的归档集合**:进程里擦会把桌面端快照里那些其实已经没有日志的行放回正常列表。
|
|
15
|
+
* 归档集合的收尾同样在宿主启动时做,见 pruneOrphanedArchivedSessionsOnce。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
19
19
|
const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
20
|
-
const { canonicalDeviceKey, rememberDeletedSessionIds
|
|
20
|
+
const { canonicalDeviceKey, rememberDeletedSessionIds } = require('./session-cache.js');
|
|
21
21
|
const { invalidateSessionList } = require('./sessions.js');
|
|
22
22
|
const { unwrap } = require('./envelope.js');
|
|
23
|
-
const {
|
|
23
|
+
const { queueArchivedClearIds } = require('../session-cleanup.cjs');
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* POST /api/mobile/v1/workspace/clear-archived(token)
|
|
27
27
|
* body: { confirm: true } → { ok: true, deleted: N }。confirm 不为 true 一律 400。
|
|
28
|
+
*
|
|
29
|
+
* deleted = 这次被排进队列、手机上立刻消失的条数(含盘上早就没有数据的那些);日志要到
|
|
30
|
+
* 宿主下次启动才真正从磁盘上消失。
|
|
28
31
|
*/
|
|
29
32
|
async function handleClearArchived(ctx, req, res) {
|
|
30
33
|
const body = await readJsonBody(req, 64 * 1024);
|
|
@@ -41,17 +44,13 @@ async function handleClearArchived(ctx, req, res) {
|
|
|
41
44
|
const archivedIds = Array.isArray(workspaceResponse.value?.archivedSessionIds) ? workspaceResponse.value.archivedSessionIds : [];
|
|
42
45
|
const selected = archivedIds.filter((id) => typeof id === 'string' && id);
|
|
43
46
|
if (selected.length === 0) return { status: 200, body: { ok: true, deleted: 0 } };
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
const already = deletedSessionIdsOf(ctx);
|
|
49
|
-
const cleared = new Set(removed);
|
|
50
|
-
for (const id of sessionIdsWithoutAnyData(selected)) if (!already.has(id)) cleared.add(id);
|
|
51
|
-
rememberDeletedSessionIds(ctx, cleared);
|
|
47
|
+
// 排不进队列就整次失败(路由回 500):回「已清理」却没排上,重启之后它们原样回来。
|
|
48
|
+
queueArchivedClearIds(selected);
|
|
49
|
+
// 宿主那份快照可能还带着它们,先在本进程压住:手机列表与归档列表立刻看不到。
|
|
50
|
+
rememberDeletedSessionIds(ctx, selected);
|
|
52
51
|
// 不立刻失效的话,最长 10 秒还能点到已删的行。
|
|
53
52
|
invalidateSessionList(ctx);
|
|
54
|
-
return { status: 200, body: { ok: true, deleted:
|
|
53
|
+
return { status: 200, body: { ok: true, deleted: selected.length } };
|
|
55
54
|
});
|
|
56
55
|
sendJson(res, outcome.status, outcome.body);
|
|
57
56
|
}
|
package/lib/mobile/completion.js
CHANGED
|
@@ -7,9 +7,21 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 旧要求的后果(2026-10-17 真机):宿主没采到那一瞬间的轮次永远不亮,桌面亮着绿点 /
|
|
9
9
|
* 红点,手机上什么都没有。
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ 这里有两笔**性质不同**的账,别混回去:
|
|
12
|
+
*
|
|
13
|
+
* - `completionState*`(按设备):**哪台手机还没看过这一轮**。它是提醒,只有 App 的
|
|
14
|
+
* `/session/read` 能清,天然属于「谁在问」。后台通知(/monitor)还靠它。
|
|
15
|
+
* - `lastTurnFactsOf`(不分设备):**这一轮什么时候结束的、成没成**。它是事实,跟
|
|
16
|
+
* 谁在问无关,所以列表行可以直接带它去镜像快照 —— 而快照是全局一份、给所有设备读的。
|
|
17
|
+
*
|
|
18
|
+
* 之前列表行只带前者,镜像又是用固定设备键构建的,于是快照里那份「未读」永远清不掉:
|
|
19
|
+
* 手机上点开看过,侧栏的绿点/红点照样挂着,而绕过镜像的那种读又立刻变干净 —— 同一行
|
|
20
|
+
* 一会儿一个状态。现在 App 侧自己拿事实 + 本机已读水位算「亮不亮」,两笔账各归各位。
|
|
10
21
|
*/
|
|
11
22
|
|
|
12
23
|
const { completionStateOf, completionStateMapsOf } = require('./session-cache.js');
|
|
24
|
+
const { settleOutcome, recordLastTurn } = require('./session-last-turn.js');
|
|
13
25
|
|
|
14
26
|
/** 本进程起点:比它更早结束的轮次不重播。 */
|
|
15
27
|
const PROCESS_STARTED_AT = Date.now();
|
|
@@ -37,7 +49,7 @@ function observeCompletionEvent(ctx, sessionId, event) {
|
|
|
37
49
|
const priorEvents = Array.isArray(previous.completionEvents) ? previous.completionEvents : [];
|
|
38
50
|
const events = [...priorEvents.filter((item) => item.type !== event.type), event];
|
|
39
51
|
const next = { ...previous, running: event.type === 'turn/end' ? false : previous.running, completionEvents: events };
|
|
40
|
-
if (event.type === 'turn/end') confirmCompletionFromHistory(next, events, false);
|
|
52
|
+
if (event.type === 'turn/end') confirmCompletionFromHistory(next, events, false, ctx, sessionId);
|
|
41
53
|
states.set(sessionId, next);
|
|
42
54
|
}
|
|
43
55
|
}
|
|
@@ -73,8 +85,14 @@ function updateCompletionStates(ctx, items, deviceKey = 'legacy') {
|
|
|
73
85
|
return states;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
88
|
+
/**
|
|
89
|
+
* 把 history 最后一轮的结局折到 state 上:结束时刻越过 baselineOf 才算提醒。
|
|
90
|
+
*
|
|
91
|
+
* 事实那一半(这一轮什么时候结束、成没成)由 `session-last-turn.js` 记下来,与设备无关;
|
|
92
|
+
* 这里只维护提醒。传了 `ctx` + `sessionId` 就顺手把事实记进去,**不受已读水位影响** ——
|
|
93
|
+
* 它说的是「这一轮怎么结束的」,跟哪台手机看过没关系(见文件头)。
|
|
94
|
+
*/
|
|
95
|
+
function confirmCompletionFromHistory(state, events, running, ctx = null, sessionId = null) {
|
|
78
96
|
if (!state || !Array.isArray(events)) return;
|
|
79
97
|
let latestTurn;
|
|
80
98
|
const ends = new Map();
|
|
@@ -97,40 +115,30 @@ function confirmCompletionFromHistory(state, events, running) {
|
|
|
97
115
|
if (running || state.turn === undefined) return;
|
|
98
116
|
const end = ends.get(state.turn);
|
|
99
117
|
if (!end) return;
|
|
118
|
+
const outcome = settleOutcome({ turn: state.turn, end, assistant: assistants.get(state.turn) });
|
|
119
|
+
if (ctx && sessionId) recordLastTurn(ctx, sessionId, outcome);
|
|
100
120
|
const baseline = baselineOf(state);
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
const assistant = assistants.get(state.turn);
|
|
104
|
-
const reason = end.data ? end.data.reason : undefined;
|
|
105
|
-
const kind = reason && typeof reason.kind === 'string' ? reason.kind : undefined;
|
|
106
|
-
// 本轮以错误收尾:宿主不会再补一条最终消息,但它刚刚追加过 turn/end —— 下面那条
|
|
107
|
-
// 「assistant 早于 turn/end 就算完成」的规则会把这种轮次判成「已完成待读」,侧栏于是
|
|
108
|
-
// 把一次失败显示成绿点。失败是宿主列表摘要里没有的信息,只能在这里折出来交给 App。
|
|
109
|
-
if (kind === 'error') {
|
|
110
|
-
const error = reason && reason.error && typeof reason.error === 'object' ? reason.error : {};
|
|
121
|
+
if (outcome.endedAt <= baseline) return; // 读过的 / 冷启动之前结束的那一轮
|
|
122
|
+
if (outcome.status === 'failed') {
|
|
111
123
|
state.completed = false;
|
|
112
124
|
state.failure = {
|
|
113
|
-
turn:
|
|
114
|
-
code:
|
|
115
|
-
message:
|
|
116
|
-
at:
|
|
125
|
+
turn: outcome.turn,
|
|
126
|
+
code: outcome.code,
|
|
127
|
+
message: outcome.message,
|
|
128
|
+
at: outcome.endedAt,
|
|
117
129
|
};
|
|
118
130
|
return;
|
|
119
131
|
}
|
|
120
132
|
// max-tokens / aborted 等:这一轮没跑完,同样不能报完成;但它没有错误码可显示,
|
|
121
133
|
// 所以只清完成标记,不产生红点。
|
|
122
|
-
if (
|
|
134
|
+
if (outcome.status === 'incomplete') {
|
|
123
135
|
state.completed = false;
|
|
124
136
|
state.failure = undefined;
|
|
125
137
|
return;
|
|
126
138
|
}
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
state.completed = true;
|
|
131
|
-
// 成功收尾的轮次覆盖旧失败:标记必须跟着最新一次运行走。
|
|
132
|
-
state.failure = undefined;
|
|
133
|
-
}
|
|
139
|
+
state.completed = true;
|
|
140
|
+
// 成功收尾的轮次覆盖旧失败:标记必须跟着最新一次运行走。
|
|
141
|
+
state.failure = undefined;
|
|
134
142
|
}
|
|
135
143
|
|
|
136
144
|
module.exports = { observeCompletionEvent, updateCompletionStates, confirmCompletionFromHistory };
|
package/lib/mobile/events.js
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* 但宿主在 DSH 0.2.0 兼容桥下只会订阅 workspace/follow + session/control,会话状态变化
|
|
7
7
|
* 走的是 session/projection(被白名单丢掉),于是这条流连着却可能长时间一帧都不发;
|
|
8
8
|
* 插件自己补了 device/list-changed(来自 lib/shared/list-signal.js,清单变了)、
|
|
9
|
-
* device/session-activity(来自 lib/shared/session-signal.js
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* device/session-activity(来自 lib/shared/session-signal.js,某个会话的正文水位涨了)、
|
|
10
|
+
* device/desktop-view-changed(来自 lib/shared/desktop-view-signal.js,桌面侧栏折叠态
|
|
11
|
+
* 变了)与 device/heartbeat(心跳)四类自产帧,App 侧靠它们判断「这条流还活着」
|
|
12
|
+
* 「该重拉列表」「该给某个会话补正文」「该重取折叠态」并据此决定要不要把兜底轮询降频。
|
|
12
13
|
*
|
|
13
14
|
* 断流补偿:这条流不做 Last-Event-ID 续传(App 只把它当信号,不当数据源),所以**连上时**
|
|
14
15
|
* 会立刻补发「当前状态」——一帧最近已知 revision 的 device/list-changed + 每个已跟踪会话
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
|
|
20
21
|
const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
|
|
21
22
|
const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
|
|
23
|
+
const { desktopViewSignal: sharedDesktopViewSignal } = require('../shared/desktop-view-signal.js');
|
|
22
24
|
const { writeSse: sharedWriteSse, stableEventId: sharedEventId } = require('../shared/sse-write.js');
|
|
23
25
|
|
|
24
26
|
const DEDUP_WINDOW = 2048;
|
|
@@ -194,6 +196,22 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
194
196
|
if (ac.signal.aborted) return;
|
|
195
197
|
if (!writeFrame({ type: 'device/session-activity', sessionId: event.sessionId, seq: event.seq, at: event.at })) ac.abort();
|
|
196
198
|
});
|
|
199
|
+
/**
|
|
200
|
+
* 自产的「桌面侧栏折叠态变了」帧。
|
|
201
|
+
*
|
|
202
|
+
* 桌面端那份 groupExpansion 只活在浏览器 localStorage,插件靠客户端半边轮询上报,
|
|
203
|
+
* 落在 lib/desktop-view.cjs;手机要拿到它只能问 GET /desktop-view。过去 App 用
|
|
204
|
+
* 「跟列表刷新走 + 5 分钟最短间隔」去猜它有没有变,稳态下一天白问几百次 —— 而插件
|
|
205
|
+
* 早就知道它哪一刻变了(desktop-view.cjs 的 update())。这里补上那个出口。
|
|
206
|
+
*
|
|
207
|
+
* 只发 updatedAt、不发内容:手机据此重拉那一发,拿到的是与列表同一口径的完整镜像,
|
|
208
|
+
* 也不必让这条「信号流」承担数据同步(它的帧一律可丢、可重放)。
|
|
209
|
+
* 同样必须直订 + 收尾注销,理由与上面两条一致。
|
|
210
|
+
*/
|
|
211
|
+
const unsubscribeDesktopView = sharedDesktopViewSignal.onBump((event) => {
|
|
212
|
+
if (ac.signal.aborted) return;
|
|
213
|
+
if (!writeFrame({ type: 'device/desktop-view-changed', updatedAt: event.updatedAt, at: event.at })) ac.abort();
|
|
214
|
+
});
|
|
197
215
|
/**
|
|
198
216
|
* 连上就先补一帧「当前状态」。
|
|
199
217
|
*
|
|
@@ -213,6 +231,12 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
213
231
|
if (list && list.revision) {
|
|
214
232
|
if (!writeFrame({ type: 'device/list-changed', revision: list.revision, at: list.at || Date.now() })) return false;
|
|
215
233
|
}
|
|
234
|
+
// 桌面侧栏折叠态:手机从此只在变化时拉一次。老 App 不认识这一帧(走默认分支忽略),
|
|
235
|
+
// 所以补发它不会改变老客户端的任何行为。
|
|
236
|
+
const view = sharedDesktopViewSignal.snapshot();
|
|
237
|
+
if (view && view.updatedAt > 0) {
|
|
238
|
+
if (!writeFrame({ type: 'device/desktop-view-changed', updatedAt: view.updatedAt, at: view.at || Date.now() })) return false;
|
|
239
|
+
}
|
|
216
240
|
for (const [sessionId, seq] of Object.entries(sharedSessionSignal.snapshot())) {
|
|
217
241
|
if (!(seq > 0)) continue;
|
|
218
242
|
if (!writeFrame({ type: 'device/session-activity', sessionId, seq, at: Date.now() })) return false;
|
|
@@ -279,6 +303,7 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
279
303
|
clearInterval(keepAlive);
|
|
280
304
|
unsubscribeSignal();
|
|
281
305
|
unsubscribeActivity();
|
|
306
|
+
unsubscribeDesktopView();
|
|
282
307
|
releaseSignal();
|
|
283
308
|
try {
|
|
284
309
|
if (!res.writableEnded) {
|
|
@@ -110,7 +110,6 @@ const ENDPOINTS = {
|
|
|
110
110
|
|
|
111
111
|
// skills / subagents
|
|
112
112
|
'skills.list': inv('skills', 'list'),
|
|
113
|
-
'subagents.history': inv('subagents', 'history'),
|
|
114
113
|
'subagents.prompt': inv('subagents', 'prompt', (p) => ({
|
|
115
114
|
request: {
|
|
116
115
|
...pick(p, ['parentSessionId', 'childSessionId', 'mode', 'content', 'clientTimeZone']),
|
|
@@ -21,6 +21,28 @@ function sessionHint(request) {
|
|
|
21
21
|
return typeof sessionId === 'string' && sessionId ? sessionId : null;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* 这条单会话流该按哪种地址订阅。
|
|
26
|
+
*
|
|
27
|
+
* 子会话必须换成 `{kind:'subagent'}` 的 durable 地址:宿主 `validateAddress` 对
|
|
28
|
+
* `origin === 'subagent'` 的会话拿普通地址一律回 agent-busy,subscribe 直接失败 ——
|
|
29
|
+
* 表现就是「子代理页面打得开,但内容永远不自己更新」。请求体里带上 `parentSessionId`
|
|
30
|
+
* 就是「这是个子会话」的信号(与历史那条同源,见 stream.js 的 handleStream)。
|
|
31
|
+
*/
|
|
32
|
+
function sessionAddress(payload) {
|
|
33
|
+
const sessionId = payload && payload.sessionId;
|
|
34
|
+
const parentSessionId = payload && payload.parentSessionId;
|
|
35
|
+
if (typeof parentSessionId === 'string' && parentSessionId) {
|
|
36
|
+
return {
|
|
37
|
+
kind: 'subagent',
|
|
38
|
+
parentSessionId,
|
|
39
|
+
childSessionId: sessionId,
|
|
40
|
+
mode: (payload && payload.mode) || 'continuable',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { kind: 'session', sessionId };
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
/** 一条流跑不通不该拖垮其余流:这里把异常吃掉,只丢这一条源的帧。 */
|
|
25
47
|
async function* tolerate(frames, what, ctx) {
|
|
26
48
|
try {
|
|
@@ -207,20 +229,28 @@ async function* deviceFrames(ctx, signal) {
|
|
|
207
229
|
/** 旧 `events.mux`:设备级帧流,带 sessionId 时收窄成单会话视角。 */
|
|
208
230
|
function muxFrames(ctx, request, signal) {
|
|
209
231
|
const sessionId = sessionHint(request);
|
|
232
|
+
const payload = request && request.payload;
|
|
210
233
|
return (async function* run() {
|
|
211
234
|
if (!sessionId) {
|
|
212
235
|
yield* deviceFrames(ctx, signal);
|
|
213
236
|
return;
|
|
214
237
|
}
|
|
238
|
+
const address = sessionAddress(payload);
|
|
239
|
+
// 子会话没有 job 面板(任务归属父会话),也就不订阅那一条:拿子会话 id 去问 job.list
|
|
240
|
+
// 只会白打一发,而且它的失败会被 tolerate 吞成一条警告。
|
|
215
241
|
const control = openStream(ctx, 'session', 'control', {}, signal).then((frames) => tolerate(frames, 'session/control', ctx));
|
|
216
|
-
const jobs =
|
|
242
|
+
const jobs = address.kind === 'session'
|
|
243
|
+
? openStream(ctx, 'job', 'list', { request: { sessionId } }, signal).then((frames) => tolerate(frames, `job/${sessionId}`, ctx))
|
|
244
|
+
: null;
|
|
217
245
|
// assistantStream: 逐块增量只走这条流的 assistant-stream 通知(见 followFramePayloads)。
|
|
218
246
|
const follow = openStream(ctx, 'session', 'follow', {
|
|
219
|
-
request: { address
|
|
247
|
+
request: { address, maxMessages: 1, assistantStream: true },
|
|
220
248
|
}, signal).then((frames) => tolerate(frames, `session/${sessionId}`, ctx));
|
|
221
249
|
const projections = projectionState();
|
|
222
250
|
const followState = { turn: null, step: null, streaming: false };
|
|
223
|
-
const
|
|
251
|
+
const opening = [['control', control], ['follow', follow]];
|
|
252
|
+
if (jobs !== null) opening.push(['jobs', jobs]);
|
|
253
|
+
const sources = await Promise.all(opening.map(([source, promise]) => promise.then(async function* wrap(iterable) {
|
|
224
254
|
for await (const frame of iterable) {
|
|
225
255
|
if (source === 'follow') {
|
|
226
256
|
// follow 一帧可能产出 0..n 个 mux 帧(事件原样 + 增量通知合成 chunk 事件)。
|
|
@@ -13,13 +13,36 @@ const { invoke, invokeEnvelope, ok, fail, pick, warn, deadline, openStream, SYNT
|
|
|
13
13
|
* follow 拿游标(读完首帧立刻 break,网关会取消这条流),再翻一页历史。
|
|
14
14
|
* 返回值把 records 拆回旧形状的 events(history-derive.js 只认 events/hasMore)。
|
|
15
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* 这次 history 要读的是「谁」的地址。
|
|
18
|
+
*
|
|
19
|
+
* 普通会话是 `{kind:'session'}`;子会话必须换成**带父会话的 durable 地址**
|
|
20
|
+
* `{kind:'subagent'}` —— 宿主对 `origin === 'subagent'` 的会话拿普通地址一律回
|
|
21
|
+
* agent-busy(见 dsh-api-session-controller 的 validateAddress),这跟续话那条
|
|
22
|
+
* 是同一个门。`mode` 也要带:宿主会拿它跟子会话自己的 descriptor 对一遍。
|
|
23
|
+
*/
|
|
24
|
+
function historyAddress(payload) {
|
|
25
|
+
const sessionId = payload && payload.sessionId;
|
|
26
|
+
const parentSessionId = payload && payload.parentSessionId;
|
|
27
|
+
if (typeof parentSessionId === 'string' && parentSessionId) {
|
|
28
|
+
return {
|
|
29
|
+
kind: 'subagent',
|
|
30
|
+
parentSessionId,
|
|
31
|
+
childSessionId: sessionId,
|
|
32
|
+
mode: (payload && payload.mode) || 'continuable',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return { kind: 'session', sessionId };
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
async function historyViaPage(ctx, payload, signal) {
|
|
17
39
|
const sessionId = payload && payload.sessionId;
|
|
18
40
|
if (typeof sessionId !== 'string' || !sessionId) return fail({ code: 'bad-request', message: '缺少 sessionId' }, 'bad-request');
|
|
41
|
+
const address = historyAddress(payload);
|
|
19
42
|
let throughSeq = -1;
|
|
20
43
|
try {
|
|
21
44
|
const frames = await openStream(ctx, 'session', 'follow', {
|
|
22
|
-
request: { address
|
|
45
|
+
request: { address, maxMessages: 1 },
|
|
23
46
|
}, deadline(signal, SYNTHETIC_DEADLINE_MS));
|
|
24
47
|
for await (const frame of frames) {
|
|
25
48
|
if (frame && frame.type === 'snapshot' && Number.isSafeInteger(frame.cursor)) {
|
|
@@ -31,7 +54,7 @@ async function historyViaPage(ctx, payload, signal) {
|
|
|
31
54
|
return fail(error);
|
|
32
55
|
}
|
|
33
56
|
const request = {
|
|
34
|
-
address
|
|
57
|
+
address,
|
|
35
58
|
throughSeq,
|
|
36
59
|
...pick(payload, ['maxMessages', 'beforeSeq', 'turnWindow']),
|
|
37
60
|
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/mobile/v1/monitor —— 安卓后台提醒服务专用的「状态位」视图(只读)。
|
|
5
|
+
*
|
|
6
|
+
* 为什么单开一条:那条前台服务活在 JS 之外(App 退到后台、进程被系统回收,只有它还在),
|
|
7
|
+
* 所以「这一轮跑完了 / 跑挂了 / 停下来等你回话」只能由它自己定时去问。它此前问的是完整
|
|
8
|
+
* 会话清单(`?fresh=1`),而那份清单为了画侧栏还捎着**最后一条消息预览** —— 预览要为最近
|
|
9
|
+
* 20 个会话各读一次 history。后台每 8 秒白读 20 次日志,只为判断「completed 翻没翻」。
|
|
10
|
+
*
|
|
11
|
+
* 这条只回监控需要的那几格:在不在跑、刚完成没有、在等谁回话、上一轮是不是跑挂的、标题。
|
|
12
|
+
* 并支持 `?rev=` 条件刷新:状态位与上次一模一样时回一句 `unchanged`,连 items 都不带
|
|
13
|
+
* (稳态下这一发是几十字节)。
|
|
14
|
+
*
|
|
15
|
+
* 完成态仍以 completion 状态机为准,与 `/sessions` 用同一本账:常态下这里**不读 history**,
|
|
16
|
+
* 只有「跑过、现在不在跑、结局还没定」的那几条才读一次 —— 那是唯一能把 failedTurn 折出来
|
|
17
|
+
* 的地方(事件流漏帧时全靠它,见 completion.js 的坑)。
|
|
18
|
+
*
|
|
19
|
+
* 刻意不进 `/sessions`:那边一旦按调用方分叉视图,镜像推送与 App 的写后刷新也会跟着
|
|
20
|
+
* 受影响,而这两条都要求「一份完整清单」。分路由比加参数安全。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { sendJson } = require('../shared/http-util');
|
|
24
|
+
const { stableRevision, unchangedListBody } = require('../shared/list-revision.js');
|
|
25
|
+
const { callSessions } = require('./rpc.js');
|
|
26
|
+
const { unwrap } = require('./envelope.js');
|
|
27
|
+
const { listTitle } = require('./history-derive.js');
|
|
28
|
+
const {
|
|
29
|
+
canonicalDeviceKey, deletedSessionIdsOf, completionGenerationOf,
|
|
30
|
+
} = require('./session-cache.js');
|
|
31
|
+
const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
|
|
32
|
+
const { sessionPreviewFor } = require('./session-preview.js');
|
|
33
|
+
const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags.js');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 一条会话在监控视图里的那几格。
|
|
37
|
+
*
|
|
38
|
+
* 字段名与完整清单**逐字一致**(sessionId / title / running / completed / pendingInteraction /
|
|
39
|
+
* lastTurnError),因为原生那边解析的是同一套字段:换名字就得同步改 Kotlin,而那段代码
|
|
40
|
+
* 在 App 之外,改错一次是「后台永远不提醒」这种看不见的故障。
|
|
41
|
+
*/
|
|
42
|
+
function monitorItem(summary, state) {
|
|
43
|
+
const item = {
|
|
44
|
+
sessionId: summary.sessionId,
|
|
45
|
+
title: listTitle(summary),
|
|
46
|
+
running: summary.running === true,
|
|
47
|
+
completed: state ? state.completed === true : false,
|
|
48
|
+
};
|
|
49
|
+
const pending = pendingInteractionOf(summary.sessionId, summary);
|
|
50
|
+
if (pending) item.pendingInteraction = pending;
|
|
51
|
+
if (state && state.failure) item.lastTurnError = state.failure;
|
|
52
|
+
return item;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 状态位视图:一次宿主 list + 归档集合,**零次 history 读取**(除非有刚跑完还没定结局的)。
|
|
57
|
+
*
|
|
58
|
+
* 返回 `{ items, revision }`;宿主列表形状不对时返回 `{ degraded: true }`,宿主报错时返回
|
|
59
|
+
* `{ error }` —— 两种都由调用方照 `/sessions` 的口径回,别在这里编一个空清单:空清单会被
|
|
60
|
+
* 原生当成「所有会话都没了」,基线一清,下一轮就为所有正在跑的会话重新报一次完成。
|
|
61
|
+
*/
|
|
62
|
+
async function buildMonitorState(ctx, deviceKey) {
|
|
63
|
+
const { value, error } = unwrap(await callSessions(ctx, 'list', {}, 'mobile'));
|
|
64
|
+
if (error) return { error };
|
|
65
|
+
const summaries = Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : null);
|
|
66
|
+
if (summaries === null) return { degraded: true };
|
|
67
|
+
|
|
68
|
+
const generation = completionGenerationOf(ctx, deviceKey);
|
|
69
|
+
const deleted = deletedSessionIdsOf(ctx);
|
|
70
|
+
const { archived } = await workspaceSessionFlags(ctx);
|
|
71
|
+
const live = summaries.filter((s) => s && typeof s.sessionId === 'string' && s.sessionId
|
|
72
|
+
&& !deleted.has(s.sessionId) && !archived.has(s.sessionId));
|
|
73
|
+
|
|
74
|
+
// 状态机跟着这份清单走:归档 / 删除的会话在这里被踢掉,与完整列表同一口径。
|
|
75
|
+
const states = updateCompletionStates(ctx, live, deviceKey);
|
|
76
|
+
|
|
77
|
+
// 结局还没定的那几条:跑过、现在不在跑、既没完成也没失败。常态下这个集合是空的。
|
|
78
|
+
const unsettled = live.filter((s) => {
|
|
79
|
+
if (s.running) return false;
|
|
80
|
+
const state = states.get(s.sessionId);
|
|
81
|
+
return state && state.wasRunning === true && state.running !== true
|
|
82
|
+
&& state.completed !== true && !state.failure;
|
|
83
|
+
});
|
|
84
|
+
if (unsettled.length > 0) {
|
|
85
|
+
await Promise.all(unsettled.map(async (summary) => {
|
|
86
|
+
try {
|
|
87
|
+
const preview = await sessionPreviewFor(ctx, summary);
|
|
88
|
+
if (!preview.events.length) return;
|
|
89
|
+
// 读到这些历史的这一刻,别处可能刚发起一次写后刷新(它会 bump 代次);
|
|
90
|
+
// 用旧快照去确认结局等于把「还在跑的一轮」写成已完成。
|
|
91
|
+
if (completionGenerationOf(ctx, deviceKey) !== generation) return;
|
|
92
|
+
confirmCompletionFromHistory(states.get(summary.sessionId), preview.events, false, ctx, summary.sessionId);
|
|
93
|
+
} catch {
|
|
94
|
+
// 这一轮没读到就原样留着:判定条件(没完成、没失败)下一轮仍然成立,会再试。
|
|
95
|
+
}
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const items = live.map((summary) => monitorItem(summary, states.get(summary.sessionId)));
|
|
100
|
+
// 按 id 排序后再算指纹:宿主是按 updatedAt 排的,聊一句话顺序就换,指纹会跟着每轮都变,
|
|
101
|
+
// 条件刷新等于白做。监控视图不关心顺序。
|
|
102
|
+
items.sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
|
|
103
|
+
return { items, revision: stableRevision(items) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** GET /api/mobile/v1/monitor?rev=…(token) */
|
|
107
|
+
async function handleMonitor(ctx, req, res) {
|
|
108
|
+
const deviceKey = canonicalDeviceKey(req);
|
|
109
|
+
const url = new URL(req.url, 'http://x');
|
|
110
|
+
const rev = (url.searchParams.get('rev') || '').trim();
|
|
111
|
+
|
|
112
|
+
let built;
|
|
113
|
+
try {
|
|
114
|
+
built = await buildMonitorState(ctx, deviceKey);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return sendJson(res, 500, {
|
|
117
|
+
ok: false,
|
|
118
|
+
error: { code: 'monitor-failed', message: (error && error.message) || '监控视图构建失败' },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (built.error) return sendJson(res, 400, { ok: false, error: built.error });
|
|
122
|
+
if (built.degraded) {
|
|
123
|
+
// 与 /sessions 同一个码:中转见到 5xx 会退回上一份好副本,手机那侧因此是「没收到新东西」
|
|
124
|
+
// 而不是「一台会话都没有」。
|
|
125
|
+
return sendJson(res, 500, {
|
|
126
|
+
ok: false,
|
|
127
|
+
error: { code: 'host-session-list-degraded', message: '宿主会话列表形状异常:响应里没有 items 数组' },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const body = { ok: true, generatedAt: Date.now(), revision: built.revision, items: built.items };
|
|
132
|
+
sendJson(res, 200, unchangedListBody(body, rev) || body);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { handleMonitor, buildMonitorState, monitorItem };
|