claude-nb 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +38 -0
- package/Makefile +60 -0
- package/README.md +63 -0
- package/VERSION +1 -0
- package/bin/_pip_entry.py +25 -0
- package/bin/board +287 -0
- package/bin/cnb +150 -0
- package/bin/cnb.js +33 -0
- package/bin/dispatcher +151 -0
- package/bin/dispatcher-watchdog +57 -0
- package/bin/doctor +328 -0
- package/bin/init +316 -0
- package/bin/registry +347 -0
- package/bin/swarm +896 -0
- package/lib/__init__.py +1 -0
- package/lib/board_admin.py +128 -0
- package/lib/board_bbs.py +99 -0
- package/lib/board_bug.py +161 -0
- package/lib/board_db.py +262 -0
- package/lib/board_lock.py +113 -0
- package/lib/board_mailbox.py +145 -0
- package/lib/board_maintenance.py +237 -0
- package/lib/board_msg.py +230 -0
- package/lib/board_task.py +200 -0
- package/lib/board_view.py +366 -0
- package/lib/board_vote.py +164 -0
- package/lib/build_lock.py +221 -0
- package/lib/cli.py +34 -0
- package/lib/common.py +285 -0
- package/lib/concerns/__init__.py +42 -0
- package/lib/concerns/adaptive_throttle.py +26 -0
- package/lib/concerns/base.py +25 -0
- package/lib/concerns/bug_sla_checker.py +32 -0
- package/lib/concerns/config.py +22 -0
- package/lib/concerns/coral_manager.py +61 -0
- package/lib/concerns/coral_poker.py +57 -0
- package/lib/concerns/file_watcher.py +127 -0
- package/lib/concerns/health_checker.py +72 -0
- package/lib/concerns/helpers.py +152 -0
- package/lib/concerns/idle_detector.py +56 -0
- package/lib/concerns/idle_killer.py +41 -0
- package/lib/concerns/idle_nudger.py +38 -0
- package/lib/concerns/inbox_nudger.py +34 -0
- package/lib/concerns/resource_monitor.py +47 -0
- package/lib/concerns/session_keepalive.py +23 -0
- package/lib/concerns/time_announcer.py +34 -0
- package/lib/crypto.py +92 -0
- package/lib/health.py +187 -0
- package/lib/inject.py +164 -0
- package/lib/migrate.py +109 -0
- package/lib/monitor.py +373 -0
- package/lib/panel.py +137 -0
- package/lib/resources.py +341 -0
- package/migrations/001_foreign_keys.sql +77 -0
- package/migrations/002_session_persona.sql +1 -0
- package/migrations/003_mailbox.sql +9 -0
- package/package.json +28 -0
- package/pyproject.toml +71 -0
- package/registry/0001-meridian.json +12 -0
- package/registry/0002-forge.json +12 -0
- package/registry/0003-lead.json +12 -0
- package/registry/0004-ms-encrypted-mailbox-live.json +12 -0
- package/registry/GENESIS.json +9 -0
- package/registry/pubkeys.json +5 -0
- package/schema.sql +138 -0
package/lib/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# lib/ package — Python modules for cnb
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""board_admin — suspend / resume / kudos / kudos-list."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from lib.board_db import BoardDB, ts
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cmd_suspend(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
10
|
+
name = identity.lower()
|
|
11
|
+
if not args:
|
|
12
|
+
print("Usage: ./board --as <name> suspend <session>")
|
|
13
|
+
raise SystemExit(1)
|
|
14
|
+
target = args[0].lower()
|
|
15
|
+
|
|
16
|
+
session_exists = db.scalar("SELECT COUNT(*) FROM sessions WHERE name=?", (target,))
|
|
17
|
+
if not session_exists:
|
|
18
|
+
print(f"ERROR: 会话 '{target}' 不存在")
|
|
19
|
+
raise SystemExit(1)
|
|
20
|
+
|
|
21
|
+
already = db.scalar("SELECT COUNT(*) FROM suspended WHERE name=?", (target,))
|
|
22
|
+
if already:
|
|
23
|
+
print(f"{target} 已在停工名单中")
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
db.execute("INSERT INTO suspended(name, suspended_by) VALUES (?, ?)", (target, name))
|
|
27
|
+
|
|
28
|
+
sf = db.env.suspended_file
|
|
29
|
+
lines = sf.read_text().splitlines() if sf.exists() else []
|
|
30
|
+
if target not in lines:
|
|
31
|
+
lines.append(target)
|
|
32
|
+
sf.write_text("\n".join(lines) + "\n")
|
|
33
|
+
|
|
34
|
+
print(f"{target}: 已停工")
|
|
35
|
+
|
|
36
|
+
prefix = db.env.prefix
|
|
37
|
+
sess = f"{prefix}-{target}"
|
|
38
|
+
r = subprocess.run(["tmux", "has-session", "-t", sess], capture_output=True)
|
|
39
|
+
if r.returncode == 0:
|
|
40
|
+
subprocess.run(
|
|
41
|
+
["tmux", "send-keys", "-t", sess, "/exit", "Enter"],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
)
|
|
44
|
+
time.sleep(2)
|
|
45
|
+
subprocess.run(["tmux", "kill-session", "-t", sess], capture_output=True)
|
|
46
|
+
print(f"{target}: tmux session 已关闭")
|
|
47
|
+
|
|
48
|
+
now = ts()
|
|
49
|
+
db.execute(
|
|
50
|
+
"INSERT INTO messages(ts, sender, recipient, body) VALUES (?, 'SYSTEM', 'all', ?)",
|
|
51
|
+
(now, f"SUSPEND {target} by {name}"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_resume(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
56
|
+
name = identity.lower()
|
|
57
|
+
if not args:
|
|
58
|
+
print("Usage: ./board --as <name> resume <session>")
|
|
59
|
+
raise SystemExit(1)
|
|
60
|
+
target = args[0].lower()
|
|
61
|
+
|
|
62
|
+
exists = db.scalar("SELECT COUNT(*) FROM suspended WHERE name=?", (target,))
|
|
63
|
+
if not exists:
|
|
64
|
+
print(f"ERROR: {target} 不在停工名单中")
|
|
65
|
+
raise SystemExit(1)
|
|
66
|
+
|
|
67
|
+
db.execute("DELETE FROM suspended WHERE name=?", (target,))
|
|
68
|
+
|
|
69
|
+
sf = db.env.suspended_file
|
|
70
|
+
if sf.exists():
|
|
71
|
+
lines = [l for l in sf.read_text().splitlines() if l != target]
|
|
72
|
+
sf.write_text("\n".join(lines) + "\n" if lines else "")
|
|
73
|
+
|
|
74
|
+
print(f"{target}: 已恢复")
|
|
75
|
+
now = ts()
|
|
76
|
+
db.execute(
|
|
77
|
+
"INSERT INTO messages(ts, sender, recipient, body) VALUES (?, 'SYSTEM', 'all', ?)",
|
|
78
|
+
(now, f"RESUME {target} by {name}"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_kudos(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
83
|
+
name = identity.lower()
|
|
84
|
+
if len(args) < 2:
|
|
85
|
+
print("Usage: ./board --as <name> kudos <target> <reason> [--evidence <commit/link>]")
|
|
86
|
+
raise SystemExit(1)
|
|
87
|
+
|
|
88
|
+
evidence = ""
|
|
89
|
+
clean_args = []
|
|
90
|
+
i = 0
|
|
91
|
+
while i < len(args):
|
|
92
|
+
if args[i] in ("--evidence", "-e") and i + 1 < len(args):
|
|
93
|
+
evidence = args[i + 1]
|
|
94
|
+
i += 2
|
|
95
|
+
continue
|
|
96
|
+
clean_args.append(args[i])
|
|
97
|
+
i += 1
|
|
98
|
+
|
|
99
|
+
target = clean_args[0].lower()
|
|
100
|
+
reason = " ".join(clean_args[1:])
|
|
101
|
+
|
|
102
|
+
if name == target:
|
|
103
|
+
print("ERROR: cannot kudos yourself")
|
|
104
|
+
raise SystemExit(1)
|
|
105
|
+
|
|
106
|
+
db.execute(
|
|
107
|
+
"INSERT INTO kudos(sender, target, reason, evidence) VALUES (?, ?, ?, ?)",
|
|
108
|
+
(name, target, reason, evidence or None),
|
|
109
|
+
)
|
|
110
|
+
now = ts()
|
|
111
|
+
db.execute(
|
|
112
|
+
"INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, 'all', ?)",
|
|
113
|
+
(now, name, f"[KUDOS] → {target}: {reason}"),
|
|
114
|
+
)
|
|
115
|
+
print(f"OK kudos sent to {target} (visible to all)")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_kudos_list(db: BoardDB) -> None:
|
|
119
|
+
print("=== Kudos Board ===\n")
|
|
120
|
+
print("Leaderboard:")
|
|
121
|
+
for who, count in db.query("SELECT target, COUNT(*) as c FROM kudos GROUP BY target ORDER BY c DESC"):
|
|
122
|
+
print(f" {who}: {count} kudos")
|
|
123
|
+
print("\nRecent:")
|
|
124
|
+
rows = db.query(
|
|
125
|
+
"SELECT '[' || ts || '] ' || sender || ' → ' || target || ': ' || reason FROM kudos ORDER BY id DESC LIMIT 10"
|
|
126
|
+
)
|
|
127
|
+
for (line,) in reversed(rows):
|
|
128
|
+
print(f" {line}")
|
package/lib/board_bbs.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""board_bbs — forum commands: post / reply / thread / threads."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
5
|
+
from lib.board_db import BoardDB, ts
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cmd_post(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
9
|
+
name = identity.lower()
|
|
10
|
+
if len(args) < 2:
|
|
11
|
+
print("Usage: ./board --as <name> post <标题> <内容>")
|
|
12
|
+
raise SystemExit(1)
|
|
13
|
+
title = args[0]
|
|
14
|
+
now = ts()
|
|
15
|
+
tid = hashlib.sha256(f"{title}{now}{identity}".encode()).hexdigest()[:6]
|
|
16
|
+
|
|
17
|
+
db.execute(
|
|
18
|
+
"INSERT INTO threads(id, title, author) VALUES (?, ?, ?)",
|
|
19
|
+
(tid, title, name),
|
|
20
|
+
)
|
|
21
|
+
db.execute(
|
|
22
|
+
"INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, 'all', ?)",
|
|
23
|
+
(now, name, f"[BBS] 新帖「{title}」({tid})"),
|
|
24
|
+
)
|
|
25
|
+
print(f"OK 帖子已创建: {tid}")
|
|
26
|
+
print(f" 标题: {title}")
|
|
27
|
+
print(f" 查看: ./board --as <name> thread {tid}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cmd_reply(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
31
|
+
name = identity.lower()
|
|
32
|
+
if len(args) < 2:
|
|
33
|
+
print("Usage: ./board --as <name> reply <帖子ID> <内容>")
|
|
34
|
+
raise SystemExit(1)
|
|
35
|
+
tid = args[0]
|
|
36
|
+
body = " ".join(args[1:])
|
|
37
|
+
|
|
38
|
+
full_tid = db.scalar(
|
|
39
|
+
"SELECT id FROM threads WHERE id LIKE ? ESCAPE '\\' LIMIT 1",
|
|
40
|
+
(tid + "%",),
|
|
41
|
+
)
|
|
42
|
+
if not full_tid:
|
|
43
|
+
print(f"ERROR: 帖子 {tid} 不存在")
|
|
44
|
+
raise SystemExit(1)
|
|
45
|
+
|
|
46
|
+
db.execute(
|
|
47
|
+
"INSERT INTO thread_replies(thread_id, author, body) VALUES (?, ?, ?)",
|
|
48
|
+
(full_tid, name, body),
|
|
49
|
+
)
|
|
50
|
+
title = db.scalar("SELECT title FROM threads WHERE id=?", (full_tid,))
|
|
51
|
+
now = ts()
|
|
52
|
+
db.execute(
|
|
53
|
+
"INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, 'all', ?)",
|
|
54
|
+
(now, name, f"[BBS] 回帖「{title}」({full_tid})"),
|
|
55
|
+
)
|
|
56
|
+
print(f"OK 回帖成功 (帖子: {full_tid})")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cmd_thread(db: BoardDB, args: list[str]) -> None:
|
|
60
|
+
if not args:
|
|
61
|
+
print("Usage: ./board thread <帖子ID>")
|
|
62
|
+
raise SystemExit(1)
|
|
63
|
+
tid = args[0]
|
|
64
|
+
full_tid = db.scalar(
|
|
65
|
+
"SELECT id FROM threads WHERE id LIKE ? ESCAPE '\\' LIMIT 1",
|
|
66
|
+
(tid + "%",),
|
|
67
|
+
)
|
|
68
|
+
if not full_tid:
|
|
69
|
+
print(f"ERROR: 帖子 {tid} 不存在")
|
|
70
|
+
raise SystemExit(1)
|
|
71
|
+
|
|
72
|
+
row = db.query_one("SELECT title, author, created_at FROM threads WHERE id=?", (full_tid,))
|
|
73
|
+
title, author, created = row
|
|
74
|
+
print(f"# {title}")
|
|
75
|
+
print(f"> @{author} — {created}\n")
|
|
76
|
+
|
|
77
|
+
for rauthor, rbody, rts in db.query(
|
|
78
|
+
"SELECT author, body, ts FROM thread_replies WHERE thread_id=? ORDER BY id",
|
|
79
|
+
(full_tid,),
|
|
80
|
+
):
|
|
81
|
+
print("---")
|
|
82
|
+
print(f"> @{rauthor} — {rts}\n")
|
|
83
|
+
print(rbody)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_threads(db: BoardDB) -> None:
|
|
87
|
+
print("=== BBS 话题列表 ===\n")
|
|
88
|
+
rows = db.query(
|
|
89
|
+
"SELECT t.id, t.title, t.author, t.created_at, "
|
|
90
|
+
"(SELECT COUNT(*) FROM thread_replies r WHERE r.thread_id=t.id) "
|
|
91
|
+
"FROM threads t ORDER BY t.created_at DESC"
|
|
92
|
+
)
|
|
93
|
+
if not rows:
|
|
94
|
+
print(" (暂无话题)\n")
|
|
95
|
+
print(" 发帖: ./board --as <name> post <标题> <内容>")
|
|
96
|
+
else:
|
|
97
|
+
for tid, title, author, date, replies in rows:
|
|
98
|
+
print(f" [{tid}] {title} ({replies} 回帖) by {author} {date}")
|
|
99
|
+
print()
|
package/lib/board_bug.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""board_bug — bug tracker: report / assign / fix / list / overdue."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from lib.board_db import BoardDB, ts
|
|
7
|
+
from lib.common import is_terminal_bug_status
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cmd_bug(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
11
|
+
subcmd = args[0] if args else "list"
|
|
12
|
+
rest = args[1:] if len(args) > 1 else []
|
|
13
|
+
dispatch = {
|
|
14
|
+
"report": lambda: _bug_report(db, identity, rest),
|
|
15
|
+
"assign": lambda: _bug_assign(db, identity, rest),
|
|
16
|
+
"fix": lambda: _bug_fix(db, identity, rest),
|
|
17
|
+
"list": lambda: _bug_list(db, rest),
|
|
18
|
+
"overdue": lambda: _bug_overdue(db),
|
|
19
|
+
}
|
|
20
|
+
fn = dispatch.get(subcmd)
|
|
21
|
+
if not fn:
|
|
22
|
+
print("Usage: ./board --as <name> bug {report|assign|fix|list|overdue}")
|
|
23
|
+
raise SystemExit(1)
|
|
24
|
+
fn()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _bug_report(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
28
|
+
name = identity.lower()
|
|
29
|
+
if len(args) < 2:
|
|
30
|
+
print("Usage: ./board --as <name> bug report <P0|P1|P2> <description>")
|
|
31
|
+
raise SystemExit(1)
|
|
32
|
+
severity = args[0].upper()
|
|
33
|
+
if severity not in ("P0", "P1", "P2"):
|
|
34
|
+
print("ERROR: severity must be P0, P1, or P2")
|
|
35
|
+
raise SystemExit(1)
|
|
36
|
+
desc = " ".join(args[1:])
|
|
37
|
+
sla = {"P0": "immediate", "P1": "4h", "P2": "24h"}[severity]
|
|
38
|
+
|
|
39
|
+
now = ts()
|
|
40
|
+
|
|
41
|
+
with db.conn() as c:
|
|
42
|
+
max_id = db.scalar("SELECT COALESCE(MAX(CAST(SUBSTR(id, 5) AS INTEGER)), 0) FROM bugs", c=c)
|
|
43
|
+
next_id = f"BUG-{max_id + 1:03d}"
|
|
44
|
+
db.execute(
|
|
45
|
+
"INSERT INTO bugs(id, severity, sla, reporter, status, description) VALUES (?, ?, ?, ?, 'OPEN', ?)",
|
|
46
|
+
(next_id, severity, sla, name, desc),
|
|
47
|
+
c=c,
|
|
48
|
+
)
|
|
49
|
+
msg_id = db.execute(
|
|
50
|
+
"INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, 'all', ?)",
|
|
51
|
+
(now, name, f"[{next_id}/{severity}] {desc}"),
|
|
52
|
+
c=c,
|
|
53
|
+
)
|
|
54
|
+
db.deliver_to_inbox(name, "all", msg_id, c=c)
|
|
55
|
+
print(f"OK {next_id} ({severity}) created")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _bug_assign(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
59
|
+
name = identity.lower()
|
|
60
|
+
if len(args) < 2:
|
|
61
|
+
print("Usage: ./board --as <name> bug assign <BUG-NNN> <session>")
|
|
62
|
+
raise SystemExit(1)
|
|
63
|
+
bugid = args[0].upper()
|
|
64
|
+
if not bugid.startswith("BUG-"):
|
|
65
|
+
bugid = f"BUG-{bugid}"
|
|
66
|
+
assignee = args[1].lower()
|
|
67
|
+
|
|
68
|
+
exists = db.scalar("SELECT COUNT(*) FROM bugs WHERE id=?", (bugid,))
|
|
69
|
+
if not exists:
|
|
70
|
+
print(f"ERROR: {bugid} not found")
|
|
71
|
+
raise SystemExit(1)
|
|
72
|
+
|
|
73
|
+
now = ts()
|
|
74
|
+
with db.conn() as c:
|
|
75
|
+
db.execute("UPDATE bugs SET assignee=?, status='ASSIGNED' WHERE id=?", (assignee, bugid), c=c)
|
|
76
|
+
db.execute(
|
|
77
|
+
"INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, ?, ?)",
|
|
78
|
+
(now, name, assignee, f"[{bugid}] assigned to you"),
|
|
79
|
+
c=c,
|
|
80
|
+
)
|
|
81
|
+
print(f"OK {bugid} assigned to {assignee}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _bug_fix(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
85
|
+
name = identity.lower()
|
|
86
|
+
if len(args) < 2:
|
|
87
|
+
print("Usage: ./board --as <name> bug fix <BUG-NNN> <evidence>")
|
|
88
|
+
raise SystemExit(1)
|
|
89
|
+
bugid = args[0].upper()
|
|
90
|
+
if not bugid.startswith("BUG-"):
|
|
91
|
+
bugid = f"BUG-{bugid}"
|
|
92
|
+
evidence = " ".join(args[1:])
|
|
93
|
+
|
|
94
|
+
row = db.query_one("SELECT status FROM bugs WHERE id=?", (bugid,))
|
|
95
|
+
if not row:
|
|
96
|
+
print(f"ERROR: {bugid} not found")
|
|
97
|
+
raise SystemExit(1)
|
|
98
|
+
|
|
99
|
+
if is_terminal_bug_status(row["status"]):
|
|
100
|
+
print(f"Bug {bugid} is already {row['status']}.")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
now = ts()
|
|
104
|
+
with db.conn() as c:
|
|
105
|
+
db.execute("UPDATE bugs SET status='FIXED', fixed_at=?, evidence=? WHERE id=?", (now, evidence, bugid), c=c)
|
|
106
|
+
db.execute(
|
|
107
|
+
"INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, 'all', ?)",
|
|
108
|
+
(now, name, f"[{bugid}] FIXED — {evidence}"),
|
|
109
|
+
c=c,
|
|
110
|
+
)
|
|
111
|
+
print(f"OK {bugid} marked FIXED")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _bug_list(db: BoardDB, args: list[str]) -> None:
|
|
115
|
+
filt = args[0] if args else "open"
|
|
116
|
+
print("=== Bug Tracker ===")
|
|
117
|
+
|
|
118
|
+
if filt == "open":
|
|
119
|
+
rows = db.query(
|
|
120
|
+
"SELECT id, severity, status, assignee, reporter, reported_at, description "
|
|
121
|
+
"FROM bugs WHERE status != 'FIXED' ORDER BY reported_at"
|
|
122
|
+
)
|
|
123
|
+
elif filt == "all":
|
|
124
|
+
rows = db.query(
|
|
125
|
+
"SELECT id, severity, status, assignee, reporter, reported_at, description FROM bugs ORDER BY reported_at"
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
rows = db.query(
|
|
129
|
+
"SELECT id, severity, status, assignee, reporter, reported_at, description "
|
|
130
|
+
"FROM bugs WHERE status = ? ORDER BY reported_at",
|
|
131
|
+
(filt.upper(),),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if not rows:
|
|
135
|
+
print(f" (no bugs matching filter: {filt})")
|
|
136
|
+
else:
|
|
137
|
+
for bid, sev, status, assignee, reporter, reported, desc in rows:
|
|
138
|
+
print(f"\n {bid} [{sev}] {status}")
|
|
139
|
+
print(f" Reporter: {reporter} Assignee: {assignee or 'unassigned'}")
|
|
140
|
+
print(f" Reported: {reported}")
|
|
141
|
+
print(f" {desc}")
|
|
142
|
+
print()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _bug_overdue(db: BoardDB) -> None:
|
|
146
|
+
now_epoch = int(time.time())
|
|
147
|
+
rows = db.query("SELECT id, severity, reported_at FROM bugs WHERE status != 'FIXED'")
|
|
148
|
+
found = False
|
|
149
|
+
for bid, sev, reported in rows:
|
|
150
|
+
try:
|
|
151
|
+
dt = datetime.strptime(reported, "%Y-%m-%d %H:%M")
|
|
152
|
+
rep_epoch = int(dt.timestamp())
|
|
153
|
+
except (ValueError, TypeError):
|
|
154
|
+
continue
|
|
155
|
+
elapsed = now_epoch - rep_epoch
|
|
156
|
+
limit = {"P0": 0, "P1": 14400, "P2": 86400}.get(sev, 0)
|
|
157
|
+
if elapsed > limit:
|
|
158
|
+
found = True
|
|
159
|
+
print(f"OVERDUE: {bid} [{sev}] — {elapsed // 60}min since reported")
|
|
160
|
+
if not found:
|
|
161
|
+
print("No overdue bugs.")
|
package/lib/board_db.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""board_db — DB connection, helpers, and .md file sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from lib.common import ( # noqa: F401 — re-export ts for board_* modules
|
|
12
|
+
BaseDB,
|
|
13
|
+
ClaudesEnv,
|
|
14
|
+
Signal,
|
|
15
|
+
sanitize_session_name,
|
|
16
|
+
ts,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Emitted when deliver_to_inbox() delivers a message. Session name is the arg.
|
|
20
|
+
# Subscribers (e.g., FileWatcher) can react instantly without polling.
|
|
21
|
+
inbox_delivered = Signal[str]()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BoardDB(BaseDB):
|
|
25
|
+
"""Unified SQLite wrapper — new connection per call, no pooling.
|
|
26
|
+
|
|
27
|
+
Accepts either a ClaudesEnv (production) or a bare Path/str (tests, lightweight callers).
|
|
28
|
+
Adds .md file sync helpers on top of the BaseDB interface.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, env_or_path: ClaudesEnv | Path | str):
|
|
32
|
+
if isinstance(env_or_path, ClaudesEnv):
|
|
33
|
+
self.env: ClaudesEnv | None = env_or_path
|
|
34
|
+
self.db_path = env_or_path.board_db
|
|
35
|
+
if not self.db_path.exists():
|
|
36
|
+
print("ERROR: board.db not found. Run: cnb init <session-names>", flush=True)
|
|
37
|
+
raise SystemExit(1)
|
|
38
|
+
# Auto-apply pending schema migrations (idempotent, fast if up-to-date)
|
|
39
|
+
self._auto_migrate()
|
|
40
|
+
else:
|
|
41
|
+
self.env = None
|
|
42
|
+
self.db_path = Path(env_or_path)
|
|
43
|
+
|
|
44
|
+
def _auto_migrate(self) -> None:
|
|
45
|
+
"""Apply pending schema migrations on first load (idempotent).
|
|
46
|
+
|
|
47
|
+
Only prints output when migrations were actually applied.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
from lib.migrate import run_migrations
|
|
51
|
+
|
|
52
|
+
install_home = self.env.install_home if self.env else Path(__file__).resolve().parent.parent
|
|
53
|
+
run_migrations(self.db_path, install_home)
|
|
54
|
+
except SystemExit:
|
|
55
|
+
raise
|
|
56
|
+
except Exception as e:
|
|
57
|
+
print(f"WARNING: migration check failed: {e}", flush=True)
|
|
58
|
+
|
|
59
|
+
@contextmanager
|
|
60
|
+
def conn(self) -> Generator[sqlite3.Connection, None, None]:
|
|
61
|
+
"""New connection per call. Commits on success, rolls back on exception."""
|
|
62
|
+
c = sqlite3.connect(str(self.db_path))
|
|
63
|
+
c.execute("PRAGMA journal_mode=WAL")
|
|
64
|
+
c.execute("PRAGMA foreign_keys=ON")
|
|
65
|
+
c.row_factory = sqlite3.Row
|
|
66
|
+
try:
|
|
67
|
+
yield c
|
|
68
|
+
c.commit()
|
|
69
|
+
except Exception:
|
|
70
|
+
c.rollback()
|
|
71
|
+
raise
|
|
72
|
+
finally:
|
|
73
|
+
c.close()
|
|
74
|
+
|
|
75
|
+
def query(
|
|
76
|
+
self, sql: str, params: tuple[Any, ...] = (), *, c: sqlite3.Connection | None = None
|
|
77
|
+
) -> list[sqlite3.Row]:
|
|
78
|
+
if c is not None:
|
|
79
|
+
return c.execute(sql, params).fetchall()
|
|
80
|
+
with self.conn() as conn:
|
|
81
|
+
return conn.execute(sql, params).fetchall()
|
|
82
|
+
|
|
83
|
+
def query_one(
|
|
84
|
+
self, sql: str, params: tuple[Any, ...] = (), *, c: sqlite3.Connection | None = None
|
|
85
|
+
) -> sqlite3.Row | None:
|
|
86
|
+
rows = self.query(sql, params, c=c)
|
|
87
|
+
return rows[0] if rows else None
|
|
88
|
+
|
|
89
|
+
def scalar(self, sql: str, params: tuple[Any, ...] = (), *, c: sqlite3.Connection | None = None) -> Any:
|
|
90
|
+
row = self.query_one(sql, params, c=c)
|
|
91
|
+
return row[0] if row else None
|
|
92
|
+
|
|
93
|
+
def execute(self, sql: str, params: tuple[Any, ...] = (), *, c: sqlite3.Connection | None = None) -> int:
|
|
94
|
+
if c is not None:
|
|
95
|
+
return c.execute(sql, params).lastrowid
|
|
96
|
+
with self.conn() as conn:
|
|
97
|
+
cur = conn.execute(sql, params)
|
|
98
|
+
return cur.lastrowid
|
|
99
|
+
|
|
100
|
+
def execute_changes(self, sql: str, params: tuple[Any, ...] = (), *, c: sqlite3.Connection | None = None) -> int:
|
|
101
|
+
if c is not None:
|
|
102
|
+
c.execute(sql, params)
|
|
103
|
+
return c.execute("SELECT changes()").fetchone()[0]
|
|
104
|
+
with self.conn() as conn:
|
|
105
|
+
conn.execute(sql, params)
|
|
106
|
+
return conn.execute("SELECT changes()").fetchone()[0]
|
|
107
|
+
|
|
108
|
+
def ensure_session(self, name: str, *, c: sqlite3.Connection | None = None) -> None:
|
|
109
|
+
n = name.lower()
|
|
110
|
+
existing = self.scalar("SELECT COUNT(*) FROM sessions WHERE name=?", (n,), c=c)
|
|
111
|
+
if existing == 0:
|
|
112
|
+
self.execute("INSERT INTO sessions(name) VALUES (?)", (n,), c=c)
|
|
113
|
+
|
|
114
|
+
# --- .md file sync ---
|
|
115
|
+
#
|
|
116
|
+
# All writes use temp-file + atomic rename to avoid corruption on
|
|
117
|
+
# crash mid-write. Section detection is strict: ## heading must be
|
|
118
|
+
# followed by space or end-of-line (not e.g. "## @收件箱-extra").
|
|
119
|
+
|
|
120
|
+
INBOX_HEADINGS: tuple[str, ...] = ("## @inbox", "## @收件箱")
|
|
121
|
+
TASK_HEADINGS: tuple[str, ...] = ("## Current task", "## 当前任务")
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _replace_section(
|
|
125
|
+
text: str,
|
|
126
|
+
headings: tuple[str, ...],
|
|
127
|
+
replacement: str | None,
|
|
128
|
+
) -> str | None:
|
|
129
|
+
"""Replace or remove the content of a named Markdown section.
|
|
130
|
+
|
|
131
|
+
If *replacement* is None, the section is removed. Returns the new
|
|
132
|
+
text, or None if the section was not found and no append is needed.
|
|
133
|
+
|
|
134
|
+
Heading detection: a line is a section boundary if it starts with "## "
|
|
135
|
+
(after stripping leading whitespace). Target headings are matched
|
|
136
|
+
case-insensitively against the normalized prefix.
|
|
137
|
+
"""
|
|
138
|
+
lines = text.split("\n")
|
|
139
|
+
out: list[str] = []
|
|
140
|
+
in_section = False
|
|
141
|
+
found = False
|
|
142
|
+
|
|
143
|
+
normalized_headings = tuple(h.strip().lower() for h in headings)
|
|
144
|
+
|
|
145
|
+
for line in lines:
|
|
146
|
+
stripped = line.strip().lower()
|
|
147
|
+
if any(stripped == h or stripped.startswith(h + " ") for h in normalized_headings):
|
|
148
|
+
found = True
|
|
149
|
+
in_section = True
|
|
150
|
+
if replacement is not None:
|
|
151
|
+
out.append(line)
|
|
152
|
+
out.append(replacement)
|
|
153
|
+
continue
|
|
154
|
+
if in_section and stripped.startswith("## "):
|
|
155
|
+
in_section = False
|
|
156
|
+
out.append("")
|
|
157
|
+
out.append(line)
|
|
158
|
+
continue
|
|
159
|
+
if in_section:
|
|
160
|
+
continue
|
|
161
|
+
out.append(line)
|
|
162
|
+
|
|
163
|
+
if not found:
|
|
164
|
+
return None
|
|
165
|
+
return "\n".join(out)
|
|
166
|
+
|
|
167
|
+
def _atomic_write(self, path: Path, content: str) -> None:
|
|
168
|
+
"""Write *content* to *path* atomically (temp + rename)."""
|
|
169
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
170
|
+
try:
|
|
171
|
+
tmp.write_text(content)
|
|
172
|
+
tmp.replace(path)
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
# ── inbox sync ──
|
|
177
|
+
|
|
178
|
+
def sync_inbox_to_file(self, target: str, *, c: sqlite3.Connection | None = None) -> None:
|
|
179
|
+
"""Sync unread inbox messages to {target}.md file.
|
|
180
|
+
|
|
181
|
+
Accepts an optional *c* to use the caller's connection (so that
|
|
182
|
+
uncommitted inbox rows are visible within a transaction).
|
|
183
|
+
"""
|
|
184
|
+
if not self.env:
|
|
185
|
+
return
|
|
186
|
+
sf = self.env.sessions_dir / f"{target}.md"
|
|
187
|
+
if not sf.exists():
|
|
188
|
+
return
|
|
189
|
+
rows = self.query(
|
|
190
|
+
"SELECT '- [' || m.ts || '] **' || m.sender || '**: ' || substr(m.body, 1, 60) "
|
|
191
|
+
"FROM inbox i JOIN messages m ON i.message_id=m.id "
|
|
192
|
+
"WHERE i.session=? AND i.read=0 ORDER BY m.ts",
|
|
193
|
+
(target,),
|
|
194
|
+
c=c,
|
|
195
|
+
)
|
|
196
|
+
inbox_lines = "\n".join(r[0] for r in rows) if rows else ""
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
text = sf.read_text()
|
|
200
|
+
except OSError:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
result = self._replace_section(text, self.INBOX_HEADINGS, inbox_lines or None)
|
|
204
|
+
if result is not None:
|
|
205
|
+
self._atomic_write(sf, result)
|
|
206
|
+
elif inbox_lines:
|
|
207
|
+
heading = self.INBOX_HEADINGS[1] # canonical: Chinese heading
|
|
208
|
+
self._atomic_write(sf, text.rstrip("\n") + f"\n\n{heading}\n{inbox_lines}\n")
|
|
209
|
+
|
|
210
|
+
def clear_inbox_file(self, target: str) -> None:
|
|
211
|
+
if not self.env:
|
|
212
|
+
return
|
|
213
|
+
sf = self.env.sessions_dir / f"{target}.md"
|
|
214
|
+
if not sf.exists():
|
|
215
|
+
return
|
|
216
|
+
try:
|
|
217
|
+
text = sf.read_text()
|
|
218
|
+
except OSError:
|
|
219
|
+
return
|
|
220
|
+
result = self._replace_section(text, self.INBOX_HEADINGS, None)
|
|
221
|
+
if result is not None:
|
|
222
|
+
self._atomic_write(sf, result)
|
|
223
|
+
|
|
224
|
+
# ── status sync ──
|
|
225
|
+
|
|
226
|
+
def sync_status_to_file(self, target: str, status: str) -> None:
|
|
227
|
+
if not self.env:
|
|
228
|
+
return
|
|
229
|
+
sf = self.env.sessions_dir / f"{target}.md"
|
|
230
|
+
if not sf.exists():
|
|
231
|
+
return
|
|
232
|
+
try:
|
|
233
|
+
text = sf.read_text()
|
|
234
|
+
except OSError:
|
|
235
|
+
return
|
|
236
|
+
result = self._replace_section(text, self.TASK_HEADINGS, status)
|
|
237
|
+
if result is not None:
|
|
238
|
+
self._atomic_write(sf, result)
|
|
239
|
+
|
|
240
|
+
def deliver_to_inbox(
|
|
241
|
+
self, sender: str, recipient: str, msg_id: int, *, c: sqlite3.Connection | None = None
|
|
242
|
+
) -> None:
|
|
243
|
+
if recipient == "all":
|
|
244
|
+
sessions = self.query("SELECT name FROM sessions WHERE name != ?", (sender,), c=c)
|
|
245
|
+
for (target,) in sessions:
|
|
246
|
+
self.execute(
|
|
247
|
+
"INSERT INTO inbox(session, message_id) VALUES (?, ?)",
|
|
248
|
+
(target, msg_id),
|
|
249
|
+
c=c,
|
|
250
|
+
)
|
|
251
|
+
for (target,) in sessions:
|
|
252
|
+
self.sync_inbox_to_file(target, c=c)
|
|
253
|
+
inbox_delivered.emit(target)
|
|
254
|
+
else:
|
|
255
|
+
self.ensure_session(recipient, c=c)
|
|
256
|
+
self.execute(
|
|
257
|
+
"INSERT INTO inbox(session, message_id) VALUES (?, ?)",
|
|
258
|
+
(recipient, msg_id),
|
|
259
|
+
c=c,
|
|
260
|
+
)
|
|
261
|
+
self.sync_inbox_to_file(recipient, c=c)
|
|
262
|
+
inbox_delivered.emit(recipient)
|