#!/usr/bin/env python3 """Pure rendering / brief / CURRENT claim-archive helpers for agentctl. No I/O against the live repo except via callers. Safe to unit-test in isolation. """ from __future__ import annotations import datetime as _dt import re from typing import Any, Iterable, Optional TASK_DATE_RE = re.compile(r'(? str: """Render a plain monospace table. Empty rows → header-only block.""" cols = [str(h) for h in headers] body = [[_cell(v) for v in row] for row in rows] widths = [len(c) for c in cols] for row in body: for i, cell in enumerate(row): if i < len(widths): widths[i] = max(widths[i], len(cell)) else: widths.append(len(cell)) while len(row) < len(widths): row.append('') def line(cells): return ' '.join(cell.ljust(widths[i]) for i, cell in enumerate(cells)) out = [line(cols), line(['-' * w for w in widths])] out.extend(line(row) for row in body) return '\n'.join(out) def _cell(value: Any) -> str: if value is None: return '-' text = str(value) return text.replace('\n', ' ').strip() or '-' def _duration(created: Any, now: Optional[float] = None) -> str: try: ts = float(created) except (TypeError, ValueError): return '-' age = (now if now is not None else _dt.datetime.now().timestamp()) - ts if age < 0: age = 0 if age < 60: return f'{int(age)}s' if age < 3600: return f'{int(age // 60)}m' if age < 86400: return f'{int(age // 3600)}h' return f'{int(age // 86400)}d' def render_status_table(payload: dict, *, now: Optional[float] = None) -> str: """Human table for `agentctl status` payload.""" lines = [ f"agentctl status main={payload.get('main', '-')} " f"epoch={_short(payload.get('epoch'))} version={payload.get('version', '-')}", '', ] tasks = payload.get('tasks') or [] active = [t for t in tasks if t.get('state') not in ('done', 'cancelled')] terminal = [t for t in tasks if t.get('state') in ('done', 'cancelled')] lines.append(f'## tasks active={len(active)} closed={len(terminal)} total={len(tasks)}') task_rows = [ [ t.get('id'), t.get('owner'), t.get('state'), _short_path(t.get('worktree')), _short(t.get('branch')), ] for t in active ] lines.append(format_table(['task', 'owner', 'state', 'worktree', 'branch'], task_rows) if task_rows else '(no active tasks)') lines.append('') runs = payload.get('runs') or [] lines.append(f'## runs live={len(runs)}') run_rows = [ [ r.get('id'), r.get('task'), r.get('state'), r.get('supervisor_alive'), _short(r.get('kind') or r.get('action')), ] for r in runs ] lines.append(format_table(['run', 'task', 'state', 'alive', 'kind'], run_rows) if run_rows else '(no live runs)') lines.append('') queue = payload.get('queue') or [] lines.append(f'## queue depth={len(queue)}') q_rows = [ [ q.get('id'), q.get('task'), q.get('state'), q.get('priority'), _short(q.get('head_sha')), _duration(q.get('created'), now), ] for q in queue ] lines.append(format_table(['id', 'task', 'state', 'pri', 'head', 'age'], q_rows) if q_rows else '(queue empty)') policy = payload.get('policy') or {} lines.append('') lines.append( f"## policy loaded={policy.get('loaded', policy.get('status', '-'))} " f"revision={policy.get('policy_revision') or policy.get('revision') or '-'}" ) return '\n'.join(lines) + '\n' def render_queue_table(payload: dict, *, now: Optional[float] = None) -> str: items = payload.get('items') or [] rows = [ [ i.get('id'), i.get('task'), i.get('state'), i.get('priority'), _short(i.get('head_sha')), _short(i.get('expected_dev')), _duration(i.get('created'), now), ] for i in items ] header = f"queue list count={len(items)}\n" body = format_table(['id', 'task', 'state', 'pri', 'head', 'expected_dev', 'age'], rows) if rows \ else '(queue empty)' return header + body + '\n' def render_claim_table(payload: dict, *, now: Optional[float] = None) -> str: claims = payload.get('claims') or [] rows = [ [ c.get('id') or c.get('resource'), c.get('task'), c.get('resource'), c.get('state'), _duration(c.get('updated') or c.get('created'), now), ] for c in claims ] header = f"claim list count={len(claims)}\n" body = format_table(['id', 'task', 'resource', 'state', 'age'], rows) if rows else '(no claims)' return header + body + '\n' def render_cleanup_table(payload: dict) -> str: trees = payload.get('worktrees') or [] ready = [t for t in trees if t.get('cleanup_allowed') or t.get('status') == 'allowed'] blocked = [t for t in trees if t not in ready] rows = [] for t in trees: status = 'allowed' if (t.get('cleanup_allowed') or t.get('status') == 'allowed') else 'blocked' reason = t.get('blocked_reason') or t.get('reason') or '' if not reason and isinstance(t.get('gates'), list): fails = [g.get('name') for g in t['gates'] if g.get('status') != 'pass'] reason = ','.join(fails[:3]) rows.append([ _short_path(t.get('path') or t.get('worktree')), status, t.get('cleanup_owner') or '-', reason or '-', ]) header = ( f"cleanup report total={len(trees)} allowed={len(ready)} " f"blocked/pending={len(blocked)}\n" ) body = format_table(['path', 'status', 'owner', 'reason'], rows) if rows else '(no worktrees)' return header + body + '\n' def build_brief( *, status: Optional[dict] = None, docs_drift: Optional[dict] = None, cleanup: Optional[dict] = None, catalog_drift: Optional[dict] = None, daemon: Optional[dict] = None, now: Optional[float] = None, ) -> str: """Aggregate read-only sources into a one-page Markdown briefing.""" status = status or {} docs_drift = docs_drift or {} cleanup = cleanup or {} catalog_drift = catalog_drift or {} daemon = daemon or {} tasks = status.get('tasks') or [] active = [t for t in tasks if t.get('state') not in ('done', 'cancelled')] blocked = [t for t in active if t.get('state') == 'blocked'] runs = status.get('runs') or [] queue = status.get('queue') or [] trees = cleanup.get('worktrees') or [] pending_cleanup = [ t for t in trees if not (t.get('cleanup_allowed') or t.get('status') == 'allowed') ] regressions = docs_drift.get('regressions') or catalog_drift.get('regressions') or [] hard = [r for r in regressions if r.get('severity') == 'hard' or 'done' in str(r.get('message', ''))] stamp = _dt.datetime.fromtimestamp(now or _dt.datetime.now().timestamp()).strftime('%Y-%m-%d %H:%M') lines = [ f'# agentctl brief ({stamp})', '', f"- daemon: **{daemon.get('status', 'unknown')}** " f"manager.lock.held={((daemon.get('manager_lock') or {}).get('held'))} " f"queue pending={((daemon.get('queue') or {}).get('pending', '-'))}", f"- tasks: active={len(active)} blocked={len(blocked)} " f"live runs={len(runs)} integrate queue={len(queue)}", f"- cleanup pending: {len(pending_cleanup)} / {len(trees)} worktrees", f"- MATRIX drift hard alerts: {len(hard)}", '', '## Active tasks', ] if active: for t in active[:20]: lines.append( f"- `{t.get('id')}` owner={t.get('owner')} state={t.get('state')} " f"wt={_short_path(t.get('worktree'))}" ) if len(active) > 20: lines.append(f'- … and {len(active) - 20} more') else: lines.append('- (none)') lines.append('') lines.append('## Blockers / alerts') if blocked: for t in blocked: lines.append(f"- blocked task `{t.get('id')}`") if hard: for r in hard[:10]: lines.append( f"- MATRIX regression `{r.get('record_id')}`: " f"{r.get('previous')}→{r.get('current')}" ) if pending_cleanup: lines.append(f"- cleanup backlog: {len(pending_cleanup)} worktrees not removable") if not blocked and not hard and not pending_cleanup: lines.append('- (none)') lines.append('') lines.append('## Integrate queue') if queue: for q in queue[:10]: lines.append( f"- `{q.get('task')}` state={q.get('state')} pri={q.get('priority')} " f"head={_short(q.get('head_sha'))}" ) else: lines.append('- (empty)') return '\n'.join(lines) + '\n' def parse_current_claim_rows(content: str) -> list[dict]: """Extract parallel-claim table rows from CURRENT.md body.""" section = _claim_section_body(content) if section is None: return [] rows = [] for line in section.splitlines(): stripped = line.strip() if not stripped.startswith('|'): continue cells = [c.strip() for c in stripped.strip('|').split('|')] if not cells or len(cells) < 6: continue if cells[0].casefold() in ('owner', '---') or set(cells[0]) <= {'-', ':'}: continue if all(re.fullmatch(r':?-+:?', c.replace(' ', '') or '-') for c in cells): continue rows.append({ 'owner': cells[0], 'task': cells[1], 'branch': cells[2], 'worktree': cells[3], 'mutex': cells[4], 'status': cells[5], 'line': stripped, }) return rows def parse_task_date(task_id: str) -> Optional[_dt.date]: match = TASK_DATE_RE.search(task_id or '') if not match: return None try: return _dt.date(int(match.group(1)), int(match.group(2)), int(match.group(3))) except ValueError: return None def select_archive_candidates( rows: list[dict], *, retain_days: int = 7, today: Optional[_dt.date] = None, include_undated: bool = False, ) -> list[dict]: """Pick status=done rows older than retain_days (by date embedded in task id).""" today = today or _dt.date.today() cutoff = today - _dt.timedelta(days=max(0, int(retain_days))) selected = [] for row in rows: if (row.get('status') or '').strip() != 'done': continue parsed = parse_task_date(row.get('task') or '') entry = dict(row) entry['parsed_date'] = parsed.isoformat() if parsed else None if parsed is None: entry['eligible'] = bool(include_undated) entry['reason'] = 'undated' if include_undated else 'undated_skipped' selected.append(entry) continue if parsed <= cutoff: entry['eligible'] = True entry['reason'] = f'older_than_{retain_days}d' else: entry['eligible'] = False entry['reason'] = 'within_retain_window' selected.append(entry) return selected def build_archived_current(content: str, remove_task_ids: Iterable[str]) -> str: """Return CURRENT.md with listed claim rows removed from the parallel table.""" remove = set(remove_task_ids) start, end = _claim_section_span(content) if start is None: raise ValueError('CURRENT.md missing ## 并行认领 section') lines = content.splitlines(keepends=True) kept = [] for line in lines[start:end]: stripped = line.strip() if stripped.startswith('|'): cells = [c.strip() for c in stripped.strip('|').split('|')] if len(cells) >= 2 and cells[1] in remove: continue kept.append(line) return ''.join(lines[:start] + kept + lines[end:]) def build_archive_section(rows: list[dict], archive_date: str) -> str: """Markdown fragment to append to the CURRENT history archive.""" lines = [ '', f'## 并行认领归档 {archive_date}', '', f'由 `agentctl claim archive` 从 CURRENT 迁出(status=done)。共 {len(rows)} 行。', '', CLAIM_HEADER, '|---|---|---|---|---|---|', ] for row in rows: lines.append( f"| {row.get('owner', '')} | {row.get('task', '')} | {row.get('branch', '')} | " f"{row.get('worktree', '')} | {row.get('mutex', '')} | {row.get('status', '')} |" ) lines.append('') return '\n'.join(lines) def _claim_section_body(content: str) -> Optional[str]: start, end = _claim_section_span(content) if start is None: return None return ''.join(content.splitlines(keepends=True)[start:end]) def _claim_section_span(content: str) -> tuple[Optional[int], Optional[int]]: lines = content.splitlines(keepends=True) start = None for i, line in enumerate(lines): if line.startswith(CLAIM_SECTION): start = i break if start is None: return None, None end = len(lines) for j in range(start + 1, len(lines)): if lines[j].startswith('## '): end = j break return start, end def _short(value: Any, n: int = 12) -> str: if value is None: return '-' text = str(value) return text if len(text) <= n else text[:n] def _short_path(value: Any) -> str: if not value: return '-' text = str(value).replace('\\', '/') if '/.worktrees/' in text: return '.worktrees/' + text.split('/.worktrees/', 1)[1] return text if len(text) <= 48 else '…' + text[-47:]