hiwork-core 0.0.38 → 0.0.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/ai-router-client.js +152 -4
- package/lib/browser-login.js +9 -2
- package/lib/client-policy.js +77 -2
- package/lib/index.js +452 -44
- package/lib/knowledge-config.js +14 -12
- package/lib/memory-config.js +7 -7
- package/lib/orchestration.js +50 -3
- package/lib/provision-session.js +1 -0
- package/lib/provision.js +5 -0
- package/lib/session-fanout.js +29 -0
- package/lib/session-supervisor.js +156 -0
- package/lib/ttl-cache.js +45 -0
- package/lib/types/ai-router-client.d.ts +74 -0
- package/lib/types/client/types.d.ts +7 -1
- package/lib/types/client-policy.d.ts +30 -2
- package/lib/types/index.d.ts +5 -0
- package/lib/types/orchestration.d.ts +5 -3
- package/lib/types/provision-session.d.ts +7 -0
- package/lib/types/provision.d.ts +5 -0
- package/lib/types/session-fanout.d.ts +19 -0
- package/lib/types/session-supervisor.d.ts +101 -0
- package/lib/types/ttl-cache.d.ts +29 -0
- package/lib/types/types.d.ts +6 -0
- package/package.json +1 -1
package/lib/knowledge-config.js
CHANGED
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
* 本模块不 import cordis 运行时:provide 走结构化的 {@link KnowledgeConfigContext} 端口,
|
|
46
46
|
* 调用方(index.ts)传真实 ctx;单测直接伪造。
|
|
47
47
|
*/
|
|
48
|
+
import { TtlCache } from "./ttl-cache.js";
|
|
48
49
|
import { PKG_VERSION } from "./version.js";
|
|
49
50
|
/** cordis 服务名(hiwork-knowledge 的 Host 半边按此名 `ctx.get` 探测)。 */
|
|
50
51
|
export const HIWORK_KNOWLEDGE_SERVICE = 'hiworkKnowledge';
|
|
@@ -188,11 +189,12 @@ function reasonOfStatus(status) {
|
|
|
188
189
|
return 'unreachable';
|
|
189
190
|
return 'error';
|
|
190
191
|
}
|
|
191
|
-
/** 视图投影 + 5
|
|
192
|
+
/** 视图投影 + 5 分钟内存缓存(无状态依赖,只有两条会话绑定的 TTL 缓存)。 */
|
|
192
193
|
class KnowledgeConfigProvider {
|
|
193
194
|
deps;
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
configCache = new TtlCache({ ttlMs: KNOWLEDGE_CONFIG_TTL_MS });
|
|
196
|
+
// 令牌缓存比配置缓存短 30s(KNOWLEDGE_TOKEN_CACHE_TTL_MS 已扣掉余量,见其注释)。
|
|
197
|
+
tokenCache = new TtlCache({ ttlMs: KNOWLEDGE_TOKEN_CACHE_TTL_MS });
|
|
196
198
|
constructor(deps) {
|
|
197
199
|
this.deps = deps;
|
|
198
200
|
}
|
|
@@ -208,9 +210,10 @@ class KnowledgeConfigProvider {
|
|
|
208
210
|
}
|
|
209
211
|
if (!sessionCookie)
|
|
210
212
|
return failure('unauthenticated');
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
213
|
+
if (!force) {
|
|
214
|
+
const hit = this.tokenCache.get(sessionCookie, this.now());
|
|
215
|
+
if (hit !== undefined)
|
|
216
|
+
return hit;
|
|
214
217
|
}
|
|
215
218
|
const gateway = this.deps.gateway();
|
|
216
219
|
if (!gateway)
|
|
@@ -238,7 +241,7 @@ class KnowledgeConfigProvider {
|
|
|
238
241
|
return id ? [{ id, name: textOrUndefined(obj['name']) ?? '' }] : [];
|
|
239
242
|
}).filter(item => knowledgeBaseIds.includes(item.id)) : [];
|
|
240
243
|
const view = { ok: true, token, audience, knowledgeBaseIds, knowledgeBases };
|
|
241
|
-
this.
|
|
244
|
+
this.tokenCache.put(sessionCookie, view, this.now());
|
|
242
245
|
return view;
|
|
243
246
|
}
|
|
244
247
|
catch (error) {
|
|
@@ -263,10 +266,9 @@ class KnowledgeConfigProvider {
|
|
|
263
266
|
if (sessionCookie === undefined || sessionCookie === '')
|
|
264
267
|
return failure('unauthenticated');
|
|
265
268
|
if (!force) {
|
|
266
|
-
const hit = this.
|
|
267
|
-
if (hit !== undefined
|
|
268
|
-
return hit
|
|
269
|
-
}
|
|
269
|
+
const hit = this.configCache.get(sessionCookie, this.now());
|
|
270
|
+
if (hit !== undefined)
|
|
271
|
+
return hit;
|
|
270
272
|
}
|
|
271
273
|
const gateway = this.deps.gateway();
|
|
272
274
|
// 没有网关地址 = 还没完成登录(baseURL 是登录时写下的),与网络不通归同一档。
|
|
@@ -297,7 +299,7 @@ class KnowledgeConfigProvider {
|
|
|
297
299
|
return failure('error');
|
|
298
300
|
}
|
|
299
301
|
// 只缓存成功结果:拉不到时下一次调用重新出网(一次抖动不该锁住一个 TTL)。
|
|
300
|
-
this.
|
|
302
|
+
this.configCache.put(sessionCookie, view, this.now());
|
|
301
303
|
return view;
|
|
302
304
|
}
|
|
303
305
|
now() {
|
package/lib/memory-config.js
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
* 本模块不 import cordis 运行时:provide 走结构化的 {@link MemoryConfigContext} 端口,
|
|
33
33
|
* 调用方(index.ts)传真实 ctx;单测直接伪造。
|
|
34
34
|
*/
|
|
35
|
+
import { TtlCache } from "./ttl-cache.js";
|
|
35
36
|
import { PKG_VERSION } from "./version.js";
|
|
36
37
|
/** cordis 服务名(hiwork-memory 的 Host 半边按此名 `ctx.get` 探测)。 */
|
|
37
38
|
export const HIWORK_MEMORY_SERVICE = 'hiworkMemory';
|
|
@@ -145,10 +146,10 @@ function reasonOfStatus(status) {
|
|
|
145
146
|
return 'unreachable';
|
|
146
147
|
return 'error';
|
|
147
148
|
}
|
|
148
|
-
/** 视图投影 + 5
|
|
149
|
+
/** 视图投影 + 5 分钟内存缓存(无状态依赖,只有一条会话绑定的 TTL 缓存)。 */
|
|
149
150
|
class MemoryConfigProvider {
|
|
150
151
|
deps;
|
|
151
|
-
|
|
152
|
+
cache = new TtlCache({ ttlMs: MEMORY_CONFIG_TTL_MS });
|
|
152
153
|
constructor(deps) {
|
|
153
154
|
this.deps = deps;
|
|
154
155
|
}
|
|
@@ -166,10 +167,9 @@ class MemoryConfigProvider {
|
|
|
166
167
|
if (sessionCookie === undefined || sessionCookie === '')
|
|
167
168
|
return failure('unauthenticated');
|
|
168
169
|
if (!force) {
|
|
169
|
-
const hit = this.
|
|
170
|
-
if (hit !== undefined
|
|
171
|
-
return hit
|
|
172
|
-
}
|
|
170
|
+
const hit = this.cache.get(sessionCookie, this.now());
|
|
171
|
+
if (hit !== undefined)
|
|
172
|
+
return hit;
|
|
173
173
|
}
|
|
174
174
|
const gateway = this.deps.gateway();
|
|
175
175
|
// 没有网关地址 = 还没完成登录(baseURL 是登录时写下的),与网络不通归同一档。
|
|
@@ -200,7 +200,7 @@ class MemoryConfigProvider {
|
|
|
200
200
|
return failure('error');
|
|
201
201
|
}
|
|
202
202
|
// 只缓存成功结果:拉不到时下一次调用重新出网(一次抖动不该锁住一个 TTL)。
|
|
203
|
-
this.
|
|
203
|
+
this.cache.put(sessionCookie, view, this.now());
|
|
204
204
|
return view;
|
|
205
205
|
}
|
|
206
206
|
now() {
|
package/lib/orchestration.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { GatewayError } from "./ai-router-client.js";
|
|
2
2
|
import { NO_BASE_URL_ERROR, resolveDeviceInfo, startBrowserLogin } from "./browser-login.js";
|
|
3
|
-
import { API_KEY_REF, SESSION_REF, PENDING_PASSWORD_REF, REMEMBERED_PASSWORD_REF, buildPiAiProviderProfiles, computeModelIds, computeRouteModels, } from "./provision.js";
|
|
3
|
+
import { API_KEY_REF, SESSION_REF, REFRESH_TOKEN_REF, PENDING_PASSWORD_REF, REMEMBERED_PASSWORD_REF, buildPiAiProviderProfiles, computeModelIds, computeRouteModels, } from "./provision.js";
|
|
4
4
|
import { computeBudgets, computeKeys, provisionSession, toLoginError, } from "./provision-session.js";
|
|
5
5
|
// 端口契约与供给尾部现居 src/provision-session.ts(供 browser-login.ts 共用且避免循环依赖);
|
|
6
6
|
// 此处原样再导出,保持既有导入面(index.ts / 测试)不变。
|
|
@@ -23,14 +23,21 @@ export async function runLogin(opts) {
|
|
|
23
23
|
// 表单回退也上报设备信息(网关记录登录设备);设备未知时 resolveDeviceInfo 会生成,
|
|
24
24
|
// 任何情况下都不得因设备信息让登录失败。
|
|
25
25
|
const device = await resolveDeviceInfo(opts.credentials);
|
|
26
|
-
const { sessionCookie, user } = await opts.gateway.login(pending.username, password.value, device);
|
|
26
|
+
const { sessionCookie, user, sessionExpiresAt, refreshToken } = await opts.gateway.login(pending.username, password.value, device);
|
|
27
27
|
await opts.credentials.set(SESSION_REF, sessionCookie);
|
|
28
|
+
// grant 必须与会话同批落盘:新登录成功却没拿到 grant(老网关)时要清掉残留的旧值,
|
|
29
|
+
// 否则上一次登录/上一个账号的 grant 会与新会话并存(会话已换、可续期的还是旧账号)。
|
|
30
|
+
if (refreshToken === undefined)
|
|
31
|
+
await opts.credentials.unset(REFRESH_TOKEN_REF).catch(() => { });
|
|
32
|
+
else
|
|
33
|
+
await opts.credentials.set(REFRESH_TOKEN_REF, refreshToken);
|
|
28
34
|
await provisionSession({
|
|
29
35
|
...opts,
|
|
30
36
|
baseURL,
|
|
31
37
|
sessionCookie,
|
|
32
38
|
user,
|
|
33
39
|
username: pending.username,
|
|
40
|
+
sessionExpiresAt,
|
|
34
41
|
requestUnset: ['pendingLogin'],
|
|
35
42
|
onApiKeyWritten: async () => {
|
|
36
43
|
if (opts.value.rememberPassword === true)
|
|
@@ -57,8 +64,17 @@ export async function runLogin(opts) {
|
|
|
57
64
|
return loginError;
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
|
-
/**
|
|
67
|
+
/** 用已存会话刷新模型列表(会话过期时先试 refresh grant 静默重认证,再回落记住密码/登录门)。 */
|
|
61
68
|
export async function runRefresh(opts) {
|
|
69
|
+
return refreshWith(opts, false);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `runRefresh` 的实现体。`grantRetried` 是**内部标志**:只有本函数在「refresh grant 换到新会话、
|
|
73
|
+
* 带新 SESSION_REF 重跑供给」时置 true;公共入口 `runRefresh` 永远传 false,外部无法传入,
|
|
74
|
+
* 因此一次刷新里 grant 路径最多走一次——第二次仍 SESSION_EXPIRED 时直接回落到既有链路
|
|
75
|
+
* (记住密码重登 / 清会话开登录门),不会用同一个 grant 打转。
|
|
76
|
+
*/
|
|
77
|
+
async function refreshWith(opts, grantRetried) {
|
|
62
78
|
const baseURL = opts.value.baseURL;
|
|
63
79
|
if (baseURL === undefined) {
|
|
64
80
|
await opts.state.patch({ unset: ['refreshRequest'] });
|
|
@@ -97,6 +113,34 @@ export async function runRefresh(opts) {
|
|
|
97
113
|
}
|
|
98
114
|
catch (error) {
|
|
99
115
|
const refreshError = toLoginError(error, '无法连接网关,请检查 baseURL 或网络');
|
|
116
|
+
if (refreshError.code === 'SESSION_EXPIRED' && !grantRetried) {
|
|
117
|
+
// 会话过期先试 refresh grant(M2 网关):成功即拿到新会话与轮换后的 grant,重跑一次供给。
|
|
118
|
+
const grant = await opts.credentials.resolve(REFRESH_TOKEN_REF).catch(() => undefined);
|
|
119
|
+
if (grant !== undefined && grant.value !== '') {
|
|
120
|
+
try {
|
|
121
|
+
const renewed = await opts.gateway.refreshSession(grant.value);
|
|
122
|
+
await opts.credentials.set(SESSION_REF, renewed.sessionCookie);
|
|
123
|
+
// 轮换:新 grant 落盘;响应没回 token(已消费)时清掉,绝不留下可能已被作废的旧值。
|
|
124
|
+
if (renewed.refreshToken === undefined)
|
|
125
|
+
await opts.credentials.unset(REFRESH_TOKEN_REF).catch(() => { });
|
|
126
|
+
else
|
|
127
|
+
await opts.credentials.set(REFRESH_TOKEN_REF, renewed.refreshToken);
|
|
128
|
+
// 新会话的绝对寿命只在网关给了 expires_at 时更新;没给就保留旧估计,不用编造值。
|
|
129
|
+
if (renewed.sessionExpiresAt !== undefined) {
|
|
130
|
+
await opts.state.patch({ set: { sessionExpiresAt: renewed.sessionExpiresAt } });
|
|
131
|
+
}
|
|
132
|
+
return refreshWith(opts, true);
|
|
133
|
+
}
|
|
134
|
+
catch (grantError) {
|
|
135
|
+
// 只有「grant 明确作废」才丢弃本地值:400(失效/已轮换)= 该 grant 没用了;
|
|
136
|
+
// 404 = 老网关没有这条路由,静默弃用。其余(网络/5xx)保留 grant,下次再试。
|
|
137
|
+
const code = grantError instanceof GatewayError ? grantError.code : undefined;
|
|
138
|
+
if (code === 'REFRESH_GRANT_INVALID' || code === 'REFRESH_GRANT_ABSENT') {
|
|
139
|
+
await opts.credentials.unset(REFRESH_TOKEN_REF).catch(() => { });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
100
144
|
if (refreshError.code === 'SESSION_EXPIRED' && opts.value.rememberPassword === true && opts.value.username !== undefined) {
|
|
101
145
|
const remembered = await opts.credentials.resolve(REMEMBERED_PASSWORD_REF).catch(() => undefined);
|
|
102
146
|
if (remembered !== undefined) {
|
|
@@ -124,6 +168,7 @@ export async function runLogout(opts) {
|
|
|
124
168
|
}
|
|
125
169
|
await opts.credentials.unset(API_KEY_REF).catch(() => { });
|
|
126
170
|
await opts.credentials.unset(SESSION_REF).catch(() => { });
|
|
171
|
+
await opts.credentials.unset(REFRESH_TOKEN_REF).catch(() => { });
|
|
127
172
|
await opts.credentials.unset(PENDING_PASSWORD_REF).catch(() => { });
|
|
128
173
|
await opts.credentials.unset(REMEMBERED_PASSWORD_REF).catch(() => { });
|
|
129
174
|
await opts.piAi.applyAiRouterProfiles(null);
|
|
@@ -131,6 +176,8 @@ export async function runLogout(opts) {
|
|
|
131
176
|
unset: [
|
|
132
177
|
'pendingLogin', 'refreshRequest', 'logoutRequest', 'browserLoginRequest', 'browserLogin',
|
|
133
178
|
'username', 'displayName', 'lastLogin', 'lastError', 'keyId', 'budgets', 'keys',
|
|
179
|
+
// 会话寿命随会话一起清空:残留的旧 expiresAt 会让保活决策基于已失效的会话。
|
|
180
|
+
'sessionExpiresAt',
|
|
134
181
|
],
|
|
135
182
|
});
|
|
136
183
|
}
|
package/lib/provision-session.js
CHANGED
|
@@ -70,6 +70,7 @@ export async function provisionSession(opts) {
|
|
|
70
70
|
const displayName = opts.user.display_name?.trim() || undefined;
|
|
71
71
|
const set = {
|
|
72
72
|
...(opts.username === undefined ? {} : { username: opts.username }),
|
|
73
|
+
...(opts.sessionExpiresAt === undefined ? {} : { sessionExpiresAt: opts.sessionExpiresAt }),
|
|
73
74
|
...(displayName === undefined ? {} : { displayName }),
|
|
74
75
|
lastLogin: {
|
|
75
76
|
at: new Date().toISOString(),
|
package/lib/provision.js
CHANGED
|
@@ -6,6 +6,11 @@ export const SESSION_REF = 'AI_ROUTER_SESSION';
|
|
|
6
6
|
export const PENDING_PASSWORD_REF = 'AI_ROUTER_PENDING_PASSWORD';
|
|
7
7
|
/** 勾选“记住密码”后保存的凭据 ref(由 credentials provider 加密存储)。 */
|
|
8
8
|
export const REMEMBERED_PASSWORD_REF = 'AI_ROUTER_REMEMBERED_PASSWORD';
|
|
9
|
+
/**
|
|
10
|
+
* 网关 refresh grant 的凭据 ref 名(M2 起 login/token 响应下发,登出/换新时清理)。
|
|
11
|
+
* 仅供 `runRefresh` 的静默重认证使用:绝不出现在状态或日志里,老网关没有它时静默弃用。
|
|
12
|
+
*/
|
|
13
|
+
export const REFRESH_TOKEN_REF = 'AI_ROUTER_REFRESH_TOKEN';
|
|
9
14
|
/**
|
|
10
15
|
* 注册到 llm-pi-ai 的协议路由表:路由名 → pi-ai 协议。
|
|
11
16
|
* 网关按「入站协议 + 模型」匹配模型映射(internal/service/provider_service.go),
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话供给成功后的联动刷新(spec §4.2)。
|
|
3
|
+
*
|
|
4
|
+
* 会话刚建立 / 刚续上时,**按人 + 按会话**下发的派生清单必须立刻对齐一次:client policy、
|
|
5
|
+
* 受管 MCP、受管技能、工作台应用。这些清单各自的 30 分钟(策略是 6 小时)预热只保证
|
|
6
|
+
* 「最迟那么久之后」收敛;换了账号、会话重建、或长期挂机后重新登录,旧清单在预热窗口内
|
|
7
|
+
* 就是错的。所以供给成功是一条事件,而不是等下一个定时器。
|
|
8
|
+
*
|
|
9
|
+
* 这里把「四件事依次触发」收成一个纯函数:任一 handler 抛异常只记日志、不影响其余
|
|
10
|
+
* ——刷新是尽力而为的旁路,绝不能反过来把登录 / 续期主链路带崩。
|
|
11
|
+
*/
|
|
12
|
+
export function buildSessionFanout(handlers) {
|
|
13
|
+
const entries = [
|
|
14
|
+
['client policy', handlers.onPolicyRefresh],
|
|
15
|
+
['managed mcp', handlers.onManagedMcpRefresh],
|
|
16
|
+
['managed skills', handlers.onManagedSkillsRefresh],
|
|
17
|
+
['workbench', handlers.onWorkbenchRefresh],
|
|
18
|
+
];
|
|
19
|
+
return () => {
|
|
20
|
+
for (const [label, run] of entries) {
|
|
21
|
+
try {
|
|
22
|
+
run();
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
handlers.log?.(`hiwork-core: 会话联动刷新「${label}」失败(${error instanceof Error ? error.name : typeof error})`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { GatewayError } from "./ai-router-client.js";
|
|
2
|
+
/**
|
|
3
|
+
* 会话心跳保活(spec §4.1b)。
|
|
4
|
+
*
|
|
5
|
+
* 网关会话是**服务端滑动续期**的:任意已认证请求在剩余寿命 < TTL/2 时会被续满。但桌面端
|
|
6
|
+
* 长时间挂机(不点任何功能)就不会发出任何已认证请求,续期永远不会触发,24h 后会话必过期。
|
|
7
|
+
* 本模块用一个兜底定时器(每小时醒一次),在「近期没有任何已认证请求成功」且「会话可能快
|
|
8
|
+
* 到期」时主动打一个轻量会话请求,把会话按住。
|
|
9
|
+
*
|
|
10
|
+
* 寿命预算:客户端不一定知道服务端真实 TTL(旧网关的 Set-Cookie 不带过期属性、`/api/auth/token`
|
|
11
|
+
* 与表单登录也不回 expires_at),所以用 {@link SESSION_TTL_ASSUMPTION_MS}(24h,与服务端缺省
|
|
12
|
+
* `session.duration` 一致)作为**安全上界**估算:只要在「剩余不足假定寿命一半」时打一次心跳,
|
|
13
|
+
* 服务端就一定会把它续满。新网关在带会话的成功响应里下发 `X-Session-Expires-At`,
|
|
14
|
+
* 心跳响应也可能带精确寿命——这些值先进内存估计({@link SessionSupervisor.noteAuthedSuccess}),
|
|
15
|
+
* 用来判定「还要不要打心跳」;只有心跳成功才写 settings(热路径零写入)。
|
|
16
|
+
*
|
|
17
|
+
* 只读端口 + 单向动作:本模块不持有任何凭据、不写日志里的会话值;会话已失效(401)的恢复
|
|
18
|
+
* 动作由调用方经 401 汇聚接线(Task A3),这里只记中文日志、绝不向宿主抛。
|
|
19
|
+
*/
|
|
20
|
+
/** 假定会话总寿命(服务端 `session.duration` 缺省 24h;滑动续期的安全上界)。 */
|
|
21
|
+
export const SESSION_TTL_ASSUMPTION_MS = 24 * 60 * 60_000;
|
|
22
|
+
/** 心跳兜底检查间隔(1h)。 */
|
|
23
|
+
export const SESSION_CHECK_INTERVAL_MS = 60 * 60_000;
|
|
24
|
+
/**
|
|
25
|
+
* 会话恢复的冷却窗口(60s):多通道并发 401(voice / usage / MCP …)只触发一次恢复,
|
|
26
|
+
* 且恢复动作本身(写 refreshRequest → 静默重登或开登录门)不会在窗口内被反复排队。
|
|
27
|
+
*/
|
|
28
|
+
export const SESSION_RECOVERY_COOLDOWN_MS = 60_000;
|
|
29
|
+
export class SessionSupervisor {
|
|
30
|
+
deps;
|
|
31
|
+
timer;
|
|
32
|
+
/** 上次「已知会话存活」的时刻;undefined = 本进程内还没确认过(重启后的首次检查照打)。 */
|
|
33
|
+
lastAuthedSuccessAt;
|
|
34
|
+
/**
|
|
35
|
+
* 最近一次带会话成功响应里 `X-Session-Expires-At` 给出的**内存**寿命估计(原样时刻串)。
|
|
36
|
+
* 只活在内存里——热路径绝不写 settings(Review Focus 5),只有心跳成功才落盘。
|
|
37
|
+
*/
|
|
38
|
+
lastHeaderExpiresAt;
|
|
39
|
+
/** 恢复请求是否在途(单飞去抖)。 */
|
|
40
|
+
recoveryInFlight = false;
|
|
41
|
+
/** 上次触发恢复的时刻(冷却窗口基准)。 */
|
|
42
|
+
lastRecoveryAt;
|
|
43
|
+
constructor(deps) {
|
|
44
|
+
this.deps = deps;
|
|
45
|
+
}
|
|
46
|
+
/** 开始兜底检查;重复调用不叠加定时器。 */
|
|
47
|
+
start() {
|
|
48
|
+
if (this.timer !== undefined)
|
|
49
|
+
return;
|
|
50
|
+
this.timer = setInterval(() => { void this.wake(); }, SESSION_CHECK_INTERVAL_MS);
|
|
51
|
+
this.timer.unref?.();
|
|
52
|
+
}
|
|
53
|
+
/** 停止兜底检查(调用后零活动)。 */
|
|
54
|
+
stop() {
|
|
55
|
+
if (this.timer === undefined)
|
|
56
|
+
return;
|
|
57
|
+
clearInterval(this.timer);
|
|
58
|
+
this.timer = undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 带会话的请求成功 → 记活动时间(由 ai-router-client 的活动汇聚驱动)。
|
|
62
|
+
* `expiresAt` 为响应头 `X-Session-Expires-At` 给出的会话寿命:**只更新内存估计**,
|
|
63
|
+
* 绝不在此写 settings——热路径上每个成功请求都写盘会打爆设置面(Review Focus 5)。
|
|
64
|
+
* 不可解析的值直接丢弃(当作没给),不覆盖既有估计。
|
|
65
|
+
*/
|
|
66
|
+
noteAuthedSuccess(expiresAt) {
|
|
67
|
+
this.lastAuthedSuccessAt = this.now();
|
|
68
|
+
if (expiresAt !== undefined && !Number.isNaN(Date.parse(expiresAt)))
|
|
69
|
+
this.lastHeaderExpiresAt = expiresAt;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 带会话的请求收到 401 → 去抖后触发一次会话恢复。
|
|
73
|
+
* 去抖有两层:`recoveryInFlight`(单飞,覆盖「恢复请求还没落地,同刻又来了 5 条 401」)
|
|
74
|
+
* 与 `SESSION_RECOVERY_COOLDOWN_MS` 冷却(覆盖恢复完成后短时间内的余波 401)。
|
|
75
|
+
* 恢复失败只记日志——apply 后写入面需要几秒才就绪,那一侧的失败不该惊动宿主。
|
|
76
|
+
*/
|
|
77
|
+
reportAuthed401(source) {
|
|
78
|
+
if (this.recoveryInFlight)
|
|
79
|
+
return;
|
|
80
|
+
const now = this.now();
|
|
81
|
+
if (this.lastRecoveryAt !== undefined && now - this.lastRecoveryAt < SESSION_RECOVERY_COOLDOWN_MS)
|
|
82
|
+
return;
|
|
83
|
+
this.recoveryInFlight = true;
|
|
84
|
+
this.lastRecoveryAt = now;
|
|
85
|
+
void this.deps.requestRecovery()
|
|
86
|
+
.catch(() => { this.log(`hiwork-core: 会话恢复触发失败(来源 ${source}),等待下个 401 或心跳重试`); })
|
|
87
|
+
.finally(() => { this.recoveryInFlight = false; });
|
|
88
|
+
}
|
|
89
|
+
now() {
|
|
90
|
+
return this.deps.now?.() ?? Date.now();
|
|
91
|
+
}
|
|
92
|
+
log(message) {
|
|
93
|
+
this.deps.log?.(message);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 一次兜底检查。判定顺序(早退即不发请求):
|
|
97
|
+
* 无会话 / 无 baseURL → 返回;距上次已认证成功不足 TTL/2 → 返回;
|
|
98
|
+
* 已知剩余寿命 > TTL/2 → 返回;否则 ping。
|
|
99
|
+
* 寿命未知也 ping——重启后的首次检查即校验会话是否还活着。
|
|
100
|
+
* 「已知剩余寿命」取 settings 值与内存估计(响应头)的**较大者**:头值只会更晚更准,
|
|
101
|
+
* 用较大者才不会因为 settings 里的旧估算偏早而白打心跳。
|
|
102
|
+
*/
|
|
103
|
+
async wake() {
|
|
104
|
+
try {
|
|
105
|
+
const session = await this.deps.getSession();
|
|
106
|
+
if (session === undefined || session === '')
|
|
107
|
+
return;
|
|
108
|
+
if (this.deps.getBaseURL() === undefined)
|
|
109
|
+
return;
|
|
110
|
+
const now = this.now();
|
|
111
|
+
if (this.lastAuthedSuccessAt !== undefined && now - this.lastAuthedSuccessAt < SESSION_TTL_ASSUMPTION_MS / 2)
|
|
112
|
+
return;
|
|
113
|
+
const knownExpiresAtMs = this.knownExpiresAtMs();
|
|
114
|
+
if (knownExpiresAtMs !== undefined && knownExpiresAtMs - now > SESSION_TTL_ASSUMPTION_MS / 2)
|
|
115
|
+
return;
|
|
116
|
+
let pinged;
|
|
117
|
+
try {
|
|
118
|
+
pinged = await this.deps.ping(session);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
// 会话失效(401)与网络异常一律不抛:前者交给 401 汇聚触发恢复,后者下个周期再试。
|
|
122
|
+
const expired = error instanceof GatewayError && error.code === 'SESSION_EXPIRED';
|
|
123
|
+
this.log(expired
|
|
124
|
+
? 'hiwork-core: 会话心跳发现会话已失效,等待 401 汇聚触发恢复'
|
|
125
|
+
: 'hiwork-core: 会话心跳请求失败,将在下个周期重试');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// 服务端已按滑动续期处理本次请求;能拿到精确寿命就用它,否则按安全上界估算回写。
|
|
129
|
+
const pingedExpiresAt = pinged?.expiresAt;
|
|
130
|
+
const pingedMs = pingedExpiresAt === undefined ? Number.NaN : Date.parse(pingedExpiresAt);
|
|
131
|
+
const nextExpiresAt = pingedExpiresAt === undefined || Number.isNaN(pingedMs)
|
|
132
|
+
? new Date(this.now() + SESSION_TTL_ASSUMPTION_MS).toISOString()
|
|
133
|
+
: pingedExpiresAt;
|
|
134
|
+
try {
|
|
135
|
+
await this.deps.patchSessionExpiresAt(nextExpiresAt);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
this.log('hiwork-core: 会话心跳成功但寿命回写失败,将在下个周期重试');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// 读取端口(凭据存储 / 设置面)抛异常也不得打断定时器或惊动宿主。
|
|
143
|
+
this.log('hiwork-core: 会话心跳检查失败,将在下个周期重试');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* 已知的会话到期时刻(epoch ms):settings 值与内存头值里**有效且较晚**的那个;
|
|
148
|
+
* 两者都没有(或都不可解析)返回 undefined = 寿命未知。
|
|
149
|
+
*/
|
|
150
|
+
knownExpiresAtMs() {
|
|
151
|
+
const values = [this.deps.getSessionExpiresAt(), this.lastHeaderExpiresAt]
|
|
152
|
+
.map((iso) => (iso === undefined ? Number.NaN : Date.parse(iso)))
|
|
153
|
+
.filter((ms) => !Number.isNaN(ms));
|
|
154
|
+
return values.length === 0 ? undefined : Math.max(...values);
|
|
155
|
+
}
|
|
156
|
+
}
|
package/lib/ttl-cache.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话绑定的 TTL 缓存原语(spec §4.4)。
|
|
3
|
+
*
|
|
4
|
+
* knowledge / memory / image / usage 各自手写过一遍「会话配 TTL」的内存缓存,语义已经
|
|
5
|
+
* 开始漂移。原语只做三件事,把「成功才 put、失败不 put、force 旁路」留给调用方:
|
|
6
|
+
* - 值与**会话键**绑定:读别人的会话键一定 miss(换账号绝不串号);
|
|
7
|
+
* - 命中条件是 `now - putAt < ttlMs`(严格小于,与既有实现逐字一致);
|
|
8
|
+
* - `invalidate` 支持全清与单键清理。
|
|
9
|
+
*
|
|
10
|
+
* **只保留一条条目**(不是每个键一条):这与迁移前的既有实现逐字等价——写入新会话的
|
|
11
|
+
* 缓存会顶掉旧会话那条。这条语义被 knowledge 的既有测试钉着(换账号再换回来必须重新
|
|
12
|
+
* 出网),也是「一次只服务一个登录账号」的诚实表达,故不改成 Map。
|
|
13
|
+
*
|
|
14
|
+
* 余量(skew)不在这里做:不同调用方的提前量不同(如 knowledge 令牌要扣 30s),
|
|
15
|
+
* 由调用方在 `ttlMs` 里自己扣——原语只认一个 TTL,不猜语义。
|
|
16
|
+
*/
|
|
17
|
+
export class TtlCache {
|
|
18
|
+
ttlMs;
|
|
19
|
+
entry;
|
|
20
|
+
constructor(opts) {
|
|
21
|
+
this.ttlMs = opts.ttlMs;
|
|
22
|
+
}
|
|
23
|
+
/** 命中返回缓存值;会话键不同或已到期返回 undefined。`now` 可注入(缺省 Date.now)。 */
|
|
24
|
+
get(sessionKey, now = Date.now()) {
|
|
25
|
+
const hit = this.entry;
|
|
26
|
+
if (hit === undefined || hit.sessionKey !== sessionKey)
|
|
27
|
+
return undefined;
|
|
28
|
+
if (now - hit.at >= this.ttlMs)
|
|
29
|
+
return undefined;
|
|
30
|
+
return hit.value;
|
|
31
|
+
}
|
|
32
|
+
/** 写入/覆盖缓存(新会话键会顶掉旧会话那条)。 */
|
|
33
|
+
put(sessionKey, value, now = Date.now()) {
|
|
34
|
+
this.entry = { sessionKey, value, at: now };
|
|
35
|
+
}
|
|
36
|
+
/** 清掉指定会话键的条目(不影响别的键,虽然只会存在一条);不传参数则清空。 */
|
|
37
|
+
invalidate(sessionKey) {
|
|
38
|
+
if (sessionKey === undefined) {
|
|
39
|
+
this.entry = undefined;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (this.entry?.sessionKey === sessionKey)
|
|
43
|
+
this.entry = undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
import type { DeviceSession, LoginErrorCode } from './types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* 带会话请求的活动事件(spec §4.1b/c):成功 = 会话仍然活着,401 = 会话已失效。
|
|
4
|
+
* 这是「隐式心跳」与「401 统一汇聚」的唯一信号源——**只带 kind 与会话寿命,不带任何请求细节**
|
|
5
|
+
* (URL / 响应体 / cookie 都不外传),避免凭据经观察者泄漏到日志。
|
|
6
|
+
* success 的 `expiresAt` 是响应头 `X-Session-Expires-At`(网关 C5 起下发)解析出的会话绝对
|
|
7
|
+
* 寿命(原样透传服务端时刻串);头缺失或不可解析时该字段不出现。
|
|
8
|
+
*/
|
|
9
|
+
export type SessionActivityEvent = {
|
|
10
|
+
kind: 'success';
|
|
11
|
+
expiresAt?: string;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'unauthorized';
|
|
14
|
+
};
|
|
15
|
+
/** 挂载活动 sink(传 undefined 等价于清除,便于测试与宿主无处可摘时兜底)。 */
|
|
16
|
+
export declare function setSessionActivitySink(sink: ((event: SessionActivityEvent) => void) | undefined): void;
|
|
17
|
+
/**
|
|
18
|
+
* 清除活动 sink,**仅当当前 sink 就是入参那一个**。
|
|
19
|
+
* HMR 会重挂同一个模块:旧 fiber 的清理回调可能在新的 sink 挂上之后才跑,
|
|
20
|
+
* 无条件清空会把新挂载的观察者一起摘掉(心跳与 401 汇聚会静默失效)。
|
|
21
|
+
*/
|
|
22
|
+
export declare function clearSessionActivitySink(sink: (event: SessionActivityEvent) => void): void;
|
|
2
23
|
/**
|
|
3
24
|
* 生图的单次请求超时(**按网关下发的预算算**,不是常量)。
|
|
4
25
|
*
|
|
@@ -585,10 +606,16 @@ export interface GatewayClient {
|
|
|
585
606
|
* 表单登录(loginMode=form 的回退路径)。
|
|
586
607
|
* `device` 为可选设备信息:已知时随请求上报 `X-HiWork-*` 头(供网关记录登录设备),
|
|
587
608
|
* 未知则不发——**设备信息缺失绝不能让登录失败**。
|
|
609
|
+
* `sessionExpiresAt`:从 Set-Cookie 的 `Expires`/`Max-Age` 解析出的会话绝对寿命(ISO),
|
|
610
|
+
* 旧网关不下发过期属性时为 undefined(调用方按假定寿命估算)。
|
|
611
|
+
* `refreshToken`:M2 网关下发的 refresh grant(非空字符串才收);老网关缺失时为 undefined,
|
|
612
|
+
* 此时调用方要清掉本地残留的旧 grant。
|
|
588
613
|
*/
|
|
589
614
|
login(username: string, password: string, device?: DeviceIdentity): Promise<{
|
|
590
615
|
sessionCookie: string;
|
|
591
616
|
user: GatewayUser;
|
|
617
|
+
sessionExpiresAt?: string;
|
|
618
|
+
refreshToken?: string;
|
|
592
619
|
}>;
|
|
593
620
|
/**
|
|
594
621
|
* 浏览器授权登录:用一次性 code + PKCE verifier 换取会话(返回形状与 login 一致)。
|
|
@@ -601,6 +628,19 @@ export interface GatewayClient {
|
|
|
601
628
|
}): Promise<{
|
|
602
629
|
sessionCookie: string;
|
|
603
630
|
user: GatewayUser;
|
|
631
|
+
sessionExpiresAt?: string;
|
|
632
|
+
refreshToken?: string;
|
|
633
|
+
}>;
|
|
634
|
+
/**
|
|
635
|
+
* 用 refresh grant 换一份新会话(M2 网关的静默重认证)。**不调用它做探测**:
|
|
636
|
+
* 老网关(没有 `/api/auth/refresh`)会回 404,调用方按 `REFRESH_GRANT_ABSENT` 静默弃用本地 grant。
|
|
637
|
+
* 成功后本地必须落盘新的 `sessionCookie` 与轮换后的 `refreshToken`(响应没回 token 时清除)。
|
|
638
|
+
*/
|
|
639
|
+
refreshSession(refreshToken: string): Promise<{
|
|
640
|
+
sessionCookie: string;
|
|
641
|
+
user: GatewayUser;
|
|
642
|
+
sessionExpiresAt?: string;
|
|
643
|
+
refreshToken?: string;
|
|
604
644
|
}>;
|
|
605
645
|
/** 当前账号的登录设备列表。 */
|
|
606
646
|
listSessions(sessionCookie: string): Promise<DeviceSession[]>;
|
|
@@ -666,6 +706,8 @@ export declare class AiRouterClient implements GatewayClient {
|
|
|
666
706
|
login(username: string, password: string, device?: DeviceIdentity): Promise<{
|
|
667
707
|
sessionCookie: string;
|
|
668
708
|
user: GatewayUser;
|
|
709
|
+
sessionExpiresAt?: string;
|
|
710
|
+
refreshToken?: string;
|
|
669
711
|
}>;
|
|
670
712
|
/**
|
|
671
713
|
* POST /api/auth/token:用一次性 code + PKCE verifier 换取会话。
|
|
@@ -679,6 +721,25 @@ export declare class AiRouterClient implements GatewayClient {
|
|
|
679
721
|
}): Promise<{
|
|
680
722
|
sessionCookie: string;
|
|
681
723
|
user: GatewayUser;
|
|
724
|
+
sessionExpiresAt?: string;
|
|
725
|
+
refreshToken?: string;
|
|
726
|
+
}>;
|
|
727
|
+
/**
|
|
728
|
+
* POST /api/auth/refresh:用 refresh grant 换一份新会话(M2 网关的静默重认证)。
|
|
729
|
+
*
|
|
730
|
+
* 拒绝语义必须分开,调用方据此决定「弃用 grant」还是「保留待重试」:
|
|
731
|
+
* - 400 = grant 无效(过期 / 已被轮换 / 复用检测拒绝)→ `REFRESH_GRANT_INVALID`,本地作废;
|
|
732
|
+
* - 404 = 老网关根本没有这条路由 → `REFRESH_GRANT_ABSENT`,本地作废且**静默降级**;
|
|
733
|
+
* - 其余非 2xx(5xx / 反代错误页)与网络层异常 → `GATEWAY_UNREACHABLE`,grant 保留,
|
|
734
|
+
* 下次 SESSION_EXPIRED 再试。
|
|
735
|
+
*
|
|
736
|
+
* 任何日志/异常信息都不得包含 refresh_token / session_id 明文。
|
|
737
|
+
*/
|
|
738
|
+
refreshSession(refreshToken: string): Promise<{
|
|
739
|
+
sessionCookie: string;
|
|
740
|
+
user: GatewayUser;
|
|
741
|
+
sessionExpiresAt?: string;
|
|
742
|
+
refreshToken?: string;
|
|
682
743
|
}>;
|
|
683
744
|
/** GET /api/auth/sessions:当前账号的登录设备列表。 */
|
|
684
745
|
listSessions(sessionCookie: string): Promise<DeviceSession[]>;
|
|
@@ -966,3 +1027,16 @@ export declare function deviceHeaders(device?: DeviceIdentity): Record<string, s
|
|
|
966
1027
|
* 旧运行时回退 get('set-cookie') 时可能拿到逗号合并串,故分隔符同时接受 `,` 与 `;`。
|
|
967
1028
|
*/
|
|
968
1029
|
export declare function extractSessionId(headers: Headers): string | undefined;
|
|
1030
|
+
/**
|
|
1031
|
+
* 从 Set-Cookie 解析会话过期时间(ISO 串;解析不出返回 undefined)。
|
|
1032
|
+
* 与 {@link extractSessionId} 同源遍历全部 Set-Cookie,但只认 `session_id` 那一节里的
|
|
1033
|
+
* `Expires=`(绝对时刻,`Date.parse`,NaN 弃用)或 `Max-Age=`(相对秒数,now + 秒);
|
|
1034
|
+
* 两者都有优先 `Expires`——服务端给的是权威时刻,Max-Age 只是保守回退。
|
|
1035
|
+
* 旧网关可能两个属性都不带(网关契约尚未下发过期信息),此时返回 undefined,
|
|
1036
|
+
* 由调用方的「按假定寿命估算」路径接手,绝不在这里抛错或猜一个假值。
|
|
1037
|
+
*
|
|
1038
|
+
* 旧运行时回退 `get('set-cookie')` 会拿到逗号合并串,故先按「逗号 + cookie 名=」切节:
|
|
1039
|
+
* `Expires` 的日期串里也有逗号,但逗号后面不是 `name=`,不会被误切。
|
|
1040
|
+
* `now` 可注入以便测试(默认 `Date.now()`)。
|
|
1041
|
+
*/
|
|
1042
|
+
export declare function parseSessionCookieExpiry(headers: Headers, now?: number): string | undefined;
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/** 稳定错误码,供 settings card 分支渲染。 */
|
|
2
|
-
export type LoginErrorCode = 'AUTH_FAILED' | 'RATE_LIMITED' | 'GATEWAY_UNREACHABLE' | 'NO_KEY_ASSIGNED' | 'SESSION_EXPIRED'
|
|
2
|
+
export type LoginErrorCode = 'AUTH_FAILED' | 'RATE_LIMITED' | 'GATEWAY_UNREACHABLE' | 'NO_KEY_ASSIGNED' | 'SESSION_EXPIRED'
|
|
3
|
+
/** 400:grant 失效 / 已被轮换复用 / 已过期——该 grant 作废,回落记住密码。 */
|
|
4
|
+
| 'REFRESH_GRANT_INVALID'
|
|
5
|
+
/** 404:老网关没有 `/api/auth/refresh`——静默弃用本地 grant,用户无感知。 */
|
|
6
|
+
| 'REFRESH_GRANT_ABSENT' | 'KEY_DISABLED' | 'KEY_INVALID' | 'NOT_FOUND'
|
|
3
7
|
/** 该分配已被管理员撤销(或从未属于当前账号):取断言时 404。 */
|
|
4
8
|
| 'MCP_ASSIGNMENT_GONE'
|
|
5
9
|
/** 服务或分配已停用(或被改成了不可下发的凭证方式):取断言时 409。 */
|
|
6
10
|
| 'MCP_NOT_DELIVERABLE'
|
|
7
11
|
/** 本部署未配置断言签名私钥(`mcp.assertion_key`):取断言时 503。 */
|
|
8
12
|
| 'MCP_ASSERTION_DISABLED'
|
|
13
|
+
/** 断言签发频率超限(服务端按用户 60 次/分限流):取断言时 429,下一轮同步会自动重试。 */
|
|
14
|
+
| 'MCP_ASSERTION_RATE_LIMITED'
|
|
9
15
|
/** 该分配已被管理员撤销(或从未属于当前账号):取包体时 404。 */
|
|
10
16
|
| 'SKILL_ASSIGNMENT_GONE'
|
|
11
17
|
/** 本部署未启用技能下发:取包体时 503。 */
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* 客户端版本策略(spec §6):`GET /api/client/policy` 的免登录只读拉取。
|
|
3
3
|
*
|
|
4
4
|
* 网关返回的是**最终判定**(`loginMode` 等),插件只做投影不做策略逻辑,因此管理端
|
|
5
|
-
* 调整 `client.login_mode`
|
|
5
|
+
* 调整 `client.login_mode` 后无需重启插件、无需发版:由 {@link createClientPolicyPoller}
|
|
6
|
+
* 每 6 小时重拉一次(对齐服务端 `Cache-Control: max-age=300` 的注释预期),失败按
|
|
7
|
+
* 1min → 10min → 1h 阶梯退避重试,紧急止血策略(如 `login_mode=form`)由此能到达在线客户端。
|
|
6
8
|
*
|
|
7
|
-
* 本模块只负责「拉 +
|
|
9
|
+
* 本模块只负责「拉 + 严格解析 + 轮询节律」,投影与 fail-open 缺省由 `src/index.ts` 的
|
|
8
10
|
* `projectLoginMode` 完成:**非 200 / 非 JSON / 枚举非法 / 超时 / 网络异常一律返回
|
|
9
11
|
* undefined**,调用方据此回退 `DEFAULT_LOGIN_MODE`,绝不把用户锁在门外(spec §5.4 / §6.4)。
|
|
10
12
|
*/
|
|
@@ -57,3 +59,29 @@ export declare function buildClientPolicyURL(opts: FetchClientPolicyOptions): st
|
|
|
57
59
|
* 不记录响应原文——策略接口当前无敏感字段,但保持与其他网关调用一致的日志纪律。
|
|
58
60
|
*/
|
|
59
61
|
export declare function fetchClientPolicy(opts: FetchClientPolicyOptions): Promise<ClientPolicy | undefined>;
|
|
62
|
+
/** 策略轮询间隔(6h,对齐服务端 `Cache-Control: max-age=300` 的注释预期)。 */
|
|
63
|
+
export declare const CLIENT_POLICY_POLL_INTERVAL_MS: number;
|
|
64
|
+
/**
|
|
65
|
+
* 失败退避阶梯(1min → 10min → 1h),末档封顶循环。
|
|
66
|
+
* 退避只影响「多久后重试」,不影响已经投影好的策略值——失败时保留上一轮结果。
|
|
67
|
+
*/
|
|
68
|
+
export declare const CLIENT_POLICY_RETRY_DELAYS_MS: readonly [60000, number, number];
|
|
69
|
+
export interface ClientPolicyPoller {
|
|
70
|
+
/** 请求一次拉取:**当前没有排程**(也没在跑)时立即发起,否则什么都不做。 */
|
|
71
|
+
trigger(): void;
|
|
72
|
+
/** 停止轮询并清掉已排程的定时器(调用后零回调)。 */
|
|
73
|
+
dispose(): void;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 自排程的策略轮询器(不用 setInterval:每轮的间隔由上一轮结果决定)。
|
|
77
|
+
* - `attempt` 返回 true(成功拉到并投影)→ 6h 后再来;
|
|
78
|
+
* - 返回 false 或抛异常 → 按 {@link CLIENT_POLICY_RETRY_DELAYS_MS} 退避,末档封顶循环;
|
|
79
|
+
* - 任意一次成功把退避档归零(下一次失败重新从 1min 起步);
|
|
80
|
+
* - 定时器 `unref`,不拖住进程退出;`trigger()` 幂等(已排程/在飞时是 no-op),
|
|
81
|
+
* 让宿主 200ms 消费循环可以无脑反复调用。
|
|
82
|
+
*/
|
|
83
|
+
export declare function createClientPolicyPoller(deps: {
|
|
84
|
+
/** 发起一次拉取并投影;成功返回 true。baseURL 未配置时返回 false。 */
|
|
85
|
+
attempt: () => Promise<boolean>;
|
|
86
|
+
log?: (message: string) => void;
|
|
87
|
+
}): ClientPolicyPoller;
|