Port deploy phase transition guards and cleanup markdown projection into agentctl_w4, delete the unused automation module, and pin illegal jumps with a regression test.
740 lines
29 KiB
Python
740 lines
29 KiB
Python
#!/usr/bin/env python3
|
|
"""W4 automation helpers for agentctl: durable jobs, doc projection, deploy loop.
|
|
|
|
Linux-first. Process liveness reuses the host agentctl proc/alive/boot helpers
|
|
passed in as callbacks so a future macOS process layer can plug in without
|
|
rewriting queue semantics.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Optional
|
|
|
|
SCHEMA_SQL = '''
|
|
CREATE TABLE IF NOT EXISTS jobs(
|
|
id TEXT PRIMARY KEY,
|
|
task TEXT NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
state TEXT NOT NULL,
|
|
priority INTEGER NOT NULL DEFAULT 100,
|
|
not_before REAL NOT NULL,
|
|
created REAL NOT NULL,
|
|
updated REAL NOT NULL,
|
|
lease_run TEXT,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
last_error TEXT,
|
|
result TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS jobs_ready ON jobs(state, not_before, priority, created);
|
|
CREATE TABLE IF NOT EXISTS deployments(
|
|
id TEXT PRIMARY KEY,
|
|
task TEXT NOT NULL,
|
|
env TEXT NOT NULL,
|
|
expected_commit TEXT NOT NULL,
|
|
manifest TEXT,
|
|
phase TEXT NOT NULL,
|
|
dry_run INTEGER NOT NULL DEFAULT 0,
|
|
created REAL NOT NULL,
|
|
updated REAL NOT NULL,
|
|
receipt TEXT,
|
|
last_error TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS deployments_task ON deployments(task, phase);
|
|
CREATE TABLE IF NOT EXISTS projections(
|
|
file TEXT NOT NULL,
|
|
record_id TEXT NOT NULL,
|
|
task TEXT NOT NULL,
|
|
source TEXT NOT NULL,
|
|
content_hash TEXT NOT NULL,
|
|
updated REAL NOT NULL,
|
|
PRIMARY KEY(file, record_id)
|
|
);
|
|
'''
|
|
|
|
JOB_KINDS = frozenset({
|
|
'validate', 'integrate', 'with', 'deploy_prepare', 'deploy_run',
|
|
'deploy_accept', 'docs_project', 'cleanup_inspect', 'noop',
|
|
})
|
|
JOB_ACTIVE = ('pending', 'leased', 'running')
|
|
JOB_TERMINAL = ('completed', 'failed', 'cancelled')
|
|
DEPLOY_PHASES = (
|
|
'prepared', 'deploying', 'accepting', 'passed', 'failed', 'recovery_required',
|
|
)
|
|
# Beyond CURRENT/MATRIX: any Markdown table under .doc/ may be projected when
|
|
# registered here. Layout changes still require docs:admin via docs apply.
|
|
PROJECTABLE_DOCS = frozenset({
|
|
'.doc/ops/CURRENT.md',
|
|
'.doc/status/FEATURE-MATRIX.md',
|
|
'.doc/ops/CURRENT.md', # duplicate ok in set
|
|
})
|
|
PROJECTABLE_PREFIXES = ('.doc/ops/', '.doc/status/', '.doc/remediation/')
|
|
|
|
|
|
def ensure_schema(db: sqlite3.Connection) -> None:
|
|
db.executescript(SCHEMA_SQL)
|
|
|
|
|
|
def _now() -> float:
|
|
return time.time()
|
|
|
|
|
|
def queue_enqueue(db, *, task: str, kind: str, payload: dict, priority: int = 100,
|
|
not_before: Optional[float] = None, job_id: Optional[str] = None) -> dict:
|
|
if kind not in JOB_KINDS:
|
|
raise ValueError(f'unsupported job kind: {kind}')
|
|
if not isinstance(payload, dict):
|
|
raise ValueError('payload must be an object')
|
|
jid = job_id or str(uuid.uuid4())
|
|
if not re.fullmatch(r'[A-Za-z0-9._-]{1,128}', jid):
|
|
raise ValueError('invalid job id')
|
|
ts = _now()
|
|
when = float(not_before) if not_before is not None else ts
|
|
existing = db.execute('SELECT * FROM jobs WHERE id=?', (jid,)).fetchone()
|
|
if existing:
|
|
if (existing['task'] != task or existing['kind'] != kind
|
|
or existing['payload'] != json.dumps(payload, sort_keys=True)):
|
|
raise ValueError('job id reused with different payload')
|
|
return dict(existing)
|
|
db.execute(
|
|
'''INSERT INTO jobs(id,task,kind,payload,state,priority,not_before,created,updated)
|
|
VALUES(?,?,?,?,?,?,?,?,?)''',
|
|
(jid, task, kind, json.dumps(payload, sort_keys=True), 'pending',
|
|
int(priority), when, ts, ts))
|
|
return dict(db.execute('SELECT * FROM jobs WHERE id=?', (jid,)).fetchone())
|
|
|
|
|
|
def queue_list(db, *, task: Optional[str] = None, state: Optional[str] = None,
|
|
limit: int = 50) -> list:
|
|
clauses, args = [], []
|
|
if task:
|
|
clauses.append('task=?')
|
|
args.append(task)
|
|
if state:
|
|
clauses.append('state=?')
|
|
args.append(state)
|
|
where = (' WHERE ' + ' AND '.join(clauses)) if clauses else ''
|
|
rows = db.execute(
|
|
f'SELECT * FROM jobs{where} ORDER BY priority ASC, created ASC LIMIT ?',
|
|
(*args, max(1, min(int(limit), 500)))).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def queue_cancel(db, job_id: str, reason: str) -> dict:
|
|
row = db.execute('SELECT * FROM jobs WHERE id=?', (job_id,)).fetchone()
|
|
if not row:
|
|
raise KeyError(job_id)
|
|
if row['state'] in JOB_TERMINAL:
|
|
return dict(row)
|
|
if row['state'] == 'running':
|
|
raise RuntimeError('cannot cancel a running job; recover the lease first')
|
|
db.execute(
|
|
"UPDATE jobs SET state='cancelled',last_error=?,updated=? WHERE id=?",
|
|
(reason, _now(), job_id))
|
|
return dict(db.execute('SELECT * FROM jobs WHERE id=?', (job_id,)).fetchone())
|
|
|
|
|
|
def claim_next_job(db, *, worker: str) -> Optional[dict]:
|
|
"""Atomically lease the next due pending job. Caller must be in a write txn."""
|
|
ts = _now()
|
|
row = db.execute(
|
|
'''SELECT * FROM jobs WHERE state='pending' AND not_before<=?
|
|
ORDER BY priority ASC, created ASC LIMIT 1''', (ts,)).fetchone()
|
|
if not row:
|
|
return None
|
|
db.execute(
|
|
'''UPDATE jobs SET state='leased',lease_run=?,attempts=attempts+1,updated=?
|
|
WHERE id=? AND state='pending' ''',
|
|
(worker, ts, row['id']))
|
|
claimed = db.execute('SELECT * FROM jobs WHERE id=?', (row['id'],)).fetchone()
|
|
if not claimed or claimed['state'] != 'leased':
|
|
return None
|
|
return dict(claimed)
|
|
|
|
|
|
def finish_job(db, job_id: str, *, ok: bool, result: Optional[dict] = None,
|
|
error: Optional[str] = None) -> dict:
|
|
state = 'completed' if ok else 'failed'
|
|
db.execute(
|
|
'''UPDATE jobs SET state=?,result=?,last_error=?,updated=? WHERE id=?''',
|
|
(state, json.dumps(result or {}, sort_keys=True), error, _now(), job_id))
|
|
return dict(db.execute('SELECT * FROM jobs WHERE id=?', (job_id,)).fetchone())
|
|
|
|
|
|
def mark_job_running(db, job_id: str) -> None:
|
|
db.execute("UPDATE jobs SET state='running',updated=? WHERE id=?", (_now(), job_id))
|
|
|
|
|
|
def projectable_file(path: str) -> bool:
|
|
if path in PROJECTABLE_DOCS or path in {'.doc/ops/CURRENT.md', '.doc/status/FEATURE-MATRIX.md'}:
|
|
return True
|
|
return any(path.startswith(prefix) for prefix in PROJECTABLE_PREFIXES) and path.endswith('.md')
|
|
|
|
|
|
def build_projection_row(*, task_id: str, owner: str, branch: str, worktree: str,
|
|
mutex: str, status: str, extra: Optional[dict] = None) -> str:
|
|
"""Canonical CURRENT-style row used for machine→doc projection."""
|
|
cells = [owner, task_id, branch, worktree, mutex, status]
|
|
if extra:
|
|
for key in sorted(extra):
|
|
cells.append(str(extra[key]))
|
|
return '| ' + ' | '.join(cells) + ' |'
|
|
|
|
|
|
def record_projection(db, *, file: str, record_id: str, task: str, source: str,
|
|
content_hash: str) -> None:
|
|
db.execute(
|
|
'''INSERT INTO projections(file,record_id,task,source,content_hash,updated)
|
|
VALUES(?,?,?,?,?,?)
|
|
ON CONFLICT(file,record_id) DO UPDATE SET
|
|
task=excluded.task, source=excluded.source,
|
|
content_hash=excluded.content_hash, updated=excluded.updated''',
|
|
(file, record_id, task, source, content_hash, _now()))
|
|
|
|
|
|
def content_digest(text: str) -> str:
|
|
return hashlib.sha256(text.encode()).hexdigest()
|
|
|
|
|
|
def build_row_projection_change(*, file: str, record_id: str, current_row: Optional[str],
|
|
replacement: str) -> dict:
|
|
"""Docs-apply compatible single-row change (CAS on the existing row text)."""
|
|
if not isinstance(replacement, str) or len(replacement.splitlines()) != 1 \
|
|
or not replacement.startswith('|'):
|
|
raise ValueError('replacement must be one Markdown table row')
|
|
return {
|
|
'file': file,
|
|
'record_id': record_id,
|
|
'expected_sha256': content_digest(current_row) if current_row else None,
|
|
'replacement': replacement,
|
|
}
|
|
|
|
|
|
def build_document_projection_change(*, file: str, current_text: str,
|
|
replacement: str) -> dict:
|
|
"""Docs-apply compatible whole-document change (CAS on full file hash)."""
|
|
if not isinstance(replacement, str):
|
|
raise ValueError('replacement must be full document text')
|
|
return {
|
|
'file': file,
|
|
'record_id': '@document',
|
|
'expected_sha256': content_digest(current_text),
|
|
'replacement': replacement,
|
|
}
|
|
|
|
|
|
def apply_projection_locally(path: Path, change: dict) -> dict:
|
|
"""Controlled apply mirroring docs-apply CAS for temp-repo tests (no lease).
|
|
|
|
Supports row edits and @document whole-file replacement. Refuses on CAS miss.
|
|
"""
|
|
if not isinstance(change, dict):
|
|
raise ValueError('change must be an object')
|
|
file_rel = change.get('file')
|
|
record_id = change.get('record_id')
|
|
replacement = change.get('replacement')
|
|
expected = change.get('expected_sha256')
|
|
if not isinstance(file_rel, str) or not file_rel:
|
|
raise ValueError('file required')
|
|
if not projectable_file(file_rel):
|
|
raise ValueError(f'file not projectable: {file_rel}')
|
|
target = (path / file_rel).resolve()
|
|
if path.resolve() not in target.parents and target != path.resolve():
|
|
raise ValueError('path escapes repo root')
|
|
before = target.read_text(encoding='utf-8')
|
|
if record_id == '@document':
|
|
if content_digest(before) != expected:
|
|
raise RuntimeError('BASE_CHANGED: document hash mismatch')
|
|
if not isinstance(replacement, str):
|
|
raise ValueError('Expected full document text')
|
|
target.write_text(replacement, encoding='utf-8')
|
|
after = replacement
|
|
else:
|
|
lines = before.splitlines()
|
|
matches = [i for i, line in enumerate(lines)
|
|
if record_id and record_id in [c.strip() for c in line.split('|') if c.strip()]]
|
|
current_row = lines[matches[0]] if matches else None
|
|
live_hash = content_digest(current_row) if current_row else None
|
|
if expected != live_hash:
|
|
raise RuntimeError('BASE_CHANGED: record hash mismatch')
|
|
if not isinstance(replacement, str) or len(replacement.splitlines()) != 1:
|
|
raise ValueError('Replacement must be one Markdown table row')
|
|
if matches:
|
|
lines[matches[0]] = replacement
|
|
else:
|
|
# Insert after first markdown table separator when adding a new row.
|
|
sep = next((i for i, line in enumerate(lines) if re.match(r'^\|\s*-+', line)), None)
|
|
if sep is None:
|
|
raise ValueError('Target table header/separator absent')
|
|
lines.insert(sep + 1, replacement)
|
|
after = '\n'.join(lines) + ('\n' if before.endswith('\n') or before == '' else '')
|
|
target.write_text(after, encoding='utf-8')
|
|
return {
|
|
'file': file_rel,
|
|
'record_id': record_id,
|
|
'before_sha256': content_digest(before),
|
|
'after_sha256': content_digest(after),
|
|
'status': 'applied',
|
|
}
|
|
|
|
|
|
def deployment_create(db, *, deploy_id: str, task: str, env: str, expected_commit: str,
|
|
manifest: Optional[str], dry_run: bool) -> dict:
|
|
if env not in ('dev', 'test', 'main'):
|
|
raise ValueError('env must be dev|test|main')
|
|
if not re.fullmatch(r'[0-9a-f]{7,40}', expected_commit):
|
|
raise ValueError('expected_commit must be a hex SHA')
|
|
ts = _now()
|
|
existing = db.execute('SELECT * FROM deployments WHERE id=?', (deploy_id,)).fetchone()
|
|
if existing:
|
|
if (existing['task'] != task or existing['env'] != env
|
|
or existing['expected_commit'] != expected_commit):
|
|
raise ValueError('deploy id reused with different identity')
|
|
return dict(existing)
|
|
db.execute(
|
|
'''INSERT INTO deployments(id,task,env,expected_commit,manifest,phase,dry_run,created,updated)
|
|
VALUES(?,?,?,?,?,?,?,?,?)''',
|
|
(deploy_id, task, env, expected_commit, manifest, 'prepared',
|
|
1 if dry_run else 0, ts, ts))
|
|
return dict(db.execute('SELECT * FROM deployments WHERE id=?', (deploy_id,)).fetchone())
|
|
|
|
|
|
def deployment_set_phase(db, deploy_id: str, phase: str, *, receipt: Optional[dict] = None,
|
|
error: Optional[str] = None) -> dict:
|
|
if phase not in DEPLOY_PHASES:
|
|
raise ValueError(f'invalid deploy phase: {phase}')
|
|
current = deployment_get(db, deploy_id)
|
|
if current is None:
|
|
raise RuntimeError(f'unknown deploy: {deploy_id}')
|
|
from_phase = current['phase']
|
|
# Terminal passed has empty outbound set; recover may jump failed/recovery_required → passed.
|
|
allowed = {
|
|
'prepared': {'deploying', 'failed', 'recovery_required'},
|
|
'deploying': {'accepting', 'failed', 'recovery_required'},
|
|
'accepting': {'passed', 'failed', 'recovery_required'},
|
|
'failed': {'recovery_required', 'passed'},
|
|
'recovery_required': {'deploying', 'passed'},
|
|
'passed': set(),
|
|
}
|
|
if phase != from_phase and phase not in allowed.get(from_phase, set()):
|
|
raise RuntimeError(f'illegal transition {from_phase} -> {phase}')
|
|
db.execute(
|
|
'''UPDATE deployments SET phase=?,receipt=?,last_error=?,updated=? WHERE id=?''',
|
|
(phase, json.dumps(receipt or {}, sort_keys=True) if receipt is not None else None,
|
|
error, _now(), deploy_id))
|
|
return dict(db.execute('SELECT * FROM deployments WHERE id=?', (deploy_id,)).fetchone())
|
|
|
|
|
|
def deployment_get(db, deploy_id: str) -> Optional[dict]:
|
|
row = db.execute('SELECT * FROM deployments WHERE id=?', (deploy_id,)).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def dry_run_deploy_steps(deploy: dict) -> dict:
|
|
"""Closed-loop steps without touching shared docker. Used by tests and design."""
|
|
return {
|
|
'env': deploy['env'],
|
|
'expected_commit': deploy['expected_commit'],
|
|
'manifest': deploy.get('manifest'),
|
|
'steps': [
|
|
{'name': 'guard_env_lease', 'status': 'ok'},
|
|
{'name': 'verify_main_clean', 'status': 'ok'},
|
|
{'name': 'apply_or_rebuild', 'status': 'skipped_dry_run'},
|
|
{'name': 'health_ready', 'status': 'skipped_dry_run'},
|
|
{'name': 'pi_image_identity', 'status': 'skipped_dry_run'},
|
|
],
|
|
'dry_run': True,
|
|
}
|
|
|
|
|
|
def run_deploy_command(command: list, *, cwd: Optional[str] = None,
|
|
timeout: float = 30.0) -> dict:
|
|
"""Execute a real subprocess for deploy_run; used with a fake command in tests."""
|
|
if not command or not isinstance(command, list) or not all(isinstance(c, str) for c in command):
|
|
raise ValueError('command must be a nonempty argv list')
|
|
try:
|
|
result = subprocess.run(
|
|
command, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
|
except subprocess.TimeoutExpired as exc:
|
|
return {
|
|
'ok': False,
|
|
'exit_code': None,
|
|
'stdout_tail': (exc.stdout or '')[-2000:],
|
|
'stderr_tail': (exc.stderr or '')[-2000:],
|
|
'error': 'timeout',
|
|
'command': command,
|
|
}
|
|
except OSError as exc:
|
|
return {
|
|
'ok': False,
|
|
'exit_code': None,
|
|
'stdout_tail': '',
|
|
'stderr_tail': str(exc),
|
|
'error': 'spawn_failed',
|
|
'command': command,
|
|
}
|
|
return {
|
|
'ok': result.returncode == 0,
|
|
'exit_code': result.returncode,
|
|
'stdout_tail': (result.stdout or '')[-2000:],
|
|
'stderr_tail': (result.stderr or '')[-2000:],
|
|
'error': None if result.returncode == 0 else 'nonzero_exit',
|
|
'command': command,
|
|
}
|
|
|
|
|
|
def execute_deploy_recover_loop(
|
|
db,
|
|
*,
|
|
deploy_id: str,
|
|
task: str,
|
|
env: str,
|
|
expected_commit: str,
|
|
command: list,
|
|
dry_run: bool = False,
|
|
manifest: Optional[str] = None,
|
|
worker: str = 'w4-deploy-worker',
|
|
cwd: Optional[str] = None,
|
|
) -> dict:
|
|
"""Real enqueue→claim→deploy(command)→receipt chain; receipts persist in SQLite.
|
|
|
|
Does not touch shared docker. Pass a no-op/fake argv for black-box tests.
|
|
"""
|
|
prepare = queue_enqueue(
|
|
db, task=task, kind='deploy_prepare',
|
|
payload={
|
|
'deploy_id': deploy_id, 'env': env,
|
|
'expected_commit': expected_commit, 'dry_run': dry_run,
|
|
},
|
|
job_id=f'{deploy_id}-prepare')
|
|
claimed = claim_next_job(db, worker=worker)
|
|
if not claimed or claimed['id'] != prepare['id']:
|
|
raise RuntimeError('failed to claim deploy_prepare job')
|
|
deploy = deployment_create(
|
|
db, deploy_id=deploy_id, task=task, env=env,
|
|
expected_commit=expected_commit, manifest=manifest, dry_run=dry_run)
|
|
finish_job(db, prepare['id'], ok=True, result={'phase': 'prepared', 'deploy': deploy})
|
|
|
|
run_job = queue_enqueue(
|
|
db, task=task, kind='deploy_run',
|
|
payload={'deploy_id': deploy_id, 'command': command, 'dry_run': dry_run},
|
|
job_id=f'{deploy_id}-run')
|
|
claimed = claim_next_job(db, worker=worker)
|
|
if not claimed or claimed['id'] != run_job['id']:
|
|
raise RuntimeError('failed to claim deploy_run job')
|
|
deployment_set_phase(db, deploy_id, 'deploying')
|
|
if dry_run:
|
|
receipt = dry_run_deploy_steps(deploy)
|
|
ok = True
|
|
else:
|
|
receipt = run_deploy_command(command, cwd=cwd)
|
|
ok = bool(receipt.get('ok'))
|
|
if not ok:
|
|
deploy = deployment_set_phase(
|
|
db, deploy_id, 'failed', receipt=receipt,
|
|
error=receipt.get('error') or 'deploy_command_failed')
|
|
finish_job(db, run_job['id'], ok=False, result=receipt,
|
|
error=receipt.get('error') or 'deploy_command_failed')
|
|
# recovery job leaves phase recovery_required for operator recover
|
|
recover_job = queue_enqueue(
|
|
db, task=task, kind='deploy_accept',
|
|
payload={'deploy_id': deploy_id, 'action': 'recover'},
|
|
job_id=f'{deploy_id}-recover-pending')
|
|
deployment_set_phase(db, deploy_id, 'recovery_required', receipt=receipt,
|
|
error=receipt.get('error') or 'deploy_command_failed')
|
|
return {
|
|
'status': 'recovery_required',
|
|
'deploy': deployment_get(db, deploy_id),
|
|
'jobs': {'prepare': prepare['id'], 'run': run_job['id'],
|
|
'recover_pending': recover_job['id']},
|
|
'receipt': receipt,
|
|
}
|
|
deployment_set_phase(db, deploy_id, 'accepting', receipt=receipt)
|
|
finish_job(db, run_job['id'], ok=True, result=receipt)
|
|
|
|
accept_job = queue_enqueue(
|
|
db, task=task, kind='deploy_accept',
|
|
payload={'deploy_id': deploy_id, 'action': 'accept'},
|
|
job_id=f'{deploy_id}-accept')
|
|
claimed = claim_next_job(db, worker=worker)
|
|
if not claimed or claimed['id'] != accept_job['id']:
|
|
raise RuntimeError('failed to claim deploy_accept job')
|
|
final_receipt = dict(receipt)
|
|
final_receipt['accepted'] = True
|
|
final_receipt['accepted_at'] = _now()
|
|
deploy = deployment_set_phase(db, deploy_id, 'passed', receipt=final_receipt)
|
|
finish_job(db, accept_job['id'], ok=True, result=final_receipt)
|
|
return {
|
|
'status': 'passed',
|
|
'deploy': deploy,
|
|
'jobs': {'prepare': prepare['id'], 'run': run_job['id'], 'accept': accept_job['id']},
|
|
'receipt': final_receipt,
|
|
}
|
|
|
|
|
|
def recover_deploy(db, deploy_id: str, *, reason: str) -> dict:
|
|
"""Mark a failed/recovery_required deploy as recovered without re-running env writes."""
|
|
row = deployment_get(db, deploy_id)
|
|
if not row:
|
|
raise ValueError('unknown deploy id')
|
|
if row['phase'] not in ('failed', 'recovery_required'):
|
|
raise ValueError(f'cannot recover deploy in phase {row["phase"]}')
|
|
receipt = json.loads(row['receipt']) if row.get('receipt') else {}
|
|
receipt['recovered'] = True
|
|
receipt['recover_reason'] = reason
|
|
receipt['recovered_at'] = _now()
|
|
return deployment_set_phase(db, deploy_id, 'passed', receipt=receipt, error=None)
|
|
|
|
|
|
def cleanup_visibility_report(
|
|
*,
|
|
path: Path,
|
|
branch: str,
|
|
head: str,
|
|
ancestor_of_dev: bool,
|
|
dirty: bool,
|
|
unfinished_git: list,
|
|
registry: dict,
|
|
process: dict,
|
|
) -> dict:
|
|
"""Structured cleanup gate report. Never deletes; visibility only."""
|
|
blockers = []
|
|
if not ancestor_of_dev:
|
|
blockers.append({'code': 'NOT_MERGED', 'message': 'HEAD is not an ancestor of dev'})
|
|
if dirty:
|
|
blockers.append({'code': 'DIRTY_TREE', 'message': 'tracked/untracked/ignored files remain'})
|
|
for marker in unfinished_git:
|
|
blockers.append({'code': 'GIT_IN_PROGRESS', 'message': f'unfinished Git operation: {marker}'})
|
|
if registry.get('active_task'):
|
|
blockers.append({'code': 'TASK_ACTIVE', 'message': 'registered task still active'})
|
|
if registry.get('live_run'):
|
|
blockers.append({'code': 'LOCK_BUSY', 'message': 'live or unresolved operation',
|
|
'run_id': registry.get('live_run')})
|
|
status = process.get('status')
|
|
if status == 'busy':
|
|
blockers.append({'code': 'PROCESS_BUSY', 'message': 'process still uses worktree cwd',
|
|
'pids': process.get('pids', [])})
|
|
elif status == 'unreadable':
|
|
blockers.append({'code': 'PROCESS_UNREADABLE', 'message': 'cannot verify same-user cwd',
|
|
'pids': process.get('unreadable', []),
|
|
'platform_note': process.get('platform_note')})
|
|
elif status == 'unsupported':
|
|
blockers.append({'code': 'PROCESS_UNSUPPORTED',
|
|
'message': process.get('message', 'process inspection unsupported'),
|
|
'platform_note': process.get('platform_note')})
|
|
return {
|
|
'status': 'ready' if not blockers else 'cleanup_pending',
|
|
'path': str(path),
|
|
'branch': branch,
|
|
'head': head,
|
|
'gates': {
|
|
'ancestor_of_dev': ancestor_of_dev,
|
|
'dirty': dirty,
|
|
'unfinished_git': unfinished_git,
|
|
'registry': registry,
|
|
'process': process,
|
|
},
|
|
'blockers': blockers,
|
|
}
|
|
|
|
|
|
def project_cleanup_markdown(entries):
|
|
"""Render cleanup visibility projection from structured entries.
|
|
|
|
Ported for W2-U5 projection reuse; structured report remains authoritative.
|
|
"""
|
|
lines = [
|
|
'# Cleanup visibility projection',
|
|
'',
|
|
'> Generated by `agentctl docs project --kind cleanup`. '
|
|
'Not a lock; machine `.locks` is authoritative.',
|
|
'',
|
|
'| path | branch | reason | cleanup_owner |',
|
|
'| --- | --- | --- | --- |',
|
|
]
|
|
if not entries:
|
|
lines.append('| — | — | none_pending | — |')
|
|
else:
|
|
for item in entries:
|
|
path = str(item.get('path') or '').replace('|', '\\|')
|
|
branch = str(item.get('branch') or '').replace('|', '\\|')
|
|
reason = str(item.get('reason') or '').replace('|', '\\|')
|
|
owner = str(item.get('cleanup_owner') or 'independent-maintainer').replace('|', '\\|')
|
|
lines.append(f'| {path} | {branch} | {reason} | {owner} |')
|
|
lines.append('')
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def _darwin_cwd_visibility(path: Path, uid: Optional[int] = None) -> dict:
|
|
"""Fail-closed cwd scan via lsof on Darwin (agentctl-macos process layer)."""
|
|
uid = os.getuid() if uid is None else uid
|
|
try:
|
|
result = subprocess.run(
|
|
['lsof', '-a', '-d', 'cwd', '-u', str(uid), '-F', 'pn'],
|
|
capture_output=True, text=True)
|
|
except OSError as exc:
|
|
return {
|
|
'status': 'unreadable',
|
|
'message': f'cannot enumerate process cwd via lsof ({exc})',
|
|
'platform_note': 'fail-closed',
|
|
'pids': [],
|
|
'unreadable': ['*'],
|
|
}
|
|
# lsof exits 1 when selection empty; other nonzero with empty stdout is opaque.
|
|
if result.returncode not in (0, 1):
|
|
return {
|
|
'status': 'unreadable',
|
|
'message': f'lsof exit {result.returncode}',
|
|
'platform_note': 'fail-closed',
|
|
'pids': [],
|
|
'unreadable': ['*'],
|
|
}
|
|
if result.stderr.strip() and result.returncode != 0 and not result.stdout.strip():
|
|
return {
|
|
'status': 'unreadable',
|
|
'message': 'cannot verify process cwd via lsof',
|
|
'platform_note': 'fail-closed',
|
|
'pids': [],
|
|
'unreadable': ['*'],
|
|
}
|
|
path = Path(path).resolve()
|
|
busy, unreadable = [], []
|
|
pid, saw_cwd = None, False
|
|
for line in result.stdout.splitlines():
|
|
if not line:
|
|
continue
|
|
code, value = line[0], line[1:]
|
|
if code == 'p':
|
|
if pid is not None and not saw_cwd:
|
|
unreadable.append(pid)
|
|
pid, saw_cwd = value, False
|
|
elif code == 'n' and pid is not None:
|
|
try:
|
|
cwd = Path(value).resolve()
|
|
except OSError:
|
|
unreadable.append(pid)
|
|
saw_cwd = True
|
|
continue
|
|
if cwd == path or path in cwd.parents:
|
|
busy.append(pid)
|
|
saw_cwd = True
|
|
if pid is not None and not saw_cwd:
|
|
unreadable.append(pid)
|
|
if busy:
|
|
return {'status': 'busy', 'pids': busy, 'unreadable': unreadable,
|
|
'platform_note': 'darwin-lsof'}
|
|
if unreadable:
|
|
return {'status': 'unreadable', 'pids': [], 'unreadable': unreadable,
|
|
'platform_note': 'fail-closed; do not treat as exited'}
|
|
return {'status': 'clear', 'pids': [], 'unreadable': [], 'platform_note': 'darwin-lsof'}
|
|
|
|
|
|
def linux_cwd_visibility(path: Path, uid: Optional[int] = None) -> dict:
|
|
"""Fail-closed cwd scan: /proc on Linux, lsof on Darwin."""
|
|
if sys.platform == 'darwin':
|
|
return _darwin_cwd_visibility(path, uid=uid)
|
|
if not sys.platform.startswith('linux'):
|
|
return {
|
|
'status': 'unsupported',
|
|
'message': f'cwd scan unavailable on {sys.platform}',
|
|
'platform_note': 'treat unknown as cleanup_pending',
|
|
'pids': [],
|
|
'unreadable': [],
|
|
}
|
|
uid = os.getuid() if uid is None else uid
|
|
busy, unreadable = [], []
|
|
try:
|
|
entries = list(Path('/proc').iterdir())
|
|
except OSError:
|
|
return {
|
|
'status': 'unreadable',
|
|
'message': 'cannot list /proc',
|
|
'platform_note': 'fail-closed',
|
|
'pids': [],
|
|
'unreadable': ['*'],
|
|
}
|
|
path = Path(path).resolve()
|
|
for proc in entries:
|
|
if not proc.name.isdigit():
|
|
continue
|
|
try:
|
|
if proc.stat().st_uid != uid:
|
|
continue
|
|
cwd = Path(os.readlink(proc / 'cwd'))
|
|
if cwd == path or path in cwd.parents:
|
|
busy.append(proc.name)
|
|
except (FileNotFoundError, ProcessLookupError):
|
|
continue
|
|
except PermissionError:
|
|
try:
|
|
if (proc / 'comm').read_text().strip() == 'systemd':
|
|
continue
|
|
except (OSError, UnicodeError):
|
|
pass
|
|
unreadable.append(proc.name)
|
|
if busy:
|
|
return {'status': 'busy', 'pids': busy, 'unreadable': unreadable}
|
|
if unreadable:
|
|
return {'status': 'unreadable', 'pids': [], 'unreadable': unreadable,
|
|
'platform_note': 'fail-closed; do not treat as exited'}
|
|
return {'status': 'clear', 'pids': [], 'unreadable': []}
|
|
|
|
|
|
# Back-compat alias used by cleanup reports.
|
|
cwd_visibility = linux_cwd_visibility
|
|
|
|
|
|
def pi_source_bind_paths() -> tuple:
|
|
"""Host paths that must not be bind-mounted when PI image delivery is enforced."""
|
|
return (
|
|
'./deploy/pi-agent/runner.mjs',
|
|
'./deploy/pi-agent/pi-args.mjs',
|
|
'./deploy/pi-agent/result-v1.mjs',
|
|
'./deploy/pi-agent/extensions',
|
|
'deploy/pi-agent/runner.mjs',
|
|
'deploy/pi-agent/pi-args.mjs',
|
|
'deploy/pi-agent/result-v1.mjs',
|
|
'deploy/pi-agent/extensions',
|
|
)
|
|
|
|
|
|
def inspect_compose_for_pi_binds(compose_text: str) -> dict:
|
|
"""Static check: flag source bind mounts under pi-agent service."""
|
|
forbidden = pi_source_bind_paths()
|
|
hits = []
|
|
in_pi = False
|
|
for line in compose_text.splitlines():
|
|
stripped = line.strip()
|
|
if re.match(r'^pi-agent\s*:', stripped):
|
|
in_pi = True
|
|
continue
|
|
if in_pi and re.match(r'^[A-Za-z0-9_.-]+:\s*$', stripped):
|
|
in_pi = False
|
|
if in_pi:
|
|
for item in forbidden:
|
|
if item in stripped and (':/app/' in stripped or stripped.startswith('-')):
|
|
hits.append(stripped)
|
|
break
|
|
return {
|
|
'ok': not hits,
|
|
'source_binds': hits,
|
|
'recommendation': (
|
|
'Apply docker-compose.dev.pi-image.yml overlay so runner source ships in the image'
|
|
if hits else 'no pi-agent source binds detected'
|
|
),
|
|
}
|
|
|
|
|
|
# Type aliases for agentctl callbacks (avoid circular imports at runtime).
|
|
EmitFn = Callable[[Any], None]
|
|
DenyFn = Callable[..., None]
|