diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index adfc9202..1c93514b 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - 'src-rust/**' + - 'scripts/test-headless-schemas.py' - '.github/workflows/rust-ci.yml' workflow_dispatch: @@ -18,6 +19,22 @@ env: CARGO_TERM_COLOR: always jobs: + headless-schemas: + name: Headless JSON Schema constraints + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Validate runtime fixtures and cross-field constraints + run: | + python -m pip install jsonschema==4.25.1 + python scripts/test-headless-schemas.py + rust: name: Format, lint, and test runs-on: ubuntu-latest diff --git a/docs/headless-contract.md b/docs/headless-contract.md index cba95c69..4719248e 100644 --- a/docs/headless-contract.md +++ b/docs/headless-contract.md @@ -6,22 +6,26 @@ The runtime also accepts version `2` briefs during the coven-github migration and echoes `"2"` in their result envelopes. Version `3` remains the normative schema; all other major versions are rejected. -This document is the single source of truth for the interface between -`coven-github` (the GitHub ingress adapter) and `coven-code` (the execution -runtime) when the runtime is invoked in headless mode. - -It is normative. Where the prose in [`COVEN-GITHUB.md`](../COVEN-GITHUB.md) or any -other doc disagrees with this file, **this file wins**. Both repositories MUST -implement exactly what is specified here, and changes require a contract version -bump (see [Versioning](#versioning)). - -The contract is enforced on the `coven-github` side by golden fixtures in -[`docs/contracts/`](contracts/) and a conformance test -(`crates/github/tests/contract.rs`) that round-trips those fixtures through the -Rust types. `coven-code` MUST validate its emitted `result.json` against -[`docs/contracts/result.schema.json`](contracts/result.schema.json) and its -accepted `session-brief.json` against -[`docs/contracts/session-brief.schema.json`](contracts/session-brief.schema.json). +This document specifies the runtime's version 3 interface. The adapter's +[upstream contract](https://github.com/OpenCoven/coven-github/blob/main/docs/headless-contract.md) +and fixtures currently describe version 2. The local fixtures include v3 +review-memory provenance and are not verbatim upstream copies. + +Where [`COVEN-GITHUB.md`](../COVEN-GITHUB.md) disagrees with this runtime +specification, this file wins. Coordinate breaking changes with the adapter +and bump the contract version (see [Versioning](#6-versioning)). + +The runtime fixtures live in +[`src-rust/crates/cli/tests/headless_contract/`](../src-rust/crates/cli/tests/headless_contract/). +Rust tests round-trip those fixtures through runtime types. Run +`uv run scripts/test-headless-schemas.py` to validate the v3 fixtures and +cross-field constraints with a Draft 2020-12 JSON Schema validator. + +Accepting a v2 brief and echoing its version does not prove strict adapter-v2 +schema conformance: the runtime also emits `review.memory`, which the current +adapter-v2 schema does not accept. That migration requires coordinated work +in both repositories. This document does not claim adapter memory-grant or +memory-write governance support. The key words MUST, MUST NOT, SHOULD, and MAY are used as in RFC 2119. @@ -197,7 +201,7 @@ the file MAY be absent. | `summary` | string | One-line familiar-voice summary. Used in the Check Run and PR title. | | `pr_body` | string | Full PR body, **authored by the familiar** in its own voice — not a template. | | `review` | object | Structured review evidence and findings. Required even when `mode` is `none`. See [3.2](#32-review). | -| `exit_reason` | string enum \| null | `null` on success; otherwise the terminal cause. See [3.4](#34-exit_reason). | +| `exit_reason` | string enum \| null | `null` or absent on success; required for `failure` and `needs_input`; optional for `partial`. See [3.4](#34-exit_reason). | > **Drift note (supersedes `COVEN-GITHUB.md`):** the prose result envelope listed > an `events` array. Progress/event streaming is **not** part of the v3 result @@ -241,7 +245,9 @@ and `needs_input` do not open a PR by themselves. ### 3.4 `exit_reason` -`null` on success. Otherwise one of: +For `success`, omit this field or set it to `null`. For `failure` and +`needs_input`, provide one of the causes below. A `partial` result may omit +the field, set it to `null`, or provide a cause when one is known. | Value | Meaning | |---|---| @@ -319,11 +325,11 @@ must accept the new required nullable provenance fields and `provenance` arrays. | Artifact | Purpose | |---|---| -| [`docs/contracts/session-brief.schema.json`](contracts/session-brief.schema.json) | JSON Schema for the input brief. | -| [`docs/contracts/result.schema.json`](contracts/result.schema.json) | JSON Schema for the output envelope. | -| [`docs/contracts/session-brief.example.json`](contracts/session-brief.example.json) | Golden input fixture. | -| [`docs/contracts/result.example.json`](contracts/result.example.json) | Golden output fixture. | -| `crates/github/tests/contract.rs` | Round-trips the golden fixtures through the Rust types — fails the build if the adapter drifts from this contract. | +| [`session-brief.schema.json`](../src-rust/crates/cli/tests/headless_contract/session-brief.schema.json) | JSON Schema for the input brief. | +| [`result.schema.json`](../src-rust/crates/cli/tests/headless_contract/result.schema.json) | JSON Schema for the output envelope. | +| [`session-brief.example.json`](../src-rust/crates/cli/tests/headless_contract/session-brief.example.json) | Golden input fixture. | +| [`result.example.json`](../src-rust/crates/cli/tests/headless_contract/result.example.json) | Golden output fixture. | +| `scripts/test-headless-schemas.py` | Validates runtime fixtures and cross-field schema constraints. | A `coven-code` change is contract-conformant when its emitted `result.json` validates against `result.schema.json` and it accepts any brief that validates diff --git a/scripts/test-headless-schemas.py b/scripts/test-headless-schemas.py new file mode 100644 index 00000000..dfbf0100 --- /dev/null +++ b/scripts/test-headless-schemas.py @@ -0,0 +1,71 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["jsonschema==4.25.1"] +# /// +"""Validate the runtime v3 fixtures and their cross-field constraints. + +Run from any directory: uv run scripts/test-headless-schemas.py +""" +import copy +import json +from pathlib import Path +import unittest + +from jsonschema import Draft202012Validator + +FIXTURES = Path(__file__).resolve().parents[1] / "src-rust/crates/cli/tests/headless_contract" + + +class HeadlessSchemas(unittest.TestCase): + def setUp(self): + self.brief = json.loads((FIXTURES / "session-brief.example.json").read_text()) + self.result = json.loads((FIXTURES / "result.example.json").read_text()) + self.validators = {} + for name in ("session-brief", "result"): + schema = json.loads((FIXTURES / f"{name}.schema.json").read_text()) + Draft202012Validator.check_schema(schema) + self.validators[name] = Draft202012Validator(schema) + + def test_golden_fixtures(self): + self.validators["session-brief"].validate(self.brief) + self.validators["result"].validate(self.result) + + def test_brief_requires_version(self): + del self.brief["contract_version"] + self.assertFalse(self.validators["session-brief"].is_valid(self.brief)) + + def test_result_status_and_exit_reason(self): + for status in ("success", "failure", "needs_input", "partial"): + for reason in (None, "test_failure", "ambiguous_spec", "git_conflict", "infra_error"): + with self.subTest(status=status, reason=reason): + value = copy.deepcopy(self.result) + value.update(status=status, exit_reason=reason) + expected = ( + reason is None if status == "success" + else status == "partial" or reason is not None + ) + self.assertEqual(self.validators["result"].is_valid(value), expected) + for status in ("failure", "needs_input"): + with self.subTest(status=status, reason="absent"): + value = copy.deepcopy(self.result) + value["status"] = status + value.pop("exit_reason", None) + self.assertFalse(self.validators["result"].is_valid(value)) + + def test_review_mode_and_evidence(self): + for mode in ("none", "pull_request", "review_comment"): + for evidence in ("not_applicable", "complete", "partial", "missing"): + for files in ([], ["src/example.rs"]): + with self.subTest(mode=mode, evidence=evidence, files=files): + value = copy.deepcopy(self.result) + value["review"].update(mode=mode, evidence_status=evidence, reviewed_files=files) + expected = ( + evidence == "not_applicable" if mode == "none" + else evidence != "not_applicable" + and (bool(files) or evidence == "missing") + ) + self.assertEqual(self.validators["result"].is_valid(value), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index 1e173857..1ec9fcac 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -546,7 +546,7 @@ async fn main() -> anyhow::Result<()> { .with_writer(std::io::stderr) .init(); - // --context loads a coven-github session brief (headless contract v1). It + // --context loads a coven-github session brief (headless contract v3). It // implies full headless + skip-permissions and overrides model/cwd from the // brief. A brief with an unsupported major version is rejected here. let github_context = SessionBrief::load(cli.context.as_ref())?; diff --git a/src-rust/crates/cli/tests/headless_contract/README.md b/src-rust/crates/cli/tests/headless_contract/README.md index 3c0d0508..55847145 100644 --- a/src-rust/crates/cli/tests/headless_contract/README.md +++ b/src-rust/crates/cli/tests/headless_contract/README.md @@ -1,27 +1,31 @@ -# Vendored headless-contract fixtures +# Runtime headless-contract fixtures -These four files are **verbatim copies** of the locked headless execution -contract artifacts owned by the `coven-github` repository: +These fixtures describe Coven Code's version 3 headless interface: - `session-brief.schema.json` - `result.schema.json` - `session-brief.example.json` - `result.example.json` -Canonical source (single source of truth): -`OpenCoven/coven-github` → `docs/headless-contract.md` + `docs/contracts/`. +Run the JSON Schema regression tests from the repository root: -**Contract version: `1`.** +```sh +uv run scripts/test-headless-schemas.py +``` -`coven-code` is the *consumer* of the session brief and the *producer* of the -result envelope. The conformance tests in `crates/cli/src/headless.rs` round-trip -these golden fixtures through the runtime types and assert that: +The script pins its validator dependency and tests both golden fixtures, +required version declarations, review evidence, and result exit reasons. +Rust tests in `crates/cli/src/headless.rs` separately exercise runtime parsing, +serialization, and selected schema properties. -- every brief that validates against `session-brief.schema.json` is accepted, -- every `result.json` the runtime emits validates against `result.schema.json`. +The [upstream adapter contract](https://github.com/OpenCoven/coven-github/blob/main/docs/headless-contract.md) +and its `docs/contracts/` fixtures currently use version 2. These runtime +fixtures use version 3 and include `review.memory` provenance. They are not +verbatim copies. The runtime still accepts v2 briefs and echoes that version, +but its extra review provenance needs reconciliation with the strict adapter-v2 +schema before claiming cross-repository conformance. -If a test here fails, the runtime drifted from the contract **or** the contract -changed. Do not "bless" the fixtures — re-copy them from `coven-github` only as -part of a deliberate, version-bumped contract change (see the contract doc's -Versioning section). A breaking change MUST bump `contract_version` on both -sides. +See the [runtime contract](../../../../../docs/headless-contract.md) for the +normative v3 behavior. Coordinate breaking changes and version bumps with +both repositories; do not overwrite these fixtures with an older upstream +schema. diff --git a/src-rust/crates/cli/tests/headless_contract/result.schema.json b/src-rust/crates/cli/tests/headless_contract/result.schema.json index 663d813e..18ed3d8e 100644 --- a/src-rust/crates/cli/tests/headless_contract/result.schema.json +++ b/src-rust/crates/cli/tests/headless_contract/result.schema.json @@ -165,11 +165,23 @@ "required": ["mode"] }, "then": { + "properties": { + "evidence_status": { "enum": ["complete", "partial", "missing"] } + }, "anyOf": [ { "properties": { "reviewed_files": { "minItems": 1 } }, "required": ["reviewed_files"] }, { "properties": { "evidence_status": { "const": "missing" } }, "required": ["evidence_status"] } ] } + }, + { + "if": { + "properties": { "mode": { "const": "none" } }, + "required": ["mode"] + }, + "then": { + "properties": { "evidence_status": { "const": "not_applicable" } } + } } ] }, @@ -177,5 +189,33 @@ "type": ["string", "null"], "enum": ["test_failure", "ambiguous_spec", "git_conflict", "infra_error", null] } - } + }, + "allOf": [ + { + "if": { + "properties": { "status": { "const": "success" } }, + "required": ["status"] + }, + "then": { + "properties": { + "exit_reason": { "const": null } + } + } + }, + { + "if": { + "properties": { "status": { "enum": ["failure", "needs_input"] } }, + "required": ["status"] + }, + "then": { + "properties": { + "exit_reason": { + "type": "string", + "enum": ["test_failure", "ambiguous_spec", "git_conflict", "infra_error"] + } + }, + "required": ["exit_reason"] + } + } + ] } diff --git a/src-rust/crates/cli/tests/headless_contract/session-brief.schema.json b/src-rust/crates/cli/tests/headless_contract/session-brief.schema.json index bd06bc0c..750cf189 100644 --- a/src-rust/crates/cli/tests/headless_contract/session-brief.schema.json +++ b/src-rust/crates/cli/tests/headless_contract/session-brief.schema.json @@ -5,7 +5,7 @@ "description": "Tokenless read-context payload the adapter writes for coven-code --headless --context. Contract version 3.", "type": "object", "additionalProperties": false, - "required": ["trigger", "repo", "task", "familiar", "workspace"], + "required": ["contract_version", "trigger", "repo", "task", "familiar", "workspace"], "properties": { "contract_version": { "type": "string",