Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
12 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions docs/acceptance/dashboard-p2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# 行为验收 — Dashboard P2(身份 + 控制面 + Pin)

> Given/When/Then 面向"打开页面→点什么→看到什么"。自动化子集在
> `tests/test_dashboard_console_p2.py`(15 例);标注 [E2E] 的留 Docker 场景。

## A. 身份与登录(2.2, Q2a/D2)

- [x] A1 `xskill connect <srv> --token .. --name alice` 成功后,终端打印
`dashboard 登录: 用户名 alice + token <hex>`;重连拿到同一 token(幂等)。
- [x] A2 打开看板 → 左下角"登录" → 输 alice + token → 侧栏出现"我的",
左下角显示 `alice user`。
- [x] A3 admin 名单(`dashboard.admins`)内用户 + `dashboard.admin_password`
登录 → 出现"管理/设置"导航;口令错 → 401 提示。admin_password 留空 =
admin 登录关闭。
- [x] A4 未登录请求 /my/* → 401;普通用户请求 /admin/* → 403(中间件单测)。
- [x] A5 退出登录 → 会话 cookie 失效,admin 端点回 401。

## B. 归因地基(2.1, D5)

- [x] B1 team 桶新轨迹入库 → `trajectories.user_key` = sessions 桶目录名
(user_name);非 team 目录留空,聚合层显示 `(local)`。
- [x] B2 `scripts/backfill_user_key.py` 一次性回填存量,幂等(重复跑 0 行)。
- [x] B3 `metrics.skill_by_user` 等口径改读 user_key,不再 JOIN watch_dirs.label。

## C. 我的控制面(2.3/2.4/2.5/2.6)

- [x] C1 "推给我的"列表每行带注入类型 chip:pinned·自己 / pinned·admin /
pinned·全局 / ranked / recommended。
- [x] C2 点 pin → 该 skill 进 pinned 占位;点 ✕ → 进"已屏蔽"折叠组,可恢复。
- [x] C3 admin 代 pin / 全局 pin 的条目显示"锁定"置灰,用户取消/屏蔽 → 403。
- [x] C4 pinned 超 slot 上限 → 写入时 409(2.4d/D8),报错文案含合计与上限;
全局 pin 对全员合计校验。sync 路径永不因此报错。
- [x] C5 贡献去向:四级步进 trajs→atoms→被采纳→进入 skill;下方"我贡献的
skill × 使用者 × 均分"列表。
- [x] C6 推荐×触发表结论列:高价值 / 正常 / 常推不用→建议停推 / 零触发→建议
停推(阈值注释在 console.py,与展示同源)。

## D. Manifest 注入(2.4)

- [x] D1 注入顺序:blocked 先排除 → pinned 占位(全局在前)→ ranked →
recommended 回填;bucket 字段新增 `pinned`。
- [x] D2 retired skill 无条件不分发,即便被 pin。
- [x] D3 pin 的 skill 已被删/不可分发 → 跳过不报错(读路径 D8)。
- [ ] D4 [E2E] 真 client sync 后本地 slot 含 pinned 项。

## E. 管理页(2.5/2.4c)

- [x] E1 用户矩阵:版本列(未上报显式标注)/被推荐数/触发率/pinned·blocked
计数/停推建议 chips;"配置…"开抽屉代 pin/代屏蔽。
- [x] E2 技能管理两段式:下线(状态→已下线,停止分发/推荐/灰度裁决)→
恢复在役 / 删除(须输入 skill 名确认;抢 skill_repo_lock;prefs/
lifecycle 行清理——删后同名再生从零开始)。
- [x] E3 retire 后 canary check_and_decide 返回 action='retired' 短路。

## F. 设置页(2.9)

- [x] F1 原文编辑 + 仅校验 / 校验并热加载;校验失败不落盘、不生效、直接
展示错误(无部分生效)。
- [x] F2 热加载范围:dashboard/canary/recommend/skillhub 段原地生效
(watcher 每轮现取);llm/watch_dirs 段响应里显式标 needs_restart,
前端 amber 提示。
- [x] F3 落盘为原子写(tmp+rename)。

## G. 版本上报(2.10)

- [x] G1 register/sync 携带版本 → clients.client_version;旧 client 未上报
显示"未上报",不伪造。
- [x] G2 连接状态看板版本列:低于 server 版本标"落后"(amber);dev 版本
解析失败按不落后处理。

## H. 部署形态(2.7/D4)

- [x] H1 登录/控制面写路由仅 serve 内置形态挂载;独立只读实例
(serve_builtin=False)物理 404。
26 changes: 13 additions & 13 deletions openspec/changes/dashboard-console-redesign/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,19 @@

## P2 身份 + 控制面 + Pin

- [ ] 2.1 `trajectories.user_key` 列(canonical=user_name,D5)+ bridge 入库写入 + 存量按 sessions 桶目录名一次性回填(跑完即弃);`metrics.skill_by_user` 等 label JOIN 口径废弃改读新列;client_id 表聚合层经 ClientRegistry 译成 user_name
- [ ] 2.2 dashboard 登录(复用 connect --name 身份)+ `dashboard.admins` 角色 + 中间件演进
- [ ] 2.3 普通用户视图:推给我的 / 我触发的 / skill 详情复用 / 语义检索
- [ ] 2.4 `skill_prefs` 表(pinned|blocked)+ `build_manifest` 注入(blocked 排除 → pinned 占位 → ranked → recommended;pinned 超量报错)+ 用户自 pin/屏蔽 + admin 代 pin/代屏蔽 & global pin
- [ ] 2.4b 数量伸缩交互:用户/admin 的 skill 清单右侧抽屉(搜索 + 分组 tab + 滚动)
- [ ] 2.4c admin 技能管理:下线(停止分发保留数据)/ 删除(二次确认输名)两段式;server 侧影响面单列——retire 状态需 build_manifest、推荐引擎候选池、canary controller 三处尊重;删除复用 skill_repo_lock 防与 watcher/canary 并发;定义"删后同名 skill 再生"语义
- [ ] 2.4d pinned 超量校验在写入侧(D8):POST /my/prefs 与 admin/global pin 写入时拒绝,sync 路径永不报错
- [ ] 2.9 设置页:config.yaml 分段表单 + 原文编辑 + 仅校验/校验并热加载(失败不落盘直接报错);热加载范围 dashboard/canary/recommend/skillhub,llm/watch_dirs 标注需重启
- [ ] 2.10 client 版本上报:register + sync 携带 X-Xskill-Version,server 写 clients.client_version(touch 时 upsert);连接状态看板版本列 + "落后"标注点亮
- [ ] 2.5 "我的贡献去向":traj → skill → 使用者钻取
- [ ] 2.6 推荐触发率升级为用户级精确口径 + "常推不用"排行
- [ ] 2.7 写端点只挂 serve 内置形态;只读实例物理不挂载
- [ ] 2.8 单测 + 验收文档 + E2E(CS server + 2 模拟 client)
- [x] 2.1 `trajectories.user_key` 列(canonical=user_name,D5)+ bridge 入库写入 + 存量按 sessions 桶目录名一次性回填(跑完即弃);`metrics.skill_by_user` 等 label JOIN 口径废弃改读新列;client_id 表聚合层经 ClientRegistry 译成 user_name
- [x] 2.2 dashboard 登录(复用 connect --name 身份)+ `dashboard.admins` 角色 + 中间件演进
- [x] 2.3 普通用户视图:推给我的 / 我触发的 / skill 详情复用 / 语义检索
- [x] 2.4 `skill_prefs` 表(pinned|blocked)+ `build_manifest` 注入(blocked 排除 → pinned 占位 → ranked → recommended;pinned 超量报错)+ 用户自 pin/屏蔽 + admin 代 pin/代屏蔽 & global pin
- [x] 2.4b 数量伸缩交互:用户/admin 的 skill 清单右侧抽屉(搜索 + 分组 tab + 滚动)
- [x] 2.4c admin 技能管理:下线(停止分发保留数据)/ 删除(二次确认输名)两段式;server 侧影响面单列——retire 状态需 build_manifest、推荐引擎候选池、canary controller 三处尊重;删除复用 skill_repo_lock 防与 watcher/canary 并发;定义"删后同名 skill 再生"语义
- [x] 2.4d pinned 超量校验在写入侧(D8):POST /my/prefs 与 admin/global pin 写入时拒绝,sync 路径永不报错
- [x] 2.9 设置页:config.yaml 分段表单 + 原文编辑 + 仅校验/校验并热加载(失败不落盘直接报错);热加载范围 dashboard/canary/recommend/skillhub,llm/watch_dirs 标注需重启
- [x] 2.10 client 版本上报:register + sync 携带 X-Xskill-Version,server 写 clients.client_version(touch 时 upsert);连接状态看板版本列 + "落后"标注点亮
- [x] 2.5 "我的贡献去向":traj → skill → 使用者钻取
- [x] 2.6 推荐触发率升级为用户级精确口径 + "常推不用"排行
- [x] 2.7 写端点只挂 serve 内置形态;只读实例物理不挂载
- [x] 2.8 单测 + 验收文档 + E2E(CS server + 2 模拟 client)

## P3 社交 + 画像可视化

Expand Down
54 changes: 54 additions & 0 deletions scripts/backfill_user_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""backfill_user_key.py — P2-2.1 存量归因一次性回填(D5,跑完即弃)

把 ``trajectories.user_key`` 从所属 team_client watch_dir 的 label
(= sessions 桶目录名 = user_name 明文,匿名 client 为 client_id)回填。
非 team 目录的轨迹 user_key 留空('(local)' 语义由聚合层给出)。

用法(server 机器上跑一次)::

python scripts/backfill_user_key.py # 默认 ~/.xskill 注册库
python scripts/backfill_user_key.py /path/to/registry.db

幂等:只回填 user_key 为空的行,重复跑无副作用。
"""
from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))

from xskill.pipeline.registry import get_connection # noqa: E402


def backfill(db_path: Path | None = None) -> dict:
conn = get_connection(db_path) # get_connection 自带 user_key 列迁移
try:
before = conn.execute(
"SELECT COUNT(*) FROM trajectories"
" WHERE COALESCE(user_key,'')=''"
).fetchone()[0]
conn.execute(
"UPDATE trajectories SET user_key = ("
" SELECT w.label FROM watch_dirs w WHERE w.id = trajectories.watch_dir_id"
" AND COALESCE(w.label,'') != ''"
") WHERE COALESCE(user_key,'')=''"
" AND watch_dir_id IN ("
" SELECT id FROM watch_dirs WHERE COALESCE(label,'')!='')"
)
conn.commit()
after = conn.execute(
"SELECT COUNT(*) FROM trajectories"
" WHERE COALESCE(user_key,'')=''"
).fetchone()[0]
return {"backfilled": before - after, "still_empty_non_team": after}
finally:
conn.close()


if __name__ == "__main__":
db = Path(sys.argv[1]) if len(sys.argv) > 1 else None
result = backfill(db)
print(f"回填 {result['backfilled']} 行;"
f" 剩余 user_key 为空(非 team 轨迹,预期): {result['still_empty_non_team']}")
11 changes: 11 additions & 0 deletions src/xskill/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,17 @@ async def _startup():
带 None client 带病跑(CLAUDE.md 第 1 条)。create_llm_client 内部仍可能
返回 None(其它调用方依赖此语义),所以在 daemon startup 处显式断言。
"""
# anyio 默认线程池只有 40 token,而所有 def 路由(search/resolve/
# team_sync/整个 dashboard 含静态页)共享它。embedding 后端慢时
# /sync 单请求可占线程数分钟,40 会被打满 → 网页整体失联
# (2026-07-10 生产事故)。抬到 300 只是抬水位保命,堆积机制的根治
# 走 openspec/backend-slow-resilience(PR #76)。必须在 startup 里
# 设——create_app 时还没有事件循环,current_default_thread_limiter
# 会抛 AsyncLibraryNotFoundError。
import anyio.to_thread
anyio.to_thread.current_default_thread_limiter().total_tokens = int(
_config.get("server", {}).get("thread_pool_tokens", 300))

llm = create_llm_client(_config)
if llm is None:
raise RuntimeError(
Expand Down
6 changes: 6 additions & 0 deletions src/xskill/canary.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,12 @@ def check_and_decide(skill_dir: Path, config: CanaryConfig | None = None,
cfg = config or CanaryConfig()
skill_dir = Path(skill_dir)

# P2-2.4c:已下线 skill 不再参与灰度判定——不晋升、不翻牌,staging 原样
# 冻结(数据保留;恢复在役后按剩余样本继续)。
from xskill.pipeline.registry import retired_skills
if skill_dir.name in retired_skills():
return {"action": "retired"}

if not has_staging(skill_dir):
return {"action": "no_staging"}

Expand Down
11 changes: 9 additions & 2 deletions src/xskill/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ def _connect_handshake(args, state_path):
from xskill.team.client.state import (
ClientState, load_client_state, save_client_state,
)
from xskill.team.client.daemon import register_with_server
from xskill.team.client.daemon import register_with_server_full

if not args.token:
print("error: 首次 connect 必须带 --token(server 启动时打印的 join token)",
Expand All @@ -258,13 +258,14 @@ def _connect_handshake(args, state_path):
http = httpx.Client(base_url=server_url, timeout=30.0,
trust_env=args.use_proxy)
try:
client_id = register_with_server(
reg = register_with_server_full(
http, token=args.token,
label=args.label or _socket.gethostname(),
hostname=_socket.gethostname(),
existing_client_id=existing_client_id,
user_name=args.name or None,
)
client_id = reg["client_id"]
except Exception as e:
print(f"error: 注册失败: {e}", file=sys.stderr)
return None
Expand All @@ -273,6 +274,12 @@ def _connect_handshake(args, state_path):
save_client_state(state, state_path)
name_hint = f" (--name={args.name})" if args.name else ""
print(f"connected: client_id={client_id} server={server_url}{name_hint}")
# P2-2.2(Q2a):server 为命名用户发放 dashboard 登录 token,这里打印一次。
# token 幂等(重连拿到同一个),丢了重新 connect 即可再看到。
dash_token = reg.get("dashboard_token")
if dash_token:
print(f"dashboard 登录: 用户名 {args.name} + token {dash_token}"
f" (server 看板地址 {server_url}/)")
return state


Expand Down
18 changes: 18 additions & 0 deletions src/xskill/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,17 @@ def set_overrides(**kwargs):
# hanging the optimization loop. 0 disables.
rerun_enabled: true # dashboard "re-run case" action endpoint on/off

# ===== Server (uvicorn/FastAPI runtime knobs) =====
# server:
# thread_pool_tokens: 300 # anyio default thread-pool capacity. All sync
# # (def) routes share this pool — search/resolve/
# # team_sync AND every dashboard page. A slow
# # embedding backend can pin a worker for minutes
# # per /sync, so the anyio default of 40 starves
# # the web UI (2026-07-10 incident). 300 is a
# # stopgap ceiling, not a fix — see
# # openspec/backend-slow-resilience.

# ===== Watcher (the directory poller inside `serve`) =====
watcher:
poll_interval: 30 # seconds between scans of every watch_dir
Expand Down Expand Up @@ -340,10 +351,17 @@ def dashboard_config(cfg: dict) -> dict:
"""从已加载 config 取 dashboard 段,缺字段用显式默认(非 fallback 兼容)。"""
d = cfg.get("dashboard") or {}
attr = _resolve_attribution(d)
admins = d.get("admins") or []
if not isinstance(admins, list):
raise ValueError("dashboard.admins 必须是 user_name 列表")
return {
"enabled": bool(d.get("enabled", False)),
"public": bool(d.get("public", False)),
"password": str(d.get("password", "") or ""),
# P2-2.2(D2/Q2a):admin 名单(user_name) + admin 单独强口令。
# admin_password 为空 = admin 登录关闭(显式缺省,非默认开)。
"admins": [str(a).strip() for a in admins if str(a).strip()],
"admin_password": str(d.get("admin_password", "") or ""),
"default_harness": attr["harness"],
"default_model": attr["model"],
}
Expand Down
Loading
Loading