@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,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 }
|