@mzzsfy/dsh-keep-working 0.0.0-stage → 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/src/scan.mjs ADDED
@@ -0,0 +1,425 @@
1
+ /**
2
+ * 重启扫描:把上次进程退出时未结案的存储会话重新拉起来。
3
+ *
4
+ * 宿主只在客户端打开会话时才物化 agent,所以冷启动时必须自己枚举存储、
5
+ * 判定尾部状态、再按需 resume。
6
+ */
7
+
8
+ import { closureVerdict, hasVisibleOutput, turnSlice } from './closure.mjs'
9
+ import { fillTemplate } from './config.mjs'
10
+ import { isTransientFailure, parseFailureFacts } from './failure.mjs'
11
+ import { isDerivedSessionHeader } from './session-kind.mjs'
12
+ import { appendFileSync, readdirSync, statSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { join } from 'node:path'
15
+
16
+ /** 扫描结论落盘,重启恢复出问题时这是唯一可观测面。 */
17
+ const logFile = () => join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'keep-working-scan.log')
18
+
19
+ export function trace(message) {
20
+ try {
21
+ appendFileSync(logFile(), `${new Date().toISOString()} ${message}\n`)
22
+ } catch {}
23
+ }
24
+
25
+ /** 宿主重启产生的取消原因,值得在重启后补一次。 */
26
+ const RESTART_CANCEL_CAUSES = new Set(['disposed', 'legacy'])
27
+
28
+ /** 尾部是否还有活;openTurn 表示回合没闭合,等同于被打断。 */
29
+ function tailVerdict(events, nowTime, config) {
30
+ let lastEnd
31
+ let lastStart
32
+ for (let index = events.length - 1; index >= 0; index -= 1) {
33
+ const item = events[index]
34
+ if (lastEnd === undefined && item?.type === 'turn/end') lastEnd = item
35
+ if (lastStart === undefined && item?.type === 'turn/start') lastStart = item
36
+ if (lastEnd !== undefined && lastStart !== undefined) break
37
+ }
38
+ const openTurn = lastStart !== undefined && (lastEnd === undefined || lastStart.seq > lastEnd.seq)
39
+ if (!openTurn && lastEnd === undefined) return 'skip:空日志'
40
+ const touchedAt = openTurn ? (events.at(-1)?.time ?? 0) : lastEnd.time
41
+ if (touchedAt < nowTime - config.freshMs) return 'skip:超出时间窗口'
42
+ if (openTurn) return 'resume:interrupted'
43
+
44
+ const reason = lastEnd.data?.reason ?? {}
45
+ switch (reason.kind) {
46
+ case 'completed': {
47
+ // 有有效写入却没交付时必须恢复:重启后重做一遍也比永远不交付安全
48
+ const verdict = closureVerdict(events, lastEnd.data.turn)
49
+ if (verdict.reason === 'file-write-requires-present') return 'resume:closure'
50
+ // 回合正常结束就当它做完了:重启后重复执行的代价高于漏恢复的概率
51
+ if (verdict.closed || hasVisibleOutput(turnSlice(events, lastEnd.data.turn))) return 'skip:正常结束'
52
+ return config.resumeSilentTurns ? 'resume:silent' : 'skip:无可见输出'
53
+ }
54
+ case 'error': {
55
+ const failure = parseFailureFacts(reason.error)
56
+ if (failure === undefined) return 'skip:错误无法解析'
57
+ if (config.classify && !isTransientFailure(failure, config.retryableErrorPatterns)) {
58
+ return 'skip:永久错误'
59
+ }
60
+ return 'resume:error'
61
+ }
62
+ case 'interrupted':
63
+ return 'resume:interrupted'
64
+ case 'max-tokens':
65
+ return 'resume:max-tokens'
66
+ case 'aborted':
67
+ return RESTART_CANCEL_CAUSES.has(reason.reason?.kind) ? 'resume:interrupted' : 'skip:用户取消'
68
+ default:
69
+ return 'skip:无需恢复'
70
+ }
71
+ }
72
+
73
+ /** 恢复文案:按尾部原因取对应说法,默认用中断文案。 */
74
+ function resumeText(config, verdict) {
75
+ const reason = verdict.slice('resume:'.length)
76
+ const template =
77
+ reason === 'error'
78
+ ? config.continueText
79
+ : reason === 'max-tokens'
80
+ ? config.continueTextMaxTokens
81
+ : reason === 'silent'
82
+ ? config.continueTextSilent
83
+ : config.continueTextInterrupted
84
+ return fillTemplate(template, {})
85
+ }
86
+
87
+ /** 把存储会话恢复为 live agent;已 live 时直接返回。 */
88
+ async function materialize(ctx, sessionId, presetId) {
89
+ const existing = ctx.agents.get(sessionId)
90
+ if (existing !== undefined) return existing
91
+ const selection = ctx.get('agentDefaultModel')?.currentSelection?.()
92
+ const agentOptions =
93
+ selection === undefined ? undefined : { provider: selection.provider, model: selection.model }
94
+ const presets = ctx.get('agentPresets')
95
+ if (presets === undefined) {
96
+ await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })
97
+ return ctx.agents.get(sessionId)
98
+ }
99
+ // 不挂 preset 的 agent 没有工具与提示变量,恢复后发出的请求无路由。
100
+ const resolvedId = (await presets.resolve(presetId)).id
101
+ await ctx.agents.resume({
102
+ resumeSessionId: sessionId,
103
+ agentOptions,
104
+ setup: async (agentCtx) => {
105
+ await presets.mount(agentCtx, resolvedId)
106
+ },
107
+ })
108
+ return ctx.agents.get(sessionId)
109
+ }
110
+
111
+ /** goal 在进程重启后会被 disarm;active 且 disarmed 时才重新武装。返回是否已交回 goal driver 驱动。 */
112
+ function rearmGoal(ctx, agent, config) {
113
+ if (!config.resumeGoals) return false
114
+ const goals = ctx.get('goals')
115
+ if (goals === undefined) return false
116
+ try {
117
+ const view = goals.get(agent)
118
+ if (view === undefined || view.revision === undefined) return false
119
+ const ref = { id: view.id, revision: view.revision }
120
+ if (view.phase === 'active' && view.activation === 'disarmed') {
121
+ goals.resume(agent, ref)
122
+ return true
123
+ }
124
+ if (view.phase === 'paused' && config.resumePausedGoals) {
125
+ goals.resume(agent, ref)
126
+ return true
127
+ }
128
+ return false
129
+ } catch (error) {
130
+ ctx.logger?.warn?.(`[keep-working] goal 恢复失败 ${agent?.id}: ${String(error)}`)
131
+ return false
132
+ }
133
+ }
134
+
135
+ /** 单轮扫描预算:超出后收手,由调用方下一轮接着扫。 */
136
+ const SCAN_BUDGET_MS = 60_000
137
+
138
+ /** 每检查这么多个会话让出一次事件循环。 */
139
+ const SCAN_YIELD_BATCH = 32
140
+
141
+ /** 会话持久层的落盘目录名,与宿主存储布局一致。 */
142
+ const SESSIONS_DIR = 'sessions'
143
+
144
+ /** 产物规范名:session.v<代际>.jsonl[.zstd],迁移中间产物与临时文件不匹配。 */
145
+ const SESSION_GENERATION = /^session(?:\.v(\d+))?\.jsonl(?:\.[a-z0-9]+)?$/
146
+
147
+ /** 会话目录前缀,目录名即会话 id。 */
148
+ const SESSION_DIR_PREFIX = 'session-'
149
+
150
+ /** 会话产物路径:目录内按代际取最高的一份,避免升级后旧代际文件的旧 mtime 掩盖新写入。 */
151
+ function findArtifact(sessionDir) {
152
+ let names
153
+ try {
154
+ names = readdirSync(sessionDir)
155
+ } catch {
156
+ return undefined
157
+ }
158
+ const candidates = names
159
+ .map((name) => ({ name, generation: SESSION_GENERATION.exec(name)?.[1] ?? '0' }))
160
+ .filter((item) => item.generation !== undefined)
161
+ .sort((left, right) => Number(right.generation) - Number(left.generation))
162
+ return candidates.length === 0 ? undefined : join(sessionDir, candidates[0].name)
163
+ }
164
+
165
+ /**
166
+ * 直接扫会话目录取最近改动过的会话。
167
+ *
168
+ * 宿主的 `persistence.list()` 要为上千个会话逐个解析快照,固定开销接近十秒,
169
+ * 而筛选只需要 id 与修改时间。目录扫描是百毫秒级,命中后再向持久层要头部。
170
+ * 目录缺失(换后端或尚未落盘)时返回 undefined,由调用方走服务接口。
171
+ * 工作区目录不可读会让快照不完整,此时 partial 为真,调用方不得据此认定"没有待恢复会话"。
172
+ * 单个产物缺失不算:非 zstd 后端、旧代际与删除竞态都会表现为缺失,是常态而非读取失败。
173
+ */
174
+ export function listFreshByDirectory(config, nowTime, persistence) {
175
+ if (persistence !== undefined && typeof persistence.stat !== 'function') return undefined
176
+ const root = join(process.env.DSH_HOME || join(homedir(), '.dsh'), SESSIONS_DIR)
177
+ let workspaces
178
+ try {
179
+ workspaces = readdirSync(root, { withFileTypes: true })
180
+ } catch (error) {
181
+ // 根目录不存在等于"一个会话都没有",这是权威空快照而不是读取失败:
182
+ // 新装或被清空时若回退持久层重试,会每秒重扫到新鲜窗口耗尽
183
+ if (error?.code === 'ENOENT') return { items: [], partial: false }
184
+ return undefined
185
+ }
186
+ const cutoff = nowTime - config.freshMs
187
+ const fresh = []
188
+ let partial = false
189
+ for (const workspace of workspaces) {
190
+ if (!workspace.isDirectory()) continue
191
+ let entries
192
+ try {
193
+ entries = readdirSync(join(root, workspace.name), { withFileTypes: true })
194
+ } catch {
195
+ partial = true
196
+ continue
197
+ }
198
+ for (const entry of entries) {
199
+ if (!entry.isDirectory() || !entry.name.startsWith(SESSION_DIR_PREFIX)) continue
200
+ const path = findArtifact(join(root, workspace.name, entry.name))
201
+ if (path === undefined) continue
202
+ let stat
203
+ try {
204
+ stat = statSync(path)
205
+ } catch {
206
+ continue
207
+ }
208
+ if (stat.mtimeMs < cutoff) continue
209
+ fresh.push({ id: entry.name, path, mtimeMs: stat.mtimeMs })
210
+ }
211
+ }
212
+ return { items: fresh, partial }
213
+ }
214
+
215
+ /**
216
+ * 按文件修改时间筛出候选会话。
217
+ *
218
+ * 存储里可能有上千个会话,逐个解码全量事件是分钟级开销;真正需要恢复的只有
219
+ * 最近 freshMs 内被写过的那几个。优先走目录扫描,失败时回落到持久层快照。
220
+ */
221
+ async function listFreshSessions(ctx, config, nowTime, scanDirectory = listFreshByDirectory) {
222
+ const persistence = ctx.get('sessionPersistence')
223
+ const byDirectory = scanDirectory(config, nowTime, persistence)
224
+ // 没有持久层服务就拿不到 header,无法区分子会话;此时应走全量回退而不是盲恢复
225
+ if (byDirectory !== undefined && persistence === undefined) return undefined
226
+ if (byDirectory !== undefined) {
227
+ trace(`目录扫出 ${byDirectory.items.length} 个近期会话`)
228
+ // 头部里带着 preset 与父子身份,只有命中的少数会话才需要问持久层
229
+ let statFailed = false
230
+ const statted = await Promise.all(
231
+ byDirectory.items.map(async (item) => {
232
+ try {
233
+ return { item, snapshot: await persistence.stat?.(item.id) }
234
+ } catch (error) {
235
+ // 服务读失败不等于该会话不存在:整批当快照不完整处理,不能据此收工
236
+ statFailed = true
237
+ trace(`读取会话头失败 ${item.id}: ${error instanceof Error ? error.message : String(error)}`)
238
+ return undefined
239
+ }
240
+ }),
241
+ )
242
+ const fresh = []
243
+ for (const entry of statted) {
244
+ if (entry === undefined) continue
245
+ const header = entry.snapshot?.header
246
+ // 没有头部就无法判定是不是子会话,跳过而不是按主会话恢复
247
+ if (header === undefined) continue
248
+ if (isDerivedSessionHeader(header)) continue
249
+ fresh.push({
250
+ id: entry.item.id,
251
+ presetId: header?.agentPreset,
252
+ mtimeMs: entry.item.mtimeMs,
253
+ eventCount: entry.snapshot?.eventCount ?? 0,
254
+ })
255
+ }
256
+ // 先小后大:大日志解压代价高,先让小的快速出结果,再处理残留的大会话
257
+ fresh.sort((left, right) => left.eventCount - right.eventCount || right.mtimeMs - left.mtimeMs)
258
+ // 有读失败时快照不完整:既不能宣称"没有待恢复会话",也不能退回持久层重列
259
+ return { items: fresh, source: byDirectory.partial || statFailed ? 'partial' : 'directory' }
260
+ }
261
+
262
+ if (persistence === undefined || typeof persistence.list !== 'function') return undefined
263
+ const snapshots = await persistence.list()
264
+ if (!Array.isArray(snapshots)) return undefined
265
+ trace(`持久层快照 ${snapshots.length} 个`)
266
+ const cutoff = nowTime - config.freshMs
267
+ const fresh = []
268
+ for (const snapshot of snapshots) {
269
+ const header = snapshot?.header
270
+ if (header === undefined || typeof header.id !== 'string' || header.id === '') continue
271
+ if (isDerivedSessionHeader(header)) continue
272
+ const path = snapshot?.location?.path
273
+ if (typeof path !== 'string' || path === '') continue
274
+ let mtimeMs
275
+ try {
276
+ mtimeMs = statSync(path).mtimeMs
277
+ } catch {
278
+ continue
279
+ }
280
+ if (mtimeMs < cutoff) continue
281
+ fresh.push({ id: header.id, presetId: header.agentPreset, mtimeMs, eventCount: snapshot.eventCount ?? 0 })
282
+ }
283
+ fresh.sort((left, right) => left.eventCount - right.eventCount || right.mtimeMs - left.mtimeMs)
284
+ return { items: fresh, source: 'persistence' }
285
+ }
286
+
287
+ /** 全量列表回退:持久层缺席时按宿主会话索引遍历。 */
288
+ async function listAllSessions(ctx) {
289
+ const query = ctx.get('sessionQuery')
290
+ if (query === undefined) return undefined
291
+ const records = await query.listSessions()
292
+ if (!Array.isArray(records)) return undefined
293
+ return records
294
+ .map((record) => {
295
+ const header = record?.header
296
+ if (header === undefined || typeof header.id !== 'string' || header.id === '') return undefined
297
+ return { id: header.id, presetId: header.agentPreset, live: record.live === true, header }
298
+ })
299
+ .filter((item) => item !== undefined)
300
+ }
301
+
302
+ /**
303
+ * 本轮扫描是否可以收工。
304
+ *
305
+ * 目录扫描给出的是权威快照:空结果表示近期没有会话被写过,应当收工,
306
+ * 否则冷启动后每 1s 重扫全目录直到新鲜窗口耗尽。持久层与全量回退的空列表
307
+ * 可能只是服务尚未就绪,仍需重试;本轮预算用完时一律重试。
308
+ */
309
+ export function scanSettled(result) {
310
+ if (result.settled === true) return true
311
+ if (!result.complete) return false
312
+ // 快照不完整时既不能宣称扫完了,也不能据此认定剩余会话无需恢复
313
+ if (result.source === 'partial') return false
314
+ return result.source === 'directory' || result.listed > 0
315
+ }
316
+
317
+ /** 扫描并恢复;返回本轮观察与恢复数量。 */
318
+ export async function scanStoredSessions({
319
+ ctx,
320
+ config,
321
+ createUserMessage,
322
+ now = Date.now,
323
+ budgetMs = SCAN_BUDGET_MS,
324
+ seen = new Set(),
325
+ scanDirectory = listFreshByDirectory,
326
+ }) {
327
+ const result = { listed: 0, scanned: 0, resumed: 0, complete: true, source: undefined, settled: false }
328
+ const deadline = Date.now() + budgetMs
329
+ trace('扫描开始')
330
+ // 关掉扫描或全局暂停是终态,不该按秒重试到新鲜窗口耗尽
331
+ if (!config.scanOnBoot || config.paused) return { ...result, settled: true }
332
+ const query = ctx.get('sessionQuery')
333
+ if (query === undefined) {
334
+ // 宿主没有会话查询面时重启恢复整体不可用,同样是终态
335
+ trace('sessionQuery 缺失,放弃')
336
+ return { ...result, settled: true }
337
+ }
338
+ // 归档集合决定哪些会话不该被拉起。恢复是写操作,误恢复已归档会话会真的
339
+ // 消耗额度,所以这里的方向与只读场景相反:拿不到权威集合就不恢复。
340
+ // 注册表是异步加载的,先等它就绪,避免每轮重试都白做一遍候选筛选。
341
+ let archived
342
+ try {
343
+ const registry = ctx.get('workspaceRegistry')
344
+ if (registry === undefined) throw new Error('工作区服务缺失')
345
+ archived = new Set(registry.archivedSessionIds)
346
+ } catch (error) {
347
+ trace(`归档集合未就绪,本轮不恢复: ${error instanceof Error ? error.message : String(error)}`)
348
+ result.complete = false
349
+ return result
350
+ }
351
+ trace(`已归档 ${archived.size} 个`)
352
+ const listed = await listFreshSessions(ctx, config, now(), scanDirectory)
353
+ const candidates = listed?.items ?? (await listAllSessions(ctx))
354
+ if (candidates === undefined) return result
355
+ // 候选来源决定空列表的含义:只有目录扫描能证明"近期没有会话被写过",
356
+ // 持久层与全量回退的空列表都可能只是服务尚未就绪
357
+ result.source = listed?.source ?? 'query'
358
+ result.listed = candidates.length
359
+ trace(
360
+ listed === undefined
361
+ ? `列出 ${candidates.length} 个存储会话(全量回退)`
362
+ : `按修改时间筛出 ${candidates.length} 个候选会话(${result.source})`,
363
+ )
364
+
365
+ for (const candidate of candidates) {
366
+ if (result.resumed >= config.scanLimit) {
367
+ // 还有候选没处理,不能宣称本轮扫完
368
+ result.complete = false
369
+ break
370
+ }
371
+ // 存储量可能上千,逐个解码全量事件代价高;超出本轮预算就收手,下轮接着扫
372
+ if (Date.now() >= deadline) {
373
+ result.complete = false
374
+ break
375
+ }
376
+ const sessionId = candidate.id
377
+ if (isDerivedSessionHeader(candidate.header ?? { id: sessionId })) continue
378
+ if (candidate.live === true) continue
379
+ if (ctx.agents.get(sessionId) !== undefined) continue
380
+ if (archived.has(sessionId)) continue
381
+ // 跨轮去重:已经裁决过的会话不再重复冷读,预算才能推进到后续会话
382
+ if (seen.has(sessionId)) continue
383
+ seen.add(sessionId)
384
+
385
+ let observation
386
+ try {
387
+ observation = await query.observeSession(sessionId)
388
+ const events = observation?.events
389
+ if (!Array.isArray(events) || events.length === 0) continue
390
+ result.scanned += 1
391
+ const verdict = tailVerdict(events, now(), config)
392
+ trace(`会话 ${sessionId} 事件 ${events.length} 判定 ${verdict}`)
393
+ if (!verdict.startsWith('resume')) continue
394
+ const agent = await materialize(ctx, sessionId, candidate.presetId)
395
+ if (agent === undefined) continue
396
+ // goal 已交回 driver 驱动:它自己会排下一轮,再补消息会多出一次重复回合
397
+ if (rearmGoal(ctx, agent, config)) {
398
+ result.resumed += 1
399
+ trace(`已交回 goal 驱动 ${sessionId}`)
400
+ ctx.logger?.info?.(`[keep-working] 重启恢复(goal) ${sessionId}`)
401
+ continue
402
+ }
403
+ const message = createUserMessage({
404
+ content: [{ type: 'text', text: resumeText(config, verdict) }],
405
+ source: { kind: 'user' },
406
+ })
407
+ agent.followup(message)
408
+ result.resumed += 1
409
+ trace(`已恢复 ${sessionId}`)
410
+ ctx.logger?.info?.(`[keep-working] 重启恢复 ${sessionId}`)
411
+ } catch (error) {
412
+ trace(`处理失败 ${sessionId ?? '?'}: ${error instanceof Error ? error.message : String(error)}`)
413
+ ctx.logger?.warn?.(
414
+ `[keep-working] 扫描 ${sessionId} 失败: ${error instanceof Error ? error.message : String(error)}`,
415
+ )
416
+ } finally {
417
+ observation?.[Symbol.dispose]?.()
418
+ // 长扫描逐批让出事件循环,否则宿主在冷启动窗口内无法响应其它请求
419
+ if (result.scanned > 0 && result.scanned % SCAN_YIELD_BATCH === 0) {
420
+ await new Promise((resolve) => setTimeout(resolve, 0))
421
+ }
422
+ }
423
+ }
424
+ return result
425
+ }
@@ -0,0 +1,7 @@
1
+ // 子 agent 会话判定:origin 权威;无 origin 的旧记录按 parentSession +
2
+ // delegationDepth(普通 fork 的 depth 为 0,不算)
3
+ export function isDerivedSessionHeader(header) {
4
+ if (!header) return false
5
+ if (header.origin === 'subagent') return true
6
+ return header.parentSession !== undefined && (header.delegationDepth ?? 0) >= 1
7
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * 设置面:面板字段直接来自 cordis Config 的 volatile 字段,
3
+ * 经宿主 settings 服务读写,插件不另立 schema。
4
+ */
5
+
6
+ import { DEFAULTS } from './config.mjs'
7
+
8
+ export const NAMESPACE = 'keep-working'
9
+
10
+ /** 面板暴露的字段,其余字段留给 cordis 配置。 */
11
+ export const PANEL_FIELDS = [
12
+ 'paused',
13
+ 'graceMs',
14
+ 'cooldownMs',
15
+ 'maxConsecutive',
16
+ 'scanOnBoot',
17
+ 'resumeSilentTurns',
18
+ 'pauseWhenOffline',
19
+ 'classify',
20
+ 'continueText',
21
+ 'continueTextInterrupted',
22
+ 'continueTextSilent',
23
+ 'continueTextMaxTokens',
24
+ 'closureText',
25
+ ]
26
+
27
+ /** 读面板值;服务缺失或读取失败时返回 undefined,由调用方回落 cordis 配置。 */
28
+ export function readSettings(ctx) {
29
+ const settings = ctx.get('settings')
30
+ if (settings === undefined || typeof settings.describe !== 'function') return undefined
31
+ try {
32
+ const descriptor = settings.describe().find((item) => item.ns === NAMESPACE)
33
+ return descriptor?.value
34
+ } catch {
35
+ return undefined
36
+ }
37
+ }
38
+
39
+ /** 读当前修订号,写回时用作乐观锁。 */
40
+ function currentRevision(settings) {
41
+ try {
42
+ return settings.describe().find((item) => item.ns === NAMESPACE)?.revision
43
+ } catch {
44
+ return undefined
45
+ }
46
+ }
47
+
48
+ /** 写面板值;返回是否成功,失败由路由层转成 503。 */
49
+ export async function writeSettings(ctx, patch) {
50
+ const settings = ctx.get('settings')
51
+ if (settings === undefined || typeof settings.update !== 'function') return false
52
+ await settings.update(NAMESPACE, patch, currentRevision(settings))
53
+ return true
54
+ }
55
+
56
+ /** 面板默认值:统一从 DEFAULTS 取,避免前后端各写一份。 */
57
+ export function panelDefaults() {
58
+ return Object.fromEntries(PANEL_FIELDS.map((key) => [key, DEFAULTS[key]]))
59
+ }
@@ -0,0 +1,34 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ const PACKAGE = '@mzzsfy/dsh-keep-working'
6
+ const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
7
+
8
+ test('样式注入点全部自带 data-plugin 标记', () => {
9
+ const created = source.match(/createElement\('style'\)/g) ?? []
10
+ const marked = source.match(new RegExp(`setAttribute\\('data-plugin', '${PACKAGE}'\\)`, 'g')) ?? []
11
+ assert.ok(created.length > 0, '应存在样式注入点')
12
+ assert.equal(created.length, marked.length)
13
+ })
14
+
15
+ test('客户端注册 id 与包名一致', () => {
16
+ assert.match(source, new RegExp(`id: '${PACKAGE}'`))
17
+ })
18
+
19
+ test('样式类名带包前缀', () => {
20
+ const classes = source.match(/\.([a-z]+)-[a-z_]+/g) ?? []
21
+ const foreign = classes.filter((token) => token.startsWith('.kw-') === false)
22
+ assert.deepEqual(foreign, [])
23
+ })
24
+
25
+ test('布尔开关为 switch 形态', () => {
26
+ assert.match(source, /kw-switch__track/)
27
+ assert.match(source, /type: 'checkbox'/)
28
+ })
29
+
30
+ test('配置卡片挂在插件管理页包详情槽,不占设置导航', () => {
31
+ assert.match(source, /slots\.inject\('plugins\.bundle\.config'/)
32
+ assert.match(source, /name: 'plugins\.bundle\.config', key: PKG_ID/)
33
+ assert.equal(/settings\.section/.test(source), false, '不应再注册设置侧边栏分区')
34
+ })
@@ -0,0 +1,101 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ // 脱离浏览器执行 client 半区:React 以最小桩替代,验证组件树结构与数据绑定
6
+ const loads = []
7
+ globalThis.window = { __ModuleLoader__: { load: (def) => loads.push(def) } }
8
+ globalThis.document = {
9
+ createElement: () => ({ textContent: '', attrs: {}, setAttribute(k, v) { this.attrs[k] = v }, remove() {} }),
10
+ head: { appendChild() {} },
11
+ }
12
+
13
+ new Function(readFileSync(new URL('../src/client.js', import.meta.url), 'utf8'))()
14
+
15
+ const CONFIG = {
16
+ paused: false,
17
+ graceMs: 3000,
18
+ cooldownMs: 20000,
19
+ maxConsecutive: 3,
20
+ scanOnBoot: true,
21
+ resumeSilentTurns: true,
22
+ pauseWhenOffline: true,
23
+ classify: true,
24
+ continueText: '继续。上一步结果未知,确认后继续。',
25
+ continueTextInterrupted: '继续。上一步结果未知,确认后继续。',
26
+ continueTextSilent: '继续。上一轮没有可见输出,直接说明结果或继续执行。',
27
+ continueTextMaxTokens: '继续。上一轮被输出上限截断,分段接着输出。',
28
+ closureText: '未检测到结案信号',
29
+ }
30
+
31
+ /** 收集树中的文本与控件类型,便于断言面板内容。 */
32
+ function walk(node, out = { texts: [], inputs: [], textareas: [], switches: 0 }) {
33
+ if (node === null || node === undefined || typeof node === 'string') {
34
+ if (typeof node === 'string') out.texts.push(node)
35
+ return out
36
+ }
37
+ if (Array.isArray(node)) {
38
+ for (const child of node) walk(child, out)
39
+ return out
40
+ }
41
+ const { type, props, children } = node
42
+ if (type === 'input') out.inputs.push(props)
43
+ if (type === 'textarea') out.textareas.push(props)
44
+ if (typeof props?.className === 'string' && props.className.includes('kw-switch')) out.switches += 1
45
+ for (const child of children ?? []) walk(child, out)
46
+ return out
47
+ }
48
+
49
+ const loadPanel = () => {
50
+ const def = loads[0]
51
+ const states = [
52
+ [CONFIG, () => {}],
53
+ [null, () => {}],
54
+ ['', () => {}],
55
+ [false, () => {}],
56
+ ]
57
+ let cursor = 0
58
+ const React = {
59
+ createElement: (type, props, ...children) => ({ type, props, children }),
60
+ useState: () => states[cursor++] ?? [null, () => {}],
61
+ useEffect: () => {},
62
+ useCallback: (fn) => fn,
63
+ }
64
+ const mod = def.factory((name) => {
65
+ if (name === 'react') return React
66
+ throw new Error(`未声明依赖 ${name}`)
67
+ })
68
+ return mod.__test.KeepWorkingPanel
69
+ }
70
+
71
+ test('面板渲染出四个分组', () => {
72
+ const Panel = loadPanel()
73
+ const tree = walk(Panel())
74
+ for (const title of ['总开关', '节奏', '恢复项', '补发文案']) {
75
+ assert.ok(tree.texts.includes(title), `缺少分组 ${title}`)
76
+ }
77
+ })
78
+
79
+ test('布尔项为开关形态', () => {
80
+ const Panel = loadPanel()
81
+ const tree = walk(Panel())
82
+ assert.ok(tree.switches >= 5, `开关数量不足: ${tree.switches}`)
83
+ })
84
+
85
+ test('数值项按秒换算展示', () => {
86
+ const Panel = loadPanel()
87
+ const tree = walk(Panel())
88
+ const numbers = tree.inputs.filter((props) => props.type === 'number')
89
+ const values = numbers.map((props) => props.value)
90
+ assert.ok(values.includes(CONFIG.graceMs / 1000), '等待时间应按秒显示')
91
+ assert.ok(values.includes(CONFIG.cooldownMs / 1000), '冷却时间应按秒显示')
92
+ assert.ok(values.includes(CONFIG.maxConsecutive), '最大次数应原样显示')
93
+ })
94
+
95
+ test('文案项全部渲染', () => {
96
+ const Panel = loadPanel()
97
+ const tree = walk(Panel())
98
+ const texts = tree.textareas
99
+ assert.equal(texts.length, 5)
100
+ assert.ok(texts.some((props) => props.value === CONFIG.closureText))
101
+ })