- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
1158 lines
66 KiB
Python
1158 lines
66 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""board.py —— 生成**看板快照** `board.json`(供 `assets/board.html` 实时渲染)。
|
||
|
||
设计原则:
|
||
· **只读**:只读台账/状态/投递台账/宿主库(三张只读表)。(组件探测属**使用方**,见 board_ext)⛔ 不写任何账本。
|
||
· **零依赖**:只用标准库。⛔ 不引第三方。
|
||
· **快**:一轮 < 0.1 s(socket 探测 0.3 s 超时上限;宿主库只读一条 SQL)。
|
||
🔴 **总则:看板不能影响程序执行**(用户 2026-09-30 明令)。三条落地:
|
||
① **解耦**:协作程序/守护程序**一行都不引用本文件**(已核);本文件**从不写任何账本**。
|
||
② **异步**:`--serve` 由**后台线程**按 `--interval` 秒产快照,**请求线程只吐内存缓存**
|
||
⇒ 请求路径 ⛔ 不碰 DB/⛔ 不读文件 ⇒ 开多少标签页都不增加宿主负载。
|
||
③ **降级不静默**:任何一块读不到 ⇒ 记进 `warn` 并在界面显示,⛔ 不伪装成"0 个会话"。
|
||
|
||
用法:
|
||
python board.py # 产一次 board.json(默认写到 inbox/board.json)
|
||
python board.py --out <路径>
|
||
python board.py --serve [端口] [--interval 秒] # 起本地只读看板(默认 8788 / 3 秒)
|
||
python board.py --serve 8788 --takeover # 显式"接管":先停旧实例再起(换新代码时用)
|
||
|
||
🔴🔴 **单实例护栏**(2026-09-30 加,治「多实例打架」):
|
||
Windows 的 `SO_REUSEADDR`(`ThreadingHTTPServer` 默认 `allow_reuse_address=1`)允许**同端口重复绑定
|
||
且不报错** ⇒ 多个 `--serve` 会**静默并存**,同一个 URL 被不同进程随机应答 ⇒ 快照/代码版本互相打架
|
||
(实拍:8788 已被占用时,第二个实例照样打印"看板已起")。
|
||
⇒ 现规则:**端口上已有本看板 ⇒ 默认拒绝启动**(提示复用现成那个);要换代码用 `--takeover`。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import glob
|
||
import json
|
||
import os
|
||
import re
|
||
import socket
|
||
import sqlite3
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
CFG_P = Path(os.environ.get("COLLABD_CONFIG") or (HERE / "collabd.config.json"))
|
||
WS = Path(os.environ.get("DSH_COLLAB_WS") or "").resolve() if os.environ.get("DSH_COLLAB_WS") else None
|
||
|
||
|
||
def _cfg() -> dict:
|
||
try:
|
||
return json.loads(CFG_P.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
C = _cfg()
|
||
if WS is None:
|
||
WS = Path(str(C.get("workspace") or "")).resolve()
|
||
INBOX = WS / str(C.get("inbox") or "tmp/supervise-inbox")
|
||
|
||
|
||
def _j(p: Path, dv=None):
|
||
try:
|
||
return json.loads(p.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return dv
|
||
|
||
|
||
def _tail_jsonl(p: Path, n: int = 10) -> list:
|
||
try:
|
||
with open(p, "rb") as f:
|
||
f.seek(max(0, os.path.getsize(p) - 65536))
|
||
ls = f.read().decode("utf-8", "replace").strip().splitlines()
|
||
out = []
|
||
for ln in ls[-n:]:
|
||
try:
|
||
out.append(json.loads(ln))
|
||
except Exception:
|
||
pass
|
||
return out
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _host_db() -> Path | None:
|
||
p = str(C.get("host_db") or "")
|
||
if p and os.path.isfile(p):
|
||
return Path(p)
|
||
d = os.environ.get("CODEBUDDY_CONFIG_DIR") or r"E:\ProgramData\.workbuddy"
|
||
q = Path(d) / "workbuddy.db"
|
||
return q if q.is_file() else None
|
||
|
||
|
||
def _main_sid(st: dict) -> str:
|
||
"""主会话 sid。🔴 **与 `collabd.py::_main_sid()` 必须逐字同款**(判据只此一处权威:
|
||
① 声明为 `main` 的角色 ② 退回最近一次投递到的会话 ③ 都取不到 ⇒ 空串,⛔ 不猜)。"""
|
||
for _sid, _r in (st.get("roles") or {}).items():
|
||
if str(_r) == "main" and str(_sid).startswith(tuple("0123456789abcdef")):
|
||
return str(_sid)
|
||
return str(((st.get("wake") or {}).get("sessionId")) or "")
|
||
|
||
|
||
def _same_ws(cwd: str) -> bool:
|
||
"""`cwd` 是否就是**本工作区**(斜杠/大小写无关)。⛔ 仅用于**主会话解析**,⛔ 不用于"会话归属本项目"。"""
|
||
a = str(cwd or "").replace("\\", "/").rstrip("/").lower()
|
||
b = str(WS).replace("\\", "/").rstrip("/").lower()
|
||
return bool(a) and a == b
|
||
|
||
|
||
# 🔴 主会话的**显式前缀**(用户 2026-09-30 定名:「主会话前缀 可以叫 主控」)。
|
||
# ⚠️ 实测现役主会话写成 `主控 · 协作机制 · …` —— **中点分隔、无方括号**。
|
||
MAIN_PREFIX = "主控"
|
||
|
||
|
||
def _role_of_title(name: str) -> str:
|
||
"""标题 ⇒ 会话角色(`main`/`worker`/`waker`/`""`)。
|
||
|
||
🔴 **⛔ 与 `collabd.py::parse_session_name()` 必须逐条同款**(本文件此后是"两处同款"的第 2 处)。
|
||
`selftest.py::看板与协作程序:命名判据不许漂` 会**真的把两边的函数拉出来对账**,⛔ 不许一边改一边不改。
|
||
|
||
**四种形态**(判据只看标题:⛔ 不看 cwd 归属、⛔ 不看 status)
|
||
① `[角色]-[类别]-<具体>`(合规) ② 一级前缀 `[协作]…`
|
||
③ 🆕 **接续会话**:`[<类别>] 接续 · …` / `接续棒:…` ⇒ **worker**
|
||
④ 🆕 **主控前缀**:`主控 · <类别> · …`(中点分隔、无方括号)⇒ **main**
|
||
|
||
🔴 为什么必须认 ③:接续会话是**会话自己建的新会话**,标题常**没有角色方括号**;
|
||
旧判据只排 `[协作]` ⇒ 它会被当**主会话候选**(且标题里的 `[<类别>]` 又让它变成
|
||
"该类别的主会话")⇒ 通知**投给它自己**(自指死结,2026-10-01 实测)。
|
||
⛔ 认不出 ⇒ 空串(⛔ 不猜、⛔ 不回落 cwd 推断 —— 架构 §2.3)。
|
||
"""
|
||
nm = str(name or "").strip()
|
||
if not nm.startswith("["):
|
||
head = nm.split("·")[0].split(":")[0].split(":")[0].strip()
|
||
if head.startswith(MAIN_PREFIX):
|
||
return "main"
|
||
return "worker" if "接续" in nm else ""
|
||
_m0 = re.match(r"^\[([^\]]*)\]\s*(.*)$", nm)
|
||
r = _m0.group(1).strip() if _m0 else ""
|
||
if r in ("主", "协作", "唤醒"):
|
||
return {"主": "main", "协作": "worker", "唤醒": "waker"}[r]
|
||
if _m0 and "接续" in _m0.group(2):
|
||
return "worker" # `[<类别>] 接续 · …` —— 方括号里是类别,⛔ 不是角色
|
||
return ""
|
||
|
||
|
||
def _scan_ws_mains(st: dict, topics=None) -> dict:
|
||
"""🔴 扫一遍宿主库,挑出**本工作区里所有"可能是主会话"的会话**(按最近活动倒序)。
|
||
|
||
🔴 与 `collabd.py::_scan_mains()` **同款判据**(⛔ **改一处必须改两处** —— 漂了就没人能发现主会话换了):
|
||
· 只看 `cwd == 本工作区`(⛔ 不回落其他推断)
|
||
· ⛔ **排除全部"协作侧"命名**:`[协作]-…`/`[唤醒]-…`/**接续会话**(`[<类别>] 接续 · …`)——
|
||
三者都是**干活的棒**,⛔ 不是主会话候选(🔴 2026-10-01 扩:旧版只排 `[协作]` ⇒ 接续棒冒充主会话)
|
||
· 角色判据见 `_role_of_title()`(⛔ 与 `collabd.py::parse_session_name()` **逐条同款**)
|
||
· ⚠️ **不按 `status='working'` 筛**(主会话两轮之间是空闲 ⇒ 筛了就永远漏掉它)
|
||
· 类别取自**标题里出现的 `goal.topics` 项**(最长优先,见 `_topic_in_title()`)
|
||
|
||
@returns `{"cand": [sid…], "named": [sid…], "by_topic": {类别: {"sid","source","explicit"}}}`
|
||
⚠️ `by_topic` 的值是 **dict**(与 `collabd.py::_scan_mains()` **同款**)—— ⛔ 不是"类别→sid"的裸映射,
|
||
否则调用方会把整个 dict 当成 sid 用(**实测踩到过**)。
|
||
"""
|
||
out: dict = {"cand": [], "named": [], "by_topic": {}}
|
||
db = _host_db()
|
||
if not db:
|
||
return out
|
||
# 🔴 `topics` 传入 ⇒ **按这个目标的类别认主会话**(多目标 tab 用);
|
||
# ⛔ 不传 ⇒ 回落活跃目标(`goal.json`)⇒ **单目标部署行为逐字不变**。
|
||
tps = _as_list(topics) if topics is not None else _goal_topics()
|
||
try:
|
||
con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3)
|
||
try:
|
||
con.execute("pragma busy_timeout=300")
|
||
except Exception:
|
||
pass
|
||
rows = con.execute(
|
||
"select id,cwd,coalesce(custom_title,title,'') from sessions "
|
||
"where deleted_at is null order by last_activity_at desc limit 50").fetchall()
|
||
con.close()
|
||
for r in rows:
|
||
t = str(r[2] or "")
|
||
# 🔴 2026-10-01 改:从"只排 `[协作]`"改成"**排掉全部"协作侧"命名**"(接续会话 + 唤醒会话)——
|
||
# ⛔ 与 `collabd.py::_scan_mains()` **逐条同款**,理由见那边注释(接续棒冒充主会话=自指死结)。
|
||
if not (_same_ws(str(r[1] or "")) and _role_of_title(t) not in ("worker", "waker")):
|
||
continue
|
||
sid = str(r[0] or "")
|
||
out["cand"].append(sid)
|
||
ts = t.strip()
|
||
_ex = ts.startswith("主控") or ts.startswith("[主控]")
|
||
if _ex:
|
||
out["named"].append(sid)
|
||
_tp = _topic_in_title(t, tps)
|
||
if _tp:
|
||
_cur = out["by_topic"].get(_tp)
|
||
# 同一类别多条候选 ⇒ ① 显式「主控」优先 ② 否则取**最近活动**(首见即最近)
|
||
if _cur is None or (_ex and not _cur.get("explicit")):
|
||
out["by_topic"][_tp] = {
|
||
"sid": sid, "source": ("prefix:主控" if _ex else "topic"), "explicit": _ex}
|
||
except Exception:
|
||
pass
|
||
return out
|
||
|
||
|
||
def _resolve_main(st: dict, topics=None) -> dict:
|
||
"""🔴 主会话**解析**(⛔ 不只是读登记)—— **与 `collabd.py::resolve_main()` 必须同款**。
|
||
|
||
2026-09-30 用户实测报障:「**还是没有把这个会话识别为主会话**」。
|
||
根因:board.py 当时只走**旧的静态判据** `_main_sid()`(① 登记为 main ② **退回"上次投给谁"**),
|
||
而投递侧 `collabd.py` 早已改成 `resolve_main()` 的**动态解析** ⇒ **两边不同源**:
|
||
主会话一换(或旧主会话停了),投递会跟随、**看板却继续指着旧会话**,
|
||
并且因为 `in_project()` 的①号判据就是 `main_sid` ⇒ **正在用的那个会话连列都进不来**。
|
||
⇒ 本函数把「按**工作区**认」补上,判据与 collabd 逐条对齐:
|
||
① 本工作区里**显式标了 `主控`** 的 ⇒ 认(用户自己标的,比"最近活动"可信)
|
||
② 否则本工作区里**标题不带 `[协作]`** 且**最近活动**的那条
|
||
③ 都取不到 ⇒ 退回登记 `_main_sid()`;再没有 ⇒ `""`(⛔ 不猜)
|
||
⚠️ 用户口径(2026-09-30):「以**一个工作区**为主会话的工作区」「下面**应该都可能是主会话**」。
|
||
⛔ 这与 §2.3「不回落 cwd 推断**项目归属**」不冲突 —— 那条管的是"会话属于哪个需求项目",
|
||
本函数管的是"这条工作区里**哪条是主会话**",是**另一件事**。
|
||
"""
|
||
reg = _main_sid(st)
|
||
sc = _scan_ws_mains(st, topics)
|
||
named, cand = sc["named"], sc["cand"]
|
||
pick = named[0] if named else (cand[0] if cand else "")
|
||
# 🔴 `all_sids` = **解析出来的那些**(默认 + 登记 + 各任务类别),⛔ **不是**"本工作区所有会话" ——
|
||
# 只有解析出来的才算主会话,否则"本工作区随便开个无关会话"会被当成本项目(判据被无声放宽)。
|
||
_all: list = []
|
||
for _s in ([pick, reg] + [str((_v or {}).get("sid") or "") for _v in sc["by_topic"].values()]):
|
||
if _s and _s not in _all:
|
||
_all.append(_s)
|
||
out: dict = {"all_sids": _all, "by_topic": dict(sc["by_topic"])}
|
||
if pick:
|
||
out.update({"sid": pick,
|
||
"source": ("prefix:主控" if named else "workspace"),
|
||
"switched_from": (reg if (reg and reg != pick) else "")})
|
||
return out
|
||
out.update({"sid": reg, "source": ("register" if reg else "no-register"), "switched_from": ""})
|
||
return out
|
||
|
||
|
||
def project_scope(g: dict | None = None) -> dict:
|
||
"""🔴 **本需求目标项目的身份 + 归属判据**(用户 2026-09-30 明示两件事:
|
||
① 「**要明确哪些会话是属于某个需求目标项目的**」
|
||
② 「**这个看板上应该明确显示是哪个需求目标项目**」)。
|
||
|
||
🔴 **判据与 `collabd.py::_in_project()` 同款** ⇒ 看板列的"在跑会话" ≡ `--ready-next` 认的"在跑会话"
|
||
(⛔ 否则两边各说各话:看板显示没人跑、收尾确认却被拦)。
|
||
⛔ **刻意不用 cwd 推断** —— 架构 §2.3 明令「绝不回落到 cwd 推断」。
|
||
|
||
🔴 2026-10-01 多目标:可选传入 `g`(某个目标)⇒ **按这个目标的类别算归属**(tab 用)。
|
||
⛔ 不传 ⇒ 回落活跃目标 `goal.json` ⇒ **单目标部署行为逐字不变**。
|
||
"""
|
||
g = _goal() if g is None else g
|
||
st = _j(INBOX / "collabd-state.json", {}) or {}
|
||
# 🔴 2026-09-30:主会话**解析**(⛔ 不再是"读登记/上次投给谁"那种静态值)——
|
||
# 与 `collabd.py::resolve_main()` 同源,投递侧跟随谁、看板就显示谁。
|
||
_rm = _resolve_main(st, _goal_topics(g))
|
||
msid = str(_rm.get("sid") or "")
|
||
msids = list(_rm.get("all_sids") or []) # 🔴 多任务类别 ⇒ 本工作区可能有多条主会话
|
||
short = str(g.get("short") or "")
|
||
topics = _goal_topics(g) # 🔴 任务类别清单(缺省回落 [short])
|
||
gid = str(g.get("id") or "")
|
||
crit = [] # 给人读的判据清单(看板上原样展示)
|
||
if msid:
|
||
crit.append("主会话 %s(%s)" % (msid[:8], _rm.get("source") or "?"))
|
||
if len(msids) > 1:
|
||
crit.append("本工作区另有主会话 %d 条(共 %d)" % (len(msids) - 1, len(msids)))
|
||
for _t in topics:
|
||
crit.append("标题含 [%s]" % _t)
|
||
if gid:
|
||
crit.append("显式声明 --declare --goal %s" % gid)
|
||
return {"goal_id": gid, "short": short, "topics": topics, "title": str(g.get("title") or ""),
|
||
"topics_source": _goal_topics_source(g), # 🔴 类别来源(declared/fallback/none)
|
||
"main_sid": msid, "main_sids": msids, "workspace": str(WS), "criteria": crit,
|
||
# 🆕 每个**任务类别**各自的主会话(`{类别: {sid,source,explicit}}`)——
|
||
# 看板"按类别"渲染要用它(用户:「通过协作会话名称前缀区分具体任务会话」)
|
||
"main_by_topic": dict(_rm.get("by_topic") or {})}
|
||
|
||
|
||
def in_project(sid: str, title: str, sc: dict) -> bool:
|
||
"""🔴 **一个会话是否属于本项目**。⛔ **与 `collabd.py::_in_project()` 同款 —— 改一处必须改两处**。
|
||
|
||
🔴 2026-09-30 **泛化**:归属判据的"标题含 `[<主题>]`"从**单个** `goal.short` 扩到
|
||
**`goal.topics` 清单** —— 用户要求「**同一个工作区**多会话协作(主会话根据任务**自动梳理任务类别**:
|
||
**通过协作会话名称前缀的方式区分具体任务会话**)」⇒ 同一工作区里**多个任务类别**的会话
|
||
都必须被认成"本项目"(⛔ 否则除一个之外**静默漏管**:看板不列、收尾确认也不把它们算进来)。
|
||
⛔ `sc` 里没有 `topics` ⇒ **回落 `short`**(旧调用点/自测用例原样可用)。
|
||
⛔ `sc` 里没有 `main_sids` ⇒ 只看 `main_sid`。
|
||
"""
|
||
if sid and (sid == sc.get("main_sid") or sid in (sc.get("main_sids") or [])):
|
||
return True
|
||
ti = str(title or "")
|
||
_tps = sc.get("topics") or ([sc["short"]] if sc.get("short") else [])
|
||
for _t in _tps:
|
||
if _t and ("[%s]" % _t) in ti:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _goal() -> dict:
|
||
return _j(INBOX / "goal.json", {}) or {}
|
||
|
||
|
||
GOALS_DIR = INBOX / "goals" # 🔴 多目标:一个文件=一个目标(字段与 goal.json 同款)
|
||
|
||
|
||
def goal_files() -> list:
|
||
"""🔴 **所有目标文件**(活跃的排第一):`goal.json` + `goals/*.json`。
|
||
|
||
用户 2026-10-01:「把协作实时看板改为 **tab 支持多个目标**执行协作状态展示」
|
||
⇒ 数据模型(我的决定,可推翻):
|
||
· `INBOX/goal.json` = **当前活跃目标** —— 唯一权威,`collabd.py` 只认它 ⇒ ⛔ 零改动;
|
||
· `INBOX/goals/<id>.json` = **其余目标**(同一套字段:`id`/`title`/`short`/`topics`/
|
||
`acceptance_state`/`run`);
|
||
· 看板把两者合成一个**有序列表** ⇒ **一个 tab 一个目标**。
|
||
|
||
⛔ 没有 `goals/` 目录 ⇒ 列表里只有 1 项 ⇒ **与旧版逐字一致**(向后兼容)。
|
||
⚠️ 去重键=`id`(缺 `id` ⇒ 用文件名)—— 同一个目标两处都有 ⇒ **以 `goal.json` 为准**。
|
||
|
||
@returns `[{"path","goal","active","key"}]`(⛔ 读不到任何目标 ⇒ `[]`)
|
||
"""
|
||
out: list = []
|
||
seen: set = set()
|
||
|
||
def _add(p: Path, active: bool) -> None:
|
||
g = _j(p, {}) or {}
|
||
if not g:
|
||
return
|
||
k = str(g.get("id") or "").strip() or p.stem
|
||
if k in seen:
|
||
return
|
||
seen.add(k)
|
||
out.append({"path": str(p), "goal": g, "active": active, "key": k})
|
||
|
||
_add(INBOX / "goal.json", True)
|
||
try:
|
||
if GOALS_DIR.is_dir():
|
||
for p in sorted(GOALS_DIR.glob("*.json")):
|
||
_add(p, False)
|
||
except Exception:
|
||
pass
|
||
return out
|
||
|
||
|
||
def _goal_topics(g: dict | None = None) -> list:
|
||
"""🔴 **本需求下的"任务类别"清单**(`goal.json.topics`)。
|
||
|
||
2026-09-30 用户要求:「要能支持**同一个工作区** 多会话协作(主会话根据任务**自动梳理任务类别**:
|
||
**通过协作会话名称前缀的方式区分具体任务会话**)」
|
||
⇒ **任务类别 ≡ 会话标题二级前缀 `[主题]` ≡ 台账条目的 `line` ≡ 分工板的一行**(同一个词,四处同义)。
|
||
|
||
⛔ `topics` 缺省 ⇒ 回落 `[goal.short]` ⇒ **单类别时的行为与旧版逐字一致**(向后兼容)。
|
||
🔴 与 `collabd.py::_goal_topics()` **同款**(⛔ 改一处必须改两处)。
|
||
"""
|
||
g = _goal() if g is None else g
|
||
out: list = []
|
||
for _t in ((g or {}).get("topics") or []):
|
||
_t = str(_t or "").strip()
|
||
if _t and _t not in out:
|
||
out.append(_t)
|
||
if not out:
|
||
_s = str((g or {}).get("short") or "").strip()
|
||
if _s:
|
||
out.append(_s)
|
||
return out
|
||
|
||
|
||
def _goal_topics_source(g: dict | None = None) -> dict:
|
||
"""🔴 **任务类别清单是"说明来的"还是"回落来的"** —— 看板必须说得出来源。
|
||
|
||
用户 2026-10-01 订正:「**目标是 通过对话在调用 会话协作skill时说明的,不是固定的**」
|
||
⇒ `topics` 缺省回落 `short` 是**兼容**(单类别部署照旧),但**看板不许把"没声明过"
|
||
显示成"就是这么定的"** —— 同族红线:**读到了却不说,或者没有却说有**。
|
||
|
||
返回 `{"kind": "declared"|"fallback"|"none", "at": "...", "by": "...", "candidates": [...]}`
|
||
"""
|
||
g = _goal() if g is None else g
|
||
g = g or {}
|
||
raw = [str(x or "").strip() for x in (g.get("topics") or []) if str(x or "").strip()]
|
||
if raw:
|
||
kind = "declared"
|
||
elif str(g.get("short") or "").strip():
|
||
kind = "fallback"
|
||
else:
|
||
kind = "none"
|
||
_cand = ((g.get("_topics候选") or {}) or {}).get("候选") if isinstance(g.get("_topics候选"), dict) else None
|
||
return {"kind": kind,
|
||
"at": str(g.get("topics_declared_at") or ""),
|
||
"by": str(g.get("topics_declared_by") or ""),
|
||
"candidates": _as_list(_cand),
|
||
# 🔴 目标本身**是不是**"在对话里说明过"(`declared_at` 由 `goalctl declare` 写)
|
||
"goal_declared_at": str(g.get("declared_at") or ""),
|
||
"goal_declared_by": str(g.get("declared_by") or "")}
|
||
|
||
|
||
def _as_list(v) -> list:
|
||
"""把 `str | list | tuple | set` 归一成 `list[str]`(⛔ 空值 ⇒ `[]`)。"""
|
||
if v is None:
|
||
return []
|
||
if isinstance(v, str):
|
||
return [v] if v.strip() else []
|
||
try:
|
||
return [str(x) for x in v if str(x or "").strip()]
|
||
except Exception:
|
||
return [str(v)]
|
||
|
||
|
||
def _topic_in_title(title: str, topics=None) -> str:
|
||
"""标题里出现的**任务类别**(🔴 **最长优先**);⛔ 都不命中 ⇒ 空串。
|
||
|
||
⚠️ 判据是**子串**(不要求 `[...]` 包裹):主会话是口语式命名(「主控 · 唤醒机制」),
|
||
协作棒是严格 `[协作]-<类别>-<具体>` —— 两种写法都要认。
|
||
🔴 与 `collabd.py::_topic_in_title()` **同款**(⛔ 改一处必须改两处)。
|
||
"""
|
||
ti = str(title or "")
|
||
tps = _as_list(topics) if topics is not None else _goal_topics()
|
||
hit = ""
|
||
for _t in tps:
|
||
if _t and _t in ti and len(_t) > len(hit):
|
||
hit = _t
|
||
return hit
|
||
|
||
|
||
def _labor(tasks: dict, sess: list, goal: dict) -> list:
|
||
"""🔴 **分工板**(用户 2026-09-30 两条明示:
|
||
① 「协作会话**不是历史记录**,是**展示分工**的板块」
|
||
② 「每个分工板块可以**展示最近的协作任务**」)。
|
||
|
||
**分工位 = 线**(`goal.topics` ∪ `goal.lines` ∪ 台账里出现过的线)—— 线是本机制里真实存在的分工维度。
|
||
每格给四样:**最近的协作任务**/**当前承接会话**/**件汇总**/**状态色**。
|
||
|
||
🔴 **三类"归属"别混(2026-09-30 修 · 用户报「桌面线有会话在跑、看板却说无协作任务」)**:
|
||
· **会话 → 本项目**:按 §2.3 的三级判据(主会话 sid / 标题含 `[任务类别]` / `--declare`),
|
||
⛔ **绝不回落到 cwd**。这个判据在 `project_scope()` 里,本函数**收进来的 sess 已经过它筛**。
|
||
· **会话 → 哪条线**(🆕 2026-09-30 多类别):**优先按任务类别归位** ——
|
||
会话标题二级前缀 `[主题]` 命中的类别(`s["topic"]`)等于该线 ⇒ 归这条线。
|
||
🔴 这是"**同一个工作区**多会话协作"的正确判据:所有会话 cwd 相同,⛔ 按 cwd 分必然全塌成一行。
|
||
· **会话 → 哪条线**(旧/兼容):**线也可能就是工作区名**(跨工作区时代的 `goal.lines`)⇒
|
||
用 `cwd_tail ∈ lines` 判线归属。⚠️ 两条判据**并列**(`topic == 线 ∨ cwd_tail == 线`),
|
||
因为同一个部署里可能**同时**存在"按类别命名的新会话"和"按工作区命名的旧数据"。
|
||
|
||
🔴 **修掉的两种误报**(旧版只看台账里的件 ⇒ 会漏):
|
||
① 「(该线暂无协作任务)」:台账没件 **≠** 该线没活 —— 该线正在跑棒时这话是错的。
|
||
⇒ 台账无件时,**回落到"该线最近的一个会话"**当"最近的协作任务"。
|
||
② 「当前无会话在跑」:旧版只认"在跑会话标题里出现**台账件 id**" ⇒
|
||
棒在做台账里还没有的件(如 V6)时**匹配不到** ⇒ 明明在跑却说没人跑。
|
||
⇒ 承接判据改成:**topic == 线 ∨ cwd_tail == 线** ∨ 标题命中该线的台账件。
|
||
🔴 **状态色三值**:`busy`=该线有会话在跑|`gap`=**该线还有未完成的件却没人在跑**|`idle`=没活也没人跑。
|
||
"""
|
||
lines: list = []
|
||
# 🔴 2026-09-30 改:**任务类别(`goal.topics`)排在最前** —— 用户要求
|
||
# 「**同一个工作区**多会话协作(主会话根据任务**自动梳理任务类别**:**通过协作会话名称前缀
|
||
# 的方式区分具体任务会话**,所有主会话,协作会话,自动唤醒任务,都在**一个工作区**)」
|
||
# ⇒ 分工板的"一 行"从**工作区**改成**任务类别**(⛔ 同工作区多类别时,按工作区分会全塌成一行)。
|
||
# ⚠️ 旧的两路(`goal.lines` + 台账 `line`)**保留** ⇒ 跨工作区时代的既有部署照旧显示。
|
||
for _tp in _goal_topics(goal):
|
||
if _tp and _tp not in lines:
|
||
lines.append(_tp)
|
||
for _ln in (goal.get("lines") or []):
|
||
_ln = str(_ln or "")
|
||
if _ln and _ln not in lines:
|
||
lines.append(_ln)
|
||
for _v in (tasks or {}).values():
|
||
_ln = str((_v or {}).get("line") or "")
|
||
if _ln and _ln not in lines:
|
||
lines.append(_ln)
|
||
if not lines:
|
||
lines = ["(未标注线)"]
|
||
_tps = [t for t in _goal_topics(goal) if t] # 🔴 当前"任务类别"清单(`kind` 判据用它)
|
||
|
||
_running = [s for s in (sess or []) if str(s.get("status")) == "working"]
|
||
|
||
def _item_in_title(title: str) -> str:
|
||
for _tid in sorted((tasks or {}).keys(), key=len, reverse=True):
|
||
if _tid and re.search(r"(?<![0-9A-Za-z])%s(?![0-9A-Za-z])" % re.escape(str(_tid)),
|
||
str(title or "")):
|
||
return str(_tid)
|
||
return ""
|
||
|
||
_now = time.time()
|
||
out = []
|
||
for ln in lines:
|
||
items = {k: (v or {}) for k, v in (tasks or {}).items()
|
||
if str((v or {}).get("line") or "") == ln}
|
||
done = [k for k, v in items.items() if str(v.get("state")) == "done"]
|
||
n_open = len(items) - len(done)
|
||
_latest = None
|
||
for k, v in items.items():
|
||
_ts = float(v.get("t_end") or v.get("t") or 0)
|
||
if _latest is None or _ts > _latest[0]:
|
||
_latest = (_ts, k, v)
|
||
# 承接会话:① 会话就在这条线的工作区里(cwd_tail == 线) ② 或标题命中该线的台账件
|
||
holders = []
|
||
for s in _running:
|
||
_ct = str(s.get("cwd_tail") or "")
|
||
_tp = str(s.get("topic") or "")
|
||
_tid = _item_in_title(s.get("title"))
|
||
# 🔴 2026-09-30:`_tp == ln` = **按任务类别归位**(新,同工作区多类别)
|
||
# `_ct == ln` = 按工作区归位(旧,向后兼容跨工作区时代的部署)
|
||
if _tp == ln or _ct == ln or (_tid and _tid in items):
|
||
holders.append({"id8": str(s.get("id8") or ""),
|
||
"what": (_tid or str(s.get("title") or ""))[:26]})
|
||
|
||
# 最近的协作任务:台账有件 ⇒ 用最新那件;**台账无件 ⇒ 回落到该线最近的会话**
|
||
# (⛔ 别因为"台账没件"就说"暂无协作任务" —— 该线可能正在跑一件还没上报的活)
|
||
latest = None
|
||
if _latest:
|
||
latest = {"item": _latest[1], "state": str(_latest[2].get("state") or "?"),
|
||
"by": str(_latest[2].get("by") or ""),
|
||
"age_min": round((_now - _latest[0]) / 60.0, 1)}
|
||
else:
|
||
# 🔴 2026-10-01 修:⛔ **别再排除 `role == "主会话"` 的会话**。
|
||
# 原判据(`… and role != "主会话"`)本意是"最近在做的=承接会话,不含主会话",
|
||
# 但**该类只有主会话自己在跑**时会把候选整类判空 ⇒ 同一格里 ③ 说
|
||
# 「(该类还没有记录,也无可归到它的会话)」、④ 却说「承接 <同一个会话>」
|
||
# = **自相矛盾**(用户 2026-10-01 看到的正是这个画面,由此追问
|
||
# 「唤醒机制跟主会话都一个 ID,难道是主会话?」)。
|
||
# ⇒ 候选**不排除任何角色**,只把 `working` 排前 ——
|
||
# "正在跑的"就是"最近在做的",③④ 指向同一条会话时是**真相**,⛔ 不是重复。
|
||
_cand = [s for s in (sess or [])
|
||
if (str(s.get("topic") or "") == ln or str(s.get("cwd_tail") or "") == ln)]
|
||
_cand.sort(key=lambda x: (0 if str(x.get("status")) == "working" else 1,
|
||
float(x.get("age_min") or 1e9)))
|
||
if _cand:
|
||
_s0 = _cand[0]
|
||
latest = {"item": str(_s0.get("title") or "")[:28],
|
||
"state": ("working" if str(_s0.get("status")) == "working"
|
||
else str(_s0.get("status") or "?")),
|
||
"by": str(_s0.get("id8") or ""),
|
||
"age_min": float(_s0.get("age_min") or 0), "from_session": True}
|
||
|
||
out.append({
|
||
"line": ln,
|
||
"name": str((C.get("lines") or {}).get(ln) or ln),
|
||
"items": {k: str(items[k].get("state") or "?") for k in sorted(items)},
|
||
"done": len(done), "total": len(items), "open": n_open,
|
||
"latest": latest,
|
||
"running": holders,
|
||
"state": ("busy" if holders else ("gap" if n_open else "idle")),
|
||
# 🔴 2026-09-30:**这一行到底是不是"任务类别"**。
|
||
# `kind="topic"` ⇒ 命中 `goal.topics`(=当前类别,架构图第三层的正式分工位);
|
||
# `kind="legacy"` ⇒ **只在台账/`goal.lines` 里出现过,不在当前类别清单**(跨工作区
|
||
# 时代的旧线名,或已退役的类别)⇒ 它**不是**类别,⛔ 不许让工作区名冒充类别。
|
||
# ⛔ 也**不许把它静默丢掉**(同族红线:读到了却不显示 = 少说一句话)⇒
|
||
# 看板把它**折叠成一格「未归类」**画出来(见 build() 的 `orphan`)。
|
||
"kind": ("topic" if ln in _tps else "legacy"),
|
||
})
|
||
return out
|
||
|
||
|
||
def _session_rows() -> list:
|
||
"""🔴 宿主库里的**原始会话行**(只读**一次** ⇒ 多目标共用,⛔ 不 N 个目标读 N 次)。
|
||
|
||
⛔ 读不到 ⇒ **抛异常**(调用方升级成 `err`,⛔ 不静默伪装成"0 个会话" —— 那是假情报)。
|
||
"""
|
||
db = _host_db()
|
||
if not db:
|
||
return []
|
||
con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3)
|
||
con.row_factory = sqlite3.Row
|
||
try:
|
||
con.execute("pragma busy_timeout=300")
|
||
except Exception:
|
||
pass
|
||
rows = con.execute(
|
||
"select id,title,custom_title,status,cwd,updated_at,last_activity_at "
|
||
"from sessions order by updated_at desc limit 60").fetchall()
|
||
con.close()
|
||
return rows
|
||
|
||
|
||
def _sessions(limit: int = 8, sc: dict | None = None, rows=None) -> dict:
|
||
"""返回 `{"mine": [...], "others_running": n, "err": ""}` —— **只把本项目的会话列进看板**。
|
||
`mine` 里**主会话排最前**(看板第一眼要能看到"哪个是主会话")。
|
||
|
||
🔴 **不许拖慢宿主**(用户 2026-09-30:「看板不能影响程序执行」):
|
||
· 只读连接(`mode=ro`)⇒ WAL 下**读者不阻塞写者**,宿主的写事务该多快还是多快
|
||
· `busy_timeout=300` ⇒ 万一撞上写锁,**0.3 秒就放弃**,⛔ 不排队、⛔ 不长时间占着
|
||
· 失败 ⇒ 返回 `err` 让界面显示「宿主库暂不可读」,⛔ **不静默伪装成"0 个会话"**(那是假情报)
|
||
|
||
🔴 2026-10-01 多目标:`sc` 传入 ⇒ 按该目标算归属;`rows` 传入 ⇒ 复用已读的行(⛔ 不重复读库)。
|
||
"""
|
||
if sc is None:
|
||
sc = project_scope()
|
||
try:
|
||
if rows is None:
|
||
rows = _session_rows()
|
||
now = time.time()
|
||
main, work, others, unrec = [], [], 0, []
|
||
_wstail = str(sc.get("workspace") or "").replace("\\", "/").rstrip("/").split("/")[-1]
|
||
for r in rows:
|
||
title = str(r["custom_title"] or "") or str(r["title"] or "")
|
||
cwd = str(r["cwd"] or "")
|
||
st = str(r["status"] or "")
|
||
sid = str(r["id"] or "")
|
||
if not in_project(sid, title, sc):
|
||
if st == "working":
|
||
others += 1 # ⚠️ 别的线在跑 ⇒ **只计数**,⛔ 不混进列表
|
||
# 🔴 2026-10-01 加(用户报「创建了协作会话但看板没展示」):**同工作区、
|
||
# 但按命名判据认不出来**的会话必须**说出口**(同族红线:读到了却不说)。
|
||
# ⚠️⚠️ 这里**只用 cwd 决定"要不要提醒",⛔ 绝不用它决定"归不归本项目"** ——
|
||
# 归属判据仍是 §2.3 那三级(主会话 sid / 标题含 `[<类别>]` / `--declare`),
|
||
# ⛔ 不回落 cwd。这批会话**不进 `mine`、不进分工板、不算"本项目会话"**。
|
||
# 为什么必须有:棒名写成 `[协作]-<昵称>-…`(漏方括号/用 short)时,
|
||
# `parse_session_name()` 与 `in_project()` **双双认不出** ⇒ 它会**静默消失**。
|
||
_ct = cwd.replace("\\", "/").rstrip("/").split("/")[-1]
|
||
if _wstail and _ct == _wstail:
|
||
# ⚠️ 带 `age_min`:看板**只把"在跑的 / 近 3 小时的"摊在版面上**,
|
||
# 陈年历史只计一个数 —— 否则半个版面上全是早已完成的旧棒(用户反复要求"精简")。
|
||
unrec.append({"id8": sid[:8], "title": title[:60], "status": st,
|
||
"age_min": round((now - float(r["updated_at"] or 0) / 1000.0) / 60.0, 1)})
|
||
continue
|
||
rec = {
|
||
"id8": sid[:8],
|
||
"title": title[:60],
|
||
"status": st,
|
||
# 🔴 2026-09-30:多任务类别 ⇒ "主会话"是**一组**(`main_sids`),⛔ 不是"唯一那一条"
|
||
"role": ("主会话" if (sid == sc.get("main_sid") or sid in (sc.get("main_sids") or []))
|
||
else "协作会话"),
|
||
# 🔴 任务类别(=标题二级前缀 `[主题]`)—— 分工板按它归位
|
||
# (用户:「通过协作会话名称前缀的方式区分具体任务会话」⇒ 同一个词,四处同义)
|
||
"topic": _topic_in_title(title, sc.get("topics") or []),
|
||
"cwd_tail": cwd.replace("\\", "/").rstrip("/").split("/")[-1],
|
||
"age_min": round((now - float(r["updated_at"] or 0) / 1000.0) / 60.0, 1),
|
||
}
|
||
(main if rec["role"] == "主会话" else work).append(rec)
|
||
mine = (main + work)[:limit]
|
||
return {"mine": mine, "others_running": others, "unrecognized": unrec, "err": ""}
|
||
except Exception as e:
|
||
return {"mine": [], "others_running": 0, "unrecognized": [],
|
||
"err": "WorkBuddy 库暂不可读:%s" % e}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# 🔴 职责边界(用户 2026-09-30 定则):
|
||
# 「**技能就是技能 程序就是程序,谁用产生的文件 放在他自己那里**」
|
||
# +「**禁止用抽象词,用 系统-模块-功能名**」
|
||
#
|
||
# ⇒ **本文件(技能侧)⛔ 不含任何项目的路径、端口、真名、历史**。
|
||
# 凡是"某个项目要看哪些组件、它们叫什么真名、端到端怎么算通过"这类**项目知识**,
|
||
# 一律由**使用方自己的扩展文件**提供,本文件只负责**把它读进来并渲染**:
|
||
#
|
||
# 配置项 `board_ext`(工作区相对路径,默认 `.workbuddy/collab/board_ext.py`)
|
||
# ⇒ 那个文件暴露 `build(ws: str) -> dict`(契约见其文件头)
|
||
#
|
||
# ⛔ 没配 / 读不到 / 抛异常 ⇒ **降级**(这一块不显示,看板照常)+ 记进 `warn`,
|
||
# ⛔ 绝不让使用方的脚本把看板搞挂。
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
EXT_CACHE: dict = {} # ⚠️ 缓存 **(mtime, size, 模块)**;⛔ 绝不缓存 `build()` 的**结果**(见下)
|
||
|
||
|
||
def _ext(ws: Path, warn: list) -> dict:
|
||
"""🔴 读**使用方自己的**看板扩展(⛔ 技能侧不含任何项目知识)。
|
||
|
||
契约:`build(ws) -> {"title","tag","chips":[{label,up,ok,bad,tip}],"paragraphs":[{text,tone}],"tip",
|
||
"triggers":[{name,detail,detail2,label,up,edge,tip}]}`。
|
||
|
||
⚠️ `triggers` 为**可选**:表示**外部触发源(时间驱动)** ⇒ 画进架构图 R5 行右半。
|
||
(用户 2026-09-30:「把心跳的节点也放到看板协作架构图中」。)空或缺 ⇒ 退回"只有钩子子进程"的老版面。
|
||
任何异常都吞掉并降级 —— 看板**不能因为使用方的脚本坏了就打不开**。
|
||
|
||
🔴🔴 **⛔ 不许缓存 `build()` 的结果**(2026-09-30 我自己踩的坑):
|
||
第一版把**结果 dict** 缓存了 ⇒ 扩展里的**探针只跑了一次** ⇒ 前置状态**永久冻结在服务启动那一刻**,
|
||
而且**不报错**(表现为"看板说该端口未监听,而 netstat 明明确有 LISTENING")。
|
||
⇒ 只缓存**模块对象**(省一次 import),**每次快照都重新调用 `build(ws)`**。
|
||
(⛔ 本文件的注释与 docstring 里也**不写任何项目的端口/路径** —— 有静态用例守着。)
|
||
"""
|
||
rel = str(C.get("board_ext") or ".workbuddy/collab/board_ext.py")
|
||
p = (ws / rel)
|
||
out: dict = {"title": "前置", "tag": "", "chips": [], "paragraphs": [], "tip": ""}
|
||
if not p.is_file():
|
||
out["_missing"] = "未配看板扩展(board_ext):%s" % p
|
||
return out
|
||
try:
|
||
key = str(p)
|
||
try:
|
||
sig = (os.path.getmtime(p), os.path.getsize(p)) # 🔴 文件签名
|
||
except OSError:
|
||
sig = (0.0, -1)
|
||
hit = EXT_CACHE.get(key)
|
||
if hit is None or hit[0] != sig:
|
||
# 🔴 2026-09-30 改:**按文件签名重载**。旧实现只按路径缓存模块 ⇒ 改了 `board_ext.py`
|
||
# 必须**重启服务**才生效,而"没生效"在界面上表现为"我改了但看板没变化"
|
||
# (用户当天连问两次)⇒ 改成 mtime+size 变了就重载,⛔ 不再需要重启。
|
||
import importlib.util
|
||
spec = importlib.util.spec_from_file_location("_board_ext_%d" % abs(hash(key)), str(p))
|
||
mod = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(mod)
|
||
EXT_CACHE[key] = (sig, mod)
|
||
mod = EXT_CACHE[key][1]
|
||
got = mod.build(str(ws)) # 🔴 每次快照都重跑探针(⛔ 结果不缓存)
|
||
if isinstance(got, dict):
|
||
out.update(got)
|
||
except Exception as e:
|
||
out["_err"] = str(e)
|
||
warn.append("看板扩展加载失败(已降级):%s" % e)
|
||
return out
|
||
|
||
|
||
def _queue(tasks: dict, st: dict) -> dict:
|
||
"""🔴 **队列计数**(用户 2026-09-30:「协作程序 也要显示**当前待验收队列数量**」)。
|
||
|
||
🔴 **队列 = 需求台账 `tasks.json`**(`architecture.md` §迭代记录明载),**四态**:
|
||
`pending 待执行` / `running 执行中` / `done 已完成` / `blocked 有阻碍`。
|
||
⚠️ **机制里没有「待验收」这个态**(⛔ 不臆造一个数字出来)⇒ 这里如实给**四态明细 + 未完结数**:
|
||
`open`(未完结)= 待执行 + 执行中 + 有阻碍 + 其它;`done` = 已完成。
|
||
⚠️ 另附 `notify_n`(= `queue_info.n`,**待主会话反馈**的通知条数)—— 它与台账是两码事,⛔ 别混。
|
||
"""
|
||
by = {"pending": 0, "running": 0, "done": 0, "blocked": 0, "other": 0}
|
||
for v in (tasks or {}).values():
|
||
s = str((v or {}).get("state") or "").strip().lower()
|
||
by[s if s in by else "other"] += 1
|
||
qi = st.get("queue_info") or {}
|
||
try:
|
||
_nn = int(qi.get("n") or 0)
|
||
except Exception:
|
||
_nn = 0
|
||
return {"total": len(tasks or {}), "by": by,
|
||
"open": by["pending"] + by["running"] + by["blocked"] + by["other"],
|
||
"notify_n": _nn}
|
||
|
||
|
||
# ⛔ 「监督守护为什么停」是**某个项目的历史事实** ⇒ 归**使用方**(写在其 `board_ext.py` 里,
|
||
# 经 `ext["guard_stop_reason"]` 取回);技能侧⛔ 不落任何项目的止损史。
|
||
|
||
|
||
def _age_min(p: Path):
|
||
"""文件 mtime 距今多少分钟。取不到 ⇒ None(⛔ 不拿 0 冒充"刚更新")。"""
|
||
try:
|
||
return round((time.time() - p.stat().st_mtime) / 60.0, 1)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _runtime() -> dict:
|
||
"""🔴 **协作程序 / 监督程序的实时状态**(用户 2026-09-30 要求架构图「**要能展示实时状态**」)。
|
||
取的全是**真痕迹**(状态戳 mtime),⛔ 不猜、⛔ 不按"配置里写着要常驻"就当它活着:
|
||
· **协作程序**:`_tick.stamp`(宿主钩子唤起的一次性投递轮)/`collabd-once.stamp`(投影轮)取更新时间
|
||
· **投递**(旧名「监督程序」):**不是一个该常驻的进程** —— 它就是**宿主钩子唤起的一次性
|
||
`--tick`**。所以这里报的是「**这条链通不通**」(看 `--tick` 戳的新鲜度),⛔ 不是"启动没启动"。
|
||
⚠️ 教训仍在:图上必须能一眼看出**谁其实没在跑** —— 但"没在跑"得先说清**它本来该不该跑**。
|
||
"""
|
||
tick = _age_min(INBOX / "_tick.stamp")
|
||
proj = _age_min(INBOX / "collabd-once.stamp")
|
||
glog = _age_min(INBOX / "guard.log")
|
||
stopped = (INBOX / "guard.stop").exists()
|
||
|
||
_a = [a for a in (tick, proj) if a is not None]
|
||
prog_up = bool(_a) and min(_a) < 15.0 # 15 分钟内有轮 ⇒ 算在线
|
||
prog_age = min(_a) if _a else None
|
||
prog_by = ""
|
||
if prog_up:
|
||
prog_by = "钩子 --tick" if (tick is not None and (proj is None or tick <= proj)) else "投影轮 --once"
|
||
|
||
guard_up = (not stopped) and (glog is not None and glog < 5.0)
|
||
return {
|
||
# 🔴 2026-09-30 用户:「**协作程序 应该一直运行,改成 在线**」⇒ 正常态就报两个字「在线」,
|
||
# ⛔ 不再堆"钩子 --tick · N 分钟前"那种细节长串(细节进 `how`,给界面做 tooltip 用)。
|
||
# ⚠️ **但不放假绿**:真没轮动就说清"在线 · 已 N 分钟没轮"——机制还在,只是没被唤起;
|
||
# ⛔ 也不写「已停」——它不是一个"该常驻、可被停"的进程,那个词本身就会误导。
|
||
"prog": {"up": prog_up, "age_min": prog_age, "by": prog_by,
|
||
"tick_age_min": tick, "proj_age_min": proj,
|
||
"label": ("在线" if prog_up else
|
||
(("在线 · 已 %s 分钟没轮" % prog_age) if prog_age is not None
|
||
else "在线 · 本轮还没跑过")),
|
||
"how": ("由 WorkBuddy 钩子按需唤起:--once(协作程序)/--tick(上报)"
|
||
+ ((";最近一轮=%s" % prog_by) if prog_by else ""))},
|
||
# 🔴🔴 **「投递」这个词取代了旧的「监督程序」**(2026-09-30 改口径 · 用户连问四次
|
||
# "监督程序为什么打不开 / 它一直停着能起什么作用 / 还需要保留吗 / 跟它有关系吗"):
|
||
# · 「监督程序」是**投递这条职责的旧名** —— 它从来不是一个"该常驻的进程";
|
||
# · ⛔ **别再报"已停"** —— 那会让人以为有东西坏了,而实际上**没有东西该在跑**;
|
||
# · 真正该报的是「**投递这条链通不通**」⇒ 判据用 `--tick` 戳的新鲜度(投递轮就是它)。
|
||
# 🔴 **必须原样保留的**:投递方唯一性(只有 `--tick` 能推进队列)—— 22:53 事故的修法。
|
||
"deliver": {"up": (tick is not None and tick < 15), "stopped": stopped, "age_min": tick,
|
||
# ⚠️ 这行窄(`PW-36` 还要给 12px 字号)⇒ 文案要短:机制那句在下面一行已经有了,
|
||
# 这里只报**新鲜度**,⛔ 别重复"由…钩子唤起"(会溢出成"…前…"被截断)。
|
||
"label": (("就绪 · %s 分钟前跑过" % tick)
|
||
if (tick is not None and tick < 15) else "长时间未触发(钩子没被唤起)"),
|
||
"reason": ""}, # ⛔ 不再给"停因"——那个问题已经不存在了
|
||
}
|
||
|
||
|
||
# ⛔ 上一版这里还有 `_parse_relay_line` / `_overlay_node` / `_end_to_end_verified` 三个函数 ——
|
||
# 它们解析的是**某个项目的中继客户端日志**(glob 路径、字段名、`streams>0` 判据)。
|
||
# 按「技能就是技能,谁用产生的文件放在他自己那里」,**已整体搬到使用方的
|
||
# `.workbuddy/collab/board_ext.py`**;技能侧只保留「读扩展并渲染」这一层(见 `_ext()`)。
|
||
|
||
|
||
def _orphan(lb: list) -> dict:
|
||
"""把 `labor` 里 `kind != "topic"` 的行**汇总成一份可见的"未归类"**(⛔ 不许静默丢)。
|
||
|
||
🔴 判据同族红线:**"读到了却没显示" = 少说一句话** —— 它不崩溃,所以最难发现。
|
||
场景:任务类别清单已迁移(唤醒机制/…),台账里仍有**跨工作区时代的旧线名** ⇒ 旧版把它们
|
||
也当"分工位"塞进 `labor`,而架构图只画前 N 格 ⇒ 那几格(连同里面的件)**整块消失**。
|
||
⇒ 现在:它们**不是类别**(`kind="legacy"`,⛔ 不占正式分工位),但看板必须**明确说出来**。
|
||
"""
|
||
_lg = [x for x in (lb or []) if str(x.get("kind")) != "topic"]
|
||
if not _lg:
|
||
return {"lines": [], "n": 0, "total": 0, "done": 0, "open": 0}
|
||
return {"lines": [{"line": str(x.get("line") or ""),
|
||
"name": str(x.get("name") or x.get("line") or ""),
|
||
"total": int(x.get("total") or 0),
|
||
"done": int(x.get("done") or 0),
|
||
"open": int(x.get("open") or 0)} for x in _lg],
|
||
"n": len(_lg),
|
||
"total": sum(int(x.get("total") or 0) for x in _lg),
|
||
"done": sum(int(x.get("done") or 0) for x in _lg),
|
||
"open": sum(int(x.get("open") or 0) for x in _lg)}
|
||
|
||
|
||
def _acc_summary(acc: dict) -> str:
|
||
"""🔴 验收状态一行话。⛔ 与 goalctl / collabd 同一条红线:
|
||
**一条有效判据都没有 ⇒ 判不出来**,⛔ 不许静默显示成「无」(那读起来像"全过")。"""
|
||
if not acc:
|
||
return "⚠️ 未声明验收判据(只有说明行)⇒ **判不出来**,⛔ 不因此判完成"
|
||
bad = [k for k, v in acc.items() if str(v).lower() != "pass"]
|
||
if not bad:
|
||
return "全部 pass(%d 条)" % len(acc)
|
||
return "非 pass:%s" % bad
|
||
|
||
|
||
def _goal_block(g: dict, active: bool, tasks_all: dict, srows, st: dict, warn: list,
|
||
multi: bool = False, all_topics=None) -> dict:
|
||
"""🔴 **一个目标的完整快照块** —— 看板的一个 tab 就是它(用户 2026-10-01)。
|
||
|
||
🔴 **台账怎么分到各目标**:**按任务类别**(类别清单 ≡ 台账条目的 `line`,同一个词,四处同义)。
|
||
· **只有一个目标** ⇒ **原样全给**(⛔ 不做任何切分 ⇒ 与旧版逐字一致);
|
||
· **多个目标** ⇒ 按 `line ∈ 本目标 topics` 切;`line` 不属**任何**目标的那些件
|
||
(=跨工作区时代留下的旧线名)**归到"活跃目标"名下并原样显示** ——
|
||
⛔ 不静默丢弃(同族红线:**读到了就要说**,⛔ 别让它们整批不见)。
|
||
"""
|
||
tps = _goal_topics(g)
|
||
tpset = set(tps)
|
||
tasks = dict(tasks_all)
|
||
if multi:
|
||
_at = set(all_topics or [])
|
||
tasks = {k: v for k, v in tasks_all.items()
|
||
if str((v or {}).get("line") or "") in tpset}
|
||
if active:
|
||
for k, v in tasks_all.items():
|
||
if str((v or {}).get("line") or "") not in _at:
|
||
tasks.setdefault(k, v)
|
||
sc = project_scope(g)
|
||
try:
|
||
_ss = _sessions(sc=sc, rows=srows)
|
||
except Exception as e: # ⛔ 兜底:绝不让一个目标把整份快照搞挂
|
||
_ss = {"mine": [], "others_running": 0, "err": "会话读取异常:%s" % e}
|
||
if _ss.get("err"):
|
||
_nm = str(g.get("short") or g.get("id") or "目标")
|
||
warn.append("%s:%s" % (_nm, _ss["err"]))
|
||
_lb = _labor(tasks, _ss["mine"], g) # 🔴 只算一次:分工板与"未归类"汇总都用它
|
||
qi = st.get("queue_info") or {}
|
||
acc = {k: v for k, v in (g.get("acceptance_state") or {}).items()
|
||
if not str(k).startswith("_")}
|
||
return {
|
||
"key": str(g.get("id") or g.get("short") or ""),
|
||
"active": bool(active),
|
||
"run": str(g.get("run") or "active"),
|
||
"paused": str(g.get("run") or "active").strip().lower() != "active",
|
||
"goal": {"title": g.get("title") or "", "short": g.get("short") or "", "acceptance": acc},
|
||
"acc_summary": _acc_summary(acc),
|
||
# 🔴 项目身份:看板顶部「本项目」区用它 ⇒ 一眼看清"这个看板是哪个需求目标项目的"
|
||
# 🆕 2026-09-30:`topics` / `main_by_topic` —— 用户要求「**同一个工作区**多会话协作
|
||
# (主会话根据任务**自动梳理任务类别**:**通过协作会话名称前缀**区分具体任务会话)」⇒
|
||
# 看板必须把"**这个工作区有哪几个任务类别、每类的主会话是谁**"直接摆出来
|
||
# (⛔ 否则用户只看到一堆会话,看不出"哪条管哪一类")。
|
||
"project": {"id": sc["goal_id"], "short": sc["short"], "title": sc["title"],
|
||
"main_sid8": (sc["main_sid"] or "")[:8], "criteria": sc["criteria"],
|
||
"workspace": sc["workspace"],
|
||
"topics": list(sc.get("topics") or []),
|
||
# 🔴 2026-10-01:类别清单的**来源**(用户:「目标是**通过对话在调用 会话协作技能时说明的**,
|
||
# 不是固定的」)⇒ 看板要能回答"这批类别是**在对话里说明的**,还是**回落**来的"。
|
||
"topics_source": dict(sc.get("topics_source") or {}),
|
||
"main_sids8": [str(s)[:8] for s in (sc.get("main_sids") or [])],
|
||
"main_by_topic": {str(_t): (str(_v.get("sid") or "")[:8] or "")
|
||
for _t, _v in ((sc.get("main_by_topic") or {}).items())}},
|
||
"others_running": _ss["others_running"],
|
||
# 🔴 2026-10-01:**同工作区、但按命名判据没归入本项目**的会话(用户报「创建了协作会话
|
||
# 但看板没展示」)—— 它们**不是**本项目会话(⛔ 不进 `sessions`、⛔ 不进分工板),
|
||
# 但**必须看得见**(同族红线:读到了却不说)⇒ 交给 `assets/board.html` 在图外说明里点名。
|
||
# 典型成因:棒名写成 `[协作]-<昵称>-…`(漏方括号/用 `goal.short` 而非 `topics`)。
|
||
"sessions_unrecognized": _ss.get("unrecognized") or [],
|
||
"tasks": {k: {"state": str((v or {}).get("state") or "?"),
|
||
"line": str((v or {}).get("line") or ""),
|
||
"by": str((v or {}).get("by") or ""),
|
||
"artifact": str((v or {}).get("artifact") or "")} for k, v in tasks.items()},
|
||
"sessions": _ss["mine"],
|
||
# 🔴 分工板(架构图「协作会话」层用它渲染)—— ⛔ 不是历史会话列表
|
||
"labor": _lb,
|
||
# 🔴🔴 2026-09-30 加:**"未归类"汇总** —— 台账里那些**不在当前任务类别清单**的线。
|
||
# 为什么必须有:类别清单已换成任务类别(唤醒机制/…),而台账里还躺着**跨工作区时代
|
||
# 的旧线名**(`ai1net-dsh-anywhere` / `ai1net-dsh-desktop`,各自 2 件且都已完成)。
|
||
# 旧版把这类行也塞进 `labor` 当"分工位",架构图又只画前 4 格 ⇒ **4 件已完成的活整格不见**,
|
||
# 读者只看到 4 个"件 0 · 完成 0"。⇒ 两处修正:① 它们**不是类别**(`kind='legacy'`)
|
||
# ② 但**必须看得见**(图上折叠成一格 + 表里标注)。
|
||
"orphan": _orphan(_lb),
|
||
"queue": _queue(tasks, st), # 🔴 队列计数(协作程序节点用)
|
||
"progress": {"last_progress_at": st.get("last_progress_at"),
|
||
"goals_open": bool(qi.get("probe", {}).get("goal_open")),
|
||
"main_busy": bool(qi.get("probe", {}).get("main_busy")),
|
||
"prog_age_min": qi.get("probe", {}).get("prog_age_min")},
|
||
}
|
||
|
||
|
||
def build() -> dict:
|
||
"""🔴 **生成一份只读快照**。保证:**永不抛异常**(任何一处读不到 ⇒ 记进 `warn` 并降级),
|
||
⇒ 调用方永远拿得到一份可渲染的数据,⛔ 不会因为看板读不到某个文件而连累别人。
|
||
|
||
🔴 2026-10-01 多目标(用户:「把协作实时看板改为 **tab 支持多个目标**执行协作状态展示」):
|
||
· 多出一个 `goals` 数组 —— **一格一个目标**(活跃的排最前,其余按文件名);
|
||
· 顶层的 `goal`/`project`/`tasks`/`sessions`/`labor`/`orphan`/`queue`/`progress`
|
||
=**活跃目标那一份** ⇒ ⛔ 老渲染器与老断言**逐字照旧可用**(向后兼容);
|
||
· `front`/`runtime`/`notify`/`wakeups`/`meta` **不随目标变**(它们是工作区级的)。
|
||
"""
|
||
warn = []
|
||
_gfs = goal_files()
|
||
if not _gfs: # ⛔ 连 goal.json 都没有 ⇒ 也要给出一份可渲染的
|
||
_gfs = [{"path": str(INBOX / "goal.json"), "goal": {}, "active": True, "key": ""}]
|
||
tasks = _j(INBOX / "tasks.json", {}) or {}
|
||
st = _j(INBOX / "collabd-state.json", {}) or {}
|
||
if not _gfs[0]["goal"]:
|
||
warn.append("读不到 goal.json")
|
||
if not st:
|
||
warn.append("读不到 collabd-state.json")
|
||
try:
|
||
srows = _session_rows() # 🔴 只读一次库 ⇒ 所有目标共用
|
||
except Exception as e:
|
||
srows = []
|
||
warn.append("WorkBuddy 库暂不可读:%s" % e)
|
||
_multi = len(_gfs) > 1
|
||
_all_tps = [t for it in _gfs for t in _goal_topics(it["goal"])]
|
||
blocks = [_goal_block(it["goal"], it["active"], tasks, srows, st, warn, _multi, _all_tps)
|
||
for it in _gfs]
|
||
_ext_d = _ext(WS, warn) # 🔴 使用方自己的扩展(⛔ 技能侧不含项目知识)
|
||
_rt = _runtime() # ⚠️ 「投递」不再需要"停因"(那个问题已随改名消失)⇒ 不回填 reason
|
||
_ext_d.pop("guard_stop_reason", None) # 使用方若还留着这个键 ⇒ 不渲染(避免又把旧问题带回来)
|
||
qi = st.get("queue_info") or {}
|
||
out = dict(blocks[0]) # ⇒ 顶层=**活跃目标那一份**(向后兼容)
|
||
out.update({
|
||
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"epoch": round(time.time(), 1),
|
||
"goals": blocks, # 🔴 tab 的数据源(一格一个目标)
|
||
"wakeups": [{"ts": w.get("ts"), "kind": w.get("kind") or "-", "http": w.get("http"), "ok": w.get("ok")}
|
||
for w in _tail_jsonl(INBOX / "wakeups.jsonl", 10)],
|
||
# 🔴 「前置」整块由**使用方**提供(见 `board_ext`)—— 技能侧只负责把它渲染出来。
|
||
# ⛔ 技能里不许出现任何项目的端口/路径/真名/历史。
|
||
"front": _ext_d,
|
||
"runtime": _rt, # 🔴 实时状态(架构图用)
|
||
"notify": {"awaiting": qi.get("awaiting") or "", "phase": qi.get("phase") or "",
|
||
"sent": qi.get("sent") or "", "notice": qi.get("notice") or ""},
|
||
"warn": warn, # ⛔ 降级不静默:界面要显示"哪一块没读到"
|
||
"meta": {"supervise_interval": C.get("supervise_interval", 30),
|
||
"queue_idle_min": 5, "wake_min_gap": C.get("wake_min_gap", 300),
|
||
"workspace": str(WS), "inbox": str(INBOX),
|
||
# 🔴 多目标:看板据此知道"要不要画 tab 条"(1 个目标 ⇒ 不画,与旧版一致)
|
||
"goals_dir": str(GOALS_DIR), "goal_count": len(blocks)},
|
||
})
|
||
return out
|
||
|
||
|
||
|
||
def _port_owner(port: int):
|
||
"""监听 `127.0.0.1:<port>` 的 PID(字符串);查不到 ⇒ None。只认 LISTENING。
|
||
|
||
⚠️ 用 `netstat -ano` 而非 `pkill -f`(后者匹配 argv,会误伤)。返回 `None` 与"确无进程"**不区分**
|
||
—— 调用方一律按"没能定位到旧实例"处理(⛔ 不据此下"一定没有别人在跑"的结论)。
|
||
"""
|
||
try:
|
||
import subprocess
|
||
out = subprocess.run(["netstat", "-ano"], capture_output=True, text=True,
|
||
timeout=10, errors="replace").stdout or ""
|
||
except Exception:
|
||
return None
|
||
key = "127.0.0.1:%d " % int(port)
|
||
for ln in out.splitlines():
|
||
if "LISTENING" in ln and key in ln:
|
||
p = ln.split()
|
||
if p:
|
||
return p[-1]
|
||
return None
|
||
|
||
|
||
def _live_board(port: int, timeout: float = 1.2) -> bool:
|
||
"""同端口上是否已有**本看板**在跑:`/healthz` 能连上 **且** body 是我们的签名。
|
||
|
||
⛔ 判据必须收紧:① ⛔ 不能只认「端口开着」(别人也可能占这个口)
|
||
② ⛔ 不能只认 HTTP 200(2026-09-30 实测:本机有服务在 `/api/v1/health` 上返 200,
|
||
body 却是 `{"ret":1,"version":"3"}`)。✅ 签名 = 同时含 `"ok"` 与 `"snapshots"`
|
||
—— 本看板的 `/healthz` 是这两个键,别的服务不会同时给。
|
||
"""
|
||
import urllib.request
|
||
try:
|
||
with urllib.request.urlopen("http://127.0.0.1:%d/healthz" % int(port), timeout=timeout) as x:
|
||
body = x.read(400).decode("utf-8", "replace")
|
||
except Exception:
|
||
return False
|
||
return ('"ok"' in body) and ("snapshots" in body)
|
||
|
||
|
||
def _stop_pid(pid: str) -> bool:
|
||
"""按 PID 强停(只给 `--takeover` 用;⛔ 不在其它路径上调用)。返回是否成功。"""
|
||
try:
|
||
import subprocess
|
||
r = subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True,
|
||
text=True, timeout=15, errors="replace")
|
||
return r.returncode == 0
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def serve(port: int = 8788, interval: float = 3.0, takeover: bool = False) -> int:
|
||
"""🔴 本地看板服务(**只绑 127.0.0.1**)。⛔ 不引第三方、⛔ 不开对外端口、⛔ 不写任何账本。
|
||
|
||
🔴🔴 **总则:看板不能影响程序执行**(用户 2026-09-30 明令:「看板不能影响程序执行,可以**异步**、
|
||
可以**延迟**」)。据此,请求路径与数据生产**彻底解耦**:
|
||
· **一个后台线程**每 `interval` 秒生成一次快照 → 存进**内存缓存**
|
||
· **请求线程只吐缓存字节** —— ⛔ 不碰 DB、⛔ 不读文件、⛔ 不做任何可能阻塞的事
|
||
⇒ 开 10 个标签页 = 10 次内存读;宿主库的查询频率**恒定**为 `1/interval`,与页面数无关
|
||
· 刷新失败 ⇒ **保留上一份快照**(界面显示"延迟 N 秒")⇒ ⛔ 绝不 500、⛔ 绝不给空板
|
||
· `--interval` 可取大(实时性换零负担);页面按同一 `interval` 自取,⛔ 不自行加频
|
||
"""
|
||
import threading
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
||
html_p = HERE.parent / "assets" / "board.html"
|
||
cache = {"bytes": None, "at": 0.0, "err": "", "n": 0}
|
||
|
||
def _refresh_loop():
|
||
"""唯一的生产者(后台守护线程)。⛔ 它出任何事都只影响"数据新不新",⛔ 不影响服务存活。"""
|
||
while True:
|
||
try:
|
||
d = build()
|
||
try:
|
||
_hsig = "%d.%d" % (os.path.getmtime(html_p), os.path.getsize(html_p))
|
||
except OSError:
|
||
_hsig = "0"
|
||
# 🔴 `html_sig`:架构图的**布局与样式全在 board.html 里**,而页面只轮询本 JSON
|
||
# ⇒ 改了 HTML,不刷新页面就看不到(用户 2026-09-30 为此连问两次)。
|
||
# 页面拿这个签名做"变了就自动重载",⛔ 不再需要用户手动 F5。
|
||
d["board"] = {"refresh_interval": interval, "generated_at": round(time.time(), 1),
|
||
"readonly": True, "html_sig": _hsig}
|
||
cache["bytes"] = json.dumps(d, ensure_ascii=False, indent=1).encode("utf-8")
|
||
cache["at"] = time.time()
|
||
cache["err"] = ""
|
||
cache["n"] += 1
|
||
except Exception as e: # ⛔ 不清旧快照:宁可给"旧的"也不给"空的"
|
||
cache["err"] = str(e)
|
||
time.sleep(max(0.5, float(interval)))
|
||
|
||
threading.Thread(target=_refresh_loop, daemon=True).start()
|
||
|
||
class H(BaseHTTPRequestHandler):
|
||
protocol_version = "HTTP/1.1"
|
||
|
||
def _send(self, code, body: bytes, ctype: str):
|
||
try:
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", ctype)
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
except Exception: # 客户端提前断开 ⇒ 静默(⛔ 不刷日志)
|
||
pass
|
||
|
||
def do_GET(self): # noqa: N802
|
||
path = self.path.split("?")[0]
|
||
if path in ("/", "/board.html", "/index.html"):
|
||
try:
|
||
self._send(200, html_p.read_bytes(), "text/html; charset=utf-8")
|
||
except Exception as e:
|
||
self._send(500, ("board.html 读不到:%s" % e).encode("utf-8"),
|
||
"text/plain; charset=utf-8")
|
||
elif path == "/board.json":
|
||
body = cache["bytes"]
|
||
if body is None: # 冷启动:第一份还没出来
|
||
body = json.dumps({"warming": True,
|
||
"board": {"refresh_interval": interval}}, ensure_ascii=False).encode("utf-8")
|
||
self._send(200, body, "application/json; charset=utf-8")
|
||
elif path == "/healthz":
|
||
self._send(200, json.dumps({"ok": True, "snapshots": cache["n"],
|
||
"age": round(time.time() - cache["at"], 1),
|
||
"err": cache["err"]}, ensure_ascii=False).encode("utf-8"),
|
||
"application/json; charset=utf-8")
|
||
else:
|
||
self._send(404, b"not found", "text/plain; charset=utf-8")
|
||
|
||
def log_message(self, *a): # ⛔ 静默(不刷屏、不唤醒宿主)
|
||
pass
|
||
|
||
# 🔴🔴 单实例护栏(2026-09-30 加,治「多实例打架」)—— ⛔ 别删,原因见文件头。
|
||
# · 默认:端口上已有本看板 ⇒ **拒绝启动**(避免"随机应答不同快照/不同代码版本")
|
||
# · `--takeover`:显式接管 —— 先停掉旧实例,再起本实例
|
||
if takeover:
|
||
_own = _port_owner(port)
|
||
if _own and _own != str(os.getpid()):
|
||
_ok = _stop_pid(_own)
|
||
print("%s 接管:已停旧看板 PID %s(%s)" % ("✓" if _ok else "⚠️", _own,
|
||
"成功" if _ok else "失败,见 taskkill 输出"))
|
||
sys.stdout.flush()
|
||
time.sleep(1.0) # 等端口真正释放
|
||
elif _live_board(port):
|
||
_own = _port_owner(port)
|
||
print("⛔ 已有看板在跑(127.0.0.1:%d%s)⇒ **本次不启动**,避免多实例打架。" %
|
||
(int(port), (",PID %s" % _own) if _own else ""))
|
||
print(" · 只是想看板子 ⇒ 直接用 http://127.0.0.1:%d/ ,⛔ 不用再起一个。" % int(port))
|
||
print(" · 要换上新代码 ⇒ 加 `--takeover`(先停旧实例再接管),或先停掉它。")
|
||
sys.stdout.flush()
|
||
return 0
|
||
srv = ThreadingHTTPServer(("127.0.0.1", int(port)), H)
|
||
srv.daemon_threads = True
|
||
print("看板已起:http://127.0.0.1:%d/ (只绑回环 · 每 %ss 异步快照 · 请求零阻塞)" % (int(port), interval))
|
||
sys.stdout.flush()
|
||
try:
|
||
srv.serve_forever()
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
srv.server_close()
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
if "--serve" in sys.argv:
|
||
i = sys.argv.index("--serve")
|
||
p = 8788
|
||
if i + 1 < len(sys.argv) and sys.argv[i + 1].isdigit():
|
||
p = int(sys.argv[i + 1])
|
||
iv = 3.0
|
||
if "--interval" in sys.argv: # 想更省 ⇒ 调大(实时性换零负担)
|
||
j = sys.argv.index("--interval")
|
||
if j + 1 < len(sys.argv):
|
||
try:
|
||
iv = max(0.5, float(sys.argv[j + 1]))
|
||
except Exception:
|
||
iv = 3.0
|
||
return serve(p, iv, takeover=("--takeover" in sys.argv))
|
||
out = INBOX / "board.json"
|
||
if "--out" in sys.argv:
|
||
i = sys.argv.index("--out")
|
||
if i + 1 < len(sys.argv):
|
||
out = Path(sys.argv[i + 1])
|
||
INBOX.mkdir(parents=True, exist_ok=True)
|
||
d = build()
|
||
out.write_text(json.dumps(d, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
print("板快照已写:%s(%d 字节)" % (out, out.stat().st_size))
|
||
print(" 目标:%s | %s" % (d["goal"]["title"][:40], _acc_summary(d["goal"]["acceptance"])))
|
||
_f = d.get("front") or {}
|
||
_ch = _f.get("chips") or []
|
||
print(" 前置(%s):%s" % (
|
||
_f.get("title") or "—",
|
||
" | ".join("%s=%s" % (c.get("label"), "上线" if c.get("up") else "离线") for c in _ch)
|
||
or (_f.get("_missing") or "(使用方未配 board_ext)")))
|
||
print(" 台账:%s" % {k: v["state"] for k, v in d["tasks"].items()})
|
||
_r = d.get("runtime") or {}
|
||
print(" 实时:协作程序=%s | 上报=%s" % ((_r.get("prog") or {}).get("label"),
|
||
(_r.get("deliver") or {}).get("label")))
|
||
if d.get("warn"):
|
||
print(" ⚠️ 降级(不静默):%s" % ";".join(d["warn"]))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|