diff --git a/src/code_mower/board_observation.py b/src/code_mower/board_observation.py new file mode 100644 index 00000000..7ed9444d --- /dev/null +++ b/src/code_mower/board_observation.py @@ -0,0 +1,451 @@ +"""Closed, provider-neutral local observation contract for Code Mower Board. + +This module defines a read model, not workflow authority. It accepts bounded +lifecycle facts from later adapters and never dispatches, renews a lease, or +changes provider state. Records stay local: this contract does not add or +widen any Board/cloud event field. +""" + +from __future__ import annotations + +import json +import math +import re +from datetime import datetime +from functools import lru_cache +from pathlib import Path +from typing import Any, Mapping + +from .remote_session import RemoteError, public_projection + + +SCHEMA = "code_mower.boardObservation.v1" +MAX_BYTES = 262_144 +MAX_DEPTH = 16 +MAX_SOURCES = 16 +MAX_RUNS = 32 + +STAGES = frozenset( + { + "unknown", + "queued", + "building", + "in_review", + "changes_requested", + "ready_for_human_review", + "ready_to_merge", + "merged", + } +) +ACTORS = frozenset( + {"none", "owner", "orchestrator", "builder", "reviewer", "automation", "maintainer"} +) + +# Lower rank wins. This table is the only source of primary-action precedence. +REASON_ROUTES: Mapping[str, tuple[int, str, str]] = { + "approval_required": (10, "owner", "respond_to_approval"), + "user_input_required": (20, "owner", "answer_question"), + "source_unavailable": (30, "orchestrator", "restore_source"), + "identity_unlinked": (40, "orchestrator", "connect_session"), + "stale_observation": (50, "orchestrator", "refresh_evidence"), + "provider_failed": (60, "orchestrator", "inspect_failure"), + "provider_suspended": (70, "orchestrator", "inspect_provider"), + "cancelled": (80, "orchestrator", "inspect_cancellation"), + "changes_requested": (90, "builder", "address_findings"), + "update_required": (100, "builder", "update_branch"), + "ci_failed": (110, "builder", "fix_checks"), + "gate_failed": (120, "builder", "resolve_gate"), + "review_stale": (130, "orchestrator", "request_review"), + "review_requested": (140, "reviewer", "review_current_head"), + "review_in_progress": (150, "reviewer", "finish_review"), + "ci_pending": (160, "automation", "wait_for_checks"), + "gate_pending": (170, "automation", "wait_for_gate"), + "human_review_required": (180, "owner", "review_change"), + "ready_to_merge": (190, "maintainer", "merge"), +} +REASONS = frozenset(REASON_ROUTES) +ACTIONS = frozenset(route[2] for route in REASON_ROUTES.values()) | {"none"} + +_SECRET = re.compile( + r"(?:ghp_|github_pat_|xox[baprs]-|sk-[A-Za-z0-9]|bearer\s+|eyJ[A-Za-z0-9_-]{8,}\.)", re.I +) + + +class BoardObservationError(ValueError): + """Fixed diagnostics intentionally omit observed values and local paths.""" + + +@lru_cache(maxsize=1) +def schema() -> dict[str, Any]: + return json.loads(Path(__file__).with_name("board_observation.schema.json").read_text()) + + +def _check(value: Any, rule: Mapping[str, Any], *, depth: int = 0) -> None: + """Validate the deliberately small JSON-Schema subset used by this contract.""" + if depth > MAX_DEPTH: + raise BoardObservationError("invalid_contract") + if value is None and isinstance(rule.get("type"), list) and "null" in rule["type"]: + return + if "$ref" in rule: + name = str(rule["$ref"]).rsplit("/", 1)[-1] + _check(value, schema()["$defs"][name], depth=depth + 1) + return + if "const" in rule and (type(value) is not type(rule["const"]) or value != rule["const"]): + raise BoardObservationError("invalid_contract") + if "enum" in rule and value not in rule["enum"]: + raise BoardObservationError("invalid_contract") + allowed_types = rule.get("type") + if isinstance(allowed_types, str): + allowed_types = [allowed_types] + if allowed_types: + expected = { + "object": dict, + "array": list, + "string": str, + "integer": int, + "number": (int, float), + "boolean": bool, + "null": type(None), + } + if not any( + type(value) is expected[kind] + if isinstance(expected[kind], type) + else type(value) in expected[kind] + for kind in allowed_types + ): + raise BoardObservationError("invalid_contract") + if type(value) is dict: + required = set(rule.get("required", ())) + if not required <= set(value): + raise BoardObservationError("invalid_contract") + properties = rule.get("properties", {}) + if rule.get("additionalProperties") is False and set(value) - set(properties): + raise BoardObservationError("invalid_contract") + for key, child in value.items(): + child_rule = properties.get(key) + if child_rule is not None: + _check(child, child_rule, depth=depth + 1) + elif type(value) is list: + if not rule.get("minItems", 0) <= len(value) <= rule.get("maxItems", MAX_RUNS): + raise BoardObservationError("invalid_contract") + if rule.get("uniqueItems"): + encoded = [json.dumps(item, sort_keys=True, separators=(",", ":")) for item in value] + if len(encoded) != len(set(encoded)): + raise BoardObservationError("invalid_contract") + for child in value: + _check(child, rule["items"], depth=depth + 1) + elif type(value) is str: + if not rule.get("minLength", 0) <= len(value) <= rule.get("maxLength", MAX_BYTES): + raise BoardObservationError("invalid_contract") + if "pattern" in rule and re.fullmatch(rule["pattern"], value) is None: + raise BoardObservationError("invalid_contract") + elif type(value) in (int, float): + if ( + not math.isfinite(value) + or value < rule.get("minimum", value) + or value > rule.get("maximum", value) + ): + raise BoardObservationError("invalid_contract") + + +def _timestamp(value: str) -> datetime: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except (AttributeError, ValueError): + raise BoardObservationError("invalid_timestamp") from None + if parsed.tzinfo is None: + raise BoardObservationError("invalid_timestamp") + return parsed + + +def _nullable_timestamp(value: str | None) -> datetime | None: + return None if value is None else _timestamp(value) + + +def derive_primary(reasons: list[str] | tuple[str, ...]) -> dict[str, str]: + """Return the deterministic primary route for an already closed reason set.""" + if type(reasons) not in (list, tuple) or any(reason not in REASONS for reason in reasons): + raise BoardObservationError("invalid_reason") + if len(reasons) != len(set(reasons)): + raise BoardObservationError("invalid_reason") + if not reasons: + return {"actor": "none", "action": "none"} + reason = min(reasons, key=lambda item: (REASON_ROUTES[item][0], item)) + _rank, actor, action = REASON_ROUTES[reason] + return {"actor": actor, "action": action} + + +def ordered_reasons(reasons: list[str] | tuple[str, ...]) -> list[str]: + """Canonicalize a set of closed reasons for producer implementations.""" + if type(reasons) not in (list, tuple) or any(reason not in REASONS for reason in reasons): + raise BoardObservationError("invalid_reason") + if len(reasons) != len(set(reasons)): + raise BoardObservationError("invalid_reason") + return sorted(reasons, key=lambda item: (REASON_ROUTES[item][0], item)) + + +def _validate_source(source: Mapping[str, Any], *, created_at: datetime) -> None: + checked = _timestamp(source["checked_at"]) + observed = _nullable_timestamp(source["observed_at"]) + event = _nullable_timestamp(source["event_at"]) + heartbeat = _nullable_timestamp(source["heartbeat_at"]) + if checked > created_at or any( + item is not None and item > checked for item in (observed, event, heartbeat) + ): + raise BoardObservationError("invalid_timestamp") + if event is not None and observed is not None and event > observed: + raise BoardObservationError("invalid_timestamp") + if observed is None and any(item is not None for item in (event, heartbeat)): + raise BoardObservationError("invalid_timestamp") + if source["freshness"] in {"fresh", "stale"} and observed is None: + raise BoardObservationError("invalid_freshness") + if (source["freshness"] == "unavailable") != (source["coverage"] == "unavailable"): + raise BoardObservationError("invalid_freshness") + + +def _validate_measurements(measurements: Mapping[str, Any]) -> None: + for measurement in measurements.values(): + unavailable = measurement["coverage"] == "unavailable" + if unavailable: + if measurement != { + "value": None, + "coverage": "unavailable", + "observed": 0, + "total": None, + }: + raise BoardObservationError("invalid_measurement") + continue + if measurement["value"] is None or measurement["observed"] < 1: + raise BoardObservationError("invalid_measurement") + total = measurement["total"] + if total is None or total < measurement["observed"]: + raise BoardObservationError("invalid_measurement") + if measurement["coverage"] == "complete" and measurement["observed"] != total: + raise BoardObservationError("invalid_measurement") + if measurement["coverage"] == "partial" and measurement["observed"] >= total: + raise BoardObservationError("invalid_measurement") + + +def _validate_lifecycle(run: Mapping[str, Any]) -> None: + lifecycle = run["lifecycle"] + if lifecycle is None: + return + try: + projected = public_projection(dict(lifecycle)) + except RemoteError: + raise BoardObservationError("invalid_lifecycle") from None + if projected != lifecycle: + raise BoardObservationError("invalid_lifecycle") + allowed_phases = { + "pending": {"dispatched"}, + "running": {"observed_running", "provider_progress"}, + "waiting_for_user": {"waiting_for_user"}, + "waiting_for_approval": {"waiting_for_approval"}, + "complete": {"implementation_complete"}, + "failed": {"failed"}, + "suspended": {"failed"}, + "terminated": {"cancelled"}, + "archived": {"implementation_complete", "cancelled"}, + "uncertain": {"dispatched"}, + } + if run["phase"] not in allowed_phases[lifecycle["state"]]: + raise BoardObservationError("invalid_lifecycle") + + +def _validate_evidence(work: Mapping[str, Any], sources: Mapping[str, Mapping[str, Any]]) -> None: + head = work["pull_request"]["head_sha"] + number = work["pull_request"]["number"] + if (number is None) != (head is None): + raise BoardObservationError("identity_mismatch") + evidence = work["evidence"] + allowed_states = { + "lease": {"held", "absent", "expired", "unverifiable", "unknown"}, + "assignment": {"assigned", "unassigned", "unknown"}, + "review_request": {"requested", "not_requested", "unknown"}, + "review": {"not_started", "running", "pass", "blocked", "stale", "unknown"}, + "ci": {"not_started", "pending", "pass", "failed", "unknown"}, + "gate_publisher": {"not_started", "pending", "pass", "failed", "unknown"}, + "gate": {"not_started", "pending", "pass", "failed", "unknown"}, + "merge": { + "none", + "draft", + "open", + "blocked", + "ready", + "merged", + "closed_unmerged", + "unknown", + }, + } + for name, item in evidence.items(): + source_id = item["source_id"] + state = item["state"] + if state not in allowed_states[name]: + raise BoardObservationError("invalid_evidence") + if source_id is not None and source_id not in sources: + raise BoardObservationError("identity_mismatch") + if state not in {"unknown", "not_started", "none"} and source_id is None: + raise BoardObservationError("identity_mismatch") + if name in {"review", "ci", "gate"}: + evidence_head = item["head_sha"] + if state in {"unknown", "not_started"} and ( + source_id is not None + or evidence_head is not None + or item["coverage"] != "unavailable" + ): + raise BoardObservationError("invalid_evidence") + if state in {"running", "pass", "blocked", "pending", "failed", "stale"}: + if number is None or evidence_head is None: + raise BoardObservationError("identity_mismatch") + if state != "stale" and evidence_head is not None and evidence_head != head: + raise BoardObservationError("identity_mismatch") + if ( + name in {"review", "gate"} + and state not in {"unknown", "not_started"} + and item["coverage"] != "full" + ): + raise BoardObservationError("invalid_evidence") + if evidence["review"]["state"] == "running": + source = sources[evidence["review"]["source_id"]] + if source["freshness"] != "fresh": + raise BoardObservationError("stale_live_claim") + + +def _safe_display(display: Mapping[str, Any]) -> None: + label = display["session_label"] + if display["authorized"] is False and label is not None: + raise BoardObservationError("unauthorized_display") + if label is None: + return + if any(ord(character) < 32 or 127 <= ord(character) <= 159 for character in label): + raise BoardObservationError("invalid_contract") + if ( + label.startswith(("/", "~", "\\")) + or re.match(r"[A-Za-z]:[\\/]", label) + or _SECRET.search(label) + ): + raise BoardObservationError("privacy_violation") + + +def validate(value: object) -> dict[str, Any]: + """Validate a decoded observation and return a detached, ASCII-safe copy.""" + _check(value, schema()) + if not isinstance(value, dict): # defensive; the packaged schema already establishes this + raise BoardObservationError("invalid_contract") + created_at = _timestamp(value["created_at"]) + _safe_display(value["display"]) + sources = {source["id"]: source for source in value["sources"]} + if len(sources) != len(value["sources"]): + raise BoardObservationError("identity_mismatch") + for source in sources.values(): + _validate_source(source, created_at=created_at) + + kind, scope, work, unlinked = value["kind"], value["scope"], value["work"], value["unlinked"] + if kind == "unlinked": + if ( + scope["session_id"] is not None + or scope["worktree_id"] is not None + or work is not None + or not unlinked + ): + raise BoardObservationError("identity_mismatch") + for run in unlinked: + if run["source_id"] not in sources: + raise BoardObservationError("identity_mismatch") + if _timestamp(run["observed_at"]) > created_at: + raise BoardObservationError("invalid_timestamp") + return json.loads(json.dumps(value, ensure_ascii=True, allow_nan=False)) + + if scope["session_id"] is None or scope["worktree_id"] is None or unlinked: + raise BoardObservationError("identity_mismatch") + if kind == "no_work": + if work is not None: + raise BoardObservationError("identity_mismatch") + required = {"session", "work_queue", "run_registry"} + covered = { + source["kind"] + for source in sources.values() + if source["freshness"] == "fresh" and source["coverage"] == "complete" + } + if not required <= covered: + raise BoardObservationError("insufficient_coverage") + return json.loads(json.dumps(value, ensure_ascii=True, allow_nan=False)) + + if work is None: + raise BoardObservationError("identity_mismatch") + reasons = work["reasons"] + if reasons != ordered_reasons(reasons) or work["primary"] != derive_primary(reasons): + raise BoardObservationError("invalid_route") + _validate_measurements(work["measurements"]) + _validate_evidence(work, sources) + for run in work["runs"]: + binding = run["binding"] + if ( + binding["session_id"] != scope["session_id"] + or binding["work_id"] != work["id"] + or binding["repository"] != scope["repository"] + or binding["worktree_id"] != scope["worktree_id"] + or run["source_id"] not in sources + ): + raise BoardObservationError("identity_mismatch") + source = sources[run["source_id"]] + if run["phase"] == "assigned" and run["basis"] not in {"configured", "requested"}: + raise BoardObservationError("invalid_evidence") + if run["phase"] in {"dispatched", "observed_running"} and run["basis"] != "observed": + raise BoardObservationError("invalid_evidence") + if ( + run["phase"] in {"provider_progress", "waiting_for_user", "waiting_for_approval"} + and run["basis"] != "provider_reported" + ): + raise BoardObservationError("invalid_evidence") + event = _nullable_timestamp(run["event_at"]) + observed = _timestamp(run["observed_at"]) + heartbeat = _nullable_timestamp(run["heartbeat_at"]) + if observed > created_at or any( + item is not None and item > observed for item in (event, heartbeat) + ): + raise BoardObservationError("invalid_timestamp") + if observed > _timestamp(source["checked_at"]): + raise BoardObservationError("invalid_timestamp") + if run["phase"] in { + "observed_running", + "provider_progress", + "waiting_for_user", + "waiting_for_approval", + }: + if source["freshness"] != "fresh" or heartbeat is None: + raise BoardObservationError("stale_live_claim") + if run["phase"] == "provider_progress": + if run["basis"] != "provider_reported" or run["reported_stage"] is None: + raise BoardObservationError("invalid_lifecycle") + elif run["reported_stage"] is not None: + raise BoardObservationError("invalid_lifecycle") + _validate_lifecycle(run) + run_ids = [run["id"] for run in work["runs"]] + if len(run_ids) != len(set(run_ids)): + raise BoardObservationError("identity_mismatch") + return json.loads(json.dumps(value, ensure_ascii=True, allow_nan=False)) + + +def decode(raw: bytes) -> dict[str, Any]: + """Decode bounded JSON while rejecting duplicate keys and non-finite numbers.""" + if type(raw) is not bytes or len(raw) > MAX_BYTES: + raise BoardObservationError("invalid_contract") + + def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, item in items: + if key in result: + raise BoardObservationError("invalid_contract") + result[key] = item + return result + + try: + value = json.loads( + raw, + object_pairs_hook=pairs, + parse_constant=lambda _value: (_ for _ in ()).throw(ValueError()), + ) + return validate(value) + except (ValueError, TypeError, RecursionError, UnicodeError): + raise BoardObservationError("invalid_contract") from None diff --git a/src/code_mower/board_observation.schema.json b/src/code_mower/board_observation.schema.json new file mode 100644 index 00000000..bbfb01e3 --- /dev/null +++ b/src/code_mower/board_observation.schema.json @@ -0,0 +1,268 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "code_mower.boardObservation.v1", + "title": "Provider-neutral local Board observation", + "description": "Closed, metadata-only local read model. It is not a cloud event or workflow authority.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "kind", "created_at", "scope", "display", "sources", "work", "unlinked"], + "properties": { + "schema": {"const": "code_mower.boardObservation.v1"}, + "kind": {"enum": ["work", "no_work", "unlinked"]}, + "created_at": {"$ref": "#/$defs/timestamp"}, + "scope": {"$ref": "#/$defs/scope"}, + "display": {"$ref": "#/$defs/display"}, + "sources": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": {"$ref": "#/$defs/source"} + }, + "work": {"$ref": "#/$defs/work"}, + "unlinked": { + "type": "array", + "minItems": 0, + "maxItems": 32, + "items": {"$ref": "#/$defs/unlinked_run"} + } + }, + "$defs": { + "identifier": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,127}$", + "maxLength": 128 + }, + "nullable_identifier": { + "type": ["string", "null"], + "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,127}$", + "maxLength": 128 + }, + "timestamp": { + "type": "string", + "minLength": 20, + "maxLength": 40 + }, + "nullable_timestamp": { + "type": ["string", "null"], + "maxLength": 40 + }, + "nullable_head": { + "type": ["string", "null"], + "pattern": "^[a-f0-9]{40}$", + "maxLength": 40 + }, + "scope": { + "type": "object", + "additionalProperties": false, + "required": ["session_id", "repository", "worktree_id"], + "properties": { + "session_id": {"$ref": "#/$defs/nullable_identifier"}, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + "maxLength": 200 + }, + "worktree_id": { + "type": ["string", "null"], + "pattern": "^sha256:[a-f0-9]{64}$", + "maxLength": 71 + } + } + }, + "display": { + "type": "object", + "additionalProperties": false, + "required": ["authorized", "session_label"], + "properties": { + "authorized": {"type": "boolean"}, + "session_label": {"type": ["string", "null"], "maxLength": 120} + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "freshness", "coverage", "event_at", "observed_at", "checked_at", "heartbeat_at"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "kind": {"enum": ["session", "work_queue", "run_registry", "lease", "local_process", "local_runner", "remote_session", "github", "review", "ci", "gate", "measurement"]}, + "freshness": {"enum": ["fresh", "stale", "unavailable"]}, + "coverage": {"enum": ["complete", "partial", "unavailable"]}, + "event_at": {"$ref": "#/$defs/nullable_timestamp"}, + "observed_at": {"$ref": "#/$defs/nullable_timestamp"}, + "checked_at": {"$ref": "#/$defs/timestamp"}, + "heartbeat_at": {"$ref": "#/$defs/nullable_timestamp"} + } + }, + "binding": { + "type": "object", + "additionalProperties": false, + "required": ["session_id", "work_id", "repository", "worktree_id"], + "properties": { + "session_id": {"$ref": "#/$defs/identifier"}, + "work_id": {"$ref": "#/$defs/identifier"}, + "repository": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + "maxLength": 200 + }, + "worktree_id": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$", + "maxLength": 71 + } + } + }, + "counts": { + "type": "object", + "additionalProperties": false, + "required": ["dispatch", "message", "cancel", "collect"], + "properties": { + "dispatch": {"type": "integer", "minimum": 0, "maximum": 10000}, + "message": {"type": "integer", "minimum": 0, "maximum": 10000}, + "cancel": {"type": "integer", "minimum": 0, "maximum": 10000}, + "collect": {"type": "integer", "minimum": 0, "maximum": 10000} + } + }, + "lifecycle": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["schema", "state", "reason", "next_action", "counts"], + "properties": { + "schema": {"const": "code_mower.remote_session.v1"}, + "state": {"enum": ["archived", "complete", "failed", "pending", "running", "suspended", "terminated", "uncertain", "waiting_for_approval", "waiting_for_user"]}, + "reason": {"enum": ["approval_required", "inspect_provider_then_acknowledge", "none", "provider_unavailable", "reconcile_dispatch", "result_not_ready", "result_unavailable", "session_failed", "session_suspended", "user_input_required"]}, + "next_action": {"enum": ["acknowledge_delivered", "inspect_provider", "none", "status"]}, + "counts": {"$ref": "#/$defs/counts"} + } + }, + "run": { + "type": "object", + "additionalProperties": false, + "required": ["id", "binding", "provider", "role", "phase", "basis", "reported_stage", "source_id", "event_at", "observed_at", "heartbeat_at", "lifecycle"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "binding": {"$ref": "#/$defs/binding"}, + "provider": {"$ref": "#/$defs/identifier"}, + "role": {"enum": ["orchestrator", "builder", "reviewer"]}, + "phase": {"enum": ["assigned", "dispatched", "observed_running", "provider_progress", "waiting_for_user", "waiting_for_approval", "implementation_complete", "failed", "cancelled"]}, + "basis": {"enum": ["configured", "requested", "observed", "provider_reported"]}, + "reported_stage": {"type": ["string", "null"], "enum": [null, "planning", "implementing", "testing", "reviewing"]}, + "source_id": {"$ref": "#/$defs/identifier"}, + "event_at": {"$ref": "#/$defs/nullable_timestamp"}, + "observed_at": {"$ref": "#/$defs/timestamp"}, + "heartbeat_at": {"$ref": "#/$defs/nullable_timestamp"}, + "lifecycle": {"$ref": "#/$defs/lifecycle"} + } + }, + "unlinked_run": { + "type": "object", + "additionalProperties": false, + "required": ["id", "provider", "role", "source_id", "observed_at"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "provider": {"$ref": "#/$defs/identifier"}, + "role": {"enum": ["unknown", "orchestrator", "builder", "reviewer"]}, + "source_id": {"$ref": "#/$defs/identifier"}, + "observed_at": {"$ref": "#/$defs/timestamp"} + } + }, + "pull_request": { + "type": "object", + "additionalProperties": false, + "required": ["number", "head_sha"], + "properties": { + "number": {"type": ["integer", "null"], "minimum": 1, "maximum": 2147483647}, + "head_sha": {"$ref": "#/$defs/nullable_head"} + } + }, + "primary": { + "type": "object", + "additionalProperties": false, + "required": ["actor", "action"], + "properties": { + "actor": {"enum": ["none", "owner", "orchestrator", "builder", "reviewer", "automation", "maintainer"]}, + "action": {"enum": ["none", "respond_to_approval", "answer_question", "restore_source", "connect_session", "refresh_evidence", "inspect_failure", "inspect_provider", "inspect_cancellation", "address_findings", "update_branch", "fix_checks", "resolve_gate", "request_review", "review_current_head", "finish_review", "wait_for_checks", "wait_for_gate", "review_change", "merge"]} + } + }, + "evidence_item": { + "type": "object", + "additionalProperties": false, + "required": ["state", "source_id"], + "properties": { + "state": {"enum": ["unknown", "none", "not_started", "held", "absent", "expired", "unverifiable", "assigned", "unassigned", "requested", "not_requested", "running", "pending", "pass", "blocked", "failed", "stale", "draft", "open", "ready", "merged", "closed_unmerged"]}, + "source_id": {"$ref": "#/$defs/nullable_identifier"} + } + }, + "head_evidence_item": { + "type": "object", + "additionalProperties": false, + "required": ["state", "source_id", "head_sha", "coverage"], + "properties": { + "state": {"enum": ["unknown", "not_started", "running", "pending", "pass", "blocked", "failed", "stale"]}, + "source_id": {"$ref": "#/$defs/nullable_identifier"}, + "head_sha": {"$ref": "#/$defs/nullable_head"}, + "coverage": {"enum": ["full", "sampled", "unavailable"]} + } + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["lease", "assignment", "review_request", "review", "ci", "gate_publisher", "gate", "merge"], + "properties": { + "lease": {"$ref": "#/$defs/evidence_item"}, + "assignment": {"$ref": "#/$defs/evidence_item"}, + "review_request": {"$ref": "#/$defs/evidence_item"}, + "review": {"$ref": "#/$defs/head_evidence_item"}, + "ci": {"$ref": "#/$defs/head_evidence_item"}, + "gate_publisher": {"$ref": "#/$defs/evidence_item"}, + "gate": {"$ref": "#/$defs/head_evidence_item"}, + "merge": {"$ref": "#/$defs/evidence_item"} + } + }, + "measurement": { + "type": "object", + "additionalProperties": false, + "required": ["value", "coverage", "observed", "total"], + "properties": { + "value": {"type": ["number", "null"], "minimum": 0, "maximum": 1000000000}, + "coverage": {"enum": ["complete", "partial", "unavailable"]}, + "observed": {"type": "integer", "minimum": 0, "maximum": 10000}, + "total": {"type": ["integer", "null"], "minimum": 0, "maximum": 10000} + } + }, + "measurements": { + "type": "object", + "additionalProperties": false, + "required": ["elapsed_seconds", "cost_usd", "quality_score", "productivity_count", "provenance_count"], + "properties": { + "elapsed_seconds": {"$ref": "#/$defs/measurement"}, + "cost_usd": {"$ref": "#/$defs/measurement"}, + "quality_score": {"$ref": "#/$defs/measurement"}, + "productivity_count": {"$ref": "#/$defs/measurement"}, + "provenance_count": {"$ref": "#/$defs/measurement"} + } + }, + "work": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["id", "reference", "stage", "reasons", "primary", "pull_request", "runs", "evidence", "measurements"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "reference": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:/#-]{0,127}$", "maxLength": 128}, + "stage": {"enum": ["unknown", "queued", "building", "in_review", "changes_requested", "ready_for_human_review", "ready_to_merge", "merged"]}, + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 19, + "uniqueItems": true, + "items": {"enum": ["approval_required", "user_input_required", "source_unavailable", "identity_unlinked", "stale_observation", "provider_failed", "provider_suspended", "cancelled", "changes_requested", "update_required", "ci_failed", "gate_failed", "review_stale", "review_requested", "review_in_progress", "ci_pending", "gate_pending", "human_review_required", "ready_to_merge"]} + }, + "primary": {"$ref": "#/$defs/primary"}, + "pull_request": {"$ref": "#/$defs/pull_request"}, + "runs": {"type": "array", "minItems": 0, "maxItems": 32, "items": {"$ref": "#/$defs/run"}}, + "evidence": {"$ref": "#/$defs/evidence"}, + "measurements": {"$ref": "#/$defs/measurements"} + } + } + } +} diff --git a/src/code_mower/package_manifest.py b/src/code_mower/package_manifest.py index cf1b7b12..0b8ce93b 100644 --- a/src/code_mower/package_manifest.py +++ b/src/code_mower/package_manifest.py @@ -15,6 +15,8 @@ ("tools/code_mower_bootstrap.py", "src/code_mower/bootstrap.py", "core"), ("src/code_mower/board.py", "src/code_mower/board.py", "core"), ("src/code_mower/board_store.py", "src/code_mower/board_store.py", "core"), + ("src/code_mower/board_observation.py", "src/code_mower/board_observation.py", "core"), + ("src/code_mower/board_observation.schema.json", "src/code_mower/board_observation.schema.json", "schema"), ("src/code_mower/file_locks.py", "src/code_mower/file_locks.py", "core"), ("src/code_mower/builder_runs.py", "src/code_mower/builder_runs.py", "core"), ("tools/code_mower_builder_experiment.py", "src/code_mower/builder_experiment.py", "core"), diff --git a/tests/fixtures/board_observations.json b/tests/fixtures/board_observations.json new file mode 100644 index 00000000..d0de2e51 --- /dev/null +++ b/tests/fixtures/board_observations.json @@ -0,0 +1,1185 @@ +{ + "adversarial": [ + { + "base": "observed_running", + "error": "invalid_contract", + "name": "unknown_field", + "set": { + "": { + "created_at": "2026-09-12T20:00:00Z", + "display": { + "authorized": false, + "session_label": null + }, + "kind": "work", + "schema": "code_mower.boardObservation.v1", + "scope": { + "repository": "codemower-ai/code-mower", + "session_id": "sessionfixture00000000000000000001", + "worktree_id": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "sources": [ + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "sessionobs", + "kind": "session", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "queueobs", + "kind": "work_queue", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "registryobs", + "kind": "run_registry", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "leaseobs", + "kind": "lease", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": "2026-09-12T19:59:50Z", + "id": "providerobs", + "kind": "remote_session", + "observed_at": "2026-09-12T19:59:50Z" + } + ], + "unexpected": "not_permitted", + "unlinked": [], + "work": { + "evidence": { + "assignment": { + "source_id": "queueobs", + "state": "assigned" + }, + "ci": { + "coverage": "unavailable", + "head_sha": null, + "source_id": null, + "state": "unknown" + }, + "gate": { + "coverage": "unavailable", + "head_sha": null, + "source_id": null, + "state": "unknown" + }, + "gate_publisher": { + "source_id": null, + "state": "unknown" + }, + "lease": { + "source_id": "leaseobs", + "state": "held" + }, + "merge": { + "source_id": null, + "state": "unknown" + }, + "review": { + "coverage": "unavailable", + "head_sha": null, + "source_id": null, + "state": "unknown" + }, + "review_request": { + "source_id": null, + "state": "unknown" + } + }, + "id": "runningwork", + "measurements": { + "cost_usd": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "elapsed_seconds": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "productivity_count": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "provenance_count": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "quality_score": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + } + }, + "primary": { + "action": "none", + "actor": "none" + }, + "pull_request": { + "head_sha": null, + "number": null + }, + "reasons": [], + "reference": "issue-946", + "runs": [ + { + "basis": "observed", + "binding": { + "repository": "codemower-ai/code-mower", + "session_id": "sessionfixture00000000000000000001", + "work_id": "runningwork", + "worktree_id": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "event_at": "2026-09-12T19:59:40Z", + "heartbeat_at": "2026-09-12T19:59:50Z", + "id": "runrunningwork", + "lifecycle": { + "counts": { + "cancel": 0, + "collect": 0, + "dispatch": 1, + "message": 0 + }, + "next_action": "status", + "reason": "none", + "schema": "code_mower.remote_session.v1", + "state": "running" + }, + "observed_at": "2026-09-12T19:59:50Z", + "phase": "observed_running", + "provider": "codex", + "reported_stage": null, + "role": "builder", + "source_id": "providerobs" + } + ], + "stage": "building" + } + } + } + }, + { + "base": "observed_running", + "error": "identity_mismatch", + "name": "cross_session_join", + "set": { + "/work/runs/0/binding/session_id": "othersession" + } + }, + { + "base": "observed_running", + "error": "identity_mismatch", + "name": "worktree_mismatch", + "set": { + "/work/runs/0/binding/worktree_id": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + }, + { + "base": "observed_running", + "error": "invalid_timestamp", + "name": "invalid_timestamp", + "set": { + "/sources/0/observed_at": "not-a-time" + } + }, + { + "base": "observed_running", + "error": "stale_live_claim", + "name": "stale_running_claim", + "set": { + "/sources/4/coverage": "partial", + "/sources/4/freshness": "stale" + } + }, + { + "base": "observed_running", + "error": "unauthorized_display", + "name": "label_without_authorization", + "set": { + "/display/session_label": "Unauthorized label" + } + }, + { + "base": "reviewed", + "error": "identity_mismatch", + "name": "review_head_mismatch", + "set": { + "/work/evidence/review/head_sha": "cccccccccccccccccccccccccccccccccccccccc" + } + }, + { + "base": "observed_running", + "error": "invalid_measurement", + "name": "missing_measurement_as_zero", + "set": { + "/work/measurements/cost_usd/value": 0 + } + }, + { + "base": "observed_running", + "error": "invalid_contract", + "name": "unknown_reason", + "set": { + "/work/reasons": [ + "made_up_reason" + ] + } + }, + { + "base": "waiting_for_user", + "error": "invalid_route", + "name": "wrong_primary_route", + "set": { + "/work/primary/action": "address_findings", + "/work/primary/actor": "builder" + } + }, + { + "base": "no_work", + "error": "insufficient_coverage", + "name": "insufficient_no_work_coverage", + "set": { + "/sources/1/coverage": "partial", + "/sources/1/freshness": "stale" + } + }, + { + "base": "unlinked", + "error": "identity_mismatch", + "name": "unlinked_claims_session", + "set": { + "/scope/session_id": "sessionfixture00000000000000000001" + } + }, + { + "base": "observed_running", + "error": "invalid_contract", + "name": "oversized_identifier", + "set": { + "/work/id": "wxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + ], + "schema": "code_mower.boardObservationFixtures.v1", + "templates": { + "no_work": { + "created_at": "2026-09-12T20:00:00Z", + "display": { + "authorized": true, + "session_label": "Board contract delivery" + }, + "kind": "no_work", + "schema": "code_mower.boardObservation.v1", + "scope": { + "repository": "codemower-ai/code-mower", + "session_id": "sessionfixture00000000000000000001", + "worktree_id": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "sources": [ + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "sessionobs", + "kind": "session", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "queueobs", + "kind": "work_queue", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "registryobs", + "kind": "run_registry", + "observed_at": "2026-09-12T19:59:50Z" + } + ], + "unlinked": [], + "work": null + }, + "unlinked": { + "created_at": "2026-09-12T20:00:00Z", + "display": { + "authorized": false, + "session_label": null + }, + "kind": "unlinked", + "schema": "code_mower.boardObservation.v1", + "scope": { + "repository": "codemower-ai/code-mower", + "session_id": null, + "worktree_id": null + }, + "sources": [ + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "processobs", + "kind": "local_process", + "observed_at": "2026-09-12T19:59:50Z" + } + ], + "unlinked": [ + { + "id": "unlinkedrun", + "observed_at": "2026-09-12T19:59:50Z", + "provider": "claude", + "role": "unknown", + "source_id": "processobs" + } + ], + "work": null + }, + "work_pr": { + "created_at": "2026-09-12T20:00:00Z", + "display": { + "authorized": false, + "session_label": null + }, + "kind": "work", + "schema": "code_mower.boardObservation.v1", + "scope": { + "repository": "codemower-ai/code-mower", + "session_id": "sessionfixture00000000000000000001", + "worktree_id": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "sources": [ + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "sessionobs", + "kind": "session", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "queueobs", + "kind": "work_queue", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "registryobs", + "kind": "run_registry", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "leaseobs", + "kind": "lease", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": "2026-09-12T19:59:50Z", + "id": "providerobs", + "kind": "remote_session", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "githubobs", + "kind": "github", + "observed_at": "2026-09-12T19:59:50Z" + } + ], + "unlinked": [], + "work": { + "evidence": { + "assignment": { + "source_id": "queueobs", + "state": "assigned" + }, + "ci": { + "coverage": "unavailable", + "head_sha": null, + "source_id": null, + "state": "unknown" + }, + "gate": { + "coverage": "unavailable", + "head_sha": null, + "source_id": null, + "state": "unknown" + }, + "gate_publisher": { + "source_id": null, + "state": "unknown" + }, + "lease": { + "source_id": "leaseobs", + "state": "held" + }, + "merge": { + "source_id": null, + "state": "unknown" + }, + "review": { + "coverage": "unavailable", + "head_sha": null, + "source_id": null, + "state": "unknown" + }, + "review_request": { + "source_id": null, + "state": "unknown" + } + }, + "id": "completework", + "measurements": { + "cost_usd": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "elapsed_seconds": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "productivity_count": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "provenance_count": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "quality_score": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + } + }, + "primary": { + "action": "review_current_head", + "actor": "reviewer" + }, + "pull_request": { + "head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "number": 946 + }, + "reasons": [ + "review_requested" + ], + "reference": "issue-946", + "runs": [ + { + "basis": "provider_reported", + "binding": { + "repository": "codemower-ai/code-mower", + "session_id": "sessionfixture00000000000000000001", + "work_id": "completework", + "worktree_id": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "event_at": "2026-09-12T19:59:40Z", + "heartbeat_at": null, + "id": "runcompletework", + "lifecycle": { + "counts": { + "cancel": 0, + "collect": 0, + "dispatch": 1, + "message": 0 + }, + "next_action": "none", + "reason": "none", + "schema": "code_mower.remote_session.v1", + "state": "complete" + }, + "observed_at": "2026-09-12T19:59:50Z", + "phase": "implementation_complete", + "provider": "codex", + "reported_stage": null, + "role": "builder", + "source_id": "providerobs" + } + ], + "stage": "in_review" + } + }, + "work_run": { + "created_at": "2026-09-12T20:00:00Z", + "display": { + "authorized": false, + "session_label": null + }, + "kind": "work", + "schema": "code_mower.boardObservation.v1", + "scope": { + "repository": "codemower-ai/code-mower", + "session_id": "sessionfixture00000000000000000001", + "worktree_id": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "sources": [ + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "sessionobs", + "kind": "session", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "queueobs", + "kind": "work_queue", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "registryobs", + "kind": "run_registry", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "leaseobs", + "kind": "lease", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": "2026-09-12T19:59:50Z", + "id": "providerobs", + "kind": "remote_session", + "observed_at": "2026-09-12T19:59:50Z" + } + ], + "unlinked": [], + "work": { + "evidence": { + "assignment": { + "source_id": "queueobs", + "state": "assigned" + }, + "ci": { + "coverage": "unavailable", + "head_sha": null, + "source_id": null, + "state": "unknown" + }, + "gate": { + "coverage": "unavailable", + "head_sha": null, + "source_id": null, + "state": "unknown" + }, + "gate_publisher": { + "source_id": null, + "state": "unknown" + }, + "lease": { + "source_id": "leaseobs", + "state": "held" + }, + "merge": { + "source_id": null, + "state": "unknown" + }, + "review": { + "coverage": "unavailable", + "head_sha": null, + "source_id": null, + "state": "unknown" + }, + "review_request": { + "source_id": null, + "state": "unknown" + } + }, + "id": "runningwork", + "measurements": { + "cost_usd": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "elapsed_seconds": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "productivity_count": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "provenance_count": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "quality_score": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + } + }, + "primary": { + "action": "none", + "actor": "none" + }, + "pull_request": { + "head_sha": null, + "number": null + }, + "reasons": [], + "reference": "issue-946", + "runs": [ + { + "basis": "observed", + "binding": { + "repository": "codemower-ai/code-mower", + "session_id": "sessionfixture00000000000000000001", + "work_id": "runningwork", + "worktree_id": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "event_at": "2026-09-12T19:59:40Z", + "heartbeat_at": "2026-09-12T19:59:50Z", + "id": "runrunningwork", + "lifecycle": { + "counts": { + "cancel": 0, + "collect": 0, + "dispatch": 1, + "message": 0 + }, + "next_action": "status", + "reason": "none", + "schema": "code_mower.remote_session.v1", + "state": "running" + }, + "observed_at": "2026-09-12T19:59:50Z", + "phase": "observed_running", + "provider": "codex", + "reported_stage": null, + "role": "builder", + "source_id": "providerobs" + } + ], + "stage": "building" + } + }, + "work_without_run": { + "created_at": "2026-09-12T20:00:00Z", + "display": { + "authorized": false, + "session_label": null + }, + "kind": "work", + "schema": "code_mower.boardObservation.v1", + "scope": { + "repository": "codemower-ai/code-mower", + "session_id": "sessionfixture00000000000000000001", + "worktree_id": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "sources": [ + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "sessionobs", + "kind": "session", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "queueobs", + "kind": "work_queue", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "registryobs", + "kind": "run_registry", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "complete", + "event_at": "2026-09-12T19:59:40Z", + "freshness": "fresh", + "heartbeat_at": null, + "id": "leaseobs", + "kind": "lease", + "observed_at": "2026-09-12T19:59:50Z" + }, + { + "checked_at": "2026-09-12T20:00:00Z", + "coverage": "unavailable", + "event_at": "2026-09-12T17:59:00Z", + "freshness": "unavailable", + "heartbeat_at": "2026-09-12T18:00:00Z", + "id": "providerobs", + "kind": "remote_session", + "observed_at": "2026-09-12T18:00:00Z" + } + ], + "unlinked": [], + "work": { + "evidence": { + "assignment": { + "source_id": "queueobs", + "state": "assigned" + }, + "ci": { + "coverage": "unavailable", + "head_sha": null, + "source_id": null, + "state": "unknown" + }, + "gate": { + "coverage": "unavailable", + "head_sha": null, + "source_id": null, + "state": "unknown" + }, + "gate_publisher": { + "source_id": null, + "state": "unknown" + }, + "lease": { + "source_id": "leaseobs", + "state": "held" + }, + "merge": { + "source_id": null, + "state": "unknown" + }, + "review": { + "coverage": "unavailable", + "head_sha": null, + "source_id": null, + "state": "unknown" + }, + "review_request": { + "source_id": null, + "state": "unknown" + } + }, + "id": "unavailablework", + "measurements": { + "cost_usd": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "elapsed_seconds": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "productivity_count": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "provenance_count": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + }, + "quality_score": { + "coverage": "unavailable", + "observed": 0, + "total": null, + "value": null + } + }, + "primary": { + "action": "restore_source", + "actor": "orchestrator" + }, + "pull_request": { + "head_sha": null, + "number": null + }, + "reasons": [ + "source_unavailable" + ], + "reference": "issue-946", + "runs": [], + "stage": "unknown" + } + } + }, + "valid": [ + { + "name": "assigned", + "set": { + "/work/id": "assignedwork", + "/work/runs/0/basis": "configured", + "/work/runs/0/binding/work_id": "assignedwork", + "/work/runs/0/heartbeat_at": null, + "/work/runs/0/id": "runassignedwork", + "/work/runs/0/lifecycle": null, + "/work/runs/0/phase": "assigned", + "/work/stage": "queued" + }, + "template": "work_run" + }, + { + "name": "dispatched", + "set": { + "/work/id": "dispatchedwork", + "/work/runs/0/binding/work_id": "dispatchedwork", + "/work/runs/0/heartbeat_at": null, + "/work/runs/0/id": "rundispatchedwork", + "/work/runs/0/lifecycle/next_action": "none", + "/work/runs/0/lifecycle/state": "pending", + "/work/runs/0/phase": "dispatched", + "/work/stage": "queued" + }, + "template": "work_run" + }, + { + "name": "observed_running", + "set": {}, + "template": "work_run" + }, + { + "name": "provider_reported_progress", + "set": { + "/work/id": "progresswork", + "/work/runs/0/basis": "provider_reported", + "/work/runs/0/binding/work_id": "progresswork", + "/work/runs/0/id": "runprogresswork", + "/work/runs/0/phase": "provider_progress", + "/work/runs/0/reported_stage": "testing" + }, + "template": "work_run" + }, + { + "name": "waiting_for_user", + "set": { + "/work/id": "questionwork", + "/work/primary/action": "answer_question", + "/work/primary/actor": "owner", + "/work/reasons": [ + "user_input_required" + ], + "/work/runs/0/basis": "provider_reported", + "/work/runs/0/binding/work_id": "questionwork", + "/work/runs/0/id": "runquestionwork", + "/work/runs/0/lifecycle/next_action": "none", + "/work/runs/0/lifecycle/reason": "user_input_required", + "/work/runs/0/lifecycle/state": "waiting_for_user", + "/work/runs/0/phase": "waiting_for_user" + }, + "template": "work_run" + }, + { + "name": "waiting_for_approval", + "set": { + "/work/id": "approvalwork", + "/work/primary/action": "respond_to_approval", + "/work/primary/actor": "owner", + "/work/reasons": [ + "approval_required" + ], + "/work/runs/0/basis": "provider_reported", + "/work/runs/0/binding/work_id": "approvalwork", + "/work/runs/0/id": "runapprovalwork", + "/work/runs/0/lifecycle/next_action": "none", + "/work/runs/0/lifecycle/reason": "approval_required", + "/work/runs/0/lifecycle/state": "waiting_for_approval", + "/work/runs/0/phase": "waiting_for_approval" + }, + "template": "work_run" + }, + { + "name": "implementation_complete", + "set": {}, + "template": "work_pr" + }, + { + "name": "failed", + "set": { + "/work/id": "failedwork", + "/work/primary/action": "inspect_failure", + "/work/primary/actor": "orchestrator", + "/work/reasons": [ + "provider_failed" + ], + "/work/runs/0/basis": "provider_reported", + "/work/runs/0/binding/work_id": "failedwork", + "/work/runs/0/heartbeat_at": null, + "/work/runs/0/id": "runfailedwork", + "/work/runs/0/lifecycle/next_action": "inspect_provider", + "/work/runs/0/lifecycle/reason": "session_failed", + "/work/runs/0/lifecycle/state": "failed", + "/work/runs/0/phase": "failed" + }, + "template": "work_run" + }, + { + "name": "cancelled", + "set": { + "/work/id": "cancelledwork", + "/work/primary/action": "inspect_cancellation", + "/work/primary/actor": "orchestrator", + "/work/reasons": [ + "cancelled" + ], + "/work/runs/0/binding/work_id": "cancelledwork", + "/work/runs/0/heartbeat_at": null, + "/work/runs/0/id": "runcancelledwork", + "/work/runs/0/lifecycle/next_action": "none", + "/work/runs/0/lifecycle/state": "terminated", + "/work/runs/0/phase": "cancelled" + }, + "template": "work_run" + }, + { + "name": "reviewed", + "set": { + "/work/evidence/review/coverage": "full", + "/work/evidence/review/head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "/work/evidence/review/source_id": "githubobs", + "/work/evidence/review/state": "pass", + "/work/evidence/review_request/source_id": "githubobs", + "/work/evidence/review_request/state": "requested", + "/work/id": "reviewedwork", + "/work/primary/action": "review_change", + "/work/primary/actor": "owner", + "/work/reasons/0": "human_review_required", + "/work/runs/0/binding/work_id": "reviewedwork", + "/work/runs/0/id": "runreviewedwork", + "/work/stage": "ready_for_human_review" + }, + "template": "work_pr" + }, + { + "name": "ready", + "set": { + "/work/evidence/ci/coverage": "full", + "/work/evidence/ci/head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "/work/evidence/ci/source_id": "githubobs", + "/work/evidence/ci/state": "pass", + "/work/evidence/gate/coverage": "full", + "/work/evidence/gate/head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "/work/evidence/gate/source_id": "githubobs", + "/work/evidence/gate/state": "pass", + "/work/evidence/gate_publisher/source_id": "githubobs", + "/work/evidence/gate_publisher/state": "pass", + "/work/evidence/merge/source_id": "githubobs", + "/work/evidence/merge/state": "ready", + "/work/evidence/review/coverage": "full", + "/work/evidence/review/head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "/work/evidence/review/source_id": "githubobs", + "/work/evidence/review/state": "pass", + "/work/evidence/review_request/source_id": "githubobs", + "/work/evidence/review_request/state": "requested", + "/work/id": "readywork", + "/work/primary/action": "merge", + "/work/primary/actor": "maintainer", + "/work/reasons/0": "ready_to_merge", + "/work/runs/0/binding/work_id": "readywork", + "/work/runs/0/id": "runreadywork", + "/work/stage": "ready_to_merge" + }, + "template": "work_pr" + }, + { + "name": "publisher_pass_gate_pending", + "set": { + "/work/evidence/gate/coverage": "full", + "/work/evidence/gate/head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "/work/evidence/gate/source_id": "githubobs", + "/work/evidence/gate/state": "pending", + "/work/evidence/gate_publisher/source_id": "githubobs", + "/work/evidence/gate_publisher/state": "pass", + "/work/id": "publisherwork", + "/work/primary/action": "wait_for_gate", + "/work/primary/actor": "automation", + "/work/reasons/0": "gate_pending", + "/work/runs/0/binding/work_id": "publisherwork", + "/work/runs/0/id": "runpublisherwork" + }, + "template": "work_pr" + }, + { + "name": "merged", + "set": { + "/work/evidence/ci/coverage": "full", + "/work/evidence/ci/head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "/work/evidence/ci/source_id": "githubobs", + "/work/evidence/ci/state": "pass", + "/work/evidence/gate/coverage": "full", + "/work/evidence/gate/head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "/work/evidence/gate/source_id": "githubobs", + "/work/evidence/gate/state": "pass", + "/work/evidence/gate_publisher/source_id": "githubobs", + "/work/evidence/gate_publisher/state": "pass", + "/work/evidence/merge/source_id": "githubobs", + "/work/evidence/merge/state": "merged", + "/work/evidence/review/coverage": "full", + "/work/evidence/review/head_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "/work/evidence/review/source_id": "githubobs", + "/work/evidence/review/state": "pass", + "/work/evidence/review_request/source_id": "githubobs", + "/work/evidence/review_request/state": "requested", + "/work/id": "mergedwork", + "/work/primary/action": "none", + "/work/primary/actor": "none", + "/work/reasons": [], + "/work/runs/0/binding/work_id": "mergedwork", + "/work/runs/0/id": "runmergedwork", + "/work/stage": "merged" + }, + "template": "work_pr" + }, + { + "name": "stale", + "set": { + "/sources/4/coverage": "partial", + "/sources/4/event_at": "2026-09-12T17:59:00Z", + "/sources/4/freshness": "stale", + "/sources/4/heartbeat_at": "2026-09-12T18:00:00Z", + "/sources/4/observed_at": "2026-09-12T18:00:00Z", + "/work/id": "stalework", + "/work/primary/action": "refresh_evidence", + "/work/primary/actor": "orchestrator", + "/work/reasons": [ + "stale_observation", + "review_requested" + ], + "/work/runs/0/binding/work_id": "stalework", + "/work/runs/0/id": "runstalework" + }, + "template": "work_pr" + }, + { + "name": "source_unavailable_preserves_last_observation", + "set": {}, + "template": "work_without_run" + }, + { + "name": "no_work", + "set": {}, + "template": "no_work" + }, + { + "name": "unlinked", + "set": {}, + "template": "unlinked" + } + ] +} diff --git a/tests/test_board_observation.py b/tests/test_board_observation.py new file mode 100644 index 00000000..1bff8fa0 --- /dev/null +++ b/tests/test_board_observation.py @@ -0,0 +1,265 @@ +"""Offline contract and adversarial tests for the Board read model.""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path + +import unittest + +from code_mower import remote_session +from code_mower.board_observation import ( + ACTIONS, + ACTORS, + MAX_BYTES, + REASONS, + SCHEMA, + STAGES, + BoardObservationError, + decode, + derive_primary, + ordered_reasons, + schema, + validate, +) + + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "board_observations.json" + + +def fixture() -> dict: + return json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + + +def apply_fixture_values(record: dict, values: dict[str, object]) -> dict: + result = copy.deepcopy(record) + for pointer, value in values.items(): + if pointer == "": + result = copy.deepcopy(value) + continue + parts = [part.replace("~1", "/").replace("~0", "~") for part in pointer.split("/")[1:]] + target: object = result + for part in parts[:-1]: + target = target[int(part)] if isinstance(target, list) else target[part] + if isinstance(target, list): + target[int(parts[-1])] = copy.deepcopy(value) + else: + target[parts[-1]] = copy.deepcopy(value) + return result + + +def case_record(case: dict) -> dict: + template = fixture()["templates"][case["template"]] + return apply_fixture_values(template, case["set"]) + + +def named_record(name: str) -> dict: + case = next(item for item in fixture()["valid"] if item["name"] == name) + return case_record(case) + + +class BoardObservationTests(unittest.TestCase): + def test_valid_fixtures_cover_frozen_truth_states(self) -> None: + expected = { + "assigned", + "dispatched", + "observed_running", + "provider_reported_progress", + "waiting_for_user", + "waiting_for_approval", + "implementation_complete", + "failed", + "cancelled", + "reviewed", + "ready", + "publisher_pass_gate_pending", + "merged", + "stale", + "source_unavailable_preserves_last_observation", + "unlinked", + "no_work", + } + cases = fixture()["valid"] + + assert {case["name"] for case in cases} == expected + for case in cases: + record = case_record(case) + assert validate(record) == record + + def test_adversarial_fixtures_fail_closed_with_bounded_diagnostics(self) -> None: + for case in fixture()["adversarial"]: + record = apply_fixture_values(named_record(case["base"]), case["set"]) + with self.assertRaisesRegex(BoardObservationError, f"^{case['error']}$"): + validate(record) + + def test_multiple_reasons_have_one_canonical_primary_route(self) -> None: + reasons = ["review_requested", "ci_failed", "approval_required"] + + assert ordered_reasons(reasons) == ["approval_required", "ci_failed", "review_requested"] + assert derive_primary(reasons) == {"actor": "owner", "action": "respond_to_approval"} + + record = copy.deepcopy(named_record("observed_running")) + record["work"]["reasons"] = ordered_reasons(reasons) + record["work"]["primary"] = derive_primary(reasons) + assert validate(record)["work"]["primary"]["actor"] == "owner" + + def test_reason_duplicates_and_noncanonical_order_fail(self) -> None: + with self.assertRaisesRegex(BoardObservationError, "invalid_reason"): + derive_primary(["ci_pending", "ci_pending"]) + + record = copy.deepcopy(named_record("observed_running")) + record["work"]["reasons"] = ["review_requested", "approval_required"] + record["work"]["primary"] = derive_primary(record["work"]["reasons"]) + with self.assertRaisesRegex(BoardObservationError, "invalid_route"): + validate(record) + + def test_provider_lifecycle_is_exactly_the_existing_public_projection(self) -> None: + record = named_record("waiting_for_approval") + lifecycle = record["work"]["runs"][0]["lifecycle"] + + assert remote_session.public_projection(lifecycle) == lifecycle + lifecycle_schema = schema()["$defs"]["lifecycle"]["properties"] + assert set(lifecycle_schema["state"]["enum"]) == remote_session.STATES + assert set(lifecycle_schema["reason"]["enum"]) == remote_session.REASONS + assert set(lifecycle_schema["next_action"]["enum"]) == remote_session.ACTIONS + + def test_request_label_lease_and_gate_publisher_do_not_claim_execution_or_gate(self) -> None: + assigned = validate(named_record("assigned")) + assert assigned["work"]["evidence"]["lease"]["state"] == "held" + assert assigned["work"]["runs"][0]["phase"] == "assigned" + assert assigned["work"]["runs"][0]["basis"] == "configured" + + lease_only = copy.deepcopy(assigned) + lease_only["work"]["runs"] = [] + assert validate(lease_only)["work"]["runs"] == [] + + requested = copy.deepcopy(named_record("implementation_complete")) + requested["work"]["evidence"]["review_request"] = { + "state": "requested", + "source_id": "githubobs", + } + assert validate(requested)["work"]["evidence"]["review"]["state"] == "unknown" + + published = validate(named_record("publisher_pass_gate_pending")) + assert published["work"]["evidence"]["gate_publisher"]["state"] == "pass" + assert published["work"]["evidence"]["gate"]["state"] == "pending" + + def test_unavailable_source_preserves_last_observation_without_live_claim(self) -> None: + record = validate(named_record("source_unavailable_preserves_last_observation")) + unavailable = next(source for source in record["sources"] if source["id"] == "providerobs") + + assert unavailable["freshness"] == "unavailable" + assert unavailable["coverage"] == "unavailable" + assert unavailable["observed_at"] is not None + assert record["work"]["runs"] == [] + assert record["work"]["primary"] == { + "actor": "orchestrator", + "action": "restore_source", + } + + def test_no_work_requires_fresh_complete_session_queue_and_run_coverage(self) -> None: + record = copy.deepcopy(named_record("no_work")) + assert validate(record)["kind"] == "no_work" + + record["sources"][2]["coverage"] = "partial" + with self.assertRaisesRegex(BoardObservationError, "insufficient_coverage"): + validate(record) + + def test_unknown_measurement_is_null_not_zero_or_success(self) -> None: + measurements = validate(named_record("observed_running"))["work"]["measurements"] + for measurement in measurements.values(): + assert measurement == { + "value": None, + "coverage": "unavailable", + "observed": 0, + "total": None, + } + + def test_authorized_session_label_is_explicit_and_bounded(self) -> None: + record = copy.deepcopy(named_record("no_work")) + assert validate(record)["display"]["session_label"] == "Board contract delivery" + + record["display"]["session_label"] = "/private/example" + with self.assertRaisesRegex(BoardObservationError, "privacy_violation"): + validate(record) + + def test_decode_is_bounded_duplicate_safe_and_finite(self) -> None: + record = named_record("observed_running") + assert decode(json.dumps(record).encode()) == record + with self.assertRaisesRegex(BoardObservationError, "invalid_contract"): + decode(b'{"schema":"a","schema":"b"}') + with self.assertRaisesRegex(BoardObservationError, "invalid_contract"): + decode(b"x" * (MAX_BYTES + 1)) + + nonfinite = copy.deepcopy(record) + nonfinite["work"]["measurements"]["cost_usd"] = { + "value": float("nan"), + "coverage": "complete", + "observed": 1, + "total": 1, + } + with self.assertRaisesRegex(BoardObservationError, "invalid_contract"): + validate(nonfinite) + + def test_validation_returns_a_detached_copy(self) -> None: + record = named_record("observed_running") + result = validate(record) + result["sources"][0]["freshness"] = "stale" + assert record["sources"][0]["freshness"] == "fresh" + + def test_schema_is_closed_and_constants_do_not_drift(self) -> None: + contract = schema() + assert contract["$id"] == SCHEMA + assert contract["additionalProperties"] is False + work = contract["$defs"]["work"]["properties"] + assert set(work["stage"]["enum"]) == STAGES + assert set(work["reasons"]["items"]["enum"]) == REASONS + primary = contract["$defs"]["primary"]["properties"] + assert set(primary["actor"]["enum"]) == ACTORS + assert set(primary["action"]["enum"]) == ACTIONS + + def test_fixture_is_metadata_only(self) -> None: + payload = fixture() + forbidden_keys = { + "body", + "description", + "diff", + "prompt", + "transcript", + "context", + "graph", + "credentials", + "raw_output", + "path", + "pid", + "secret", + "token", + } + + def visit(value: object) -> None: + if isinstance(value, dict): + assert not (set(value) & forbidden_keys) + if "message" in value: + assert type(value["message"]) is int + for item in value.values(): + visit(item) + elif isinstance(value, list): + for item in value: + visit(item) + elif isinstance(value, str): + assert not value.startswith(("/" + "Users/", "/" + "home/")) + assert "github_pat_" not in value + + visit(payload) + + def test_packaged_contract_files_are_declared(self) -> None: + from code_mower.package_manifest import PACKAGE_FILES + + targets = {target for _source, target, _mode in PACKAGE_FILES} + assert "src/code_mower/board_observation.py" in targets + assert "src/code_mower/board_observation.schema.json" in targets + + +if __name__ == "__main__": + unittest.main()