dsh-agent-foreman 0.0.0-stage → 0.2.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/lib/store.js ADDED
@@ -0,0 +1,705 @@
1
+ /**
2
+ * 包工头 · 名册与台账的持久化。
3
+ *
4
+ * 两块数据,两种寿命:
5
+ *
6
+ * - **名册(roster.json)** 是配置:承包商叫什么、跑哪个 provider/模型、开启与否。
7
+ * 它跨会话存在,由设置页编辑,派单时读取。
8
+ * - **台账(runs.jsonl)** 是运行记录:每次派单追加一行(不存正文,只存结构与
9
+ * 字数/耗时),给设置页的历史面板用。看板正文只活在内存里,一轮任务结束就
10
+ * 随结果回到父智能体手里——它才是真正的交付通道。
11
+ *
12
+ * 这里不做任何 IO 缓存:名册文件是人手改也说得过去的纯 JSON,每次读都重新解析
13
+ * 代价可以忽略,换来"改完立刻生效"。
14
+ *
15
+ * @module dsh-agent-foreman/store
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+
20
+ /** 派单的默认策略;设置页可覆盖,工具调用时也可按单覆盖。 */
21
+ export const DEFAULT_POLICY = Object.freeze({
22
+ /** 同时在场的承包商数量上限。深了会撞 subagents 的全局并发上限。 */
23
+ concurrency: 4,
24
+ /** 单张工单的墙钟预算(秒)。到点未交付即判超时并释放。 */
25
+ timeoutSeconds: 900,
26
+ /** 打回后允许返工的次数。0 表示只审不改。 */
27
+ maxRework: 1,
28
+ /** 派出去的子智能体自身还能不能再派(递归预算)。 */
29
+ maxDepth: 1,
30
+ /** 启动子智能体用的 subagents provider 名(宿主原生是 `spawn`)。 */
31
+ provider: 'spawn',
32
+ /**
33
+ * 团队模式:把承包商做成宿主的「智能体团队」成员来派单。
34
+ *
35
+ * 开启后成员具备三项能力:
36
+ * 1. **原生团队标签页**——成员出现在宿主「智能体团队」区;
37
+ * 2. **常驻跨轮记忆**——成员会话持久,同一人跨单记得上下文;
38
+ * 3. **模型可选**——通过拦截注入在创建时锁定名册模型(`agentOptions`)。
39
+ *
40
+ * 产物回收靠成员自报 `foreman_report`,不依赖读成员会话。读不到会话时自动
41
+ * 退回普通模式,不阻断派单。
42
+ */
43
+ teamMode: true,
44
+ });
45
+
46
+ /** 名册规模上限。超过 24 个承包商同时在场没有意义,只会撞并发上限。 */
47
+ const MEMBER_LIMIT = 24;
48
+ /** 名字长度上限。 */
49
+ const NAME_LIMIT = 40;
50
+ /** 岗位长度上限。和名字同档:岗位是给人看的分组标签,不是一句话。 */
51
+ const ROLE_LIMIT = 40;
52
+ /** 控制字符:名字与岗位都必须是单行可读文本。 */
53
+ const CONTROL_CHARS = /[\u0000-\u001f]/;
54
+ /** 单个承包商提示词长度上限。 */
55
+ const PROMPT_LIMIT = 4000;
56
+
57
+ /** 单个承包商同时干活的工单数上限。 */
58
+ const PARALLEL_LIMIT = 8;
59
+ /** 台账保留条数上限,超出后重写为最近的这一批。 */
60
+ const LEDGER_LIMIT = 400;
61
+
62
+ /** 快照文件名只允许这些字符:runId 直接进路径,必须先过这道闸。 */
63
+ const SNAPSHOT_ID = /^[A-Za-z0-9._-]+$/;
64
+ /** 快照序列化深度上限:看板数据只有两层,深过这个必然是挂上来的宿主对象。 */
65
+ const SNAPSHOT_DEPTH = 8;
66
+ /** 快照读取的默认条数(引擎按 id 找时会要得更多)。 */
67
+ const SNAPSHOT_LIMIT = 50;
68
+ /**
69
+ * 不写进快照的字段名:它们是宿主对象或运行时句柄,既不可序列化,也会把整张
70
+ * 上下文图(含父会话)拖进磁盘。
71
+ */
72
+ const SNAPSHOT_DROP = new Set([
73
+ 'parent', 'ctx', 'context', 'agent', 'agentContext', 'session', 'service', 'services',
74
+ 'signal', 'controller', 'activation', 'dispose', 'logger', 'abortController',
75
+ ]);
76
+ /**
77
+ * 键名像"凭证容器"的字段一律不落盘。
78
+ *
79
+ * 用后缀锚定而不是子串匹配:`inputTokens` / `maxTokens` 这类计量字段要保留,
80
+ * `apiKey` / `access_token` / `secret` 这类才跳过。
81
+ */
82
+ const SECRET_KEY = /(?:^|[-_])(?:api[-_]?key|apikey|key|secret|password|passwd|credential|credentials|authorization|cookie|token)$/i;
83
+ /** 值里像密钥的片段做一次替换:常见前缀 + 足够长的随机串。 */
84
+ const SECRET_VALUE = /\b(?:sk|pk|ghp|gho|ghs|xox[baprs])[-_][A-Za-z0-9_-]{8,}|\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/g;
85
+
86
+ /** 插件自己的错误类型:带一个稳定的 code,路由层据此回 4xx。 */
87
+ export class ForemanError extends Error {
88
+ /**
89
+ * @param message - 面向人/模型的可读说明。
90
+ * @param code - 稳定标识(`DUPLICATE_NAME`、`UNKNOWN_MEMBER` …)。
91
+ */
92
+ constructor(message, code = 'INVALID') {
93
+ super(message);
94
+ this.name = 'ForemanError';
95
+ this.code = code;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 解析数据目录。
101
+ *
102
+ * 和桌面端其它自建插件一致:显式环境变量优先,其次 `LOCALAPPDATA`,最后退回
103
+ * 宿主给的 baseDir。写在固定位置的好处是换 profile 后名册还在,坏处是同一台
104
+ * 机器的所有 profile 共用一份名册——对"承包商队伍"来说这正是想要的。
105
+ *
106
+ * @param baseDir - 宿主提供的 baseDir(`ctx.get('baseDir')`),可能缺省。
107
+ * @param env - 环境变量来源,测试可注入。
108
+ * @returns 绝对路径。
109
+ */
110
+ export function resolveDataDir(baseDir, env = process.env) {
111
+ const explicit = env.DSH_AGENT_FOREMAN_DATA;
112
+ if (typeof explicit === 'string' && explicit.trim() !== '')
113
+ return explicit;
114
+ const local = env.LOCALAPPDATA;
115
+ if (typeof local === 'string' && local.trim() !== '')
116
+ return path.join(local, 'dsh-agent-foreman');
117
+ return path.join(baseDir ?? process.cwd(), '.data');
118
+ }
119
+
120
+ /** 把名字压成稳定 id:只留字母数字与连字符,全大写转小写。 */
121
+ function slugify(name) {
122
+ const slug = name
123
+ .toLowerCase()
124
+ .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
125
+ .replace(/^-+|-+$/g, '');
126
+ return slug === '' ? 'member' : slug;
127
+ }
128
+
129
+ /**
130
+ * 校验并规范化一个承包商条目。
131
+ *
132
+ * 路由三态是这里的核心约定:`provider`/`model` 要么都有值(显式指定),要么都
133
+ * 为空(继承父会话)。只给一半是配置错误——派单时无法推断,宁可现在就报。
134
+ *
135
+ * @param raw - 界面或工具传来的原始对象。
136
+ * @param existing - 现有名册,用于查重。
137
+ * @returns 规范化后的承包商。
138
+ */
139
+ export function normalizeMember(raw, existing = []) {
140
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
141
+ throw new ForemanError('承包商必须是一个对象', 'INVALID_MEMBER');
142
+ const name = typeof raw.name === 'string' ? raw.name.trim() : '';
143
+ if (name === '')
144
+ throw new ForemanError('承包商必须有名字', 'INVALID_NAME');
145
+ if ([...name].length > NAME_LIMIT)
146
+ throw new ForemanError(`承包商名字最多 ${NAME_LIMIT} 个字符`, 'INVALID_NAME');
147
+ if (CONTROL_CHARS.test(name))
148
+ throw new ForemanError('承包商名字不能包含控制字符', 'INVALID_NAME');
149
+
150
+ const provider = typeof raw.provider === 'string' ? raw.provider.trim() : '';
151
+ const model = typeof raw.model === 'string' ? raw.model.trim() : '';
152
+ if ((provider === '') !== (model === ''))
153
+ throw new ForemanError(`承包商「${name}」的 provider 与 model 必须同时给出,或同时留空表示继承父会话`, 'INVALID_ROUTE');
154
+
155
+ const reasoningEffort = typeof raw.reasoningEffort === 'string' ? raw.reasoningEffort.trim() : '';
156
+
157
+ // 岗位(role):派单时 `contractor` 除了写名字,也可以写岗位名,引擎会在该岗位
158
+ // 的启用实例之间轮转。旧名册里的 `role` 曾是一段描述性文字(被并进提示词),
159
+ // 所以这里只把"像岗位"的值当岗位——超长或带控制字符的一律按旧文本处理,
160
+ // 折回提示词、岗位退回名字。硬报错会让老条目在升级后整条被丢掉,那不是兼容。
161
+ const rawRole = typeof raw.role === 'string' ? raw.role.trim() : '';
162
+ const roleLike = rawRole !== '' && [...rawRole].length <= ROLE_LIMIT && !CONTROL_CHARS.test(rawRole);
163
+ const role = roleLike ? rawRole : name;
164
+
165
+ // 每个承包商可以带一段自己的提示词:写给"这个人"的常驻说明(手艺、禁忌)。
166
+ // 设置页用眼睛图标打开编辑,派单时原样塞进它的工单提示词里;旧名册的
167
+ // specialty/note 会在第一次保存时并进来,避免用户升级后丢配置。role 不再并进
168
+ // 提示词——它是可检索的分组,并进去会让"岗位"和"这个人怎么干活"混为一谈;
169
+ // 唯一例外是上面那种旧描述性 role。
170
+ const promptParts = [];
171
+ const sources = { prompt: raw.prompt, specialty: raw.specialty, note: raw.note };
172
+ if (!roleLike && rawRole !== '')
173
+ sources.legacyRole = rawRole.replace(CONTROL_CHARS, ' ');
174
+ for (const [key, rawValue] of Object.entries(sources)) {
175
+ const value = typeof rawValue === 'string' ? rawValue.trim() : '';
176
+ if (value !== '' && !promptParts.includes(value))
177
+ promptParts.push(value);
178
+ }
179
+ const prompt = promptParts.join(String.fromCharCode(10));
180
+ if ([...prompt].length > PROMPT_LIMIT)
181
+ throw new ForemanError(`承包商的提示词最多 ${PROMPT_LIMIT} 个字符`, 'INVALID_PROMPT');
182
+
183
+ // 并发:同一个承包商同时接几张单,默认 1(串行,不会自己跟自己抢文件)。
184
+ const maxParallel = normalizeNumber(raw.maxParallel, 1, 1, PARALLEL_LIMIT);
185
+
186
+ const id = typeof raw.id === 'string' && raw.id.trim() !== '' ? raw.id.trim() : slugify(name);
187
+ const duplicate = existing.find(other => other.id !== id && other.name.toLowerCase() === name.toLowerCase());
188
+ if (duplicate !== undefined)
189
+ throw new ForemanError(`已经有同名承包商「${name}」`, 'DUPLICATE_NAME');
190
+ if (existing.length >= MEMBER_LIMIT && !existing.some(other => other.id === id))
191
+ throw new ForemanError(`名册最多 ${MEMBER_LIMIT} 个承包商`, 'ROSTER_FULL');
192
+
193
+ return { id, name, role, prompt, enabled: raw.enabled !== false, provider, model, reasoningEffort, maxParallel };
194
+ }
195
+
196
+ /**
197
+ * 一个数值策略字段的规范化:非数或越界时回退默认值,避免面板手抖毁掉配置。
198
+ * @param value - 候选值。
199
+ * @param fallback - 默认值。
200
+ * @param min - 下界(含)。
201
+ * @param max - 上界(含)。
202
+ */
203
+ function normalizeNumber(value, fallback, min, max) {
204
+ const parsed = typeof value === 'number' ? value : Number.parseFloat(String(value ?? ''));
205
+ if (!Number.isFinite(parsed))
206
+ return fallback;
207
+ return Math.min(max, Math.max(min, Math.round(parsed)));
208
+ }
209
+
210
+ /**
211
+ * 校验并合并策略字段。
212
+ * @param raw - 部分策略。
213
+ * @param base - 当前策略,作为缺省来源。
214
+ * @returns 完整策略。
215
+ */
216
+ export function normalizePolicy(raw, base = DEFAULT_POLICY) {
217
+ const source = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : {};
218
+ const provider = typeof source.provider === 'string' && source.provider.trim() !== ''
219
+ ? source.provider.trim()
220
+ : base.provider;
221
+ return {
222
+ concurrency: normalizeNumber(source.concurrency, base.concurrency, 1, 8),
223
+ timeoutSeconds: normalizeNumber(source.timeoutSeconds, base.timeoutSeconds, 10, 3600),
224
+ maxRework: normalizeNumber(source.maxRework, base.maxRework, 0, 3),
225
+ maxDepth: normalizeNumber(source.maxDepth, base.maxDepth, 0, 2),
226
+ provider,
227
+ teamMode: typeof source.teamMode === 'boolean' ? source.teamMode : base.teamMode === true,
228
+ };
229
+ }
230
+
231
+ /** 空名册。 */
232
+ function emptyState() {
233
+ return { version: 1, members: [], policy: { ...DEFAULT_POLICY } };
234
+ }
235
+
236
+ /**
237
+ * 把一个 id 规范成能安全拼进文件名的形态。
238
+ *
239
+ * 派单 id 是模型/面板给的字符串,直接进路径就等于把 `<dataDir>/runs/` 的边界
240
+ * 交给调用方——`../../roster.json` 这种值必须在这里被挡住。白名单只有
241
+ * `[A-Za-z0-9._-]`,再排掉 `.` 与 `..`(它们本身也符合白名单,但指向目录)。
242
+ *
243
+ * @param value - 候选 id。
244
+ * @returns 合法 id;不合法时为空串(调用方据此跳过或拒绝)。
245
+ */
246
+ export function sanitizeRunId(value) {
247
+ const text = typeof value === 'string' ? value.trim() : '';
248
+ if (text === '' || text.length > 120)
249
+ return '';
250
+ if (text === '.' || text === '..')
251
+ return '';
252
+ return SNAPSHOT_ID.test(text) ? text : '';
253
+ }
254
+
255
+ /**
256
+ * 深拷贝成"只含 JSON 可序列化字段"的普通对象。
257
+ *
258
+ * 快照要落盘,所以函数、Symbol、循环引用、宿主对象都必须在这里被剪掉;剪掉的
259
+ * 对象字段直接不写,数组里的空洞补 null 以保住下标。字符串里的密钥片段做一次
260
+ * 替换——快照是本机文件,但本机文件也会被复制、贴进 issue。
261
+ *
262
+ * @param value - 任意值。
263
+ * @param depth - 当前深度(内部用)。
264
+ * @param seen - 当前路径上的对象(用于发现循环引用,内部用)。
265
+ * @returns 可 JSON 化的副本;该值不值得保存时返回 undefined。
266
+ */
267
+ function toSerializable(value, depth = 0, seen = new Set()) {
268
+ if (value === null)
269
+ return null;
270
+ const type = typeof value;
271
+ if (type === 'string')
272
+ return value.replace(SECRET_VALUE, '[redacted]');
273
+ if (type === 'number')
274
+ return Number.isFinite(value) ? value : null;
275
+ if (type === 'boolean')
276
+ return value;
277
+ if (type === 'bigint')
278
+ return String(value);
279
+ if (type !== 'object')
280
+ return undefined;
281
+ if (seen.has(value) || depth >= SNAPSHOT_DEPTH)
282
+ return undefined;
283
+ if (value instanceof Date)
284
+ return Number.isFinite(value.getTime()) ? value.toISOString() : undefined;
285
+ if (value instanceof Error)
286
+ return { name: String(value.name ?? 'Error'), message: String(value.message ?? '') };
287
+ seen.add(value);
288
+ try {
289
+ if (Array.isArray(value)) {
290
+ const list = [];
291
+ for (const item of value) {
292
+ const next = toSerializable(item, depth + 1, seen);
293
+ list.push(next === undefined ? null : next);
294
+ }
295
+ return list;
296
+ }
297
+ const output = {};
298
+ for (const [key, item] of Object.entries(value)) {
299
+ if (SNAPSHOT_DROP.has(key) || SECRET_KEY.test(key))
300
+ continue;
301
+ const next = toSerializable(item, depth + 1, seen);
302
+ if (next !== undefined)
303
+ output[key] = next;
304
+ }
305
+ return output;
306
+ }
307
+ finally {
308
+ seen.delete(value);
309
+ }
310
+ }
311
+
312
+ /** 快照的"新鲜度"时间戳:优先更新时间,其次结束时间,最后创建时间。 */
313
+ function snapshotStamp(snapshot) {
314
+ for (const key of ['updatedAt', 'finishedAt', 'createdAt']) {
315
+ const value = snapshot?.[key];
316
+ if (typeof value === 'string' && value !== '')
317
+ return value;
318
+ }
319
+ return '';
320
+ }
321
+
322
+ /**
323
+ * 比较两份快照谁更新:先看 `updatedAt`/`finishedAt`/`createdAt`,再退化到
324
+ * `savedAt`(写盘时刻),最后按文件名字典序兜底,保证结果稳定。
325
+ * @returns 正数表示 left 更新。
326
+ */
327
+ function compareSnapshots(left, right) {
328
+ if (left.stamp !== right.stamp)
329
+ return left.stamp > right.stamp ? 1 : -1;
330
+ const leftSaved = typeof left.snapshot.savedAt === 'string' ? left.snapshot.savedAt : '';
331
+ const rightSaved = typeof right.snapshot.savedAt === 'string' ? right.snapshot.savedAt : '';
332
+ if (leftSaved !== rightSaved)
333
+ return leftSaved > rightSaved ? 1 : -1;
334
+ if (left.name !== right.name)
335
+ return left.name > right.name ? 1 : -1;
336
+ return 0;
337
+ }
338
+
339
+ /**
340
+ * 把临时文件换成正式文件,Windows 上对短暂的占用做几次重试。
341
+ *
342
+ * POSIX 的 rename 会原子覆盖,Windows 上目标文件被杀毒/索引/另一个读者短暂占住
343
+ * 时 `EPERM`/`EBUSY`/`EACCES` 是可能出现的,重试两三次通常会过去。这里用同步
344
+ * 小睡而不是异步等待:写快照的调用点是同步的,异步会把"已经存好了"变成一种
345
+ * 需要 await 的猜测。
346
+ *
347
+ * @param temp - 临时文件。
348
+ * @param file - 目标文件。
349
+ */
350
+ function renameWithRetry(temp, file, attempts = 4) {
351
+ let lastError;
352
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
353
+ try {
354
+ fs.renameSync(temp, file);
355
+ return;
356
+ }
357
+ catch (error) {
358
+ lastError = error;
359
+ const code = error?.code;
360
+ if (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES')
361
+ throw error;
362
+ if (attempt < attempts - 1)
363
+ sleepSync(5 * (attempt + 1));
364
+ }
365
+ }
366
+ throw lastError;
367
+ }
368
+
369
+ /** 同步小睡:只有在已经被阻塞的同步路径上才用得上。 */
370
+ function sleepSync(ms) {
371
+ try {
372
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
373
+ }
374
+ catch {
375
+ // 极少数环境不支持 SharedArrayBuffer:那就直接重试,不睡。
376
+ }
377
+ }
378
+
379
+ /**
380
+ * 名册存储:一个 JSON 文件加一个 JSONL 台账。
381
+ *
382
+ * 写盘用同步 API:每次改动的数据量是几百字节,异步只会让"面板改了但工具读到
383
+ * 旧值"这种竞态变得可能。
384
+ */
385
+ export class ForemanStore {
386
+ /** @param dataDir - 数据目录(见 {@link resolveDataDir})。 */
387
+ constructor(dataDir) {
388
+ this.dataDir = dataDir;
389
+ this.rosterFile = path.join(dataDir, 'roster.json');
390
+ this.ledgerFile = path.join(dataDir, 'runs.jsonl');
391
+ /** 快照目录:`runs.jsonl` 之外的另一份,供重启后只读恢复。 */
392
+ this.runsDir = path.join(dataDir, 'runs');
393
+ this.state = this.#load();
394
+ }
395
+
396
+ /** 读盘;坏文件当作空名册,但不覆盖它(下次写入才会重建)。 */
397
+ #load() {
398
+ try {
399
+ const text = fs.readFileSync(this.rosterFile, 'utf8');
400
+ const parsed = JSON.parse(text);
401
+ const members = Array.isArray(parsed?.members)
402
+ ? parsed.members.flatMap((entry) => {
403
+ try {
404
+ return [normalizeMember(entry, [])];
405
+ }
406
+ catch {
407
+ // 单条坏数据不该让整份名册失效:跳过它,保住其它条目。
408
+ return [];
409
+ }
410
+ })
411
+ : [];
412
+ return { version: 1, members, policy: normalizePolicy(parsed?.policy) };
413
+ }
414
+ catch {
415
+ return emptyState();
416
+ }
417
+ }
418
+
419
+ /** 落盘。名册是配置,写失败必须冒泡——静默丢弃改动比报错更坏。 */
420
+ #persist() {
421
+ fs.mkdirSync(this.dataDir, { recursive: true });
422
+ const temp = `${this.rosterFile}.tmp`;
423
+ fs.writeFileSync(temp, `${JSON.stringify(this.state, null, 2)}\n`, 'utf8');
424
+ fs.renameSync(temp, this.rosterFile);
425
+ }
426
+
427
+ /** 名册副本(防止调用方改到内部状态)。 */
428
+ members() {
429
+ return this.state.members.map(member => ({ ...member }));
430
+ }
431
+
432
+ /** 当前策略副本。 */
433
+ policy() {
434
+ return { ...this.state.policy };
435
+ }
436
+
437
+ /** 名册与策略的快照。 */
438
+ snapshot() {
439
+ return { members: this.members(), policy: this.policy() };
440
+ }
441
+
442
+ /**
443
+ * 整体替换名册(设置页的保存语义)。
444
+ * @param list - 承包商数组。
445
+ * @returns 保存后的名册。
446
+ */
447
+ saveMembers(list) {
448
+ if (!Array.isArray(list))
449
+ throw new ForemanError('名册必须是数组', 'INVALID_ROSTER');
450
+ const members = [];
451
+ for (const raw of list) {
452
+ const candidate = normalizeMember(raw, members);
453
+ if (members.some(other => other.id === candidate.id))
454
+ throw new ForemanError(`承包商 id 重复:「${candidate.id}」`, 'DUPLICATE_ID');
455
+ members.push(candidate);
456
+ }
457
+ this.state.members = members;
458
+ this.#persist();
459
+ return this.members();
460
+ }
461
+
462
+ /**
463
+ * 新增或更新一个承包商(按 id 或名字匹配)。
464
+ * @param raw - 承包商条目。
465
+ * @returns 规范化后的条目。
466
+ */
467
+ upsertMember(raw) {
468
+ const list = this.members();
469
+ const id = typeof raw?.id === 'string' ? raw.id.trim() : '';
470
+ const name = typeof raw?.name === 'string' ? raw.name.trim() : '';
471
+ const index = list.findIndex(other => (id !== '' && other.id === id)
472
+ || (name !== '' && other.name.toLowerCase() === name.toLowerCase()));
473
+ const others = index === -1 ? list : list.filter((_, i) => i !== index);
474
+ const member = normalizeMember(raw, others);
475
+ if (index === -1)
476
+ list.push(member);
477
+ else
478
+ list[index] = member;
479
+ this.saveMembers(list);
480
+ return { ...member };
481
+ }
482
+
483
+ /**
484
+ * 删除一个承包商。
485
+ * @param key - id 或名字。
486
+ * @returns 是否真的删掉了。
487
+ */
488
+ removeMember(key) {
489
+ const wanted = typeof key === 'string' ? key.trim().toLowerCase() : '';
490
+ const list = this.members();
491
+ const next = list.filter(member => member.id.toLowerCase() !== wanted && member.name.toLowerCase() !== wanted);
492
+ if (next.length === list.length)
493
+ return false;
494
+ this.saveMembers(next);
495
+ return true;
496
+ }
497
+
498
+ /**
499
+ * 合并策略字段。
500
+ * @param patch - 部分策略。
501
+ * @returns 保存后的策略。
502
+ */
503
+ savePolicy(patch) {
504
+ this.state.policy = normalizePolicy(patch, this.state.policy);
505
+ this.#persist();
506
+ return this.policy();
507
+ }
508
+
509
+ /**
510
+ * 追加一条运行记录。写失败只记日志不影响派单——台账是附加物,不是交付物。
511
+ * @param record - 精简过的运行记录。
512
+ * @returns 是否写入成功。
513
+ */
514
+ recordRun(record) {
515
+ try {
516
+ fs.mkdirSync(this.dataDir, { recursive: true });
517
+ fs.appendFileSync(this.ledgerFile, `${JSON.stringify(record)}\n`, 'utf8');
518
+ this.#trimLedger();
519
+ return true;
520
+ }
521
+ catch {
522
+ return false;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * 把一份完整产物落盘。
528
+ *
529
+ * 看板里只放前若干千字(见 engine 的 ARTIFACT_LIMIT),被截掉的部分躺在这里,
530
+ * 需要细节时按路径去读——这样"摘要短"和"有据可查"不再互相牵制。
531
+ *
532
+ * @param runId - 派单 id。
533
+ * @param orderId - 工单 id。
534
+ * @param text - 完整产物正文。
535
+ * @returns 绝对路径;写失败时空串(产物落盘是附加物,不该让派单失败)。
536
+ */
537
+ writeArtifact(runId, orderId, text) {
538
+ try {
539
+ const dir = path.join(this.dataDir, 'artifacts');
540
+ fs.mkdirSync(dir, { recursive: true });
541
+ const safe = `${runId}-${orderId}`.replace(/[^\w.-]+/g, '_');
542
+ const file = path.join(dir, `${safe}.md`);
543
+ fs.writeFileSync(file, text, 'utf8');
544
+ return file;
545
+ }
546
+ catch {
547
+ return '';
548
+ }
549
+ }
550
+
551
+ /**
552
+ * 保存一轮运行的可序列化快照到 `<dataDir>/runs/<runId>.json`。
553
+ *
554
+ * 和台账是两份东西:`runs.jsonl` 仍然是设置页的历史(格式不动),快照则是
555
+ * "插件重启后还能把这一轮看板找回来"的还原点,所以它要带上工单正文。
556
+ *
557
+ * 写盘走"临时文件 + rename":进程在写到一半时被杀,读者看到的要么是旧文件、
558
+ * 要么是新文件,不会是一截残缺的 JSON。写失败一律返回 false 不抛——快照是
559
+ * 附加物,写不进去不该让派单本身失败。
560
+ *
561
+ * 落盘前会递归剪掉不可序列化的东西(函数、循环引用、`parent`/`ctx` 这类宿主
562
+ * 对象),并跳过疑似密钥的字段与片段。`member.prompt` 属于本机数据目录里的
563
+ * 配置文本,正常保留。
564
+ *
565
+ * @param run - 一轮运行记录。
566
+ * @returns 是否写入成功。
567
+ */
568
+ saveRunSnapshot(run) {
569
+ try {
570
+ if (run === null || typeof run !== 'object')
571
+ return false;
572
+ // 没有工单数组的记录读回来也没用(看板渲染与恢复都依赖它),
573
+ // 写盘前就拒掉,读写两侧保持同一套形状约束。
574
+ if (!Array.isArray(run.orders))
575
+ return false;
576
+ const id = sanitizeRunId(run.id);
577
+ if (id === '')
578
+ return false;
579
+ const plain = toSerializable(run);
580
+ if (plain === undefined || typeof plain !== 'object' || Array.isArray(plain))
581
+ return false;
582
+ // 剪枝可能把 id 弄丢(比如它本来就不是字符串),这里补回规范化后的值。
583
+ plain.id = id;
584
+ // 快照自带时间戳:引擎恢复时要靠它判断"同一 runId 的哪份更新"。
585
+ plain.savedAt = new Date().toISOString();
586
+ fs.mkdirSync(this.runsDir, { recursive: true });
587
+ const file = path.join(this.runsDir, `${id}.json`);
588
+ const temp = path.join(this.runsDir, `.${id}.${process.pid}.${Date.now()}.tmp`);
589
+ try {
590
+ fs.writeFileSync(temp, `${JSON.stringify(plain, null, 2)}\n`, 'utf8');
591
+ renameWithRetry(temp, file);
592
+ }
593
+ catch {
594
+ try {
595
+ fs.rmSync(temp, { force: true });
596
+ }
597
+ catch {
598
+ // 清理临时文件失败无关紧要:下次 save 会覆盖同名 tmp。
599
+ }
600
+ return false;
601
+ }
602
+ return true;
603
+ }
604
+ catch {
605
+ return false;
606
+ }
607
+ }
608
+
609
+ /**
610
+ * 读出快照目录里最近的若干份运行。
611
+ *
612
+ * 单条损坏只跳过它,不影响其它条——一份被截断的 JSON 不该让整块看板消失。
613
+ * 同一 runId 出现多次(旧版直接覆盖写、手工拷贝留下副本等)时保留较新的那份,
614
+ * 判定依据是 `updatedAt` / `finishedAt`,退化到 `createdAt` / `savedAt`。
615
+ *
616
+ * @param limit - 条数上限。
617
+ * @returns 快照数组,新在前。
618
+ */
619
+ loadRunSnapshots(limit = SNAPSHOT_LIMIT) {
620
+ const max = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : SNAPSHOT_LIMIT;
621
+ let names;
622
+ try {
623
+ names = fs.readdirSync(this.runsDir);
624
+ }
625
+ catch {
626
+ return [];
627
+ }
628
+ /** runId → 最新的一份快照。 */
629
+ const newest = new Map();
630
+ for (const name of names) {
631
+ if (typeof name !== 'string' || !name.endsWith('.json'))
632
+ continue;
633
+ const id = sanitizeRunId(name.slice(0, -'.json'.length));
634
+ if (id === '')
635
+ continue;
636
+ let snapshot;
637
+ try {
638
+ const parsed = JSON.parse(fs.readFileSync(path.join(this.runsDir, name), 'utf8'));
639
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
640
+ continue;
641
+ if (!Array.isArray(parsed.orders))
642
+ continue;
643
+ snapshot = parsed;
644
+ }
645
+ catch {
646
+ // 损坏 / 半截文件:跳过这一条。
647
+ continue;
648
+ }
649
+ // 身份以文件里的 `id` 为准,认不出时才退回文件名:手工拷贝留下的
650
+ // `R-1.backup.json` 与 `R-1.json` 内容里是同一个 runId,属于同一轮。
651
+ const inner = sanitizeRunId(snapshot.id);
652
+ const key = inner !== '' ? inner : id;
653
+ const entry = { name, stamp: snapshotStamp(snapshot), snapshot: { ...snapshot, id: key } };
654
+ const previous = newest.get(key);
655
+ if (previous === undefined || compareSnapshots(entry, previous) >= 0)
656
+ newest.set(key, entry);
657
+ }
658
+ return [...newest.values()]
659
+ .sort((left, right) => compareSnapshots(right, left))
660
+ .slice(0, max)
661
+ .map(entry => entry.snapshot);
662
+ }
663
+
664
+ /** 台账超长时重写为最近 {LEDGER_LIMIT} 条,避免无限膨胀。 */ #trimLedger() {
665
+ try {
666
+ const stat = fs.statSync(this.ledgerFile);
667
+ if (stat.size < 512 * 1024)
668
+ return;
669
+ const lines = fs.readFileSync(this.ledgerFile, 'utf8').split('\n').filter(line => line.trim() !== '');
670
+ if (lines.length <= LEDGER_LIMIT)
671
+ return;
672
+ fs.writeFileSync(this.ledgerFile, `${lines.slice(-LEDGER_LIMIT).join('\n')}\n`, 'utf8');
673
+ }
674
+ catch {
675
+ // 台账裁剪失败可以接受:下一轮还会再试。
676
+ }
677
+ }
678
+
679
+ /**
680
+ * 读最近若干条运行记录(新在前)。
681
+ * @param limit - 条数上限。
682
+ * @returns 运行记录数组;文件缺失或损坏时为空数组。
683
+ */
684
+ recentRuns(limit = 20) {
685
+ try {
686
+ const text = fs.readFileSync(this.ledgerFile, 'utf8');
687
+ return text
688
+ .split('\n')
689
+ .filter(line => line.trim() !== '')
690
+ .slice(-limit)
691
+ .reverse()
692
+ .flatMap((line) => {
693
+ try {
694
+ return [JSON.parse(line)];
695
+ }
696
+ catch {
697
+ return [];
698
+ }
699
+ });
700
+ }
701
+ catch {
702
+ return [];
703
+ }
704
+ }
705
+ }