claude-nb 0.3.0 → 0.4.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/README.md +70 -33
- package/VERSION +1 -1
- package/bin/board +10 -0
- package/bin/cnb +109 -48
- package/bin/init +5 -39
- package/bin/swarm +41 -860
- package/lib/board_db.py +15 -137
- package/lib/board_lock.py +0 -1
- package/lib/board_mailbox.py +15 -7
- package/lib/board_maintenance.py +26 -27
- package/lib/board_msg.py +18 -69
- package/lib/board_task.py +35 -38
- package/lib/board_tui.py +112 -0
- package/lib/board_view.py +12 -24
- package/lib/concerns/__init__.py +4 -11
- package/lib/concerns/{coral_manager.py → coral.py} +53 -3
- package/lib/concerns/file_watcher.py +1 -1
- package/lib/concerns/health.py +136 -0
- package/lib/concerns/helpers.py +1 -5
- package/lib/concerns/idle.py +130 -0
- package/lib/concerns/notifications.py +90 -0
- package/lib/migrate.py +1 -0
- package/lib/monitor.py +1 -18
- package/lib/swarm.py +456 -0
- package/lib/swarm_backend.py +266 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/registry/pubkeys.json +2 -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_db.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""board_db — DB connection
|
|
1
|
+
"""board_db — DB connection and helpers."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -25,7 +25,7 @@ class BoardDB(BaseDB):
|
|
|
25
25
|
"""Unified SQLite wrapper — new connection per call, no pooling.
|
|
26
26
|
|
|
27
27
|
Accepts either a ClaudesEnv (production) or a bare Path/str (tests, lightweight callers).
|
|
28
|
-
|
|
28
|
+
SQLite is the single source of truth; no .md file sync.
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
31
|
def __init__(self, env_or_path: ClaudesEnv | Path | str):
|
|
@@ -111,145 +111,24 @@ class BoardDB(BaseDB):
|
|
|
111
111
|
if existing == 0:
|
|
112
112
|
self.execute("INSERT INTO sessions(name) VALUES (?)", (n,), c=c)
|
|
113
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
114
|
def deliver_to_inbox(
|
|
241
115
|
self, sender: str, recipient: str, msg_id: int, *, c: sqlite3.Connection | None = None
|
|
242
116
|
) -> None:
|
|
243
117
|
if recipient == "all":
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
"INSERT INTO inbox(session, message_id)
|
|
248
|
-
(
|
|
249
|
-
c=c,
|
|
118
|
+
|
|
119
|
+
def _do(conn: sqlite3.Connection) -> list[str]:
|
|
120
|
+
conn.execute(
|
|
121
|
+
"INSERT INTO inbox(session, message_id) SELECT name, ? FROM sessions WHERE name != ?",
|
|
122
|
+
(msg_id, sender),
|
|
250
123
|
)
|
|
251
|
-
|
|
252
|
-
|
|
124
|
+
return [r[0] for r in conn.execute("SELECT name FROM sessions WHERE name != ?", (sender,)).fetchall()]
|
|
125
|
+
|
|
126
|
+
if c is not None:
|
|
127
|
+
targets = _do(c)
|
|
128
|
+
else:
|
|
129
|
+
with self.conn() as conn:
|
|
130
|
+
targets = _do(conn)
|
|
131
|
+
for target in targets:
|
|
253
132
|
inbox_delivered.emit(target)
|
|
254
133
|
else:
|
|
255
134
|
self.ensure_session(recipient, c=c)
|
|
@@ -258,5 +137,4 @@ class BoardDB(BaseDB):
|
|
|
258
137
|
(recipient, msg_id),
|
|
259
138
|
c=c,
|
|
260
139
|
)
|
|
261
|
-
self.sync_inbox_to_file(recipient, c=c)
|
|
262
140
|
inbox_delivered.emit(recipient)
|
package/lib/board_lock.py
CHANGED
|
@@ -72,7 +72,6 @@ def cmd_git_unlock(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
72
72
|
(now, holder, f"[GIT-LOCK] {name} force-released your git lock"),
|
|
73
73
|
)
|
|
74
74
|
db.execute("INSERT INTO inbox(session, message_id) VALUES (?, ?)", (holder, msg_id))
|
|
75
|
-
db.sync_inbox_to_file(holder)
|
|
76
75
|
|
|
77
76
|
db.execute("DELETE FROM git_locks WHERE id=1")
|
|
78
77
|
print("OK git-lock released")
|
package/lib/board_mailbox.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""board_mailbox — encrypted async messaging between registered agents."""
|
|
2
2
|
|
|
3
|
+
import base64
|
|
3
4
|
import json
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
7
|
+
from cryptography.exceptions import InvalidTag
|
|
8
|
+
|
|
6
9
|
from lib.board_db import BoardDB, ts
|
|
7
10
|
from lib.crypto import (
|
|
8
11
|
generate_keypair,
|
|
@@ -66,7 +69,11 @@ def cmd_seal(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
66
69
|
raise SystemExit(1)
|
|
67
70
|
|
|
68
71
|
recipient = args[0].lower()
|
|
69
|
-
plaintext = " ".join(args[1:])
|
|
72
|
+
plaintext = " ".join(args[1:]).strip()
|
|
73
|
+
|
|
74
|
+
if not plaintext:
|
|
75
|
+
print("ERROR: 消息不能为空")
|
|
76
|
+
raise SystemExit(1)
|
|
70
77
|
|
|
71
78
|
recipient_pubkey_hex = _find_pubkey(recipient)
|
|
72
79
|
if not recipient_pubkey_hex:
|
|
@@ -110,12 +117,13 @@ def cmd_unseal(db: BoardDB, identity: str) -> None:
|
|
|
110
117
|
plaintext = unseal_b64(encrypted_body, private)
|
|
111
118
|
print(f" [{msg_ts}] **{sender}**: {plaintext}")
|
|
112
119
|
decrypted_ids.append(msg_id)
|
|
113
|
-
except
|
|
114
|
-
print(f" [{msg_ts}] **{sender}**: [解密失败 —
|
|
120
|
+
except (InvalidTag, ValueError, base64.binascii.Error, UnicodeDecodeError) as e:
|
|
121
|
+
print(f" [{msg_ts}] **{sender}**: [解密失败 — {type(e).__name__}: {e}]")
|
|
115
122
|
|
|
116
123
|
if decrypted_ids:
|
|
117
|
-
|
|
118
|
-
|
|
124
|
+
with db.conn() as c:
|
|
125
|
+
for mid in decrypted_ids:
|
|
126
|
+
db.execute("UPDATE mailbox SET read=1 WHERE id=?", (mid,), c=c)
|
|
119
127
|
print(f"\n已标记 {len(decrypted_ids)} 条为已读")
|
|
120
128
|
|
|
121
129
|
|
|
@@ -141,5 +149,5 @@ def cmd_mailbox_log(db: BoardDB, identity: str) -> None:
|
|
|
141
149
|
try:
|
|
142
150
|
plaintext = unseal_b64(encrypted_body, private)
|
|
143
151
|
print(f" [{msg_ts}] {sender}: {plaintext}")
|
|
144
|
-
except
|
|
145
|
-
print(f" [{msg_ts}] {sender}: [无法解密]")
|
|
152
|
+
except (InvalidTag, ValueError, base64.binascii.Error, UnicodeDecodeError) as e:
|
|
153
|
+
print(f" [{msg_ts}] {sender}: [无法解密 — {type(e).__name__}: {e}]")
|
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
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import shutil
|
|
5
|
-
import subprocess
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
|
|
8
7
|
from lib.board_db import BoardDB, ts
|
|
@@ -14,29 +13,6 @@ def _ack_marker_path(db: BoardDB, name: str) -> Path:
|
|
|
14
13
|
return db.env.sessions_dir / f".{name}.ack_max_id"
|
|
15
14
|
|
|
16
15
|
|
|
17
|
-
def _nudge_session(db: BoardDB, recipient: str) -> None:
|
|
18
|
-
"""Inject a fixed prompt into the recipient's tmux session to check inbox.
|
|
19
|
-
|
|
20
|
-
Only sends a hardcoded command — never user-controlled content — to avoid
|
|
21
|
-
shell metacharacter injection via tmux send-keys.
|
|
22
|
-
"""
|
|
23
|
-
if recipient == "all":
|
|
24
|
-
sessions = [r[0] for r in db.query("SELECT name FROM sessions WHERE name != 'all'")]
|
|
25
|
-
else:
|
|
26
|
-
sessions = [recipient]
|
|
27
|
-
prefix = db.env.prefix
|
|
28
|
-
board = db.env.install_home / "bin" / "board"
|
|
29
|
-
for name in sessions:
|
|
30
|
-
if not name.isalnum():
|
|
31
|
-
continue
|
|
32
|
-
sess = f"{prefix}-{name}"
|
|
33
|
-
r = subprocess.run(["tmux", "has-session", "-t", sess], capture_output=True)
|
|
34
|
-
if r.returncode == 0:
|
|
35
|
-
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)
|
|
38
|
-
|
|
39
|
-
|
|
40
16
|
def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
41
17
|
name = identity.lower()
|
|
42
18
|
flags, send_args = parse_flags(args, value_flags={"attach": ["--attach", "-a"]})
|
|
@@ -100,10 +76,6 @@ def cmd_send(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
100
76
|
db.deliver_to_inbox(name, to, msg_id, c=c)
|
|
101
77
|
|
|
102
78
|
print("OK sent")
|
|
103
|
-
if attach_ref:
|
|
104
|
-
print(f" 附件已存储: {stored_path}")
|
|
105
|
-
|
|
106
|
-
_nudge_session(db, to)
|
|
107
79
|
|
|
108
80
|
|
|
109
81
|
def cmd_status(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
@@ -118,8 +90,7 @@ def cmd_status(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
118
90
|
"UPDATE sessions SET status=?, updated_at=? WHERE name=?",
|
|
119
91
|
(full_status, now, name),
|
|
120
92
|
)
|
|
121
|
-
|
|
122
|
-
print("OK status updated")
|
|
93
|
+
print("OK")
|
|
123
94
|
|
|
124
95
|
|
|
125
96
|
def cmd_inbox(db: BoardDB, identity: str) -> None:
|
|
@@ -129,25 +100,21 @@ def cmd_inbox(db: BoardDB, identity: str) -> None:
|
|
|
129
100
|
raise SystemExit(1)
|
|
130
101
|
count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
|
|
131
102
|
if not count:
|
|
132
|
-
print("
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
_ack_marker_path(db, name).write_text(str(max_id))
|
|
148
|
-
print(f"\n(运行 ack 清除: board --as {identity} ack)")
|
|
149
|
-
|
|
150
|
-
_task_print_queue_short(db, name)
|
|
103
|
+
print("(empty)")
|
|
104
|
+
return
|
|
105
|
+
rows = db.query(
|
|
106
|
+
"SELECT i.message_id, m.ts, m.sender, m.body "
|
|
107
|
+
"FROM inbox i JOIN messages m ON i.message_id=m.id "
|
|
108
|
+
"WHERE i.session=? AND i.read=0 ORDER BY m.ts",
|
|
109
|
+
(name,),
|
|
110
|
+
)
|
|
111
|
+
max_id = 0
|
|
112
|
+
for msg_id, msg_ts, sender, body in rows:
|
|
113
|
+
print(f'<message from="{sender}" ts="{msg_ts}">\n{body}\n</message>')
|
|
114
|
+
if msg_id > max_id:
|
|
115
|
+
max_id = msg_id
|
|
116
|
+
if max_id > 0:
|
|
117
|
+
_ack_marker_path(db, name).write_text(str(max_id))
|
|
151
118
|
|
|
152
119
|
|
|
153
120
|
def cmd_ack(db: BoardDB, identity: str) -> None:
|
|
@@ -169,8 +136,7 @@ def cmd_ack(db: BoardDB, identity: str) -> None:
|
|
|
169
136
|
count = db.scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,))
|
|
170
137
|
|
|
171
138
|
if count == 0:
|
|
172
|
-
print("
|
|
173
|
-
db.clear_inbox_file(name)
|
|
139
|
+
print("OK")
|
|
174
140
|
marker.unlink(missing_ok=True)
|
|
175
141
|
return
|
|
176
142
|
|
|
@@ -182,8 +148,7 @@ def cmd_ack(db: BoardDB, identity: str) -> None:
|
|
|
182
148
|
else:
|
|
183
149
|
db.execute("UPDATE inbox SET read=1 WHERE session=? AND read=0", (name,))
|
|
184
150
|
|
|
185
|
-
print(f"OK {count}
|
|
186
|
-
db.clear_inbox_file(name)
|
|
151
|
+
print(f"OK {count}")
|
|
187
152
|
marker.unlink(missing_ok=True)
|
|
188
153
|
|
|
189
154
|
|
|
@@ -212,19 +177,3 @@ def cmd_log(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
212
177
|
)
|
|
213
178
|
for (line,) in reversed(rows):
|
|
214
179
|
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}")
|
package/lib/board_task.py
CHANGED
|
@@ -5,15 +5,17 @@ from lib.common import is_privileged, is_terminal_task_status, parse_flags
|
|
|
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:
|
|
@@ -77,27 +79,27 @@ def _task_add(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
77
79
|
|
|
78
80
|
desc = " ".join(desc_parts)
|
|
79
81
|
|
|
80
|
-
db.
|
|
82
|
+
with db.conn() as c:
|
|
83
|
+
db.ensure_session(target, c=c)
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
active = db.scalar("SELECT COUNT(*) FROM tasks WHERE session=? AND status='active'", (target,), c=c)
|
|
86
|
+
status = "active" if not active else "pending"
|
|
84
87
|
|
|
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}"),
|
|
88
|
+
task_id = db.execute(
|
|
89
|
+
"INSERT INTO tasks(session, description, status, priority) VALUES (?, ?, ?, ?)",
|
|
90
|
+
(target, desc, status, priority),
|
|
91
|
+
c=c,
|
|
96
92
|
)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
93
|
+
print(f"OK #{task_id}")
|
|
94
|
+
|
|
95
|
+
if target != name:
|
|
96
|
+
now = ts()
|
|
97
|
+
msg_id = db.execute(
|
|
98
|
+
"INSERT INTO messages(ts, sender, recipient, body) VALUES (?, ?, ?, ?)",
|
|
99
|
+
(now, name, target, f"[TASK #{task_id}] {desc}"),
|
|
100
|
+
c=c,
|
|
101
|
+
)
|
|
102
|
+
db.execute("INSERT INTO inbox(session, message_id) VALUES (?, ?)", (target, msg_id), c=c)
|
|
101
103
|
|
|
102
104
|
|
|
103
105
|
def _task_done(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
@@ -116,7 +118,11 @@ def _task_done(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
116
118
|
_print_queue(db, name)
|
|
117
119
|
return
|
|
118
120
|
|
|
119
|
-
|
|
121
|
+
try:
|
|
122
|
+
task_id = int(task_id)
|
|
123
|
+
except ValueError:
|
|
124
|
+
print(f"ERROR: 无效的任务 ID: {task_id}")
|
|
125
|
+
raise SystemExit(1)
|
|
120
126
|
row = db.query_one("SELECT session, status, description FROM tasks WHERE id=?", (task_id,))
|
|
121
127
|
if not row:
|
|
122
128
|
print(f"ERROR: task #{task_id} not found")
|
|
@@ -134,18 +140,9 @@ def _task_done(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
|
134
140
|
|
|
135
141
|
now = ts()
|
|
136
142
|
db.execute("UPDATE tasks SET status='done', done_at=? WHERE id=?", (now, task_id))
|
|
137
|
-
print(f"OK
|
|
143
|
+
print(f"OK #{task_id} done")
|
|
138
144
|
|
|
139
145
|
_promote_next(db, assignee)
|
|
140
|
-
nxt = db.query_one(
|
|
141
|
-
"SELECT id, description FROM tasks WHERE session=? AND status='active' ORDER BY id ASC LIMIT 1",
|
|
142
|
-
(assignee,),
|
|
143
|
-
)
|
|
144
|
-
if nxt:
|
|
145
|
-
print(f"Next: #{nxt[0]} {nxt[1]}")
|
|
146
|
-
else:
|
|
147
|
-
print(f"No remaining active/pending tasks for {assignee}.")
|
|
148
|
-
_print_queue(db, assignee)
|
|
149
146
|
|
|
150
147
|
|
|
151
148
|
def _task_list(db: BoardDB, identity: str, args: list[str]) -> None:
|