@twinsearth/w2m-dsh-plugin 2.2.6 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +35 -0
- package/README.md +1 -1
- package/codex/mcp-servers.toml +1 -1
- package/cordis.patch.yml +16 -0
- package/docs/acceptance/3.0.0/RELEASE-3000.json +52 -0
- package/docs/acceptance/3.1.0/RELEASE-3100.json +46 -0
- package/lib/client.js +603 -0
- package/lib/decision-center.js +11 -0
- package/lib/queue-journal.js +15 -0
- package/package.json +8 -4
- package/plugin.json +1 -1
- package/src/decision-center/index.mjs +2265 -0
- package/src/decision-center/kernel.mjs +165 -0
- package/src/decision-center/lib/ai-secret-store.mjs +202 -0
- package/src/decision-center/lib/decision-outbox.mjs +129 -0
- package/src/decision-center/lib/producer-contract.mjs +162 -0
- package/src/decision-center/lib/ssrf-guard.mjs +170 -0
- package/src/decision-center/mount.mjs +26 -0
- package/src/decision-center/routes.mjs +356 -0
- package/src/decision-center/vendor/cosmokit.mjs +421 -0
- package/src/decision-center/vendor/schemastery.mjs +638 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/queue-journal/index.mjs +188 -0
- package/src/queue-journal/lib/composer-journal.mjs +128 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W2M-local replacement for the dsh-Fang kernel barrel the decision-center modules import.
|
|
3
|
+
*
|
|
4
|
+
* The ported plugin was written against `../kernel/index.mjs`, a barrel that existed to share ids,
|
|
5
|
+
* a tiny bus, filesystem helpers, the data-directory resolver and the deployment-role gate between
|
|
6
|
+
* the ten Fang plugins. W2M mounts one plugin, so the same names are supplied here with W2M paths:
|
|
7
|
+
*
|
|
8
|
+
* * `z` is the vendored schemastery the Fang tree carries (MIT, upstream cordis). Keeping it means
|
|
9
|
+
* the plugin's `Config` definitions and their descriptions survive the port verbatim;
|
|
10
|
+
* * `SERVICE` names the Cordis services this plugin provides and optionally reads. The values are
|
|
11
|
+
* W2M-prefixed so a profile that still mounts Fang's decision center cannot collide with this one;
|
|
12
|
+
* * `resolveFangLayout` returns only what the ported code reads -- `dataDir` -- anchored in the same
|
|
13
|
+
* `$DSH_HOME/xclient` state directory every other W2M component uses;
|
|
14
|
+
* * `roleGate` has nothing to gate: the mother/fork deployment matrix is a Fang concept, and a W2M
|
|
15
|
+
* profile is always the host, so the gate reports active.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { randomUUID } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
import { resolveStateDir } from '../util/cli.mjs';
|
|
22
|
+
|
|
23
|
+
export { default as z } from './vendor/schemastery.mjs';
|
|
24
|
+
|
|
25
|
+
/** Cordis service keys. Shared by the plugin that provides them and the routes that read them. */
|
|
26
|
+
export const SERVICE = Object.freeze({
|
|
27
|
+
auditor: 'w2mAuditor',
|
|
28
|
+
decisionCenter: 'w2mDecisionCenter',
|
|
29
|
+
restart: 'w2mRestart',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Audit topic names.
|
|
34
|
+
*
|
|
35
|
+
* The ported module imports this object but records nothing through it -- tickets carry their own
|
|
36
|
+
* `audit` array in the store -- so the port keeps the import surface without inventing topics W2M
|
|
37
|
+
* does not emit.
|
|
38
|
+
*/
|
|
39
|
+
export const AUDIT = Object.freeze({});
|
|
40
|
+
|
|
41
|
+
/** Deterministic, dependency-free event bus (same contract as the Fang kernel's). */
|
|
42
|
+
export function createBus() {
|
|
43
|
+
const map = new Map();
|
|
44
|
+
return {
|
|
45
|
+
on(topic, fn) {
|
|
46
|
+
if (typeof fn !== 'function') return () => {};
|
|
47
|
+
if (!map.has(topic)) map.set(topic, new Set());
|
|
48
|
+
map.get(topic).add(fn);
|
|
49
|
+
return () => map.get(topic)?.delete(fn);
|
|
50
|
+
},
|
|
51
|
+
off(topic, fn) {
|
|
52
|
+
map.get(topic)?.delete(fn);
|
|
53
|
+
},
|
|
54
|
+
emit(topic, payload) {
|
|
55
|
+
const set = map.get(topic);
|
|
56
|
+
if (!set) return 0;
|
|
57
|
+
let n = 0;
|
|
58
|
+
for (const fn of [...set]) {
|
|
59
|
+
try {
|
|
60
|
+
fn(payload);
|
|
61
|
+
n += 1;
|
|
62
|
+
} catch {
|
|
63
|
+
/* a listener must never break the emitter */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return n;
|
|
67
|
+
},
|
|
68
|
+
listenerCount(topic) {
|
|
69
|
+
return map.get(topic)?.size ?? 0;
|
|
70
|
+
},
|
|
71
|
+
clear(topic) {
|
|
72
|
+
if (topic === undefined) map.clear();
|
|
73
|
+
else map.delete(topic);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const bus = createBus();
|
|
79
|
+
|
|
80
|
+
/** `prefix_<base36 time><random>`: sortable enough to read, unique enough to key a ticket. */
|
|
81
|
+
export function newId(prefix = 'id') {
|
|
82
|
+
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function newUuid() {
|
|
86
|
+
return randomUUID();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function nowMs() {
|
|
90
|
+
return Date.now();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function ensureDir(dir) {
|
|
94
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
95
|
+
return dir;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function exists(file) {
|
|
99
|
+
try {
|
|
100
|
+
fs.lstatSync(file);
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function readJson(file, fallback = null) {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
110
|
+
} catch {
|
|
111
|
+
return fallback;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function writeFileAtomic(file, data) {
|
|
116
|
+
ensureDir(path.dirname(file));
|
|
117
|
+
const tmp = `${file}.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
118
|
+
fs.writeFileSync(tmp, data);
|
|
119
|
+
try {
|
|
120
|
+
fs.renameSync(tmp, file);
|
|
121
|
+
} catch {
|
|
122
|
+
// Windows can refuse a rename while a reader holds the file open; retry once, then fall back.
|
|
123
|
+
try {
|
|
124
|
+
fs.copyFileSync(tmp, file);
|
|
125
|
+
} finally {
|
|
126
|
+
try {
|
|
127
|
+
fs.rmSync(tmp, { force: true });
|
|
128
|
+
} catch {
|
|
129
|
+
/* the temp file is disposable */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return file;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function writeJsonAtomic(file, value, { indent = 2 } = {}) {
|
|
137
|
+
return writeFileAtomic(file, `${JSON.stringify(value, null, indent)}\n`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve the data directory the decision store lives in.
|
|
142
|
+
*
|
|
143
|
+
* Only `dataDir` is read by the ported code. An explicit `dataRoot` wins (tests and standalone
|
|
144
|
+
* deployments point it at a throwaway directory); otherwise the store sits under
|
|
145
|
+
* `$DSH_HOME/xclient/decision-center`, beside every other W2M state file.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveFangLayout(options = {}) {
|
|
148
|
+
const { dataRoot = '' } = options;
|
|
149
|
+
const explicit = String(dataRoot ?? '').trim();
|
|
150
|
+
const dataDir =
|
|
151
|
+
explicit !== '' ? path.resolve(explicit) : path.join(resolveStateDir(null, ''), 'decision-center');
|
|
152
|
+
return { dataDir };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Deployment-role gate.
|
|
157
|
+
*
|
|
158
|
+
* Fang gates a plugin per mother/fork role. W2M always runs as the host, so the gate reports active
|
|
159
|
+
* and keeps the call site (and its `logger` diagnostic) unchanged.
|
|
160
|
+
*/
|
|
161
|
+
export function roleGate(pluginKey, { logger = null } = {}) {
|
|
162
|
+
const reason = `${pluginKey} active in host`;
|
|
163
|
+
if (logger && typeof logger.info === 'function') logger.info(reason);
|
|
164
|
+
return { role: 'host', active: true, reason };
|
|
165
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
//
|
|
4
|
+
// ai-secret-store — AI advisor 的 API Key 持久化:只走 OS 钥匙串 / DPAPI,绝不落明文。
|
|
5
|
+
//
|
|
6
|
+
// 支持的 backend:
|
|
7
|
+
// * win32 : DPAPI(用户级)——PowerShell 调 ProtectedData.Protect/Unprotect,secret 走 stdin
|
|
8
|
+
// * linux : secret-tool(Secret Service / libsecret)——secret 走 stdin
|
|
9
|
+
// * darwin : 暂未实现(明确返回 unavailable,不假装成功)
|
|
10
|
+
// * 其它 : unavailable
|
|
11
|
+
//
|
|
12
|
+
// 磁盘上只存:
|
|
13
|
+
// <dataDir>/decision-ai-secret.json { version, backend, provider, model, baseUrl, updatedAt }
|
|
14
|
+
// <dataDir>/decision-ai-secret.enc backend 加密后的 blob(DPAPI 是 base64 密文;secret-tool 不落盘)
|
|
15
|
+
// 若 backend 不可用 => available=false,调用方必须退回「仅内存」,绝不写明文。
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { spawnSync } from 'node:child_process'
|
|
19
|
+
|
|
20
|
+
import { ensureDir, writeJsonAtomic } from '../kernel.mjs'
|
|
21
|
+
|
|
22
|
+
export const SECRET_META_FILE = 'decision-ai-secret.json'
|
|
23
|
+
export const SECRET_BLOB_FILE = 'decision-ai-secret.enc'
|
|
24
|
+
export const DEFAULT_SERVICE = 'w2m-decision-ai'
|
|
25
|
+
|
|
26
|
+
const PS_PROTECT = [
|
|
27
|
+
'Add-Type -AssemblyName System.Security',
|
|
28
|
+
'$b64=[Console]::In.ReadToEnd()',
|
|
29
|
+
'$bytes=[Convert]::FromBase64String($b64)',
|
|
30
|
+
'$enc=[System.Security.Cryptography.ProtectedData]::Protect($bytes,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
|
|
31
|
+
'[Console]::Out.Write([Convert]::ToBase64String($enc))',
|
|
32
|
+
].join('; ')
|
|
33
|
+
|
|
34
|
+
const PS_UNPROTECT = [
|
|
35
|
+
'Add-Type -AssemblyName System.Security',
|
|
36
|
+
'$b64=[Console]::In.ReadToEnd()',
|
|
37
|
+
'$bytes=[Convert]::FromBase64String($b64)',
|
|
38
|
+
'$raw=[System.Security.Cryptography.ProtectedData]::Unprotect($bytes,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
|
|
39
|
+
'[Console]::Out.Write([Convert]::ToBase64String($raw))',
|
|
40
|
+
].join('; ')
|
|
41
|
+
|
|
42
|
+
/** 默认 runner:同步执行,stdin 传 secret,stdout 回结果;不把 secret 放进 argv。 */
|
|
43
|
+
export function defaultRunner(command, args, input = '') {
|
|
44
|
+
const result = spawnSync(command, args, { input, encoding: 'utf8', windowsHide: true, timeout: 15000 })
|
|
45
|
+
return {
|
|
46
|
+
ok: result.status === 0,
|
|
47
|
+
status: result.status ?? -1,
|
|
48
|
+
stdout: String(result.stdout ?? '').trim(),
|
|
49
|
+
stderr: String(result.stderr ?? '').trim(),
|
|
50
|
+
error: result.error ? String(result.error?.message ?? result.error) : '',
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function detectSecretBackend({ platform = process.platform, runner = defaultRunner } = {}) {
|
|
55
|
+
if (platform === 'win32') {
|
|
56
|
+
const probe = runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Add-Type -AssemblyName System.Security; "ok"'])
|
|
57
|
+
return probe.ok ? { backend: 'dpapi', available: true } : { backend: 'unavailable', available: false, reason: 'dpapi-probe-failed' }
|
|
58
|
+
}
|
|
59
|
+
if (platform === 'linux') {
|
|
60
|
+
const probe = runner('secret-tool', ['--version'])
|
|
61
|
+
return probe.ok ? { backend: 'secret-service', available: true } : { backend: 'unavailable', available: false, reason: 'secret-tool-missing' }
|
|
62
|
+
}
|
|
63
|
+
return { backend: 'unavailable', available: false, reason: platform === 'darwin' ? 'keychain-not-implemented' : 'platform-unsupported' }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {{dataDir:string, platform?:string, runner?:Function, service?:string, logger?:object}} options
|
|
68
|
+
*/
|
|
69
|
+
export function createAiSecretStore(options = {}) {
|
|
70
|
+
const dataDir = String(options.dataDir ?? '')
|
|
71
|
+
if (!dataDir) throw new Error('createAiSecretStore: dataDir required')
|
|
72
|
+
const platform = options.platform ?? process.platform
|
|
73
|
+
const runner = typeof options.runner === 'function' ? options.runner : defaultRunner
|
|
74
|
+
const service = String(options.service ?? DEFAULT_SERVICE)
|
|
75
|
+
const log = (level, message) => { try { options.logger?.[level]?.(`[ai-secret-store] ${message}`) } catch { /* ignore */ } }
|
|
76
|
+
const metaFile = path.join(dataDir, SECRET_META_FILE)
|
|
77
|
+
const blobFile = path.join(dataDir, SECRET_BLOB_FILE)
|
|
78
|
+
// 懒探测:只有真正需要持久化/读取能力时才执行后端探测(测试/未配置 AI 时零开销)。
|
|
79
|
+
let detection = null
|
|
80
|
+
const ensureBackend = () => {
|
|
81
|
+
if (detection === null) detection = detectSecretBackend({ platform, runner })
|
|
82
|
+
return detection
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const readJson = (file) => {
|
|
86
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
|
|
87
|
+
}
|
|
88
|
+
const removeIfExists = (file) => { try { if (fs.existsSync(file)) fs.unlinkSync(file) } catch { /* ignore */ } }
|
|
89
|
+
|
|
90
|
+
function encrypt(secretJson) {
|
|
91
|
+
const detection = ensureBackend()
|
|
92
|
+
const plain = Buffer.from(secretJson, 'utf8').toString('base64')
|
|
93
|
+
if (detection.backend === 'dpapi') {
|
|
94
|
+
const out = runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', PS_PROTECT], plain)
|
|
95
|
+
if (!out.ok || !out.stdout) return { ok: false, reason: 'dpapi-protect-failed', detail: out.stderr || out.error }
|
|
96
|
+
return { ok: true, payload: out.stdout }
|
|
97
|
+
}
|
|
98
|
+
if (detection.backend === 'secret-service') {
|
|
99
|
+
const out = runner('secret-tool', ['store', '--label', 'dsh-fang decision AI key', 'service', service, 'slot', 'api-key'], secretJson)
|
|
100
|
+
if (!out.ok) return { ok: false, reason: 'secret-tool-store-failed', detail: out.stderr || out.error }
|
|
101
|
+
return { ok: true, payload: '' }
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, reason: 'secret-backend-unavailable' }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function decrypt(payload) {
|
|
107
|
+
const detection = ensureBackend()
|
|
108
|
+
if (detection.backend === 'dpapi') {
|
|
109
|
+
const out = runner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', PS_UNPROTECT], String(payload ?? ''))
|
|
110
|
+
if (!out.ok || !out.stdout) return { ok: false, reason: 'dpapi-unprotect-failed', detail: out.stderr || out.error }
|
|
111
|
+
try { return { ok: true, secretJson: Buffer.from(out.stdout, 'base64').toString('utf8') } } catch { return { ok: false, reason: 'dpapi-decode-failed' } }
|
|
112
|
+
}
|
|
113
|
+
if (detection.backend === 'secret-service') {
|
|
114
|
+
const out = runner('secret-tool', ['lookup', 'service', service, 'slot', 'api-key'])
|
|
115
|
+
if (!out.ok || !out.stdout) return { ok: false, reason: 'secret-tool-lookup-failed', detail: out.stderr || out.error }
|
|
116
|
+
return { ok: true, secretJson: out.stdout }
|
|
117
|
+
}
|
|
118
|
+
return { ok: false, reason: 'secret-backend-unavailable' }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
get backend() { return ensureBackend().backend },
|
|
123
|
+
get available() { return ensureBackend().available === true },
|
|
124
|
+
get reason() { return ensureBackend().reason ?? null },
|
|
125
|
+
service,
|
|
126
|
+
capabilities() {
|
|
127
|
+
const detection = ensureBackend()
|
|
128
|
+
return { backend: detection.backend, available: detection.available === true, reason: detection.reason ?? null, encryptedAtRest: detection.backend === 'dpapi' ? 'dpapi-user' : detection.backend === 'secret-service' ? 'os-secret-service' : 'none' }
|
|
129
|
+
},
|
|
130
|
+
/** 保存 { provider, model, baseUrl, apiKey };失败返回 ok:false,调用方保持仅内存。 */
|
|
131
|
+
save(config = {}) {
|
|
132
|
+
const detection = ensureBackend()
|
|
133
|
+
if (!detection.available) return { ok: false, reason: detection.reason ?? 'secret-backend-unavailable' }
|
|
134
|
+
const apiKey = String(config.apiKey ?? '').trim()
|
|
135
|
+
if (!apiKey) return { ok: false, reason: 'api-key-required' }
|
|
136
|
+
const meta = {
|
|
137
|
+
version: 1,
|
|
138
|
+
backend: detection.backend,
|
|
139
|
+
provider: String(config.provider ?? '').slice(0, 40),
|
|
140
|
+
model: String(config.model ?? '').slice(0, 120),
|
|
141
|
+
baseUrl: String(config.baseUrl ?? '').slice(0, 500),
|
|
142
|
+
updatedAt: Date.now(),
|
|
143
|
+
}
|
|
144
|
+
const encrypted = encrypt(JSON.stringify({
|
|
145
|
+
apiKey,
|
|
146
|
+
...(Array.isArray(config.profiles) ? { profiles: config.profiles } : {}),
|
|
147
|
+
...(config.routes && typeof config.routes === 'object' ? { routes: config.routes } : {}),
|
|
148
|
+
...(config.modelByLevel && typeof config.modelByLevel === 'object' ? { modelByLevel: config.modelByLevel } : {}),
|
|
149
|
+
}))
|
|
150
|
+
if (!encrypted.ok) return encrypted
|
|
151
|
+
try {
|
|
152
|
+
ensureDir(dataDir)
|
|
153
|
+
if (encrypted.payload) {
|
|
154
|
+
fs.writeFileSync(blobFile, encrypted.payload, { encoding: 'utf8', mode: 0o600 })
|
|
155
|
+
try { fs.chmodSync(blobFile, 0o600) } catch { /* windows */ }
|
|
156
|
+
}
|
|
157
|
+
writeJsonAtomic(metaFile, meta)
|
|
158
|
+
return { ok: true, backend: detection.backend, meta }
|
|
159
|
+
} catch (error) {
|
|
160
|
+
log('warn', `save failed: ${String(error?.message ?? error)}`)
|
|
161
|
+
return { ok: false, reason: 'persist-failed', detail: String(error?.message ?? error) }
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
/** 读取已持久化配置;不存在/backend 不匹配/解密失败 => null(不抛)。 */
|
|
165
|
+
load() {
|
|
166
|
+
// 没有落盘元数据时不要探测 backend(避免无 AI 配置的宿主每次启动都拉起 PowerShell)。
|
|
167
|
+
if (!fs.existsSync(metaFile)) return null
|
|
168
|
+
const detection = ensureBackend()
|
|
169
|
+
if (!detection.available) return null
|
|
170
|
+
const meta = readJson(metaFile)
|
|
171
|
+
if (!meta || meta.backend !== detection.backend) return null
|
|
172
|
+
let payload = ''
|
|
173
|
+
try { payload = fs.existsSync(blobFile) ? fs.readFileSync(blobFile, 'utf8') : '' } catch { payload = '' }
|
|
174
|
+
if (detection.backend === 'dpapi' && !payload) return null
|
|
175
|
+
const decrypted = decrypt(payload)
|
|
176
|
+
if (!decrypted.ok) { log('warn', `load failed: ${decrypted.reason}`); return null }
|
|
177
|
+
let secret = null
|
|
178
|
+
try { secret = JSON.parse(decrypted.secretJson) } catch { return null }
|
|
179
|
+
const apiKey = String(secret?.apiKey ?? '').trim()
|
|
180
|
+
if (!apiKey) return null
|
|
181
|
+
return {
|
|
182
|
+
provider: String(meta.provider ?? 'custom'),
|
|
183
|
+
model: String(meta.model ?? ''),
|
|
184
|
+
baseUrl: String(meta.baseUrl ?? ''),
|
|
185
|
+
apiKey,
|
|
186
|
+
...(Array.isArray(secret.profiles) ? { profiles: secret.profiles } : {}),
|
|
187
|
+
...(secret.routes && typeof secret.routes === 'object' ? { routes: secret.routes } : {}),
|
|
188
|
+
...(secret.modelByLevel && typeof secret.modelByLevel === 'object' ? { modelByLevel: secret.modelByLevel } : {}),
|
|
189
|
+
source: `os:${detection.backend}`,
|
|
190
|
+
backend: detection.backend,
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
clear() {
|
|
194
|
+
removeIfExists(metaFile)
|
|
195
|
+
removeIfExists(blobFile)
|
|
196
|
+
if (ensureBackend().backend === 'secret-service') {
|
|
197
|
+
try { runner('secret-tool', ['clear', 'service', service, 'slot', 'api-key']) } catch { /* ignore */ }
|
|
198
|
+
}
|
|
199
|
+
return { ok: true }
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
//
|
|
4
|
+
// decision-outbox — fork 侧的「向母体提交 decision」耐久 outbox(P4 地基)。
|
|
5
|
+
//
|
|
6
|
+
// 方向:fork → mother 走 HTTP ingest,不走 adapter(adapter 是 mother → fork 控制通道)。
|
|
7
|
+
// 母体地址/token 从 DSH_FANG_MOTHER 指向的母体 dataDir 读取:
|
|
8
|
+
// <motherDataDir>/endpoint.json { url }
|
|
9
|
+
// <motherDataDir>/fang-auth.json { token }(0600)
|
|
10
|
+
//
|
|
11
|
+
// 现状:库本身可用,但还没有接进 fork 的 Agent tool / umbrella ASK;未接 = 不算接线。
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { randomUUID } from 'node:crypto'
|
|
15
|
+
|
|
16
|
+
import { ensureDir, readJson, writeJsonAtomic } from '../kernel.mjs'
|
|
17
|
+
|
|
18
|
+
export const OUTBOX_DIRNAME = 'decision-outbox'
|
|
19
|
+
|
|
20
|
+
export function resolveMotherTarget(motherDataDir) {
|
|
21
|
+
const dir = String(motherDataDir ?? '').trim()
|
|
22
|
+
if (!dir) return { ok: false, reason: 'mother-data-dir-required' }
|
|
23
|
+
const endpoint = readJson(path.join(dir, 'endpoint.json'), null)
|
|
24
|
+
const auth = readJson(path.join(dir, 'fang-auth.json'), null)
|
|
25
|
+
const url = String(endpoint?.url ?? '').replace(/\/+$/, '')
|
|
26
|
+
const token = String(auth?.token ?? '')
|
|
27
|
+
if (!url || !token) return { ok: false, reason: 'mother-endpoint-unavailable', url, hasToken: Boolean(token) }
|
|
28
|
+
return { ok: true, url, token }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {{dataDir:string, fetchImpl?:Function, logger?:object, now?:()=>number}} options
|
|
33
|
+
*/
|
|
34
|
+
export function createDecisionOutbox(options = {}) {
|
|
35
|
+
const dataDir = String(options.dataDir ?? '')
|
|
36
|
+
if (!dataDir) throw new Error('createDecisionOutbox: dataDir required')
|
|
37
|
+
const dir = path.join(dataDir, OUTBOX_DIRNAME)
|
|
38
|
+
const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch
|
|
39
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
40
|
+
const log = (level, message) => { try { options.logger?.[level]?.(`[decision-outbox] ${message}`) } catch { /* ignore */ } }
|
|
41
|
+
const fileOf = (id) => path.join(dir, `${id}.json`)
|
|
42
|
+
const listIds = () => {
|
|
43
|
+
try { return fs.readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => name.slice(0, -5)).sort() } catch { return [] }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function enqueue(payload) {
|
|
47
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new TypeError('enqueue: payload object required')
|
|
48
|
+
ensureDir(dir)
|
|
49
|
+
const id = `out-${now()}-${randomUUID()}`
|
|
50
|
+
const record = { id, payload, createdAt: now(), attempts: 0, nextAt: 0 }
|
|
51
|
+
writeJsonAtomic(fileOf(id), record)
|
|
52
|
+
return record
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function post(target, payload) {
|
|
56
|
+
const response = await fetchImpl(`${target.url}/fang/api/decisions/ingest`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'content-type': 'application/json', 'x-fang-auth': target.token },
|
|
59
|
+
body: JSON.stringify(payload),
|
|
60
|
+
})
|
|
61
|
+
const text = await response.text().catch(() => '')
|
|
62
|
+
let body = null
|
|
63
|
+
try { body = JSON.parse(text) } catch { body = null }
|
|
64
|
+
return { ok: response.ok, status: response.status, body }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 先直连母体;失败(含母体不可达)就落 outbox,返回 queued:true。 */
|
|
68
|
+
async function submit(payload, { motherDataDir } = {}) {
|
|
69
|
+
const target = resolveMotherTarget(motherDataDir)
|
|
70
|
+
if (!target.ok) {
|
|
71
|
+
const record = enqueue(payload)
|
|
72
|
+
return { ok: true, queued: true, outboxId: record.id, ticket: null, reason: target.reason }
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const result = await post(target, payload)
|
|
76
|
+
if (result.ok) return { ok: true, queued: false, status: result.status, ticket: result.body?.ticket ?? null }
|
|
77
|
+
const record = enqueue(payload)
|
|
78
|
+
record.lastStatus = result.status
|
|
79
|
+
writeJsonAtomic(fileOf(record.id), record)
|
|
80
|
+
return { ok: true, queued: true, outboxId: record.id, ticket: null, reason: `http-${result.status}` }
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const record = enqueue(payload)
|
|
83
|
+
log('warn', `submit queued: ${String(error?.message ?? error)}`)
|
|
84
|
+
return { ok: true, queued: true, outboxId: record.id, ticket: null, reason: 'network-error' }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 重放 outbox;成功即删文件,失败保留并写退避 nextAt。 */
|
|
89
|
+
async function flush({ motherDataDir, limit = 50 } = {}) {
|
|
90
|
+
const target = resolveMotherTarget(motherDataDir)
|
|
91
|
+
const ids = listIds()
|
|
92
|
+
if (!target.ok) return { ok: false, reason: target.reason, sent: 0, failed: 0, remaining: ids.length }
|
|
93
|
+
let sent = 0
|
|
94
|
+
let failed = 0
|
|
95
|
+
for (const id of ids.slice(0, Math.max(0, limit))) {
|
|
96
|
+
const record = readJson(fileOf(id), null)
|
|
97
|
+
if (!record?.payload) { try { fs.unlinkSync(fileOf(id)) } catch { /* ignore */ } continue }
|
|
98
|
+
try {
|
|
99
|
+
const result = await post(target, record.payload)
|
|
100
|
+
if (result.ok) {
|
|
101
|
+
try { fs.unlinkSync(fileOf(id)) } catch { /* ignore */ }
|
|
102
|
+
sent += 1
|
|
103
|
+
} else {
|
|
104
|
+
record.attempts = (Number(record.attempts) || 0) + 1
|
|
105
|
+
record.nextAt = now() + Math.min(60_000, 2 ** record.attempts * 1000)
|
|
106
|
+
record.lastStatus = result.status
|
|
107
|
+
writeJsonAtomic(fileOf(id), record)
|
|
108
|
+
failed += 1
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
record.attempts = (Number(record.attempts) || 0) + 1
|
|
112
|
+
record.nextAt = now() + Math.min(60_000, 2 ** record.attempts * 1000)
|
|
113
|
+
record.lastError = String(error?.message ?? error).slice(0, 300)
|
|
114
|
+
writeJsonAtomic(fileOf(id), record)
|
|
115
|
+
failed += 1
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { ok: true, sent, failed, remaining: listIds().length }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
dir,
|
|
123
|
+
enqueue,
|
|
124
|
+
submit,
|
|
125
|
+
flush,
|
|
126
|
+
pending: () => listIds().length,
|
|
127
|
+
list: () => listIds().map((id) => readJson(fileOf(id), null)).filter(Boolean),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
//
|
|
4
|
+
// producer-contract — 业务生产者 → 决策中心的统一调用契约(v1)。
|
|
5
|
+
//
|
|
6
|
+
// 这五个系统本体不在本仓:本模块只定义「它们该怎么调用」,并提供:
|
|
7
|
+
// * normalizeProducerPayload(input) 字段校验 + 默认值 + 稳定 dedupeKey
|
|
8
|
+
// * createProducerSubmitter({submit}) 统一提交封装(返回统一 envelope)
|
|
9
|
+
// * producerCatalog() 机器可读契约目录(UI/外部系统可发现)
|
|
10
|
+
import { nowMs } from '../kernel.mjs'
|
|
11
|
+
|
|
12
|
+
export const PRODUCER_CONTRACT_VERSION = 1
|
|
13
|
+
export const PRODUCER_LEVELS = Object.freeze(['red', 'yellow', 'blue', 'green'])
|
|
14
|
+
export const PRODUCER_NAMES = Object.freeze(['HarnessMM', 'PluginUpdater', 'SuperAgent', 'PJM', 'Worker'])
|
|
15
|
+
|
|
16
|
+
export const PRODUCER_CONTRACTS = Object.freeze({
|
|
17
|
+
HarnessMM: Object.freeze({
|
|
18
|
+
kinds: ['harnessmm-suggestion'],
|
|
19
|
+
defaultLevel: 'blue',
|
|
20
|
+
sourceRole: 'worker',
|
|
21
|
+
required: ['title'],
|
|
22
|
+
defaultActions: [
|
|
23
|
+
{ id: 'accept', label: '采纳', type: 'default' },
|
|
24
|
+
{ id: 'ignore', label: '忽略', type: 'default' },
|
|
25
|
+
],
|
|
26
|
+
}),
|
|
27
|
+
PluginUpdater: Object.freeze({
|
|
28
|
+
kinds: ['plugin-updater-backup'],
|
|
29
|
+
defaultLevel: 'green',
|
|
30
|
+
sourceRole: 'worker',
|
|
31
|
+
required: ['title'],
|
|
32
|
+
defaultActions: [],
|
|
33
|
+
}),
|
|
34
|
+
SuperAgent: Object.freeze({
|
|
35
|
+
kinds: ['superagent-completion'],
|
|
36
|
+
defaultLevel: 'green',
|
|
37
|
+
sourceRole: 'worker',
|
|
38
|
+
required: ['title'],
|
|
39
|
+
defaultActions: [],
|
|
40
|
+
}),
|
|
41
|
+
PJM: Object.freeze({
|
|
42
|
+
kinds: ['pjm-timeout', 'pjm-fatal'],
|
|
43
|
+
defaultLevel: 'yellow',
|
|
44
|
+
sourceRole: 'pjm',
|
|
45
|
+
required: ['title'],
|
|
46
|
+
defaultActions: [
|
|
47
|
+
{ id: 'retry', label: '重试', type: 'primary' },
|
|
48
|
+
{ id: 'abandon', label: '放弃', type: 'default' },
|
|
49
|
+
],
|
|
50
|
+
}),
|
|
51
|
+
Worker: Object.freeze({
|
|
52
|
+
kinds: ['worker-deviation'],
|
|
53
|
+
defaultLevel: 'yellow',
|
|
54
|
+
sourceRole: 'worker',
|
|
55
|
+
required: ['title'],
|
|
56
|
+
defaultActions: [
|
|
57
|
+
{ id: 'review', label: '查看', type: 'primary' },
|
|
58
|
+
{ id: 'ignore', label: '忽略', type: 'default' },
|
|
59
|
+
],
|
|
60
|
+
}),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const fail = (code, extra = {}) => ({ ok: false, code, ...extra })
|
|
64
|
+
|
|
65
|
+
/** 稳定默认 dedupeKey:调用方显式传优先,否则用常见业务 id 派生。 */
|
|
66
|
+
function deriveDedupeKey(producer, kind, raw) {
|
|
67
|
+
if (raw.dedupeKey !== undefined && raw.dedupeKey !== null && String(raw.dedupeKey).trim()) return String(raw.dedupeKey).trim().slice(0, 200)
|
|
68
|
+
const ref = raw.id ?? raw.backupId ?? raw.suggestionId ?? raw.taskId ?? raw.projectId ?? raw.workerId
|
|
69
|
+
if (ref === undefined || ref === null || String(ref).trim() === '') return undefined
|
|
70
|
+
return `${producer}:${kind}:${String(ref).trim()}`.slice(0, 200)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 校验并补全统一 payload。成功返回 { ok:true, payload };失败返回 { ok:false, code, errors }。
|
|
75
|
+
* 不写盘、不提交。
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeProducerPayload(input = {}) {
|
|
78
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return fail('invalid-payload')
|
|
79
|
+
const producer = String(input.producer ?? '').trim()
|
|
80
|
+
const contract = PRODUCER_CONTRACTS[producer]
|
|
81
|
+
if (!contract) return fail('unknown-producer', { allowedProducers: [...PRODUCER_NAMES] })
|
|
82
|
+
const kind = String(input.kind ?? contract.kinds[0]).trim()
|
|
83
|
+
if (!contract.kinds.includes(kind)) {
|
|
84
|
+
return fail('unknown-kind', { producer, allowedKinds: [...contract.kinds] })
|
|
85
|
+
}
|
|
86
|
+
const level = input.level === undefined || input.level === null ? contract.defaultLevel : String(input.level).trim().toLowerCase()
|
|
87
|
+
if (!PRODUCER_LEVELS.includes(level)) return fail('bad-level', { allowedLevels: [...PRODUCER_LEVELS] })
|
|
88
|
+
const title = String(input.title ?? '').trim()
|
|
89
|
+
if (!title) return fail('title-required')
|
|
90
|
+
const actions = Array.isArray(input.actions) ? input.actions.slice(0, 12)
|
|
91
|
+
: (Array.isArray(contract.defaultActions) && contract.defaultActions.length > 0 ? contract.defaultActions.map((item) => ({ ...item })) : undefined)
|
|
92
|
+
const options = Array.isArray(input.options) ? input.options.slice(0, 12) : undefined
|
|
93
|
+
if (level !== 'green' && !(actions?.length) && !(options?.length)) {
|
|
94
|
+
return fail('options-or-actions-required', { level })
|
|
95
|
+
}
|
|
96
|
+
const timeoutMinutes = Number.isFinite(Number(input.timeoutMinutes))
|
|
97
|
+
? Math.max(0, Number(input.timeoutMinutes))
|
|
98
|
+
: kind === 'pjm-timeout' ? 5 : undefined
|
|
99
|
+
const payload = {
|
|
100
|
+
producer,
|
|
101
|
+
kind,
|
|
102
|
+
level,
|
|
103
|
+
title: title.slice(0, 500),
|
|
104
|
+
context: String(input.context ?? input.description ?? `${producer}/${kind}`).slice(0, 12000),
|
|
105
|
+
...(actions ? { actions } : {}),
|
|
106
|
+
...(options ? { options } : {}),
|
|
107
|
+
...(timeoutMinutes === undefined ? {} : { timeoutMinutes }),
|
|
108
|
+
...(deriveDedupeKey(producer, kind, input) ? { dedupeKey: deriveDedupeKey(producer, kind, input) } : {}),
|
|
109
|
+
sourceRole: String(input.sourceRole ?? contract.sourceRole),
|
|
110
|
+
...(input.sessionId ? { sessionId: String(input.sessionId).slice(0, 200) } : {}),
|
|
111
|
+
...(input.metadata && typeof input.metadata === 'object' ? { metadata: input.metadata } : {}),
|
|
112
|
+
producerContractVersion: PRODUCER_CONTRACT_VERSION,
|
|
113
|
+
at: nowMs(),
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, payload }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 统一提交封装:返回固定 envelope,外部系统不需要理解 decision-center 内部形状。 */
|
|
119
|
+
export function createProducerSubmitter({ submit } = {}) {
|
|
120
|
+
if (typeof submit !== 'function') throw new TypeError('createProducerSubmitter: submit required')
|
|
121
|
+
return async function submitProducerEvent(input = {}) {
|
|
122
|
+
const normalized = normalizeProducerPayload(input)
|
|
123
|
+
if (!normalized.ok) {
|
|
124
|
+
return { ok: false, code: normalized.code, errors: normalized.errors ?? null, ...normalized }
|
|
125
|
+
}
|
|
126
|
+
let out = null
|
|
127
|
+
try {
|
|
128
|
+
out = await submit(normalized.payload)
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return { ok: false, code: 'submit-failed', error: String(error?.message ?? error).slice(0, 500) }
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
ok: out?.ok !== false,
|
|
134
|
+
producer: normalized.payload.producer,
|
|
135
|
+
kind: normalized.payload.kind,
|
|
136
|
+
level: normalized.payload.level,
|
|
137
|
+
ticketId: out?.ticket?.id ?? null,
|
|
138
|
+
deduped: out?.deduped === true,
|
|
139
|
+
queued: out?.queued === true,
|
|
140
|
+
outboxId: out?.outboxId ?? null,
|
|
141
|
+
reason: out?.reason ?? null,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function producerCatalog() {
|
|
147
|
+
return {
|
|
148
|
+
ok: true,
|
|
149
|
+
version: PRODUCER_CONTRACT_VERSION,
|
|
150
|
+
producers: PRODUCER_NAMES.map((producer) => {
|
|
151
|
+
const contract = PRODUCER_CONTRACTS[producer]
|
|
152
|
+
return {
|
|
153
|
+
producer,
|
|
154
|
+
kinds: [...contract.kinds],
|
|
155
|
+
defaultLevel: contract.defaultLevel,
|
|
156
|
+
sourceRole: contract.sourceRole,
|
|
157
|
+
required: [...contract.required],
|
|
158
|
+
payload: ['producer', 'kind', 'level?', 'title', 'context', 'actions|options', 'timeoutMinutes?', 'dedupeKey?', 'sourceRole?', 'sessionId?'],
|
|
159
|
+
}
|
|
160
|
+
}),
|
|
161
|
+
}
|
|
162
|
+
}
|