feat: multi-workspace task data auto-detection + time parsing improvements

- Auto-detect task data dir from ~/.openclaw/workspace-*/data (#117)
- Score and select best task source (non-demo tasks preferred)
- Update healthz and live-status to use detected data dir
- Add robust parseDateFlexible() for timestamp handling (#67)
- Add UTF-8 encoding to file_lock reads for Windows compat (#96)
- Use absolute path in install.sh hint (#107)

Closes #117, Closes #107
This commit is contained in:
cft0808
2026-03-26 21:59:35 +08:00
parent 332ef07fc9
commit 7cb0a6ad12
4 changed files with 96 additions and 18 deletions

View File

@@ -1714,16 +1714,40 @@ function renderLiveActivity(data){
}
}
function fmtActivityTime(ts){
if(!ts)return '';
function parseDateFlexible(ts){
if(ts===null||ts===undefined||ts==='') return null;
if(ts instanceof Date) return isNaN(ts.getTime())?null:ts;
if(typeof ts==='number'){
const d=new Date(ts);
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
const ms = ts > 1e12 ? ts : ts * 1000;
const d = new Date(ms);
return isNaN(d.getTime()) ? null : d;
}
if(typeof ts==='string'&&ts.length>=19){
return ts.substring(11,19);
if(typeof ts!=='string') return null;
const s = ts.trim();
if(!s) return null;
if(/^\d+$/.test(s)){
const n = Number(s);
if(Number.isFinite(n)){
const ms = n > 1e12 ? n : n * 1000;
const d = new Date(ms);
return isNaN(d.getTime()) ? null : d;
}
}
return String(ts).substring(0,8);
const localMatch = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/);
if(localMatch){
const [, y, m, d, h, mi, se] = localMatch;
const dt = new Date(Number(y), Number(m)-1, Number(d), Number(h), Number(mi), Number(se));
return isNaN(dt.getTime()) ? null : dt;
}
const iso = s.includes(' ') ? s.replace(' ', 'T') : s;
const parsed = new Date(iso);
return isNaN(parsed.getTime()) ? null : parsed;
}
function fmtActivityTime(ts){
const d = parseDateFlexible(ts);
if(!d) return '';
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
}
/* ══ MODEL CONFIG ══ */
@@ -2149,8 +2173,8 @@ function extractAgent(t){
function timeAgo(iso){
if(!iso) return '';
try{
const d = new Date(iso.includes('T')?iso:iso.replace(' ','T')+'Z');
if(isNaN(d.getTime())) return '';
const d = parseDateFlexible(iso);
if(!d) return '';
const diff = Date.now() - d.getTime();
const mins = Math.floor(diff/60000);
if(mins<0) return '刚刚';

View File

@@ -50,6 +50,7 @@ BASE = pathlib.Path(__file__).parent
DIST = BASE / 'dist' # React 构建产物 (npm run build)
DATA = BASE.parent / "data"
SCRIPTS = BASE.parent / 'scripts'
_ACTIVE_TASK_DATA_DIR = None
# 静态资源 MIME 类型
_MIME_TYPES = {
@@ -83,16 +84,67 @@ def cors_headers(h):
h.send_header('Access-Control-Allow-Headers', 'Content-Type')
def _iter_task_data_dirs():
"""返回可用的任务数据目录候选(优先 workspace其次本地 data"""
dirs = [DATA]
for p in sorted(OCLAW_HOME.glob('workspace-*/data')):
if p.is_dir():
dirs.append(p)
return dirs
def _task_source_score(task_file: pathlib.Path):
"""给任务源打分:优先非 demo 任务,其次任务数,再按文件更新时间。"""
try:
tasks = atomic_json_read(task_file, [])
except Exception:
tasks = []
if not isinstance(tasks, list):
tasks = []
non_demo = sum(1 for t in tasks if str((t or {}).get('id', '')) and not str((t or {}).get('id', '')).startswith('JJC-DEMO'))
try:
mtime = task_file.stat().st_mtime
except Exception:
mtime = 0
return (1 if non_demo > 0 else 0, non_demo, len(tasks), mtime)
def get_task_data_dir():
"""自动选择当前任务数据目录,并缓存结果以保持一次服务期内稳定。"""
global _ACTIVE_TASK_DATA_DIR
if _ACTIVE_TASK_DATA_DIR and _ACTIVE_TASK_DATA_DIR.is_dir():
return _ACTIVE_TASK_DATA_DIR
best_dir = DATA
best_score = (-1, -1, -1, -1)
for d in _iter_task_data_dirs():
tf = d / 'tasks_source.json'
if not tf.exists():
continue
score = _task_source_score(tf)
if score > best_score:
best_score = score
best_dir = d
_ACTIVE_TASK_DATA_DIR = best_dir
log.info(f'任务数据源: {_ACTIVE_TASK_DATA_DIR}')
return _ACTIVE_TASK_DATA_DIR
def load_tasks():
return atomic_json_read(DATA / 'tasks_source.json', [])
task_data_dir = get_task_data_dir()
return atomic_json_read(task_data_dir / 'tasks_source.json', [])
def save_tasks(tasks):
atomic_json_write(DATA / 'tasks_source.json', tasks)
task_data_dir = get_task_data_dir()
atomic_json_write(task_data_dir / 'tasks_source.json', tasks)
# Trigger refresh (异步,不阻塞,避免僵尸进程)
script = task_data_dir.parent / 'scripts' / 'refresh_live_data.py'
if not script.exists():
script = SCRIPTS / 'refresh_live_data.py'
def _refresh():
try:
subprocess.run(['python3', str(SCRIPTS / 'refresh_live_data.py')], timeout=30)
subprocess.run(['python3', str(script)], timeout=30)
except Exception as e:
log.warning(f'refresh_live_data.py 触发失败: {e}')
threading.Thread(target=_refresh, daemon=True).start()
@@ -2168,12 +2220,14 @@ class Handler(BaseHTTPRequestHandler):
if p in ('', '/dashboard', '/dashboard.html'):
self.send_file(DIST / 'index.html')
elif p == '/healthz':
checks = {'dataDir': DATA.is_dir(), 'tasksReadable': (DATA / 'tasks_source.json').exists()}
checks['dataWritable'] = os.access(str(DATA), os.W_OK)
task_data_dir = get_task_data_dir()
checks = {'dataDir': task_data_dir.is_dir(), 'tasksReadable': (task_data_dir / 'tasks_source.json').exists()}
checks['dataWritable'] = os.access(str(task_data_dir), os.W_OK)
all_ok = all(checks.values())
self.send_json({'status': 'ok' if all_ok else 'degraded', 'ts': now_iso(), 'checks': checks})
elif p == '/api/live-status':
self.send_json(read_json(DATA / 'live_status.json'))
task_data_dir = get_task_data_dir()
self.send_json(read_json(task_data_dir / 'live_status.json'))
elif p == '/api/agent-config':
self.send_json(read_json(DATA / 'agent_config.json'))
elif p == '/api/model-change-log':

View File

@@ -431,7 +431,7 @@ echo " 1. 配置 API Key如尚未配置:"
echo " openclaw agents add taizi # 按提示输入 Anthropic API Key"
echo " ./install.sh # 重新运行以同步到所有 Agent"
echo " 2. 启动数据刷新循环: bash scripts/run_loop.sh &"
echo " 3. 启动看板服务器: python3 dashboard/server.py"
echo " 3. 启动看板服务器: python3 \"\$REPO_DIR/dashboard/server.py\""
echo " 4. 打开看板: http://127.0.0.1:7891"
echo ""
warn "首次安装必须配置 API Key否则 Agent 会报错"

View File

@@ -68,7 +68,7 @@ def atomic_json_read(path: pathlib.Path, default: Any = None) -> Any:
try:
_lock_shared(fd)
try:
return json.loads(path.read_text()) if path.exists() else default
return json.loads(path.read_text(encoding='utf-8')) if path.exists() else default
except Exception:
return default
finally:
@@ -93,7 +93,7 @@ def atomic_json_update(
_lock_exclusive(fd)
# Read
try:
data = json.loads(path.read_text()) if path.exists() else default
data = json.loads(path.read_text(encoding='utf-8')) if path.exists() else default
except Exception:
data = default
# Modify