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,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「待审批」视图的数据:只收 `status === "pending" && level === "approval"` 的队列条目。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不复用 pending 视图的条目:待处理页混了正在跑 / 卡死 / 待入队,那一页是「现在发生了什么」;
|
|
5
|
+
* 待审批页要能直接点按钮,所以每条必须自带批与不批所需的最小事实 ——
|
|
6
|
+
* 改了哪个文件(targets)、改了多少行(proposal)、评审怎么说(verdict + blocking 条数)。
|
|
7
|
+
*
|
|
8
|
+
* 纪律与 data.ts 相同:只读、坏数据当空、绝不因为一条烂 run.json 让整页 500。
|
|
9
|
+
*/
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import type { QueueItem } from "../queue.ts";
|
|
12
|
+
import { readProposalSummary } from "../proposal.ts";
|
|
13
|
+
import { asNumber, asText, readJson } from "./json-read.ts";
|
|
14
|
+
|
|
15
|
+
export interface ApprovalItem {
|
|
16
|
+
runId: string;
|
|
17
|
+
skill: string;
|
|
18
|
+
level: string;
|
|
19
|
+
/** 相对仓库根的落盘目标(队列里就存着,不重算) */
|
|
20
|
+
targets: string[];
|
|
21
|
+
createdAt: number | null;
|
|
22
|
+
/** 提案摘要:类型与增删行数(读不到 02-proposal.json 就是 null) */
|
|
23
|
+
proposal: { type: string; added: number; removed: number } | null;
|
|
24
|
+
/** 评审结论:verdict 与 blocking 条数(读不到 03-review.json 就是 null) */
|
|
25
|
+
review: { verdict: string | null; blocking: number } | null;
|
|
26
|
+
/** 队列里记的备注(入队时写的层级理由等) */
|
|
27
|
+
detail: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readReview(runDir: string): ApprovalItem["review"] {
|
|
31
|
+
const raw = readJson<Record<string, unknown>>(path.join(runDir, "03-review.json"));
|
|
32
|
+
if (!raw) return null;
|
|
33
|
+
return {
|
|
34
|
+
verdict: typeof raw.verdict === "string" ? raw.verdict : null,
|
|
35
|
+
blocking: Array.isArray(raw.blocking) ? raw.blocking.length : 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 待批条目:只有 approval 级要人看;auto 级由 session_start / settle 自己跑掉 */
|
|
40
|
+
export function buildApprovals(evolutionDir: string, items: readonly QueueItem[]): ApprovalItem[] {
|
|
41
|
+
return items
|
|
42
|
+
.filter((item) => item.status === "pending" && item.level === "approval")
|
|
43
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
44
|
+
.map((item) => {
|
|
45
|
+
const runDir = path.join(evolutionDir, "runs", item.runId);
|
|
46
|
+
const summary = readProposalSummary(runDir);
|
|
47
|
+
return {
|
|
48
|
+
runId: item.runId,
|
|
49
|
+
skill: item.skill,
|
|
50
|
+
level: item.level,
|
|
51
|
+
targets: item.targets,
|
|
52
|
+
createdAt: asNumber(item.createdAt),
|
|
53
|
+
proposal: summary ? { type: summary.type, added: summary.added, removed: summary.removed } : null,
|
|
54
|
+
review: readReview(runDir),
|
|
55
|
+
detail: asText(item.detail),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 操作审计:`.pi/evolution/actions.log`(JSONL,一行一个动作)。
|
|
3
|
+
*
|
|
4
|
+
* 三条纪律:
|
|
5
|
+
* 1. **只追加**:审计是事后追溯用的,绝不改写既有行(读写同一份文件时,改写必然丢别人的尾行);
|
|
6
|
+
* 2. **写失败不反噬动作**:动作已经生效(或已经失败)了,日志写不进去只能留 console,
|
|
7
|
+
* 不能把结果改写成报错 —— 与 lib/telemetry.ts「运行态写失败只记日志」同一口径;
|
|
8
|
+
* 3. **只读尾部**:文件会一直长,读的时候只取尾部 64KB;从半行开始的那半截解析必然失败,靠坏行跳过自然吃掉。
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
|
|
13
|
+
export const AUDIT_FILE = "actions.log";
|
|
14
|
+
/** 读尾部窗口:够装上千条动作,又不至于让一页面板去读一个几百 MB 的日志 */
|
|
15
|
+
const READ_WINDOW_BYTES = 64 * 1024;
|
|
16
|
+
|
|
17
|
+
export const AUDIT_ACTIONS = ["toggle", "run", "rerun", "cleanup", "approve", "reject"] as const;
|
|
18
|
+
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `ok` = 全部生效;`partial` = 命令跑完但没派发(总开关关 / 已有复盘在跑 / 去抖窗口里);
|
|
22
|
+
* `error: …` = 没做成,后面跟一句原因(校验失败、回调抛错都走这条)。
|
|
23
|
+
*/
|
|
24
|
+
export type AuditResult = "ok" | "partial" | `error: ${string}`;
|
|
25
|
+
|
|
26
|
+
export interface AuditEntry {
|
|
27
|
+
/** 毫秒时间戳:与 runs/config 用同一种时间,渲染层才能算出「几分钟前」 */
|
|
28
|
+
at: number;
|
|
29
|
+
action: AuditAction;
|
|
30
|
+
/** 只有 rerun / cleanup 这类针对具体 run 的动作才有 */
|
|
31
|
+
runId?: string;
|
|
32
|
+
/**
|
|
33
|
+
* 只有开关动作(`/toggle?field=`)才有:`telemetry`(采集)/ `enabled`(复盘),
|
|
34
|
+
* 越界被拒时写的是原样的查询值 —— 事后才能分清「他点的是哪个开关」。
|
|
35
|
+
*/
|
|
36
|
+
field?: string;
|
|
37
|
+
result: AuditResult;
|
|
38
|
+
/**
|
|
39
|
+
* 来源标记固定 `web`:将来 TUI 也记审计时,靠它区分「谁点的」,
|
|
40
|
+
* 现在写死也留字段,是因为日志是 append-only,事后补不回来。
|
|
41
|
+
*/
|
|
42
|
+
via: "web";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function auditFile(evolutionDir: string): string {
|
|
46
|
+
return path.join(evolutionDir, AUDIT_FILE);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function appendAudit(evolutionDir: string, entry: AuditEntry): void {
|
|
50
|
+
try {
|
|
51
|
+
fs.mkdirSync(evolutionDir, { recursive: true });
|
|
52
|
+
fs.appendFileSync(auditFile(evolutionDir), `${JSON.stringify(entry)}\n`, "utf8");
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error(`[skill-evolve] 审计写入失败(${auditFile(evolutionDir)}):${String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 读文件尾部窗口的文本;不存在或读失败返回空串(文件不存在 = 还没发生过动作,不是错误) */
|
|
59
|
+
function tailText(file: string): string {
|
|
60
|
+
let fd: number | null = null;
|
|
61
|
+
try {
|
|
62
|
+
const size = fs.statSync(file).size;
|
|
63
|
+
const start = Math.max(0, size - READ_WINDOW_BYTES);
|
|
64
|
+
fd = fs.openSync(file, "r");
|
|
65
|
+
const buffer = Buffer.alloc(size - start);
|
|
66
|
+
fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
67
|
+
return buffer.toString("utf8");
|
|
68
|
+
} catch {
|
|
69
|
+
return "";
|
|
70
|
+
} finally {
|
|
71
|
+
if (fd !== null) {
|
|
72
|
+
try {
|
|
73
|
+
fs.closeSync(fd);
|
|
74
|
+
} catch {
|
|
75
|
+
// 关不掉不影响已读到的内容,下一次读还会重新 open
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 尾部 `limit` 条,按文件顺序返回(老的在前)。
|
|
83
|
+
* 坏行一律跳过:半行、手改、并发截断都当不存在 —— 审计不该因为一行写坏了整页打不开。
|
|
84
|
+
*/
|
|
85
|
+
export function readAudit(evolutionDir: string, limit = 20): AuditEntry[] {
|
|
86
|
+
const entries: AuditEntry[] = [];
|
|
87
|
+
for (const line of tailText(auditFile(evolutionDir)).split("\n")) {
|
|
88
|
+
if (line.trim().length === 0) continue;
|
|
89
|
+
let parsed: Partial<AuditEntry>;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(line) as Partial<AuditEntry>;
|
|
92
|
+
} catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (typeof parsed.at !== "number" || !Number.isFinite(parsed.at)) continue;
|
|
96
|
+
if (typeof parsed.action !== "string" || !isAction(parsed.action)) continue;
|
|
97
|
+
if (typeof parsed.result !== "string" || parsed.result.length === 0) continue;
|
|
98
|
+
if (parsed.via !== "web") continue;
|
|
99
|
+
const entry: AuditEntry = {
|
|
100
|
+
at: parsed.at,
|
|
101
|
+
action: parsed.action,
|
|
102
|
+
result: parsed.result as AuditResult,
|
|
103
|
+
via: "web",
|
|
104
|
+
};
|
|
105
|
+
if (typeof parsed.runId === "string" && parsed.runId.length > 0) entry.runId = parsed.runId;
|
|
106
|
+
if (typeof parsed.field === "string" && parsed.field.length > 0) entry.field = parsed.field;
|
|
107
|
+
entries.push(entry);
|
|
108
|
+
}
|
|
109
|
+
return limit > 0 ? entries.slice(-limit) : [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 未知动作不往页面上渲染:日志里出现新动作(老版本 / 手改)时面板不能崩 */
|
|
113
|
+
function isAction(value: string): value is AuditAction {
|
|
114
|
+
return (AUDIT_ACTIONS as readonly string[]).includes(value);
|
|
115
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web 面板的生命周期:跟着 pi 进程走(用户 2026-11-08:「期望是 pi 启动,他也启动,reload 就退出重启」)。
|
|
3
|
+
*
|
|
4
|
+
* 四条边界:
|
|
5
|
+
* 1. **面板是附属物**:自动启动失败只记日志,绝不阻塞会话启动,也不抛给事件回调;
|
|
6
|
+
* 2. **端口占用要分清主人**:`locks/web.json` 里是别的活 pid 就不抢(那是另一个 pi 窗口的面板);
|
|
7
|
+
* 是本进程 pid 才允许重试 —— `/reload` 不换 pid,旧实例的残影只能这样认出来;
|
|
8
|
+
* 3. **关停要真释放**:`/reload` 先给旧实例发 `session_shutdown`、再给新实例发 `session_start`,
|
|
9
|
+
* 旧实例必须等监听真的关闭,否则新实例第一次 listen 必吃 EADDRINUSE(重试就是兜这一手);
|
|
10
|
+
* 4. **本文件不 import node:http**:真启动由 index.ts 注入(`startWebPanel`),单测喂假 start 即可,
|
|
11
|
+
* 所以这里一个端口都不占。
|
|
12
|
+
*/
|
|
13
|
+
import { loadConfig, saveWebConfig, type EvolutionConfig } from "../config.ts";
|
|
14
|
+
import type { MutationQueue } from "../telemetry.ts";
|
|
15
|
+
import { isAlive, readWebPid } from "./panel-pid.ts";
|
|
16
|
+
import type { WebPanel, WebPanelErrorCode, WebPanelResult } from "./server.ts";
|
|
17
|
+
|
|
18
|
+
/** 首次之外最多重试 3 次;间隔按 reload 实测节奏放大(旧实例关 socket 基本在第一次重试前完成) */
|
|
19
|
+
const RETRY_DELAYS_MS: readonly number[] = [300, 600, 900];
|
|
20
|
+
|
|
21
|
+
/** 自动启动只关心「成没成 + 地址 / 失败码」,面板对象由 WebLifecycle 自己持有 */
|
|
22
|
+
export type WebStartResult =
|
|
23
|
+
| { ok: true; url: string }
|
|
24
|
+
| { ok: false; code: WebPanelErrorCode; message: string };
|
|
25
|
+
|
|
26
|
+
export type WebAutostartResult =
|
|
27
|
+
| { started: true; url: string }
|
|
28
|
+
| {
|
|
29
|
+
started: false;
|
|
30
|
+
/** disabled:配置没开;owned-elsewhere:别的活实例占着;failed:重试到底仍失败 */
|
|
31
|
+
reason: "disabled" | "owned-elsewhere" | "failed";
|
|
32
|
+
/** owned-elsewhere 时是抢手面板的地址 */
|
|
33
|
+
url?: string;
|
|
34
|
+
message?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface WebAutostartDeps {
|
|
38
|
+
config: EvolutionConfig;
|
|
39
|
+
evolutionDir: string;
|
|
40
|
+
/** 起监听;端口来自配置(已校验过 1..65535) */
|
|
41
|
+
start(port: number): Promise<WebStartResult>;
|
|
42
|
+
/** 注入 sleep 便于断言重试节奏(真实现是 setTimeout) */
|
|
43
|
+
sleep?(ms: number): Promise<void>;
|
|
44
|
+
log?(msg: string): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 会话启动时的自动起面板。
|
|
49
|
+
*
|
|
50
|
+
* 计时口径:第一次失败后重试 3 次,间隔 300 / 600 / 900ms(即总共最多 4 次 start 调用)。
|
|
51
|
+
* 只有端口占用值得重试:没权限(EACCES)与未知错误重试还是同一个结果,直接判失败。
|
|
52
|
+
*/
|
|
53
|
+
export async function maybeStartWeb(deps: WebAutostartDeps): Promise<WebAutostartResult> {
|
|
54
|
+
if (deps.config.web.enabled !== true) return { started: false, reason: "disabled" };
|
|
55
|
+
const log = deps.log ?? (() => {});
|
|
56
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((done) => setTimeout(done, ms)));
|
|
57
|
+
const port = deps.config.web.port;
|
|
58
|
+
let message = "";
|
|
59
|
+
for (let retry = 0; ; retry += 1) {
|
|
60
|
+
const result = await deps.start(port);
|
|
61
|
+
if (result.ok) {
|
|
62
|
+
log(`[skill-evolve] web 面板随会话启动:${result.url}`);
|
|
63
|
+
return { started: true, url: result.url };
|
|
64
|
+
}
|
|
65
|
+
message = result.message;
|
|
66
|
+
if (result.code !== "EADDRINUSE") break;
|
|
67
|
+
const owner = readWebPid(deps.evolutionDir);
|
|
68
|
+
// pid 等于本进程 = /reload 后旧实例的残影(进程没换),可以抢;别人的活面板不许抢
|
|
69
|
+
if (owner && owner.pid !== process.pid && isAlive(owner.pid)) {
|
|
70
|
+
log(`[skill-evolve] web 面板由 pid ${owner.pid} 持有(${owner.url}),本实例不抢`);
|
|
71
|
+
return { started: false, reason: "owned-elsewhere", url: owner.url };
|
|
72
|
+
}
|
|
73
|
+
const delay = RETRY_DELAYS_MS[retry];
|
|
74
|
+
if (delay === undefined) break;
|
|
75
|
+
log(`[skill-evolve] web 面板重试 ${retry + 1}/3:端口 ${port} 仍被占用`);
|
|
76
|
+
await sleep(delay);
|
|
77
|
+
}
|
|
78
|
+
log(`[skill-evolve] web 面板未启动(端口 ${port}):${message}`);
|
|
79
|
+
return { started: false, reason: "failed", message };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `/skill-evolve web [--port N] [--once]` 的参数:端口由命令层校验,这里只管语义 */
|
|
83
|
+
export interface WebLaunchRequest {
|
|
84
|
+
/** 显式端口;不给就用配置里的值 */
|
|
85
|
+
port?: number;
|
|
86
|
+
/** true → 只本次会话有效,不写回配置 */
|
|
87
|
+
once: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface WebLifecycleDeps {
|
|
91
|
+
/** 演化目录随会话工作区变(换工作区后配置要读新的那份),所以给 getter */
|
|
92
|
+
evolutionDir(): string;
|
|
93
|
+
/** 真正的启动实现(index.ts 注入 startWebPanel;单测注入假实现,零端口) */
|
|
94
|
+
start(port?: number): Promise<WebPanelResult>;
|
|
95
|
+
queue: MutationQueue;
|
|
96
|
+
log(msg: string): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface WebLifecycle {
|
|
100
|
+
/** `session_start`:配置开着就起,任何失败只记日志 */
|
|
101
|
+
startForSession(): Promise<void>;
|
|
102
|
+
/** 命令路径:起面板,非 `--once` 时把 web.enabled 写回配置 */
|
|
103
|
+
command(request: WebLaunchRequest): Promise<string>;
|
|
104
|
+
/** `session_shutdown`:等监听真的关闭(reason 只进日志,用于自证是哪条路走的) */
|
|
105
|
+
shutdown(reason: string): Promise<void>;
|
|
106
|
+
/** `/skill-evolve off`:关面板 + 写 web.enabled=false */
|
|
107
|
+
disable(): Promise<string>;
|
|
108
|
+
/** 当前面板地址;没跑就是 undefined(命令与 status 用) */
|
|
109
|
+
url(): string | undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 面板单例 + 起停 + 配置写回:一个会话(一个扩展实例)只该有一个面板 */
|
|
113
|
+
export function createWebLifecycle(deps: WebLifecycleDeps): WebLifecycle {
|
|
114
|
+
let panel: WebPanel | null = null;
|
|
115
|
+
|
|
116
|
+
/** 自动启动的入口:成功时顺手把面板记成单例,shutdown 才有东西可关 */
|
|
117
|
+
const launch = async (port: number): Promise<WebStartResult> => {
|
|
118
|
+
const result = await deps.start(port);
|
|
119
|
+
if (!result.ok) return result;
|
|
120
|
+
panel = result.panel;
|
|
121
|
+
return { ok: true, url: result.panel.url };
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const startForSession = async (): Promise<void> => {
|
|
125
|
+
// 单例:同一实例重复 session_start(new / resume / fork)不重复起
|
|
126
|
+
if (panel) return;
|
|
127
|
+
try {
|
|
128
|
+
const evolutionDir = deps.evolutionDir();
|
|
129
|
+
await maybeStartWeb({ config: loadConfig(evolutionDir, deps.log), evolutionDir, start: launch, log: deps.log });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
// 自动起是锦上添花:异常只记日志,绝不冒到会话启动
|
|
132
|
+
deps.log(`[skill-evolve] web 面板自动启动异常:${String(error)}`);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const command = async (request: WebLaunchRequest): Promise<string> => {
|
|
137
|
+
if (panel) return `面板已在 ${panel.url} 运行`;
|
|
138
|
+
const result = await deps.start(request.port);
|
|
139
|
+
if (!result.ok) return result.message;
|
|
140
|
+
panel = result.panel;
|
|
141
|
+
const url = panel.url;
|
|
142
|
+
const boundary = "只绑 127.0.0.1(无鉴权,别转发到公网);待审批页 ?view=approvals 可直接批;机器可读副本 /api/state";
|
|
143
|
+
if (request.once) return `面板已起:${url}(--once:只本次会话有效,没写进配置)\n${boundary}`;
|
|
144
|
+
try {
|
|
145
|
+
// 端口一起写:下次自动起必须落在同一个地址,否则「pi 启动时会自动起」就是句空话
|
|
146
|
+
await saveWebConfig(deps.evolutionDir(), { enabled: true, port: panel.port }, deps.queue);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return `面板已起:${url}(写配置失败,下次 pi 启动不会自动起:${String(error)})\n${boundary}`;
|
|
149
|
+
}
|
|
150
|
+
return `面板已起:${url}(已在配置里开启,pi 启动时会自动起;换端口 --port 7799)\n${boundary}`;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const shutdown = async (reason: string): Promise<void> => {
|
|
154
|
+
const current = panel;
|
|
155
|
+
panel = null;
|
|
156
|
+
if (!current) return;
|
|
157
|
+
try {
|
|
158
|
+
// close() 内含 server.closeAsync 语义:回调返回时监听已释放,端口可被立刻重新 listen
|
|
159
|
+
await current.close();
|
|
160
|
+
deps.log(`[skill-evolve] web 面板关闭(reason=${reason})`);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
deps.log(`[skill-evolve] web 面板关闭失败(reason=${reason}):${String(error)}`);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const disable = async (): Promise<string> => {
|
|
167
|
+
await shutdown("off");
|
|
168
|
+
try {
|
|
169
|
+
await saveWebConfig(deps.evolutionDir(), { enabled: false }, deps.queue);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return `Web 面板已关,但写 web.enabled=false 失败:${String(error)}`;
|
|
172
|
+
}
|
|
173
|
+
return "Web 面板已关,配置已写 web.enabled=false(下次 pi 启动不再自动起;重开:/skill-evolve web)";
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return { startForSession, command, shutdown, disable, url: () => panel?.url };
|
|
177
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 只读 Web 面板的数据聚合:把 `.pi/evolution/` 的散落文件拼成三个视图要的形状。
|
|
3
|
+
*
|
|
4
|
+
* 三条纪律:
|
|
5
|
+
* 1. **只读**:这里不写盘、不开监听、不改任何状态 —— 面板永远不能变成第二个写入方;
|
|
6
|
+
* 2. **坏数据当空**:run.json / 队列 / 各类产出一律可能「写到一半被 kill」,任何字段缺失都退化成
|
|
7
|
+
* null / [],绝不因为一条烂记录让整页 500(用户从飞书点进来只想要个概览);
|
|
8
|
+
* 3. **数字不重算**:summary 直接复用 collectPanelState,和 TUI 状态栏同一口径 ——
|
|
9
|
+
* 两处各算一套,迟早出现「TUI 说待批 2、网页说待批 3」。
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { collectPanelState } from "../panel.ts";
|
|
14
|
+
import { readQueue, type QueueItem } from "../queue.ts";
|
|
15
|
+
import { listRuns, type RunSummary } from "../runs.ts";
|
|
16
|
+
import { CHILD_LOG } from "../spawn-plan.ts";
|
|
17
|
+
import { classifyStale, readLogTail } from "../stale.ts";
|
|
18
|
+
import { readRunError } from "../stale.ts";
|
|
19
|
+
import { asNumber, asText } from "./json-read.ts";
|
|
20
|
+
import { buildApplied, type AppliedItem } from "./applied.ts";
|
|
21
|
+
import { buildApprovals, type ApprovalItem } from "./approvals.ts";
|
|
22
|
+
import { buildReports, type RunReport } from "./reports.ts";
|
|
23
|
+
|
|
24
|
+
export { MAX_FINDING_ROWS } from "./reports.ts";
|
|
25
|
+
export type { FindingRow, RunReport } from "./reports.ts";
|
|
26
|
+
export type { ApprovalItem } from "./approvals.ts";
|
|
27
|
+
export type { AppliedItem } from "./applied.ts";
|
|
28
|
+
|
|
29
|
+
/** 列表页(技能管理页 `skills` 与两个详情页 `run` / `skill` 不在这个联合里,见 filter.ts 的 PANEL_VIEWS) */
|
|
30
|
+
export const WEB_VIEWS = ["pending", "approvals", "applied", "done", "reports"] as const;
|
|
31
|
+
export type WebView = (typeof WEB_VIEWS)[number];
|
|
32
|
+
|
|
33
|
+
/** 已处理列表上限:网页是「翻最近发生了什么」,不是审计日志(审计看 runs/ 目录本身) */
|
|
34
|
+
export const DONE_LIMIT = 50;
|
|
35
|
+
/** 扫描最近多少个 run:卡死 / 待入队这类判断只需要看近期,runs/ 里可能有几百个历史目录 */
|
|
36
|
+
const SCAN_LIMIT = 100;
|
|
37
|
+
|
|
38
|
+
/** 单 run 默认上限:与 DEFAULT_CONFIG.maxRuntimeMs 同值,面板只用来算「上限 N 分钟」 */
|
|
39
|
+
const DEFAULT_MAX_RUNTIME_MS = 20 * 60_000;
|
|
40
|
+
|
|
41
|
+
/** 待处理四来源:正在跑 / 已判卡死 / 队列待批 / 评审通过但还没入队 */
|
|
42
|
+
export type PendingKind = "running" | "approval" | "stale" | "reviewed";
|
|
43
|
+
|
|
44
|
+
export interface PendingItem {
|
|
45
|
+
runId: string;
|
|
46
|
+
kind: PendingKind;
|
|
47
|
+
skill: string;
|
|
48
|
+
at: number | null;
|
|
49
|
+
/** 展开后的纯文本(渲染层负责转义) */
|
|
50
|
+
detail: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DoneItem {
|
|
54
|
+
runId: string;
|
|
55
|
+
status: string;
|
|
56
|
+
/** queue = 采纳队列(有层级与详情);run = 只有 run.json 的终态(比如 rejected 从未入队) */
|
|
57
|
+
source: "queue" | "run";
|
|
58
|
+
level: string;
|
|
59
|
+
skill: string;
|
|
60
|
+
at: number | null;
|
|
61
|
+
detail: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface WebSummary {
|
|
65
|
+
reviewing: number;
|
|
66
|
+
stale: number;
|
|
67
|
+
pendingApprovals: number;
|
|
68
|
+
appliedTotal: number;
|
|
69
|
+
rollbackRate: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface WebState {
|
|
73
|
+
pending: PendingItem[];
|
|
74
|
+
/** 待审批:只收 pending + approval 级,带按钮所需的最小事实(见 ./approvals.ts) */
|
|
75
|
+
approvals: ApprovalItem[];
|
|
76
|
+
/** 已采纳:只收队列 applied,且**不再**进 done(两个 tab 不许混) */
|
|
77
|
+
applied: AppliedItem[];
|
|
78
|
+
done: DoneItem[];
|
|
79
|
+
reports: RunReport[];
|
|
80
|
+
summary: WebSummary;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface WebStateDeps {
|
|
84
|
+
/** 现时(单测注入固定时钟) */
|
|
85
|
+
now?: number;
|
|
86
|
+
/** pid 存活判定(默认 process.kill(pid,0);单测注入,测试里不起真进程) */
|
|
87
|
+
alive?: (pid: number) => boolean;
|
|
88
|
+
/** 单 run 上限(毫秒),只用于「上限 N 分钟」那一行 */
|
|
89
|
+
maxRuntimeMs?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function defaultAlive(pid: number): boolean {
|
|
93
|
+
try {
|
|
94
|
+
process.kill(pid, 0); // 信号 0 只探测存在性,不真发信号
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 把毫秒压成「Xm Ys」:面板上人只关心量级,不关心毫秒 */
|
|
102
|
+
function duration(ms: number): string {
|
|
103
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
104
|
+
return `${Math.floor(total / 60)}m ${total % 60}s`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 「正在跑」那一条:光看 `复盘 1` 这个数字不知道是谁、跑到哪、要不要杀,
|
|
109
|
+
* 所以把阶段 / 进程 / 最近输出 / 看日志的命令四行摊开(用户 2026-11-08:「复盘中我也想看到状态呢」)。
|
|
110
|
+
*/
|
|
111
|
+
function runningItem(
|
|
112
|
+
run: RunSummary,
|
|
113
|
+
evolutionDir: string,
|
|
114
|
+
now: number,
|
|
115
|
+
alive: (pid: number) => boolean,
|
|
116
|
+
maxRuntimeMs: number,
|
|
117
|
+
): PendingItem | null {
|
|
118
|
+
if (run.status !== "dispatched" || run.pid === null || !alive(run.pid)) return null;
|
|
119
|
+
const reviewing = run.supervisorPid !== null && alive(run.supervisorPid);
|
|
120
|
+
const logPath = path.join(evolutionDir, "runs", run.runId, CHILD_LOG);
|
|
121
|
+
let mtime: number | null = null;
|
|
122
|
+
try {
|
|
123
|
+
mtime = fs.statSync(logPath).mtimeMs; // 拿不到就是还没写过日志
|
|
124
|
+
} catch {
|
|
125
|
+
mtime = null;
|
|
126
|
+
}
|
|
127
|
+
const tail = readLogTail(logPath, 1);
|
|
128
|
+
const lastLine = tail.length > 0 ? tail[tail.length - 1]!.trim() : "";
|
|
129
|
+
const progressLine =
|
|
130
|
+
mtime === null
|
|
131
|
+
? "最近输出:(还没有输出)"
|
|
132
|
+
: `最近输出:${Math.max(0, Math.round((now - mtime) / 1000))}s 前 · 末行:${lastLine.length > 0 ? lastLine : "(还没有输出)"}`;
|
|
133
|
+
return {
|
|
134
|
+
runId: run.runId,
|
|
135
|
+
kind: "running",
|
|
136
|
+
skill: run.proposal?.skill ?? "",
|
|
137
|
+
at: run.dispatchedAt,
|
|
138
|
+
detail: [
|
|
139
|
+
`阶段:${reviewing ? "评审中" : "复盘产出中"}`,
|
|
140
|
+
`进程:pid ${run.pid} · 已跑 ${duration(now - (run.dispatchedAt ?? now))}(上限 ${Math.round(maxRuntimeMs / 60_000)} 分钟)`,
|
|
141
|
+
progressLine,
|
|
142
|
+
`日志:tail -f .pi/evolution/runs/${run.runId}/${CHILD_LOG}`,
|
|
143
|
+
].join("\n"),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 四类条目的展示优先级:正在跑最上头,然后卡死、待批、待入队 */
|
|
148
|
+
const PENDING_RANK: Record<PendingKind, number> = { running: 0, stale: 1, approval: 2, reviewed: 3 };
|
|
149
|
+
|
|
150
|
+
function buildPending(
|
|
151
|
+
items: readonly QueueItem[],
|
|
152
|
+
runs: readonly RunSummary[],
|
|
153
|
+
evolutionDir: string,
|
|
154
|
+
deps: WebStateDeps = {},
|
|
155
|
+
): PendingItem[] {
|
|
156
|
+
const now = deps.now ?? Date.now();
|
|
157
|
+
const alive = deps.alive ?? defaultAlive;
|
|
158
|
+
const maxRuntimeMs = deps.maxRuntimeMs ?? DEFAULT_MAX_RUNTIME_MS;
|
|
159
|
+
const pending: PendingItem[] = [];
|
|
160
|
+
// ⓪ 正在跑的:有活 pid 就是「进行中」,先列出来(它不是等批也不是失败)
|
|
161
|
+
const running = new Set<string>();
|
|
162
|
+
for (const run of runs) {
|
|
163
|
+
const item = runningItem(run, evolutionDir, now, alive, maxRuntimeMs);
|
|
164
|
+
if (!item) continue;
|
|
165
|
+
running.add(run.runId);
|
|
166
|
+
pending.push(item);
|
|
167
|
+
}
|
|
168
|
+
// ① 队列里待人批的(approval 级;auto 级不需要人,出现 pending 只是还没轮到执行)
|
|
169
|
+
for (const item of items) {
|
|
170
|
+
if (item.status !== "pending" || item.level !== "approval") continue;
|
|
171
|
+
pending.push({
|
|
172
|
+
runId: item.runId,
|
|
173
|
+
kind: "approval",
|
|
174
|
+
skill: item.skill,
|
|
175
|
+
at: asNumber(item.createdAt),
|
|
176
|
+
detail: [
|
|
177
|
+
"层级:approval(必须人批,auto 不会替你执行)",
|
|
178
|
+
`目标:${item.targets.join("、") || "(未写 targets)"}`,
|
|
179
|
+
item.detail ? `备注:${item.detail}` : "",
|
|
180
|
+
`操作:去「待审批」页(?view=approvals)直接点批准 / 拒绝,也可以在这里回 TUI 敲 /skill-evolve approve ${item.runId}`,
|
|
181
|
+
]
|
|
182
|
+
.filter((line) => line.length > 0)
|
|
183
|
+
.join("\n"),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// ② 已判定卡死的 run:它是「失败」而不是「等批」,单独一类,别混进待批数字里。
|
|
187
|
+
// 这里必须包含「判据命中、run.json 还没落盘」的那一批:顶部 summary.stale 用的是
|
|
188
|
+
// collectPanelState 的同一口径,少一类就会出现「头顶说卡死 5、列表只有 3 条」。
|
|
189
|
+
for (const run of runs) {
|
|
190
|
+
if (running.has(run.runId)) continue; // 同一个 run 不能既「进行中」又「卡死」
|
|
191
|
+
const persisted = run.status === "stale";
|
|
192
|
+
if (!persisted && !classifyStale(run, now).stale) continue;
|
|
193
|
+
const error = readRunError(path.join(evolutionDir, "runs", run.runId));
|
|
194
|
+
const tail = error?.childLogTail ?? [];
|
|
195
|
+
const lastLine = tail.length > 0 ? tail[tail.length - 1]!.trim() : "";
|
|
196
|
+
pending.push({
|
|
197
|
+
runId: run.runId,
|
|
198
|
+
kind: "stale",
|
|
199
|
+
skill: run.proposal?.skill ?? "",
|
|
200
|
+
at: error?.at ?? run.dispatchedAt,
|
|
201
|
+
detail: [
|
|
202
|
+
`原因:${error?.reason === "timeout" ? "超时(子进程还活着但超过单 run 上限)" : "子进程已退出"}`,
|
|
203
|
+
lastLine.length > 0 ? `日志末行:${lastLine}` : "日志末行:(子进程没留下任何输出)",
|
|
204
|
+
`排查:cat ${path.join(evolutionDir, "runs", run.runId, "run-error.json")}`,
|
|
205
|
+
"不会自动重跑:需要重新复盘就 /skill-evolve run",
|
|
206
|
+
].join("\n"),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
// ④ 子进程已产出结论、评审还没跑的:不是失败(子进程本来就该退出),属于「等下一轮接上」
|
|
210
|
+
for (const run of runs) {
|
|
211
|
+
if (run.status !== "analyzed" && run.status !== "proposed") continue;
|
|
212
|
+
pending.push({
|
|
213
|
+
runId: run.runId,
|
|
214
|
+
kind: "reviewed",
|
|
215
|
+
skill: run.proposal?.skill ?? "",
|
|
216
|
+
at: run.dispatchedAt,
|
|
217
|
+
detail: [
|
|
218
|
+
`状态:已出结论(${run.status}),评审还没跑`,
|
|
219
|
+
"会自动接上:下次 settle 或 session_start 继续;想立即接上就 /skill-evolve run",
|
|
220
|
+
`证据:cat ${path.join(evolutionDir, "runs", run.runId, "01-findings.json")}`,
|
|
221
|
+
].join("\n"),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// ③ 评审通过但还没进队列(入队发生在 settle / session_start,中间可能还没轮到)
|
|
225
|
+
const queued = new Set(items.map((item) => item.runId));
|
|
226
|
+
for (const run of runs) {
|
|
227
|
+
if (run.status !== "reviewed" || queued.has(run.runId)) continue;
|
|
228
|
+
pending.push({
|
|
229
|
+
runId: run.runId,
|
|
230
|
+
kind: "reviewed",
|
|
231
|
+
skill: run.proposal?.skill ?? "",
|
|
232
|
+
at: run.dispatchedAt,
|
|
233
|
+
detail: [
|
|
234
|
+
`评审:${asText(run.review?.verdict, "(未写 verdict)")} · 建议动作 ${asText(run.review?.action, "无")}`,
|
|
235
|
+
"状态:评审已通过,尚未入队(settle / session_start 会自动补)",
|
|
236
|
+
`兜底:/skill-evolve approve ${run.runId}`,
|
|
237
|
+
].join("\n"),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return pending.sort((a, b) => PENDING_RANK[a.kind] - PENDING_RANK[b.kind] || (b.at ?? 0) - (a.at ?? 0));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 「已处理」收的终态:回滚 / 失败 / 否决 / 被取代。
|
|
245
|
+
* **不含 applied** —— 采纳有自己的页(见 ./applied.ts),两边都收就等于两个 tab 都算一遍。
|
|
246
|
+
*/
|
|
247
|
+
const DONE_STATUSES = new Set(["rolled-back", "failed", "rejected", "superseded"]);
|
|
248
|
+
|
|
249
|
+
function buildDone(items: readonly QueueItem[], runs: readonly RunSummary[]): DoneItem[] {
|
|
250
|
+
const byRun = new Map<string, DoneItem>();
|
|
251
|
+
for (const run of runs) {
|
|
252
|
+
if (!DONE_STATUSES.has(run.status)) continue;
|
|
253
|
+
byRun.set(run.runId, {
|
|
254
|
+
runId: run.runId,
|
|
255
|
+
status: run.status,
|
|
256
|
+
source: "run",
|
|
257
|
+
level: asText(run.review?.action, "-"),
|
|
258
|
+
skill: run.proposal?.skill ?? "",
|
|
259
|
+
at: run.dispatchedAt,
|
|
260
|
+
detail: run.review?.verdict ? `评审:${run.review.verdict}` : "",
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
// 队列记录覆盖同 runId:它有层级(auto/approval)与失败详情,比 run.json 只有状态有用
|
|
264
|
+
for (const item of items) {
|
|
265
|
+
if (!DONE_STATUSES.has(item.status)) continue;
|
|
266
|
+
byRun.set(item.runId, {
|
|
267
|
+
runId: item.runId,
|
|
268
|
+
status: item.status,
|
|
269
|
+
source: "queue",
|
|
270
|
+
level: item.level,
|
|
271
|
+
skill: item.skill,
|
|
272
|
+
at: asNumber(item.createdAt),
|
|
273
|
+
detail: item.detail ?? "",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return [...byRun.values()].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, DONE_LIMIT);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function collectWebState(input: { evolutionDir: string } & WebStateDeps): WebState {
|
|
280
|
+
const { evolutionDir, ...deps } = input;
|
|
281
|
+
const items = readQueue(evolutionDir);
|
|
282
|
+
const runs = listRuns(evolutionDir, SCAN_LIMIT);
|
|
283
|
+
const panel = collectPanelState({ evolutionDir });
|
|
284
|
+
return {
|
|
285
|
+
pending: buildPending(items, runs, evolutionDir, deps),
|
|
286
|
+
approvals: buildApprovals(evolutionDir, items),
|
|
287
|
+
applied: buildApplied(items, evolutionDir),
|
|
288
|
+
done: buildDone(items, runs),
|
|
289
|
+
reports: buildReports(evolutionDir, runs),
|
|
290
|
+
summary: {
|
|
291
|
+
reviewing: panel.reviewing,
|
|
292
|
+
stale: panel.staleCount,
|
|
293
|
+
pendingApprovals: panel.pendingApprovals,
|
|
294
|
+
appliedTotal: items.filter((item) => item.status === "applied").length,
|
|
295
|
+
rollbackRate: panel.rollbackRate,
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|