dsh-mobile-plugin 0.0.0-stage → 0.1.57
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 +128 -2
- package/config/agent-presets/standard/agent.cordis.yml +170 -0
- package/config/agent-presets/standard/preset.yml +3 -0
- package/cordis.mobile-host.yml +48 -0
- package/cordis.patch.yml +21 -0
- package/dsh-mobile-credentials/lib/credentials.js +135 -0
- package/dsh-mobile-credentials/package.json +13 -0
- package/host.js +128 -0
- package/lib/auto-archive.cjs +98 -0
- package/lib/client.js +690 -0
- package/lib/desktop-half-stats.cjs +101 -0
- package/lib/desktop-view.cjs +109 -0
- package/lib/host-guard.cjs +230 -0
- package/lib/host-patches.cjs +46 -0
- package/lib/http/package.json +3 -0
- package/lib/http/routing.js +84 -0
- package/lib/impl.js +406 -0
- package/lib/mirror/pusher.cjs +706 -0
- package/lib/mirror-snapshot.cjs +39 -0
- package/lib/mobile/agent-presets.js +182 -0
- package/lib/mobile/attachments.js +116 -0
- package/lib/mobile/auth.js +78 -0
- package/lib/mobile/completion.js +114 -0
- package/lib/mobile/credentials.js +150 -0
- package/lib/mobile/directory-browse.js +295 -0
- package/lib/mobile/envelope.js +51 -0
- package/lib/mobile/events.js +293 -0
- package/lib/mobile/history-derive.js +608 -0
- package/lib/mobile/host-bridge/endpoints.js +189 -0
- package/lib/mobile/host-bridge/event-synthesis.js +232 -0
- package/lib/mobile/host-bridge/shims.js +218 -0
- package/lib/mobile/host-bridge/transport.js +168 -0
- package/lib/mobile/host-bridge.js +71 -0
- package/lib/mobile/host-settings.js +282 -0
- package/lib/mobile/idempotency.js +78 -0
- package/lib/mobile/jobs.js +97 -0
- package/lib/mobile/local-images.js +153 -0
- package/lib/mobile/package.json +3 -0
- package/lib/mobile/plugins.js +149 -0
- package/lib/mobile/questions.js +71 -0
- package/lib/mobile/references.js +209 -0
- package/lib/mobile/rpc.js +76 -0
- package/lib/mobile/session-cache.js +263 -0
- package/lib/mobile/session-goal.js +44 -0
- package/lib/mobile/session-log.js +94 -0
- package/lib/mobile/session-mirror.js +177 -0
- package/lib/mobile/session-models.js +153 -0
- package/lib/mobile/session-presets.js +52 -0
- package/lib/mobile/session-search.js +65 -0
- package/lib/mobile/session-send.js +278 -0
- package/lib/mobile/session-snapshot.js +142 -0
- package/lib/mobile/sessions.js +462 -0
- package/lib/mobile/skills.js +80 -0
- package/lib/mobile/stream.js +268 -0
- package/lib/mobile/subagents.js +265 -0
- package/lib/mobile/workspaces.js +214 -0
- package/lib/panel/index.html +33 -0
- package/lib/read-receipt.cjs +124 -0
- package/lib/reconnect-scheduler.cjs +37 -0
- package/lib/relay/core.js +77 -0
- package/lib/relay/lan-network.cjs +85 -0
- package/lib/relay/lan-proxy.js +593 -0
- package/lib/relay/package.json +3 -0
- package/lib/relay/pairing-policy.js +45 -0
- package/lib/relay/port-allocator.cjs +60 -0
- package/lib/relay/preview.js +374 -0
- package/lib/relay/protocol.js +177 -0
- package/lib/relay/status.js +145 -0
- package/lib/relay/tunnel.js +721 -0
- package/lib/routes/control-plane.js +311 -0
- package/lib/routes/mobile-api.js +208 -0
- package/lib/routes/package.json +3 -0
- package/lib/session-cleanup.cjs +153 -0
- package/lib/shared/credentials-entry.js +48 -0
- package/lib/shared/dshtoken.js +62 -0
- package/lib/shared/fresh-request.js +35 -0
- package/lib/shared/http-util.js +71 -0
- package/lib/shared/list-revision.js +51 -0
- package/lib/shared/list-signal.js +87 -0
- package/lib/shared/package.json +3 -0
- package/lib/shared/paths.js +27 -0
- package/lib/shared/session-signal.js +110 -0
- package/lib/shared/workspace-guard.js +127 -0
- package/lib/sidecar-facade-worker.cjs +353 -0
- package/lib/sidecar-facade.cjs +141 -0
- package/lib/sidecar-lifecycle.cjs +246 -0
- package/package.json +74 -4
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端「引用发现」API:把桌面端输入框里的 @文件 / @会话 补全补齐到手机上。
|
|
5
|
+
*
|
|
6
|
+
* 桌面端由 dsh-client-ui-reference 渲染:输入 @ 后浏览器同时发起
|
|
7
|
+
* fileReferences/list 与 sessionReferenceResolver/candidates 两个 Remote 调用,
|
|
8
|
+
* 结果按「文件在前、会话在后」的确定性顺序分组展示;选中文件插入普通 @路径 文本,
|
|
9
|
+
* 选中会话插入宿主给的规范 mention(@[label](dsh-session:…))。
|
|
10
|
+
*
|
|
11
|
+
* 关键:这两个宿主服务 **不在 apiProxy 上** —— 它们是 Typert Remote 服务
|
|
12
|
+
* (dsh-file-reference 的 fileReferences、dsh-session-reference 的
|
|
13
|
+
* sessionReferenceResolver)。手机侧本来就能到它们:dsh-mobile-plugin 与宿主同进程,
|
|
14
|
+
* lib/mobile/sessions.js 已经用 `ctx.get('typertGateway')` 派发过 commands.execute,
|
|
15
|
+
* lib/mobile/plugins.js 也走同一条路读 pluginInventory,这里照搬。
|
|
16
|
+
*
|
|
17
|
+
* 两条接口(都只是转发宿主的发现域,不做任何排序或截断之外的加工):
|
|
18
|
+
* - GET /api/mobile/v1/file-references?sessionId=…&query=… → fileReferences.list
|
|
19
|
+
* - GET /api/mobile/v1/session-references?sessionId=…&query=… → sessionReferenceResolver.candidates
|
|
20
|
+
*
|
|
21
|
+
* 参数按宿主的 **wire 名** 精确校验:两个方法都是 { agentId, query },query 是普通
|
|
22
|
+
* JSON 字符串(与 messageFeedback 的 { request } 包装不同)。
|
|
23
|
+
*
|
|
24
|
+
* 语义按宿主原文透传:
|
|
25
|
+
* - query 是「@ 或 @" 后面的那段文字」;空串表示列出全部(宿主自己限量)。
|
|
26
|
+
* - 文件候选只有 path/kind,目录候选带在路径尾部追加 / 的责任在客户端。
|
|
27
|
+
* - 会话候选的 mention 是宿主的规范写法,客户端必须原样发送,不能拿 label 重建。
|
|
28
|
+
* - 两个域各自独立降级:任一域不可用不影响另一个域(桌面端也是这个约定)。
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const { sendJson } = require('../shared/http-util');
|
|
32
|
+
|
|
33
|
+
const REFERENCE_ID_MAX_CHARS = 200; // sessionId 长度上限
|
|
34
|
+
const REFERENCE_QUERY_MAX_CHARS = 200; // 查询串长度上限(宿主 schema 只要求 string)
|
|
35
|
+
const REFERENCE_MAX_ITEMS = 50; // 与宿主 session-reference 的 candidateLimit 默认值一致
|
|
36
|
+
const REFERENCE_KINDS = new Set(['file', 'directory']);
|
|
37
|
+
|
|
38
|
+
/** 宿主网关;宿主没组合 dsh-api-gateway 时返回 null,调用方回 503。 */
|
|
39
|
+
function gatewayOf(ctx) {
|
|
40
|
+
return (ctx.get && ctx.get('typertGateway')) || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 网关缺失/命名空间缺失对客户端是同一件事:宿主这个能力没装。 */
|
|
44
|
+
const UNAVAILABLE = Object.freeze({
|
|
45
|
+
code: 'reference-unavailable',
|
|
46
|
+
message: '宿主没有组合引用发现服务(dsh-file-reference / dsh-session-reference / dsh-api-gateway)',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** 网关自己抛的错(不是业务失败)的文字化。 */
|
|
50
|
+
function errorText(error) {
|
|
51
|
+
if (!error) return '未知错误';
|
|
52
|
+
if (typeof error.message === 'string' && error.message) return error.message;
|
|
53
|
+
return String(error);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 经 typertGateway 调一次引用发现。
|
|
58
|
+
*
|
|
59
|
+
* 注意返回值形状:`gateway.invoke()` 返回的是 **decode 后的业务结果本体**
|
|
60
|
+
* (dsh-api-gateway/lib/index.js:112 `return decode(descriptor.result, result, …)`),
|
|
61
|
+
* 那层 `{ok:true,value}` 外壳是浏览器传输(connection.rpc.intercept('/api'))加的,
|
|
62
|
+
* invoke 不负责包。所以形状取决于服务声明的 result schema:
|
|
63
|
+
* - messageFeedback 声明的是 ok/error 联合 → invoke 回来就是 {ok:true,value}
|
|
64
|
+
* - fileReferences.list 声明的是 z.array(…) → invoke 回来就是一个裸数组
|
|
65
|
+
* 两个都认,这样宿主以后把 schema 换成联合也不会坏。
|
|
66
|
+
*
|
|
67
|
+
* @returns {Promise<{value?: unknown, error?: {code: string, message: string}}>}
|
|
68
|
+
*/
|
|
69
|
+
async function callReference(ctx, namespace, method, agentId, query) {
|
|
70
|
+
const gateway = gatewayOf(ctx);
|
|
71
|
+
if (!gateway) return { error: { ...UNAVAILABLE } };
|
|
72
|
+
let result;
|
|
73
|
+
try {
|
|
74
|
+
// 两个方法的声明参数都是 agent(wire=agentId) + query(wire=query),args 必须精确匹配;
|
|
75
|
+
// 末位 signal 是可选的取消边界(gateway 会补 NEVER_ABORTED_SIGNAL),这里给真的,
|
|
76
|
+
// 客户端断开时能让宿主那次发现提前收工。
|
|
77
|
+
result = await gateway.invoke({
|
|
78
|
+
namespace,
|
|
79
|
+
method,
|
|
80
|
+
args: { agentId, query },
|
|
81
|
+
signal: new AbortController().signal,
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// 0.2.0 的网关错误码带 `gateway/` 前缀(dsh-api-gateway/src/types.ts:117-136);
|
|
85
|
+
// 0.1.x 时期是不带前缀的。两种都认,否则「宿主没装这个服务」的降级分支形同虚设。
|
|
86
|
+
const raw = error && typeof error.code === 'string' ? error.code.replace(/^gateway\//, '') : ''
|
|
87
|
+
const code = raw === 'invocation-unavailable' || raw === 'service-unavailable' || raw === 'method-unavailable'
|
|
88
|
+
? UNAVAILABLE.code
|
|
89
|
+
: 'reference-error';
|
|
90
|
+
return { error: { code, message: errorText(error) } };
|
|
91
|
+
}
|
|
92
|
+
// 正常形状:裸数组(宿主声明的 result 就是数组)
|
|
93
|
+
if (Array.isArray(result)) return { value: result };
|
|
94
|
+
if (result && typeof result === 'object') {
|
|
95
|
+
if (result.ok === true) return { value: result.value };
|
|
96
|
+
if (result.ok === false) {
|
|
97
|
+
const raw = result.error && typeof result.error === 'object' ? result.error : {};
|
|
98
|
+
const code = typeof raw.code === 'string' && raw.code ? raw.code : 'reference-error';
|
|
99
|
+
return { error: { code, message: errorText(raw) } };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { error: { code: 'reference-error', message: '宿主引用发现服务返回了意外的结果' } };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 文件/目录候选:宿主只承诺 { path, kind },别的形状一律丢掉。 */
|
|
106
|
+
function normalizeFileCandidate(row) {
|
|
107
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) return null;
|
|
108
|
+
if (typeof row.path !== 'string' || !row.path) return null;
|
|
109
|
+
if (!REFERENCE_KINDS.has(row.kind)) return null;
|
|
110
|
+
return { path: row.path, kind: row.kind };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 会话候选:mention 是宿主的规范写法(@[label](dsh-session:…)),客户端必须原样发送,
|
|
115
|
+
* 所以没有 mention 的行等于不可用,直接丢;label 缺失时退回 sessionId。
|
|
116
|
+
*/
|
|
117
|
+
function normalizeSessionCandidate(row) {
|
|
118
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) return null;
|
|
119
|
+
if (typeof row.sessionId !== 'string' || !row.sessionId) return null;
|
|
120
|
+
if (typeof row.mention !== 'string' || !row.mention) return null;
|
|
121
|
+
const label = typeof row.label === 'string' && row.label ? row.label : row.sessionId;
|
|
122
|
+
return {
|
|
123
|
+
sessionId: row.sessionId,
|
|
124
|
+
label,
|
|
125
|
+
mention: row.mention,
|
|
126
|
+
...(typeof row.cwd === 'string' && row.cwd ? { cwd: row.cwd } : {}),
|
|
127
|
+
createdAt: typeof row.createdAt === 'number' ? row.createdAt : 0,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 会话 id 校验:空/超长直接拒绝,不把垃圾转发给宿主。 */
|
|
132
|
+
function normalizeId(value, label) {
|
|
133
|
+
if (value === null || value === undefined) return { error: `缺少 ${label}` };
|
|
134
|
+
if (typeof value !== 'string') return { error: `${label} 必须是字符串` };
|
|
135
|
+
const id = value.trim();
|
|
136
|
+
if (!id) return { error: `缺少 ${label}` };
|
|
137
|
+
if (id.length > REFERENCE_ID_MAX_CHARS) return { error: `${label} 过长` };
|
|
138
|
+
return { id };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 查询串:缺失等同空串(列出全部);只截长度,内容原样交给宿主筛选。 */
|
|
142
|
+
function normalizeQuery(value) {
|
|
143
|
+
if (value === null || value === undefined) return { query: '' };
|
|
144
|
+
if (typeof value !== 'string') return { error: 'query 必须是字符串' };
|
|
145
|
+
if (value.length > REFERENCE_QUERY_MAX_CHARS) return { error: `query 过长(上限 ${REFERENCE_QUERY_MAX_CHARS} 字符)` };
|
|
146
|
+
return { query: value };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 能力缺失是 503,其余都是 400。 */
|
|
150
|
+
function statusOf(error) {
|
|
151
|
+
if (!error) return 400;
|
|
152
|
+
if (error.code === UNAVAILABLE.code) return 503;
|
|
153
|
+
return 400;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 两条 GET 路由共用的骨架:校验 → 转发 → 规范化 → 限量。 */
|
|
157
|
+
async function handleReferenceList(ctx, req, res, spec) {
|
|
158
|
+
const params = new URL(req.url, 'http://x').searchParams;
|
|
159
|
+
const session = normalizeId(params.get('sessionId'), 'sessionId');
|
|
160
|
+
if (session.error) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: session.error } });
|
|
161
|
+
const query = normalizeQuery(params.get('query'));
|
|
162
|
+
if (query.error) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: query.error } });
|
|
163
|
+
|
|
164
|
+
const { value, error } = await callReference(ctx, spec.namespace, spec.method, session.id, query.query);
|
|
165
|
+
if (error) return sendJson(res, statusOf(error), { ok: false, error });
|
|
166
|
+
|
|
167
|
+
const rows = Array.isArray(value) ? value : [];
|
|
168
|
+
const items = [];
|
|
169
|
+
for (const row of rows) {
|
|
170
|
+
const item = spec.normalize(row);
|
|
171
|
+
if (item) items.push(item);
|
|
172
|
+
if (items.length >= REFERENCE_MAX_ITEMS) break;
|
|
173
|
+
}
|
|
174
|
+
sendJson(res, 200, { ok: true, items });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** GET /api/mobile/v1/file-references?sessionId=…&query=… → { ok, items:[{path,kind}] }。 */
|
|
178
|
+
function handleFileReferences(ctx, req, res) {
|
|
179
|
+
return handleReferenceList(ctx, req, res, {
|
|
180
|
+
namespace: 'fileReferences',
|
|
181
|
+
method: 'list',
|
|
182
|
+
normalize: normalizeFileCandidate,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* GET /api/mobile/v1/session-references?sessionId=…&query=… →
|
|
188
|
+
* { ok, items:[{sessionId,label,mention,cwd?,createdAt}] }。
|
|
189
|
+
*/
|
|
190
|
+
function handleSessionReferences(ctx, req, res) {
|
|
191
|
+
return handleReferenceList(ctx, req, res, {
|
|
192
|
+
namespace: 'sessionReferenceResolver',
|
|
193
|
+
method: 'candidates',
|
|
194
|
+
normalize: normalizeSessionCandidate,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
handleFileReferences,
|
|
200
|
+
handleSessionReferences,
|
|
201
|
+
normalizeFileCandidate,
|
|
202
|
+
normalizeSessionCandidate,
|
|
203
|
+
normalizeId,
|
|
204
|
+
normalizeQuery,
|
|
205
|
+
statusOf,
|
|
206
|
+
REFERENCE_QUERY_MAX_CHARS,
|
|
207
|
+
REFERENCE_MAX_ITEMS,
|
|
208
|
+
REFERENCE_UNAVAILABLE: UNAVAILABLE,
|
|
209
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 调宿主 apiProxy 的两扇门:`sessions.*` 与 `agentPresets.*`。
|
|
5
|
+
*
|
|
6
|
+
* 抽出来是因为这两扇门此前各有两份实现——sessions.js 一份、agent-presets.js 一份
|
|
7
|
+
* callAgentPresets(逐字相同),而 callSessions 是 sessions.js 内部 21 处的公共依赖,
|
|
8
|
+
* 一旦 sessions.js 被拆分,21 处调用就得跟着搬家。
|
|
9
|
+
*
|
|
10
|
+
* 这里收的不只是「转发」:callSessions 还带两件必须跟着转发走的语义。
|
|
11
|
+
*
|
|
12
|
+
* 1. signal 必须显式传。宿主 `sessions.<method>(request, signal)` 的第二个参数是
|
|
13
|
+
* 取消信号,宿主内部 `isAborted(signal)` 直接读 `signal.aborted`、不做
|
|
14
|
+
* undefined 兜底:search 这类会查 signal 的方法不传就抛
|
|
15
|
+
* `Cannot read properties of undefined (reading 'aborted')`。
|
|
16
|
+
* 2. list 的在途合流。sessions.list 会扫描所有压缩日志;同一个 Host 可能同时收到
|
|
17
|
+
* 桌面 completion、移动端列表和通知监控的请求,不合流就是一次刷新被放大成 N 次
|
|
18
|
+
* 磁盘扫描。**但写后刷新(?fresh=1)不能搭在途 list 的车**:被合流到的那个请求
|
|
19
|
+
* 发起于写入之前,拿回来的是旧列表,于是「刚改完又显示成改之前」。
|
|
20
|
+
*
|
|
21
|
+
* 依赖方向:rpc.js → history-derive(只为 rpcId 前缀常量),是叶子之上的薄层。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const { MOBILE_PROMPT_RPC_PREFIX } = require('./history-derive.js');
|
|
25
|
+
|
|
26
|
+
// 移动端提示词的 rpcId 约定与 user 消息回溯共用,前缀常量定义放在没有依赖的
|
|
27
|
+
// history-derive 里(history-derive 要用 clientMessageIdFromSource,而会话模块
|
|
28
|
+
// 又 require 它;反向引用会成环——历史上就是这个函数在 history-derive 里找不到
|
|
29
|
+
// 定义,历史路径一有 user 消息就报 "clientMessageIdFromSource is not defined")。
|
|
30
|
+
function mobilePromptRpcId(clientMessageId) {
|
|
31
|
+
return clientMessageId ? `${MOBILE_PROMPT_RPC_PREFIX}${clientMessageId}` : 'mobile';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 调宿主 apiProxy.agentPresets.*(Agent 预设列表 / 选择)。
|
|
36
|
+
* 注意:宿主 list 声明不接 signal,select 接第二个参数;这里统一不传,避免把
|
|
37
|
+
* undefined 交给内部会读 signal 的方法。
|
|
38
|
+
*/
|
|
39
|
+
function callAgentPresets(ctx, method, payload) {
|
|
40
|
+
return ctx.apiProxy.agentPresets[method]({ rpcId: 'mobile', method: `agentPreset.${method}`, payload });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// sessions.list 的在途合流表按 ctx 隔离(不同宿主 / 测试上下文各算各的)。
|
|
44
|
+
const sessionListInFlight = new WeakMap();
|
|
45
|
+
|
|
46
|
+
function callSessions(ctx, method, payload, rpcId = 'mobile', signal, options) {
|
|
47
|
+
const invoke = () => ctx.apiProxy.sessions[method]({ rpcId, method: `session.${method}`, payload }, signal);
|
|
48
|
+
if (method !== 'list') return invoke();
|
|
49
|
+
if (options && options.fresh) return invoke();
|
|
50
|
+
|
|
51
|
+
const existing = sessionListInFlight.get(ctx);
|
|
52
|
+
if (existing) return existing;
|
|
53
|
+
|
|
54
|
+
let request;
|
|
55
|
+
try {
|
|
56
|
+
request = Promise.resolve(invoke());
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// 同步抛出的也要变成 rejected promise:下面要挂 .then 清理并把它交给调用方,
|
|
59
|
+
// 否则同步异常会绕过 tracked 的回收,永久把这次合流卡在表里。
|
|
60
|
+
request = Promise.reject(error);
|
|
61
|
+
}
|
|
62
|
+
const tracked = request.then(
|
|
63
|
+
value => {
|
|
64
|
+
if (sessionListInFlight.get(ctx) === tracked) sessionListInFlight.delete(ctx);
|
|
65
|
+
return value;
|
|
66
|
+
},
|
|
67
|
+
error => {
|
|
68
|
+
if (sessionListInFlight.get(ctx) === tracked) sessionListInFlight.delete(ctx);
|
|
69
|
+
throw error;
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
sessionListInFlight.set(ctx, tracked);
|
|
73
|
+
return tracked;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { callSessions, callAgentPresets, mobilePromptRpcId };
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 会话侧的按 ctx 隔离状态:十一张表,一个入口。
|
|
5
|
+
*
|
|
6
|
+
* 这些表此前全是 sessions.js 的模块级 WeakMap。它们之所以必须按 ctx 隔离,是因为
|
|
7
|
+
* 它们的值属于**宿主进程状态**(某个会话正在跑、某个列表刚构建完、某条发送已经
|
|
8
|
+
* 幂等过),不同宿主 / 测试上下文混在一起会互相污染。
|
|
9
|
+
*
|
|
10
|
+
* 抽出来是为了让「哪些东西是模块级可变状态」这件事有个明确的名字和位置。此前它们
|
|
11
|
+
* 和会话 handler 混在一个 1400 行文件里,读代码时得先分清哪行是状态、哪行是逻辑。
|
|
12
|
+
* 抽完之后形状很干净:**每个访问器都保证返回的是那个表里给 ctx(或 ctx+device)
|
|
13
|
+
* 建好的那个 Map/状态对象,调用方拿到就能直接用,不需要再判空。**
|
|
14
|
+
*
|
|
15
|
+
* 依赖方向:session-cache.js 不依赖任何上层模块,是叶子。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Model responses are derived metadata only. They are never populated with credentials
|
|
19
|
+
// or provider secrets, and the key includes the requesting device and session.
|
|
20
|
+
const sessionModelsCache = new WeakMap();
|
|
21
|
+
const MAX_SESSION_MODEL_CACHE_ENTRIES = 256;
|
|
22
|
+
|
|
23
|
+
const sessionListResponseCache = new WeakMap();
|
|
24
|
+
const mirrorHistoryCache = new WeakMap();
|
|
25
|
+
// Cache only the derived last-message preview. The full history stays outside
|
|
26
|
+
// the cache so repeated mobile list refreshes do not re-read/decompress logs.
|
|
27
|
+
const sessionPreviewCache = new WeakMap();
|
|
28
|
+
const sessionPreviewInFlight = new WeakMap();
|
|
29
|
+
// 上一次真正重读该会话 history 补预览的时间(运行中会话的节流用,见 sessions.js)。
|
|
30
|
+
const sessionPreviewClock = new WeakMap();
|
|
31
|
+
// 完成提醒属于宿主进程状态:按 ctx 隔离,避免不同宿主/测试上下文互相污染。
|
|
32
|
+
const completionStates = new WeakMap();
|
|
33
|
+
const completionGenerations = new WeakMap();
|
|
34
|
+
// Prevent duplicate prompt calls when a mobile request is retried after a timeout.
|
|
35
|
+
const sendIdempotency = new WeakMap();
|
|
36
|
+
// 写操作(新建/改名/归档/分叉)的 requestId 重放表:同一次写在换通道重投后不再执行第二遍。
|
|
37
|
+
// 与 sendIdempotency 分开是因为键与生命周期不同(那边按 sessionId+clientMessageId,60s)。
|
|
38
|
+
const writeIdempotency = new WeakMap();
|
|
39
|
+
|
|
40
|
+
/** 设备维度:同一个 ctx 下多台手机各自一份,缺省用 'legacy'(未带 x-dsh-device 的调用)。 */
|
|
41
|
+
function canonicalDeviceKey(req, fallback = 'legacy') {
|
|
42
|
+
const value = req?.headers?.['x-dsh-device'];
|
|
43
|
+
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sessionModelsStateOf(ctx, deviceKey, sessionId) {
|
|
47
|
+
let state = sessionModelsCache.get(ctx);
|
|
48
|
+
if (!state) { state = new Map(); sessionModelsCache.set(ctx, state); }
|
|
49
|
+
const key = `${deviceKey}\0${sessionId}`;
|
|
50
|
+
let entry = state.get(key);
|
|
51
|
+
if (!entry) {
|
|
52
|
+
while (state.size >= MAX_SESSION_MODEL_CACHE_ENTRIES) state.delete(state.keys().next().value);
|
|
53
|
+
entry = { value: null, fetchedAt: 0, promise: null };
|
|
54
|
+
} else {
|
|
55
|
+
// Treat accesses as recent so active sessions survive the bounded eviction.
|
|
56
|
+
state.delete(key);
|
|
57
|
+
}
|
|
58
|
+
state.set(key, entry);
|
|
59
|
+
return entry;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function invalidateSessionModels(ctx, req, sessionId) {
|
|
63
|
+
const deviceKey = canonicalDeviceKey(req);
|
|
64
|
+
const state = sessionModelsCache.get(ctx);
|
|
65
|
+
state?.delete(`${deviceKey}\0${sessionId}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sessionListResponseState(ctx, deviceKey = 'legacy') {
|
|
69
|
+
let byDevice = sessionListResponseCache.get(ctx);
|
|
70
|
+
if (!byDevice) {
|
|
71
|
+
byDevice = new Map();
|
|
72
|
+
sessionListResponseCache.set(ctx, byDevice);
|
|
73
|
+
}
|
|
74
|
+
let state = byDevice.get(deviceKey);
|
|
75
|
+
if (!state) {
|
|
76
|
+
// droppedAt 是这份缓存的「朝代」:dropSessionListCache 每次丢弃内容就 +1,
|
|
77
|
+
// 于是变更前发起的那次构建落地时能看出自己的数据已经过时(见 handleSessions)。
|
|
78
|
+
state = { value: null, updatedAt: 0, promise: null, droppedAt: 0 };
|
|
79
|
+
byDevice.set(deviceKey, state);
|
|
80
|
+
}
|
|
81
|
+
return state;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sessionPreviewStateOf(ctx) {
|
|
85
|
+
let state = sessionPreviewCache.get(ctx);
|
|
86
|
+
if (!state) {
|
|
87
|
+
state = new Map();
|
|
88
|
+
sessionPreviewCache.set(ctx, state);
|
|
89
|
+
}
|
|
90
|
+
return state;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sessionPreviewInFlightStateOf(ctx) {
|
|
94
|
+
let state = sessionPreviewInFlight.get(ctx);
|
|
95
|
+
if (!state) {
|
|
96
|
+
state = new Map();
|
|
97
|
+
sessionPreviewInFlight.set(ctx, state);
|
|
98
|
+
}
|
|
99
|
+
return state;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 运行中会话上一次补预览的时间戳表(按 ctx 隔离,见 sessions.js 的节流)。 */
|
|
103
|
+
function sessionPreviewClockOf(ctx) {
|
|
104
|
+
let state = sessionPreviewClock.get(ctx);
|
|
105
|
+
if (!state) {
|
|
106
|
+
state = new Map();
|
|
107
|
+
sessionPreviewClock.set(ctx, state);
|
|
108
|
+
}
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 镜像用的历史缓存:按 ctx 一张 Map,key 自带 LRU 淘汰。 */
|
|
113
|
+
function mirrorHistoryStateOf(ctx) {
|
|
114
|
+
let cache = mirrorHistoryCache.get(ctx);
|
|
115
|
+
if (!cache) { cache = new Map(); mirrorHistoryCache.set(ctx, cache); }
|
|
116
|
+
return cache;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function completionStateOf(ctx, deviceKey = 'legacy') {
|
|
120
|
+
let byDevice = completionStates.get(ctx);
|
|
121
|
+
if (!byDevice) {
|
|
122
|
+
byDevice = new Map();
|
|
123
|
+
completionStates.set(ctx, byDevice);
|
|
124
|
+
}
|
|
125
|
+
let states = byDevice.get(deviceKey);
|
|
126
|
+
if (!states) {
|
|
127
|
+
states = new Map();
|
|
128
|
+
byDevice.set(deviceKey, states);
|
|
129
|
+
}
|
|
130
|
+
return states;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 清掉某个会话在**所有**设备维度上的完成提醒,返回真正清掉的设备数。
|
|
135
|
+
*
|
|
136
|
+
* 与 handleSessionRead 里那句 `completionStateOf(ctx, deviceKey).delete(...)` 的区别:
|
|
137
|
+
* 那个只清「刚点开的那台手机」自己的提醒(每台手机各有一份,互不干扰);这个用于
|
|
138
|
+
* **桌面端说「我读过了」**——读一次就是读过了,不该只清某台设备。
|
|
139
|
+
*
|
|
140
|
+
* 清掉(而不是标记成已读)是刻意的:重建出来的条目 `wasRunning` 为 false,历史折叠
|
|
141
|
+
* (completion.js 的 confirmCompletionFromHistory)会因此拒绝确认,于是**之后**完成的
|
|
142
|
+
* 轮次照样能重新亮起来 —— 「新一轮完成压过更早的已读」这条语义就落在这里。
|
|
143
|
+
*/
|
|
144
|
+
function clearCompletionForAllDevices(ctx, sessionId) {
|
|
145
|
+
const byDevice = completionStates.get(ctx);
|
|
146
|
+
if (!byDevice) return 0;
|
|
147
|
+
let cleared = 0;
|
|
148
|
+
for (const states of byDevice.values()) {
|
|
149
|
+
if (states.delete(sessionId)) cleared += 1;
|
|
150
|
+
}
|
|
151
|
+
return cleared;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function completionGenerationOf(ctx, deviceKey) {
|
|
155
|
+
let generations = completionGenerations.get(ctx);
|
|
156
|
+
if (!generations) {
|
|
157
|
+
generations = new Map();
|
|
158
|
+
completionGenerations.set(ctx, generations);
|
|
159
|
+
}
|
|
160
|
+
return generations.get(deviceKey) || 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function bumpCompletionGeneration(ctx, deviceKey) {
|
|
164
|
+
const next = completionGenerationOf(ctx, deviceKey) + 1;
|
|
165
|
+
completionGenerations.get(ctx).set(deviceKey, next);
|
|
166
|
+
return next;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sendIdempotencyStateOf(ctx) {
|
|
170
|
+
let state = sendIdempotency.get(ctx);
|
|
171
|
+
if (!state) {
|
|
172
|
+
state = new Map();
|
|
173
|
+
sendIdempotency.set(ctx, state);
|
|
174
|
+
}
|
|
175
|
+
return state;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 写操作幂等表(实现在 mobile/idempotency.js,这里只保证按 ctx 隔离)。 */
|
|
179
|
+
function writeIdempotencyStateOf(ctx) {
|
|
180
|
+
let state = writeIdempotency.get(ctx);
|
|
181
|
+
if (!state) {
|
|
182
|
+
state = new Map();
|
|
183
|
+
writeIdempotency.set(ctx, state);
|
|
184
|
+
}
|
|
185
|
+
return state;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 丢弃会话列表缓存(含正在构建的那一次)。
|
|
190
|
+
*
|
|
191
|
+
* `/sessions` 的完整响应会缓存 SESSION_LIST_CACHE_MS(10 秒),本意是压住手机轮询对
|
|
192
|
+
* 桌面的冲击。但凡是**改变列表内容**的动作都必须立刻把它丢掉,否则 App 在自己刚做完
|
|
193
|
+
* 的动作上看到的是最长 10 秒前的旧数据——真机上「重命名会话」保存后侧栏那一行纹丝不
|
|
194
|
+
* 动、「归档会话」按下去那一行照旧留着,看着都像没生效(宿主的标题其实早就改了)。
|
|
195
|
+
*
|
|
196
|
+
* 只清 `value` 不够:若此刻正有一次构建在飞,它读的是变更前的历史,落地后会把旧数据
|
|
197
|
+
* 又写回缓存。所以连 `promise` 一起清,并用 completion generation 兜住(与
|
|
198
|
+
* handleSessionRead 同款守卫,见那里的注释)。
|
|
199
|
+
*
|
|
200
|
+
* @param {object} ctx
|
|
201
|
+
* @param {string} [deviceKey] 只清这一台设备的缓存;缺省清掉该 ctx 下所有设备。
|
|
202
|
+
*/
|
|
203
|
+
function invalidateSessionList(ctx, deviceKey) {
|
|
204
|
+
const byDevice = sessionListResponseCache.get(ctx);
|
|
205
|
+
if (!byDevice) return;
|
|
206
|
+
const keys = deviceKey === undefined ? [...byDevice.keys()] : [deviceKey];
|
|
207
|
+
for (const key of keys) {
|
|
208
|
+
const state = byDevice.get(key);
|
|
209
|
+
if (!state) continue;
|
|
210
|
+
state.value = null;
|
|
211
|
+
state.updatedAt = 0;
|
|
212
|
+
state.promise = null;
|
|
213
|
+
bumpCompletionGeneration(ctx, key);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 丢弃会话列表缓存,但**不抬 completion generation**。
|
|
219
|
+
*
|
|
220
|
+
* 与 invalidateSessionList 的分工:那个用于「写操作刚发生」,必须连在飞构建与完成态
|
|
221
|
+
* 一起作废;这个用于「宿主的清单内容变了,我(镜像推送调度器)先看见了」。后者在生成期
|
|
222
|
+
* 每 500ms 就会发生一次(会话的 updatedAt 一直在动),如果也抬 generation,
|
|
223
|
+
* buildSessionsResponse 里那句 `completionGenerationOf(...) === generation` 就永远不成立,
|
|
224
|
+
* `confirmCompletionFromHistory` 再也执行不到 —— 侧栏的 completed / 红点会彻底不亮。
|
|
225
|
+
*
|
|
226
|
+
* 所以在飞构建靠状态自带的「朝代」(droppedAt)而不是 generation 来作废:handleSessions
|
|
227
|
+
* 建构建前记下朝代,落地时发现朝代变了就不把旧内容写回缓存。这样既不需要抬 generation,
|
|
228
|
+
* 又不会出现「变更前发起的那次构建在变更后落地,把旧列表按新时间戳缓存 10 秒」。
|
|
229
|
+
*
|
|
230
|
+
* @param {object} ctx
|
|
231
|
+
* @param {string} [deviceKey] 只丢这一台设备的缓存;缺省丢掉该 ctx 下所有设备。
|
|
232
|
+
*/
|
|
233
|
+
function dropSessionListCache(ctx, deviceKey) {
|
|
234
|
+
const byDevice = sessionListResponseCache.get(ctx);
|
|
235
|
+
if (!byDevice) return;
|
|
236
|
+
const keys = deviceKey === undefined ? [...byDevice.keys()] : [deviceKey];
|
|
237
|
+
for (const key of keys) {
|
|
238
|
+
const state = byDevice.get(key);
|
|
239
|
+
if (!state) continue;
|
|
240
|
+
state.value = null;
|
|
241
|
+
state.updatedAt = 0;
|
|
242
|
+
state.droppedAt = (state.droppedAt || 0) + 1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
canonicalDeviceKey,
|
|
248
|
+
sessionModelsStateOf,
|
|
249
|
+
invalidateSessionModels,
|
|
250
|
+
sessionListResponseState,
|
|
251
|
+
invalidateSessionList,
|
|
252
|
+
dropSessionListCache,
|
|
253
|
+
sessionPreviewStateOf,
|
|
254
|
+
sessionPreviewInFlightStateOf,
|
|
255
|
+
sessionPreviewClockOf,
|
|
256
|
+
mirrorHistoryStateOf,
|
|
257
|
+
completionStateOf,
|
|
258
|
+
clearCompletionForAllDevices,
|
|
259
|
+
completionGenerationOf,
|
|
260
|
+
bumpCompletionGeneration,
|
|
261
|
+
sendIdempotencyStateOf,
|
|
262
|
+
writeIdempotencyStateOf,
|
|
263
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** 目标条动作透传:edit / pause / resume / clear。ref 来自 goal 会话投影,宿主以它做 CAS。 */
|
|
4
|
+
|
|
5
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
6
|
+
const { unwrap } = require('./envelope.js');
|
|
7
|
+
|
|
8
|
+
/** 调宿主 apiProxy.goals.*(目标条的动作:edit/pause/resume/clear)。 */
|
|
9
|
+
function callGoals(ctx, method, payload) {
|
|
10
|
+
return ctx.apiProxy.goals[method]({ rpcId: 'mobile', method: `goals.${method}`, payload });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* POST /api/mobile/v1/session/goal(token){ action, ref, objective? }
|
|
15
|
+
* 目标条动作透传:pause / resume / clear / edit(edit 需 objective)。
|
|
16
|
+
* ref 来自 goal 会话投影({id, revision}),宿主以 ref 做 CAS 守卫。
|
|
17
|
+
*/
|
|
18
|
+
async function handleGoalAction(ctx, req, res) {
|
|
19
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
20
|
+
const action = body && typeof body.action === 'string' ? body.action : '';
|
|
21
|
+
const ref = body && body.ref;
|
|
22
|
+
const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId : '';
|
|
23
|
+
if (!action || !sessionId || !ref || typeof ref !== 'object') {
|
|
24
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, action, ref }' } });
|
|
25
|
+
}
|
|
26
|
+
if (action === 'edit') {
|
|
27
|
+
const objective = body.objective;
|
|
28
|
+
if (typeof objective !== 'string' || !objective.trim()) {
|
|
29
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'edit 需要非空 objective' } });
|
|
30
|
+
}
|
|
31
|
+
const { value, error } = unwrap(await callGoals(ctx, 'edit', { sessionId, ref, objective: objective.trim() }));
|
|
32
|
+
if (error) return sendJson(res, 400, { ok: false, error });
|
|
33
|
+
return sendJson(res, 200, { ok: true, ...(value || {}) });
|
|
34
|
+
}
|
|
35
|
+
const allowed = new Set(['pause', 'resume', 'clear']);
|
|
36
|
+
if (!allowed.has(action)) {
|
|
37
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: `不支持的目标动作: ${action}` } });
|
|
38
|
+
}
|
|
39
|
+
const { value, error } = unwrap(await callGoals(ctx, action, { sessionId, ref }));
|
|
40
|
+
if (error) return sendJson(res, 400, { ok: false, error });
|
|
41
|
+
sendJson(res, 200, { ok: true, ...(value || {}) });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { callGoals, handleGoalAction };
|