dsh-subagent-profile 0.3.2 → 0.3.3
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/README.md +38 -29
- package/README.zh.md +38 -29
- package/index.mjs +188 -62
- package/lib/client.js +1240 -47
- package/lib/core/catalog-cache.mjs +45 -7
- package/lib/core/catalog.mjs +6 -6
- package/lib/core/cost-guard.mjs +71 -44
- package/lib/core/decision-trace.mjs +433 -0
- package/lib/core/delegation.mjs +106 -48
- package/lib/core/dispatch-gates.mjs +146 -0
- package/lib/core/dispatch-guard.mjs +150 -0
- package/lib/core/dispatch-schema.mjs +98 -14
- package/lib/core/dispatch-tool.mjs +208 -199
- package/lib/core/escape.mjs +130 -0
- package/lib/core/evolution-ledger.mjs +289 -0
- package/lib/core/evolution-summary.mjs +432 -0
- package/lib/core/http-routes.mjs +155 -40
- package/lib/core/intersection.mjs +6 -9
- package/lib/core/presets-sync.mjs +256 -136
- package/lib/core/profile-provider.mjs +41 -39
- package/lib/core/profiles-store.mjs +103 -76
- package/lib/core/pure.mjs +110 -66
- package/lib/core/shims.mjs +56 -75
- package/lib/core/whitelist.mjs +23 -17
- package/package.json +2 -3
- package/presets/orchestrator/agent.cordis.yml +243 -271
- package/presets/orchestrator/NOTICE +0 -3
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// lib/core/escape.mjs — 逃生舱放行集(evolution-escape.json)管理 + 放行审计快照。
|
|
2
|
+
// 逃生舱默认关(开关态在 state.json,由 profiles-store 持久化);放行集文件只
|
|
3
|
+
// 存「被显式 opt-in 的非 system-trust 预设 id」——文件损坏/缺失/形状不符一律
|
|
4
|
+
// fail-soft 空集(绝不阻断启动,也绝不把坏数据当放行成员)。
|
|
5
|
+
//
|
|
6
|
+
// 放行集独立追踪、独立落盘于 subagent-evolution/ 域(与派发台账同根),不被 preset
|
|
7
|
+
// 同步裁剪。写路径原子写(tmp+rename),失败仅 warn + 通知治理审计钩子(不抛)。
|
|
8
|
+
// 逃生舱语义:成员只在「其余三道闸全过」时放行;非成员即便开关开也恒拒——这里只
|
|
9
|
+
// 提供名单,不参与任何闸判定。
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const ESCAPE_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
// 文件形状判定:只接受 {version:1, presets:[string...]}。版本/形状不符一律判空集
|
|
17
|
+
// (fail-soft),并把坏文件当「未配置」而非「配置了危险成员」。
|
|
18
|
+
function classifyEscapeFile(parsed) {
|
|
19
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.version === 1 && Array.isArray(parsed.presets)) {
|
|
20
|
+
return parsed.presets.filter((id) => typeof id === 'string' && id !== '');
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 读放行集文件 → 内存 Set。缺失/损坏/形状不符均回退空集并 warn,绝不 throw。
|
|
26
|
+
function loadEscapeSet(file, logger) {
|
|
27
|
+
const presets = new Set();
|
|
28
|
+
if (!existsSync(file)) return presets;
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
31
|
+
const entries = classifyEscapeFile(parsed);
|
|
32
|
+
if (entries === undefined) {
|
|
33
|
+
logger.warn('[dsh-subagent-profile] escape set: 放行集文件形状不符,按空集处理');
|
|
34
|
+
return presets;
|
|
35
|
+
}
|
|
36
|
+
for (const id of entries) presets.add(id);
|
|
37
|
+
} catch {
|
|
38
|
+
logger.warn('[dsh-subagent-profile] escape set: 放行集文件损坏,按空集处理');
|
|
39
|
+
}
|
|
40
|
+
return presets;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 原子写放行集文件(tmp+rename);失败 fail-soft:清理 tmp + warn + 通知治理审计
|
|
44
|
+
// 钩子(写失败计一次治理丢失),返回 {persisted}。内存 Set 仍驱动本进程。
|
|
45
|
+
function persistEscapeSet(file, presets, logger, onGovernanceFailure) {
|
|
46
|
+
const payload = { version: ESCAPE_VERSION, presets: [...presets] };
|
|
47
|
+
const tmp = `${file}.tmp`;
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
50
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
51
|
+
renameSync(tmp, file);
|
|
52
|
+
return { persisted: true };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
55
|
+
logger.warn('[dsh-subagent-profile] escape set: 放行集写入失败:', error instanceof Error ? error.message : String(error));
|
|
56
|
+
if (typeof onGovernanceFailure === 'function') onGovernanceFailure();
|
|
57
|
+
return { persisted: false, error: error instanceof Error ? error.message : String(error) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 放行集工厂:per-apply 单例(与 profiles store 同口径)。返回 { list, add,
|
|
62
|
+
// remove };add/remove 都原子落盘并回传 {persisted}。list 返回数组副本(供设置页
|
|
63
|
+
// 只读列表与 resolveWhitelist 叠加)。成员判定由调用方对 list() 结果做 includes。
|
|
64
|
+
export function createEscapeStore({ dshHome, logger, onGovernanceFailure = () => {} }) {
|
|
65
|
+
const file = join(dshHome, 'subagent-evolution', 'evolution-escape.json');
|
|
66
|
+
const presets = loadEscapeSet(file, logger);
|
|
67
|
+
return {
|
|
68
|
+
list: () => [...presets],
|
|
69
|
+
add(id) {
|
|
70
|
+
if (typeof id !== 'string' || id === '') return { persisted: false, error: '放行集成员必须是非空字符串' };
|
|
71
|
+
presets.add(id);
|
|
72
|
+
return persistEscapeSet(file, presets, logger, onGovernanceFailure);
|
|
73
|
+
},
|
|
74
|
+
remove(id) {
|
|
75
|
+
presets.delete(id);
|
|
76
|
+
return persistEscapeSet(file, presets, logger, onGovernanceFailure);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 放行审计快照:从 decisionTrace.gates 抽取「白名单 + 其余三道闸」的 verdict。
|
|
82
|
+
// cost 保留硬上限/llm 能力检查的 field→verdict,whitelist/intersection/approval
|
|
83
|
+
// 只留 verdict 字符串。只读、不 throw——trace 缺 gates 或某闸缺失时对应键缺席
|
|
84
|
+
// (审计记录仍恒可写)。
|
|
85
|
+
export function escapeGatesSnapshot(trace) {
|
|
86
|
+
const gates = Array.isArray(trace?.gates) ? trace.gates : [];
|
|
87
|
+
const snapshot = {};
|
|
88
|
+
for (const gate of gates) {
|
|
89
|
+
if (!gate || typeof gate !== 'object') continue;
|
|
90
|
+
if (gate.name === 'cost') {
|
|
91
|
+
snapshot.cost = {
|
|
92
|
+
verdict: gate.verdict,
|
|
93
|
+
checks: (Array.isArray(gate.output?.checks) ? gate.output.checks : [])
|
|
94
|
+
.map((check) => ({ field: check?.field, verdict: check?.verdict })),
|
|
95
|
+
};
|
|
96
|
+
} else if (gate.name === 'whitelist' || gate.name === 'intersection' || gate.name === 'approval') {
|
|
97
|
+
snapshot[gate.name] = gate.verdict;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return snapshot;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 逃生舱放行审计:非 system 预设经逃生舱通过闸链时写一条高可见治理审计事件(含
|
|
104
|
+
// preset、来源会话、白名单+三道闸快照)。调用点在闸链末尾(三道闸 + 预算闸之后、
|
|
105
|
+
// 分派之前)。getEscapeSet 在开关关时返回空数组 → includes 恒 false → 零审计;成员
|
|
106
|
+
// 判定即「开关开且 preset 在放行集」。写失败绝不静默(recordGovernanceAudit 内部
|
|
107
|
+
// warn + 治理丢失计数)。deps 由调用方注入(含 getEscapeSet / evoLedger)。
|
|
108
|
+
export function recordEscapeAllow(deps, parent, merged, trace) {
|
|
109
|
+
if (typeof merged.preset !== 'string' || merged.preset === 'inherit') return;
|
|
110
|
+
if (!deps.getEscapeSet().includes(merged.preset)) return;
|
|
111
|
+
deps.evoLedger.recordGovernanceAudit({
|
|
112
|
+
kind: 'escape-allow',
|
|
113
|
+
preset: merged.preset,
|
|
114
|
+
session_id: parent.session?.header?.id ?? null,
|
|
115
|
+
gates_snapshot: escapeGatesSnapshot(trace),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// provider 渠道放行审计:profile provider 的 preflight 白名单放行 escape 成员时
|
|
120
|
+
// (非 dispatch 工具的宿主原生 subagent 渠道),同样写高可见审计——「每个放行都
|
|
121
|
+
// 留痕」是绝对语义。快照为 provider 渠道口径:whitelist/cost 在 preflight 已过、
|
|
122
|
+
// intersection 在子会话 setup 时执行(deferred)、审批不豁免(never)。
|
|
123
|
+
export function recordEscapeAllowProvider(evoLedger, parent, preset) {
|
|
124
|
+
evoLedger.recordGovernanceAudit({
|
|
125
|
+
kind: 'escape-allow-provider',
|
|
126
|
+
preset,
|
|
127
|
+
session_id: parent?.session?.header?.id ?? null,
|
|
128
|
+
gates_snapshot: { whitelist: 'pass', cost: 'pass', intersection: 'deferred', approval: 'never' },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// lib/core/evolution-ledger.mjs — 派发台账(dispatch.jsonl)采集。JSONL 追加不可变:
|
|
2
|
+
// 只 appendFileSync 追加一行,绝不改写既有行。import-free(仅 node 内置
|
|
3
|
+
// fs/crypto/path + lib/core/pure.mjs),可被 bare-CI 单测直接 import。采集异常只
|
|
4
|
+
// warn + 丢失计数,绝不阻断派发(fail-soft)。
|
|
5
|
+
//
|
|
6
|
+
// 审计分级:每类写失败分别计数,计数与 health 落盘到同目录的 ledger.meta.json
|
|
7
|
+
// ({v:1, lostTelemetry, lostGovernance, health})。派发台账写失败计 lostTelemetry
|
|
8
|
+
// (fail-soft 仅告警);profile 变更/治理写失败走 markGovernanceFailure 计
|
|
9
|
+
// lostGovernance 且 health 置 degraded(高可见,设置页红字暴露)。meta 独立成域
|
|
10
|
+
// subagent-evolution/(以 dshHome 为根,不在 .agent-presets/ 下,不被 preset 同步
|
|
11
|
+
// 裁剪)。health 持久化:重启保持 degraded,直到一次 meta 写成功(成功派发触发恢复)
|
|
12
|
+
// 才回 ok。meta.json 原子写(tmp+rename),损坏 fail-soft 从零,写失败绝不抛。
|
|
13
|
+
//
|
|
14
|
+
// 隐私红线:绝不落子 Agent 原始输出 / prompt 原文 / persona 原文——每条记录只有长度
|
|
15
|
+
// (min-PII)与确定性结构指纹。persona 只存 present 布尔 + 文本结构指纹;toolFilter 只
|
|
16
|
+
// 存 present + allow/deny 数组结构指纹。parent_adopted / goal_achieved / human_edited
|
|
17
|
+
// 不入库(写入那个时刻它们尚不存在,事后回填会破坏 append-only 不可变性,只由聚合层
|
|
18
|
+
// 惰性 join)。
|
|
19
|
+
|
|
20
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { textFrom } from './pure.mjs';
|
|
24
|
+
|
|
25
|
+
const LEDGER_VERSION = 1;
|
|
26
|
+
const GOVERNANCE_AUDIT_VERSION = 1;
|
|
27
|
+
const SOURCE = 'system';
|
|
28
|
+
const ORIGIN = 'subagent';
|
|
29
|
+
|
|
30
|
+
// --- 结构指纹(确定性,输入相同恒产出相同指纹)----------------------------------
|
|
31
|
+
// 长度做导流 + sha1 前 8 区分内容:仅截断哈希可被碰撞枚举,叠加前缀长度后同时刻画
|
|
32
|
+
// 内容形状与体量,避免可枚举碰撞。不含原文。
|
|
33
|
+
function fingerprint(text) {
|
|
34
|
+
const value = String(text ?? '');
|
|
35
|
+
const hash = createHash('sha1').update(value, 'utf8').digest('hex').slice(0, 8);
|
|
36
|
+
return `L${value.length}_${hash}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// prompt 规范化结构指纹:长度 + 换行数 + sha1 前 8(内容形状标识,独缺原文)。
|
|
40
|
+
function structureFingerprint(prompt) {
|
|
41
|
+
const value = String(prompt ?? '');
|
|
42
|
+
let newlines = 0;
|
|
43
|
+
for (let i = 0; i < value.length; i += 1) if (value[i] === '\n') newlines += 1;
|
|
44
|
+
return `${fingerprint(value)}_NL${newlines}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// allow/deny 数组结构指纹:各数组的项数 + 内容 sha1(只对数组结构,不含父级配置)。
|
|
48
|
+
function toolFilterFingerprint(toolFilter) {
|
|
49
|
+
const list = (arr) => (Array.isArray(arr) ? arr : []);
|
|
50
|
+
const allow = list(toolFilter?.allow);
|
|
51
|
+
const deny = list(toolFilter?.deny);
|
|
52
|
+
const part = (label, arr) => `${label}:N${arr.length}_${fingerprint(JSON.stringify(arr))}`;
|
|
53
|
+
return `${part('allow', allow)};${part('deny', deny)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// cfg(requested/effective 双栏共用)。pending 为 merged(continuable 时部分值会被
|
|
57
|
+
// 丢弃);opts 覆盖生效栏(continuable 生效 preset 恒 'inherit'、reasoningEffort 被
|
|
58
|
+
// 宿主忽略应空置)。双栏拆分正是为捕捉「请求了但被宿主丢弃」的失真。空字段统一
|
|
59
|
+
// 归一为 null(不写 undefined,保持 JSONL 行形状稳定)。
|
|
60
|
+
function cfgOf(merged, opts = {}) {
|
|
61
|
+
const persona = typeof merged?.persona === 'string' ? merged.persona : '';
|
|
62
|
+
const hasPersona = persona.length > 0;
|
|
63
|
+
const toolFilterSet = merged?.toolFilter !== undefined && merged?.toolFilter !== null;
|
|
64
|
+
const str = (value) => (typeof value === 'string' && value.length > 0 ? value : null);
|
|
65
|
+
return {
|
|
66
|
+
preset: opts.preset ?? (typeof merged?.preset === 'string' && merged.preset !== '' ? merged.preset : 'inherit'),
|
|
67
|
+
provider: str(merged?.provider),
|
|
68
|
+
model: str(merged?.model),
|
|
69
|
+
reasoningEffort: opts.reasoningEffort !== undefined ? opts.reasoningEffort : str(merged?.reasoningEffort),
|
|
70
|
+
maxTokens: typeof merged?.maxTokens === 'number' ? merged.maxTokens : null,
|
|
71
|
+
maxDepth: typeof merged?.maxDepth === 'number' ? merged.maxDepth : null,
|
|
72
|
+
persona_present: hasPersona,
|
|
73
|
+
persona_fp: hasPersona ? fingerprint(persona) : null,
|
|
74
|
+
toolFilter_present: toolFilterSet,
|
|
75
|
+
toolFilter_fp: toolFilterSet ? toolFilterFingerprint(merged.toolFilter) : null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 记录静态元数据(写入即知的共享字段,不含 ts/v——由 record 落盘时补)。三分支各自
|
|
80
|
+
// 在此基础上补 child_id 与 outcome 后调用 record。pluginVersion 由工厂闭包注入。
|
|
81
|
+
function buildBase({ parent, args, merged, mode, pluginVersion }) {
|
|
82
|
+
const prompt = typeof args?.prompt === 'string' ? args.prompt : '';
|
|
83
|
+
const effectivePreset = mode === 'continuable' ? 'inherit' : (merged?.preset ?? 'inherit');
|
|
84
|
+
// continuable 下宿主忽略 reasoningEffort(决策轨迹侧将其列入 ignored),生效栏
|
|
85
|
+
// 空置以保持「生效值」语义与聚合层读值不失真。
|
|
86
|
+
const effectiveReasoningEffort = mode === 'continuable' ? null : (merged?.reasoningEffort ?? null);
|
|
87
|
+
return {
|
|
88
|
+
// session_id 空值归一 null:它是未来聚合层惰性 join 的键,静默缺失会破坏
|
|
89
|
+
// join 且难察觉,显式 null 至少保持 JSONL 行形状稳定。
|
|
90
|
+
session_id: parent?.session?.header?.id ?? null,
|
|
91
|
+
mode,
|
|
92
|
+
origin: ORIGIN,
|
|
93
|
+
requested: cfgOf(merged),
|
|
94
|
+
effective: cfgOf(merged, { preset: effectivePreset, reasoningEffort: effectiveReasoningEffort }),
|
|
95
|
+
task: {
|
|
96
|
+
len: prompt.length,
|
|
97
|
+
structure: structureFingerprint(prompt),
|
|
98
|
+
},
|
|
99
|
+
source: SOURCE,
|
|
100
|
+
provenance: `dispatch:v${pluginVersion}`,
|
|
101
|
+
plugin_version: pluginVersion,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- outcome 组装(前台/后台结算时携带;continuable 无结算 → 不产 outcome)--------
|
|
106
|
+
|
|
107
|
+
// tool_count 为「携带 usage 的 assistant/message 事件数」(collectChildCalls 的
|
|
108
|
+
// totalCalls),是工具调用次数的可测代理而非字面计数值——宿主会话面无更直接的
|
|
109
|
+
// 工具调用计数字段;拿不到记 null。
|
|
110
|
+
function foregroundOutcome({ result, elapsedMs, prunedOutputLen, toolCount }) {
|
|
111
|
+
const stopReason = result?.stopReason ?? 'completed';
|
|
112
|
+
const completed = stopReason === 'completed';
|
|
113
|
+
return {
|
|
114
|
+
status: completed ? 'completed' : (stopReason === 'aborted' ? 'killed' : 'failed'),
|
|
115
|
+
stop_reason: stopReason,
|
|
116
|
+
elapsed_ms: elapsedMs,
|
|
117
|
+
output_len: prunedOutputLen,
|
|
118
|
+
tool_count: completed ? toolCount : null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function backgroundOutcome(settled) {
|
|
123
|
+
const s = settled !== null && typeof settled === 'object' ? settled : {};
|
|
124
|
+
const totalCalls = s.calls !== null && typeof s.calls === 'object' && typeof s.calls.totalCalls === 'number'
|
|
125
|
+
? s.calls.totalCalls
|
|
126
|
+
: null;
|
|
127
|
+
return {
|
|
128
|
+
status: s.status ?? 'failed',
|
|
129
|
+
stop_reason: s.stopReason ?? 'error',
|
|
130
|
+
elapsed_ms: s.elapsedMs,
|
|
131
|
+
output_len: typeof s.output === 'string' ? s.output.length : 0,
|
|
132
|
+
tool_count: totalCalls,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- 三分支 finish 助手:每条派发只加一行调用(守 dispatch-tool 行门)------------
|
|
137
|
+
|
|
138
|
+
export function finishForegroundLedger(ledger, base, childId, result, prunedBlocks, elapsedMs, childCalls) {
|
|
139
|
+
const calls = childCalls ?? {};
|
|
140
|
+
ledger.record({
|
|
141
|
+
...base,
|
|
142
|
+
child_id: childId,
|
|
143
|
+
mode: 'foreground',
|
|
144
|
+
outcome: foregroundOutcome({
|
|
145
|
+
result,
|
|
146
|
+
elapsedMs,
|
|
147
|
+
prunedOutputLen: textFrom(prunedBlocks).length,
|
|
148
|
+
toolCount: typeof calls.totalCalls === 'number' ? calls.totalCalls : null,
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function finishBackgroundLedger(ledger, base, childId, settled) {
|
|
154
|
+
ledger.record({ ...base, child_id: childId, mode: 'background', outcome: backgroundOutcome(settled) });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function finishContinuableLedger(ledger, base, childId) {
|
|
158
|
+
ledger.record({ ...base, child_id: childId, mode: 'continuable' });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- 审计 meta(ledger.meta.json)----------------------------------------------
|
|
162
|
+
|
|
163
|
+
// meta 默认态(缺失/损坏/形状不符均回退此值 → fail-soft 从零)。last_write_ts 为
|
|
164
|
+
// 最近一次成功落盘时间(§12.4 字段;旧文件无该字段时经合并回退 0)。
|
|
165
|
+
const META_DEFAULTS = { v: 1, lostTelemetry: 0, lostGovernance: 0, health: 'ok', last_write_ts: 0 };
|
|
166
|
+
|
|
167
|
+
// 读取并校验 meta.json;缺失/损坏/形状不符回退默认并 warn(fail-soft 从零)。
|
|
168
|
+
// 只接受 v:1 与必填三字段的数字/字符串形态,防止被手改的坏元数据冒充。
|
|
169
|
+
function loadAuditMeta(metaFile, warn) {
|
|
170
|
+
if (!existsSync(metaFile)) return { ...META_DEFAULTS };
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(readFileSync(metaFile, 'utf8'));
|
|
173
|
+
if (
|
|
174
|
+
parsed !== null && typeof parsed === 'object' && parsed.v === 1
|
|
175
|
+
&& typeof parsed.lostTelemetry === 'number' && Number.isFinite(parsed.lostTelemetry)
|
|
176
|
+
&& typeof parsed.lostGovernance === 'number' && Number.isFinite(parsed.lostGovernance)
|
|
177
|
+
&& (parsed.health === 'ok' || parsed.health === 'degraded')
|
|
178
|
+
) {
|
|
179
|
+
return { ...META_DEFAULTS, ...parsed };
|
|
180
|
+
}
|
|
181
|
+
warn(`ledger meta: 审计元数据形状不符,已从零重建(${metaFile})`);
|
|
182
|
+
return { ...META_DEFAULTS };
|
|
183
|
+
} catch {
|
|
184
|
+
warn(`ledger meta: 审计元数据损坏,已从零重建(${metaFile})`);
|
|
185
|
+
return { ...META_DEFAULTS };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- 工厂 -----------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
// 原子写 meta.json(tmp+rename)。metaState 为可写闭包状态 {dir, metaFile, lost,
|
|
192
|
+
// governanceLost, health};写成功以 healthValue 为准(恢复 ok 即在此触发)。写失败
|
|
193
|
+
// fail-soft:清理 tmp + warn、返回 false。warnOnFailure 供派发遥测失败路径关闭——
|
|
194
|
+
// 该路径已就台账写失败 warn 一次,避免重复告警。
|
|
195
|
+
function persistAuditMeta(metaState, healthValue, warn, { warnOnFailure = true } = {}) {
|
|
196
|
+
const payload = { v: 1, lostTelemetry: metaState.lost, lostGovernance: metaState.governanceLost, health: healthValue, last_write_ts: Date.now() };
|
|
197
|
+
let reported = false;
|
|
198
|
+
try {
|
|
199
|
+
mkdirSync(metaState.dir, { recursive: true });
|
|
200
|
+
const tmp = `${metaState.metaFile}.tmp`;
|
|
201
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
202
|
+
renameSync(tmp, metaState.metaFile);
|
|
203
|
+
metaState.health = healthValue;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
try { rmSync(`${metaState.metaFile}.tmp`, { force: true }); } catch { /* best effort */ }
|
|
206
|
+
reported = true;
|
|
207
|
+
if (warnOnFailure) {
|
|
208
|
+
warn(`ledger meta: 审计元数据写入失败(${error instanceof Error ? error.message : String(error)})`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return !reported;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- 治理审计(append-only)-------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
// 治理失败统一接线:计 lostGovernance + health 置 degraded,meta 尽力落盘保证
|
|
217
|
+
// 重启仍 degraded;内存 health 无论落盘成败一律降级(写失败只 warn,不降回 ok)。
|
|
218
|
+
function markGovernanceFailureMeta(meta, persistMeta) {
|
|
219
|
+
meta.governanceLost += 1;
|
|
220
|
+
meta.health = 'degraded';
|
|
221
|
+
persistMeta('degraded');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 治理审计事件(放行审计等)追加一行到 governance-audit.jsonl(append-only 不可变)。
|
|
225
|
+
// 写失败绝不静默:warn + markGovernanceFailureMeta(计 lostGovernance + degraded);
|
|
226
|
+
// 写成功若正处 degraded,即为一次成功文件写 → 恢复 ok(与派发台账同口径)。
|
|
227
|
+
function appendGovernanceAudit(meta, dir, auditFile, persistMeta, warn, entry) {
|
|
228
|
+
const line = JSON.stringify({ v: GOVERNANCE_AUDIT_VERSION, ts: Date.now(), ...entry });
|
|
229
|
+
try {
|
|
230
|
+
mkdirSync(dir, { recursive: true });
|
|
231
|
+
appendFileSync(auditFile, `${line}\n`, 'utf8');
|
|
232
|
+
if (meta.health === 'degraded') persistMeta('ok');
|
|
233
|
+
} catch (error) {
|
|
234
|
+
warn(`dispatch ledger: 治理审计事件写入失败(${error instanceof Error ? error.message : String(error)})`);
|
|
235
|
+
markGovernanceFailureMeta(meta, persistMeta);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function createEvolutionLedger({ dshHome, pluginVersion, warn = () => {} } = {}) {
|
|
240
|
+
if (typeof dshHome !== 'string' || dshHome.length === 0) {
|
|
241
|
+
throw new Error('dispatch ledger: 派发台账需要 dshHome 目录');
|
|
242
|
+
}
|
|
243
|
+
const dir = join(dshHome, 'subagent-evolution');
|
|
244
|
+
const file = join(dir, 'dispatch.jsonl');
|
|
245
|
+
const auditFile = join(dir, 'governance-audit.jsonl');
|
|
246
|
+
const persisted = loadAuditMeta(join(dir, 'ledger.meta.json'), warn);
|
|
247
|
+
// 可写审计状态:lost(遥测)/governanceLost/health,初始为持久化值。
|
|
248
|
+
const meta = {
|
|
249
|
+
dir,
|
|
250
|
+
metaFile: join(dir, 'ledger.meta.json'),
|
|
251
|
+
lost: persisted.lostTelemetry,
|
|
252
|
+
governanceLost: persisted.lostGovernance,
|
|
253
|
+
health: persisted.health,
|
|
254
|
+
};
|
|
255
|
+
const persistMeta = (healthValue, opts) => persistAuditMeta(meta, healthValue, warn, opts);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
baseEntry: (input) => buildBase({ ...input, pluginVersion }),
|
|
259
|
+
// 追加不可变:mkdir recursive + appendFileSync。写失败只 warn + 丢失计数 ++,
|
|
260
|
+
// 绝不抛错、绝不阻断派发。写失败计 lostTelemetry 并落盘 meta;写成功恢复 ok。
|
|
261
|
+
record(entry) {
|
|
262
|
+
try {
|
|
263
|
+
// 序列化也在 try 内(循环引用/BigInt 等理论异常会绕过 fail-soft 外泄;
|
|
264
|
+
// 实际 entry 为自产标量,防御性收口)。
|
|
265
|
+
const line = JSON.stringify({ v: LEDGER_VERSION, ts: Date.now(), ...entry });
|
|
266
|
+
mkdirSync(dir, { recursive: true });
|
|
267
|
+
appendFileSync(file, `${line}\n`, 'utf8');
|
|
268
|
+
if (meta.health === 'degraded') persistMeta('ok');
|
|
269
|
+
} catch (error) {
|
|
270
|
+
meta.lost += 1;
|
|
271
|
+
warn(`dispatch ledger: 派发台账写入失败,已丢失 ${meta.lost} 条(${error instanceof Error ? error.message : String(error)})`);
|
|
272
|
+
persistMeta(meta.health, { warnOnFailure: false });
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
recordGovernanceAudit(entry) {
|
|
276
|
+
appendGovernanceAudit(meta, dir, auditFile, persistMeta, warn, entry);
|
|
277
|
+
},
|
|
278
|
+
// 治理审计失败(profile 变更写失败等):计 lostGovernance + health 置 degraded,
|
|
279
|
+
// 不抛、不阻断调用方;meta 尽力落盘保证重启仍 degraded。
|
|
280
|
+
markGovernanceFailure() {
|
|
281
|
+
markGovernanceFailureMeta(meta, persistMeta);
|
|
282
|
+
},
|
|
283
|
+
// 进程内 health('ok' | 'degraded')。
|
|
284
|
+
metaHealth: () => meta.health,
|
|
285
|
+
// 设置页 summary 的 audit 字段来源:{lostTelemetry, lostGovernance, health}。
|
|
286
|
+
auditState: () => ({ lostTelemetry: meta.lost, lostGovernance: meta.governanceLost, health: meta.health }),
|
|
287
|
+
lostCount: () => meta.lost,
|
|
288
|
+
};
|
|
289
|
+
}
|