hiwork-core 0.0.11 → 0.0.17
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 +6 -0
- package/lib/ai-router-client.js +255 -1
- package/lib/client.js +815 -36
- package/lib/index.js +1098 -2
- package/lib/managed-skills.js +446 -0
- package/lib/office-credentials.js +113 -0
- package/lib/office-relay.js +113 -0
- package/lib/plugin-updates-rpc.js +187 -0
- package/lib/types/ai-router-client.d.ts +244 -0
- package/lib/types/client/contracts.d.ts +38 -0
- package/lib/types/client/managed-skills.d.ts +218 -0
- package/lib/types/client/managed-workbench.d.ts +212 -0
- package/lib/types/client/plugin-updates.d.ts +150 -0
- package/lib/types/client/styles.d.ts +27 -0
- package/lib/types/client/types.d.ts +27 -1
- package/lib/types/client/user-badge.d.ts +12 -2
- package/lib/types/managed-skills.d.ts +261 -0
- package/lib/types/office-credentials.d.ts +99 -0
- package/lib/types/office-relay.d.ts +105 -0
- package/lib/types/plugin-updates-rpc.d.ts +169 -0
- package/lib/types/workbench-bridge.d.ts +259 -0
- package/lib/workbench-bridge.js +447 -0
- package/package.json +1 -1
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/hiwork-workbench` loopback RPC 的 Host 实现:管理员登记并分配给当前账号的**工作台应用清单**。
|
|
3
|
+
*
|
|
4
|
+
* 契约见 spec `docs/superpowers/specs/2026-09-17-hiwork-workbench-design.md` §4.1(清单 JSON)与
|
|
5
|
+
* §4.5(Host 面)。本文件是 `src/managed-skills.ts` 同一副骨架的**第三条链**,也是三条里最短的一条:
|
|
6
|
+
* 工作台 M0 只做「看得到货架」,因此
|
|
7
|
+
* - 端点只有两个:`snapshot`(你现在有什么)/ `refresh`(现在去拉一次)。**没有 `package` 那一档**
|
|
8
|
+
* (清单里没有大字节,一条 GET 全量下发);
|
|
9
|
+
* - **M1 的四个端点(`submit` / `poll` / `cancel` / `materialize`)刻意不在本文件里**:它们要动
|
|
10
|
+
* 工作区落盘与上传字节,属于任务运行时的范围(plan T5 的「M0 的边界」)。别为了「一次写完」
|
|
11
|
+
* 顺手加上——那会把 M0 的验收面撑成 M1。
|
|
12
|
+
*
|
|
13
|
+
* 与 MCP / 技能逐字同款的三条铁律:
|
|
14
|
+
* - **网关会话 cookie 绝不离开 Host**:cookie 只在 `resolveSession()` 内部出现,客户端拿到的是
|
|
15
|
+
* 清单(经 loopback RPC 过去,里面没有任何令牌);
|
|
16
|
+
* - **拉清单失败绝不清空上一次成功的清单**:失败返回「保留旧值 + 标注失败原因」,于是断网时
|
|
17
|
+
* 界面还能显示上次成功缓存 + 「可能不是最新」(spec §10 的离线那一行);
|
|
18
|
+
* - **`snapshot` 可能陈旧、只有 `refresh` 的成功返回值可作真相**:下架 / 撤销分配后卡片消失
|
|
19
|
+
* 只能基于 `refresh` 的返回值判定(spec §4.5)。
|
|
20
|
+
*
|
|
21
|
+
* 与技能清单不同的一处:**远端会回 404**(网关没配置工作台时那条路由压根没注册,spec §10),
|
|
22
|
+
* 它被映射成 `WORKBENCH_DISABLED` 而非「网关不可达」——这是可展示的功能状态,不是故障。
|
|
23
|
+
*
|
|
24
|
+
* 演进纪律(spec §4.1):**未知字段忽略;未知 `executor.kind` / `inputs[].type` 原样透传**。
|
|
25
|
+
* 本文件的规范化只做「形状兜底 + 已知字段改名(`icon` → `iconUrl`)」,绝不按白名单过滤语义
|
|
26
|
+
* ——老客户端遇到新 kind 由客户端置灰 + 提示升级(spec D2),Host 不替它做丢弃决策。
|
|
27
|
+
*
|
|
28
|
+
* 本模块不 import cordis 运行时,只依赖结构化的 {@link WorkbenchRpcDeps} 端口,便于单测直接伪造。
|
|
29
|
+
*/
|
|
30
|
+
import { GatewayError, } from "./ai-router-client.js";
|
|
31
|
+
/** RPC 频道;必须与 client 半边 `src/client/managed-workbench.ts` 的 `MANAGED_WORKBENCH_CHANNEL` 一致(由测试锁定)。 */
|
|
32
|
+
export const WORKBENCH_RPC_CHANNEL = '/hiwork-workbench';
|
|
33
|
+
/**
|
|
34
|
+
* 端点白名单:M0 只有「拿清单」这一件事的两个入口。
|
|
35
|
+
*
|
|
36
|
+
* `snapshot` 是「你现在有什么」(有缓存就直接给,冷启动才拉一次),`refresh` 是「现在去拉一次」。
|
|
37
|
+
* 分两个端点的理由与 MCP / 技能同款:同步策略(启动 / 超 TTL / 手动刷新)由消费方自己定,
|
|
38
|
+
* Host 不该替它决定什么时候必须联网。
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ `submit` / `poll` / `cancel` / `materialize` 属 M1(spec §4.5),**不许提前登记**:
|
|
41
|
+
* 白名单是客户端可用的能力边界,多一项就等于先发布了一个没有实现的承诺。
|
|
42
|
+
*/
|
|
43
|
+
export const WORKBENCH_RPC_ENDPOINTS = ['snapshot', 'refresh'];
|
|
44
|
+
/**
|
|
45
|
+
* Host 侧后台预热间隔:30 分钟(与 MCP / 技能两份同量级)。
|
|
46
|
+
* 它**不是**权威节奏——只为让客户端首次 `snapshot` 不必等一次网络往返。
|
|
47
|
+
*/
|
|
48
|
+
export const WORKBENCH_REFRESH_INTERVAL_MS = 30 * 60_000;
|
|
49
|
+
/** 请求侧错误(只有 `bad-request`:未知端点 / 非法 payload)。 */
|
|
50
|
+
export class WorkbenchRequestError extends Error {
|
|
51
|
+
code;
|
|
52
|
+
constructor(code, message) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.code = code;
|
|
55
|
+
this.name = 'WorkbenchRequestError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** 通用失败文案:不暴露任何网关细节。 */
|
|
59
|
+
export const WORKBENCH_GENERIC_FAILURE_MESSAGE = '工作台应用清单暂时无法获取,请稍后重试';
|
|
60
|
+
/** 未登录 / 会话失效的统一文案(与用量、每日一句、MCP / 技能清单几个频道同口径)。 */
|
|
61
|
+
export const WORKBENCH_SESSION_EXPIRED_MESSAGE = '登录状态已过期,请重新登录';
|
|
62
|
+
/** 本部署未启用工作台(网关 404)的文案:spec §10 的降级矩阵里这一行要给人可行动的提示。 */
|
|
63
|
+
export const WORKBENCH_DISABLED_MESSAGE = '本部署未启用工作台,请联系管理员';
|
|
64
|
+
/** 工作台未启用时的稳定 code(与网关客户端的映射一致,消费方按它走降级文案)。 */
|
|
65
|
+
export const WORKBENCH_DISABLED_CODE = 'WORKBENCH_DISABLED';
|
|
66
|
+
/**
|
|
67
|
+
* 各稳定错误码的用户可见文案(网关 message 含实现细节时用它兜底)。
|
|
68
|
+
* 只列本频道**真会收到**的 code:工作台 M0 没有「应用已下架」那一档——下架的表现是
|
|
69
|
+
* 「这次清单里没有这条」,不是一条错误(spec §10)。
|
|
70
|
+
*/
|
|
71
|
+
const WORKBENCH_MESSAGE_FALLBACK = {
|
|
72
|
+
SESSION_EXPIRED: WORKBENCH_SESSION_EXPIRED_MESSAGE,
|
|
73
|
+
GATEWAY_UNREACHABLE: WORKBENCH_GENERIC_FAILURE_MESSAGE,
|
|
74
|
+
[WORKBENCH_DISABLED_CODE]: WORKBENCH_DISABLED_MESSAGE,
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* 网关 message 消毒(与 `usage-rpc.ts` 的 `displayGatewayMessage` 同实现,MCP / 技能那两半逐字同款):
|
|
78
|
+
* 含 baseURL / HTTP 状态码 / URL 的原始消息一律换成稳定文案,其余(网关自己写好的中文业务文案)
|
|
79
|
+
* 原样保留。code 不受影响,始终原样透传。
|
|
80
|
+
*/
|
|
81
|
+
export function displayWorkbenchMessage(code, message) {
|
|
82
|
+
const text = typeof message === 'string' ? message.trim() : '';
|
|
83
|
+
if (text === '')
|
|
84
|
+
return WORKBENCH_MESSAGE_FALLBACK[code] ?? WORKBENCH_GENERIC_FAILURE_MESSAGE;
|
|
85
|
+
const leaksImplementationDetail = /https?:\/\//i.test(text)
|
|
86
|
+
|| /\bHTTP\s*\d{3}\b/i.test(text)
|
|
87
|
+
|| /\bbaseURL\b/i.test(text);
|
|
88
|
+
if (leaksImplementationDetail)
|
|
89
|
+
return WORKBENCH_MESSAGE_FALLBACK[code] ?? WORKBENCH_GENERIC_FAILURE_MESSAGE;
|
|
90
|
+
return text;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* payload 校验:两个端点都**只放行空对象**(M0 没有任何入参)。
|
|
94
|
+
*
|
|
95
|
+
* 与技能清单的空入参分支逐字同款:出现任何字段都是客户端实现缺陷,直接 `bad-request`
|
|
96
|
+
* ——一个 `{ code: 'pdf-to-word' }` 被静默忽略,会让调用方以为「按 code 过滤」生效了。
|
|
97
|
+
*/
|
|
98
|
+
export function parseWorkbenchPayload(endpoint, raw) {
|
|
99
|
+
if (raw === undefined || raw === null)
|
|
100
|
+
return {};
|
|
101
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
102
|
+
throw new WorkbenchRequestError('bad-request', '工作台清单请求参数不合法。');
|
|
103
|
+
}
|
|
104
|
+
const record = raw;
|
|
105
|
+
const keys = Object.keys(record);
|
|
106
|
+
const first = keys[0];
|
|
107
|
+
if (first !== undefined) {
|
|
108
|
+
throw new WorkbenchRequestError('bad-request', `工作台清单请求包含未知字段 '${first}'。`);
|
|
109
|
+
}
|
|
110
|
+
// endpoint 已在调用方过白名单;这里保留参数是为了类型上与其它频道的 parse* 同形。
|
|
111
|
+
void endpoint;
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
/** 是否为白名单端点。 */
|
|
115
|
+
export function isWorkbenchRpcEndpoint(endpoint) {
|
|
116
|
+
return WORKBENCH_RPC_ENDPOINTS.includes(endpoint);
|
|
117
|
+
}
|
|
118
|
+
/** 非空字符串或 undefined(清单里一个字段坏了不该让整条清单失败)。 */
|
|
119
|
+
function textOrUndefined(value) {
|
|
120
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
121
|
+
}
|
|
122
|
+
/** 宽松的记录判定(数组与 null 都不算)。 */
|
|
123
|
+
function isRecord(value) {
|
|
124
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
125
|
+
}
|
|
126
|
+
/** 字符串数组(非字符串项丢弃;这是形状兜底,不是语义白名单)。 */
|
|
127
|
+
function stringArray(value) {
|
|
128
|
+
if (!Array.isArray(value))
|
|
129
|
+
return [];
|
|
130
|
+
return value.filter((item) => typeof item === 'string' && item.trim() !== '');
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* 执行器规范化:**唯一要求是 `kind` 存在且是非空字符串**。
|
|
134
|
+
*
|
|
135
|
+
* 三个刻意的选择:
|
|
136
|
+
* - 认不认识的 kind 都照发(`kind` 是开放集合,spec §4.1 的演进纪律;老客户端遇到新 kind
|
|
137
|
+
* 自己置灰 + 提示升级,见 spec D2);
|
|
138
|
+
* - 未知键**原样保留**(`submitPath` / `limits` / 未来的 `target` 都在这里过去);
|
|
139
|
+
* - 非字符串的 `mode` 丢掉而不是透传:契约面声明的就是 `mode?: string`,留一个数字进去
|
|
140
|
+
* 等于让消费方拿到一个自己类型没声明过的值。
|
|
141
|
+
*/
|
|
142
|
+
function normalizeExecutor(raw) {
|
|
143
|
+
if (!isRecord(raw))
|
|
144
|
+
return undefined;
|
|
145
|
+
const kind = textOrUndefined(raw['kind']);
|
|
146
|
+
if (kind === undefined)
|
|
147
|
+
return undefined;
|
|
148
|
+
const executor = { ...raw, kind };
|
|
149
|
+
const mode = textOrUndefined(raw['mode']);
|
|
150
|
+
if (mode === undefined)
|
|
151
|
+
delete executor.mode;
|
|
152
|
+
else
|
|
153
|
+
executor.mode = mode;
|
|
154
|
+
return executor;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* 输入项规范化:**缺 `key` 的整条丢掉**(没有 key 就没有表单绑定的坐标),其余全部兜底。
|
|
158
|
+
*
|
|
159
|
+
* `type` 不认识的照样发出去(spec §4.1:未知 type → 该应用置灰,不是整页崩);`accept` /
|
|
160
|
+
* `options` / `default` 这些**只对某些 type 有意义**的键靠 `...raw` 原样带走。
|
|
161
|
+
*/
|
|
162
|
+
function normalizeInput(raw) {
|
|
163
|
+
if (!isRecord(raw))
|
|
164
|
+
return undefined;
|
|
165
|
+
const key = textOrUndefined(raw['key']);
|
|
166
|
+
if (key === undefined)
|
|
167
|
+
return undefined;
|
|
168
|
+
return {
|
|
169
|
+
...raw,
|
|
170
|
+
key,
|
|
171
|
+
type: textOrUndefined(raw['type']) ?? '',
|
|
172
|
+
label: textOrUndefined(raw['label']) ?? key,
|
|
173
|
+
required: raw['required'] === true,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 输出项规范化:与输入项同款,但只有 `key` / `type` 两个契约字段
|
|
178
|
+
* (产物怎么命名由网关侧决定,客户端不参与)。
|
|
179
|
+
*/
|
|
180
|
+
function normalizeOutput(raw) {
|
|
181
|
+
if (!isRecord(raw))
|
|
182
|
+
return undefined;
|
|
183
|
+
const key = textOrUndefined(raw['key']);
|
|
184
|
+
if (key === undefined)
|
|
185
|
+
return undefined;
|
|
186
|
+
return { ...raw, key, type: textOrUndefined(raw['type']) ?? '' };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* 把网关响应里的一条应用规范化成 {@link WorkbenchAppView}。
|
|
190
|
+
*
|
|
191
|
+
* **只在两种情况下丢条目**:不是对象,或缺了 `code` / `executor.kind`
|
|
192
|
+
* ——前者是卡片与路径的唯一坐标,后者是「这条能不能跑」的唯一判据(少了它连「置灰 + 提示升级」
|
|
193
|
+
* 都渲染不出来)。丢弃时记一条日志,因为服务端数据问题的证据在 Host 侧,客户端看不到网关响应。
|
|
194
|
+
*
|
|
195
|
+
* **其余一律兜底而不丢**:`displayName` 缺省回 `code`、`summary` / `tags` / `effects` 缺省为空。
|
|
196
|
+
* 这与「未知 kind 透传」是同一条纪律的两面:Host 不做语义过滤,只保证形状可用。
|
|
197
|
+
*/
|
|
198
|
+
function normalizeApp(raw, log) {
|
|
199
|
+
if (!isRecord(raw))
|
|
200
|
+
return undefined;
|
|
201
|
+
const code = textOrUndefined(raw['code']);
|
|
202
|
+
const executor = normalizeExecutor(raw['executor']);
|
|
203
|
+
if (code === undefined || executor === undefined) {
|
|
204
|
+
log?.('hiwork-core: 工作台清单里有一条结构不完整的应用(缺 code 或 executor.kind),已跳过');
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
const iconUrl = textOrUndefined(raw['icon']);
|
|
208
|
+
const category = isRecord(raw['category']) ? raw['category'] : undefined;
|
|
209
|
+
const inputs = [];
|
|
210
|
+
for (const item of Array.isArray(raw['inputs']) ? raw['inputs'] : []) {
|
|
211
|
+
const normalized = normalizeInput(item);
|
|
212
|
+
if (normalized !== undefined)
|
|
213
|
+
inputs.push(normalized);
|
|
214
|
+
}
|
|
215
|
+
const outputs = [];
|
|
216
|
+
for (const item of Array.isArray(raw['outputs']) ? raw['outputs'] : []) {
|
|
217
|
+
const normalized = normalizeOutput(item);
|
|
218
|
+
if (normalized !== undefined)
|
|
219
|
+
outputs.push(normalized);
|
|
220
|
+
}
|
|
221
|
+
const revision = raw['revision'];
|
|
222
|
+
return {
|
|
223
|
+
code,
|
|
224
|
+
displayName: textOrUndefined(raw['displayName']) ?? code,
|
|
225
|
+
summary: textOrUndefined(raw['summary']) ?? '',
|
|
226
|
+
// `exactOptionalPropertyTypes` 下不许显式写 undefined,用条件展开。
|
|
227
|
+
...(iconUrl === undefined ? {} : { iconUrl }),
|
|
228
|
+
category: {
|
|
229
|
+
code: textOrUndefined(category?.['code']) ?? '',
|
|
230
|
+
name: textOrUndefined(category?.['name']) ?? '',
|
|
231
|
+
},
|
|
232
|
+
tags: stringArray(raw['tags']),
|
|
233
|
+
featured: raw['featured'] === true,
|
|
234
|
+
revision: typeof revision === 'number' && Number.isFinite(revision) ? revision : 0,
|
|
235
|
+
executor,
|
|
236
|
+
inputs,
|
|
237
|
+
outputs,
|
|
238
|
+
effects: stringArray(raw['effects']),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** 任意异常 → 可展示的失败(网关 code 透传,message 消毒;其余收敛为通用失败)。 */
|
|
242
|
+
function failureOf(error) {
|
|
243
|
+
if (error instanceof WorkbenchRequestError)
|
|
244
|
+
return { code: error.code, message: error.message, details: {} };
|
|
245
|
+
if (error instanceof GatewayError) {
|
|
246
|
+
return { code: error.code, message: displayWorkbenchMessage(error.code, error.message), details: {} };
|
|
247
|
+
}
|
|
248
|
+
return { code: 'internal', message: WORKBENCH_GENERIC_FAILURE_MESSAGE, details: {} };
|
|
249
|
+
}
|
|
250
|
+
/** 会话 cookie 规范化:空串与 undefined 同义(都是「当前没有会话」),与 `resolveSession` 的口径一致。 */
|
|
251
|
+
function normalizeSession(sessionCookie) {
|
|
252
|
+
return sessionCookie === undefined || sessionCookie === '' ? undefined : sessionCookie;
|
|
253
|
+
}
|
|
254
|
+
/** 取消判定(用函数包一层,避免 TS 把属性窄化固化到 `await` 之后)。 */
|
|
255
|
+
function isAborted(signal) {
|
|
256
|
+
return signal?.aborted === true;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* 清单缓存:**只有成功响应才覆写**,失败只记原因;且缓存**按会话绑定**(见 {@link WorkbenchStore.bindSession})。
|
|
260
|
+
*
|
|
261
|
+
* 「成功」的判据里含**响应形状**:200 但响应不是「带数组 `apps` 的对象」按**取数失败**处理
|
|
262
|
+
* (见 {@link WorkbenchStore.fetchOnce})——那种响应是中间层给的,不是服务端说「你没有应用」,
|
|
263
|
+
* 读成空清单会让货架在反代维护页面前清空,用户以为管理员把应用全撤了(spec §10 的离线态本意
|
|
264
|
+
* 是「显示上次成功缓存 + 可能不是最新」,不是「什么都不显示」)。
|
|
265
|
+
*
|
|
266
|
+
* 进程内的(不落盘)是有意的:清单是「服务端此刻认什么」的投影,跨重启的旧清单没有价值
|
|
267
|
+
* ——重启后消费方会立刻 `warm()` 一次,拿到新的比拿到旧的更安全。
|
|
268
|
+
*/
|
|
269
|
+
export class WorkbenchStore {
|
|
270
|
+
deps;
|
|
271
|
+
now;
|
|
272
|
+
cached;
|
|
273
|
+
lastError;
|
|
274
|
+
/** 产生 `cached` 的那个会话 cookie(只在内存里)。会话绑定的全部依据就是它。 */
|
|
275
|
+
cachedSession;
|
|
276
|
+
/**
|
|
277
|
+
* 在飞请求连同它所用的会话一起记账:并发合并**只允许发生在同一会话内**。
|
|
278
|
+
* 合并跨会话等于把 A 那次拉取的结果交给 B(登出 / 换账号后货架会显示上一个人的应用)。
|
|
279
|
+
*/
|
|
280
|
+
inFlight;
|
|
281
|
+
constructor(deps, now = () => Date.now()) {
|
|
282
|
+
this.deps = deps;
|
|
283
|
+
this.now = now;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* 当前缓存(同步读,不发请求)。从没成功过返回 undefined;
|
|
287
|
+
* 最近一次失败会以 `stale + lastError` 标注在上一次成功的结果上。
|
|
288
|
+
*
|
|
289
|
+
* ⚠️ **本方法不做会话绑定**——它是 spec §4.5 冻结的同步读,而当前会话只能异步解析(见
|
|
290
|
+
* {@link peekForCurrentSession}),同步函数里根本判不了「这份缓存还算不算我的」。
|
|
291
|
+
* 所以模块内唯一的正确用法是:先 `await store.peekForCurrentSession()`,再(或由它内部)`peek()`。
|
|
292
|
+
* 直接调 `peek()` 只适合诊断与测试断言内存状态。
|
|
293
|
+
*/
|
|
294
|
+
peek() {
|
|
295
|
+
if (this.cached === undefined)
|
|
296
|
+
return undefined;
|
|
297
|
+
return this.lastError === undefined
|
|
298
|
+
? this.cached
|
|
299
|
+
: { ...this.cached, stale: true, lastError: this.lastError };
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* 「当前会话下的缓存」= 解析当前会话 → 剪枝 → {@link peek}。
|
|
303
|
+
*
|
|
304
|
+
* 与 `src/managed-skills.ts` 的同名方法逐字同款:`peek()` 是 spec §4.5 冻结的**同步**接口,而
|
|
305
|
+
* 「当前是谁」只有 `deps.resolveSession()` 异步拿得到,于是分工只能是「async 的一侧先绑会话,
|
|
306
|
+
* sync 的 `peek()` 再读」。端点实现必须走它、不许绕过。
|
|
307
|
+
*/
|
|
308
|
+
async peekForCurrentSession() {
|
|
309
|
+
this.bindSession(normalizeSession(await this.deps.resolveSession()));
|
|
310
|
+
return this.peek();
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* 会话绑定:缓存只属于产生它的那个会话;当前会话不是它(含**当前没有会话**)→ 立刻丢弃。
|
|
314
|
+
*
|
|
315
|
+
* 与技能清单那半逐字同款(那里写了为什么**不许**为了「两半对齐」改回 MCP 的写法):
|
|
316
|
+
* 绑会话**自带**——不依赖任何调用方、不依赖谁记得清缓存,登出后(当前会话为空)与换账号后
|
|
317
|
+
* 都不会再吐出上一个人的清单。`clear()` 也没有任何调用方(端点白名单够不到 Host 内存),
|
|
318
|
+
* 把安全不变量挂在一个没人调的钩子上正是已经失败过一次的做法。
|
|
319
|
+
*
|
|
320
|
+
* 它也**不**触碰「失败不覆写缓存」那条铁律:剪枝的触发条件是**会话变了**,不是「这一次拉取
|
|
321
|
+
* 失败了」。失败路径仍然原样保留本会话上一次成功的清单(`stale + lastError`)。
|
|
322
|
+
*/
|
|
323
|
+
bindSession(current) {
|
|
324
|
+
if (current === this.cachedSession)
|
|
325
|
+
return;
|
|
326
|
+
this.cached = undefined;
|
|
327
|
+
this.cachedSession = undefined;
|
|
328
|
+
// 失败记录也是**按会话的**:换了人(或登出)之后,上一个人的 lastError 与这一份都不该
|
|
329
|
+
// 继续挂在账上——否则新的 `snapshot` 会把别人的失败当作自己的「最近一次失败」展示。
|
|
330
|
+
this.lastError = undefined;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* 去网关拉一次清单并更新缓存。失败时抛异常,且**本会话的缓存原样保留**。
|
|
334
|
+
*
|
|
335
|
+
* 并发调用合并成一次请求:后台预热与消费方的手动刷新撞在一起时不必打两遍网关。
|
|
336
|
+
* 合并**只在同一会话内**——两个会话合并到一起,就等于把 A 的结果发给 B。
|
|
337
|
+
*/
|
|
338
|
+
async load() {
|
|
339
|
+
const session = normalizeSession(await this.deps.resolveSession());
|
|
340
|
+
const inFlight = this.inFlight;
|
|
341
|
+
if (inFlight !== undefined && inFlight.session === session)
|
|
342
|
+
return await inFlight.task;
|
|
343
|
+
const task = this.fetchOnce(session);
|
|
344
|
+
this.inFlight = { session, task };
|
|
345
|
+
try {
|
|
346
|
+
return await task;
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
// 只有自己那次还在账上才清:更晚发起的(另一个会话的)请求不该被这一笔收尾抹掉。
|
|
350
|
+
if (this.inFlight?.task === task)
|
|
351
|
+
this.inFlight = undefined;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* 清空内存清单(登出 / 换账号调用)。
|
|
356
|
+
*
|
|
357
|
+
* ⚠️ **当前没有任何调用方**:这是 spec §4.5 冻结要求提供的接口(与技能清单那半同款),而它的
|
|
358
|
+
* 用途已经被**会话绑定**覆盖——缓存按会话剪枝,登出 / 换账号后再也吐不出上一个人的清单。
|
|
359
|
+
* 保留它是为了不改冻结接口;真要删得先改 spec §4.5。冻结接口里的成员不要悄悄删掉。
|
|
360
|
+
*/
|
|
361
|
+
clear() {
|
|
362
|
+
this.cached = undefined;
|
|
363
|
+
this.lastError = undefined;
|
|
364
|
+
this.cachedSession = undefined;
|
|
365
|
+
}
|
|
366
|
+
async fetchOnce(sessionCookie) {
|
|
367
|
+
try {
|
|
368
|
+
if (sessionCookie === undefined) {
|
|
369
|
+
throw new GatewayError('SESSION_EXPIRED', WORKBENCH_SESSION_EXPIRED_MESSAGE);
|
|
370
|
+
}
|
|
371
|
+
const gateway = this.deps.gateway();
|
|
372
|
+
if (gateway === undefined) {
|
|
373
|
+
throw new GatewayError('GATEWAY_UNREACHABLE', '网关地址未配置,请先在「网关模型」里完成登录');
|
|
374
|
+
}
|
|
375
|
+
const view = await gateway.getMyWorkbenchApps(sessionCookie);
|
|
376
|
+
// 200 + 能解析的 JSON,但**不是「带数组 apps 的对象」**⇒ 按取数失败处理,绝不
|
|
377
|
+
// 当成「成功拉到的空清单」。这类响应只可能来自夹在中间的层(反代维护页 / SSO 门户 /
|
|
378
|
+
// 任何在 200 上回自己那份 JSON 的东西)——服务端这条端点的 200 形状是冻结契约(spec §4.1)。
|
|
379
|
+
// 读成空清单的后果是货架在故障期间被清空,用户据此以为管理员把应用全下架了。
|
|
380
|
+
if (!isRecord(view) || !Array.isArray(view.apps)) {
|
|
381
|
+
this.deps.log?.('hiwork-core: 工作台清单响应不是带 apps 数组的对象(可能的中间层),按取数失败处理');
|
|
382
|
+
throw new GatewayError('GATEWAY_UNREACHABLE', WORKBENCH_GENERIC_FAILURE_MESSAGE);
|
|
383
|
+
}
|
|
384
|
+
const apps = [];
|
|
385
|
+
for (const item of view.apps) {
|
|
386
|
+
const normalized = normalizeApp(item, this.deps.log);
|
|
387
|
+
if (normalized !== undefined)
|
|
388
|
+
apps.push(normalized);
|
|
389
|
+
}
|
|
390
|
+
const snapshot = {
|
|
391
|
+
revision: typeof view.revision === 'number' && Number.isFinite(view.revision) ? view.revision : 0,
|
|
392
|
+
fetchedAt: new Date(this.now()).toISOString(),
|
|
393
|
+
apps,
|
|
394
|
+
stale: false,
|
|
395
|
+
};
|
|
396
|
+
// 成功才覆写,并**连同产生它的会话**一起记账:会话绑定(bindSession)之后要靠这一行。
|
|
397
|
+
// 迟到的写回(旧会话的请求在换账号之后才返回)不构成漏洞:它只会把 cachedSession 记回旧
|
|
398
|
+
// 会话,而任何一次读取都要先过 bindSession——不匹配就当场丢弃,永远到不了调用方手里。
|
|
399
|
+
this.cached = snapshot;
|
|
400
|
+
this.cachedSession = sessionCookie;
|
|
401
|
+
this.lastError = undefined;
|
|
402
|
+
return snapshot;
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
this.lastError = failureOf(error);
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
|
|
412
|
+
* 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
|
|
413
|
+
*/
|
|
414
|
+
export async function handleWorkbenchEndpoint(endpoint, rawPayload, deps, store, signal) {
|
|
415
|
+
try {
|
|
416
|
+
if (isAborted(signal))
|
|
417
|
+
return failure('cancelled', '工作台清单请求已取消。');
|
|
418
|
+
if (!isWorkbenchRpcEndpoint(endpoint)) {
|
|
419
|
+
throw new WorkbenchRequestError('bad-request', `未知的工作台清单端点 '${endpoint}'`);
|
|
420
|
+
}
|
|
421
|
+
parseWorkbenchPayload(endpoint, rawPayload);
|
|
422
|
+
// snapshot:有缓存就直接给(可能是 stale),冷启动才走一次网络。
|
|
423
|
+
// 注意这里**没有**直接用 `store.peek()`:spec §4.5 的 `peek()` 是同步读、拿不到当前会话,
|
|
424
|
+
// 而缓存必须按会话绑定(登出 / 换账号后不许再吐出上一个人的清单),所以走 async 前置
|
|
425
|
+
// `peekForCurrentSession()`——它先解析当前 cookie 做一次同步剪枝,再返回缓存。两个端点都走它,
|
|
426
|
+
// 于是 `refresh` 也会顺手剪掉已经不匹配的缓存(拉失败时也不会留着别人的旧清单)。
|
|
427
|
+
const cached = await store.peekForCurrentSession();
|
|
428
|
+
if (endpoint === 'snapshot' && cached !== undefined)
|
|
429
|
+
return { ok: true, value: cached };
|
|
430
|
+
return { ok: true, value: await store.load() };
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
deps.log?.(`hiwork-core: 工作台清单 RPC '${endpoint}' 失败(${error instanceof Error ? error.name : typeof error})`);
|
|
434
|
+
return { ok: false, error: failureOf(error) };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function failure(code, message) {
|
|
438
|
+
return { ok: false, error: { code, message, details: {} } };
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* 注册 `/hiwork-workbench` 频道,返回异步 disposer(交给 `ctx.effect` 管理生命周期)。
|
|
442
|
+
*/
|
|
443
|
+
export function registerWorkbenchRpc(ctx, deps, store) {
|
|
444
|
+
const handler = (endpoint, payload, signal) => handleWorkbenchEndpoint(endpoint, payload, deps, store, signal);
|
|
445
|
+
const remove = ctx.connection.rpc.handle(WORKBENCH_RPC_CHANNEL, handler, { authority: 'loopback' });
|
|
446
|
+
return async () => { await remove(); };
|
|
447
|
+
}
|