@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.4

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.
Files changed (144) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/CONTRACT-INTEROP.md +77 -0
  12. package/docs/CRASH-SAFETY.md +69 -0
  13. package/docs/DECISION-RELAY.md +46 -0
  14. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  15. package/docs/FORK-SEED.md +64 -0
  16. package/docs/MAIN-CITY-GATE.md +58 -0
  17. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  18. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  19. package/docs/PM-DECISION-POLICY.md +61 -0
  20. package/docs/WORKFLOW-UI.md +86 -0
  21. package/icon.svg +8 -0
  22. package/locale/en.json +6 -0
  23. package/locale/zh.json +6 -0
  24. package/package.json +71 -4
  25. package/sdk/README.md +65 -0
  26. package/sdk/examples/status-badge/README.md +16 -0
  27. package/sdk/examples/status-badge/client.js +60 -0
  28. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  29. package/sdk/examples/status-badge/index.js +12 -0
  30. package/sdk/examples/status-badge/package.json +24 -0
  31. package/sdk/templates/plugin/README.md +36 -0
  32. package/sdk/templates/plugin/client.js +35 -0
  33. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  34. package/sdk/templates/plugin/icon.svg +5 -0
  35. package/sdk/templates/plugin/index.js +23 -0
  36. package/sdk/templates/plugin/locale/en.json +6 -0
  37. package/sdk/templates/plugin/package.json +36 -0
  38. package/src/compose.mjs +67 -0
  39. package/src/contract/VENDOR.json +16 -0
  40. package/src/contract/client.js +93 -0
  41. package/src/contract/constants.js +94 -0
  42. package/src/contract/envelope.js +83 -0
  43. package/src/contract/index.js +20 -0
  44. package/src/contract/negotiate.js +106 -0
  45. package/src/contract/relay.js +223 -0
  46. package/src/contract/server.js +77 -0
  47. package/src/contract/transports.js +319 -0
  48. package/src/index.mjs +11 -0
  49. package/src/kernel/core.mjs +100 -0
  50. package/src/kernel/extensions.mjs +61 -0
  51. package/src/kernel/fsx.mjs +349 -0
  52. package/src/kernel/index.mjs +21 -0
  53. package/src/kernel/ledger.mjs +94 -0
  54. package/src/kernel/paths.mjs +182 -0
  55. package/src/kernel/role.mjs +68 -0
  56. package/src/kernel/rounds.mjs +172 -0
  57. package/src/kernel/schema.mjs +5 -0
  58. package/src/kernel/types.mjs +266 -0
  59. package/src/kernel/vendor/cosmokit.mjs +421 -0
  60. package/src/kernel/vendor/schemastery.mjs +638 -0
  61. package/src/lib/adapter-client.mjs +383 -0
  62. package/src/lib/adapter-protocol.mjs +243 -0
  63. package/src/lib/ai-secret-store.mjs +202 -0
  64. package/src/lib/boot-guard.mjs +186 -0
  65. package/src/lib/branch-store.mjs +84 -0
  66. package/src/lib/branch.mjs +74 -0
  67. package/src/lib/bridge-client.mjs +133 -0
  68. package/src/lib/broker-adapter.mjs +67 -0
  69. package/src/lib/broker.mjs +45 -0
  70. package/src/lib/business-producers.mjs +109 -0
  71. package/src/lib/command-cache.mjs +209 -0
  72. package/src/lib/compat.mjs +170 -0
  73. package/src/lib/composer-journal.mjs +128 -0
  74. package/src/lib/container/index.mjs +806 -0
  75. package/src/lib/decision-events.mjs +129 -0
  76. package/src/lib/decision-ingest-client.mjs +41 -0
  77. package/src/lib/decision-outbox.mjs +129 -0
  78. package/src/lib/decision-policy.mjs +52 -0
  79. package/src/lib/default-essence.mjs +61 -0
  80. package/src/lib/desktop-launch.mjs +138 -0
  81. package/src/lib/essence.mjs +1211 -0
  82. package/src/lib/fork-client.mjs +410 -0
  83. package/src/lib/fork-config-lock.mjs +166 -0
  84. package/src/lib/fork-seed.mjs +304 -0
  85. package/src/lib/fork-sessions.mjs +183 -0
  86. package/src/lib/fork-supervision-settings.mjs +58 -0
  87. package/src/lib/fork-supervisor.mjs +122 -0
  88. package/src/lib/fs-guard.mjs +28 -0
  89. package/src/lib/http-auth.mjs +162 -0
  90. package/src/lib/idle-audit.mjs +178 -0
  91. package/src/lib/interaction-poke.mjs +61 -0
  92. package/src/lib/main-gate.mjs +579 -0
  93. package/src/lib/mirror-etag.mjs +92 -0
  94. package/src/lib/mirror-official-source.mjs +93 -0
  95. package/src/lib/mirror.mjs +478 -0
  96. package/src/lib/mother-link.mjs +156 -0
  97. package/src/lib/mutex.mjs +31 -0
  98. package/src/lib/permissions.mjs +115 -0
  99. package/src/lib/pnpm-store.mjs +189 -0
  100. package/src/lib/preferences.mjs +212 -0
  101. package/src/lib/producer-contract.mjs +162 -0
  102. package/src/lib/reflow.mjs +41 -0
  103. package/src/lib/remote.mjs +408 -0
  104. package/src/lib/restart-contract.mjs +752 -0
  105. package/src/lib/roots.mjs +30 -0
  106. package/src/lib/rule-registry.mjs +1127 -0
  107. package/src/lib/runtime-capture.mjs +307 -0
  108. package/src/lib/semaphore.mjs +34 -0
  109. package/src/lib/session-bundle.mjs +138 -0
  110. package/src/lib/session-format.mjs +672 -0
  111. package/src/lib/sse-parser.mjs +102 -0
  112. package/src/lib/ssrf-guard.mjs +170 -0
  113. package/src/lib/status-store.mjs +257 -0
  114. package/src/lib/task-handle.mjs +622 -0
  115. package/src/lib/ui-actions.mjs +55 -0
  116. package/src/lib/umbrella.mjs +1661 -0
  117. package/src/lib/upgrade-coordinator.mjs +107 -0
  118. package/src/lib/upgrade-status.mjs +43 -0
  119. package/src/plugins/adapter.mjs +1468 -0
  120. package/src/plugins/approver.mjs +1210 -0
  121. package/src/plugins/auditor.mjs +330 -0
  122. package/src/plugins/buffer-keeper.mjs +522 -0
  123. package/src/plugins/decision-center.mjs +2581 -0
  124. package/src/plugins/forker.mjs +1610 -0
  125. package/src/plugins/healer.mjs +702 -0
  126. package/src/plugins/hot-reloader.mjs +274 -0
  127. package/src/plugins/imports.mjs +247 -0
  128. package/src/plugins/orchestrator.mjs +3938 -0
  129. package/src/plugins/restart-guard.mjs +1022 -0
  130. package/src/plugins/router.mjs +714 -0
  131. package/src/plugins/scorer.mjs +661 -0
  132. package/src/plugins/session-bridge.mjs +166 -0
  133. package/src/plugins/session-catalog.mjs +771 -0
  134. package/src/plugins/session-router.mjs +125 -0
  135. package/src/plugins/session-sync.mjs +171 -0
  136. package/src/plugins/umbrella.mjs +419 -0
  137. package/tools/boot-guard.mjs +95 -0
  138. package/tools/decision-ingest.mjs +53 -0
  139. package/tools/desktop-launch.mjs +71 -0
  140. package/tools/fangctl.mjs +408 -0
  141. package/tools/gate/main-city.mjs +204 -0
  142. package/tools/mcp/server.mjs +121 -0
  143. package/tools/mcp/tools.mjs +65 -0
  144. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,102 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang v2.6.5 — 增量 SSE 解析器(母体侧 SSE 传输的帧语法层)。
4
+ //
5
+ // 语义(与 docs/FUZZ-SSE-PARSER.md 的不变量一致):
6
+ // * 只按 `\n` 切行;行尾若有且仅有一个 `\r` 则裁掉(CRLF 支持,lone-CR 当作 data 字符);
7
+ // * 空行 = 帧边界:只有 dataLines 非空才产出帧(纯 id/event/注释帧被丢弃);
8
+ // * `:` 开头为注释(心跳),忽略;`id` / `event` / `data` 之外字段忽略(`retry` 也忽略);
9
+ // * 冒号后一个空格可选;多行 data 用 `\n` 拼接;dispatch 后重置 id/event/data;
10
+ // * flush() 丢弃未完成的残帧(EOF 语义),不产出帧。
11
+ export class SseParser {
12
+ constructor() {
13
+ this.buffer = ''
14
+ this.curId = undefined
15
+ this.curEvent = undefined
16
+ this.dataLines = []
17
+ }
18
+
19
+ /**
20
+ * 喂入一段字符串,返回本次能解析出的完整帧。
21
+ * @param {unknown} chunk
22
+ * @returns {Array<{id?: string, event?: string, data: string}>}
23
+ */
24
+ push(chunk) {
25
+ this.buffer += String(chunk ?? '')
26
+ const frames = []
27
+ let start = 0
28
+ while (start < this.buffer.length) {
29
+ const nl = this.buffer.indexOf('\n', start)
30
+ if (nl === -1) break
31
+ let line = this.buffer.slice(start, nl)
32
+ if (line.endsWith('\r')) line = line.slice(0, -1)
33
+ start = nl + 1
34
+ const frame = this._processLine(line)
35
+ if (frame) frames.push(frame)
36
+ }
37
+ this.buffer = this.buffer.slice(start)
38
+ return frames
39
+ }
40
+
41
+ /**
42
+ * 流结束:按 SSE 规范丢弃未完成的半帧(没有空行边界的 data 不 dispatch)。
43
+ * @returns {[]}
44
+ */
45
+ flush() {
46
+ this.buffer = ''
47
+ this.curId = undefined
48
+ this.curEvent = undefined
49
+ this.dataLines = []
50
+ return []
51
+ }
52
+
53
+ /** @private */
54
+ _processLine(line) {
55
+ if (line === '') {
56
+ if (this.dataLines.length === 0) {
57
+ this.curId = undefined
58
+ this.curEvent = undefined
59
+ return null
60
+ }
61
+ const frame = { data: this.dataLines.join('\n') }
62
+ if (this.curId !== undefined) frame.id = this.curId
63
+ if (this.curEvent !== undefined) frame.event = this.curEvent
64
+ this.curId = undefined
65
+ this.curEvent = undefined
66
+ this.dataLines = []
67
+ return frame
68
+ }
69
+
70
+ if (line.startsWith(':')) return null // 注释 / 心跳
71
+
72
+ const colon = line.indexOf(':')
73
+ let field = line
74
+ let value = ''
75
+ if (colon >= 0) {
76
+ field = line.slice(0, colon)
77
+ value = line.slice(colon + 1)
78
+ if (value.startsWith(' ')) value = value.slice(1)
79
+ }
80
+
81
+ switch (field) {
82
+ case 'id':
83
+ this.curId = value
84
+ break
85
+ case 'event':
86
+ this.curEvent = value
87
+ break
88
+ case 'data':
89
+ this.dataLines.push(value)
90
+ break
91
+ default:
92
+ break // retry / 未知字段:忽略
93
+ }
94
+ return null
95
+ }
96
+ }
97
+
98
+ /** @param {object} [options] 保留参数位;返回新的 SseParser。 */
99
+ export function createSseParser(options = {}) {
100
+ void options
101
+ return new SseParser()
102
+ }
@@ -0,0 +1,170 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // ssrf-guard — AI / 出站端点的最小 SSRF 防护:
5
+ // * 字面量私网 IP:默认拒绝,除非显式 allowlist
6
+ // * 云元数据地址/主机名:永远拒绝(allowlist 也不能放行)
7
+ // * hostname:配置时只做语法检查;请求前做一次 DNS 解析,
8
+ // 解析到私网/元数据则拒绝(防“公网域名解析到内网”的常见 SSRF)
9
+ //
10
+ // 诚实边界:DNS 预检与 fetch 的真实解析之间仍有 TOCTOU 窗口(DNS rebinding),
11
+ // Node fetch 没有 pinned-lookup 接口;因此这里只做“高置信拦截”,不宣称完备。
12
+ import net from 'node:net'
13
+ import dns from 'node:dns/promises'
14
+
15
+ const METADATA_HOSTS = new Set([
16
+ 'metadata.google.internal',
17
+ 'metadata.goog',
18
+ 'instance-data',
19
+ ])
20
+
21
+ const METADATA_ADDRESSES = new Set([
22
+ '169.254.169.254', // AWS / GCP / Azure IMDS
23
+ '100.100.100.200', // Alibaba Cloud
24
+ '192.0.0.192', // Oracle Cloud
25
+ 'fd00:ec2::254', // AWS IPv6 IMDS
26
+ ])
27
+
28
+ export function normalizeHost(value) {
29
+ let host = String(value ?? '').trim().toLowerCase()
30
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1)
31
+ return host.replace(/\.$/, '')
32
+ }
33
+
34
+ export function isMetadataAddress(value) {
35
+ return METADATA_ADDRESSES.has(normalizeHost(value))
36
+ }
37
+
38
+ export function isMetadataHost(value) {
39
+ return METADATA_HOSTS.has(normalizeHost(value))
40
+ }
41
+
42
+ const inV4Range = (parts, base, bits) => {
43
+ if (!Array.isArray(parts) || parts.length !== 4) return false
44
+ let value = 0
45
+ for (const part of parts) value = (value << 8) | part
46
+ const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0
47
+ return (value & mask) === (base & mask)
48
+ }
49
+
50
+ function ipv4Parts(ip) {
51
+ const parts = ip.split('.').map(Number)
52
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null
53
+ return parts
54
+ }
55
+
56
+ function ipv4ToInt(parts) {
57
+ return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0
58
+ }
59
+
60
+ export function isPrivateIpv4(ip) {
61
+ const parts = ipv4Parts(ip)
62
+ if (!parts) return false
63
+ const value = ipv4ToInt(parts)
64
+ // RFC1918
65
+ if (inV4Range(parts, ipv4ToInt([10, 0, 0, 0]), 8)) return true
66
+ if (inV4Range(parts, ipv4ToInt([172, 16, 0, 0]), 12)) return true
67
+ if (inV4Range(parts, ipv4ToInt([192, 168, 0, 0]), 16)) return true
68
+ // loopback / link-local / unspecified
69
+ if (inV4Range(parts, ipv4ToInt([127, 0, 0, 0]), 8)) return true
70
+ if (inV4Range(parts, ipv4ToInt([169, 254, 0, 0]), 16)) return true
71
+ if (inV4Range(parts, ipv4ToInt([0, 0, 0, 0]), 8)) return true
72
+ // CGNAT / benchmark / multicast / reserved
73
+ if (inV4Range(parts, ipv4ToInt([100, 64, 0, 0]), 10)) return true
74
+ if (inV4Range(parts, ipv4ToInt([198, 18, 0, 0]), 15)) return true
75
+ if (inV4Range(parts, ipv4ToInt([198, 51, 100, 0]), 24)) return true
76
+ if (inV4Range(parts, ipv4ToInt([203, 0, 113, 0]), 24)) return true
77
+ if (((value & 0xF0000000) >>> 0) === 0xE0000000) return true // multicast
78
+ if (((value & 0xF0000000) >>> 0) === 0xF0000000) return true // reserved
79
+ return false
80
+ }
81
+
82
+ function ipv6Groups(ip) {
83
+ const raw = normalizeHost(ip)
84
+ if (!raw.includes(':')) return null
85
+ const [head, tail] = raw.split('::')
86
+ const parse = (segment) => segment.length === 0 ? [] : segment.split(':')
87
+ let groups = []
88
+ if (raw.includes('::')) groups = [...parse(head), ...Array(8 - parse(head).length - parse(tail ?? '').length).fill('0'), ...parse(tail ?? '')]
89
+ else groups = raw.split(':')
90
+ if (groups.length !== 8) return null
91
+ return groups.map((group) => parseInt(group || '0', 16)).filter((part) => Number.isInteger(part))
92
+ }
93
+
94
+ export function isPrivateIpv6(ip) {
95
+ const raw = normalizeHost(ip)
96
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(raw)
97
+ if (mapped) return isPrivateIpv4(mapped[1])
98
+ const groups = ipv6Groups(raw)
99
+ if (!groups) return false
100
+ if (groups.every((group, index) => (index < 7 ? group === 0 : group === 1))) return true // ::1
101
+ if (groups.every((group) => group === 0)) return true // ::
102
+ if ((groups[0] & 0xFE00) === 0xFC00) return true // fc00::/7
103
+ if ((groups[0] & 0xFFC0) === 0xFE80) return true // fe80::/10
104
+ if ((groups[0] & 0xFF00) === 0xFF00) return true // multicast
105
+ // IPv4-mapped ::ffff:a.b.c.d
106
+ if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xFFFF) {
107
+ const v4 = `${groups[6] >> 8}.${groups[6] & 0xFF}.${groups[7] >> 8}.${groups[7] & 0xFF}`
108
+ return isPrivateIpv4(v4)
109
+ }
110
+ return false
111
+ }
112
+
113
+ export function isPrivateAddress(ip) {
114
+ const host = normalizeHost(ip)
115
+ const family = net.isIP(host)
116
+ if (family === 4) return isPrivateIpv4(host)
117
+ if (family === 6) return isPrivateIpv6(host)
118
+ return false
119
+ }
120
+
121
+ /** 同步分类:仅处理字面量 IP 与元数据主机名;DNS 主机名留给请求前预检。 */
122
+ export function classifyEndpointHost(hostname, { allowHosts = [] } = {}) {
123
+ const host = normalizeHost(hostname)
124
+ const allowed = new Set((Array.isArray(allowHosts) ? allowHosts : []).map(normalizeHost))
125
+ if (isMetadataHost(host)) return { ok: false, reason: 'metadata-address-blocked', kind: 'metadata' }
126
+ const family = net.isIP(host)
127
+ if (family === 0) {
128
+ if (host === 'localhost') return { ok: true, kind: 'loopback' }
129
+ return { ok: true, kind: 'hostname' }
130
+ }
131
+ if (isMetadataAddress(host)) return { ok: false, reason: 'metadata-address-blocked', kind: 'metadata' }
132
+ if (family === 4 && inV4Range(ipv4Parts(host) ?? [], ipv4ToInt([127, 0, 0, 0]), 8)) return { ok: true, kind: 'loopback' }
133
+ if (family === 6) {
134
+ const groups = ipv6Groups(host)
135
+ if (groups && groups.every((group, index) => (index < 7 ? group === 0 : group === 1))) return { ok: true, kind: 'loopback' }
136
+ }
137
+ if ((family === 4 && isPrivateIpv4(host)) || (family === 6 && isPrivateIpv6(host))) {
138
+ if (allowed.has(host)) return { ok: true, kind: 'private-allowlisted' }
139
+ return { ok: false, reason: 'private-address-blocked', kind: 'private' }
140
+ }
141
+ return { ok: true, kind: family === 4 ? 'public-ipv4' : 'public-ipv6' }
142
+ }
143
+
144
+ /** 请求前 DNS 预检;解析失败视为 NXDOMAIN(交给 fetch 失败,不视为 SSRF)。 */
145
+ export async function assertEndpointSafe(hostname, { allowHosts = [], resolve = dns.lookup, timeoutMs = 1500 } = {}) {
146
+ const sync = classifyEndpointHost(hostname, { allowHosts })
147
+ if (!sync.ok) return sync
148
+ if (sync.kind !== 'hostname') return sync
149
+ const host = normalizeHost(hostname)
150
+ const allowed = new Set((Array.isArray(allowHosts) ? allowHosts : []).map(normalizeHost))
151
+ // 明确的 metadata 主机名已在 sync 拦截;其余做一次解析。
152
+ let addresses = []
153
+ try {
154
+ const result = await Promise.race([
155
+ resolve(host, { all: true, verbatim: true }),
156
+ new Promise((_, reject) => setTimeout(() => reject(new Error('dns-timeout')), timeoutMs).unref?.()),
157
+ ])
158
+ addresses = Array.isArray(result) ? result : (result?.address ? [result] : [])
159
+ } catch (error) {
160
+ return { ok: true, kind: 'hostname-unresolved', detail: String(error?.message ?? error) }
161
+ }
162
+ for (const entry of addresses) {
163
+ const address = normalizeHost(entry?.address ?? entry)
164
+ if (isMetadataAddress(address)) return { ok: false, reason: 'metadata-address-blocked', kind: 'metadata', address }
165
+ if (isPrivateAddress(address) && !allowed.has(host)) {
166
+ return { ok: false, reason: 'private-address-blocked', kind: 'private', address }
167
+ }
168
+ }
169
+ return { ok: true, kind: 'hostname-public', addresses: addresses.map((entry) => normalizeHost(entry?.address ?? entry)) }
170
+ }
@@ -0,0 +1,257 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // dsh-Fang v3.1.0 — MirrorStatusStore(插件层外部 store,框架无关)。
3
+ //
4
+ // 供 DSH client-half(React)用 useSyncExternalStore 订阅:
5
+ // const snap = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
6
+ //
7
+ // 语义:
8
+ // * 订阅者 0 → 不轮询;首个订阅者立即 refresh;
9
+ // * 轮询间隔 = activeConsumers > 0 且未空闲降级 ? activeIntervalMs : idleIntervalMs;
10
+ // * acquireActive() 引用计数 + 每次调用重置 idleTimer(默认 30s 无交互 → 降回 idle);
11
+ // * pokeActive() 只报告“用户还活着”,不改变引用计数(refcount=0 时 no-op);
12
+ // * 降级后 pokeActive() 立即恢复高频档(不立即 refresh,节奏仍由 interval 决定);
13
+ // * refresh 结果 304 时只更新 lastUpdatedAt,不替换 status 引用;
14
+ // * dispose() 清理所有定时器并 abort in-flight。
15
+ //
16
+ // 测试可注入 timers/now/fetcher,避免真实等待。
17
+
18
+ export const INITIAL_MIRROR_SNAPSHOT = Object.freeze({
19
+ status: null, loading: false, error: null, lastUpdatedAt: null,
20
+ })
21
+
22
+ export class MirrorStatusStore {
23
+ /**
24
+ * @param {{fetcher: (options: {force?: boolean, signal?: AbortSignal}) => Promise<{status: object|null, notModified: boolean}>,
25
+ * idleIntervalMs?: number, activeIntervalMs?: number, activeIdleTimeoutMs?: number,
26
+ * logger?: {warn?: Function},
27
+ * timers?: {setTimeout: Function, clearTimeout: Function, setInterval: Function, clearInterval: Function},
28
+ * now?: () => number}} options
29
+ */
30
+ constructor(options) {
31
+ if (options === null || typeof options !== 'object' || typeof options.fetcher !== 'function') {
32
+ throw new Error('MirrorStatusStore: fetcher required')
33
+ }
34
+ this.fetcher = options.fetcher
35
+ this.idleIntervalMs = Math.max(1, Number(options.idleIntervalMs) || 60_000)
36
+ this.activeIntervalMs = Math.max(1, Number(options.activeIntervalMs) || 5_000)
37
+ this.activeIdleTimeoutMs = Math.max(1, Number(options.activeIdleTimeoutMs) || 30_000)
38
+ this.logger = options.logger ?? {}
39
+ this.timers = options.timers ?? { setTimeout, clearTimeout, setInterval, clearInterval }
40
+ this.now = typeof options.now === 'function' ? options.now : () => Date.now()
41
+ this.snapshot = INITIAL_MIRROR_SNAPSHOT
42
+ this.subscribers = new Set()
43
+ this.idleSinks = new Set()
44
+ this.activeConsumers = 0
45
+ this.activeAcquiredAt = null
46
+ this.lastPokeAt = null
47
+ this.idleTimedOut = false
48
+ this.idleTimer = null
49
+ this.timer = null
50
+ this.inFlight = false
51
+ this.stopped = false
52
+ this.controller = typeof AbortController === 'function' ? new AbortController() : null
53
+ this.getSnapshot = () => this.snapshot
54
+ this.subscribe = (callback) => this.addSubscriber(callback)
55
+ }
56
+
57
+ addSubscriber(callback) {
58
+ this.subscribers.add(callback)
59
+ if (this.subscribers.size === 1) this.startPolling()
60
+ return () => {
61
+ this.subscribers.delete(callback)
62
+ if (this.subscribers.size === 0) this.stopPolling()
63
+ }
64
+ }
65
+
66
+ startPolling() {
67
+ void this.refresh()
68
+ this.rescheduleTimer()
69
+ }
70
+
71
+ stopPolling() {
72
+ this.clearTimer()
73
+ }
74
+
75
+ /** 注册高频消费者;返回幂等 release。 */
76
+ acquireActive() {
77
+ this.activeConsumers += 1
78
+ if (this.activeConsumers === 1) {
79
+ this.activeAcquiredAt = this.now()
80
+ this.lastPokeAt = null
81
+ }
82
+ this.idleTimedOut = false
83
+ this.resetIdleTimer()
84
+ this.rescheduleTimer()
85
+ let released = false
86
+ return () => {
87
+ if (released) return
88
+ released = true
89
+ this.activeConsumers = Math.max(0, this.activeConsumers - 1)
90
+ if (this.activeConsumers === 0) {
91
+ this.idleTimedOut = false
92
+ this.activeAcquiredAt = null
93
+ this.lastPokeAt = null
94
+ this.clearIdleTimer()
95
+ }
96
+ this.rescheduleTimer()
97
+ }
98
+ }
99
+
100
+ /** 报告用户交互;refcount=0 或已 dispose 时 no-op。 */
101
+ pokeActive() {
102
+ if (this.stopped || this.activeConsumers === 0) return
103
+ this.lastPokeAt = this.now()
104
+ if (!this.idleTimedOut) { this.resetIdleTimer(); return }
105
+ this.idleTimedOut = false
106
+ this.resetIdleTimer()
107
+ this.rescheduleTimer()
108
+ }
109
+
110
+ /** 是否处于空闲降级态(供重算轮询等消费者降频)。 */
111
+ isIdle() {
112
+ return this.idleTimedOut
113
+ }
114
+
115
+ /**
116
+ * 订阅空闲降级事件(用于 Auditor 统计“打开后多久走开”)。
117
+ * @param {(event: {at:number, timeoutMs:number, acquiredAt:number, lastPokeAt:number|null,
118
+ * idleAfterMs:number, totalActiveMs:number, activeConsumers:number}) => void} sink
119
+ * @returns {() => void} unsubscribe
120
+ */
121
+ onIdleDowngraded(sink) {
122
+ if (typeof sink !== 'function') throw new TypeError('onIdleDowngraded: sink must be a function')
123
+ this.idleSinks.add(sink)
124
+ return () => { this.idleSinks.delete(sink) }
125
+ }
126
+
127
+ /** 当前有效轮询间隔(诊断/测试用)。 */
128
+ currentIntervalMs() {
129
+ return this.activeConsumers > 0 && !this.idleTimedOut ? this.activeIntervalMs : this.idleIntervalMs
130
+ }
131
+
132
+ /** 当前交互超时(毫秒)。 */
133
+ getActiveIdleTimeout() {
134
+ return this.activeIdleTimeoutMs
135
+ }
136
+
137
+ /**
138
+ * 运行时调整“无交互降级”超时(设置面板 15/30/60s)。
139
+ * - 无活跃消费者:只记录,下次 acquireActive 生效;
140
+ * - 已降级:视为用户回来操作,恢复高频并重建 idleTimer;
141
+ * - 活跃未降级:只重建 idleTimer(不重建轮询 timer,避免推迟下一次轮询)。
142
+ * @param {number} ms
143
+ */
144
+ setActiveIdleTimeout(ms) {
145
+ const next = Number(ms)
146
+ if (!Number.isFinite(next) || next < 1) throw new RangeError('invalid activeIdleTimeoutMs')
147
+ if (next === this.activeIdleTimeoutMs) return
148
+ this.activeIdleTimeoutMs = next
149
+ if (this.stopped || this.activeConsumers === 0) return
150
+ if (this.idleTimedOut) {
151
+ this.idleTimedOut = false
152
+ this.rescheduleTimer()
153
+ }
154
+ this.resetIdleTimer()
155
+ }
156
+
157
+ async refresh(options = {}) {
158
+ if (this.stopped || this.inFlight) return
159
+ if (this.controller !== null && this.controller.signal.aborted) return
160
+ this.inFlight = true
161
+ this.update({ loading: true })
162
+ try {
163
+ const result = await this.fetcher({
164
+ force: options.force === true,
165
+ signal: this.controller === null ? undefined : this.controller.signal,
166
+ })
167
+ if (this.stopped || (this.controller !== null && this.controller.signal.aborted)) return
168
+ if (result.notModified === true) {
169
+ this.update({ loading: false, error: null, lastUpdatedAt: this.now() })
170
+ } else if (result.status !== null && result.status !== undefined) {
171
+ this.update({ status: result.status, loading: false, error: null, lastUpdatedAt: this.now() })
172
+ }
173
+ } catch (error) {
174
+ if (this.stopped || (this.controller !== null && this.controller.signal.aborted)) return
175
+ this.logger.warn?.('[mirror-status] refresh failed', {
176
+ error: String(error instanceof Error ? error.message : error),
177
+ })
178
+ this.update({ loading: false, error: String(error instanceof Error ? error.message : error) })
179
+ } finally {
180
+ this.inFlight = false
181
+ }
182
+ }
183
+
184
+ dispose() {
185
+ this.stopped = true
186
+ this.clearTimer()
187
+ this.clearIdleTimer()
188
+ if (this.controller !== null) this.controller.abort()
189
+ this.subscribers.clear()
190
+ this.idleSinks.clear()
191
+ }
192
+
193
+ // ── internals ────────────────────────────────────────────────────────────
194
+
195
+ resetIdleTimer() {
196
+ this.clearIdleTimer()
197
+ if (this.stopped || this.activeConsumers === 0) return
198
+ this.idleTimer = this.timers.setTimeout(() => { this.handleIdleTimeout() }, this.activeIdleTimeoutMs)
199
+ this.idleTimer?.unref?.()
200
+ }
201
+
202
+ /** 降级发生:记录事件(真实走开时长 = now - lastPokeAt/acquiredAt)并通知订阅者。 */
203
+ handleIdleTimeout() {
204
+ this.idleTimer = null
205
+ if (this.stopped || this.activeConsumers === 0) return
206
+ const at = this.now()
207
+ const acquiredAt = this.activeAcquiredAt ?? at
208
+ const reference = this.lastPokeAt ?? acquiredAt
209
+ const event = {
210
+ at,
211
+ timeoutMs: this.activeIdleTimeoutMs,
212
+ acquiredAt,
213
+ lastPokeAt: this.lastPokeAt,
214
+ idleAfterMs: at - reference,
215
+ totalActiveMs: at - acquiredAt,
216
+ activeConsumers: this.activeConsumers,
217
+ }
218
+ this.idleTimedOut = true
219
+ this.rescheduleTimer()
220
+ this.logger.warn?.('[mirror-status] active consumer idle, downgrading to idle interval', {
221
+ idleIntervalMs: this.idleIntervalMs, idleAfterMs: event.idleAfterMs,
222
+ })
223
+ for (const sink of [...this.idleSinks]) {
224
+ try { sink(event) } catch { /* 订阅者异常隔离 */ }
225
+ }
226
+ }
227
+
228
+ clearIdleTimer() {
229
+ if (this.idleTimer !== null) {
230
+ this.timers.clearTimeout(this.idleTimer)
231
+ this.idleTimer = null
232
+ }
233
+ }
234
+
235
+ rescheduleTimer() {
236
+ if (this.stopped || this.subscribers.size === 0) return
237
+ this.clearTimer()
238
+ this.timer = this.timers.setInterval(() => { void this.refresh() }, this.currentIntervalMs())
239
+ this.timer?.unref?.()
240
+ }
241
+
242
+ clearTimer() {
243
+ if (this.timer !== null) {
244
+ this.timers.clearInterval(this.timer)
245
+ this.timer = null
246
+ }
247
+ }
248
+
249
+ update(patch) {
250
+ this.snapshot = { ...this.snapshot, ...patch }
251
+ for (const callback of [...this.subscribers]) {
252
+ try { callback() } catch { /* 订阅者异常隔离 */ }
253
+ }
254
+ }
255
+ }
256
+
257
+ export const _internals = Object.freeze({ INITIAL_MIRROR_SNAPSHOT })