dsh-card-dispatch 0.0.0-stage → 0.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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * card-dispatch · 浏览器半(client)
3
+ *
4
+ * 只做一件事:在左侧边栏加一行「派卡台」,点开后在主区域用 **iframe** 嵌入控制台
5
+ * (`/api/card-dispatch/`,由宿主半的 human-channel 提供)。
6
+ *
7
+ * 为什么用 iframe 而不是把整页重写成 React 组件:
8
+ * 1. 控制台是一份完全自包含的 HTML(`src/console.html`),浏览器里直接打开也能用,
9
+ * 桌面端只是把它塞进侧边栏——同一份实现,不会出现「面板和网页行为不一致」;
10
+ * 2. 页面里的 `fetch` 与 GUI 同源,宿主签发的连接 cookie 自动带上,不需要再搭 RPC 通道;
11
+ * 3. 客户端模块加载失败也不会连累台账:这个文件只负责注册座位,所有异常都吞掉并打日志。
12
+ *
13
+ * 与 dsh-go-workbench 相同的注册方式(本机已验证可用的写法):
14
+ * `slots.inject('sidebar.panellist', …)` 加一行;`slots.inject('main', …)` 用我们的 key
15
+ * 挂整页面板。座位由 shell 声明,回调缺席时面板只是不出现,不会让 GUI 启动失败。
16
+ *
17
+ * @module dsh-card-dispatch/client
18
+ */
19
+
20
+ 'use strict'
21
+
22
+ ;(function () {
23
+ /** 控制台路径(与宿主半 HUMAN_ROUTE_PREFIX 一致)。 */
24
+ var CONSOLE_PATH = '/api/card-dispatch/'
25
+ var PANEL_ID = 'card-dispatch'
26
+ var PANEL_ORDER = 42
27
+
28
+ /** 是否运行在 DSH 客户端模块加载器里(false = Node 单测环境)。 */
29
+ var IS_BROWSER =
30
+ typeof window !== 'undefined' &&
31
+ window.__ModuleLoader__ !== undefined &&
32
+ typeof window.__ModuleLoader__.load === 'function'
33
+
34
+ /**
35
+ * 构建客户端模块(`require` 由加载器注入,React 从浏览器模块表取)。
36
+ * @param {(id: string) => any} require 模块加载器提供的 require。
37
+ * @returns {object} 客户端插件模块。
38
+ */
39
+ function createCardDispatchModule(require) {
40
+ var React = require('react')
41
+ var h = React.createElement
42
+
43
+ /** 侧边栏图标:一个「流水线 + 勾」的简化线稿,跟着主题文字色走。 */
44
+ function PanelIcon() {
45
+ return h(
46
+ 'svg',
47
+ { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
48
+ strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' },
49
+ h('rect', { x: 3, y: 4, width: 7, height: 5, rx: 1.5 }),
50
+ h('rect', { x: 14, y: 4, width: 7, height: 5, rx: 1.5 }),
51
+ h('path', { d: 'M6.5 9v4.5h11V9' }),
52
+ h('rect', { x: 8.5, y: 13.5, width: 7, height: 5, rx: 1.5 }),
53
+ h('path', { d: 'M10.2 16l1.3 1.3 2.3-2.4' }),
54
+ )
55
+ }
56
+
57
+ /**
58
+ * 整页面板:占满主区域,里面是控制台 iframe。
59
+ * @returns {object} React 元素。
60
+ */
61
+ function Panel() {
62
+ return h(
63
+ 'div',
64
+ { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
65
+ h('iframe', {
66
+ src: CONSOLE_PATH,
67
+ title: '派卡台控制台',
68
+ style: { flex: '1 1 auto', width: '100%', border: 'none', background: 'transparent' },
69
+ }),
70
+ )
71
+ }
72
+
73
+ /**
74
+ * 注册座位。任何一步失败都只打日志:界面缺一行,绝不是台账故障。
75
+ * @param {object} ctx 客户端插件上下文。
76
+ */
77
+ function apply(ctx) {
78
+ try {
79
+ var slots = ctx.slots
80
+ if (!slots || typeof slots.register !== 'function' || typeof slots.inject !== 'function') {
81
+ console.error('[card-dispatch] 客户端缺少 slots 服务,侧边栏入口未注册')
82
+ return
83
+ }
84
+ var disposers = []
85
+ var add = function (dispose) {
86
+ if (typeof dispose === 'function') disposers.push(dispose)
87
+ }
88
+ ctx.effect(function () {
89
+ return function () {
90
+ for (var index = 0; index < disposers.length; index += 1) {
91
+ try {
92
+ disposers[index]()
93
+ } catch (error) {
94
+ // 卸载期的失败没有意义,忽略
95
+ }
96
+ }
97
+ }
98
+ }, 'card-dispatch: client panel')
99
+
100
+ add(
101
+ slots.inject('sidebar.panellist', function () {
102
+ return slots.register(
103
+ {
104
+ name: 'sidebar.panellist',
105
+ id: PANEL_ID,
106
+ order: PANEL_ORDER,
107
+ label: function () {
108
+ return '派卡台'
109
+ },
110
+ },
111
+ PanelIcon,
112
+ )
113
+ }),
114
+ )
115
+ add(
116
+ slots.inject('main', function () {
117
+ return slots.register({ name: 'main', key: PANEL_ID }, Panel)
118
+ }),
119
+ )
120
+ } catch (error) {
121
+ console.error('[card-dispatch] 客户端面板注册失败:', error)
122
+ }
123
+ }
124
+
125
+ return {
126
+ name: PANEL_ID,
127
+ inject: ['slots'],
128
+ apply: apply,
129
+ Panel: Panel,
130
+ PanelIcon: PanelIcon,
131
+ CONSOLE_PATH: CONSOLE_PATH,
132
+ }
133
+ }
134
+
135
+ // 浏览器侧:注册进 DSH 的客户端模块加载器(id 必须等于包名)
136
+ if (IS_BROWSER) {
137
+ window.__ModuleLoader__.load({
138
+ id: 'dsh-card-dispatch',
139
+ factory: function (require) {
140
+ return createCardDispatchModule(require)
141
+ },
142
+ })
143
+ }
144
+
145
+ // Node 侧(单测):把工厂挂到 globalThis——本包是 type:module,CJS 的 module.exports
146
+ // 分支在 ESM 里取不到,所以两种加载方式都给一条路。
147
+ if (!IS_BROWSER) {
148
+ if (typeof globalThis !== 'undefined') {
149
+ globalThis.__CARD_DISPATCH_CLIENT_FACTORY__ = createCardDispatchModule
150
+ }
151
+ if (typeof module !== 'undefined' && module.exports !== undefined) {
152
+ module.exports = { createCardDispatchModule: createCardDispatchModule }
153
+ }
154
+ }
155
+ })()
@@ -0,0 +1,49 @@
1
+ /**
2
+ * config-store.js:面板改的开关落到 `dataDir/config.json`。
3
+ *
4
+ * 三个刻意选择:
5
+ *
6
+ * 1. **落盘的是「归一化后的完整配置」**,不是人提交的那个补丁。这样下次启动时哪怕
7
+ * 缺省值变了,也不会出现「文件里只有半份配置」的中间态(偏差 D-10 的同一个教训)。
8
+ * 2. **读失败一律当空配置**。文件被手改成半截 JSON 时,插件要能起来并让人在面板里
9
+ * 看见「配置读取失败」,而不是整个宿主装不上。
10
+ * 3. **写入不是原子的**,但损坏时可恢复(下次读失败 = 缺省值)。这里没有并发写者:
11
+ * 只有本机人在面板上点,够用;换成原子写在当前沙箱里反而更容易踩到 rename 的坑。
12
+ */
13
+
14
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
15
+ import { join, resolve } from 'node:path'
16
+
17
+ export function createConfigStore({ dir }) {
18
+ const root = resolve(dir)
19
+ const file = join(root, 'config.json')
20
+
21
+ /** @returns {{config: object, error: string|null}} */
22
+ function load() {
23
+ try {
24
+ const text = readFileSync(file, 'utf8')
25
+ const parsed = JSON.parse(text)
26
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
27
+ return { config: {}, error: `配置文件的顶层必须是对象,实际是 ${Array.isArray(parsed) ? 'array' : typeof parsed}` }
28
+ }
29
+ return { config: parsed, error: null }
30
+ } catch (error) {
31
+ if (error?.code === 'ENOENT') return { config: {}, error: null }
32
+ return { config: {}, error: `配置文件读取失败:${error?.message ?? String(error)}` }
33
+ }
34
+ }
35
+
36
+ /** @param {object} config 归一化后的完整配置 */
37
+ function save(config) {
38
+ mkdirSync(root, { recursive: true })
39
+ writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
40
+ return config
41
+ }
42
+
43
+ return { file, load, save }
44
+ }
45
+
46
+ /** 配置的存放位置(面板/日志里展示用)。 */
47
+ export function configFilePath(dir) {
48
+ return join(resolve(dir), 'config.json')
49
+ }
package/src/config.js ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * config.js:插件的**唯一配置真源**。
3
+ *
4
+ * 设计约束(都是踩过的坑):
5
+ *
6
+ * 1. **任何键都可选、任何值都不许让插件装不上**。宿主 profile 的 `cordis.patch.yml`
7
+ * 里可能还留着上一版插件的键(`acknowledgedFindings` / `defaultRole` / `budgetPerDay`…),
8
+ * 按 id 打补丁是**整体替换 config**,少写一个键就会冲掉别的值——所以缺省值必须由
9
+ * 代码兜住,而不是靠 YAML 写全(偏差 D-10)。
10
+ * 2. **只做数值/枚举/布尔的收敛,不做语义校验**。类型不对就退回缺省并记一条 note,
11
+ * 由 `/status` 告诉人哪一项没生效,而不是启动时抛错。
12
+ * 3. 配置分两类,面板和文档都按这个分类展示:
13
+ * - `enforce`:插件能真拦的(开关、预算、并发、证据门槛);
14
+ * - `observe`:平台行为,插件只能体检和提示(委派深度、拓扑审计)。
15
+ */
16
+
17
+ /** 配置项声明表:归一化、面板、文档共用这一份,避免三处写法漂移。 */
18
+ export const CONFIG_SPEC = Object.freeze([
19
+ { path: 'enabled', type: 'boolean', default: false, kind: 'enforce', label: '总开关', help: '关掉后不注册执行能力,只留控制台' },
20
+ { path: 'permission', type: 'enum', values: ['read-only', 'workspace-write', 'danger-full-access', null], default: 'workspace-write', kind: 'enforce', label: '卡片权限预设' },
21
+ { path: 'dataDir', type: 'string', default: '.card-dispatch', kind: 'enforce', label: '数据目录' },
22
+ { path: 'gates.requirePlanApproval', type: 'boolean', default: true, kind: 'enforce', label: '计划闸', help: 'DAG 未经人确认不开会话' },
23
+ { path: 'gates.budgetPerDay', type: 'int', min: 0, max: 100000, default: 200, kind: 'enforce', label: '每日运行预算' },
24
+ { path: 'gates.maxParallelRuns', type: 'int', min: 1, max: 32, default: 3, kind: 'enforce', label: '并发上限' },
25
+ { path: 'gates.maxReworkRounds', type: 'int', min: 0, max: 10, default: 2, kind: 'enforce', label: '打回上限' },
26
+ { path: 'gates.requireEvidence', type: 'boolean', default: true, kind: 'enforce', label: '交付门槛', help: '没有 commit / 报告不允许提交' },
27
+ { path: 'models.useModel', type: 'boolean', default: true, kind: 'enforce', label: '用模型写判据草稿', help: '关掉就只用规则提炼(点一次生成也会花钱)' },
28
+ { path: 'models.draftRoute', type: 'string', default: '', kind: 'enforce', label: '判据草稿用哪个模型', help: 'provider/model;留空=宿主默认' },
29
+ { path: 'org.departments', type: 'stringList', default: ['dev', 'verify', 'integrate'], kind: 'enforce', label: '部门' },
30
+ { path: 'org.depthRequired', type: 'int', min: 0, max: 3, default: 2, kind: 'observe', label: '所需委派深度', help: '主管要带人就得 2;插件改不了,只能体检' },
31
+ { path: 'execution.driver', type: 'enum', values: ['task-board', 'none'], default: 'task-board', kind: 'enforce', label: '执行层驱动' },
32
+ { path: 'execution.teamRun', type: 'boolean', default: true, kind: 'enforce', label: '用 teamRun 卡片', help: '父卡起 Lead,子卡起 teammate' },
33
+ { path: 'audit.orgChart', type: 'boolean', default: true, kind: 'observe', label: '拓扑审计', help: '人员越权在平台上已不可能,这里只做事后稽查' },
34
+ ])
35
+
36
+ /** 旧版顶层键 → 新路径。只在新闻路径都没写时生效。 */
37
+ const LEGACY_ALIASES = Object.freeze({
38
+ budgetPerDay: 'gates.budgetPerDay',
39
+ maxParallelRuns: 'gates.maxParallelRuns',
40
+ })
41
+
42
+ /** 反查表:新路径 → 旧顶层键,供 coerce 用。 */
43
+ const LEGACY_LOOKUP = Object.freeze(
44
+ Object.fromEntries(Object.entries(LEGACY_ALIASES).map(([legacy, path]) => [path, legacy])),
45
+ )
46
+
47
+ /** 同一项配置的降级来源:新路径没写时按顺序回退(旧版嵌套结构)。 */
48
+ const FALLBACK_PATHS = Object.freeze({
49
+ permission: ['autoProvision.permission'],
50
+ })
51
+
52
+ /** 认得但并不等于「没用过」的旧顶层键:它们不该出现在「不认识」清单里。 */
53
+ const LEGACY_TOP = new Set(['budgetPerDay', 'maxParallelRuns', 'autoProvision'])
54
+
55
+ function getPath(source, path) {
56
+ return path.split('.').reduce((node, key) => (node == null ? undefined : node[key]), source)
57
+ }
58
+
59
+ function hasPath(source, path) {
60
+ const keys = path.split('.')
61
+ let node = source
62
+ for (const key of keys) {
63
+ if (node == null || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, key)) return false
64
+ node = node[key]
65
+ }
66
+ return true
67
+ }
68
+
69
+ function toBoolean(value) {
70
+ if (typeof value === 'boolean') return value
71
+ if (value === 'true') return true
72
+ if (value === 'false') return false
73
+ return undefined
74
+ }
75
+
76
+ function toInt(value, min, max) {
77
+ const number = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10)
78
+ if (!Number.isFinite(number)) return undefined
79
+ const rounded = Math.trunc(number)
80
+ if (rounded < min || rounded > max) return undefined
81
+ return rounded
82
+ }
83
+
84
+ function toStringList(value) {
85
+ if (!Array.isArray(value)) return undefined
86
+ const out = value
87
+ .map((item) => String(item ?? '').trim())
88
+ .filter((item) => item !== '')
89
+ return out.length > 0 ? out : undefined
90
+ }
91
+
92
+ /**
93
+ * 按声明表收敛一项配置。
94
+ * @returns {{value:any, note:string|null}}
95
+ */
96
+ function coerce(spec, raw, source) {
97
+ const candidates = [spec.path]
98
+ if (LEGACY_LOOKUP[spec.path]) candidates.push(LEGACY_LOOKUP[spec.path])
99
+ for (const fallback of FALLBACK_PATHS[spec.path] ?? []) candidates.push(fallback)
100
+ const found = candidates.find((path) => hasPath(source, path))
101
+ const rawValue = found === undefined ? undefined : getPath(source, found)
102
+
103
+ if (rawValue === undefined) return { value: spec.default, note: null }
104
+
105
+ let value
106
+ switch (spec.type) {
107
+ case 'boolean':
108
+ value = toBoolean(rawValue)
109
+ break
110
+ case 'int':
111
+ value = toInt(rawValue, spec.min, spec.max)
112
+ break
113
+ case 'string':
114
+ value = typeof rawValue === 'string' && rawValue.trim() !== '' ? rawValue.trim() : undefined
115
+ break
116
+ case 'stringList':
117
+ value = toStringList(rawValue)
118
+ break
119
+ case 'enum':
120
+ value = spec.values.includes(rawValue) ? rawValue : undefined
121
+ break
122
+ default:
123
+ value = undefined
124
+ }
125
+
126
+ if (value === undefined) {
127
+ return { value: spec.default, note: `配置项 ${spec.path} 的值不合法(收到 ${JSON.stringify(rawValue)}),已退回缺省值` }
128
+ }
129
+ return { value, note: null }
130
+ }
131
+
132
+ function setPath(target, path, value) {
133
+ const keys = path.split('.')
134
+ let node = target
135
+ for (const key of keys.slice(0, -1)) {
136
+ if (node[key] == null || typeof node[key] !== 'object') node[key] = {}
137
+ node = node[key]
138
+ }
139
+ node[keys[keys.length - 1]] = value
140
+ }
141
+
142
+ /**
143
+ * 归一化:永远返回完整配置,永远不抛错。
144
+ *
145
+ * @param {object} [raw] 宿主传进来的原始 config(可能是 null、字符串、旧版结构)
146
+ * @returns {{config: object, notes: string[], unknownKeys: string[]}}
147
+ */
148
+ export function normalizeConfigDetailed(raw) {
149
+ const source = raw !== null && typeof raw === 'object' ? raw : {}
150
+ const config = {}
151
+ const notes = []
152
+
153
+ for (const spec of CONFIG_SPEC) {
154
+ const { value, note } = coerce(spec, raw, source)
155
+ setPath(config, spec.path, value)
156
+ if (note !== null) notes.push(note)
157
+ }
158
+
159
+ // 未知顶层键:不报错,但要能被人看见(旧版键还在 profile 里时,这条 note 就是线索)
160
+ const knownTop = new Set(CONFIG_SPEC.map((spec) => spec.path.split('.')[0]))
161
+ const unknownKeys = Object.keys(source).filter((key) => !knownTop.has(key) && !LEGACY_TOP.has(key))
162
+ if (unknownKeys.length > 0) {
163
+ notes.push(`配置里有本版不认识的键:${unknownKeys.join('、')}(已忽略;若是旧版遗留,可以删掉)`)
164
+ }
165
+
166
+ return { config, notes, unknownKeys }
167
+ }
168
+
169
+ /**
170
+ * 兼容旧调用点:只要配置对象。
171
+ * @param {object} [raw]
172
+ * @returns {object}
173
+ */
174
+ export function normalizeConfig(raw) {
175
+ return normalizeConfigDetailed(raw).config
176
+ }
177
+
178
+ /**
179
+ * 面板/文档用的声明表快照(去掉内部字段,加上当前值)。
180
+ * @param {object} config
181
+ * @returns {Array<object>}
182
+ */
183
+ export function describeConfig(config) {
184
+ return CONFIG_SPEC.map((spec) => ({
185
+ path: spec.path,
186
+ label: spec.label,
187
+ kind: spec.kind,
188
+ type: spec.type,
189
+ help: spec.help ?? '',
190
+ values: spec.values ?? null,
191
+ value: getPath(config, spec.path),
192
+ }))
193
+ }
194
+
195
+ /**
196
+ * 把「面板提交的部分配置」合并进当前配置。
197
+ *
198
+ * 只接受声明表里出现过的路径;未知路径直接报出来,不静默丢弃——
199
+ * 人以为改了其实没改,比报错更糟。
200
+ *
201
+ * @param {object} current 已归一化的配置
202
+ * @param {object} patch 面板提交的**扁平**补丁(键是 `gates.budgetPerDay` 这种路径)
203
+ * @returns {{config: object, rejected: string[]}}
204
+ */
205
+ export function mergeConfig(current, patch) {
206
+ const known = new Set(CONFIG_SPEC.map((spec) => spec.path))
207
+ const accepted = {}
208
+ const rejected = []
209
+ for (const [path, value] of Object.entries(patch ?? {})) {
210
+ if (!known.has(path)) {
211
+ rejected.push(path)
212
+ continue
213
+ }
214
+ setPath(accepted, path, value)
215
+ }
216
+ const merged = normalizeConfig(deepMerge(current, accepted))
217
+ return { config: merged, rejected }
218
+ }
219
+
220
+ /** 递归合并:patch 覆盖 base 的同名键,其余保持 base 的值。 */
221
+ function deepMerge(base, patch) {
222
+ const out = { ...(base ?? {}) }
223
+ for (const [key, value] of Object.entries(patch ?? {})) {
224
+ const nested = value !== null && typeof value === 'object' && !Array.isArray(value)
225
+ const baseNested = out[key] !== null && typeof out[key] === 'object' && !Array.isArray(out[key])
226
+ out[key] = nested && baseNested ? deepMerge(out[key], value) : value
227
+ }
228
+ return out
229
+ }