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.
- package/CHANGELOG.ja.md +75 -29
- package/CHANGELOG.ko.md +75 -29
- package/CHANGELOG.md +146 -29
- package/INSTALL.ja.md +83 -42
- package/INSTALL.ko.md +83 -42
- package/INSTALL.md +86 -44
- package/INSTALL.zh.md +82 -42
- package/README.en.md +303 -170
- package/README.ja.md +177 -106
- package/README.ko.md +177 -106
- package/README.md +159 -20
- package/dsh.plugin.json +13 -0
- package/lib/client.js +397 -11
- package/lib/client.js.map +1 -1
- package/package.json +101 -78
- package/src/bridge.js +61 -1
- package/src/client/badge-text.ts +49 -0
- package/src/client/index.ts +11 -1
- package/src/client/locales.ts +163 -95
- package/src/client/pause-button-text.ts +60 -0
- package/src/client/pause-button.tsx +129 -0
- package/src/client/settings-card.tsx +391 -191
- package/src/client/status-badge.tsx +59 -66
- package/src/client/styles.ts +30 -0
- package/src/deferrals.js +197 -0
- package/src/index.js +405 -293
- package/src/pause-gate.js +472 -416
- package/src/provider-directory.js +101 -0
- package/src/provider.js +139 -0
- package/src/request-guard.js +191 -0
- package/src/retry.js +227 -202
- package/src/settings.js +118 -97
- package/src/step-gate.js +399 -0
- package/src/targets.js +70 -0
- package/src/time.js +195 -104
- package/src/tool-call-id.js +44 -0
- package/src/wiring.js +305 -0
package/src/index.js
CHANGED
|
@@ -1,293 +1,405 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-session-guard — host half。
|
|
3
|
-
*
|
|
4
|
-
* 高峰自动会话门:
|
|
5
|
-
* - 每 30s tick 判定状态(NORMAL ↔ PAUSED_PEAK),纯状态机见 scheduler.js。
|
|
6
|
-
* - 入峰(且非周末):对所有 running root session 调 gate.stopNextTurn
|
|
7
|
-
* (有 taskControl → 会话门 safe+wait;无 → 回退锁等待队列)。
|
|
8
|
-
* - 退峰/周末:gate.resume 全部。
|
|
9
|
-
* - `ctx.provide('sessionGuard')` 冗余端口,input-traffic 冻结按钮透传接入。
|
|
10
|
-
* - 设置:设置 → 插件 → session-guard 子板块,简单开关(enabled / weekendMode / queueFallback)。
|
|
11
|
-
* schemastery 为常规 dependency,设置经 `ctx.inject(['settings'])` 本地接口注册(src/settings.js,
|
|
12
|
-
* 对齐 dsh-thinking-levels;不 value-import dsh-settings);设置服务缺失时 fail-open 用默认配置照常运行。
|
|
13
|
-
*/
|
|
14
|
-
import { computeState, transition } from './scheduler.js'
|
|
15
|
-
import { wallClock, isWeekend, isInPeak, BILLING_TIMEZONE } from './time.js'
|
|
16
|
-
import { createStore } from './store.js'
|
|
17
|
-
import { createGate } from './gate.js'
|
|
18
|
-
import { createBridge } from './bridge.js'
|
|
19
|
-
import { createRetry } from './retry.js'
|
|
20
|
-
import { createPauseStore } from './pause-store.js'
|
|
21
|
-
import { createPauseGate } from './pause-gate.js'
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// ──
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — host half。
|
|
3
|
+
*
|
|
4
|
+
* 高峰自动会话门:
|
|
5
|
+
* - 每 30s tick 判定状态(NORMAL ↔ PAUSED_PEAK),纯状态机见 scheduler.js。
|
|
6
|
+
* - 入峰(且非周末):对所有 running root session 调 gate.stopNextTurn
|
|
7
|
+
* (有 taskControl → 会话门 safe+wait;无 → 回退锁等待队列)。
|
|
8
|
+
* - 退峰/周末:gate.resume 全部。
|
|
9
|
+
* - `ctx.provide('sessionGuard')` 冗余端口,input-traffic 冻结按钮透传接入。
|
|
10
|
+
* - 设置:设置 → 插件 → session-guard 子板块,简单开关(enabled / weekendMode / queueFallback)。
|
|
11
|
+
* schemastery 为常规 dependency,设置经 `ctx.inject(['settings'])` 本地接口注册(src/settings.js,
|
|
12
|
+
* 对齐 dsh-thinking-levels;不 value-import dsh-settings);设置服务缺失时 fail-open 用默认配置照常运行。
|
|
13
|
+
*/
|
|
14
|
+
import { computeState, transition } from './scheduler.js'
|
|
15
|
+
import { wallClock, isWeekend, isInPeak, BILLING_TIMEZONE } from './time.js'
|
|
16
|
+
import { createStore } from './store.js'
|
|
17
|
+
import { createGate } from './gate.js'
|
|
18
|
+
import { createBridge } from './bridge.js'
|
|
19
|
+
import { createRetry } from './retry.js'
|
|
20
|
+
import { createPauseStore } from './pause-store.js'
|
|
21
|
+
import { createPauseGate } from './pause-gate.js'
|
|
22
|
+
import { createStepGate } from './step-gate.js'
|
|
23
|
+
import { detectTaskControl } from './detect.js'
|
|
24
|
+
import { makeIsRoot } from './request-guard.js'
|
|
25
|
+
import { createWiring } from './wiring.js'
|
|
26
|
+
import { NS, DEFAULT_SETTINGS, SettingsSchema, registerSettings } from './settings.js'
|
|
27
|
+
|
|
28
|
+
export const name = 'session-guard'
|
|
29
|
+
export const inject = ['agents', 'webServer', 'settings', 'timer', 'commands', 'goals']
|
|
30
|
+
|
|
31
|
+
export { NS, DEFAULT_SETTINGS }
|
|
32
|
+
|
|
33
|
+
export function apply(ctx) {
|
|
34
|
+
const store = createStore()
|
|
35
|
+
let lastState = null
|
|
36
|
+
|
|
37
|
+
/** 读实时设置(settings 服务不可用时回退默认)。 */
|
|
38
|
+
function readCfg() {
|
|
39
|
+
try {
|
|
40
|
+
const v = ctx.settings.get(NS)
|
|
41
|
+
return v && typeof v === 'object' ? { ...DEFAULT_SETTINGS, ...v } : { ...DEFAULT_SETTINGS }
|
|
42
|
+
} catch {
|
|
43
|
+
return { ...DEFAULT_SETTINGS }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── SSE 推送(v0.2.0):step 门状态变化时立刻推给对应会话页面 ──
|
|
48
|
+
// 客户端「暂停会话 / 继续会话」按钮据此更新,无需等轮询。
|
|
49
|
+
const sseClients = new Set()
|
|
50
|
+
|
|
51
|
+
function sseSend(client, payload) {
|
|
52
|
+
try {
|
|
53
|
+
client.res.write(`data: ${JSON.stringify(payload)}\n\n`)
|
|
54
|
+
} catch {
|
|
55
|
+
/* 连接已断开:交给 close 事件清理 */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function broadcastStep(sessionId, stepState) {
|
|
60
|
+
const id = String(sessionId)
|
|
61
|
+
for (const client of [...sseClients]) {
|
|
62
|
+
if (client.sessionId !== '' && client.sessionId !== id) continue
|
|
63
|
+
sseSend(client, { type: 'step', sessionId: id, state: stepState })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── 自研会话门(脱离 dsh-task-control,真暂停)──
|
|
68
|
+
// 状态持久化 + 引擎;经 gate.stopNextTurn/resume 主路径接入;/pause /resume /cancel 命令。
|
|
69
|
+
// v0.2.0 追加 step 级门控(src/step-gate.js):stepGate 与 pauseGate/wiring 互相需要,
|
|
70
|
+
// 用 late-bound 引用装配,避免构造期循环依赖。
|
|
71
|
+
const pauseStore = createPauseStore()
|
|
72
|
+
let pauseGateRef = null
|
|
73
|
+
let wiringRef = null
|
|
74
|
+
const stepGate = createStepGate({
|
|
75
|
+
getSettings: readCfg,
|
|
76
|
+
shouldHoldSession: (cfg, id) => (wiringRef ? wiringRef.shouldPauseSession(cfg, id) : true),
|
|
77
|
+
isHeldByDeferrals: (id) => (wiringRef ? wiringRef.deferrals.has(id) : false),
|
|
78
|
+
isRootAgent: makeIsRoot(ctx),
|
|
79
|
+
pauseGate: { pause: (id, opts) => pauseGateRef.pause(id, opts) },
|
|
80
|
+
logger: ctx.logger,
|
|
81
|
+
onChange: (id, snapshot) => broadcastStep(id, snapshot),
|
|
82
|
+
})
|
|
83
|
+
const pauseGate = createPauseGate({ ctx, pauseStore, stepGate })
|
|
84
|
+
pauseGateRef = pauseGate
|
|
85
|
+
const gate = createGate({ getCtx: () => ctx, getSettings: readCfg, store, pauseGate })
|
|
86
|
+
const bridge = createBridge(ctx, gate, store, pauseGate, stepGate)
|
|
87
|
+
|
|
88
|
+
// ── 官方 provider 二维判定接线(目标追踪 / 请求级守卫 / 入峰过滤 / 退峰释放)──
|
|
89
|
+
// 纯逻辑见 provider.js / provider-directory.js / deferrals.js / request-guard.js / targets.js,
|
|
90
|
+
// 接线与编排见 wiring.js;这里只负责装配与生命周期。
|
|
91
|
+
const wiring = createWiring({ ctx, getSettings: readCfg, gate, stepGate, logger: ctx.logger })
|
|
92
|
+
wiringRef = wiring
|
|
93
|
+
|
|
94
|
+
// ── 冗余端口:input-traffic 冻结按钮透传接入(D5/D6/D8)──
|
|
95
|
+
ctx.provide('sessionGuard', bridge)
|
|
96
|
+
|
|
97
|
+
// ── 自研会话门:安全边界监听(session/event 落地延迟暂停)──
|
|
98
|
+
// 监听在命令注册之前,让任何会话事件都能在安全边界落地 pending pause。
|
|
99
|
+
// 同一条监听顺带做「最近真实目标」追踪(request/header 两版本通吃)。
|
|
100
|
+
if (typeof ctx.on === 'function') {
|
|
101
|
+
ctx.effect(() => ctx.on('session/event', (session, event) => {
|
|
102
|
+
try {
|
|
103
|
+
pauseGate.handleEvent(session, event)
|
|
104
|
+
} catch (e) {
|
|
105
|
+
ctx.logger?.warn?.('[session-guard] session event handling failed: ' + String(e))
|
|
106
|
+
}
|
|
107
|
+
wiring.onSessionEvent(session, event)
|
|
108
|
+
}), 'session-guard: pause-gate events')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── 请求级守卫(agent/request waterfall)──
|
|
112
|
+
// 覆盖缺口:tick 只在状态跳变时处理 running 会话,入峰后新启动 / 中途切官方都会漏。
|
|
113
|
+
ctx.effect(() => wiring.installGuard(), 'session-guard: request guard')
|
|
114
|
+
|
|
115
|
+
// ── step 级门控(agent/pre-step waterfall,v0.2.0)──
|
|
116
|
+
// 高峰 + 非周末 + 目标官方 → 在下一个 step 的模型请求前拉门;退峰原地续跑。
|
|
117
|
+
ctx.effect(() => wiring.installStepGuard(), 'session-guard: step guard')
|
|
118
|
+
|
|
119
|
+
ctx.effect(() => () => wiring.dispose(), 'session-guard: wiring dispose')
|
|
120
|
+
|
|
121
|
+
// ── 手动会话门命令(/pause /resume /cancel,全量移植)──
|
|
122
|
+
if (typeof ctx.commands?.register === 'function') {
|
|
123
|
+
const tokensOf = (rawInput) => String(rawInput ?? '').trim().split(/\s+/).filter(Boolean)
|
|
124
|
+
ctx.effect(() => ctx.commands.register({
|
|
125
|
+
name: 'pause',
|
|
126
|
+
description: 'pause the running task (safe: defers to the safe boundary; force: interrupts tools and reasoning now; wait: let reasoning finish; bare /pause follows the pause settings)',
|
|
127
|
+
input: { hint: '[force|safe] [stop|wait]' },
|
|
128
|
+
handler: (invocation) => {
|
|
129
|
+
const sid = String(invocation?.agent?.id ?? '')
|
|
130
|
+
if (!sid) return { kind: 'error', text: 'no session for this command' }
|
|
131
|
+
const opts = {}
|
|
132
|
+
for (const t of tokensOf(invocation.rawInput)) {
|
|
133
|
+
if (t === 'force' || t === 'safe') opts.mode = t
|
|
134
|
+
if (t === 'stop' || t === 'wait') opts.reason = t
|
|
135
|
+
}
|
|
136
|
+
return pauseGate.pause(sid, opts)
|
|
137
|
+
},
|
|
138
|
+
}))
|
|
139
|
+
ctx.effect(() => ctx.commands.register({
|
|
140
|
+
name: 'resume',
|
|
141
|
+
description: 'resume the paused task and continue from the pause point (a force-paused task with an interrupted tool needs `confirm`, plus `rerun`/`skip` for the tool)',
|
|
142
|
+
input: { hint: '[confirm] [rerun|skip]' },
|
|
143
|
+
handler: (invocation) => {
|
|
144
|
+
const sid = String(invocation?.agent?.id ?? '')
|
|
145
|
+
if (!sid) return { kind: 'error', text: 'no session for this command' }
|
|
146
|
+
const tokens = tokensOf(invocation.rawInput)
|
|
147
|
+
// v0.2.0:step 门挂起时,「恢复」= 放行该 step 且本峰内不再拦(用户显式继续)。
|
|
148
|
+
bridge.stepResume(sid, { bypass: true, reason: 'command' })
|
|
149
|
+
return pauseGate.resume(sid, {
|
|
150
|
+
confirm: tokens.includes('confirm'),
|
|
151
|
+
choice: tokens.includes('skip') ? 'skip' : 'rerun',
|
|
152
|
+
})
|
|
153
|
+
},
|
|
154
|
+
}))
|
|
155
|
+
ctx.effect(() => ctx.commands.register({
|
|
156
|
+
name: 'cancel',
|
|
157
|
+
description: 'cancel the running task (stops the current turn immediately, keeps the queue)',
|
|
158
|
+
handler: (invocation) => {
|
|
159
|
+
const sid = String(invocation?.agent?.id ?? '')
|
|
160
|
+
if (!sid) return { kind: 'error', text: 'no session for this command' }
|
|
161
|
+
bridge.stepResume(sid, { bypass: false, reason: 'command' })
|
|
162
|
+
return pauseGate.cancel(sid)
|
|
163
|
+
},
|
|
164
|
+
}))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── 后端自动重试(D9):冻结/门控期间让路,绝不绕过会话门 ──
|
|
168
|
+
createRetry({
|
|
169
|
+
ctx,
|
|
170
|
+
getSettings: readCfg,
|
|
171
|
+
isFrozen: (sessionId) => {
|
|
172
|
+
const st = bridge.state(sessionId)
|
|
173
|
+
if (st.queueLocked) return true
|
|
174
|
+
if (st.paused) return true // 自研会话门真暂停
|
|
175
|
+
if (st.pausedStep) return true // v0.2.0:step 门挂起同样让路,绝不绕过会话门
|
|
176
|
+
if (st.taskControl && st.taskControl.paused) return true
|
|
177
|
+
return false
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// ── 设置子板块(设置 → 插件 → session-guard,简单开关)──
|
|
182
|
+
// fail-open:原生设置栈可用才注册,缺失则静默降级用默认配置(永不因设置依赖而崩)。
|
|
183
|
+
void registerSettings(ctx)
|
|
184
|
+
|
|
185
|
+
// ── 状态机驱动(30s tick)──
|
|
186
|
+
// 入峰:只暂停「最近目标为官方或 unknown」的 running 会话(providerGuard 关闭时退回全部);
|
|
187
|
+
// 退峰:先放行挂起的请求,再按 deferredResume / offPeakAutoResume 决定续跑(见 wiring.js)。
|
|
188
|
+
async function onEnterPeak(cfg) {
|
|
189
|
+
await wiring.onEnterPeak(cfg)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function onLeavePeak(cfg) {
|
|
193
|
+
await wiring.onLeavePeak(cfg)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function tick() {
|
|
197
|
+
try {
|
|
198
|
+
const cfg = readCfg()
|
|
199
|
+
const next = computeState(cfg, new Date())
|
|
200
|
+
if (lastState === null) {
|
|
201
|
+
lastState = next
|
|
202
|
+
return // 首次 tick 只记录基线,不触发(避免装插件瞬间误暂停)
|
|
203
|
+
}
|
|
204
|
+
const t = transition(lastState, next)
|
|
205
|
+
if (t.enter) {
|
|
206
|
+
lastState = { ...next }
|
|
207
|
+
void onEnterPeak(cfg)
|
|
208
|
+
} else if (t.leave) {
|
|
209
|
+
lastState = { ...next }
|
|
210
|
+
void onLeavePeak(cfg)
|
|
211
|
+
} else {
|
|
212
|
+
lastState = next
|
|
213
|
+
}
|
|
214
|
+
} catch (e) {
|
|
215
|
+
ctx.logger?.error?.(`[session-guard] tick failed: ${String(e && e.message || e)}`)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
ctx.effect(() => ctx.timer.interval(tick, 30_000), 'session-guard: tick')
|
|
220
|
+
tick()
|
|
221
|
+
|
|
222
|
+
// ── HTTP 路由 ──
|
|
223
|
+
if (ctx.webServer && typeof ctx.webServer.register === 'function') {
|
|
224
|
+
ctx.effect(() => ctx.webServer.register({
|
|
225
|
+
kind: 'prefix',
|
|
226
|
+
path: '/session-guard',
|
|
227
|
+
handler: async (req, res) => {
|
|
228
|
+
try {
|
|
229
|
+
const url = new URL(req.url ?? '/', 'http://dsh.local')
|
|
230
|
+
const method = req.method ?? 'GET'
|
|
231
|
+
const json = (code, payload) => {
|
|
232
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
|
|
233
|
+
res.end(JSON.stringify(payload))
|
|
234
|
+
}
|
|
235
|
+
// GET /session-guard/state?session=<id>
|
|
236
|
+
if (method === 'GET' && url.pathname === '/session-guard/state') {
|
|
237
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
238
|
+
if (!sessionId) return json(400, { ok: false, error: 'missing session' })
|
|
239
|
+
const st = bridge.state(sessionId)
|
|
240
|
+
return json(200, {
|
|
241
|
+
ok: true,
|
|
242
|
+
state: st,
|
|
243
|
+
// v0.2.0:PRD §6.2 的 `paused: { step, turn }` 形状(顶层,兼容既有 state.paused 布尔)
|
|
244
|
+
// step = 已拉门;manual = 已请求但还没到边界(按钮同样显示「继续会话」)
|
|
245
|
+
paused: { step: st.pausedStep === true, turn: st.paused === true, manual: st.stepManual === true },
|
|
246
|
+
stepGate: {
|
|
247
|
+
held: st.pausedStep === true,
|
|
248
|
+
manual: st.stepManual === true,
|
|
249
|
+
since: st.stepHeldSince,
|
|
250
|
+
bypass: st.stepBypass === true,
|
|
251
|
+
},
|
|
252
|
+
target: wiring.targets.get(sessionId),
|
|
253
|
+
held: wiring.deferrals.has(sessionId),
|
|
254
|
+
deferred: wiring.deferrals.isDeferred(sessionId),
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
// GET /session-guard/events?session=<id> —— SSE:step 门状态变化即时推送
|
|
258
|
+
if (method === 'GET' && url.pathname === '/session-guard/events') {
|
|
259
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
260
|
+
res.writeHead(200, {
|
|
261
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
262
|
+
'cache-control': 'no-cache, no-transform',
|
|
263
|
+
connection: 'keep-alive',
|
|
264
|
+
})
|
|
265
|
+
res.write(': connected\n\n')
|
|
266
|
+
const client = { sessionId, res }
|
|
267
|
+
sseClients.add(client)
|
|
268
|
+
sseSend(client, { type: 'step', sessionId, state: stepGate.state(sessionId) })
|
|
269
|
+
const keepAlive = setInterval(() => {
|
|
270
|
+
try {
|
|
271
|
+
res.write(': ping\n\n')
|
|
272
|
+
} catch {
|
|
273
|
+
/* 连接已断开 */
|
|
274
|
+
}
|
|
275
|
+
}, 25_000)
|
|
276
|
+
if (typeof keepAlive.unref === 'function') keepAlive.unref()
|
|
277
|
+
const cleanup = () => {
|
|
278
|
+
clearInterval(keepAlive)
|
|
279
|
+
sseClients.delete(client)
|
|
280
|
+
}
|
|
281
|
+
req.on?.('close', cleanup)
|
|
282
|
+
res.on?.('close', cleanup)
|
|
283
|
+
return undefined
|
|
284
|
+
}
|
|
285
|
+
// GET /session-guard/provider?provider=<id> —— 官方判定诊断(排查误判用)
|
|
286
|
+
if (method === 'GET' && url.pathname === '/session-guard/provider') {
|
|
287
|
+
const provider = url.searchParams.get('provider') ?? ''
|
|
288
|
+
if (!provider) return json(400, { ok: false, error: 'missing provider' })
|
|
289
|
+
return json(200, { ok: true, verdict: wiring.directory.describe(provider) })
|
|
290
|
+
}
|
|
291
|
+
// GET /session-guard/settings
|
|
292
|
+
if (method === 'GET' && url.pathname === '/session-guard/settings') {
|
|
293
|
+
const cfg = readCfg()
|
|
294
|
+
return json(200, { ok: true, settings: cfg, taskControlAvailable: detectTaskControl(ctx) })
|
|
295
|
+
}
|
|
296
|
+
// GET /session-guard/status —— 全局当前阶段(状态徽标轮询用)
|
|
297
|
+
// 峰谷判定:固定北京时间(BILLING_TIMEZONE),与 DeepSeek 官方计费一致
|
|
298
|
+
// 周末判定:用配置时区(用户本地的周末)
|
|
299
|
+
if (method === 'GET' && url.pathname === '/session-guard/status') {
|
|
300
|
+
const cfg = readCfg()
|
|
301
|
+
const now = new Date()
|
|
302
|
+
const wcUser = wallClock(cfg.timezone, now)
|
|
303
|
+
const wcBilling = wallClock(BILLING_TIMEZONE, now)
|
|
304
|
+
const weekend = isWeekend(wcUser.weekday)
|
|
305
|
+
const peak = cfg.enabled && !weekend && isInPeak(wcBilling, cfg.peakWindows || [])
|
|
306
|
+
return json(200, {
|
|
307
|
+
ok: true,
|
|
308
|
+
status: {
|
|
309
|
+
phase: weekend ? 'weekend' : peak ? 'peak' : 'off-peak',
|
|
310
|
+
weekend,
|
|
311
|
+
peak,
|
|
312
|
+
state: lastState,
|
|
313
|
+
enabled: cfg.enabled,
|
|
314
|
+
weekendMode: cfg.weekendMode,
|
|
315
|
+
providerGuard: cfg.providerGuard === true,
|
|
316
|
+
stepLevelPause: cfg.stepLevelPause === true,
|
|
317
|
+
held: wiring.deferrals.size(),
|
|
318
|
+
deferred: wiring.deferrals.deferredSize(),
|
|
319
|
+
stepHeld: stepGate.heldIds().length,
|
|
320
|
+
timezone: cfg.timezone,
|
|
321
|
+
billingTimezone: BILLING_TIMEZONE,
|
|
322
|
+
now: now.toISOString(),
|
|
323
|
+
},
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
// GET /session-guard/diag —— 运行时诊断:settings 服务形状 + 已注册 namespace 列表
|
|
327
|
+
if (method === 'GET' && url.pathname === '/session-guard/diag') {
|
|
328
|
+
const settings = ctx.settings
|
|
329
|
+
let hasSettings = !!settings && typeof settings === 'object'
|
|
330
|
+
let hasRegister = typeof (settings && settings.register) === 'function'
|
|
331
|
+
let namespaces = null
|
|
332
|
+
let describeErr = null
|
|
333
|
+
try {
|
|
334
|
+
const d = typeof settings.describe === 'function' ? settings.describe() : null
|
|
335
|
+
namespaces = Array.isArray(d) ? d.map((x) => x && x.ns) : d
|
|
336
|
+
} catch (e) {
|
|
337
|
+
describeErr = String(e && e.message || e)
|
|
338
|
+
}
|
|
339
|
+
return json(200, {
|
|
340
|
+
ok: true,
|
|
341
|
+
diag: {
|
|
342
|
+
hasSettings,
|
|
343
|
+
settingsType: hasSettings ? (settings.constructor ? settings.constructor.name : typeof settings) : typeof settings,
|
|
344
|
+
settingsKeys: hasSettings ? Object.keys(settings) : [],
|
|
345
|
+
hasRegister,
|
|
346
|
+
hasGet: typeof (settings && settings.get) === 'function',
|
|
347
|
+
hasDescribe: typeof (settings && settings.describe) === 'function',
|
|
348
|
+
namespaces,
|
|
349
|
+
describeErr,
|
|
350
|
+
ns: NS,
|
|
351
|
+
schemaOk: !!SettingsSchema && typeof SettingsSchema === 'function',
|
|
352
|
+
providerGuard: readCfg().providerGuard === true,
|
|
353
|
+
configurableProviders: wiring.directory.entries().length,
|
|
354
|
+
held: wiring.deferrals.size(),
|
|
355
|
+
deferred: wiring.deferrals.deferredSize(),
|
|
356
|
+
stepGate: {
|
|
357
|
+
held: stepGate.heldIds(),
|
|
358
|
+
bypass: stepGate._bypassed(),
|
|
359
|
+
timeoutMs: readCfg().stepGateTimeoutMs,
|
|
360
|
+
enabled: readCfg().stepLevelPause === true,
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
// POST /session-guard/rpc { action, sessionId, ... }
|
|
366
|
+
if (method === 'POST' && url.pathname === '/session-guard/rpc') {
|
|
367
|
+
const chunks = []
|
|
368
|
+
for await (const c of req) chunks.push(c)
|
|
369
|
+
const body = Buffer.concat(chunks).toString('utf8')
|
|
370
|
+
let parsed = {}
|
|
371
|
+
try {
|
|
372
|
+
parsed = body ? JSON.parse(body) : {}
|
|
373
|
+
} catch {
|
|
374
|
+
return json(400, { ok: false, error: 'invalid json' })
|
|
375
|
+
}
|
|
376
|
+
const sessionId = String(parsed.sessionId ?? '')
|
|
377
|
+
if (!sessionId) return json(400, { ok: false, error: 'missing sessionId' })
|
|
378
|
+
const action = String(parsed.action ?? '')
|
|
379
|
+
if (action === 'stopNextTurn') return json(200, { ok: true, result: await bridge.stopNextTurn(sessionId, parsed) })
|
|
380
|
+
if (action === 'resume') return json(200, { ok: true, result: await bridge.resume(sessionId, parsed) })
|
|
381
|
+
if (action === 'lockQueue') return json(200, { ok: true, result: bridge.lockQueue(sessionId, parsed.reason) })
|
|
382
|
+
if (action === 'unlockQueue') return json(200, { ok: true, result: bridge.unlockQueue(sessionId) })
|
|
383
|
+
if (action === 'stepResume') {
|
|
384
|
+
return json(200, {
|
|
385
|
+
ok: true,
|
|
386
|
+
result: bridge.stepResume(sessionId, {
|
|
387
|
+
bypass: parsed.bypass !== false,
|
|
388
|
+
reason: typeof parsed.reason === 'string' && parsed.reason !== '' ? parsed.reason : 'rpc',
|
|
389
|
+
}),
|
|
390
|
+
})
|
|
391
|
+
}
|
|
392
|
+
if (action === 'stepPause') return json(200, { ok: true, result: bridge.stepPause(sessionId) })
|
|
393
|
+
if (action === 'state') return json(200, { ok: true, state: bridge.state(sessionId) })
|
|
394
|
+
return json(400, { ok: false, error: `unknown action ${action}` })
|
|
395
|
+
}
|
|
396
|
+
return json(404, { ok: false, error: `unknown ${method} ${url.pathname}` })
|
|
397
|
+
} catch (e) {
|
|
398
|
+
ctx.logger?.error?.(`[session-guard] route error: ${String(e && e.message || e)}`)
|
|
399
|
+
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
|
|
400
|
+
res.end(JSON.stringify({ ok: false, error: String(e && e.message || e) }))
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
}), 'session-guard: routes')
|
|
404
|
+
}
|
|
405
|
+
}
|