@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.
- 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/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/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 +579 -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 +3938 -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/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +408 -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,212 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// dsh-Fang v3.0.8 — schema 驱动的镜像偏好存储(零依赖)。
|
|
3
|
+
//
|
|
4
|
+
// 一个字段 = 一条 schema 描述符(kind/default/min/max/step/options/migrate);
|
|
5
|
+
// PreferencesStore 只做通用的“读文件 → 逐字段迁移+校验 → 补默认值 → 需要时写回”。
|
|
6
|
+
// 新增字段只改 PREFERENCES_SCHEMA;UI 可基于 schema 声明式渲染。
|
|
7
|
+
//
|
|
8
|
+
// 向后兼容:
|
|
9
|
+
// * 旧文件缺失的新字段自动补默认值并写回;
|
|
10
|
+
// * 旧值经 migrate/coerce 修正后写回,并报告 migrated 字段;
|
|
11
|
+
// * 未知字段被丢弃并报告 droppedUnknown;
|
|
12
|
+
// * 损坏 JSON 不覆盖原文件(保留证据),返回默认值。
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
|
|
17
|
+
import { ensureDir, readJson, writeJsonAtomic } from '../kernel/index.mjs'
|
|
18
|
+
/** @typedef {{kind:'number', defaultValue:number, min:number, max:number, step:number, unit?:string, migrate?:Function}} NumberField */
|
|
19
|
+
/** @typedef {{kind:'enum', defaultValue:string, options:Array<{value:string,label:string}>, migrate?:Function}} EnumField */
|
|
20
|
+
/** @typedef {{kind:'boolean', defaultValue:boolean, migrate?:Function}} BooleanField */
|
|
21
|
+
|
|
22
|
+
export const PREFERENCES_SCHEMA = Object.freeze({
|
|
23
|
+
activeIdleTimeoutMs: Object.freeze({
|
|
24
|
+
kind: 'number', label: '对话框交互超时', unit: 'ms',
|
|
25
|
+
description: '无交互超过此时长后降低状态轮询频率',
|
|
26
|
+
defaultValue: 30_000, min: 5_000, max: 300_000, step: 5_000,
|
|
27
|
+
// 旧版本只接受 15/30/60s;其他值视为无效 → 用默认值
|
|
28
|
+
migrate: (raw) => ([15_000, 30_000, 60_000].includes(raw) ? raw : undefined),
|
|
29
|
+
}),
|
|
30
|
+
pollIntervalMs: Object.freeze({
|
|
31
|
+
kind: 'number', label: '活跃轮询间隔', unit: 'ms',
|
|
32
|
+
description: '对话框打开时的状态刷新频率',
|
|
33
|
+
defaultValue: 5_000, min: 1_000, max: 60_000, step: 1_000,
|
|
34
|
+
}),
|
|
35
|
+
idlePollIntervalMs: Object.freeze({
|
|
36
|
+
kind: 'number', label: '空闲轮询间隔', unit: 'ms',
|
|
37
|
+
description: '无交互时的状态刷新频率',
|
|
38
|
+
defaultValue: 60_000, min: 30_000, max: 600_000, step: 10_000,
|
|
39
|
+
}),
|
|
40
|
+
backupRetentionDays: Object.freeze({
|
|
41
|
+
kind: 'number', label: '备份保留天数', unit: 'day',
|
|
42
|
+
description: '超过此天数的自动备份会被清理;手动恢复点不受影响',
|
|
43
|
+
defaultValue: 30, min: 1, max: 365, step: 1,
|
|
44
|
+
}),
|
|
45
|
+
idleDowngradeNotification: Object.freeze({
|
|
46
|
+
kind: 'enum', label: '空闲降级提示',
|
|
47
|
+
description: '用户离开后是否显示提示',
|
|
48
|
+
defaultValue: 'silent',
|
|
49
|
+
options: Object.freeze([
|
|
50
|
+
{ value: 'silent', label: '静默' },
|
|
51
|
+
{ value: 'toast', label: '轻提示' },
|
|
52
|
+
{ value: 'banner', label: '横幅' },
|
|
53
|
+
]),
|
|
54
|
+
}),
|
|
55
|
+
strictConflictMode: Object.freeze({
|
|
56
|
+
kind: 'boolean', label: '严格冲突模式',
|
|
57
|
+
description: '开启后任何原身冲突都会阻止派生,而不是自动跳过',
|
|
58
|
+
defaultValue: false,
|
|
59
|
+
}),
|
|
60
|
+
syncIntervalHours: Object.freeze({
|
|
61
|
+
kind: 'enum', label: '母体自动同步间隔',
|
|
62
|
+
description: '每隔多久从本机母体 profile 克隆到镜像(off = 关闭;同步前自动备份)',
|
|
63
|
+
defaultValue: 'off',
|
|
64
|
+
options: Object.freeze([
|
|
65
|
+
{ value: 'off', label: '关闭' },
|
|
66
|
+
{ value: '6', label: '6 小时' },
|
|
67
|
+
{ value: '12', label: '12 小时' },
|
|
68
|
+
{ value: '24', label: '24 小时' },
|
|
69
|
+
]),
|
|
70
|
+
}),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export const PREFERENCES_KEYS = Object.freeze(Object.keys(PREFERENCES_SCHEMA))
|
|
74
|
+
|
|
75
|
+
/** 由 schema 生成默认偏好(新字段自动出现在这里)。 */
|
|
76
|
+
export function buildDefaultPreferences(schema = PREFERENCES_SCHEMA) {
|
|
77
|
+
const out = {}
|
|
78
|
+
for (const [key, field] of Object.entries(schema)) out[key] = field.defaultValue
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 校验/对齐单个字段;非法返回 undefined。
|
|
84
|
+
* @param {NumberField|EnumField|BooleanField} field
|
|
85
|
+
* @param {unknown} value
|
|
86
|
+
*/
|
|
87
|
+
export function coerceField(field, value) {
|
|
88
|
+
if (field.kind === 'number') {
|
|
89
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
|
90
|
+
if (value < field.min || value > field.max) return undefined
|
|
91
|
+
return Math.round((value - field.min) / field.step) * field.step + field.min
|
|
92
|
+
}
|
|
93
|
+
if (field.kind === 'enum') {
|
|
94
|
+
if (typeof value !== 'string') return undefined
|
|
95
|
+
return field.options.some(option => option.value === value) ? value : undefined
|
|
96
|
+
}
|
|
97
|
+
if (field.kind === 'boolean') return typeof value === 'boolean' ? value : undefined
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 纯函数:把任意 raw 规范成完整偏好。测试可直接调用。
|
|
103
|
+
* @param {unknown} raw
|
|
104
|
+
* @param {Record<string, NumberField|EnumField|BooleanField>} [schema]
|
|
105
|
+
* @returns {{preferences:Record<string, unknown>, existed:boolean, migrated:string[], droppedUnknown:string[]}}
|
|
106
|
+
*/
|
|
107
|
+
export function normalizePreferences(raw, schema = PREFERENCES_SCHEMA) {
|
|
108
|
+
const preferences = buildDefaultPreferences(schema)
|
|
109
|
+
const migrated = []
|
|
110
|
+
const droppedUnknown = []
|
|
111
|
+
const missing = []
|
|
112
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
113
|
+
return { preferences, existed: false, migrated, droppedUnknown, missing: Object.keys(schema) }
|
|
114
|
+
}
|
|
115
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
116
|
+
const field = schema[key]
|
|
117
|
+
if (field === undefined) { droppedUnknown.push(key); continue }
|
|
118
|
+
let next = value
|
|
119
|
+
if (typeof field.migrate === 'function') {
|
|
120
|
+
next = field.migrate(value)
|
|
121
|
+
if (next === undefined) { migrated.push(key); continue }
|
|
122
|
+
}
|
|
123
|
+
const coerced = coerceField(field, next)
|
|
124
|
+
if (coerced === undefined) { migrated.push(key); continue }
|
|
125
|
+
preferences[key] = coerced
|
|
126
|
+
if (coerced !== value) migrated.push(key)
|
|
127
|
+
}
|
|
128
|
+
for (const key of Object.keys(schema)) {
|
|
129
|
+
if (!(key in raw)) missing.push(key)
|
|
130
|
+
}
|
|
131
|
+
return { preferences, existed: true, migrated, droppedUnknown, missing }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export class PreferencesStore {
|
|
135
|
+
/**
|
|
136
|
+
* @param {{mirrorRoot: string, logger?: {warn?: Function},
|
|
137
|
+
* schema?: Record<string, NumberField|EnumField|BooleanField>}} options
|
|
138
|
+
*/
|
|
139
|
+
constructor(options) {
|
|
140
|
+
this.mirrorRoot = String(options?.mirrorRoot ?? '')
|
|
141
|
+
if (this.mirrorRoot.length === 0) throw new Error('PreferencesStore: mirrorRoot required')
|
|
142
|
+
this.logger = options.logger ?? {}
|
|
143
|
+
this.schema = options.schema ?? PREFERENCES_SCHEMA
|
|
144
|
+
this.current = buildDefaultPreferences(this.schema)
|
|
145
|
+
this.loaded = false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
filePath() {
|
|
149
|
+
return path.join(this.mirrorRoot, 'preferences.json')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 读取并规范偏好;缺失/迁移时写回;损坏文件保持原样(保留证据)。 */
|
|
153
|
+
load() {
|
|
154
|
+
this.loaded = true
|
|
155
|
+
const file = this.filePath()
|
|
156
|
+
const existed = fs.existsSync(file)
|
|
157
|
+
let raw = null
|
|
158
|
+
let corrupt = false
|
|
159
|
+
if (existed) {
|
|
160
|
+
try { raw = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { corrupt = true }
|
|
161
|
+
}
|
|
162
|
+
const report = normalizePreferences(raw, this.schema)
|
|
163
|
+
this.current = report.preferences
|
|
164
|
+
if (corrupt) {
|
|
165
|
+
this.logger.warn?.('[preferences] corrupt preferences.json; using defaults without overwrite')
|
|
166
|
+
return this.get()
|
|
167
|
+
}
|
|
168
|
+
const shouldPersist = !existed || report.migrated.length > 0
|
|
169
|
+
|| report.droppedUnknown.length > 0 || report.missing.length > 0
|
|
170
|
+
if (shouldPersist) this.persist()
|
|
171
|
+
if (report.migrated.length > 0 || report.droppedUnknown.length > 0) {
|
|
172
|
+
this.logger.warn?.('[preferences] normalized', {
|
|
173
|
+
migrated: report.migrated, droppedUnknown: report.droppedUnknown,
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
return this.get()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 最近一次 load 的迁移报告(供诊断/测试)。 */
|
|
180
|
+
lastReport() {
|
|
181
|
+
return { migrated: [], droppedUnknown: [], existed: true }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
get() {
|
|
185
|
+
return { ...this.current }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 增量更新:按 schema 校验,非法值抛错且不改内存状态;未知字段忽略。
|
|
190
|
+
* @param {Record<string, unknown>} patch
|
|
191
|
+
*/
|
|
192
|
+
update(patch = {}) {
|
|
193
|
+
const next = { ...this.current }
|
|
194
|
+
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
195
|
+
const field = this.schema[key]
|
|
196
|
+
if (field === undefined) continue
|
|
197
|
+
const coerced = coerceField(field, value)
|
|
198
|
+
if (coerced === undefined) {
|
|
199
|
+
throw new RangeError(`invalid value for ${key}: ${JSON.stringify(value)}`)
|
|
200
|
+
}
|
|
201
|
+
next[key] = coerced
|
|
202
|
+
}
|
|
203
|
+
this.current = next
|
|
204
|
+
this.persist()
|
|
205
|
+
return this.get()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
persist() {
|
|
209
|
+
ensureDir(this.mirrorRoot)
|
|
210
|
+
writeJsonAtomic(this.filePath(), this.get())
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -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/index.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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// v3.0.8 reflow: seal → verify → quarantine → adopt. No merge, no config/credential upload.
|
|
3
|
+
export const REFLOW_STATES = Object.freeze(['sealed', 'verified', 'quarantine', 'adopted'])
|
|
4
|
+
|
|
5
|
+
const NEXT = {
|
|
6
|
+
sealed: 'verified', // seal → verify
|
|
7
|
+
verified: 'adopted', // verify → adopt
|
|
8
|
+
quarantine: 'adopted', // only after explicit quarantine resolution
|
|
9
|
+
adopted: null,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const FORBIDDEN_KEYS = new Set(['token', 'password', 'secret', 'credential', 'auth', 'cookie', 'sessionkey', 'apikey'])
|
|
13
|
+
|
|
14
|
+
export function transition(current, event) {
|
|
15
|
+
if (event === 'quarantine') {
|
|
16
|
+
if (current !== 'verified') return { ok: false, error: 'invalid-transition' }
|
|
17
|
+
return { ok: true, state: 'quarantine' }
|
|
18
|
+
}
|
|
19
|
+
if (event === 'adopt' && current === 'verified') return { ok: true, state: 'adopted' }
|
|
20
|
+
if (event === 'recover' && current === 'quarantine') return { ok: true, state: 'verified' }
|
|
21
|
+
return { ok: false, error: 'invalid-transition' }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function assertSafeArtifact(artifact) {
|
|
25
|
+
const walk = (value) => {
|
|
26
|
+
if (Array.isArray(value)) return value.forEach(walk)
|
|
27
|
+
if (!value || typeof value !== 'object') return
|
|
28
|
+
for (const [key, child] of Object.entries(value)) {
|
|
29
|
+
if (FORBIDDEN_KEYS.has(String(key).toLowerCase())) throw new Error('reflow-forbidden-field:' + key)
|
|
30
|
+
walk(child)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
walk(artifact)
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function canAdopt(state, artifact) {
|
|
38
|
+
if (state !== 'verified') return { ok: false, error: 'not-verified' }
|
|
39
|
+
try { assertSafeArtifact(artifact) } catch (error) { return { ok: false, error: String(error.message) } }
|
|
40
|
+
return { ok: true }
|
|
41
|
+
}
|