dsh-subagent-profile 0.3.4 → 0.4.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/README.md +1 -0
- package/README.zh.md +1 -0
- package/index.mjs +14 -5
- package/lib/client.js +948 -170
- package/lib/core/delegation.mjs +3 -2
- package/lib/core/evolution-advice.mjs +125 -26
- package/lib/core/evolution-assets.mjs +167 -0
- package/lib/core/evolution-draft.mjs +312 -0
- package/lib/core/evolution-engine.mjs +269 -0
- package/lib/core/evolution-generate.mjs +196 -0
- package/lib/core/evolution-ledger.mjs +131 -26
- package/lib/core/evolution-persistence.mjs +213 -0
- package/lib/core/evolution-renewal.mjs +64 -0
- package/lib/core/evolution-routes.mjs +109 -0
- package/lib/core/http-helpers.mjs +35 -0
- package/lib/core/http-routes.mjs +21 -36
- package/lib/core/profile-provider.mjs +2 -1
- package/lib/core/profiles-store.mjs +23 -5
- package/lib/core/session-read.mjs +17 -0
- package/lib/core/shims.mjs +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// lib/core/evolution-generate.mjs — 候选生成路径(从 evolution-engine 拆出守行门):
|
|
2
|
+
// 一条建议 → ≤3 条互斥候选,逐候选过三道闸(闸由装配层注入)后落资产域。更新策略
|
|
3
|
+
// 与有效期用户可配:自动换新在「候选到期且数据显著变化」时把旧候选转 expired(留
|
|
4
|
+
// 审计)再产新一代;手动维护不自动换代、不自动过期,只经「重新生成候选」刷新
|
|
5
|
+
// (留审计)。纯函数 + 注入 deps,零 IO、零 LLM。
|
|
6
|
+
|
|
7
|
+
import { sanitizeProfile } from './pure.mjs';
|
|
8
|
+
import { profileKeyOf } from './evolution-summary.mjs';
|
|
9
|
+
import { buildEvolutionCandidates, profileFingerprint, sourceConfigFromKey } from './evolution-draft.mjs';
|
|
10
|
+
import { candidateExpired, candidateExpiryOf, dataFingerprintOf, dataSignificantlyChanged, expireAuditEntry, refreshAuditEntry } from './evolution-renewal.mjs';
|
|
11
|
+
|
|
12
|
+
// 候选总量护栏:evolution 资产数达到 20 条即跳过生成并 warn(阈值待实测后校准)。
|
|
13
|
+
const ASSET_CAP = 20;
|
|
14
|
+
|
|
15
|
+
// 存量 profile 的 L1 身份键(与聚合层同口径:persona/toolFilter 只认存在性)。
|
|
16
|
+
function keyCfg(profile) {
|
|
17
|
+
const cfg = {};
|
|
18
|
+
if (typeof profile.preset === 'string' && profile.preset !== '' && profile.preset !== 'inherit') cfg.preset = profile.preset;
|
|
19
|
+
if (typeof profile.provider === 'string' && profile.provider !== '') cfg.provider = profile.provider;
|
|
20
|
+
if (typeof profile.model === 'string' && profile.model !== '') cfg.model = profile.model;
|
|
21
|
+
if (typeof profile.persona === 'string' && profile.persona.length > 0) cfg.persona_present = true;
|
|
22
|
+
if (profile.toolFilter !== undefined && profile.toolFilter !== null) cfg.toolFilter_present = true;
|
|
23
|
+
return cfg;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 注册表中与该 L1 身份键匹配的方案(净化后快照)——指纹输入,编辑任一方案
|
|
27
|
+
// 都会使候选过期。
|
|
28
|
+
export function matchingRegistryProfiles(store, profileKey) {
|
|
29
|
+
const profiles = store.profiles instanceof Map ? [...store.profiles.values()] : [];
|
|
30
|
+
return profiles
|
|
31
|
+
.filter((p) => p !== null && typeof p === 'object' && profileKeyOf(keyCfg(p)) === profileKey)
|
|
32
|
+
.map((p) => sanitizeProfile(p, { strict: false }).clean);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 单条候选 → 资产对象。baseline_snapshot 留档金丝雀基线(from + 生成时刻的加权分/
|
|
36
|
+
// 样本数);data_fingerprint 留档换代判定输入;expiry 按用户设置(手动维护或不自动
|
|
37
|
+
// 过期档为 null,否则 = 生成时刻 + 有效期小时数)。
|
|
38
|
+
export function buildAsset(deps, item, draft, now, summaries) {
|
|
39
|
+
const l1Entry = summaries !== null && typeof summaries === 'object' && summaries.l1 !== null && typeof summaries.l1 === 'object'
|
|
40
|
+
? summaries.l1[item.profileKey]
|
|
41
|
+
: undefined;
|
|
42
|
+
const l2 = summaries !== null && typeof summaries === 'object' ? summaries.l2 : undefined;
|
|
43
|
+
if (draft === null || typeof draft !== 'object') return null;
|
|
44
|
+
const from = sourceConfigFromKey(item.profileKey, l2);
|
|
45
|
+
const registry = matchingRegistryProfiles(deps.store, item.profileKey);
|
|
46
|
+
const fingerprint = profileFingerprint({ profileKey: item.profileKey, from, registryEntries: registry });
|
|
47
|
+
const axis = item.suggestion === '降' ? 'capability' : 'budget';
|
|
48
|
+
const direction = item.suggestion === '降' ? 'down' : 'up';
|
|
49
|
+
return {
|
|
50
|
+
v: 1,
|
|
51
|
+
id: draft.config.id,
|
|
52
|
+
source: 'evolution',
|
|
53
|
+
provenance: { pluginVersion: deps.pluginVersion, generatedAt: now, basis: draft.basis },
|
|
54
|
+
state: 'draft',
|
|
55
|
+
version: 1,
|
|
56
|
+
expiry: candidateExpiryOf(deps.getCandidateMode(), deps.getCandidateTtlH(), now),
|
|
57
|
+
from,
|
|
58
|
+
to: draft.config,
|
|
59
|
+
axis,
|
|
60
|
+
direction,
|
|
61
|
+
baseline_snapshot: {
|
|
62
|
+
from,
|
|
63
|
+
score: l1Entry?.score ?? null,
|
|
64
|
+
n: l1Entry?.confidence?.n ?? 0,
|
|
65
|
+
confidence: item.confidence ?? '',
|
|
66
|
+
generatedAt: now,
|
|
67
|
+
},
|
|
68
|
+
data_fingerprint: dataFingerprintOf(item, summaries),
|
|
69
|
+
profile_key: item.profileKey,
|
|
70
|
+
profile_fingerprint: fingerprint,
|
|
71
|
+
config: draft.config,
|
|
72
|
+
name: draft.name,
|
|
73
|
+
description: draft.description,
|
|
74
|
+
createdAt: now,
|
|
75
|
+
updatedAt: now,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 单条建议 → ≤3 条候选,逐候选过三道闸(复用装配层注入的闸评估)后落盘;单条
|
|
80
|
+
// 候选闸不过或落盘失败只跳过该候选(fail-soft),不影响其余候选与建议展示。
|
|
81
|
+
async function generateCandidatesFor(deps, { item, snapshot, taken, now, summaries }) {
|
|
82
|
+
const l1Entry = summaries !== null && typeof summaries === 'object' && summaries.l1 !== null && typeof summaries.l1 === 'object'
|
|
83
|
+
? summaries.l1[item.profileKey]
|
|
84
|
+
: undefined;
|
|
85
|
+
const l2 = summaries !== null && typeof summaries === 'object' ? summaries.l2 : undefined;
|
|
86
|
+
const drafts = buildEvolutionCandidates({
|
|
87
|
+
advice: item,
|
|
88
|
+
l1Entry,
|
|
89
|
+
l2,
|
|
90
|
+
snapshot,
|
|
91
|
+
whitelist: deps.whitelist,
|
|
92
|
+
takenIds: taken,
|
|
93
|
+
now,
|
|
94
|
+
});
|
|
95
|
+
const generated = [];
|
|
96
|
+
const skipped = [];
|
|
97
|
+
for (const draft of drafts) {
|
|
98
|
+
const gates = await deps.assessGates(draft.config);
|
|
99
|
+
if (!gates.ok) {
|
|
100
|
+
skipped.push({ profileKey: item.profileKey, candidateId: draft.config.id, reason: gates.reason });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const asset = buildAsset(deps, item, draft, now, summaries);
|
|
104
|
+
if (asset === null) continue;
|
|
105
|
+
const persisted = deps.assets.add(asset);
|
|
106
|
+
if (persisted.persisted) {
|
|
107
|
+
taken.add(asset.id);
|
|
108
|
+
generated.push(asset.id);
|
|
109
|
+
} else {
|
|
110
|
+
skipped.push({ profileKey: item.profileKey, candidateId: draft.config.id, reason: 'persist-failed' });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { generated, skipped };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// auto 模式的待处置门槛:null = 可生成;{skip} = 幂等跳过;{expire} = 需先转
|
|
117
|
+
// expired 再换代。观察期(proposed)恒阻断;手动维护不自动换代。
|
|
118
|
+
function pendingGate(deps, item, now, summaries, mode) {
|
|
119
|
+
const unresolved = deps.assets.findUnresolved(item.profileKey);
|
|
120
|
+
if (unresolved.length === 0) return null;
|
|
121
|
+
if (mode === 'manual' || unresolved.some((entry) => entry.state === 'proposed')) return { skip: 'pending' };
|
|
122
|
+
const expired = unresolved.filter((entry) => candidateExpired(entry, now));
|
|
123
|
+
if (expired.length < unresolved.length) return { skip: 'pending' };
|
|
124
|
+
const next = dataFingerprintOf(item, summaries);
|
|
125
|
+
const changed = expired.some((entry) => {
|
|
126
|
+
const asset = deps.assets.get(entry.id);
|
|
127
|
+
return dataSignificantlyChanged(asset !== undefined ? (asset.data_fingerprint ?? {}) : {}, next);
|
|
128
|
+
});
|
|
129
|
+
return changed ? { expire: expired } : { skip: 'no-significant-change' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 生成候选(建议 → 资产域):建议链开关门(evolutionAdvice)→ 总量护栏 → 逐条建议:
|
|
133
|
+
// auto:无未处置候选才生成;未到期幂等跳过;全部到期且数据显著变化 → 旧候选转
|
|
134
|
+
// expired(留审计)再产新一代;到期但数据没显著变化 → 保留旧候选跳过。
|
|
135
|
+
// manual:有任何未处置候选即幂等跳过(不自动换代、不自动过期)。
|
|
136
|
+
// forceProfileKey:只对该键绕过上述待处置门槛(「重新生成候选」内部复用)。
|
|
137
|
+
export async function generateCandidates(deps, { advice, summaries, forceProfileKey = null } = {}) {
|
|
138
|
+
if (!deps.getAdviceEnabled()) return { generated: [], skipped: [], reason: 'advice-disabled' };
|
|
139
|
+
const snapshot = await deps.catalog.getSnapshot();
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const mode = deps.getCandidateMode();
|
|
142
|
+
const evolutionCount = deps.assets.countBySource('evolution');
|
|
143
|
+
if (evolutionCount >= ASSET_CAP) {
|
|
144
|
+
deps.logger.warn('[dsh-subagent-profile] 候选总量护栏:evolution 资产已达 20 条上限,跳过生成(阈值待实测后校准)');
|
|
145
|
+
return { generated: [], skipped: [] };
|
|
146
|
+
}
|
|
147
|
+
const taken = new Set([...deps.store.profiles.keys(), ...deps.assets.ids()]);
|
|
148
|
+
const generated = [];
|
|
149
|
+
const skipped = [];
|
|
150
|
+
for (const item of Array.isArray(advice) ? advice : []) {
|
|
151
|
+
if (evolutionCount + generated.length >= ASSET_CAP) break;
|
|
152
|
+
const forced = forceProfileKey !== null && item.profileKey === forceProfileKey;
|
|
153
|
+
if (!forced) {
|
|
154
|
+
const gate = pendingGate(deps, item, now, summaries, mode);
|
|
155
|
+
if (gate === null) { /* 无未处置候选,正常生成 */ }
|
|
156
|
+
else if (gate.skip !== undefined) { skipped.push({ profileKey: item.profileKey, reason: gate.skip }); continue; }
|
|
157
|
+
else {
|
|
158
|
+
for (const entry of gate.expire) {
|
|
159
|
+
const transitioned = deps.assets.transition(entry.id, 'expired', { expire_reason: 'expiry-and-data-change' });
|
|
160
|
+
if (transitioned.persisted) {
|
|
161
|
+
const asset = deps.assets.get(entry.id);
|
|
162
|
+
if (asset !== undefined) deps.evoLedger.recordGovernanceAudit(expireAuditEntry(asset, '候选到期且数据显著变化,已换新'));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const perItem = await generateCandidatesFor(deps, { item, snapshot, taken, now, summaries });
|
|
168
|
+
generated.push(...perItem.generated);
|
|
169
|
+
skipped.push(...perItem.skipped);
|
|
170
|
+
}
|
|
171
|
+
return { generated, skipped };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 重新生成候选(人工触发):该身份键所有未处置候选先转 expired,再产新一代;
|
|
175
|
+
// 审计 suggestion-refresh 一条(人工触发即确认)。无当前建议时只清理不产新候选。
|
|
176
|
+
export async function regenerateCandidates(deps, { profileKey, advice, summaries }) {
|
|
177
|
+
const unresolved = deps.assets.findUnresolved(profileKey);
|
|
178
|
+
const expired = [];
|
|
179
|
+
for (const entry of unresolved) {
|
|
180
|
+
const transitioned = deps.assets.transition(entry.id, 'expired', { expire_reason: 'manual-regenerate' });
|
|
181
|
+
if (transitioned.persisted) expired.push(entry.id);
|
|
182
|
+
}
|
|
183
|
+
const result = await generateCandidates(deps, { advice, summaries, forceProfileKey: profileKey });
|
|
184
|
+
const generatedIds = Array.isArray(result.generated) ? result.generated : [];
|
|
185
|
+
const item = (Array.isArray(advice) ? advice : []).find((a) => a !== null && typeof a === 'object' && a.profileKey === profileKey);
|
|
186
|
+
const l2 = summaries !== null && typeof summaries === 'object' ? summaries.l2 : undefined;
|
|
187
|
+
const first = generatedIds.length > 0 ? deps.assets.get(generatedIds[0]) : undefined;
|
|
188
|
+
deps.evoLedger.recordGovernanceAudit(refreshAuditEntry({
|
|
189
|
+
profileKey,
|
|
190
|
+
from: sourceConfigFromKey(profileKey, l2),
|
|
191
|
+
to: first !== undefined ? first.to : null,
|
|
192
|
+
axis: item?.suggestion === '升' ? 'budget' : 'capability',
|
|
193
|
+
direction: item?.suggestion === '升' ? 'up' : 'down',
|
|
194
|
+
}));
|
|
195
|
+
return { expired, generated: generatedIds, skipped: Array.isArray(result.skipped) ? result.skipped : [] };
|
|
196
|
+
}
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// 只 appendFileSync 追加一行,绝不改写既有行。import-free(仅 node 内置
|
|
3
3
|
// fs/crypto/path + lib/core/pure.mjs),可被 bare-CI 单测直接 import。采集异常只
|
|
4
4
|
// warn + 丢失计数,绝不阻断派发(fail-soft)。
|
|
5
|
+
// 轮转:追加成功后在写路径惰性检查阈值(记录数/字节数),命中即封存当前台账为
|
|
6
|
+
// dispatch-<seq>-<ts>.jsonl 并新建空台账续写;封存件超保留上限时淘汰最早者并
|
|
7
|
+
// 提示回放窗口缩短。轮转失败只 warn(本条已落盘),下次写入重试。
|
|
5
8
|
//
|
|
6
9
|
// 审计分级:每类写失败分别计数,计数与 health 落盘到同目录的 ledger.meta.json
|
|
7
10
|
// ({v:1, lostTelemetry, lostGovernance, health})。派发台账写失败计 lostTelemetry
|
|
@@ -17,10 +20,11 @@
|
|
|
17
20
|
// 不入库(写入那个时刻它们尚不存在,事后回填会破坏 append-only 不可变性,只由聚合层
|
|
18
21
|
// 惰性 join)。
|
|
19
22
|
|
|
20
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
21
24
|
import { createHash } from 'node:crypto';
|
|
22
25
|
import { join } from 'node:path';
|
|
23
26
|
import { textFrom } from './pure.mjs';
|
|
27
|
+
import { createRotation } from './evolution-persistence.mjs';
|
|
24
28
|
|
|
25
29
|
const LEDGER_VERSION = 1;
|
|
26
30
|
const GOVERNANCE_AUDIT_VERSION = 1;
|
|
@@ -169,11 +173,26 @@ export function finishContinuableLedger(ledger, base, childId) {
|
|
|
169
173
|
// --- 审计 meta(ledger.meta.json)----------------------------------------------
|
|
170
174
|
|
|
171
175
|
// meta 默认态(缺失/损坏/形状不符均回退此值 → fail-soft 从零)。last_write_ts 为
|
|
172
|
-
// 最近一次成功落盘时间(旧文件无该字段时经合并回退 0)。
|
|
173
|
-
|
|
176
|
+
// 最近一次成功落盘时间(旧文件无该字段时经合并回退 0)。lost_count 计版本迁移
|
|
177
|
+
// 类丢失;rotation_seq 为轮转序号(0 = 尚未轮转)。
|
|
178
|
+
const META_DEFAULTS = {
|
|
179
|
+
v: 1,
|
|
180
|
+
lostTelemetry: 0,
|
|
181
|
+
lostGovernance: 0,
|
|
182
|
+
health: 'ok',
|
|
183
|
+
last_write_ts: 0,
|
|
184
|
+
lost_count: 0,
|
|
185
|
+
rotation_seq: 0,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// 可选字段(last_write_ts/lost_count/rotation_seq)缺省或非数字时回退默认,
|
|
189
|
+
// 保证旧版本写出的 meta 文件可读。
|
|
190
|
+
function optionalNumber(value, fallback) {
|
|
191
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
192
|
+
}
|
|
174
193
|
|
|
175
194
|
// 读取并校验 meta.json;缺失/损坏/形状不符回退默认并 warn(fail-soft 从零)。
|
|
176
|
-
// 只接受 v:1
|
|
195
|
+
// 只接受 v:1 与必填字段的数字/字符串形态,防止被手改的坏元数据冒充。
|
|
177
196
|
function loadAuditMeta(metaFile, warn) {
|
|
178
197
|
if (!existsSync(metaFile)) return { ...META_DEFAULTS };
|
|
179
198
|
try {
|
|
@@ -184,7 +203,13 @@ function loadAuditMeta(metaFile, warn) {
|
|
|
184
203
|
&& typeof parsed.lostGovernance === 'number' && Number.isFinite(parsed.lostGovernance)
|
|
185
204
|
&& (parsed.health === 'ok' || parsed.health === 'degraded')
|
|
186
205
|
) {
|
|
187
|
-
return {
|
|
206
|
+
return {
|
|
207
|
+
...META_DEFAULTS,
|
|
208
|
+
...parsed,
|
|
209
|
+
last_write_ts: optionalNumber(parsed.last_write_ts, 0),
|
|
210
|
+
lost_count: optionalNumber(parsed.lost_count, 0),
|
|
211
|
+
rotation_seq: optionalNumber(parsed.rotation_seq, 0),
|
|
212
|
+
};
|
|
188
213
|
}
|
|
189
214
|
warn(`ledger meta: 审计元数据形状不符,已从零重建(${metaFile})`);
|
|
190
215
|
return { ...META_DEFAULTS };
|
|
@@ -201,7 +226,15 @@ function loadAuditMeta(metaFile, warn) {
|
|
|
201
226
|
// fail-soft:清理 tmp + warn、返回 false。warnOnFailure 供派发遥测失败路径关闭——
|
|
202
227
|
// 该路径已就台账写失败 warn 一次,避免重复告警。
|
|
203
228
|
function persistAuditMeta(metaState, healthValue, warn, { warnOnFailure = true } = {}) {
|
|
204
|
-
const payload = {
|
|
229
|
+
const payload = {
|
|
230
|
+
v: 1,
|
|
231
|
+
lostTelemetry: metaState.lost,
|
|
232
|
+
lostGovernance: metaState.governanceLost,
|
|
233
|
+
health: healthValue,
|
|
234
|
+
last_write_ts: Date.now(),
|
|
235
|
+
lost_count: metaState.migrationLost,
|
|
236
|
+
rotation_seq: metaState.rotationSeq,
|
|
237
|
+
};
|
|
205
238
|
let reported = false;
|
|
206
239
|
try {
|
|
207
240
|
mkdirSync(metaState.dir, { recursive: true });
|
|
@@ -247,42 +280,105 @@ function appendGovernanceAudit(meta, dir, auditFile, persistMeta, warn, onAlert,
|
|
|
247
280
|
}
|
|
248
281
|
}
|
|
249
282
|
|
|
250
|
-
|
|
283
|
+
// 台账现有行数(按换行符计数,文件缺失/读失败回退 0):轮转的记录数判据在
|
|
284
|
+
// 内存累计,避免每次追加都读整文件。
|
|
285
|
+
function countLines(file) {
|
|
286
|
+
try {
|
|
287
|
+
if (!existsSync(file)) return 0;
|
|
288
|
+
const text = readFileSync(file, 'utf8');
|
|
289
|
+
let count = 0;
|
|
290
|
+
for (let i = 0; i < text.length; i += 1) if (text[i] === '\n') count += 1;
|
|
291
|
+
return count;
|
|
292
|
+
} catch {
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 台账字节数(文件缺失/读失败回退 0)。
|
|
298
|
+
function fileBytes(file) {
|
|
299
|
+
try {
|
|
300
|
+
return statSync(file).size;
|
|
301
|
+
} catch {
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 轮转执行器装配:追加成功后惰性检查;首次轮转前若 meta 文件缺失,先按现值落盘
|
|
307
|
+
// (rotation_seq 从 0 起步),seq 递增后再落盘。文件操作异常向上抛,由调用方
|
|
308
|
+
// fail-soft 处理。
|
|
309
|
+
function makeRotationCheck({ dir, file, metaFile, meta, persistMeta, warn, rotation }) {
|
|
310
|
+
return createRotation({
|
|
311
|
+
dir,
|
|
312
|
+
dispatchFile: file,
|
|
313
|
+
getRotationSeq: () => meta.rotationSeq,
|
|
314
|
+
setRotationSeq: (seq) => { meta.rotationSeq = seq; },
|
|
315
|
+
initMetaIfMissing: () => {
|
|
316
|
+
if (!existsSync(metaFile)) persistMeta(meta.health, { warnOnFailure: false });
|
|
317
|
+
},
|
|
318
|
+
persistMeta: () => persistMeta(meta.health),
|
|
319
|
+
warn,
|
|
320
|
+
maxRecords: rotation.maxRecords,
|
|
321
|
+
maxBytes: rotation.maxBytes,
|
|
322
|
+
keep: rotation.keep,
|
|
323
|
+
now: rotation.now,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 追加不可变:mkdir recursive + appendFileSync。写失败只 warn + 丢失计数 ++,
|
|
328
|
+
// 绝不抛错、绝不阻断派发。写失败计 lostTelemetry 并落盘 meta;写成功恢复 ok。
|
|
329
|
+
// 轮转检查独立于追加成败:轮转失败只 warn,本条已落盘、下次写入重试。
|
|
330
|
+
function makeRecord({ dir, file, meta, persistMeta, warn, checkRotation }) {
|
|
331
|
+
return (entry) => {
|
|
332
|
+
try {
|
|
333
|
+
// 序列化也在 try 内(循环引用/BigInt 等理论异常会绕过 fail-soft 外泄;
|
|
334
|
+
// 实际 entry 为自产标量,防御性收口)。
|
|
335
|
+
const line = JSON.stringify({ v: LEDGER_VERSION, ts: Date.now(), ...entry });
|
|
336
|
+
mkdirSync(dir, { recursive: true });
|
|
337
|
+
appendFileSync(file, `${line}\n`, 'utf8');
|
|
338
|
+
meta.lineCount += 1;
|
|
339
|
+
if (meta.health === 'degraded') persistMeta('ok');
|
|
340
|
+
} catch (error) {
|
|
341
|
+
meta.lost += 1;
|
|
342
|
+
warn(`dispatch ledger: 派发台账写入失败,已丢失 ${meta.lost} 条(${error instanceof Error ? error.message : String(error)})`);
|
|
343
|
+
persistMeta(meta.health, { warnOnFailure: false });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const result = checkRotation(meta.lineCount, fileBytes(file));
|
|
348
|
+
if (result.rotated) meta.lineCount = 0;
|
|
349
|
+
} catch (error) {
|
|
350
|
+
warn(`dispatch ledger: 台账轮转失败,将在下次写入重试(${error instanceof Error ? error.message : String(error)})`);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function createEvolutionLedger({ dshHome, pluginVersion, warn = () => {}, onAlert, rotation = {} } = {}) {
|
|
251
356
|
if (typeof dshHome !== 'string' || dshHome.length === 0) {
|
|
252
357
|
throw new Error('dispatch ledger: 派发台账需要 dshHome 目录');
|
|
253
358
|
}
|
|
254
359
|
const dir = join(dshHome, 'subagent-evolution');
|
|
255
360
|
const file = join(dir, 'dispatch.jsonl');
|
|
256
361
|
const auditFile = join(dir, 'governance-audit.jsonl');
|
|
257
|
-
const
|
|
258
|
-
|
|
362
|
+
const metaFile = join(dir, 'ledger.meta.json');
|
|
363
|
+
const persisted = loadAuditMeta(metaFile, warn);
|
|
364
|
+
// 可写审计状态:lost(遥测)/governanceLost/migrationLost/rotationSeq/health,
|
|
365
|
+
// 初始为持久化值;lineCount 为内存行数累计(构造时读文件初始化)。
|
|
259
366
|
const meta = {
|
|
260
367
|
dir,
|
|
261
|
-
metaFile
|
|
368
|
+
metaFile,
|
|
262
369
|
lost: persisted.lostTelemetry,
|
|
263
370
|
governanceLost: persisted.lostGovernance,
|
|
371
|
+
migrationLost: persisted.lost_count,
|
|
372
|
+
rotationSeq: persisted.rotation_seq,
|
|
264
373
|
health: persisted.health,
|
|
374
|
+
lineCount: countLines(file),
|
|
265
375
|
};
|
|
266
376
|
const persistMeta = (healthValue, opts) => persistAuditMeta(meta, healthValue, warn, opts);
|
|
377
|
+
const checkRotation = makeRotationCheck({ dir, file, metaFile, meta, persistMeta, warn, rotation });
|
|
267
378
|
|
|
268
379
|
return {
|
|
269
380
|
baseEntry: (input) => buildBase({ ...input, pluginVersion }),
|
|
270
|
-
|
|
271
|
-
// 绝不抛错、绝不阻断派发。写失败计 lostTelemetry 并落盘 meta;写成功恢复 ok。
|
|
272
|
-
record(entry) {
|
|
273
|
-
try {
|
|
274
|
-
// 序列化也在 try 内(循环引用/BigInt 等理论异常会绕过 fail-soft 外泄;
|
|
275
|
-
// 实际 entry 为自产标量,防御性收口)。
|
|
276
|
-
const line = JSON.stringify({ v: LEDGER_VERSION, ts: Date.now(), ...entry });
|
|
277
|
-
mkdirSync(dir, { recursive: true });
|
|
278
|
-
appendFileSync(file, `${line}\n`, 'utf8');
|
|
279
|
-
if (meta.health === 'degraded') persistMeta('ok');
|
|
280
|
-
} catch (error) {
|
|
281
|
-
meta.lost += 1;
|
|
282
|
-
warn(`dispatch ledger: 派发台账写入失败,已丢失 ${meta.lost} 条(${error instanceof Error ? error.message : String(error)})`);
|
|
283
|
-
persistMeta(meta.health, { warnOnFailure: false });
|
|
284
|
-
}
|
|
285
|
-
},
|
|
381
|
+
record: makeRecord({ dir, file, meta, persistMeta, warn, checkRotation }),
|
|
286
382
|
recordGovernanceAudit(entry) {
|
|
287
383
|
appendGovernanceAudit(meta, dir, auditFile, persistMeta, warn, onAlert, entry);
|
|
288
384
|
},
|
|
@@ -291,10 +387,19 @@ export function createEvolutionLedger({ dshHome, pluginVersion, warn = () => {},
|
|
|
291
387
|
markGovernanceFailure() {
|
|
292
388
|
markGovernanceFailureMeta(meta, persistMeta, onAlert);
|
|
293
389
|
},
|
|
390
|
+
// 版本迁移类丢失计数:资产加载方在跳过不可迁移资产时调用(fail-soft 留痕,
|
|
391
|
+
// 不降级 health)。计数随 meta 落盘,重启保持。
|
|
392
|
+
markMigrationLoss() {
|
|
393
|
+
meta.migrationLost += 1;
|
|
394
|
+
persistMeta(meta.health, { warnOnFailure: false });
|
|
395
|
+
},
|
|
294
396
|
// 进程内 health('ok' | 'degraded')。
|
|
295
397
|
metaHealth: () => meta.health,
|
|
296
398
|
// 设置页 summary 的 audit 字段来源:{lostTelemetry, lostGovernance, health}。
|
|
297
399
|
auditState: () => ({ lostTelemetry: meta.lost, lostGovernance: meta.governanceLost, health: meta.health }),
|
|
298
400
|
lostCount: () => meta.lost,
|
|
401
|
+
// 迁移类丢失计数与当前轮转序号(诊断/测试读取)。
|
|
402
|
+
migrationLostCount: () => meta.migrationLost,
|
|
403
|
+
rotationSeq: () => meta.rotationSeq,
|
|
299
404
|
};
|
|
300
405
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// lib/core/evolution-persistence.mjs — 进化资产持久化机制:版本前向迁移与派发台账轮转。
|
|
2
|
+
// 迁移:可重写资产顶层 v 版本号,load 时仅在内存链式前向迁移(v→v+1),迁移完成后
|
|
3
|
+
// 原子写回(tmp+rename);未知/超前版本或缺少迁移步骤一律 fail-soft 跳过并计丢失,
|
|
4
|
+
// 绝不阻断其余功能、绝不改写台账号行。
|
|
5
|
+
// 轮转:dispatch.jsonl 达阈值(记录数或字节数)时封存为 dispatch-<seq>-<ts>.jsonl,
|
|
6
|
+
// 新建空台账续写;封存只 rename + 新建(内容字节不变、封存件不可变);封存件超过
|
|
7
|
+
// 保留份数时按最早封存淘汰,并一次性提示回放窗口缩短。
|
|
8
|
+
// import-free(仅 node 内置),可被 bare-CI 单测直接导入。
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
// --- 版本迁移 ---------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
// 各可重写资产的当前版本号。新增资产类型在此登记。
|
|
24
|
+
export const CURRENT_VERSIONS = Object.freeze({ summaries: 1 });
|
|
25
|
+
|
|
26
|
+
// 前向迁移表:<kind, fromV> → 迁移函数(入参 v=n 的数据,须返回 v=n+1 的数据)。
|
|
27
|
+
// 当前各资产无跨版本 schema 变化,表为空;机制(链式前向迁移 / 未知版本
|
|
28
|
+
// fail-soft / 迁移后写回)由测试注入样例迁移覆盖。出现真实 schema 变化时在此
|
|
29
|
+
// 登记步骤并同步提升 CURRENT_VERSIONS 对应项。
|
|
30
|
+
export const MIGRATIONS = Object.freeze({});
|
|
31
|
+
|
|
32
|
+
// 单次加载允许的最多迁移步数(防迁移表被误配成死循环)。
|
|
33
|
+
const MAX_MIGRATION_STEPS = 100;
|
|
34
|
+
|
|
35
|
+
// 迁移错误:版本非法/超前/缺步骤等,由调用方按 fail-soft 处理。
|
|
36
|
+
export class MigrationError extends Error {}
|
|
37
|
+
|
|
38
|
+
// 内存链式前向迁移:v 小于当前版本时逐级执行迁移表步骤,返回 {data, migrated,
|
|
39
|
+
// fromV, toV};v 等于当前版本原样返回;v 非法/超前或缺步骤抛 MigrationError。
|
|
40
|
+
export function migrateUp(data, kind, { migrations = MIGRATIONS, currentVersions = CURRENT_VERSIONS } = {}) {
|
|
41
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
42
|
+
throw new MigrationError('数据不是普通对象');
|
|
43
|
+
}
|
|
44
|
+
const current = currentVersions[kind];
|
|
45
|
+
if (typeof current !== 'number' || !Number.isInteger(current)) {
|
|
46
|
+
throw new MigrationError(`未知资产类型:${String(kind)}`);
|
|
47
|
+
}
|
|
48
|
+
const fromV = data.v;
|
|
49
|
+
if (!Number.isInteger(fromV) || fromV < 1) {
|
|
50
|
+
throw new MigrationError(`版本号非法:${String(fromV)}`);
|
|
51
|
+
}
|
|
52
|
+
if (fromV > current) throw new MigrationError(`版本 ${fromV} 超前于当前版本 ${current}`);
|
|
53
|
+
if (fromV === current) return { data, migrated: false, fromV, toV: fromV };
|
|
54
|
+
const steps = (migrations ?? {})[kind] ?? {};
|
|
55
|
+
let cursor = data;
|
|
56
|
+
let v = fromV;
|
|
57
|
+
while (v < current) {
|
|
58
|
+
const step = steps[v];
|
|
59
|
+
if (typeof step !== 'function') {
|
|
60
|
+
throw new MigrationError(`缺少 ${String(kind)} v${v} 到 v${v + 1} 的迁移步骤`);
|
|
61
|
+
}
|
|
62
|
+
cursor = step(cursor);
|
|
63
|
+
if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor)) {
|
|
64
|
+
throw new MigrationError(`迁移步骤 v${v} 产出非法数据`);
|
|
65
|
+
}
|
|
66
|
+
v += 1;
|
|
67
|
+
if (v - fromV > MAX_MIGRATION_STEPS) throw new MigrationError('迁移步数超过上限');
|
|
68
|
+
}
|
|
69
|
+
// 归一顶层版本号:迁移步骤可省略写 v,此处强制对齐当前版本。
|
|
70
|
+
cursor.v = current;
|
|
71
|
+
return { data: cursor, migrated: true, fromV, toV: current };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 迁移写回(tmp+rename):崩溃不产生截断文件;失败仅告警(内存迁移结果仍可用),
|
|
75
|
+
// 不抛、不重复计丢失。
|
|
76
|
+
function writeBack(file, data, warnLog) {
|
|
77
|
+
const tmp = `${file}.tmp`;
|
|
78
|
+
try {
|
|
79
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
80
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
81
|
+
renameSync(tmp, file);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
84
|
+
warnLog(`[dsh-subagent-profile] 资产迁移写回失败(${file}):${error instanceof Error ? error.message : String(error)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 读取并迁移单个资产文件。缺失返回 null(不告警不计丢失);损坏/形状不符/版本
|
|
89
|
+
// 无法迁移 → 告警 + onLoss + null(fail-soft 跳过);迁移发生则原子写回后再返回。
|
|
90
|
+
// logger 取带 warn 方法的对象(缺省静默)。
|
|
91
|
+
export function loadWithMigration(
|
|
92
|
+
file,
|
|
93
|
+
kind,
|
|
94
|
+
{ logger, migrations = MIGRATIONS, currentVersions = CURRENT_VERSIONS, onLoss } = {}
|
|
95
|
+
) {
|
|
96
|
+
const warnLog = (message) => {
|
|
97
|
+
if (logger !== undefined && logger !== null && typeof logger.warn === 'function') logger.warn(message);
|
|
98
|
+
};
|
|
99
|
+
const lose = () => { if (typeof onLoss === 'function') onLoss(); };
|
|
100
|
+
if (!existsSync(file)) return null;
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
warnLog(`[dsh-subagent-profile] ${String(kind)} 资产损坏,跳过该资产(fail-soft):${error instanceof Error ? error.message : String(error)}`);
|
|
106
|
+
lose();
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
110
|
+
warnLog(`[dsh-subagent-profile] ${String(kind)} 资产形状不符,跳过该资产(fail-soft)`);
|
|
111
|
+
lose();
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
let result;
|
|
115
|
+
try {
|
|
116
|
+
result = migrateUp(parsed, kind, { migrations, currentVersions });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const version = parsed.v !== undefined ? String(parsed.v) : '未知';
|
|
119
|
+
warnLog(`[dsh-subagent-profile] ${String(kind)} 资产版本 ${version} 无法迁移,跳过该资产(fail-soft):${error instanceof Error ? error.message : String(error)}`);
|
|
120
|
+
lose();
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (result.migrated) writeBack(file, result.data, warnLog);
|
|
124
|
+
return result.data;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- 台账轮转 ----------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
// 轮转阈值:记录数(>50k)或字节数(>5MB),任一命中即轮转。阈值为建议初值,
|
|
130
|
+
// 待实测后校准。
|
|
131
|
+
export const ROTATION_DEFAULT_MAX_RECORDS = 50000;
|
|
132
|
+
export const ROTATION_DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
133
|
+
// 封存件保留上限(回放窗口):超过按最早封存淘汰。
|
|
134
|
+
export const ROTATION_DEFAULT_KEEP = 8;
|
|
135
|
+
|
|
136
|
+
// 封存件文件名:dispatch-<seq>-<ts>.jsonl(同 seq 同毫秒碰撞时追加 -<n>)。
|
|
137
|
+
const ARCHIVE_PATTERN = /^dispatch-(\d+)-(\d+)(?:-\d+)?\.jsonl$/;
|
|
138
|
+
|
|
139
|
+
// 是否达轮转阈值:记录数超限或字节数超限(任一命中)。
|
|
140
|
+
export function rotationNeeded(records, bytes, { maxRecords = ROTATION_DEFAULT_MAX_RECORDS, maxBytes = ROTATION_DEFAULT_MAX_BYTES } = {}) {
|
|
141
|
+
return records > maxRecords || bytes > maxBytes;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 目录内封存件清单(按 seq、ts 升序 = 最早封存在前)。目录缺失/读失败返回空表。
|
|
145
|
+
function listArchives(dir) {
|
|
146
|
+
let names;
|
|
147
|
+
try {
|
|
148
|
+
names = readdirSync(dir);
|
|
149
|
+
} catch {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
return names
|
|
153
|
+
.map((name) => ARCHIVE_PATTERN.exec(name))
|
|
154
|
+
.filter((match) => match !== null)
|
|
155
|
+
.map((match) => ({ name: match[0], seq: Number(match[1]), ts: Number(match[2]) }))
|
|
156
|
+
.sort((a, b) => a.seq - b.seq || a.ts - b.ts);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 淘汰最早封存件至保留上限;发生淘汰时一次性提示回放窗口缩短(每次轮转最多一次,
|
|
160
|
+
// 因本函数每次轮转只执行一次)。单个文件删除失败不阻断其余淘汰。
|
|
161
|
+
function pruneArchives(dir, keep, warn) {
|
|
162
|
+
const list = listArchives(dir);
|
|
163
|
+
if (list.length <= keep) return 0;
|
|
164
|
+
const excess = list.slice(0, list.length - keep);
|
|
165
|
+
let removed = 0;
|
|
166
|
+
for (const entry of excess) {
|
|
167
|
+
try {
|
|
168
|
+
rmSync(join(dir, entry.name), { force: true });
|
|
169
|
+
removed += 1;
|
|
170
|
+
} catch { /* 单个封存件删除失败不影响主流程 */ }
|
|
171
|
+
}
|
|
172
|
+
warn(`dispatch ledger: 回放窗口缩短——封存件已超过 ${keep} 份,淘汰最旧 ${removed} 份`);
|
|
173
|
+
return removed;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 创建轮转执行器:check(records, bytes) 在阈值命中时执行一次轮转,返回
|
|
177
|
+
// {rotated, archived, seq, pruned};未命中返回 {rotated:false}。seq 账本与 meta
|
|
178
|
+
// 落盘由调用方注入:getRotationSeq/setRotationSeq 管内存 seq,initMetaIfMissing
|
|
179
|
+
// 保证首次轮转先建 meta(seq 现值落盘),persistMeta 以更新后的 seq 落盘。
|
|
180
|
+
// 文件操作异常向上抛,由调用方按 fail-soft 处理(本条已落盘的追加不受影响)。
|
|
181
|
+
export function createRotation({
|
|
182
|
+
dir,
|
|
183
|
+
dispatchFile,
|
|
184
|
+
getRotationSeq = () => 0,
|
|
185
|
+
setRotationSeq = () => {},
|
|
186
|
+
initMetaIfMissing = () => {},
|
|
187
|
+
persistMeta = () => true,
|
|
188
|
+
warn = () => {},
|
|
189
|
+
maxRecords = ROTATION_DEFAULT_MAX_RECORDS,
|
|
190
|
+
maxBytes = ROTATION_DEFAULT_MAX_BYTES,
|
|
191
|
+
keep = ROTATION_DEFAULT_KEEP,
|
|
192
|
+
now = Date.now,
|
|
193
|
+
} = {}) {
|
|
194
|
+
return (records, bytes) => {
|
|
195
|
+
if (!rotationNeeded(records, bytes, { maxRecords, maxBytes })) return { rotated: false };
|
|
196
|
+
initMetaIfMissing();
|
|
197
|
+
const seq = getRotationSeq() + 1;
|
|
198
|
+
const stamp = now();
|
|
199
|
+
let archive = join(dir, `dispatch-${seq}-${stamp}.jsonl`);
|
|
200
|
+
let attempt = 0;
|
|
201
|
+
while (existsSync(archive)) {
|
|
202
|
+
attempt += 1;
|
|
203
|
+
archive = join(dir, `dispatch-${seq}-${stamp}-${attempt}.jsonl`);
|
|
204
|
+
}
|
|
205
|
+
// 只 rename + 新建:封存内容字节不变,绝不重写任何已写行。
|
|
206
|
+
renameSync(dispatchFile, archive);
|
|
207
|
+
setRotationSeq(seq);
|
|
208
|
+
persistMeta();
|
|
209
|
+
writeFileSync(dispatchFile, '', 'utf8');
|
|
210
|
+
const pruned = pruneArchives(dir, keep, warn);
|
|
211
|
+
return { rotated: true, archived: archive, seq, pruned };
|
|
212
|
+
};
|
|
213
|
+
}
|