docs: preserve reviewed NetKingdom session artifacts
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 4s
Reject generated-only commits / generated-commit-guard (push) Successful in 11s

This commit is contained in:
codex 2026-11-01 22:57:27 +01:00
parent e305e2262d
commit 2f18be7880
50 changed files with 13563 additions and 0 deletions

View file

@ -0,0 +1,42 @@
from __future__ import annotations
import re
import uuid
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from api.schemas.task import TaskRead
from api.schemas.workplan import WorkplanRead
from api.schemas.workplan_dependency import WorkplanDependencyRead
class RepositoryProjectionReconcile(BaseModel):
expected_commit: str
acknowledge_retirements: bool = False
# None keeps today's blanket acknowledgement. An empty list is present and
# means the caller expects zero retirements. Combined with
# acknowledge_retirements, the call is ambiguous and retires nothing.
expected_retirement_uuids: list[uuid.UUID] | None = None
@field_validator("expected_commit")
@classmethod
def validate_commit(cls, value: str) -> str:
value = value.strip().lower()
if not re.fullmatch(r"[0-9a-f]{40}", value):
raise ValueError("expected_commit must be a full 40-character Git SHA")
return value
class RepositoryProjectionSnapshot(BaseModel):
"""One bounded read of a repository's complete work-record projection."""
schema_version: Literal["state-hub.repository-projection-snapshot.v1"] = Field(
validation_alias="schema",
serialization_alias="schema",
)
repo_slug: str
repo_id: uuid.UUID
workplans: list[WorkplanRead]
tasks: list[TaskRead]
dependencies: list[WorkplanDependencyRead]

View file

@ -0,0 +1,182 @@
import uuid
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, field_validator
from api.schemas.workplan_dependency import WorkplanDepStub
from api.work_record_flavor import (
FLAVOR_PROMOTION_REASONS,
WORK_RECORD_FLAVORS,
normalize_flavor,
normalize_promotion_reason,
)
from api.workplan_status import normalize_workplan_status
WorkplanStatus = Literal[
"proposed",
"ready",
"active",
"blocked",
"backlog",
"finished",
"archived",
]
ExecutionState = Literal["manual", "queued", "scheduled", "launching", "paused", "completed", "cancelled"]
LaunchMode = Literal["manual", "queued", "scheduled", "immediate"]
ConcurrencyMode = Literal["sequential", "parallel"]
class WorkplanStatusMixin(BaseModel):
@field_validator("status", mode="before", check_fields=False)
@classmethod
def _normalise_status(cls, value):
return normalize_workplan_status(value)
class WorkplanFlavorMixin(BaseModel):
@field_validator("flavor", mode="before", check_fields=False)
@classmethod
def _normalise_flavor(cls, value):
flavor = normalize_flavor(value)
if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
raise ValueError(
f"Unknown work-record flavor {flavor!r}; "
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
)
return flavor
@field_validator("flavor_promotion_reason", mode="before", check_fields=False)
@classmethod
def _normalise_promotion_reason(cls, value):
reason = normalize_promotion_reason(value)
if reason is not None and reason not in FLAVOR_PROMOTION_REASONS:
raise ValueError(
f"Unknown flavor_promotion_reason {reason!r}; "
f"expected one of {', '.join(FLAVOR_PROMOTION_REASONS)}"
)
return reason
@field_validator("flavor_promoted_from", mode="before", check_fields=False)
@classmethod
def _normalise_promoted_from(cls, value):
flavor = normalize_flavor(value)
if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
raise ValueError(
f"Unknown flavor_promoted_from {flavor!r}; "
f"expected one of {', '.join(WORK_RECORD_FLAVORS)}"
)
return flavor
class WorkplanCreate(WorkplanStatusMixin, WorkplanFlavorMixin):
id: uuid.UUID | None = None
repo_id: uuid.UUID
topic_id: uuid.UUID | None = None
slug: str
title: str
description: str | None = None
status: WorkplanStatus = "active"
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
flavor: str | None = None
flavor_promotion_reason: str | None = None
flavor_promoted_from: str | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
repo_goal_id: uuid.UUID | None = None
class WorkplanUpdate(WorkplanStatusMixin, WorkplanFlavorMixin):
title: str | None = None
description: str | None = None
status: WorkplanStatus | None = None
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
flavor: str | None = None
flavor_promotion_reason: str | None = None
flavor_promoted_from: str | None = None
execution_state: ExecutionState | None = None
launch_mode: LaunchMode | None = None
concurrency_mode: ConcurrencyMode | None = None
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
topic_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None
repo_goal_id: uuid.UUID | None = None
class WorkplanFileBinding(BaseModel):
workplan_id: uuid.UUID
filename: str
relative_path: str
repo_slug: str
archived: bool = False
status: WorkplanStatus | None = None
class WorkplanBindingsSync(BaseModel):
bindings: list[WorkplanFileBinding]
class WorkplanRead(WorkplanStatusMixin):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
repo_id: uuid.UUID
topic_id: uuid.UUID | None = None
repo_goal_id: uuid.UUID | None = None
slug: str
title: str
description: str | None = None
status: WorkplanStatus
owner: str | None = None
due_date: date | None = None
planning_priority: str | None = None
planning_order: int | None = None
flavor: str | None = None
flavor_promotion_reason: str | None = None
flavor_promoted_from: str | None = None
execution_state: ExecutionState = "manual"
launch_mode: LaunchMode = "manual"
concurrency_mode: ConcurrencyMode = "sequential"
queue_rank: int | None = None
execution_group: str | None = None
scheduled_for: datetime | None = None
backing_filename: str | None = None
backing_relative_path: str | None = None
backing_archived: bool | None = None
backing_synced_at: datetime | None = None
# Set when forge projection retired the row (slug stamped "@retired-").
# A retired row is a tombstone, not live work, whatever its status.
projection_retired_at: datetime | None = None
# Derived (CUST-WP-0074 rule 5): "human" | "external" | "none" while
# status is blocked, else None. Set by
# api.services.wait_kind.annotate_workplans on read routes.
blocked_kind: str | None = None
created_at: datetime
updated_at: datetime
class WorkplanWithTaskCounts(WorkplanRead):
tasks_total: int = 0
tasks_wait: int = 0
tasks_todo: int = 0
tasks_progress: int = 0
tasks_done: int = 0
tasks_cancel: int = 0
class WorkplanWithDeps(WorkplanWithTaskCounts):
"""WorkplanWithTaskCounts enriched with dependency graph edges."""
depends_on: list[WorkplanDepStub] = []
blocks: list[WorkplanDepStub] = []
blocked_reasons: list[dict] = []

View file

@ -0,0 +1,71 @@
import uuid
from datetime import datetime
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field
class WorkplanDependencyCreate(BaseModel):
to_workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("to_workplan_id", "to_workstream_id"),
)
to_task_id: uuid.UUID | None = None
from_task_id: uuid.UUID | None = None
relationship_type: str = "blocks"
description: str | None = None
class WorkplanDependencyRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
from_workplan_id: uuid.UUID
from_task_id: uuid.UUID | None = None
to_workplan_id: uuid.UUID | None = None
to_task_id: uuid.UUID | None = None
relationship_type: str
description: str | None = None
source_owner: str | None = None
source_commit: str | None = None
created_at: datetime
updated_at: datetime
class WorkplanDepStub(BaseModel):
"""Minimal projection of the other end of a dependency edge."""
dep_id: uuid.UUID
target_type: str = "workplan"
relationship_type: str = "blocks"
workplan_id: uuid.UUID | None = Field(
default=None,
validation_alias=AliasChoices("workplan_id", "workstream_id"),
)
workplan_slug: str | None = Field(
default=None,
validation_alias=AliasChoices("workplan_slug", "workstream_slug"),
)
workplan_title: str | None = Field(
default=None,
validation_alias=AliasChoices("workplan_title", "workstream_title"),
)
task_id: uuid.UUID | None = None
task_title: str | None = None
# The waiting task on a task-scoped edge (workplan_dependencies.from_task_id);
# None on frontmatter edges.
from_task_id: uuid.UUID | None = None
from_task_title: str | None = None
description: str | None = None
@computed_field # type: ignore[prop-decorator]
@property
def workstream_id(self) -> uuid.UUID | None:
return self.workplan_id
@computed_field # type: ignore[prop-decorator]
@property
def workstream_slug(self) -> str | None:
return self.workplan_slug
@computed_field # type: ignore[prop-decorator]
@property
def workstream_title(self) -> str | None:
return self.workplan_title

View file

@ -0,0 +1,41 @@
"""Legacy aliases — prefer ``api.schemas.workplan``."""
from api.schemas.workplan import (
ConcurrencyMode,
ExecutionState,
LaunchMode,
WorkplanCreate,
WorkplanRead,
WorkplanStatus,
WorkplanStatusMixin,
WorkplanUpdate,
WorkplanWithDeps,
WorkplanWithTaskCounts,
)
WorkstreamStatus = WorkplanStatus
WorkstreamStatusMixin = WorkplanStatusMixin
WorkstreamCreate = WorkplanCreate
WorkstreamUpdate = WorkplanUpdate
WorkstreamRead = WorkplanRead
WorkstreamWithTaskCounts = WorkplanWithTaskCounts
WorkstreamWithDeps = WorkplanWithDeps
__all__ = [
"WorkstreamStatus",
"WorkstreamStatusMixin",
"WorkstreamCreate",
"WorkstreamUpdate",
"WorkstreamRead",
"WorkstreamWithTaskCounts",
"WorkstreamWithDeps",
"WorkplanStatus",
"WorkplanStatusMixin",
"WorkplanCreate",
"WorkplanUpdate",
"WorkplanRead",
"WorkplanWithTaskCounts",
"WorkplanWithDeps",
"ExecutionState",
"LaunchMode",
"ConcurrencyMode",
]

View file

@ -0,0 +1,19 @@
"""Legacy aliases — prefer ``api.schemas.workplan_dependency``."""
from api.schemas.workplan_dependency import (
WorkplanDepStub,
WorkplanDependencyCreate,
WorkplanDependencyRead,
)
WorkstreamDependencyCreate = WorkplanDependencyCreate
WorkstreamDependencyRead = WorkplanDependencyRead
WorkstreamDepStub = WorkplanDepStub
__all__ = [
"WorkstreamDependencyCreate",
"WorkstreamDependencyRead",
"WorkstreamDepStub",
"WorkplanDependencyCreate",
"WorkplanDependencyRead",
"WorkplanDepStub",
]

View file

@ -0,0 +1 @@
"""Service-layer helpers for State Hub API features."""

View file

@ -0,0 +1,215 @@
from __future__ import annotations
import asyncio
import json
import re
import subprocess
import sys
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from api.config import settings
from api.models.progress_event import ProgressEvent
from api.schemas.consistency_sweep import (
ConsistencySweepIssueSummary,
ConsistencySweepRemoteAllRun,
ConsistencySweepRepoResult,
)
_LOCK_SKIP_MARKER = "another fix-consistency-remote --all run is already active"
_CLEAN_RE = re.compile(r"^\s*CLEAN \(skipped\):\s*(.+)$", re.MULTILINE)
_MISSING_RE = re.compile(r"^\s*NOT ON THIS HOST \(skipped\):\s*(.+)$", re.MULTILINE)
_BUDGET_RE = re.compile(
r"^\s*BUDGET EXHAUSTED after \d+s \(skipped\):\s*(.+)$",
re.MULTILINE,
)
def _script_path() -> Path:
return Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py"
def _split_slug_list(value: str) -> list[str]:
return [part.strip() for part in value.split(",") if part.strip()]
def _parse_stderr(stderr: str) -> dict[str, list[str]]:
return {
"skipped_clean": _split_slug_list(_CLEAN_RE.search(stderr).group(1))
if _CLEAN_RE.search(stderr)
else [],
"skipped_missing": _split_slug_list(_MISSING_RE.search(stderr).group(1))
if _MISSING_RE.search(stderr)
else [],
"skipped_budget": _split_slug_list(_BUDGET_RE.search(stderr).group(1))
if _BUDGET_RE.search(stderr)
else [],
}
def _extract_json_payload(text: str) -> Any:
stripped = text.strip()
if not stripped:
return []
decoder = json.JSONDecoder()
for index, char in enumerate(stripped):
if char not in "{[":
continue
try:
payload, _end = decoder.raw_decode(stripped, index)
return payload
except json.JSONDecodeError:
continue
raise json.JSONDecodeError("No JSON payload found", stripped, 0)
def _parse_stdout(stdout: str) -> list[ConsistencySweepRepoResult]:
text = stdout.strip()
if not text:
return []
payload = _extract_json_payload(text)
items = payload if isinstance(payload, list) else [payload]
results: list[ConsistencySweepRepoResult] = []
for item in items:
summary = item.get("summary") or {}
results.append(
ConsistencySweepRepoResult(
repo_slug=str(item.get("repo_slug") or ""),
repo_path=str(item.get("repo_path") or ""),
result=str(item.get("result") or "pass"),
summary=ConsistencySweepIssueSummary(
fail=int(summary.get("fail", 0)),
automation_error=int(summary.get("automation_error", 0)),
warn=int(summary.get("warn", 0)),
info=int(summary.get("info", 0)),
),
fixes_applied=list(item.get("fixes_applied") or []),
)
)
return results
async def run_remote_all_sweep(
session: AsyncSession,
*,
max_seconds: int,
source: str = "api",
) -> ConsistencySweepRemoteAllRun:
started_at = datetime.now(tz=UTC)
cmd = [
sys.executable,
str(_script_path()),
"--remote",
"--all",
"--json",
"--api-base",
settings.api_base,
"--max-seconds",
str(max_seconds),
]
result = await asyncio.to_thread(
subprocess.run,
cmd,
capture_output=True,
text=True,
)
completed_at = datetime.now(tz=UTC)
lock_skipped = _LOCK_SKIP_MARKER in result.stderr
stderr_meta = _parse_stderr(result.stderr)
repos_processed = [] if lock_skipped else _parse_stdout(result.stdout)
automation_error = result.returncode != 0 and not lock_skipped
progress_event_id = await _log_sweep_progress(
session,
started_at=started_at,
completed_at=completed_at,
max_seconds=max_seconds,
source=source,
exit_code=result.returncode,
automation_error=automation_error,
lock_skipped=lock_skipped,
repos_processed=repos_processed,
**stderr_meta,
)
return ConsistencySweepRemoteAllRun(
started_at=started_at,
completed_at=completed_at,
max_seconds=max_seconds,
source=source,
exit_code=result.returncode,
automation_error=automation_error,
lock_skipped=lock_skipped,
repos_processed=repos_processed,
skipped_clean=stderr_meta["skipped_clean"],
skipped_missing=stderr_meta["skipped_missing"],
skipped_budget=stderr_meta["skipped_budget"],
progress_event_id=progress_event_id,
)
async def _log_sweep_progress(
session: AsyncSession,
*,
started_at: datetime,
completed_at: datetime,
max_seconds: int,
source: str,
exit_code: int,
automation_error: bool,
lock_skipped: bool,
repos_processed: list[ConsistencySweepRepoResult],
skipped_clean: list[str],
skipped_missing: list[str],
skipped_budget: list[str],
) -> uuid.UUID:
processed_count = len(repos_processed)
error_count = sum(1 for repo in repos_processed if repo.result == "error")
assessment_fail_count = sum(1 for repo in repos_processed if repo.result == "fail")
warn_count = sum(1 for repo in repos_processed if repo.result == "warn")
if lock_skipped:
summary = "State Hub consistency sweep skipped: prior remote-all run still active"
elif automation_error:
summary = (
"State Hub consistency sweep automation error: "
f"exit_code={exit_code}, {processed_count} repos partially processed"
)
else:
summary = (
"State Hub consistency sweep completed: "
f"{processed_count} processed, {len(skipped_clean)} clean, "
f"{len(skipped_missing)} missing, {len(skipped_budget)} budget-skipped, "
f"{assessment_fail_count} assessment-fail, {error_count} automation-error, "
f"{warn_count} warned"
)
event = ProgressEvent(
event_type="consistency_sweep_remote_all",
summary=summary,
detail={
"started_at": _iso(started_at),
"completed_at": _iso(completed_at),
"max_seconds": max_seconds,
"source": source,
"exit_code": exit_code,
"automation_error": automation_error,
"assessment_failures": assessment_fail_count,
"automation_errors": error_count,
"lock_skipped": lock_skipped,
"repos_processed": [item.model_dump(mode="json") for item in repos_processed],
"skipped_clean": skipped_clean,
"skipped_missing": skipped_missing,
"skipped_budget": skipped_budget,
},
author="state-hub",
)
session.add(event)
await session.commit()
await session.refresh(event)
return event.id
def _iso(value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")

View file

@ -0,0 +1,112 @@
from __future__ import annotations
from typing import Any
from api.workplan_status import normalize_workplan_status
EXECUTION_STATES = {
"manual": "Not queued for autonomous pickup; humans or agents may still work manually.",
"queued": "Candidate for ordered pickup when dependencies and concurrency allow it.",
"scheduled": "Waiting for an external launch window; State Hub stores the requested time.",
"launching": "Legacy state only; it does not prove that any consumer accepted pickup.",
"paused": "Temporarily held outside the pickup stack.",
"completed": "Execution intent is closed; lifecycle status remains authoritative.",
"cancelled": "Execution intent was cancelled without changing lifecycle status.",
}
LAUNCH_MODES = {
"manual": "Do not request automation; keep intent visible only.",
"queued": "Place in the prioritized stack for later pickup.",
"scheduled": "Request pickup at or after a selected time.",
"immediate": "Legacy intent only; workplan launch-request pickup is retired.",
}
CONCURRENCY_MODES = {
"sequential": "Respect queue order and avoid parallel pickup for the same group.",
"parallel": "Eligible for concurrent pickup with other ready work.",
}
STATE_HUB_RESPONSIBILITIES = [
"store lifecycle status separately from execution intent",
"rank candidate workplans and expose dependency-aware eligibility",
"preserve historical launch-request and execution-intent rows during retirement",
"reject new workplan launch requests because no pickup consumer exists",
]
ACTIVITY_CORE_RESPONSIBILITIES = [
"own schedules, wakeups, and recurring automation",
"claim and complete ops runs created by ActivityDefinition fires",
"do not consume State Hub workplan launch requests",
]
EXECUTION_REPLACEMENTS = {
"POST /execution/launch-requests": (
"Queue work in the authoritative repository file; for recurring or operational "
"fires use an ActivityDefinition and activity-core ops_run"
),
"GET /execution/launch-requests": "GET /ops-runs on activity-core for automation history",
"GET /execution/workplan-stack": "Repo Manager work index or hub-core projection",
"PATCH /execution/workplans/{id}/intent": "Edit the authoritative workplan file",
}
EXECUTION_STATE_RANK = {
"launching": 0,
"queued": 1,
"scheduled": 2,
"manual": 3,
"paused": 4,
"completed": 5,
"cancelled": 6,
}
PRIORITY_RANK = {
"critical": 0,
"high": 1,
"medium": 2,
"low": 3,
}
CLOSED_WORKPLAN_STATUSES = {"finished", "archived"}
def execution_state_for_launch(launch_mode: str, immediate_pickup: bool = False) -> str:
mode = (launch_mode or "queued").strip().lower()
if immediate_pickup or mode == "immediate":
return "launching"
if mode == "scheduled":
return "scheduled"
if mode == "manual":
return "manual"
return "queued"
def workplan_blockers(
workplan_id: Any,
dependency_targets: dict[Any, list[Any]],
workplan_status: dict[Any, str],
workstream_id: Any = None,
) -> list[Any]:
scope_id = workplan_id if workplan_id is not None else workstream_id
blockers = []
for target_id in dependency_targets.get(scope_id, []):
target_status = normalize_workplan_status(workplan_status.get(target_id))
if target_status not in CLOSED_WORKPLAN_STATUSES:
blockers.append(target_id)
return blockers
workstream_blockers = workplan_blockers
def queue_sort_key(workstream: Any, *, eligible: bool) -> list[int | str]:
priority = str(getattr(workstream, "planning_priority", "") or "").strip().lower()
execution_state = str(getattr(workstream, "execution_state", "") or "manual").strip().lower()
return [
0 if eligible else 1,
EXECUTION_STATE_RANK.get(execution_state, 99),
PRIORITY_RANK.get(priority, 50),
getattr(workstream, "queue_rank", None) if getattr(workstream, "queue_rank", None) is not None else 999_999,
getattr(workstream, "planning_order", None) if getattr(workstream, "planning_order", None) is not None else 999_999,
str(getattr(workstream, "slug", "") or ""),
]

View file

@ -0,0 +1,689 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any
from pydantic import ValidationError
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode
from api.models.progress_event import ProgressEvent
from api.schemas.fabric_graph import FabricGraphExportPayload
DISPLAY_ONLY_EDGE_TYPES = {
"collapsed_into",
"declares",
"grouped_with",
"highlight_path",
"near",
"owns_deployment",
"same_color_group",
}
class FabricGraphValidationError(Exception):
def __init__(self, detail: dict[str, Any]) -> None:
super().__init__(str(detail))
self.detail = detail
def split_graph_ingest_body(body: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
"""Support both direct FabricGraphExport payloads and the documented wrapper."""
if isinstance(body.get("graph"), dict):
metadata = {key: value for key, value in body.items() if key != "graph"}
return body["graph"], metadata
return body, {}
async def ingest_fabric_graph_export(
session: AsyncSession,
payload: dict[str, Any],
*,
source_repo_slug: str,
source_url: str | None,
requested_by: str,
) -> tuple[FabricGraphImport, bool, bool]:
now = datetime.now(timezone.utc)
content_hash = graph_content_hash(payload)
try:
export = validate_fabric_graph_export(payload)
except ValueError as exc:
import_run = await _record_invalid_import(
session,
payload,
source_repo_slug=source_repo_slug,
source_url=source_url,
content_hash=content_hash,
error=str(exc),
now=now,
requested_by=requested_by,
)
raise FabricGraphValidationError(
{
"message": str(exc),
"import_id": str(import_run.id),
"validation_status": import_run.validation_status,
}
) from exc
existing = await _find_import(session, source_repo_slug, content_hash)
if existing and existing.validation_status == "valid":
await _mark_latest(session, existing)
existing.last_seen_at = now
await _record_progress(
session,
"Fabric graph export already ingested; refreshed latest marker.",
{
"source_repo_slug": source_repo_slug,
"source_url": source_url,
"content_hash": content_hash,
"node_count": existing.node_count,
"edge_count": existing.edge_count,
"requested_by": requested_by,
"idempotent": True,
},
)
await session.commit()
await session.refresh(existing)
return existing, False, True
source = export.source
import_run = FabricGraphImport(
source_repo_slug=source_repo_slug,
source_url=source_url,
source_commit=source.commit if source else None,
source_path=source.path if source else None,
api_version=export.api_version,
schema_version=export.schema_version,
export_kind=export.kind,
exported_at=export.generated_at,
netkingdom_id=export.netkingdom.id if export.netkingdom else None,
king_actor_id=export.netkingdom.king_actor_id if export.netkingdom else None,
actor_count=len(export.actors),
fabric_count=len(export.fabrics),
unresolved_count=len(export.unresolved),
compatibility=export.compatibility,
content_hash=content_hash,
node_count=len(export.nodes),
edge_count=len(export.edges),
validation_status="valid",
error_details=None,
graph_json=export.model_dump(mode="json", by_alias=True),
is_latest=True,
last_seen_at=now,
)
await _mark_previous_not_latest(session, source_repo_slug)
session.add(import_run)
await session.flush()
for node in export.nodes:
raw = node.model_dump(mode="json")
containment = raw.get("containment") if isinstance(raw.get("containment"), dict) else {}
ownership = raw.get("ownership") if isinstance(raw.get("ownership"), dict) else {}
accounting = raw.get("accounting") if isinstance(raw.get("accounting"), dict) else {}
evidence = raw.get("evidence") if isinstance(raw.get("evidence"), dict) else {}
session.add(
FabricGraphNode(
import_id=import_run.id,
source_repo_slug=source_repo_slug,
graph_id=node.id,
kind=node.kind,
name=node.name,
repo_slug=node.repo or "",
domain_slug=node.domain or "",
lifecycle=node.lifecycle or "",
canonical_type=raw.get("canonical_type"),
canon_category=node.canon_category,
canon_anchor=node.canon_anchor,
mapping_fit=node.mapping_fit,
evidence_state=node.evidence_state or evidence.get("state"),
evidence_review_state=evidence.get("review_state"),
evidence_confidence=_float_or_none(evidence.get("confidence")),
netkingdom_id=containment.get("netkingdom_id"),
fabric_id=containment.get("fabric_id"),
subfabric_id=containment.get("subfabric_id"),
environment=containment.get("environment"),
deployment_scenario_id=containment.get("deployment_scenario_id"),
owner_actor_id=ownership.get("owner_actor_id"),
owner_role=ownership.get("owner_role"),
ownership_resolution=ownership.get("resolution"),
cost_center_id=accounting.get("cost_center_id"),
profit_center_id=accounting.get("profit_center_id"),
display_only=bool(raw.get("display_only", False)),
attributes=node.attributes,
raw_json=raw,
)
)
for edge in export.edges:
raw = edge.model_dump(mode="json", by_alias=True)
provider = raw.get("provider") if isinstance(raw.get("provider"), dict) else {}
consumer = raw.get("consumer") if isinstance(raw.get("consumer"), dict) else {}
boundary = raw.get("boundary") if isinstance(raw.get("boundary"), dict) else {}
utility = raw.get("utility") if isinstance(raw.get("utility"), dict) else {}
accounting = raw.get("accounting") if isinstance(raw.get("accounting"), dict) else {}
evidence = raw.get("evidence") if isinstance(raw.get("evidence"), dict) else {}
session.add(
FabricGraphEdge(
import_id=import_run.id,
source_repo_slug=source_repo_slug,
edge_key=edge_key(raw),
from_graph_id=edge.from_graph_id,
to_graph_id=edge.to_graph_id,
edge_type=edge.edge_type,
canonical_type=edge.canonical_type,
canon_anchor=edge.canon_anchor,
mapping_fit=edge.mapping_fit,
evidence_state=edge.evidence_state or evidence.get("state"),
evidence_review_state=evidence.get("review_state"),
evidence_confidence=_float_or_none(evidence.get("confidence")),
relationship_category=edge.relationship_category,
provider_owner_actor_id=provider.get("owner_actor_id"),
provider_fabric_id=provider.get("fabric_id"),
provider_subfabric_id=provider.get("subfabric_id"),
consumer_owner_actor_id=consumer.get("owner_actor_id"),
consumer_fabric_id=consumer.get("fabric_id"),
consumer_subfabric_id=consumer.get("subfabric_id"),
crosses_fabric_boundary=boundary.get("crosses_fabric_boundary"),
crosses_subfabric_boundary=boundary.get("crosses_subfabric_boundary"),
utility_type=utility.get("utility_type"),
utility_contract_id=utility.get("contract_id"),
utility_payment_schema_id=utility.get("payment_schema_id"),
utility_metering_basis=utility.get("metering_basis"),
utility_business_model=utility.get("business_model"),
cost_center_id=accounting.get("cost_center_id"),
profit_center_id=accounting.get("profit_center_id"),
provider_profit_center_id=accounting.get("provider_profit_center_id"),
consumer_cost_center_id=accounting.get("consumer_cost_center_id"),
display_only=bool(edge.display_only),
attributes=edge.attributes,
raw_json=raw,
)
)
await _record_progress(
session,
"Fabric graph export ingested as State Hub read model.",
{
"source_repo_slug": source_repo_slug,
"source_url": source_url,
"content_hash": content_hash,
"node_count": len(export.nodes),
"edge_count": len(export.edges),
"requested_by": requested_by,
},
)
await session.commit()
await session.refresh(import_run)
return import_run, True, False
async def activate_fabric_graph_import(
session: AsyncSession,
import_id: Any,
*,
requested_by: str,
) -> tuple[FabricGraphImport | None, Any | None, bool]:
"""Select a retained valid import as the active read model.
Imports are immutable. Activation only moves the per-source ``is_latest``
marker, which makes cutover rollback fast and avoids re-fetching an older
authority payload during an incident.
"""
result = await session.execute(
select(FabricGraphImport).where(FabricGraphImport.id == import_id)
)
import_run = result.scalar_one_or_none()
if import_run is None:
return None, None, False
if import_run.validation_status != "valid":
raise ValueError("Only a valid Fabric graph import can be activated.")
previous_result = await session.execute(
select(FabricGraphImport).where(
FabricGraphImport.source_repo_slug == import_run.source_repo_slug,
FabricGraphImport.is_latest.is_(True),
)
)
previous = previous_result.scalars().first()
if previous is not None and previous.id == import_run.id:
return import_run, previous.id, False
await _mark_latest(session, import_run)
import_run.last_seen_at = datetime.now(timezone.utc)
await _record_progress(
session,
"Fabric graph read model activation changed.",
{
"source_repo_slug": import_run.source_repo_slug,
"import_id": str(import_run.id),
"previous_import_id": str(previous.id) if previous else None,
"content_hash": import_run.content_hash,
"requested_by": requested_by,
},
)
await session.commit()
await session.refresh(import_run)
return import_run, previous.id if previous else None, True
def validate_fabric_graph_export(payload: dict[str, Any]) -> FabricGraphExportPayload:
try:
export = FabricGraphExportPayload.model_validate(payload)
except ValidationError as exc:
first = exc.errors()[0]
location = ".".join(str(part) for part in first.get("loc", [])) or "<root>"
message = first.get("msg", "invalid payload")
raise ValueError(f"invalid FabricGraphExport at {location}: {message}") from exc
contract_errors = _contract_errors(export, payload)
if contract_errors:
raise ValueError(f"invalid FabricGraphExport contract: {contract_errors[0]}")
canon_errors = _canon_metadata_errors(export)
if canon_errors:
raise ValueError(f"invalid FabricGraphExport canon metadata: {canon_errors[0]}")
return export
def graph_content_hash(payload: dict[str, Any]) -> str:
canonical = _canonical_payload(payload)
raw = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def edge_key(edge: dict[str, Any]) -> str:
raw = json.dumps(edge, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _float_or_none(value: Any) -> float | None:
return float(value) if isinstance(value, (int, float)) else None
async def record_fabric_graph_error(
session: AsyncSession,
summary: str,
*,
source_repo_slug: str,
source_url: str | None,
error: str,
requested_by: str,
) -> None:
await _record_progress(
session,
summary,
{
"source_repo_slug": source_repo_slug,
"source_url": source_url,
"error": error,
"requested_by": requested_by,
},
)
await session.commit()
async def _find_import(
session: AsyncSession, source_repo_slug: str, content_hash: str
) -> FabricGraphImport | None:
result = await session.execute(
select(FabricGraphImport).where(
FabricGraphImport.source_repo_slug == source_repo_slug,
FabricGraphImport.content_hash == content_hash,
)
)
return result.scalar_one_or_none()
async def _mark_latest(session: AsyncSession, import_run: FabricGraphImport) -> None:
await _mark_previous_not_latest(session, import_run.source_repo_slug, exclude_id=import_run.id)
import_run.is_latest = True
async def _mark_previous_not_latest(
session: AsyncSession,
source_repo_slug: str,
*,
exclude_id: Any | None = None,
) -> None:
stmt = update(FabricGraphImport).where(FabricGraphImport.source_repo_slug == source_repo_slug)
if exclude_id is not None:
stmt = stmt.where(FabricGraphImport.id != exclude_id)
await session.execute(stmt.values(is_latest=False))
async def _record_invalid_import(
session: AsyncSession,
payload: dict[str, Any],
*,
source_repo_slug: str,
source_url: str | None,
content_hash: str,
error: str,
now: datetime,
requested_by: str,
) -> FabricGraphImport:
existing = await _find_import(session, source_repo_slug, content_hash)
if existing and existing.validation_status == "invalid":
existing.last_seen_at = now
import_run = existing
else:
import_run = FabricGraphImport(
source_repo_slug=source_repo_slug,
source_url=source_url,
# Invalid payloads are retained for diagnostics, but their
# denormalized provenance must not make the rejection path fail.
# The complete input remains available in ``graph_json``.
source_commit=_bounded_source_value(payload, "commit", max_length=255),
source_path=_source_value(payload, "path"),
api_version=str(payload.get("apiVersion")) if payload.get("apiVersion") else None,
export_kind=str(payload.get("kind")) if payload.get("kind") else None,
exported_at=_parse_datetime(payload.get("generated_at")),
content_hash=content_hash,
node_count=0,
edge_count=0,
validation_status="invalid",
error_details={"error": error},
graph_json=payload,
is_latest=False,
last_seen_at=now,
)
session.add(import_run)
await session.flush()
await _record_progress(
session,
"Fabric graph export rejected during validation.",
{
"source_repo_slug": source_repo_slug,
"source_url": source_url,
"content_hash": content_hash,
"error": error,
"requested_by": requested_by,
},
)
await session.commit()
await session.refresh(import_run)
return import_run
async def _record_progress(session: AsyncSession, summary: str, detail: dict[str, Any]) -> None:
session.add(
ProgressEvent(
event_type="fabric_graph_import",
summary=summary,
detail=detail,
author="state-hub",
)
)
def _canonical_payload(payload: dict[str, Any]) -> dict[str, Any]:
canonical = json.loads(json.dumps(payload, sort_keys=True, default=str))
canonical.pop("generated_at", None)
return canonical
def _contract_errors(export: FabricGraphExportPayload, payload: dict[str, Any]) -> list[str]:
if export.api_version == "railiance.fabric/v1alpha2":
return _financial_contract_errors(export, payload)
return _legacy_contract_errors(export)
def _legacy_contract_errors(export: FabricGraphExportPayload) -> list[str]:
errors: list[str] = []
if export.schema_version:
errors.append("v1alpha1 exports must not set schema_version")
for index, node in enumerate(export.nodes):
_require_fields(
errors,
f"nodes[{index}]",
{
"repo": node.repo,
"domain": node.domain,
"lifecycle": node.lifecycle,
},
("repo", "domain", "lifecycle"),
)
return errors
def _financial_contract_errors(
export: FabricGraphExportPayload, payload: dict[str, Any]
) -> list[str]:
errors: list[str] = []
for field in ("schema_version", "netkingdom", "actors", "fabrics"):
if field not in payload:
errors.append(f"missing required financial export field {field!r}")
if export.schema_version != "financial-fabric-v1":
errors.append("schema_version must be 'financial-fabric-v1' for v1alpha2 exports")
if export.netkingdom is None:
errors.append("netkingdom must be an object for v1alpha2 exports")
netkingdom_id = ""
king_actor_id = ""
else:
netkingdom_id = export.netkingdom.id
king_actor_id = export.netkingdom.king_actor_id
actor_roles: dict[str, str] = {}
for index, actor in enumerate(export.actors):
if actor.id in actor_roles:
errors.append(f"actors[{index}].id {actor.id!r} is duplicated")
actor_roles[actor.id] = actor.role
if king_actor_id and actor_roles.get(king_actor_id) != "king":
errors.append("netkingdom.king_actor_id must reference an actor with role 'king'")
fabric_kinds: dict[str, str] = {}
for index, fabric in enumerate(export.fabrics):
if fabric.id in fabric_kinds:
errors.append(f"fabrics[{index}].id {fabric.id!r} is duplicated")
fabric_kinds[fabric.id] = fabric.kind
if fabric.netkingdom_id != netkingdom_id:
errors.append(f"fabrics[{index}].netkingdom_id must match netkingdom.id")
if fabric.kind == "Fabric":
if not fabric.lord_actor_id:
errors.append(f"fabrics[{index}].lord_actor_id is required for Fabric")
elif actor_roles.get(fabric.lord_actor_id) not in {"lord", "king"}:
errors.append(f"fabrics[{index}].lord_actor_id must reference a lord or king actor")
if fabric.kind == "Subfabric":
if not fabric.parent_fabric_id:
errors.append(f"fabrics[{index}].parent_fabric_id is required for Subfabric")
elif fabric.parent_fabric_id not in fabric_kinds:
errors.append(
f"fabrics[{index}].parent_fabric_id references unknown fabric {fabric.parent_fabric_id!r}"
)
if not fabric.tenant_actor_id:
errors.append(f"fabrics[{index}].tenant_actor_id is required for Subfabric")
elif actor_roles.get(fabric.tenant_actor_id) != "tenant":
errors.append(f"fabrics[{index}].tenant_actor_id must reference a tenant actor")
node_ids: set[str] = set()
for index, node in enumerate(export.nodes):
path = f"nodes[{index}]"
if node.id in node_ids:
errors.append(f"{path}.id {node.id!r} is duplicated")
node_ids.add(node.id)
if node.containment is None:
errors.append(f"{path}.containment must be an object for v1alpha2 exports")
else:
if node.containment.netkingdom_id != netkingdom_id:
errors.append(f"{path}.containment.netkingdom_id must match netkingdom.id")
_validate_fabric_ref(errors, f"{path}.containment.fabric_id", node.containment.fabric_id, fabric_kinds, "Fabric")
if node.containment.subfabric_id:
_validate_fabric_ref(
errors,
f"{path}.containment.subfabric_id",
node.containment.subfabric_id,
fabric_kinds,
"Subfabric",
)
if node.ownership is None:
errors.append(f"{path}.ownership must be an object for v1alpha2 exports")
else:
_validate_actor_ref(errors, f"{path}.ownership.owner_actor_id", node.ownership.owner_actor_id, actor_roles)
if actor_roles.get(node.ownership.owner_actor_id) not in {node.ownership.owner_role, ""}:
errors.append(f"{path}.ownership.owner_role does not match referenced actor role")
if node.evidence is None:
errors.append(f"{path}.evidence must be an object for v1alpha2 exports")
elif (
node.evidence.review_state == "accepted"
and node.ownership
and node.ownership.resolution not in {"explicit", "inherited"}
):
errors.append(f"{path}.ownership.resolution must be explicit or inherited for accepted nodes")
for index, edge in enumerate(export.edges):
path = f"edges[{index}]"
if edge.from_graph_id not in node_ids:
errors.append(f"{path}.from references unknown node {edge.from_graph_id!r}")
if edge.to_graph_id not in node_ids:
errors.append(f"{path}.to references unknown node {edge.to_graph_id!r}")
if edge.relationship_category is None:
errors.append(f"{path}.relationship_category is required for v1alpha2 exports")
if edge.evidence is None:
errors.append(f"{path}.evidence must be an object for v1alpha2 exports")
if edge.edge_type == "provides_utility_to" and edge.relationship_category != "utility":
errors.append(f"{path}.relationship_category must be 'utility' for provides_utility_to edges")
if edge.relationship_category == "utility":
if edge.provider is None:
errors.append(f"{path}.provider is required for utility edges")
else:
_validate_utility_side(errors, f"{path}.provider", edge.provider, actor_roles, fabric_kinds)
if edge.consumer is None:
errors.append(f"{path}.consumer is required for utility edges")
else:
_validate_utility_side(errors, f"{path}.consumer", edge.consumer, actor_roles, fabric_kinds)
if edge.boundary is None:
errors.append(f"{path}.boundary is required for utility edges")
if edge.utility is None:
errors.append(f"{path}.utility is required for utility edges")
return errors
def _validate_actor_ref(
errors: list[str],
path: str,
actor_id: str,
actor_roles: dict[str, str],
) -> None:
if actor_id not in actor_roles:
errors.append(f"{path} references unknown actor {actor_id!r}")
def _validate_fabric_ref(
errors: list[str],
path: str,
fabric_id: str,
fabric_kinds: dict[str, str],
expected_kind: str,
) -> None:
actual_kind = fabric_kinds.get(fabric_id)
if actual_kind is None:
errors.append(f"{path} references unknown fabric {fabric_id!r}")
elif actual_kind != expected_kind:
errors.append(f"{path} must reference a {expected_kind}")
def _validate_utility_side(
errors: list[str],
path: str,
side: Any,
actor_roles: dict[str, str],
fabric_kinds: dict[str, str],
) -> None:
_validate_actor_ref(errors, f"{path}.owner_actor_id", side.owner_actor_id, actor_roles)
if side.fabric_id not in fabric_kinds:
errors.append(f"{path}.fabric_id references unknown fabric {side.fabric_id!r}")
if side.subfabric_id:
_validate_fabric_ref(errors, f"{path}.subfabric_id", side.subfabric_id, fabric_kinds, "Subfabric")
def _canon_metadata_errors(export: FabricGraphExportPayload) -> list[str]:
errors: list[str] = []
for index, node in enumerate(export.nodes):
if any(
value is not None
for value in (
node.canon_category,
node.canon_anchor,
node.mapping_fit,
node.evidence_state,
)
):
_require_fields(
errors,
f"nodes[{index}]",
{
"canon_category": node.canon_category,
"mapping_fit": node.mapping_fit,
"evidence_state": node.evidence_state,
},
("canon_category", "mapping_fit", "evidence_state"),
)
for index, edge in enumerate(export.edges):
has_canon_fields = any(
value is not None
for value in (
edge.canonical_type,
edge.canon_anchor,
edge.mapping_fit,
edge.display_only,
edge.evidence_state,
)
)
if has_canon_fields:
_require_fields(
errors,
f"edges[{index}]",
{
"mapping_fit": edge.mapping_fit,
"display_only": edge.display_only,
"evidence_state": edge.evidence_state,
},
("mapping_fit", "display_only", "evidence_state"),
)
if edge.edge_type in DISPLAY_ONLY_EDGE_TYPES and edge.display_only is not True:
errors.append(
f"edges[{index}] uses display-only edge type {edge.edge_type!r} without display_only=true"
)
if edge.display_only is True and edge.edge_type and not has_canon_fields:
errors.append(f"edges[{index}] is display-only but lacks canon metadata")
return errors
def _require_fields(
errors: list[str],
path: str,
item: dict[str, Any],
fields: tuple[str, ...],
) -> None:
for field in fields:
if item.get(field) in (None, ""):
errors.append(f"{path} missing required canon metadata field {field!r}")
def _source_value(payload: dict[str, Any], field: str) -> str | None:
source = payload.get("source")
if not isinstance(source, dict):
return None
value = source.get(field)
return str(value) if value else None
def _bounded_source_value(
payload: dict[str, Any], field: str, *, max_length: int
) -> str | None:
value = _source_value(payload, field)
return value[:max_length] if value is not None else None
def _parse_datetime(value: Any) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
normalized = value.replace("Z", "+00:00")
return datetime.fromisoformat(normalized)
except ValueError:
return None

View file

@ -0,0 +1,122 @@
"""Resolve the forge read credential (STATE-WP-0084-T03, MASON-WP-0003-T05).
Three sources, tried in order: a mounted file, an environment variable, and
OpenBao via Kubernetes auth. Production uses the third — this cluster has no
agent injector and no secrets-store CSI driver, so the pod authenticates with
its projected ServiceAccount token and reads the KV path itself, which is what
`MASON-WP-0003-T02` built the Kubernetes auth role for. The first two exist so
the code is runnable and testable outside the cluster.
**Absent is a supported state, never an error.** A hub with no credential still
derives every public repository; only private ones become unreadable, and
`STATE-WP-0084-T01` already reports that as its own named condition rather than
as records that stopped deriving. So every failure here — no configuration, no
network, OpenBao down, permission denied — resolves to `None`. Raising would
convert "cannot read nine repositories" into "the whole pass failed".
"""
from __future__ import annotations
import logging
import os
import time
from pathlib import Path
import httpx
logger = logging.getLogger(__name__)
TOKEN_ENV = "FORGE_READ_TOKEN"
TOKEN_FILE_ENV = "FORGE_READ_TOKEN_FILE"
OPENBAO_ADDR_ENV = "OPENBAO_ADDR"
OPENBAO_ROLE_ENV = "OPENBAO_K8S_ROLE"
OPENBAO_JWT_PATH_ENV = "OPENBAO_K8S_TOKEN_PATH"
OPENBAO_AUTH_MOUNT_ENV = "OPENBAO_K8S_AUTH_MOUNT"
SECRET_PATH_ENV = "FORGE_READ_SECRET_PATH"
SECRET_KEY_ENV = "FORGE_READ_SECRET_KEY"
# Long enough that a fleet reset of 121 repositories does not re-authenticate
# 121 times; short enough that a rotated token is picked up without a redeploy,
# which is what MASON-WP-0003-T02 requires of this lane.
CACHE_TTL_SECONDS = 300.0
_cache: tuple[float, str | None] | None = None
def reset_cache() -> None:
global _cache
_cache = None
def _from_file() -> str | None:
path = os.environ.get(TOKEN_FILE_ENV)
if not path:
return None
try:
return Path(path).read_text(encoding="utf-8").strip() or None
except OSError:
# Deliberately not falling through to the environment: a broken mount
# that silently used a stale value would look like success.
logger.warning("forge credential: token file %s is unreadable", path)
return None
def _from_env() -> str | None:
return (os.environ.get(TOKEN_ENV) or "").strip() or None
def _from_openbao() -> str | None:
addr = (os.environ.get(OPENBAO_ADDR_ENV) or "").strip().rstrip("/")
secret_path = (os.environ.get(SECRET_PATH_ENV) or "").strip().strip("/")
role = (os.environ.get(OPENBAO_ROLE_ENV) or "").strip()
jwt_path = os.environ.get(OPENBAO_JWT_PATH_ENV) or "/var/run/secrets/openbao/token"
mount = (os.environ.get(OPENBAO_AUTH_MOUNT_ENV) or "kubernetes").strip("/")
key = (os.environ.get(SECRET_KEY_ENV) or "token").strip()
if not (addr and secret_path and role):
return None
try:
jwt = Path(jwt_path).read_text(encoding="utf-8").strip()
except OSError:
logger.warning("forge credential: no ServiceAccount token at %s", jwt_path)
return None
try:
with httpx.Client(timeout=10.0) as client:
login = client.post(
f"{addr}/v1/auth/{mount}/login", json={"role": role, "jwt": jwt}
)
login.raise_for_status()
client_token = login.json()["auth"]["client_token"]
read = client.get(
f"{addr}/v1/{secret_path}", headers={"X-Vault-Token": client_token}
)
read.raise_for_status()
data = read.json()["data"]
# KV v2 nests the payload under a second "data"; v1 does not.
if isinstance(data.get("data"), dict):
data = data["data"]
except (httpx.HTTPError, KeyError, ValueError) as exc:
# Never include the response body: a failed KV read can echo content.
logger.warning("forge credential: OpenBao lookup failed (%s)", type(exc).__name__)
return None
value = data.get(key)
if not isinstance(value, str) or not value.strip():
logger.warning("forge credential: key %r absent at the KV path", key)
return None
return value.strip()
def forge_read_token(*, use_cache: bool = True) -> str | None:
"""The forge read credential, or `None` if this instance has none."""
global _cache
now = time.monotonic()
if use_cache and _cache is not None and now - _cache[0] < CACHE_TTL_SECONDS:
return _cache[1]
if os.environ.get(TOKEN_FILE_ENV):
# Configured to use a file means *that* file and nothing else. Falling
# back would let a broken mount quietly resolve to a stale environment
# value that nobody knows is in use.
token = _from_file()
else:
token = _from_env() or _from_openbao()
_cache = (now, token)
return token

View file

@ -0,0 +1,247 @@
"""Narrow Forgejo repository boundary used by repository rename operations.
The lifecycle service depends on this protocol instead of calling Forgejo
directly. Tests replace it with an in-memory gateway, which makes interruption
and resume tests deterministic and guarantees that API tests cannot rename a
live repository.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from urllib.parse import quote, urljoin
import httpx
from api.config import settings
from api.services.forge_credential import forge_read_token
WRITE_TOKEN_ENV = "FORGE_RENAME_TOKEN"
WRITE_TOKEN_FILE_ENV = "FORGE_RENAME_TOKEN_FILE"
class ForgeRepositoryError(RuntimeError):
pass
class ForgeRepositoryUnreadable(ForgeRepositoryError):
pass
class ForgeRepositoryRedirected(ForgeRepositoryUnreadable):
"""Requested coordinate redirects; no credentials are forwarded."""
def __init__(self, source_url: str, location: str):
super().__init__("Forge repository coordinate redirects")
self.target_url = urljoin(source_url, location) if location else None
class ForgeRepositoryConflict(ForgeRepositoryError):
pass
@dataclass(frozen=True)
class ForgeRepositorySnapshot:
repository_id: int
owner: str
name: str
full_name: str
default_branch: str
head_commit: str
clone_url: str | None
html_url: str | None
projection_readable: bool
projection_source_present: bool
projection_entry_count: int | None
def as_dict(self) -> dict:
return {
"repository_id": self.repository_id,
"owner": self.owner,
"name": self.name,
"full_name": self.full_name,
"default_branch": self.default_branch,
"head_commit": self.head_commit,
"clone_url": self.clone_url,
"html_url": self.html_url,
"projection_readable": self.projection_readable,
"projection_source_present": self.projection_source_present,
"projection_entry_count": self.projection_entry_count,
}
class ForgeRepositoryGateway(Protocol):
async def inspect(
self, *, instance: str, owner: str, name: str
) -> ForgeRepositorySnapshot | None: ...
async def rename(
self, *, instance: str, owner: str, old_name: str, new_name: str
) -> ForgeRepositorySnapshot: ...
async def descends_from(
self, *, instance: str, owner: str, name: str, base: str, head: str
) -> bool: ...
def _write_token() -> str | None:
path = os.environ.get(WRITE_TOKEN_FILE_ENV)
if path:
try:
return Path(path).read_text(encoding="utf-8").strip() or None
except OSError:
return None
return (os.environ.get(WRITE_TOKEN_ENV) or "").strip() or None
class ForgejoRepositoryGateway:
"""Forgejo v1 API adapter with separate read and rename credentials."""
@staticmethod
def _headers(token: str | None) -> dict[str, str]:
return {"Authorization": f"token {token}"} if token else {}
@staticmethod
def _api(instance: str, owner: str, name: str) -> str:
base = instance.rstrip("/")
return f"{base}/api/v1/repos/{quote(owner, safe='')}/{quote(name, safe='')}"
async def _inspect_with_token(
self, *, instance: str, owner: str, name: str, token: str | None
) -> ForgeRepositorySnapshot | None:
url = self._api(instance, owner, name)
timeout = settings.repository_rename_forge_timeout_seconds
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url, headers=self._headers(token))
if response.status_code in {301, 302, 303, 307, 308}:
raise ForgeRepositoryRedirected(
url, response.headers.get("location", "")
)
if response.status_code == 404:
if token is None:
raise ForgeRepositoryUnreadable(
"Forge returned not-found without an authenticated absence proof"
)
return None
response.raise_for_status()
repo = response.json()
branch_name = str(repo.get("default_branch") or "")
if not branch_name:
raise ForgeRepositoryUnreadable("Forge repository has no default branch")
branch = await client.get(
f"{url}/branches/{quote(branch_name, safe='')}",
headers=self._headers(token),
)
branch.raise_for_status()
branch_data = branch.json()
commit = branch_data.get("commit") or {}
head = str(commit.get("id") or commit.get("sha") or "")
if not head:
raise ForgeRepositoryUnreadable("Forge default branch has no readable head")
projection = await client.get(
f"{url}/contents/workplans",
params={"ref": head},
headers=self._headers(token),
)
source_present = projection.status_code == 200
if projection.status_code not in {200, 404}:
projection.raise_for_status()
entries = projection.json() if source_present else None
except ForgeRepositoryError:
raise
except (httpx.HTTPError, ValueError, KeyError) as exc:
raise ForgeRepositoryUnreadable(
f"Forge repository inspection failed ({type(exc).__name__})"
) from exc
return ForgeRepositorySnapshot(
repository_id=int(repo["id"]),
owner=str((repo.get("owner") or {}).get("login") or owner),
name=str(repo["name"]),
full_name=str(repo.get("full_name") or f"{owner}/{name}"),
default_branch=branch_name,
head_commit=head,
clone_url=repo.get("clone_url"),
html_url=repo.get("html_url"),
projection_readable=True,
projection_source_present=source_present,
projection_entry_count=(len(entries) if isinstance(entries, list) else None),
)
async def inspect(
self, *, instance: str, owner: str, name: str
) -> ForgeRepositorySnapshot | None:
return await self._inspect_with_token(
instance=instance, owner=owner, name=name, token=forge_read_token()
)
async def rename(
self, *, instance: str, owner: str, old_name: str, new_name: str
) -> ForgeRepositorySnapshot:
token = _write_token()
if not token:
raise ForgeRepositoryUnreadable("Forge rename credential is unavailable")
url = self._api(instance, owner, old_name)
try:
async with httpx.AsyncClient(
timeout=settings.repository_rename_forge_timeout_seconds
) as client:
response = await client.patch(
url,
json={"name": new_name},
headers=self._headers(token),
)
if response.status_code in {409, 422}:
raise ForgeRepositoryConflict("Forge rejected the target repository name")
response.raise_for_status()
except ForgeRepositoryError:
raise
except httpx.HTTPError as exc:
raise ForgeRepositoryUnreadable(
f"Forge repository rename failed ({type(exc).__name__})"
) from exc
renamed = await self._inspect_with_token(
instance=instance, owner=owner, name=new_name, token=token
)
if renamed is None:
raise ForgeRepositoryUnreadable("Renamed Forge repository is not readable")
return renamed
async def descends_from(
self, *, instance: str, owner: str, name: str, base: str, head: str
) -> bool:
"""True when ``base`` is an ancestor of (or equal to) ``head``.
``compare/<head>...<base>`` lists commits reachable from ``base`` but not
from ``head``; it is empty exactly when ``base`` is contained in ``head``.
"""
if base == head:
return True
url = f"{self._api(instance, owner, name)}/compare/{quote(head, safe='')}...{quote(base, safe='')}"
try:
async with httpx.AsyncClient(
timeout=settings.repository_rename_forge_timeout_seconds
) as client:
response = await client.get(url, headers=self._headers(forge_read_token()))
if response.status_code == 404:
return False
response.raise_for_status()
data = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise ForgeRepositoryUnreadable(
f"Forge ancestry check failed ({type(exc).__name__})"
) from exc
commits = data.get("commits")
total = data.get("total_commits")
return (isinstance(commits, list) and not commits) or total == 0
_gateway = ForgejoRepositoryGateway()
def get_forge_repository_gateway() -> ForgeRepositoryGateway:
return _gateway

View file

@ -0,0 +1,195 @@
"""Exact closed source bindings predating canonical Forge identity.
These are frozen historical source views, not aliases or UUID allocation rules.
An altered/missing source requires explicit owner review; generic source UUIDs
remain untrusted. Shared Activity views represent the one archived parent.
"""
from pathlib import Path
from hashlib import sha256
import json
BINDINGS = json.loads(r'''[
{
"repo": "activity-core",
"path": "workplans/archived/260707-custodian-WP-0003-event-bridge.md",
"sha256": "83ea8bcb151e003419b73c59dbd028f4a1ce3d1120a82e4a128a772503ff3b76",
"record_id": "custodian-WP-0003",
"uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5",
"parent_record_id": "custodian-WP-0003"
},
{
"repo": "activity-core",
"path": "workplans/custodian-WP-0001-temporal-backbone.md",
"sha256": "020e2eac973f2daa41c1687f8e867da9d521f945547e6aa82c87993b3ff6fc64",
"record_id": "custodian-WP-0001",
"uuid": "e22516cc-4fdf-4dad-a928-a9e04a9fa7c7",
"parent_record_id": "custodian-WP-0001"
},
{
"repo": "activity-core",
"path": "workplans/custodian-WP-0002-triggers-ops.md",
"sha256": "d8cbc61631c01b6f11a03a63f2fb6275fc853ff7f7459ce65973ed1a233daa01",
"record_id": "custodian-WP-0002",
"uuid": "3a4f47d9-8bc1-434e-acb4-bed5d4dacda0",
"parent_record_id": "custodian-WP-0002"
},
{
"repo": "activity-core",
"path": "workplans/custodian-WP-0003a-event-bridge-model-rules-registry.md",
"sha256": "ffa6682e9ba3aaf559bd44203d561c90dea69bcf1b1e778209a7b209e6e5cdd3",
"record_id": "custodian-WP-0003a",
"uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5",
"parent_record_id": "custodian-WP-0003"
},
{
"repo": "activity-core",
"path": "workplans/custodian-WP-0003b-event-bridge-parser-workflow-triggers.md",
"sha256": "4fc34e0f821e2df51e5c08c7ab34da35b65690381d01f0cfdf55d00c970b414c",
"record_id": "custodian-WP-0003b",
"uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5",
"parent_record_id": "custodian-WP-0003"
},
{
"repo": "activity-core",
"path": "workplans/custodian-WP-0003c-event-bridge-context-integration.md",
"sha256": "b6cd62b26515e509670ca39736b6bcec9633beedd46c9bd8f7598c06bac7276b",
"record_id": "custodian-WP-0003c",
"uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5",
"parent_record_id": "custodian-WP-0003"
},
{
"repo": "activity-core",
"path": "workplans/custodian-WP-0004-railiance-ops.md",
"sha256": "20df02eea740e109c937130e7f8801a636fc0990ed8c6d2ced55659f8957adb1",
"record_id": "custodian-WP-0004",
"uuid": "759b1255-aa78-42b5-8fab-c5fcf168f5f4",
"parent_record_id": "custodian-WP-0004"
},
{
"repo": "the-custodian",
"path": "workplans/ADHOC-2026-08-25-retired.md",
"sha256": "344a1293dbb3a67a944eb34ca4a0337487d8f047226027a2107922346fbab2dd",
"record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED",
"uuid": "1c53d3db-6633-5e2d-987f-10706d73be9e",
"parent_record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED"
},
{
"repo": "railiance-cluster",
"path": "workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md",
"sha256": "8d6f8533c119117686bc0d5bb9ed5349726aa6c3189ff98b53409c68ac685ca9",
"record_id": "RAIL-BS-WP-0002",
"uuid": "4c63dfc6-9eac-4e79-9f77-8f644ad7147d",
"parent_record_id": "RAIL-BS-WP-0002",
"tasks": [
{
"label": "T01",
"uuid": "3f042630-eab0-4c6a-9167-e2b28ff20e40",
"status": "done"
},
{
"label": "T02",
"uuid": "e8510646-46ed-4697-a345-f3d3009eea78",
"status": "done"
},
{
"label": "T03",
"uuid": "dab2c07f-8aa0-4635-8df6-857e87e93fc5",
"status": "done"
},
{
"label": "T04",
"uuid": "5c3d40e4-239b-488e-9519-6f7a38d2325f",
"status": "done"
},
{
"label": "T05",
"uuid": "7f9e0e58-a130-467a-a2d0-b3f2564e496f",
"status": "done"
}
]
},
{
"repo": "railiance-cluster",
"path": "workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md",
"sha256": "4dc2f61e15adc3c5c81f9ecf8b933512275b95e8f829fac52ae7cde1385cea4a",
"record_id": "RAIL-BS-WP-0003",
"uuid": "7ee9ee22-1fae-4567-9194-8d70a9e0f45b",
"parent_record_id": "RAIL-BS-WP-0003",
"tasks": [
{
"label": "T01",
"uuid": "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc",
"status": "done"
},
{
"label": "T02",
"uuid": "ab166073-30a7-4702-a037-4091e8706e20",
"status": "done"
},
{
"label": "T03",
"uuid": "140da396-8e30-4f4d-b88c-c42c0cd46c01",
"status": "done"
},
{
"label": "T04",
"uuid": "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d",
"status": "done"
}
]
},
{
"repo": "railiance-cluster",
"path": "workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md",
"sha256": "04f787078232395bd2d43861de16c521848559fa286f06e1caa9cb779aa71ac3",
"record_id": "RAIL-BS-WP-0004",
"uuid": "7e8b0c20-51eb-40c9-9e3b-85dd380d7625",
"parent_record_id": "RAIL-BS-WP-0004",
"tasks": [
{
"label": "T01",
"uuid": "4526a842-ea31-4874-9231-92ab556cfe7b",
"status": "done"
},
{
"label": "T02",
"uuid": "a6313e06-1976-46a7-8e31-df4eb2eca880",
"status": "done"
},
{
"label": "T03",
"uuid": "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3",
"status": "done"
},
{
"label": "T04",
"uuid": "08233868-d522-4117-bc4e-6c0f52545665",
"status": "done"
},
{
"label": "T05",
"uuid": "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8",
"status": "done"
},
{
"label": "T06",
"uuid": "f8e4a094-c367-40eb-b895-da17bc144b07",
"status": "done"
}
]
}
]''')
def bound_sources(repo_root: Path, repo_slug: str):
entries = [entry for entry in BINDINGS if entry["repo"] == repo_slug]
result = []
for entry in entries:
path = repo_root / entry["path"]
if path.is_symlink() or not path.is_file():
raise ValueError("Historical source binding missing or symlinked: " + entry["path"])
data = path.read_bytes()
if sha256(data).hexdigest() != entry["sha256"]:
raise ValueError("Historical source binding changed: " + entry["path"])
result.append((entry, data.decode("utf-8")))
return result

View file

@ -0,0 +1,388 @@
"""Pure contract for informed-decision forwarding and notifications.
Dev and request validation only. This module does not read a token, open a
socket, register a submitter, or decide a hub decision. ``drafted_by`` is
asserted provenance; it is never an authenticated identity.
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from typing import Any, Mapping
KIND = "informed-decision.memo-intake"
VERSION = 1
OVERVIEW_LINE = "The signed-in overview is the authoritative review entry point."
PROVENANCE_NOTE = (
"drafted_by is asserted provenance, not authenticated identity. "
"A forwarded token proves only the hub submitter."
)
RESOLVING_VERBS = frozenset({"accept", "decline"})
NOTIFICATION_FIELDS = ("memo_id", "question", "memo_class", "submitter")
MAX_QUESTION = 400
MAX_DRAFTED_BY = 200
MAX_PACKET_BYTES = 256 * 1024
MAX_ENVELOPE_BYTES = 1024 * 1024
MAX_PACKET_ITEMS = 32
MAX_NOTIFICATION_BYTES = 8192
_KEY_RE = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
_MEMO_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{2,62}$")
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,62}$")
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
_SHA_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
_ERROR_RE = re.compile(r"^[a-z0-9_]{1,64}$")
ENVELOPE_KEYS = {
"kind", "version", "idempotency_key", "memo_class", "review_group",
"drafted_by", "serves", "approval_intent", "memo", "packet",
}
ENVELOPE_REQUIRED = ENVELOPE_KEYS - {"drafted_by", "serves"}
INTENT_KEYS = {"action", "principal", "purpose", "target"}
TARGET_KEYS = {"id", "type", "system"}
PACKET_KEYS = {"item_id", "media_type", "content"}
MEDIA_TYPES = {"text/markdown", "text/plain", "application/json"}
# Shapes only. A hit is refused by code; the matched text is never echoed.
_SECRET_MARKERS = (
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{16,}"),
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
re.compile(r"\b(hvs|hvb|hvr)\.[A-Za-z0-9_-]{20,}"),
re.compile(
r"\b(sk-(or-)?[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{30,}|"
r"github_pat_[A-Za-z0-9_]{30,}|glpat-[A-Za-z0-9_-]{20,}|"
r"AKIA[0-9A-Z]{16}|xox[abpr]-[A-Za-z0-9-]{10,})"
),
re.compile(
r"(?i)\b(password|passwd|client_secret|secret|api[_-]?key|access[_-]?token|token)"
r"\s*[:=]\s*['\"]?(?=[A-Za-z0-9_+.=-]*\d)[A-Za-z0-9_+.=-]{12,}(?=['\"\s,;)]|$)"
),
re.compile(r"://[^/@\s:]+:[^/@\s]+@"),
)
class ContractError(ValueError):
def __init__(self, code: str, status: int = 400) -> None:
super().__init__(code)
self.code = code
self.status = status
def canonical(value: Any) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def digest(value: Any) -> str:
return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest()
def scan_secrets(value: Any) -> None:
for text in _strings(value):
for pattern in _SECRET_MARKERS:
if pattern.search(text):
raise ContractError("secret_marker")
def stable_idempotency_key(decision_id: str, memo_id: str) -> str:
_uuid(decision_id, "hub_decision_id")
_memo_id(memo_id)
return f"state-hub.{decision_id}.{memo_id}"
def resolution_applies(verb: str, serves: Mapping | None) -> bool:
"""Terminal human accept/decline with resolves=on-answer only."""
if verb not in RESOLVING_VERBS:
return False
if not isinstance(serves, Mapping):
return False
return serves.get("resolves") == "on-answer"
def correlate_resolution(
memo_id: str, *, post_status: int | None, existing: Mapping | None
) -> str:
"""Classify a resolve attempt. A 409 is a conflict only after the existing
resolution was read; an unreadable 409 stays retryable."""
_memo_id(memo_id)
if post_status is None:
return "failed"
if 200 <= post_status < 300:
return "resolved"
if post_status != 409:
return "failed"
if not isinstance(existing, Mapping):
return "failed"
rationale = existing.get("rationale")
if not isinstance(rationale, str):
return "failed"
if f"memo {memo_id} " in rationale:
return "already_resolved"
return "conflict"
def safe_error_code(value: Any, fallback: str) -> str:
if isinstance(value, str) and _ERROR_RE.match(value):
return value
return fallback
@dataclass(frozen=True)
class Notification:
memo_id: str
question: str
memo_class: str
submitter: str
def as_body(self) -> dict[str, str]:
return {
"memo_id": self.memo_id,
"question": self.question,
"memo_class": self.memo_class,
"submitter": self.submitter,
}
@property
def body_digest(self) -> str:
return digest(self.as_body())
def message_subject(self) -> str:
return f"Decision memo {self.memo_id}"
def message_text(self) -> str:
return OVERVIEW_LINE + "\n" + canonical(self.as_body())
def parse_delivery_id(value: str | None) -> str:
if value is None or value == "":
raise ContractError("delivery_id_required")
if not isinstance(value, str) or not _KEY_RE.match(value):
raise ContractError("delivery_id_invalid")
return value
def parse_notification(payload: Any) -> Notification:
if not isinstance(payload, dict):
raise ContractError("invalid_notification")
if set(payload) - set(NOTIFICATION_FIELDS):
raise ContractError("unknown_field")
if set(NOTIFICATION_FIELDS) - set(payload):
raise ContractError("missing_field")
scan_secrets(payload)
if any("://" in text for text in _strings(payload)):
raise ContractError("notification_has_link")
return Notification(
memo_id=_memo_id(payload["memo_id"]),
question=_one_question(payload["question"]),
memo_class=_slug(payload["memo_class"], "memo_class"),
submitter=_slug(payload["submitter"], "submitter"),
)
def build_envelope(
*,
decision_id: str,
memo: Mapping,
packet: list,
memo_class: str,
review_group: str,
approval_intent: Mapping,
drafted_by: str | None = None,
resolves: str = "on-answer",
) -> dict:
if not isinstance(memo, Mapping) or not isinstance(memo.get("id"), str):
raise ContractError("invalid_memo")
envelope: dict[str, Any] = {
"kind": KIND,
"version": VERSION,
"idempotency_key": stable_idempotency_key(decision_id, memo["id"]),
"memo_class": memo_class,
"review_group": review_group,
"serves": {"hub_decision_id": decision_id, "resolves": resolves},
"approval_intent": dict(approval_intent),
"memo": dict(memo),
"packet": list(packet),
}
if drafted_by is not None:
envelope["drafted_by"] = drafted_by
return validate_envelope(envelope)
def validate_envelope(envelope: Any) -> dict:
if not isinstance(envelope, dict):
raise ContractError("invalid_envelope")
extra = set(envelope) - ENVELOPE_KEYS
if extra:
raise ContractError("unknown_field")
missing = ENVELOPE_REQUIRED - set(envelope)
if missing:
raise ContractError("missing_field")
if envelope["kind"] != KIND or type(envelope["version"]) is not int or envelope["version"] != VERSION:
raise ContractError("unsupported_kind")
scan_secrets(envelope)
_packet(envelope["packet"])
_bounded_tree(envelope)
encoded = canonical(envelope).encode("utf-8")
if len(encoded) > MAX_ENVELOPE_BYTES:
raise ContractError("envelope_too_large")
memo = envelope["memo"]
if not isinstance(memo, dict):
raise ContractError("invalid_memo")
memo_id = _memo_id(memo.get("id"))
if "question" in memo:
_one_question(memo["question"])
key = _text(envelope["idempotency_key"], pattern=_KEY_RE, code="idempotency_key_invalid")
if "serves" in envelope:
decision_id = _serves(envelope["serves"])
if key != stable_idempotency_key(decision_id, memo_id):
raise ContractError("idempotency_key_not_stable")
elif not (memo_id.startswith("review-") and _UUID_RE.fullmatch(memo_id[7:]) and key == memo_id):
# Lifecycle service supplies its retained snapshot id/key. Never invent
# a Hub decision relationship or rewrite that immutable envelope.
raise ContractError("lifecycle_handoff_key_invalid")
_slug(envelope["memo_class"], "memo_class")
_slug(envelope["review_group"], "review_group")
drafted_by = envelope.get("drafted_by")
if drafted_by is not None:
_text(drafted_by, max_len=MAX_DRAFTED_BY, code="drafted_by_invalid")
_intent(envelope["approval_intent"])
if "submitter" in envelope or "actor" in envelope.get("approval_intent", {}):
raise ContractError("unknown_field")
return json.loads(canonical(envelope))
def submission_digest(envelope: Mapping) -> str:
body = {key: value for key, value in envelope.items() if key != "idempotency_key"}
return digest(body)
def bounded_receipt(body: Any, expected_memo_id: str) -> dict[str, str]:
if not isinstance(body, dict):
raise ContractError("receipt_incomplete")
memo_id = body.get("memo_id")
approval_id = body.get("approval_id")
binding = body.get("approval_binding_digest")
if memo_id != expected_memo_id or not isinstance(approval_id, str) or not _UUID_RE.match(approval_id):
raise ContractError("receipt_mismatch")
if not isinstance(binding, str) or not _SHA_RE.match(binding):
raise ContractError("receipt_incomplete")
return {
"memo_id": memo_id,
"approval_id": approval_id,
"approval_binding_digest": binding,
}
def _serves(serves: Any) -> str:
if not isinstance(serves, dict) or set(serves) != {"hub_decision_id", "resolves"}:
raise ContractError("invalid_serves")
if serves["resolves"] not in ("on-answer", "never"):
raise ContractError("unknown_enum")
return _uuid(serves["hub_decision_id"], "hub_decision_id")
def _intent(intent: Any) -> None:
if not isinstance(intent, dict) or set(intent) != INTENT_KEYS:
raise ContractError("invalid_intent")
for field in ("action", "principal", "purpose"):
_text(intent[field], max_len=400, code="invalid_intent")
target = intent["target"]
if not isinstance(target, dict) or set(target) != TARGET_KEYS:
raise ContractError("invalid_intent")
for field in TARGET_KEYS:
_text(target[field], max_len=200, code="invalid_intent")
def _packet(packet: Any) -> None:
if not isinstance(packet, list) or not packet or len(packet) > MAX_PACKET_ITEMS:
raise ContractError("packet_required" if not packet else "packet_unbounded")
seen: set[str] = set()
for item in packet:
if not isinstance(item, dict) or set(item) != PACKET_KEYS:
raise ContractError("invalid_packet")
if item["media_type"] not in MEDIA_TYPES:
raise ContractError("unsupported_media_type")
content = item["content"]
if not isinstance(content, str) or len(content.encode("utf-8")) > MAX_PACKET_BYTES:
raise ContractError("packet_too_large")
item_id = _text(item["item_id"], max_len=200, code="invalid_packet")
if item_id in seen:
raise ContractError("duplicate_packet_item")
seen.add(item_id)
def _one_question(value: Any) -> str:
text = _text(value, max_len=MAX_QUESTION, code="question_invalid")
if "\n" in text or "\r" in text or "://" in text:
raise ContractError("question_invalid")
if text.count("?") != 1 or not text.endswith("?"):
raise ContractError("question_not_one_question")
return text
def _memo_id(value: Any) -> str:
return _text(value, pattern=_MEMO_ID_RE, code="memo_id_invalid")
def _slug(value: Any, code: str) -> str:
return _text(value, pattern=_SLUG_RE, code=code + "_invalid")
def _uuid(value: Any, code: str) -> str:
return _text(value, pattern=_UUID_RE, code=code + "_invalid")
def _text(value: Any, *, max_len: int = 128, pattern: re.Pattern[str] | None = None, code: str) -> str:
if not isinstance(value, str) or not value or len(value) > max_len:
raise ContractError(code)
if pattern is not None and not pattern.match(value):
raise ContractError(code)
return value
def _bounded_tree(value: Any) -> None:
total = 0
def walk(node: Any, key: str | None = None) -> None:
nonlocal total
if isinstance(node, str):
size = len(node.encode("utf-8"))
if size > MAX_PACKET_BYTES:
raise ContractError("field_too_large")
if key == "question":
_one_question(node)
total += size
elif isinstance(node, dict):
for child_key, child in node.items():
if not isinstance(child_key, str):
raise ContractError("invalid_type")
walk(child_key, None)
walk(child, child_key)
elif isinstance(node, list):
for child in node:
walk(child, key)
elif isinstance(node, bool) or type(node) in (int, float) or node is None:
# type() excludes bool; bool subclasses int, so it must be named first.
return
else:
raise ContractError("invalid_type")
if total > MAX_ENVELOPE_BYTES:
raise ContractError("envelope_too_large")
walk(value)
def _strings(value: Any):
if isinstance(value, str):
yield value
elif isinstance(value, dict):
for key, child in value.items():
if isinstance(key, str):
yield key
yield from _strings(child)
elif isinstance(value, list):
for child in value:
yield from _strings(child)

View file

@ -0,0 +1,152 @@
"""Shared legacy REST compat metering (routes and query params)."""
from __future__ import annotations
import logging
from fastapi import HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from api.services.legacy_meter import identity_from_request, record_legacy_usage
logger = logging.getLogger(__name__)
_LEGACY_OWNER = "state-hub.api"
# Planning horizon for legacy workstream interfaces; actual removal remains
# legacy-meter-gated per docs/workplan-terminology-transition.md.
_LEGACY_WORKSTREAM_SUNSET = "Wed, 30 Jun 2027 23:59:59 GMT"
def legacy_response_headers(replacement_ref: str) -> dict[str, str]:
return {
"Deprecation": "true",
"Sunset": _LEGACY_WORKSTREAM_SUNSET,
"X-StateHub-Replacement": replacement_ref,
"Link": f'<{replacement_ref}>; rel="successor-version"',
}
def mark_legacy_response(response: Response | None, replacement_ref: str) -> None:
if response is None:
return
for key, value in legacy_response_headers(replacement_ref).items():
if key == "Link":
response.headers.append(key, value)
else:
response.headers[key] = value
def legacy_query_param_key(method: str, route: str, param: str = "workstream_id") -> str:
return f"rest_api:{method} {route}?{param}"
async def meter_legacy_query_param(
*,
session: AsyncSession,
request: Request | None,
response: Response | None,
method: str,
route: str,
replacement_ref: str,
param: str = "workstream_id",
) -> None:
interface_key = legacy_query_param_key(method, route, param)
mark_legacy_response(response, replacement_ref)
try:
await record_legacy_usage(
session,
interface_key=interface_key,
interface_kind="rest_api",
replacement_ref=replacement_ref,
owner_component=_LEGACY_OWNER,
replacement_verified=True,
identity=identity_from_request(request),
)
except Exception:
await session.rollback()
logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True)
def legacy_body_field_key(method: str, route: str, field: str = "workstream_id") -> str:
return f"rest_api:{method} {route} {field}"
async def meter_legacy_body_field(
*,
session: AsyncSession,
request: Request | None,
response: Response | None,
method: str,
route: str,
replacement_ref: str,
field: str = "workstream_id",
) -> None:
interface_key = legacy_body_field_key(method, route, field)
mark_legacy_response(response, replacement_ref)
try:
await record_legacy_usage(
session,
interface_key=interface_key,
interface_kind="rest_api",
replacement_ref=replacement_ref,
owner_component=_LEGACY_OWNER,
replacement_verified=True,
identity=identity_from_request(request),
)
except Exception:
await session.rollback()
logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True)
async def retire_legacy_route(
*,
session: AsyncSession,
request: Request | None,
response: Response | None,
interface_key: str,
replacement_ref: str,
detail: str,
) -> None:
"""Record final legacy-meter usage for this route, then return 410 Gone."""
mark_legacy_response(response, replacement_ref)
try:
await record_legacy_usage(
session,
interface_key=interface_key,
interface_kind="rest_api",
replacement_ref=replacement_ref,
owner_component=_LEGACY_OWNER,
replacement_verified=True,
identity=identity_from_request(request),
)
except Exception:
await session.rollback()
logger.warning("legacy-meter failed to record %s", interface_key, exc_info=True)
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail=detail,
headers=legacy_response_headers(replacement_ref),
)
async def meter_legacy_body_from_model(
body: object,
*,
session: AsyncSession,
request: Request | None,
response: Response | None,
method: str,
route: str,
replacement_ref: str,
field: str = "workstream_id",
) -> None:
if not getattr(body, "used_legacy_workstream_id", False):
return
await meter_legacy_body_field(
session=session,
request=request,
response=response,
method=method,
route=route,
replacement_ref=replacement_ref,
field=field,
)

View file

@ -0,0 +1,438 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from typing import Any
from fastapi import Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
from api.schemas.legacy_meter import (
LegacyInterfaceRead,
LegacyInterfaceSummary,
LegacyUsageCounters,
LegacyUsageSummary,
LegacyWeeklyReview,
)
UNKNOWN_BUCKET = "unknown"
VALID_INTERFACE_KINDS = {
"rest_api",
"mcp_tool",
"procedure_call",
"event_subject",
"cli",
"dashboard_route",
"schema_field",
}
VALID_INTERFACE_STATUSES = {"legacy", "retirement_candidate", "retired"}
@dataclass(frozen=True)
class LegacyUsageIdentity:
tenant_key: str = UNKNOWN_BUCKET
user_key: str = UNKNOWN_BUCKET
component_key: str = UNKNOWN_BUCKET
def identity_from_request(request: Request | None) -> LegacyUsageIdentity:
if request is None:
return LegacyUsageIdentity()
headers = request.headers
return LegacyUsageIdentity(
tenant_key=_clean_bucket(headers.get("x-statehub-tenant") or headers.get("x-tenant-id")),
user_key=_clean_bucket(headers.get("x-statehub-user") or headers.get("x-user-id")),
component_key=_clean_bucket(headers.get("x-statehub-component") or headers.get("x-component")),
)
async def register_legacy_interface(
session: AsyncSession,
*,
interface_key: str,
interface_kind: str,
replacement_ref: str,
owner_component: str = "state-hub",
replacement_verified: bool = False,
manual_hold: bool = False,
hold_reason: str | None = None,
notes: str | None = None,
status: str = "legacy",
commit: bool = True,
preserve_controls: bool = False,
) -> LegacyInterface:
interface_key = _required(interface_key, "interface_key")
replacement_ref = _required(replacement_ref, "replacement_ref")
interface_kind = _validate_kind(interface_kind)
status = _validate_status(status)
existing = await _get_by_key(session, interface_key)
if existing is None:
existing = LegacyInterface(
interface_key=interface_key,
interface_kind=interface_kind,
replacement_ref=replacement_ref,
owner_component=_clean_value(owner_component, "state-hub"),
replacement_verified=replacement_verified,
manual_hold=manual_hold,
hold_reason=hold_reason,
notes=notes,
status=status,
)
session.add(existing)
else:
existing.interface_kind = interface_kind
existing.replacement_ref = replacement_ref
existing.owner_component = _clean_value(owner_component, "state-hub")
existing.replacement_verified = existing.replacement_verified or replacement_verified
if not preserve_controls:
existing.manual_hold = manual_hold
existing.hold_reason = hold_reason
existing.notes = notes if notes is not None else existing.notes
existing.status = status
if commit:
await session.commit()
await session.refresh(existing)
else:
await session.flush()
return existing
async def patch_legacy_interface(
session: AsyncSession,
interface: LegacyInterface,
updates: dict[str, Any],
) -> LegacyInterface:
if "interface_kind" in updates:
updates["interface_kind"] = _validate_kind(updates["interface_kind"])
if "status" in updates and updates["status"] is not None:
updates["status"] = _validate_status(updates["status"])
if updates["status"] == "retired" and interface.retired_at is None:
interface.retired_at = datetime.now(tz=timezone.utc)
for field, value in updates.items():
if value is not None or field in {"manual_hold", "replacement_verified", "hold_reason", "notes"}:
setattr(interface, field, value)
await session.commit()
await session.refresh(interface)
return interface
async def record_legacy_usage(
session: AsyncSession,
*,
interface_key: str,
interface_kind: str,
replacement_ref: str,
identity: LegacyUsageIdentity | None = None,
owner_component: str = "state-hub",
replacement_verified: bool = False,
observed_at: datetime | None = None,
call_count: int = 1,
commit: bool = True,
) -> LegacyInterface:
if call_count < 1:
raise ValueError("call_count must be >= 1")
observed_at = observed_at or datetime.now(tz=timezone.utc)
if observed_at.tzinfo is None:
observed_at = observed_at.replace(tzinfo=timezone.utc)
identity = identity or LegacyUsageIdentity()
interface = await register_legacy_interface(
session,
interface_key=interface_key,
interface_kind=interface_kind,
replacement_ref=replacement_ref,
owner_component=owner_component,
replacement_verified=replacement_verified,
preserve_controls=True,
commit=False,
)
for bucket_kind, bucket_key in (
("call", "total"),
("tenant", identity.tenant_key),
("user", identity.user_key),
("component", identity.component_key),
):
await _increment_bucket(
session,
interface,
period_start=observed_at.date(),
bucket_kind=bucket_kind,
bucket_key=_clean_bucket(bucket_key),
observed_at=observed_at,
call_count=call_count,
)
if commit:
await session.commit()
await session.refresh(interface)
else:
await session.flush()
return interface
async def get_legacy_interface_by_key(
session: AsyncSession,
interface_key: str,
) -> LegacyInterface | None:
return await _get_by_key(session, interface_key)
async def list_legacy_interfaces(session: AsyncSession) -> list[LegacyInterface]:
result = await session.execute(
select(LegacyInterface).order_by(LegacyInterface.interface_kind, LegacyInterface.interface_key)
)
return list(result.scalars().all())
async def legacy_usage_summary(
session: AsyncSession,
*,
window_start: datetime | None = None,
window_end: datetime | None = None,
) -> LegacyUsageSummary:
window_end = _ensure_datetime(window_end) or datetime.now(tz=timezone.utc)
window_start = _ensure_datetime(window_start) or (window_end - timedelta(days=7))
interfaces = await list_legacy_interfaces(session)
buckets = await _usage_buckets(session)
by_interface: dict[Any, list[LegacyInterfaceUsageBucket]] = {}
for bucket in buckets:
by_interface.setdefault(bucket.legacy_interface_id, []).append(bucket)
summaries = [
_summarize_interface(interface, by_interface.get(interface.id, []), window_start, window_end)
for interface in interfaces
]
return LegacyUsageSummary(
generated_at=datetime.now(tz=timezone.utc),
window_start=window_start,
window_end=window_end,
interfaces=summaries,
)
async def legacy_weekly_review(
session: AsyncSession,
*,
window_start: datetime | None = None,
window_end: datetime | None = None,
) -> LegacyWeeklyReview:
summary = await legacy_usage_summary(
session,
window_start=window_start,
window_end=window_end,
)
candidates = [item for item in summary.interfaces if item.retirement_candidate]
return LegacyWeeklyReview(
generated_at=summary.generated_at,
window_start=summary.window_start,
window_end=summary.window_end,
activity_core_handoff={
"activity_id": "statehub-legacy-interface-review",
"cadence": "weekly",
"source_endpoint": "/legacy-meter/weekly-review",
"state_owner": "state-hub",
"scheduler_owner": "activity-core",
},
interfaces=summary.interfaces,
retirement_candidates=candidates,
)
async def _increment_bucket(
session: AsyncSession,
interface: LegacyInterface,
*,
period_start: date,
bucket_kind: str,
bucket_key: str,
observed_at: datetime,
call_count: int,
) -> LegacyInterfaceUsageBucket:
result = await session.execute(
select(LegacyInterfaceUsageBucket).where(
LegacyInterfaceUsageBucket.legacy_interface_id == interface.id,
LegacyInterfaceUsageBucket.period_start == period_start,
LegacyInterfaceUsageBucket.bucket_kind == bucket_kind,
LegacyInterfaceUsageBucket.bucket_key == bucket_key,
)
)
bucket = result.scalar_one_or_none()
if bucket is None:
bucket = LegacyInterfaceUsageBucket(
legacy_interface_id=interface.id,
period_start=period_start,
bucket_kind=bucket_kind,
bucket_key=bucket_key,
call_count=call_count,
first_seen_at=observed_at,
last_seen_at=observed_at,
)
session.add(bucket)
return bucket
bucket.call_count += call_count
if observed_at < bucket.first_seen_at:
bucket.first_seen_at = observed_at
if observed_at > bucket.last_seen_at:
bucket.last_seen_at = observed_at
return bucket
async def _get_by_key(session: AsyncSession, interface_key: str) -> LegacyInterface | None:
result = await session.execute(
select(LegacyInterface).where(LegacyInterface.interface_key == interface_key)
)
return result.scalar_one_or_none()
async def _usage_buckets(session: AsyncSession) -> list[LegacyInterfaceUsageBucket]:
result = await session.execute(select(LegacyInterfaceUsageBucket))
return list(result.scalars().all())
def _summarize_interface(
interface: LegacyInterface,
buckets: list[LegacyInterfaceUsageBucket],
window_start: datetime,
window_end: datetime,
) -> LegacyInterfaceSummary:
all_time = _counters(buckets)
window_buckets = [
bucket for bucket in buckets
if bucket.last_seen_at >= window_start and bucket.first_seen_at < window_end
]
window = _counters(window_buckets)
last_seen = max((bucket.last_seen_at for bucket in buckets), default=None)
retirement_candidate, reason = _retirement_state(
interface,
window.calls,
all_time_calls=all_time.calls,
last_seen_at=last_seen,
window_end=window_end,
)
return LegacyInterfaceSummary(
interface=LegacyInterfaceRead.model_validate(interface),
all_time=all_time,
window=window,
last_seen_at=last_seen,
retirement_candidate=retirement_candidate,
retirement_reason=reason,
)
def _counters(buckets: list[LegacyInterfaceUsageBucket]) -> LegacyUsageCounters:
calls = sum(bucket.call_count for bucket in buckets if bucket.bucket_kind == "call")
tenants = _bucket_counts(buckets, "tenant")
users = _bucket_counts(buckets, "user")
components = _bucket_counts(buckets, "component")
return LegacyUsageCounters(
calls=calls,
tenant_count=len(tenants),
user_count=len(users),
component_count=len(components),
tenants=tenants,
users=users,
components=components,
)
def _bucket_counts(buckets: list[LegacyInterfaceUsageBucket], bucket_kind: str) -> dict[str, int]:
counts: dict[str, int] = {}
for bucket in buckets:
if bucket.bucket_kind == bucket_kind:
counts[bucket.bucket_key] = counts.get(bucket.bucket_key, 0) + bucket.call_count
return counts
# Minimum quiet period before an interface may be retired, scaled to how much
# traffic it ever carried. A review window only proves the interface was silent
# for that window; for a high-volume interface that is weak evidence, since a
# monthly or quarterly caller can easily fall outside it. Thresholds are
# (all-time calls, minimum days since last_seen_at).
RETIREMENT_QUIET_LADDER: tuple[tuple[int, int], ...] = (
(10_000, 60),
(100, 30),
(1, 7),
)
def _required_quiet_days(all_time_calls: int) -> int:
"""Days of silence required before retirement, by all-time call volume."""
for threshold, days in RETIREMENT_QUIET_LADDER:
if all_time_calls >= threshold:
return days
return 0
def _retirement_state(
interface: LegacyInterface,
window_calls: int,
*,
all_time_calls: int = 0,
last_seen_at: datetime | None = None,
window_end: datetime | None = None,
) -> tuple[bool, str]:
if interface.status == "retired":
return False, "already retired"
if interface.manual_hold:
return False, interface.hold_reason or "manual hold"
if not interface.replacement_ref.strip():
return False, "missing replacement reference"
if not interface.replacement_verified:
return False, "replacement not verified"
if window_calls > 0:
return False, f"{window_calls} call(s) in review window"
required_days = _required_quiet_days(all_time_calls)
if required_days and last_seen_at is not None and window_end is not None:
quiet_days = (window_end - _ensure_datetime(last_seen_at)).days
if quiet_days < required_days:
return False, (
f"quiet {quiet_days}d of {required_days}d required "
f"for {all_time_calls} all-time call(s)"
)
return True, (
f"no usage in review window; quiet {quiet_days}d "
f"(>= {required_days}d required for {all_time_calls} all-time call(s))"
)
return True, "no measured usage in review window"
def _ensure_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def _required(value: str | None, field: str) -> str:
cleaned = _clean_value(value)
if not cleaned:
raise ValueError(f"{field} is required")
return cleaned
def _clean_value(value: str | None, default: str = "") -> str:
return str(value or default).strip()
def _clean_bucket(value: str | None) -> str:
cleaned = _clean_value(value)
return cleaned or UNKNOWN_BUCKET
def _validate_kind(kind: str) -> str:
cleaned = _required(kind, "interface_kind")
if cleaned not in VALID_INTERFACE_KINDS:
raise ValueError(f"interface_kind must be one of {sorted(VALID_INTERFACE_KINDS)}")
return cleaned
def _validate_status(status: str) -> str:
cleaned = _required(status, "status")
if cleaned not in VALID_INTERFACE_STATUSES:
raise ValueError(f"status must be one of {sorted(VALID_INTERFACE_STATUSES)}")
return cleaned

View file

@ -0,0 +1,135 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from api.task_status import ACTIVE_TASK_STATUSES, normalize_task_status, status_value
from api.workplan_status import normalize_workplan_status
TASK_STARTED_STATUS = "progress"
TASK_NOT_STARTED_STATUS = "todo"
TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
# Legacy alias
normalize_workstream_status = normalize_workplan_status
@dataclass(frozen=True)
class LifecycleTransitionResult:
entity_type: str
previous_status: str
target_status: str
changed: bool
parent_activated: bool = False
def should_activate_parent_for_task_start(
*,
previous_task_status: Any,
new_task_status: Any,
parent_workplan_status: Any = None,
parent_workstream_status: Any = None,
) -> bool:
"""Return whether a task start should move its parent to active."""
parent_status = parent_workplan_status if parent_workplan_status is not None else parent_workstream_status
return (
status_value(previous_task_status) == TASK_NOT_STARTED_STATUS
and status_value(new_task_status) == TASK_STARTED_STATUS
and normalize_workplan_status(parent_status)
in PARENT_ACTIVATION_STATUSES
)
def has_active_task_status(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
"""Return whether any task status represents currently active work."""
return any(status_value(status) in TASK_ACTIVE_STATUSES for status in task_statuses)
def should_activate_parent_for_active_tasks(
*,
parent_workplan_status: Any = None,
parent_workstream_status: Any = None,
task_statuses: list[Any] | tuple[Any, ...],
) -> bool:
"""Return whether existing task state implies an active parent workplan."""
parent_status = parent_workplan_status if parent_workplan_status is not None else parent_workstream_status
return (
normalize_workplan_status(parent_status)
in PARENT_ACTIVATION_STATUSES
and has_active_task_status(task_statuses)
)
def activate_parent_for_task_start(
*,
previous_task_status: Any,
new_task_status: Any,
parent_workplan: Any = None,
parent_workstream: Any = None,
) -> bool:
"""Activate a planning-state parent workplan when real task work starts."""
parent = parent_workplan if parent_workplan is not None else parent_workstream
if parent is None:
return False
if not should_activate_parent_for_task_start(
previous_task_status=previous_task_status,
new_task_status=new_task_status,
parent_workplan_status=getattr(parent, "status", None),
parent_workstream_status=getattr(parent, "status", None),
):
return False
parent.status = "active"
return True
def transition_workplan_status(
workplan: Any,
target_status: Any,
) -> LifecycleTransitionResult:
"""Apply a canonical workplan status transition."""
previous_status = normalize_workplan_status(getattr(workplan, "status", None))
normalised_target = normalize_workplan_status(target_status)
workplan.status = normalised_target
return LifecycleTransitionResult(
entity_type="workplan",
previous_status=previous_status,
target_status=normalised_target,
changed=previous_status != normalised_target,
)
transition_workstream_status = transition_workplan_status
def transition_task_status(
task: Any,
target_status: Any,
*,
parent_workplan: Any = None,
parent_workstream: Any = None,
previous_task_status: Any = None,
status_coercer: Any = None,
) -> LifecycleTransitionResult:
"""Apply a task status transition and activate the parent when work starts."""
parent = parent_workplan if parent_workplan is not None else parent_workstream
previous_status = status_value(
getattr(task, "status", None)
if previous_task_status is None
else previous_task_status
)
normalised_target = normalize_task_status(target_status)
task.status = status_coercer(normalised_target) if status_coercer else normalised_target
parent_activated = activate_parent_for_task_start(
previous_task_status=previous_status,
new_task_status=normalised_target,
parent_workplan=parent,
parent_workstream=parent,
)
return LifecycleTransitionResult(
entity_type="task",
previous_status=previous_status,
target_status=normalised_target,
changed=previous_status != normalised_target,
parent_activated=parent_activated,
)

View file

@ -0,0 +1,149 @@
from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any
import yaml
from api.config import settings
class MarkitectUnavailable(RuntimeError):
"""Raised when neither MarkiTect's Python API nor CLI can render templates."""
class MarkitectRenderError(RuntimeError):
"""Raised when MarkiTect rejects a template or data payload."""
def render_markdown_template(template_path: Path, data: dict[str, Any]) -> str:
"""Render a Markdown template with MarkiTect.
Prefer the Python API when available. Fall back to the CLI because local
workstations may have MarkiTect installed as a standalone tool.
"""
template_text = template_path.read_text(encoding="utf-8")
try:
from markitect_tool.template import render_template
except ImportError:
return _render_with_cli(template_path, data)
try:
return render_template(template_text, data, strict=True).markdown
except Exception as exc: # MarkiTect raises its own TemplateError hierarchy.
raise MarkitectRenderError(str(exc)) from exc
def _render_with_cli(template_path: Path, data: dict[str, Any]) -> str:
cli = _find_markitect_cli()
if cli is None:
raise MarkitectUnavailable(
"MarkiTect is required to render RecentlyOnScope digests. "
"Install markitect_tool, put `markitect` on PATH, or set STATE_HUB_MARKITECT_CLI_PATH."
)
with tempfile.TemporaryDirectory(prefix="state-hub-markitect-") as tmp:
data_path = Path(tmp) / "data.yaml"
data_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
result = _run_first_supported(
[
[cli, "template", "render", str(template_path), "--data", str(data_path)],
[cli, "template-render", str(template_path), str(data_path), "--strict"],
]
)
if result.returncode != 0:
detail = (result.stderr or result.stdout or "MarkiTect render failed").strip()
raise MarkitectRenderError(detail)
return result.stdout
def inspect_markdown_template(template_path: Path) -> dict[str, Any]:
"""Return MarkiTect template analysis data where available."""
template_text = template_path.read_text(encoding="utf-8")
try:
from markitect_tool.template import analyze_template
except ImportError:
return _inspect_with_cli(template_path)
analysis = analyze_template(template_text).to_dict()
if not analysis.get("valid", False):
raise MarkitectRenderError("; ".join(analysis.get("syntax_errors") or []))
return analysis
def _inspect_with_cli(template_path: Path) -> dict[str, Any]:
cli = _find_markitect_cli()
if cli is None:
raise MarkitectUnavailable(
"MarkiTect is required to inspect RecentlyOnScope templates. "
"Install markitect_tool, put `markitect` on PATH, or set STATE_HUB_MARKITECT_CLI_PATH."
)
result = subprocess.run(
[cli, "template", "inspect", str(template_path), "--format", "json"],
check=False,
capture_output=True,
text=True,
)
if _is_unsupported_cli_command(result):
with tempfile.TemporaryDirectory(prefix="state-hub-markitect-") as tmp:
data_path = Path(tmp) / "empty.yaml"
data_path.write_text("{}\n", encoding="utf-8")
legacy = subprocess.run(
[cli, "template-render", str(template_path), str(data_path), "--lenient", "--validate"],
check=False,
capture_output=True,
text=True,
)
if legacy.returncode != 0:
detail = (legacy.stderr or legacy.stdout or "MarkiTect template inspection failed").strip()
raise MarkitectRenderError(detail)
return {"valid": True, "variables": [], "legacy_cli": True}
if result.returncode != 0:
detail = (result.stderr or result.stdout or "MarkiTect template inspection failed").strip()
raise MarkitectRenderError(detail)
try:
return json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise MarkitectRenderError("MarkiTect returned invalid JSON during template inspection") from exc
def _find_markitect_cli() -> str | None:
candidates = [
settings.state_hub_markitect_cli_path,
shutil.which("markitect"),
"/home/worsch/bin/markitect",
]
for candidate in candidates:
if not candidate:
continue
path = Path(candidate).expanduser()
if path.is_file():
return str(path)
if shutil.which(str(candidate)):
return str(candidate)
return None
def _run_first_supported(commands: list[list[str]]) -> subprocess.CompletedProcess[str]:
last_result: subprocess.CompletedProcess[str] | None = None
for command in commands:
result = subprocess.run(command, check=False, capture_output=True, text=True)
if not _is_unsupported_cli_command(result):
return result
last_result = result
return last_result if last_result is not None else subprocess.CompletedProcess([], 1, "", "")
def _is_unsupported_cli_command(result: subprocess.CompletedProcess[str]) -> bool:
output = f"{result.stdout}\n{result.stderr}"
return result.returncode != 0 and (
"No such command" in output
or "Missing command" in output
or "Got unexpected extra argument" in output
)

View file

@ -0,0 +1,174 @@
"""Private, explicitly queued State Hub memo dispatch. No API route or human act."""
from contextlib import contextmanager
import argparse
import fcntl
import json
import os
from pathlib import Path
import signal
import stat
import threading
import urllib.error
import urllib.request
from api.services.informed_decision_contract import MAX_ENVELOPE_BYTES, validate_envelope
from api.services.memo_forwarding import MemoForwarder
ORIGIN = 'http://informed-decision-workload.informed-decision.svc.cluster.local:8081'
TOKEN = '/var/run/secrets/state-hub/informed-decision/intake-token'
MAX_RESPONSE = 65536
class Refused(RuntimeError):
pass
def read_json(path, limit):
with Path(path).open('rb') as stream:
raw = stream.read(limit + 1)
if len(raw) > limit:
raise Refused('input_too_large')
return json.loads(raw)
def token_file(path):
# Reopen on every attempt: projected files rotate by symlink replacement.
with Path(path).open('rb') as stream:
raw = stream.read(16385)
value = raw.decode('ascii').strip()
if not value or len(raw) > 16384 or any(c.isspace() for c in value):
raise Refused('token_unavailable')
return value
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def transport(method, url, body, headers):
if method != 'POST' or url != ORIGIN + '/v1/memos':
raise Refused('route_refused')
raw = json.dumps(body, allow_nan=False, separators=(',', ':')).encode()
if len(raw) > MAX_ENVELOPE_BYTES:
raise Refused('input_too_large')
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
request = urllib.request.Request(url, data=raw, headers=headers, method=method)
try:
try:
response = opener.open(request, timeout=5)
except urllib.error.HTTPError as error:
response = error
with response:
status = response.code
raw = response.read(MAX_RESPONSE + 1)
if len(raw) > MAX_RESPONSE:
raise OSError('transport_unavailable')
try:
body = json.loads(raw)
except (ValueError, UnicodeError):
body = {}
return status, body
except Exception:
# Never propagate URLs, response bodies, credentials or transport errors.
raise OSError('transport_unavailable') from None
class Dispatcher:
def __init__(self, directory, *, send=transport, read_token=lambda: token_file(TOKEN), clock=None):
self.directory = Path(directory)
if not self.directory.is_absolute():
raise Refused('absolute_queue_path_required')
self.directory.mkdir(mode=0o700, parents=False, exist_ok=True)
info = self.directory.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700:
raise Refused('private_queue_required')
# SQLite files inherit 0600; reject existing unsafe custody, including links.
self.path = self.directory / 'forward.sqlite'
for name in ('forward.sqlite', 'forward.sqlite-wal', 'forward.sqlite-shm', 'dispatch.lock'):
path = self.directory / name
if path.exists() or path.is_symlink():
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600:
raise Refused('private_queue_required')
self.send, self.read_token, self.clock = send, read_token, clock
with self.lock():
previous = os.umask(0o077)
try:
self.ledger = MemoForwarder(self.path, transport=send,
intake_base_url=ORIGIN, hub_base_url='http://state-hub.state-hub.svc.cluster.local:8000',
token_reader=read_token, **({'clock': clock} if clock else {}))
finally:
os.umask(previous)
@contextmanager
def lock(self):
fd = os.open(self.directory / 'dispatch.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
previous = os.umask(0o077)
try:
yield
finally:
os.umask(previous)
except BlockingIOError:
raise Refused('queue_busy') from None
finally:
os.close(fd)
def enqueue(self, envelope):
validate_envelope(envelope)
if envelope['memo_class'] != 'decision-record' or envelope['review_group'] != 'net-kingdom-admins':
raise Refused('routing_refused')
with self.lock():
return self.ledger.submit(envelope, dispatch=False)
def once(self):
# Hold across remote attempt + local receipt to serialize other processes.
# If killed after POST, restart replays the same envelope/key. Intake owns
# native create uncertainty and refuses automatic native-approval repost.
with self.lock():
return self.ledger.run_due()
def counts(self):
with self.lock(), self.ledger._connect() as db:
return dict(db.execute('SELECT state,COUNT(*) FROM pose_ledger GROUP BY state'))
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--queue', required=True, type=Path)
commands = parser.add_subparsers(dest='command', required=True)
commands.add_parser('enqueue').add_argument('--envelope', required=True, type=Path)
for name in ('once', 'run', 'status'):
commands.add_parser(name)
args = parser.parse_args(argv)
try:
dispatcher = Dispatcher(args.queue)
if args.command == 'enqueue':
result = dispatcher.enqueue(read_json(args.envelope, MAX_ENVELOPE_BYTES))
print(json.dumps({'status': result.status, 'detail': result.detail}))
elif args.command == 'once':
result = dispatcher.once()
print(json.dumps({'status': result.status if result else 'idle'}))
elif args.command == 'status':
print(json.dumps(dispatcher.counts()))
else:
stopped = threading.Event()
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, lambda *_: stopped.set())
while not stopped.is_set():
try:
dispatcher.once()
except Refused as exc:
if str(exc) != 'queue_busy':
raise
stopped.wait(2)
return 0
except Exception:
print('{"error":"dispatch_refused"}')
return 1
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -0,0 +1,447 @@
"""Pose, replay, and conflict ledger with injected transport.
Not imported by the API runtime. The private dispatcher composes this ledger
under a process lock; dev tests retain the injectable adapter. It never reads a token
file, mints a credential, or dials a default host. Tests inject the transport
and, when a pose should carry a header, a token callable. The callable's
value is not retained.
Choosing the workload identity that projects an intake-audience token, and
reviewing that identity into the submitter registry, is an owner step. This
fixture does not name or admit an account.
"""
from __future__ import annotations
import json
import sqlite3
import time
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterator
from api.services.informed_decision_contract import (
PROVENANCE_NOTE,
ContractError,
bounded_receipt,
correlate_resolution,
resolution_applies,
safe_error_code,
submission_digest,
validate_envelope,
)
Transport = Callable[[str, str, dict, dict], tuple[int, dict]]
TokenReader = Callable[[], str]
_RETRYABLE = frozenset({408, 429})
@dataclass(frozen=True)
class ForwardResult:
status: str
idempotency_key: str
receipt: dict | None
drafted_by: str | None
drafted_by_provenance: str
provenance_note: str
detail: str = ""
class MemoForwarder:
def __init__(
self,
path: str | Path,
*,
transport: Transport,
intake_base_url: str,
hub_base_url: str,
token_reader: TokenReader | None = None,
clock: Callable[[], float] = time.time,
) -> None:
self.path = str(path)
self.transport = transport
self.intake_base_url = _base_url(intake_base_url)
self.hub_base_url = _base_url(hub_base_url)
self.token_reader = token_reader
self.clock = clock
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
with self._connect() as db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS pose_ledger (
idempotency_key TEXT PRIMARY KEY,
submission_digest TEXT NOT NULL,
envelope_json TEXT NOT NULL,
drafted_by TEXT,
drafted_by_provenance TEXT NOT NULL
CHECK (drafted_by_provenance IN ('asserted', 'absent')),
provenance_note TEXT NOT NULL,
state TEXT NOT NULL
CHECK (state IN ('pending', 'delivered', 'blocked')),
receipt_json TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt REAL NOT NULL DEFAULT 0,
last_error TEXT,
blocked_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS resolution_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
decision_id TEXT NOT NULL,
memo_id TEXT NOT NULL,
verb TEXT NOT NULL,
resolves TEXT,
status TEXT NOT NULL,
error_type TEXT,
payload_json TEXT,
next_attempt REAL,
created_at TEXT NOT NULL
);
"""
)
def submit(self, envelope: dict, *, dispatch: bool = True) -> ForwardResult:
checked = validate_envelope(envelope)
key = checked["idempotency_key"]
body_digest = submission_digest(checked)
drafted_by = checked.get("drafted_by")
provenance = "asserted" if drafted_by else "absent"
now = self.clock()
early: tuple[str, str] | None = None
with self._connect() as db:
row = db.execute(
"SELECT * FROM pose_ledger WHERE idempotency_key=?", (key,)
).fetchone()
if row is None:
db.execute(
"""INSERT INTO pose_ledger (
idempotency_key, submission_digest, envelope_json, drafted_by,
drafted_by_provenance, provenance_note, state, attempts,
next_attempt, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, 0, ?, ?)""",
(
key, body_digest, _dump(checked), drafted_by, provenance,
PROVENANCE_NOTE, _iso(now), _iso(now),
),
)
elif row["submission_digest"] != body_digest:
db.execute(
"""UPDATE pose_ledger SET blocked_reason='idempotency_conflict',
updated_at=? WHERE idempotency_key=?""",
(_iso(now), key),
)
if row["state"] != "delivered":
db.execute(
"UPDATE pose_ledger SET state='blocked' WHERE idempotency_key=?",
(key,),
)
early = ("blocked", "idempotency_conflict")
elif row["state"] == "delivered":
early = ("replay", "replay")
elif row["state"] == "blocked":
early = ("blocked", row["blocked_reason"] or "blocked")
elif row["next_attempt"] > now:
early = ("pending", row["last_error"] or "waiting")
if early is not None:
return self._result(key, *early)
if not dispatch:
return self._result(key, "pending", "queued")
return self._attempt(key)
def run_due(self) -> ForwardResult | None:
now = self.clock()
with self._connect() as db:
row = db.execute(
"""SELECT idempotency_key FROM pose_ledger
WHERE state='pending' AND next_attempt<=?
ORDER BY created_at LIMIT 1""",
(now,),
).fetchone()
key = None if row is None else row["idempotency_key"]
if key is None:
return None
return self._attempt(key)
def pose(self, key: str) -> dict | None:
with self._connect() as db:
row = db.execute(
"SELECT * FROM pose_ledger WHERE idempotency_key=?", (key,)
).fetchone()
return dict(row) if row is not None else None
def observe_resolution(
self, *, decision_id: str, memo_id: str, verb: str, serves: dict | None, payload: dict | None
) -> str:
resolves = serves.get("resolves") if isinstance(serves, dict) else None
if not resolution_applies(verb, serves):
self._append_resolution(
decision_id=decision_id, memo_id=memo_id, verb=verb, resolves=resolves,
status="not_applicable", error_type=None, payload=None, retry_at=None,
)
return "not_applicable"
prior = self._latest_resolution(decision_id, memo_id)
if prior and prior["status"] in {"resolved", "already_resolved", "conflict"}:
return prior["status"]
if prior and prior["status"] == "failed":
if prior["next_attempt"] is not None and prior["next_attempt"] > self.clock():
return "failed"
if payload is None and prior["payload_json"]:
payload = json.loads(prior["payload_json"])
if not isinstance(payload, dict):
raise ValueError("resolution payload is required")
encoded = _dump(payload)
if len(encoded.encode("utf-8")) > 8192:
self._append_resolution(
decision_id=decision_id, memo_id=memo_id, verb=verb, resolves=resolves,
status="conflict", error_type="payload_unbounded", payload=None, retry_at=None,
)
return "conflict"
try:
status, _body = self.transport(
"POST",
f"{self.hub_base_url}/decisions/{decision_id}/resolve",
payload,
{},
)
status = int(status)
except OSError as exc:
self._append_resolution(
decision_id=decision_id, memo_id=memo_id, verb=verb, resolves=resolves,
status="failed", error_type=type(exc).__name__, payload=payload,
retry_at=self.clock() + _backoff(1),
)
return "failed"
except (TypeError, ValueError):
self._append_resolution(
decision_id=decision_id, memo_id=memo_id, verb=verb, resolves=resolves,
status="failed", error_type="transport_invalid", payload=payload,
retry_at=self.clock() + _backoff(1),
)
return "failed"
existing = None
if status == 409:
try:
read_status, existing = self.transport(
"GET", f"{self.hub_base_url}/decisions/{decision_id}", {}, {}
)
if int(read_status) != 200 or not isinstance(existing, dict):
existing = None
raise OSError("resolution_unreadable")
except OSError as exc:
error = exc.__class__.__name__ if str(exc) != "resolution_unreadable" else "resolution_unreadable"
self._append_resolution(
decision_id=decision_id, memo_id=memo_id, verb=verb, resolves=resolves,
status="failed", error_type=error, payload=payload,
retry_at=self.clock() + _backoff(1),
)
return "failed"
outcome = correlate_resolution(memo_id, post_status=status, existing=existing)
self._append_resolution(
decision_id=decision_id, memo_id=memo_id, verb=verb, resolves=resolves,
status=outcome, error_type=None if outcome != "failed" else f"http_{status}",
payload=payload, retry_at=None if outcome != "failed" else self.clock() + _backoff(1),
)
return outcome
def run_due_resolution(self) -> str | None:
now = self.clock()
with self._connect() as db:
row = db.execute(
"""SELECT * FROM resolution_ledger
WHERE status='failed' AND next_attempt IS NOT NULL AND next_attempt<=?
ORDER BY id DESC LIMIT 1""",
(now,),
).fetchone()
pending = dict(row) if row is not None else None
if pending is None:
return None
payload = json.loads(pending["payload_json"]) if pending["payload_json"] else None
serves = {"hub_decision_id": pending["decision_id"], "resolves": pending["resolves"]}
return self.observe_resolution(
decision_id=pending["decision_id"],
memo_id=pending["memo_id"],
verb=pending["verb"],
serves=serves,
payload=payload,
)
def resolutions(self) -> list[dict]:
with self._connect() as db:
rows = db.execute(
"SELECT * FROM resolution_ledger ORDER BY id"
).fetchall()
return [dict(row) for row in rows]
def _attempt(self, key: str) -> ForwardResult:
with self._connect() as db:
row = db.execute(
"SELECT * FROM pose_ledger WHERE idempotency_key=?", (key,)
).fetchone()
current = dict(row) if row is not None else None
if current is None or current["state"] != "pending":
return self._result(key, current["state"] if current else "blocked", "not_pending")
envelope = json.loads(current["envelope_json"])
token, token_error = self._read_token()
if token_error:
self._mark_retry(key, token_error)
return self._result(key, "pending", token_error)
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
try:
status, body = self.transport(
"POST", f"{self.intake_base_url}/v1/memos", envelope, headers
)
status = int(status)
except OSError as exc:
self._mark_retry(key, type(exc).__name__)
return self._result(key, "pending", type(exc).__name__)
except (TypeError, ValueError):
self._mark_retry(key, "transport_invalid")
return self._result(key, "pending", "transport_invalid")
if status in (200, 201):
try:
receipt = bounded_receipt(body, envelope["memo"]["id"])
except ContractError as exc:
code = safe_error_code(exc.code, "receipt_mismatch")
self._mark_blocked(key, code)
return self._result(key, "blocked", code)
self._mark_delivered(key, receipt)
return self._result(key, "delivered", "delivered")
if status == 409 or (status not in _RETRYABLE and 400 <= status < 500):
code = safe_error_code(
body.get("error") if isinstance(body, dict) else None,
"conflict" if status == 409 else f"http_{status}",
)
self._mark_blocked(key, code)
return self._result(key, "blocked", code)
self._mark_retry(key, f"http_{status}")
return self._result(key, "pending", f"http_{status}")
def _read_token(self) -> tuple[str | None, str | None]:
if self.token_reader is None:
return None, "projected_token_absent"
try:
token = self.token_reader()
except Exception as exc:
return None, type(exc).__name__
if not isinstance(token, str) or not token.strip() or token != token.strip():
return None, "projected_token_absent"
if len(token) > 16384:
return None, "projected_token_absent"
return token, None
def _mark_retry(self, key: str, error: str) -> None:
now = self.clock()
with self._connect() as db:
attempts = db.execute(
"SELECT attempts FROM pose_ledger WHERE idempotency_key=?", (key,)
).fetchone()["attempts"] + 1
db.execute(
"""UPDATE pose_ledger SET state='pending', attempts=?, last_error=?,
next_attempt=?, updated_at=? WHERE idempotency_key=?""",
(attempts, error, now + _backoff(attempts), _iso(now), key),
)
def _mark_blocked(self, key: str, reason: str) -> None:
now = self.clock()
with self._connect() as db:
db.execute(
"""UPDATE pose_ledger SET state='blocked', blocked_reason=?, last_error=?,
updated_at=? WHERE idempotency_key=?""",
(reason, reason, _iso(now), key),
)
def _mark_delivered(self, key: str, receipt: dict) -> None:
now = self.clock()
with self._connect() as db:
db.execute(
"""UPDATE pose_ledger SET state='delivered', receipt_json=?, last_error=NULL,
blocked_reason=NULL, updated_at=? WHERE idempotency_key=?""",
(_dump(receipt), _iso(now), key),
)
def _result(self, key: str, status: str, detail: str) -> ForwardResult:
row = self.pose(key)
receipt = json.loads(row["receipt_json"]) if row and row["receipt_json"] else None
return ForwardResult(
status=status,
idempotency_key=key,
receipt=receipt,
drafted_by=row["drafted_by"] if row else None,
drafted_by_provenance=row["drafted_by_provenance"] if row else "absent",
provenance_note=row["provenance_note"] if row else PROVENANCE_NOTE,
detail=detail,
)
def _append_resolution(
self, *, decision_id, memo_id, verb, resolves, status, error_type, payload, retry_at
) -> None:
with self._connect() as db:
db.execute(
"""UPDATE resolution_ledger SET next_attempt=NULL
WHERE decision_id=? AND memo_id=? AND status='failed'""",
(decision_id, memo_id),
)
db.execute(
"""INSERT INTO resolution_ledger (
decision_id, memo_id, verb, resolves, status, error_type,
payload_json, next_attempt, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
decision_id, memo_id, verb, resolves, status, error_type,
_dump(payload) if payload is not None else None,
retry_at, _iso(self.clock()),
),
)
def _latest_resolution(self, decision_id: str, memo_id: str) -> dict | None:
with self._connect() as db:
row = db.execute(
"""SELECT * FROM resolution_ledger
WHERE decision_id=? AND memo_id=? ORDER BY id DESC LIMIT 1""",
(decision_id, memo_id),
).fetchone()
return dict(row) if row is not None else None
@contextmanager
def _connect(self) -> Iterator[sqlite3.Connection]:
db = sqlite3.connect(self.path)
db.row_factory = sqlite3.Row
try:
db.execute("PRAGMA journal_mode=WAL")
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
def _base_url(value: str) -> str:
if (
not isinstance(value, str)
or not (value.startswith("http://") or value.startswith("https://"))
):
raise ValueError("base_url is required")
if "127.0.0.1" in value or "localhost" in value:
raise ValueError("base_url must be an injected test host")
return value.rstrip("/")
def _backoff(attempts: int) -> float:
return float(min(3600, 2 ** min(attempts, 11)))
def _dump(value: dict) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def _iso(timestamp: float) -> str:
return datetime.fromtimestamp(timestamp, timezone.utc).isoformat()
# Compatibility for the original injectable development fixture.
DevIntakeFixture = MemoForwarder

View file

@ -0,0 +1,67 @@
"""Deduplicated informed-decision notification receipts.
The signed-in overview is the review entry point. A receipt stores and
announces only memo id, question, class, and submitter. Replays of the same
delivery id return the retained receipt; a different body conflicts and leaves
the original readable.
"""
from __future__ import annotations
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.agent_message import AgentMessage
from api.models.memo_notification import MemoNotificationReceipt
from api.services.informed_decision_contract import Notification
from api.services.repository_aliases import canonicalize_repository_slug
NOTIFICATION_SENDER = "informed-decision"
NOTIFICATION_RECIPIENT = "bernd"
class NotificationConflict(Exception):
def __init__(self, receipt: MemoNotificationReceipt) -> None:
self.receipt = receipt
async def accept_notification(
session: AsyncSession, delivery_id: str, notice: Notification
) -> tuple[MemoNotificationReceipt, bool]:
existing = await session.get(MemoNotificationReceipt, delivery_id)
if existing is not None:
if existing.body_digest != notice.body_digest:
raise NotificationConflict(existing)
return existing, True
message = AgentMessage(
from_agent=await canonicalize_repository_slug(session, NOTIFICATION_SENDER),
to_agent=await canonicalize_repository_slug(session, NOTIFICATION_RECIPIENT),
subject=notice.message_subject(),
body=notice.message_text(),
kind="message",
)
session.add(message)
await session.flush()
receipt = MemoNotificationReceipt(
delivery_id=delivery_id,
body_digest=notice.body_digest,
memo_id=notice.memo_id,
question=notice.question,
memo_class=notice.memo_class,
submitter=notice.submitter,
message_id=message.id,
authoritative_review="signed-in-overview",
)
session.add(receipt)
try:
await session.commit()
except IntegrityError:
await session.rollback()
existing = await session.get(MemoNotificationReceipt, delivery_id)
if existing is None:
raise
if existing.body_digest != notice.body_digest:
raise NotificationConflict(existing)
return existing, True
await session.refresh(receipt)
return receipt, False

View file

@ -0,0 +1,212 @@
"""Per-recipient broadcast receipts and standing notices (STATE-WP-0093).
Broadcast visibility is per reader: a ``message_receipts`` row per
(message, agent) records delivery, read and acknowledgement. Direct messages
never get receipts and keep the global ``AgentMessage.read_at``.
- ``news`` broadcasts stay in a reader's unread inbox until that reader has
any receipt (delivery at orientation is enough).
- ``standing`` broadcasts stay until the reader acknowledges, the notice
expires, or it is superseded (which archives it).
"""
from __future__ import annotations
import uuid
from collections.abc import Iterable, Sequence
from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, exists, func, not_, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.agent_message import BROADCAST, AgentMessage, MessageReceipt
from api.models.managed_repo import ManagedRepo
from api.schemas.agent_message import NoticeStatus, StandingNoticeDigest
NEWS_DEFAULT_TTL = timedelta(days=30)
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def is_broadcast(message: AgentMessage) -> bool:
return message.to_agent == BROADCAST
def not_expired(now: datetime):
return or_(AgentMessage.expires_at.is_(None), AgentMessage.expires_at > now)
def broadcast_unread_clause(reader_values: Sequence[str], now: datetime):
"""SQL clause: broadcast is still unread for a reader (any of its slugs)."""
has_any_receipt = exists().where(
and_(
MessageReceipt.message_id == AgentMessage.id,
MessageReceipt.agent.in_(reader_values),
)
)
has_ack = exists().where(
and_(
MessageReceipt.message_id == AgentMessage.id,
MessageReceipt.agent.in_(reader_values),
MessageReceipt.acknowledged_at.is_not(None),
)
)
return and_(
AgentMessage.to_agent == BROADCAST,
not_expired(now),
or_(
and_(AgentMessage.kind == "standing", not_(has_ack)),
and_(AgentMessage.kind != "standing", not_(has_any_receipt)),
),
)
async def upsert_receipt(
session: AsyncSession,
message_ids: Iterable[uuid.UUID],
agent: str,
*,
delivered: bool = False,
read: bool = False,
acknowledged: bool = False,
at: datetime | None = None,
) -> None:
"""Idempotently stamp receipt fields; the first timestamp wins."""
ids = list(message_ids)
if not ids or not agent or agent == BROADCAST:
return
at = at or utcnow()
rows = [
{
"message_id": mid,
"agent": agent,
"delivered_at": at if delivered else None,
"read_at": at if (read or acknowledged) else None,
"acknowledged_at": at if acknowledged else None,
}
for mid in ids
]
stmt = pg_insert(MessageReceipt).values(rows)
table = MessageReceipt.__table__
stmt = stmt.on_conflict_do_update(
index_elements=[table.c.message_id, table.c.agent],
set_={
col: func.coalesce(table.c[col], stmt.excluded[col])
for col in ("delivered_at", "read_at", "acknowledged_at")
},
)
await session.execute(stmt)
async def receipts_for(
session: AsyncSession,
message_ids: Iterable[uuid.UUID],
reader_values: Sequence[str],
) -> dict[uuid.UUID, dict[str, datetime | None]]:
"""Merged receipt state per message across a reader's historical slugs."""
ids = list(message_ids)
if not ids:
return {}
result = await session.execute(
select(MessageReceipt).where(
MessageReceipt.message_id.in_(ids),
MessageReceipt.agent.in_(reader_values),
)
)
merged: dict[uuid.UUID, dict[str, datetime | None]] = {}
for receipt in result.scalars().all():
state = merged.setdefault(
receipt.message_id,
{"delivered_at": None, "read_at": None, "acknowledged_at": None},
)
for field in state:
value = getattr(receipt, field)
if value is not None and (state[field] is None or value < state[field]):
state[field] = value
return merged
async def _active_repo_slugs(session: AsyncSession) -> list[str]:
result = await session.execute(
select(ManagedRepo.slug).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug)
)
return list(result.scalars().all())
async def live_standing_notices(
session: AsyncSession, now: datetime | None = None
) -> list[AgentMessage]:
now = now or utcnow()
result = await session.execute(
select(AgentMessage)
.where(
AgentMessage.to_agent == BROADCAST,
AgentMessage.kind == "standing",
AgentMessage.archived_at.is_(None),
not_expired(now),
)
.order_by(AgentMessage.created_at.desc())
)
return list(result.scalars().all())
async def notice_statuses(session: AsyncSession) -> list[NoticeStatus]:
notices = await live_standing_notices(session)
if not notices:
return []
repos = await _active_repo_slugs(session)
result = await session.execute(
select(MessageReceipt).where(
MessageReceipt.message_id.in_([n.id for n in notices])
)
)
by_notice: dict[uuid.UUID, list[MessageReceipt]] = {}
for receipt in result.scalars().all():
by_notice.setdefault(receipt.message_id, []).append(receipt)
statuses: list[NoticeStatus] = []
for notice in notices:
receipts = by_notice.get(notice.id, [])
acked = sorted({r.agent for r in receipts if r.acknowledged_at is not None})
seen = {r.agent for r in receipts}
delivered_only = sorted(seen - set(acked))
unreached = [slug for slug in repos if slug not in seen]
statuses.append(
NoticeStatus(
id=notice.id,
from_agent=notice.from_agent,
subject=notice.subject,
body=notice.body,
created_at=notice.created_at,
expires_at=notice.expires_at,
supersedes_id=notice.supersedes_id,
acknowledged=acked,
delivered_only=delivered_only,
unreached=unreached,
acked_count=len(acked),
delivered_only_count=len(delivered_only),
unreached_count=len(unreached),
)
)
return statuses
async def standing_notice_digests(session: AsyncSession) -> list[StandingNoticeDigest]:
statuses = await notice_statuses(session)
if not statuses:
return []
repos = set(await _active_repo_slugs(session))
digests = []
for status in statuses:
acked_repos = len(repos.intersection(status.acknowledged))
digests.append(
StandingNoticeDigest(
id=status.id,
subject=status.subject,
expires_at=status.expires_at,
acked=acked_repos,
pending=len(repos) - acked_repos,
)
)
return digests

View file

@ -0,0 +1,130 @@
"""Read-only activity-core ops_run projection (STATE-WP-0078)."""
from __future__ import annotations
import asyncio
import time
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from api.config import settings
from api.schemas.ops_run import OpsRunProjection, OpsRunProjectionItem
_cache: OpsRunProjection | None = None
_cache_at: float = 0.0
_lock = asyncio.Lock()
def reset_ops_run_projection_cache() -> None:
global _cache, _cache_at
_cache = None
_cache_at = 0.0
def _parse_datetime(value: Any) -> datetime | None:
if not value or not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
async def _fetch_ops_runs() -> dict[str, Any]:
if not settings.activity_core_url:
raise RuntimeError("ACTIVITY_CORE_URL is not configured")
headers = {}
if settings.activity_core_worker_token:
headers["X-Worker-Token"] = settings.activity_core_worker_token
async with httpx.AsyncClient(
base_url=settings.activity_core_url.rstrip("/"),
timeout=10.0,
follow_redirects=True,
trust_env=False,
headers=headers,
) as client:
response = await client.get("/ops-runs", params={"limit": 200})
response.raise_for_status()
return response.json()
def _project(payload: dict[str, Any], now: datetime) -> OpsRunProjection:
raw_items = payload.get("items") or []
counts = payload.get("counts") or {}
failed_cutoff = now - timedelta(hours=24)
stuck_cutoff = now - timedelta(hours=max(0.1, settings.ops_run_sla_hours))
failed_24h = 0
stuck = 0
items: list[OpsRunProjectionItem] = []
for raw in raw_items:
state = str(raw.get("state") or "unknown")
updated_at = _parse_datetime(raw.get("updated_at"))
created_at = _parse_datetime(raw.get("created_at"))
if state == "failed" and updated_at and updated_at >= failed_cutoff:
failed_24h += 1
if state in {"open", "claimed"} and (created_at or updated_at):
if (created_at or updated_at) < stuck_cutoff:
stuck += 1
result = raw.get("result") if isinstance(raw.get("result"), dict) else {}
last_error = result.get("error") or raw.get("last_error")
if state in {"open", "claimed", "failed"}:
items.append(OpsRunProjectionItem(
id=str(raw.get("id")),
definition=str(raw.get("activity_definition_id")) if raw.get("activity_definition_id") else None,
target_repo=raw.get("target_repo"),
state=state,
lease={
"owner": raw.get("claim_owner"),
"until": raw.get("lease_until"),
"attempt": raw.get("attempt", 0),
} if raw.get("claim_owner") or raw.get("lease_until") else None,
updated_at=updated_at,
last_error=str(last_error) if last_error else None,
))
items.sort(key=lambda item: item.updated_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
return OpsRunProjection(
available=True,
generated_at=now,
open=int(counts.get("open", sum(item.state == "open" for item in items))),
claimed=int(counts.get("claimed", sum(item.state == "claimed" for item in items))),
failed_24h=failed_24h,
stuck_open_or_claimed=stuck,
items=items[:50],
)
async def get_ops_run_projection(*, refresh: bool = False) -> OpsRunProjection:
global _cache, _cache_at
now_mono = time.monotonic()
ttl = max(1.0, settings.ops_run_projection_ttl_seconds)
if not refresh and _cache is not None and now_mono - _cache_at < ttl:
return _cache
async with _lock:
now_mono = time.monotonic()
if not refresh and _cache is not None and now_mono - _cache_at < ttl:
return _cache
now = datetime.now(timezone.utc)
try:
projection = _project(await _fetch_ops_runs(), now)
except Exception as exc:
if _cache is not None and _cache.available:
projection = _cache.model_copy(update={
"stale": True,
"error": f"activity-core refresh failed: {exc}",
})
else:
projection = OpsRunProjection(
generated_at=now,
error=f"activity-core unavailable: {exc}",
)
_cache = projection
_cache_at = now_mono
return projection

View file

@ -0,0 +1,632 @@
from __future__ import annotations
import os
import re
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import yaml
from fastapi import HTTPException
from sqlalchemy import false, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.config import settings
from api.models.decision import Decision
from api.models.domain import Domain
from api.models.managed_repo import ManagedRepo
from api.models.progress_event import ProgressEvent
from api.models.task import Task, TaskStatus
from api.models.topic import Topic
from api.models.workplan import Workplan
from api.schemas.recently_on_scope import (
RecentlyOnScopeFailedDomain,
RecentlyOnScopeHourlyRun,
RecentlyOnScopeReportMetadata,
RecentlyOnScopeSourceCounts,
RecentlyOnScopeSkippedDomain,
)
from api.services.markitect_templates import inspect_markdown_template, render_markdown_template
_DURATION_RE = re.compile(r"^(?P<count>[1-9][0-9]*)(?P<unit>[mhd])$")
_REPORT_ID_RE = re.compile(r"^[0-9]{8}T[0-9]{6}Z--(?:[0-9]+[mhd]|[0-9]{8}T[0-9]{6}Z)$")
_TEMPLATE_PATH = Path("templates/recently-on-scope/domain-digest.md")
_TEMPLATE_VERSION = "1"
@dataclass(frozen=True)
class DigestWindow:
range: str
since: datetime
until: datetime
exact: bool = False
def parse_duration(value: str) -> timedelta:
match = _DURATION_RE.fullmatch(value.strip())
if match is None:
raise ValueError("range must use a duration such as 15m, 1h, 6h, or 1d")
count = int(match.group("count"))
unit = match.group("unit")
if unit == "m":
return timedelta(minutes=count)
if unit == "h":
return timedelta(hours=count)
return timedelta(days=count)
def resolve_window(
range_value: str = "1h",
since: datetime | None = None,
until: datetime | None = None,
*,
now: datetime | None = None,
) -> DigestWindow:
duration = parse_duration(range_value)
end = _as_utc(until or now or datetime.now(tz=UTC))
start = _as_utc(since) if since is not None else end - duration
if start >= end:
raise ValueError("since must be earlier than until")
return DigestWindow(range=range_value, since=start, until=end, exact=since is not None or until is not None)
def report_root() -> Path:
root = Path(settings.state_hub_report_dir).expanduser()
if not root.is_absolute():
root = Path.cwd() / root
return root
def template_path() -> Path:
path = _TEMPLATE_PATH
if not path.is_absolute():
path = Path.cwd() / path
return path
def report_id_for(window: DigestWindow) -> str:
if window.exact:
return f"{_stamp(window.since)}--{_stamp(window.until)}"
return f"{_stamp(window.until)}--{window.range}"
def report_path_for(domain_slug: str, window: DigestWindow) -> Path:
return report_root() / domain_slug / f"{report_id_for(window)}.md"
async def collect_domain_activity(
session: AsyncSession,
domain_slug: str,
window: DigestWindow,
) -> dict[str, Any]:
domain = await _get_domain(domain_slug, session)
topics = await _list_topics(domain.id, session)
topic_ids = [topic.id for topic in topics]
workstreams = await _list_workstreams(topic_ids, session)
workstream_ids = [workstream.id for workstream in workstreams]
tasks = await _list_tasks(workstream_ids, session)
task_ids = [task.id for task in tasks]
decisions = await _list_recent_decisions(topic_ids, workstream_ids, window, session)
decision_ids = [decision.id for decision in decisions]
progress_events = await _list_recent_progress(topic_ids, workstream_ids, task_ids, decision_ids, window, session)
repos = await _list_repos(domain.id, session)
recent_workstreams = [
workstream for workstream in workstreams
if _in_window(workstream.created_at, window) or _in_window(workstream.updated_at, window)
]
recent_tasks = [
task for task in tasks
if _in_window(task.created_at, window) or _in_window(task.updated_at, window)
]
attention_tasks = [
task for task in tasks
if task.needs_human and _enum_value(task.status) not in {TaskStatus.done.value, TaskStatus.cancel.value}
]
data = {
"domain": _domain_data(domain),
"window": {
"range": window.range,
"since": _iso(window.since),
"until": _iso(window.until),
},
"generated_at": _iso(datetime.now(tz=UTC)),
"template_version": _TEMPLATE_VERSION,
"source_counts": {
"progress_events": len(progress_events),
"decisions": len(decisions),
"workstreams": len(recent_workstreams),
"tasks": len(recent_tasks),
"repos": len(repos),
"attention_items": len(attention_tasks),
},
"progress_events": [_progress_data(event) for event in progress_events],
"decisions": [_decision_data(decision) for decision in decisions],
"workstreams": [_workstream_data(workstream) for workstream in recent_workstreams],
"tasks": [_task_data(task) for task in recent_tasks],
"repos": [_repo_data(repo) for repo in repos],
"attention_items": [_attention_task_data(task) for task in attention_tasks],
}
data |= _section_text(data)
return data
async def generate_report(
session: AsyncSession,
domain_slug: str,
window: DigestWindow,
) -> tuple[RecentlyOnScopeReportMetadata, str]:
data = await collect_domain_activity(session, domain_slug, window)
return _render_report_from_data(data, window)
async def generate_hourly_reports(
session: AsyncSession,
window: DigestWindow,
*,
active_only: bool = True,
include_attention: bool = False,
) -> RecentlyOnScopeHourlyRun:
generated: list[RecentlyOnScopeReportMetadata] = []
skipped: list[RecentlyOnScopeSkippedDomain] = []
failed: list[RecentlyOnScopeFailedDomain] = []
domains = await _list_domains(session, active_only=active_only)
for domain in domains:
try:
data = await collect_domain_activity(session, domain.slug, window)
counts = RecentlyOnScopeSourceCounts(**data["source_counts"])
if not _has_qualifying_activity(counts, include_attention=include_attention):
skipped.append(
RecentlyOnScopeSkippedDomain(
domain_slug=domain.slug,
reason="no qualifying activity in window",
source_counts=counts,
)
)
continue
metadata, _markdown = _render_report_from_data(data, window)
generated.append(metadata)
except Exception as exc: # pragma: no cover - exercised via router tests
failed.append(RecentlyOnScopeFailedDomain(domain_slug=domain.slug, error=str(exc)))
generated_at = datetime.now(tz=UTC)
progress_event_id = await _log_hourly_progress(
session,
window,
generated_at=generated_at,
active_only=active_only,
include_attention=include_attention,
generated=generated,
skipped=skipped,
failed=failed,
)
return RecentlyOnScopeHourlyRun(
range=window.range,
since=window.since,
until=window.until,
generated_at=generated_at,
active_only=active_only,
include_attention=include_attention,
generated=generated,
skipped=skipped,
failed=failed,
progress_event_id=progress_event_id,
)
def _render_report_from_data(
data: dict[str, Any],
window: DigestWindow,
) -> tuple[RecentlyOnScopeReportMetadata, str]:
tmpl = template_path()
inspect_markdown_template(tmpl)
markdown = render_markdown_template(tmpl, data)
path = report_path_for(data["domain"]["slug"], window)
_write_report(path, markdown)
return _metadata_from_data(report_id_for(window), path, data, window), markdown
def list_reports(domain_slug: str) -> list[RecentlyOnScopeReportMetadata]:
directory = report_root() / domain_slug
if not directory.is_dir():
return []
reports = []
for path in sorted(directory.glob("*.md"), reverse=True):
metadata = metadata_from_report(path)
if metadata is not None:
reports.append(metadata)
return reports
def read_report(domain_slug: str, report_id: str) -> str:
if _REPORT_ID_RE.fullmatch(report_id) is None:
raise FileNotFoundError(report_id)
path = report_root() / domain_slug / f"{report_id}.md"
if not path.is_file():
raise FileNotFoundError(report_id)
return path.read_text(encoding="utf-8")
def metadata_from_report(path: Path) -> RecentlyOnScopeReportMetadata | None:
text = path.read_text(encoding="utf-8")
frontmatter = _frontmatter(text)
if not frontmatter:
return None
source_counts = frontmatter.get("source_counts") or {}
try:
return RecentlyOnScopeReportMetadata(
id=path.stem,
domain_slug=str(frontmatter["domain_slug"]),
range=str(frontmatter["range"]),
since=_as_utc(frontmatter["since"]),
until=_as_utc(frontmatter["until"]),
generated_at=_as_utc(frontmatter["generated_at"]),
path=str(path),
source_counts=RecentlyOnScopeSourceCounts(**source_counts),
)
except (KeyError, TypeError, ValueError):
return None
async def _list_domains(session: AsyncSession, *, active_only: bool) -> list[Domain]:
stmt = select(Domain).order_by(Domain.slug)
if active_only:
stmt = stmt.where(Domain.status == "active")
result = await session.execute(stmt)
return list(result.scalars().all())
def _has_qualifying_activity(
counts: RecentlyOnScopeSourceCounts,
*,
include_attention: bool,
) -> bool:
if (
counts.progress_events
or counts.decisions
or counts.workstreams
or counts.tasks
):
return True
return include_attention and counts.attention_items > 0
async def _log_hourly_progress(
session: AsyncSession,
window: DigestWindow,
*,
generated_at: datetime,
active_only: bool,
include_attention: bool,
generated: list[RecentlyOnScopeReportMetadata],
skipped: list[RecentlyOnScopeSkippedDomain],
failed: list[RecentlyOnScopeFailedDomain],
) -> uuid.UUID:
event = ProgressEvent(
event_type="recently_on_scope_hourly",
summary=(
"RecentlyOnScope hourly batch completed: "
f"{len(generated)} generated, {len(skipped)} skipped, {len(failed)} failed"
),
detail={
"range": window.range,
"since": _iso(window.since),
"until": _iso(window.until),
"generated_at": _iso(generated_at),
"active_only": active_only,
"include_attention": include_attention,
"generated": [item.model_dump(mode="json") for item in generated],
"skipped": [item.model_dump(mode="json") for item in skipped],
"failed": [item.model_dump(mode="json") for item in failed],
},
author="state-hub",
)
session.add(event)
await session.commit()
await session.refresh(event)
return event.id
async def _get_domain(domain_slug: str, session: AsyncSession) -> Domain:
result = await session.execute(select(Domain).where(Domain.slug == domain_slug))
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{domain_slug}' not found")
return domain
async def _list_topics(domain_id: uuid.UUID, session: AsyncSession) -> list[Topic]:
result = await session.execute(select(Topic).where(Topic.domain_id == domain_id).order_by(Topic.slug))
return list(result.scalars().all())
async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -> list[Workplan]:
result = await session.execute(
select(Workplan)
.where(_in(Workplan.topic_id, topic_ids))
.order_by(Workplan.updated_at.desc(), Workplan.created_at.desc())
)
return list(result.scalars().all())
async def _list_tasks(workstream_ids: list[uuid.UUID], session: AsyncSession) -> list[Task]:
result = await session.execute(
select(Task)
.where(_in(Task.workplan_id, workstream_ids))
.order_by(Task.updated_at.desc(), Task.created_at.desc())
)
return list(result.scalars().all())
async def _list_recent_decisions(
topic_ids: list[uuid.UUID],
workstream_ids: list[uuid.UUID],
window: DigestWindow,
session: AsyncSession,
) -> list[Decision]:
result = await session.execute(
select(Decision)
.where(or_(_in(Decision.topic_id, topic_ids), _in(Decision.workplan_id, workstream_ids)))
.where(
or_(
_between(Decision.created_at, window),
_between(Decision.updated_at, window),
_between(Decision.decided_at, window),
)
)
.order_by(Decision.updated_at.desc(), Decision.created_at.desc())
)
return list(result.scalars().all())
async def _list_recent_progress(
topic_ids: list[uuid.UUID],
workstream_ids: list[uuid.UUID],
task_ids: list[uuid.UUID],
decision_ids: list[uuid.UUID],
window: DigestWindow,
session: AsyncSession,
) -> list[ProgressEvent]:
result = await session.execute(
select(ProgressEvent)
.where(_between(ProgressEvent.created_at, window))
.where(
or_(
_in(ProgressEvent.topic_id, topic_ids),
_in(ProgressEvent.workplan_id, workstream_ids),
_in(ProgressEvent.task_id, task_ids),
_in(ProgressEvent.decision_id, decision_ids),
)
)
.order_by(ProgressEvent.created_at.desc())
)
return list(result.scalars().all())
async def _list_repos(domain_id: uuid.UUID, session: AsyncSession) -> list[ManagedRepo]:
result = await session.execute(
select(ManagedRepo)
.where(ManagedRepo.domain_id == domain_id)
.where(ManagedRepo.status == "active")
.order_by(ManagedRepo.slug)
)
return list(result.scalars().all())
def _in(column, values: list[uuid.UUID]):
return column.in_(values) if values else false()
def _between(column, window: DigestWindow):
return column.is_not(None) & (column >= window.since) & (column <= window.until)
def _in_window(value: datetime | None, window: DigestWindow) -> bool:
if value is None:
return False
value = _as_utc(value)
return window.since <= value <= window.until
def _metadata_from_data(
report_id: str,
path: Path,
data: dict[str, Any],
window: DigestWindow,
) -> RecentlyOnScopeReportMetadata:
return RecentlyOnScopeReportMetadata(
id=report_id,
domain_slug=data["domain"]["slug"],
range=window.range,
since=window.since,
until=window.until,
generated_at=_as_utc(data["generated_at"]),
path=str(path),
source_counts=RecentlyOnScopeSourceCounts(**data["source_counts"]),
)
def _write_report(path: Path, markdown: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f".{path.name}.tmp")
tmp.write_text(markdown, encoding="utf-8")
os.replace(tmp, path)
def _frontmatter(text: str) -> dict[str, Any] | None:
if not text.startswith("---\n"):
return None
end = text.find("\n---", 4)
if end == -1:
return None
data = yaml.safe_load(text[4:end].strip()) or {}
return data if isinstance(data, dict) else None
def _section_text(data: dict[str, Any]) -> dict[str, str]:
return {
"progress_section": _progress_section(data["progress_events"]),
"decisions_section": _decisions_section(data["decisions"]),
"workstreams_section": _workstreams_section(data["workstreams"]),
"tasks_section": _tasks_section(data["tasks"]),
"attention_section": _attention_section(data["attention_items"]),
"repos_section": _repos_section(data["repos"]),
}
def _progress_section(items: list[dict[str, Any]]) -> str:
if not items:
return "_No progress events in this window._"
return "\n".join(
f"- `{item['created_at']}` `{item['event_type']}` {item['summary']}"
+ (f" - {item['author']}" if item.get("author") else "")
for item in items
)
def _decisions_section(items: list[dict[str, Any]]) -> str:
if not items:
return "_No decisions changed in this window._"
return "\n".join(
f"- `{item['updated_at']}` **{item['title']}** - {item['status']}"
+ (f"; decided by {item['decided_by']}" if item.get("decided_by") else "")
for item in items
)
def _workstreams_section(items: list[dict[str, Any]]) -> str:
if not items:
return "_No workstreams changed in this window._"
return "\n".join(
f"- `{item['updated_at']}` **{item['title']}** (`{item['slug']}`) - {item['status']}"
for item in items
)
def _tasks_section(items: list[dict[str, Any]]) -> str:
if not items:
return "_No tasks changed in this window._"
return "\n".join(
f"- `{item['updated_at']}` **{item['title']}** - {item['status']} / {item['priority']}"
for item in items
)
def _attention_section(items: list[dict[str, Any]]) -> str:
if not items:
return "_No open human-intervention items for this domain._"
return "\n".join(
f"- **{item['title']}** - {item.get('intervention_note') or item.get('blocking_reason') or 'needs human attention'}"
for item in items
)
def _repos_section(items: list[dict[str, Any]]) -> str:
if not items:
return "_No active repositories are registered for this domain._"
return "\n".join(
f"- `{item['slug']}` - {item['name']}" + (f" ({item['remote_url']})" if item.get("remote_url") else "")
for item in items
)
def _domain_data(domain: Domain) -> dict[str, Any]:
return {
"id": str(domain.id),
"slug": domain.slug,
"name": domain.name,
"description": domain.description or "",
}
def _progress_data(event: ProgressEvent) -> dict[str, Any]:
return {
"id": str(event.id),
"created_at": _iso(event.created_at),
"event_type": event.event_type,
"summary": event.summary,
"author": event.author,
"workplan_id": str(event.workplan_id) if event.workplan_id else None,
"workstream_id": str(event.workplan_id) if event.workplan_id else None,
"task_id": str(event.task_id) if event.task_id else None,
"decision_id": str(event.decision_id) if event.decision_id else None,
}
def _decision_data(decision: Decision) -> dict[str, Any]:
return {
"id": str(decision.id),
"title": decision.title,
"status": _enum_value(decision.status),
"decision_type": _enum_value(decision.decision_type),
"decided_by": decision.decided_by,
"decided_at": _iso(decision.decided_at) if decision.decided_at else "",
"created_at": _iso(decision.created_at),
"updated_at": _iso(decision.updated_at),
}
def _workstream_data(workstream: Workplan) -> dict[str, Any]:
return {
"id": str(workstream.id),
"slug": workstream.slug,
"title": workstream.title,
"status": workstream.status,
"owner": workstream.owner or "",
"created_at": _iso(workstream.created_at),
"updated_at": _iso(workstream.updated_at),
}
def _task_data(task: Task) -> dict[str, Any]:
return {
"id": str(task.id),
"workstream_id": str(task.workplan_id),
"title": task.title,
"status": _enum_value(task.status),
"priority": _enum_value(task.priority),
"assignee": task.assignee or "",
"blocking_reason": task.blocking_reason or "",
"created_at": _iso(task.created_at),
"updated_at": _iso(task.updated_at),
}
def _attention_task_data(task: Task) -> dict[str, Any]:
data = _task_data(task)
data["intervention_note"] = task.intervention_note or ""
return data
def _repo_data(repo: ManagedRepo) -> dict[str, Any]:
return {
"id": str(repo.id),
"slug": repo.slug,
"name": repo.name,
"remote_url": repo.remote_url or "",
"local_path": repo.local_path or "",
}
def _enum_value(value: Any) -> Any:
return getattr(value, "value", value)
def _as_utc(value: datetime | str) -> datetime:
if isinstance(value, str):
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def _iso(value: datetime) -> str:
return _as_utc(value).isoformat().replace("+00:00", "Z")
def _stamp(value: datetime) -> str:
return _as_utc(value).strftime("%Y%m%dT%H%M%SZ")

View file

@ -0,0 +1,152 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Any
from api.services.lifecycle import status_value
from api.task_status import CANONICAL_TASK_STATUSES
from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status
class ReconciliationClass(str, Enum):
WRITE_THROUGH = "write_through"
DEFERRED = "deferred"
HUMAN_CONFIRMATION = "human_confirmation"
@dataclass(frozen=True)
class StateChangeClassification:
reconciliation_class: ReconciliationClass
reason: str
follow_up: str
WRITE_THROUGH_WORKPLAN_STATUSES = {"proposed", "ready", "active", "backlog"}
TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
def classify_workplan_status_change(
*,
current_status: Any,
target_status: Any,
file_backed: bool,
archived_file: bool = False,
tasks_terminal: bool | None = None,
) -> StateChangeClassification:
"""Classify a UI-originated workstream status transition."""
current = normalize_workplan_status(current_status)
target = normalize_workplan_status(target_status)
if not file_backed:
return StateChangeClassification(
ReconciliationClass.DEFERRED,
"workstream is not backed by a local workplan file",
"create a reconciliation record for operator follow-up",
)
if archived_file:
return StateChangeClassification(
ReconciliationClass.HUMAN_CONFIRMATION,
"workplan file is archived",
"confirm whether to restore the file or leave the DB state unchanged",
)
if current == target:
return StateChangeClassification(
ReconciliationClass.WRITE_THROUGH,
"status is unchanged",
"no file update required",
)
if target in WRITE_THROUGH_WORKPLAN_STATUSES and current not in CLOSED_WORKPLAN_STATUSES:
return StateChangeClassification(
ReconciliationClass.WRITE_THROUGH,
"open lifecycle transition can be represented in workplan frontmatter",
"patch workplan status and sync the DB from file",
)
if target == "finished":
if tasks_terminal is True:
return StateChangeClassification(
ReconciliationClass.WRITE_THROUGH,
"all known tasks are terminal",
"patch workplan status to finished and sync the DB from file",
)
return StateChangeClassification(
ReconciliationClass.HUMAN_CONFIRMATION,
"finishing with non-terminal or unknown task state can hide open work",
"ask for confirmation or finish/cancel remaining tasks first",
)
if target == "archived":
return StateChangeClassification(
ReconciliationClass.HUMAN_CONFIRMATION,
"archiving may move files and hide active context",
"ask for confirmation and run the archive workflow",
)
if target == "blocked":
return StateChangeClassification(
ReconciliationClass.HUMAN_CONFIRMATION,
"blocked workstreams need an explicit blocker or dependency reason",
"capture the blocker before writing status",
)
return StateChangeClassification(
ReconciliationClass.DEFERRED,
f"unsupported workstream status transition {current!r} -> {target!r}",
"create a reconciliation record for operator review",
)
classify_workstream_status_change = classify_workplan_status_change
def classify_task_status_change(
*,
current_status: Any,
target_status: Any,
file_backed: bool,
task_linked: bool,
archived_file: bool = False,
blocking_reason: str | None = None,
) -> StateChangeClassification:
"""Classify a UI-originated task status transition."""
current = status_value(current_status)
target = status_value(target_status)
if not file_backed:
return StateChangeClassification(
ReconciliationClass.DEFERRED,
"task belongs to a workstream without a local workplan file",
"create a reconciliation record for operator follow-up",
)
if archived_file:
return StateChangeClassification(
ReconciliationClass.HUMAN_CONFIRMATION,
"task belongs to an archived workplan file",
"confirm whether to restore or edit the archived file",
)
if not task_linked:
return StateChangeClassification(
ReconciliationClass.DEFERRED,
"task is not linked to a task block in the workplan file",
"create or repair the task block link before writing status",
)
if current == target:
return StateChangeClassification(
ReconciliationClass.WRITE_THROUGH,
"status is unchanged",
"no file update required",
)
if target == "wait" and not (blocking_reason or "").strip():
return StateChangeClassification(
ReconciliationClass.HUMAN_CONFIRMATION,
"waiting tasks should explain the wait condition",
"capture the wait reason before writing status",
)
if target in TASK_STATUSES:
return StateChangeClassification(
ReconciliationClass.WRITE_THROUGH,
"task status can be represented in the workplan task block",
"patch task block status and sync the DB from file",
)
return StateChangeClassification(
ReconciliationClass.DEFERRED,
f"unsupported task status transition {current!r} -> {target!r}",
"create a reconciliation record for operator review",
)

View file

@ -0,0 +1,544 @@
"""State Hub dual-run adapter → Repo Manager (RMGR-WP-0002/0003 Stage B).
Config file (same as repo-manager): ~/.repo-manager/dual-run.yaml
or RM_DUAL_RUN_CONFIG. Env overrides when set:
RM_WRITEBACK, RM_RECONCILE, RM_WRITEBACK_PUSH, RM_PILOT_REPOS, RM_METER_PATH
Rollback: disable in config / unset env → native State Hub path.
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import sys
import uuid
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
logger = logging.getLogger(__name__)
_TRUE = frozenset({"1", "true", "yes", "on"})
_FALSE = frozenset({"0", "false", "no", "off"})
def config_path() -> Path:
raw = os.environ.get("RM_DUAL_RUN_CONFIG", "~/.repo-manager/dual-run.yaml")
return Path(raw).expanduser()
@lru_cache(maxsize=1)
def _load_file_config() -> dict[str, Any]:
path = config_path()
if not path.is_file():
return {}
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError):
return {}
return data if isinstance(data, dict) else {}
def reload_config() -> None:
_load_file_config.cache_clear()
def _env_bool(name: str) -> bool | None:
raw = os.environ.get(name)
if raw is None or raw.strip() == "":
return None
v = raw.strip().lower()
if v in _TRUE:
return True
if v in _FALSE:
return False
return None
def _cfg_bool(key: str, env_name: str, default: bool = False) -> bool:
ev = _env_bool(env_name)
if ev is not None:
return ev
cfg = _load_file_config()
if key in cfg:
val = cfg[key]
if isinstance(val, bool):
return val
if isinstance(val, str):
return val.strip().lower() in _TRUE
return default
def writeback_enabled() -> bool:
return _cfg_bool("writeback", "RM_WRITEBACK", False)
def reconcile_enabled() -> bool:
return _cfg_bool("reconcile", "RM_RECONCILE", False)
def writeback_push_enabled() -> bool:
return _cfg_bool("writeback_push", "RM_WRITEBACK_PUSH", False)
def pilot_slugs() -> set[str] | None:
raw = os.environ.get("RM_PILOT_REPOS")
if raw is not None:
raw = raw.strip()
if not raw:
return set()
return {s.strip() for s in raw.split(",") if s.strip()}
cfg = _load_file_config()
if "pilot_repos" not in cfg:
return None
val = cfg["pilot_repos"]
if val is None:
return None
if isinstance(val, str):
if not val.strip():
return set()
return {s.strip() for s in val.split(",") if s.strip()}
if isinstance(val, list):
return {str(s).strip() for s in val if str(s).strip()}
return None
def slug_allowed(slug: str | None) -> bool:
if not slug:
return False
pilots = pilot_slugs()
if pilots is None:
return True
return slug in pilots
def writeback_for_repo(slug: str | None) -> bool:
return writeback_enabled() and slug_allowed(slug)
def reconcile_for_repo(slug: str | None) -> bool:
return reconcile_enabled() and slug_allowed(slug)
def meter_path() -> Path:
raw = os.environ.get("RM_METER_PATH")
if raw:
return Path(raw).expanduser()
cfg = _load_file_config()
if cfg.get("meter_path"):
return Path(str(cfg["meter_path"])).expanduser()
return Path("~/.repo-manager/mutation-meter.jsonl").expanduser()
def record_mutation(
*,
source: str,
kind: str,
repo_slug: str | None,
detail: dict[str, Any] | None = None,
) -> None:
try:
from datetime import UTC, datetime
path = meter_path()
path.parent.mkdir(parents=True, exist_ok=True)
row = {
"ts": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
"source": source,
"kind": kind,
"repo_slug": repo_slug,
"detail": detail or {},
}
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(row, default=str) + "\n")
except OSError:
pass
def run_rmgr(args: list[str], *, timeout: int = 120) -> tuple[int, str, str]:
env = os.environ.copy()
custom = os.environ.get("RMGR_BIN", "").strip()
if custom:
cmd = custom.split() + args
else:
src = os.environ.get("REPO_MANAGER_SRC", "").strip()
sibling = Path(src) if src else (Path.home() / "repo-manager" / "src")
if not sibling.is_dir() and (Path.home() / "repo-manager" / "src").is_dir():
sibling = Path.home() / "repo-manager" / "src"
if sibling.is_dir():
env["PYTHONPATH"] = str(sibling) + (
os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else ""
)
cmd = [
sys.executable,
"-c",
"from repo_manager.cli import main; import sys; raise SystemExit(main(sys.argv[1:]))",
*args,
]
else:
cmd = ["rmgr", *args]
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
env=env,
)
return proc.returncode, proc.stdout, proc.stderr
def rm_update_task_status(
*,
repo_path: str | Path,
task_id: str,
status: str,
repo_slug: str | None = None,
reason: str = "state-hub dual-run",
push: bool | None = None,
correlation_id: str | None = None,
) -> dict[str, Any]:
correlation_id = correlation_id or str(uuid.uuid4())
if push is None:
push = writeback_push_enabled()
args = [
"update-task-status",
"--path",
str(repo_path),
"--task-id",
str(task_id),
"--status",
status,
"--reason",
reason,
"--correlation-id",
correlation_id,
"--idempotency-key",
f"sh-dual-{task_id}-{status}-{correlation_id}",
]
if repo_slug:
args.extend(["--slug", repo_slug])
if push:
args.append("--push")
code, out, err = run_rmgr(args)
try:
result = json.loads(out.strip() or "{}")
except json.JSONDecodeError:
result = {
"status": "failed",
"error": {
"code": "internal",
"message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
},
"correlation_id": correlation_id,
}
if code != 0 and result.get("status") not in ("applied", "rejected"):
result.setdefault("status", "failed")
result.setdefault(
"error",
{"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"},
)
return result
def rm_update_workplan(
*,
repo_path: str | Path,
workplan_id: str,
operation: str = "update",
title: str | None = None,
goal: str | None = None,
status: str | None = None,
owner: str | None = None,
domain: str | None = None,
topic_slug: str | None = None,
repo_slug: str | None = None,
reason: str = "state-hub dual-run",
push: bool | None = None,
correlation_id: str | None = None,
confirm_archive: bool = False,
) -> dict[str, Any]:
"""Delegate a file-backed workplan mutation to Repo Manager.
``operation=archive`` is the recoverable counterpart of State Hub's
DELETE workplan route; it never erases repository history.
"""
correlation_id = correlation_id or str(uuid.uuid4())
if operation not in {"create", "update", "archive"}:
return {
"status": "rejected",
"correlation_id": correlation_id,
"error": {"code": "validation_error", "message": f"invalid operation {operation!r}"},
}
if push is None:
push = writeback_push_enabled()
cli_operation = "delete" if operation == "archive" else operation
args = [
"workplan",
cli_operation,
"--path",
str(repo_path),
"--workplan-id",
str(workplan_id),
"--reason",
reason,
"--correlation-id",
correlation_id,
"--idempotency-key",
f"sh-dual-workplan-{operation}-{workplan_id}-{correlation_id}",
]
for flag, value in (
("--title", title),
("--goal", goal),
("--status", status),
("--owner", owner),
("--domain", domain),
("--topic-slug", topic_slug),
("--slug", repo_slug),
):
if value is not None:
args.extend([flag, value])
if operation == "archive" and confirm_archive:
args.append("--confirm")
if push:
args.append("--push")
code, out, err = run_rmgr(args)
try:
result = json.loads(out.strip() or "{}")
except json.JSONDecodeError:
result = {
"status": "failed",
"error": {
"code": "internal",
"message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
},
"correlation_id": correlation_id,
}
if code != 0 and result.get("status") not in ("applied", "rejected"):
result.setdefault("status", "failed")
result.setdefault(
"error",
{"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"},
)
return result
def rm_update_register_entry(
*,
repo_path: str | Path,
kind: str,
entry_id: str,
operation: str = "put",
title: str | None = None,
status: str | None = None,
data: dict[str, Any] | None = None,
note: str | None = None,
author: str | None = None,
repo_slug: str | None = None,
reason: str = "state-hub retirement adapter",
push: bool | None = None,
correlation_id: str | None = None,
) -> dict[str, Any]:
"""Delegate register create/update/defer/note to Repo Manager."""
correlation_id = correlation_id or str(uuid.uuid4())
if operation not in {"put", "defer", "note"}:
return {
"status": "rejected",
"correlation_id": correlation_id,
"error": {"code": "validation_error", "message": f"invalid operation {operation!r}"},
}
if push is None:
push = writeback_push_enabled()
args = [
"register",
operation,
"--path",
str(repo_path),
"--kind",
kind,
"--entry-id",
entry_id,
"--reason",
reason,
"--correlation-id",
correlation_id,
"--idempotency-key",
f"sh-dual-register-{operation}-{kind}-{entry_id}-{correlation_id}",
]
for flag, value in (
("--title", title),
("--status", status),
("--note", note),
("--author", author),
("--slug", repo_slug),
):
if value is not None:
args.extend([flag, value])
if data is not None:
args.extend(["--data-json", json.dumps(data, separators=(",", ":"))])
if push:
args.append("--push")
code, out, err = run_rmgr(args)
try:
result = json.loads(out.strip() or "{}")
except json.JSONDecodeError:
result = {
"status": "failed",
"error": {
"code": "internal",
"message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
},
"correlation_id": correlation_id,
}
if code != 0 and result.get("status") not in ("applied", "rejected"):
result.setdefault("status", "failed")
result.setdefault(
"error",
{"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"},
)
return result
def rm_scan_sbom(
*,
repo_path: str | Path,
repo_slug: str | None = None,
) -> dict[str, Any]:
"""Derive a versioned SBOM snapshot from repository-owned sources."""
args = ["sbom", "scan", "--path", str(repo_path)]
if repo_slug:
args.extend(["--slug", repo_slug])
code, out, err = run_rmgr(args)
try:
result = json.loads(out.strip() or "{}")
except json.JSONDecodeError:
result = {
"ok": False,
"error": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
}
if code != 0:
result.setdefault("ok", False)
result.setdefault("error", f"rmgr exit={code} stderr={err!r}")
result["exit_code"] = code
return result
def rm_scaffold(
*,
repo_path: str | Path,
flavor: str,
wp_prefix: str | None = None,
slug: str | None = None,
domain: str = "infotech",
force: bool = False,
commit: bool = False,
) -> dict[str, Any]:
"""Delegate repository scaffolding to ``rmgr scaffold`` (STATE-WP-0080-T02)."""
args = [
"scaffold",
"--path",
str(repo_path),
"--flavor",
flavor,
"--domain",
domain,
]
if slug:
args.extend(["--slug", slug])
if wp_prefix:
args.extend(["--wp-prefix", wp_prefix])
if force:
args.append("--force")
if not commit:
args.append("--no-commit")
code, out, err = run_rmgr(args)
try:
result = json.loads(out.strip() or "{}")
except json.JSONDecodeError:
result = {
"status": "failed",
"error": {
"code": "internal",
"message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}",
},
}
if code != 0 and result.get("status") not in ("applied", "rejected"):
result.setdefault("status", "failed")
result.setdefault(
"error",
{"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"},
)
result["exit_code"] = code
return result
def rm_reconcile(*, repo_path: str | Path, repo_slug: str | None = None) -> dict[str, Any]:
args = ["reconcile", "--path", str(repo_path)]
if repo_slug:
args.extend(["--slug", repo_slug])
code, out, err = run_rmgr(args)
try:
result = json.loads(out.strip() or "{}")
except json.JSONDecodeError:
result = {"ok": False, "error": err or out, "exit_code": code}
result["exit_code"] = code
return result
def try_writeback_for_task(
*,
repo_path: str | Path | None,
repo_slug: str | None,
task_id: str,
status: str,
reason: str = "state-hub dual-run PATCH /tasks",
) -> dict[str, Any] | None:
if not writeback_for_repo(repo_slug):
return None
if not repo_path:
logger.warning("RM dual-run writeback skipped: no repo_path for %s", repo_slug)
return None
path = Path(repo_path)
if not path.is_dir():
logger.warning("RM dual-run writeback skipped: missing path %s", path)
return None
result = rm_update_task_status(
repo_path=path,
task_id=task_id,
status=status,
repo_slug=repo_slug,
reason=reason,
push=writeback_push_enabled(),
)
if result.get("status") == "applied":
record_mutation(
source="repo-manager",
kind="task_status_writeback_via_sh_facade",
repo_slug=repo_slug,
detail={
"task_id": task_id,
"status": status,
"git_sha": (result.get("evidence") or {}).get("git_sha"),
"push_ok": (result.get("evidence") or {}).get("push_ok"),
},
)
else:
logger.warning(
"RM dual-run writeback failed for %s task=%s: %s",
repo_slug,
task_id,
result.get("error") or result,
)
return result

View file

@ -0,0 +1,229 @@
"""Canonical repository identity resolution across current and prior slugs.
The slug registry is the lookup boundary. Historical records deliberately keep
the slug they recorded; callers use ``slug_values`` when they need an identity-
wide read and ``canonical_slug`` when they create a new reference.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from fastapi import HTTPException
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload
from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode
from api.models.managed_repo import ManagedRepo
from api.models.repository_rename import RepositorySlug
@dataclass(frozen=True)
class RepositorySlugResolution:
repo: ManagedRepo
requested_slug: str
canonical_slug: str
slug_status: str
aliases: tuple[str, ...]
source_operation_id: uuid.UUID | None = None
@property
def slug_values(self) -> tuple[str, ...]:
return (self.canonical_slug, *self.aliases)
async def resolve_repository_slug(
session: AsyncSession,
slug: str,
*,
required: bool = True,
) -> RepositorySlugResolution | None:
"""Resolve a current or protected prior slug to one repository UUID."""
record = (
await session.execute(
select(RepositorySlug).where(RepositorySlug.slug == slug)
)
).scalar_one_or_none()
# Compatibility for databases upgraded before the identity backfill has run.
if record is None:
repo = (
await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
).scalar_one_or_none()
if repo is None:
if required:
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
return None
return RepositorySlugResolution(
repo=repo,
requested_slug=slug,
canonical_slug=repo.slug,
slug_status="canonical",
aliases=(),
)
repo = await session.get(ManagedRepo, record.repo_id)
if repo is None:
raise HTTPException(
status_code=409,
detail=f"Repository slug registry entry '{slug}' has no repository",
)
records = list(
(
await session.execute(
select(RepositorySlug)
.where(RepositorySlug.repo_id == repo.id)
.order_by(RepositorySlug.kind.desc(), RepositorySlug.slug)
)
).scalars()
)
canonicals = [item for item in records if item.kind == "canonical"]
if len(canonicals) != 1 or canonicals[0].slug != repo.slug:
raise HTTPException(
status_code=409,
detail=f"Repository '{repo.id}' has inconsistent canonical slug state",
)
return RepositorySlugResolution(
repo=repo,
requested_slug=slug,
canonical_slug=repo.slug,
slug_status=record.kind,
aliases=tuple(item.slug for item in records if item.kind == "alias"),
source_operation_id=record.source_operation_id,
)
async def repository_resolutions_for_repositories(
session: AsyncSession,
repositories: list[ManagedRepo],
) -> dict[uuid.UUID, RepositorySlugResolution]:
"""Resolve a repository collection with one slug-registry query.
Collection callers already hold canonical ``ManagedRepo`` rows. Resolving
every row through :func:`resolve_repository_slug` would issue two identity
queries per repository. Load the complete alias sets once instead while
retaining the pre-registry compatibility behavior.
"""
if not repositories:
return {}
repository_ids = [repository.id for repository in repositories]
records = list(
(
await session.execute(
select(RepositorySlug)
.options(
noload(RepositorySlug.repo),
noload(RepositorySlug.source_operation),
)
.where(RepositorySlug.repo_id.in_(repository_ids))
.order_by(RepositorySlug.repo_id, RepositorySlug.kind.desc(), RepositorySlug.slug)
)
).scalars()
)
records_by_repository: dict[uuid.UUID, list[RepositorySlug]] = {
repository_id: [] for repository_id in repository_ids
}
for record in records:
records_by_repository[record.repo_id].append(record)
resolutions: dict[uuid.UUID, RepositorySlugResolution] = {}
for repository in repositories:
repository_records = records_by_repository[repository.id]
requested_record = next(
(record for record in repository_records if record.slug == repository.slug),
None,
)
if requested_record is None:
# Compatibility for databases upgraded before the identity backfill.
resolutions[repository.id] = RepositorySlugResolution(
repo=repository,
requested_slug=repository.slug,
canonical_slug=repository.slug,
slug_status="canonical",
aliases=(),
)
continue
canonicals = [record for record in repository_records if record.kind == "canonical"]
if len(canonicals) != 1 or canonicals[0].slug != repository.slug:
raise HTTPException(
status_code=409,
detail=f"Repository '{repository.id}' has inconsistent canonical slug state",
)
resolutions[repository.id] = RepositorySlugResolution(
repo=repository,
requested_slug=repository.slug,
canonical_slug=repository.slug,
slug_status=requested_record.kind,
aliases=tuple(
record.slug for record in repository_records if record.kind == "alias"
),
source_operation_id=requested_record.source_operation_id,
)
return resolutions
async def canonicalize_repository_slug(session: AsyncSession, value: str) -> str:
"""Canonicalize a value only when it is a registered repository identity."""
resolution = await resolve_repository_slug(session, value, required=False)
return resolution.canonical_slug if resolution is not None else value
async def repository_resolution_for_id(
session: AsyncSession, repo: ManagedRepo
) -> RepositorySlugResolution:
return await resolve_repository_slug(session, repo.slug) # type: ignore[return-value]
async def stale_external_references(
session: AsyncSession,
resolution: RepositorySlugResolution,
) -> list[dict[str, object]]:
"""Name external projections that still contain an historical slug.
These are handoffs to the projection owner, never implicit rewrite targets.
"""
if not resolution.aliases:
return []
aliases = list(resolution.aliases)
checks = (
(FabricGraphImport, "source_repo_slug"),
(FabricGraphNode, "source_repo_slug"),
(FabricGraphNode, "repo_slug"),
(FabricGraphEdge, "source_repo_slug"),
)
stale: list[dict[str, object]] = []
for model, field_name in checks:
field = getattr(model, field_name)
rows = (
await session.execute(
select(field, func.count()).where(field.in_(aliases)).group_by(field)
)
).all()
for value, count in rows:
stale.append(
{
"owner": "railiance-fabric",
"surface": model.__tablename__,
"field": field_name,
"value": value,
"count": count,
"status": "stale",
"handoff": "owner update or re-ingest required",
}
)
return stale
def affected_slug_predicate(field: object, slugs: tuple[str, ...]):
"""Match a JSONB slug-list against any name in one repository lineage."""
return or_(*(field.contains([slug]) for slug in slugs))

View file

@ -0,0 +1,23 @@
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.managed_repo import ManagedRepo
from api.models.repository_rename import RepositoryForgeIdentity, RepositorySlug
def stage_initial_repository_identity(
session: AsyncSession, repo: ManagedRepo
) -> None:
"""Attach non-guessed identity state to a newly flushed repository row."""
if repo.id is None:
raise ValueError("repository must be flushed before identity records are staged")
session.add_all(
[
RepositoryForgeIdentity(repo_id=repo.id),
RepositorySlug(
repo_id=repo.id,
slug=repo.slug,
kind="canonical",
protected=True,
),
]
)

View file

@ -0,0 +1,336 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.review_contract import ReviewContract, ReviewReceipt
from api.schemas.review_contract import (
GateReviewState,
OwnerReviewState,
ReviewAggregateRead,
)
def canonical_json(value: Any) -> bytes:
return json.dumps(
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode()
def digest_document(value: Any) -> str:
return hashlib.sha256(canonical_json(value)).hexdigest()
def _assertion_id(text: str) -> str:
return "assertion:" + hashlib.sha256(text.encode()).hexdigest()[:20]
def normalize_contract(raw: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""Validate v1 or adapt the WP-0024 prototype without executing its checks."""
digest = digest_document(raw)
if raw.get("interface") == "railiance.owner-review" and raw.get("version") == 1:
owners_raw = raw.get("owners")
task_owners = raw.get("task_owners")
hashes = raw.get("artifact_sha256")
if (
not isinstance(owners_raw, dict)
or not owners_raw
or not isinstance(task_owners, dict)
or not task_owners
):
raise HTTPException(
422, "legacy contract requires non-empty owners and task_owners"
)
if not isinstance(hashes, dict):
raise HTTPException(422, "legacy contract requires artifact_sha256")
owners = []
for owner_id, scope in owners_raw.items():
artifacts = scope.get("artifacts") or []
assertions = scope.get("assertions") or []
checks = scope.get("checks") or []
if (
not artifacts
or not assertions
or not checks
or any(path not in hashes for path in artifacts)
):
raise HTTPException(422, f"{owner_id}: incomplete legacy owner scope")
owners.append(
{
"id": owner_id,
"artifact_ids": artifacts,
"assertions": [
{"id": _assertion_id(text), "statement": text}
for text in assertions
],
"check_ids": checks,
}
)
owner_ids = {item["id"] for item in owners}
gates = []
for gate_id, required in task_owners.items():
if (
not required
or len(required) != len(set(required))
or not set(required) <= owner_ids
):
raise HTTPException(422, f"{gate_id}: invalid legacy owner set")
gates.append({"id": gate_id, "policy": "all_required", "owners": required})
normalized = {
"schema_version": "review-contract/v1",
"contract_key": raw.get("workplan_id"),
"subject": {"kind": "workplan", "id": raw.get("workplan_id")},
"owners": owners,
"gates": gates,
"artifacts": {
path: {"algorithm": "sha256", "digest": value}
for path, value in hashes.items()
},
"allowed_dispositions": [
item.replace("-", "_") for item in (raw.get("decisions") or [])
],
"legacy": {
"interface": raw["interface"],
"version": 1,
"source_digest": digest,
},
}
return validate_v1(normalized), digest
if raw.get("schema_version") != "review-contract/v1":
raise HTTPException(422, "schema_version must be review-contract/v1")
return validate_v1(raw), digest
def validate_v1(doc: dict[str, Any]) -> dict[str, Any]:
contract_key, subject = doc.get("contract_key"), doc.get("subject")
owners, gates, artifacts = doc.get("owners"), doc.get("gates"), doc.get("artifacts")
dispositions = doc.get("allowed_dispositions")
if not isinstance(contract_key, str) or not contract_key:
raise HTTPException(422, "contract_key is required")
if (
not isinstance(subject, dict)
or not subject.get("kind")
or not subject.get("id")
):
raise HTTPException(422, "typed subject kind and id are required")
if (
not isinstance(owners, list)
or not owners
or not isinstance(gates, list)
or not gates
):
raise HTTPException(422, "non-empty owners and gates are required")
if not isinstance(artifacts, dict) or not artifacts:
raise HTTPException(422, "revision-pinned artifacts are required")
if (
set(dispositions or []) != {"approve", "request_changes"}
or len(dispositions) != 2
):
raise HTTPException(
422, "allowed_dispositions must contain approve and request_changes only"
)
owner_ids: set[str] = set()
for owner in owners:
if not isinstance(owner, dict):
raise HTTPException(422, "owner entries must be objects")
owner_id = owner.get("id")
artifact_ids = owner.get("artifact_ids") or []
assertions = owner.get("assertions") or []
checks = owner.get("check_ids") or []
assertion_ids = [
item.get("id") for item in assertions if isinstance(item, dict)
]
if (
not owner_id
or owner_id in owner_ids
or not artifact_ids
or not checks
or not assertion_ids
):
raise HTTPException(
422,
"each owner needs a unique id and non-empty artifact/assertion/check scope",
)
if len(assertion_ids) != len(set(assertion_ids)) or any(
path not in artifacts for path in artifact_ids
):
raise HTTPException(422, f"{owner_id}: invalid assertion or artifact scope")
owner_ids.add(owner_id)
for artifact_id, artifact in artifacts.items():
if (
not isinstance(artifact, dict)
or artifact.get("algorithm") != "sha256"
or not _is_sha256(artifact.get("digest"))
):
raise HTTPException(
422, f"{artifact_id}: only sha256 artifacts are supported"
)
gate_ids: set[str] = set()
for gate in gates:
if not isinstance(gate, dict):
raise HTTPException(422, "gate entries must be objects")
required = gate.get("owners") or []
if (
gate.get("policy") != "all_required"
or not gate.get("id")
or gate["id"] in gate_ids
):
raise HTTPException(
422, "v1 gates require unique ids and all_required policy"
)
if (
not required
or len(required) != len(set(required))
or not set(required) <= owner_ids
):
raise HTTPException(422, f"{gate.get('id')}: gate owner set is invalid")
gate_ids.add(gate["id"])
return doc
def _is_sha256(value: Any) -> bool:
return (
isinstance(value, str)
and len(value) == 64
and all(c in "0123456789abcdef" for c in value)
)
def owner_scope(contract: ReviewContract, owner_id: str) -> dict[str, Any]:
for owner in contract.document["owners"]:
if owner["id"] == owner_id:
return owner
raise HTTPException(422, f"unknown owner: {owner_id}")
def validate_receipt(contract: ReviewContract, body: Any) -> dict[str, Any]:
if body.contract_digest != contract.contract_digest:
raise HTTPException(
409, "contract digest mismatch; re-review the active contract"
)
scope = owner_scope(contract, body.owner_id)
if body.disposition == "request_changes":
if not body.note or not body.note.strip():
raise HTTPException(422, "request_changes requires a note")
else:
expected_artifacts = {
key: contract.document["artifacts"][key]["digest"]
for key in scope["artifact_ids"]
}
if body.artifact_hashes != expected_artifacts:
raise HTTPException(
422, "approval artifact hashes do not exactly match the owner scope"
)
expected_assertions = {item["id"] for item in scope["assertions"]}
if set(body.assertion_ids) != expected_assertions or len(
body.assertion_ids
) != len(expected_assertions):
raise HTTPException(
422, "approval assertions do not exactly match the owner scope"
)
expected_checks = set(scope["check_ids"])
actual_checks = {
item.get("id") for item in body.checks if isinstance(item, dict)
}
if actual_checks != expected_checks or len(body.checks) != len(expected_checks):
raise HTTPException(
422, "approval checks do not exactly match the owner scope"
)
if any(
item.get("passed") is not True or item.get("read_only") is not True
for item in body.checks
):
raise HTTPException(
422, "every approval check must be passed and explicitly read_only"
)
return {
"owner_id": body.owner_id,
"actor": body.actor,
"disposition": body.disposition,
"contract_digest": body.contract_digest,
"artifact_hashes": body.artifact_hashes,
"assertion_ids": body.assertion_ids,
"checks": body.checks,
"note": body.note,
"source": body.source.model_dump(),
}
async def active_contract(session: AsyncSession, contract_key: str) -> ReviewContract:
result = await session.execute(
select(ReviewContract).where(
ReviewContract.contract_key == contract_key, ReviewContract.active.is_(True)
)
)
contract = result.scalar_one_or_none()
if contract is None:
raise HTTPException(404, f"active review contract not found: {contract_key}")
return contract
async def aggregate(
session: AsyncSession, contract: ReviewContract
) -> ReviewAggregateRead:
rows = await session.execute(
select(ReviewReceipt)
.where(ReviewReceipt.contract_id == contract.id)
.order_by(ReviewReceipt.submitted_at.desc(), ReviewReceipt.id.desc())
)
latest: dict[str, ReviewReceipt] = {}
for receipt in rows.scalars():
latest.setdefault(receipt.owner_id, receipt)
prior = await session.execute(
select(ReviewReceipt.owner_id)
.join(ReviewContract)
.where(
ReviewContract.contract_key == contract.contract_key,
ReviewContract.id != contract.id,
)
.distinct()
)
stale_owners = set(prior.scalars())
owner_states, statuses = [], {}
for owner in contract.document["owners"]:
owner_id, receipt = owner["id"], latest.get(owner["id"])
state = (
receipt.disposition.replace("approve", "approved")
if receipt
else ("stale" if owner_id in stale_owners else "missing")
)
statuses[owner_id] = state
owner_states.append(
OwnerReviewState(
owner_id=owner_id,
status=state,
receipt_id=receipt.id if receipt else None,
receipt_digest=receipt.receipt_digest if receipt else None,
submitted_at=receipt.submitted_at if receipt else None,
)
)
gates = [
GateReviewState(
gate_id=gate["id"],
policy="all_required",
owners=gate["owners"],
satisfied=all(statuses[owner] == "approved" for owner in gate["owners"]),
)
for gate in contract.document["gates"]
]
return ReviewAggregateRead(
contract_key=contract.contract_key,
contract_digest=contract.contract_digest,
satisfied=all(gate.satisfied for gate in gates),
authorizes_execution=False,
owners=owner_states,
gates=gates,
)
def utc_now() -> datetime:
return datetime.now(timezone.utc)

View file

@ -0,0 +1,70 @@
"""Read-only client for the reversible SBOM Nexus compatibility facade."""
from __future__ import annotations
from typing import Any
import httpx
from api.config import settings
class SBOMNexusError(RuntimeError):
"""An SBOM Nexus request failed without a usable compatibility response."""
def __init__(self, status_code: int, detail: str):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
def reads_from_nexus() -> bool:
return settings.sbom_nexus_read_mode == "nexus"
def writes_to_nexus() -> bool:
return settings.sbom_nexus_write_mode == "nexus"
async def request_json(
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
) -> Any:
"""Call Nexus; never fall back silently to legacy storage."""
if not settings.sbom_nexus_url:
raise SBOMNexusError(503, "SBOM Nexus read mode is enabled without SBOM_NEXUS_URL")
try:
async with httpx.AsyncClient(
base_url=settings.sbom_nexus_url.rstrip("/"),
timeout=settings.sbom_nexus_timeout_seconds,
) as client:
response = await client.request(method, path, params=params, json=body)
except httpx.RequestError as exc:
raise SBOMNexusError(502, f"SBOM Nexus is unavailable: {exc.__class__.__name__}") from exc
if response.status_code >= 400:
detail = f"SBOM Nexus returned HTTP {response.status_code}"
try:
payload = response.json()
if isinstance(payload, dict) and isinstance(payload.get("detail"), str):
detail = payload["detail"]
except ValueError:
pass
status_code = 404 if response.status_code == 404 else 502
raise SBOMNexusError(status_code, detail)
try:
return response.json()
except ValueError as exc:
raise SBOMNexusError(502, "SBOM Nexus returned invalid JSON") from exc
async def get_json(path: str, *, params: dict[str, Any] | None = None) -> Any:
return await request_json("GET", path, params=params)
async def post_json(path: str, *, body: dict[str, Any]) -> Any:
return await request_json("POST", path, body=body)

View file

@ -0,0 +1,63 @@
"""Report whether the database schema matches the code (STATE-WP-0083-T07).
Central ran two revisions behind the code it was serving, and `review_contracts`
did not exist there although its migration shipped inside the running image.
Nothing surfaced that: the API starts happily against a schema it was not built
for, and only fails when a request happens to touch the missing table.
A hub that cannot say which schema it is running has the same problem as a
projection that cannot name its source commit — it is asserting correctness it
cannot demonstrate.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
_MIGRATIONS = Path(__file__).resolve().parents[2] / "migrations"
@lru_cache(maxsize=1)
def code_head_revision() -> str | None:
"""The head revision the shipped migration scripts define.
Read from the migration files rather than the database: this is what the
code expects, and it must be knowable without a working connection.
"""
try:
from alembic.config import Config
from alembic.script import ScriptDirectory
cfg = Config()
cfg.set_main_option("script_location", str(_MIGRATIONS))
heads = ScriptDirectory.from_config(cfg).get_heads()
return heads[0] if len(heads) == 1 else ",".join(sorted(heads)) or None
except Exception:
return None
async def db_revision(session: AsyncSession) -> str | None:
try:
result = await session.execute(text("select version_num from alembic_version"))
return result.scalar()
except Exception:
return None
async def schema_state(session: AsyncSession) -> dict[str, Any]:
applied = await db_revision(session)
expected = code_head_revision()
# "unknown" is deliberately not "ok": an instance that cannot determine its
# own schema state must not report agreement it has not established.
if applied is None or expected is None:
status = "unknown"
elif applied == expected:
status = "ok"
else:
status = "behind"
return {"status": status, "applied": applied, "expected": expected}

View file

@ -0,0 +1,118 @@
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.suggestion import (
OPEN_SUGGESTION_STAGES,
Suggestion,
SuggestionRelevanceBump,
SuggestionStage,
)
DEBOUNCE_SECONDS = 3600
async def bump_relevance(
session: AsyncSession,
suggestion: Suggestion,
*,
source: str,
source_key: str,
reason: str | None = None,
) -> bool:
"""Increment relevance when a suggestion is needed-but-unmet.
Returns True when the counter was incremented, False when debounced.
"""
if suggestion.stage not in OPEN_SUGGESTION_STAGES:
return False
cutoff = datetime.now(tz=timezone.utc) - timedelta(seconds=DEBOUNCE_SECONDS)
existing = (
await session.execute(
select(SuggestionRelevanceBump.id)
.where(SuggestionRelevanceBump.suggestion_id == suggestion.id)
.where(SuggestionRelevanceBump.source == source)
.where(SuggestionRelevanceBump.source_key == source_key)
.where(SuggestionRelevanceBump.created_at >= cutoff)
.limit(1)
)
).scalar_one_or_none()
if existing is not None:
return False
now = datetime.now(tz=timezone.utc)
suggestion.relevance += 1
suggestion.relevance_events += 1
suggestion.last_requested_at = now
session.add(
SuggestionRelevanceBump(
suggestion_id=suggestion.id,
source=source,
source_key=source_key,
reason=reason,
)
)
return True
async def bump_matching_for_capability_request(
session: AsyncSession,
*,
request_id: uuid.UUID,
title: str,
description: str,
capability_type: str,
catalog_entry_id: uuid.UUID | None = None,
) -> int:
"""Bump open suggestions that match an unfulfilled capability need."""
rows = (
await session.execute(
select(Suggestion).where(Suggestion.stage.in_(OPEN_SUGGESTION_STAGES))
)
).scalars().all()
haystack = f"{title} {description} {capability_type}".lower()
bumped = 0
for suggestion in rows:
matched = False
if suggestion.origin_ref:
ref = suggestion.origin_ref.lower()
if ref in haystack or any(token in haystack for token in ref.split("-") if len(token) > 4):
matched = True
if not matched and suggestion.title.lower() in haystack:
matched = True
if not matched:
continue
if await bump_relevance(
session,
suggestion,
source="capability_request",
source_key=str(request_id),
reason=f"Capability request matched: {title}",
):
bumped += 1
return bumped
async def bump_suggestions_for_next_steps(
session: AsyncSession,
suggestions: list[Suggestion],
) -> int:
"""Bump only suggestions surfaced in a next-steps response."""
bucket = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H")
bumped = 0
for suggestion in suggestions:
if await bump_relevance(
session,
suggestion,
source="next_steps",
source_key=bucket,
reason="Surfaced during get_next_steps lookup",
):
bumped += 1
return bumped

View file

@ -0,0 +1,17 @@
from __future__ import annotations
from api.models.suggestion import Suggestion
def cost_of_delay(suggestion: Suggestion) -> float:
return suggestion.base_value + (suggestion.relevance_weight * suggestion.relevance)
def compute_wsjf(suggestion: Suggestion) -> float:
size = suggestion.job_size if suggestion.job_size > 0 else 3.0
return round(cost_of_delay(suggestion) / size, 2)
def suggestion_sort_key(suggestion: Suggestion) -> tuple[float, int, str]:
"""Higher WSJF first; tie-break on relevance then title."""
return (-compute_wsjf(suggestion), -suggestion.relevance, suggestion.title.lower())

View file

@ -0,0 +1,298 @@
"""Revision-gated cache for ``GET /state/summary`` with stale-while-revalidate."""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from collections.abc import Awaitable, Callable
from typing import Literal
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import noload
from api.models.capability_request import CapabilityRequest
from api.models.contribution import Contribution
from api.models.decision import Decision
from api.models.domain import Domain
from api.models.extension_point import ExtensionPoint
from api.models.managed_repo import ManagedRepo
from api.models.progress_event import ProgressEvent
from api.models.sbom_snapshot import SBOMSnapshot
from api.models.task import Task
from api.models.technical_debt import TechnicalDebt
from api.models.topic import Topic
from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.schemas.progress_event import ProgressEventRead
from api.schemas.state import StateSummary
from api.services.sbom_nexus import get_json as get_sbom_nexus_json
from api.services.sbom_nexus import reads_from_nexus
logger = logging.getLogger(__name__)
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
_MAX_STALE_AGE_SECONDS = 300.0
InvalidateScope = Literal["all", "core", "progress"]
CacheStatus = Literal["hit-revision", "stale", "miss", "progress-section"]
BuildSummaryFn = Callable[[AsyncSession], Awaitable[StateSummary]]
# Tables feeding the stable (non-progress) summary core.
_CORE_TABLES: tuple[tuple[str, type], ...] = (
("topics", Topic),
("workplans", Workplan),
("tasks", Task),
("decisions", Decision),
("workplan_dependencies", WorkplanDependency),
("managed_repos", ManagedRepo),
("contributions", Contribution),
("capability_requests", CapabilityRequest),
("domains", Domain),
("extension_points", ExtensionPoint),
("technical_debt", TechnicalDebt),
)
@dataclass(frozen=True)
class SummaryRevision:
"""Cheap fingerprints of hub data that affect ``/state/summary``."""
core: datetime
progress: datetime | None
sbom: datetime | None
def core_fingerprint(self) -> str:
return _fingerprint(self.core, self.sbom)
def progress_fingerprint(self) -> str:
return self.progress.isoformat() if self.progress else ""
def combined_fingerprint(self) -> str:
return f"{self.core_fingerprint()}|{self.progress_fingerprint()}"
def _fingerprint(*parts: datetime | None) -> str:
normalized = [
(part or _EPOCH).astimezone(timezone.utc).isoformat()
for part in parts
]
return "|".join(normalized)
async def fetch_summary_revision(session: AsyncSession) -> SummaryRevision:
"""Return per-section revision watermarks (indexed MAX scans)."""
core_parts: list[datetime] = []
for _name, model in _CORE_TABLES:
value = (
await session.execute(select(func.max(model.updated_at)))
).scalar_one_or_none()
if value is not None:
core_parts.append(value)
if reads_from_nexus():
snapshots = await get_sbom_nexus_json("/sbom/snapshots/")
snapshot_times = [
datetime.fromisoformat(item["snapshot_at"].replace("Z", "+00:00"))
for item in snapshots
]
sbom_at = max(snapshot_times, default=None)
else:
sbom_at = (
await session.execute(select(func.max(SBOMSnapshot.snapshot_at)))
).scalar_one_or_none()
progress_at = (
await session.execute(select(func.max(ProgressEvent.created_at)))
).scalar_one_or_none()
core = max(core_parts, default=_EPOCH)
if sbom_at is not None and sbom_at > core:
core = sbom_at
return SummaryRevision(core=core, progress=progress_at, sbom=sbom_at)
async def fetch_recent_progress(session: AsyncSession, *, limit: int = 20) -> list[ProgressEventRead]:
rows = await session.execute(
select(ProgressEvent)
.options(noload("*"))
.order_by(ProgressEvent.created_at.desc())
.limit(limit)
)
return [ProgressEventRead.model_validate(event) for event in rows.scalars().all()]
def merge_summary(core: StateSummary, recent_progress: list[ProgressEventRead]) -> StateSummary:
return core.model_copy(update={"recent_progress": recent_progress})
@dataclass
class _CacheEntry:
summary: StateSummary
core_revision: str
progress_revision: str
built_at: float
class SummaryCache:
def __init__(self) -> None:
self._entry: _CacheEntry | None = None
self._refresh_task: asyncio.Task | None = None
self.last_error: str | None = None
self._build_fn: BuildSummaryFn | None = None
def configure(self, build_fn: BuildSummaryFn) -> None:
self._build_fn = build_fn
def reset(self) -> None:
self._entry = None
self.last_error = None
if self._refresh_task is not None and not self._refresh_task.done():
self._refresh_task.cancel()
self._refresh_task = None
def invalidate(self, scope: InvalidateScope = "all") -> None:
if scope == "all" or self._entry is None:
self.reset()
return
if scope == "core":
self.reset()
elif scope == "progress":
self._entry.progress_revision = "__invalid__"
def store(self, summary: StateSummary, revision: SummaryRevision) -> None:
import time
self._entry = _CacheEntry(
summary=summary,
core_revision=revision.core_fingerprint(),
progress_revision=revision.progress_fingerprint(),
built_at=time.monotonic(),
)
self.last_error = None
def _entry_age(self) -> float | None:
import time
if self._entry is None:
return None
return time.monotonic() - self._entry.built_at
def _entry_matches(self, revision: SummaryRevision) -> tuple[bool, bool]:
if self._entry is None:
return False, False
core_match = self._entry.core_revision == revision.core_fingerprint()
progress_match = self._entry.progress_revision == revision.progress_fingerprint()
return core_match, progress_match
def resolve(
self,
revision: SummaryRevision,
*,
force_refresh: bool,
) -> tuple[CacheStatus, StateSummary | None]:
import time
if force_refresh:
return "miss", None
if self._entry is None:
return "miss", None
age = self._entry_age()
if age is not None and age > _MAX_STALE_AGE_SECONDS:
return "miss", None
core_match, progress_match = self._entry_matches(revision)
if core_match and progress_match:
return "hit-revision", self._entry.summary
if core_match and not progress_match:
return "progress-section", self._entry.summary
# Core changed — serve stale full snapshot while refreshing.
return "stale", self._entry.summary
def schedule_refresh(self, revision: SummaryRevision) -> None:
if self._build_fn is None:
return
if self._refresh_task is not None and not self._refresh_task.done():
return
self._refresh_task = asyncio.create_task(
self._refresh_background(revision),
name="summary-cache-refresh",
)
async def _refresh_background(self, revision: SummaryRevision) -> None:
from api.database import async_session_factory
if self._build_fn is None:
return
try:
async with async_session_factory() as session:
current = await fetch_summary_revision(session)
summary = await self._build_fn(session)
self.store(summary, current)
except Exception as exc:
self.last_error = str(exc)
logger.exception("summary cache background refresh failed")
_summary_cache = SummaryCache()
def get_summary_cache() -> SummaryCache:
return _summary_cache
def invalidate_summary_cache(scope: InvalidateScope = "all") -> None:
_summary_cache.invalidate(scope)
def reset_summary_cache_for_tests() -> None:
_summary_cache.reset()
_INVALIDATION_REGISTERED = False
def register_summary_cache_invalidation() -> None:
"""Clear summary cache when ORM rows that affect summary are written."""
global _INVALIDATION_REGISTERED
if _INVALIDATION_REGISTERED:
return
_INVALIDATION_REGISTERED = True
from sqlalchemy import event
def _invalidate_core(*_args: object, **_kwargs: object) -> None:
invalidate_summary_cache("core")
def _invalidate_progress(*_args: object, **_kwargs: object) -> None:
invalidate_summary_cache("progress")
for _name, model in _CORE_TABLES:
event.listen(model, "after_insert", _invalidate_core)
event.listen(model, "after_update", _invalidate_core)
event.listen(model, "after_delete", _invalidate_core)
event.listen(SBOMSnapshot, "after_insert", _invalidate_core)
event.listen(SBOMSnapshot, "after_delete", _invalidate_core)
event.listen(ProgressEvent, "after_insert", _invalidate_progress)
async def apply_progress_section(
session: AsyncSession,
summary: StateSummary,
revision: SummaryRevision,
) -> StateSummary:
recent = await fetch_recent_progress(session)
merged = merge_summary(summary, recent)
cache = get_summary_cache()
if cache._entry is not None and cache._entry.core_revision == revision.core_fingerprint():
cache._entry.summary = merged
cache._entry.progress_revision = revision.progress_fingerprint()
else:
cache.store(merged, revision)
return merged

View file

@ -0,0 +1,214 @@
"""Backfill canonical record ids onto existing task rows (STATE-WP-0083-T06).
Only the repository files hold the mapping. A file task declares both its
canonical id and the projection UUID it was registered under:
```task
id: CUST-WP-0067-T01
state_hub_task_id: "f3608db4-..."
```
so the pairing can be read directly rather than guessed from titles. Anything a
file does not claim is left alone: a task row whose canonical id cannot be
established keeps `record_id` null, and the reset continues to refuse to act on
it. An unknown identity must stay unknown rather than be inferred.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
_ID_RE = re.compile(r"^id:\s*(\S+)", re.MULTILINE)
_UUID_RE = re.compile(r'state_hub_task_id:\s*"?([0-9a-f-]{36})"?')
_FRONTMATTER_ID_RE = re.compile(r"^id:\s*(\S+)", re.MULTILINE)
# A task id written as a bare "T01" is unique only inside its own workplan.
# Storing it as a canonical identifier makes every workplan's first task share
# one identity — 51 such ids were assigned to 148 rows before this was caught.
_SHORT_TASK_ID_RE = re.compile(r"^T\d+$", re.IGNORECASE)
def qualify_task_id(task_id: str, workplan_id: str | None) -> str | None:
"""Return a canonical task id, or None when identity cannot be established."""
task_id = task_id.strip()
if not _SHORT_TASK_ID_RE.fullmatch(task_id):
return task_id
if not workplan_id:
# Unqualifiable: leaving it unidentified is correct, since an identity
# that is not unique is worse than none.
return None
return f"{workplan_id.strip()}-{task_id.upper()}"
@dataclass
class BackfillReport:
scanned_files: int = 0
pairs_found: int = 0
updated: int = 0
already_set: int = 0
conflicts: list[dict[str, str]] = field(default_factory=list)
unmatched_uuids: int = 0
def to_dict(self) -> dict[str, Any]:
return {
"schema": "state-hub.task-record-id-backfill.v1",
"scanned_files": self.scanned_files,
"pairs_found": self.pairs_found,
"updated": self.updated,
"already_set": self.already_set,
"unmatched_uuids": self.unmatched_uuids,
"conflicts": self.conflicts,
}
def collect_pairs(roots: list[Path]) -> tuple[dict[str, str], BackfillReport]:
"""Map projection UUID -> canonical record id, from workplan files."""
report = BackfillReport()
pairs: dict[str, str] = {}
for root in roots:
wp_dir = root / "workplans"
if not wp_dir.is_dir():
continue
for path in sorted(wp_dir.rglob("*.md")):
if path.name.startswith("."):
continue
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
report.scanned_files += 1
head = text.split("---", 2)[1] if text.startswith("---") and text.count("---") >= 2 else ""
wp_match = _FRONTMATTER_ID_RE.search(head)
workplan_id = wp_match.group(1).strip() if wp_match else None
for block in _TASK_BLOCK_RE.finditer(text):
body = block.group(1)
rid = _ID_RE.search(body)
uid = _UUID_RE.search(body)
if not rid or not uid:
continue
record_id = qualify_task_id(rid.group(1), workplan_id)
if record_id is None:
continue
task_uuid = uid.group(1)
prior = pairs.get(task_uuid)
if prior and prior != record_id:
# One UUID claimed by two canonical ids: a duplicate
# registration. Recording it and skipping is the only safe
# option — picking one would fabricate an identity.
report.conflicts.append(
{"uuid": task_uuid, "first": prior, "second": record_id}
)
continue
pairs[task_uuid] = record_id
report.pairs_found = len(pairs)
return pairs, report
async def backfill_task_record_ids(
session: Any, roots: list[Path], *, dry_run: bool = True
) -> BackfillReport:
from sqlalchemy import select
from api.models.task import Task
pairs, report = collect_pairs(roots)
if not pairs:
return report
rows = list((await session.execute(select(Task))).scalars())
by_id = {str(r.id): r for r in rows}
for task_uuid, record_id in pairs.items():
row = by_id.get(task_uuid)
if row is None:
report.unmatched_uuids += 1
continue
if row.record_id == record_id:
report.already_set += 1
continue
if row.record_id and row.record_id != record_id:
report.conflicts.append(
{"uuid": task_uuid, "first": row.record_id, "second": record_id}
)
continue
report.updated += 1
if not dry_run:
row.record_id = record_id
return report
async def backfill_from_forge(
session: Any,
repo_slugs: list[str],
*,
forge_base: str | None = None,
dry_run: bool = True,
) -> BackfillReport:
"""Backfill from repositories cloned out of the forge.
The local-path variant above needs a workstation checkout, which central
does not have and should not depend on: `ADR-012` decision 1 makes the forge
the projection source, and a backfill sourced from someone's laptop would
reintroduce exactly the coupling that ADR removes.
Central can clone the forge directly, so it reads the pairing from the same
place it derives everything else.
"""
import tempfile
from api.services.forge_projection import (
DEFAULT_FORGE_BASE,
ForgeDeriveError,
_run_git,
)
base = forge_base or DEFAULT_FORGE_BASE
report = BackfillReport()
pairs: dict[str, str] = {}
for slug in repo_slugs:
url = f"{base.rstrip('/')}/{slug}.git"
with tempfile.TemporaryDirectory(prefix=f"backfill-{slug}-") as tmp:
try:
_run_git("clone", "--depth", "1", "--quiet", url, tmp)
except (ForgeDeriveError, Exception):
# A repository that cannot be read contributes nothing. It must
# not silently reduce what the rest can identify.
continue
repo_pairs, repo_report = collect_pairs([Path(tmp)])
report.scanned_files += repo_report.scanned_files
report.conflicts.extend(repo_report.conflicts)
for uid, rid in repo_pairs.items():
prior = pairs.get(uid)
if prior and prior != rid:
report.conflicts.append({"uuid": uid, "first": prior, "second": rid})
continue
pairs[uid] = rid
report.pairs_found = len(pairs)
if not pairs:
return report
from sqlalchemy import select
from api.models.task import Task
rows = list((await session.execute(select(Task))).scalars())
by_id = {str(r.id): r for r in rows}
for uid, rid in pairs.items():
row = by_id.get(uid)
if row is None:
report.unmatched_uuids += 1
continue
if row.record_id == rid:
report.already_set += 1
continue
if row.record_id and row.record_id != rid:
report.conflicts.append({"uuid": uid, "first": row.record_id, "second": rid})
continue
report.updated += 1
if not dry_run:
row.record_id = rid
return report

View file

@ -0,0 +1,16 @@
"""Token source adapters for measured agent usage."""
from api.services.token_sources.base import TokenSourceRecord, parse_iso
from api.services.token_sources.codex import collect_codex_sessions, iter_codex_session_files, parse_codex_session
from api.services.token_sources.claude import collect_claude_transcripts, iter_claude_transcript_files, parse_claude_transcript
__all__ = [
"TokenSourceRecord",
"parse_iso",
"collect_codex_sessions",
"iter_codex_session_files",
"parse_codex_session",
"collect_claude_transcripts",
"iter_claude_transcript_files",
"parse_claude_transcript",
]

View file

@ -0,0 +1,171 @@
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class RepoRef:
repo_id: str
slug: str
local_path: str | None = None
host_paths: dict[str, Any] | None = None
remote_url: str | None = None
git_fingerprint: str | None = None
@dataclass(frozen=True)
class RepoMatch:
repo_id: str
slug: str
method: str
confidence: float
def normalise_cwd(raw: str | None) -> str | None:
if not raw:
return None
value = raw.replace("\\", "/")
prefixes = (
"//wsl.localhost/Ubuntu-24.04",
"//wsl$/Ubuntu-24.04",
)
for prefix in prefixes:
if value.startswith(prefix):
return value[len(prefix):] or "/"
if len(value) >= 3 and value[1:3] == ":/":
drive = value[0].lower()
return f"/mnt/{drive}{value[2:]}"
return value
def normalise_remote_url(raw: str | None) -> str | None:
if not raw:
return None
value = raw.strip()
if value.endswith(".git"):
value = value[:-4]
if value.startswith("git@") and ":" in value:
host, path = value[4:].split(":", 1)
value = f"ssh://{host}/{path}"
return value.lower().rstrip("/")
def repo_refs_from_api(repos: list[dict[str, Any]]) -> list[RepoRef]:
refs = []
for repo in repos:
repo_id = repo.get("id")
slug = repo.get("slug")
if not repo_id or not slug:
continue
refs.append(
RepoRef(
repo_id=str(repo_id),
slug=str(slug),
local_path=repo.get("local_path"),
host_paths=repo.get("host_paths") if isinstance(repo.get("host_paths"), dict) else {},
remote_url=repo.get("remote_url"),
git_fingerprint=repo.get("git_fingerprint"),
)
)
return refs
def _git(cwd: str, *args: str) -> str | None:
try:
result = subprocess.run(
["git", *args],
cwd=cwd,
check=False,
capture_output=True,
text=True,
timeout=5,
)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
value = result.stdout.strip().splitlines()
return value[0] if value else None
def git_fingerprint_for_path(cwd: str | None) -> str | None:
path = normalise_cwd(cwd)
if not path or not Path(path).exists():
return None
root = _git(path, "rev-parse", "--show-toplevel")
if not root:
return None
return _git(root, "rev-list", "--max-parents=0", "HEAD")
def git_remote_for_path(cwd: str | None) -> str | None:
path = normalise_cwd(cwd)
if not path or not Path(path).exists():
return None
root = _git(path, "rev-parse", "--show-toplevel")
if not root:
return None
return _git(root, "remote", "get-url", "origin")
def _repo_paths(repo: RepoRef) -> list[str]:
paths = [repo.local_path]
if repo.host_paths:
paths.extend(str(v) for v in repo.host_paths.values() if v)
result = []
for raw in paths:
path = normalise_cwd(str(raw)) if raw and raw != "(unknown)" else None
if path:
result.append(path.rstrip("/"))
return result
def resolve_repo(cwd: str | None, repos: list[RepoRef]) -> RepoMatch | None:
path = normalise_cwd(cwd)
fingerprint = git_fingerprint_for_path(path)
remote = normalise_remote_url(git_remote_for_path(path))
if fingerprint:
candidates = [repo for repo in repos if repo.git_fingerprint == fingerprint]
if len(candidates) == 1:
repo = candidates[0]
return RepoMatch(repo.repo_id, repo.slug, "git_fingerprint", 0.98)
if remote:
remote_candidates = [
repo for repo in candidates
if normalise_remote_url(repo.remote_url) == remote
]
if len(remote_candidates) == 1:
repo = remote_candidates[0]
return RepoMatch(repo.repo_id, repo.slug, "git_fingerprint_remote", 0.99)
if remote:
candidates = [repo for repo in repos if normalise_remote_url(repo.remote_url) == remote]
if len(candidates) == 1:
repo = candidates[0]
return RepoMatch(repo.repo_id, repo.slug, "remote_url", 0.90)
if not path:
return None
path_matches: list[tuple[str, RepoRef]] = []
for repo in repos:
for repo_path in _repo_paths(repo):
if path == repo_path or path.startswith(f"{repo_path}/"):
path_matches.append((repo_path, repo))
if not path_matches:
return None
path_matches.sort(key=lambda item: len(item[0]), reverse=True)
exact = [item for item in path_matches if path == item[0]]
if exact:
basename = Path(path).name
for _, repo in exact:
if repo.slug == basename:
return RepoMatch(repo.repo_id, repo.slug, "path_exact_slug", 0.85)
repo = exact[0][1]
return RepoMatch(repo.repo_id, repo.slug, "path_exact", 0.80)
repo = path_matches[0][1]
return RepoMatch(repo.repo_id, repo.slug, "path_prefix", 0.75)

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def parse_iso(value: str) -> datetime:
raw = value.strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
if "T" not in raw:
raw = f"{raw}T00:00:00+00:00"
parsed = datetime.fromisoformat(raw)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
@dataclass
class TokenSourceRecord:
source_provider: str
source_id: str
source_path: Path
source_created_at: datetime | None
session_id: str | None = None
cwd: str | None = None
model: str | None = None
agent: str | None = None
tokens_in: int = 0
tokens_out: int = 0
cached_input_tokens: int = 0
reasoning_output_tokens: int = 0
raw_total_tokens: int | None = None
parser_version: str | None = None
confidence: float = 1.0
raw_metadata: dict[str, Any] = field(default_factory=dict)
@property
def tokens_total(self) -> int:
return self.tokens_in + self.tokens_out
def to_token_event_payload(self, repo_id: str | None = None) -> dict[str, Any]:
raw_total = self.raw_total_tokens
if raw_total is None:
raw_total = self.tokens_in + self.tokens_out
created_at = self.source_created_at.isoformat() if self.source_created_at else None
return {
"tokens_in": self.tokens_in,
"tokens_out": self.tokens_out,
"repo_id": repo_id,
"session_id": self.session_id,
"model": self.model,
"agent": self.agent,
"ref_type": "session",
"ref_id": self.source_id,
"note": f"measured:{self.source_provider}",
"created_at": created_at,
"measurement_kind": "measured",
"source_provider": self.source_provider,
"source_id": self.source_id,
"source_path": str(self.source_path),
"source_created_at": created_at,
"parser_version": self.parser_version,
"confidence": self.confidence,
"cached_input_tokens": self.cached_input_tokens,
"reasoning_output_tokens": self.reasoning_output_tokens,
"raw_total_tokens": raw_total,
"raw_metadata": self.raw_metadata,
}

View file

@ -0,0 +1,120 @@
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from api.services.token_sources.base import TokenSourceRecord, parse_iso
PARSER_VERSION = "claude-transcript-v1"
def iter_claude_transcript_files(claude_home: Path) -> list[Path]:
projects = claude_home / "projects"
if not projects.is_dir():
return []
return sorted(projects.glob("**/*.jsonl"))
def _usage_from_entry(entry: dict[str, Any]) -> dict[str, Any]:
message = entry.get("message")
if isinstance(message, dict) and isinstance(message.get("usage"), dict):
return message["usage"]
usage = entry.get("usage")
return usage if isinstance(usage, dict) else {}
def parse_claude_transcript(path: Path, since: datetime) -> TokenSourceRecord | None:
session_id = path.stem
cwd: str | None = None
model: str | None = None
first_at: datetime | None = None
last_at: datetime | None = None
tokens_in = tokens_out = 0
cached_input_tokens = 0
raw_total_tokens = 0
usage_records = 0
malformed_lines = 0
try:
handle = path.open("r", encoding="utf-8", errors="ignore")
except OSError:
return None
with handle:
for line in handle:
try:
entry: dict[str, Any] = json.loads(line)
except json.JSONDecodeError:
malformed_lines += 1
continue
ts = entry.get("timestamp") or entry.get("created_at")
parsed_ts = parse_iso(ts) if isinstance(ts, str) else None
if parsed_ts:
first_at = first_at or parsed_ts
last_at = parsed_ts
session_id = str(entry.get("session_id") or entry.get("conversation_id") or session_id)
cwd = entry.get("cwd") or entry.get("project_cwd") or cwd
model = entry.get("model") or model
message = entry.get("message")
if isinstance(message, dict):
model = message.get("model") or model
usage = _usage_from_entry(entry)
if not usage:
continue
if parsed_ts is not None and parsed_ts < since:
continue
input_tokens = int(usage.get("input_tokens") or 0)
cache_creation = int(usage.get("cache_creation_input_tokens") or 0)
cache_read = int(usage.get("cache_read_input_tokens") or 0)
output_tokens = int(usage.get("output_tokens") or 0)
if input_tokens == 0 and output_tokens == 0 and cache_creation == 0 and cache_read == 0:
continue
tokens_in += input_tokens
tokens_out += output_tokens
cached_input_tokens += cache_creation + cache_read
raw_total_tokens += input_tokens + cache_creation + cache_read + output_tokens
usage_records += 1
if usage_records == 0 or tokens_in + tokens_out + cached_input_tokens == 0:
return None
return TokenSourceRecord(
source_provider="claude_transcript",
source_id=f"claude:{session_id}",
source_path=path,
source_created_at=last_at,
session_id=session_id,
cwd=cwd,
model=model,
agent="claude",
tokens_in=tokens_in,
tokens_out=tokens_out,
cached_input_tokens=cached_input_tokens,
raw_total_tokens=raw_total_tokens or None,
parser_version=PARSER_VERSION,
confidence=1.0,
raw_metadata={
"started_at": first_at.isoformat() if first_at else None,
"usage_records": usage_records,
"malformed_lines": malformed_lines,
"source_file_name": path.name,
},
)
def collect_claude_transcripts(claude_home: Path, since: datetime) -> list[TokenSourceRecord]:
by_id: dict[str, TokenSourceRecord] = {}
for path in iter_claude_transcript_files(claude_home):
parsed = parse_claude_transcript(path, since)
if parsed is None:
continue
current = by_id.get(parsed.source_id)
if current is None or parsed.tokens_total > current.tokens_total:
by_id[parsed.source_id] = parsed
return sorted(by_id.values(), key=lambda item: item.source_created_at or datetime.min.replace(tzinfo=since.tzinfo))

View file

@ -0,0 +1,124 @@
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from api.services.token_sources.base import TokenSourceRecord, parse_iso
PARSER_VERSION = "codex-desktop-v1"
def iter_codex_session_files(codex_home: Path) -> list[Path]:
files: list[Path] = []
sessions = codex_home / "sessions"
archived = codex_home / "archived_sessions"
if sessions.is_dir():
files.extend(sorted(sessions.glob("*/*/*/*.jsonl")))
if archived.is_dir():
files.extend(sorted(archived.glob("*.jsonl")))
return files
def parse_codex_session(path: Path, since: datetime) -> TokenSourceRecord | None:
fallback_id = path.stem.removeprefix("rollout-")
session_id = fallback_id
started_at: datetime | None = None
last_at: datetime | None = None
cwd: str | None = None
model: str | None = None
tokens_in = tokens_out = 0
cached_input_tokens = reasoning_output_tokens = 0
raw_total_tokens = 0
usage_records = 0
malformed_lines = 0
try:
handle = path.open("r", encoding="utf-8", errors="ignore")
except OSError:
return None
with handle:
for line in handle:
try:
entry: dict[str, Any] = json.loads(line)
except json.JSONDecodeError:
malformed_lines += 1
continue
ts = entry.get("timestamp")
parsed_ts = parse_iso(ts) if isinstance(ts, str) else None
if parsed_ts:
last_at = parsed_ts
started_at = started_at or parsed_ts
payload = entry.get("payload") or {}
if entry.get("type") == "session_meta":
meta_id = payload.get("id")
if meta_id:
session_id = str(meta_id)
cwd = payload.get("cwd") or cwd
meta_ts = payload.get("timestamp")
if isinstance(meta_ts, str):
started_at = parse_iso(meta_ts)
elif entry.get("type") == "turn_context":
cwd = payload.get("cwd") or cwd
model = payload.get("model") or model
elif entry.get("type") == "event_msg" and payload.get("type") == "token_count":
if parsed_ts is None or parsed_ts < since:
continue
info = payload.get("info") or {}
last = info.get("last_token_usage") or {}
if not isinstance(last, dict):
continue
input_tokens = int(last.get("input_tokens") or 0)
output_tokens = int(last.get("output_tokens") or 0)
if input_tokens == 0 and output_tokens == 0:
continue
tokens_in += input_tokens
tokens_out += output_tokens
cached_input_tokens += int(last.get("cached_input_tokens") or 0)
reasoning_output_tokens += int(last.get("reasoning_output_tokens") or 0)
raw_total_tokens += int(last.get("total_tokens") or input_tokens + output_tokens)
usage_records += 1
last_at = parsed_ts
if usage_records == 0 or tokens_in + tokens_out == 0:
return None
return TokenSourceRecord(
source_provider="codex_session",
source_id=f"codex:{session_id}",
source_path=path,
source_created_at=last_at,
session_id=session_id,
cwd=cwd,
model=model,
agent="codex",
tokens_in=tokens_in,
tokens_out=tokens_out,
cached_input_tokens=cached_input_tokens,
reasoning_output_tokens=reasoning_output_tokens,
raw_total_tokens=raw_total_tokens or None,
parser_version=PARSER_VERSION,
confidence=1.0,
raw_metadata={
"started_at": started_at.isoformat() if started_at else None,
"usage_records": usage_records,
"malformed_lines": malformed_lines,
"source_file_name": path.name,
},
)
def collect_codex_sessions(codex_home: Path, since: datetime) -> list[TokenSourceRecord]:
by_id: dict[str, TokenSourceRecord] = {}
for path in iter_codex_session_files(codex_home):
parsed = parse_codex_session(path, since)
if parsed is None:
continue
current = by_id.get(parsed.source_id)
if current is None or parsed.tokens_total > current.tokens_total:
by_id[parsed.source_id] = parsed
return sorted(by_id.values(), key=lambda item: item.source_created_at or datetime.min.replace(tzinfo=since.tzinfo))

View file

@ -0,0 +1,138 @@
"""Derived wait qualifiers (CUST-WP-0074 rule 5) — read model only.
A ``wait`` task is *external* when it carries at least one dependency edge
(``workplan_dependencies.from_task_id``), an owner-return marker
(``blocked_on: message-from:<agent>``, rule 6) or a ``wait_until`` date (a
time window clears by itself); *human* when ``needs_human`` is set, *both*
when both hold and *unqualified* otherwise. A ``blocked`` workplan is
*human* when any of its wait tasks is human or both, else *external* when any
is external, else *none*. Nothing here is stored; the routers call the
``annotate_*`` helpers and Pydantic picks the attributes up on serialisation.
"""
from __future__ import annotations
import re
import uuid
from collections.abc import Iterable, Sequence
from datetime import date
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.task import Task, TaskStatus
from api.models.workplan import Workplan
from api.models.workplan_dependency import WorkplanDependency
from api.task_status import normalize_task_status
from api.workplan_status import normalize_workplan_status
WAIT_KIND_EXTERNAL = "external"
WAIT_KIND_HUMAN = "human"
WAIT_KIND_BOTH = "both"
WAIT_KIND_UNQUALIFIED = "unqualified"
BLOCKED_KIND_EXTERNAL = "external"
BLOCKED_KIND_HUMAN = "human"
BLOCKED_KIND_NONE = "none"
# Rule 6 owner-return marker; mirrors _BLOCKED_ON_RE in scripts/consistency_check.py.
MESSAGE_FROM_RE = re.compile(r"^message-from:.+$")
def is_external_wait(
*,
has_dependency: bool,
blocked_on: str | None = None,
wait_until: date | None = None,
) -> bool:
"""Case A holds for a dependency edge, an owner-return marker or a wait window."""
if has_dependency or wait_until is not None:
return True
return bool(blocked_on and MESSAGE_FROM_RE.match(blocked_on.strip()))
def derive_wait_kind(
status: Any,
*,
needs_human: bool,
has_dependency: bool,
blocked_on: str | None = None,
wait_until: date | None = None,
) -> str | None:
"""Rule 1 / rule 5 for one task. ``None`` unless the task is waiting."""
if normalize_task_status(status, default="todo") != "wait":
return None
external = is_external_wait(
has_dependency=has_dependency, blocked_on=blocked_on, wait_until=wait_until
)
if external and needs_human:
return WAIT_KIND_BOTH
if needs_human:
return WAIT_KIND_HUMAN
if external:
return WAIT_KIND_EXTERNAL
return WAIT_KIND_UNQUALIFIED
def derive_blocked_kind(status: Any, wait_kinds: Iterable[str | None]) -> str | None:
"""Rule 5 for one workplan. ``None`` unless the workplan is blocked."""
if normalize_workplan_status(status) != "blocked":
return None
kinds = {kind for kind in wait_kinds if kind}
if WAIT_KIND_HUMAN in kinds or WAIT_KIND_BOTH in kinds:
return BLOCKED_KIND_HUMAN
if WAIT_KIND_EXTERNAL in kinds:
return BLOCKED_KIND_EXTERNAL
return BLOCKED_KIND_NONE
async def task_ids_with_dependencies(
session: AsyncSession, task_ids: Sequence[uuid.UUID]
) -> set[uuid.UUID]:
if not task_ids:
return set()
rows = await session.execute(
select(WorkplanDependency.from_task_id).where(
WorkplanDependency.from_task_id.in_(list(task_ids))
)
)
return {row[0] for row in rows if row[0] is not None}
async def annotate_tasks(session: AsyncSession, tasks: Sequence[Task]) -> Sequence[Task]:
"""Set ``task.wait_kind`` on each ORM task (serialised by TaskRead)."""
waiting = [t for t in tasks if normalize_task_status(t.status, default="todo") == "wait"]
with_deps = await task_ids_with_dependencies(session, [t.id for t in waiting])
for task in tasks:
task.wait_kind = derive_wait_kind(
task.status,
needs_human=bool(task.needs_human),
has_dependency=task.id in with_deps,
blocked_on=task.blocked_on,
wait_until=task.wait_until,
)
return tasks
async def annotate_workplans(
session: AsyncSession, workplans: Sequence[Workplan]
) -> Sequence[Workplan]:
"""Set ``workplan.blocked_kind`` on each ORM workplan (serialised by WorkplanRead)."""
blocked = [
wp for wp in workplans if normalize_workplan_status(wp.status) == "blocked"
]
kinds_by_workplan: dict[uuid.UUID, list[str | None]] = {wp.id: [] for wp in blocked}
if blocked:
rows = await session.execute(
select(Task).where(
Task.workplan_id.in_(list(kinds_by_workplan)),
Task.status == TaskStatus.wait,
)
)
wait_tasks = list(rows.scalars().all())
await annotate_tasks(session, wait_tasks)
for task in wait_tasks:
kinds_by_workplan[task.workplan_id].append(task.wait_kind)
for wp in workplans:
wp.blocked_kind = derive_blocked_kind(wp.status, kinds_by_workplan.get(wp.id, []))
return workplans

View file

@ -0,0 +1,800 @@
"""Transactional State Hub projection migration for deterministic work-record UUIDs."""
from __future__ import annotations
import hashlib
import json
import re
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.managed_repo import ManagedRepo
from api.models.task import Task, TaskPriority, TaskStatus
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
from api.services.repository_aliases import resolve_repository_slug
from api.models.workplan import Workplan
PLAN_SCHEMA = "repo-manager.identifier-migration-plan.v1"
FLEET_NAMESPACE = "helixforge"
DERIVATION_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009")
_RECORD_ID_RE = re.compile(r"^[A-Z][A-Z0-9-]*-WP-[0-9]{4}(?:-T[0-9]{2,})?$")
class IdentifierMigrationError(ValueError):
"""The signed plan or current projection is unsafe to migrate."""
@dataclass(frozen=True)
class IdentifierMigrationResult:
repo_slug: str
plan_sha256: str
direction: str
replacements: int
migrated: int
already_derived: int
assignments_deferred: int
@dataclass(frozen=True)
class SealedProjectionRepairReceipt:
"""Non-secret evidence for one bounded pre-derivation projection repair."""
schema: str
outcome: str
repo_slug: str
repository_id: str
workplan_record_id: str
workplan_old_id: str
task_records: tuple[tuple[str, str], ...]
plan_sha256: str
source_revision: str
source_fingerprint: str
projection_identity: str
observed_at: str
def verify_plan(plan: dict[str, Any]) -> str:
"""Validate the immutable plan envelope and deterministic UUID mappings."""
if plan.get("schema") != PLAN_SCHEMA:
raise IdentifierMigrationError("unsupported identifier migration plan schema")
if plan.get("namespace") != FLEET_NAMESPACE:
raise IdentifierMigrationError(
f"plan namespace must be {FLEET_NAMESPACE!r}"
)
if plan.get("apply_policy") != "all-or-nothing per repository":
raise IdentifierMigrationError("plan does not declare repository-atomic apply")
if plan.get("ready_to_apply") is not True:
raise IdentifierMigrationError("plan is not ready_to_apply")
expected = plan.get("plan_sha256")
if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-f]{64}", expected):
raise IdentifierMigrationError("plan has no valid SHA-256 seal")
unsealed = {key: value for key, value in plan.items() if key != "plan_sha256"}
canonical = json.dumps(unsealed, sort_keys=True, separators=(",", ":")).encode()
actual = hashlib.sha256(canonical).hexdigest()
if actual != expected:
raise IdentifierMigrationError("plan SHA-256 mismatch")
return expected
def _repository_mappings(
plan: dict[str, Any], repo_slug: str
) -> tuple[list[dict[str, Any]], int]:
matches = [item for item in plan.get("repositories", []) if item.get("repo") == repo_slug]
if len(matches) != 1:
raise IdentifierMigrationError(
f"repository {repo_slug!r} must occur exactly once in plan"
)
repository = matches[0]
if repository.get("eligible") is not True or repository.get("atomic_unit") is not True:
raise IdentifierMigrationError(f"repository {repo_slug!r} is not eligible and atomic")
replacements: list[dict[str, Any]] = []
assignments = 0
seen_old: set[uuid.UUID] = set()
seen_new: set[uuid.UUID] = set()
for raw in repository.get("mappings", []):
action = raw.get("action")
if action == "assign":
assignments += 1
continue
if action == "unchanged":
continue
if action != "replace":
raise IdentifierMigrationError(f"unsupported mapping action {action!r}")
if raw.get("repo") != repo_slug:
raise IdentifierMigrationError("mapping repository does not match atomic unit")
kind = raw.get("kind")
record_id = raw.get("record_id")
if kind not in {"workplan", "task"}:
raise IdentifierMigrationError(f"unsupported record kind {kind!r}")
if not isinstance(record_id, str) or not _RECORD_ID_RE.fullmatch(record_id):
raise IdentifierMigrationError(f"noncanonical record id {record_id!r}")
try:
old_id = uuid.UUID(str(raw.get("current_uuid")))
new_id = uuid.UUID(str(raw.get("derived_uuid")))
except (TypeError, ValueError) as exc:
raise IdentifierMigrationError(f"invalid UUID mapping for {record_id}") from exc
expected_new = uuid.uuid5(
DERIVATION_NAMESPACE_UUID,
f"{FLEET_NAMESPACE}\n{record_id}",
)
if new_id != expected_new:
raise IdentifierMigrationError(f"derived UUID mismatch for {record_id}")
if old_id == new_id or old_id in seen_old or new_id in seen_new:
raise IdentifierMigrationError(f"non-unique replacement for {record_id}")
seen_old.add(old_id)
seen_new.add(new_id)
replacements.append(
{
"kind": kind,
"record_id": record_id,
"old_id": old_id,
"new_id": new_id,
}
)
return replacements, assignments
def _repository_entry(plan: dict[str, Any], repo_slug: str) -> dict[str, Any]:
matches = [item for item in plan.get("repositories", []) if item.get("repo") == repo_slug]
if len(matches) != 1:
raise IdentifierMigrationError(
f"repository {repo_slug!r} must occur exactly once in plan"
)
return matches[0]
def _repair_unit_mappings(
plan: dict[str, Any], repo_slug: str, unit: dict[str, Any]
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
replacements, _assignments = _repository_mappings(plan, repo_slug)
workplan = unit.get("workplan")
tasks = unit.get("tasks")
if not isinstance(workplan, dict) or not isinstance(tasks, list):
raise IdentifierMigrationError("repair unit requires one workplan and a task list")
workplan_record_id = workplan.get("record_id")
workplan_matches = [
mapping
for mapping in replacements
if mapping["kind"] == "workplan" and mapping["record_id"] == workplan_record_id
]
if len(workplan_matches) != 1:
raise IdentifierMigrationError(
"repair workplan must occur exactly once as a replacement in the sealed plan"
)
workplan_mapping = workplan_matches[0]
raw_workplan_mapping = next(
raw
for raw in _repository_entry(plan, repo_slug).get("mappings", [])
if raw.get("kind") == "workplan" and raw.get("record_id") == workplan_record_id
)
authoritative_path = raw_workplan_mapping.get("path")
if not isinstance(authoritative_path, str) or not authoritative_path:
raise IdentifierMigrationError("sealed workplan mapping has no authoritative path")
task_record_ids = [task.get("record_id") for task in tasks if isinstance(task, dict)]
if len(task_record_ids) != len(tasks) or len(set(task_record_ids)) != len(tasks):
raise IdentifierMigrationError("repair task record ids must be explicit and unique")
task_mappings = [
mapping
for mapping in replacements
if mapping["kind"] == "task" and mapping["record_id"] in task_record_ids
]
mapped_task_ids = {mapping["record_id"] for mapping in task_mappings}
if mapped_task_ids != set(task_record_ids):
raise IdentifierMigrationError(
"repair tasks do not exactly match replacement mappings in the sealed plan"
)
raw_unit_task_ids = {
raw.get("record_id")
for raw in _repository_entry(plan, repo_slug).get("mappings", [])
if raw.get("kind") == "task"
and raw.get("action") == "replace"
and raw.get("path") == authoritative_path
}
if raw_unit_task_ids != set(task_record_ids):
raise IdentifierMigrationError(
"repair must include every replaced task in the sealed workplan unit"
)
return workplan_mapping, task_mappings
def _uuid_field(value: Any, label: str) -> uuid.UUID:
try:
return uuid.UUID(str(value))
except (TypeError, ValueError) as exc:
raise IdentifierMigrationError(f"invalid UUID for {label}") from exc
def _repair_payloads(
unit: dict[str, Any],
workplan_mapping: dict[str, Any],
task_mappings: list[dict[str, Any]],
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
raw_workplan = unit["workplan"]
if _uuid_field(raw_workplan.get("id"), "workplan") != workplan_mapping["old_id"]:
raise IdentifierMigrationError("repair workplan UUID does not match sealed old UUID")
slug = raw_workplan.get("slug")
title = raw_workplan.get("title")
status = raw_workplan.get("status")
if not isinstance(slug, str) or not slug or not isinstance(title, str) or not title:
raise IdentifierMigrationError("repair workplan requires explicit slug and title")
if status not in {
"proposed", "ready", "active", "blocked", "backlog", "finished", "archived"
}:
raise IdentifierMigrationError(f"unsupported repair workplan status {status!r}")
workplan_payload = {
"id": workplan_mapping["old_id"],
"topic_id": _uuid_field(raw_workplan.get("topic_id"), "workplan topic"),
"slug": slug,
"title": title,
"description": raw_workplan.get("description"),
"status": status,
"owner": raw_workplan.get("owner"),
"planning_priority": raw_workplan.get("planning_priority"),
"planning_order": raw_workplan.get("planning_order"),
}
mappings_by_record = {mapping["record_id"]: mapping for mapping in task_mappings}
task_payloads: list[dict[str, Any]] = []
for raw_task in unit["tasks"]:
mapping = mappings_by_record[raw_task["record_id"]]
if _uuid_field(raw_task.get("id"), raw_task["record_id"]) != mapping["old_id"]:
raise IdentifierMigrationError(
f"repair task UUID does not match sealed old UUID for {raw_task['record_id']}"
)
title = raw_task.get("title")
if not isinstance(title, str) or not title:
raise IdentifierMigrationError(
f"repair task {raw_task['record_id']} requires an explicit title"
)
try:
status_value = TaskStatus(raw_task.get("status", "todo"))
priority_value = TaskPriority(raw_task.get("priority", "medium"))
except ValueError as exc:
raise IdentifierMigrationError(
f"invalid task state for {raw_task['record_id']}"
) from exc
parent_record_id = raw_task.get("parent_record_id")
if parent_record_id is not None and parent_record_id not in mappings_by_record:
raise IdentifierMigrationError(
f"parent task for {raw_task['record_id']} is outside the sealed repair unit"
)
task_payloads.append(
{
"record_id": raw_task["record_id"],
"id": mapping["old_id"],
"title": title,
"description": raw_task.get("description"),
"status": status_value,
"priority": priority_value,
"assignee": raw_task.get("assignee"),
"blocking_reason": raw_task.get("blocking_reason"),
"needs_human": bool(raw_task.get("needs_human", False)),
"intervention_note": raw_task.get("intervention_note"),
"parent_task_id": (
mappings_by_record[parent_record_id]["old_id"]
if parent_record_id is not None
else None
),
}
)
if task_payloads[-1]["needs_human"] and not task_payloads[-1]["intervention_note"]:
raise IdentifierMigrationError(
f"repair task {raw_task['record_id']} needs an intervention note"
)
if (
task_payloads[-1]["status"] == TaskStatus.wait
and task_payloads[-1]["needs_human"]
and not task_payloads[-1]["blocking_reason"]
):
raise IdentifierMigrationError(
f"repair task {raw_task['record_id']} needs a blocking reason"
)
pending = list(task_payloads)
ordered: list[dict[str, Any]] = []
emitted: set[uuid.UUID] = set()
while pending:
ready = [
task
for task in pending
if task["parent_task_id"] is None or task["parent_task_id"] in emitted
]
if not ready:
raise IdentifierMigrationError("repair task parentage contains a cycle")
for task in ready:
pending.remove(task)
ordered.append(task)
emitted.add(task["id"])
return workplan_payload, ordered
def _same_workplan(row: Workplan, expected: dict[str, Any], repo_id: uuid.UUID) -> bool:
fields = (
"id", "topic_id", "slug", "title", "description", "status", "owner",
"planning_priority", "planning_order",
)
return row.repo_id == repo_id and all(
getattr(row, field) == expected[field] for field in fields
)
def _same_task(row: Task, expected: dict[str, Any], workplan_id: uuid.UUID) -> bool:
fields = (
"id", "title", "description", "status", "priority", "assignee",
"blocking_reason", "needs_human", "intervention_note", "parent_task_id",
)
return row.workplan_id == workplan_id and all(
getattr(row, field) == expected[field] for field in fields
)
_TASK_DUPLICATE_FIELDS = (
"workplan_id",
"title",
"description",
"status",
"priority",
"assignee",
"due_date",
"blocking_reason",
"needs_human",
"intervention_note",
"parent_task_id",
)
def _same_duplicate_task(
legacy: Task,
derived: Task,
record_id: str,
) -> bool:
"""Accept only a deterministic duplicate of an otherwise identical legacy row."""
return (
legacy.record_id in {None, record_id}
and derived.record_id == record_id
and all(
getattr(legacy, field) == getattr(derived, field)
for field in _TASK_DUPLICATE_FIELDS
)
)
async def _task_reference_count(session: AsyncSession, task_id: uuid.UUID) -> int:
"""Count every inbound task reference before duplicate coalescence.
Coalescence deliberately refuses referenced legacy rows. Updating references
could collide with relationships already attached to the deterministic row;
a zero-reference proof keeps the repair lossless and reversible.
"""
return int(
await session.scalar(
text(
"SELECT "
"(SELECT count(*) FROM token_events WHERE task_id = :task_id) + "
"(SELECT count(*) FROM tasks WHERE parent_task_id = :task_id) + "
"(SELECT count(*) FROM review_contracts WHERE task_id = :task_id) + "
"(SELECT count(*) FROM progress_events WHERE task_id = :task_id) + "
"(SELECT count(*) FROM capability_requests WHERE blocking_task_id = :task_id) + "
"(SELECT count(*) FROM workplan_dependencies WHERE to_task_id = :task_id) + "
"(SELECT count(*) FROM workplan_dependencies WHERE from_task_id = :task_id) + "
"(SELECT count(*) FROM suggestions WHERE promoted_task_id = :task_id)"
),
{"task_id": task_id},
)
or 0
)
async def repair_absent_prederivation_projection(
session: AsyncSession,
plan: dict[str, Any],
repo_slug: str,
unit: dict[str, Any],
*,
expected_plan_sha256: str,
source_revision: str,
source_fingerprint: str,
source_clean: bool,
source_synchronized: bool,
primary_confirmed: bool,
projection_identity: str,
observed_at: datetime | None = None,
) -> SealedProjectionRepairReceipt:
"""Restore one exact random-ID workplan unit into an absent projection.
This is intentionally separate from ordinary consistency repair. The caller
must independently prove Git cleanliness/synchronization and provide the
authoritative unit parsed from the pinned source revision.
"""
plan_sha256 = verify_plan(plan)
if plan_sha256 != expected_plan_sha256:
raise IdentifierMigrationError("explicit plan SHA-256 does not match sealed plan")
if not primary_confirmed:
raise IdentifierMigrationError("projection repair requires explicit primary confirmation")
if not source_clean or not source_synchronized:
raise IdentifierMigrationError("projection repair requires a clean synchronized source")
if not re.fullmatch(r"[0-9a-f]{40}", source_revision):
raise IdentifierMigrationError("source revision must be a full Git SHA")
if not re.fullmatch(r"[0-9a-f]{64}", source_fingerprint):
raise IdentifierMigrationError("source fingerprint must be SHA-256")
if not projection_identity.strip():
raise IdentifierMigrationError("projection identity is required")
repository_entry = _repository_entry(plan, repo_slug)
if source_revision != repository_entry.get("planned_head_sha"):
raise IdentifierMigrationError("source revision drifted from sealed plan")
if source_fingerprint != repository_entry.get("source_fingerprint"):
raise IdentifierMigrationError("source fingerprint drifted from sealed plan")
workplan_mapping, task_mappings = _repair_unit_mappings(plan, repo_slug, unit)
workplan_payload, task_payloads = _repair_payloads(
unit, workplan_mapping, task_mappings
)
if session.in_transaction():
raise IdentifierMigrationError("projection repair requires a fresh database session")
outcome = "repaired"
async with session.begin():
resolution = await resolve_repository_slug(session, repo_slug, required=False)
if resolution is None:
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
repo = await session.scalar(
select(ManagedRepo)
.where(ManagedRepo.id == resolution.repo.id)
.with_for_update()
)
if repo.topic_id != workplan_payload["topic_id"]:
raise IdentifierMigrationError("repair topic does not match repository projection")
old_ids = [workplan_mapping["old_id"], *(m["old_id"] for m in task_mappings)]
new_workplan = await session.get(Workplan, workplan_mapping["new_id"])
new_tasks = [await session.get(Task, mapping["new_id"]) for mapping in task_mappings]
if new_workplan is not None or any(task is not None for task in new_tasks):
raise IdentifierMigrationError("derived target presence blocks projection repair")
alias_count = await session.scalar(
select(func.count()).select_from(WorkRecordIdentifierAlias).where(
WorkRecordIdentifierAlias.old_id.in_(old_ids)
)
)
if alias_count:
raise IdentifierMigrationError("durable alias presence blocks projection repair")
old_workplan = await session.get(Workplan, workplan_mapping["old_id"])
old_tasks = [await session.get(Task, mapping["old_id"]) for mapping in task_mappings]
present_count = int(old_workplan is not None) + sum(task is not None for task in old_tasks)
expected_count = 1 + len(task_mappings)
if present_count not in {0, expected_count}:
raise IdentifierMigrationError("partial old projection presence blocks repair")
if present_count == expected_count:
if not _same_workplan(old_workplan, workplan_payload, repo.id):
raise IdentifierMigrationError(
"existing old workplan differs from sealed repair unit"
)
expected_tasks = {task["id"]: task for task in task_payloads}
if any(
not _same_task(task, expected_tasks[task.id], workplan_mapping["old_id"])
for task in old_tasks
):
raise IdentifierMigrationError("existing old tasks differ from sealed repair unit")
outcome = "verified_noop"
else:
slug_conflict = await session.scalar(
select(Workplan.id).where(Workplan.slug == workplan_payload["slug"])
)
if slug_conflict is not None:
raise IdentifierMigrationError("workplan slug is already projected")
session.add(
Workplan(repo_id=repo.id, **workplan_payload)
)
await session.flush()
for task_payload in task_payloads:
values = {key: value for key, value in task_payload.items() if key != "record_id"}
session.add(Task(workplan_id=workplan_mapping["old_id"], **values))
await session.flush()
timestamp = observed_at or datetime.now(UTC)
if timestamp.tzinfo is None:
raise IdentifierMigrationError("receipt observation time must be timezone-aware")
return SealedProjectionRepairReceipt(
schema="state-hub.sealed-prederivation-projection-repair-receipt.v1",
outcome=outcome,
repo_slug=repo_slug,
repository_id=str(repo.id),
workplan_record_id=workplan_mapping["record_id"],
workplan_old_id=str(workplan_mapping["old_id"]),
task_records=tuple(
(mapping["record_id"], str(mapping["old_id"])) for mapping in task_mappings
),
plan_sha256=plan_sha256,
source_revision=source_revision,
source_fingerprint=source_fingerprint,
projection_identity=projection_identity,
observed_at=timestamp.astimezone(UTC).isoformat().replace("+00:00", "Z"),
)
async def _assert_projection_preconditions(
session: AsyncSession,
repository_id: uuid.UUID,
repo_slug: str,
replacements: list[dict[str, Any]],
*,
reverse: bool,
) -> dict[str, str]:
states: dict[str, str] = {}
for mapping in replacements:
source_id = mapping["new_id"] if reverse else mapping["old_id"]
target_id = mapping["old_id"] if reverse else mapping["new_id"]
if mapping["kind"] == "workplan":
query = text(
"SELECT workplans.id, workplans.repo_id, workplans.slug "
"FROM workplans WHERE workplans.id = :record_id FOR UPDATE"
)
else:
query = text(
"SELECT tasks.id, workplans.repo_id, tasks.record_id "
"FROM tasks "
"JOIN workplans ON workplans.id = tasks.workplan_id "
"WHERE tasks.id = :record_id FOR UPDATE"
)
source = (await session.execute(query, {"record_id": source_id})).one_or_none()
target = (await session.execute(query, {"record_id": target_id})).one_or_none()
for label, row in (("source", source), ("target", target)):
if row is not None and row.repo_id != repository_id:
raise IdentifierMigrationError(
f"{label} {mapping['kind']} {row.id} belongs to another repository"
)
if reverse:
if source is None:
raise IdentifierMigrationError(
f"source {mapping['kind']} {source_id} is absent from repository {repo_slug}"
)
if target is not None:
raise IdentifierMigrationError(
f"target {mapping['kind']} {target_id} already exists"
)
states[mapping["record_id"]] = "derived_source"
continue
if source is not None and target is not None:
if mapping["kind"] != "task":
raise IdentifierMigrationError(
f"both legacy and derived {mapping['kind']} rows exist for {mapping['record_id']}"
)
legacy_task = await session.scalar(
select(Task).where(Task.id == source_id).with_for_update()
)
derived_task = await session.scalar(
select(Task).where(Task.id == target_id).with_for_update()
)
if (
legacy_task is None
or derived_task is None
or not _same_duplicate_task(
legacy_task, derived_task, mapping["record_id"]
)
):
raise IdentifierMigrationError(
f"legacy and derived task rows differ for {mapping['record_id']}"
)
references = await _task_reference_count(session, source_id)
if references:
raise IdentifierMigrationError(
f"legacy duplicate task has {references} inbound references for "
f"{mapping['record_id']}"
)
states[mapping["record_id"]] = "duplicate_target"
continue
if source is None and target is None:
raise IdentifierMigrationError(
f"neither legacy nor derived {mapping['kind']} row exists for {mapping['record_id']}"
)
if source is not None:
states[mapping["record_id"]] = "legacy_source"
continue
# A pre-existing derived target is safe only when it already represents
# the canonical record from this repository. This is the state produced
# when forge reconciliation reached a partial projection before the
# sealed file migration did.
identity = target.slug if mapping["kind"] == "workplan" else target.record_id
if not isinstance(identity, str) or identity.strip().lower() != mapping["record_id"].lower():
raise IdentifierMigrationError(
f"derived target identity mismatch for {mapping['record_id']}"
)
states[mapping["record_id"]] = "derived_target"
return states
async def apply_repository_identifier_migration(
session: AsyncSession,
plan: dict[str, Any],
repo_slug: str,
) -> IdentifierMigrationResult:
"""Replace one repository's projected UUIDs in one database transaction."""
plan_sha256 = verify_plan(plan)
replacements, assignments = _repository_mappings(plan, repo_slug)
if session.in_transaction():
raise IdentifierMigrationError("migration requires a fresh database session")
async with session.begin():
resolution = await resolve_repository_slug(session, repo_slug, required=False)
if resolution is None:
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
projection_states = await _assert_projection_preconditions(
session, resolution.repo.id, repo_slug, replacements, reverse=False
)
aliases = {
alias.old_id: alias
for alias in (
await session.execute(
select(WorkRecordIdentifierAlias).where(
WorkRecordIdentifierAlias.old_id.in_(
[mapping["old_id"] for mapping in replacements]
)
)
)
).scalars()
}
for mapping in replacements:
alias = aliases.get(mapping["old_id"])
if alias is None:
alias = WorkRecordIdentifierAlias(
old_id=mapping["old_id"],
new_id=mapping["new_id"],
record_kind=mapping["kind"],
record_id=mapping["record_id"],
repo_slug=repo_slug,
namespace=FLEET_NAMESPACE,
plan_sha256=plan_sha256,
)
session.add(alias)
aliases[mapping["old_id"]] = alias
elif (
alias.new_id != mapping["new_id"]
or alias.record_kind != mapping["kind"]
or alias.record_id != mapping["record_id"]
or alias.repo_slug != repo_slug
or alias.namespace != FLEET_NAMESPACE
or alias.plan_sha256 != plan_sha256
or alias.migration_status not in {"reversed", "applied"}
):
raise IdentifierMigrationError(
f"conflicting durable alias for {mapping['old_id']}"
)
await session.flush()
for mapping in (
item
for item in replacements
if projection_states[item["record_id"]] == "duplicate_target"
):
result = await session.execute(
text("DELETE FROM tasks WHERE id = :old_id"),
{"old_id": mapping["old_id"]},
)
if result.rowcount != 1:
raise IdentifierMigrationError(
f"failed to coalesce duplicate {mapping['record_id']}"
)
for kind in ("workplan", "task"):
table = "workplans" if kind == "workplan" else "tasks"
for mapping in (
item
for item in replacements
if item["kind"] == kind
and projection_states[item["record_id"]] == "legacy_source"
):
result = await session.execute(
text(f"UPDATE {table} SET id = :new_id WHERE id = :old_id"),
{"old_id": mapping["old_id"], "new_id": mapping["new_id"]},
)
if result.rowcount != 1:
raise IdentifierMigrationError(
f"failed to replace {mapping['record_id']}"
)
now = datetime.now(UTC)
for alias in aliases.values():
alias.migration_status = "applied"
alias.applied_at = now
alias.reversed_at = None
return IdentifierMigrationResult(
repo_slug=repo_slug,
plan_sha256=plan_sha256,
direction="forward",
replacements=len(replacements),
migrated=sum(
state in {"legacy_source", "duplicate_target"}
for state in projection_states.values()
),
already_derived=sum(
state == "derived_target" for state in projection_states.values()
),
assignments_deferred=assignments,
)
async def reverse_repository_identifier_migration(
session: AsyncSession,
plan: dict[str, Any],
repo_slug: str,
) -> IdentifierMigrationResult:
"""Restore one repository's prior projected UUIDs and retain aliases."""
plan_sha256 = verify_plan(plan)
replacements, assignments = _repository_mappings(plan, repo_slug)
if session.in_transaction():
raise IdentifierMigrationError("migration requires a fresh database session")
async with session.begin():
resolution = await resolve_repository_slug(session, repo_slug, required=False)
if resolution is None:
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
aliases = list(
(
await session.execute(
select(WorkRecordIdentifierAlias).where(
WorkRecordIdentifierAlias.plan_sha256 == plan_sha256,
WorkRecordIdentifierAlias.repo_slug == repo_slug,
)
)
).scalars()
)
aliases_by_old = {alias.old_id: alias for alias in aliases}
if len(aliases_by_old) != len(replacements):
raise IdentifierMigrationError("durable alias set is incomplete")
for mapping in replacements:
alias = aliases_by_old.get(mapping["old_id"])
if (
alias is None
or alias.new_id != mapping["new_id"]
or alias.record_kind != mapping["kind"]
or alias.record_id != mapping["record_id"]
or alias.migration_status != "applied"
):
raise IdentifierMigrationError(
f"durable alias is not applied for {mapping['record_id']}"
)
await _assert_projection_preconditions(
session, resolution.repo.id, repo_slug, replacements, reverse=True
)
for kind in ("task", "workplan"):
table = "tasks" if kind == "task" else "workplans"
for mapping in (item for item in replacements if item["kind"] == kind):
result = await session.execute(
text(f"UPDATE {table} SET id = :old_id WHERE id = :new_id"),
{"old_id": mapping["old_id"], "new_id": mapping["new_id"]},
)
if result.rowcount != 1:
raise IdentifierMigrationError(
f"failed to reverse {mapping['record_id']}"
)
now = datetime.now(UTC)
for alias in aliases:
alias.migration_status = "reversed"
alias.reversed_at = now
return IdentifierMigrationResult(
repo_slug=repo_slug,
plan_sha256=plan_sha256,
direction="reverse",
replacements=len(replacements),
migrated=len(replacements),
already_derived=0,
assignments_deferred=assignments,
)

View file

@ -0,0 +1,175 @@
from __future__ import annotations
import re
import socket
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from api.models.managed_repo import ManagedRepo
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
@dataclass(frozen=True)
class WorkplanFileRef:
repo_path: Path
path: Path
archived: bool
@property
def relative_path(self) -> str:
return str(self.path.relative_to(self.repo_path))
def resolve_repo_path(repo: ManagedRepo | None) -> Path | None:
if repo is None:
return None
hostname = socket.gethostname()
host_paths = repo.host_paths or {}
candidates = [host_paths.get(hostname), repo.local_path]
for raw in candidates:
if not raw:
continue
path = Path(raw).expanduser()
if path.is_dir():
return path
return None
def find_workplan_for_workplan(
repo: ManagedRepo | None,
workplan_id: uuid.UUID,
) -> WorkplanFileRef | None:
repo_path = resolve_repo_path(repo)
if repo_path is None:
return None
workplans_dir = repo_path / "workplans"
for directory, archived in (
(workplans_dir, False),
(workplans_dir / "archived", True),
):
if not directory.is_dir():
continue
for path in sorted(directory.glob("*.md")):
meta = _frontmatter(path)
file_id = meta.get("state_hub_workplan_id") or meta.get("state_hub_workstream_id")
if str(file_id or "").strip().strip('"') == str(workplan_id):
return WorkplanFileRef(repo_path=repo_path, path=path, archived=archived)
return None
find_workplan_for_workstream = find_workplan_for_workplan
def task_block_linked(path: Path, task_id: uuid.UUID) -> bool:
return _task_block_for_task(path, task_id) is not None
def workplan_status(path: Path) -> str | None:
status = _frontmatter(path).get("status")
return str(status).strip() if status is not None else None
def task_block_status(path: Path, task_id: uuid.UUID) -> str | None:
meta = _task_block_for_task(path, task_id)
if meta is None:
return None
status = meta.get("status")
return str(status).strip() if status is not None else None
def patch_workplan_status(path: Path, status: str) -> bool:
return _patch_frontmatter_field(path, "status", status)
def patch_task_status(path: Path, task_id: uuid.UUID, status: str) -> bool:
text = path.read_text(encoding="utf-8")
def _replace(match: re.Match) -> str:
block = match.group(0)
meta = _parse_task_block(match.group(1))
if str(meta.get("state_hub_task_id", "")).strip().strip('"') != str(task_id):
return block
replaced = re.sub(
r"^(status:\s*)\S+",
rf"\g<1>{status}",
block,
count=1,
flags=re.MULTILINE,
)
if replaced != block:
return replaced
return block.replace("\n```", f"\nstatus: {status}\n```", 1)
new_text = _TASK_BLOCK_RE.sub(_replace, text)
if new_text == text:
return False
path.write_text(new_text, encoding="utf-8")
return True
def _frontmatter(path: Path) -> dict[str, Any]:
try:
text = path.read_text(encoding="utf-8")
except OSError:
return {}
if not text.startswith("---"):
return {}
parts = text.split("---", 2)
if len(parts) < 3:
return {}
try:
return yaml.safe_load(parts[1].strip()) or {}
except yaml.YAMLError:
return {}
def _patch_frontmatter_field(path: Path, key: str, value: str) -> bool:
text = path.read_text(encoding="utf-8")
if not text.startswith("---"):
return False
lines = text.split("\n")
close_idx = None
for i, line in enumerate(lines[1:], 1):
if line.strip() == "---":
close_idx = i
break
if close_idx is None:
return False
new_line = f"{key}: {value}"
for i in range(1, close_idx):
if re.match(rf"^\s*{re.escape(key)}\s*:", lines[i]):
if lines[i] == new_line:
return False
lines[i] = new_line
path.write_text("\n".join(lines), encoding="utf-8")
return True
lines.insert(close_idx, new_line)
path.write_text("\n".join(lines), encoding="utf-8")
return True
def _task_block_for_task(path: Path, task_id: uuid.UUID) -> dict[str, Any] | None:
try:
text = path.read_text(encoding="utf-8")
except OSError:
return None
for match in _TASK_BLOCK_RE.finditer(text):
meta = _parse_task_block(match.group(1))
if str(meta.get("state_hub_task_id", "")).strip().strip('"') == str(task_id):
return meta
return None
def _parse_task_block(raw: str) -> dict[str, Any]:
try:
return yaml.safe_load(raw.strip()) or {}
except yaml.YAMLError:
return {}

View file

@ -0,0 +1,227 @@
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from api.database import async_session_factory
from api.models.write_idempotency_key import WriteIdempotencyKey
IDEMPOTENCY_HEADER = b"idempotency-key"
REPLAY_HEADER = "X-StateHub-Idempotency-Replay"
CONFLICT_STATUS = 409
DEFAULT_IDEMPOTENCY_TTL_DAYS = 14
@dataclass(frozen=True)
class WriteRouteRule:
method: str
pattern: str
route_class: str
description: str
def matches(self, method: str, path: str) -> bool:
normalized = path.rstrip("/") or "/"
return self.method == method.upper() and re.fullmatch(self.pattern, normalized) is not None
WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = (
WriteRouteRule("POST", r"/progress", "append", "append progress event"),
WriteRouteRule("POST", r"/messages", "append", "send agent message"),
WriteRouteRule("PATCH", r"/messages/[^/]+/read", "append", "mark known message read"),
WriteRouteRule("POST", r"/token-events", "append", "record token event"),
WriteRouteRule("POST", r"/token-events/upsert", "append", "upsert token event"),
WriteRouteRule("POST", r"/decisions", "append", "record decision"),
WriteRouteRule("PATCH", r"/tasks/[^/]+", "replace", "update task"),
WriteRouteRule("POST", r"/tasks/bulk-status-sync", "replace", "bulk task status sync"),
WriteRouteRule(
"POST",
r"/repos/[^/]+/work-record-projection/reconcile",
"replace",
"reconcile one forge-derived repository projection",
),
WriteRouteRule("PATCH", r"/decisions/[^/]+", "replace", "update decision"),
WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"),
WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"),
WriteRouteRule("PATCH", r"/workstreams/[^/]+", "replace", "update legacy workstream alias"),
)
def route_rule_for(method: str, path: str) -> WriteRouteRule | None:
for rule in WRITE_ROUTE_RULES:
if rule.matches(method, path):
return rule
return None
def route_class_for(method: str, path: str) -> str | None:
rule = route_rule_for(method, path)
return rule.route_class if rule else None
def canonical_request_hash(method: str, path: str, query_string: bytes, body: bytes) -> str:
try:
parsed: Any = json.loads(body.decode("utf-8")) if body else None
body_repr = json.dumps(parsed, sort_keys=True, separators=(",", ":"))
except (UnicodeDecodeError, json.JSONDecodeError):
body_repr = body.hex()
query = query_string.decode("utf-8", errors="replace")
seed = f"{method.upper()}\n{path}\n{query}\n{body_repr}".encode("utf-8")
return hashlib.sha256(seed).hexdigest()
def _header_value(headers: list[tuple[bytes, bytes]], name: bytes) -> str | None:
lname = name.lower()
for key, value in headers:
if key.lower() == lname:
return value.decode("utf-8", errors="replace")
return None
async def _send_json_response(response: JSONResponse, scope: Scope, receive: Receive, send: Send) -> None:
await response(scope, receive, send)
class WriteIdempotencyMiddleware:
"""Replay exact duplicate write requests carrying Idempotency-Key.
The middleware is intentionally narrow: it only participates on the offline
relay allowlist. Non-allowlisted routes keep their normal behavior even if a
caller sends an Idempotency-Key header.
"""
def __init__(self, app: ASGIApp, *, ttl_days: int = DEFAULT_IDEMPOTENCY_TTL_DAYS) -> None:
self.app = app
self.ttl_days = ttl_days
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
method = str(scope.get("method", "")).upper()
path = str(scope.get("path", ""))
rule = route_rule_for(method, path)
headers = list(scope.get("headers") or [])
key = _header_value(headers, IDEMPOTENCY_HEADER)
if rule is None or not key:
await self.app(scope, receive, send)
return
body = await self._read_body(receive)
request_hash = canonical_request_hash(method, path, scope.get("query_string", b""), body)
source_host = _header_value(headers, b"x-statehub-source-host")
source_agent = _header_value(headers, b"x-statehub-source-agent")
async with async_session_factory() as session:
existing = (await session.execute(
select(WriteIdempotencyKey).where(WriteIdempotencyKey.key == key)
)).scalar_one_or_none()
if existing is not None:
existing.last_seen_at = datetime.now(tz=timezone.utc)
await session.commit()
if existing.request_hash != request_hash:
await _send_json_response(
JSONResponse(
status_code=CONFLICT_STATUS,
content={
"error": "Idempotency-Key was reused with a different request",
"idempotency_key": key,
},
),
scope,
self._receive_from_body(body),
send,
)
return
await _send_json_response(
JSONResponse(
status_code=existing.response_status,
content=existing.response_body,
headers={REPLAY_HEADER: "true"},
),
scope,
self._receive_from_body(body),
send,
)
return
start_message: Message | None = None
body_parts: list[bytes] = []
async def capture_send(message: Message) -> None:
nonlocal start_message
if message["type"] == "http.response.start":
start_message = message
elif message["type"] == "http.response.body":
body_parts.append(message.get("body", b""))
await send(message)
await self.app(scope, self._receive_from_body(body), capture_send)
if start_message is None:
return
status = int(start_message.get("status", 500))
if status < 200 or status >= 300:
return
response_body_bytes = b"".join(body_parts)
try:
response_body = json.loads(response_body_bytes.decode("utf-8")) if response_body_bytes else None
except (UnicodeDecodeError, json.JSONDecodeError):
return
async with async_session_factory() as session:
existing = (await session.execute(
select(WriteIdempotencyKey).where(WriteIdempotencyKey.key == key)
)).scalar_one_or_none()
if existing is not None:
return
now = datetime.now(tz=timezone.utc)
session.add(WriteIdempotencyKey(
key=key,
method=method,
path=path,
route_class=rule.route_class,
request_hash=request_hash,
response_status=status,
response_body=response_body,
source_host=source_host,
source_agent=source_agent,
first_seen_at=now,
last_seen_at=now,
expires_at=now + timedelta(days=self.ttl_days),
))
await session.commit()
@staticmethod
async def _read_body(receive: Receive) -> bytes:
chunks: list[bytes] = []
while True:
message = await receive()
if message["type"] != "http.request":
continue
chunks.append(message.get("body", b""))
if not message.get("more_body", False):
break
return b"".join(chunks)
@staticmethod
def _receive_from_body(body: bytes) -> Receive:
sent = False
async def receive() -> Message:
nonlocal sent
if sent:
return {"type": "http.request", "body": b"", "more_body": False}
sent = True
return {"type": "http.request", "body": body, "more_body": False}
return receive

View file

@ -0,0 +1,84 @@
from __future__ import annotations
from typing import Any
CANONICAL_TASK_STATUSES: tuple[str, ...] = (
"wait",
"todo",
"progress",
"done",
"cancel",
)
TASK_STATUS_CODES: dict[str, str] = {
"WAIT": "wait",
"TODO": "todo",
"PROG": "progress",
"DONE": "done",
"CNCL": "cancel",
}
LEGACY_TASK_STATUS_ALIASES: dict[str, str] = {
"blocked": "wait",
"waiting": "wait",
"in_progress": "progress",
"in-progress": "progress",
"prog": "progress",
"cancelled": "cancel",
"canceled": "cancel",
"cncl": "cancel",
}
OPEN_TASK_STATUSES: frozenset[str] = frozenset({"wait", "todo", "progress"})
ACTIVE_TASK_STATUSES: frozenset[str] = frozenset({"wait", "progress"})
TERMINAL_TASK_STATUSES: frozenset[str] = frozenset({"done", "cancel"})
TASK_STATUS_ORDER: dict[str, int] = {
"todo": 0,
"wait": 1,
"progress": 1,
"done": 2,
"cancel": 2,
}
TASK_STATUS_LABELS: dict[str, str] = {
"wait": "wait",
"todo": "todo",
"progress": "progress",
"done": "done",
"cancel": "cancel",
}
def raw_status_value(status: Any) -> str:
if hasattr(status, "value"):
status = status.value
return str(status or "").strip()
def normalize_task_status(status: Any, *, default: str | None = None) -> str:
"""Normalize canon task statuses plus legacy aliases to stored values."""
value = raw_status_value(status).lower()
if not value:
if default is not None:
return default
raise ValueError("task status is required")
value = LEGACY_TASK_STATUS_ALIASES.get(value, value)
if value not in CANONICAL_TASK_STATUSES:
allowed = ", ".join(CANONICAL_TASK_STATUSES)
aliases = ", ".join(sorted(LEGACY_TASK_STATUS_ALIASES))
raise ValueError(f"task status must be one of {allowed}; legacy aliases: {aliases}")
return value
def status_value(status: Any, *, default: str | None = None) -> str:
"""Compatibility wrapper used by lifecycle and reconciliation code."""
return normalize_task_status(status, default=default)
def is_open_task_status(status: Any) -> bool:
return normalize_task_status(status, default="todo") in OPEN_TASK_STATUSES
def is_terminal_task_status(status: Any) -> bool:
return normalize_task_status(status, default="todo") in TERMINAL_TASK_STATUSES

View file

@ -0,0 +1,48 @@
"""Work-record flavor (STATE-WP-0092).
Flavor is a closed bucket on workplans and tasks, orthogonal to kind and
status. Unset flavor is not residual.
"""
from __future__ import annotations
from typing import Any
WORK_RECORD_FLAVORS: tuple[str, ...] = (
"planning",
"implementation",
"refactoring",
"extension",
"residual",
)
RESIDUAL_FLAVOR = "residual"
FLAVOR_PROMOTION_REASONS: tuple[str, ...] = ("demand", "risk")
_EMPTY = {"", "~", "null", "none", "nil"}
def normalize_flavor(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip().lower()
if text in _EMPTY:
return None
return text
def is_known_flavor(value: Any) -> bool:
flavor = normalize_flavor(value)
return flavor is None or flavor in WORK_RECORD_FLAVORS
def is_residual_flavor(value: Any) -> bool:
return normalize_flavor(value) == RESIDUAL_FLAVOR
def normalize_promotion_reason(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip().lower()
if text in _EMPTY:
return None
return text

View file

@ -0,0 +1,190 @@
from __future__ import annotations
import fnmatch
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any
CANONICAL_WORKPLAN_STATUSES: tuple[str, ...] = (
"proposed",
"ready",
"active",
"blocked",
"backlog",
"finished",
"archived",
)
LEGACY_WORKPLAN_STATUS_ALIASES: dict[str, str] = {
"todo": "ready",
"done": "finished",
"completed": "finished",
"accepted": "finished",
}
SUPPORTED_WORKPLAN_STATUSES: tuple[str, ...] = (
*CANONICAL_WORKPLAN_STATUSES,
*LEGACY_WORKPLAN_STATUS_ALIASES.keys(),
)
OPEN_WORKPLAN_STATUSES: tuple[str, ...] = ("ready", "active", "blocked")
CURRENT_WORKPLAN_STATUSES: tuple[str, ...] = ("active", "blocked")
CLOSED_WORKPLAN_STATUSES: tuple[str, ...] = ("finished", "archived")
PLANNING_WORKPLAN_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog")
# Legacy aliases (workstream terminology)
CANONICAL_WORKSTREAM_STATUSES = CANONICAL_WORKPLAN_STATUSES
LEGACY_WORKSTREAM_STATUS_ALIASES = LEGACY_WORKPLAN_STATUS_ALIASES
SUPPORTED_WORKSTREAM_STATUSES = SUPPORTED_WORKPLAN_STATUSES
OPEN_WORKSTREAM_STATUSES = OPEN_WORKPLAN_STATUSES
CURRENT_WORKSTREAM_STATUSES = CURRENT_WORKPLAN_STATUSES
CLOSED_WORKSTREAM_STATUSES = CLOSED_WORKPLAN_STATUSES
PLANNING_WORKSTREAM_STATUSES = PLANNING_WORKPLAN_STATUSES
@dataclass(frozen=True)
class ReadyReviewStatus:
needs_review: bool
reason: str = ""
changed_paths: tuple[str, ...] = ()
def normalize_workplan_status(status: Any, *, has_started: bool | None = None) -> str:
"""Return the canonical lifecycle status for a stored or legacy value."""
value = _status_value(status)
if value == "todo" and has_started:
return "active"
return LEGACY_WORKPLAN_STATUS_ALIASES.get(value, value)
normalize_workstream_status = normalize_workplan_status
def is_canonical_workplan_status(status: Any) -> bool:
return _status_value(status) in CANONICAL_WORKPLAN_STATUSES
is_canonical_workstream_status = is_canonical_workplan_status
def is_supported_workplan_status(status: Any) -> bool:
return _status_value(status) in SUPPORTED_WORKPLAN_STATUSES
is_supported_workstream_status = is_supported_workplan_status
def workplan_has_started(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
return any(_status_value(status) not in {"", "todo"} for status in task_statuses)
workstream_has_started = workplan_has_started
def ready_review_status(
repo_dir: str | Path,
reviewed_against_commit: Any,
context_paths: Any = None,
) -> ReadyReviewStatus:
"""Return whether a ready workplan needs review against current repo state.
When context paths are supplied, only changes under those paths matter.
Missing or invalid git metadata is treated conservatively as needs review.
"""
reviewed = str(reviewed_against_commit or "").strip().strip("\"'")
if not reviewed:
return ReadyReviewStatus(False)
repo = Path(repo_dir)
head = _git_output(repo, ["rev-parse", "HEAD"])
if not head:
return ReadyReviewStatus(True, "could not determine repository HEAD")
if reviewed == head:
return ReadyReviewStatus(False)
if not _git_commit_exists(repo, reviewed):
return ReadyReviewStatus(True, f"review commit {reviewed[:12]} is not available")
patterns = _as_list(context_paths)
if not patterns:
return ReadyReviewStatus(
True,
f"reviewed against {reviewed[:12]}, current HEAD is {head[:12]}",
)
changed = _changed_paths_since(repo, reviewed)
if changed is None:
return ReadyReviewStatus(True, "could not compare reviewed commit with HEAD")
matching = tuple(path for path in changed if _matches_any_context(path, patterns))
if not matching:
return ReadyReviewStatus(False)
return ReadyReviewStatus(
True,
f"{len(matching)} context path(s) changed since {reviewed[:12]}",
matching,
)
def _status_value(status: Any) -> str:
if hasattr(status, "value") and not isinstance(status, (str, bytes, bytearray)):
status = status.value
return str(status or "").strip().lower()
def _as_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, (list, tuple, set)):
return [str(item).strip().replace("\\", "/") for item in value if str(item).strip()]
if isinstance(value, str):
return [item.strip().replace("\\", "/") for item in value.split(",") if item.strip()]
return [str(value).strip().replace("\\", "/")]
def _git_output(repo: Path, args: list[str]) -> str | None:
try:
return subprocess.check_output(
["git", "-C", str(repo), *args],
stderr=subprocess.DEVNULL,
text=True,
).strip()
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return None
def _git_commit_exists(repo: Path, commit: str) -> bool:
try:
subprocess.run(
["git", "-C", str(repo), "cat-file", "-e", f"{commit}^{{commit}}"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return True
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return False
def _changed_paths_since(repo: Path, commit: str) -> tuple[str, ...] | None:
output = _git_output(repo, ["diff", "--name-only", f"{commit}..HEAD", "--"])
if output is None:
return None
return tuple(path.strip().replace("\\", "/") for path in output.splitlines() if path.strip())
def _matches_any_context(path: str, patterns: list[str]) -> bool:
norm_path = path.strip().replace("\\", "/").lstrip("./")
for raw_pattern in patterns:
pattern = raw_pattern.strip().replace("\\", "/").lstrip("./")
if not pattern:
continue
if any(char in pattern for char in "*?[]"):
if fnmatch.fnmatch(norm_path, pattern):
return True
elif norm_path == pattern or norm_path.startswith(f"{pattern.rstrip('/')}/"):
return True
return False

View file

@ -0,0 +1,177 @@
---
id: RAIL-BS-WP-0002
type: workplan
title: "k3s and Kubernetes Platform Baseline"
domain: financials
repo: railiance-cluster
status: finished
owner: railiance
topic_slug: railiance
repo_goal_id: "70ab2379-fb9d-4fec-a09d-b2a717e4ace8"
state_hub_workstream_id: "4c63dfc6-9eac-4e79-9f77-8f644ad7147d"
created: "2026-03-09"
updated: "2026-03-10"
completed: "2026-03-10"
---
> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
# k3s and Kubernetes Platform Baseline
## Goal
Install k3s, Helm, and the baseline Kubernetes services on the converged
HostEurope node. This workplan picks up exactly where `railiance-hosts`
leaves off: a hardened, verified OS node that is ready for Kubernetes.
## Pre-condition
`railiance-infra` converge + Goss verify must pass before any task here
is executed:
```bash
# In railiance-infra/
make converge
make verify # must exit 0
```
## Boundary
This repo owns everything from k3s upward. It must not re-configure items
defined in `railiance-infra/spec/server-baseline.yaml`. See ADR-003:
`railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`.
**Out of scope here:** platform services (PostgreSQL, storage, identity)
→ `railiance-platform`. Application deployments (Gitea, coulomb services)
→ `railiance-apps`.
---
## Tasks
### T01 — Ansible playbook: install k3s (server mode)
```task
id: T01
status: done
priority: high
state_hub_task_id: "3f042630-eab0-4c6a-9167-e2b28ff20e40"
completed: "2026-03-10"
```
Harden `ansible/bootstrap.yml` to a production-ready k3s install:
- Use the official k3s install script pinned to a specific version
(`INSTALL_K3S_VERSION=v1.35.1+k3s1`)
- `INSTALL_K3S_EXEC="server --cluster-init --write-kubeconfig-mode=644"`
(cluster-init enables embedded etcd for future HA expansion)
- Wait for node `Ready` before proceeding:
```bash
k3s kubectl wait node --all --for=condition=Ready --timeout=120s
```
- Fetch kubeconfig to the control node as `~/.kube/config-hosteurope`
**Done when:** `k3s kubectl get nodes` returns `Ready` from both the server
and the control node (via kubeconfig).
---
### T02 — Helm installation
```task
id: T02
status: done
priority: high
state_hub_task_id: "e8510646-46ed-4697-a345-f3d3009eea78"
completed: "2026-03-10"
```
Add a task (or a role `roles/helm/`) that:
1. Downloads the Helm binary (pinned version) to `/usr/local/bin/helm`
2. Verifies the checksum
3. Confirms `helm version` succeeds
**Done when:** `helm version` succeeds on the HostEurope node.
---
### T03 — Smoke test: k3s + Helm
```task
id: T03
status: done
priority: high
state_hub_task_id: "dab2c07f-8aa0-4635-8df6-857e87e93fc5"
completed: "2026-03-10"
```
Extend `tests/smoke_kube.sh` to assert:
- `k3s kubectl get nodes` → node in Ready state
- `helm version` exits 0
- CoreDNS pod running in `kube-system`
- Traefik ingress controller pod running (default in k3s)
Run via:
```bash
ansible-playbook -i ansible/hosts.ini ansible/smoke.yml
```
or directly over SSH if the kubeconfig is available locally.
**Done when:** all assertions pass and the script exits 0.
---
### T04 — Commit kubeconfig management notes
```task
id: T04
status: done
priority: medium
state_hub_task_id: "5c3d40e4-239b-488e-9519-6f7a38d2325f"
completed: "2026-03-10"
```
Document in `docs/kubeconfig.md`:
- Where the kubeconfig is fetched to (`~/.kube/config-hosteurope`)
- How to merge it into `~/.kube/config`
- How to switch context: `kubectl config use-context default`
- Security note: kubeconfig is gitignored (contains cluster CA + client cert)
**Done when:** doc written and committed.
---
### T05 — Add `make k3s-install` and `make smoke` targets
```task
id: T05
status: done
priority: medium
state_hub_task_id: "7f9e0e58-a130-467a-a2d0-b3f2564e496f"
completed: "2026-03-10"
```
Add to Makefile (create one if none exists):
```makefile
k3s-install: ## Install k3s and Helm on all inventory hosts
ansible-playbook -i ansible/hosts.ini ansible/bootstrap.yml
smoke: ## Run Kubernetes smoke tests
bash tests/smoke_kube.sh
```
**Done when:** both targets work and are listed in `make help`.
---
## References
- Repo goal: `70ab2379-fb9d-4fec-a09d-b2a717e4ace8` (Install k3s and Kubernetes Baseline)
- Domain goal: `6f96c712-60e6-4ea9-ab06-168878eafbce` (Three-Phoenix Secure Kubernetes Infrastructure)
- Pre-condition: railiance-infra WP-0001 (Secure Single-Server Bootstrap) — completed 2026-03-09
- Boundary ADR: `railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`
- k3s releases: https://github.com/k3s-io/k3s/releases

View file

@ -0,0 +1,196 @@
---
id: RAIL-BS-WP-0003
type: workplan
title: "pgpool CrashLoopBackOff on PostgreSQL HA failover — missing secret key"
domain: financials
repo: railiance-cluster
status: finished
owner: tegwick
created: "2026-03-10"
updated: "2026-03-10"
state_hub_workstream_id: "7ee9ee22-1fae-4567-9194-8d70a9e0f45b"
---
> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
# Bug Report: pgpool CrashLoopBackOff on PostgreSQL HA failover
## Summary
On 2026-03-10 a PostgreSQL HA failover caused all three postgresql pods to
restart. pgpool — the connection pooler between Gitea and PostgreSQL — then
entered CrashLoopBackOff and produced no logs. As a result Gitea's login
and all write operations hung indefinitely. The root page was still served
(from Valkey cache) which masked the failure.
The fix was to patch a missing key in a Kubernetes secret. The root cause is
that the `gitea-12.2.0` Helm chart (postgresql-ha subchart v16.2.2) does not
populate the `pgpool-password` key in the `gitea-postgresql-ha-postgresql`
secret, even though the pgpool pod requires it at startup.
---
## Timeline
| Time (UTC) | Event |
|---|---|
| ~09:45 | postgresql-0, postgresql-2 pods restarted (repmgr failover) |
| ~09:45 | pgpool pod restarted and entered CrashLoopBackOff |
| ~11:00 | User noticed Gitea login hanging; home page still loading |
| ~13:00 | Root cause identified: missing `pgpool-password` secret key |
| ~13:10 | Secret patched; pgpool pod deleted and restarted cleanly |
| ~13:15 | Gitea fully operational |
---
## Root Cause
The Bitnami `pgpool` container startup script reads the file
`/opt/bitnami/pgpool/secrets/pgpool-password`, which is mounted from the
`gitea-postgresql-ha-postgresql` Kubernetes Secret via a `subPath` volume
mount. That secret key was never created by the Helm chart, so the file did
not exist. The container exited immediately with no logs.
The pod had been running for 20 days without a restart, so this gap was
never discovered during initial deployment.
---
## Evidence
```bash
# Secret was missing the pgpool-password key
sudo k3s kubectl get secret -n default gitea-postgresql-ha-postgresql -o yaml
# data: keys were password, postgres-password, repmgr-password only
# pgpool-password was absent
# pgpool pod describe showed 824 back-off restarts over 173 minutes
# No logs in either current or --previous output
sudo k3s kubectl logs -n default <pgpool-pod> --previous
# (empty)
# Gitea process had zero TCP connections to PostgreSQL port 5432
# but many connections to Valkey port 6379
cat /proc/<gitea-pid>/net/tcp | grep 1538 # 1538 = 5432 hex — no results
```
---
## Immediate Fix Applied
```bash
# Add the missing key (value = sr-check-password = changeme4 = base64: Y2hhbmdlbWU0)
sudo k3s kubectl patch secret -n default gitea-postgresql-ha-postgresql \
--type='json' \
-p='[{"op":"add","path":"/data/pgpool-password","value":"Y2hhbmdlbWU0"}]'
# Restart pgpool
sudo k3s kubectl delete pod -n default <pgpool-pod-name>
```
---
## Risk: Fix Will Be Lost on helm upgrade
The patched secret is managed by Helm (annotation:
`meta.helm.sh/release-name: gitea`). A `helm upgrade` will regenerate the
secret from the chart template, which does not include `pgpool-password`,
and the bug will recur.
---
## Tasks
### T01 — Add pgpool-password to Helm values
```task
id: T01
status: done
priority: high
state_hub_task_id: "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc"
```
Create or update `helm/gitea-values.yaml` (or equivalent) to permanently
include the pgpool-password so it survives `helm upgrade`:
```yaml
postgresql-ha:
postgresql:
pgpoolPassword: <value matching sr-check-password>
```
**Done when:** `helm upgrade gitea` completes and pgpool starts cleanly
without manual secret patching.
---
### T02 — Add pgpool health check to smoke test
```task
id: T02
status: done
priority: high
state_hub_task_id: "ab166073-30a7-4702-a037-4091e8706e20"
```
Extend `tests/smoke_kube.sh` to assert:
```bash
# All postgresql-ha pods Running
kubectl get pods -n default | grep gitea-postgresql-ha | grep -v Running && exit 1
# pgpool specifically not in CrashLoopBackOff
kubectl get pod -n default -l app.kubernetes.io/component=pgpool \
-o jsonpath='{.items[0].status.containerStatuses[0].state}' | grep -v crash
```
**Done when:** the smoke test catches a pgpool failure within 5 minutes.
---
### T03 — Add HA failover test
```task
id: T03
status: done
priority: high
state_hub_task_id: "140da396-8e30-4f4d-b88c-c42c0cd46c01"
```
Create `tests/test_ha_failover.sh` that:
1. Records Gitea login response time (baseline)
2. Kills the primary PostgreSQL pod: `kubectl delete pod gitea-postgresql-ha-postgresql-0 -n default`
3. Waits for repmgr to promote a replica (max 60s)
4. Asserts Gitea login POST still succeeds within 10s
5. Asserts pgpool pod is Running (not CrashLoopBackOff)
6. Asserts all postgresql pods return to Running
This test must pass before any PostgreSQL HA deployment is considered done.
**Done when:** script exits 0 against a live cluster.
---
### T04 — Document the incident in docs/
```task
id: T04
status: done
priority: medium
state_hub_task_id: "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d"
```
Add `docs/incidents/2026-03-10-pgpool-missing-secret.md` with the full
timeline, root cause, and fix, so future operators understand what happened
and how to recover.
**Done when:** doc committed and linked from `docs/README.md`.
---
## References
- Bitnami postgresql-ha chart v16.2.2
- Gitea Helm chart v12.2.0
- Related decision: D3 (HA testing policy) in `DECISIONS.md`