pi-skill-evolve 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +116 -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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一 diff 的解析与渲染(纯函数:不读盘、不拼页面、不发请求)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么自己写而不塞进 render-run.ts:解析(行号计数、hunk 边界)是最容易出错的一段,
|
|
5
|
+
* 单独一个文件才能用喂字符串的方式把 hunk 计数、`\ No newline`、`<script>` 这些边界一个个钉住。
|
|
6
|
+
*
|
|
7
|
+
* 三条纪律:
|
|
8
|
+
* 1. **全部转义**:diff 里出现 `<` `&` `-->` 是常态,渲染前逐行 escapeHtml,绝不拼原始字符串;
|
|
9
|
+
* 2. **行号靠 hunk 头推**:`@@ -1,3 +1,4 @@` 只给起点,之后的增删各自推进哪一列必须算对,
|
|
10
|
+
* 算错的后果是「用户对着行号去改文件,改错行」;
|
|
11
|
+
* 3. **超长先截断**:一个技能 SKILL.md 的 diff 可能上千行,一次全渲会把页面卡住;
|
|
12
|
+
* 截断后由调用方给「完整 diff」下载链接(`GET /diff?runId=`)。
|
|
13
|
+
*/
|
|
14
|
+
import { escapeHtml } from "./text.ts";
|
|
15
|
+
|
|
16
|
+
/** 页面内联预览行数上限:超过就只渲前 400 行 + 下载链接 */
|
|
17
|
+
export const DIFF_PREVIEW_LINES = 400;
|
|
18
|
+
|
|
19
|
+
/** 一行的角色:文件头 / hunk 头 / 增 / 删 / 上下文 / 其它元信息(`diff --git`、`\ No newline`) */
|
|
20
|
+
export type DiffLineKind = "file" | "hunk" | "add" | "del" | "ctx" | "meta";
|
|
21
|
+
|
|
22
|
+
export interface DiffLine {
|
|
23
|
+
kind: DiffLineKind;
|
|
24
|
+
/** 原文(未转义,渲染时才转义) */
|
|
25
|
+
text: string;
|
|
26
|
+
/** 旧文件行号;增行与元信息为 null */
|
|
27
|
+
oldLine: number | null;
|
|
28
|
+
/** 新文件行号;删行与元信息为 null */
|
|
29
|
+
newLine: number | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `@@ -旧起点[,旧行数] +新起点[,新行数] @@ 可选的小节标题` */
|
|
33
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 逐行解析统一 diff。
|
|
37
|
+
*
|
|
38
|
+
* 行号规则(与 `git diff` / `patch` 的输出一致):
|
|
39
|
+
* - `@@` 头把两个计数器重置为头里给的起点;
|
|
40
|
+
* - 上下文行两边都加一;
|
|
41
|
+
* - `+` 只推进新行号,`-` 只推进旧行号;
|
|
42
|
+
* - hunk 之外的行(`diff --git`、`index`、`\ No newline`)不给行号。
|
|
43
|
+
*/
|
|
44
|
+
export function parseDiffLines(text: string): DiffLine[] {
|
|
45
|
+
const raw = text.split(/\r?\n/);
|
|
46
|
+
// 末尾换行会 split 出一个空串:它不是一个空行,丢掉它,否则行号与真实文件差一行
|
|
47
|
+
if (raw.length > 0 && raw[raw.length - 1] === "") raw.pop();
|
|
48
|
+
const lines: DiffLine[] = [];
|
|
49
|
+
let oldCursor = 0;
|
|
50
|
+
let newCursor = 0;
|
|
51
|
+
let inHunk = false;
|
|
52
|
+
for (const line of raw) {
|
|
53
|
+
const hunk = HUNK_HEADER.exec(line);
|
|
54
|
+
if (hunk) {
|
|
55
|
+
oldCursor = Number(hunk[1]);
|
|
56
|
+
newCursor = Number(hunk[2]);
|
|
57
|
+
inHunk = true;
|
|
58
|
+
lines.push({ kind: "hunk", text: line, oldLine: null, newLine: null });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (line.startsWith("--- ") || line.startsWith("+++ ")) {
|
|
62
|
+
inHunk = false;
|
|
63
|
+
lines.push({ kind: "file", text: line, oldLine: null, newLine: null });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (line.startsWith("\\")) {
|
|
67
|
+
// "":不算行,也不推计数器
|
|
68
|
+
lines.push({ kind: "meta", text: line, oldLine: null, newLine: null });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (inHunk && line.startsWith("+")) {
|
|
72
|
+
lines.push({ kind: "add", text: line, oldLine: null, newLine: newCursor });
|
|
73
|
+
newCursor += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (inHunk && line.startsWith("-")) {
|
|
77
|
+
lines.push({ kind: "del", text: line, oldLine: oldCursor, newLine: null });
|
|
78
|
+
oldCursor += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (inHunk) {
|
|
82
|
+
// 上下文行(含以空格开头、以及被裁掉前导空格的空行)
|
|
83
|
+
lines.push({ kind: "ctx", text: line, oldLine: oldCursor, newLine: newCursor });
|
|
84
|
+
oldCursor += 1;
|
|
85
|
+
newCursor += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
lines.push({ kind: "meta", text: line, oldLine: null, newLine: null });
|
|
89
|
+
}
|
|
90
|
+
return lines;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const KIND_CLASS: Record<DiffLineKind, string> = {
|
|
94
|
+
file: "dl file",
|
|
95
|
+
hunk: "dl hunk",
|
|
96
|
+
add: "dl add",
|
|
97
|
+
del: "dl del",
|
|
98
|
+
ctx: "dl ctx",
|
|
99
|
+
meta: "dl meta",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function lineNumber(value: number | null, width: number): string {
|
|
103
|
+
return value === null ? " ".repeat(width) : String(value).padStart(width, " ");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 行号列宽度:取本段最大行号的位数(截断后按可见段算,避免 5 位行号把 3 位全挤歪) */
|
|
107
|
+
function numberWidth(lines: readonly DiffLine[]): number {
|
|
108
|
+
let widest = 1;
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
for (const value of [line.oldLine, line.newLine]) {
|
|
111
|
+
if (value !== null) widest = Math.max(widest, String(value).length);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return widest;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 渲染成 `<pre class="diff">`;每行 `<span class="ln old">` + `<span class="ln new">` + 原文 */
|
|
118
|
+
export function renderDiffLines(lines: readonly DiffLine[]): string {
|
|
119
|
+
const width = numberWidth(lines);
|
|
120
|
+
const rows = lines.map((line) => {
|
|
121
|
+
const old = lineNumber(line.oldLine, width);
|
|
122
|
+
const next = lineNumber(line.newLine, width);
|
|
123
|
+
return `<span class="${KIND_CLASS[line.kind]}"><span class="ln old">${old}</span><span class="ln new">${next}</span><span class="tx">${escapeHtml(line.text)}</span></span>`;
|
|
124
|
+
});
|
|
125
|
+
return `<pre class="diff">${rows.join("\n")}</pre>`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface DiffView {
|
|
129
|
+
html: string;
|
|
130
|
+
/** diff 总行数(截断前的) */
|
|
131
|
+
total: number;
|
|
132
|
+
/** 是否被截断(调用方据此加「完整 diff」下载链接) */
|
|
133
|
+
truncated: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 解析 + 渲染 + 截断一步到位:`maxLines` 默认 400,只渲前 400 行 */
|
|
137
|
+
export function renderDiff(text: string, maxLines: number = DIFF_PREVIEW_LINES): DiffView {
|
|
138
|
+
const lines = parseDiffLines(text);
|
|
139
|
+
const shown = lines.slice(0, maxLines);
|
|
140
|
+
return { html: renderDiffLines(shown), total: lines.length, truncated: lines.length > shown.length };
|
|
141
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 列表筛选 + 分页:五个列表页(待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告)共用一套 query 语义。
|
|
3
|
+
*
|
|
4
|
+
* 为什么全用 GET query:面板零 JS,筛选必须能拷链接、能进浏览历史、刷新即最新;
|
|
5
|
+
* 为什么页码在这里算而不是在渲染层:同一份 filter 决定「取哪 20 条」与「第 X / Y 页」,
|
|
6
|
+
* 两处各算一次必然出现「页脚说 3 页、列表只有 2 页内容」。
|
|
7
|
+
*
|
|
8
|
+
* 筛选参数怎么落到条目上(三页字段名不同,这一层是唯一的转换点):
|
|
9
|
+
* - `skill` 精确匹配(不是模糊):技能名本身就是完整标识,模糊匹配只会让人找不到自己要的那一个;
|
|
10
|
+
* - `status` 精确匹配:待处理页没有 status 字段,用 kind 一一映射(running/stale/reviewed 同名,
|
|
11
|
+
* approval 映射成 `approval` —— 页面上看到的就是这个词);
|
|
12
|
+
* - `range` 按条目自己的时间字段算:待处理/已处理用 `at`,报告用 `startedAt`;取不到时间的一律**不**落进带范围的筛选
|
|
13
|
+
* (宁可让它只在 `range=all` 出现,也不能让「不知道时间」冒充「最近 7 天」)。
|
|
14
|
+
*/
|
|
15
|
+
import type { AppliedItem, ApprovalItem, DoneItem, PendingItem, RunReport, WebState, WebView } from "./data.ts";
|
|
16
|
+
import { WEB_VIEWS } from "./data.ts";
|
|
17
|
+
|
|
18
|
+
export const PAGE_SIZE = 20;
|
|
19
|
+
export const RANGE_KEYS = ["24h", "7d", "30d", "all"] as const;
|
|
20
|
+
export type RangeKey = (typeof RANGE_KEYS)[number];
|
|
21
|
+
export const DEFAULT_RANGE: RangeKey = "7d";
|
|
22
|
+
export const RANGE_LABEL: Record<RangeKey, string> = {
|
|
23
|
+
"24h": "最近 24 小时",
|
|
24
|
+
"7d": "最近 7 天",
|
|
25
|
+
"30d": "最近 30 天",
|
|
26
|
+
all: "全部时间",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** 状态下拉的可选值:既有 run 终态,也有待处理页自己的两种(running / stale) */
|
|
30
|
+
export const STATUS_OPTIONS = ["running", "stale", "reviewed", "applied", "rejected", "failed", "analyzed"] as const;
|
|
31
|
+
|
|
32
|
+
export interface ListFilter {
|
|
33
|
+
skill: string;
|
|
34
|
+
/** 空串 = 全部 */
|
|
35
|
+
status: string;
|
|
36
|
+
range: RangeKey;
|
|
37
|
+
/** 从 1 开始;非法或越界都在 paginate 里归位,不在这里猜 */
|
|
38
|
+
page: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const NO_FILTER: ListFilter = { skill: "", status: "", range: "all", page: 1 };
|
|
42
|
+
|
|
43
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
/** 范围起点;`all` 返回 null 表示不限 */
|
|
46
|
+
export function rangeStart(range: RangeKey, now: number): number | null {
|
|
47
|
+
switch (range) {
|
|
48
|
+
case "24h":
|
|
49
|
+
return now - DAY_MS;
|
|
50
|
+
case "7d":
|
|
51
|
+
return now - 7 * DAY_MS;
|
|
52
|
+
case "30d":
|
|
53
|
+
return now - 30 * DAY_MS;
|
|
54
|
+
default:
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 解析 query:缺省 range 用默认 7d,缺省 page 用 1;未知值一律回落默认,不报错(筛选是浏览工具,不该 400) */
|
|
60
|
+
export function parseListQuery(params: URLSearchParams): ListFilter {
|
|
61
|
+
const range = params.get("range") ?? "";
|
|
62
|
+
const page = Number.parseInt(params.get("page") ?? "1", 10);
|
|
63
|
+
return {
|
|
64
|
+
skill: (params.get("skill") ?? "").trim(),
|
|
65
|
+
status: (params.get("status") ?? "").trim(),
|
|
66
|
+
range: (RANGE_KEYS as readonly string[]).includes(range) ? (range as RangeKey) : DEFAULT_RANGE,
|
|
67
|
+
page: Number.isFinite(page) && page >= 1 ? page : 1,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 分页结果就是渲染层唯一的数据来源:`page` 是归位之后的真实页码(越界会被夹到最后一页),
|
|
73
|
+
* `pages` 至少是 1(空列表也要显示「第 1 / 1 页,共 0 条」,否则页脚会消失,人以为筛选坏了)。
|
|
74
|
+
*/
|
|
75
|
+
export function paginate<T>(
|
|
76
|
+
rows: readonly T[],
|
|
77
|
+
page: number,
|
|
78
|
+
): { rows: T[]; page: number; pages: number; total: number } {
|
|
79
|
+
const total = rows.length;
|
|
80
|
+
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
81
|
+
const current = Math.min(Math.max(1, Math.trunc(page)), pages);
|
|
82
|
+
const start = (current - 1) * PAGE_SIZE;
|
|
83
|
+
return { rows: rows.slice(start, start + PAGE_SIZE), page: current, pages, total };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 列表页的数据形状:`view` 是判别字段,渲染层据此拿到强类型的 rows */
|
|
87
|
+
export type ViewPage =
|
|
88
|
+
| { view: "pending"; rows: PendingItem[]; page: number; pages: number; total: number }
|
|
89
|
+
| { view: "approvals"; rows: ApprovalItem[]; page: 1; pages: 1; total: number }
|
|
90
|
+
| { view: "applied"; rows: AppliedItem[]; page: number; pages: number; total: number }
|
|
91
|
+
| { view: "done"; rows: DoneItem[]; page: number; pages: number; total: number }
|
|
92
|
+
| { view: "reports"; rows: RunReport[]; page: number; pages: number; total: number };
|
|
93
|
+
|
|
94
|
+
const PENDING_STATUS: Record<PendingItem["kind"], string> = {
|
|
95
|
+
running: "running",
|
|
96
|
+
stale: "stale",
|
|
97
|
+
reviewed: "reviewed",
|
|
98
|
+
approval: "approval",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** 时间是否落进范围:`at` 为 null 的条目只有不限范围时才出现(见文件头「不冒充最近 N 天」) */
|
|
102
|
+
function inRange(at: number | null, filter: ListFilter, now: number): boolean {
|
|
103
|
+
const start = rangeStart(filter.range, now);
|
|
104
|
+
if (start === null) return true;
|
|
105
|
+
return at !== null && at >= start;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function skillMatched(skill: string, filter: ListFilter): boolean {
|
|
109
|
+
return filter.skill.length === 0 || skill === filter.skill;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function statusMatched(status: string, filter: ListFilter): boolean {
|
|
113
|
+
return filter.status.length === 0 || status === filter.status;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 报告页的技能名藏在提案里:没有提案(还没到出稿那一步)就是空串,只在「全部技能」时命中 */
|
|
117
|
+
function reportSkill(item: RunReport): string {
|
|
118
|
+
return item.proposal?.skill ?? "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 待处理没有分页(它天然很短,且有 5 秒自动刷新,翻页会把人翻丢) */
|
|
122
|
+
export function buildViewPage(state: WebState, view: WebView, filter: ListFilter, now: number): ViewPage {
|
|
123
|
+
if (view === "approvals") {
|
|
124
|
+
return { view, rows: state.approvals, page: 1, pages: 1, total: state.approvals.length };
|
|
125
|
+
}
|
|
126
|
+
if (view === "applied") {
|
|
127
|
+
// 已采纳页的状态只有一个值(applied),下拉里选别的就什么都不剩 —— 这是筛选的真结果,不是 bug
|
|
128
|
+
const rows = state.applied.filter(
|
|
129
|
+
(item) =>
|
|
130
|
+
skillMatched(item.skill, filter) &&
|
|
131
|
+
(filter.status.length === 0 || filter.status === "applied") &&
|
|
132
|
+
inRange(item.appliedAt, filter, now),
|
|
133
|
+
);
|
|
134
|
+
return { view, ...paginate(rows, filter.page) };
|
|
135
|
+
}
|
|
136
|
+
if (view === "done") {
|
|
137
|
+
const rows = state.done.filter(
|
|
138
|
+
(item) => skillMatched(item.skill, filter) && statusMatched(item.status, filter) && inRange(item.at, filter, now),
|
|
139
|
+
);
|
|
140
|
+
return { view, ...paginate(rows, filter.page) };
|
|
141
|
+
}
|
|
142
|
+
if (view === "reports") {
|
|
143
|
+
const rows = state.reports.filter(
|
|
144
|
+
(item) =>
|
|
145
|
+
skillMatched(reportSkill(item), filter) &&
|
|
146
|
+
statusMatched(item.status, filter) &&
|
|
147
|
+
inRange(item.startedAt, filter, now),
|
|
148
|
+
);
|
|
149
|
+
return { view, ...paginate(rows, filter.page) };
|
|
150
|
+
}
|
|
151
|
+
const rows = state.pending.filter(
|
|
152
|
+
(item) =>
|
|
153
|
+
skillMatched(item.skill, filter) &&
|
|
154
|
+
statusMatched(PENDING_STATUS[item.kind], filter) &&
|
|
155
|
+
inRange(item.at, filter, now),
|
|
156
|
+
);
|
|
157
|
+
return { view, ...paginate(rows, filter.page) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 生成翻页链接:必须带上其它筛选参数,否则点下一页等于把筛选清空 */
|
|
161
|
+
export function pageHref(view: WebView, filter: ListFilter, page: number): string {
|
|
162
|
+
const params = new URLSearchParams({ view, range: filter.range, page: String(page) });
|
|
163
|
+
if (filter.skill.length > 0) params.set("skill", filter.skill);
|
|
164
|
+
if (filter.status.length > 0) params.set("status", filter.status);
|
|
165
|
+
return `/?${params.toString()}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* `?view=` → 页面名。只认白名单,其它(含 null、空串、`../`)一律回落待处理页 ——
|
|
170
|
+
* 这个值会进 `<a href>` 与 303 的 location,所以必须是枚举而不是原样回显。
|
|
171
|
+
*/
|
|
172
|
+
export function parseView(raw: string | null): WebView {
|
|
173
|
+
return (WEB_VIEWS as readonly string[]).includes(raw ?? "") ? (raw as WebView) : "pending";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 面板上的全部页面:五个列表页 + 技能管理 + 两个详情页。
|
|
178
|
+
*
|
|
179
|
+
* 详情页(run / skill)与技能管理页**需要额外参数**(runId / name / days),所以它们不能进 `VIEW_ORDER`,
|
|
180
|
+
* 也不能靠 `parseView` 认 —— 这是两件事,两个函数各自负责一件事。
|
|
181
|
+
*/
|
|
182
|
+
export const PANEL_VIEWS = [...WEB_VIEWS, "skills", "skill", "run"] as const;
|
|
183
|
+
export type PanelView = (typeof PANEL_VIEWS)[number];
|
|
184
|
+
|
|
185
|
+
/** `?view=` → 全页面名;只认白名单,其余(含 null、`../`)回落待处理页 */
|
|
186
|
+
export function parsePanelView(raw: string | null): PanelView {
|
|
187
|
+
return (PANEL_VIEWS as readonly string[]).includes(raw ?? "") ? (raw as PanelView) : "pending";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 303 回跳只能落在「能显示横幅」的页:列表页 + 技能管理。
|
|
192
|
+
* 详情页回跳会丢参数(`?view=run` 没有 runId 就是 404),所以它们一律归一成待处理页。
|
|
193
|
+
*/
|
|
194
|
+
export function parseRedirectView(raw: string | null): WebView | "skills" {
|
|
195
|
+
const view = parsePanelView(raw);
|
|
196
|
+
if (view === "skills") return "skills";
|
|
197
|
+
return (WEB_VIEWS as readonly string[]).includes(view) ? (view as WebView) : "pending";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 是不是「什么都没筛」(默认 range=7d、第 1 页):待处理页的自动刷新靠它决定 URL 要不要带参数 */
|
|
201
|
+
export function isDefaultFilter(filter: ListFilter): boolean {
|
|
202
|
+
return filter.skill.length === 0 && filter.status.length === 0 && filter.page === 1 && filter.range === DEFAULT_RANGE;
|
|
203
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 面板的 HTTP 底层小工具:发响应、303 回跳、Host/Origin 校验、读表单体。
|
|
3
|
+
*
|
|
4
|
+
* 抽出来只有一个理由:写操作(actions.ts)与路由(server.ts)都要用,而这套校验必须只有一份 ——
|
|
5
|
+
* 两处各写一遍,迟早出现「/action 比 /approve 松一点」的洞。
|
|
6
|
+
*/
|
|
7
|
+
import type * as http from "node:http";
|
|
8
|
+
|
|
9
|
+
export const HTML_TYPE = "text/html; charset=utf-8";
|
|
10
|
+
export const TEXT_TYPE = "text/plain; charset=utf-8";
|
|
11
|
+
export const FORM_TYPE = "application/x-www-form-urlencoded";
|
|
12
|
+
/** 表单请求体上限:几个 hidden 字段 + runId,2KB 已经是一百倍余量;超了就是有人在探 */
|
|
13
|
+
export const MAX_BODY_BYTES = 2048;
|
|
14
|
+
|
|
15
|
+
export function sendText(res: http.ServerResponse, status: number, text: string, type = TEXT_TYPE): void {
|
|
16
|
+
res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
|
|
17
|
+
res.end(text);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 303 回跳:结果首行进 query,页面顶部渲染成横幅;绝不把错误变成 500 页。
|
|
22
|
+
* `view` 决定回哪一页 —— 审批动作回待审批页,控制条动作回点按钮时那一页。
|
|
23
|
+
*/
|
|
24
|
+
export function sendBack(
|
|
25
|
+
res: http.ServerResponse,
|
|
26
|
+
key: "msg" | "err",
|
|
27
|
+
text: string,
|
|
28
|
+
view = "approvals",
|
|
29
|
+
): void {
|
|
30
|
+
// 首行够用:approve 的结论是多行五闸明细,全塞进 URL 只会又长又难读
|
|
31
|
+
const line = text.split("\n")[0]!.trim().slice(0, 300) || (key === "msg" ? "完成" : "失败(无详情)");
|
|
32
|
+
res.writeHead(303, {
|
|
33
|
+
location: `/?view=${view}&${key}=${encodeURIComponent(line)}`,
|
|
34
|
+
"content-type": TEXT_TYPE,
|
|
35
|
+
"cache-control": "no-store",
|
|
36
|
+
});
|
|
37
|
+
res.end(`${line}\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Host / Origin 校验:只认本机当前端口。
|
|
42
|
+
*
|
|
43
|
+
* 浏览器同源表单提交一定带 `Origin: http://<Host>`,所以两者一致就够了;
|
|
44
|
+
* 命令行 curl 不带 Origin(也就不带浏览器自动附带的凭据),此时靠 csrf 兜底。
|
|
45
|
+
*/
|
|
46
|
+
export function sourceDenied(request: http.IncomingMessage, port: number): string | null {
|
|
47
|
+
const host = String(request.headers.host ?? "").toLowerCase();
|
|
48
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
|
|
49
|
+
return "Host 头不在白名单(只认 127.0.0.1 / localhost 的当前端口)";
|
|
50
|
+
}
|
|
51
|
+
const origin = request.headers.origin;
|
|
52
|
+
if (origin !== undefined && origin.toLowerCase() !== `http://${host}`) return "Origin 头与面板地址不一致";
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 读表单请求体:超过上限返回 null(继续把剩余的流读掉,避免客户端卡在写一半) */
|
|
57
|
+
export function readBody(request: http.IncomingMessage, limit: number): Promise<string | null> {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const chunks: Buffer[] = [];
|
|
60
|
+
let total = 0;
|
|
61
|
+
request.on("data", (chunk: Buffer) => {
|
|
62
|
+
total += chunk.length;
|
|
63
|
+
if (total <= limit) chunks.push(chunk);
|
|
64
|
+
});
|
|
65
|
+
request.on("end", () => resolve(total > limit ? null : Buffer.concat(chunks).toString("utf8")));
|
|
66
|
+
request.on("error", () => resolve(null));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 只读面板的防御式 JSON 读取:`runs/` 里的文件随时可能「写到一半被 kill」,
|
|
3
|
+
* 所以所有解析都走这里 —— 读不到 / 坏 JSON 一律 null,调用方各自退化成空,绝不让一页 500。
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import { DISPOSITIONS } from "../attribution.ts";
|
|
7
|
+
|
|
8
|
+
export function readJson<T>(file: string): T | null {
|
|
9
|
+
try {
|
|
10
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
11
|
+
return JSON.parse(raw) as T;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function asText(value: unknown, fallback = ""): string {
|
|
18
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function asNumber(value: unknown): number | null {
|
|
22
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function asRecord(value: unknown): Record<string, unknown> | null {
|
|
26
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 归因编码(A~E / semantic / F / G)加一句中文标签:光看字母没人知道 B 是 frontmatter 坏 */
|
|
30
|
+
export function reasonLabel(code: string): string {
|
|
31
|
+
const disposition = DISPOSITIONS[code as keyof typeof DISPOSITIONS];
|
|
32
|
+
return disposition ? `${code} ${disposition.label}` : code;
|
|
33
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「谁持有 Web 面板」的落盘记录:`.pi/evolution/locks/web.json`。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有它:面板的 HTTP 服务跑在 pi 进程内(见 ./server.ts),端口被占时
|
|
5
|
+
* `lsof -ti:7788 | xargs kill` 会连 pi 一起杀掉。把「pid + 地址 + 起于何时」写下来,
|
|
6
|
+
* 说「端口被占」时才能分清「本机面板在跑」与「别的服务占了端口」,清理动作不必靠猜 PID。
|
|
7
|
+
*
|
|
8
|
+
* 三条边界:
|
|
9
|
+
* 1. **只记事实,不承担互斥**:跨进程互斥是 lib/lock.ts 的 dispatch 目录锁;这份文件坏了当没有,
|
|
10
|
+
* 绝不能让面板因为记录文件起不来;
|
|
11
|
+
* 2. **写失败只记日志**:此时面板已经监听成功,丢记录顶多让「占用提示」少一层判读依据;
|
|
12
|
+
* 3. **删记录要比 pid**:只有文件里的 pid 就是调用方时才删,否则会抹掉另一个实例的活记录。
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { isPidAlive } from "../lock.ts";
|
|
17
|
+
|
|
18
|
+
/** 记录文件的相对位置(相对 evolutionDir);绝对路径见 webPidFile() */
|
|
19
|
+
export const WEB_PID_FILE = path.join("locks", "web.json");
|
|
20
|
+
|
|
21
|
+
export interface WebPidInfo {
|
|
22
|
+
/** 持有面板的进程(= pi 主进程)pid */
|
|
23
|
+
pid: number;
|
|
24
|
+
host: string;
|
|
25
|
+
/** 实际监听端口:`port: 0` 时是内核分配的端口,不是请求值 */
|
|
26
|
+
port: number;
|
|
27
|
+
/** 面板地址(`http://host:port`),提示语直接给用户复制 */
|
|
28
|
+
url: string;
|
|
29
|
+
/** 起监听的毫秒戳 */
|
|
30
|
+
startedAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function webPidFile(evolutionDir: string): string {
|
|
34
|
+
return path.join(evolutionDir, WEB_PID_FILE);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 默认日志口:面板已经在跑,记录写失败只能落日志,不能反过来把面板搞挂 */
|
|
38
|
+
const defaultLog = (msg: string): void => console.error(msg);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 写记录(原子):先写 `web.json.tmp` 再 rename —— 同盘 rename 是原子的,
|
|
42
|
+
* 读方永远看不到半截 JSON。任何失败都只记日志、不抛、不留 `.tmp` 垃圾。
|
|
43
|
+
*/
|
|
44
|
+
export function writeWebPid(evolutionDir: string, info: WebPidInfo, log: (msg: string) => void = defaultLog): void {
|
|
45
|
+
const file = webPidFile(evolutionDir);
|
|
46
|
+
const tmp = `${file}.tmp`;
|
|
47
|
+
try {
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
fs.writeFileSync(tmp, `${JSON.stringify(info)}\n`, "utf8");
|
|
50
|
+
fs.renameSync(tmp, file);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
log(`[skill-evolve] 写 Web 面板记录失败(面板本身照常运行):${String(error)}`);
|
|
53
|
+
try {
|
|
54
|
+
fs.rmSync(tmp, { force: true });
|
|
55
|
+
} catch {
|
|
56
|
+
// 读方只认 web.json,.tmp 清不掉也不影响判读
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 读记录:坏 JSON、类型不对、字段缺失都返回 null(调用方一律按「没有记录」处理)。
|
|
63
|
+
* `port` / `startedAt` 也要求合法,否则提示语会出现 `NaN` 或 1970 年这种假信息。
|
|
64
|
+
*/
|
|
65
|
+
export function readWebPid(evolutionDir: string): WebPidInfo | null {
|
|
66
|
+
try {
|
|
67
|
+
const raw: unknown = JSON.parse(fs.readFileSync(webPidFile(evolutionDir), "utf8"));
|
|
68
|
+
if (!raw || typeof raw !== "object") return null;
|
|
69
|
+
const info = raw as Partial<WebPidInfo>;
|
|
70
|
+
const { pid, host, port, url, startedAt } = info;
|
|
71
|
+
if (!Number.isInteger(pid) || (pid as number) <= 0) return null;
|
|
72
|
+
if (typeof host !== "string" || host.length === 0) return null;
|
|
73
|
+
if (!Number.isInteger(port) || (port as number) <= 0) return null;
|
|
74
|
+
if (typeof url !== "string" || url.length === 0) return null;
|
|
75
|
+
if (typeof startedAt !== "number" || !Number.isFinite(startedAt)) return null;
|
|
76
|
+
return { pid: pid as number, host, port: port as number, url, startedAt: startedAt as number };
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 删记录:只有文件里的 pid 等于调用方 pid 时才删。
|
|
84
|
+
* 别人的记录(或读不出来的坏记录)一律不动 —— 无法证明是自己写的,就不该改。
|
|
85
|
+
*/
|
|
86
|
+
export function clearWebPid(evolutionDir: string, callerPid: number, log: (msg: string) => void = defaultLog): void {
|
|
87
|
+
const record = readWebPid(evolutionDir);
|
|
88
|
+
if (!record || record.pid !== callerPid) return;
|
|
89
|
+
try {
|
|
90
|
+
fs.rmSync(webPidFile(evolutionDir), { force: true });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
log(`[skill-evolve] 删 Web 面板记录失败:${String(error)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** pid 是否存活:与跨进程锁同一条判据(EPERM = 进程在、只是无权发信号,同样算活) */
|
|
97
|
+
export function isAlive(pid: number): boolean {
|
|
98
|
+
return isPidAlive(pid);
|
|
99
|
+
}
|