pi-skill-evolve 0.1.6 → 0.1.8
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/extensions/skill-evolve/lib/runs.ts +4 -1
- package/extensions/skill-evolve/lib/web/filter.ts +15 -0
- package/extensions/skill-evolve/lib/web/pending.ts +5 -5
- package/extensions/skill-evolve/lib/web/render-controls.ts +4 -3
- package/extensions/skill-evolve/lib/web/render-rows.ts +2 -1
- package/extensions/skill-evolve/test/pending-kinds.test.ts +142 -0
- package/extensions/skill-evolve/test/web-filter-labels.test.ts +57 -0
- package/package.json +1 -1
|
@@ -216,9 +216,12 @@ export function listRuns(evolutionDir: string, limit = 10): RunSummary[] {
|
|
|
216
216
|
const proposal = raw.proposal ?? readProposalSummary(runDir);
|
|
217
217
|
// 同理:结论写盘了但宿主进程已退出、没人把状态推到 analyzed —— 读的时候补上。
|
|
218
218
|
// 不补的话,一个已经成功的复盘会被卡死判定误抦成「复盘失败」(面板最忌讳的事)。
|
|
219
|
+
// 但**写了 run-error.json 的 run 不许补**:那是判定过卡死并留了证据的失败案件,
|
|
220
|
+
// 补成 analyzed 会让它从「卡死」里消失、还挂着「待入队」的灰牌(2026-11-08 实测 7 条被误标)。
|
|
219
221
|
const hasFindings = fs.existsSync(path.join(runDir, "01-findings.json"));
|
|
222
|
+
const hasErrorReport = fs.existsSync(path.join(runDir, "run-error.json"));
|
|
220
223
|
const status =
|
|
221
|
-
hasFindings && (raw.status === "dispatched" || raw.status === "stale")
|
|
224
|
+
hasFindings && !hasErrorReport && (raw.status === "dispatched" || raw.status === "stale")
|
|
222
225
|
? "analyzed"
|
|
223
226
|
: (raw.status ?? "unknown");
|
|
224
227
|
runs.push({
|
|
@@ -29,6 +29,21 @@ export const RANGE_LABEL: Record<RangeKey, string> = {
|
|
|
29
29
|
/** 状态下拉的可选值:既有 run 终态,也有待处理页自己的两种(running / stale) */
|
|
30
30
|
export const STATUS_OPTIONS = ["running", "stale", "reviewed", "applied", "rejected", "failed", "analyzed"] as const;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* 状态下拉的中文标签(用户 2026-11-08:「这里的下拉应该是中文」)。
|
|
34
|
+
* 值仍然是状态机原文(URL 与过滤逻辑不变),只改给人看的那一侧;
|
|
35
|
+
* 与徽章文案保持同一套词——同一个概念全站只能有一个中文名。
|
|
36
|
+
*/
|
|
37
|
+
export const STATUS_LABEL: Record<(typeof STATUS_OPTIONS)[number], string> = {
|
|
38
|
+
running: "复盘中",
|
|
39
|
+
stale: "卡死",
|
|
40
|
+
reviewed: "待入队",
|
|
41
|
+
applied: "已采纳",
|
|
42
|
+
rejected: "已拒绝",
|
|
43
|
+
failed: "失败",
|
|
44
|
+
analyzed: "待评审",
|
|
45
|
+
};
|
|
46
|
+
|
|
32
47
|
export interface ListFilter {
|
|
33
48
|
skill: string;
|
|
34
49
|
/** 空串 = 全部 */
|
|
@@ -27,8 +27,8 @@ function defaultAlive(pid: number): boolean {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
export type PendingKind = "running" | "approval" | "stale" | "reviewed";
|
|
30
|
+
/** 待处理五来源:正在跑 / 已判卡死 / 队列待批 / 已出结论等评审 / 评审通过等入队 */
|
|
31
|
+
export type PendingKind = "running" | "approval" | "stale" | "to-review" | "reviewed";
|
|
32
32
|
|
|
33
33
|
export interface PendingItem {
|
|
34
34
|
runId: string;
|
|
@@ -89,8 +89,8 @@ function runningItem(
|
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
/**
|
|
93
|
-
const PENDING_RANK: Record<PendingKind, number> = { running: 0, stale: 1, approval: 2,
|
|
92
|
+
/** 五类条目的展示优先级:正在跑最上头,然后卡死、待批、待评审、待入队 */
|
|
93
|
+
const PENDING_RANK: Record<PendingKind, number> = { running: 0, stale: 1, approval: 2, "to-review": 3, reviewed: 4 };
|
|
94
94
|
|
|
95
95
|
export function buildPending(
|
|
96
96
|
items: readonly QueueItem[],
|
|
@@ -159,7 +159,7 @@ export function buildPending(
|
|
|
159
159
|
if (run.status !== "analyzed" && run.status !== "proposed") continue;
|
|
160
160
|
pending.push({
|
|
161
161
|
runId: run.runId,
|
|
162
|
-
kind: "
|
|
162
|
+
kind: "to-review",
|
|
163
163
|
session: sessionOfRun(run),
|
|
164
164
|
skill: run.proposal?.skill ?? "",
|
|
165
165
|
at: run.dispatchedAt,
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
telemetryOffWhileEnabled,
|
|
22
22
|
} from "../flags.ts";
|
|
23
23
|
import type { WebView } from "./data.ts";
|
|
24
|
-
import { RANGE_KEYS, RANGE_LABEL, STATUS_OPTIONS, type ListFilter, type ViewPage } from "./filter.ts";
|
|
24
|
+
import { RANGE_KEYS, RANGE_LABEL, STATUS_LABEL, STATUS_OPTIONS, type ListFilter, type ViewPage } from "./filter.ts";
|
|
25
25
|
import { listHref, type ListExtras } from "./list-query.ts";
|
|
26
26
|
import { escapeHtml, formatStamp, formatTimeSeconds } from "./text.ts";
|
|
27
27
|
import type { WebControls } from "./activity.ts";
|
|
@@ -110,7 +110,8 @@ ${warn}${backlog}`;
|
|
|
110
110
|
function statusSelect(current: string): string {
|
|
111
111
|
const options = [`<option value="">全部状态</option>`];
|
|
112
112
|
for (const status of STATUS_OPTIONS) {
|
|
113
|
-
|
|
113
|
+
// value 仍是状态机原文(过滤逻辑不变),只把展示换中文
|
|
114
|
+
options.push(`<option value="${status}"${status === current ? " selected" : ""}>${STATUS_LABEL[status]}</option>`);
|
|
114
115
|
}
|
|
115
116
|
return options.join("");
|
|
116
117
|
}
|
|
@@ -129,7 +130,7 @@ export function renderFilterForm(view: WebView, filter: ListFilter, extras: List
|
|
|
129
130
|
const keep = extraInputs(extras);
|
|
130
131
|
return `<form class="filter" method="get">
|
|
131
132
|
<input type="hidden" name="view" value="${view}">
|
|
132
|
-
${keep}<label>技能 <input type="text" name="skill" value="${escapeHtml(filter.skill)}" placeholder="
|
|
133
|
+
${keep}<label>技能 <input type="text" name="skill" value="${escapeHtml(filter.skill)}" placeholder="技能名包含…" size="16"></label>
|
|
133
134
|
<label>状态 <select name="status">${statusSelect(filter.status)}</select></label>
|
|
134
135
|
<label>时间 <select name="range">${rangeSelect(filter.range)}</select></label>
|
|
135
136
|
<button type="submit">筛选</button>
|
|
@@ -102,12 +102,13 @@ function approvalForm(action: "approve" | "reject", item: ApprovalItem, csrf: st
|
|
|
102
102
|
</form>`;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/**
|
|
105
|
+
/** 待处理:五种 kind 各一个徽章;「待评审 / 待入队」是灰的(它们在排队,不需要人动手) */
|
|
106
106
|
export function pendingRows(items: readonly PendingItem[], ctx: RowContext): ListRow[] {
|
|
107
107
|
const badge: Record<PendingItem["kind"], { text: string; tone: BadgeTone }> = {
|
|
108
108
|
running: { text: "复盘中", tone: "yellow" },
|
|
109
109
|
approval: { text: "待审批", tone: "blue" },
|
|
110
110
|
stale: { text: "卡死", tone: "red" },
|
|
111
|
+
"to-review": { text: "待评审", tone: "gray" },
|
|
111
112
|
reviewed: { text: "待入队", tone: "gray" },
|
|
112
113
|
};
|
|
113
114
|
return items.map((item) => ({
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 待处理四类徽章的验收测试(用户 2026-11-08:「待入队是什么意思」暴露的两个误标)。
|
|
3
|
+
*
|
|
4
|
+
* 两条口径:
|
|
5
|
+
* 1. **写了 run-error.json 的 run 不许被补成 analyzed**——它是判定过卡死的失败案件,
|
|
6
|
+
* 补成 analyzed 会从「卡死」里消失并挂上「待入队」灰牌(实测 7 条被误标)。
|
|
7
|
+
* 2. `analyzed` / `proposed`(结论出来了、评审还没跑)与 `reviewed`(评审过了、还没进队列)
|
|
8
|
+
* 是两件事,徽章必须是「待评审」与「待入队」,不能共用一个词。
|
|
9
|
+
*
|
|
10
|
+
* 纪律:run 目录一律建在临时目录里,不读写真实 `.pi/evolution`。
|
|
11
|
+
*/
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
|
|
18
|
+
import { listRuns, type RunSummary } from "../lib/runs.ts";
|
|
19
|
+
import { buildPending } from "../lib/web/pending.ts";
|
|
20
|
+
import { pendingRows } from "../lib/web/render-rows.ts";
|
|
21
|
+
import type { SessionRef } from "../lib/web/run-session.ts";
|
|
22
|
+
|
|
23
|
+
const NO_SESSION: SessionRef = { sessionId: null, file: null };
|
|
24
|
+
|
|
25
|
+
function tmpEvolution(): string {
|
|
26
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "sk-pending-kinds-"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 造一条 run:只写 run.json(以及可选的 01-findings.json / run-error.json) */
|
|
30
|
+
function writeRun(
|
|
31
|
+
dir: string,
|
|
32
|
+
runId: string,
|
|
33
|
+
status: string,
|
|
34
|
+
extra: { findings?: boolean; error?: boolean } = {},
|
|
35
|
+
): void {
|
|
36
|
+
const runDir = path.join(dir, "runs", runId);
|
|
37
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
38
|
+
fs.writeFileSync(
|
|
39
|
+
path.join(runDir, "run.json"),
|
|
40
|
+
`${JSON.stringify({ runId, status, sessionId: null, dispatchedAt: 1_700_000_000_000 }, null, 2)}\n`,
|
|
41
|
+
"utf8",
|
|
42
|
+
);
|
|
43
|
+
if (extra.findings === true) fs.writeFileSync(path.join(runDir, "01-findings.json"), "{}\n", "utf8");
|
|
44
|
+
if (extra.error === true) {
|
|
45
|
+
fs.writeFileSync(
|
|
46
|
+
path.join(runDir, "run-error.json"),
|
|
47
|
+
`${JSON.stringify({ reason: "child-exited", at: 1, childLogTail: [] }, null, 2)}\n`,
|
|
48
|
+
"utf8",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function summaryOf(runId: string, status: string): RunSummary {
|
|
54
|
+
return {
|
|
55
|
+
runId,
|
|
56
|
+
status: status as RunSummary["status"],
|
|
57
|
+
sessionId: null,
|
|
58
|
+
sessionFile: null,
|
|
59
|
+
dispatchedAt: 1_700_000_000_000,
|
|
60
|
+
pid: null,
|
|
61
|
+
supervisorPid: null,
|
|
62
|
+
} as RunSummary;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("写了 run-error.json 的 stale 保持 stale,不被补成 analyzed", () => {
|
|
66
|
+
const dir = tmpEvolution();
|
|
67
|
+
writeRun(dir, "run-broken", "stale", { findings: true, error: true });
|
|
68
|
+
const [run] = listRuns(dir);
|
|
69
|
+
assert.equal(run?.status, "stale");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("有结论、没写 run-error.json 的 dispatched 补成 analyzed(原有行为不回归)", () => {
|
|
73
|
+
const dir = tmpEvolution();
|
|
74
|
+
writeRun(dir, "run-ok", "dispatched", { findings: true });
|
|
75
|
+
const [run] = listRuns(dir);
|
|
76
|
+
assert.equal(run?.status, "analyzed");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("analyzed 归到「待评审」,不是「待入队」", () => {
|
|
80
|
+
const dir = tmpEvolution();
|
|
81
|
+
writeRun(dir, "run-a", "analyzed", { findings: true });
|
|
82
|
+
const items = buildPending([], listRuns(dir), dir);
|
|
83
|
+
const item = items.find((x) => x.runId === "run-a");
|
|
84
|
+
assert.equal(item?.kind, "to-review");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("reviewed 且未入队才算「待入队」", () => {
|
|
88
|
+
const dir = tmpEvolution();
|
|
89
|
+
writeRun(dir, "run-r", "reviewed", { findings: true });
|
|
90
|
+
const items = buildPending([], listRuns(dir), dir);
|
|
91
|
+
assert.equal(items.find((x) => x.runId === "run-r")?.kind, "reviewed");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("徽章文案:待评审 / 待入队 / 卡死 三者不同", () => {
|
|
95
|
+
const rows = pendingRows(
|
|
96
|
+
[
|
|
97
|
+
{ runId: "a", kind: "to-review", session: NO_SESSION, skill: "", at: null, detail: "" },
|
|
98
|
+
{ runId: "b", kind: "reviewed", session: NO_SESSION, skill: "", at: null, detail: "" },
|
|
99
|
+
{ runId: "c", kind: "stale", session: NO_SESSION, skill: "", at: null, detail: "" },
|
|
100
|
+
],
|
|
101
|
+
{ sessionId: null, active: false } as never,
|
|
102
|
+
);
|
|
103
|
+
assert.deepEqual(
|
|
104
|
+
rows.map((row) => row.badge?.text),
|
|
105
|
+
["待评审", "待入队", "卡死"],
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("只有卡死与待审批需要人动手", () => {
|
|
110
|
+
const rows = pendingRows(
|
|
111
|
+
[
|
|
112
|
+
{ runId: "a", kind: "to-review", session: NO_SESSION, skill: "", at: null, detail: "" },
|
|
113
|
+
{ runId: "b", kind: "reviewed", session: NO_SESSION, skill: "", at: null, detail: "" },
|
|
114
|
+
{ runId: "c", kind: "stale", session: NO_SESSION, skill: "", at: null, detail: "" },
|
|
115
|
+
{ runId: "d", kind: "approval", session: NO_SESSION, skill: "", at: null, detail: "" },
|
|
116
|
+
],
|
|
117
|
+
{ sessionId: null, active: false } as never,
|
|
118
|
+
);
|
|
119
|
+
assert.deepEqual(
|
|
120
|
+
rows.map((row) => row.needsAction === true),
|
|
121
|
+
[false, false, true, true],
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("排序:卡死排在待评审之前(失败优先于排队)", () => {
|
|
126
|
+
const items = [
|
|
127
|
+
{ runId: "a", kind: "to-review" as const, session: NO_SESSION, skill: "", at: 10, detail: "" },
|
|
128
|
+
{ runId: "c", kind: "stale" as const, session: NO_SESSION, skill: "", at: 5, detail: "" },
|
|
129
|
+
];
|
|
130
|
+
const dir = tmpEvolution();
|
|
131
|
+
writeRun(dir, "unused", "analyzed", { findings: true });
|
|
132
|
+
// buildPending 自己排序;这里只借它把两个 kind 的排序权重验证出来
|
|
133
|
+
const sorted = [...items].sort((a, b) => {
|
|
134
|
+
const rank: Record<string, number> = { running: 0, stale: 1, approval: 2, "to-review": 3, reviewed: 4 };
|
|
135
|
+
return rank[a.kind]! - rank[b.kind]!;
|
|
136
|
+
});
|
|
137
|
+
assert.deepEqual(
|
|
138
|
+
sorted.map((item) => item.runId),
|
|
139
|
+
["c", "a"],
|
|
140
|
+
);
|
|
141
|
+
assert.equal(summaryOf("x", "stale").status, "stale");
|
|
142
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 筛选栏文案的验收测试(用户 2026-11-08:「这里的下拉应该是中文, 但是这个搜索框是干嘛的?」)。
|
|
3
|
+
*
|
|
4
|
+
* 两条口径:
|
|
5
|
+
* 1. 状态下拉的 **value 仍是状态机原文**(URL / 过滤逻辑一个字符都不改),只把给人看的一侧换中文;
|
|
6
|
+
* 中文名与列表徽章必须同一套词,同一个概念全站只能有一个名字。
|
|
7
|
+
* 2. 技能那个输入框是**筛选条件**,不是搜索框也不是下拉:placeholder 必须写成「技能名包含…」,
|
|
8
|
+
* 让人一眼知道「填一部分就够」。
|
|
9
|
+
*
|
|
10
|
+
* 纪律:纯字符串断言,不碰文件系统、不起服务。
|
|
11
|
+
*/
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
|
|
15
|
+
import { parseListQuery, STATUS_LABEL, STATUS_OPTIONS } from "../lib/web/filter.ts";
|
|
16
|
+
import { renderFilterForm } from "../lib/web/render-controls.ts";
|
|
17
|
+
|
|
18
|
+
const FILTER = parseListQuery(new URLSearchParams(""));
|
|
19
|
+
const EXTRAS = { live: false, open: "all", only: false } as never;
|
|
20
|
+
|
|
21
|
+
function form(): string {
|
|
22
|
+
return renderFilterForm("pending", FILTER, EXTRAS);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("状态下拉每一项都显示中文,value 保持状态机原文", () => {
|
|
26
|
+
const html = form();
|
|
27
|
+
for (const status of STATUS_OPTIONS) {
|
|
28
|
+
assert.ok(html.includes(`value="${status}"`), `value 必须是 ${status}`);
|
|
29
|
+
assert.ok(html.includes(`>${STATUS_LABEL[status]}<`), `${status} 要显示成 ${STATUS_LABEL[status]}`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("状态下拉里不出现英文状态词", () => {
|
|
34
|
+
const html = form();
|
|
35
|
+
const select = html.slice(html.indexOf('name="status"'), html.indexOf("</select>", html.indexOf('name="status"')));
|
|
36
|
+
for (const status of STATUS_OPTIONS) {
|
|
37
|
+
assert.ok(!select.includes(`>${status}<`), `不应再出现英文项 >${status}<`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("中文标签覆盖七个可选状态,且没有空值", () => {
|
|
42
|
+
assert.equal(Object.keys(STATUS_LABEL).length, STATUS_OPTIONS.length);
|
|
43
|
+
for (const label of Object.values(STATUS_LABEL)) assert.ok(label.trim().length > 0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("技能输入框的 placeholder 说明它是筛选条件", () => {
|
|
47
|
+
const html = form();
|
|
48
|
+
assert.ok(html.includes('name="skill"'));
|
|
49
|
+
assert.ok(html.includes('placeholder="技能名包含…"'), "要告诉人这里填的是「技能名的一部分」");
|
|
50
|
+
assert.ok(!html.includes('placeholder="全部技能"'), "旧 placeholder 让人误以为是静态字段");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("筛选表单仍然是 GET + 保留 view", () => {
|
|
54
|
+
const html = form();
|
|
55
|
+
assert.ok(html.includes('method="get"'));
|
|
56
|
+
assert.ok(html.includes('<input type="hidden" name="view" value="pending">'));
|
|
57
|
+
});
|