diff --git a/.hoplite/settings.json b/.hoplite/settings.json new file mode 100644 index 0000000..90e6ad7 --- /dev/null +++ b/.hoplite/settings.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "ports": {"preview": 3000, "additional": {}}, + "scripts": { + "setup": { + "enabled": true, + "command": "apt-get update -qq && apt-get install -y -qq git-lfs >/dev/null 2>&1; uv sync --all-extras" + }, + "run": {"enabled": true, "command": null}, + "archive": {"enabled": true, "command": null}, + "check": {"enabled": true, "command": "uv run pytest -q && uv run ruff check src tests && uv run mypy src/cam"} + }, + "mcpServers": [] +} diff --git a/src/cam/core/orchestrator/gates.py b/src/cam/core/orchestrator/gates.py index 6a70855..13bc571 100644 --- a/src/cam/core/orchestrator/gates.py +++ b/src/cam/core/orchestrator/gates.py @@ -146,6 +146,7 @@ async def resolve_gate( signing_key: bytes, store: Any, # RunStore protocol audit_fn: Any | None = None, # AuditService.record — optional + reason: str | None = None, ) -> ApprovalDecision: """Verify token → expiry → single-use → authz → record → resume/reject. @@ -224,6 +225,7 @@ async def resolve_gate( channel=channel, token_id=token_id, decided_at=decided_at, + reason=reason, ) await store.save_approval_decision(approval_decision) @@ -235,7 +237,8 @@ async def resolve_gate( action = "gate.approved" if decision == "approve" else "gate.rejected" await _maybe_audit( audit_fn, actor, action, - {"run_id": run_id, "step": step, "channel": channel, "token_id": token_id} + {"run_id": run_id, "step": step, "channel": channel, "token_id": token_id, + "reason": reason} ) new_run_status = RunStatus.RUNNING if decision == "approve" else RunStatus.REJECTED diff --git a/src/cam/core/orchestrator/triggers.py b/src/cam/core/orchestrator/triggers.py index fceb603..4a8ccbb 100644 --- a/src/cam/core/orchestrator/triggers.py +++ b/src/cam/core/orchestrator/triggers.py @@ -31,6 +31,7 @@ async def start_run( trigger: TriggerRef, store: Any, idem_key: str | None = None, + run_id: str | None = None, ) -> WorkflowRun: """Create and enqueue a new workflow run — idempotent on dedupe_key. @@ -43,6 +44,9 @@ async def start_run( trigger: How the run was triggered. store: RunStore (in-memory for tests, SQLAlchemy for prod). idem_key: Optional explicit dedupe key (overrides trigger.dedupe_key). + run_id: Optional caller-supplied run id. Callers that need to report + whether a fresh run was created can supply one and compare it to + the returned run's id (a dedupe hit returns the existing run). """ defn = get_workflow_latest(workflow_name) defn.validate_context(context) @@ -52,7 +56,7 @@ async def start_run( trigger = trigger.model_copy(update={"dedupe_key": dedupe_key}) now = datetime.now(tz=UTC) - run_id = str(uuid.uuid4()) + run_id = run_id or str(uuid.uuid4()) run = WorkflowRun( id=run_id, diff --git a/src/cam/core/workflows/intake/workflow.py b/src/cam/core/workflows/intake/workflow.py index a11aa89..fc162fe 100644 --- a/src/cam/core/workflows/intake/workflow.py +++ b/src/cam/core/workflows/intake/workflow.py @@ -15,6 +15,7 @@ from __future__ import annotations import hashlib +import uuid from dataclasses import dataclass from typing import Any @@ -218,11 +219,13 @@ async def tool_intake_run( idempotency_key = hashlib.sha256(base.encode()).hexdigest()[:16] trigger = make_agent_trigger(actor, idem_key=idempotency_key) + candidate_id = str(uuid.uuid4()) context = {"lead": lead.model_dump(mode="json"), "case_type_hint": case_type_hint} run = await start_run( workflow_name="intake", context=context, trigger=trigger, store=run_store, idem_key=idempotency_key, + run_id=candidate_id, ) return {"run_id": run.id, "status": run.status, "current_step": run.current_step, - "is_new": run.trigger.dedupe_key == idempotency_key} + "is_new": run.id == candidate_id} diff --git a/src/cam/mcp_server/workflow_tools.py b/src/cam/mcp_server/workflow_tools.py index e06b82b..42fcf1b 100644 --- a/src/cam/mcp_server/workflow_tools.py +++ b/src/cam/mcp_server/workflow_tools.py @@ -54,21 +54,25 @@ async def tool_workflow_run( Risk tier: write (confirm). Starting a run never bypasses inner gates. """ + import uuid + from cam.core.orchestrator.triggers import make_agent_trigger, start_run trigger = make_agent_trigger(actor, idem_key=inp.idem_key) + candidate_id = str(uuid.uuid4()) run = await start_run( workflow_name=inp.workflow, context=inp.context, trigger=trigger, store=store, idem_key=inp.idem_key, + run_id=candidate_id, ) return WorkflowRunOutput( run_id=run.id, status=run.status, current_step=run.current_step, - is_new=True, # simplified — the store dedup handles idempotency + is_new=run.id == candidate_id, ) @@ -196,6 +200,7 @@ async def tool_approval_decide( signing_key=signing_key, store=store, audit_fn=audit_fn, + reason=inp.reason, ) return ApprovalDecideOutput( gate_request_id=decision_record.gate_request_id, diff --git a/src/cam/sidecar/approvals.py b/src/cam/sidecar/approvals.py index 6592281..76e1d07 100644 --- a/src/cam/sidecar/approvals.py +++ b/src/cam/sidecar/approvals.py @@ -1,12 +1,18 @@ -"""Web UI approval endpoint — GET/POST /approvals/{token} — spec G8.3.""" +"""Web UI approval endpoint — GET/POST /approvals/{token} — spec G8.3. + +Human-facing pages: the approver arrives from an email/web link and must see +what they are approving, record a reason, and get a readable confirmation — +never raw JSON. +""" from __future__ import annotations -from datetime import UTC +from datetime import UTC, datetime +from html import escape from typing import Literal from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import HTMLResponse from pydantic import BaseModel router = APIRouter(prefix="/approvals", tags=["approvals"]) @@ -17,6 +23,36 @@ class ApprovalSubmission(BaseModel): reason: str | None = None +_PAGE_STYLE = """body{font-family:sans-serif;max-width:600px;margin:4rem auto;padding:0 1rem} +.card{background:#f8f9fa;border-radius:8px;padding:1.5rem;margin-bottom:1.5rem} +.approve{background:#198754;color:#fff;border:none;padding:.75rem 2rem; + border-radius:4px;cursor:pointer;font-size:1rem;margin-right:1rem} +.reject{background:#dc3545;color:#fff;border:none;padding:.75rem 2rem; + border-radius:4px;cursor:pointer;font-size:1rem} +textarea{width:100%;box-sizing:border-box;margin:1rem 0;padding:.5rem; + border:1px solid #ccc;border-radius:4px;font-family:inherit} +.ok{color:#198754}.err{color:#dc3545}""" + + +def _page(title: str, body: str, status_code: int = 200) -> HTMLResponse: + html = f""" + +
{escape(reason)}
+This link is single-use and expires 24 hours after it was issued. +Ask the requester to re-trigger the approval if you still need to act on it.
Run: {payload.get("rid","")}
-Step: {payload.get("step","")}
-Expires: {expires_at}
+ # Enrich with the workflow name when a run store is wired (fail-soft). + workflow_name = "" + store = getattr(app_state, "run_store", None) + if store is not None and run_id: + try: + run = await store.get_run(run_id) + if run is not None: + workflow_name = str(run.workflow) + except Exception: + workflow_name = "" + + workflow_line = ( + f'Workflow: {escape(workflow_name)}
' if workflow_name else "" + ) + body = f"""Run: {escape(run_id)}
+Step: {escape(step)}
+Expires: {escape(expires_at)}
Reason recorded: {escape(reason)}
" if reason else "" + body = f"""The workflow step was {escape(outcome)}.
+Run: {escape(result.run_id)}
+Gate: {escape(result.gate_request_id)}
+ {reason_line} +You can close this window — the workflow continues automatically.
""" + return _page("Decision recorded", body) diff --git a/tests/test_e2e_workflows.py b/tests/test_e2e_workflows.py index 01a9b7f..590fab8 100644 --- a/tests/test_e2e_workflows.py +++ b/tests/test_e2e_workflows.py @@ -257,3 +257,26 @@ async def test_e2e_05_duplicate_lead_produces_single_run() -> None: assert r1["run_id"] == r2["run_id"], "Duplicate trigger must return the same run" assert len(run_store._runs) == 1, "Only one run should exist" + assert r1["is_new"] is True, "First submission must report a new run" + assert r2["is_new"] is False, "Duplicate submission must report the existing run" + + +async def test_e2e_06_workflow_run_reports_is_new() -> None: + """workflow.run reports is_new=False when the idem key dedupes to an existing run.""" + from cam.mcp_server.workflow_tools import WorkflowRunInput, tool_workflow_run + + services = _make_services() + register_intake_workflow(services) + + run_store = InMemoryRunStore() + inp = WorkflowRunInput( + workflow="intake", + context={"lead": _make_lead().model_dump(mode="json")}, + idem_key="e2e-06-idem-001", + ) + out1 = await tool_workflow_run(inp, run_store) + out2 = await tool_workflow_run(inp, run_store) + + assert out1.is_new is True + assert out2.is_new is False + assert out1.run_id == out2.run_id diff --git a/tests/test_intake.py b/tests/test_intake.py index f206154..c50676d 100644 --- a/tests/test_intake.py +++ b/tests/test_intake.py @@ -361,6 +361,8 @@ async def test_duplicate_lead_same_run() -> None: r1 = await tool_intake_run(lead, store, idempotency_key="idem-abc") r2 = await tool_intake_run(lead, store, idempotency_key="idem-abc") assert r1["run_id"] == r2["run_id"] + assert r1["is_new"] is True + assert r2["is_new"] is False # ────────────────────────────────────────────────────────────── diff --git a/tests/test_orchestrator_gates.py b/tests/test_orchestrator_gates.py index 583f4d9..b8dbd69 100644 --- a/tests/test_orchestrator_gates.py +++ b/tests/test_orchestrator_gates.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + import pytest from cam.core.orchestrator.dsl import GateConfig, StepContext, clear_registry, workflow @@ -130,3 +132,29 @@ def test_token_verify_wrong_key() -> None: raw_token, _ = issue_token("gid", "rid", "GATE:step", "mcp", KEY) _, err = verify_token(raw_token, b"wrong-key-32-bytes-padded-00000") assert err == "tampered" + + +# h. Approver reason is recorded on the decision and in the audit stream +async def test_reason_recorded_on_decision() -> None: + store, run_id, raw_token, _ = await _build_gated_run("wf_reason") + audits: list[dict[str, Any]] = [] + + async def _audit(**kwargs: Any) -> None: + audits.append(kwargs) + + await resolve_gate( + raw_token, "approve", "attorney", "mcp", KEY, store, + audit_fn=_audit, reason="Reviewed draft; ready to send", + ) + decision = store.all_decisions()[0] + assert decision.reason == "Reviewed draft; ready to send" + approved = [a for a in audits if a.get("action") == "gate.approved"] + assert approved, "gate.approved must be audited" + assert approved[0]["inputs"]["reason"] == "Reviewed draft; ready to send" + + +# i. Omitted reason stays None (backwards compatible) +async def test_reason_defaults_to_none() -> None: + store, run_id, raw_token, _ = await _build_gated_run("wf_no_reason") + await resolve_gate(raw_token, "approve", "attorney", "mcp", KEY, store) + assert store.all_decisions()[0].reason is None diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py index c53df20..35168dc 100644 --- a/tests/test_sidecar.py +++ b/tests/test_sidecar.py @@ -5,7 +5,7 @@ import hashlib import hmac import json -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta import pytest from fastapi.testclient import TestClient @@ -13,6 +13,14 @@ from cam.connectors.reference import ReferenceCaseConnector from cam.connectors.registry import clear_registry, register_connector from cam.connectors.webhook.pipeline import reference_normaliser +from cam.core.orchestrator.gates import issue_token +from cam.core.orchestrator.states import ( + GateRequest, + RunStatus, + TriggerRef, + WorkflowRun, +) +from cam.core.orchestrator.store import InMemoryRunStore from cam.sidecar.main import create_app NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) @@ -53,6 +61,12 @@ def _fake_sink_calls_append(self, e): self.calls.append(e) sink = _FakeSink() redis = _FakeRedis() + # Simulate an authenticated approver session (production wires SSO here). + @app.middleware("http") + async def _fake_auth(request, call_next): # type: ignore[no-untyped-def] + request.state.user_identity = "attorney" + return await call_next(request) + app.state.webhook_secrets = {"reference": WEBHOOK_SECRET} app.state.redis_client = redis app.state.trigger_sink = sink @@ -143,7 +157,6 @@ def test_webhook_unknown_event_type_202(client): def _make_raw_token(gate_id: str, run_id: str, step: str = "GATE:approve") -> str: - from cam.core.orchestrator.gates import issue_token raw_token, _ = issue_token(gate_id, run_id, step, "web", SIGNING_KEY) return raw_token @@ -177,3 +190,79 @@ def test_approval_post_bad_decision_400(client): token = _make_raw_token("gate-1", "run-1") r = client.post(f"/approvals/{token}", data={"decision": "maybe"}) assert r.status_code in (400, 422, 503), f"Unexpected status {r.status_code}" + + +# --------------------------------------------------------------------------- +# Approval UI — human-readable pages, reason capture, workflow context +# --------------------------------------------------------------------------- + + +async def _seed_gated_run( + app, run_id: str = "run-web-1", gate_id: str = "gate-web-1", ttl_seconds: int = 86400 +) -> tuple[InMemoryRunStore, str]: + """Create a run + pending gate + token and wire the store into the app.""" + store = InMemoryRunStore() + now = datetime.now(tz=UTC) + store._runs[run_id] = WorkflowRun( + id=run_id, + workflow="intake", + workflow_version=1, + status=RunStatus.AWAITING_APPROVAL, + trigger=TriggerRef(kind="agent", source="test"), + created_at=now, + updated_at=now, + ) + await store.create_gate_request( + GateRequest( + id=gate_id, + run_id=run_id, + step="GATE:human_review", + required_role="attorney", + status="pending", + created_at=now, + expires_at=now + timedelta(seconds=ttl_seconds), + ) + ) + raw_token, token_record = issue_token(gate_id, run_id, "GATE:human_review", "web", + SIGNING_KEY, ttl_seconds=ttl_seconds) + await store.save_token(token_record) + app.state.run_store = store + return store, raw_token + + +async def test_approval_page_shows_workflow_and_reason_field(client): + _, raw_token = await _seed_gated_run(client.app) + r = client.get(f"/approvals/{raw_token}") + assert r.status_code == 200 + assert "intake" in r.text, "Page should show the workflow being approved" + assert 'name="reason"' in r.text, "Page should offer a reason field" + + +async def test_approval_page_invalid_token_is_html_401(client): + token = _make_raw_token("gate-1", "run-1") + tampered = token[:-4] + "XXXX" + r = client.get(f"/approvals/{tampered}") + assert r.status_code == 401 + assert "