Files
Agent-System/scripts/agentctl_console.py
T
weichen f9f055bee1 feat(agentctl): add governance console P1 serve and action APIs
Implement loopback HTTP console with claim new, integrate prepare, and pi packet sharing the same helpers as CLI; ship SPA and MCP brief/claim_archive wrappers.
2026-09-24 14:13:27 +00:00

592 lines
26 KiB
Python

#!/usr/bin/env python3
"""Governance console helpers: claim_new, integrate_prepare, pi_packet, serve.
Shared by CLI and HTTP (same-process). Raises agentctl.Denied on refusal.
Returns payload dicts; CLI wrappers call emit().
"""
from __future__ import annotations
import argparse
import contextlib
import json
import mimetypes
import re
import tempfile
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Optional
from urllib.parse import parse_qs, urlparse
import agentctl as ac
import agentctl_visibility as visibility
try:
import pi_subagents as pi
except Exception: # noqa: BLE001 — optional for environments without full PI deps
pi = None
CURRENT_REL = '.doc/ops/CURRENT.md'
DEFAULT_HOST = '127.0.0.1'
DEFAULT_PORT = 7432
DEFAULT_STATIC = 'scripts/governance-console'
FORBIDDEN_BODY_KEYS = frozenset({'command', 'shell', 'argv'})
HTTP_STATUS = {
'INVALID_REQUEST': 400,
'CAPABILITY_REQUIRED': 403,
'PATH_OUT_OF_SCOPE': 403,
'BASE_CHANGED': 409,
'WORKTREE_MISMATCH': 409,
'LOCK_BUSY': 409,
'UNMANAGED_DOCUMENT_CHANGE': 409,
'DIRTY_TREE': 409,
'FROZEN': 409,
'EVIDENCE_REQUIRED': 422,
'REQUEST_ID_REUSED': 409,
'RECOVERY_REQUIRED': 500,
}
def _caps(task) -> list:
raw = task['capabilities']
return json.loads(raw) if isinstance(raw, str) else list(raw or [])
def _rel_worktree(path: Path, main: Path) -> str:
try:
return str(path.resolve().relative_to(main.resolve()))
except ValueError:
text = str(path)
if '/.worktrees/' in text.replace('\\', '/'):
return '.worktrees/' + text.replace('\\', '/').split('/.worktrees/', 1)[1]
return text
def claim_new(repo: ac.Repo, args) -> dict:
"""Create or replace a CURRENT claim row via single-row docs apply."""
task = ac.task_row(repo, args.task)
if repo.current != Path(task['worktree']) or repo.current == repo.main:
ac.deny('WORKTREE_MISMATCH', 'claim new must run inside the task worktree')
if 'docs:publish' not in _caps(task) and 'docs:admin' not in _caps(task):
ac.deny('CAPABILITY_REQUIRED', 'claim new requires docs:publish')
owner = getattr(args, 'owner', None) or task.get('owner') or 'Cursor'
branch = getattr(args, 'branch', None) or task.get('branch') or f"feature/{args.task}"
worktree = getattr(args, 'worktree', None) or _rel_worktree(Path(task['worktree']), repo.main)
mutex = getattr(args, 'mutex', None) or '-'
status = getattr(args, 'status', None) or 'in_progress'
if status not in ac.PROJECTION_STATUS_VOCAB:
ac.deny('INVALID_REQUEST', 'status must be a projection vocab value')
content = ac.safe_path(repo.current, CURRENT_REL).read_text(encoding='utf-8')
existing = ac.doc_rows(content, args.task)
expected = ac.digest(existing[0][1]) if existing else None
replacement = (
f"| {owner} | {args.task} | {branch} | {worktree} | {mutex} | {status} |"
)
if existing and existing[0][1].strip() == replacement.strip():
return {
'status': 'idempotent',
'record_id': args.task,
'request_id': getattr(args, 'request_id', None),
'row': replacement,
}
if existing and not getattr(args, 'replace', False):
ac.deny('INVALID_REQUEST', 'row exists; pass --replace')
request_id = getattr(args, 'request_id', None) or f"{args.task}-claim-new-{uuid.uuid4().hex[:8]}"
request = {
'request_id': request_id,
'changes': [{
'file': CURRENT_REL,
'record_id': args.task,
'expected_sha256': expected,
'replacement': replacement,
}],
}
req_path = Path(tempfile.mkstemp(prefix='agentctl-claim-new-', suffix='.json')[1])
try:
req_path.write_text(json.dumps(request, ensure_ascii=False), encoding='utf-8')
captured = []
with _capture_emit(captured):
ac.docs_apply(repo, argparse.Namespace(task=args.task, request=str(req_path)))
applied = next((c for c in captured if isinstance(c, dict)), {})
status = applied.get('status')
if status == 'replayed':
out_status = 'replayed'
else:
out_status = 'replaced' if existing else 'created'
return {
'status': out_status,
'record_id': args.task,
'request_id': request_id,
'run_id': applied.get('run_id'),
'row': replacement,
}
finally:
with contextlib.suppress(OSError):
req_path.unlink()
def integrate_prepare(repo: ac.Repo, args) -> dict:
"""Build integrate candidate worktree and --no-ff merge task branch."""
task = ac.task_row(repo, args.task)
expected = getattr(args, 'expected_dev', None)
if not expected or not re.fullmatch(r'[0-9a-f]{40}|[0-9a-f]{64}', expected):
ac.deny('INVALID_REQUEST', 'expected-dev must be a full commit hash')
tip = ac.git(repo.main, 'rev-parse', 'refs/heads/dev')
if tip != expected:
ac.deny('BASE_CHANGED', 'refs/heads/dev tip differs from expected-dev',
tip=tip, expected_dev=expected)
integrate_id = getattr(args, 'integrate_id', None) or f"integrate-{args.task}"
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}', integrate_id):
ac.deny('INVALID_REQUEST', 'Invalid integrate-id')
branch = f'feature/{integrate_id}'
integ_path = repo.main / '.worktrees' / integrate_id
task_branch = task['branch']
if not task_branch:
ac.deny('INVALID_REQUEST', 'Source task has no branch')
if integ_path.exists():
integ_repo = ac.Repo(str(integ_path))
head = ac.git(integ_path, 'rev-parse', 'HEAD')
first = ac.git(integ_path, 'rev-parse', 'HEAD^1', check=False)
if first.returncode == 0 and first.stdout.strip() == expected:
# Already prepared on this base.
return {
'status': 'prepared',
'integrate_task': integrate_id,
'worktree': str(integ_path),
'head': head,
'expected_dev': expected,
'first_parent': expected,
'idempotent': True,
}
porcelain = ac.git(integ_path, 'status', '--porcelain', '--untracked-files=all')
if porcelain:
ac.deny('INVALID_REQUEST', 'Existing integrate worktree is dirty',
path=str(integ_path))
# Different base: refuse rather than reset.
ac.deny('BASE_CHANGED', 'Integrate worktree exists for a different base; remove it first',
path=str(integ_path), head=head)
else:
integ_path.parent.mkdir(exist_ok=True)
ac.git(repo.main, 'worktree', 'add', '-b', branch, str(integ_path), expected)
# Register integrate task (must run as checkout identity).
_register_integrate_task(integ_path, integrate_id, owner=task.get('owner') or 'Cursor')
integ_repo = ac.Repo(str(integ_path))
integ_task = ac.task_row(integ_repo, integrate_id)
request_id = getattr(args, 'request_id', None) or f"{integrate_id}-prepare"
msg = f"merge: candidate {args.task} into dev"
with ac.Lease(integ_repo, integ_task, ['path:.', 'git:index', 'git:dev'],
request_id, ac.digest(msg + expected + task_branch), kind='integrate_prepare') as lease:
if lease.replay:
head = ac.git(integ_path, 'rev-parse', 'HEAD')
return {
'status': 'replayed',
'run_id': lease.request_id,
'integrate_task': integrate_id,
'worktree': str(integ_path),
'head': head,
'expected_dev': expected,
}
result = ac.git(integ_path, 'merge', '--no-ff', '-m', msg, task_branch, check=False)
if result.returncode != 0:
with contextlib.suppress(Exception):
ac.git(integ_path, 'merge', '--abort', check=False)
lease.finish(1, True)
ac.deny('GIT_ERROR', 'merge --no-ff failed', stderr=(result.stderr or '')[:500])
head = ac.git(integ_path, 'rev-parse', 'HEAD')
first_parent = ac.git(integ_path, 'rev-parse', 'HEAD^1')
if first_parent != expected:
lease.finish(1, True)
ac.deny('BASE_CHANGED', 'first parent is not expected-dev',
first_parent=first_parent, expected_dev=expected)
lease.finish()
return {
'status': 'prepared',
'integrate_task': integrate_id,
'worktree': str(integ_path),
'head': head,
'expected_dev': expected,
'first_parent': first_parent,
'run_id': request_id,
'idempotent': False,
}
def _register_integrate_task(integ_path: Path, integrate_id: str, owner: str):
"""Register integrate task from that checkout (subprocess avoids cwd gymnastics)."""
import subprocess
import sys
agentctl = Path(__file__).resolve()
cmd = [
sys.executable, '-B', str(agentctl.parent / 'agentctl.py'),
'task', 'register',
'--id', integrate_id, '--owner', owner,
'--scope', '.',
'--capability', 'git:dev',
'--capability', 'docs:publish',
]
proc = subprocess.run(cmd, cwd=str(integ_path), capture_output=True, text=True)
if proc.returncode != 0:
# Already registered with same identity is ok if output says registered/exists matching.
err = (proc.stderr or proc.stdout or '').strip()
try:
payload = json.loads(err.splitlines()[-1])
except Exception:
payload = {}
if payload.get('code') == 'TASK_EXISTS':
return
if payload.get('status') == 'registered':
return
ac.deny('INVALID_REQUEST', 'Failed to register integrate task', detail=err[:500])
def pi_packet(repo: ac.Repo, args) -> dict:
"""Build PI dispatch packet JSON; optional write under report assets."""
task = ac.task_row(repo, args.task)
wt = Path(task['worktree'])
role = getattr(args, 'role', None) or getattr(args, 'role_id', None)
known = getattr(pi, 'KNOWN_ROLES', None) if pi else None
if not role:
ac.deny('INVALID_REQUEST', 'role is required')
if known is not None and role not in known:
ac.deny('UNKNOWN_ROLE', 'Unknown role_id', role_id=role)
goal = getattr(args, 'goal', None)
auth = getattr(args, 'authorization_ref', None)
if not goal or not auth:
ac.deny('INVALID_REQUEST', 'goal and authorization-ref are required')
raw_sha = getattr(args, 'source_sha', None)
if not raw_sha:
raw_sha = ac.git(wt, 'rev-parse', 'HEAD')
if pi is not None:
try:
source_sha = pi.freeze_source_sha(wt, raw_sha)
except Exception as exc: # ExecutorError
code = getattr(exc, 'code', 'INVALID_SOURCE_SHA')
ac.deny(code, getattr(exc, 'message', str(exc)), **getattr(exc, 'details', {}))
else:
if not re.fullmatch(r'[0-9a-f]{40}|[0-9a-f]{64}', raw_sha):
ac.deny('INVALID_SOURCE_SHA', 'source_sha must be a full commit hash')
source_sha = raw_sha
request_id = getattr(args, 'request_id', None) or str(uuid.uuid4())
rid_re = getattr(pi, 'REQUEST_ID_RE', re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$'))
if not rid_re.fullmatch(request_id):
ac.deny('INVALID_REQUEST', 'Invalid request_id')
input_files = list(getattr(args, 'input_file', None) or getattr(args, 'input_files', None) or [])
approved = []
for item in (getattr(args, 'approved_command', None) or []):
if isinstance(item, list):
approved.append(item)
elif isinstance(item, str):
approved.append(item.split())
else:
ac.deny('INVALID_REQUEST', 'approved-command must be string or list')
timeout = int(getattr(args, 'timeout_seconds', None) or 600)
packet = {
'task_id': args.task,
'request_id': request_id,
'role_id': role,
'goal': goal,
'authorization_ref': auth,
'source_sha': source_sha,
'input_files': input_files,
'timeout_seconds': timeout,
'approved_commands': approved,
}
wrote = None
if getattr(args, 'write', False):
rel_root = getattr(pi, 'REPORT_ROOT_REL', '.doc/test-reports/assets/pi-subagents')
rel = f"{rel_root}/{args.task}/{request_id}/packet.json"
# Write under task worktree with lease.
if repo.current != wt:
ac.deny('WORKTREE_MISMATCH', 'pi packet --write must run in the task worktree')
dest = ac.safe_path(repo.current, rel)
dest.parent.mkdir(parents=True, exist_ok=True)
body = json.dumps(packet, ensure_ascii=False, indent=2) + '\n'
with ac.Lease(repo, task, [f'path:{rel}'], f"{args.task}-pi-packet-{request_id}",
ac.digest(body), kind='pi_packet') as lease:
if lease.replay:
return {'status': 'replayed', 'run_id': lease.request_id, 'packet': packet, 'path': rel}
ac.atomic_write(dest, body)
lease.finish()
wrote = rel
return {'status': 'ok', 'packet': packet, 'path': wrote}
@contextlib.contextmanager
def _capture_emit(bucket: list):
original = ac.emit
def _cap(value, stream=None):
bucket.append(value)
ac.emit = _cap
try:
yield bucket
finally:
ac.emit = original
def denied_http_status(code: str) -> int:
if code in HTTP_STATUS:
return HTTP_STATUS[code]
if code.startswith('UNKNOWN_'):
return 404
return 500
def build_brief_text(repo: ac.Repo) -> str:
status = ac.collect_status_payload(repo)
try:
docs = ac.collect_docs_drift_payload(repo)
except Exception as exc: # noqa: BLE001
docs = {'status': 'error', 'error': str(exc)}
try:
cleanup = ac.collect_cleanup_payload(repo)
except Exception as exc: # noqa: BLE001
cleanup = {'status': 'error', 'error': str(exc), 'worktrees': []}
try:
cat = ac.collect_catalog_drift_payload(repo)
except Exception as exc: # noqa: BLE001
cat = {'status': 'error', 'error': str(exc)}
try:
daemon = ac.collect_daemon_payload(repo)
except Exception as exc: # noqa: BLE001
daemon = {'status': 'error', 'error': str(exc)}
return visibility.build_brief(
status=status, docs_drift=docs, cleanup=cleanup,
catalog_drift=cat, daemon=daemon,
)
def _queue_payload(repo: ac.Repo, all_items: bool = False) -> dict:
states = None if all_items else ('pending', 'claimed')
with repo.db(True) as db:
if states:
rows = db.execute(
"SELECT * FROM queue_items WHERE state IN (?,?) ORDER BY priority DESC, created ASC",
states).fetchall()
else:
rows = db.execute('SELECT * FROM queue_items ORDER BY created ASC').fetchall()
return {'status': 'ok', 'items': [ac.queue_row(r) for r in rows]}
def _claims_payload(repo: ac.Repo, all_items: bool = False) -> dict:
with repo.db() as db:
try:
if all_items:
rows = [dict(r) for r in db.execute(
'SELECT * FROM claims ORDER BY updated DESC').fetchall()]
else:
rows = [dict(r) for r in db.execute(
"SELECT * FROM claims WHERE state='held' ORDER BY updated DESC").fetchall()]
except Exception:
rows = []
return {'status': 'ok', 'claims': rows}
def serve(repo: ac.Repo, args) -> int:
host = getattr(args, 'host', None) or DEFAULT_HOST
port = int(getattr(args, 'port', None) or DEFAULT_PORT)
if host not in ('127.0.0.1', 'localhost', '::1'):
ac.deny('INVALID_REQUEST', 'serve host must be loopback (127.0.0.1)')
static_rel = getattr(args, 'static', None) or DEFAULT_STATIC
static_root = (repo.main / static_rel).resolve()
if not str(static_root).startswith(str(repo.main.resolve())):
ac.deny('PATH_OUT_OF_SCOPE', 'static root must stay under main worktree')
# Prefer main tip; if not yet integrated, allow the same relative path from
# the current checkout when it remains under the main worktree tree.
if not (static_root / 'index.html').is_file():
alt = (repo.current / static_rel).resolve()
if str(alt).startswith(str(repo.main.resolve())) and (alt / 'index.html').is_file():
static_root = alt
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *a):
pass
def _send(self, code: int, body: Any, content_type='application/json; charset=utf-8'):
if isinstance(body, (dict, list)):
data = json.dumps(body, ensure_ascii=False, sort_keys=True).encode('utf-8')
elif isinstance(body, str):
data = body.encode('utf-8')
else:
data = body
self.send_response(code)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(data)
def _read_json(self) -> dict:
length = int(self.headers.get('Content-Length') or 0)
if length > 1_000_000:
ac.deny('INVALID_REQUEST', 'request body too large')
raw = self.rfile.read(length) if length else b'{}'
try:
data = json.loads(raw.decode('utf-8') or '{}')
except Exception:
ac.deny('INVALID_REQUEST', 'JSON body required')
if not isinstance(data, dict):
ac.deny('INVALID_REQUEST', 'JSON object required')
if FORBIDDEN_BODY_KEYS & set(data):
ac.deny('INVALID_REQUEST', 'command/shell/argv fields are forbidden')
return data
def do_GET(self): # noqa: N802
try:
parsed = urlparse(self.path)
path = parsed.path
qs = parse_qs(parsed.query)
fmt = (qs.get('format') or ['json'])[0]
all_items = (qs.get('all') or ['0'])[0] in ('1', 'true', 'yes')
if path == '/api/status':
payload = ac.collect_status_payload(repo)
if fmt == 'table':
return self._send(200, visibility.render_status_table(payload), 'text/plain; charset=utf-8')
return self._send(200, payload)
if path == '/api/brief':
brief_fmt = (qs.get('format') or ['markdown'])[0]
text = build_brief_text(repo)
if brief_fmt == 'json':
return self._send(200, {'status': 'ok', 'brief': text})
return self._send(200, text, 'text/markdown; charset=utf-8')
if path == '/api/queue':
payload = _queue_payload(repo, all_items)
if fmt == 'table':
return self._send(200, visibility.render_queue_table(payload), 'text/plain; charset=utf-8')
return self._send(200, payload)
if path == '/api/claims':
payload = _claims_payload(repo, all_items)
if fmt == 'table':
return self._send(200, visibility.render_claim_table(payload), 'text/plain; charset=utf-8')
return self._send(200, payload)
if path == '/api/cleanup':
payload = ac.collect_cleanup_payload(repo)
if fmt == 'table':
return self._send(200, visibility.render_cleanup_table(payload), 'text/plain; charset=utf-8')
return self._send(200, payload)
if path == '/api/docs/drift':
return self._send(200, ac.collect_docs_drift_payload(repo))
if path == '/api/catalog/drift':
return self._send(200, ac.collect_catalog_drift_payload(repo))
if path == '/api/daemon':
return self._send(200, ac.collect_daemon_payload(repo))
if path in ('/', '/index.html'):
return self._static('index.html')
if path.startswith('/static/'):
return self._static(path[len('/static/'):])
return self._send(404, {'status': 'denied', 'code': 'NOT_FOUND', 'message': path})
except ac.Denied as exc:
return self._send(denied_http_status(exc.code), {
'status': 'denied', 'code': exc.code, 'message': exc.message, **exc.details,
})
except Exception as exc: # noqa: BLE001
return self._send(500, {
'status': 'denied', 'code': 'RECOVERY_REQUIRED', 'message': str(exc)[:300],
})
def do_POST(self): # noqa: N802
try:
path = urlparse(self.path).path
body = self._read_json()
if path == '/api/claim/new':
ns = argparse.Namespace(
task=body.get('task'),
owner=body.get('owner'),
mutex=body.get('mutex', '-'),
branch=body.get('branch'),
worktree=body.get('worktree'),
status=body.get('status', 'in_progress'),
replace=bool(body.get('replace')),
request_id=body.get('request_id'),
)
if not ns.task:
ac.deny('INVALID_REQUEST', 'task required')
# claim_new must run in task worktree
task = ac.task_row(repo, ns.task)
task_repo = ac.Repo(task['worktree'])
return self._send(200, claim_new(task_repo, ns))
if path == '/api/claim/archive':
ns = argparse.Namespace(
task=body.get('task'),
retain_days=int(body.get('retain_days', 7)),
include_undated=bool(body.get('include_undated')),
apply=True,
)
if not ns.task:
ac.deny('INVALID_REQUEST', 'task required')
task = ac.task_row(repo, ns.task)
task_repo = ac.Repo(task['worktree'])
captured = []
with _capture_emit(captured):
ac.claim_archive(task_repo, ns)
return self._send(200, captured[-1] if captured else {'status': 'ok'})
if path == '/api/integrate/prepare':
ns = argparse.Namespace(
task=body.get('task'),
expected_dev=body.get('expected_dev'),
integrate_id=body.get('integrate_id'),
request_id=body.get('request_id'),
)
if not ns.task or not ns.expected_dev:
ac.deny('INVALID_REQUEST', 'task and expected_dev required')
return self._send(200, integrate_prepare(repo, ns))
if path == '/api/pi/packet':
ns = argparse.Namespace(
task=body.get('task'),
role=body.get('role') or body.get('role_id'),
goal=body.get('goal'),
authorization_ref=body.get('authorization_ref'),
source_sha=body.get('source_sha'),
timeout_seconds=body.get('timeout_seconds', 600),
input_file=body.get('input_files') or [],
approved_command=body.get('approved_commands') or [],
request_id=body.get('request_id'),
write=bool(body.get('write')),
)
if not ns.task:
ac.deny('INVALID_REQUEST', 'task required')
task = ac.task_row(repo, ns.task)
task_repo = ac.Repo(task['worktree']) if ns.write else repo
return self._send(200, pi_packet(task_repo, ns))
return self._send(404, {'status': 'denied', 'code': 'NOT_FOUND', 'message': path})
except ac.Denied as exc:
return self._send(denied_http_status(exc.code), {
'status': 'denied', 'code': exc.code, 'message': exc.message, **exc.details,
})
except Exception as exc: # noqa: BLE001
return self._send(500, {
'status': 'denied', 'code': 'RECOVERY_REQUIRED', 'message': str(exc)[:300],
})
def _static(self, name: str):
name = name.lstrip('/')
if '..' in name or name.startswith('/'):
return self._send(400, {'status': 'denied', 'code': 'INVALID_REQUEST', 'message': 'bad path'})
target = (static_root / name).resolve()
if not str(target).startswith(str(static_root)) or not target.is_file():
return self._send(404, {'status': 'denied', 'code': 'NOT_FOUND', 'message': name})
ctype = mimetypes.guess_type(str(target))[0] or 'application/octet-stream'
return self._send(200, target.read_bytes(), ctype)
server = ThreadingHTTPServer((host, port), Handler)
ac.emit({
'status': 'serving',
'host': host,
'port': port,
'static': str(static_root),
'url': f'http://{host}:{port}/',
})
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
return 0