-
Notifications
You must be signed in to change notification settings - Fork 5
feat(settings): PROOF9 defaults + workspace config tabs (#556) #589
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
aa06351
f823f33
ae32da7
6a45ec2
13f18e4
1e77321
2f81b20
e794150
62d51f3
46f072a
45c085e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| """Shared helpers for v2 routers.""" | ||
|
|
||
| import json | ||
| import os | ||
| import tempfile | ||
| from pathlib import Path | ||
|
|
||
|
|
||
| def atomic_write_json(path: Path, payload: dict) -> None: | ||
| """Write JSON via per-call unique temp-file + os.replace. | ||
|
|
||
| A unique temp name is required so concurrent writers to the same target | ||
| do not collide on a shared `.tmp` suffix. | ||
| """ | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| fd, tmp_name = tempfile.mkstemp( | ||
| prefix=f".{path.name}.", suffix=".tmp", dir=path.parent | ||
| ) | ||
| try: | ||
| with os.fdopen(fd, "w") as f: | ||
| f.write(json.dumps(payload, indent=2)) | ||
| os.replace(tmp_name, path) | ||
| except Exception: | ||
| # Clean up the temp file on failure so we don't leak it. | ||
| try: | ||
| os.unlink(tmp_name) | ||
| except OSError: | ||
| pass | ||
| raise |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,14 +13,15 @@ | |
| GET /api/v2/proof/requirements/{req_id}/evidence list_evidence() | ||
| """ | ||
|
|
||
| import json | ||
| import logging | ||
| import time | ||
| import uuid | ||
| from datetime import date | ||
| from typing import Any, Optional | ||
| from typing import Any, Literal, Optional | ||
|
|
||
| from fastapi import APIRouter, Depends, HTTPException, Query, Request | ||
| from pydantic import BaseModel, Field | ||
| from pydantic import BaseModel, Field, field_validator | ||
|
|
||
| from codeframe.core.proof.capture import capture_requirement | ||
| from codeframe.core.proof.ledger import ( | ||
|
|
@@ -33,6 +34,8 @@ | |
| waive_requirement, | ||
| ) | ||
| from codeframe.core.proof.models import ( | ||
| PROOF9_GATE_ORDER, | ||
| PROOF_CONFIG_FILENAME, | ||
| Gate, | ||
| ReqStatus, | ||
| Severity, | ||
|
|
@@ -44,6 +47,7 @@ | |
| from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard | ||
| from codeframe.ui.dependencies import get_v2_workspace | ||
| from codeframe.ui.response_models import ErrorCodes, api_error | ||
| from codeframe.ui.routers._helpers import atomic_write_json | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
@@ -413,11 +417,18 @@ async def run_proof_endpoint( | |
| for req_id, gate_results in results.items() | ||
| } | ||
|
|
||
| passed = all( | ||
| satisfied | ||
| for gate_results in results.values() | ||
| for _, satisfied in gate_results | ||
| ) | ||
| # Use the strictness-aware overall_passed that run_proof() persisted, | ||
| # so warn-mode does not surface as failure via the cached run status. | ||
| persisted_run = get_run(workspace, run_id) | ||
| if persisted_run is not None: | ||
| passed = persisted_run.overall_passed | ||
| else: | ||
| # Fallback only if persistence unexpectedly failed | ||
| passed = all( | ||
| satisfied | ||
| for gate_results in results.values() | ||
| for _, satisfied in gate_results | ||
| ) | ||
| response = RunProofResponse( | ||
| success=True, | ||
| run_id=run_id, | ||
|
|
@@ -634,6 +645,78 @@ async def get_run_evidence_endpoint( | |
| ) | ||
|
|
||
|
|
||
| # ============================================================================ | ||
| # PROOF9 Config (issue #556) | ||
| # | ||
| # Persists which gates are enabled by default and the strictness setting | ||
| # (strict vs warn) to .codeframe/proof_config.json. | ||
| # ============================================================================ | ||
|
|
||
|
|
||
| _VALID_GATES = {g.value for g in Gate} | ||
|
|
||
|
|
||
| def _proof_config_path(workspace: Workspace): | ||
| return workspace.state_dir / PROOF_CONFIG_FILENAME | ||
|
|
||
|
|
||
| def _default_proof_config() -> dict: | ||
| return {"enabled_gates": list(PROOF9_GATE_ORDER), "strictness": "strict"} | ||
|
|
||
|
|
||
| class ProofConfigResponse(BaseModel): | ||
| enabled_gates: list[str] | ||
| strictness: Literal["strict", "warn"] | ||
|
|
||
|
|
||
| class UpdateProofConfigRequest(BaseModel): | ||
| enabled_gates: list[str] | ||
| strictness: Literal["strict", "warn"] | ||
|
|
||
| @field_validator("enabled_gates") | ||
| @classmethod | ||
| def _validate_gates(cls, v: list[str]) -> list[str]: | ||
| unknown = [g for g in v if g not in _VALID_GATES] | ||
| if unknown: | ||
| raise ValueError( | ||
| f"Unknown gate(s): {unknown}. Valid: {sorted(_VALID_GATES)}" | ||
| ) | ||
| return v | ||
|
|
||
|
|
||
| @router.get("/config", response_model=ProofConfigResponse) | ||
| @rate_limit_standard() | ||
| async def get_proof_config( | ||
| request: Request, | ||
| workspace: Workspace = Depends(get_v2_workspace), | ||
| ) -> ProofConfigResponse: | ||
| """Load PROOF9 defaults for this workspace. | ||
|
|
||
| Returns the all-gates-enabled + strict defaults if no config file exists. | ||
| """ | ||
| path = _proof_config_path(workspace) | ||
| if path.exists(): | ||
| try: | ||
| data = json.loads(path.read_text()) | ||
| return ProofConfigResponse(**data) | ||
| except (OSError, json.JSONDecodeError, ValueError) as e: | ||
| logger.warning("Invalid proof_config.json — falling back to defaults: %s", e) | ||
| return ProofConfigResponse(**_default_proof_config()) | ||
|
|
||
|
|
||
| @router.put("/config", response_model=ProofConfigResponse) | ||
| @rate_limit_standard() | ||
| async def update_proof_config( | ||
| request: Request, | ||
| body: UpdateProofConfigRequest, | ||
| workspace: Workspace = Depends(get_v2_workspace), | ||
| ) -> ProofConfigResponse: | ||
| """Persist PROOF9 defaults to .codeframe/proof_config.json.""" | ||
| payload = {"enabled_gates": body.enabled_gates, "strictness": body.strictness} | ||
| atomic_write_json(_proof_config_path(workspace), payload) | ||
| return ProofConfigResponse(**payload) | ||
|
Comment on lines
+650
to
+721
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift Move PROOF config policy out of the router. This block now owns default values, gate validation, file layout, and corrupted-file fallback behavior. That is domain logic, and keeping it in As per coding guidelines, 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| @router.get("/requirements/{req_id}/evidence", response_model=list[EvidenceResponse]) | ||
| @rate_limit_standard() | ||
| async def list_evidence_endpoint( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle non-object JSON configs to avoid runtime crashes
At Line 46,
data.get(...)assumes the parsed JSON is a dict. Ifproof_config.jsonis valid JSON but not an object (e.g.,[]), this raisesAttributeErrorand aborts the run instead of falling back to defaults.Suggested fix
try: data = json.loads(path.read_text()) except (OSError, json.JSONDecodeError) as exc: logger.warning("Invalid %s — using defaults: %s", PROOF_CONFIG_FILENAME, exc) return None, "strict" + if not isinstance(data, dict): + logger.warning( + "Invalid %s shape (expected JSON object) — using defaults", + PROOF_CONFIG_FILENAME, + ) + return None, "strict" enabled: Optional[set[Gate]] = None gates_raw = data.get("enabled_gates")📝 Committable suggestion
🤖 Prompt for AI Agents