dsh-session-guard 0.1.0
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/LICENSE +21 -0
- package/README.md +128 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +271 -0
- package/lib/client.js.map +1 -0
- package/package.json +67 -0
- package/src/bridge.js +76 -0
- package/src/client/index.ts +56 -0
- package/src/client/settings-card.tsx +191 -0
- package/src/client/status-badge.tsx +66 -0
- package/src/detect.js +27 -0
- package/src/gate.js +93 -0
- package/src/index.js +289 -0
- package/src/pause-gate.js +416 -0
- package/src/pause-store.js +101 -0
- package/src/retry.js +202 -0
- package/src/scheduler.js +42 -0
- package/src/settings.js +97 -0
- package/src/store.js +77 -0
- package/src/time.js +88 -0
package/src/retry.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 后端自动重试(host side,D9)。
|
|
3
|
+
*
|
|
4
|
+
* 监听 session/event:turn/end 以 error / interrupted / max-tokens 结束时,
|
|
5
|
+
* 分类瞬时/永久失败,自适应退避后自动 `agent.followup(retryText)` 续跑。
|
|
6
|
+
*
|
|
7
|
+
* 关键纪律:
|
|
8
|
+
* - **冻结让路**:会话被本插件门控(高峰暂停 / 队列锁)时不触发重试——
|
|
9
|
+
* 与 input-traffic「freeze 是一等公民」一致,自动重试不得绕过会话门。
|
|
10
|
+
* - 用户手动介入(user/message)与成功回合重置连续计数。
|
|
11
|
+
* - 子代理会话不重试(由父代理处理)。
|
|
12
|
+
* - 永久失败(鉴权/余额/模型不存在/上下文超限等)重试无益 → 停止并告警。
|
|
13
|
+
*
|
|
14
|
+
* 纯决策逻辑(classifyTurnEnd / isTransientFailure / effectiveCooldown /
|
|
15
|
+
* shouldRetry)零依赖可单测;事件接线在 createRetry。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 默认重试配置。 */
|
|
19
|
+
export const DEFAULT_RETRY = Object.freeze({
|
|
20
|
+
retryEnabled: false, // ← 自动重试开关(默认关,保守)
|
|
21
|
+
retryText: '继续(自动重试)',
|
|
22
|
+
retryGraceMs: 3000, // 失败后等待多久再发
|
|
23
|
+
retryCooldownMs: 20000, // 同一会话两次重试最小间隔
|
|
24
|
+
retryBackoffFactor: 2,
|
|
25
|
+
retryBackoffMaxMs: 300000,
|
|
26
|
+
retryMaxConsecutive: 3, // 连续重试上限,超过停止
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/** 瞬时 vs 永久失败分类:瞬时值得重试,永久重试无益。 */
|
|
30
|
+
export function isTransientFailure({ code, message, status } = {}) {
|
|
31
|
+
const haystack = `${code ?? ''} ${message ?? ''}`.toLowerCase()
|
|
32
|
+
if (status !== undefined && (status === 401 || status === 403)) return false
|
|
33
|
+
const permanent =
|
|
34
|
+
/auth|unauthor|forbidden|credential|api\s*[_-]?\s*key|permission/i.test(haystack) ||
|
|
35
|
+
/insufficient.*(balance|quota)|billing|payment/i.test(haystack) ||
|
|
36
|
+
/model[^a-z]*not[^a-z]*found|unknown[_-]?model|not.*support.*model/i.test(haystack) ||
|
|
37
|
+
/context.*(length|limit|overflow|exceed)|token.*limit|max.*context/i.test(haystack) ||
|
|
38
|
+
/invalid[_-]?request|bad[_-]?request/i.test(haystack)
|
|
39
|
+
return !permanent
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* turn/end reason → 是否可自动重试。
|
|
44
|
+
* - completed / aborted(用户停)/ blocked(策略拒)→ 否
|
|
45
|
+
* - error → 按 isTransientFailure 分类
|
|
46
|
+
* - interrupted(崩溃修复)→ 可重试
|
|
47
|
+
* - max-tokens → 可重试
|
|
48
|
+
*/
|
|
49
|
+
export function classifyTurnEnd(reason, failure) {
|
|
50
|
+
const kind = reason && reason.kind
|
|
51
|
+
if (kind === 'completed' || kind === 'aborted' || kind === 'blocked') return false
|
|
52
|
+
if (kind === 'error') return isTransientFailure(failure)
|
|
53
|
+
if (kind === 'interrupted' || kind === 'max-tokens') return true
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 自适应退避:consecutive 次连续后 cooldown * factor^n,封顶 max。 */
|
|
58
|
+
export function effectiveCooldown(consecutive, base, factor, max) {
|
|
59
|
+
const mult = Math.pow(factor, Math.max(0, consecutive))
|
|
60
|
+
return Math.min(Math.max(base, base * mult), Math.max(base, max))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 纯决策:此刻是否应触发重试。
|
|
65
|
+
* @param {object} s 会话状态 { consecutive, lastAttemptAt, pending }
|
|
66
|
+
* @param {object} cfg 重试配置
|
|
67
|
+
* @param {boolean} frozen 会话是否被门控(高峰暂停/队列锁)
|
|
68
|
+
* @param {number} now
|
|
69
|
+
*/
|
|
70
|
+
export function shouldRetry(s, cfg, frozen, now = Date.now()) {
|
|
71
|
+
if (!cfg.retryEnabled) return false
|
|
72
|
+
if (frozen) return false // 冻结让路(D9)
|
|
73
|
+
if (s.pending) return false // 已有排队重试
|
|
74
|
+
if (s.consecutive >= cfg.retryMaxConsecutive) return false
|
|
75
|
+
if (now - s.lastAttemptAt < effectiveCooldown(s.consecutive, cfg.retryCooldownMs, cfg.retryBackoffFactor, cfg.retryBackoffMaxMs)) return false
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 会话状态工厂。 */
|
|
80
|
+
export function freshRetryState() {
|
|
81
|
+
return { consecutive: 0, lastAttemptAt: 0, pending: false }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 事件接线(host):
|
|
86
|
+
* @param {object} deps
|
|
87
|
+
* @param {object} deps.ctx host context
|
|
88
|
+
* @param {()=>object} deps.getSettings 读实时配置
|
|
89
|
+
* @param {(sessionId:string)=>boolean} deps.isFrozen 会话是否被门控
|
|
90
|
+
* @param {(sessionId:string, text:string)=>void} [deps.send] 发送函数(默认 agent.followup)
|
|
91
|
+
*/
|
|
92
|
+
export function createRetry({ ctx, getSettings, isFrozen, send }) {
|
|
93
|
+
const states = new Map()
|
|
94
|
+
|
|
95
|
+
function state(sessionId) {
|
|
96
|
+
let s = states.get(sessionId)
|
|
97
|
+
if (!s) {
|
|
98
|
+
s = freshRetryState()
|
|
99
|
+
states.set(sessionId, s)
|
|
100
|
+
}
|
|
101
|
+
return s
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 从 turn/end reason 提取失败事实。 */
|
|
105
|
+
function failureFacts(reason) {
|
|
106
|
+
const error = reason && reason.error
|
|
107
|
+
return {
|
|
108
|
+
code: error && typeof error.code === 'string' ? error.code : 'UNKNOWN',
|
|
109
|
+
message: error && typeof error.message === 'string' ? error.message : '',
|
|
110
|
+
status: error && typeof error.status === 'number' ? error.status : undefined,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function schedule(sessionId, reason) {
|
|
115
|
+
const s = state(sessionId)
|
|
116
|
+
const cfg = getSettings()
|
|
117
|
+
if (s.pending) return
|
|
118
|
+
const frozen = isFrozen(sessionId)
|
|
119
|
+
if (!shouldRetry({ ...s, pending: true }, cfg, frozen)) return
|
|
120
|
+
s.pending = true
|
|
121
|
+
const grace = cfg.retryGraceMs ?? DEFAULT_RETRY.retryGraceMs
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
s.pending = false
|
|
124
|
+
// 到点再复核:门控/上限变化后放弃。
|
|
125
|
+
if (!shouldRetry(s, cfg, isFrozen(sessionId))) return
|
|
126
|
+
const agent = ctx.agents.get(sessionId)
|
|
127
|
+
if (!agent || agent.status !== 'idle') return
|
|
128
|
+
const text = cfg.retryText ?? DEFAULT_RETRY.retryText
|
|
129
|
+
try {
|
|
130
|
+
if (send) {
|
|
131
|
+
send(sessionId, text)
|
|
132
|
+
} else {
|
|
133
|
+
// 与 autoresume 同构:直接构造 plugin-source 消息,避免运行时依赖。
|
|
134
|
+
agent.followup({
|
|
135
|
+
id: `session-guard-retry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
136
|
+
role: 'user',
|
|
137
|
+
content: [{ type: 'text', text }],
|
|
138
|
+
source: { kind: 'plugin', plugin: 'session-guard', form: 'notice' },
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
s.lastAttemptAt = Date.now()
|
|
142
|
+
s.consecutive += 1
|
|
143
|
+
ctx.logger?.info?.(`[session-guard] auto-retry ${sessionId} (${String(reason && reason.kind)}), #${s.consecutive}`)
|
|
144
|
+
} catch (e) {
|
|
145
|
+
ctx.logger?.warn?.(`[session-guard] auto-retry send failed: ${String(e && e.message || e)}`)
|
|
146
|
+
}
|
|
147
|
+
}, grace)
|
|
148
|
+
// 会话对象上的清理钩子(若宿主提供)。
|
|
149
|
+
const dispose = () => clearTimeout(timer)
|
|
150
|
+
return dispose
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function onEvent(session, event) {
|
|
154
|
+
const sessionId = session && session.id
|
|
155
|
+
if (typeof sessionId !== 'string') return
|
|
156
|
+
const s = state(sessionId)
|
|
157
|
+
switch (event.type) {
|
|
158
|
+
case 'turn/end': {
|
|
159
|
+
const reason = event.data && event.data.reason
|
|
160
|
+
if (reason && reason.kind === 'completed') {
|
|
161
|
+
s.consecutive = 0 // 成功回合重置
|
|
162
|
+
s.pending = false
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
if (reason && reason.kind === 'aborted') {
|
|
166
|
+
// 用户主动停止:不重试,重置。
|
|
167
|
+
s.consecutive = 0
|
|
168
|
+
s.pending = false
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
if (reason && classifyTurnEnd(reason, failureFacts(reason))) {
|
|
172
|
+
schedule(sessionId, reason)
|
|
173
|
+
}
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
case 'user/message': {
|
|
177
|
+
// 用户手动介入:重置(无论是否我们的回显都保守重置)。
|
|
178
|
+
if (event.data && event.data.source && event.data.source.kind === 'user') {
|
|
179
|
+
s.consecutive = 0
|
|
180
|
+
s.pending = false
|
|
181
|
+
}
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
default:
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
ctx.on('session/event', (session, event) => {
|
|
190
|
+
try {
|
|
191
|
+
onEvent(session, event)
|
|
192
|
+
} catch (e) {
|
|
193
|
+
ctx.logger?.warn?.(`[session-guard] retry event failed: ${String(e && e.message || e)}`)
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
onEvent,
|
|
199
|
+
state,
|
|
200
|
+
states,
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/scheduler.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 纯状态机(可单测,零依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 状态:NORMAL ↔ PAUSED_PEAK。
|
|
5
|
+
* 副作用(暂停/恢复具体会话)由 host 的 tick 执行,这里只做判定与迁移检测。
|
|
6
|
+
*/
|
|
7
|
+
import { shouldPause } from './time.js'
|
|
8
|
+
|
|
9
|
+
export const STATES = {
|
|
10
|
+
NORMAL: 'NORMAL',
|
|
11
|
+
PAUSED_PEAK: 'PAUSED_PEAK',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 计算当前状态。
|
|
16
|
+
* @param {object} settings 同 time.shouldPause 的 settings
|
|
17
|
+
* @param {Date|number} now
|
|
18
|
+
* @returns {{state:string, reason:string, at:number}}
|
|
19
|
+
*/
|
|
20
|
+
export function computeState(settings, now) {
|
|
21
|
+
const date = now instanceof Date ? now : new Date(now)
|
|
22
|
+
const d = shouldPause(settings, date)
|
|
23
|
+
return {
|
|
24
|
+
state: d.pause ? STATES.PAUSED_PEAK : STATES.NORMAL,
|
|
25
|
+
reason: d.reason,
|
|
26
|
+
at: date.getTime(),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 迁移检测:从 prev 到 next 是否发生了 入峰(enter) / 退峰(leave)。
|
|
32
|
+
* @param {{state:string}|null} prev
|
|
33
|
+
* @param {{state:string}} next
|
|
34
|
+
* @returns {{enter:boolean, leave:boolean}}
|
|
35
|
+
*/
|
|
36
|
+
export function transition(prev, next) {
|
|
37
|
+
const was = prev && prev.state
|
|
38
|
+
return {
|
|
39
|
+
enter: next.state === STATES.PAUSED_PEAK && was !== STATES.PAUSED_PEAK,
|
|
40
|
+
leave: was === STATES.PAUSED_PEAK && next.state !== STATES.PAUSED_PEAK,
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 设置子板块(设置 → 插件 → session-guard,简单开关)。
|
|
3
|
+
*
|
|
4
|
+
* 注册方式对齐 dsh-thinking-levels 的官方做法:
|
|
5
|
+
* - `@deepseek-ai/schemastery` 为**常规 dependency**(随插件安装,可解析)。
|
|
6
|
+
* - **不 value-import `@deepseek-ai/dsh-settings`**:该服务由 dsh runtime 经
|
|
7
|
+
* cordis `settings` 注入面提供,不属于 profile 的可解析树;这里只用本地最小
|
|
8
|
+
* 接口(SettingsScopeLike / SettingsServiceLike / SettingsAwareCtx)在
|
|
9
|
+
* `ctx.inject(['settings'], ...)` 里注册命名空间,`base` 层叠组合配置。
|
|
10
|
+
* - 仍 fail-open:任何解析 / 注入失败都静默降级用 DEFAULT_SETTINGS,绝不因
|
|
11
|
+
* 设置依赖而崩。
|
|
12
|
+
*/
|
|
13
|
+
import z from '@deepseek-ai/schemastery'
|
|
14
|
+
import { DEFAULT_RETRY } from './retry.js'
|
|
15
|
+
|
|
16
|
+
/** 设置命名空间(设置 → 插件 → session-guard)。 */
|
|
17
|
+
export const NS = 'session-guard'
|
|
18
|
+
|
|
19
|
+
/** 默认配置(核心逻辑依赖;设置服务不可用时即用此值)。 */
|
|
20
|
+
export const DEFAULT_SETTINGS = Object.freeze({
|
|
21
|
+
enabled: true, // 高峰自动处理开关(简单开关)
|
|
22
|
+
offPeakAutoResume: true, // 低谷自动恢复开关:低峰时段自动恢复被高峰暂停的会话
|
|
23
|
+
weekendMode: true, // 周末模式开关:识别周末,无视峰谷(简单开关)
|
|
24
|
+
timezone: 'Asia/Shanghai',
|
|
25
|
+
peakWindows: [
|
|
26
|
+
{ start: '09:00', end: '12:00' },
|
|
27
|
+
{ start: '14:00', end: '18:00' },
|
|
28
|
+
],
|
|
29
|
+
pauseMode: 'safe', // 透传 taskControl.pause mode
|
|
30
|
+
pauseReason: 'wait', // 透传 taskControl.pause reason
|
|
31
|
+
queueFallback: true, // 无会话门时回退锁等待队列(简单开关)
|
|
32
|
+
retryEnabled: false, // 自动重试开关(后端,D9;默认关,保守)
|
|
33
|
+
retryText: DEFAULT_RETRY.retryText,
|
|
34
|
+
retryGraceMs: DEFAULT_RETRY.retryGraceMs,
|
|
35
|
+
retryCooldownMs: DEFAULT_RETRY.retryCooldownMs,
|
|
36
|
+
retryBackoffFactor: DEFAULT_RETRY.retryBackoffFactor,
|
|
37
|
+
retryBackoffMaxMs: DEFAULT_RETRY.retryBackoffMaxMs,
|
|
38
|
+
retryMaxConsecutive: DEFAULT_RETRY.retryMaxConsecutive,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 设置 schema(schemastery 原生命令,zod 变体):
|
|
43
|
+
* - 枚举用 z.union([z.const(...)]);默认值用字段级 .default()。
|
|
44
|
+
*/
|
|
45
|
+
export const SettingsSchema = z.object({
|
|
46
|
+
enabled: z.boolean().default(DEFAULT_SETTINGS.enabled),
|
|
47
|
+
offPeakAutoResume: z.boolean().default(DEFAULT_SETTINGS.offPeakAutoResume),
|
|
48
|
+
weekendMode: z.boolean().default(DEFAULT_SETTINGS.weekendMode),
|
|
49
|
+
timezone: z.string().default(DEFAULT_SETTINGS.timezone),
|
|
50
|
+
peakWindows: z
|
|
51
|
+
.array(z.object({ start: z.string(), end: z.string() }))
|
|
52
|
+
.default(DEFAULT_SETTINGS.peakWindows),
|
|
53
|
+
pauseMode: z.union([z.const('safe'), z.const('force')]).default(DEFAULT_SETTINGS.pauseMode),
|
|
54
|
+
pauseReason: z.union([z.const('wait'), z.const('stop')]).default(DEFAULT_SETTINGS.pauseReason),
|
|
55
|
+
queueFallback: z.boolean().default(DEFAULT_SETTINGS.queueFallback),
|
|
56
|
+
retryEnabled: z.boolean().default(DEFAULT_SETTINGS.retryEnabled),
|
|
57
|
+
retryText: z.string().default(DEFAULT_SETTINGS.retryText),
|
|
58
|
+
retryGraceMs: z.number().min(0).default(DEFAULT_SETTINGS.retryGraceMs),
|
|
59
|
+
retryCooldownMs: z.number().min(0).default(DEFAULT_SETTINGS.retryCooldownMs),
|
|
60
|
+
retryBackoffFactor: z.number().min(1).default(DEFAULT_SETTINGS.retryBackoffFactor),
|
|
61
|
+
retryBackoffMaxMs: z.number().min(0).default(DEFAULT_SETTINGS.retryBackoffMaxMs),
|
|
62
|
+
retryMaxConsecutive: z.number().min(0).default(DEFAULT_SETTINGS.retryMaxConsecutive),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* dsh settings 服务的最小本地接口(仿 dsh-thinking-levels 的 local face):
|
|
67
|
+
* 插件不 value-import `@deepseek-ai/dsh-settings`,只通过这些形状在注入面调用。
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/** @typedef {{ get(): unknown; watch(cb: () => void): () => void }} SettingsScopeLike */
|
|
71
|
+
/** @typedef {{ register(ns: string, schema: unknown, options?: { base?: unknown }): SettingsScopeLike }} SettingsServiceLike */
|
|
72
|
+
/** @typedef {{ inject(deps: readonly string[], fn: (s: { settings: SettingsServiceLike; effect(cb: () => (() => void) | void, label?: string): void }) => void): void }} SettingsAwareCtx */
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 注册 设置 → 插件 → session-guard 子板块(简单开关)。
|
|
76
|
+
* - 走 `settings` 注入面,`base` 层叠组合配置;对 runtime 调用方经 `ctx.settings.get(NS)` 读取。
|
|
77
|
+
* - 任何失败(settings 服务缺失 / 注入异常)→ 返回 false,静默降级用默认配置。
|
|
78
|
+
* @param {object} ctx - host context(应含 cordis `settings` 注入面)。
|
|
79
|
+
* @returns {boolean} 注册成功 true;设置服务不可用时 false(fail-open)。
|
|
80
|
+
*/
|
|
81
|
+
export function registerSettings(ctx) {
|
|
82
|
+
try {
|
|
83
|
+
// 本插件顶层 `inject` 已声明 `settings`(见 src/index.js 的
|
|
84
|
+
// `export const inject`),所以 apply 时 `ctx.settings` 已是完整
|
|
85
|
+
// SettingsProvider(带 .register),直接注册即可 —— 无需再 `ctx.inject`
|
|
86
|
+
// 二次动态注入(对已在 fiber 上解析的服务做二次注入,回调作为异步插件
|
|
87
|
+
// apply 排队,`registerSettings` 同步返回 true 会掩盖实际操作未生效)。
|
|
88
|
+
// 与 dsh-thinking-levels / dsh-context 的区别仅在于它们顶层未声明
|
|
89
|
+
// settings,才必须动态注入;这里已声明,直接用最可靠。
|
|
90
|
+
const svc = /** @type {{ register(ns: string, schema: unknown, options?: { base?: unknown }): unknown }} */ (ctx.settings)
|
|
91
|
+
// base 用副本:DEFAULT_SETTINGS 被 Object.freeze,直接当 base 可能被写。
|
|
92
|
+
svc.register(NS, SettingsSchema, { base: { ...DEFAULT_SETTINGS } })
|
|
93
|
+
return true
|
|
94
|
+
} catch {
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 每会话持久化状态存储(学 dsh-task-control 的独立存储模式,D4)。
|
|
3
|
+
*
|
|
4
|
+
* 暂停/锁定状态不写 session log(harness 持久化 reader 只认已知事件类型),
|
|
5
|
+
* 改为插件自有 JSON 文件:`$DSH_HOME/.dsh/session-guard/<sessionId>.json`
|
|
6
|
+
* (可用 DSH_SESSION_GUARD_STATE_DIR 覆盖根目录)。原子写:tmp + rename。
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/** 状态根目录。 */
|
|
12
|
+
export function storeRoot() {
|
|
13
|
+
return (
|
|
14
|
+
process.env.DSH_SESSION_GUARD_STATE_DIR ||
|
|
15
|
+
join(process.env.DSH_HOME || process.env.HOME || process.cwd(), '.dsh', 'session-guard')
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 会话 id → 安全文件名。 */
|
|
20
|
+
function encodeSessionId(id) {
|
|
21
|
+
return String(id).replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 每会话状态文件路径。 */
|
|
25
|
+
export function stateFilePath(id) {
|
|
26
|
+
return join(storeRoot(), `${encodeSessionId(id)}.json`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 未锁定基线。 */
|
|
30
|
+
export function idleState(id) {
|
|
31
|
+
return { sessionId: String(id), queueLocked: false, lockReason: null, updatedAt: null }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 创建存储(内存缓存 + 磁盘持久化)。
|
|
36
|
+
*/
|
|
37
|
+
export function createStore() {
|
|
38
|
+
const cache = new Map()
|
|
39
|
+
return {
|
|
40
|
+
/** 读一会话状态;无记录返回 null(调用方自行 fallback idleState)。 */
|
|
41
|
+
get(id) {
|
|
42
|
+
if (cache.has(id)) return cache.get(id)
|
|
43
|
+
try {
|
|
44
|
+
const f = stateFilePath(id)
|
|
45
|
+
if (!existsSync(f)) return null
|
|
46
|
+
const v = JSON.parse(readFileSync(f, 'utf8'))
|
|
47
|
+
cache.set(id, v)
|
|
48
|
+
return v
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
/** 原子写。 */
|
|
54
|
+
set(id, value) {
|
|
55
|
+
cache.set(id, value)
|
|
56
|
+
try {
|
|
57
|
+
const f = stateFilePath(id)
|
|
58
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
59
|
+
const tmp = `${f}.tmp`
|
|
60
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2))
|
|
61
|
+
renameSync(tmp, f)
|
|
62
|
+
} catch (e) {
|
|
63
|
+
// 落盘失败仅影响重启恢复,内存态仍可用。
|
|
64
|
+
console.error(`[session-guard] store write failed: ${String(e && e.message || e)}`)
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
/** 清除(解锁时)。 */
|
|
68
|
+
clear(id) {
|
|
69
|
+
cache.delete(id)
|
|
70
|
+
try {
|
|
71
|
+
rmSync(stateFilePath(id), { force: true })
|
|
72
|
+
} catch {
|
|
73
|
+
/* ignore */
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/time.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 时间判定(纯函数,零依赖,可单测)。
|
|
3
|
+
*
|
|
4
|
+
* 高峰/周末识别必须基于「配置时区」的墙钟,而不是裸 UTC(D1):
|
|
5
|
+
* 周末判断用 `Intl.DateTimeFormat(timeZone)` 投影,避免 off-peak 那种
|
|
6
|
+
* 用 `getUTCDay()` 导致北京周末边界错 8 小时的 bug。
|
|
7
|
+
*
|
|
8
|
+
* 高峰时段为左闭右开 [start, end);跨午夜窗口(start > end)安全。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** 解析 "HH:mm" → 当日分钟数,非法返回 null。 */
|
|
12
|
+
export function parseHHMM(s) {
|
|
13
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(String(s).trim())
|
|
14
|
+
if (!m) return null
|
|
15
|
+
const h = Number(m[1])
|
|
16
|
+
const mi = Number(m[2])
|
|
17
|
+
if (h > 23 || mi > 59) return null
|
|
18
|
+
return h * 60 + mi
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 闭开区间 [s, e),跨午夜安全(s > e 时视为跨天环绕)。 */
|
|
22
|
+
export function inWindow(t, s, e) {
|
|
23
|
+
if (s === e) return false
|
|
24
|
+
return s < e ? t >= s && t < e : t >= s || t < e
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 把某时刻投影为配置时区(IANA,DST 感知)的墙钟分量。
|
|
29
|
+
* @param {string} tz IANA 时区名,如 "Asia/Shanghai"
|
|
30
|
+
* @param {Date} date
|
|
31
|
+
* @returns {{year:number,month:number,day:number,weekday:number,minutes:number}}
|
|
32
|
+
* weekday: 0=周日 ... 6=周六(与 getUTCDay 同约定,但基于配置时区的日期)
|
|
33
|
+
*/
|
|
34
|
+
export function wallClock(tz, date) {
|
|
35
|
+
const f = new Intl.DateTimeFormat('en-US', {
|
|
36
|
+
timeZone: tz,
|
|
37
|
+
hour12: false,
|
|
38
|
+
year: 'numeric',
|
|
39
|
+
month: '2-digit',
|
|
40
|
+
day: '2-digit',
|
|
41
|
+
hour: '2-digit',
|
|
42
|
+
minute: '2-digit',
|
|
43
|
+
})
|
|
44
|
+
const parts = {}
|
|
45
|
+
for (const p of f.formatToParts(date)) parts[p.type] = p.value
|
|
46
|
+
const year = Number(parts.year)
|
|
47
|
+
const month = Number(parts.month)
|
|
48
|
+
const day = Number(parts.day)
|
|
49
|
+
// 由该时区的「今天」日期反推 weekday,避免 UTC 边界错位(D1)。
|
|
50
|
+
const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay()
|
|
51
|
+
const minutes = Number(parts.hour) * 60 + Number(parts.minute)
|
|
52
|
+
return { year, month, day, weekday, minutes }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 周末(0=周日, 6=周六)。 */
|
|
56
|
+
export function isWeekend(weekday) {
|
|
57
|
+
return weekday === 0 || weekday === 6
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 某墙钟分钟是否处于任一高峰窗口内。 */
|
|
61
|
+
export function isInPeak(wc, windows) {
|
|
62
|
+
return windows.some((w) => {
|
|
63
|
+
const s = parseHHMM(w && w.start)
|
|
64
|
+
const e = parseHHMM(w && w.end)
|
|
65
|
+
if (s === null || e === null) return false
|
|
66
|
+
return inWindow(wc.minutes, s, e)
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 主判定:此刻是否应触发高峰暂停。
|
|
72
|
+
* @param {object} settings { enabled, weekendMode, timezone, peakWindows }
|
|
73
|
+
* @param {Date} date
|
|
74
|
+
* @returns {{pause:boolean, reason:string}}
|
|
75
|
+
* reason: 'disabled' | 'weekend' | 'peak' | 'off-peak'
|
|
76
|
+
*/
|
|
77
|
+
export function shouldPause(settings, date) {
|
|
78
|
+
if (!settings || settings.enabled !== true) return { pause: false, reason: 'disabled' }
|
|
79
|
+
const wc = wallClock(settings.timezone, date)
|
|
80
|
+
// 周末模式:识别周末 → 无视峰谷,畅快跑(D6)。
|
|
81
|
+
if (settings.weekendMode === true && isWeekend(wc.weekday)) {
|
|
82
|
+
return { pause: false, reason: 'weekend' }
|
|
83
|
+
}
|
|
84
|
+
if (isInPeak(wc, settings.peakWindows || [])) {
|
|
85
|
+
return { pause: true, reason: 'peak' }
|
|
86
|
+
}
|
|
87
|
+
return { pause: false, reason: 'off-peak' }
|
|
88
|
+
}
|