pi-skill-evolve 0.0.0-stage → 0.1.2
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/CHANGELOG.md +12 -0
- package/README.md +81 -2
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/after.md +10 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/delete-ban/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/after.md +9 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/rg-to-find/fixture.json +6 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/after.md +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/change.diff +8 -0
- package/extensions/skill-evolve/fixtures/selftest/weak-description/fixture.json +6 -0
- package/extensions/skill-evolve/index.ts +300 -0
- package/extensions/skill-evolve/lib/apply-gates.ts +204 -0
- package/extensions/skill-evolve/lib/apply-targets.ts +90 -0
- package/extensions/skill-evolve/lib/apply.ts +141 -0
- package/extensions/skill-evolve/lib/attribution.ts +138 -0
- package/extensions/skill-evolve/lib/audit.ts +221 -0
- package/extensions/skill-evolve/lib/baseline.ts +176 -0
- package/extensions/skill-evolve/lib/child-env.ts +10 -0
- package/extensions/skill-evolve/lib/classify-action.ts +161 -0
- package/extensions/skill-evolve/lib/command.ts +100 -0
- package/extensions/skill-evolve/lib/config-command.ts +38 -0
- package/extensions/skill-evolve/lib/config.ts +194 -0
- package/extensions/skill-evolve/lib/detect.ts +62 -0
- package/extensions/skill-evolve/lib/diff-apply.ts +90 -0
- package/extensions/skill-evolve/lib/diff-gen.ts +78 -0
- package/extensions/skill-evolve/lib/dispatch.ts +300 -0
- package/extensions/skill-evolve/lib/evolution-apply.ts +265 -0
- package/extensions/skill-evolve/lib/evolution-model.ts +72 -0
- package/extensions/skill-evolve/lib/evolution-observe.ts +82 -0
- package/extensions/skill-evolve/lib/evolution-views.ts +89 -0
- package/extensions/skill-evolve/lib/evolution.ts +289 -0
- package/extensions/skill-evolve/lib/flags.ts +44 -0
- package/extensions/skill-evolve/lib/gate0.ts +78 -0
- package/extensions/skill-evolve/lib/hard-rules.ts +167 -0
- package/extensions/skill-evolve/lib/lock.ts +215 -0
- package/extensions/skill-evolve/lib/logfile.ts +52 -0
- package/extensions/skill-evolve/lib/mechanical-report.ts +95 -0
- package/extensions/skill-evolve/lib/mechanical.ts +136 -0
- package/extensions/skill-evolve/lib/metrics.ts +172 -0
- package/extensions/skill-evolve/lib/model-config.ts +95 -0
- package/extensions/skill-evolve/lib/notify.ts +132 -0
- package/extensions/skill-evolve/lib/panel.ts +174 -0
- package/extensions/skill-evolve/lib/pending.ts +34 -0
- package/extensions/skill-evolve/lib/proc.ts +65 -0
- package/extensions/skill-evolve/lib/prompt.ts +80 -0
- package/extensions/skill-evolve/lib/proposal.ts +227 -0
- package/extensions/skill-evolve/lib/prune-plan.ts +62 -0
- package/extensions/skill-evolve/lib/queue.ts +136 -0
- package/extensions/skill-evolve/lib/replay-fixture.ts +80 -0
- package/extensions/skill-evolve/lib/replay.ts +246 -0
- package/extensions/skill-evolve/lib/report.ts +181 -0
- package/extensions/skill-evolve/lib/review-post.ts +101 -0
- package/extensions/skill-evolve/lib/review-prompt.ts +82 -0
- package/extensions/skill-evolve/lib/review-record.ts +63 -0
- package/extensions/skill-evolve/lib/review-run.ts +131 -0
- package/extensions/skill-evolve/lib/review-spawn.ts +83 -0
- package/extensions/skill-evolve/lib/review-supervisor.ts +267 -0
- package/extensions/skill-evolve/lib/round.ts +116 -0
- package/extensions/skill-evolve/lib/run-watch.ts +34 -0
- package/extensions/skill-evolve/lib/runs.ts +208 -0
- package/extensions/skill-evolve/lib/selftest.ts +201 -0
- package/extensions/skill-evolve/lib/session-index.ts +145 -0
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +177 -0
- package/extensions/skill-evolve/lib/single-flight.ts +111 -0
- package/extensions/skill-evolve/lib/spawn-plan.ts +103 -0
- package/extensions/skill-evolve/lib/stale.ts +217 -0
- package/extensions/skill-evolve/lib/telemetry.ts +205 -0
- package/extensions/skill-evolve/lib/ui-gate.ts +34 -0
- package/extensions/skill-evolve/lib/web/actions.ts +299 -0
- package/extensions/skill-evolve/lib/web/activity.ts +57 -0
- package/extensions/skill-evolve/lib/web/applied.ts +64 -0
- package/extensions/skill-evolve/lib/web/approvals.ts +58 -0
- package/extensions/skill-evolve/lib/web/audit-log.ts +115 -0
- package/extensions/skill-evolve/lib/web/autostart.ts +177 -0
- package/extensions/skill-evolve/lib/web/data.ts +298 -0
- package/extensions/skill-evolve/lib/web/diff-lines.ts +141 -0
- package/extensions/skill-evolve/lib/web/filter.ts +203 -0
- package/extensions/skill-evolve/lib/web/http.ts +68 -0
- package/extensions/skill-evolve/lib/web/json-read.ts +33 -0
- package/extensions/skill-evolve/lib/web/panel-pid.ts +99 -0
- package/extensions/skill-evolve/lib/web/render-controls.ts +156 -0
- package/extensions/skill-evolve/lib/web/render-rows.ts +252 -0
- package/extensions/skill-evolve/lib/web/render-run.ts +188 -0
- package/extensions/skill-evolve/lib/web/render-skills.ts +164 -0
- package/extensions/skill-evolve/lib/web/render.ts +136 -0
- package/extensions/skill-evolve/lib/web/reports.ts +160 -0
- package/extensions/skill-evolve/lib/web/run-detail.ts +234 -0
- package/extensions/skill-evolve/lib/web/run-path.ts +21 -0
- package/extensions/skill-evolve/lib/web/server.ts +268 -0
- package/extensions/skill-evolve/lib/web/shell.ts +87 -0
- package/extensions/skill-evolve/lib/web/skills.ts +202 -0
- package/extensions/skill-evolve/lib/web/style.ts +107 -0
- package/extensions/skill-evolve/lib/web/text.ts +52 -0
- package/extensions/skill-evolve/lib/web/trash.ts +36 -0
- package/extensions/skill-evolve/lib/weekly.ts +285 -0
- package/extensions/skill-evolve/prompts//345/244/215/347/233/230.md +172 -0
- package/extensions/skill-evolve/scripts/replay.ts +93 -0
- package/extensions/skill-evolve/test/apply.test.ts +278 -0
- package/extensions/skill-evolve/test/approve.test.ts +248 -0
- package/extensions/skill-evolve/test/attribution.test.ts +133 -0
- package/extensions/skill-evolve/test/audit.test.ts +151 -0
- package/extensions/skill-evolve/test/baseline.test.ts +132 -0
- package/extensions/skill-evolve/test/classify-action.test.ts +133 -0
- package/extensions/skill-evolve/test/command.test.ts +267 -0
- package/extensions/skill-evolve/test/config-command.test.ts +73 -0
- package/extensions/skill-evolve/test/config.test.ts +154 -0
- package/extensions/skill-evolve/test/detect.test.ts +71 -0
- package/extensions/skill-evolve/test/diff-apply.test.ts +46 -0
- package/extensions/skill-evolve/test/dispatch-busy.test.ts +223 -0
- package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +88 -0
- package/extensions/skill-evolve/test/dispatch.test.ts +306 -0
- package/extensions/skill-evolve/test/evolution.test.ts +378 -0
- package/extensions/skill-evolve/test/fake-review-spawn.ts +60 -0
- package/extensions/skill-evolve/test/hard-rules.test.ts +123 -0
- package/extensions/skill-evolve/test/lock.test.ts +138 -0
- package/extensions/skill-evolve/test/logfile.test.ts +85 -0
- package/extensions/skill-evolve/test/mechanical-wiring.test.ts +217 -0
- package/extensions/skill-evolve/test/metrics.test.ts +129 -0
- package/extensions/skill-evolve/test/model-config.test.ts +84 -0
- package/extensions/skill-evolve/test/notify.test.ts +133 -0
- package/extensions/skill-evolve/test/observe.test.ts +100 -0
- package/extensions/skill-evolve/test/panel-pid.test.ts +84 -0
- package/extensions/skill-evolve/test/panel.test.ts +115 -0
- package/extensions/skill-evolve/test/proposal.test.ts +159 -0
- package/extensions/skill-evolve/test/queue.test.ts +110 -0
- package/extensions/skill-evolve/test/reject.test.ts +134 -0
- package/extensions/skill-evolve/test/replay.test.ts +111 -0
- package/extensions/skill-evolve/test/report.test.ts +66 -0
- package/extensions/skill-evolve/test/review-post.test.ts +92 -0
- package/extensions/skill-evolve/test/review-prompt.test.ts +105 -0
- package/extensions/skill-evolve/test/review-spawn.test.ts +81 -0
- package/extensions/skill-evolve/test/review-supervisor.test.ts +243 -0
- package/extensions/skill-evolve/test/selftest.test.ts +113 -0
- package/extensions/skill-evolve/test/session-index.test.ts +128 -0
- package/extensions/skill-evolve/test/single-flight.test.ts +122 -0
- package/extensions/skill-evolve/test/stale.test.ts +272 -0
- package/extensions/skill-evolve/test/telemetry.test.ts +145 -0
- package/extensions/skill-evolve/test/ui-gate.test.ts +77 -0
- package/extensions/skill-evolve/test/web-actions.test.ts +262 -0
- package/extensions/skill-evolve/test/web-applied.test.ts +100 -0
- package/extensions/skill-evolve/test/web-audit.test.ts +87 -0
- package/extensions/skill-evolve/test/web-autostart.test.ts +268 -0
- package/extensions/skill-evolve/test/web-data.test.ts +420 -0
- package/extensions/skill-evolve/test/web-diff-lines.test.ts +155 -0
- package/extensions/skill-evolve/test/web-filter.test.ts +212 -0
- package/extensions/skill-evolve/test/web-panel-pid.test.ts +97 -0
- package/extensions/skill-evolve/test/web-render-run.test.ts +132 -0
- package/extensions/skill-evolve/test/web-render-skills.test.ts +134 -0
- package/extensions/skill-evolve/test/web-render.test.ts +300 -0
- package/extensions/skill-evolve/test/web-run-detail.test.ts +256 -0
- package/extensions/skill-evolve/test/web-server-views.test.ts +113 -0
- package/extensions/skill-evolve/test/web-server.test.ts +465 -0
- package/extensions/skill-evolve/test/web-shell.test.ts +67 -0
- package/extensions/skill-evolve/test/web-skills.test.ts +239 -0
- package/extensions/skill-evolve/test/weekly.test.ts +123 -0
- package/package.json +34 -4
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L0 遥测:append-only JSONL,按天分片 `.pi/evolution/telemetry/YYYY-MM-DD.jsonl`。
|
|
3
|
+
*
|
|
4
|
+
* 三条硬约定:
|
|
5
|
+
* 1. 写失败只记日志,绝不抛到主会话(采集器坏了不能影响用户干活);
|
|
6
|
+
* 2. 每行同时存毫秒戳与 ISO 串(人读 ISO,机器算差值用毫秒);
|
|
7
|
+
* 3. 这里只写事实,不做归因。
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
|
|
12
|
+
/** 写入串行化:生产注入 pi 的 withFileMutationQueue,单测注入直写 */
|
|
13
|
+
export type MutationQueue = <T>(filePath: string, fn: () => Promise<T>) => Promise<T>;
|
|
14
|
+
|
|
15
|
+
export const directQueue: MutationQueue = async (_filePath, fn) => fn();
|
|
16
|
+
|
|
17
|
+
export type TelemetryKind =
|
|
18
|
+
| "advertised"
|
|
19
|
+
| "loaded"
|
|
20
|
+
| "failed"
|
|
21
|
+
| "missed"
|
|
22
|
+
| "pending"
|
|
23
|
+
| "dispatched"
|
|
24
|
+
| "skipped"
|
|
25
|
+
| "superseded"
|
|
26
|
+
| "handler_error"
|
|
27
|
+
/** 机械归因结果:{kind:"attributed", skill, category, reason} */
|
|
28
|
+
| "attributed"
|
|
29
|
+
/** 硬禁令命中:{kind:"conflict", ruleId, level, skill, line} */
|
|
30
|
+
| "conflict";
|
|
31
|
+
|
|
32
|
+
/** 曝光快照里的一条技能(filePath 可能为空:嵌入式调用缺该字段时要能兜底) */
|
|
33
|
+
export interface TelemetrySkill {
|
|
34
|
+
name: string;
|
|
35
|
+
filePath: string;
|
|
36
|
+
disabled: boolean;
|
|
37
|
+
source: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TelemetryEvent {
|
|
41
|
+
ts: number;
|
|
42
|
+
iso: string;
|
|
43
|
+
kind: TelemetryKind;
|
|
44
|
+
sessionId?: string;
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type TelemetryInput = { kind: TelemetryKind; ts?: number; [key: string]: unknown };
|
|
49
|
+
|
|
50
|
+
export interface MissedStat {
|
|
51
|
+
skill: string;
|
|
52
|
+
count: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TelemetrySummary {
|
|
56
|
+
/** 跑了多少轮(advertised 事件数) */
|
|
57
|
+
rounds: number;
|
|
58
|
+
/** 曝光了多少个技能条目(各轮 skills 长度之和) */
|
|
59
|
+
advertised: number;
|
|
60
|
+
loaded: number;
|
|
61
|
+
failed: number;
|
|
62
|
+
missed: number;
|
|
63
|
+
/** 加载事件数 / 曝光条目数;重复加载会让它大于 1,读数时注意 */
|
|
64
|
+
loadRate: number;
|
|
65
|
+
missedTop: MissedStat[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TelemetryWriter {
|
|
69
|
+
append(event: TelemetryInput): void;
|
|
70
|
+
/** 同步追加:只给 `session_shutdown` 用(主进程马上退出,异步队列可能来不及落盘) */
|
|
71
|
+
appendSync(event: TelemetryInput): void;
|
|
72
|
+
flush(): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 本地日期(YYYY-MM-DD):报告按人看的「今天」对齐,不按 UTC */
|
|
76
|
+
export function dayOf(ts: number): string {
|
|
77
|
+
const date = new Date(ts);
|
|
78
|
+
const pad = (value: number): string => String(value).padStart(2, "0");
|
|
79
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function shardFile(dir: string, ts: number): string {
|
|
83
|
+
return path.join(dir, `${dayOf(ts)}.jsonl`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 除零返回 0,不返回 NaN/Infinity:报告里出现 NaN 比没数更糟 */
|
|
87
|
+
export function ratio(part: number, total: number): number {
|
|
88
|
+
return Number.isFinite(part) && Number.isFinite(total) && total > 0 ? part / total : 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function summarize(events: readonly TelemetryEvent[]): TelemetrySummary {
|
|
92
|
+
let rounds = 0;
|
|
93
|
+
let advertised = 0;
|
|
94
|
+
let loaded = 0;
|
|
95
|
+
let failed = 0;
|
|
96
|
+
let missed = 0;
|
|
97
|
+
const misses = new Map<string, number>();
|
|
98
|
+
|
|
99
|
+
for (const event of events) {
|
|
100
|
+
if (event.kind === "advertised") {
|
|
101
|
+
rounds += 1;
|
|
102
|
+
const skills = Array.isArray(event.skills) ? (event.skills as TelemetrySkill[]) : [];
|
|
103
|
+
// disableModelInvocation 的技能压根没进 prompt,不算曝光(它们属于归因 A 类,单独处理)
|
|
104
|
+
advertised += skills.filter((skill) => skill?.disabled !== true).length;
|
|
105
|
+
} else if (event.kind === "loaded") {
|
|
106
|
+
loaded += 1;
|
|
107
|
+
} else if (event.kind === "failed") {
|
|
108
|
+
failed += 1;
|
|
109
|
+
} else if (event.kind === "missed") {
|
|
110
|
+
missed += 1;
|
|
111
|
+
const skill = typeof event.skill === "string" ? event.skill : "(未知)";
|
|
112
|
+
misses.set(skill, (misses.get(skill) ?? 0) + 1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const missedTop = [...misses.entries()]
|
|
117
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
118
|
+
.slice(0, 10)
|
|
119
|
+
.map(([skill, count]) => ({ skill, count }));
|
|
120
|
+
|
|
121
|
+
return { rounds, advertised, loaded, failed, missed, loadRate: ratio(loaded, advertised), missedTop };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createTelemetry(options: {
|
|
125
|
+
dir: string;
|
|
126
|
+
queue: MutationQueue;
|
|
127
|
+
/** 可传 getter:`/skill-evolve on|off` 当场改配置后采集要立刻跟着变,不能靠重启会话 */
|
|
128
|
+
enabled: boolean | (() => boolean);
|
|
129
|
+
log?: (msg: string) => void;
|
|
130
|
+
now?: () => number;
|
|
131
|
+
}): TelemetryWriter {
|
|
132
|
+
const log = options.log ?? (() => {});
|
|
133
|
+
const pending = new Set<Promise<void>>();
|
|
134
|
+
const enabledNow = (): boolean => (typeof options.enabled === "function" ? options.enabled() : options.enabled);
|
|
135
|
+
|
|
136
|
+
const recordOf = (event: TelemetryInput): { record: TelemetryEvent; file: string; line: string } => {
|
|
137
|
+
const ts = typeof event.ts === "number" && Number.isFinite(event.ts) ? event.ts : (options.now?.() ?? Date.now());
|
|
138
|
+
const record: TelemetryEvent = { ...event, ts, iso: new Date(ts).toISOString() };
|
|
139
|
+
const file = shardFile(options.dir, ts);
|
|
140
|
+
return { record, file, line: `${JSON.stringify(record)}\n` };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const append = (event: TelemetryInput): void => {
|
|
144
|
+
if (!enabledNow()) return;
|
|
145
|
+
const { file, line } = recordOf(event);
|
|
146
|
+
const write = options.queue(file, async () => {
|
|
147
|
+
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
|
148
|
+
await fs.promises.appendFile(file, line, "utf8");
|
|
149
|
+
});
|
|
150
|
+
const settled = write.then(
|
|
151
|
+
() => undefined,
|
|
152
|
+
(error: unknown) => {
|
|
153
|
+
log(`[skill-evolve] 遥测写入失败(${file}):${String(error)}`);
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
pending.add(settled);
|
|
157
|
+
void settled.then(() => pending.delete(settled));
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const appendSync = (event: TelemetryInput): void => {
|
|
161
|
+
if (!enabledNow()) return;
|
|
162
|
+
const { file, line } = recordOf(event);
|
|
163
|
+
try {
|
|
164
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
165
|
+
fs.appendFileSync(file, line, "utf8");
|
|
166
|
+
} catch (error) {
|
|
167
|
+
log(`[skill-evolve] 遥测写入失败(${file}):${String(error)}`);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const flush = async (): Promise<void> => {
|
|
172
|
+
await Promise.all([...pending]);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return { append, appendSync, flush };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 读最近 days 天的事件(跨天分片按日期倒推,坏行跳过) */
|
|
179
|
+
export function readEvents(dir: string, days: number, now: number = Date.now()): TelemetryEvent[] {
|
|
180
|
+
const span = Number.isFinite(days) && days > 0 ? Math.floor(days) : 7;
|
|
181
|
+
const events: TelemetryEvent[] = [];
|
|
182
|
+
for (let offset = span - 1; offset >= 0; offset -= 1) {
|
|
183
|
+
const day = new Date(now);
|
|
184
|
+
day.setDate(day.getDate() - offset);
|
|
185
|
+
const file = shardFile(dir, day.getTime());
|
|
186
|
+
let text: string;
|
|
187
|
+
try {
|
|
188
|
+
text = fs.readFileSync(file, "utf8");
|
|
189
|
+
} catch {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
for (const line of text.split("\n")) {
|
|
193
|
+
if (line.trim().length === 0) continue;
|
|
194
|
+
try {
|
|
195
|
+
const parsed: unknown = JSON.parse(line);
|
|
196
|
+
if (parsed && typeof (parsed as TelemetryEvent).kind === "string") {
|
|
197
|
+
events.push(parsed as TelemetryEvent);
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// 坏行是旁路数据的常态,跳过即可,不影响报告结论
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return events;
|
|
205
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 界面推送闸:TUI 默认**零推送**(用户 2026-11-08:「tui 的这些推送不看了,影响我」)。
|
|
3
|
+
*
|
|
4
|
+
* 开关来自配置 `ui.widget` / `ui.entries`,默认全关;显式打开才调宿主的 setWidget / setStatus /
|
|
5
|
+
* appendEntry。实时状态另有出口,不需要占着对话区:
|
|
6
|
+
* `/skill-evolve status`、Web 面板(`/skill-evolve web`)、`.pi/evolution/logs/extension.log`。
|
|
7
|
+
*
|
|
8
|
+
* 为什么闸放在 lib 而不是 index.ts:index.ts 依赖 pi 包,`node --test` 里 import 不进来
|
|
9
|
+
* (找不到 `@earendil-works/pi-coding-agent`),只有下沉到 lib 才能被单测覆盖。
|
|
10
|
+
*/
|
|
11
|
+
import type { UiConfig } from "./config.ts";
|
|
12
|
+
import { panelWidget, statusLine, type PanelState } from "./panel.ts";
|
|
13
|
+
|
|
14
|
+
/** 宿主 TUI 的最小形状(pi 的 ctx.ui 结构兼容):只声明本文件要用的两个方法 */
|
|
15
|
+
export interface UiPanelHost {
|
|
16
|
+
setWidget(id: string, content: string[] | undefined): void;
|
|
17
|
+
setStatus(id: string, content: string | undefined): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const PANEL_ID = "skill-evolve";
|
|
21
|
+
|
|
22
|
+
/** widget 开关关着就一次都不调(连「清空」都不做):默认零推送 */
|
|
23
|
+
export function renderPanel(host: UiPanelHost, flags: UiConfig, state: PanelState): void {
|
|
24
|
+
if (!flags.widget) return;
|
|
25
|
+
host.setWidget(PANEL_ID, panelWidget(state) ?? undefined);
|
|
26
|
+
host.setStatus(PANEL_ID, statusLine(state) ?? undefined);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** entries 开关关着就不写 `/tree` 条目;写入成功返回 true(单测按返回值断言) */
|
|
30
|
+
export function writeEntry<T>(flags: UiConfig, write: (entry: T) => void, entry: T): boolean {
|
|
31
|
+
if (!flags.entries) return false;
|
|
32
|
+
write(entry);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 写操作的唯一入口:参数校验 → 调注入的回调 → 记一条审计。
|
|
3
|
+
*
|
|
4
|
+
* 三条纪律:
|
|
5
|
+
* 1. **不重写业务**:开关 / 派发 / 采纳 / 清理的实现都在 lib 与 index.ts 里,这里只做转发。
|
|
6
|
+
* 网页自己再实现一遍,就会变成「网页点的派发不走去抖、TUI 点的走」这种两套语义;
|
|
7
|
+
* 2. **成功与失败都留痕**:过了 csrf 的每次动作都写一条 actions.log,回调抛错也要写 ——
|
|
8
|
+
* 只记成功的审计等于「出事的时候什么都没有」;
|
|
9
|
+
* 3. **校验口径与 /approve 完全一致**:Content-Type / Host / Origin / 2KB / csrf 五道,
|
|
10
|
+
* 任何一道不过都不执行动作(`/action` 与 `/toggle` 不因为「是新增路径」就少一道)。
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import type * as http from "node:http";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { loadConfig, type ConfigFlag } from "../config.ts";
|
|
16
|
+
import { FLAG_FIELDS, parseFlag } from "../flags.ts";
|
|
17
|
+
import { appendAudit, type AuditAction, type AuditResult } from "./audit-log.ts";
|
|
18
|
+
import { parseRedirectView } from "./filter.ts";
|
|
19
|
+
import { FORM_TYPE, MAX_BODY_BYTES, readBody, sendBack, sendText, sourceDenied } from "./http.ts";
|
|
20
|
+
import { readJson } from "./json-read.ts";
|
|
21
|
+
import { resolveRunDir } from "./run-path.ts";
|
|
22
|
+
import { CHILD_LOG } from "../spawn-plan.ts";
|
|
23
|
+
import { SUPERVISOR_LOG } from "../review-spawn.ts";
|
|
24
|
+
import { readLogTail } from "../stale.ts";
|
|
25
|
+
|
|
26
|
+
/** `/action` 的 `name` 白名单:三个名字各自对应一个注入回调(开关走独立的 `/toggle?field=`) */
|
|
27
|
+
export const ACTION_NAMES = ["run", "rerun", "cleanup"] as const;
|
|
28
|
+
export type ActionName = (typeof ACTION_NAMES)[number];
|
|
29
|
+
|
|
30
|
+
/** 一个写动作:返回给人看的结论(首行进 303 的 msg / err) */
|
|
31
|
+
export type WebAction = (runId: string) => Promise<string>;
|
|
32
|
+
/** 单个开关:`field` = telemetry(采集,不花 token)/ enabled(复盘,花 token);`next` 由这里取反后传入 */
|
|
33
|
+
export type ToggleHook = (field: ConfigFlag, next: boolean) => Promise<string>;
|
|
34
|
+
/** 立即复盘 / 重跑:都是「触发一次新的复盘」,由 index.ts 接 evo.runNow */
|
|
35
|
+
export type RunHook = () => Promise<string>;
|
|
36
|
+
|
|
37
|
+
export interface WriteHooks {
|
|
38
|
+
/** 批准:由扩展注入 evo.apply.approve(runId, false),与 TUI 同一条路 */
|
|
39
|
+
onApprove?: WebAction;
|
|
40
|
+
/** 拒绝:只改队列与 run.json 状态,不碰任何技能文件 */
|
|
41
|
+
onReject?: WebAction;
|
|
42
|
+
onToggle?: ToggleHook;
|
|
43
|
+
onRunNow?: RunHook;
|
|
44
|
+
/** 清理记录:只对 stale 放行,实现是把 run 目录移进 runs/.trash/ */
|
|
45
|
+
onCleanup?: WebAction;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WriteContext extends WriteHooks {
|
|
49
|
+
evolutionDir: string;
|
|
50
|
+
csrf: string;
|
|
51
|
+
/** 实际监听端口(port=0 时只有监听后才知道):Host / Origin 校验不接受外部声明 */
|
|
52
|
+
port(): number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const POST_PATHS = new Set(["/approve", "/reject", "/action", "/toggle"]);
|
|
56
|
+
/** 与 lib/report.ts 的 renderManualDispatch 首行对齐:能派发才是 ok,其余都是 partial */
|
|
57
|
+
const DISPATCHED_PREFIX = "已派生复盘:";
|
|
58
|
+
/** `child.log` 优先,其次 `supervisor.log`:前者是子进程自己的输出,会话细节都在里面 */
|
|
59
|
+
const LOG_TAIL_LINES = 200;
|
|
60
|
+
|
|
61
|
+
export function isPostPath(pathname: string): boolean {
|
|
62
|
+
return POST_PATHS.has(pathname);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 动作失败 / 校验失败时的统一收尾:一句人话 + 一条 error 审计(runId / field 原样入库) */
|
|
66
|
+
function reject(
|
|
67
|
+
ctx: WriteContext,
|
|
68
|
+
response: http.ServerResponse,
|
|
69
|
+
action: AuditAction,
|
|
70
|
+
message: string,
|
|
71
|
+
extra: { runId?: string; field?: string } = {},
|
|
72
|
+
): void {
|
|
73
|
+
console.error(`[skill-evolve] Web 面板 ${action} 被拒:${message}`);
|
|
74
|
+
appendAudit(ctx.evolutionDir, { at: Date.now(), action, ...extra, result: `error: ${message}`, via: "web" });
|
|
75
|
+
sendText(response, 400, `400:${message}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 本实例没注入回调 = 只读面板:403(路径合法,只是没有实现) */
|
|
79
|
+
function rejectReadOnly(response: http.ServerResponse): void {
|
|
80
|
+
sendText(response, 403, "403:本实例只读(没注入写回调),不接受写操作\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function errorText(error: unknown): string {
|
|
84
|
+
const first = String(error).split("\n")[0]!.trim();
|
|
85
|
+
return first.length > 0 ? first.slice(0, 200) : "未知错误";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 派发类动作的结论:`已派生复盘:<runId>` 才算 ok,其余(总开关关 / 已有复盘在跑 / 去抖窗口里)
|
|
90
|
+
* 都是 partial。这不是抠字眼 —— 审计里 ok 与 partial 混在一起,回头就查不出「点了十次都没派发」。
|
|
91
|
+
*/
|
|
92
|
+
function dispatchResult(text: string): AuditResult {
|
|
93
|
+
return text.startsWith(DISPATCHED_PREFIX) ? "ok" : "partial";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 执行 + 记审计 + 303 回跳;回调抛错也回 303(网页不该因为一次动作出错就变成 500 页) */
|
|
97
|
+
async function settle(
|
|
98
|
+
ctx: WriteContext,
|
|
99
|
+
response: http.ServerResponse,
|
|
100
|
+
action: AuditAction,
|
|
101
|
+
view: string,
|
|
102
|
+
call: () => Promise<string>,
|
|
103
|
+
extra: { runId?: string; field?: string } = {},
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
try {
|
|
106
|
+
const text = await call();
|
|
107
|
+
appendAudit(ctx.evolutionDir, {
|
|
108
|
+
at: Date.now(),
|
|
109
|
+
action,
|
|
110
|
+
...extra,
|
|
111
|
+
result: action === "run" || action === "rerun" ? dispatchResult(text) : "ok",
|
|
112
|
+
via: "web",
|
|
113
|
+
});
|
|
114
|
+
sendBack(response, "msg", text, view);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const message = errorText(error);
|
|
117
|
+
console.error(`[skill-evolve] Web 面板 ${action} 失败:${message}`);
|
|
118
|
+
appendAudit(ctx.evolutionDir, { at: Date.now(), action, ...extra, result: `error: ${message}`, via: "web" });
|
|
119
|
+
sendBack(response, "err", message, view);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** run.json 的 status;读不到就是 null —— 当它不是 stale(宁可不让清理,也不误移一个活着的 run) */
|
|
124
|
+
function runStatusOf(runDir: string): string | null {
|
|
125
|
+
const raw = readJson<Record<string, unknown>>(path.join(runDir, "run.json"));
|
|
126
|
+
return typeof raw?.status === "string" ? raw.status : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 取 runId 并验目录:白名单 → 必须仍在 runs/ 下 → 必须真实存在。
|
|
131
|
+
* 三道都不过就在这里写一条 error 审计并回 400,调用方拿到 null 直接 return。
|
|
132
|
+
*/
|
|
133
|
+
function resolveTarget(
|
|
134
|
+
ctx: WriteContext,
|
|
135
|
+
response: http.ServerResponse,
|
|
136
|
+
action: AuditAction,
|
|
137
|
+
form: URLSearchParams,
|
|
138
|
+
): { runId: string; runDir: string } | null {
|
|
139
|
+
const runId = (form.get("runId") ?? "").trim();
|
|
140
|
+
if (runId.length === 0) {
|
|
141
|
+
reject(ctx, response, action, "缺少 runId");
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const runDir = resolveRunDir(ctx.evolutionDir, runId);
|
|
145
|
+
if (runDir === null) {
|
|
146
|
+
reject(ctx, response, action, "runId 不合法(只允许字母数字与 . _ -)", { runId });
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
if (!fs.existsSync(runDir)) {
|
|
150
|
+
reject(ctx, response, action, `找不到这个 run:${runId}`, { runId });
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return { runId, runDir };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `POST /action`:三个名字各自校验参数,然后交给对应回调(开关另有 `/toggle`) */
|
|
157
|
+
async function handleNamedAction(
|
|
158
|
+
ctx: WriteContext,
|
|
159
|
+
response: http.ServerResponse,
|
|
160
|
+
form: URLSearchParams,
|
|
161
|
+
view: string,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
const raw = (form.get("name") ?? "").trim();
|
|
164
|
+
if (!(ACTION_NAMES as readonly string[]).includes(raw)) {
|
|
165
|
+
sendText(response, 400, `400:name 只认 ${ACTION_NAMES.join(" / ")}\n`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const action = raw as ActionName;
|
|
169
|
+
if (action === "run") {
|
|
170
|
+
const runNow = ctx.onRunNow;
|
|
171
|
+
if (!runNow) return rejectReadOnly(response);
|
|
172
|
+
await settle(ctx, response, action, view, () => runNow());
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (action === "rerun") {
|
|
176
|
+
const runNow = ctx.onRunNow;
|
|
177
|
+
if (!runNow) return rejectReadOnly(response);
|
|
178
|
+
const target = resolveTarget(ctx, response, action, form);
|
|
179
|
+
if (target === null) return;
|
|
180
|
+
// 重跑不复用旧 runId:语义是「再派发一次复盘」,新旧两份结果都要留着对比
|
|
181
|
+
await settle(ctx, response, action, view, () => runNow(), { runId: target.runId });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const cleanup = ctx.onCleanup;
|
|
185
|
+
if (!cleanup) return rejectReadOnly(response);
|
|
186
|
+
const target = resolveTarget(ctx, response, action, form);
|
|
187
|
+
if (target === null) return;
|
|
188
|
+
const status = runStatusOf(target.runDir);
|
|
189
|
+
if (status !== "stale") {
|
|
190
|
+
reject(ctx, response, action, `只有卡死(stale)的 run 能清理,当前状态:${status ?? "未知"}`, { runId: target.runId });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
await settle(ctx, response, action, view, () => cleanup(target.runId), { runId: target.runId });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `POST /toggle?field=telemetry|enabled`:只翻一个字段。五道校验与 /action 共用,
|
|
198
|
+
* 查询串只决定「翻哪一个」,不影响任何一道校验。
|
|
199
|
+
*/
|
|
200
|
+
async function handleToggleField(
|
|
201
|
+
ctx: WriteContext,
|
|
202
|
+
response: http.ServerResponse,
|
|
203
|
+
form: URLSearchParams,
|
|
204
|
+
url: URL,
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
const raw = url.searchParams.get("field");
|
|
207
|
+
const field = parseFlag(raw);
|
|
208
|
+
if (field === null) {
|
|
209
|
+
// 不猜:写错一个字段名绝不能静默改到另一个开关(把复盘当成采集翻,本意完全被丢掉)
|
|
210
|
+
const shown = raw === null ? "空" : raw;
|
|
211
|
+
reject(ctx, response, "toggle", `field 只认 ${FLAG_FIELDS.join(" / ")}(收到 ${shown})`, raw === null ? {} : { field: raw });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const toggle = ctx.onToggle;
|
|
215
|
+
if (!toggle) return rejectReadOnly(response);
|
|
216
|
+
// 取反用的是「读配置」而不是页面上的旧文字:两个标签页开着同一个面板时,页面显示的状态可能已经过期
|
|
217
|
+
const next = !loadConfig(ctx.evolutionDir)[field];
|
|
218
|
+
await settle(ctx, response, "toggle", parseRedirectView(form.get("view")), () => toggle(field, next), { field });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* POST 总入口。校验顺序与历史一致:路径白名单 → Content-Type → Host/Origin → 体积 → csrf,
|
|
223
|
+
* 之后才看「这个动作有没有实现」。顺序本身就是信息:csrf 不对的请求连 runId 都不该被读。
|
|
224
|
+
*/
|
|
225
|
+
export async function handlePost(
|
|
226
|
+
ctx: WriteContext,
|
|
227
|
+
request: http.IncomingMessage,
|
|
228
|
+
response: http.ServerResponse,
|
|
229
|
+
url: URL,
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
if (!POST_PATHS.has(url.pathname)) {
|
|
232
|
+
response.setHeader("allow", "GET");
|
|
233
|
+
sendText(response, 405, "405:面板只提供 GET,写操作只有 /approve、/reject、/action 与 /toggle?field=\n");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const type = String(request.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
|
|
237
|
+
if (type !== FORM_TYPE) {
|
|
238
|
+
sendText(response, 415, `415:Content-Type 必须是 ${FORM_TYPE}\n`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const denied = sourceDenied(request, ctx.port());
|
|
242
|
+
if (denied) {
|
|
243
|
+
sendText(response, 403, `403:${denied}\n`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const body = await readBody(request, MAX_BODY_BYTES);
|
|
247
|
+
if (body === null) {
|
|
248
|
+
sendText(response, 413, `413:请求体超过 ${MAX_BODY_BYTES} 字节\n`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const form = new URLSearchParams(body);
|
|
252
|
+
// csrf 先比:令牌不对就什么都不做,连 name / runId 都不看
|
|
253
|
+
if (form.get("csrf") !== ctx.csrf) {
|
|
254
|
+
sendText(response, 403, "403:csrf 校验失败,本次请求未执行任何动作\n");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (url.pathname === "/toggle") {
|
|
258
|
+
await handleToggleField(ctx, response, form, url);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (url.pathname === "/action") {
|
|
262
|
+
// 回跳页由表单里的 view 决定:控制条动作用户在哪一页点的,就回哪一页
|
|
263
|
+
await handleNamedAction(ctx, response, form, parseRedirectView(form.get("view")));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const action: AuditAction = url.pathname === "/approve" ? "approve" : "reject";
|
|
267
|
+
const run = action === "approve" ? ctx.onApprove : ctx.onReject;
|
|
268
|
+
if (!run) return rejectReadOnly(response);
|
|
269
|
+
const runId = (form.get("runId") ?? "").trim();
|
|
270
|
+
if (runId.length === 0) {
|
|
271
|
+
// 与 /action 的校验失败同一种收尾:失败也要在审计里留一条
|
|
272
|
+
reject(ctx, response, action, "缺少 runId");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
await settle(ctx, response, action, "approvals", () => run(runId), { runId });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* `GET /log?runId=`:只读,不做 csrf(没有副作用,也不会改任何状态)。
|
|
280
|
+
* 内容只可能是 run 目录下的两个日志文件之一,且 runId 过了白名单 + 目录归属校验,否则一律 404 ——
|
|
281
|
+
* 404 而不是 403,是因为「这个 run 不存在」与「你想读别人的文件」不该给出可区分的回答。
|
|
282
|
+
*/
|
|
283
|
+
export function handleLog(evolutionDir: string, response: http.ServerResponse, url: URL): void {
|
|
284
|
+
const runId = (url.searchParams.get("runId") ?? "").trim();
|
|
285
|
+
const runDir = resolveRunDir(evolutionDir, runId);
|
|
286
|
+
if (runDir === null || !fs.existsSync(runDir)) {
|
|
287
|
+
sendText(response, 404, `404:没有这个 run 的日志(runId=${runId})\n`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const lines = readLogTail(path.join(runDir, CHILD_LOG), LOG_TAIL_LINES);
|
|
291
|
+
const source = lines.length > 0 ? CHILD_LOG : SUPERVISOR_LOG;
|
|
292
|
+
const tail = lines.length > 0 ? lines : readLogTail(path.join(runDir, SUPERVISOR_LOG), LOG_TAIL_LINES);
|
|
293
|
+
if (tail.length === 0) {
|
|
294
|
+
sendText(response, 404, `404:${runId} 还没有日志(${CHILD_LOG} 与 ${SUPERVISOR_LOG} 都不存在或为空)\n`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// 首行写清这是谁的哪份日志:面板上点「看日志」看到的是纯文本,没有它分不清是子进程还是监督进程
|
|
298
|
+
sendText(response, 200, `# ${runId} · ${source}(末 ${tail.length} 行)\n${tail.join("\n")}\n`);
|
|
299
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 顶部控制条要的两个事实:总开关现在是什么状态、最后一次动作发生在什么时候。
|
|
3
|
+
*
|
|
4
|
+
* 「最后活动」取两个来源的较大值:`runs/<runId>/run.json` 的 dispatchedAt(复盘派发)与遥测最新事件的 ts(采集)。
|
|
5
|
+
* 一个记录都没有就返回 null,让页面写「还没有任何记录」—— 绝不能拿 now 顶替,
|
|
6
|
+
* 否则一台刚装好、什么都没跑过的机器会显示「刚刚有活动」。
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { loadConfig } from "../config.ts";
|
|
11
|
+
import { listRuns } from "../runs.ts";
|
|
12
|
+
import { readLogTail } from "../stale.ts";
|
|
13
|
+
|
|
14
|
+
export interface WebControls {
|
|
15
|
+
/** 采集遥测开关(config.telemetry,与 `/skill-evolve collect` 同一个字段):只写本地 jsonl,不花 token */
|
|
16
|
+
telemetry: boolean;
|
|
17
|
+
/** 自动复盘开关(config.enabled,与 `/skill-evolve review` 同一个字段):派生子进程,花 token */
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
lastActivityAt: number | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 只需要「最新那一条」,但 dispatchedAt 为 null 的 run 会占名额,多取一些兜住 */
|
|
23
|
+
const RUN_SCAN_LIMIT = 50;
|
|
24
|
+
/** 一天一个分片,最后一条就是最新的;50 行足够跳过坏行与残行 */
|
|
25
|
+
const TELEMETRY_TAIL_LINES = 50;
|
|
26
|
+
|
|
27
|
+
function latestTelemetryTs(telemetryDir: string): number | null {
|
|
28
|
+
let names: string[];
|
|
29
|
+
try {
|
|
30
|
+
names = fs.readdirSync(telemetryDir).filter((name) => name.endsWith(".jsonl"));
|
|
31
|
+
} catch {
|
|
32
|
+
return null; // 没开过采集 = 没有分片目录,不是错误
|
|
33
|
+
}
|
|
34
|
+
// 分片名是 YYYY-MM-DD.jsonl:字典序倒排即时间倒排,第一个读得出 ts 的分片就是最新的
|
|
35
|
+
for (const name of names.sort().reverse()) {
|
|
36
|
+
const lines = readLogTail(path.join(telemetryDir, name), TELEMETRY_TAIL_LINES);
|
|
37
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
38
|
+
try {
|
|
39
|
+
const ts = (JSON.parse(lines[index]!) as { ts?: unknown }).ts;
|
|
40
|
+
if (typeof ts === "number" && Number.isFinite(ts)) return ts;
|
|
41
|
+
} catch {
|
|
42
|
+
// 坏行(写入中途被杀)继续往前找,不能因为最后一行坏了就说「没有活动」
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function collectControls(evolutionDir: string): WebControls {
|
|
50
|
+
let latest = latestTelemetryTs(path.join(evolutionDir, "telemetry"));
|
|
51
|
+
for (const run of listRuns(evolutionDir, RUN_SCAN_LIMIT)) {
|
|
52
|
+
if (run.dispatchedAt === null) continue;
|
|
53
|
+
if (latest === null || run.dispatchedAt > latest) latest = run.dispatchedAt;
|
|
54
|
+
}
|
|
55
|
+
const config = loadConfig(evolutionDir);
|
|
56
|
+
return { telemetry: config.telemetry, enabled: config.enabled, lastActivityAt: latest };
|
|
57
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「已采纳」列表的数据:队列里 `status="applied"` 的条目 + 提案摘要 + 五闸结果。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个文件:「已采纳」是用户要反复回看的一页(改了什么、五闸过没过、什么时候落的地),
|
|
5
|
+
* 它的字段比其它列表都多(提案类型 / 行数 / 采纳时刻 / 逐闸结果),塞回 data.ts 会把那个文件推过 300 行。
|
|
6
|
+
*
|
|
7
|
+
* 只读:读 02-proposal.json、04-decision.json、06-verify.json,一个都不写。
|
|
8
|
+
*/
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { readVerify } from "../apply.ts";
|
|
11
|
+
import { readProposalSummary } from "../proposal.ts";
|
|
12
|
+
import type { QueueItem } from "../queue.ts";
|
|
13
|
+
import { asNumber, asText, readJson } from "./json-read.ts";
|
|
14
|
+
|
|
15
|
+
/** 已采纳列表上限:与 DONE_LIMIT 同量级 —— 网页看近期,不是审计日志 */
|
|
16
|
+
export const APPLIED_LIMIT = 50;
|
|
17
|
+
|
|
18
|
+
/** 一个已采纳条目:技能 · 提案类型 · +N/-M · 采纳时间 · 五闸是否全 ok */
|
|
19
|
+
export interface AppliedItem {
|
|
20
|
+
runId: string;
|
|
21
|
+
/** auto(不需要人) / approval(人批) */
|
|
22
|
+
level: string;
|
|
23
|
+
skill: string;
|
|
24
|
+
/** 提案类型:improve / create / merge / prune;读不到 02-proposal.json 时写「—」 */
|
|
25
|
+
type: string;
|
|
26
|
+
added: number;
|
|
27
|
+
removed: number;
|
|
28
|
+
/** 采纳时间:取 04-decision.json 的 at(队列 createdAt 是入队时刻),读不到退回入队时刻 */
|
|
29
|
+
appliedAt: number | null;
|
|
30
|
+
/** 五闸逐条(06-verify.json);没有记录就是空数组 */
|
|
31
|
+
gates: { gate: string; name: string; ok: boolean }[];
|
|
32
|
+
/** 五闸是否全 ok;null = 没有 06-verify.json(判不了,不能显示成失败) */
|
|
33
|
+
gatesOk: boolean | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 采纳时刻为什么取 04-decision.json 而不是队列 createdAt:
|
|
38
|
+
* 队列 createdAt 是**入队**时刻(可能比批准早几小时),用户看「已采纳」要看的是技能文件真正落地的时刻。
|
|
39
|
+
*/
|
|
40
|
+
export function buildApplied(items: readonly QueueItem[], evolutionDir: string): AppliedItem[] {
|
|
41
|
+
const rows: AppliedItem[] = [];
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
if (item.status !== "applied") continue;
|
|
44
|
+
const runDir = path.join(evolutionDir, "runs", item.runId);
|
|
45
|
+
const proposal = readProposalSummary(runDir);
|
|
46
|
+
const verify = readVerify(runDir);
|
|
47
|
+
const decision = readJson<Record<string, unknown>>(path.join(runDir, "04-decision.json"));
|
|
48
|
+
const gates = Array.isArray(verify?.gates)
|
|
49
|
+
? verify.gates.map((gate) => ({ gate: asText(gate.gate), name: asText(gate.name), ok: gate.ok === true }))
|
|
50
|
+
: [];
|
|
51
|
+
rows.push({
|
|
52
|
+
runId: item.runId,
|
|
53
|
+
level: item.level,
|
|
54
|
+
skill: item.skill.length > 0 ? item.skill : (proposal?.skill ?? ""),
|
|
55
|
+
type: proposal?.type ?? asText(item.proposalType, "—"),
|
|
56
|
+
added: proposal?.added ?? 0,
|
|
57
|
+
removed: proposal?.removed ?? 0,
|
|
58
|
+
appliedAt: asNumber(decision?.at) ?? asNumber(item.createdAt),
|
|
59
|
+
gates,
|
|
60
|
+
gatesOk: verify ? gates.every((gate) => gate.ok) : null,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return rows.sort((a, b) => (b.appliedAt ?? 0) - (a.appliedAt ?? 0)).slice(0, APPLIED_LIMIT);
|
|
64
|
+
}
|