claude-nb 0.3.0 → 0.5.1
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/Makefile +8 -2
- package/README.md +57 -36
- package/VERSION +1 -1
- package/bin/board +112 -34
- package/bin/cnb +152 -65
- package/bin/dispatcher +25 -11
- package/bin/doctor +3 -5
- package/bin/init +13 -47
- package/bin/notify +224 -0
- package/bin/registry +8 -23
- package/bin/swarm +41 -860
- package/bin/sync-version +131 -0
- package/lib/board_admin.py +19 -9
- package/lib/board_bbs.py +23 -8
- package/lib/board_bug.py +2 -1
- package/lib/board_db.py +31 -141
- package/lib/board_lock.py +5 -1
- package/lib/board_mailbox.py +18 -8
- package/lib/board_maintenance.py +26 -27
- package/lib/board_msg.py +76 -39
- package/lib/board_pending.py +233 -0
- package/lib/board_pulse.py +14 -0
- package/lib/board_task.py +41 -32
- package/lib/board_tui.py +120 -0
- package/lib/board_view.py +70 -50
- package/lib/board_vote.py +9 -3
- package/lib/build_lock.py +7 -7
- package/lib/common.py +45 -3
- package/lib/concerns/__init__.py +7 -11
- package/lib/concerns/{coral_manager.py → coral.py} +54 -4
- package/lib/concerns/digest_scheduler.py +109 -0
- package/lib/concerns/file_watcher.py +73 -68
- package/lib/concerns/health.py +136 -0
- package/lib/concerns/helpers.py +1 -5
- package/lib/concerns/idle.py +130 -0
- package/lib/concerns/notification_push.py +171 -0
- package/lib/concerns/notifications.py +145 -0
- package/lib/concerns/nudge_coordinator.py +148 -0
- package/lib/digest.py +62 -0
- package/lib/health.py +2 -2
- package/lib/inject.py +2 -2
- package/lib/migrate.py +1 -0
- package/lib/monitor.py +9 -22
- package/lib/notification_config.py +101 -0
- package/lib/swarm.py +464 -0
- package/lib/swarm_backend.py +300 -0
- package/lib/theme_profiles.py +89 -0
- package/migrations/004_heartbeat.sql +1 -0
- package/migrations/005_notification_log.sql +12 -0
- package/migrations/006_pending_actions.sql +15 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/registry/README.md +9 -0
- package/registry/pubkeys.json +2 -1
- package/schema.sql +29 -1
- package/lib/concerns/bug_sla_checker.py +0 -32
- package/lib/concerns/coral_poker.py +0 -57
- package/lib/concerns/health_checker.py +0 -72
- package/lib/concerns/idle_detector.py +0 -56
- package/lib/concerns/idle_killer.py +0 -41
- package/lib/concerns/idle_nudger.py +0 -38
- package/lib/concerns/inbox_nudger.py +0 -34
- package/lib/concerns/resource_monitor.py +0 -47
- package/lib/concerns/session_keepalive.py +0 -23
- package/lib/concerns/time_announcer.py +0 -34
package/lib/board_maintenance.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""board_maintenance — data maintenance: prune, backup, restore."""
|
|
2
2
|
|
|
3
|
-
import shutil
|
|
4
3
|
import time
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
|
|
@@ -14,9 +13,9 @@ from lib.board_db import BoardDB
|
|
|
14
13
|
def cmd_prune(db: BoardDB, args: list[str]) -> None:
|
|
15
14
|
"""Prune old messages and inbox entries.
|
|
16
15
|
|
|
17
|
-
Usage: board --as <name> prune [--before YYYY-MM-DD] [--
|
|
16
|
+
Usage: board --as <name> prune [--before YYYY-MM-DD] [--dry-run]
|
|
18
17
|
"""
|
|
19
|
-
usage = "Usage: board --as <name> prune [--before YYYY-MM-DD] [--
|
|
18
|
+
usage = "Usage: board --as <name> prune [--before YYYY-MM-DD] [--dry-run]"
|
|
20
19
|
|
|
21
20
|
before_days = 90 # default: keep 90 days
|
|
22
21
|
dry_run = False
|
|
@@ -27,13 +26,6 @@ def cmd_prune(db: BoardDB, args: list[str]) -> None:
|
|
|
27
26
|
if args[i] == "--before" and i + 1 < len(args):
|
|
28
27
|
before_days = _parse_days(args[i + 1])
|
|
29
28
|
i += 2
|
|
30
|
-
elif args[i] == "--keep" and i + 1 < len(args):
|
|
31
|
-
try:
|
|
32
|
-
int(args[i + 1])
|
|
33
|
-
except ValueError:
|
|
34
|
-
print(f"ERROR: --keep requires a number, got '{args[i + 1]}'")
|
|
35
|
-
raise SystemExit(1)
|
|
36
|
-
i += 2
|
|
37
29
|
elif args[i] == "--dry-run":
|
|
38
30
|
dry_run = True
|
|
39
31
|
i += 1
|
|
@@ -48,22 +40,29 @@ def cmd_prune(db: BoardDB, args: list[str]) -> None:
|
|
|
48
40
|
cutoff = _days_ago_ts(before_days)
|
|
49
41
|
|
|
50
42
|
# Count what would be deleted
|
|
51
|
-
old_inbox =
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
43
|
+
old_inbox = (
|
|
44
|
+
db.scalar(
|
|
45
|
+
"SELECT COUNT(*) FROM inbox WHERE message_id IN (SELECT id FROM messages WHERE ts < ?)",
|
|
46
|
+
(cutoff,),
|
|
47
|
+
)
|
|
48
|
+
or 0
|
|
49
|
+
)
|
|
50
|
+
old_messages = (
|
|
51
|
+
db.scalar(
|
|
52
|
+
"SELECT COUNT(*) FROM messages WHERE ts < ?",
|
|
53
|
+
(cutoff,),
|
|
54
|
+
)
|
|
55
|
+
or 0
|
|
56
|
+
)
|
|
60
57
|
|
|
61
58
|
# Also count old read inbox entries (not just those linked to old messages)
|
|
62
|
-
old_read_inbox =
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
59
|
+
old_read_inbox = (
|
|
60
|
+
db.scalar(
|
|
61
|
+
"SELECT COUNT(*) FROM inbox WHERE read=1 AND delivered_at < ?",
|
|
62
|
+
(cutoff,),
|
|
63
|
+
)
|
|
64
|
+
or 0
|
|
65
|
+
)
|
|
67
66
|
|
|
68
67
|
if dry_run:
|
|
69
68
|
print("=== DRY RUN: would delete ===")
|
|
@@ -204,9 +203,7 @@ def cmd_restore(db: BoardDB, args: list[str]) -> None:
|
|
|
204
203
|
try:
|
|
205
204
|
conn = sqlite3.connect(str(source))
|
|
206
205
|
conn.execute("PRAGMA integrity_check")
|
|
207
|
-
tables = conn.execute(
|
|
208
|
-
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
209
|
-
).fetchall()
|
|
206
|
+
tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
|
210
207
|
conn.close()
|
|
211
208
|
if not tables:
|
|
212
209
|
print("ERROR: backup file contains no tables")
|
|
@@ -229,6 +226,8 @@ def cmd_restore(db: BoardDB, args: list[str]) -> None:
|
|
|
229
226
|
return
|
|
230
227
|
|
|
231
228
|
# Atomic restore: copy to temp, rename
|
|
229
|
+
import shutil
|
|
230
|
+
|
|
232
231
|
tmp = db.db_path.with_suffix(".db.restore-tmp")
|
|
233
232
|
shutil.copy2(source, tmp)
|
|
234
233
|
tmp.replace(db.db_path)
|
package/lib/board_msg.py
CHANGED
|
@@ -1,25 +1,60 @@
|
|
|
1
1
|
"""board_msg — send / inbox / ack / status / log commands."""
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
|
+
import re
|
|
4
5
|
import shutil
|
|
5
6
|
import subprocess
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
from lib.board_db import BoardDB, ts
|
|
9
|
-
from lib.common import parse_flags
|
|
10
|
+
from lib.common import parse_flags, validate_identity
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def _ack_marker_path(db: BoardDB, name: str) -> Path:
|
|
13
14
|
"""Path to file recording max message_id seen by last inbox call."""
|
|
15
|
+
assert db.env is not None
|
|
14
16
|
return db.env.sessions_dir / f".{name}.ack_max_id"
|
|
15
17
|
|
|
16
18
|
|
|
19
|
+
def _is_idle(sess: str) -> bool:
|
|
20
|
+
"""Check if a Claude Code session is idle at its prompt (not mid-response).
|
|
21
|
+
|
|
22
|
+
Claude Code's prompt layout puts the prompt marker a few lines above the bottom
|
|
23
|
+
(status bar, project name, permissions line are below it), so we
|
|
24
|
+
check the last ~8 lines rather than just the final line.
|
|
25
|
+
"""
|
|
26
|
+
r = subprocess.run(
|
|
27
|
+
["tmux", "capture-pane", "-t", sess, "-p", "-S", "-10"],
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
timeout=3,
|
|
31
|
+
)
|
|
32
|
+
if r.returncode != 0:
|
|
33
|
+
return False
|
|
34
|
+
text = r.stdout.rstrip()
|
|
35
|
+
if not text:
|
|
36
|
+
return False
|
|
37
|
+
busy_indicators = ("Choreographing", "Seasoning", "Churned", "Gitifying", "Running", "ctrl+b", "thinking")
|
|
38
|
+
for line in reversed(text.splitlines()):
|
|
39
|
+
stripped = line.strip()
|
|
40
|
+
if not stripped:
|
|
41
|
+
continue
|
|
42
|
+
if any(ind in stripped for ind in busy_indicators):
|
|
43
|
+
return False
|
|
44
|
+
if "❯" in stripped or "Press up to edit" in stripped:
|
|
45
|
+
return True
|
|
46
|
+
break
|
|
47
|
+
return "❯" in text or "Press up to edit" in text
|
|
48
|
+
|
|
49
|
+
|
|
17
50
|
def _nudge_session(db: BoardDB, recipient: str) -> None:
|
|
18
51
|
"""Inject a fixed prompt into the recipient's tmux session to check inbox.
|
|
19
52
|
|
|
20
|
-
Only
|
|
21
|
-
|
|
53
|
+
Only nudges idle agents — busy agents will pick up messages via the
|
|
54
|
+
PostToolBatch hook. This avoids commands piling up in Claude Code's
|
|
55
|
+
message queue when injected mid-response.
|
|
22
56
|
"""
|
|
57
|
+
assert db.env is not None
|
|
23
58
|
if recipient == "all":
|
|
24
59
|
sessions = [r[0] for r in db.query("SELECT name FROM sessions WHERE name != 'all'")]
|
|
25
60
|
else:
|
|
@@ -27,17 +62,25 @@ def _nudge_session(db: BoardDB, recipient: str) -> None:
|
|
|
27
62
|
prefix = db.env.prefix
|
|
28
63
|
board = db.env.install_home / "bin" / "board"
|
|
29
64
|
for name in sessions:
|
|
30
|
-
if not
|
|
65
|
+
if not re.match(r"^[a-z0-9][a-z0-9_-]*$", name):
|
|
31
66
|
continue
|
|
32
67
|
sess = f"{prefix}-{name}"
|
|
33
|
-
|
|
34
|
-
|
|
68
|
+
try:
|
|
69
|
+
r = subprocess.run(["tmux", "has-session", "-t", sess], capture_output=True, timeout=5)
|
|
70
|
+
if r.returncode != 0:
|
|
71
|
+
continue
|
|
72
|
+
if not _is_idle(sess):
|
|
73
|
+
continue
|
|
35
74
|
cmd = f"{board} --as {name} inbox"
|
|
36
|
-
subprocess.run(["tmux", "send-keys", "-t", sess, "-l", cmd], capture_output=True)
|
|
37
|
-
subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], capture_output=True)
|
|
75
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "-l", cmd], capture_output=True, timeout=5)
|
|
76
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], capture_output=True, timeout=5)
|
|
77
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
78
|
+
continue
|
|
38
79
|
|
|
39
80
|
|
|
40
81
|
def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
82
|
+
assert db.env is not None
|
|
83
|
+
validate_identity(db, identity)
|
|
41
84
|
name = identity.lower()
|
|
42
85
|
flags, send_args = parse_flags(args, value_flags={"attach": ["--attach", "-a"]})
|
|
43
86
|
attach_file = flags.get("attach")
|
|
@@ -48,9 +91,8 @@ def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
48
91
|
|
|
49
92
|
to = send_args[0].lower()
|
|
50
93
|
|
|
51
|
-
if to != "all"
|
|
52
|
-
|
|
53
|
-
raise SystemExit(1)
|
|
94
|
+
if to != "all":
|
|
95
|
+
db.ensure_session(to)
|
|
54
96
|
|
|
55
97
|
msg = " ".join(send_args[1:]) if len(send_args) > 1 else ""
|
|
56
98
|
|
|
@@ -62,7 +104,7 @@ def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
62
104
|
stored_path = ""
|
|
63
105
|
h = ""
|
|
64
106
|
if attach_file:
|
|
65
|
-
path = Path(attach_file)
|
|
107
|
+
path = Path(str(attach_file))
|
|
66
108
|
if not path.is_file():
|
|
67
109
|
print(f"ERROR: file not found: {attach_file}")
|
|
68
110
|
raise SystemExit(1)
|
|
@@ -107,6 +149,7 @@ def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
107
149
|
|
|
108
150
|
|
|
109
151
|
def cmd_status(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
152
|
+
validate_identity(db, identity)
|
|
110
153
|
name = identity.lower()
|
|
111
154
|
if not args:
|
|
112
155
|
print("Usage: ./board --as <name> status <description>")
|
|
@@ -118,39 +161,51 @@ def cmd_status(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
118
161
|
"UPDATE sessions SET status=?, updated_at=? WHERE name=?",
|
|
119
162
|
(full_status, now, name),
|
|
120
163
|
)
|
|
121
|
-
db.sync_status_to_file(name, full_status)
|
|
122
164
|
print("OK status updated")
|
|
123
165
|
|
|
124
166
|
|
|
125
167
|
def cmd_inbox(db: BoardDB, identity: str) -> None:
|
|
168
|
+
validate_identity(db, identity)
|
|
126
169
|
name = identity.lower()
|
|
127
|
-
if not db.scalar("SELECT COUNT(*) FROM sessions WHERE name=?", (name,)):
|
|
128
|
-
print(f"ERROR: 会话 '{name}' 未注册")
|
|
129
|
-
raise SystemExit(1)
|
|
130
170
|
count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
|
|
131
171
|
if not count:
|
|
132
172
|
print("收件箱为空")
|
|
133
173
|
else:
|
|
134
|
-
print(f"你有 {count} 条未读:")
|
|
135
174
|
rows = db.query(
|
|
136
|
-
"SELECT i.message_id,
|
|
175
|
+
"SELECT i.message_id, m.ts, m.sender, m.body "
|
|
137
176
|
"FROM inbox i JOIN messages m ON i.message_id=m.id "
|
|
138
177
|
"WHERE i.session=? AND i.read=0 ORDER BY m.ts",
|
|
139
178
|
(name,),
|
|
140
179
|
)
|
|
141
180
|
max_id = 0
|
|
142
|
-
for msg_id,
|
|
143
|
-
print(
|
|
181
|
+
for msg_id, msg_ts, sender, body in rows:
|
|
182
|
+
print(f'<message from="{sender}" ts="{msg_ts}">\n{body}\n</message>')
|
|
144
183
|
if msg_id > max_id:
|
|
145
184
|
max_id = msg_id
|
|
146
185
|
if max_id > 0:
|
|
147
186
|
_ack_marker_path(db, name).write_text(str(max_id))
|
|
148
|
-
print(f"\n(运行 ack 清除: board --as {identity} ack)")
|
|
149
187
|
|
|
150
188
|
_task_print_queue_short(db, name)
|
|
151
189
|
|
|
152
190
|
|
|
191
|
+
def _task_print_queue_short(db: BoardDB, target: str) -> None:
|
|
192
|
+
rows = db.query(
|
|
193
|
+
"SELECT id, status, priority, description FROM tasks "
|
|
194
|
+
"WHERE session=? AND status != 'done' "
|
|
195
|
+
"ORDER BY CASE status WHEN 'active' THEN 0 ELSE 1 END, priority DESC, id ASC",
|
|
196
|
+
(target,),
|
|
197
|
+
)
|
|
198
|
+
print("\n任务队列:")
|
|
199
|
+
if not rows:
|
|
200
|
+
print(" (无待办任务)")
|
|
201
|
+
return
|
|
202
|
+
for tid, status, priority, desc in rows:
|
|
203
|
+
marker = "*" if status == "active" else " "
|
|
204
|
+
print(f" {marker} #{tid} [{status} p{priority}] {desc}")
|
|
205
|
+
|
|
206
|
+
|
|
153
207
|
def cmd_ack(db: BoardDB, identity: str) -> None:
|
|
208
|
+
validate_identity(db, identity)
|
|
154
209
|
name = identity.lower()
|
|
155
210
|
marker = _ack_marker_path(db, name)
|
|
156
211
|
max_id = None
|
|
@@ -170,7 +225,6 @@ def cmd_ack(db: BoardDB, identity: str) -> None:
|
|
|
170
225
|
|
|
171
226
|
if count == 0:
|
|
172
227
|
print("收件箱已经是空的")
|
|
173
|
-
db.clear_inbox_file(name)
|
|
174
228
|
marker.unlink(missing_ok=True)
|
|
175
229
|
return
|
|
176
230
|
|
|
@@ -183,7 +237,6 @@ def cmd_ack(db: BoardDB, identity: str) -> None:
|
|
|
183
237
|
db.execute("UPDATE inbox SET read=1 WHERE session=? AND read=0", (name,))
|
|
184
238
|
|
|
185
239
|
print(f"OK {count} 条已清空(完整记录在 messages.log)")
|
|
186
|
-
db.clear_inbox_file(name)
|
|
187
240
|
marker.unlink(missing_ok=True)
|
|
188
241
|
|
|
189
242
|
|
|
@@ -212,19 +265,3 @@ def cmd_log(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
212
265
|
)
|
|
213
266
|
for (line,) in reversed(rows):
|
|
214
267
|
print(line)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def _task_print_queue_short(db: BoardDB, target: str) -> None:
|
|
218
|
-
rows = db.query(
|
|
219
|
-
"SELECT id, status, priority, description FROM tasks "
|
|
220
|
-
"WHERE session=? AND status != 'done' "
|
|
221
|
-
"ORDER BY CASE status WHEN 'active' THEN 0 ELSE 1 END, priority DESC, id ASC",
|
|
222
|
-
(target,),
|
|
223
|
-
)
|
|
224
|
-
print("\n任务队列:")
|
|
225
|
-
if not rows:
|
|
226
|
-
print(" (无待办任务)")
|
|
227
|
-
return
|
|
228
|
-
for tid, status, priority, desc in rows:
|
|
229
|
-
marker = "*" if status == "active" else " "
|
|
230
|
-
print(f" {marker} #{tid} [{status} p{priority}] {desc}")
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""board_pending — pending actions queue: add / list / verify / retry / resolve."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from lib.board_db import BoardDB, ts
|
|
6
|
+
from lib.common import parse_flags
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
VALID_TYPES = ("auth", "approve", "confirm")
|
|
10
|
+
PENDING_STATUSES = ("pending", "reminded")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cmd_pending(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
14
|
+
subcmd = args[0] if args else "list"
|
|
15
|
+
rest = args[1:] if len(args) > 1 else []
|
|
16
|
+
|
|
17
|
+
if subcmd == "add":
|
|
18
|
+
_pending_add(db, identity, rest)
|
|
19
|
+
elif subcmd == "list":
|
|
20
|
+
_pending_list(db, identity, rest)
|
|
21
|
+
elif subcmd == "verify":
|
|
22
|
+
_pending_verify(db, identity, rest)
|
|
23
|
+
elif subcmd == "retry":
|
|
24
|
+
_pending_retry(db, identity, rest)
|
|
25
|
+
elif subcmd == "resolve":
|
|
26
|
+
_pending_resolve(db, identity, rest)
|
|
27
|
+
else:
|
|
28
|
+
print("Usage: ./board --as <name> pending {add|list|verify|retry|resolve}")
|
|
29
|
+
raise SystemExit(1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _pending_add(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
33
|
+
name = identity.lower()
|
|
34
|
+
flags, positional = parse_flags(
|
|
35
|
+
args,
|
|
36
|
+
value_flags={
|
|
37
|
+
"type": ["--type", "-t"],
|
|
38
|
+
"command": ["--command", "-c"],
|
|
39
|
+
"reason": ["--reason", "-r"],
|
|
40
|
+
"verify": ["--verify", "-v"],
|
|
41
|
+
"retry": ["--retry"],
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
action_type = str(flags.get("type", "")).lower()
|
|
46
|
+
command = str(flags.get("command", ""))
|
|
47
|
+
reason = str(flags.get("reason", ""))
|
|
48
|
+
verify_cmd = str(flags.get("verify", "")) or None
|
|
49
|
+
retry_cmd = str(flags.get("retry", "")) or None
|
|
50
|
+
|
|
51
|
+
if not action_type or not command or not reason:
|
|
52
|
+
print("Usage: ./board --as <name> pending add --type <auth|approve|confirm> --command <cmd> --reason <why> [--verify <cmd>] [--retry <cmd>]")
|
|
53
|
+
raise SystemExit(1)
|
|
54
|
+
|
|
55
|
+
if action_type not in VALID_TYPES:
|
|
56
|
+
print(f"ERROR: 类型必须是 {', '.join(VALID_TYPES)} 之一")
|
|
57
|
+
raise SystemExit(1)
|
|
58
|
+
|
|
59
|
+
now = ts()
|
|
60
|
+
action_id = db.execute(
|
|
61
|
+
"INSERT INTO pending_actions(type, command, reason, verify_command, retry_command, created_by, created_at) "
|
|
62
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
63
|
+
(action_type, command, reason, verify_cmd, retry_cmd, name, now),
|
|
64
|
+
)
|
|
65
|
+
print(f"OK pending #{action_id} added ({action_type})")
|
|
66
|
+
print(f" 用户需执行: {command}")
|
|
67
|
+
print(f" 原因: {reason}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _pending_list(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
71
|
+
flags, _ = parse_flags(args, bool_flags={"all": ["--all", "-a"]})
|
|
72
|
+
show_all = bool(flags.get("all"))
|
|
73
|
+
|
|
74
|
+
if show_all:
|
|
75
|
+
rows = db.query(
|
|
76
|
+
"SELECT id, type, command, reason, verify_command, retry_command, status, created_by, created_at, resolved_at "
|
|
77
|
+
"FROM pending_actions ORDER BY id"
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
rows = db.query(
|
|
81
|
+
"SELECT id, type, command, reason, verify_command, retry_command, status, created_by, created_at, resolved_at "
|
|
82
|
+
"FROM pending_actions WHERE status IN ('pending', 'reminded') ORDER BY id"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if not rows:
|
|
86
|
+
print("无待处理操作" if not show_all else "无操作记录")
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
print("=== 待处理操作 ===" if not show_all else "=== 所有操作 ===")
|
|
90
|
+
print()
|
|
91
|
+
for row in rows:
|
|
92
|
+
aid, atype, cmd, reason, verify, retry, status, creator, created, resolved = row
|
|
93
|
+
status_icon = {"pending": "⏳", "reminded": "🔔", "done": "✓", "retried": "✓✓", "failed": "✗"}.get(status, "?")
|
|
94
|
+
print(f" #{aid} [{status_icon} {status}] ({atype}) by {creator}")
|
|
95
|
+
print(f" 用户需执行: ! {cmd}")
|
|
96
|
+
print(f" 原因: {reason}")
|
|
97
|
+
if verify:
|
|
98
|
+
print(f" 验证命令: {verify}")
|
|
99
|
+
if retry:
|
|
100
|
+
print(f" 重试命令: {retry}")
|
|
101
|
+
if resolved:
|
|
102
|
+
print(f" 完成于: {resolved}")
|
|
103
|
+
print()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _pending_verify(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
107
|
+
specific_id = None
|
|
108
|
+
if args:
|
|
109
|
+
try:
|
|
110
|
+
specific_id = int(args[0].lstrip("#"))
|
|
111
|
+
except ValueError:
|
|
112
|
+
print("Usage: ./board --as <name> pending verify [#id]")
|
|
113
|
+
raise SystemExit(1)
|
|
114
|
+
|
|
115
|
+
if specific_id:
|
|
116
|
+
rows = db.query(
|
|
117
|
+
"SELECT id, verify_command, command FROM pending_actions WHERE id=? AND status IN ('pending', 'reminded')",
|
|
118
|
+
(specific_id,),
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
rows = db.query(
|
|
122
|
+
"SELECT id, verify_command, command FROM pending_actions WHERE status IN ('pending', 'reminded') AND verify_command IS NOT NULL ORDER BY id"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if not rows:
|
|
126
|
+
print("无可验证的操作")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
verified = 0
|
|
130
|
+
failed = 0
|
|
131
|
+
for aid, verify_cmd, cmd in rows:
|
|
132
|
+
if not verify_cmd:
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
r = subprocess.run(verify_cmd, shell=True, capture_output=True, text=True, timeout=30)
|
|
136
|
+
if r.returncode == 0:
|
|
137
|
+
now = ts()
|
|
138
|
+
db.execute(
|
|
139
|
+
"UPDATE pending_actions SET status='done', resolved_at=? WHERE id=?",
|
|
140
|
+
(now, aid),
|
|
141
|
+
)
|
|
142
|
+
print(f" #{aid}: 验证通过 ✓")
|
|
143
|
+
verified += 1
|
|
144
|
+
else:
|
|
145
|
+
db.execute("UPDATE pending_actions SET status='reminded' WHERE id=?", (aid,))
|
|
146
|
+
print(f" #{aid}: 验证失败 — 用户仍需执行: ! {cmd}")
|
|
147
|
+
failed += 1
|
|
148
|
+
except subprocess.TimeoutExpired:
|
|
149
|
+
print(f" #{aid}: 验证超时")
|
|
150
|
+
failed += 1
|
|
151
|
+
except OSError as e:
|
|
152
|
+
print(f" #{aid}: 验证出错: {e}")
|
|
153
|
+
failed += 1
|
|
154
|
+
|
|
155
|
+
print(f"\n验证结果: {verified} 通过, {failed} 未通过")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _pending_retry(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
159
|
+
specific_id = None
|
|
160
|
+
if args:
|
|
161
|
+
try:
|
|
162
|
+
specific_id = int(args[0].lstrip("#"))
|
|
163
|
+
except ValueError:
|
|
164
|
+
print("Usage: ./board --as <name> pending retry [#id]")
|
|
165
|
+
raise SystemExit(1)
|
|
166
|
+
|
|
167
|
+
if specific_id:
|
|
168
|
+
rows = db.query(
|
|
169
|
+
"SELECT id, retry_command FROM pending_actions WHERE id=? AND status='done'",
|
|
170
|
+
(specific_id,),
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
rows = db.query(
|
|
174
|
+
"SELECT id, retry_command FROM pending_actions WHERE status='done' AND retry_command IS NOT NULL ORDER BY id"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if not rows:
|
|
178
|
+
print("无可重试的操作(需先通过验证)")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
retried = 0
|
|
182
|
+
failed = 0
|
|
183
|
+
for aid, retry_cmd in rows:
|
|
184
|
+
if not retry_cmd:
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
r = subprocess.run(retry_cmd, shell=True, capture_output=True, text=True, timeout=60)
|
|
188
|
+
if r.returncode == 0:
|
|
189
|
+
db.execute("UPDATE pending_actions SET status='retried' WHERE id=?", (aid,))
|
|
190
|
+
print(f" #{aid}: 重试成功 ✓")
|
|
191
|
+
retried += 1
|
|
192
|
+
else:
|
|
193
|
+
db.execute("UPDATE pending_actions SET status='failed' WHERE id=?", (aid,))
|
|
194
|
+
print(f" #{aid}: 重试失败 — {r.stderr.strip() or r.stdout.strip() or 'exit ' + str(r.returncode)}")
|
|
195
|
+
failed += 1
|
|
196
|
+
except subprocess.TimeoutExpired:
|
|
197
|
+
db.execute("UPDATE pending_actions SET status='failed' WHERE id=?", (aid,))
|
|
198
|
+
print(f" #{aid}: 重试超时")
|
|
199
|
+
failed += 1
|
|
200
|
+
except OSError as e:
|
|
201
|
+
db.execute("UPDATE pending_actions SET status='failed' WHERE id=?", (aid,))
|
|
202
|
+
print(f" #{aid}: 重试出错: {e}")
|
|
203
|
+
failed += 1
|
|
204
|
+
|
|
205
|
+
print(f"\n重试结果: {retried} 成功, {failed} 失败")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _pending_resolve(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
209
|
+
if not args:
|
|
210
|
+
print("Usage: ./board --as <name> pending resolve <#id>")
|
|
211
|
+
raise SystemExit(1)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
action_id = int(args[0].lstrip("#"))
|
|
215
|
+
except ValueError:
|
|
216
|
+
print("Usage: ./board --as <name> pending resolve <#id>")
|
|
217
|
+
raise SystemExit(1)
|
|
218
|
+
|
|
219
|
+
row = db.query_one("SELECT status FROM pending_actions WHERE id=?", (action_id,))
|
|
220
|
+
if not row:
|
|
221
|
+
print(f"ERROR: pending #{action_id} 不存在")
|
|
222
|
+
raise SystemExit(1)
|
|
223
|
+
|
|
224
|
+
if row[0] not in PENDING_STATUSES:
|
|
225
|
+
print(f"pending #{action_id} 已是 {row[0]} 状态")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
now = ts()
|
|
229
|
+
db.execute(
|
|
230
|
+
"UPDATE pending_actions SET status='done', resolved_at=? WHERE id=?",
|
|
231
|
+
(now, action_id),
|
|
232
|
+
)
|
|
233
|
+
print(f"OK pending #{action_id} 已手动标记为完成")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""board_pulse — lightweight heartbeat + unread count for PostToolBatch hook."""
|
|
2
|
+
|
|
3
|
+
from lib.board_db import BoardDB, ts
|
|
4
|
+
from lib.common import validate_identity
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def cmd_pulse(db: BoardDB, identity: str) -> None:
|
|
8
|
+
name = identity.lower()
|
|
9
|
+
validate_identity(db, identity)
|
|
10
|
+
now = ts()
|
|
11
|
+
db.execute("UPDATE sessions SET last_heartbeat=? WHERE name=?", (now, name))
|
|
12
|
+
count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
|
|
13
|
+
if count:
|
|
14
|
+
print(f"{count} 条未读")
|
package/lib/board_task.py
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
"""board_task — task queue: add / done / list / next."""
|
|
2
2
|
|
|
3
3
|
from lib.board_db import BoardDB, ts
|
|
4
|
-
from lib.common import is_privileged, is_terminal_task_status, parse_flags
|
|
4
|
+
from lib.common import is_privileged, is_terminal_task_status, parse_flags, validate_identity
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def _promote_next(db: BoardDB, target: str) -> None:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
with db.conn() as c:
|
|
9
|
+
active = db.scalar("SELECT COUNT(*) FROM tasks WHERE session=? AND status='active'", (target,), c=c)
|
|
10
|
+
if active:
|
|
11
|
+
return
|
|
12
|
+
next_id = db.scalar(
|
|
13
|
+
"SELECT id FROM tasks WHERE session=? AND status='pending' ORDER BY priority DESC, id ASC LIMIT 1",
|
|
14
|
+
(target,),
|
|
15
|
+
c=c,
|
|
16
|
+
)
|
|
17
|
+
if next_id:
|
|
18
|
+
db.execute("UPDATE tasks SET status='active' WHERE id=?", (next_id,), c=c)
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
def _print_queue(db: BoardDB, target: str, include_done: bool = False) -> None:
|
|
@@ -46,6 +48,7 @@ def _print_queue(db: BoardDB, target: str, include_done: bool = False) -> None:
|
|
|
46
48
|
|
|
47
49
|
|
|
48
50
|
def cmd_task(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
51
|
+
validate_identity(db, identity)
|
|
49
52
|
subcmd = args[0] if args else "list"
|
|
50
53
|
rest = args[1:] if len(args) > 1 else []
|
|
51
54
|
|
|
@@ -77,46 +80,52 @@ def _task_add(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
77
80
|
|
|
78
81
|
desc = " ".join(desc_parts)
|
|
79
82
|
|
|
80
|
-
db.
|
|
83
|
+
with db.conn() as c:
|
|
84
|
+
db.ensure_session(target, c=c)
|
|
81
85
|
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
active = db.scalar("SELECT COUNT(*) FROM tasks WHERE session=? AND status='active'", (target,), c=c)
|
|
87
|
+
status = "active" if not active else "pending"
|
|
84
88
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
print(f"OK task #{task_id} added to {target} ({status})")
|
|
90
|
-
|
|
91
|
-
if target != name:
|
|
92
|
-
now = ts()
|
|
93
|
-
msg_id = db.execute(
|
|
94
|
-
"INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, ?, ?)",
|
|
95
|
-
(now, name, target, f"[TASK #{task_id}] {desc}"),
|
|
89
|
+
task_id = db.execute(
|
|
90
|
+
"INSERT INTO tasks(session, description, status, priority) VALUES (?, ?, ?, ?)",
|
|
91
|
+
(target, desc, status, priority),
|
|
92
|
+
c=c,
|
|
96
93
|
)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
print(f"OK task #{task_id} added to {target} ({status})")
|
|
95
|
+
|
|
96
|
+
if target != name:
|
|
97
|
+
now = ts()
|
|
98
|
+
msg_id = db.execute(
|
|
99
|
+
"INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, ?, ?)",
|
|
100
|
+
(now, name, target, f"[TASK #{task_id}] {desc}"),
|
|
101
|
+
c=c,
|
|
102
|
+
)
|
|
103
|
+
db.execute("INSERT INTO inbox(session, message_id) VALUES (?, ?)", (target, msg_id), c=c)
|
|
104
|
+
print(f"OK notified {target}")
|
|
100
105
|
_print_queue(db, target)
|
|
101
106
|
|
|
102
107
|
|
|
103
108
|
def _task_done(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
104
109
|
name = identity.lower()
|
|
105
110
|
|
|
106
|
-
|
|
111
|
+
raw_id: str | int | None = args[0] if args else None
|
|
107
112
|
|
|
108
|
-
if not
|
|
113
|
+
if not raw_id:
|
|
109
114
|
_promote_next(db, name)
|
|
110
|
-
|
|
115
|
+
raw_id = db.scalar(
|
|
111
116
|
"SELECT id FROM tasks WHERE session=? AND status='active' ORDER BY id ASC LIMIT 1",
|
|
112
117
|
(name,),
|
|
113
118
|
)
|
|
114
|
-
if not
|
|
119
|
+
if not raw_id:
|
|
115
120
|
print(f"No active task for {name}.")
|
|
116
121
|
_print_queue(db, name)
|
|
117
122
|
return
|
|
118
123
|
|
|
119
|
-
|
|
124
|
+
try:
|
|
125
|
+
task_id = int(raw_id)
|
|
126
|
+
except (ValueError, TypeError):
|
|
127
|
+
print(f"ERROR: 无效的任务 ID: {raw_id}")
|
|
128
|
+
raise SystemExit(1)
|
|
120
129
|
row = db.query_one("SELECT session, status, description FROM tasks WHERE id=?", (task_id,))
|
|
121
130
|
if not row:
|
|
122
131
|
print(f"ERROR: task #{task_id} not found")
|