dsh-project-deck 0.0.0-stage → 0.1.2
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 +21 -0
- package/README.md +111 -2
- package/backend/__pycache__/panel.cpython-314.pyc +0 -0
- package/backend/deck +1765 -0
- package/backend/deck-node +188 -0
- package/backend/deck-node.js +152 -0
- package/backend/deck-node.ps1 +106 -0
- package/backend/panel.html +855 -0
- package/backend/panel.py +953 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +366 -0
- package/lib/index.js +98 -0
- package/package.json +56 -4
package/backend/deck
ADDED
|
@@ -0,0 +1,1765 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""deck —— 项目甲板 的项目注册表与微信周期播报。
|
|
3
|
+
|
|
4
|
+
用法:
|
|
5
|
+
deck list 列出项目
|
|
6
|
+
deck preview 生成播报文本(不发送)
|
|
7
|
+
deck report 立即生成并推送到微信
|
|
8
|
+
deck every <小时> 设置播报间隔(可小数,如 0.5 / 4)
|
|
9
|
+
deck on | off 打开 / 关闭周期播报
|
|
10
|
+
deck status 服务与配置状态
|
|
11
|
+
deck add <id> --root <path> [--name 名称] [--kind code] [--note 说明]
|
|
12
|
+
deck rm <id> 移除项目
|
|
13
|
+
deck scan [--add] 扫描发现新项目;--add 按阈值自动登记
|
|
14
|
+
deck image [--no-send|--on|--off] 生成看板图并推送;--on/--off 开关周期附图
|
|
15
|
+
deck daemon (由 launchd 调用)周期播报守护进程
|
|
16
|
+
"""
|
|
17
|
+
import base64
|
|
18
|
+
import fcntl
|
|
19
|
+
import glob
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import secrets
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import urllib.parse
|
|
29
|
+
import urllib.request
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
|
|
32
|
+
HOME = os.path.expanduser('~')
|
|
33
|
+
BASE = os.path.expanduser(os.environ.get('DECK_HOME', '~/.project-deck'))
|
|
34
|
+
CONFIG = os.path.join(BASE, 'config.json')
|
|
35
|
+
REGISTRY = os.path.join(BASE, 'registry.json')
|
|
36
|
+
STATE = os.path.join(BASE, 'report-state.json')
|
|
37
|
+
LAST = os.path.join(BASE, 'last-report.txt')
|
|
38
|
+
HISTORY = os.path.join(BASE, 'reports.jsonl')
|
|
39
|
+
CHANNEL_STATE = os.path.join(BASE, 'channel-state.json')
|
|
40
|
+
LOG = os.path.join(BASE, 'logs', 'daemon.log')
|
|
41
|
+
WXSEND = os.environ.get('DECK_WXSEND', os.path.join(HOME, 'tools', 'wxsend', 'wxsend'))
|
|
42
|
+
LOCK = os.path.join(BASE, 'daemon.lock')
|
|
43
|
+
|
|
44
|
+
KINDS = {'code': '代码', 'content': '内容', 'ops': '运维', 'life': '生活', 'research': '研究'}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── 基础读写 ────────────────────────────────────────────────
|
|
48
|
+
def load_json(path, default):
|
|
49
|
+
try:
|
|
50
|
+
with open(path) as f:
|
|
51
|
+
return json.load(f)
|
|
52
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
53
|
+
return default
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def save_json(path, data):
|
|
57
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
58
|
+
tmp = path + '.tmp'
|
|
59
|
+
with open(tmp, 'w') as f:
|
|
60
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
61
|
+
f.write('\n')
|
|
62
|
+
os.replace(tmp, path)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def log(msg):
|
|
66
|
+
os.makedirs(os.path.dirname(LOG), exist_ok=True)
|
|
67
|
+
with open(LOG, 'a') as f:
|
|
68
|
+
f.write(f'[{datetime.now():%Y-%m-%d %H:%M:%S}] {msg}\n')
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── 项目数据采集 ────────────────────────────────────────────
|
|
72
|
+
def state_file(p):
|
|
73
|
+
mem = p.get('memory')
|
|
74
|
+
if not mem:
|
|
75
|
+
return None
|
|
76
|
+
path = os.path.join(HOME, '.ai', 'projects', mem.get('project', p['id']),
|
|
77
|
+
'memory', 'designs', mem.get('slug', p['id']), 'STATE.md')
|
|
78
|
+
return path if os.path.exists(path) else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
NEXT_RE = re.compile(r'next[_ ]?action\s*[::]\s*\*{0,2}\s*(.+)', re.I)
|
|
82
|
+
STATUS_RE = re.compile(r'^\s*[-*]?\s*(?:status|状态)\s*[::]\s*(.+)', re.I | re.M)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_state(path):
|
|
86
|
+
try:
|
|
87
|
+
text = open(path, encoding='utf-8', errors='replace').read(200000)
|
|
88
|
+
except OSError:
|
|
89
|
+
return None, None
|
|
90
|
+
m = NEXT_RE.search(text)
|
|
91
|
+
nxt = m.group(1).strip().strip('*').strip()[:120] if m else None
|
|
92
|
+
s = STATUS_RE.search(text)
|
|
93
|
+
st = s.group(1).strip()[:60] if s else None
|
|
94
|
+
return nxt, st
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def board_stats(root):
|
|
98
|
+
bdir = os.path.join(root, '.dsh-graph')
|
|
99
|
+
if not os.path.isdir(bdir):
|
|
100
|
+
return None
|
|
101
|
+
goals_dir = os.path.join(bdir, 'goals')
|
|
102
|
+
counts = {}
|
|
103
|
+
latest = 0
|
|
104
|
+
if os.path.isdir(goals_dir):
|
|
105
|
+
for name in os.listdir(goals_dir):
|
|
106
|
+
gpath = os.path.join(goals_dir, name, 'goal.md')
|
|
107
|
+
if not os.path.isfile(gpath):
|
|
108
|
+
continue
|
|
109
|
+
latest = max(latest, os.path.getmtime(gpath))
|
|
110
|
+
try:
|
|
111
|
+
head = open(gpath, encoding='utf-8', errors='replace').read(1200)
|
|
112
|
+
except OSError:
|
|
113
|
+
continue
|
|
114
|
+
m = re.search(r'^status:\s*(\S+)', head, re.M)
|
|
115
|
+
st = m.group(1).strip('"\'') if m else 'unknown'
|
|
116
|
+
counts[st] = counts.get(st, 0) + 1
|
|
117
|
+
return {'counts': counts, 'total': sum(counts.values()), 'latest': latest}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def git_info(root):
|
|
121
|
+
try:
|
|
122
|
+
r = subprocess.run(['git', '-C', root, 'log', '-1', '--format=%ct%x1f%h%x1f%s'],
|
|
123
|
+
capture_output=True, text=True, timeout=5)
|
|
124
|
+
if r.returncode != 0 or not r.stdout.strip():
|
|
125
|
+
return None
|
|
126
|
+
ts, sha, subj = r.stdout.strip().split('\x1f', 2)
|
|
127
|
+
dirty = subprocess.run(['git', '-C', root, 'status', '--porcelain'],
|
|
128
|
+
capture_output=True, text=True, timeout=5).stdout
|
|
129
|
+
return {'ts': int(ts), 'sha': sha, 'subject': subj[:80],
|
|
130
|
+
'dirty': len([l for l in dirty.splitlines() if l.strip()])}
|
|
131
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
SESSION_ROOT = os.path.join(HOME, '.dsh', 'sessions')
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def project_key(cwd):
|
|
139
|
+
out, sep = [], False
|
|
140
|
+
for ch in cwd:
|
|
141
|
+
code = ord(ch)
|
|
142
|
+
if ch in '/\\:':
|
|
143
|
+
if not sep:
|
|
144
|
+
out.append('-')
|
|
145
|
+
sep = True
|
|
146
|
+
elif ch != '~' and (ch.isalnum() or ch in '._-') and code < 128:
|
|
147
|
+
out.append(ch)
|
|
148
|
+
sep = False
|
|
149
|
+
else:
|
|
150
|
+
out.append('~%04X' % code)
|
|
151
|
+
sep = False
|
|
152
|
+
s = ''.join(out).lstrip('-') or 'root'
|
|
153
|
+
return '--' + s[:251] + '--'
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def session_activity(root):
|
|
157
|
+
"""该项目工作区下最近一次会话活动时间(用于播报里的「最近活动」)。"""
|
|
158
|
+
if not root:
|
|
159
|
+
return 0
|
|
160
|
+
d = os.path.join(SESSION_ROOT, project_key(os.path.realpath(root)))
|
|
161
|
+
if not os.path.isdir(d):
|
|
162
|
+
return 0
|
|
163
|
+
latest = 0
|
|
164
|
+
try:
|
|
165
|
+
for name in os.listdir(d):
|
|
166
|
+
f = os.path.join(d, name, 'session.v3.jsonl.zstd')
|
|
167
|
+
if os.path.exists(f):
|
|
168
|
+
try:
|
|
169
|
+
latest = max(latest, os.path.getmtime(f))
|
|
170
|
+
except OSError:
|
|
171
|
+
pass
|
|
172
|
+
except OSError:
|
|
173
|
+
return 0
|
|
174
|
+
return latest
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def autonomy_of(p):
|
|
178
|
+
a = p.get('autonomy') or {}
|
|
179
|
+
mode = a.get('mode', 'off')
|
|
180
|
+
if mode not in AUTONOMY_MODES:
|
|
181
|
+
mode = 'off'
|
|
182
|
+
return {'mode': mode, 'maxPerDay': int(a.get('maxPerDay', 3)),
|
|
183
|
+
'day': a.get('day', ''), 'actionsToday': int(a.get('actionsToday', 0))}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _today():
|
|
187
|
+
return datetime.now().strftime('%Y-%m-%d')
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def can_act(p):
|
|
191
|
+
a = autonomy_of(p)
|
|
192
|
+
if a['mode'] == 'off':
|
|
193
|
+
return False, 'off'
|
|
194
|
+
if a['day'] != _today():
|
|
195
|
+
return a['mode'] != 'off', 'new-day'
|
|
196
|
+
if a['actionsToday'] >= a['maxPerDay']:
|
|
197
|
+
return False, f"预算用尽({a['actionsToday']}/{a['maxPerDay']})"
|
|
198
|
+
return True, 'ok'
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def bump_actions(pid):
|
|
202
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
203
|
+
for p in reg['projects']:
|
|
204
|
+
if p['id'] == pid:
|
|
205
|
+
a = autonomy_of(p)
|
|
206
|
+
if a['day'] != _today():
|
|
207
|
+
a['day'] = _today(); a['actionsToday'] = 0
|
|
208
|
+
a['actionsToday'] += 1
|
|
209
|
+
p['autonomy'] = a
|
|
210
|
+
save_json(REGISTRY, reg)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def record_decision(pid, question, options, choice, why, reversible=True, mode=None):
|
|
214
|
+
entry = {'at': time.time(), 'project': pid, 'question': question,
|
|
215
|
+
'options': options, 'choice': choice, 'why': why,
|
|
216
|
+
'reversible': bool(reversible), 'mode': mode or ''}
|
|
217
|
+
with open(DECISIONS, 'a') as f:
|
|
218
|
+
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
|
219
|
+
log(f'[autonomy] decision {pid}: {choice} ({question[:40]})')
|
|
220
|
+
return entry
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_approvals():
|
|
224
|
+
return load_json(APPROVALS, {'items': []})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def add_approval(pid, question, options, recommend='', irreversible=True):
|
|
228
|
+
d = load_approvals()
|
|
229
|
+
item = {'id': 'ap-' + secrets.token_hex(4), 'project': pid, 'question': question,
|
|
230
|
+
'options': options, 'recommend': recommend, 'irreversible': bool(irreversible),
|
|
231
|
+
'createdAt': time.time(), 'status': 'pending'}
|
|
232
|
+
d.setdefault('items', []).append(item)
|
|
233
|
+
save_json(APPROVALS, d)
|
|
234
|
+
log(f'[autonomy] approval {item["id"]} {pid}: {question[:50]}')
|
|
235
|
+
return item
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def resolve_approval(aid, decision, choice='', why=''):
|
|
239
|
+
d = load_approvals()
|
|
240
|
+
for it in d.get('items', []):
|
|
241
|
+
if it['id'] == aid and it['status'] == 'pending':
|
|
242
|
+
it['status'] = 'approved' if decision == 'approve' else 'rejected'
|
|
243
|
+
it['resolvedAt'] = time.time()
|
|
244
|
+
it['humanChoice'] = choice or it.get('recommend', '')
|
|
245
|
+
if why:
|
|
246
|
+
it['note'] = why
|
|
247
|
+
save_json(APPROVALS, d)
|
|
248
|
+
record_decision(it['project'], it['question'], it.get('options', []),
|
|
249
|
+
it['humanChoice'] if decision == 'approve' else 'rejected',
|
|
250
|
+
'human: ' + (why or decision), True, 'human')
|
|
251
|
+
return it
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def cmd_autonomy(argv):
|
|
256
|
+
if not argv:
|
|
257
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
258
|
+
rows = [(p['id'], autonomy_of(p)) for p in reg['projects']]
|
|
259
|
+
for pid, a in rows:
|
|
260
|
+
print(f"{pid:22} {a['mode']:7} 今日 {a['actionsToday']}/{a['maxPerDay']}")
|
|
261
|
+
return 0
|
|
262
|
+
pid = argv[0]
|
|
263
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
264
|
+
proj = next((p for p in reg['projects'] if p['id'] == pid), None)
|
|
265
|
+
if not proj:
|
|
266
|
+
print('项目不存在'); return 1
|
|
267
|
+
if len(argv) < 2:
|
|
268
|
+
a = autonomy_of(proj); print(f"{pid}: {a['mode']} 今日 {a['actionsToday']}/{a['maxPerDay']}"); return 0
|
|
269
|
+
mode = argv[1]
|
|
270
|
+
if mode not in AUTONOMY_MODES:
|
|
271
|
+
print('模式只能是 off / assist / auto'); return 1
|
|
272
|
+
a = autonomy_of(proj)
|
|
273
|
+
a['mode'] = mode
|
|
274
|
+
if '--max' in argv:
|
|
275
|
+
try: a['maxPerDay'] = int(argv[argv.index('--max') + 1])
|
|
276
|
+
except (IndexError, ValueError): pass
|
|
277
|
+
proj['autonomy'] = a
|
|
278
|
+
save_json(REGISTRY, reg)
|
|
279
|
+
print(f"{pid}: 自主模式 → {mode}(每日上限 {a['maxPerDay']})")
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cmd_decide(argv):
|
|
284
|
+
if not argv:
|
|
285
|
+
print('用法: deck decide <id> --question Q --options A|B|C --choice B --why "..." [--irreversible]')
|
|
286
|
+
return 1
|
|
287
|
+
pid = argv[0]
|
|
288
|
+
opts = {}
|
|
289
|
+
i = 1
|
|
290
|
+
while i < len(argv):
|
|
291
|
+
if argv[i].startswith('--') and i + 1 < len(argv):
|
|
292
|
+
opts[argv[i][2:]] = argv[i + 1]; i += 2
|
|
293
|
+
else: i += 1
|
|
294
|
+
q = opts.get('question', ''); choice = opts.get('choice', '')
|
|
295
|
+
if not q or not choice:
|
|
296
|
+
print('缺少 --question / --choice'); return 1
|
|
297
|
+
record_decision(pid, q, opts.get('options', ''), choice, opts.get('why', ''),
|
|
298
|
+
not ('irreversible' in opts or '--irreversible' in argv))
|
|
299
|
+
bump_actions(pid)
|
|
300
|
+
print('已记录决策')
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def cmd_ask(argv):
|
|
305
|
+
if not argv:
|
|
306
|
+
print('用法: deck ask <id> --question Q --options A,B [--recommend A]')
|
|
307
|
+
return 1
|
|
308
|
+
pid = argv[0]; opts = {}
|
|
309
|
+
i = 1
|
|
310
|
+
while i < len(argv):
|
|
311
|
+
if argv[i].startswith('--') and i + 1 < len(argv):
|
|
312
|
+
opts[argv[i][2:]] = argv[i + 1]; i += 2
|
|
313
|
+
else: i += 1
|
|
314
|
+
q = opts.get('question', '')
|
|
315
|
+
if not q: print('缺少 --question'); return 1
|
|
316
|
+
it = add_approval(pid, q, opts.get('options', ''), opts.get('recommend', ''))
|
|
317
|
+
print('已加入审批队列:', it['id'])
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def cmd_approvals(argv):
|
|
322
|
+
d = load_approvals()
|
|
323
|
+
pend = [x for x in d.get('items', []) if x.get('status') == 'pending']
|
|
324
|
+
if not argv:
|
|
325
|
+
if not pend: print('没有待批事项'); return 0
|
|
326
|
+
for x in pend:
|
|
327
|
+
age = rel_time(x['createdAt'])
|
|
328
|
+
print(f"{x['id']} {x['project']:18} {age:8} {x['question'][:60]}")
|
|
329
|
+
if x.get('options'): print(' 选项:', x['options'], '| 推荐:', x.get('recommend') or '-')
|
|
330
|
+
return 0
|
|
331
|
+
if argv[0] in ('--approve', '--reject') and len(argv) > 1:
|
|
332
|
+
aid = argv[1]
|
|
333
|
+
choice = ''
|
|
334
|
+
if '--choice' in argv: choice = argv[argv.index('--choice') + 1]
|
|
335
|
+
why = ''
|
|
336
|
+
if '--why' in argv: why = argv[argv.index('--why') + 1]
|
|
337
|
+
it = resolve_approval(aid, 'approve' if argv[0] == '--approve' else 'reject', choice, why)
|
|
338
|
+
print('已处理' if it else '找不到该待批项')
|
|
339
|
+
return 0
|
|
340
|
+
print('用法: deck approvals [--approve ID [--choice X] | --reject ID [--why "..."]]')
|
|
341
|
+
return 1
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def cmd_decisions(argv):
|
|
345
|
+
limit = 20
|
|
346
|
+
if '--limit' in argv:
|
|
347
|
+
try: limit = int(argv[argv.index('--limit') + 1])
|
|
348
|
+
except (IndexError, ValueError): pass
|
|
349
|
+
if not os.path.exists(DECISIONS):
|
|
350
|
+
print('还没有决策记录'); return 0
|
|
351
|
+
rows = []
|
|
352
|
+
for line in open(DECISIONS):
|
|
353
|
+
try: rows.append(json.loads(line))
|
|
354
|
+
except json.JSONDecodeError: pass
|
|
355
|
+
for r in rows[-limit:]:
|
|
356
|
+
flag = '' if r.get('reversible', True) else ' [不可逆]'
|
|
357
|
+
print(f"{datetime.fromtimestamp(r['at']):%m-%d %H:%M} {r['project']:18} {r['choice']}{flag} ← {r['question'][:50]}")
|
|
358
|
+
return 0
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def in_quiet(cfg, ts=None):
|
|
362
|
+
q = (cfg.get('schedule') or {}).get('quiet') or {}
|
|
363
|
+
start, end = q.get('from'), q.get('to')
|
|
364
|
+
if not start or not end:
|
|
365
|
+
return False
|
|
366
|
+
hm = datetime.fromtimestamp(ts or time.time()).strftime('%H:%M')
|
|
367
|
+
if start <= end:
|
|
368
|
+
return start <= hm < end
|
|
369
|
+
return hm >= start or hm < end
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def git_since(root, since, limit=3):
|
|
373
|
+
try:
|
|
374
|
+
r = subprocess.run(['git', '-C', root, 'log', '--since', str(int(since)), '-n', str(limit),
|
|
375
|
+
'--format=%ct%x1f%s'], capture_output=True, text=True, timeout=6)
|
|
376
|
+
if r.returncode != 0:
|
|
377
|
+
return []
|
|
378
|
+
out = []
|
|
379
|
+
for line in r.stdout.strip().splitlines():
|
|
380
|
+
parts = line.split('\x1f', 1)
|
|
381
|
+
if len(parts) == 2:
|
|
382
|
+
out.append({'ts': int(parts[0]), 'subject': parts[1][:80]})
|
|
383
|
+
return out
|
|
384
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
385
|
+
return []
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def session_stats(root):
|
|
389
|
+
"""返回 {ids:[...], count, latest_at, latest_id}(按会话文件 mtime)。"""
|
|
390
|
+
d = os.path.join(SESSION_ROOT, project_key(os.path.realpath(root or '')))
|
|
391
|
+
out = {'ids': [], 'count': 0, 'latest_at': 0, 'latest_id': None}
|
|
392
|
+
if not os.path.isdir(d):
|
|
393
|
+
return out
|
|
394
|
+
try:
|
|
395
|
+
names = os.listdir(d)
|
|
396
|
+
except OSError:
|
|
397
|
+
return out
|
|
398
|
+
for name in names:
|
|
399
|
+
f = os.path.join(d, name, 'session.v3.jsonl.zstd')
|
|
400
|
+
if not os.path.exists(f):
|
|
401
|
+
continue
|
|
402
|
+
try:
|
|
403
|
+
mt = os.path.getmtime(f)
|
|
404
|
+
except OSError:
|
|
405
|
+
continue
|
|
406
|
+
out['ids'].append(name)
|
|
407
|
+
if mt > out['latest_at']:
|
|
408
|
+
out['latest_at'] = mt
|
|
409
|
+
out['latest_id'] = name
|
|
410
|
+
out['count'] = len(out['ids'])
|
|
411
|
+
return out
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def latest_session_topic(root):
|
|
415
|
+
"""该项目最近会话的首条用户消息与时间(用于「进展」)。"""
|
|
416
|
+
d = os.path.join(SESSION_ROOT, project_key(os.path.realpath(root or '')))
|
|
417
|
+
if not os.path.isdir(d):
|
|
418
|
+
return None
|
|
419
|
+
best = None
|
|
420
|
+
try:
|
|
421
|
+
for name in os.listdir(d):
|
|
422
|
+
f = os.path.join(d, name, 'session.v3.jsonl.zstd')
|
|
423
|
+
if not os.path.exists(f):
|
|
424
|
+
continue
|
|
425
|
+
mt = os.path.getmtime(f)
|
|
426
|
+
if best is None or mt > best[0]:
|
|
427
|
+
best = (mt, f)
|
|
428
|
+
except OSError:
|
|
429
|
+
return None
|
|
430
|
+
if not best:
|
|
431
|
+
return None
|
|
432
|
+
mt, f = best
|
|
433
|
+
topic = ''
|
|
434
|
+
try:
|
|
435
|
+
proc = subprocess.Popen(['zstd', '-dc', f], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
436
|
+
raw = b''
|
|
437
|
+
while raw.count(b'\n') < 25 and len(raw) < 200000:
|
|
438
|
+
chunk = proc.stdout.read(65536)
|
|
439
|
+
if not chunk:
|
|
440
|
+
break
|
|
441
|
+
raw += chunk
|
|
442
|
+
proc.kill()
|
|
443
|
+
proc.wait(timeout=5)
|
|
444
|
+
for line in raw.splitlines()[:25]:
|
|
445
|
+
try:
|
|
446
|
+
j = json.loads(line)
|
|
447
|
+
except Exception:
|
|
448
|
+
continue
|
|
449
|
+
if j.get('type') == 'user/message':
|
|
450
|
+
content = (j.get('data') or {}).get('content') or []
|
|
451
|
+
topic = ' '.join(' '.join(c.get('text', '') for c in content if isinstance(c, dict)).split())[:60]
|
|
452
|
+
if topic:
|
|
453
|
+
break
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
return {'at': mt, 'topic': topic}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def collect(p):
|
|
460
|
+
root = p.get('root', '')
|
|
461
|
+
info = {
|
|
462
|
+
'id': p['id'], 'name': p.get('name', p['id']), 'kind': p.get('kind', 'code'),
|
|
463
|
+
'status': p.get('status', 'active'), 'priority': p.get('priority', 50),
|
|
464
|
+
'exists': os.path.isdir(root), 'next': None, 'state_status': None,
|
|
465
|
+
'board': None, 'git': None, 'activity': 0, 'memory': bool(p.get('memory')),
|
|
466
|
+
}
|
|
467
|
+
sf = state_file(p) if info['exists'] else None
|
|
468
|
+
if sf:
|
|
469
|
+
nxt, st = parse_state(sf)
|
|
470
|
+
info['next'], info['state_status'] = nxt, st
|
|
471
|
+
info['activity'] = max(info['activity'], os.path.getmtime(sf))
|
|
472
|
+
info['memory'] = True
|
|
473
|
+
if info['exists']:
|
|
474
|
+
info['board'] = board_stats(root)
|
|
475
|
+
if info['board']:
|
|
476
|
+
info['activity'] = max(info['activity'], info['board']['latest'])
|
|
477
|
+
info['git'] = git_info(root)
|
|
478
|
+
if info['git']:
|
|
479
|
+
info['activity'] = max(info['activity'], info['git']['ts'])
|
|
480
|
+
try:
|
|
481
|
+
info['activity'] = max(info['activity'], int(os.path.getmtime(root)))
|
|
482
|
+
except OSError:
|
|
483
|
+
pass
|
|
484
|
+
info['activity'] = max(info['activity'], session_activity(root))
|
|
485
|
+
return info
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def rel_time(ts):
|
|
489
|
+
if not ts:
|
|
490
|
+
return '无记录'
|
|
491
|
+
delta = time.time() - ts
|
|
492
|
+
if delta < 90:
|
|
493
|
+
return '刚刚'
|
|
494
|
+
if delta < 3600:
|
|
495
|
+
return f'{int(delta // 60)} 分钟前'
|
|
496
|
+
if delta < 86400:
|
|
497
|
+
return f'{int(delta // 3600)} 小时前'
|
|
498
|
+
return f'{int(delta // 86400)} 天前'
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# ── 播报生成 ────────────────────────────────────────────────
|
|
502
|
+
def build_report(cfg):
|
|
503
|
+
rep = cfg.get('report', {})
|
|
504
|
+
md = rep.get('format', 'markdown') == 'markdown'
|
|
505
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
506
|
+
projects = [p for p in reg.get('projects', []) if p.get('status') != 'archived']
|
|
507
|
+
max_chars = int(rep.get('maxChars', 1200))
|
|
508
|
+
|
|
509
|
+
state_now = load_json(STATE, {})
|
|
510
|
+
prev_run = state_now.get('last_run') or (time.time() - 4 * 3600)
|
|
511
|
+
prev_sessions = state_now.get('sessions') or {}
|
|
512
|
+
|
|
513
|
+
entries = []
|
|
514
|
+
for p in projects:
|
|
515
|
+
info = collect(p)
|
|
516
|
+
prog, changed = [], False
|
|
517
|
+
commits = git_since(p.get('root', ''), prev_run) if info['exists'] else []
|
|
518
|
+
if commits:
|
|
519
|
+
prog.append(f"git {len(commits)} 次提交({commits[0]['subject'][:40]})")
|
|
520
|
+
changed = True
|
|
521
|
+
if info['git'] and info['git']['dirty']:
|
|
522
|
+
prog.append(f"{info['git']['dirty']} 个文件待提交")
|
|
523
|
+
changed = True
|
|
524
|
+
stats = session_stats(p.get('root', '')) if info['exists'] else {'ids': [], 'count': 0, 'latest_at': 0, 'latest_id': None}
|
|
525
|
+
prev = prev_sessions.get(p['id']) or {}
|
|
526
|
+
prev_ids = set(prev.get('ids') or [])
|
|
527
|
+
new_ids = [i for i in stats['ids'] if i not in prev_ids] if prev_ids else []
|
|
528
|
+
if new_ids:
|
|
529
|
+
prog.append(f"新增 {len(new_ids)} 个会话(共 {stats['count']})")
|
|
530
|
+
changed = True
|
|
531
|
+
elif stats['latest_at'] and stats['latest_at'] > float(prev.get('latest_at') or 0):
|
|
532
|
+
prog.append(f"会话更新({rel_time(stats['latest_at'])})")
|
|
533
|
+
changed = True
|
|
534
|
+
elif stats['count'] and not prev_ids:
|
|
535
|
+
prog.append(f"会话 {stats['count']} 个(最近 {rel_time(stats['latest_at'])})")
|
|
536
|
+
topic = latest_session_topic(p.get('root', '')) if (info['exists'] and (new_ids or stats['latest_at'] > prev_run)) else None
|
|
537
|
+
if topic and topic['topic']:
|
|
538
|
+
prog.append(f"「{topic['topic'][:36]}」")
|
|
539
|
+
if info['board'] and info['board']['total']:
|
|
540
|
+
prog.append(f"看板 {info['board']['total']} 个目标")
|
|
541
|
+
changed = True
|
|
542
|
+
nxt = info['next'] or info.get('state_status') or p.get('note') or '(未登记进展)'
|
|
543
|
+
if str(nxt).startswith('自动发现:'):
|
|
544
|
+
nxt = '待确认项目目标与下一步(自动发现)'
|
|
545
|
+
stale = bool(info['activity']) and time.time() - info['activity'] > 14 * 86400
|
|
546
|
+
entries.append({'p': p, 'info': info, 'prog': prog, 'changed': changed, 'next': nxt,
|
|
547
|
+
'stale': stale, 'priority': p.get('priority', 50)})
|
|
548
|
+
|
|
549
|
+
entries.sort(key=lambda e: (not e['changed'], -e['priority'], e['p']['id']))
|
|
550
|
+
blocks, unchanged = [], []
|
|
551
|
+
for e in entries:
|
|
552
|
+
info, p = e['info'], e['p']
|
|
553
|
+
if not e['changed']:
|
|
554
|
+
unchanged.append(info['name'])
|
|
555
|
+
continue
|
|
556
|
+
rel = rel_time(info['activity'])
|
|
557
|
+
missing = '' if info['exists'] else ' · ⚠ 目录不存在'
|
|
558
|
+
stale_hint = ' · ⚠ 14 天未动' if e['stale'] else ''
|
|
559
|
+
name, kind = info['name'], KINDS.get(info['kind'], info['kind'])
|
|
560
|
+
blk = [f'- **{name}**({kind})· {rel}{missing}{stale_hint}' if md else f'{name}({kind})· {rel}{missing}{stale_hint}']
|
|
561
|
+
blk.append(' 进展:' + (' · '.join(e['prog']) if e['prog'] else '本期无新变化'))
|
|
562
|
+
blk.append(f" 下一步:{e['next']}")
|
|
563
|
+
bits = []
|
|
564
|
+
if info['git']:
|
|
565
|
+
g = info['git']
|
|
566
|
+
bits.append(f"git {g['sha']}" + (f" +{g['dirty']} 改动" if g['dirty'] else ''))
|
|
567
|
+
if not info['memory']:
|
|
568
|
+
bits.append('未落盘')
|
|
569
|
+
if bits:
|
|
570
|
+
if md:
|
|
571
|
+
bits = [f'`{b}`' for b in bits]
|
|
572
|
+
blk.append(' ' + (' · '.join(bits) if md else ' / '.join(bits)))
|
|
573
|
+
blocks.append('\n'.join(blk))
|
|
574
|
+
|
|
575
|
+
blocked = [p for p in projects if p.get('status') == 'blocked']
|
|
576
|
+
active = [p for p in projects if p.get('status') == 'active']
|
|
577
|
+
infos = [e['info'] for e in entries]
|
|
578
|
+
recent = [i for i in infos if i['activity'] and time.time() - i['activity'] < 6 * 3600]
|
|
579
|
+
now = datetime.now()
|
|
580
|
+
|
|
581
|
+
if md:
|
|
582
|
+
lines = [f'**项目播报** · {now:%m-%d %H:%M}', '',
|
|
583
|
+
f'{len(projects)} 个项目|活跃 {len(active)} · 阻塞 {len(blocked)} · 近 6 小时更新 {len(recent)}', '']
|
|
584
|
+
else:
|
|
585
|
+
lines = [f'【项目播报 {now:%m-%d %H:%M}】',
|
|
586
|
+
f'项目 {len(projects)} 个(活跃 {len(active)},阻塞 {len(blocked)}),近 6 小时有更新 {len(recent)} 个', '']
|
|
587
|
+
|
|
588
|
+
scan_st = load_json(SCAN_STATE, {})
|
|
589
|
+
if scan_st.get('lastRun') and time.time() - scan_st['lastRun'] < 7 * 86400:
|
|
590
|
+
added = scan_st.get('added') or []
|
|
591
|
+
cands = scan_st.get('candidates') or []
|
|
592
|
+
if added:
|
|
593
|
+
lines.append('自动发现:新增 ' + str(len(added)) + ' 个项目(' + '、'.join(a['name'] for a in added[:3]) + ')')
|
|
594
|
+
elif cands:
|
|
595
|
+
lines.append(f'自动发现:{len(cands)} 个候选待确认(deck scan)')
|
|
596
|
+
if added or cands:
|
|
597
|
+
lines.append('')
|
|
598
|
+
|
|
599
|
+
# 等你拍板 + 自主推进概览
|
|
600
|
+
pend = [x for x in load_approvals().get('items', []) if x.get('status') == 'pending']
|
|
601
|
+
if pend:
|
|
602
|
+
lines.append(f'**等你拍板**:{len(pend)} 项')
|
|
603
|
+
for x in pend[:3]:
|
|
604
|
+
opts = f"({x['options']},推荐 {x.get('recommend') or '-'})" if x.get('options') else ''
|
|
605
|
+
lines.append(f"- {x['project']}:{x['question'][:50]}{opts}")
|
|
606
|
+
lines.append('')
|
|
607
|
+
modes = {'auto': 0, 'assist': 0, 'off': 0}
|
|
608
|
+
for p in projects:
|
|
609
|
+
modes[autonomy_of(p)['mode']] = modes.get(autonomy_of(p)['mode'], 0) + 1
|
|
610
|
+
if modes['auto'] or modes['assist']:
|
|
611
|
+
lines.append(f"自主推进:auto {modes['auto']} · assist {modes['assist']} · off {modes['off']}")
|
|
612
|
+
lines.append('')
|
|
613
|
+
|
|
614
|
+
# 节点离线提醒(超过 3 天没上报)
|
|
615
|
+
try:
|
|
616
|
+
off = []
|
|
617
|
+
for n in load_nodes().get('nodes', []):
|
|
618
|
+
if n.get('local'):
|
|
619
|
+
continue
|
|
620
|
+
ls = float(n.get('lastSeen') or 0)
|
|
621
|
+
if not ls:
|
|
622
|
+
off.append(f"{n.get('name') or n['id']}(从未上报)")
|
|
623
|
+
elif time.time() - ls > 3 * 86400:
|
|
624
|
+
off.append(f"{n.get('name') or n['id']}({int((time.time()-ls)//86400)} 天)")
|
|
625
|
+
if off:
|
|
626
|
+
lines.append('节点离线:' + '、'.join(off))
|
|
627
|
+
lines.append('')
|
|
628
|
+
except Exception:
|
|
629
|
+
pass
|
|
630
|
+
|
|
631
|
+
budget = max_chars - len('\n'.join(lines)) - 200
|
|
632
|
+
keep, total = [], 0
|
|
633
|
+
for b in blocks:
|
|
634
|
+
if total + len(b) + 1 > budget:
|
|
635
|
+
break
|
|
636
|
+
keep.append(b)
|
|
637
|
+
total += len(b) + 1
|
|
638
|
+
omitted = len(blocks) - len(keep)
|
|
639
|
+
parts = keep[:]
|
|
640
|
+
if unchanged:
|
|
641
|
+
parts.append('无变化:' + '、'.join(unchanged[:8]) + ('…' if len(unchanged) > 8 else ''))
|
|
642
|
+
if omitted:
|
|
643
|
+
parts.append(f'……另有 {omitted} 个有变化的项目未展开')
|
|
644
|
+
sep = '\n' if md else '\n\n'
|
|
645
|
+
lines.append(sep.join(parts))
|
|
646
|
+
if blocked:
|
|
647
|
+
names = '、'.join(p.get('name', p['id']) for p in blocked)
|
|
648
|
+
lines.append('')
|
|
649
|
+
lines.append(f'**阻塞**:{names}' if md else f'阻塞:{names}')
|
|
650
|
+
return '\n'.join(lines)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# ── 自动发现 ────────────────────────────────────────────────
|
|
654
|
+
WS_STORE = os.path.join(HOME, '.dsh', 'storages', 'workspace.json')
|
|
655
|
+
SCAN_STATE = os.path.join(BASE, 'scan-state.json')
|
|
656
|
+
SCAN_IGNORE = os.path.join(BASE, 'scan-ignore.json')
|
|
657
|
+
DECISIONS = os.path.join(BASE, 'decisions.jsonl')
|
|
658
|
+
APPROVALS = os.path.join(BASE, 'approvals.json')
|
|
659
|
+
AUTONOMY_MODES = ('off', 'assist', 'auto')
|
|
660
|
+
NODES = os.path.join(BASE, 'nodes.json')
|
|
661
|
+
NODE_SNAPS = os.path.join(BASE, 'nodes')
|
|
662
|
+
|
|
663
|
+
# 不参与扫描的目录(宿主/缓存/系统/已有项目内部)
|
|
664
|
+
EXCLUDE_NAMES = {'.git', 'node_modules', '.venv', 'venv', '__pycache__', 'Library',
|
|
665
|
+
'Applications', 'Movies', 'Music', 'Pictures', 'Public', 'Downloads',
|
|
666
|
+
'.npm', '.cache', '.Trash', '.DS_Store', 'dist', 'build', 'target',
|
|
667
|
+
'go', 'vendor', 'attachments', 'logs'}
|
|
668
|
+
MARKERS = ['.git', 'package.json', 'pnpm-workspace.yaml', 'pyproject.toml', 'setup.py',
|
|
669
|
+
'go.mod', 'Cargo.toml', 'AGENTS.md', 'CLAUDE.md', '.dsh-graph', 'README.md']
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def dsh_workspaces():
|
|
673
|
+
"""DSH 工作区 = 用户真实用过的「工作分区」,带会话数与最近活跃时间。"""
|
|
674
|
+
data = load_json(WS_STORE, {})
|
|
675
|
+
out = {}
|
|
676
|
+
for wid, w in (data.get('tables', {}).get('workspaces') or {}).items():
|
|
677
|
+
path = os.path.realpath(w.get('path', '') or '')
|
|
678
|
+
if not path:
|
|
679
|
+
continue
|
|
680
|
+
sids = w.get('sessionIds') or []
|
|
681
|
+
latest = 0
|
|
682
|
+
for sid in sids:
|
|
683
|
+
for f in glob.glob(os.path.join(HOME, '.dsh', 'sessions', '*', sid + '*')):
|
|
684
|
+
try:
|
|
685
|
+
latest = max(latest, os.path.getmtime(f))
|
|
686
|
+
except OSError:
|
|
687
|
+
pass
|
|
688
|
+
out[path] = {'path': path, 'title': w.get('title') or os.path.basename(path),
|
|
689
|
+
'sessions': len(sids), 'last': latest}
|
|
690
|
+
return out
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def is_excluded(path):
|
|
694
|
+
parts = set(path.split(os.sep))
|
|
695
|
+
if parts & EXCLUDE_NAMES:
|
|
696
|
+
return True
|
|
697
|
+
if path.startswith(os.path.join(HOME, '.dsh')) or path.startswith(os.path.join(HOME, '.ai')):
|
|
698
|
+
return True
|
|
699
|
+
if path.startswith(os.path.join(HOME, 'Library')):
|
|
700
|
+
return True
|
|
701
|
+
return False
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _marker_info(path):
|
|
705
|
+
found = []
|
|
706
|
+
latest = 0
|
|
707
|
+
for m in MARKERS:
|
|
708
|
+
fp = os.path.join(path, m)
|
|
709
|
+
if os.path.exists(fp):
|
|
710
|
+
found.append(m)
|
|
711
|
+
if m == '.git':
|
|
712
|
+
continue # .git 的 mtime 会被 git 自身刷新,不代表项目活跃
|
|
713
|
+
try:
|
|
714
|
+
latest = max(latest, os.path.getmtime(fp))
|
|
715
|
+
except OSError:
|
|
716
|
+
pass
|
|
717
|
+
return found, latest
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def scan_filesystem(cfg):
|
|
721
|
+
scan = cfg.get('scan', {})
|
|
722
|
+
roots = scan.get('roots', [os.path.join(HOME, 'Desktop'), HOME])
|
|
723
|
+
depth = int(scan.get('depth', 1))
|
|
724
|
+
found = {}
|
|
725
|
+
for r in roots:
|
|
726
|
+
base = os.path.realpath(os.path.expanduser(r))
|
|
727
|
+
if not os.path.isdir(base):
|
|
728
|
+
continue
|
|
729
|
+
# 扫描根本身不是项目(例如家目录 / 桌面)
|
|
730
|
+
found.pop(base, None)
|
|
731
|
+
level = [(base, 0)]
|
|
732
|
+
while level:
|
|
733
|
+
cur, d = level.pop()
|
|
734
|
+
if d >= depth:
|
|
735
|
+
continue
|
|
736
|
+
try:
|
|
737
|
+
entries = sorted(os.scandir(cur), key=lambda e: e.name)
|
|
738
|
+
except OSError:
|
|
739
|
+
continue
|
|
740
|
+
for e in entries:
|
|
741
|
+
if not e.is_dir(follow_symlinks=False) or e.name.startswith('.'):
|
|
742
|
+
continue
|
|
743
|
+
if e.name in EXCLUDE_NAMES:
|
|
744
|
+
continue
|
|
745
|
+
sub = os.path.realpath(e.path)
|
|
746
|
+
if is_excluded(sub):
|
|
747
|
+
continue
|
|
748
|
+
markers, latest = _marker_info(sub)
|
|
749
|
+
if markers:
|
|
750
|
+
found[sub] = {'markers': markers, 'last': latest}
|
|
751
|
+
# 只对根目录的一级做 marker 判断;更深层仅当自身像项目才继续
|
|
752
|
+
level.append((sub, d + 1))
|
|
753
|
+
return found
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def load_nodes():
|
|
757
|
+
return load_json(NODES, {'version': 1, 'nodes': []})
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def find_node(node_id):
|
|
761
|
+
return next((n for n in load_nodes().get('nodes', []) if n.get('id') == node_id), None)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def remote_snapshots():
|
|
765
|
+
out = []
|
|
766
|
+
if not os.path.isdir(NODE_SNAPS):
|
|
767
|
+
return out
|
|
768
|
+
for f in sorted(glob.glob(os.path.join(NODE_SNAPS, '*.json'))):
|
|
769
|
+
snap = load_json(f, None)
|
|
770
|
+
if isinstance(snap, dict) and snap.get('node'):
|
|
771
|
+
out.append(snap)
|
|
772
|
+
return out
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def save_node_scan(payload):
|
|
776
|
+
node_id = str(payload.get('node') or '').strip()
|
|
777
|
+
node = find_node(node_id)
|
|
778
|
+
if not node:
|
|
779
|
+
raise ValueError(f'未登记的节点 {node_id}')
|
|
780
|
+
token = str(payload.get('token') or '')
|
|
781
|
+
import hmac
|
|
782
|
+
if not token or not hmac.compare_digest(token, str(node.get('token') or '')):
|
|
783
|
+
raise ValueError('节点 token 不正确')
|
|
784
|
+
projects = payload.get('projects') or []
|
|
785
|
+
if not isinstance(projects, list) or len(projects) > 2000:
|
|
786
|
+
raise ValueError('projects 字段非法')
|
|
787
|
+
snap = {'node': node_id, 'name': node.get('name') or node_id,
|
|
788
|
+
'platform': node.get('platform', ''), 'scanned_at': time.time(),
|
|
789
|
+
'projects': projects}
|
|
790
|
+
os.makedirs(NODE_SNAPS, exist_ok=True)
|
|
791
|
+
save_json(os.path.join(NODE_SNAPS, node_id + '.json'), snap)
|
|
792
|
+
node['lastSeen'] = time.time()
|
|
793
|
+
nodes = load_nodes()
|
|
794
|
+
for n in nodes['nodes']:
|
|
795
|
+
if n['id'] == node_id:
|
|
796
|
+
n['lastSeen'] = node['lastSeen']
|
|
797
|
+
save_json(NODES, nodes)
|
|
798
|
+
log(f'[node] {node_id} 上报 {len(projects)} 个项目')
|
|
799
|
+
return snap
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def cmd_nodes(argv):
|
|
803
|
+
nodes = load_nodes()
|
|
804
|
+
if not argv:
|
|
805
|
+
if not nodes['nodes']:
|
|
806
|
+
print('还没有登记节点。deck nodes add --id hos --name "HOS 手机" --platform android')
|
|
807
|
+
return 0
|
|
808
|
+
print(f"{'id':16} {'名称':14} {'平台':10} {'最近上报':12} {'项目':>4} 状态")
|
|
809
|
+
for n in nodes['nodes']:
|
|
810
|
+
snap = load_json(os.path.join(NODE_SNAPS, n['id'] + '.json'), None)
|
|
811
|
+
last = snap.get('scanned_at') if snap else None
|
|
812
|
+
print(f"{n['id']:16} {(n.get('name') or '')[:12]:14} {n.get('platform',''):10} "
|
|
813
|
+
f"{rel_time(last) if last else '从未':12} {len(snap.get('projects', [])) if snap else 0:>4} "
|
|
814
|
+
f"{'在线' if last and time.time() - last < 86400 else '离线'}")
|
|
815
|
+
return 0
|
|
816
|
+
action = argv[0]
|
|
817
|
+
if action == 'add':
|
|
818
|
+
opts = {}
|
|
819
|
+
i = 1
|
|
820
|
+
while i < len(argv):
|
|
821
|
+
if argv[i].startswith('--') and i + 1 < len(argv):
|
|
822
|
+
opts[argv[i][2:]] = argv[i + 1]
|
|
823
|
+
i += 2
|
|
824
|
+
else:
|
|
825
|
+
i += 1
|
|
826
|
+
nid = opts.get('id')
|
|
827
|
+
if not nid:
|
|
828
|
+
print('缺少 --id')
|
|
829
|
+
return 1
|
|
830
|
+
if find_node(nid):
|
|
831
|
+
print(f'节点 {nid} 已存在')
|
|
832
|
+
return 1
|
|
833
|
+
nodes['nodes'].append({
|
|
834
|
+
'id': nid, 'name': opts.get('name', nid), 'platform': opts.get('platform', ''),
|
|
835
|
+
'address': opts.get('address', ''), 'roots': opts.get('roots', ''),
|
|
836
|
+
'token': secrets.token_urlsafe(24), 'createdAt': time.time(),
|
|
837
|
+
})
|
|
838
|
+
save_json(NODES, nodes)
|
|
839
|
+
n = find_node(nid)
|
|
840
|
+
print(f'已登记节点 {nid},token: {n["token"]}')
|
|
841
|
+
print('把 token 和服务器地址写进节点上的 ~/.deck-node.json:')
|
|
842
|
+
print(' {"server":"http://192.168.1.3:9199","token":"%s","id":"%s"}' % (n['token'], nid))
|
|
843
|
+
return 0
|
|
844
|
+
if action == 'rm':
|
|
845
|
+
if not argv[1:]:
|
|
846
|
+
return 1
|
|
847
|
+
nodes['nodes'] = [n for n in nodes['nodes'] if n['id'] != argv[1]]
|
|
848
|
+
save_json(NODES, nodes)
|
|
849
|
+
print('已移除节点', argv[1])
|
|
850
|
+
return 0
|
|
851
|
+
print('用法: deck nodes [add --id X --name Y --platform android | rm X]')
|
|
852
|
+
return 1
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def slugify(text):
|
|
856
|
+
s = re.sub(r'[^a-z0-9]+', '-', (text or '').lower()).strip('-')
|
|
857
|
+
return s or 'project'
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def load_ignore():
|
|
861
|
+
return load_json(SCAN_IGNORE, {'ignore': {}, 'blocked': []})
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def is_ignored(path):
|
|
865
|
+
ig = load_ignore()
|
|
866
|
+
now = time.time()
|
|
867
|
+
for prefix in ig.get('blocked', []):
|
|
868
|
+
if path == prefix or path.startswith(prefix.rstrip('/') + '/'):
|
|
869
|
+
return True
|
|
870
|
+
until = (ig.get('ignore') or {}).get(path)
|
|
871
|
+
return bool(until and until > now)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def discover(cfg=None):
|
|
875
|
+
cfg = cfg or load_json(CONFIG, {})
|
|
876
|
+
scan = cfg.get('scan', {})
|
|
877
|
+
threshold = int(scan.get('threshold', 70))
|
|
878
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
879
|
+
known = {os.path.realpath(p.get('root', '')): p for p in reg['projects']}
|
|
880
|
+
ws = dsh_workspaces()
|
|
881
|
+
fs = scan_filesystem(cfg)
|
|
882
|
+
now = time.time()
|
|
883
|
+
paths = set(ws) | set(fs)
|
|
884
|
+
out = []
|
|
885
|
+
home = os.path.realpath(HOME)
|
|
886
|
+
for path in sorted(paths):
|
|
887
|
+
if path in (home, os.path.realpath(os.path.expanduser('~/Desktop'))):
|
|
888
|
+
continue
|
|
889
|
+
if path in known or is_excluded(path) or is_ignored(path):
|
|
890
|
+
continue
|
|
891
|
+
# 嵌套在已登记项目内部的目录不算新项目
|
|
892
|
+
if any(path != root and path.startswith(root + os.sep) for root in known):
|
|
893
|
+
continue
|
|
894
|
+
if not os.path.isdir(path):
|
|
895
|
+
continue
|
|
896
|
+
info = ws.get(path, {})
|
|
897
|
+
markers = fs.get(path, {}).get('markers', [])
|
|
898
|
+
git = git_info(path)
|
|
899
|
+
score, reasons = 0, []
|
|
900
|
+
if info:
|
|
901
|
+
score += 50
|
|
902
|
+
reasons.append(f'DSH 工作区({info["sessions"]} 个会话)')
|
|
903
|
+
if info['last'] and now - info['last'] < 30 * 86400:
|
|
904
|
+
score += 15
|
|
905
|
+
reasons.append('近 30 天用过')
|
|
906
|
+
elif info['last'] and now - info['last'] < 90 * 86400:
|
|
907
|
+
score += 5
|
|
908
|
+
if git:
|
|
909
|
+
score += 30
|
|
910
|
+
reasons.append(f'git 仓库({git["subject"][:24]})')
|
|
911
|
+
if git['ts'] and now - git['ts'] < 30 * 86400:
|
|
912
|
+
score += 10
|
|
913
|
+
reasons.append('近 30 天有提交')
|
|
914
|
+
if markers:
|
|
915
|
+
score += 5 * min(len(markers), 2)
|
|
916
|
+
reasons.append('含 ' + ' / '.join(markers[:3]))
|
|
917
|
+
activity = max([info.get('last', 0), fs.get(path, {}).get('last', 0),
|
|
918
|
+
git['ts'] if git else 0,
|
|
919
|
+
os.path.getmtime(path) if os.path.exists(path) else 0])
|
|
920
|
+
name = info.get('title') or os.path.basename(path)
|
|
921
|
+
out.append({
|
|
922
|
+
'path': path, 'name': name, 'id': slugify(name), 'kind': 'code',
|
|
923
|
+
'score': min(score, 100), 'reasons': reasons, 'git': git,
|
|
924
|
+
'sessions': info.get('sessions', 0), 'last': activity,
|
|
925
|
+
'workspace': bool(info), 'auto': score >= threshold,
|
|
926
|
+
})
|
|
927
|
+
# 远程节点上报的项目
|
|
928
|
+
seen_paths = {c['path'] for c in out}
|
|
929
|
+
for snap in remote_snapshots():
|
|
930
|
+
node_id = snap.get('node')
|
|
931
|
+
for rp in snap.get('projects') or []:
|
|
932
|
+
path = str(rp.get('path') or '').strip()
|
|
933
|
+
if not path or path in known or path in seen_paths:
|
|
934
|
+
continue
|
|
935
|
+
seen_paths.add(path)
|
|
936
|
+
markers = rp.get('markers') or []
|
|
937
|
+
git = rp.get('git')
|
|
938
|
+
score = 25
|
|
939
|
+
reasons = [f'来自节点 {snap.get("name") or node_id}']
|
|
940
|
+
if git:
|
|
941
|
+
score += 30
|
|
942
|
+
reasons.append('git 仓库')
|
|
943
|
+
if markers:
|
|
944
|
+
score += 5 * min(len(markers), 2)
|
|
945
|
+
reasons.append('含 ' + ' / '.join(markers[:2]))
|
|
946
|
+
last = float(rp.get('last') or snap.get('scanned_at') or 0)
|
|
947
|
+
last = min(last, now)
|
|
948
|
+
if last and now - last < 30 * 86400:
|
|
949
|
+
score += 10
|
|
950
|
+
reasons.append('近 30 天有改动')
|
|
951
|
+
out.append({
|
|
952
|
+
'path': path, 'name': rp.get('name') or os.path.basename(path),
|
|
953
|
+
'id': slugify(rp.get('name') or os.path.basename(path)), 'kind': rp.get('kind', 'code'),
|
|
954
|
+
'score': min(score, 100), 'reasons': reasons, 'git': git, 'sessions': 0,
|
|
955
|
+
'last': last, 'workspace': False, 'node': node_id,
|
|
956
|
+
'auto': score >= threshold and node_id in {n['id'] for n in load_nodes().get('nodes', [])},
|
|
957
|
+
})
|
|
958
|
+
out.sort(key=lambda x: (-x['score'], -x['last']))
|
|
959
|
+
return out
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def auto_scan(cfg=None):
|
|
963
|
+
"""扫描 + 自动登记高置信度候选,返回本次结果摘要。"""
|
|
964
|
+
cfg = cfg or load_json(CONFIG, {})
|
|
965
|
+
cands = discover(cfg)
|
|
966
|
+
auto = [c for c in cands if c['auto']]
|
|
967
|
+
added = []
|
|
968
|
+
if cfg.get('scan', {}).get('autoAdd', True) and auto:
|
|
969
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
970
|
+
used = {p['id'] for p in reg['projects']}
|
|
971
|
+
for c in auto:
|
|
972
|
+
pid = c['id']
|
|
973
|
+
n = 2
|
|
974
|
+
while pid in used:
|
|
975
|
+
pid = f"{c['id']}-{n}"
|
|
976
|
+
n += 1
|
|
977
|
+
used.add(pid)
|
|
978
|
+
reg['projects'].append({
|
|
979
|
+
'id': pid, 'name': c['name'], 'aliases': [pid, c['name']],
|
|
980
|
+
'kind': c['kind'], 'status': 'active', 'priority': 40,
|
|
981
|
+
'root': c['path'], 'memory': None,
|
|
982
|
+
'note': '自动发现:' + ';'.join(c['reasons'][:3]),
|
|
983
|
+
'created_at': datetime.now().astimezone().isoformat(timespec='seconds'),
|
|
984
|
+
})
|
|
985
|
+
added.append({'id': pid, 'name': c['name'], 'path': c['path'], 'score': c['score']})
|
|
986
|
+
if added:
|
|
987
|
+
save_json(REGISTRY, reg)
|
|
988
|
+
st = load_json(SCAN_STATE, {})
|
|
989
|
+
cands_sorted = sorted([c for c in cands if not c['auto']], key=lambda c: -c['score'])[:30]
|
|
990
|
+
st.update({'lastRun': time.time(), 'added': added, 'candidates': cands_sorted})
|
|
991
|
+
save_json(SCAN_STATE, st)
|
|
992
|
+
log(f"[scan] 候选 {len(cands)},自动登记 {len(added)}")
|
|
993
|
+
return {'added': added, 'candidates': st['candidates'], 'at': st['lastRun']}
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def cmd_scan(argv):
|
|
997
|
+
cfg = load_json(CONFIG, {})
|
|
998
|
+
if '--add' in argv:
|
|
999
|
+
res = auto_scan(cfg)
|
|
1000
|
+
print(f"自动登记 {len(res['added'])} 个,剩余候选 {len(res['candidates'])} 个")
|
|
1001
|
+
for a in res['added']:
|
|
1002
|
+
print(f" + {a['id']:20} {a['name'][:16]:18} {a['path']}")
|
|
1003
|
+
return 0
|
|
1004
|
+
cands = discover(cfg)
|
|
1005
|
+
if not cands:
|
|
1006
|
+
print('没有发现新项目候选')
|
|
1007
|
+
return 0
|
|
1008
|
+
print(f"{'✓':2} {'score':5} {'id':20} {'名称':16} {'最近活动':10} 路径")
|
|
1009
|
+
for c in cands:
|
|
1010
|
+
mark = '·' if c['auto'] else '?'
|
|
1011
|
+
print(f"{mark:2} {c['score']:<5} {c['id']:20} {c['name'][:14]:16} "
|
|
1012
|
+
f"{rel_time(c['last']):10} {c['path']}")
|
|
1013
|
+
print(f" 理由:{';'.join(c['reasons']) or '—'}")
|
|
1014
|
+
print(f"\n共 {len(cands)} 个;· = 达到自动登记阈值,? = 仅候选。"
|
|
1015
|
+
f"执行 deck scan --add 自动登记高置信度项目。")
|
|
1016
|
+
return 0
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
# ── 发送 ────────────────────────────────────────────────────
|
|
1020
|
+
def channel_state():
|
|
1021
|
+
return load_json(CHANNEL_STATE, {'stale': False, 'fails': 0})
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _ctx_hash():
|
|
1025
|
+
st = load_json(WXPUSH_STATE, {})
|
|
1026
|
+
token = st.get('context_token') or ''
|
|
1027
|
+
return hashlib.sha256(token.encode()).hexdigest()[:16]
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def channel_stale():
|
|
1031
|
+
st = channel_state()
|
|
1032
|
+
if not st.get('stale'):
|
|
1033
|
+
return False
|
|
1034
|
+
# 用户给 bot 发过消息 → context_token 变了 → 自动解除 stale
|
|
1035
|
+
if st.get('ctx') and st['ctx'] != _ctx_hash():
|
|
1036
|
+
save_json(CHANNEL_STATE, {'stale': False, 'fails': 0, 'lastOk': time.time()})
|
|
1037
|
+
log('[channel] 检测到 context_token 已刷新,恢复播报')
|
|
1038
|
+
return False
|
|
1039
|
+
return True
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def mark_channel(ok, err=''):
|
|
1043
|
+
st = channel_state()
|
|
1044
|
+
if ok:
|
|
1045
|
+
st = {'stale': False, 'fails': 0, 'lastOk': time.time()}
|
|
1046
|
+
else:
|
|
1047
|
+
st['fails'] = int(st.get('fails', 0)) + 1
|
|
1048
|
+
st['ctx'] = _ctx_hash()
|
|
1049
|
+
st['lastError'] = (err or '')[:200]
|
|
1050
|
+
st['at'] = time.time()
|
|
1051
|
+
if st['fails'] >= 2:
|
|
1052
|
+
st['stale'] = True
|
|
1053
|
+
save_json(CHANNEL_STATE, st)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def send_wechat(text):
|
|
1057
|
+
if not os.path.exists(WXSEND):
|
|
1058
|
+
return 3, 'wxsend 不存在'
|
|
1059
|
+
r = subprocess.run([WXSEND], input=text, capture_output=True, text=True, timeout=30)
|
|
1060
|
+
msg = (r.stdout or r.stderr).strip()
|
|
1061
|
+
if r.returncode == 0:
|
|
1062
|
+
mark_channel(True)
|
|
1063
|
+
elif r.returncode == 2:
|
|
1064
|
+
mark_channel(False, msg.splitlines()[0] if msg else 'queued')
|
|
1065
|
+
return r.returncode, msg
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def refresh_local_node(cfg=None):
|
|
1069
|
+
"""把本机也当作一个节点:播报前刷新一次快照,面板里就不会显示「离线」。"""
|
|
1070
|
+
cfg = cfg or load_json(CONFIG, {})
|
|
1071
|
+
nodes = load_nodes()
|
|
1072
|
+
node = next((n for n in nodes.get('nodes', []) if n.get('local')), None)
|
|
1073
|
+
if not node:
|
|
1074
|
+
return
|
|
1075
|
+
fs = scan_filesystem(cfg)
|
|
1076
|
+
projects = []
|
|
1077
|
+
for path, meta in fs.items():
|
|
1078
|
+
projects.append({'path': path, 'name': os.path.basename(path), 'markers': meta.get('markers', []),
|
|
1079
|
+
'git': git_info(path), 'last': meta.get('last', 0)})
|
|
1080
|
+
try:
|
|
1081
|
+
save_node_scan({'node': node['id'], 'token': node.get('token', ''),
|
|
1082
|
+
'platform': 'darwin', 'projects': projects})
|
|
1083
|
+
log(f'[node] 本机快照已刷新({len(projects)} 个项目)')
|
|
1084
|
+
except ValueError as e:
|
|
1085
|
+
log(f'[node] 本机快照刷新失败: {e}')
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def save_session_snapshot():
|
|
1089
|
+
st = load_json(STATE, {})
|
|
1090
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
1091
|
+
snap = {}
|
|
1092
|
+
for p in reg.get('projects', []):
|
|
1093
|
+
snap[p['id']] = session_stats(p.get('root', ''))
|
|
1094
|
+
st['sessions'] = snap
|
|
1095
|
+
save_json(STATE, st)
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def do_report(cfg, quiet=False):
|
|
1099
|
+
text = build_report(cfg)
|
|
1100
|
+
if channel_stale():
|
|
1101
|
+
log('[report] 跳过:微信通道 context_token 过期(等你给 bot 发消息刷新)')
|
|
1102
|
+
if not quiet:
|
|
1103
|
+
print('⚠️ 微信通道过期,本次播报已跳过(不排队);在微信里给 bot 发任意一句话即可恢复')
|
|
1104
|
+
return 2, text, 'SKIP_CHANNEL_STALE'
|
|
1105
|
+
with open(LAST, 'w') as f:
|
|
1106
|
+
f.write(text + '\n')
|
|
1107
|
+
code, msg = send_wechat(text)
|
|
1108
|
+
try:
|
|
1109
|
+
save_session_snapshot()
|
|
1110
|
+
except Exception as e:
|
|
1111
|
+
log(f'[report] 会话快照失败: {e!r}')
|
|
1112
|
+
try:
|
|
1113
|
+
refresh_local_node(cfg)
|
|
1114
|
+
except Exception as e:
|
|
1115
|
+
log(f'[node] 本机快照刷新异常: {e!r}')
|
|
1116
|
+
log(f'report exit={code} {msg.splitlines()[-1] if msg else ""}')
|
|
1117
|
+
# 播报历史(面板读取)
|
|
1118
|
+
try:
|
|
1119
|
+
with open(HISTORY, 'a') as h:
|
|
1120
|
+
h.write(json.dumps({'at': time.time(), 'exit': code, 'text': text},
|
|
1121
|
+
ensure_ascii=False) + '\n')
|
|
1122
|
+
except OSError:
|
|
1123
|
+
pass
|
|
1124
|
+
if not quiet:
|
|
1125
|
+
print(text)
|
|
1126
|
+
print(f'\n[wechat exit {code}] {msg}')
|
|
1127
|
+
return code, text, msg
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def do_report_with_image(cfg, quiet=False, force=None):
|
|
1132
|
+
"""播报(文字)+ 可选附图;force=True/False 覆盖 config,None 跟随 config。"""
|
|
1133
|
+
code, text, msg = do_report(cfg, quiet)
|
|
1134
|
+
want = cfg.get('report', {}).get('image') if force is None else force
|
|
1135
|
+
if code == 0 and want:
|
|
1136
|
+
try:
|
|
1137
|
+
png = render_image(cfg)
|
|
1138
|
+
icode, imsg = send_wechat_image(png)
|
|
1139
|
+
if icode != 0:
|
|
1140
|
+
queue_image(png, None)
|
|
1141
|
+
log(f'[image] 附图 exit={icode} {imsg}')
|
|
1142
|
+
if not quiet:
|
|
1143
|
+
print(f'[image exit {icode}] {imsg}')
|
|
1144
|
+
except Exception as e:
|
|
1145
|
+
log(f'[image] 附图失败: {e!r}')
|
|
1146
|
+
if not quiet:
|
|
1147
|
+
print('附图失败:', e)
|
|
1148
|
+
return code, text, msg
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
# ── 微信图片播报 ────────────────────────────────────────────
|
|
1152
|
+
WXPUSH_STATE = os.environ.get('WXPUSH_STATE', os.path.join(HOME, '.wxpush', 'state.json'))
|
|
1153
|
+
CDN_BASE = 'https://novac2c.cdn.weixin.qq.com/c2c'
|
|
1154
|
+
CHROME = os.environ.get('DECK_CHROME', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')
|
|
1155
|
+
GRAPHICS = os.path.join(BASE, 'graphics')
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _wx_state():
|
|
1159
|
+
st = load_json(WXPUSH_STATE, {})
|
|
1160
|
+
return st.get('bot_token'), st.get('context_token'), st.get('ilink_user_id'), \
|
|
1161
|
+
(st.get('api_base') or 'https://ilinkai.weixin.qq.com/ilink')
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _wx_headers(token):
|
|
1165
|
+
uin = base64.b64encode(str(secrets.randbelow(0xffffffff)).encode()).decode()
|
|
1166
|
+
return {'content-type': 'application/json', 'iLink-App-ClientVersion': '1',
|
|
1167
|
+
'X-WECHAT-UIN': uin, 'Authorization': f'Bearer {token}',
|
|
1168
|
+
'AuthorizationType': 'ilink_bot_token'}
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def _wx_post(url, body, token, timeout=30):
|
|
1172
|
+
req = urllib.request.Request(url, data=json.dumps(body).encode(), headers=_wx_headers(token))
|
|
1173
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
1174
|
+
return json.loads(r.read().decode())
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def _aes_ecb(data, key):
|
|
1178
|
+
size = ((len(data) + 1 + 15) // 16) * 16
|
|
1179
|
+
plen = 16 - (len(data) % 16)
|
|
1180
|
+
padded = data + bytes([plen]) * plen
|
|
1181
|
+
try:
|
|
1182
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
1183
|
+
enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
|
|
1184
|
+
return enc.update(padded) + enc.finalize(), size
|
|
1185
|
+
except ImportError:
|
|
1186
|
+
# 系统 Python 3.9 没有 cryptography:退回 macOS 自带 openssl
|
|
1187
|
+
r = subprocess.run(['/usr/bin/openssl', 'enc', '-aes-128-ecb', '-K', key.hex(),
|
|
1188
|
+
'-nosalt', '-nopad'], input=padded, capture_output=True)
|
|
1189
|
+
if r.returncode != 0:
|
|
1190
|
+
raise RuntimeError('AES 加密失败: ' + (r.stderr.decode()[:120]))
|
|
1191
|
+
return r.stdout, size
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
PENDING_IMG = os.path.join(BASE, 'pending-image.json')
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def queue_image(png_path, caption=None):
|
|
1198
|
+
save_json(PENDING_IMG, {'path': png_path, 'caption': caption, 'at': time.time()})
|
|
1199
|
+
log(f'[image] 已入队待补发 {os.path.basename(png_path)}')
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def flush_pending_image():
|
|
1203
|
+
p = load_json(PENDING_IMG, None)
|
|
1204
|
+
if not p:
|
|
1205
|
+
return False
|
|
1206
|
+
if channel_stale():
|
|
1207
|
+
return False
|
|
1208
|
+
# 失败后 2 分钟内不重复尝试,避免空转
|
|
1209
|
+
if time.time() - float(p.get('lastTry', 0)) < 120:
|
|
1210
|
+
return False
|
|
1211
|
+
p['lastTry'] = time.time()
|
|
1212
|
+
save_json(PENDING_IMG, p)
|
|
1213
|
+
path = p.get('path', '')
|
|
1214
|
+
if not os.path.exists(path):
|
|
1215
|
+
os.remove(PENDING_IMG)
|
|
1216
|
+
return False
|
|
1217
|
+
code, msg = send_wechat_image(path, p.get('caption'))
|
|
1218
|
+
if code == 0:
|
|
1219
|
+
try:
|
|
1220
|
+
os.remove(PENDING_IMG)
|
|
1221
|
+
except OSError:
|
|
1222
|
+
pass
|
|
1223
|
+
log('[image] 队列图片补发成功')
|
|
1224
|
+
return True
|
|
1225
|
+
if time.time() - float(p.get('at', 0)) > 3 * 86400:
|
|
1226
|
+
try:
|
|
1227
|
+
os.remove(PENDING_IMG)
|
|
1228
|
+
except OSError:
|
|
1229
|
+
pass
|
|
1230
|
+
log('[image] 队列图片超过 3 天,放弃')
|
|
1231
|
+
return False
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def send_wechat_image(png_path, caption=None):
|
|
1235
|
+
token, ctx, uid, api = _wx_state()
|
|
1236
|
+
if not token or not ctx or not uid:
|
|
1237
|
+
return 2, 'wxpush 未授权(token/context_token 缺失)'
|
|
1238
|
+
try:
|
|
1239
|
+
raw = open(png_path, 'rb').read()
|
|
1240
|
+
except OSError as e:
|
|
1241
|
+
return 1, f'读图失败: {e}'
|
|
1242
|
+
rawsize = len(raw)
|
|
1243
|
+
rawmd5 = hashlib.md5(raw).hexdigest()
|
|
1244
|
+
key = secrets.token_bytes(16)
|
|
1245
|
+
ciphertext, filesize = _aes_ecb(raw, key)
|
|
1246
|
+
filekey = secrets.token_hex(16)
|
|
1247
|
+
up = _wx_post(api + '/bot/getuploadurl', {
|
|
1248
|
+
'filekey': filekey, 'media_type': 1, 'to_user_id': uid,
|
|
1249
|
+
'rawsize': rawsize, 'rawfilemd5': rawmd5, 'filesize': filesize,
|
|
1250
|
+
'no_need_thumb': True, 'aeskey': key.hex()}, token)
|
|
1251
|
+
url = up.get('upload_full_url') or up.get('upload_param')
|
|
1252
|
+
if not url:
|
|
1253
|
+
return 1, f'getuploadurl 失败: {str(up)[:120]}'
|
|
1254
|
+
if not str(url).startswith('http'):
|
|
1255
|
+
url = f'{CDN_BASE}/upload?encrypted_query_param={urllib.parse.quote(str(url))}&filekey={filekey}'
|
|
1256
|
+
req = urllib.request.Request(url, data=ciphertext,
|
|
1257
|
+
headers={'content-type': 'application/octet-stream'})
|
|
1258
|
+
with urllib.request.urlopen(req, timeout=120) as r:
|
|
1259
|
+
if r.status != 200:
|
|
1260
|
+
return 1, f'CDN 上传 HTTP {r.status}'
|
|
1261
|
+
download_param = r.headers.get('x-encrypted-param')
|
|
1262
|
+
if not download_param:
|
|
1263
|
+
return 1, 'CDN 上传响应缺少 x-encrypted-param'
|
|
1264
|
+
aes_b64 = base64.b64encode(key.hex().encode()).decode()
|
|
1265
|
+
body = {'msg': {'from_user_id': '', 'to_user_id': uid,
|
|
1266
|
+
'client_id': f'deck:{int(time.time())}:{secrets.randbelow(999999)}',
|
|
1267
|
+
'message_type': 2, 'message_state': 2, 'context_token': ctx,
|
|
1268
|
+
'item_list': [{'type': 2, 'image_item': {
|
|
1269
|
+
'media': {'encrypt_query_param': download_param, 'aes_key': aes_b64,
|
|
1270
|
+
'encrypt_type': 1},
|
|
1271
|
+
'mid_size': filesize}}]},
|
|
1272
|
+
'base_info': {'channel_version': '1.0.0'}}
|
|
1273
|
+
res = _wx_post(api + '/bot/sendmessage', body, token)
|
|
1274
|
+
if res.get('ret', 0) != 0 and not res.get('message_id'):
|
|
1275
|
+
return 1, f'图片发送失败: {str(res)[:160]}'
|
|
1276
|
+
if caption:
|
|
1277
|
+
code, msg = send_wechat(caption)
|
|
1278
|
+
if code != 0:
|
|
1279
|
+
return code, '图片已发,说明文字未送达:' + msg
|
|
1280
|
+
log(f'[image] 已发送 {os.path.basename(png_path)} ({rawsize}B)')
|
|
1281
|
+
return 0, '图片已送达微信'
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def dashboard_html(cfg, limit=8):
|
|
1285
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
1286
|
+
projects = [p for p in reg.get('projects', []) if p.get('status') != 'archived']
|
|
1287
|
+
projects.sort(key=lambda p: (-p.get('priority', 50), p['id']))
|
|
1288
|
+
infos = [(p, collect(p)) for p in projects[:limit]]
|
|
1289
|
+
active = sum(1 for p in projects if p.get('status') == 'active')
|
|
1290
|
+
blocked = sum(1 for p in projects if p.get('status') == 'blocked')
|
|
1291
|
+
recent = sum(1 for _, i in infos if i['activity'] and time.time() - i['activity'] < 6 * 3600)
|
|
1292
|
+
st = load_json(STATE, {})
|
|
1293
|
+
rep = cfg.get('report', {})
|
|
1294
|
+
nxt = (st.get('last_run') or 0) + float(rep.get('intervalHours', 4)) * 3600
|
|
1295
|
+
scan_st = load_json(SCAN_STATE, {})
|
|
1296
|
+
scan_line = ''
|
|
1297
|
+
if scan_st.get('lastRun'):
|
|
1298
|
+
added = scan_st.get('added') or []
|
|
1299
|
+
cands = scan_st.get('candidates') or []
|
|
1300
|
+
if added:
|
|
1301
|
+
scan_line = '自动发现:新增 ' + str(len(added)) + ' 个项目(' + '、'.join(a['name'] for a in added[:3]) + ')'
|
|
1302
|
+
elif cands:
|
|
1303
|
+
scan_line = '自动发现:' + str(len(cands)) + ' 个候选待确认'
|
|
1304
|
+
rows = []
|
|
1305
|
+
for p, info in infos:
|
|
1306
|
+
bits = []
|
|
1307
|
+
if info['git']:
|
|
1308
|
+
bits.append('git ' + info['git']['sha'] + ((' +' + str(info['git']['dirty'])) if info['git']['dirty'] else ''))
|
|
1309
|
+
if info['board'] and info['board']['total']:
|
|
1310
|
+
bits.append('看板 ' + str(info['board']['total']))
|
|
1311
|
+
if not info['memory']:
|
|
1312
|
+
bits.append('未落盘')
|
|
1313
|
+
nxt_action = info['next'] or p.get('note') or '(未登记进展)'
|
|
1314
|
+
status_cn = {'active': '活跃', 'paused': '暂停', 'blocked': '阻塞', 'archived': '归档'}.get(p.get('status', 'active'), p.get('status'))
|
|
1315
|
+
rows.append(
|
|
1316
|
+
'<div class="row"><div class="rowhead"><span class="name">' + p.get('name', p['id']) + '</span>'
|
|
1317
|
+
'<span class="chip">' + KINDS.get(p.get('kind', 'code'), p.get('kind')) + '</span>'
|
|
1318
|
+
'<span class="chip st">' + status_cn + '</span>'
|
|
1319
|
+
'<span class="time">' + rel_time(info['activity']) + '</span></div>'
|
|
1320
|
+
'<div class="next">' + nxt_action + '</div>'
|
|
1321
|
+
'<div class="meta">' + ' · '.join(bits) + '</div></div>')
|
|
1322
|
+
more = ''
|
|
1323
|
+
if len(projects) > limit:
|
|
1324
|
+
more = '<div class="more">……另有 ' + str(len(projects) - limit) + ' 个项目未展示</div>'
|
|
1325
|
+
height = 300 + len(infos) * 128 + 60
|
|
1326
|
+
css = (
|
|
1327
|
+
"*{box-sizing:border-box;margin:0;padding:0}"
|
|
1328
|
+
"body{width:1080px;min-height:100vh;background:radial-gradient(900px 420px at 12% -8%,#12303a 0%,transparent 60%),#0b0f14;"
|
|
1329
|
+
"color:#e6edf3;font-family:-apple-system,'PingFang SC','Helvetica Neue',Arial,sans-serif;padding:46px 54px}"
|
|
1330
|
+
".hdr{display:flex;align-items:baseline;justify-content:space-between;border-bottom:1px solid #1f2a37;padding-bottom:18px}"
|
|
1331
|
+
"h1{font-size:40px;letter-spacing:1px}h1 span{color:#35d0ba}"
|
|
1332
|
+
".sub{margin-top:14px;color:#8b98a5;font-size:20px;display:flex;gap:18px;flex-wrap:wrap}"
|
|
1333
|
+
".sub b{color:#e6edf3;font-weight:600}"
|
|
1334
|
+
".cards{margin-top:26px;display:flex;flex-direction:column;gap:16px}"
|
|
1335
|
+
".row{background:#121821;border:1px solid #1f2a37;border-radius:16px;padding:18px 22px;position:relative;overflow:hidden}"
|
|
1336
|
+
".row::before{content:'';position:absolute;left:0;top:0;bottom:0;width:4px;background:#35d0ba}"
|
|
1337
|
+
".rowhead{display:flex;align-items:center;gap:10px}"
|
|
1338
|
+
".name{font-size:23px;font-weight:700}"
|
|
1339
|
+
".chip{font-size:15px;color:#8b98a5;border:1px solid #24303d;border-radius:7px;padding:1px 9px;background:#0d131a}"
|
|
1340
|
+
".chip.st{color:#7ce7da;border-color:#1f4d48;background:#0f1e1d}"
|
|
1341
|
+
".time{margin-left:auto;font-size:16px;color:#6f7d8a}"
|
|
1342
|
+
".next{margin-top:10px;font-size:18px;color:#cfd9e3;line-height:1.5}"
|
|
1343
|
+
".meta{margin-top:8px;font-size:15px;color:#6f7d8a;font-family:ui-monospace,Menlo,monospace}"
|
|
1344
|
+
".more{color:#6f7d8a;font-size:15px;margin-top:6px}"
|
|
1345
|
+
".foot{margin-top:26px;border-top:1px solid #1f2a37;padding-top:16px;color:#6f7d8a;font-size:15px;display:flex;justify-content:space-between;gap:20px}"
|
|
1346
|
+
".scan{color:#7ce7da}"
|
|
1347
|
+
)
|
|
1348
|
+
foot_time = ('下一条播报 ' + datetime.fromtimestamp(nxt).strftime('%H:%M')) if nxt else ''
|
|
1349
|
+
return ('<!DOCTYPE html><html><head><meta charset="utf-8"><style>' + css + '</style></head><body>'
|
|
1350
|
+
'<div class="hdr"><h1>项目播报 <span>·</span> 项目甲板</h1>'
|
|
1351
|
+
'<div class="time">' + datetime.now().strftime('%Y-%m-%d %H:%M') + '</div></div>'
|
|
1352
|
+
'<div class="sub"><span><b>' + str(len(projects)) + '</b> 个项目</span>'
|
|
1353
|
+
'<span>活跃 <b>' + str(active) + '</b></span><span>阻塞 <b>' + str(blocked) + '</b></span>'
|
|
1354
|
+
'<span>近 6 小时更新 <b>' + str(recent) + '</b></span></div>'
|
|
1355
|
+
'<div class="cards">' + ''.join(rows) + more + '</div>'
|
|
1356
|
+
'<div class="foot"><span class="scan">' + (scan_line or '自动发现:无新项目') + '</span>'
|
|
1357
|
+
'<span>' + foot_time + '</span></div>'
|
|
1358
|
+
'<script>addEventListener("load",()=>{document.title="H"+document.body.scrollHeight});</script>'
|
|
1359
|
+
'</body></html>')
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def render_image(cfg=None, out=None):
|
|
1363
|
+
cfg = cfg or load_json(CONFIG, {})
|
|
1364
|
+
os.makedirs(GRAPHICS, exist_ok=True)
|
|
1365
|
+
html = dashboard_html(cfg)
|
|
1366
|
+
ts = datetime.now().strftime('%Y%m%d-%H%M%S')
|
|
1367
|
+
html_path = os.path.join(GRAPHICS, 'board-' + ts + '.html')
|
|
1368
|
+
png_path = out or os.path.join(GRAPHICS, 'board-' + ts + '.png')
|
|
1369
|
+
with open(html_path, 'w', encoding='utf-8') as f:
|
|
1370
|
+
f.write(html)
|
|
1371
|
+
height = 900
|
|
1372
|
+
try:
|
|
1373
|
+
r = subprocess.run([CHROME, '--headless=new', '--disable-gpu',
|
|
1374
|
+
'--virtual-time-budget=1500', '--dump-dom', 'file://' + html_path],
|
|
1375
|
+
capture_output=True, timeout=60, text=True)
|
|
1376
|
+
m = re.search(r'<title>H(\d+)</title>', r.stdout or '')
|
|
1377
|
+
if m:
|
|
1378
|
+
height = int(m.group(1)) + 2
|
|
1379
|
+
except Exception:
|
|
1380
|
+
pass
|
|
1381
|
+
subprocess.run([CHROME, '--headless=new', '--disable-gpu', '--hide-scrollbars',
|
|
1382
|
+
'--force-device-scale-factor=1', '--window-size=1080,' + str(height),
|
|
1383
|
+
'--screenshot=' + png_path, 'file://' + html_path],
|
|
1384
|
+
capture_output=True, timeout=60)
|
|
1385
|
+
if not os.path.exists(png_path):
|
|
1386
|
+
raise RuntimeError('截图失败(chrome headless)')
|
|
1387
|
+
for f in sorted(glob.glob(os.path.join(GRAPHICS, 'board-*.png')))[:-20]:
|
|
1388
|
+
try:
|
|
1389
|
+
os.remove(f)
|
|
1390
|
+
except OSError:
|
|
1391
|
+
pass
|
|
1392
|
+
return png_path
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def cmd_image(argv):
|
|
1396
|
+
cfg = load_json(CONFIG, {})
|
|
1397
|
+
rep = cfg.setdefault('report', {})
|
|
1398
|
+
if '--on' in argv or '--off' in argv:
|
|
1399
|
+
rep['image'] = '--on' in argv
|
|
1400
|
+
save_json(CONFIG, cfg)
|
|
1401
|
+
print('周期播报附图:' + ('开启' if rep['image'] else '关闭'))
|
|
1402
|
+
return 0
|
|
1403
|
+
try:
|
|
1404
|
+
png = render_image(cfg)
|
|
1405
|
+
except Exception as e:
|
|
1406
|
+
print('生成图片失败:', e)
|
|
1407
|
+
return 1
|
|
1408
|
+
print('图片:', png)
|
|
1409
|
+
if '--no-send' in argv:
|
|
1410
|
+
return 0
|
|
1411
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
1412
|
+
n = len([p for p in reg.get('projects', []) if p.get('status') != 'archived'])
|
|
1413
|
+
caption = f'项目播报 · {n} 个项目 · {datetime.now():%m-%d %H:%M}'
|
|
1414
|
+
code, msg = send_wechat_image(png, caption)
|
|
1415
|
+
if code != 0:
|
|
1416
|
+
queue_image(png, caption)
|
|
1417
|
+
print(f'[wechat exit {code}] {msg}')
|
|
1418
|
+
return code
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
# ── 命令 ────────────────────────────────────────────────────
|
|
1422
|
+
def cmd_list():
|
|
1423
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
1424
|
+
if not reg['projects']:
|
|
1425
|
+
print('(空)用 deck add 添加项目')
|
|
1426
|
+
return 0
|
|
1427
|
+
print(f'{"id":18} {"名称":14} {"类型":6} {"状态":8} {"优先级":6} 根目录')
|
|
1428
|
+
for p in sorted(reg['projects'], key=lambda x: -x.get('priority', 50)):
|
|
1429
|
+
print(f'{p["id"]:18} {p.get("name", "")[:12]:14} {p.get("kind","code"):6} '
|
|
1430
|
+
f'{p.get("status","active"):8} {p.get("priority",50):<6} {p.get("root","")}')
|
|
1431
|
+
return 0
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def cmd_add(argv):
|
|
1435
|
+
if not argv:
|
|
1436
|
+
print('用法: deck add <id> --root <path> [--name 名称] [--kind code] [--note 说明]')
|
|
1437
|
+
return 1
|
|
1438
|
+
pid = argv[0]
|
|
1439
|
+
opts = {}
|
|
1440
|
+
i = 1
|
|
1441
|
+
while i < len(argv):
|
|
1442
|
+
k = argv[i]
|
|
1443
|
+
if k.startswith('--') and i + 1 < len(argv):
|
|
1444
|
+
opts[k[2:]] = argv[i + 1]
|
|
1445
|
+
i += 2
|
|
1446
|
+
else:
|
|
1447
|
+
i += 1
|
|
1448
|
+
if 'root' not in opts:
|
|
1449
|
+
print('缺少 --root')
|
|
1450
|
+
return 1
|
|
1451
|
+
root = os.path.realpath(os.path.expanduser(opts['root']))
|
|
1452
|
+
if not os.path.isdir(root):
|
|
1453
|
+
print(f'目录不存在: {root}')
|
|
1454
|
+
return 1
|
|
1455
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
1456
|
+
for p in reg['projects']:
|
|
1457
|
+
if p['id'] == pid:
|
|
1458
|
+
print(f'已存在项目 {pid}')
|
|
1459
|
+
return 1
|
|
1460
|
+
if os.path.realpath(p.get('root', '')) == root:
|
|
1461
|
+
print(f'该目录已绑定项目 {p["id"]}')
|
|
1462
|
+
return 1
|
|
1463
|
+
reg['projects'].append({
|
|
1464
|
+
'id': pid, 'name': opts.get('name', pid), 'aliases': [pid],
|
|
1465
|
+
'kind': opts.get('kind', 'code'), 'status': 'active',
|
|
1466
|
+
'priority': int(opts.get('priority', 50)), 'root': root,
|
|
1467
|
+
'memory': None, 'note': opts.get('note', ''),
|
|
1468
|
+
'created_at': datetime.now().astimezone().isoformat(timespec='seconds'),
|
|
1469
|
+
})
|
|
1470
|
+
save_json(REGISTRY, reg)
|
|
1471
|
+
print(f'已登记 {pid} → {root}')
|
|
1472
|
+
return 0
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
def cmd_rm(argv):
|
|
1476
|
+
if not argv:
|
|
1477
|
+
return 1
|
|
1478
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
1479
|
+
before = len(reg['projects'])
|
|
1480
|
+
reg['projects'] = [p for p in reg['projects'] if p['id'] != argv[0]]
|
|
1481
|
+
if len(reg['projects']) == before:
|
|
1482
|
+
print(f'没有项目 {argv[0]}')
|
|
1483
|
+
return 1
|
|
1484
|
+
save_json(REGISTRY, reg)
|
|
1485
|
+
print(f'已移除 {argv[0]}(记忆与目录未动)')
|
|
1486
|
+
return 0
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def cmd_every(argv):
|
|
1490
|
+
if not argv:
|
|
1491
|
+
print(f'当前间隔 {load_json(CONFIG, {}).get("report", {}).get("intervalHours", 4)} 小时')
|
|
1492
|
+
return 0
|
|
1493
|
+
try:
|
|
1494
|
+
hours = float(argv[0])
|
|
1495
|
+
except ValueError:
|
|
1496
|
+
print('小时数无效')
|
|
1497
|
+
return 1
|
|
1498
|
+
if hours <= 0 or hours > 24 * 30:
|
|
1499
|
+
print('间隔需在 0 到 720 小时之间')
|
|
1500
|
+
return 1
|
|
1501
|
+
cfg = load_json(CONFIG, {})
|
|
1502
|
+
cfg.setdefault('report', {})['intervalHours'] = hours
|
|
1503
|
+
save_json(CONFIG, cfg)
|
|
1504
|
+
print(f'播报间隔已设为 {hours} 小时(守护进程 1 分钟内生效)')
|
|
1505
|
+
return 0
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def cmd_format(argv):
|
|
1509
|
+
cfg = load_json(CONFIG, {})
|
|
1510
|
+
rep = cfg.setdefault('report', {})
|
|
1511
|
+
cur = rep.get('format', 'markdown')
|
|
1512
|
+
if not argv:
|
|
1513
|
+
print(f'当前播报版式:{cur}(markdown | plain)')
|
|
1514
|
+
return 0
|
|
1515
|
+
v = argv[0].strip().lower()
|
|
1516
|
+
if v not in ('markdown', 'md', 'plain', 'text'):
|
|
1517
|
+
print('只支持 markdown 或 plain')
|
|
1518
|
+
return 1
|
|
1519
|
+
rep['format'] = 'markdown' if v in ('markdown', 'md') else 'plain'
|
|
1520
|
+
save_json(CONFIG, cfg)
|
|
1521
|
+
print(f'播报版式已设为 {rep["format"]}(下一次播报生效)')
|
|
1522
|
+
return 0
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def cmd_toggle(on):
|
|
1526
|
+
cfg = load_json(CONFIG, {})
|
|
1527
|
+
cfg.setdefault('report', {})['enabled'] = on
|
|
1528
|
+
save_json(CONFIG, cfg)
|
|
1529
|
+
print('周期播报:' + ('已开启' if on else '已关闭'))
|
|
1530
|
+
return 0
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def daemon_status():
|
|
1534
|
+
pid = None
|
|
1535
|
+
if os.path.exists(LOCK):
|
|
1536
|
+
try:
|
|
1537
|
+
with open(LOCK) as f:
|
|
1538
|
+
pid = int(f.read().strip() or 0)
|
|
1539
|
+
os.kill(pid, 0)
|
|
1540
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
1541
|
+
pid = None
|
|
1542
|
+
return pid
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
def cmd_doctor():
|
|
1546
|
+
reg = load_json(REGISTRY, {'projects': []})
|
|
1547
|
+
problems, warnings = [], []
|
|
1548
|
+
seen = {}
|
|
1549
|
+
for p in reg.get('projects', []):
|
|
1550
|
+
pid, root = p['id'], p.get('root', '')
|
|
1551
|
+
if root in seen:
|
|
1552
|
+
problems.append(f'{pid}: 根目录与 {seen[root]} 重复绑定({root})')
|
|
1553
|
+
seen[root] = pid
|
|
1554
|
+
if not os.path.isdir(root):
|
|
1555
|
+
problems.append(f'{pid}: 目录不存在 {root}')
|
|
1556
|
+
continue
|
|
1557
|
+
if p.get('memory') and not state_file(p):
|
|
1558
|
+
problems.append(f'{pid}: 记忆缺失(STATE 不存在)')
|
|
1559
|
+
if not os.path.isdir(os.path.join(root, '.dsh-graph')):
|
|
1560
|
+
warnings.append(f'{pid}: 无执行看板(.dsh-graph 缺失)')
|
|
1561
|
+
if not p.get('memory'):
|
|
1562
|
+
warnings.append(f'{pid}: 未落盘(无 ~/.ai STATE)')
|
|
1563
|
+
print(f'项目 {len(reg.get("projects", []))} 个,问题 {len(problems)},提示 {len(warnings)}')
|
|
1564
|
+
for x in problems:
|
|
1565
|
+
print(' ✗ ' + x)
|
|
1566
|
+
for x in warnings:
|
|
1567
|
+
print(' · ' + x)
|
|
1568
|
+
return 0 if not problems else 1
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
def cmd_status():
|
|
1572
|
+
cfg = load_json(CONFIG, {})
|
|
1573
|
+
st = load_json(STATE, {})
|
|
1574
|
+
r = cfg.get('report', {})
|
|
1575
|
+
pid = daemon_status()
|
|
1576
|
+
print(f"配置: {CONFIG}")
|
|
1577
|
+
print(f"注册表: {REGISTRY}({len(load_json(REGISTRY, {'projects': []})['projects'])} 个项目)")
|
|
1578
|
+
print(f"周期播报: {'开启' if r.get('enabled', True) else '关闭'},每 {r.get('intervalHours', 4)} 小时,"
|
|
1579
|
+
f"版式 {r.get('format', 'markdown')},{'附图' if r.get('image') else '不附图'}")
|
|
1580
|
+
print(f"守护进程: {'运行中 pid ' + str(pid) if pid else '未运行'}")
|
|
1581
|
+
pend_count = len([x for x in load_approvals().get('items', []) if x.get('status') == 'pending'])
|
|
1582
|
+
if pend_count:
|
|
1583
|
+
print(f"待批事项: {pend_count} 个")
|
|
1584
|
+
sc = load_json(SCAN_STATE, {})
|
|
1585
|
+
if sc.get('lastRun'):
|
|
1586
|
+
print(f"自动发现: 上次扫描 {datetime.fromtimestamp(sc['lastRun']):%m-%d %H:%M}"
|
|
1587
|
+
f"({rel_time(sc['lastRun'])}),候选 {len(sc.get('candidates', []))} 个")
|
|
1588
|
+
else:
|
|
1589
|
+
print("自动发现: 尚未扫描")
|
|
1590
|
+
q = (cfg.get('schedule') or {}).get('quiet') or {}
|
|
1591
|
+
if q.get('from'):
|
|
1592
|
+
print(f"安静时段: {q['from']} – {q['to']}(期间不播报、不扫描)")
|
|
1593
|
+
last = st.get('last_run')
|
|
1594
|
+
print(f"上次播报: {datetime.fromtimestamp(last):%Y-%m-%d %H:%M}({rel_time(last)})" if last else '上次播报: 从未')
|
|
1595
|
+
nxt = (last or time.time()) + r.get('intervalHours', 4) * 3600
|
|
1596
|
+
while in_quiet(cfg, nxt):
|
|
1597
|
+
nxt += 1800
|
|
1598
|
+
print(f"下次播报: {datetime.fromtimestamp(nxt):%Y-%m-%d %H:%M}")
|
|
1599
|
+
wx = subprocess.run([WXSEND, '--status'], capture_output=True, text=True)
|
|
1600
|
+
print('微信通道: ' + ('✅ 可发送' if wx.returncode == 0 else '⚠️ 未接通(播报会排队,授权后补发)'))
|
|
1601
|
+
return 0 if pid else 1
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
def cmd_daemon():
|
|
1605
|
+
os.makedirs(os.path.dirname(LOCK), exist_ok=True)
|
|
1606
|
+
fh = open(LOCK, 'w')
|
|
1607
|
+
try:
|
|
1608
|
+
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
1609
|
+
except OSError:
|
|
1610
|
+
log('已有守护进程在跑,退出')
|
|
1611
|
+
return 0
|
|
1612
|
+
fh.write(str(os.getpid()))
|
|
1613
|
+
fh.flush()
|
|
1614
|
+
log(f'守护进程启动 pid={os.getpid()}')
|
|
1615
|
+
while True:
|
|
1616
|
+
try:
|
|
1617
|
+
cfg = load_json(CONFIG, {})
|
|
1618
|
+
r = cfg.get('report', {})
|
|
1619
|
+
if in_quiet(cfg):
|
|
1620
|
+
time.sleep(60)
|
|
1621
|
+
continue
|
|
1622
|
+
try:
|
|
1623
|
+
flush_pending_image()
|
|
1624
|
+
except Exception as e:
|
|
1625
|
+
log(f'[image] 补发异常: {e!r}')
|
|
1626
|
+
# 自动发现:按 scan.intervalHours 周期扫描
|
|
1627
|
+
sc = cfg.get('scan', {})
|
|
1628
|
+
if sc.get('enabled', True):
|
|
1629
|
+
sst = load_json(SCAN_STATE, {})
|
|
1630
|
+
sinterval = float(sc.get('intervalHours', 24)) * 3600
|
|
1631
|
+
if time.time() - float(sst.get('lastRun', 0)) >= sinterval:
|
|
1632
|
+
try:
|
|
1633
|
+
auto_scan(cfg)
|
|
1634
|
+
except Exception as e:
|
|
1635
|
+
log(f'[scan] 异常: {e!r}')
|
|
1636
|
+
if r.get('enabled', True):
|
|
1637
|
+
st = load_json(STATE, {})
|
|
1638
|
+
last = st.get('last_run', 0)
|
|
1639
|
+
interval = float(r.get('intervalHours', 4)) * 3600
|
|
1640
|
+
if time.time() - last >= interval:
|
|
1641
|
+
do_report_with_image(cfg, quiet=True)
|
|
1642
|
+
st['last_run'] = time.time()
|
|
1643
|
+
save_json(STATE, st)
|
|
1644
|
+
except Exception as e: # 守护进程不能因单次失败退出
|
|
1645
|
+
log(f'循环异常: {e!r}')
|
|
1646
|
+
time.sleep(30)
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def cmd_demo(argv):
|
|
1650
|
+
"""deck demo:生成一个演示环境(假项目 + 独立面板端口),不碰真实注册表。"""
|
|
1651
|
+
import shutil
|
|
1652
|
+
home = os.path.expanduser('~/.project-deck-demo')
|
|
1653
|
+
port = '9299'
|
|
1654
|
+
for i, a in enumerate(argv):
|
|
1655
|
+
if a == '--port' and i + 1 < len(argv):
|
|
1656
|
+
port = argv[i + 1]
|
|
1657
|
+
if os.path.isdir(home):
|
|
1658
|
+
shutil.rmtree(home)
|
|
1659
|
+
os.makedirs(home, exist_ok=True)
|
|
1660
|
+
projects = [
|
|
1661
|
+
('website', '官网改版', 'content', '落地页文案与设计,本周出第一版'),
|
|
1662
|
+
('api-server', 'API 服务', 'code', '把 v1 接口迁到 v2,补 12 个测试'),
|
|
1663
|
+
('notes-app', '笔记应用', 'code', '离线同步还有 2 个 bug'),
|
|
1664
|
+
('infra', '基础设施', 'ops', '备份脚本换成 restic,验证一次恢复'),
|
|
1665
|
+
]
|
|
1666
|
+
reg = {'version': 1, 'projects': []}
|
|
1667
|
+
for pid, name, kind, note in projects:
|
|
1668
|
+
root = os.path.join(home, 'projects', pid)
|
|
1669
|
+
os.makedirs(root, exist_ok=True)
|
|
1670
|
+
open(os.path.join(root, 'README.md'), 'w').write(f'# {name}\n\n{note}\n')
|
|
1671
|
+
if kind == 'code':
|
|
1672
|
+
open(os.path.join(root, 'package.json'), 'w').write('{"name":"%s","version":"0.1.0"}\n' % pid)
|
|
1673
|
+
subprocess.run(['git', 'init', '-q'], cwd=root)
|
|
1674
|
+
subprocess.run(['git', '-c', 'user.name=demo', '-c', 'user.email=demo@example.com',
|
|
1675
|
+
'commit', '-qm', f'init: {name}', '--allow-empty'], cwd=root)
|
|
1676
|
+
reg['projects'].append({'id': pid, 'name': name, 'aliases': [pid], 'kind': kind,
|
|
1677
|
+
'status': 'active', 'priority': 60, 'root': root,
|
|
1678
|
+
'memory': None, 'note': note,
|
|
1679
|
+
'created_at': time.strftime('%Y-%m-%dT%H:%M:%S%z')})
|
|
1680
|
+
cfg = {'version': 1,
|
|
1681
|
+
'report': {'enabled': False, 'intervalHours': 4, 'format': 'markdown', 'image': False},
|
|
1682
|
+
'scan': {'enabled': False, 'intervalHours': 12},
|
|
1683
|
+
'panel': {'allowLanRead': False}}
|
|
1684
|
+
save_json(os.path.join(home, 'registry.json'), reg)
|
|
1685
|
+
save_json(os.path.join(home, 'config.json'), cfg)
|
|
1686
|
+
os.makedirs(os.path.join(home, 'graphics'), exist_ok=True)
|
|
1687
|
+
print(f'演示环境: {home}')
|
|
1688
|
+
print(f'面板地址: http://127.0.0.1:{port} (Ctrl-C 结束)')
|
|
1689
|
+
env = dict(os.environ, DECK_HOME=home, DECK_PANEL_PORT=str(port), DECK_PANEL_HOST='127.0.0.1')
|
|
1690
|
+
try:
|
|
1691
|
+
subprocess.run([sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'panel.py')], env=env)
|
|
1692
|
+
except KeyboardInterrupt:
|
|
1693
|
+
pass
|
|
1694
|
+
return 0
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def main():
|
|
1698
|
+
args = sys.argv[1:]
|
|
1699
|
+
if not args:
|
|
1700
|
+
return cmd_status()
|
|
1701
|
+
cmd, rest = args[0], args[1:]
|
|
1702
|
+
if cmd in ('list', 'ls'):
|
|
1703
|
+
return cmd_list()
|
|
1704
|
+
if cmd == 'add':
|
|
1705
|
+
return cmd_add(rest)
|
|
1706
|
+
if cmd == 'rm':
|
|
1707
|
+
return cmd_rm(rest)
|
|
1708
|
+
if cmd == 'every':
|
|
1709
|
+
return cmd_every(rest)
|
|
1710
|
+
if cmd == 'format':
|
|
1711
|
+
return cmd_format(rest)
|
|
1712
|
+
if cmd == 'scan':
|
|
1713
|
+
return cmd_scan(rest)
|
|
1714
|
+
if cmd == 'nodes':
|
|
1715
|
+
return cmd_nodes(rest)
|
|
1716
|
+
if cmd == 'image':
|
|
1717
|
+
return cmd_image(rest)
|
|
1718
|
+
if cmd == 'on':
|
|
1719
|
+
return cmd_toggle(True)
|
|
1720
|
+
if cmd == 'off':
|
|
1721
|
+
return cmd_toggle(False)
|
|
1722
|
+
if cmd == 'status':
|
|
1723
|
+
return cmd_status()
|
|
1724
|
+
if cmd == 'doctor':
|
|
1725
|
+
return cmd_doctor()
|
|
1726
|
+
if cmd == 'preview':
|
|
1727
|
+
print(build_report(load_json(CONFIG, {})))
|
|
1728
|
+
return 0
|
|
1729
|
+
if cmd == 'report':
|
|
1730
|
+
cfg0 = load_json(CONFIG, {})
|
|
1731
|
+
force = False if '--no-image' in rest else (True if '--image' in rest else None)
|
|
1732
|
+
code, text, msg = do_report_with_image(cfg0, force=force)
|
|
1733
|
+
st = load_json(STATE, {})
|
|
1734
|
+
st['last_run'] = time.time()
|
|
1735
|
+
save_json(STATE, st)
|
|
1736
|
+
return code
|
|
1737
|
+
if cmd == 'autonomy':
|
|
1738
|
+
return cmd_autonomy(rest)
|
|
1739
|
+
if cmd == 'decide':
|
|
1740
|
+
return cmd_decide(rest)
|
|
1741
|
+
if cmd == 'ask':
|
|
1742
|
+
return cmd_ask(rest)
|
|
1743
|
+
if cmd == 'approvals':
|
|
1744
|
+
return cmd_approvals(rest)
|
|
1745
|
+
if cmd == 'decisions':
|
|
1746
|
+
return cmd_decisions(rest)
|
|
1747
|
+
if cmd == 'demo':
|
|
1748
|
+
return cmd_demo(rest)
|
|
1749
|
+
if cmd == 'panel':
|
|
1750
|
+
port = os.environ.get('DECK_PANEL_PORT', '9199')
|
|
1751
|
+
url = f'http://127.0.0.1:{port}'
|
|
1752
|
+
print(f'管理面板: {url}')
|
|
1753
|
+
try:
|
|
1754
|
+
subprocess.run(['open', url], timeout=5)
|
|
1755
|
+
except (OSError, subprocess.SubprocessError):
|
|
1756
|
+
pass
|
|
1757
|
+
return 0
|
|
1758
|
+
if cmd == 'daemon':
|
|
1759
|
+
return cmd_daemon()
|
|
1760
|
+
print(__doc__)
|
|
1761
|
+
return 1
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
if __name__ == '__main__':
|
|
1765
|
+
sys.exit(main())
|