dsh-session-guard 0.1.2 → 0.2.0-beta.1

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.
@@ -1,66 +1,59 @@
1
- /**
2
- * dsh-session-guard — 状态徽标(纯展示,fail-open)。
3
- *
4
- * 轮询 host 的 /session-guard/status(全局当前阶段),显示 高峰/谷时/周末。
5
- * 仅展示,不做任何队列/会话动作;冻结按钮由 input-traffic 经桥接管(D6/D8)。
6
- */
7
- import { useEffect, useState } from 'react'
8
-
9
- /** slot 运行时注入的会话级 props(sessionId 由 dsh 的 SessionStandardProps 提供)。 */
10
- export interface StatusBadgeProps {
11
- sessionId?: string
12
- }
13
-
14
- /** /session-guard/status 返回的全局阶段。 */
15
- export interface Status {
16
- phase: 'peak' | 'off-peak' | 'weekend'
17
- enabled: boolean
18
- weekendMode: boolean
19
- timezone: string
20
- }
21
-
22
- const LABELS = {
23
- peak: '高峰',
24
- 'off-peak': '谷时',
25
- weekend: '周末',
26
- } as const
27
-
28
- const POLL_MS = 15_000
29
-
30
- /** 状态徽标:轮询全局阶段,显示 高峰/谷时/周末(enabled 关闭或请求失败时静默隐藏)。 */
31
- export function StatusBadge({ sessionId }: StatusBadgeProps) {
32
- const [status, setStatus] = useState<Status | null>(null)
33
-
34
- useEffect(() => {
35
- if (!sessionId) return
36
- let cancelled = false
37
- const poll = async () => {
38
- try {
39
- const res = await fetch('/session-guard/status')
40
- const body = await res.json().catch(() => null)
41
- if (!cancelled && body?.ok && body.status) setStatus(body.status)
42
- } catch {
43
- // fail-open:路由/网络不可达 → 静默,徽标隐藏。
44
- }
45
- }
46
- void poll()
47
- const timer = setInterval(poll, POLL_MS)
48
- return () => {
49
- cancelled = true
50
- clearInterval(timer)
51
- }
52
- }, [sessionId])
53
-
54
- if (!status || !status.enabled) return null
55
-
56
- const cls = status.phase === 'peak' ? 'sg-peak' : status.phase === 'weekend' ? 'sg-weekend' : 'sg-off'
57
- return (
58
- <span
59
- className={`sg-status ${cls}`}
60
- title={`${LABELS[status.phase]} · ${status.timezone}${status.weekendMode ? ' · 周末模式' : ''}`}
61
- data-sg-phase={status.phase}
62
- >
63
- {LABELS[status.phase]}
64
- </span>
65
- )
66
- }
1
+ /**
2
+ * dsh-session-guard — 状态徽标(纯展示,fail-open)。
3
+ *
4
+ * 轮询 host 的 /session-guard/status(全局当前阶段),显示 高峰/谷时/周末;
5
+ * 高峰期按二维判定区分「只拦官方」与「全部暂停」(文案见 badge-text.ts)。
6
+ * 仅展示,不做任何队列/会话动作;冻结按钮由 input-traffic 经桥接管(D6/D8)。
7
+ */
8
+ import { useEffect, useState } from 'react'
9
+ import { badgeTitle, peakLabel, type Status } from './badge-text'
10
+ import { injectClientCss } from './styles'
11
+
12
+ /** slot 运行时注入的会话级 props(sessionId 由 dsh 的 SessionStandardProps 提供)。 */
13
+ export interface StatusBadgeProps {
14
+ sessionId?: string
15
+ }
16
+
17
+ const POLL_MS = 15_000
18
+
19
+ /** 状态徽标:轮询全局阶段,显示 高峰/谷时/周末(enabled 关闭或请求失败时静默隐藏)。 */
20
+ export function StatusBadge({ sessionId }: StatusBadgeProps) {
21
+ const [status, setStatus] = useState<Status | null>(null)
22
+
23
+ injectClientCss()
24
+
25
+ useEffect(() => {
26
+ if (!sessionId) return
27
+ let cancelled = false
28
+ const poll = async () => {
29
+ try {
30
+ const res = await fetch('/session-guard/status')
31
+ const body = await res.json().catch(() => null)
32
+ if (!cancelled && body?.ok && body.status) setStatus(body.status)
33
+ } catch {
34
+ // fail-open:路由/网络不可达 → 静默,徽标隐藏。
35
+ }
36
+ }
37
+ void poll()
38
+ const timer = setInterval(poll, POLL_MS)
39
+ return () => {
40
+ cancelled = true
41
+ clearInterval(timer)
42
+ }
43
+ }, [sessionId])
44
+
45
+ if (!status || !status.enabled) return null
46
+
47
+ const cls = status.phase === 'peak' ? 'sg-peak' : status.phase === 'weekend' ? 'sg-weekend' : 'sg-off'
48
+ return (
49
+ <span
50
+ className={`sg-status ${cls}`}
51
+ title={badgeTitle(status)}
52
+ data-sg-phase={status.phase}
53
+ data-sg-provider-guard={status.providerGuard === true ? 'on' : 'off'}
54
+ data-sg-step-held={String(status.stepHeld ?? 0)}
55
+ >
56
+ {peakLabel(status)}
57
+ </span>
58
+ )
59
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * dsh-session-guard — 客户端样式(与 composer 右侧 input-traffic 冻结按钮同一视觉语言)。
3
+ *
4
+ * 为什么注入 `<style>` 而不是 CSS Modules:本插件的 tsdown 配置没有 CSS Modules 管线
5
+ * (input-traffic 有),而 settings-card 已经用 `<style data-plugin-css>` 的既有约定。
6
+ * 这里只做一件事:把「暂停会话 / 继续会话」按钮与状态徽标对齐到同一行的其它控件
7
+ * (高度 24px、圆角 6px、12px 字号、同样的 border / hover / pressed 令牌)。
8
+ */
9
+
10
+ /** 与 `dsh-input-traffic/src/client/freeze-button.module.css` 对齐的控件外观。 */
11
+ export const CLIENT_CSS = `
12
+ .sg-pause{display:inline-flex;align-items:center;gap:4px;height:24px;padding:0 8px;border:1px solid var(--dsw-alias-border-l3,rgba(0,0,0,.12));border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#6b7280);font-size:12px;line-height:1;cursor:pointer;white-space:nowrap;flex:none}
13
+ .sg-pause:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.04))}
14
+ .sg-pause:disabled{opacity:.55;cursor:default}
15
+ .sg-pause[aria-pressed='true']{border-color:var(--dsw-alias-state-warning-primary,#d97706);color:var(--dsw-alias-state-warning-primary,#d97706);background:color-mix(in srgb,var(--dsw-alias-state-warning-primary,#d97706) 8%,transparent)}
16
+ .sg-status{display:inline-flex;align-items:center;height:24px;padding:0 8px;border:1px solid var(--dsw-alias-border-l3,rgba(0,0,0,.12));border-radius:6px;font-size:12px;line-height:1;color:var(--dsw-alias-label-secondary,#6b7280);white-space:nowrap;flex:none}
17
+ .sg-status.sg-peak{border-color:var(--dsw-alias-state-warning-primary,#d97706);color:var(--dsw-alias-state-warning-primary,#d97706)}
18
+ .sg-status.sg-weekend{border-color:var(--dsw-alias-state-success-primary,#30a46c);color:var(--dsw-alias-state-success-primary,#30a46c)}
19
+ `
20
+
21
+ /** 注入一次(幂等;无 document 时静默跳过)。 */
22
+ export function injectClientCss(): void {
23
+ if (typeof document === 'undefined') return
24
+ if (document.querySelector('style[data-plugin-css="session-guard-client"]') !== null) return
25
+ const tag = document.createElement('style')
26
+ tag.dataset.plugin = 'dsh-session-guard'
27
+ tag.dataset.pluginCss = 'session-guard-client'
28
+ tag.textContent = CLIENT_CSS
29
+ document.head.appendChild(tag)
30
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * dsh-session-guard — 延后登记表(host,进程内)。
3
+ *
4
+ * 两类记录:
5
+ * - **挂起(hold)**:请求被挂在 `agent/request` 上,promise 待退峰 resolve。
6
+ * 退峰 → `resolve(config)` 让请求照常发出;abort / 超限 / 被新 hold 顶替 → reject。
7
+ * - **延后(deferred)**:error 模式下已经抛错的会话,记下来供退峰 `followup` 续跑。
8
+ *
9
+ * 纪律:
10
+ * - **不做磁盘持久化**(挂起态是进程内 promise,重启即消失;写盘无收益)。
11
+ * - 同一会话重复 hold → 先结算旧记录(reject `superseded`),再登记新记录。
12
+ * - `releaseAll` / `rejectAll` / `settle` 幂等:对不存在的会话是 no-op。
13
+ * - 定时器 `unref()`,不拖住进程退出。
14
+ */
15
+
16
+ /** 机器可路由的延后失败码(`retry.js` 只按这个精确码短路)。 */
17
+ export const PEAK_DEFERRED_CODE = 'PEAK_DEFERRED'
18
+
19
+ /**
20
+ * 延后错误:`code` 走结构化字段;`message` 以精确哨兵 `PEAK_DEFERRED:` 开头。
21
+ *
22
+ * 为什么要带哨兵:DSH 回合循环只对 `error instanceof LlmError` 保留结构化
23
+ * `failure`,其它一律压成 `{ message: errorChain(error), code: 'UNKNOWN' }`
24
+ * (findings 8.3)。本插件不能 value-import `@deepseek-ai/dsh-llm`,
25
+ * 因此让 `code` 与 message 前缀同时携带同一个精确哨兵,两条路径都能识别。
26
+ */
27
+ export class PeakDeferredError extends Error {
28
+ /**
29
+ * @param {string} [detail] 人类可读补充说明
30
+ * @param {object} [facts] { provider, model, sessionId }
31
+ */
32
+ constructor(detail = 'peak hours, request deferred', facts = {}) {
33
+ super(`${PEAK_DEFERRED_CODE}: ${detail}`)
34
+ this.name = 'PeakDeferredError'
35
+ this.code = PEAK_DEFERRED_CODE
36
+ this.facts = facts
37
+ }
38
+ }
39
+
40
+ /**
41
+ * @param {object} [deps]
42
+ * @param {number|(()=>number)} [deps.maxHoldMs] 挂起上限(毫秒),到期转 error;<=0 表示不设上限。
43
+ * 传函数时每次 hold 实时读取(设置热改即时生效)。
44
+ * @param {()=>number} [deps.now] 时钟(测试注入)
45
+ * @param {boolean} [deps.unrefTimers] 定时器是否 unref(生产 true,测试可关以便等待定时器)
46
+ * @returns {object} 延后登记表
47
+ */
48
+ export function createDeferrals({ maxHoldMs = 6 * 60 * 60 * 1000, now = () => Date.now(), unrefTimers = true } = {}) {
49
+ const resolveMaxHold = typeof maxHoldMs === 'function' ? maxHoldMs : () => maxHoldMs
50
+ /** @type {Map<string, object>} 活跃挂起 */
51
+ const holds = new Map()
52
+ /** @type {Map<string, object>} error 模式的延后记录 */
53
+ const deferred = new Map()
54
+
55
+ function clearTimer(entry) {
56
+ if (entry && entry.timer !== null && entry.timer !== undefined) {
57
+ clearTimeout(entry.timer)
58
+ entry.timer = null
59
+ }
60
+ if (entry && entry.signal && entry.onAbort) {
61
+ try {
62
+ entry.signal.removeEventListener('abort', entry.onAbort)
63
+ } catch {
64
+ /* 信号实现不完整时忽略 */
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 结算一个挂起记录。
71
+ * @param {string} sessionId
72
+ * @param {string} reason
73
+ * @param {'release'|'reject'} outcome
74
+ * @returns {boolean} 是否真的结算了一条
75
+ */
76
+ function settle(sessionId, reason, outcome = 'release') {
77
+ const key = String(sessionId ?? '')
78
+ const entry = holds.get(key)
79
+ if (!entry) return false
80
+ holds.delete(key)
81
+ clearTimer(entry)
82
+ if (outcome === 'reject') {
83
+ entry.reject(new PeakDeferredError(reason, { sessionId: key, provider: entry.provider, model: entry.model }))
84
+ } else {
85
+ entry.resolve(entry.config)
86
+ }
87
+ return true
88
+ }
89
+
90
+ /**
91
+ * 挂起一次请求。
92
+ * @param {string} sessionId
93
+ * @param {{provider?:string, model?:string, config?:unknown}} info
94
+ * @param {AbortSignal} [signal]
95
+ * @returns {Promise<unknown>} 退峰 resolve 原 config
96
+ */
97
+ function hold(sessionId, info = {}, signal) {
98
+ const key = String(sessionId ?? '')
99
+ // 重复 hold:先结算旧的(新请求代表同一会话的最新意图)
100
+ if (holds.has(key)) settle(key, 'superseded', 'reject')
101
+
102
+ let resolveFn
103
+ let rejectFn
104
+ const promise = new Promise((resolve, reject) => {
105
+ resolveFn = resolve
106
+ rejectFn = reject
107
+ })
108
+ const entry = {
109
+ sessionId: key,
110
+ provider: info.provider ?? null,
111
+ model: info.model ?? null,
112
+ config: info.config,
113
+ since: now(),
114
+ resolve: resolveFn,
115
+ reject: rejectFn,
116
+ promise,
117
+ timer: null,
118
+ signal: null,
119
+ onAbort: null,
120
+ }
121
+ holds.set(key, entry)
122
+
123
+ if (Number.isFinite(resolveMaxHold()) && resolveMaxHold() > 0) {
124
+ entry.timer = setTimeout(() => {
125
+ if (holds.get(key) !== entry) return
126
+ settle(key, 'max-hold-exceeded', 'reject')
127
+ }, resolveMaxHold())
128
+ if (entry.timer && unrefTimers && typeof entry.timer.unref === 'function') entry.timer.unref()
129
+ }
130
+
131
+ if (signal && typeof signal.addEventListener === 'function') {
132
+ entry.signal = signal
133
+ if (signal.aborted) {
134
+ settle(key, 'aborted', 'reject')
135
+ } else {
136
+ entry.onAbort = () => {
137
+ if (holds.get(key) === entry) settle(key, 'aborted', 'reject')
138
+ }
139
+ signal.addEventListener('abort', entry.onAbort, { once: true })
140
+ }
141
+ }
142
+
143
+ return promise
144
+ }
145
+
146
+ /** 放行全部挂起(退峰):resolve 原 config,并清空延后记录。 */
147
+ function releaseAll(reason = 'off-peak') {
148
+ const released = []
149
+ for (const key of [...holds.keys()]) {
150
+ if (settle(key, reason, 'release')) released.push(key)
151
+ }
152
+ const cleared = [...deferred.keys()]
153
+ deferred.clear()
154
+ return { released, cleared }
155
+ }
156
+
157
+ /** 拒绝全部挂起(插件卸载 / 关闭自动续跑)。 */
158
+ function rejectAll(reason = 'disposed') {
159
+ const rejected = []
160
+ for (const key of [...holds.keys()]) {
161
+ if (settle(key, reason, 'reject')) rejected.push(key)
162
+ }
163
+ return rejected
164
+ }
165
+
166
+ /** error 模式:登记一条延后记录(退峰时按 deferredResume 决定是否 followup)。 */
167
+ function remember(sessionId, info = {}) {
168
+ const key = String(sessionId ?? '')
169
+ if (key === '') return null
170
+ const rec = {
171
+ sessionId: key,
172
+ provider: info.provider ?? null,
173
+ model: info.model ?? null,
174
+ reason: info.reason ?? 'peak',
175
+ since: now(),
176
+ }
177
+ deferred.set(key, rec)
178
+ return rec
179
+ }
180
+
181
+ return {
182
+ hold,
183
+ settle,
184
+ releaseAll,
185
+ rejectAll,
186
+ remember,
187
+ /** 活跃挂起快照。 */
188
+ list: () => [...holds.values()].map((e) => ({ sessionId: e.sessionId, provider: e.provider, model: e.model, since: e.since })),
189
+ /** 延后记录快照。 */
190
+ deferredList: () => [...deferred.values()],
191
+ isDeferred: (sessionId) => deferred.has(String(sessionId ?? '')),
192
+ clearDeferred: (sessionId) => deferred.delete(String(sessionId ?? '')),
193
+ has: (sessionId) => holds.has(String(sessionId ?? '')),
194
+ size: () => holds.size,
195
+ deferredSize: () => deferred.size,
196
+ }
197
+ }