pi-skill-evolve 0.1.4 → 0.1.6

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.
Files changed (47) hide show
  1. package/extensions/skill-evolve/lib/aggregate.ts +153 -0
  2. package/extensions/skill-evolve/lib/dispatch-types.ts +65 -0
  3. package/extensions/skill-evolve/lib/dispatch.ts +32 -52
  4. package/extensions/skill-evolve/lib/evolution.ts +1 -0
  5. package/extensions/skill-evolve/lib/report.ts +4 -1
  6. package/extensions/skill-evolve/lib/runs.ts +35 -2
  7. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +24 -1
  8. package/extensions/skill-evolve/lib/telemetry.ts +2 -0
  9. package/extensions/skill-evolve/lib/web/applied.ts +4 -0
  10. package/extensions/skill-evolve/lib/web/approvals.ts +4 -0
  11. package/extensions/skill-evolve/lib/web/data.ts +13 -226
  12. package/extensions/skill-evolve/lib/web/done.ts +65 -0
  13. package/extensions/skill-evolve/lib/web/filter.ts +4 -4
  14. package/extensions/skill-evolve/lib/web/list-query.ts +43 -0
  15. package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
  16. package/extensions/skill-evolve/lib/web/pending.ts +191 -0
  17. package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
  18. package/extensions/skill-evolve/lib/web/render-rows.ts +178 -205
  19. package/extensions/skill-evolve/lib/web/render-run.ts +14 -0
  20. package/extensions/skill-evolve/lib/web/render-session.ts +138 -0
  21. package/extensions/skill-evolve/lib/web/render-skills.ts +0 -1
  22. package/extensions/skill-evolve/lib/web/render-table.ts +126 -0
  23. package/extensions/skill-evolve/lib/web/render.ts +116 -54
  24. package/extensions/skill-evolve/lib/web/reports.ts +11 -5
  25. package/extensions/skill-evolve/lib/web/run-detail.ts +6 -0
  26. package/extensions/skill-evolve/lib/web/run-session.ts +62 -0
  27. package/extensions/skill-evolve/lib/web/server.ts +21 -1
  28. package/extensions/skill-evolve/lib/web/session-file.ts +136 -0
  29. package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
  30. package/extensions/skill-evolve/lib/web/session-view.ts +276 -0
  31. package/extensions/skill-evolve/lib/web/shell.ts +42 -11
  32. package/extensions/skill-evolve/lib/web/style.ts +171 -89
  33. package/extensions/skill-evolve/lib/web/text.ts +57 -0
  34. package/extensions/skill-evolve/test/aggregate-dispatch.test.ts +165 -0
  35. package/extensions/skill-evolve/test/aggregate-harness.ts +114 -0
  36. package/extensions/skill-evolve/test/aggregate.test.ts +80 -0
  37. package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
  38. package/extensions/skill-evolve/test/session-page.test.ts +200 -0
  39. package/extensions/skill-evolve/test/session-render.test.ts +203 -0
  40. package/extensions/skill-evolve/test/session-view.test.ts +180 -0
  41. package/extensions/skill-evolve/test/web-applied.test.ts +30 -22
  42. package/extensions/skill-evolve/test/web-redesign.test.ts +264 -0
  43. package/extensions/skill-evolve/test/web-render.test.ts +57 -41
  44. package/extensions/skill-evolve/test/web-server.test.ts +4 -1
  45. package/extensions/skill-evolve/test/web-session-column.test.ts +139 -0
  46. package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
  47. package/package.json +1 -1
@@ -4,6 +4,9 @@
4
4
  * 为什么抽出来:run 详情页与技能详情页也要状态栏、六个 tab、内联样式,
5
5
  * 各自复制一遍必然出现「点进详情页 tab 高亮错页、状态栏数字少算一个」。
6
6
  * 这里只拼字符串:不读盘、不发请求、不引外部资源 —— 数据由调用方读好传进来。
7
+ *
8
+ * 顶部是**粘性区**(`position: sticky`):列表滚到第 30 行时,
9
+ * 「现在有几条要处理、我在哪个 tab、四个开关什么状态」仍然看得见 —— 这三样是这一页的全部决策输入。
7
10
  */
8
11
  import type { WebState, WebView } from "./data.ts";
9
12
  import { STYLE } from "./style.ts";
@@ -21,29 +24,50 @@ export const VIEW_LABEL: Record<TabView, string> = {
21
24
  skills: "技能管理",
22
25
  };
23
26
 
27
+ /** 每个 tab 的计数徽章;`skills` 没有计数(它不是「条数」) */
28
+ export type TabCounts = Partial<Record<TabView, number>>;
29
+
24
30
  /**
25
31
  * 当前高亮的 tab。详情页传 `null`:它们不在导航里(要 runId / 技能名才能进来),
26
32
  * 硬highlight 某个 tab 等于骗人 —— 点那个 tab 回不到这一页。
27
33
  */
28
34
  export type ActiveTab = WebView | "skills" | null;
29
35
 
30
- /** 导航:`<a href>` 只用枚举值拼,绝不把 query 原样回显 */
31
- export function tabs(active: ActiveTab): string {
36
+ /** 计数从状态里取:tab 上的数字与列表里的条数必须是同一份数据,不能各算一遍 */
37
+ export function tabCounts(state: WebState): TabCounts {
38
+ return {
39
+ pending: state.pending.length,
40
+ approvals: state.approvals.length,
41
+ // 已采纳用 summary 的累计值:列表本身有上限(50 条),拿它当总数会永远显示 50
42
+ applied: state.summary.appliedTotal,
43
+ done: state.done.length,
44
+ reports: state.reports.length,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * 导航:`<a href>` 只用枚举值拼,绝不把 query 原样回显。
50
+ * 不给 counts 时只出标签(详情页与单测用),给了就在标签后面挂一个计数徽章(`.cnt`)。
51
+ */
52
+ export function tabs(active: ActiveTab, counts: TabCounts = {}): string {
32
53
  return TAB_ORDER.map((item) => {
33
54
  const className = item === active ? ' class="active"' : "";
34
- return `<a href="/?view=${item}"${className}>${VIEW_LABEL[item]}</a>`;
55
+ const count = counts[item];
56
+ const badge = count === undefined ? "" : `<span class="cnt">${count}</span>`;
57
+ return `<a href="/?view=${item}"${className}>${VIEW_LABEL[item]}${badge}</a>`;
35
58
  }).join("\n");
36
59
  }
37
60
 
38
- /** 状态栏:全是数字,安全;「待批 N」「已采纳 N」都直接链到对应页(用户就是点那个数字的) */
61
+ /**
62
+ * 摘要行:四个数字。「待批」与「已采纳」直接链到对应页 —— 用户就是点那个数字的。
63
+ * 回滚率不在这里:它是周报口径的比率,挤进一行数字里只会让人以为「26% 也要现在处理」。
64
+ */
39
65
  export function statusBar(state: WebState): string {
40
- const percent = (state.summary.rollbackRate * 100).toFixed(0);
41
66
  return [
42
67
  `复盘中 ${state.summary.reviewing}`,
43
68
  `卡死 ${state.summary.stale}`,
44
69
  `待批 <a href="/?view=approvals">${state.summary.pendingApprovals}</a>`,
45
70
  `已采纳 <a href="/?view=applied">${state.summary.appliedTotal}</a>`,
46
- `回滚率 ${percent}%`,
47
71
  ].join(" · ");
48
72
  }
49
73
 
@@ -52,10 +76,12 @@ export interface ShellInput {
52
76
  /** 页面标题:进 `<title>`,页面上不再重复一遍 */
53
77
  title: string;
54
78
  active: ActiveTab;
55
- /** `<head>` 里的额外标签(目前只有待处理页的 5 秒自刷新) */
79
+ /** `<head>` 里的额外标签(目前只有 `&live=1` 时的 5 秒自刷新) */
56
80
  head?: string;
57
- /** 导航上方的「控制条 + 横幅」 */
81
+ /** 粘性区里、tabs 下面的那一行(列表页的四个开关与横幅) */
58
82
  top?: string;
83
+ /** 摘要行尾部的小字(列表页的「更新于 … · 刷新」) */
84
+ meta?: string;
59
85
  /** `<main>` 内容 */
60
86
  body: string;
61
87
  /** 页脚内容(列表页传「最近操作 + 只读说明」) */
@@ -63,6 +89,7 @@ export interface ShellInput {
63
89
  }
64
90
 
65
91
  export function shellPage(input: ShellInput): string {
92
+ const meta = input.meta === undefined || input.meta.length === 0 ? "" : ` · ${input.meta}`;
66
93
  return `<!doctype html>
67
94
  <html lang="zh-CN">
68
95
  <head>
@@ -72,15 +99,19 @@ ${input.head ?? ""}<title>技能进化 · ${input.title}</title>
72
99
  <style>${STYLE}</style>
73
100
  </head>
74
101
  <body>
102
+ <div class="wrap" id="top">
103
+ <header class="hd">
75
104
  <h1>技能进化面板</h1>
76
- <p class="bar">${statusBar(input.state)}</p>
77
- ${input.top ?? ""}<nav>
78
- ${tabs(input.active)}
105
+ <p class="sum">${statusBar(input.state)}${meta}</p>
106
+ <nav class="tabs">
107
+ ${tabs(input.active, tabCounts(input.state))}
79
108
  </nav>
109
+ ${input.top ?? ""}</header>
80
110
  ${input.body}
81
111
  <footer>
82
112
  ${input.foot ?? ""}
83
113
  </footer>
114
+ </div>
84
115
  </body>
85
116
  </html>
86
117
  `;
@@ -1,107 +1,189 @@
1
1
  /**
2
- * 面板的内联样式:单文件放着是因为只有一处消费(render.ts),拆出来只是为了把 render.ts 压在 300 行内。
3
- * 不引外部 CSS:面板必须离线可用,一条网络请求都不发。
2
+ * 面板的全部样式:一份内联 CSS,不引外部资源(面板要离线可用,一条网络请求都不发)。
3
+ *
4
+ * 三块约定(改样式先看这三行,别各自发明):
5
+ * 1. **颜色只从 `:root` 的令牌里取**:同一个概念(蓝=要看、绿=已落地、黄=在跑、红=卡死)全站一色;
6
+ * 2. **字号只有 12 / 13 / 14 / 20 四档,间距只用 4 / 8 / 12 / 16 / 24** —— 随手写 15px / 7px 是页面显得毛糙的根源;
7
+ * 3. **列表行是表格、不是卡片**:一行一条,扫读靠列对齐;操作是小号链接,不是每行两个大按钮。
4
8
  */
5
9
  export const STYLE = `
6
- :root { color-scheme: dark }
10
+ :root {
11
+ color-scheme: dark;
12
+ --bg: #0f1115; --panel: #161a21; --line: #252b36; --fg: #dfe3ea; --muted: #8b95a7;
13
+ --blue: #3884ff; --green: #2ea043; --red: #d1435b; --yellow: #c69026;
14
+ }
7
15
  * { box-sizing: border-box }
8
- body { margin: 0; padding: 24px; background: #0f1115; color: #d7dce5;
9
- font: 14px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace }
10
- h1 { margin: 0 0 4px; font-size: 18px; font-weight: 600 }
11
- .bar { margin: 0 0 16px; color: #8d99ae }
12
- .bar a { color: #7cc4ff; text-decoration: none }
13
- .notice { margin: 0 0 12px; padding: 8px 12px; border-left: 2px solid #3f9d5a; background: #131a17; color: #b6e2c4 }
14
- .notice.err { border-left-color: #c05c5c; background: #1a1313; color: #f0bdbd }
15
- nav { display: flex; gap: 12px; margin-bottom: 16px; border-bottom: 1px solid #232733; padding-bottom: 8px }
16
- nav a { color: #8d99ae; text-decoration: none; padding: 2px 8px; border-radius: 4px }
17
- nav a:hover { background: #1a1f29 }
18
- nav a.active { color: #0f1115; background: #7cc4ff }
19
- ul { list-style: none; margin: 0; padding: 0 }
20
- li.row { border-bottom: 1px solid #1c202a; padding: 8px 0 }
21
- summary.head { cursor: pointer; color: #cfe3ff }
22
- span.head { color: #cfe3ff }
23
- pre { margin: 8px 0 0; padding: 12px; background: #141821; border-left: 2px solid #2b3342;
24
- white-space: pre-wrap; word-break: break-word; color: #aeb7c4 }
25
- .empty { color: #6f7a8c; padding: 24px 0 }
26
- .acts { display: flex; gap: 8px; margin-top: 8px }
27
- .acts form { display: inline; margin: 0 }
28
- button { font: inherit; color: #0f1115; background: #7cc4ff; border: 0; border-radius: 4px; padding: 2px 12px; cursor: pointer }
29
- button.reject { background: #6f7a8c; color: #0f1115 }
30
- button.danger { background: #c05c5c; color: #fff }
16
+ body { margin: 0; background: var(--bg); color: var(--fg);
17
+ font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace }
18
+ .wrap { max-width: 980px; margin: 0 auto; padding: 0 16px 24px }
19
+ a { color: var(--blue); text-decoration: none }
20
+ a:hover { text-decoration: underline }
21
+ code { font: inherit; color: #c8d3e6 }
22
+ h1 { margin: 0; font-size: 20px; font-weight: 600 }
23
+ h2 { margin: 20px 0 8px; font-size: 14px; font-weight: 600; color: #c8d3e6 }
24
+ h3, h4 { margin: 12px 0 4px; font-size: 14px; font-weight: 600; color: #c8d3e6 }
25
+ /* 顶部粘性区:标题 + 一行摘要 + tabs + 四个开关,滚动时永远看得到「现在有几条要处理」 */
26
+ .hd { position: sticky; top: 0; z-index: 10; padding: 12px 0 8px; background: var(--bg);
27
+ border-bottom: 1px solid var(--line) }
28
+ .sum { margin: 4px 0 8px; color: var(--muted); font-size: 12px }
29
+ .sum a { color: #c8d3e6 }
30
+ .tabs { display: flex; gap: 4px; flex-wrap: wrap }
31
+ .tabs a { color: var(--muted); padding: 2px 8px; border-radius: 4px; white-space: nowrap }
32
+ .tabs a:hover { background: var(--panel); text-decoration: none }
33
+ .tabs a.active { color: #fff; background: #1c2740 }
34
+ .tabs .cnt { margin-left: 4px; padding: 0 6px; border-radius: 999px; font-size: 11px;
35
+ background: var(--line); color: var(--fg) }
36
+ .tabs a.active .cnt { background: var(--blue); color: #fff }
37
+ /* 控制条:四个开关与「立即复盘」挤在一行,超宽就横向滚动 —— 换行会把列表挤到屏幕外 */
38
+ .ctl { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 8px 0 0 }
39
+ .ctl form { display: inline-flex; margin: 0 }
40
+ .ctl .sw { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px;
41
+ border: 1px solid var(--line); background: var(--panel); font-size: 12px }
42
+ .ctl .state.on { color: var(--green) }
43
+ .ctl .state.off { color: var(--muted) }
44
+ .notice { margin: 12px 0 0; padding: 8px 12px; border-left: 2px solid var(--green); background: #12221a; color: #b6e2c4 }
45
+ .notice.err { border-left-color: var(--red); background: #221316; color: #f0bdbd }
46
+ .warn { margin: 8px 0 0; padding: 4px 8px; border-left: 2px solid var(--yellow); background: #231d11; color: #e8d9a8; font-size: 12px }
47
+ /* 筛选:零 JS 的 GET 表单,选中项必须能在刷新后留住 */
48
+ .filter { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 12px 0; padding: 8px 12px;
49
+ background: var(--panel); border: 1px solid var(--line); border-radius: 6px; font-size: 12px }
50
+ .filter label { color: var(--muted) }
51
+ .filter input, .filter select { font: inherit; color: var(--fg); background: var(--bg);
52
+ border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px }
53
+ button { font: inherit; color: #fff; background: var(--blue); border: 0; border-radius: 4px; padding: 2px 10px; cursor: pointer }
31
54
  button:hover { filter: brightness(1.1) }
32
- /* 顶部控制条:开关状态、切换/立即复盘按钮、最后活动 */
33
- .ctl { display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
34
- margin: 0 0 16px; padding: 8px 12px; background: #141821; border: 1px solid #232733; border-radius: 6px }
35
- .ctl form { display: inline; margin: 0 }
36
- .ctl .state { padding: 1px 8px; border-radius: 4px }
37
- .ctl .state.on { background: #16301f; color: #b6e2c4 }
38
- .ctl .state.off { background: #2a1f1f; color: #e6b0b0 }
39
- .ctl .act { color: #8d99ae }
40
- /* 单个开关:状态 + 后果说明 + 按钮竖排,两个开关之间靠 .ctl 的 gap 分隔 */
41
- .ctl .sw { display: inline-flex; align-items: center; gap: 6px }
42
- .ctl .hint { color: #8d99ae; font-size: 12px }
43
- .warn { margin: 6px 0 0; padding: 6px 10px; border-left: 3px solid #b58900;
44
- background: #2a2418; color: #e8d9a8 }
45
- /* 筛选表单与翻页:零 JS,样式也要看得出它们是一组 */
46
- .filter { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
47
- margin: 0 0 12px; padding: 8px 12px; background: #141821; border: 1px solid #232733; border-radius: 6px }
48
- .filter label { color: #8d99ae }
49
- .filter input, .filter select { font: inherit; color: #d7dce5; background: #0f1115;
50
- border: 1px solid #2b3342; border-radius: 4px; padding: 1px 6px }
51
- .page { display: flex; gap: 12px; align-items: center; margin: 12px 0 0; color: #8d99ae }
52
- .page a { color: #7cc4ff; text-decoration: none }
53
- .page .count { color: #6f7a8c }
54
- .off { color: #3f4654 }
55
- a.btn { color: #7cc4ff; text-decoration: none; padding: 2px 10px; border: 1px solid #2b3342; border-radius: 4px }
56
- a.btn:hover { background: #1a1f29 }
57
- /* 最近操作:结果分三色,ok / partial / error 扫一眼就能分开 */
58
- .audit { margin-top: 24px; padding-top: 12px; border-top: 1px solid #232733 }
59
- .audit h2 { margin: 0 0 8px; font-size: 14px; font-weight: 600; color: #8d99ae }
60
- .audit li { padding: 2px 0; color: #aeb7c4 }
61
- .audit .when { color: #6f7a8c }
62
- .audit code { font: inherit; color: #cfe3ff }
55
+ button.reject { background: #3a4453 }
56
+ button.danger { background: var(--red) }
57
+ a.btn { display: inline-block; color: var(--blue); padding: 1px 8px; border: 1px solid var(--line); border-radius: 4px }
58
+ /* 列表:真表格。一行等于一条 run,列对齐才扫得动;行尾是链接不是按钮 */
59
+ .rows { width: 100%; border-collapse: collapse; table-layout: fixed }
60
+ .rows th { padding: 4px 10px; color: var(--muted); font-size: 12px; font-weight: 600; text-align: left;
61
+ border-bottom: 1px solid var(--line) }
62
+ .rows td { padding: 8px 10px; border-bottom: 1px solid #1b2029; white-space: nowrap; vertical-align: middle }
63
+ .rows tbody tr:hover { background: #1b2029 }
64
+ .rows .status { width: 112px }
65
+ /* 单条会话的 run 不进组,会话名就写在状态格里一行小字(窄屏也不隐藏) */
66
+ .rows .sk-sess { display: block; margin-top: 2px; font-size: 12px }
67
+ .rows .sk-sess a, .rows .sk-sess { color: var(--muted) }
68
+ .rows .time { width: 108px; color: var(--muted); font-size: 12px }
69
+ .rows .skill { max-width: 220px; overflow: hidden; text-overflow: ellipsis; font-size: 12px }
70
+ .rows .delta { width: 92px; color: var(--muted); font-size: 12px }
71
+ .rows .ops { text-align: right; font-size: 12px }
72
+ .rows .ops a, .rows .ops button { color: var(--blue); background: none; border: 0; padding: 0; font-size: 12px; cursor: pointer }
73
+ .rows .ops a:hover, .rows .ops button:hover { text-decoration: underline }
74
+ .rows .ops form { display: inline; margin: 0 }
75
+ .rows .ops .rid { color: var(--muted) }
76
+ /* 同一会话的多条 run 收进一个 details:抬头一行说清「哪个会话、几条、最近什么时候、几条要处理」 */
77
+ .grp { margin: 12px 0; border: 1px solid var(--line); border-radius: 6px; background: #12151b }
78
+ .grp > summary { padding: 8px 12px; color: #c8d3e6; cursor: pointer }
79
+ .grp .rows th { border-bottom: 1px solid var(--line) }
80
+ .badge { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 12px; border: 1px solid currentColor }
81
+ .badge.gray { color: var(--muted) }
82
+ .badge.blue { color: var(--blue) }
83
+ .badge.green { color: var(--green) }
84
+ .badge.red { color: var(--red) }
85
+ .badge.yellow { color: var(--yellow) }
86
+ .badge.darkred { color: #8f2f3d }
87
+ .badge.faint { color: #5d6675 }
88
+ .empty { color: var(--muted); padding: 24px 0; text-align: center }
89
+ .empty code { color: #c8d3e6 }
90
+ .next { color: var(--muted); text-align: center; font-size: 12px; margin: 0 0 24px }
91
+ .page { display: flex; gap: 12px; align-items: center; justify-content: center; margin: 16px 0 0;
92
+ color: var(--muted); font-size: 12px }
93
+ .page .count { color: var(--muted) }
94
+ .off { color: #45505f }
95
+ .hint { color: var(--muted); font-size: 12px }
96
+ .muted { color: var(--muted) }
97
+ /* 统计 / 明细表:评审 blocking、五闸、技能管理、发现共用(别每处各写一套颜色) */
98
+ .tbl { border-collapse: collapse; width: 100%; margin: 8px 0 0; font-size: 12px }
99
+ .tbl th, .tbl td { border-bottom: 1px solid #1b2029; padding: 4px 8px; text-align: left; vertical-align: top }
100
+ .tbl th { color: var(--muted); font-weight: 600 }
101
+ .tag { display: inline-block; padding: 1px 7px; border-radius: 999px; background: var(--panel); color: var(--muted); font-size: 12px }
102
+ .tag.warn { background: #231d11; color: #e8d9a8 }
103
+ .tag.fail { background: #221316; color: #f0bdbd }
63
104
  .ok { color: #7fc78f }
64
105
  .partial { color: #d6b25e }
65
106
  .fail { color: #e08585 }
66
- /* 表格:评审 blocking / 五闸 / 发现 / 技能管理都用同一套,避免每处各写一份颜色 */
67
- .tbl { border-collapse: collapse; width: 100%; margin: 8px 0 0; font-size: 13px }
68
- .tbl th, .tbl td { border-bottom: 1px solid #1c202a; padding: 4px 8px; text-align: left; vertical-align: top }
69
- .tbl th { color: #8d99ae; font-weight: 600 }
70
- .tbl a { color: #7cc4ff; text-decoration: none }
71
- /* 状态标签:三个标签三色,合成一个就分不出该去批、去改还是去补 stub */
72
- .tag { display: inline-block; padding: 0 6px; border-radius: 4px; background: #1a1f29; color: #aeb7c4; font-size: 12px }
73
- .tag.warn { background: #2a2418; color: #e8d9a8 }
74
- .tag.fail { background: #2a1616; color: #f0bdbd }
75
- .hint { color: #8d99ae; font-size: 12px }
76
- .days { margin: 0 0 8px; color: #8d99ae }
77
- .days a { color: #7cc4ff; text-decoration: none }
78
- .days .on { color: #d7dce5; padding: 0 6px; background: #1a1f29; border-radius: 4px }
79
- /* run 详情页的概要:左标签右值,两列对齐才好扫 */
107
+ .days { margin: 0 0 8px; color: var(--muted); font-size: 12px }
108
+ .days a { margin-right: 8px }
109
+ .days .on { color: var(--fg); background: var(--panel); border-radius: 4px; padding: 0 6px }
110
+ /* run / 技能详情页的键值区:左标签右值,两列对齐才好扫 */
80
111
  .kv { display: grid; grid-template-columns: 6em 1fr; gap: 2px 12px; margin: 8px 0 0 }
81
112
  .kv-row { display: contents }
82
- .kv .k { color: #8d99ae }
83
- .kv .v { color: #d7dce5 }
113
+ .kv .k { color: var(--muted); font-size: 12px }
114
+ .kv .v { color: var(--fg) }
84
115
  .kv-line { margin: 4px 0 }
85
- .kv .v code { font: inherit; color: #cfe3ff }
86
- a.run { color: #cfe3ff; text-decoration: none; border-bottom: 1px dashed #3f4654 }
87
- a.run:hover { border-bottom-color: #7cc4ff }
88
- h2 { margin: 20px 0 6px; font-size: 15px; font-weight: 600; color: #cfe3ff }
89
- /* diff:文件头灰底、hunk 灰字、+ 绿底、- 红底;行号两列宽固定,不随内容抹动 */
90
- .diff { margin: 8px 0 0; padding: 8px 0; background: #0c0e12; border: 1px solid #232733; border-radius: 6px;
91
- overflow-x: auto; white-space: pre; font-size: 12.5px; line-height: 1.5 }
92
- .diff .dl { display: block; padding: 0 8px }
93
- /* 长行必须横向滚动而不是被裁掉:技能 description 动辄两三百字,截断等于看不见改了什么 */
94
- .diff { overflow-x: auto }
95
- .diff .dl { min-width: max-content }
116
+ a.run { color: #c8d3e6; border-bottom: 1px dashed #45505f }
117
+ a.run:hover { border-bottom-color: var(--blue); text-decoration: none }
118
+ a.sess { color: var(--muted); border-bottom: 1px dashed #45505f }
119
+ a.sess:hover { color: var(--blue); text-decoration: none }
120
+ /* diff:文件头灰底、hunk 灰字、+ 绿底、- 红底;行号定宽,长行横向滚动而不是被裁 */
121
+ .diff { margin: 8px 0 0; padding: 8px 0; background: #0c0e12; border: 1px solid var(--line); border-radius: 6px;
122
+ overflow-x: auto; white-space: pre; font-size: 12px; line-height: 1.5 }
123
+ .diff .dl { display: block; padding: 0 8px; min-width: max-content }
96
124
  .diff .ln { display: inline-block; width: 4.5em; color: #4a5364; text-align: right; padding-right: 8px; user-select: none }
97
125
  .diff .tx { white-space: pre }
98
126
  .diff .file { background: #22262f; color: #b8c1d1 }
99
- .diff .hunk { color: #8d99ae }
127
+ .diff .hunk { color: var(--muted) }
100
128
  .diff .add { background: #12281a; color: #b7e7c5 }
101
- .diff .del { background: #2a1616; color: #f0bdbd }
129
+ .diff .del { background: #221316; color: #f0bdbd }
102
130
  .diff .meta { color: #6f7a8c }
103
- /* 改后全文:长文件限高内部滚动,别把后面的评审/五闸顶到屏幕外 */
104
131
  .candidate { max-height: 480px; overflow: auto }
105
- footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid #232733; color: #6f7a8c }
106
- footer a { color: #7cc4ff }
132
+ pre { margin: 8px 0 0; padding: 12px; background: #12151b; border-left: 2px solid var(--line);
133
+ white-space: pre-wrap; word-break: break-word; color: #aeb7c4; font-size: 12px }
134
+ /* 最近操作:结果三色,ok / partial / error 扫一眼分开 */
135
+ .audit { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--line) }
136
+ .audit ul { list-style: none; margin: 0; padding: 0 }
137
+ .audit li { padding: 2px 0; color: #aeb7c4; font-size: 12px }
138
+ .audit .when { color: var(--muted) }
139
+ /* 会话页:顶部一行(返回 / 会话 / 原文件)+ 概览 + 左侧目录 + 消息流 */
140
+ .ses-top { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin: 16px 0 8px }
141
+ .ses-top .ses-id { color: #c8d3e6 }
142
+ .ses-top .to-end { margin-left: auto; font-size: 12px }
143
+ .ses-file { margin: 0; color: var(--muted); font-size: 12px; min-width: 0; word-break: break-all }
144
+ .ses-file code { user-select: all }
145
+ .ov { margin: 8px 0 16px; padding: 8px 12px; background: var(--panel); border: 1px solid var(--line);
146
+ border-radius: 6px; color: var(--muted); font-size: 12px }
147
+ .ov .ov-link { margin-left: 12px }
148
+ .ses-wrap { display: flex; gap: 24px; align-items: flex-start }
149
+ .toc { display: none }
150
+ .m { scroll-margin-top: 80px }
151
+ /* 消息块:一条一块,抬头上是角色徽章与时刻 */
152
+ .msg { margin: 16px 0; padding: 0 0 0 12px; border-left: 3px solid transparent }
153
+ .msg .mhead { display: flex; align-items: center; gap: 8px; margin-bottom: 4px }
154
+ .msg .at { color: var(--muted); font-size: 12px }
155
+ .msg.user { border-left-color: var(--blue); background: rgba(56, 132, 255, .08); padding: 8px 12px; border-radius: 4px }
156
+ .msg-body { font-size: 14px; line-height: 1.6 }
157
+ .msg-body p { margin: 4px 0 }
158
+ .msg-body ul { margin: 4px 0; padding-left: 20px }
159
+ .msg-body pre.code { margin: 8px 0; border: 1px solid var(--line) }
160
+ details.tools { margin: 12px 0; padding: 8px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px }
161
+ details.tools > summary { cursor: pointer; color: #c8d3e6; font-size: 12px }
162
+ .tool { margin: 8px 0 0; padding: 8px 0 0; border-top: 1px solid var(--line) }
163
+ .tool-name { margin: 0; color: var(--blue); font-size: 12px }
164
+ .tool-args, .tool-result { margin: 4px 0 0; padding: 8px; background: var(--bg); border-left: 1px solid var(--line) }
165
+ .tool-result { color: var(--muted) }
166
+ .to-top { display: block; margin: 24px 0 0; text-align: right; font-size: 12px }
167
+ footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px }
168
+ /* 窄屏:只保证不溢出,不做响应式框架。时间/提案列让位给内容,操作列可以换行 */
169
+ @media (max-width: 900px) {
170
+ .wrap { padding: 0 8px 16px }
171
+ .rows .time, .rows .delta { display: none }
172
+ .rows .status { width: 72px }
173
+ .rows td, .rows th { padding: 6px }
174
+ .rows .ops { white-space: normal }
175
+ }
176
+ /* 宽屏才给左侧常驻目录:窄屏用顶部折叠目录兜底(在 render-session.ts 里) */
177
+ @media (min-width: 1200px) {
178
+ .ses-wrap { flex-direction: row }
179
+ .toc { display: block; position: sticky; top: 0; align-self: flex-start; width: 260px;
180
+ max-height: calc(100vh - 80px); overflow: auto; font-size: 12px }
181
+ .toc a { display: block; padding: 2px 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
182
+ .toc a:hover { color: var(--blue); text-decoration: none }
183
+ .ses-body { flex: 1; min-width: 0 }
184
+ }
185
+ details.toc-fold { display: block; margin: 0 0 12px; font-size: 12px }
186
+ details.toc-fold > summary { cursor: pointer; color: var(--muted) }
187
+ details.toc-fold .toc-list { padding: 8px 0 0 8px }
188
+ @media (min-width: 1200px) { details.toc-fold { display: none } }
107
189
  `.trim();
@@ -43,6 +43,63 @@ export function shortRunId(runId: string): string {
43
43
  return runId.length > 16 && runId.includes("-") ? `${runId.slice(0, 8)}…${runId.slice(-4)}` : runId;
44
44
  }
45
45
 
46
+ /**
47
+ * 列表「操作」列里的 runId:只留末 8 位(真实数据的 9 条 run 前 8 位完全相同,
48
+ * 保留前缀等于没显示)。完整 id 交给链接的 `title`。
49
+ */
50
+ export function tailRunId(runId: string): string {
51
+ return runId.length > 8 ? `…${runId.slice(-8)}` : runId;
52
+ }
53
+
54
+ /** 同一个本地日期(列表「今天只写时分」的判据) */
55
+ function sameDay(a: Date, b: Date): boolean {
56
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
57
+ }
58
+
59
+ const pad2 = (value: number): string => String(value).padStart(2, "0");
60
+
61
+ /** `HH:mm`:会话页每条消息的时钟 */
62
+ export function formatTime(ts: number): string {
63
+ const date = new Date(ts);
64
+ return `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
65
+ }
66
+
67
+ /** `HH:mm:ss`:消息流抬头用(同一分钟内的多条要能分先后) */
68
+ export function formatTimeSeconds(ts: number | null): string {
69
+ if (ts === null || !Number.isFinite(ts)) return "—";
70
+ const date = new Date(ts);
71
+ return `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
72
+ }
73
+
74
+ /**
75
+ * 列表「时间」列:今天只写 `19:35`,更早的写 `11-07 18:24`。
76
+ * 一列里全是完整日期时,今天那几条反而最难扫(要对比年月日才知道是刚才还是前天)。
77
+ */
78
+ export function formatClock(ts: number | null, now: number): string {
79
+ if (ts === null || !Number.isFinite(ts)) return "—";
80
+ const date = new Date(ts);
81
+ const clock = `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
82
+ if (sameDay(date, new Date(now))) return clock;
83
+ return `${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${clock}`;
84
+ }
85
+
86
+ /** 完整时刻放 `title`:悬停要能看到可复制、可跨天比对的绝对时间 */
87
+ export function formatIso(ts: number | null): string {
88
+ if (ts === null || !Number.isFinite(ts)) return "—";
89
+ return new Date(ts).toISOString();
90
+ }
91
+
92
+ /** 会话页概要里的时间段:同一天写 `10:32 → 11:52`,跨天把日期带上 */
93
+ export function formatSpan(start: number | null, end: number | null): string {
94
+ if (start === null || end === null) return "—";
95
+ const first = new Date(start);
96
+ const last = new Date(end);
97
+ const clock = (date: Date): string => `${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
98
+ if (sameDay(first, last)) return `${clock(first)} → ${clock(last)}`;
99
+ const day = (date: Date): string => `${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${clock(date)}`;
100
+ return `${day(first)} → ${day(last)}`;
101
+ }
102
+
46
103
  /**
47
104
  * 详情第一行永远是完整 runId:列表上显示的是缩写,复制要靠这一行。
48
105
  * 文案里带 `runId:` 前缀是因为详情区是纯文本,没有别的办法标出这一行是什么。
@@ -0,0 +1,165 @@
1
+ /**
2
+ * 同会话聚合检测:**接线**的单测(派发器与退出前补发真的走到并入分支)。
3
+ *
4
+ * 这里断言的是「没建新 run、没起进程」这类后果 —— 只看返回值会漏掉「返回说并入了,其实又派了一个」。
5
+ * 场景与用户现场一致:同一会话连着几轮结算,第二轮起只并轮数。
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+
13
+ import { DEFAULT_CONFIG } from "../lib/config.ts";
14
+ import { patchRunRecordSync } from "../lib/runs.ts";
15
+ import { flushPendingSync } from "../lib/shutdown-dispatch.ts";
16
+ import { type SpawnedChild } from "../lib/spawn-plan.ts";
17
+ import { type TelemetryEvent } from "../lib/telemetry.ts";
18
+ import { FakeChild, NOW, TEMPLATE, harness, readRun, settleAsync } from "./aggregate-harness.ts";
19
+
20
+ test("派发:同会话第二条不再派新 run,而是并入(不起进程、轮数与 merged 累加)", async () => {
21
+ const h = harness("merge");
22
+ const first = await h.dispatch({ sessionId: "s1", skills: ["a"] });
23
+ assert.equal(first.status, "dispatched");
24
+ if (first.status !== "dispatched") return;
25
+ const runId = first.run.runId;
26
+ const spawns = h.calls.length;
27
+
28
+ // 去抖窗口已过:这一轮会真的走到派发逻辑,聚合检测在单飞闸之前
29
+ h.tick(DEFAULT_CONFIG.idleMs + 1_000);
30
+ const second = await h.dispatch({ sessionId: "s1", skills: ["b"] });
31
+ assert.deepEqual(second, { status: "merged", runId, rounds: 2 });
32
+ assert.equal(h.calls.length, spawns, "并入的轮次不许再起子进程");
33
+ assert.equal(fs.readdirSync(path.join(h.evolutionDir, "runs")).length, 1, "也不许再建 run 目录");
34
+
35
+ const record = readRun(h, runId);
36
+ const coverage = record.coverage as Record<string, unknown>;
37
+ assert.deepEqual([coverage.rounds, coverage.merged, coverage.lastAt], [2, 1, NOW + DEFAULT_CONFIG.idleMs + 1_000]);
38
+ // run 原有的状态与 skills 不被并入操作改写:本轮并进去的是「覆盖轮数」,不是新一轮技能清单
39
+ assert.deepEqual([record.status, record.trigger], ["dispatched", "auto"]);
40
+
41
+ const merged = h.events.filter((event) => event.kind === "merged");
42
+ assert.equal(merged.length, 1);
43
+ assert.deepEqual([merged[0]?.sessionId, merged[0]?.runId, merged[0]?.rounds], ["s1", runId, 2]);
44
+ assert.equal(h.logs.some((line) => line.includes(`同会话已有在途复盘 ${runId},本轮并入(共 2 轮),不再派生`)), true);
45
+ });
46
+
47
+ test("派发:连续并入时 rounds 递增、merged 累计;被去抖挡下的轮次也算进总数", async () => {
48
+ const h = harness("merge-twice");
49
+ const first = await h.dispatch({ sessionId: "s1", skills: ["a"] });
50
+ assert.equal(first.status, "dispatched");
51
+ if (first.status !== "dispatched") return;
52
+
53
+ // 窗口内又结算一轮:走老路记 pending(superseded),不是并入
54
+ const superseded = await h.dispatch({ sessionId: "s1", skills: ["a"] });
55
+ assert.deepEqual(superseded, { status: "superseded", rounds: 1 });
56
+
57
+ // 窗口过后这一轮:pending 里的那轮 + 这一轮一起并进去
58
+ h.tick(DEFAULT_CONFIG.idleMs + 1_000);
59
+ const merged = await h.dispatch({ sessionId: "s1", skills: ["a"] });
60
+ // 派发时覆盖 1 轮 + pending 里 1 轮 + 这一轮 = 3 轮
61
+ assert.deepEqual(merged, { status: "merged", runId: first.run.runId, rounds: 3 });
62
+
63
+ h.tick(DEFAULT_CONFIG.idleMs + 1_000);
64
+ const third = await h.dispatch({ sessionId: "s1", skills: ["a"] });
65
+ assert.deepEqual(third, { status: "merged", runId: first.run.runId, rounds: 4 });
66
+
67
+ const coverage = readRun(h, first.run.runId).coverage as Record<string, unknown>;
68
+ assert.deepEqual([coverage.rounds, coverage.merged], [4, 2]);
69
+ assert.equal(h.events.filter((event) => event.kind === "merged").length, 2);
70
+ });
71
+
72
+ test("派发:在途 run 的子进程已死 → 不并入,另开新 run(尸体交给 stale 逻辑)", async () => {
73
+ const h = harness("dead");
74
+ const first = await h.dispatch({ sessionId: "s1", skills: ["a"] });
75
+ assert.equal(first.status, "dispatched");
76
+ if (first.status !== "dispatched") return;
77
+
78
+ // 子进程退出(收尸会放锁并把这条 run 记成 abandoned),pid 判死:它不再是在途复盘
79
+ h.children[0].emit("exit", 1);
80
+ await settleAsync();
81
+ h.setAlive(false);
82
+ h.tick(DEFAULT_CONFIG.idleMs + 1_000);
83
+
84
+ const second = await h.dispatch({ sessionId: "s1", skills: ["a"] });
85
+ assert.equal(second.status, "dispatched");
86
+ if (second.status !== "dispatched") return;
87
+ assert.notEqual(second.run.runId, first.run.runId, "死 run 不许被并入,必须另开一条");
88
+ assert.equal(h.calls.length, 2);
89
+ // 聚合检测不擅自改别人的 run.json:这次并入失败,那条老 run 的 coverage 一个字都没动
90
+ assert.equal(readRun(h, first.run.runId).coverage, undefined);
91
+ assert.equal(h.events.filter((event) => event.kind === "merged").length, 0);
92
+ });
93
+
94
+ test("派发:同会话那条 run 已 analyzed(复盘确实跑完)→ 允许另开新 run", async () => {
95
+ const h = harness("analyzed");
96
+ const first = await h.dispatch({ sessionId: "s1", skills: ["a"] });
97
+ assert.equal(first.status, "dispatched");
98
+ if (first.status !== "dispatched") return;
99
+ // 复盘跑完 = 子进程已退出:先收尸放锁,再把状态写成 analyzed(复盘确实读到了产出)
100
+ h.children[0].emit("exit", 0);
101
+ await settleAsync();
102
+ h.setAlive(false);
103
+ assert.equal(patchRunRecordSync(first.run.runDir, { status: "analyzed" }), true);
104
+ h.tick(DEFAULT_CONFIG.idleMs + 1_000);
105
+
106
+ const second = await h.dispatch({ sessionId: "s1", skills: ["a"] });
107
+ assert.equal(second.status, "dispatched");
108
+ if (second.status !== "dispatched") return;
109
+ assert.notEqual(second.run.runId, first.run.runId);
110
+ });
111
+
112
+ test("派发:不同 sessionId 互不影响(各自照常派发)", async () => {
113
+ const h = harness("other-session");
114
+ const a = await h.dispatch({ sessionId: "s1", skills: ["a"] });
115
+ // s1 的子进程跑完退出(别让全局单飞闸挡住 s2 —— 它本来就该挡,这里测的是聚合不跨会话误判)
116
+ h.children[0].emit("exit", 0);
117
+ await settleAsync();
118
+ h.setAlive(false);
119
+ h.tick(DEFAULT_CONFIG.idleMs + 1_000);
120
+ const b = await h.dispatch({ sessionId: "s2", skills: ["a"] });
121
+ assert.equal(a.status, "dispatched");
122
+ assert.equal(b.status, "dispatched");
123
+ if (a.status !== "dispatched" || b.status !== "dispatched") return;
124
+ assert.notEqual(a.run.runId, b.run.runId);
125
+ assert.equal(h.calls.length, 2);
126
+ assert.equal(h.events.filter((event) => event.kind === "merged").length, 0);
127
+ });
128
+
129
+ test("退出补发:同样先过聚合检查,命中就只并轮次 + 写 merged 遥测", async () => {
130
+ const h = harness("shutdown");
131
+ const first = await h.dispatch({ sessionId: "s1", skills: ["a"] });
132
+ assert.equal(first.status, "dispatched");
133
+ if (first.status !== "dispatched") return;
134
+ const spawns = h.calls.length;
135
+
136
+ const result = flushPendingSync(
137
+ { sessionId: "s1", since: NOW - 1000, rounds: 2, skills: ["a"] },
138
+ {
139
+ evolutionDir: h.evolutionDir,
140
+ cwd: os.tmpdir(),
141
+ promptTemplatePath: TEMPLATE,
142
+ maxRuntimeMs: 20 * 60_000,
143
+ telemetry: { append: () => {}, appendSync: (event) => { h.events.push({ ...event, ts: NOW, iso: "" } as TelemetryEvent); }, flush: async () => undefined },
144
+ env: {},
145
+ spawnFn: (command, args, options) => {
146
+ h.calls.push({ command, args, options });
147
+ const child = new FakeChild(95_001 + h.children.length);
148
+ h.children.push(child);
149
+ return child as unknown as SpawnedChild;
150
+ },
151
+ autoReview: false,
152
+ alive: () => true,
153
+ log: (msg) => h.logs.push(msg),
154
+ },
155
+ NOW + 5_000,
156
+ );
157
+
158
+ assert.equal(result, "merged");
159
+ assert.equal(h.calls.length, spawns, "补发并入时不许再起子进程");
160
+ const coverage = readRun(h, first.run.runId).coverage as Record<string, unknown>;
161
+ // 在途 run 派发时覆盖 1 轮,补发这 2 轮并进去 → 3 轮
162
+ assert.deepEqual([coverage.rounds, coverage.merged], [3, 1]);
163
+ const merged = h.events.filter((event) => event.kind === "merged");
164
+ assert.deepEqual([merged[0]?.sessionId, merged[0]?.rounds], ["s1", 3]);
165
+ });