@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,133 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // bridge-client — talk to a running PC host's `@ignotuss/dsh-desktop-bridge`.
4
+ //
5
+ // The bridge is transport only: 127.0.0.1 + bearer token from
6
+ // `<DSH_HOME>/desktop-bridge.json`. The descriptor is re-read on every call because
7
+ // the port and token are regenerated each time the PC host mounts the plugin.
8
+ // This client never writes to the PC host and never logs the token.
9
+
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+
13
+ export const BRIDGE_DESCRIPTOR_FILE = 'desktop-bridge.json'
14
+ export const BRIDGE_DEFAULT_TIMEOUT_MS = 15000
15
+
16
+ export const BRIDGE_CODE = Object.freeze({
17
+ UNAVAILABLE: 'bridge-unavailable',
18
+ INVALID: 'bridge-descriptor-invalid',
19
+ HTTP: 'bridge-http',
20
+ NETWORK: 'bridge-network',
21
+ TIMEOUT: 'bridge-timeout',
22
+ INVALID_INPUT: 'bridge-invalid-input',
23
+ })
24
+
25
+ const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost'])
26
+
27
+ export function defaultDshHome() {
28
+ return String(process.env.DSH_HOME ?? '').trim() || path.join(String(process.env.USERPROFILE ?? '').trim() || '.', '.dsh')
29
+ }
30
+
31
+ export function descriptorPath(dshHome) {
32
+ return path.join(String(dshHome ?? '').trim() || defaultDshHome(), BRIDGE_DESCRIPTOR_FILE)
33
+ }
34
+
35
+ /**
36
+ * Read + validate the descriptor. Fail-closed: a malformed or non-loopback
37
+ * descriptor is an error, never a partial client.
38
+ * @returns {{ok:true, info:{version:number,address:string,port:number,token:string,pid?:number}, file:string} | {ok:false, code:string, error:string, file:string}}
39
+ */
40
+ export function readBridgeDescriptor(options = {}) {
41
+ const file = options.file ?? descriptorPath(options.dshHome)
42
+ let raw
43
+ try {
44
+ raw = fs.readFileSync(file, 'utf8')
45
+ } catch (e) {
46
+ return { ok: false, code: BRIDGE_CODE.UNAVAILABLE, error: `desktop-bridge.json not readable (${e?.code ?? 'ENOENT'})`, file }
47
+ }
48
+ let info
49
+ try { info = JSON.parse(raw) } catch { return { ok: false, code: BRIDGE_CODE.INVALID, error: 'desktop-bridge.json is not JSON', file } }
50
+ if (!info || typeof info !== 'object') return { ok: false, code: BRIDGE_CODE.INVALID, error: 'descriptor is not an object', file }
51
+ if (info.version !== 1) return { ok: false, code: BRIDGE_CODE.INVALID, error: `unsupported descriptor version ${String(info.version)}`, file }
52
+ if (!LOOPBACK.has(String(info.address ?? ''))) return { ok: false, code: BRIDGE_CODE.INVALID, error: 'bridge address is not loopback', file }
53
+ const port = Number(info.port)
54
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return { ok: false, code: BRIDGE_CODE.INVALID, error: 'invalid bridge port', file }
55
+ const token = typeof info.token === 'string' ? info.token.trim() : ''
56
+ if (!/^[0-9a-f]{32,}$/i.test(token)) return { ok: false, code: BRIDGE_CODE.INVALID, error: 'invalid bridge token', file }
57
+ return { ok: true, file, info: { version: 1, address: String(info.address), port, token, pid: info.pid, startedAt: info.startedAt } }
58
+ }
59
+
60
+ function cleanError(e) {
61
+ const code = e?.name === 'AbortError' ? BRIDGE_CODE.TIMEOUT : BRIDGE_CODE.NETWORK
62
+ return { ok: false, code, error: code === BRIDGE_CODE.TIMEOUT ? 'bridge request timed out' : 'bridge request failed' }
63
+ }
64
+
65
+ /**
66
+ * Create a client bound to a DSH home. Every method reloads the descriptor.
67
+ * @returns {{status:Function, listSessions:Function, events:Function, send:Function, create:Function}}
68
+ */
69
+ export function createBridgeClient(options = {}) {
70
+ const fetchImpl = options.fetchImpl ?? fetch
71
+ const timeoutMs = Math.max(1000, Number(options.timeoutMs) || BRIDGE_DEFAULT_TIMEOUT_MS)
72
+
73
+ async function call(method, pathname, body) {
74
+ const desc = readBridgeDescriptor({ file: options.file, dshHome: options.dshHome })
75
+ if (!desc.ok) return desc
76
+ const url = `http://${desc.info.address}:${desc.info.port}${pathname}`
77
+ let res
78
+ try {
79
+ res = await fetchImpl(url, {
80
+ method,
81
+ headers: { authorization: `Bearer ${desc.info.token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
82
+ body: body ? JSON.stringify(body) : undefined,
83
+ signal: AbortSignal.timeout(timeoutMs),
84
+ })
85
+ } catch (e) {
86
+ return cleanError(e)
87
+ }
88
+ const text = await res.text().catch(() => '')
89
+ let data = null
90
+ try { data = text ? JSON.parse(text) : null } catch { data = null }
91
+ if (!res.ok) {
92
+ return { ok: false, code: BRIDGE_CODE.HTTP, status: res.status, error: String(data?.error ?? `HTTP ${res.status}`).slice(0, 300) }
93
+ }
94
+ return { ok: true, status: res.status, data, pid: desc.info.pid, startedAt: desc.info.startedAt }
95
+ }
96
+
97
+ return {
98
+ async status() {
99
+ const desc = readBridgeDescriptor({ file: options.file, dshHome: options.dshHome })
100
+ if (!desc.ok) return desc
101
+ return { ok: true, available: true, pid: desc.info.pid, startedAt: desc.info.startedAt, file: desc.file }
102
+ },
103
+ async listSessions() {
104
+ const out = await call('GET', '/v1/sessions')
105
+ if (!out.ok) return out
106
+ return { ok: true, items: Array.isArray(out.data?.items) ? out.data.items : [] }
107
+ },
108
+ async events(sessionId, opts = {}) {
109
+ const id = String(sessionId ?? '').trim()
110
+ if (!id) return { ok: false, code: BRIDGE_CODE.INVALID_INPUT, error: 'sessionId required' }
111
+ const limit = Math.min(100, Math.max(1, Number(opts.limit) || 30))
112
+ const out = await call('GET', `/v1/sessions/${encodeURIComponent(id)}/events?limit=${limit}`)
113
+ if (!out.ok) return out
114
+ return { ok: true, sessionId: id, events: Array.isArray(out.data?.events) ? out.data.events : [] }
115
+ },
116
+ async send(sessionId, text) {
117
+ const id = String(sessionId ?? '').trim()
118
+ const body = String(text ?? '')
119
+ if (!id) return { ok: false, code: BRIDGE_CODE.INVALID_INPUT, error: 'sessionId required' }
120
+ if (!body.trim() || Buffer.byteLength(body, 'utf8') > 4096) return { ok: false, code: BRIDGE_CODE.INVALID_INPUT, error: 'text must be 1-4096 bytes' }
121
+ const out = await call('POST', `/v1/sessions/${encodeURIComponent(id)}/messages`, { text: body })
122
+ if (!out.ok) return out
123
+ return { ok: true, accepted: out.data?.accepted === true, requestId: out.data?.requestId ?? null }
124
+ },
125
+ async create(input = {}) {
126
+ const cwd = String(input.cwd ?? '').trim()
127
+ if (!cwd) return { ok: false, code: BRIDGE_CODE.INVALID_INPUT, error: 'cwd required' }
128
+ const out = await call('POST', '/v1/sessions', { cwd, ...(input.title ? { title: String(input.title) } : {}) })
129
+ if (!out.ok) return out
130
+ return { ok: true, session: out.data }
131
+ },
132
+ }
133
+ }
@@ -0,0 +1,67 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // v3.0.9 read-only broker adapter: issues only read capabilities. Write is intentionally absent.
3
+ const READ_ROOTS = new Set(['essence', 'sessions'])
4
+
5
+ export function issueReadCapability(broker, identity, root) {
6
+ if (!READ_ROOTS.has(root)) throw new Error('read-root-not-allowed')
7
+ const role = identity?.role
8
+ if (!role) throw new Error('identity-missing')
9
+ return broker.issue({
10
+ role,
11
+ root,
12
+ op: 'read',
13
+ essenceId: String(identity.essenceId || ''),
14
+ forkId: String(identity.forkId || ''),
15
+ })
16
+ }
17
+
18
+ export function checkReadCapability(broker, token, scope) {
19
+ if (!READ_ROOTS.has(scope?.root)) return { ok: false, error: 'access-denied', reason: 'read-root-not-allowed' }
20
+ return broker.resolve({
21
+ token,
22
+ root: scope.root,
23
+ op: 'read',
24
+ essenceId: scope.essenceId || '',
25
+ ownerEssenceId: scope.ownerEssenceId || '',
26
+ forkId: scope.forkId || '',
27
+ ownerForkId: scope.ownerForkId || '',
28
+ path: scope.path || '',
29
+ })
30
+ }
31
+
32
+ function headerValue(headers, name) {
33
+ const key = Object.keys(headers || {}).find((k) => k.toLowerCase() === name)
34
+ return key ? headers[key] : ''
35
+ }
36
+
37
+ export function resolveIdentity({ headers = {}, url = '/' }, resolveToken) {
38
+ const auth = String(headerValue(headers, 'x-fang-auth') || '')
39
+ const bearer = /^Bearer\s+(.+)$/i.exec(String(headerValue(headers, 'authorization') || ''))?.[1] || ''
40
+ let query = ''
41
+ try { query = new URL(url, 'http://127.0.0.1').searchParams.get('token') || '' } catch { /* ignore */ }
42
+ const token = auth || bearer || query
43
+ if (!token) return { ok: false, status: 401, error: 'unauthorized' }
44
+ if (typeof resolveToken !== 'function') return { ok: false, status: 401, error: 'unauthorized' }
45
+ const identity = resolveToken(token)
46
+ if (!identity) return { ok: false, status: 401, error: 'unauthorized' }
47
+ if (identity.denied) return { ok: false, status: 403, error: 'access-denied', reason: String(identity.reason || 'denied') }
48
+ if (!identity.role) return { ok: false, status: 401, error: 'unauthorized' }
49
+ return {
50
+ ok: true,
51
+ token,
52
+ role: String(identity.role),
53
+ essenceId: String(identity.essenceId || ''),
54
+ forkId: String(identity.forkId || ''),
55
+ }
56
+ }
57
+
58
+ export function denialToHttp(result) {
59
+ if (result?.ok) return null
60
+ const status = result?.status === 403 ? 403 : 401
61
+ return {
62
+ status,
63
+ body: status === 403
64
+ ? { ok: false, error: 'access-denied', reason: String(result?.reason || 'denied') }
65
+ : { ok: false, error: 'unauthorized' },
66
+ }
67
+ }
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+ import crypto from 'node:crypto'
4
+ import { canAccess } from './roots.mjs'
5
+
6
+ export class Broker {
7
+ constructor(secret) {
8
+ this.secret = secret || crypto.randomBytes(32)
9
+ this.audit = []
10
+ }
11
+
12
+ issue({ role, root, op, essenceId = '', forkId = '', ttlMs = 60_000 }) {
13
+ const payload = Buffer.from(JSON.stringify({ role, root, op, essenceId, forkId, exp: Date.now() + ttlMs })).toString('base64url')
14
+ const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('base64url')
15
+ return `${payload}.${sig}`
16
+ }
17
+
18
+ verify(token) {
19
+ const [payload, sig] = String(token || '').split('.')
20
+ if (!payload || !sig) return null
21
+ const expected = crypto.createHmac('sha256', this.secret).update(payload).digest('base64url')
22
+ if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null
23
+ try {
24
+ const caps = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
25
+ if (!caps.exp || caps.exp < Date.now()) return null
26
+ return caps
27
+ } catch { return null }
28
+ }
29
+
30
+ resolve({ token, root, op, essenceId = '', ownerEssenceId = '', forkId = '', ownerForkId = '', path = '' }) {
31
+ const caps = this.verify(token)
32
+ const deny = (reason) => {
33
+ this.audit.push({ ok: false, reason, root, op, essenceId, forkId, at: Date.now() })
34
+ return { ok: false, error: 'access-denied', reason }
35
+ }
36
+ if (!caps) return deny('bad-capability')
37
+ if (caps.root !== root || caps.op !== op) return deny('capability-mismatch')
38
+ if (caps.role !== 'coordinator' && caps.essenceId && ownerEssenceId && caps.essenceId !== ownerEssenceId) return deny('wrong-essence')
39
+ if (caps.role === 'fork' && caps.forkId && ownerForkId && caps.forkId !== ownerForkId) return deny('wrong-fork')
40
+ const allowed = canAccess({ role: caps.role, root, op, essenceId: caps.essenceId || essenceId, ownerEssenceId, forkId: caps.forkId || forkId, ownerForkId, path })
41
+ if (!allowed) return deny('matrix')
42
+ this.audit.push({ ok: true, root, op, essenceId: caps.essenceId, forkId: caps.forkId, at: Date.now() })
43
+ return { ok: true, role: caps.role }
44
+ }
45
+ }
@@ -0,0 +1,109 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // business-producers — 业务生产者 → 决策中心的命名适配层(P4 业务接入契约)。
5
+ //
6
+ // 这不是“外部系统已接”:HarnessMM/PluginUpdater/SuperAgent/PJM/Worker 的本体不在
7
+ // 本仓。本模块给它们一个**明确、稳定、可测试**的调用面;只要在各自进程里调用对应
8
+ // 方法(或等价 ingest payload),就会生成正确等级/kind/dedupeKey 的 ticket。
9
+ import { nowMs } from '../kernel/index.mjs'
10
+
11
+ /** 把外部系统的动作映射成 decision-center ingest/submit 的稳定 payload。 */
12
+ export function createBusinessProducers({ submit, now = nowMs } = {}) {
13
+ if (typeof submit !== 'function') throw new TypeError('createBusinessProducers: submit required')
14
+ const send = (payload) => submit({ ...payload, at: now() })
15
+
16
+ return {
17
+ /** MirrorUpgrade 成功但需要重启宿主:红色,动作 confirm_restart。 */
18
+ mirrorUpgradeRestartRequired({ from = 'unknown', to = 'unknown', reason = '' } = {}) {
19
+ return send({
20
+ producer: 'MirrorUpgrade', kind: 'mirror-upgrade', level: 'red',
21
+ title: '镜像升级完成,需重启宿主',
22
+ context: `镜像已从 ${from} 切换到 ${to}${reason ? `(${reason})` : ''};重启后新镜像才会完全生效。`,
23
+ actions: [
24
+ { id: 'confirm_restart', label: '立即重启', type: 'primary' },
25
+ { id: 'later', label: '稍后', type: 'default' },
26
+ ],
27
+ dedupeKey: `mirror-upgrade:restart:${from}->${to}`,
28
+ })
29
+ },
30
+ /** MirrorUpgrade 失败/exception。 */
31
+ mirrorUpgradeFailed({ from = 'unknown', to = null, reason = 'unknown', level = 'yellow' } = {}) {
32
+ return send({
33
+ producer: 'MirrorUpgrade', kind: 'mirror-upgrade', level,
34
+ title: '母体镜像升级失败',
35
+ context: `镜像升级未成功:${reason}${from ? `(from ${from}${to ? ` -> ${to}` : ''})` : ''}`,
36
+ actions: [{ id: 'retry', label: '重试升级', type: 'primary' }, { id: 'ignore', label: '忽略', type: 'default' }],
37
+ dedupeKey: `mirror-upgrade:failed:${reason}`.slice(0, 200),
38
+ })
39
+ },
40
+ /** EssenceManager 合并冲突:黄色,合并同一 essence 的冲突。 */
41
+ essenceConflict({ essenceId = 'unknown', conflicts = [], severity = 'warning' } = {}) {
42
+ return send({
43
+ producer: 'EssenceManager', kind: 'essence-conflict', level: severity === 'fatal' ? 'red' : 'yellow',
44
+ title: `元主合并冲突:${essenceId}`,
45
+ context: `essence=${essenceId}; conflicts=${Array.isArray(conflicts) ? conflicts.length : 0}`,
46
+ actions: [{ id: 'review', label: '查看冲突', type: 'primary' }, { id: 'ignore', label: '忽略', type: 'default' }],
47
+ dedupeKey: `essence-conflict:${essenceId}`,
48
+ })
49
+ },
50
+ /** HarnessMM 优化建议:蓝色(异步建议,不阻塞)。 */
51
+ harnessmmSuggestion({ id = null, summary = '', score = null } = {}) {
52
+ return send({
53
+ producer: 'HarnessMM', kind: 'harnessmm-suggestion', level: 'blue',
54
+ title: summary || 'HarnessMM 优化建议',
55
+ context: `suggestion=${id ?? 'n/a'}; score=${score ?? 'n/a'}`,
56
+ actions: [{ id: 'accept', label: '采纳', type: 'default' }, { id: 'ignore', label: '忽略', type: 'default' }],
57
+ dedupeKey: id ? `harnessmm:${id}` : undefined,
58
+ })
59
+ },
60
+ /** PluginUpdater 备份完成通知:绿色(FYI)。 */
61
+ pluginUpdaterBackup({ pluginId = 'unknown', backupId = null } = {}) {
62
+ return send({
63
+ producer: 'PluginUpdater', kind: 'plugin-updater-backup', level: 'green',
64
+ title: `插件更新前备份完成:${pluginId}`,
65
+ context: `plugin=${pluginId}; backup=${backupId ?? 'n/a'}`,
66
+ dedupeKey: backupId ? `plugin-updater:backup:${backupId}` : undefined,
67
+ })
68
+ },
69
+ /** SuperAgent 任务完成汇报:绿色。 */
70
+ superagentCompletion({ taskId = null, summary = '' } = {}) {
71
+ return send({
72
+ producer: 'SuperAgent', kind: 'superagent-completion', level: 'green',
73
+ title: summary || 'SuperAgent 任务完成',
74
+ context: `task=${taskId ?? 'n/a'}`,
75
+ dedupeKey: taskId ? `superagent:completed:${taskId}` : undefined,
76
+ })
77
+ },
78
+ /** PJM 超时:黄色 + 默认 5 分钟自动转灰。 */
79
+ pjmTimeout({ projectId = null, phase = 'unknown', timeoutMinutes = 5 } = {}) {
80
+ return send({
81
+ producer: 'PJM', kind: 'pjm-timeout', level: 'yellow', timeoutMinutes,
82
+ title: `PJM 超时:${projectId ?? 'unknown'}`,
83
+ context: `project=${projectId ?? 'n/a'}; phase=${phase}`,
84
+ actions: [{ id: 'retry', label: '重试', type: 'primary' }, { id: 'abandon', label: '放弃', type: 'default' }],
85
+ dedupeKey: projectId ? `pjm:timeout:${projectId}:${phase}` : undefined,
86
+ })
87
+ },
88
+ /** PJM 致命错误:黄色(需要人看,但不阻塞整个 Agent)。 */
89
+ pjmFatal({ projectId = null, error = 'unknown' } = {}) {
90
+ return send({
91
+ producer: 'PJM', kind: 'pjm-fatal', level: 'yellow',
92
+ title: `PJM 致命错误:${projectId ?? 'unknown'}`,
93
+ context: `project=${projectId ?? 'n/a'}; error=${String(error).slice(0, 500)}`,
94
+ actions: [{ id: 'retry', label: '重试', type: 'primary' }, { id: 'abandon', label: '放弃', type: 'default' }],
95
+ dedupeKey: projectId ? `pjm:fatal:${projectId}` : undefined,
96
+ })
97
+ },
98
+ /** Worker 跑偏:黄色(可在 payload 带偏离详情)。 */
99
+ workerDeviation({ workerId = null, taskId = null, detail = '' } = {}) {
100
+ return send({
101
+ producer: 'Worker', kind: 'worker-deviation', level: 'yellow',
102
+ title: `Worker 跑偏:${workerId ?? 'unknown'}`,
103
+ context: `worker=${workerId ?? 'n/a'}; task=${taskId ?? 'n/a'}; ${String(detail).slice(0, 500)}`,
104
+ actions: [{ id: 'review', label: '查看', type: 'primary' }, { id: 'ignore', label: '忽略', type: 'default' }],
105
+ dedupeKey: taskId ? `worker:deviation:${taskId}` : undefined,
106
+ })
107
+ },
108
+ }
109
+ }
@@ -0,0 +1,209 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang v2.6.3 — 幂等命令缓存(in-flight 合并 + completed LRU 淘汰)。
4
+ //
5
+ // 契约:docs/COMMAND-CACHE.md、docs/PROTOCOL-V3.md §6.2。
6
+ // 设计要点:
7
+ // * 两态:running(value 是同一个 promise)/ completed(value 是结果);
8
+ // * 同一 commandId 并发提交 → 第二个调用等待同一个 promise,不重复执行副作用;
9
+ // * LRU:Map 插入顺序即使用顺序;run() 命中会 touch,纯 get() 不 touch;
10
+ // * 淘汰:只淘汰 completed,跳过 running;全是 running 时允许临时超限,
11
+ // 等 in-flight 完成后自动收敛;
12
+ // * 失败契约:fn 返回的业务失败(CommandResult.status='rejected')**缓存**;
13
+ // fn 抛错(传输层/编程错误,命令可能没执行)**不缓存并移除**,允许重试重新执行;
14
+ // * 缓存不实现超时:超时属于命令层(AbortController),缓存层只在得到最终结果后决策。
15
+ export const COMMAND_CACHE_DEFAULT_CAPACITY = 500
16
+
17
+ /**
18
+ * @typedef {{state:'running', promise:Promise<any>|null, startedAt:number}} RunningEntry
19
+ * @typedef {{state:'completed', result:any, completedAt:number}} CompletedEntry
20
+ * @typedef {RunningEntry|CompletedEntry} CacheEntry
21
+ */
22
+
23
+ /**
24
+ * @typedef {object} CommandCacheOptions
25
+ * @property {number} [capacity=500] 容量上限(>=1);超限时只淘汰 completed 项
26
+ * @property {(commandId:string, entry:CompletedEntry)=>void} [onEvict] 淘汰回调(异常被隔离)
27
+ * @property {()=>number} [now=Date.now] 时钟注入(测试用)
28
+ */
29
+
30
+ export class CommandCache {
31
+ /** @param {CommandCacheOptions} [options] */
32
+ constructor({ capacity = COMMAND_CACHE_DEFAULT_CAPACITY, onEvict = null, now = Date.now } = {}) {
33
+ const cap = Math.floor(Number(capacity))
34
+ if (!Number.isFinite(cap) || cap < 1) {
35
+ throw new RangeError('command cache capacity must be an integer >= 1')
36
+ }
37
+ this.capacity = cap
38
+ this.onEvict = typeof onEvict === 'function' ? onEvict : null
39
+ this.now = typeof now === 'function' ? now : Date.now
40
+ /** @type {Map<string, CacheEntry>} */
41
+ this.entries = new Map()
42
+ this.counters = { executions: 0, hits: 0, dedupHits: 0, evictions: 0, droppedOnError: 0 }
43
+ }
44
+
45
+ /**
46
+ * 幂等执行:同一 commandId 至多执行一次。
47
+ * - completed 命中 → 返回缓存结果(并 touch);
48
+ * - running 命中 → 返回同一个 promise(并 touch,不重复执行 fn);
49
+ * - 未命中 → 执行 fn,完成后写 completed;fn 抛错则移除并抛出。
50
+ *
51
+ * 说明:fn 必须把「业务失败」转成结果对象返回;只有「命令没有执行到」的
52
+ * 传输层/编程错误才允许抛错(这样重试才会重新执行)。
53
+ *
54
+ * @param {string} commandId
55
+ * @param {() => Promise<any>} fn
56
+ * @returns {Promise<any>}
57
+ */
58
+ run(commandId, fn) {
59
+ const id = normalizeCommandId(commandId)
60
+ if (typeof fn !== 'function') throw new TypeError('command cache run() requires a function')
61
+
62
+ const existing = this.entries.get(id)
63
+ if (existing) {
64
+ this.counters.hits += 1
65
+ this.touch(id)
66
+ if (existing.state === 'completed') return Promise.resolve(existing.result)
67
+ this.counters.dedupHits += 1
68
+ return existing.promise
69
+ }
70
+
71
+ /** @type {RunningEntry} */
72
+ const entry = { state: 'running', promise: null, startedAt: this.now() }
73
+ const promise = Promise.resolve()
74
+ .then(() => {
75
+ this.counters.executions += 1
76
+ return fn()
77
+ })
78
+ .then(
79
+ (result) => {
80
+ // 只有「本次执行」仍然占着这个 commandId 时才落缓存(防御未来 running 失效语义变化)
81
+ const current = this.entries.get(id)
82
+ if (current === entry) {
83
+ this.entries.set(id, { state: 'completed', result, completedAt: this.now() })
84
+ this.evictIfNeeded()
85
+ }
86
+ return result
87
+ },
88
+ (error) => {
89
+ const current = this.entries.get(id)
90
+ if (current === entry) {
91
+ this.entries.delete(id)
92
+ this.counters.droppedOnError += 1
93
+ }
94
+ throw error
95
+ },
96
+ )
97
+
98
+ entry.promise = promise
99
+ this.entries.set(id, entry)
100
+ this.evictIfNeeded()
101
+ // 给 promise 挂一个空 handler:调用方忽略返回值时不会出现 unhandled rejection;
102
+ // 错误仍会通过上面返回的 promise 交给调用方。
103
+ promise.catch(() => {})
104
+ return promise
105
+ }
106
+
107
+ /** 纯查询:只返回 completed 的结果,不改变 LRU 顺序。 */
108
+ get(commandId) {
109
+ const entry = this.entries.get(String(commandId ?? ''))
110
+ return entry && entry.state === 'completed' ? entry.result : undefined
111
+ }
112
+
113
+ /** 是否已有缓存项(running 或 completed 都算)。 */
114
+ has(commandId) {
115
+ return this.entries.has(String(commandId ?? ''))
116
+ }
117
+
118
+ /** 手动失效:只对 completed 生效;running 不可失效(避免副作用重复)。 */
119
+ invalidate(commandId) {
120
+ const id = String(commandId ?? '')
121
+ const entry = this.entries.get(id)
122
+ if (!entry || entry.state !== 'completed') return false
123
+ this.entries.delete(id)
124
+ return true
125
+ }
126
+
127
+ /**
128
+ * 清空所有 completed 项,保留 running。
129
+ * running 的 promise 还在飞,完成后仍会落缓存(它确实已经执行过)。
130
+ * @returns {number} 被清掉的 completed 数量
131
+ */
132
+ clear() {
133
+ const completedIds = []
134
+ for (const [id, entry] of this.entries) {
135
+ if (entry.state === 'completed') completedIds.push(id)
136
+ }
137
+ for (const id of completedIds) this.entries.delete(id)
138
+ return completedIds.length
139
+ }
140
+
141
+ /** 当前项数(可能暂时超过 capacity:全是 running 时)。 */
142
+ size() {
143
+ return this.entries.size
144
+ }
145
+
146
+ /** 容量与状态统计(用于诊断/测试)。 */
147
+ stats() {
148
+ let running = 0
149
+ let completed = 0
150
+ for (const entry of this.entries.values()) {
151
+ if (entry.state === 'running') running += 1
152
+ else completed += 1
153
+ }
154
+ return {
155
+ total: this.entries.size,
156
+ running,
157
+ completed,
158
+ capacity: this.capacity,
159
+ overCapacity: Math.max(0, this.entries.size - this.capacity),
160
+ ...this.counters,
161
+ }
162
+ }
163
+
164
+ /** 把项移到 Map 末尾(最近使用)。 */
165
+ touch(commandId) {
166
+ const id = String(commandId ?? '')
167
+ const entry = this.entries.get(id)
168
+ if (!entry) return false
169
+ this.entries.delete(id)
170
+ this.entries.set(id, entry)
171
+ return true
172
+ }
173
+
174
+ /**
175
+ * 容量收敛:从 Map 头开始找最久未用的 completed 项淘汰。
176
+ * running 项不可淘汰;全是 running 时直接返回(允许临时超限)。
177
+ */
178
+ evictIfNeeded() {
179
+ while (this.entries.size > this.capacity) {
180
+ let victimId = null
181
+ for (const [id, entry] of this.entries) {
182
+ if (entry.state === 'completed') {
183
+ victimId = id
184
+ break
185
+ }
186
+ }
187
+ if (victimId === null) return
188
+ const victim = this.entries.get(victimId)
189
+ this.entries.delete(victimId)
190
+ this.counters.evictions += 1
191
+ if (this.onEvict) {
192
+ try {
193
+ this.onEvict(victimId, victim)
194
+ } catch {
195
+ /* 淘汰回调异常不得中断容量收敛 */
196
+ }
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ /** commandId 必须是非空字符串(Adapter 侧已用正则校验格式)。 */
203
+ function normalizeCommandId(commandId) {
204
+ const id = String(commandId ?? '').trim()
205
+ if (!id) throw new TypeError('command cache requires a non-empty commandId')
206
+ return id
207
+ }
208
+
209
+ export const _internals = { normalizeCommandId }