diff --git a/ops/dashboard/src/nanobot_ops_dashboard/app.py b/ops/dashboard/src/nanobot_ops_dashboard/app.py index 43c48d0..37a7a39 100644 --- a/ops/dashboard/src/nanobot_ops_dashboard/app.py +++ b/ops/dashboard/src/nanobot_ops_dashboard/app.py @@ -93,6 +93,34 @@ def _promotion_replay_readiness_from_promotions(promotions: list[dict] | None) - readiness_checks = detail.get('readiness_checks') or detail.get('readinessChecks') readiness_reasons = detail.get('readiness_reasons') or detail.get('readinessReasons') or [] missing_records = [name for name, value in {'decision_record': decision_record, 'accepted_record': accepted_record}.items() if _missing_record(value)] + ready_for_policy_review = ( + review_status == 'ready_for_policy_review' + or decision == 'ready_for_policy_review' + or review_packet_status == 'pending_operator_review' + or decision_record == 'pending_operator_review_packet' + or row.get('status') == 'ready_for_policy_review' + ) + if ready_for_policy_review: + return { + 'schema_version': 'promotion-replay-readiness-v1', + 'state': 'ready_for_policy_review', + 'reason': 'promotion_candidate_awaiting_policy_review', + 'promotion_id': row.get('identity_key') or row.get('title'), + 'status': row.get('status'), + 'review_status': review_status, + 'decision': decision, + 'review_packet_status': review_packet_status or 'pending_operator_review', + 'decision_record': decision_record, + 'accepted_record': accepted_record, + 'missing_records': [name for name in missing_records if name != 'accepted_record'], + 'readiness_checks': readiness_checks, + 'readiness_reasons': readiness_reasons, + 'recommended_next_action': detail.get('recommended_next_action') or 'review_promotion_candidate', + 'readiness_packet_path': detail.get('readiness_packet_path') or governance.get('readiness_packet_path'), + 'candidate_path': detail.get('candidate_path'), + 'artifact_path': detail.get('artifact_path'), + 'collected_at': row.get('collected_at'), + } if explicitly_not_ready: return { 'schema_version': 'promotion-replay-readiness-v1', diff --git a/ops/dashboard/tests/test_dashboard_truth_audit_gaps.py b/ops/dashboard/tests/test_dashboard_truth_audit_gaps.py index 2262add..6e6f7ca 100644 --- a/ops/dashboard/tests/test_dashboard_truth_audit_gaps.py +++ b/ops/dashboard/tests/test_dashboard_truth_audit_gaps.py @@ -2614,6 +2614,60 @@ def test_api_system_promotes_stuck_promotion_lifecycle_to_autonomy_verdict(tmp_p assert 'promotion_lifecycle_blocked' in system['autonomy_verdict']['reasons'] +def test_api_system_does_not_block_ready_promotion_awaiting_policy_review(tmp_path: Path) -> None: + from nanobot_ops_dashboard.storage import upsert_event + + project_root = tmp_path / 'dashboard' + repo_root = tmp_path / 'nanobot' + db = tmp_path / 'dashboard.sqlite3' + init_db(db) + insert_collection(db, {'collected_at': '2999-04-27T21:00:00Z', 'source': 'repo', 'status': 'PASS', 'active_goal': 'goal-bootstrap', 'current_task': 'Record cycle reward', 'raw_json': '{}'}) + upsert_event(db, { + 'collected_at': '2999-04-27T21:00:00Z', + 'source': 'repo', + 'event_type': 'promotion', + 'identity_key': 'promotion-ready', + 'title': 'promotion-ready | ready_for_policy_review | ready_for_policy_review', + 'status': 'ready_for_policy_review', + 'detail_json': json.dumps({ + 'candidate_path': '/state/promotions/promotion-ready.json', + 'decision_record': 'pending_operator_review_packet', + 'accepted_record': None, + 'readiness_checks': { + 'schema_version': 'promotion-readiness-inputs-v1', + 'artifact_present': True, + 'evidence_refs_present': True, + 'provenance_complete': True, + 'missing_inputs': [], + }, + 'readiness_blocker': { + 'state': 'ready_for_policy_review', + 'recommended_next_action': 'ready_for_policy_review', + }, + 'recommended_next_action': 'ready_for_policy_review', + 'governance_packet': { + 'review_packet_status': 'pending_operator_review', + 'review_status': 'ready_for_policy_review', + 'decision': 'ready_for_policy_review', + }, + }), + }) + cfg = DashboardConfig(project_root=project_root, nanobot_repo_root=repo_root, db_path=db, eeepc_ssh_host='eeepc', eeepc_ssh_key=tmp_path / 'missing-key', eeepc_state_root='/state') + + system = _call_json(create_app(cfg), '/api/system') + mission = _call_json(create_app(cfg), '/api/mission-control') + + readiness = system['control_plane']['promotion_replay_readiness'] + assert readiness['state'] == 'ready_for_policy_review' + assert readiness['reason'] == 'promotion_candidate_awaiting_policy_review' + assert readiness['decision_record'] == 'pending_operator_review_packet' + assert readiness['accepted_record'] is None + assert readiness['missing_records'] == [] + assert 'promotion_lifecycle_blocked' not in system['autonomy_verdict']['reasons'] + assert mission['headline'] != 'Blocked: accepted_record' + assert 'accepted_record' not in str(mission['headline']) + + def test_api_system_does_not_block_explicitly_not_ready_promotion(tmp_path: Path) -> None: from nanobot_ops_dashboard.storage import upsert_event