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/index.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
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 } 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 { detectTaskControl } from './detect.js'
|
|
23
|
+
import { NS, DEFAULT_SETTINGS, SettingsSchema, registerSettings } from './settings.js'
|
|
24
|
+
|
|
25
|
+
export const name = 'session-guard'
|
|
26
|
+
export const inject = ['agents', 'webServer', 'settings', 'timer', 'commands', 'goals']
|
|
27
|
+
|
|
28
|
+
export { NS, DEFAULT_SETTINGS }
|
|
29
|
+
|
|
30
|
+
export function apply(ctx) {
|
|
31
|
+
const store = createStore()
|
|
32
|
+
let lastState = null
|
|
33
|
+
|
|
34
|
+
/** 读实时设置(settings 服务不可用时回退默认)。 */
|
|
35
|
+
function readCfg() {
|
|
36
|
+
try {
|
|
37
|
+
const v = ctx.settings.get(NS)
|
|
38
|
+
return v && typeof v === 'object' ? { ...DEFAULT_SETTINGS, ...v } : { ...DEFAULT_SETTINGS }
|
|
39
|
+
} catch {
|
|
40
|
+
return { ...DEFAULT_SETTINGS }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── 自研会话门(脱离 dsh-task-control,真暂停)──
|
|
45
|
+
// 状态持久化 + 引擎;经 gate.stopNextTurn/resume 主路径接入;/pause /resume /cancel 命令。
|
|
46
|
+
const pauseStore = createPauseStore()
|
|
47
|
+
const pauseGate = createPauseGate({ ctx, pauseStore })
|
|
48
|
+
const gate = createGate({ getCtx: () => ctx, getSettings: readCfg, store, pauseGate })
|
|
49
|
+
const bridge = createBridge(ctx, gate, store, pauseGate)
|
|
50
|
+
|
|
51
|
+
// ── 冗余端口:input-traffic 冻结按钮透传接入(D5/D6/D8)──
|
|
52
|
+
ctx.provide('sessionGuard', bridge)
|
|
53
|
+
|
|
54
|
+
// ── 自研会话门:安全边界监听(session/event 落地延迟暂停)──
|
|
55
|
+
// 监听在命令注册之前,让任何会话事件都能在安全边界落地 pending pause。
|
|
56
|
+
if (typeof ctx.on === 'function') {
|
|
57
|
+
ctx.effect(() => ctx.on('session/event', (session, event) => {
|
|
58
|
+
try {
|
|
59
|
+
pauseGate.handleEvent(session, event)
|
|
60
|
+
} catch (e) {
|
|
61
|
+
ctx.logger?.warn?.('[session-guard] session event handling failed: ' + String(e))
|
|
62
|
+
}
|
|
63
|
+
}), 'session-guard: pause-gate events')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── 手动会话门命令(/pause /resume /cancel,全量移植)──
|
|
67
|
+
if (typeof ctx.commands?.register === 'function') {
|
|
68
|
+
const tokensOf = (rawInput) => String(rawInput ?? '').trim().split(/\s+/).filter(Boolean)
|
|
69
|
+
ctx.effect(() => ctx.commands.register({
|
|
70
|
+
name: 'pause',
|
|
71
|
+
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)',
|
|
72
|
+
input: { hint: '[force|safe] [stop|wait]' },
|
|
73
|
+
handler: (invocation) => {
|
|
74
|
+
const sid = String(invocation?.agent?.id ?? '')
|
|
75
|
+
if (!sid) return { kind: 'error', text: 'no session for this command' }
|
|
76
|
+
const opts = {}
|
|
77
|
+
for (const t of tokensOf(invocation.rawInput)) {
|
|
78
|
+
if (t === 'force' || t === 'safe') opts.mode = t
|
|
79
|
+
if (t === 'stop' || t === 'wait') opts.reason = t
|
|
80
|
+
}
|
|
81
|
+
return pauseGate.pause(sid, opts)
|
|
82
|
+
},
|
|
83
|
+
}))
|
|
84
|
+
ctx.effect(() => ctx.commands.register({
|
|
85
|
+
name: 'resume',
|
|
86
|
+
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)',
|
|
87
|
+
input: { hint: '[confirm] [rerun|skip]' },
|
|
88
|
+
handler: (invocation) => {
|
|
89
|
+
const sid = String(invocation?.agent?.id ?? '')
|
|
90
|
+
if (!sid) return { kind: 'error', text: 'no session for this command' }
|
|
91
|
+
const tokens = tokensOf(invocation.rawInput)
|
|
92
|
+
return pauseGate.resume(sid, {
|
|
93
|
+
confirm: tokens.includes('confirm'),
|
|
94
|
+
choice: tokens.includes('skip') ? 'skip' : 'rerun',
|
|
95
|
+
})
|
|
96
|
+
},
|
|
97
|
+
}))
|
|
98
|
+
ctx.effect(() => ctx.commands.register({
|
|
99
|
+
name: 'cancel',
|
|
100
|
+
description: 'cancel the running task (stops the current turn immediately, keeps the queue)',
|
|
101
|
+
handler: (invocation) => {
|
|
102
|
+
const sid = String(invocation?.agent?.id ?? '')
|
|
103
|
+
if (!sid) return { kind: 'error', text: 'no session for this command' }
|
|
104
|
+
return pauseGate.cancel(sid)
|
|
105
|
+
},
|
|
106
|
+
}))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── 后端自动重试(D9):冻结/门控期间让路,绝不绕过会话门 ──
|
|
110
|
+
createRetry({
|
|
111
|
+
ctx,
|
|
112
|
+
getSettings: readCfg,
|
|
113
|
+
isFrozen: (sessionId) => {
|
|
114
|
+
const st = bridge.state(sessionId)
|
|
115
|
+
if (st.queueLocked) return true
|
|
116
|
+
if (st.paused) return true // 自研会话门真暂停
|
|
117
|
+
if (st.taskControl && st.taskControl.paused) return true
|
|
118
|
+
return false
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// ── 设置子板块(设置 → 插件 → session-guard,简单开关)──
|
|
123
|
+
// fail-open:原生设置栈可用才注册,缺失则静默降级用默认配置(永不因设置依赖而崩)。
|
|
124
|
+
void registerSettings(ctx)
|
|
125
|
+
|
|
126
|
+
// ── 状态机驱动(30s tick)──
|
|
127
|
+
async function onEnterPeak(cfg) {
|
|
128
|
+
const agents = ctx.agents
|
|
129
|
+
const roots = typeof agents.roots === 'function' ? agents.roots() : agents.list()
|
|
130
|
+
const paused = []
|
|
131
|
+
for (const agent of roots) {
|
|
132
|
+
if (agent && agent.status === 'running') {
|
|
133
|
+
const r = await gate.stopNextTurn(String(agent.id), {
|
|
134
|
+
mode: cfg.pauseMode,
|
|
135
|
+
reason: cfg.pauseReason,
|
|
136
|
+
})
|
|
137
|
+
paused.push({ sessionId: String(agent.id), via: r.via, ok: r.ok })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
ctx.logger?.info?.(`[session-guard] peak entered — paused ${paused.length} running session(s): ${JSON.stringify(paused)}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function onLeavePeak(cfg) {
|
|
144
|
+
// 低谷自动恢复开关:关掉则退峰不自动恢复(会话保持暂停,需手动恢复)。
|
|
145
|
+
if (cfg.offPeakAutoResume === false) {
|
|
146
|
+
ctx.logger?.info?.('[session-guard] off-peak auto-resume disabled — sessions stay paused')
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const agents = ctx.agents
|
|
150
|
+
const roots = typeof agents.roots === 'function' ? agents.roots() : agents.list()
|
|
151
|
+
const resumed = []
|
|
152
|
+
for (const agent of roots) {
|
|
153
|
+
const r = await gate.resume(String(agent.id), { choice: 'rerun' })
|
|
154
|
+
resumed.push({ sessionId: String(agent.id), via: r.via, ok: r.ok })
|
|
155
|
+
}
|
|
156
|
+
ctx.logger?.info?.(`[session-guard] peak left — resumed ${resumed.length} session(s): ${JSON.stringify(resumed)}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function tick() {
|
|
160
|
+
try {
|
|
161
|
+
const cfg = readCfg()
|
|
162
|
+
const next = computeState(cfg, new Date())
|
|
163
|
+
if (lastState === null) {
|
|
164
|
+
lastState = next
|
|
165
|
+
return // 首次 tick 只记录基线,不触发(避免装插件瞬间误暂停)
|
|
166
|
+
}
|
|
167
|
+
const t = transition(lastState, next)
|
|
168
|
+
if (t.enter) {
|
|
169
|
+
lastState = { ...next }
|
|
170
|
+
void onEnterPeak(cfg)
|
|
171
|
+
} else if (t.leave) {
|
|
172
|
+
lastState = { ...next }
|
|
173
|
+
void onLeavePeak(cfg)
|
|
174
|
+
} else {
|
|
175
|
+
lastState = next
|
|
176
|
+
}
|
|
177
|
+
} catch (e) {
|
|
178
|
+
ctx.logger?.error?.(`[session-guard] tick failed: ${String(e && e.message || e)}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
ctx.effect(() => ctx.timer.interval(tick, 30_000), 'session-guard: tick')
|
|
183
|
+
tick()
|
|
184
|
+
|
|
185
|
+
// ── HTTP 路由 ──
|
|
186
|
+
if (ctx.webServer && typeof ctx.webServer.register === 'function') {
|
|
187
|
+
ctx.effect(() => ctx.webServer.register({
|
|
188
|
+
kind: 'prefix',
|
|
189
|
+
path: '/session-guard',
|
|
190
|
+
handler: async (req, res) => {
|
|
191
|
+
try {
|
|
192
|
+
const url = new URL(req.url ?? '/', 'http://dsh.local')
|
|
193
|
+
const method = req.method ?? 'GET'
|
|
194
|
+
const json = (code, payload) => {
|
|
195
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
|
|
196
|
+
res.end(JSON.stringify(payload))
|
|
197
|
+
}
|
|
198
|
+
// GET /session-guard/state?session=<id>
|
|
199
|
+
if (method === 'GET' && url.pathname === '/session-guard/state') {
|
|
200
|
+
const sessionId = url.searchParams.get('session') ?? ''
|
|
201
|
+
if (!sessionId) return json(400, { ok: false, error: 'missing session' })
|
|
202
|
+
return json(200, { ok: true, state: bridge.state(sessionId) })
|
|
203
|
+
}
|
|
204
|
+
// GET /session-guard/settings
|
|
205
|
+
if (method === 'GET' && url.pathname === '/session-guard/settings') {
|
|
206
|
+
const cfg = readCfg()
|
|
207
|
+
return json(200, { ok: true, settings: cfg, taskControlAvailable: detectTaskControl(ctx) })
|
|
208
|
+
}
|
|
209
|
+
// GET /session-guard/status —— 全局当前阶段(状态徽标轮询用)
|
|
210
|
+
if (method === 'GET' && url.pathname === '/session-guard/status') {
|
|
211
|
+
const cfg = readCfg()
|
|
212
|
+
const now = new Date()
|
|
213
|
+
const wc = wallClock(cfg.timezone, now)
|
|
214
|
+
const weekend = isWeekend(wc.weekday)
|
|
215
|
+
const peak = cfg.enabled && !weekend && isInPeak(wc, cfg.peakWindows || [])
|
|
216
|
+
return json(200, {
|
|
217
|
+
ok: true,
|
|
218
|
+
status: {
|
|
219
|
+
phase: weekend ? 'weekend' : peak ? 'peak' : 'off-peak',
|
|
220
|
+
weekend,
|
|
221
|
+
peak,
|
|
222
|
+
state: lastState,
|
|
223
|
+
enabled: cfg.enabled,
|
|
224
|
+
weekendMode: cfg.weekendMode,
|
|
225
|
+
timezone: cfg.timezone,
|
|
226
|
+
now: now.toISOString(),
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
// GET /session-guard/diag —— 运行时诊断:settings 服务形状 + 已注册 namespace 列表
|
|
231
|
+
if (method === 'GET' && url.pathname === '/session-guard/diag') {
|
|
232
|
+
const settings = ctx.settings
|
|
233
|
+
let hasSettings = !!settings && typeof settings === 'object'
|
|
234
|
+
let hasRegister = typeof (settings && settings.register) === 'function'
|
|
235
|
+
let namespaces = null
|
|
236
|
+
let describeErr = null
|
|
237
|
+
try {
|
|
238
|
+
const d = typeof settings.describe === 'function' ? settings.describe() : null
|
|
239
|
+
namespaces = Array.isArray(d) ? d.map((x) => x && x.ns) : d
|
|
240
|
+
} catch (e) {
|
|
241
|
+
describeErr = String(e && e.message || e)
|
|
242
|
+
}
|
|
243
|
+
return json(200, {
|
|
244
|
+
ok: true,
|
|
245
|
+
diag: {
|
|
246
|
+
hasSettings,
|
|
247
|
+
settingsType: hasSettings ? (settings.constructor ? settings.constructor.name : typeof settings) : typeof settings,
|
|
248
|
+
settingsKeys: hasSettings ? Object.keys(settings) : [],
|
|
249
|
+
hasRegister,
|
|
250
|
+
hasGet: typeof (settings && settings.get) === 'function',
|
|
251
|
+
hasDescribe: typeof (settings && settings.describe) === 'function',
|
|
252
|
+
namespaces,
|
|
253
|
+
describeErr,
|
|
254
|
+
ns: NS,
|
|
255
|
+
schemaOk: !!SettingsSchema && typeof SettingsSchema === 'function',
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
// POST /session-guard/rpc { action, sessionId, ... }
|
|
260
|
+
if (method === 'POST' && url.pathname === '/session-guard/rpc') {
|
|
261
|
+
const chunks = []
|
|
262
|
+
for await (const c of req) chunks.push(c)
|
|
263
|
+
const body = Buffer.concat(chunks).toString('utf8')
|
|
264
|
+
let parsed = {}
|
|
265
|
+
try {
|
|
266
|
+
parsed = body ? JSON.parse(body) : {}
|
|
267
|
+
} catch {
|
|
268
|
+
return json(400, { ok: false, error: 'invalid json' })
|
|
269
|
+
}
|
|
270
|
+
const sessionId = String(parsed.sessionId ?? '')
|
|
271
|
+
if (!sessionId) return json(400, { ok: false, error: 'missing sessionId' })
|
|
272
|
+
const action = String(parsed.action ?? '')
|
|
273
|
+
if (action === 'stopNextTurn') return json(200, { ok: true, result: await bridge.stopNextTurn(sessionId, parsed) })
|
|
274
|
+
if (action === 'resume') return json(200, { ok: true, result: await bridge.resume(sessionId, parsed) })
|
|
275
|
+
if (action === 'lockQueue') return json(200, { ok: true, result: bridge.lockQueue(sessionId, parsed.reason) })
|
|
276
|
+
if (action === 'unlockQueue') return json(200, { ok: true, result: bridge.unlockQueue(sessionId) })
|
|
277
|
+
if (action === 'state') return json(200, { ok: true, state: bridge.state(sessionId) })
|
|
278
|
+
return json(400, { ok: false, error: `unknown action ${action}` })
|
|
279
|
+
}
|
|
280
|
+
return json(404, { ok: false, error: `unknown ${method} ${url.pathname}` })
|
|
281
|
+
} catch (e) {
|
|
282
|
+
ctx.logger?.error?.(`[session-guard] route error: ${String(e && e.message || e)}`)
|
|
283
|
+
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
|
|
284
|
+
res.end(JSON.stringify({ ok: false, error: String(e && e.message || e) }))
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
}), 'session-guard: routes')
|
|
288
|
+
}
|
|
289
|
+
}
|