@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.5
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/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/APPROVAL-DRILL.md +42 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/approval-drill.mjs +111 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +582 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3979 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/approval-drill.mjs +21 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +425 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- package/tools/recover-queue.mjs +133 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// v3.0.9 fail-closed path guard: canonicalize and keep every path inside its root.
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
export function safeJoin(root, ...parts) {
|
|
7
|
+
const base = path.resolve(root)
|
|
8
|
+
const target = path.resolve(base, ...parts)
|
|
9
|
+
if (target !== base && !target.startsWith(base + path.sep)) throw new Error('path-escape')
|
|
10
|
+
if (String(target).includes('\0')) throw new Error('path-escape')
|
|
11
|
+
return target
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function assertInside(root, target, { followSymlink = true } = {}) {
|
|
15
|
+
const base = path.resolve(root)
|
|
16
|
+
const lexical = path.resolve(target)
|
|
17
|
+
let resolved = lexical
|
|
18
|
+
let boundary = base
|
|
19
|
+
if (followSymlink) {
|
|
20
|
+
try { boundary = fs.realpathSync.native(base) } catch { /* keep lexical */ }
|
|
21
|
+
try { resolved = fs.realpathSync.native(lexical) } catch {
|
|
22
|
+
try { resolved = path.join(fs.realpathSync.native(path.dirname(lexical)), path.basename(lexical)) } catch { /* keep lexical */ }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const rel = path.relative(boundary, resolved)
|
|
26
|
+
if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) throw new Error('path-escape')
|
|
27
|
+
return lexical
|
|
28
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// v3.0.5/B1 — 安装级 HTTP 鉴权:token 文件(0600)、loopback Host 校验、CSRF/Origin 检查。
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { ensureDir, exists } from '../kernel/index.mjs'
|
|
8
|
+
|
|
9
|
+
export const AUTH_TOKEN_FILE = 'fang-auth.json'
|
|
10
|
+
export const AUTH_HEADER = 'x-fang-auth'
|
|
11
|
+
export const MIN_TOKEN_LENGTH = 32
|
|
12
|
+
|
|
13
|
+
/** 读取/生成安装级 token(env 覆盖 > 0600 文件 > 新建)。 */
|
|
14
|
+
export function loadAuthToken(dataDir, envToken = process.env.DSH_FANG_AUTH_TOKEN) {
|
|
15
|
+
const fromEnv = String(envToken ?? '').trim()
|
|
16
|
+
if (fromEnv.length >= MIN_TOKEN_LENGTH) return fromEnv
|
|
17
|
+
const file = path.join(dataDir, AUTH_TOKEN_FILE)
|
|
18
|
+
if (exists(file)) {
|
|
19
|
+
try {
|
|
20
|
+
const doc = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
21
|
+
if (typeof doc?.token === 'string' && doc.token.length >= MIN_TOKEN_LENGTH) return doc.token
|
|
22
|
+
} catch { /* 损坏则重建 */ }
|
|
23
|
+
}
|
|
24
|
+
const token = crypto.randomBytes(32).toString('hex')
|
|
25
|
+
ensureDir(dataDir)
|
|
26
|
+
fs.writeFileSync(file, JSON.stringify({ token, createdAt: Date.now() }), { encoding: 'utf8', mode: 0o600 })
|
|
27
|
+
try { fs.chmodSync(file, 0o600) } catch { /* Windows 上 chmod 可能无效 */ }
|
|
28
|
+
return token
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Host 是否 loopback(支持 localhost / 127.0.0.1 / [::1] / ::1,可带端口)。
|
|
33
|
+
*
|
|
34
|
+
* 严格解析:`[::1]evil.example`、`127.0.0.1:3080@evil.example` 这类带尾随垃圾的
|
|
35
|
+
* Host 必须判假——否则 DNS rebinding 防护会被一个字符串前缀绕过。
|
|
36
|
+
*/
|
|
37
|
+
export function isLoopbackHost(host) {
|
|
38
|
+
const raw = String(host ?? '').trim().toLowerCase()
|
|
39
|
+
if (raw.length === 0) return false
|
|
40
|
+
if (raw === '::1') return true
|
|
41
|
+
if (raw.startsWith('[')) {
|
|
42
|
+
const match = /^\[([0-9a-f:.]+)\](?::(\d{1,5}))?$/.exec(raw)
|
|
43
|
+
if (!match) return false
|
|
44
|
+
if (match[2] !== undefined && Number(match[2]) > 65535) return false
|
|
45
|
+
return match[1] === '::1'
|
|
46
|
+
}
|
|
47
|
+
const match = /^([a-z0-9.-]+)(?::(\d{1,5}))?$/.exec(raw)
|
|
48
|
+
if (!match) return false
|
|
49
|
+
if (match[2] !== undefined && Number(match[2]) > 65535) return false
|
|
50
|
+
return match[1] === 'localhost' || match[1] === '127.0.0.1'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Origin 是否允许(同源 loopback / dsh-app 壳 / 无 Origin)。 */
|
|
54
|
+
export function isAllowedOrigin(origin) {
|
|
55
|
+
const raw = String(origin ?? '').trim()
|
|
56
|
+
if (raw.length === 0) return true
|
|
57
|
+
if (raw === 'dsh-app://app' || raw.startsWith('dsh-app://')) return true
|
|
58
|
+
try {
|
|
59
|
+
const url = new URL(raw)
|
|
60
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
|
|
61
|
+
return isLoopbackHost(url.host)
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function header(req, name) {
|
|
68
|
+
const headers = req?.headers ?? {}
|
|
69
|
+
const lower = name.toLowerCase()
|
|
70
|
+
for (const key of Object.keys(headers)) {
|
|
71
|
+
if (key.toLowerCase() === lower) return String(headers[key] ?? '')
|
|
72
|
+
}
|
|
73
|
+
return ''
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function timingSafeEqualText(expected, actual) {
|
|
77
|
+
const a = Buffer.from(String(expected), 'utf8')
|
|
78
|
+
const b = Buffer.from(String(actual), 'utf8')
|
|
79
|
+
if (a.length !== b.length || a.length === 0) return false
|
|
80
|
+
try { return crypto.timingSafeEqual(a, b) } catch { return false }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 请求鉴权决策(fail-closed)。
|
|
85
|
+
* @param {any} req
|
|
86
|
+
* @param {{token:string, publicRoute?:boolean}} options
|
|
87
|
+
* @returns {{ok:true} | {ok:false, status:number, code:string}}
|
|
88
|
+
*/
|
|
89
|
+
export function checkRequestAuth(req, options) {
|
|
90
|
+
const token = String(options?.token ?? '')
|
|
91
|
+
const method = String(req?.method ?? 'GET').toUpperCase()
|
|
92
|
+
const host = header(req, 'host')
|
|
93
|
+
if (host && !isLoopbackHost(host)) return { ok: false, status: 403, code: 'bad-host' }
|
|
94
|
+
const origin = header(req, 'origin')
|
|
95
|
+
if (!isAllowedOrigin(origin)) return { ok: false, status: 403, code: 'bad-origin' }
|
|
96
|
+
const site = header(req, 'sec-fetch-site')
|
|
97
|
+
if (site && site !== 'same-origin' && site !== 'same-site' && site !== 'none') {
|
|
98
|
+
return { ok: false, status: 403, code: 'cross-site' }
|
|
99
|
+
}
|
|
100
|
+
if (options?.publicRoute === true) return { ok: true }
|
|
101
|
+
const given = header(req, AUTH_HEADER) || (() => {
|
|
102
|
+
const auth = header(req, 'authorization')
|
|
103
|
+
const match = /^Bearer\s+(.+)$/i.exec(auth)
|
|
104
|
+
return match ? match[1].trim() : ''
|
|
105
|
+
})()
|
|
106
|
+
if (!timingSafeEqualText(token, given)) return { ok: false, status: 401, code: 'unauthorized' }
|
|
107
|
+
// 写操作要求 JSON content-type(阻止 text/plain 简单请求 CSRF;无 body 的 POST 也允许)
|
|
108
|
+
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
|
|
109
|
+
const contentType = header(req, 'content-type').toLowerCase()
|
|
110
|
+
if (contentType && !contentType.includes('application/json')) {
|
|
111
|
+
return { ok: false, status: 415, code: 'content-type-required' }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { ok: true }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Stable error envelope for every auth/host/origin rejection (additive `code`). */
|
|
118
|
+
export function authErrorBody(decision) {
|
|
119
|
+
const code = String(decision?.code ?? 'unauthorized')
|
|
120
|
+
return { ok: false, error: code, code }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Install-level guard shared by every plugin that owns `/fang/api/*` routes.
|
|
125
|
+
*
|
|
126
|
+
* Resolves the token exactly once at startup, with the same precedence as the
|
|
127
|
+
* orchestrator: explicit config > env > `<dataDir>/fang-auth.json` (0600).
|
|
128
|
+
* `enabled: false` is the explicit test/debug escape hatch and still enforces
|
|
129
|
+
* Host/Origin/CSRF — it only skips the token comparison.
|
|
130
|
+
*
|
|
131
|
+
* @param {{dataDir?:string, token?:string, envToken?:string, enabled?:boolean}} [options]
|
|
132
|
+
* @returns {{token:string, enabled:boolean, check:(req:any, routeOptions?:{public?:boolean})=>
|
|
133
|
+
* ({ok:true}|{ok:false,status:number,code:string})}}
|
|
134
|
+
*/
|
|
135
|
+
export function createHttpGuard(options = {}) {
|
|
136
|
+
const enabled = options.enabled !== false
|
|
137
|
+
const dataDir = String(options.dataDir ?? '').trim()
|
|
138
|
+
const explicit = String(options.token ?? '').trim()
|
|
139
|
+
const envToken = options.envToken === undefined ? process.env.DSH_FANG_AUTH_TOKEN : options.envToken
|
|
140
|
+
let token = ''
|
|
141
|
+
if (enabled) {
|
|
142
|
+
if (dataDir) {
|
|
143
|
+
token = loadAuthToken(dataDir, explicit || envToken)
|
|
144
|
+
} else {
|
|
145
|
+
const candidate = explicit || String(envToken ?? '').trim()
|
|
146
|
+
if (candidate.length < MIN_TOKEN_LENGTH) {
|
|
147
|
+
throw new Error('http-auth: dataDir or a >=32-char auth token is required when auth is enabled')
|
|
148
|
+
}
|
|
149
|
+
token = candidate
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
token,
|
|
154
|
+
enabled: token.length > 0,
|
|
155
|
+
check(req, routeOptions = {}) {
|
|
156
|
+
return checkRequestAuth(req, {
|
|
157
|
+
token,
|
|
158
|
+
publicRoute: token.length === 0 || routeOptions.public === true,
|
|
159
|
+
})
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// dsh-Fang v3.0.8 — 空闲降级审计聚合。
|
|
3
|
+
//
|
|
4
|
+
// MirrorStatusStore 在“活跃消费者 30s 无交互 → 降级”时发 ActiveIdleEvent;
|
|
5
|
+
// 本模块把事件写入 Auditor(单条 + 周期 summary),并从统计里生成 UI 优化建议:
|
|
6
|
+
// * idleAfterMs = 降级时刻 - 最后一次交互(真实的“走开时长”)
|
|
7
|
+
// * totalActiveMs = 降级时刻 - 首次 acquireActive(用户停留总时长)
|
|
8
|
+
// 样本不足(默认 <10)不给建议,避免小样本误导。
|
|
9
|
+
|
|
10
|
+
export const IDLE_AUDIT_KIND = 'mirror-idle-downgrade'
|
|
11
|
+
export const IDLE_AUDIT_SUMMARY_KIND = 'mirror-idle-downgrade-summary'
|
|
12
|
+
|
|
13
|
+
export class IdleAuditAggregator {
|
|
14
|
+
/**
|
|
15
|
+
* @param {{record: (payload: Record<string, unknown>) => (void|Promise<void>),
|
|
16
|
+
* minSamplesForRecommendation?: number, snapshotIntervalMs?: number,
|
|
17
|
+
* timers?: {setInterval: Function, clearInterval: Function}, now?: () => number}} options
|
|
18
|
+
*/
|
|
19
|
+
constructor(options) {
|
|
20
|
+
if (options === null || typeof options !== 'object' || typeof options.record !== 'function') {
|
|
21
|
+
throw new Error('IdleAuditAggregator: record required')
|
|
22
|
+
}
|
|
23
|
+
this.record = options.record
|
|
24
|
+
this.minSamples = Math.max(1, Math.floor(Number(options.minSamplesForRecommendation) || 10))
|
|
25
|
+
this.snapshotIntervalMs = Math.max(1, Math.floor(Number(options.snapshotIntervalMs) || 300_000))
|
|
26
|
+
this.timers = options.timers ?? { setInterval, clearInterval }
|
|
27
|
+
this.now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
28
|
+
this.events = []
|
|
29
|
+
this.rollingEvents = []
|
|
30
|
+
this.rollingCapacity = Math.max(1, Math.floor(Number(options.rollingCapacity) || 500))
|
|
31
|
+
this.snapshotTimer = null
|
|
32
|
+
this.sink = (event) => { this.handle(event) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 传给 MirrorStatusStore.onIdleDowngraded(sink)。 */
|
|
36
|
+
asSink() {
|
|
37
|
+
return this.sink
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
start() {
|
|
41
|
+
this.stop()
|
|
42
|
+
this.snapshotTimer = this.timers.setInterval(() => { void this.publishSnapshot() }, this.snapshotIntervalMs)
|
|
43
|
+
this.snapshotTimer?.unref?.()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
stop() {
|
|
47
|
+
if (this.snapshotTimer !== null) {
|
|
48
|
+
this.timers.clearInterval(this.snapshotTimer)
|
|
49
|
+
this.snapshotTimer = null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 基于滚动窗口(长期,最近 N 条)生成结构化建议;样本不足返回空数组。 */
|
|
54
|
+
getRecommendations() {
|
|
55
|
+
if (this.rollingEvents.length < this.minSamples) return []
|
|
56
|
+
return this.buildRecommendations(this.rollingEvents)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 由事件数组生成结构化建议(fieldKey/suggestedValue/text/sampleCount/confidence)。 */
|
|
60
|
+
buildRecommendations(events) {
|
|
61
|
+
const total = events.length
|
|
62
|
+
if (total === 0) return []
|
|
63
|
+
let rapid = 0
|
|
64
|
+
let full = 0
|
|
65
|
+
for (const event of events) {
|
|
66
|
+
if (event.idleAfterMs < event.timeoutMs * 0.3) rapid += 1
|
|
67
|
+
if (event.idleAfterMs >= event.timeoutMs * 0.9) full += 1
|
|
68
|
+
}
|
|
69
|
+
const rapidRatio = rapid / total
|
|
70
|
+
const fullRatio = full / total
|
|
71
|
+
const recommendations = []
|
|
72
|
+
if (rapidRatio > 0.5) {
|
|
73
|
+
recommendations.push({
|
|
74
|
+
fieldKey: 'activeIdleTimeoutMs',
|
|
75
|
+
suggestedValue: 15_000,
|
|
76
|
+
text: `超过一半(${(rapidRatio * 100).toFixed(0)}%)的对话框打开后很快走开,可考虑把交互超时降到 15 秒,减少无意义的高频轮询。`,
|
|
77
|
+
sampleCount: total,
|
|
78
|
+
confidence: rapidRatio,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
if (fullRatio > 0.8) {
|
|
82
|
+
recommendations.push({
|
|
83
|
+
fieldKey: 'activeIdleTimeoutMs',
|
|
84
|
+
suggestedValue: 60_000,
|
|
85
|
+
text: `${(fullRatio * 100).toFixed(0)}% 的用户活跃到超时窗口末尾,建议把交互超时提高到 60 秒,让状态刷新更实时。`,
|
|
86
|
+
sampleCount: total,
|
|
87
|
+
confidence: fullRatio,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
return recommendations
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 发布聚合快照(无事件则不发);发布后清空窗口。 */
|
|
94
|
+
async publishSnapshot() {
|
|
95
|
+
if (this.events.length === 0) return null
|
|
96
|
+
const windowStart = this.events[0].at
|
|
97
|
+
const windowEnd = this.events[this.events.length - 1].at
|
|
98
|
+
const summary = this.computeSummary()
|
|
99
|
+
this.events = []
|
|
100
|
+
const payload = {
|
|
101
|
+
kind: IDLE_AUDIT_SUMMARY_KIND,
|
|
102
|
+
at: this.now(),
|
|
103
|
+
windowStart,
|
|
104
|
+
windowEnd,
|
|
105
|
+
summary,
|
|
106
|
+
}
|
|
107
|
+
await this.record(payload)
|
|
108
|
+
return summary
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
computeSummary() {
|
|
112
|
+
const byTimeout = {}
|
|
113
|
+
let rapidAway = 0
|
|
114
|
+
let fullTimeout = 0
|
|
115
|
+
for (const event of this.events) {
|
|
116
|
+
const bucket = byTimeout[event.timeoutMs] ?? { count: 0, totalIdleAfterMs: 0, totalActiveMs: 0 }
|
|
117
|
+
bucket.count += 1
|
|
118
|
+
bucket.totalIdleAfterMs += event.idleAfterMs
|
|
119
|
+
bucket.totalActiveMs += event.totalActiveMs
|
|
120
|
+
byTimeout[event.timeoutMs] = bucket
|
|
121
|
+
if (event.idleAfterMs < event.timeoutMs * 0.3) rapidAway += 1
|
|
122
|
+
if (event.idleAfterMs >= event.timeoutMs * 0.9) fullTimeout += 1
|
|
123
|
+
}
|
|
124
|
+
for (const key of Object.keys(byTimeout)) {
|
|
125
|
+
const bucket = byTimeout[key]
|
|
126
|
+
bucket.avgIdleAfterMs = Math.round(bucket.totalIdleAfterMs / bucket.count)
|
|
127
|
+
bucket.avgTotalActiveMs = Math.round(bucket.totalActiveMs / bucket.count)
|
|
128
|
+
delete bucket.totalIdleAfterMs
|
|
129
|
+
delete bucket.totalActiveMs
|
|
130
|
+
}
|
|
131
|
+
const recommendations = this.events.length >= this.minSamples ? this.buildRecommendations(this.events) : []
|
|
132
|
+
return {
|
|
133
|
+
totalEvents: this.events.length,
|
|
134
|
+
byTimeout,
|
|
135
|
+
rapidAwayCount: rapidAway,
|
|
136
|
+
fullTimeoutCount: fullTimeout,
|
|
137
|
+
recommendation: recommendations[0]?.text ?? null,
|
|
138
|
+
recommendations,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
buildRecommendation(rapidAway, fullTimeout) {
|
|
143
|
+
const total = this.events.length
|
|
144
|
+
if (total < this.minSamples) return null
|
|
145
|
+
const rapidRatio = rapidAway / total
|
|
146
|
+
const fullRatio = fullTimeout / total
|
|
147
|
+
if (rapidRatio > 0.5) {
|
|
148
|
+
return `超过一半(${(rapidRatio * 100).toFixed(0)}%)的对话框打开后很快走开,建议把默认交互超时降到 15s 以减少无意义轮询。`
|
|
149
|
+
}
|
|
150
|
+
if (fullRatio > 0.8) {
|
|
151
|
+
return `${(fullRatio * 100).toFixed(0)}% 的用户活跃到超时窗口末尾,建议把默认交互超时提高到 60s。`
|
|
152
|
+
}
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
handle(event) {
|
|
157
|
+
this.events.push(event)
|
|
158
|
+
this.rollingEvents.push(event)
|
|
159
|
+
if (this.rollingEvents.length > this.rollingCapacity) {
|
|
160
|
+
this.rollingEvents.splice(0, this.rollingEvents.length - this.rollingCapacity)
|
|
161
|
+
}
|
|
162
|
+
const payload = {
|
|
163
|
+
kind: IDLE_AUDIT_KIND,
|
|
164
|
+
at: event.at,
|
|
165
|
+
timeoutMs: event.timeoutMs,
|
|
166
|
+
idleAfterMs: event.idleAfterMs,
|
|
167
|
+
totalActiveMs: event.totalActiveMs,
|
|
168
|
+
lastPokeAt: event.lastPokeAt,
|
|
169
|
+
activeConsumers: event.activeConsumers,
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const result = this.record(payload)
|
|
173
|
+
if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
|
|
174
|
+
result.catch(() => { /* 审计失败不影响 UI */ })
|
|
175
|
+
}
|
|
176
|
+
} catch { /* 审计失败不影响 UI */ }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// dsh-Fang v3.1.0 — 用户交互 poke(框架无关)。
|
|
3
|
+
//
|
|
4
|
+
// 消费者(如打开的镜像设置对话框)在挂载时调用 installInteractionPoke(store, {target}),
|
|
5
|
+
// 鼠标/键盘/滚动/触摸等轻量交互会节流触发 store.pokeActive(),让 store 保持高频档;
|
|
6
|
+
// 30s 无交互后 store 自动降回 idle 间隔。
|
|
7
|
+
// 不监听 click:点击路径本身会调用 refresh/acquireActive,无需额外 poke。
|
|
8
|
+
|
|
9
|
+
export const INTERACTION_EVENTS = Object.freeze([
|
|
10
|
+
'mousemove', 'mousedown', 'wheel', 'scroll', 'keydown', 'touchstart',
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {{pokeActive: Function}} store
|
|
15
|
+
* @param {{target?: EventTarget|null, throttleMs?: number, now?: () => number,
|
|
16
|
+
* timers?: {setTimeout: Function, clearTimeout: Function}}} [options]
|
|
17
|
+
* @returns {() => void} dispose
|
|
18
|
+
*/
|
|
19
|
+
export function installInteractionPoke(store, options = {}) {
|
|
20
|
+
const target = options.target ?? (typeof window !== 'undefined' ? window : null)
|
|
21
|
+
if (target === null || target === undefined || typeof target.addEventListener !== 'function') {
|
|
22
|
+
return () => {}
|
|
23
|
+
}
|
|
24
|
+
const throttleMs = Math.max(0, Number(options.throttleMs) || 1_000)
|
|
25
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
26
|
+
const timers = options.timers ?? { setTimeout, clearTimeout }
|
|
27
|
+
let lastPokeAt = 0
|
|
28
|
+
let trailing = null
|
|
29
|
+
let disposed = false
|
|
30
|
+
|
|
31
|
+
const fire = () => {
|
|
32
|
+
if (disposed) return
|
|
33
|
+
lastPokeAt = now()
|
|
34
|
+
store.pokeActive()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const onEvent = () => {
|
|
38
|
+
if (disposed) return
|
|
39
|
+
const elapsed = now() - lastPokeAt
|
|
40
|
+
if (elapsed >= throttleMs) { fire(); return }
|
|
41
|
+
if (trailing !== null) return
|
|
42
|
+
trailing = timers.setTimeout(() => {
|
|
43
|
+
trailing = null
|
|
44
|
+
fire()
|
|
45
|
+
}, throttleMs - elapsed)
|
|
46
|
+
trailing?.unref?.()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const eventName of INTERACTION_EVENTS) target.addEventListener(eventName, onEvent, { passive: true })
|
|
50
|
+
|
|
51
|
+
return () => {
|
|
52
|
+
disposed = true
|
|
53
|
+
for (const eventName of INTERACTION_EVENTS) {
|
|
54
|
+
try { target.removeEventListener(eventName, onEvent) } catch { /* 已移除 */ }
|
|
55
|
+
}
|
|
56
|
+
if (trailing !== null) {
|
|
57
|
+
timers.clearTimeout(trailing)
|
|
58
|
+
trailing = null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|