docs: preserve reviewed NetKingdom session artifacts
This commit is contained in:
parent
3402090e5a
commit
bd041baf9b
50 changed files with 6347 additions and 0 deletions
|
|
@ -0,0 +1,149 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class FabricGraphImport(Base, TimestampMixin):
|
||||
__tablename__ = "fabric_graph_imports"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"source_repo_slug",
|
||||
"content_hash",
|
||||
name="uq_fabric_graph_imports_source_hash",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
source_repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_commit: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
source_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
api_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
schema_version: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
export_kind: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
exported_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
netkingdom_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
king_actor_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
actor_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
fabric_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
unresolved_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
compatibility: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
node_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
edge_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
validation_status: Mapped[str] = mapped_column(String(20), nullable=False, default="valid", server_default="valid", index=True)
|
||||
error_details: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
graph_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||
is_latest: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false", index=True)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
nodes: Mapped[list["FabricGraphNode"]] = relationship(
|
||||
"FabricGraphNode", back_populates="import_run", cascade="all, delete-orphan", lazy="select"
|
||||
)
|
||||
edges: Mapped[list["FabricGraphEdge"]] = relationship(
|
||||
"FabricGraphEdge", back_populates="import_run", cascade="all, delete-orphan", lazy="select"
|
||||
)
|
||||
|
||||
|
||||
class FabricGraphNode(Base):
|
||||
__tablename__ = "fabric_graph_nodes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("import_id", "graph_id", name="uq_fabric_graph_nodes_import_graph_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
import_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("fabric_graph_imports.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
source_repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
graph_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
domain_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
lifecycle: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
canonical_type: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
canon_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
canon_anchor: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
mapping_fit: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
evidence_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
evidence_review_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
evidence_confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
netkingdom_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
fabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
subfabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
environment: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
deployment_scenario_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
owner_actor_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
owner_role: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
ownership_resolution: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
cost_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
profit_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
display_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false", index=True)
|
||||
attributes: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||
raw_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||
|
||||
import_run: Mapped["FabricGraphImport"] = relationship("FabricGraphImport", back_populates="nodes")
|
||||
|
||||
|
||||
class FabricGraphEdge(Base):
|
||||
__tablename__ = "fabric_graph_edges"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("import_id", "edge_key", name="uq_fabric_graph_edges_import_edge_key"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
import_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("fabric_graph_imports.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
source_repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
edge_key: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
from_graph_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
to_graph_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
edge_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
canonical_type: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
canon_anchor: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
mapping_fit: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
evidence_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
evidence_review_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
evidence_confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
relationship_category: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
|
||||
provider_owner_actor_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
provider_fabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
provider_subfabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
consumer_owner_actor_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
consumer_fabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
consumer_subfabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
crosses_fabric_boundary: Mapped[bool | None] = mapped_column(Boolean, nullable=True, index=True)
|
||||
crosses_subfabric_boundary: Mapped[bool | None] = mapped_column(Boolean, nullable=True, index=True)
|
||||
utility_type: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
utility_contract_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
utility_payment_schema_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
utility_metering_basis: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
utility_business_model: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
cost_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
profit_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
provider_profit_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
consumer_cost_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
display_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false", index=True)
|
||||
attributes: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||
raw_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||
|
||||
import_run: Mapped["FabricGraphImport"] = relationship("FabricGraphImport", back_populates="edges")
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid7
|
||||
|
||||
|
||||
class IntakeLane(str, enum.Enum):
|
||||
green = "green"
|
||||
blue = "blue"
|
||||
yellow = "yellow"
|
||||
orange = "orange"
|
||||
red = "red"
|
||||
|
||||
|
||||
class IntakeStatus(str, enum.Enum):
|
||||
open = "open"
|
||||
vetted = "vetted"
|
||||
routed = "routed"
|
||||
closed = "closed"
|
||||
|
||||
|
||||
class IntakeOutcome(str, enum.Enum):
|
||||
promoted = "promoted"
|
||||
declined = "declined"
|
||||
absorbed = "absorbed"
|
||||
|
||||
|
||||
OPEN_INTAKE_STATUSES = (IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed)
|
||||
|
||||
|
||||
class Intake(Base, TimestampMixin):
|
||||
"""A `kind: intake` work record — a spark: idea, finding, directive, or
|
||||
request, per canon/standards/work-record-types_v0.1.md. Lifecycle:
|
||||
open -> vetted -> routed -> closed(promoted|declined|absorbed).
|
||||
|
||||
Fresh entity per the founder-reviewed architecture draft (2026-07-20,
|
||||
WorkOrchestrationArchitectureDraft.md §8 item 6): not a rename/reuse of
|
||||
the legacy `suggestions` table.
|
||||
"""
|
||||
|
||||
__tablename__ = "intakes"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"topic_id IS NOT NULL OR workplan_id IS NOT NULL OR repo_id IS NOT NULL",
|
||||
name="ck_intakes_topic_or_workplan_or_repo",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(status != 'closed') OR (outcome IS NOT NULL)",
|
||||
name="ck_intakes_closed_requires_outcome",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(outcome != 'promoted') OR (promoted_to IS NOT NULL)",
|
||||
name="ck_intakes_promoted_requires_promoted_to",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid7
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
lane: Mapped[IntakeLane] = mapped_column(
|
||||
Enum(IntakeLane, name="intakelane"), nullable=False, default=IntakeLane.green
|
||||
)
|
||||
status: Mapped[IntakeStatus] = mapped_column(
|
||||
Enum(IntakeStatus, name="intakestatus"),
|
||||
nullable=False,
|
||||
default=IntakeStatus.open,
|
||||
index=True,
|
||||
)
|
||||
outcome: Mapped[IntakeOutcome | None] = mapped_column(
|
||||
Enum(IntakeOutcome, name="intakeoutcome"), nullable=True
|
||||
)
|
||||
origin: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
origin_ref: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
|
||||
promoted_to: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
source_repo_path: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, doc="Repo-relative path of the source file this record was authored in."
|
||||
)
|
||||
routed_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
notes: Mapped[list["IntakeNote"]] = relationship(
|
||||
"IntakeNote",
|
||||
back_populates="intake",
|
||||
lazy="selectin",
|
||||
order_by="IntakeNote.created_at",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class IntakeNote(Base):
|
||||
__tablename__ = "intake_notes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid7)
|
||||
intake_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("intakes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
intake: Mapped["Intake"] = relationship("Intake", back_populates="notes")
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class InterfaceChange(Base, TimestampMixin):
|
||||
__tablename__ = "interface_changes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
interface_type: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False
|
||||
) # rest_api | mcp_tool | cli | schema | capability
|
||||
change_type: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False
|
||||
) # breaking | additive | deprecation | removal
|
||||
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
affected_paths: Mapped[list] = mapped_column(
|
||||
JSONB, nullable=False, default=list, server_default="[]"
|
||||
)
|
||||
affected_repo_slugs: Mapped[list] = mapped_column(
|
||||
JSONB, nullable=False, default=list, server_default="[]"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="draft", index=True
|
||||
) # draft | published | resolved
|
||||
planned_for: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
published_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
author: Mapped[str] = mapped_column(String(100), nullable=False, default="custodian")
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", lazy="selectin"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_interface_changes_repo_status", "repo_id", "status"),
|
||||
)
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class LegacyInterface(Base, TimestampMixin):
|
||||
__tablename__ = "legacy_interfaces"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
interface_key: Mapped[str] = mapped_column(String(300), nullable=False, unique=True, index=True)
|
||||
interface_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
legacy_since: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||
)
|
||||
replacement_ref: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
owner_component: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False, default="state-hub", server_default="state-hub", index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False, default="legacy", server_default="legacy", index=True
|
||||
)
|
||||
replacement_verified: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="false"
|
||||
)
|
||||
manual_hold: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="false"
|
||||
)
|
||||
hold_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
retired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
usage_buckets: Mapped[list["LegacyInterfaceUsageBucket"]] = relationship( # noqa: F821
|
||||
"LegacyInterfaceUsageBucket",
|
||||
back_populates="legacy_interface",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
class LegacyInterfaceUsageBucket(Base, TimestampMixin):
|
||||
__tablename__ = "legacy_interface_usage_buckets"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"legacy_interface_id",
|
||||
"period_start",
|
||||
"bucket_kind",
|
||||
"bucket_key",
|
||||
name="uq_legacy_usage_bucket",
|
||||
),
|
||||
Index("ix_legacy_usage_interface_period", "legacy_interface_id", "period_start"),
|
||||
Index("ix_legacy_usage_bucket_kind_key", "bucket_kind", "bucket_key"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
legacy_interface_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("legacy_interfaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
period_start: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
bucket_kind: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
bucket_key: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
call_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
|
||||
legacy_interface: Mapped["LegacyInterface"] = relationship( # noqa: F821
|
||||
"LegacyInterface", back_populates="usage_buckets"
|
||||
)
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class ManagedRepo(Base, TimestampMixin):
|
||||
__tablename__ = "managed_repos"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
host_paths: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||
remote_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
git_fingerprint: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
sbom_source: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_sbom_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_state_synced_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
category: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
secondary_domains: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
capability_tags: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
business_stake: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
business_mechanics: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
classified_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
classified_by: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
standard_version: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||
"Domain", back_populates="repos", lazy="selectin"
|
||||
)
|
||||
|
||||
goals: Mapped[list["RepoGoal"]] = relationship( # noqa: F821
|
||||
"RepoGoal", back_populates="repo", lazy="selectin"
|
||||
)
|
||||
|
||||
forge_identity: Mapped["RepositoryForgeIdentity | None"] = relationship( # noqa: F821
|
||||
"RepositoryForgeIdentity",
|
||||
back_populates="repo",
|
||||
lazy="selectin",
|
||||
uselist=False,
|
||||
)
|
||||
|
||||
slug_records: Mapped[list["RepositorySlug"]] = relationship( # noqa: F821
|
||||
"RepositorySlug", back_populates="repo", lazy="selectin"
|
||||
)
|
||||
|
||||
rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship( # noqa: F821
|
||||
"RepositoryRenameOperation", back_populates="repo", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.models.base import Base
|
||||
|
||||
|
||||
class MemoNotificationReceipt(Base):
|
||||
"""Durable receipt for one informed-decision delivery id.
|
||||
|
||||
The delivery id is the sender's stable idempotency key. The body is only
|
||||
memo id, question, class, and submitter. Rows are retained, not expired.
|
||||
"""
|
||||
|
||||
__tablename__ = "memo_notification_receipts"
|
||||
|
||||
delivery_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
body_digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
memo_id: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
question: Mapped[str] = mapped_column(String(400), nullable=False)
|
||||
memo_class: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
submitter: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agent_messages.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
authoritative_review: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False, default="signed-in-overview"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
class ProgressEvent(Base):
|
||||
"""Append-only event log. No updated_at. No DELETE endpoint (constitution §5)."""
|
||||
|
||||
__tablename__ = "progress_events"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="RESTRICT", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
detail: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
session_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||
)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821
|
||||
workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="progress_events") # noqa: F821
|
||||
task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821
|
||||
decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class RepoGoalStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
paused = "paused"
|
||||
completed = "completed"
|
||||
archived = "archived"
|
||||
|
||||
|
||||
class RepoGoal(Base, TimestampMixin):
|
||||
__tablename__ = "repo_goals"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
domain_goal_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("domain_goals.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100, server_default="100")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default=RepoGoalStatus.active.value, server_default="active"
|
||||
)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", back_populates="goals", lazy="selectin"
|
||||
)
|
||||
domain_goal: Mapped["DomainGoal"] = relationship( # noqa: F821
|
||||
"DomainGoal", back_populates="repo_goals", lazy="selectin"
|
||||
)
|
||||
workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
|
||||
"Workplan", back_populates="repo_goal", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def repo_slug(self) -> str:
|
||||
return self.repo.slug if self.repo is not None else ""
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
FORGE_IDENTITY_STATES = ("unverified", "verified")
|
||||
REPOSITORY_SLUG_KINDS = ("canonical", "alias")
|
||||
REPOSITORY_RENAME_PHASES = (
|
||||
"draft",
|
||||
"preflighted",
|
||||
"forge-renamed",
|
||||
"statehub-rebound",
|
||||
"source-synced",
|
||||
"consumers-verified",
|
||||
"completed",
|
||||
"rollback-preflight",
|
||||
"rolled-back",
|
||||
)
|
||||
TERMINAL_REPOSITORY_RENAME_PHASES = ("completed", "rolled-back")
|
||||
|
||||
|
||||
class RepositoryForgeIdentity(Base, TimestampMixin):
|
||||
"""One explicitly verified (or explicitly unverified) Forge identity per repo."""
|
||||
|
||||
__tablename__ = "repository_forge_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("repo_id", name="uq_repository_forge_identities_repo_id"),
|
||||
UniqueConstraint(
|
||||
"id",
|
||||
"repo_id",
|
||||
"verification_state",
|
||||
name="uq_repository_forge_identity_verified_ref",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"provider",
|
||||
"forge_instance",
|
||||
"forge_owner",
|
||||
"forge_repository_id",
|
||||
name="uq_repository_forge_identity_tuple",
|
||||
),
|
||||
CheckConstraint(
|
||||
"verification_state IN ('unverified', 'verified')",
|
||||
name="ck_repository_forge_identity_state",
|
||||
),
|
||||
CheckConstraint(
|
||||
"forge_repository_id IS NULL OR forge_repository_id > 0",
|
||||
name="ck_repository_forge_id_positive",
|
||||
),
|
||||
CheckConstraint(
|
||||
"verification_state != 'verified' OR "
|
||||
"(provider IS NOT NULL AND forge_instance IS NOT NULL "
|
||||
"AND forge_owner IS NOT NULL AND forge_repository_id IS NOT NULL "
|
||||
"AND verified_at IS NOT NULL AND verified_by IS NOT NULL)",
|
||||
name="ck_repository_forge_verified_complete",
|
||||
),
|
||||
CheckConstraint(
|
||||
"verification_state = 'verified' OR "
|
||||
"(verified_at IS NULL AND verified_by IS NULL)",
|
||||
name="ck_repository_forge_unverified_has_no_attestation",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
provider: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
forge_instance: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
forge_owner: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
forge_repository_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
verification_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="unverified", server_default="unverified"
|
||||
)
|
||||
verified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
verified_by: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
verification_evidence: Mapped[dict] = mapped_column(
|
||||
JSONB, nullable=False, default=dict, server_default="{}"
|
||||
)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", back_populates="forge_identity", lazy="selectin"
|
||||
)
|
||||
rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship(
|
||||
"RepositoryRenameOperation",
|
||||
back_populates="forge_identity",
|
||||
lazy="selectin",
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
|
||||
class RepositoryRenameOperation(Base, TimestampMixin):
|
||||
"""Append-preserving journal for one phased canonical-coordinate change."""
|
||||
|
||||
__tablename__ = "repository_rename_operations"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"phase IN ('draft', 'preflighted', 'forge-renamed', "
|
||||
"'statehub-rebound', 'source-synced', 'consumers-verified', "
|
||||
"'completed', 'rollback-preflight', 'rolled-back')",
|
||||
name="ck_repository_rename_phase",
|
||||
),
|
||||
CheckConstraint(
|
||||
"old_slug != new_slug", name="ck_repository_rename_distinct_slugs"
|
||||
),
|
||||
CheckConstraint(
|
||||
"expected_forge_repository_id > 0",
|
||||
name="ck_repository_rename_forge_id_positive",
|
||||
),
|
||||
CheckConstraint(
|
||||
"forge_identity_state = 'verified'",
|
||||
name="ck_repository_rename_verified_identity",
|
||||
),
|
||||
CheckConstraint(
|
||||
"phase = 'draft' OR preflighted_at IS NOT NULL",
|
||||
name="ck_repository_rename_preflight_timestamp",
|
||||
),
|
||||
CheckConstraint(
|
||||
"phase != 'completed' OR completed_at IS NOT NULL",
|
||||
name="ck_repository_rename_completed_timestamp",
|
||||
),
|
||||
CheckConstraint(
|
||||
"phase != 'rolled-back' OR rolled_back_at IS NOT NULL",
|
||||
name="ck_repository_rename_rollback_timestamp",
|
||||
),
|
||||
Index(
|
||||
"uq_repository_rename_active_repo",
|
||||
"repo_id",
|
||||
unique=True,
|
||||
postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"),
|
||||
),
|
||||
Index(
|
||||
"uq_repository_rename_active_new_slug",
|
||||
"new_slug",
|
||||
unique=True,
|
||||
postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"),
|
||||
),
|
||||
Index(
|
||||
"ix_repository_rename_phase", "phase"
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["forge_identity_id", "repo_id", "forge_identity_state"],
|
||||
[
|
||||
"repository_forge_identities.id",
|
||||
"repository_forge_identities.repo_id",
|
||||
"repository_forge_identities.verification_state",
|
||||
],
|
||||
name="fk_repository_rename_verified_identity",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
forge_identity_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=False
|
||||
)
|
||||
forge_identity_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="verified", server_default="verified"
|
||||
)
|
||||
expected_provider: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
expected_forge_instance: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
expected_forge_owner: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
expected_forge_repository_id: Mapped[int] = mapped_column(
|
||||
BigInteger, nullable=False
|
||||
)
|
||||
expected_source_commit: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
expected_default_branch: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
old_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
new_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
old_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
new_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
phase: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="draft", server_default="draft"
|
||||
)
|
||||
actor: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
phase_changed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
preflighted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
preflight_expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
rolled_back_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
evidence: Mapped[dict] = mapped_column(
|
||||
JSONB, nullable=False, default=dict, server_default="{}"
|
||||
)
|
||||
error_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error_details: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
error_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", back_populates="rename_operations", lazy="selectin"
|
||||
)
|
||||
forge_identity: Mapped[RepositoryForgeIdentity] = relationship(
|
||||
"RepositoryForgeIdentity",
|
||||
back_populates="rename_operations",
|
||||
lazy="selectin",
|
||||
viewonly=True,
|
||||
)
|
||||
slugs: Mapped[list["RepositorySlug"]] = relationship(
|
||||
"RepositorySlug", back_populates="source_operation", lazy="selectin"
|
||||
)
|
||||
|
||||
|
||||
class RepositorySlug(Base, TimestampMixin):
|
||||
"""Global uniqueness boundary for current and protected prior slugs."""
|
||||
|
||||
__tablename__ = "repository_slugs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("slug", name="uq_repository_slugs_slug"),
|
||||
CheckConstraint(
|
||||
"kind IN ('canonical', 'alias')", name="ck_repository_slug_kind"
|
||||
),
|
||||
CheckConstraint(
|
||||
"kind != 'alias' OR protected",
|
||||
name="ck_repository_slug_alias_protected",
|
||||
),
|
||||
Index(
|
||||
"uq_repository_slugs_one_canonical",
|
||||
"repo_id",
|
||||
unique=True,
|
||||
postgresql_where=text("kind = 'canonical'"),
|
||||
),
|
||||
Index("ix_repository_slugs_repo_kind", "repo_id", "kind"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
protected: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default="true"
|
||||
)
|
||||
source_operation_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("repository_rename_operations.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", back_populates="slug_records", lazy="selectin"
|
||||
)
|
||||
source_operation: Mapped[RepositoryRenameOperation | None] = relationship(
|
||||
"RepositoryRenameOperation", back_populates="slugs", lazy="selectin"
|
||||
)
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid7
|
||||
|
||||
|
||||
class ReviewContract(Base, TimestampMixin):
|
||||
"""Replaceable projection of one authoritative review-contract revision."""
|
||||
|
||||
__tablename__ = "review_contracts"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid7
|
||||
)
|
||||
contract_key: Mapped[str] = mapped_column(String(160), nullable=False, index=True)
|
||||
schema_version: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
contract_digest: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, unique=True
|
||||
)
|
||||
source_repo: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
source_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source_revision: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
document: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
active: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, index=True
|
||||
)
|
||||
required_for_decision: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("decisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="RESTRICT", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
projected_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
|
||||
receipts: Mapped[list["ReviewReceipt"]] = relationship(
|
||||
"ReviewReceipt", back_populates="contract", lazy="selectin"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"contract_key",
|
||||
"source_repo",
|
||||
"source_path",
|
||||
"source_revision",
|
||||
name="uq_review_contract_source_revision",
|
||||
),
|
||||
Index("ix_review_contract_key_active", "contract_key", "active"),
|
||||
)
|
||||
|
||||
|
||||
class ReviewReceipt(Base, TimestampMixin):
|
||||
"""Immutable evidence submitted against an exact contract digest."""
|
||||
|
||||
__tablename__ = "review_receipts"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid7
|
||||
)
|
||||
contract_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("review_contracts.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
owner_id: Mapped[str] = mapped_column(String(160), nullable=False, index=True)
|
||||
actor: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
disposition: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
contract_digest: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
receipt_digest: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
artifact_hashes: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
assertion_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
checks: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_repo: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
source_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source_revision: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
submitted_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
document: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
|
||||
contract: Mapped[ReviewContract] = relationship(
|
||||
"ReviewContract", back_populates="receipts"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"source_repo",
|
||||
"source_path",
|
||||
"source_revision",
|
||||
name="uq_review_receipt_source_revision",
|
||||
),
|
||||
Index(
|
||||
"ix_review_receipt_contract_owner_time",
|
||||
"contract_id",
|
||||
"owner_id",
|
||||
"submitted_at",
|
||||
),
|
||||
)
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
class Ecosystem(str, enum.Enum):
|
||||
python = "python"
|
||||
node = "node"
|
||||
rust = "rust"
|
||||
go = "go"
|
||||
java = "java"
|
||||
terraform = "terraform"
|
||||
ansible = "ansible"
|
||||
tool = "tool"
|
||||
other = "other"
|
||||
|
||||
|
||||
class SBOMEntry(Base):
|
||||
"""Snapshot-based SBOM entry — no updated_at; new ingest replaces old rows."""
|
||||
__tablename__ = "sbom_entries"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
package_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
package_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
ecosystem: Mapped[Ecosystem] = mapped_column(
|
||||
Enum(Ecosystem, name="ecosystem"), nullable=False
|
||||
)
|
||||
license_spdx: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
is_direct: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
is_dev: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
snapshot_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("sbom_snapshots.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
snapshot_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
snapshot: Mapped["SBOMSnapshot"] = relationship( # noqa: F821
|
||||
"SBOMSnapshot", lazy="selectin", back_populates="entries"
|
||||
)
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
class SBOMSnapshot(Base):
|
||||
"""Container entity for a point-in-time SBOM scan of a repository (GEMS Complex)."""
|
||||
__tablename__ = "sbom_snapshots"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
source: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
entry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
entries: Mapped[list["SBOMEntry"]] = relationship( # noqa: F821
|
||||
"SBOMEntry", lazy="select", back_populates="snapshot"
|
||||
)
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
"""Two-dimension service catalog (STATE-WP-0062).
|
||||
|
||||
Every service is classified along two orthogonal dimensions:
|
||||
|
||||
- hosting_type: self_hosted (coulomb operates it) | cloud_hosted (consumed)
|
||||
- development_type: first_party (coulomb develops it) | third_party (external)
|
||||
|
||||
Common fields live in ``ServiceCatalog``; dimension-specific data composes via
|
||||
1:1 extension tables (``service_id`` is both PK and FK), so a self-hosted
|
||||
first-party service carries the self-hosted *and* first-party extensions without
|
||||
needing a bespoke per-class shape.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base
|
||||
|
||||
|
||||
class ServiceCatalog(Base):
|
||||
__tablename__ = "service_catalog"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
owner_or_provider: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
website_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# status: active | deprecated
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="active")
|
||||
# hosting_type: self_hosted | cloud_hosted
|
||||
hosting_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
# development_type: first_party | third_party
|
||||
development_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
# Service DoM Level (1=Operable, 2=Observable, 3=Mature); NULL = unassessed
|
||||
maturity_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
third_party: Mapped["ServiceThirdParty | None"] = relationship(
|
||||
back_populates="service", uselist=False, cascade="all, delete-orphan")
|
||||
first_party: Mapped["ServiceFirstParty | None"] = relationship(
|
||||
back_populates="service", uselist=False, cascade="all, delete-orphan")
|
||||
cloud: Mapped["ServiceCloud | None"] = relationship(
|
||||
back_populates="service", uselist=False, cascade="all, delete-orphan")
|
||||
self_hosted: Mapped["ServiceSelfHosted | None"] = relationship(
|
||||
back_populates="service", uselist=False, cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ServiceThirdParty(Base):
|
||||
"""Extension for development_type = third_party (coulomb is not dev-responsible)."""
|
||||
__tablename__ = "service_third_party"
|
||||
|
||||
service_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True)
|
||||
# pricing_model: free | paid | freemium | usage_based | unknown
|
||||
pricing_model: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown")
|
||||
upstream_packages: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
upstream_contacts: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
support_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
license: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
service: Mapped["ServiceCatalog"] = relationship(back_populates="third_party")
|
||||
|
||||
|
||||
class ServiceFirstParty(Base):
|
||||
"""Extension for development_type = first_party (coulomb develops it)."""
|
||||
__tablename__ = "service_first_party"
|
||||
|
||||
service_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
owning_domain: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
service: Mapped["ServiceCatalog"] = relationship(back_populates="first_party")
|
||||
|
||||
|
||||
class ServiceCloud(Base):
|
||||
"""Extension for hosting_type = cloud_hosted (data is processed off coulomb infra).
|
||||
|
||||
Holds the data-processor concerns that were the heart of the old TPSC record;
|
||||
they apply whenever data leaves coulomb infra, independent of who built it.
|
||||
"""
|
||||
__tablename__ = "service_cloud"
|
||||
|
||||
service_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True)
|
||||
# gdpr_maturity (CNIL/IAPP CMMI-aligned):
|
||||
# unknown | non_compliant | initial | developing | defined | managed | certified
|
||||
gdpr_maturity: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown", index=True)
|
||||
gdpr_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
dpa_available: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
tos_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
data_processing_regions: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
data_retention_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
service: Mapped["ServiceCatalog"] = relationship(back_populates="cloud")
|
||||
|
||||
|
||||
class ServiceSelfHosted(Base):
|
||||
"""Extension for hosting_type = self_hosted (coulomb operates the service)."""
|
||||
__tablename__ = "service_self_hosted"
|
||||
|
||||
service_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True)
|
||||
# three-helix instance / host the service runs on
|
||||
helix_instance: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
host_node: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
deployment_ref: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
runbook_ref: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# upstream OSS project when the self-hosted service is third-party software
|
||||
upstream_oss_project: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
|
||||
service: Mapped["ServiceCatalog"] = relationship(back_populates="self_hosted")
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class SuggestionStage(str, enum.Enum):
|
||||
suggestion = "suggestion"
|
||||
requirement = "requirement"
|
||||
promoted = "promoted"
|
||||
declined = "declined"
|
||||
|
||||
|
||||
OPEN_SUGGESTION_STAGES = (SuggestionStage.suggestion, SuggestionStage.requirement)
|
||||
|
||||
|
||||
class Suggestion(Base, TimestampMixin):
|
||||
__tablename__ = "suggestions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
origin: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
origin_ref: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
|
||||
stage: Mapped[SuggestionStage] = mapped_column(
|
||||
Enum(SuggestionStage, name="suggestionstage"),
|
||||
nullable=False,
|
||||
default=SuggestionStage.suggestion,
|
||||
index=True,
|
||||
)
|
||||
relevance: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
relevance_events: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
last_requested_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
base_value: Mapped[float] = mapped_column(Float, nullable=False, default=3.0, server_default="3")
|
||||
job_size: Mapped[float] = mapped_column(Float, nullable=False, default=3.0, server_default="3")
|
||||
relevance_weight: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=1.0, server_default="1"
|
||||
)
|
||||
promoted_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||
promoted_task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
|
||||
notes: Mapped[list["SuggestionNote"]] = relationship(
|
||||
"SuggestionNote",
|
||||
back_populates="suggestion",
|
||||
lazy="selectin",
|
||||
order_by="SuggestionNote.created_at",
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
|
||||
|
||||
class SuggestionNote(Base):
|
||||
__tablename__ = "suggestion_notes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
suggestion_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("suggestions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
stage: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
suggestion: Mapped["Suggestion"] = relationship("Suggestion", back_populates="notes")
|
||||
|
||||
|
||||
class SuggestionRelevanceBump(Base):
|
||||
"""Audit trail for relevance bumps; supports debounce lookups."""
|
||||
|
||||
__tablename__ = "suggestion_relevance_bumps"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
suggestion_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("suggestions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
source: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
source_key: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import enum
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Boolean, Date, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
wait = "wait"
|
||||
todo = "todo"
|
||||
progress = "progress"
|
||||
done = "done"
|
||||
cancel = "cancel"
|
||||
|
||||
|
||||
class TaskPriority(str, enum.Enum):
|
||||
low = "low"
|
||||
medium = "medium"
|
||||
high = "high"
|
||||
critical = "critical"
|
||||
|
||||
|
||||
class Task(Base, TimestampMixin):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# Canonical work-record identifier, e.g. "CUST-WP-0067-T01" (ADR-007).
|
||||
# Nullable because rows created before STATE-WP-0083-T06 have none; only the
|
||||
# repository files know the mapping. Without it a task can be matched only by
|
||||
# title, so renaming a heading looks like one task vanishing and another
|
||||
# appearing.
|
||||
record_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[TaskStatus] = mapped_column(
|
||||
Enum(TaskStatus), nullable=False, default=TaskStatus.todo
|
||||
)
|
||||
priority: Mapped[TaskPriority] = mapped_column(
|
||||
Enum(TaskPriority), nullable=False, default=TaskPriority.medium
|
||||
)
|
||||
assignee: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
blocking_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
needs_human: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||
intervention_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Record id of the tracked decision a human-gated wait hangs on, e.g.
|
||||
# "CCR-2026-0004" (CUST-WP-0074 Case B). Free text: the decision may live
|
||||
# in another repo and may not be indexed in the hub yet.
|
||||
decision_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
# Owner-return marker "message-from:<agent>" (CUST-WP-0074 rule 6) and a
|
||||
# date after which the wait clears by itself; either counts as external.
|
||||
blocked_on: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
wait_until: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="tasks") # noqa: F821
|
||||
subtasks: Mapped[list["Task"]] = relationship(
|
||||
"Task", foreign_keys=[parent_task_id], lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="task", lazy="selectin"
|
||||
)
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class TDStatus(str, enum.Enum):
|
||||
# Legacy general statuses
|
||||
open = "open"
|
||||
in_progress = "in_progress"
|
||||
resolved = "resolved"
|
||||
deferred = "deferred"
|
||||
wont_fix = "wont_fix"
|
||||
# Dashboard-improvement workflow steps
|
||||
submitted = "submitted"
|
||||
analyse = "analyse"
|
||||
plan = "plan"
|
||||
implement = "implement"
|
||||
test = "test"
|
||||
review = "review"
|
||||
finished = "finished"
|
||||
|
||||
|
||||
# Ordered workflow steps for dashboard-improvement suggestions
|
||||
SUGGESTION_STEPS = ["submitted", "analyse", "plan", "implement", "test", "review", "finished"]
|
||||
|
||||
|
||||
class TDNote(Base):
|
||||
__tablename__ = "td_notes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
td_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("technical_debt.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
step: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[DateTime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
td: Mapped["TechnicalDebt"] = relationship("TechnicalDebt", back_populates="notes")
|
||||
|
||||
|
||||
class TechnicalDebt(Base, TimestampMixin):
|
||||
__tablename__ = "technical_debt"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
td_id: Mapped[str | None] = mapped_column(
|
||||
String(30), nullable=True, unique=True, index=True
|
||||
) # human-readable ref, e.g. TD-CUST-001
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
location: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
debt_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="other"
|
||||
) # design | implementation | test | docs | dependencies | performance | security | other
|
||||
severity: Mapped[str] = mapped_column(String(20), nullable=False, default="medium")
|
||||
status: Mapped[TDStatus] = mapped_column(
|
||||
Enum(TDStatus, name="tdstatus"), nullable=False, default=TDStatus.open
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||
notes: Mapped[list["TDNote"]] = relationship(
|
||||
"TDNote", back_populates="td", lazy="selectin",
|
||||
order_by="TDNote.created_at",
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
class TokenEvent(Base):
|
||||
__tablename__ = "token_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"measurement_kind",
|
||||
"source_provider",
|
||||
"source_id",
|
||||
name="uq_token_events_source_identity",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
session_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
model: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tokens_in: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
tokens_out: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ref_type: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ref_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
measurement_kind: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, default="estimated", server_default="estimated", index=True
|
||||
)
|
||||
source_provider: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, default="manual", server_default="manual", index=True
|
||||
)
|
||||
source_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||
source_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_created_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
ingested_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||
)
|
||||
parser_version: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
confidence: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.35, server_default="0.35"
|
||||
)
|
||||
cached_input_tokens: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
reasoning_output_tokens: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
raw_total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cost_estimated_usd: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
raw_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=dict, server_default="{}"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||
)
|
||||
|
||||
task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
|
||||
workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class TopicStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
paused = "paused"
|
||||
archived = "archived"
|
||||
|
||||
|
||||
class Topic(Base, TimestampMixin):
|
||||
__tablename__ = "topics"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[TopicStatus] = mapped_column(
|
||||
Enum(TopicStatus), nullable=False, default=TopicStatus.active
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||
"Domain", back_populates="topics", lazy="selectin"
|
||||
)
|
||||
workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821
|
||||
"Workplan", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||
"Decision", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str | None:
|
||||
"""Returns the domain slug string for serialization."""
|
||||
if self.domain is not None:
|
||||
return self.domain.slug
|
||||
return None
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base
|
||||
|
||||
|
||||
class TPSCCatalog(Base):
|
||||
__tablename__ = "tpsc_catalog"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
provider: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
website_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Pricing: free | paid | freemium | usage_based | unknown
|
||||
pricing_model: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown")
|
||||
# GDPR maturity (CNIL/IAPP CMMI-aligned):
|
||||
# unknown | non_compliant | initial | developing | defined | managed | certified
|
||||
gdpr_maturity: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown", index=True)
|
||||
gdpr_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
dpa_available: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
tos_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
data_processing_regions: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
data_retention_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# status: active | deprecated
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
entries: Mapped[list["TPSCEntry"]] = relationship("TPSCEntry", back_populates="catalog_entry")
|
||||
|
||||
|
||||
class TPSCSnapshot(Base):
|
||||
__tablename__ = "tpsc_snapshots"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
source_file: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
entry_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
|
||||
|
||||
entries: Mapped[list["TPSCEntry"]] = relationship("TPSCEntry", back_populates="snapshot", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class TPSCEntry(Base):
|
||||
__tablename__ = "tpsc_entries"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
snapshot_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tpsc_snapshots.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
catalog_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tpsc_catalog.id", ondelete="SET NULL"), nullable=True)
|
||||
service_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# auth_type: api_key | oauth | cli | none | unknown
|
||||
auth_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
endpoint_override: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
snapshot: Mapped["TPSCSnapshot"] = relationship("TPSCSnapshot", back_populates="entries")
|
||||
catalog_entry: Mapped["TPSCCatalog | None"] = relationship("TPSCCatalog", back_populates="entries")
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class WorkRecordIdentifierAlias(Base, TimestampMixin):
|
||||
"""Durable provenance for a canonical work-record UUID replacement."""
|
||||
|
||||
__tablename__ = "work_record_identifier_aliases"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"record_kind IN ('workplan', 'task')",
|
||||
name="ck_work_record_identifier_aliases_kind",
|
||||
),
|
||||
CheckConstraint(
|
||||
"migration_status IN ('prepared', 'applied', 'reversed')",
|
||||
name="ck_work_record_identifier_aliases_status",
|
||||
),
|
||||
UniqueConstraint("old_id", name="uq_work_record_identifier_aliases_old_id"),
|
||||
UniqueConstraint("new_id", name="uq_work_record_identifier_aliases_new_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
old_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
new_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
record_kind: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
record_id: Mapped[str] = mapped_column(String(160), nullable=False, index=True)
|
||||
repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
namespace: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
plan_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
migration_status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="prepared", server_default="prepared"
|
||||
)
|
||||
applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
reversed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class Workplan(Base, TimestampMixin):
|
||||
__tablename__ = "workplans"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="active", server_default="active"
|
||||
)
|
||||
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
execution_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="manual", server_default="manual", index=True
|
||||
)
|
||||
launch_mode: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="manual", server_default="manual", index=True
|
||||
)
|
||||
concurrency_mode: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="sequential", server_default="sequential", index=True
|
||||
)
|
||||
queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
flavor_promotion_reason: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
flavor_promoted_from: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
repo_goal_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("repo_goals.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
backing_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# Projection provenance and retirement (ADR-012 decisions 2 and 7).
|
||||
# A retired record is one the forge no longer derives. It is not deleted:
|
||||
# hub-native records reference it with RESTRICT and must survive. It is not
|
||||
# `archived` either — that says the owner closed the work, which is a
|
||||
# different fact.
|
||||
projection_retired_at: Mapped["datetime | None"] = mapped_column( # noqa: F821
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
projection_retired_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
derived_from_commit: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
backing_relative_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
backing_archived: Mapped[bool | None] = mapped_column(nullable=True)
|
||||
backing_synced_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="workplans") # noqa: F821
|
||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
repo_goal: Mapped["RepoGoal"] = relationship("RepoGoal", back_populates="workplans", lazy="selectin") # noqa: F821
|
||||
tasks: Mapped[list["Task"]] = relationship( # noqa: F821
|
||||
"Task", back_populates="workplan", lazy="selectin"
|
||||
)
|
||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||
"Decision", back_populates="workplan", lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="workplan", lazy="selectin"
|
||||
)
|
||||
launch_requests: Mapped[list["WorkplanLaunchRequest"]] = relationship( # noqa: F821
|
||||
"WorkplanLaunchRequest", back_populates="workplan", lazy="selectin"
|
||||
)
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import uuid
|
||||
|
||||
from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class WorkplanDependency(Base, TimestampMixin):
|
||||
"""Directed dependency edge: `from_workplan` depends on a workplan or task.
|
||||
|
||||
Semantics: the target must reach a satisfactory state before `from_workplan`
|
||||
can fully proceed. Hard deletes are intentional —
|
||||
removing an edge removes a constraint, not information.
|
||||
"""
|
||||
|
||||
__tablename__ = "workplan_dependencies"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(to_workplan_id IS NOT NULL AND to_task_id IS NULL) "
|
||||
"OR (to_workplan_id IS NULL AND to_task_id IS NOT NULL)",
|
||||
name="ck_wp_dep_exactly_one_target",
|
||||
),
|
||||
Index(
|
||||
"uq_wp_dep_workplan_target",
|
||||
"from_workplan_id",
|
||||
"to_workplan_id",
|
||||
"relationship_type",
|
||||
unique=True,
|
||||
postgresql_where=text("to_workplan_id IS NOT NULL AND from_task_id IS NULL"),
|
||||
),
|
||||
Index(
|
||||
"uq_wp_dep_task_target",
|
||||
"from_workplan_id",
|
||||
"to_task_id",
|
||||
"relationship_type",
|
||||
unique=True,
|
||||
postgresql_where=text("to_task_id IS NOT NULL AND from_task_id IS NULL"),
|
||||
),
|
||||
# Task-block `depends_on` edges (CUST-WP-0074): the waiting task is the
|
||||
# from side, so two tasks in one workplan may wait on the same target.
|
||||
Index(
|
||||
"uq_wp_dep_from_task_workplan_target",
|
||||
"from_task_id",
|
||||
"to_workplan_id",
|
||||
"relationship_type",
|
||||
unique=True,
|
||||
postgresql_where=text("to_workplan_id IS NOT NULL AND from_task_id IS NOT NULL"),
|
||||
),
|
||||
Index(
|
||||
"uq_wp_dep_from_task_task_target",
|
||||
"from_task_id",
|
||||
"to_task_id",
|
||||
"relationship_type",
|
||||
unique=True,
|
||||
postgresql_where=text("to_task_id IS NOT NULL AND from_task_id IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
from_workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# Set when the edge originates from a task block rather than workplan
|
||||
# frontmatter; always belongs to `from_workplan_id`.
|
||||
from_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
to_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
relationship_type: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False, default="blocks", server_default="blocks", index=True
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Source-edge ownership (STATE-WP-0094-T05, docs/forge-dependency-edge-receiving.md).
|
||||
# ``forge:<repo-slug>`` when normal Forge receiving derived this edge from
|
||||
# that repository's files; NULL for externally authored relations (API,
|
||||
# MCP, consistency fix path), which receiving never removes.
|
||||
source_owner: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
|
||||
# Immutable source commit that first introduced the source-owned edge.
|
||||
source_commit: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
from_workplan: Mapped["Workplan"] = relationship( # noqa: F821
|
||||
"Workplan", foreign_keys=[from_workplan_id]
|
||||
)
|
||||
to_workplan: Mapped["Workplan | None"] = relationship( # noqa: F821
|
||||
"Workplan", foreign_keys=[to_workplan_id]
|
||||
)
|
||||
from_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[from_task_id]) # noqa: F821
|
||||
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class WorkplanLaunchRequest(Base, TimestampMixin):
|
||||
__tablename__ = "workplan_launch_requests"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
requested_by: Mapped[str] = mapped_column(String(100), nullable=False, default="dashboard")
|
||||
requested_actor: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
launch_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True)
|
||||
concurrency_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="sequential", index=True)
|
||||
priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
branch_preference: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
immediate_pickup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="requested", server_default="requested", index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
request_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||
|
||||
workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="launch_requests") # noqa: F821
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""Backward-compatibility shim — prefer ``api.models.workplan``."""
|
||||
from api.models.workplan import Workplan
|
||||
|
||||
Workstream = Workplan
|
||||
|
||||
__all__ = ["Workstream", "Workplan"]
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""Backward-compatibility shim — prefer ``api.models.workplan_dependency``."""
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
|
||||
WorkstreamDependency = WorkplanDependency
|
||||
|
||||
__all__ = ["WorkstreamDependency", "WorkplanDependency"]
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
class WriteIdempotencyKey(Base):
|
||||
__tablename__ = "write_idempotency_keys"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("key", name="uq_write_idempotency_keys_key"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
key: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
method: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
route_class: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
response_status: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
response_body: Mapped[Any] = mapped_column(JSONB, nullable=True)
|
||||
source_host: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
source_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
|
@ -0,0 +1,532 @@
|
|||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.flow_defs import assertion_result_to_dict, evaluate_transition, flow_result_to_dict
|
||||
from api.models.agent_message import BROADCAST, AgentMessage
|
||||
from api.services.message_receipts import NEWS_DEFAULT_TTL, utcnow
|
||||
from api.models.capability_catalog import CapabilityCatalog
|
||||
from api.models.capability_request import CapabilityRequest
|
||||
from api.models.domain import Domain
|
||||
from api.models.task import Task
|
||||
from api.services.suggestion_relevance import bump_matching_for_capability_request
|
||||
from api.schemas.capability_request import (
|
||||
CapabilityRequestAccept,
|
||||
CapabilityRequestCreate,
|
||||
CapabilityRequestDispute,
|
||||
CapabilityRequestPatch,
|
||||
CapabilityRequestRead,
|
||||
CapabilityRequestReroute,
|
||||
CapabilityRequestStatusPatch,
|
||||
CatalogCreate,
|
||||
CatalogPatch,
|
||||
CatalogRead,
|
||||
)
|
||||
from hub_core.routers.capabilities import (
|
||||
create_capability_request_read_router,
|
||||
create_capability_request_write_router,
|
||||
)
|
||||
from api.services.repository_aliases import resolve_repository_slug
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write-router callbacks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _route_capability(
|
||||
session: AsyncSession,
|
||||
body: CapabilityRequestCreate,
|
||||
) -> tuple[uuid.UUID | None, uuid.UUID | None, str | None]:
|
||||
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability_match(
|
||||
session,
|
||||
body.capability_type,
|
||||
body.title,
|
||||
body.description or "",
|
||||
)
|
||||
return fulfilling_domain_id, catalog_entry_id, routing_note
|
||||
|
||||
|
||||
def _build_capability_request(
|
||||
body: CapabilityRequestCreate,
|
||||
requesting_domain: Domain,
|
||||
fulfilling_domain_id: uuid.UUID | None,
|
||||
catalog_entry_id: uuid.UUID | None,
|
||||
routing_note: str | None,
|
||||
) -> CapabilityRequest:
|
||||
return CapabilityRequest(
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
capability_type=body.capability_type,
|
||||
priority=body.priority,
|
||||
requesting_domain_id=requesting_domain.id,
|
||||
requesting_agent=body.requesting_agent,
|
||||
requesting_workplan_id=body.requesting_workplan_id,
|
||||
blocking_task_id=body.blocking_task_id,
|
||||
fulfilling_domain_id=fulfilling_domain_id,
|
||||
catalog_entry_id=catalog_entry_id,
|
||||
routing_note=routing_note,
|
||||
)
|
||||
|
||||
|
||||
async def _on_capability_request_persisted(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestCreate,
|
||||
) -> None:
|
||||
await session.flush()
|
||||
await bump_matching_for_capability_request(
|
||||
session,
|
||||
request_id=req.id,
|
||||
title=body.title,
|
||||
description=body.description or "",
|
||||
capability_type=body.capability_type,
|
||||
catalog_entry_id=req.catalog_entry_id,
|
||||
)
|
||||
await _notify_on_create(session, req, body)
|
||||
|
||||
|
||||
async def _notify_on_create(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestCreate,
|
||||
) -> None:
|
||||
await session.flush()
|
||||
|
||||
if req.fulfilling_domain_id:
|
||||
ful_domain = await session.get(Domain, req.fulfilling_domain_id)
|
||||
to_agent = ful_domain.slug if ful_domain else "broadcast"
|
||||
else:
|
||||
to_agent = "broadcast"
|
||||
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent="system",
|
||||
to_agent=to_agent,
|
||||
subject=f"[capability-request] {body.title}",
|
||||
body=(
|
||||
f"New capability request from **{body.requesting_agent}** "
|
||||
f"({body.requesting_domain}):\n\n"
|
||||
f"**Type:** {body.capability_type}\n"
|
||||
f"**Priority:** {body.priority}\n\n"
|
||||
f"{body.description or '(no description)'}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _apply_accept_fields(req: CapabilityRequest, body: CapabilityRequestAccept) -> None:
|
||||
req.fulfilling_workplan_id = body.fulfilling_workplan_id
|
||||
|
||||
|
||||
async def _notify_on_accept(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestAccept,
|
||||
) -> None:
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.fulfilling_agent,
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-accepted] {req.title}",
|
||||
body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.",
|
||||
)
|
||||
|
||||
|
||||
async def _on_status_change(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestStatusPatch,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
if body.status == "completed":
|
||||
if req.blocking_task_id:
|
||||
task = await session.get(Task, req.blocking_task_id)
|
||||
if task and task.status == "wait":
|
||||
task.status = "todo"
|
||||
task.blocking_reason = None
|
||||
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent="system",
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-completed] {req.title}",
|
||||
body=(
|
||||
f"Capability request **{req.title}** has been completed.\n\n"
|
||||
f"{body.note or ''}"
|
||||
),
|
||||
)
|
||||
elif body.status == "ready_for_review":
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=req.fulfilling_agent or "system",
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-ready] {req.title} -- please review",
|
||||
body=(
|
||||
f"Capability **{req.title}** is ready for your review and optimization.\n\n"
|
||||
f"{body.note or ''}"
|
||||
),
|
||||
)
|
||||
elif body.status == "rejected":
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=req.fulfilling_agent or "system",
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-rejected] {req.title}",
|
||||
body=(
|
||||
f"Capability request **{req.title}** has been rejected.\n\n"
|
||||
f"**Reason:** {body.note or '(no reason given)'}"
|
||||
),
|
||||
)
|
||||
elif body.status == "in_progress":
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=req.fulfilling_agent or "system",
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-in-progress] {req.title}",
|
||||
body=f"Work on capability **{req.title}** is now in progress.",
|
||||
)
|
||||
|
||||
|
||||
async def _apply_capability_patch(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestPatch,
|
||||
) -> bool:
|
||||
corrections: list[str] = []
|
||||
|
||||
if body.catalog_entry_id is not None:
|
||||
old_entry_id = req.catalog_entry_id
|
||||
entry = await session.get(CapabilityCatalog, body.catalog_entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
|
||||
req.catalog_entry_id = entry.id
|
||||
req.fulfilling_domain_id = entry.domain_id
|
||||
corrections.append(
|
||||
f"catalog_entry: {old_entry_id} → {entry.id} ({entry.title}); "
|
||||
f"fulfilling_domain re-derived → {entry.domain_id}"
|
||||
)
|
||||
|
||||
if body.priority is not None:
|
||||
req.priority = body.priority
|
||||
corrections.append(f"priority → {body.priority}")
|
||||
|
||||
if body.blocking_task_id is not None:
|
||||
req.blocking_task_id = body.blocking_task_id
|
||||
corrections.append(f"blocking_task_id → {body.blocking_task_id}")
|
||||
|
||||
if body.fulfilling_workplan_id is not None:
|
||||
req.fulfilling_workplan_id = body.fulfilling_workplan_id
|
||||
corrections.append(f"fulfilling_workplan_id → {body.fulfilling_workplan_id}")
|
||||
|
||||
if not corrections:
|
||||
return False
|
||||
|
||||
correction_note = "hub correction: " + "; ".join(corrections)
|
||||
req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note
|
||||
return True
|
||||
|
||||
|
||||
async def _notify_on_dispute(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestDispute,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
dispute_entry = (
|
||||
f"disputed by {body.disputed_by}: {body.reason}"
|
||||
+ (f" (suggested: {body.suggested_domain})" if body.suggested_domain else "")
|
||||
)
|
||||
req.routing_note = (req.routing_note + "\n" + dispute_entry) if req.routing_note else dispute_entry
|
||||
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.disputed_by,
|
||||
to_agent="custodian",
|
||||
subject=f"[routing-disputed] {req.title}",
|
||||
body=(
|
||||
f"**{body.disputed_by}** has disputed the routing of capability request "
|
||||
f"**{req.title}**.\n\n"
|
||||
f"**Reason:** {body.reason}\n"
|
||||
+ (f"**Suggested domain:** {body.suggested_domain}\n" if body.suggested_domain else "")
|
||||
+ f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}"
|
||||
),
|
||||
)
|
||||
if req.fulfilling_domain_slug:
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.disputed_by,
|
||||
to_agent=req.fulfilling_domain_slug,
|
||||
subject=f"[routing-disputed] {req.title}",
|
||||
body=(
|
||||
f"The capability request **{req.title}** routed to your domain has been disputed "
|
||||
f"by **{body.disputed_by}**.\n\n"
|
||||
f"**Reason:** {body.reason}\n"
|
||||
+ (f"**Suggested domain:** {body.suggested_domain}" if body.suggested_domain else "")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _notify_on_reroute(
|
||||
session: AsyncSession,
|
||||
req: CapabilityRequest,
|
||||
body: CapabilityRequestReroute,
|
||||
new_domain_slug: str,
|
||||
) -> None:
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.rerouted_by,
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[re-routed] {req.title}",
|
||||
body=(
|
||||
f"Capability request **{req.title}** has been re-routed to **{new_domain_slug}**.\n\n"
|
||||
f"**Note:** {body.note}"
|
||||
),
|
||||
)
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.rerouted_by,
|
||||
to_agent=new_domain_slug,
|
||||
subject=f"[capability-request] {req.title}",
|
||||
body=(
|
||||
f"Capability request **{req.title}** has been re-routed to your domain.\n\n"
|
||||
f"**From:** {req.requesting_agent} ({req.requesting_domain_slug})\n"
|
||||
f"**Type:** {req.capability_type}\n"
|
||||
f"**Priority:** {req.priority}\n\n"
|
||||
f"{req.description or '(no description)'}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing algorithm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _route_capability_match(
|
||||
session: AsyncSession,
|
||||
capability_type: str,
|
||||
title: str,
|
||||
description: str,
|
||||
) -> tuple[uuid.UUID | None, uuid.UUID | None, str]:
|
||||
"""Find the best-matching catalog entry for a capability request.
|
||||
|
||||
Returns (domain_id, catalog_entry_id, routing_note).
|
||||
"""
|
||||
q = select(CapabilityCatalog).where(
|
||||
CapabilityCatalog.capability_type == capability_type,
|
||||
CapabilityCatalog.status == "active",
|
||||
)
|
||||
entries = list((await session.execute(q)).scalars().all())
|
||||
|
||||
if not entries:
|
||||
return None, None, f"no active catalog entries for type '{capability_type}' — broadcast"
|
||||
|
||||
if len(entries) == 1:
|
||||
entry = entries[0]
|
||||
return entry.domain_id, entry.id, f"single match: '{entry.title}' (domain={entry.domain_id})"
|
||||
|
||||
combined = f"{title} {description or ''}".lower()
|
||||
scored: list[tuple[int, CapabilityCatalog]] = []
|
||||
for entry in entries:
|
||||
keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3]
|
||||
score = sum(
|
||||
1 for kw in keywords
|
||||
if re.search(r"\b" + re.escape(kw.lower()) + r"\b", combined)
|
||||
)
|
||||
scored.append((score, entry))
|
||||
scored.sort(key=lambda item: -item[0])
|
||||
|
||||
best_score, best = scored[0]
|
||||
if best_score == 0:
|
||||
return None, None, (
|
||||
f"no keyword overlap for type '{capability_type}' among "
|
||||
f"{len(entries)} entries — broadcast"
|
||||
)
|
||||
if len(scored) >= 2 and scored[1][0] == best_score:
|
||||
return None, None, (
|
||||
f"ambiguous routing: '{scored[0][1].title}' and '{scored[1][1].title}' "
|
||||
f"both scored {best_score} — broadcast"
|
||||
)
|
||||
|
||||
return best.domain_id, best.id, (
|
||||
f"matched '{best.title}' (score={best_score}, "
|
||||
f"keywords matched from: {title!r})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _add_notification(
|
||||
session: AsyncSession,
|
||||
from_agent: str,
|
||||
to_agent: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
) -> None:
|
||||
msg = AgentMessage(
|
||||
from_agent=from_agent,
|
||||
to_agent=to_agent,
|
||||
subject=subject,
|
||||
body=body,
|
||||
)
|
||||
if to_agent == BROADCAST:
|
||||
# STATE-WP-0093: system broadcasts are news (seen once per reader).
|
||||
msg.kind = "news"
|
||||
msg.expires_at = utcnow() + NEWS_DEFAULT_TTL
|
||||
session.add(msg)
|
||||
|
||||
|
||||
def _check_transition(current: str, target: str) -> None:
|
||||
can_reach, failures, flow_result = evaluate_transition(
|
||||
"capability_request",
|
||||
current,
|
||||
target,
|
||||
)
|
||||
if not can_reach:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"message": f"Cannot transition from '{current}' to '{target}'.",
|
||||
"current_workstation": current,
|
||||
"target_workstation": target,
|
||||
"blocking_assertions": [
|
||||
assertion_result_to_dict(item) for item in failures
|
||||
],
|
||||
"flow_result": flow_result_to_dict(flow_result),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(tags=["capability-requests"])
|
||||
|
||||
|
||||
async def _catalog_domain(slug: str, session: AsyncSession) -> Domain:
|
||||
domain = (
|
||||
await session.execute(select(Domain).where(Domain.slug == slug))
|
||||
).scalar_one_or_none()
|
||||
if domain is None:
|
||||
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
|
||||
return domain
|
||||
|
||||
|
||||
@router.post(
|
||||
"/capability-catalog/",
|
||||
response_model=CatalogRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_catalog_entry(
|
||||
body: CatalogCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityCatalog:
|
||||
domain = await _catalog_domain(body.domain, session)
|
||||
repo_id = None
|
||||
if body.repo_slug:
|
||||
repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id
|
||||
entry = CapabilityCatalog(
|
||||
domain_id=domain.id,
|
||||
repo_id=repo_id,
|
||||
capability_type=body.capability_type,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
keywords=body.keywords,
|
||||
)
|
||||
session.add(entry)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"Catalog entry '{body.title}' for type '{body.capability_type}' "
|
||||
f"already exists in domain '{body.domain}'"
|
||||
),
|
||||
)
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.get("/capability-catalog/", response_model=list[CatalogRead])
|
||||
async def list_catalog(
|
||||
domain: str | None = Query(None),
|
||||
capability_type: str | None = Query(None),
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
repo_slug: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[CapabilityCatalog]:
|
||||
query = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc())
|
||||
if domain:
|
||||
query = query.where(
|
||||
CapabilityCatalog.domain_id == (await _catalog_domain(domain, session)).id
|
||||
)
|
||||
if capability_type:
|
||||
query = query.where(CapabilityCatalog.capability_type == capability_type)
|
||||
if repo_slug:
|
||||
query = query.where(
|
||||
CapabilityCatalog.repo_id
|
||||
== (await resolve_repository_slug(session, repo_slug)).repo.id
|
||||
)
|
||||
if status_filter and status_filter != "all":
|
||||
query = query.where(CapabilityCatalog.status == status_filter)
|
||||
elif not status_filter:
|
||||
query = query.where(CapabilityCatalog.status == "active")
|
||||
return list((await session.execute(query)).scalars().all())
|
||||
|
||||
|
||||
@router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead)
|
||||
async def patch_catalog_entry(
|
||||
entry_id: uuid.UUID,
|
||||
body: CatalogPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityCatalog:
|
||||
entry = await session.get(CapabilityCatalog, entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
|
||||
if body.repo_slug is not None:
|
||||
entry.repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id
|
||||
for field in ("description", "keywords", "status"):
|
||||
value = getattr(body, field)
|
||||
if value is not None:
|
||||
setattr(entry, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
router.include_router(
|
||||
create_capability_request_read_router(
|
||||
get_session,
|
||||
domain_model=Domain,
|
||||
request_model=CapabilityRequest,
|
||||
request_read_schema=CapabilityRequestRead,
|
||||
)
|
||||
)
|
||||
router.include_router(
|
||||
create_capability_request_write_router(
|
||||
get_session,
|
||||
domain_model=Domain,
|
||||
catalog_model=CapabilityCatalog,
|
||||
request_model=CapabilityRequest,
|
||||
request_create_schema=CapabilityRequestCreate,
|
||||
request_accept_schema=CapabilityRequestAccept,
|
||||
request_patch_schema=CapabilityRequestPatch,
|
||||
request_status_patch_schema=CapabilityRequestStatusPatch,
|
||||
request_dispute_schema=CapabilityRequestDispute,
|
||||
request_reroute_schema=CapabilityRequestReroute,
|
||||
request_read_schema=CapabilityRequestRead,
|
||||
route_request=_route_capability,
|
||||
build_request=_build_capability_request,
|
||||
on_request_persisted=_on_capability_request_persisted,
|
||||
check_transition=_check_transition,
|
||||
apply_accept_fields=_apply_accept_fields,
|
||||
after_accept=_notify_on_accept,
|
||||
after_status_change=_on_status_change,
|
||||
apply_patch=_apply_capability_patch,
|
||||
after_dispute=_notify_on_dispute,
|
||||
after_reroute=_notify_on_reroute,
|
||||
)
|
||||
)
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.schemas.consistency_sweep import (
|
||||
ConsistencySweepRemoteAllGenerate,
|
||||
ConsistencySweepRemoteAllRun,
|
||||
)
|
||||
from api.services.consistency_sweep import run_remote_all_sweep
|
||||
|
||||
router = APIRouter(prefix="/consistency/sweep", tags=["consistency"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/remote-all",
|
||||
response_model=ConsistencySweepRemoteAllRun,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def sweep_remote_all(
|
||||
body: ConsistencySweepRemoteAllGenerate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ConsistencySweepRemoteAllRun:
|
||||
try:
|
||||
return await run_remote_all_sweep(
|
||||
session,
|
||||
max_seconds=body.max_seconds,
|
||||
source=body.source,
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Consistency sweep returned invalid JSON: {exc}",
|
||||
) from exc
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.flow_defs import assertion_result_to_dict, evaluate_transition, flow_result_to_dict
|
||||
from api.models.contribution import Contribution, ContributionStatus, ContributionType
|
||||
from api.schemas.contribution import ContributionCreate, ContributionRead, ContributionStatusPatch
|
||||
|
||||
router = APIRouter(prefix="/contributions", tags=["contributions"])
|
||||
|
||||
@router.get("/", response_model=list[ContributionRead])
|
||||
async def list_contributions(
|
||||
type: ContributionType | None = Query(None),
|
||||
status: ContributionStatus | None = Query(None),
|
||||
target_repo: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Contribution]:
|
||||
q = select(Contribution).order_by(Contribution.created_at.desc())
|
||||
if type is not None:
|
||||
q = q.where(Contribution.type == type)
|
||||
if status is not None:
|
||||
q = q.where(Contribution.status == status)
|
||||
if target_repo:
|
||||
q = q.where(Contribution.target_repo == target_repo)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=ContributionRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_contribution(
|
||||
body: ContributionCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Contribution:
|
||||
contrib = Contribution(
|
||||
type=body.type,
|
||||
target_org=body.target_org,
|
||||
target_repo=body.target_repo,
|
||||
slug=body.slug,
|
||||
title=body.title,
|
||||
body_path=body.body_path,
|
||||
related_topic_id=body.related_topic_id,
|
||||
related_workplan_id=body.related_workplan_id,
|
||||
notes=body.notes,
|
||||
status=ContributionStatus.draft,
|
||||
)
|
||||
session.add(contrib)
|
||||
await session.commit()
|
||||
await session.refresh(contrib)
|
||||
return contrib
|
||||
|
||||
|
||||
@router.get("/{contribution_id}", response_model=ContributionRead)
|
||||
async def get_contribution(
|
||||
contribution_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Contribution:
|
||||
return await _get_or_404(contribution_id, session)
|
||||
|
||||
|
||||
@router.patch("/{contribution_id}/status", response_model=ContributionRead)
|
||||
async def patch_contribution_status(
|
||||
contribution_id: uuid.UUID,
|
||||
body: ContributionStatusPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Contribution:
|
||||
contrib = await _get_or_404(contribution_id, session)
|
||||
current = _status_value(contrib.status)
|
||||
target = _status_value(body.status)
|
||||
can_reach, failures, flow_result = evaluate_transition(
|
||||
"contribution",
|
||||
current,
|
||||
target,
|
||||
)
|
||||
if not can_reach:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"message": f"Cannot transition from '{current}' to '{target}'.",
|
||||
"current_workstation": current,
|
||||
"target_workstation": target,
|
||||
"blocking_assertions": [
|
||||
assertion_result_to_dict(item) for item in failures
|
||||
],
|
||||
"flow_result": flow_result_to_dict(flow_result),
|
||||
},
|
||||
)
|
||||
contrib.status = body.status
|
||||
if body.notes:
|
||||
contrib.notes = body.notes
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
if body.status == ContributionStatus.submitted:
|
||||
contrib.submitted_at = now
|
||||
elif body.status in (
|
||||
ContributionStatus.accepted, ContributionStatus.rejected,
|
||||
ContributionStatus.merged, ContributionStatus.withdrawn,
|
||||
):
|
||||
contrib.resolved_at = now
|
||||
await session.commit()
|
||||
await session.refresh(contrib)
|
||||
return contrib
|
||||
|
||||
|
||||
@router.delete("/{contribution_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def withdraw_contribution(
|
||||
contribution_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""Soft-delete: sets status to 'withdrawn'."""
|
||||
contrib = await _get_or_404(contribution_id, session)
|
||||
if contrib.status == ContributionStatus.withdrawn:
|
||||
return # idempotent
|
||||
if contrib.status in (ContributionStatus.merged, ContributionStatus.rejected):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot withdraw a contribution with status '{contrib.status}'.",
|
||||
)
|
||||
contrib.status = ContributionStatus.withdrawn
|
||||
contrib.resolved_at = datetime.now(tz=timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _get_or_404(contribution_id: uuid.UUID, session: AsyncSession) -> Contribution:
|
||||
result = await session.execute(
|
||||
select(Contribution).where(Contribution.id == contribution_id)
|
||||
)
|
||||
contrib = result.scalar_one_or_none()
|
||||
if contrib is None:
|
||||
raise HTTPException(status_code=404, detail=f"Contribution '{contribution_id}' not found")
|
||||
return contrib
|
||||
|
||||
|
||||
def _status_value(status: ContributionStatus | str) -> str:
|
||||
return status.value if isinstance(status, ContributionStatus) else str(status)
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.events import EventEnvelope, publish_event
|
||||
from api.models.decision import Decision, DecisionStatus, DecisionType
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.review_contract import ReviewContract
|
||||
from api.schemas.decision import DecisionCreate, DecisionRead, DecisionResolve, DecisionUpdate
|
||||
from api.services.legacy_compat import meter_legacy_body_from_model, meter_legacy_query_param
|
||||
from api.services.review_contracts import aggregate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/decisions", tags=["decisions"])
|
||||
|
||||
_FINANCIAL_LEGAL_KEYWORDS = (
|
||||
"financ", "legal", "payment", "purchas", "contract", "commit",
|
||||
"obligation", "external representation",
|
||||
)
|
||||
|
||||
|
||||
def _needs_escalation(body: DecisionCreate) -> str | None:
|
||||
if body.decision_type != DecisionType.pending:
|
||||
return None
|
||||
text = f"{body.title} {body.description or ''}".lower()
|
||||
for kw in _FINANCIAL_LEGAL_KEYWORDS:
|
||||
if kw in text:
|
||||
return (
|
||||
"Auto-escalated per constitution §4: this pending decision touches "
|
||||
"financial or legal territory and requires explicit human approval before action."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DecisionRead])
|
||||
async def list_decisions(
|
||||
request: Request,
|
||||
response: Response,
|
||||
topic_id: uuid.UUID | None = None,
|
||||
workplan_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: DecisionStatus | None = None,
|
||||
decision_type: DecisionType | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Decision]:
|
||||
if workstream_id is not None and workplan_id is None:
|
||||
await meter_legacy_query_param(
|
||||
session=session,
|
||||
request=request,
|
||||
response=response,
|
||||
method="GET",
|
||||
route="/decisions/",
|
||||
replacement_ref="/decisions/?workplan_id=<workplan_id>",
|
||||
)
|
||||
q = select(Decision)
|
||||
if topic_id:
|
||||
q = q.where(Decision.topic_id == topic_id)
|
||||
scope_id = workplan_id or workstream_id
|
||||
if scope_id:
|
||||
q = q.where(Decision.workplan_id == scope_id)
|
||||
if status:
|
||||
q = q.where(Decision.status == status)
|
||||
if decision_type:
|
||||
q = q.where(Decision.decision_type == decision_type)
|
||||
q = q.order_by(Decision.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=DecisionRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_decision(
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: DecisionCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
await meter_legacy_body_from_model(
|
||||
body,
|
||||
session=session,
|
||||
request=request,
|
||||
response=response,
|
||||
method="POST",
|
||||
route="/decisions/",
|
||||
replacement_ref="POST /decisions/ with workplan_id",
|
||||
)
|
||||
data = body.model_dump()
|
||||
note = _needs_escalation(body)
|
||||
if note:
|
||||
data["escalation_note"] = note
|
||||
data["status"] = DecisionStatus.escalated
|
||||
decision = Decision(**data)
|
||||
session.add(decision)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return decision
|
||||
|
||||
|
||||
@router.get("/{decision_id}", response_model=DecisionRead)
|
||||
async def get_decision(
|
||||
decision_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
return decision
|
||||
|
||||
|
||||
@router.patch("/{decision_id}", response_model=DecisionRead)
|
||||
async def update_decision(
|
||||
decision_id: uuid.UUID,
|
||||
body: DecisionUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(decision, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return decision
|
||||
|
||||
|
||||
@router.delete("/{decision_id}", response_model=DecisionRead)
|
||||
async def supersede_decision(
|
||||
decision_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
decision.status = DecisionStatus.superseded
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return decision
|
||||
|
||||
|
||||
@router.post("/{decision_id}/resolve", response_model=DecisionRead)
|
||||
async def resolve_decision_action(
|
||||
decision_id: uuid.UUID,
|
||||
body: DecisionResolve,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
if decision.status == DecisionStatus.resolved:
|
||||
raise HTTPException(status_code=409, detail="Decision already resolved")
|
||||
|
||||
review_rows = await session.execute(
|
||||
select(ReviewContract).where(
|
||||
ReviewContract.decision_id == decision.id,
|
||||
ReviewContract.active.is_(True),
|
||||
ReviewContract.required_for_decision.is_(True),
|
||||
)
|
||||
)
|
||||
for contract in review_rows.scalars():
|
||||
review_state = await aggregate(session, contract)
|
||||
if not review_state.satisfied:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "required multi-owner review is not satisfied",
|
||||
"contract_key": contract.contract_key,
|
||||
"contract_digest": contract.contract_digest,
|
||||
},
|
||||
)
|
||||
|
||||
decision.status = DecisionStatus.resolved
|
||||
decision.decision_type = DecisionType.made
|
||||
decision.rationale = body.rationale
|
||||
decision.decided_by = body.decided_by
|
||||
decision.decided_at = datetime.now(tz=timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
|
||||
event = ProgressEvent(
|
||||
topic_id=decision.topic_id,
|
||||
workplan_id=decision.workplan_id,
|
||||
decision_id=decision.id,
|
||||
event_type="decision_resolved",
|
||||
summary=f"Decision resolved: {decision.title}",
|
||||
author=body.decided_by,
|
||||
detail={"rationale": body.rationale},
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
|
||||
if body.write_log:
|
||||
await _write_project_log(decision, body.rationale, body.decided_by, session)
|
||||
|
||||
subject = "org.statehub.decision.resolved"
|
||||
envelope = EventEnvelope.new(
|
||||
subject,
|
||||
attributes={
|
||||
"decision_id": str(decision.id),
|
||||
"title": decision.title,
|
||||
"topic_id": str(decision.topic_id) if decision.topic_id else None,
|
||||
"workstream_id": str(decision.workplan_id) if decision.workplan_id else None,
|
||||
"decided_by": body.decided_by,
|
||||
"rationale_snippet": (body.rationale or "")[:240],
|
||||
},
|
||||
)
|
||||
asyncio.create_task(publish_event(subject, envelope))
|
||||
|
||||
return decision
|
||||
|
||||
|
||||
async def _write_project_log(
|
||||
decision: Decision, rationale: str, decided_by: str, session: AsyncSession
|
||||
) -> None:
|
||||
"""Append a DECISIONS.md entry to the registered project directory for this topic."""
|
||||
if decision.topic_id is None:
|
||||
return
|
||||
|
||||
rows = await session.execute(
|
||||
select(ProgressEvent)
|
||||
.where(ProgressEvent.topic_id == decision.topic_id)
|
||||
.where(ProgressEvent.event_type == "milestone")
|
||||
.order_by(ProgressEvent.created_at.desc())
|
||||
)
|
||||
project_path: str | None = None
|
||||
for pe in rows.scalars():
|
||||
if pe.summary and "Project registered with State Hub:" in pe.summary:
|
||||
project_path = (pe.detail or {}).get("project_path")
|
||||
if project_path:
|
||||
break
|
||||
|
||||
if not project_path:
|
||||
logger.warning("write_log requested but no project_path found for topic %s", decision.topic_id)
|
||||
return
|
||||
|
||||
p = Path(project_path)
|
||||
if not p.is_dir():
|
||||
logger.warning("write_log requested but project_path does not exist: %s", project_path)
|
||||
return
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
entry = (
|
||||
f"\n## {decision.title}\n\n"
|
||||
f"**Date:** {now.strftime('%Y-%m-%d')} \n"
|
||||
f"**Decided by:** {decided_by} \n\n"
|
||||
f"{rationale}\n\n"
|
||||
f"---\n"
|
||||
)
|
||||
log_file = p / "DECISIONS.md"
|
||||
if log_file.exists():
|
||||
log_file.write_text(log_file.read_text() + entry)
|
||||
else:
|
||||
log_file.write_text(
|
||||
"# Decision Log\n\n"
|
||||
"_Auto-generated by the Custodian State Hub._\n"
|
||||
+ entry
|
||||
)
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import asyncio
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.events import EventEnvelope, publish_event
|
||||
from api.models.domain import Domain
|
||||
from api.models.domain_goal import DomainGoal, DomainGoalStatus # noqa: F401 (DomainGoalStatus used in activate)
|
||||
from api.schemas.domain_goal import DomainGoalCreate, DomainGoalRead, DomainGoalUpdate
|
||||
|
||||
router = APIRouter(prefix="/domain-goals", tags=["domain-goals"])
|
||||
|
||||
|
||||
async def _resolve_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
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DomainGoalRead])
|
||||
async def list_domain_goals(
|
||||
domain_slug: str | None = None,
|
||||
status: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[DomainGoal]:
|
||||
q = select(DomainGoal)
|
||||
if domain_slug:
|
||||
domain = await _resolve_domain(domain_slug, session)
|
||||
q = q.where(DomainGoal.domain_id == domain.id)
|
||||
if status:
|
||||
q = q.where(DomainGoal.status == status)
|
||||
q = q.order_by(DomainGoal.created_at.desc())
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=DomainGoalRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_domain_goal(
|
||||
body: DomainGoalCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DomainGoal:
|
||||
if body.status == DomainGoalStatus.active:
|
||||
# Archive any existing active goal for this domain
|
||||
existing = await session.execute(
|
||||
select(DomainGoal).where(
|
||||
DomainGoal.domain_id == body.domain_id,
|
||||
DomainGoal.status == DomainGoalStatus.active,
|
||||
)
|
||||
)
|
||||
for old in existing.scalars().all():
|
||||
old.status = DomainGoalStatus.superseded
|
||||
|
||||
goal = DomainGoal(**body.model_dump())
|
||||
session.add(goal)
|
||||
await session.commit()
|
||||
await session.refresh(goal)
|
||||
return goal
|
||||
|
||||
|
||||
@router.get("/{goal_id}", response_model=DomainGoalRead)
|
||||
async def get_domain_goal(
|
||||
goal_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DomainGoal:
|
||||
goal = await session.get(DomainGoal, goal_id)
|
||||
if goal is None:
|
||||
raise HTTPException(status_code=404, detail="Domain goal not found")
|
||||
return goal
|
||||
|
||||
|
||||
@router.patch("/{goal_id}", response_model=DomainGoalRead)
|
||||
async def update_domain_goal(
|
||||
goal_id: uuid.UUID,
|
||||
body: DomainGoalUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DomainGoal:
|
||||
goal = await session.get(DomainGoal, goal_id)
|
||||
if goal is None:
|
||||
raise HTTPException(status_code=404, detail="Domain goal not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(goal, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(goal)
|
||||
return goal
|
||||
|
||||
|
||||
@router.post("/{goal_id}/activate", response_model=DomainGoalRead)
|
||||
async def activate_domain_goal(
|
||||
goal_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DomainGoal:
|
||||
"""Set this goal as the active domain goal, superseding any currently active one."""
|
||||
goal = await session.get(DomainGoal, goal_id)
|
||||
if goal is None:
|
||||
raise HTTPException(status_code=404, detail="Domain goal not found")
|
||||
was_active = goal.status == DomainGoalStatus.active
|
||||
|
||||
# Supersede any other active goal for this domain
|
||||
existing = await session.execute(
|
||||
select(DomainGoal).where(
|
||||
DomainGoal.domain_id == goal.domain_id,
|
||||
DomainGoal.status == DomainGoalStatus.active,
|
||||
DomainGoal.id != goal_id,
|
||||
)
|
||||
)
|
||||
superseded_ids: list[str] = []
|
||||
for old in existing.scalars().all():
|
||||
old.status = DomainGoalStatus.superseded
|
||||
superseded_ids.append(str(old.id))
|
||||
|
||||
goal.status = DomainGoalStatus.active
|
||||
await session.commit()
|
||||
await session.refresh(goal)
|
||||
|
||||
if not was_active:
|
||||
domain = await session.get(Domain, goal.domain_id)
|
||||
subject = "org.statehub.domain.goal.activated"
|
||||
envelope = EventEnvelope.new(
|
||||
subject,
|
||||
attributes={
|
||||
"goal_id": str(goal.id),
|
||||
"domain_id": str(goal.domain_id),
|
||||
"domain_slug": domain.slug if domain else None,
|
||||
"title": goal.title,
|
||||
"superseded_goal_ids": superseded_ids,
|
||||
},
|
||||
)
|
||||
asyncio.create_task(publish_event(subject, envelope))
|
||||
|
||||
return goal
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.domain import Domain
|
||||
from api.models.extension_point import ExtensionPoint
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.technical_debt import TechnicalDebt
|
||||
from api.models.topic import Topic
|
||||
from api.models.workplan import Workplan
|
||||
from api.schemas.domain import (
|
||||
DomainCreate,
|
||||
DomainDetail,
|
||||
DomainRead,
|
||||
DomainRename,
|
||||
DomainUpdate,
|
||||
RepoStub,
|
||||
)
|
||||
from hub_core.routers.domains import create_domains_router
|
||||
|
||||
|
||||
async def _build_domain_detail(domain: Domain, session: AsyncSession) -> DomainDetail:
|
||||
topic_count_row = await session.execute(
|
||||
select(func.count()).select_from(Topic).where(Topic.domain_id == domain.id)
|
||||
)
|
||||
topic_count = topic_count_row.scalar_one()
|
||||
|
||||
topic_ids_row = await session.execute(select(Topic.id).where(Topic.domain_id == domain.id))
|
||||
topic_ids = [row[0] for row in topic_ids_row.all()]
|
||||
|
||||
workstream_count = 0
|
||||
if topic_ids:
|
||||
workstream_count_row = await session.execute(
|
||||
select(func.count()).select_from(Workplan)
|
||||
.where(Workplan.topic_id.in_(topic_ids))
|
||||
.where(Workplan.status == "active")
|
||||
)
|
||||
workstream_count = workstream_count_row.scalar_one()
|
||||
|
||||
ep_count_row = await session.execute(
|
||||
select(func.count()).select_from(ExtensionPoint)
|
||||
.where(ExtensionPoint.domain_id == domain.id)
|
||||
)
|
||||
td_count_row = await session.execute(
|
||||
select(func.count()).select_from(TechnicalDebt)
|
||||
.where(TechnicalDebt.domain_id == domain.id)
|
||||
)
|
||||
repos_row = await session.execute(
|
||||
select(ManagedRepo)
|
||||
.where(ManagedRepo.domain_id == domain.id)
|
||||
.where(ManagedRepo.status == "active")
|
||||
.order_by(ManagedRepo.name)
|
||||
)
|
||||
repos = list(repos_row.scalars().all())
|
||||
|
||||
return DomainDetail(
|
||||
id=domain.id,
|
||||
slug=domain.slug,
|
||||
name=domain.name,
|
||||
description=domain.description,
|
||||
status=domain.status,
|
||||
created_at=domain.created_at,
|
||||
updated_at=domain.updated_at,
|
||||
topic_count=topic_count,
|
||||
workstream_count=workstream_count,
|
||||
ep_count=ep_count_row.scalar_one(),
|
||||
td_count=td_count_row.scalar_one(),
|
||||
repos=[RepoStub.model_validate(repo) for repo in repos],
|
||||
)
|
||||
|
||||
|
||||
async def _reject_archive_with_active_topics(domain: Domain, session: AsyncSession) -> None:
|
||||
active_topics = await session.execute(
|
||||
select(func.count()).select_from(Topic)
|
||||
.where(Topic.domain_id == domain.id)
|
||||
.where(Topic.status == "active")
|
||||
)
|
||||
if active_topics.scalar_one() > 0:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot archive domain with active topics. Archive or reassign topics first.",
|
||||
)
|
||||
|
||||
|
||||
router = create_domains_router(
|
||||
get_session,
|
||||
domain_model=Domain,
|
||||
repo_model=ManagedRepo,
|
||||
domain_create_schema=DomainCreate,
|
||||
domain_detail_schema=DomainDetail,
|
||||
domain_read_schema=DomainRead,
|
||||
domain_rename_schema=DomainRename,
|
||||
domain_update_schema=DomainUpdate,
|
||||
repo_stub_schema=RepoStub,
|
||||
detail_builder=_build_domain_detail,
|
||||
before_archive=_reject_archive_with_active_topics,
|
||||
list_noload_fields=("topics", "repos", "goals"),
|
||||
include_update_route=False,
|
||||
)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.workplan_launch_request import WorkplanLaunchRequest
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.schemas.execution import (
|
||||
ExecutionIntentRead,
|
||||
ExecutionIntentUpdate,
|
||||
ExecutionSemantics,
|
||||
LaunchRequestCreate,
|
||||
LaunchRequestRead,
|
||||
WorkplanQueueItem,
|
||||
)
|
||||
from api.services.execution_queue import (
|
||||
ACTIVITY_CORE_RESPONSIBILITIES,
|
||||
CONCURRENCY_MODES,
|
||||
EXECUTION_STATES,
|
||||
EXECUTION_REPLACEMENTS,
|
||||
LAUNCH_MODES,
|
||||
STATE_HUB_RESPONSIBILITIES,
|
||||
queue_sort_key,
|
||||
workplan_blockers,
|
||||
)
|
||||
from api.routers.workstreams import _legacy_key
|
||||
from api.services.legacy_compat import retire_legacy_route
|
||||
from api.services.legacy_compat import meter_legacy_query_param
|
||||
from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status
|
||||
|
||||
router = APIRouter(prefix="/execution", tags=["execution"])
|
||||
|
||||
|
||||
@router.get("/semantics", response_model=ExecutionSemantics)
|
||||
async def execution_semantics() -> ExecutionSemantics:
|
||||
return ExecutionSemantics(
|
||||
execution_states=EXECUTION_STATES,
|
||||
launch_modes=LAUNCH_MODES,
|
||||
concurrency_modes=CONCURRENCY_MODES,
|
||||
state_hub_responsibility=STATE_HUB_RESPONSIBILITIES,
|
||||
activity_core_responsibility=ACTIVITY_CORE_RESPONSIBILITIES,
|
||||
launch_requests_accepted=False,
|
||||
replacements=EXECUTION_REPLACEMENTS,
|
||||
)
|
||||
|
||||
|
||||
async def _update_execution_intent(
|
||||
*,
|
||||
workstream_id: uuid.UUID,
|
||||
body: ExecutionIntentUpdate,
|
||||
session: AsyncSession,
|
||||
) -> ExecutionIntentRead:
|
||||
ws = await session.get(Workplan, workstream_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(ws, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return _intent_read(ws)
|
||||
|
||||
|
||||
@router.patch("/workstreams/{workstream_id}/intent", status_code=status.HTTP_410_GONE)
|
||||
async def update_execution_intent(
|
||||
request: Request,
|
||||
response: Response,
|
||||
workstream_id: uuid.UUID,
|
||||
body: ExecutionIntentUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
await retire_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
response=response,
|
||||
interface_key=_legacy_key("PATCH", "/execution/workstreams/{workstream_id}/intent"),
|
||||
replacement_ref="/execution/workplans/{workplan_id}/intent",
|
||||
detail="Legacy PATCH /execution/workstreams/{workstream_id}/intent retired; "
|
||||
"use PATCH /execution/workplans/{workplan_id}/intent",
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/workplans/{workplan_id}/intent", response_model=ExecutionIntentRead)
|
||||
async def update_workplan_execution_intent(
|
||||
workplan_id: uuid.UUID,
|
||||
body: ExecutionIntentUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ExecutionIntentRead:
|
||||
return await _update_execution_intent(workstream_id=workplan_id, body=body, session=session)
|
||||
|
||||
|
||||
@router.get("/workplan-stack", response_model=list[WorkplanQueueItem])
|
||||
async def workplan_stack(
|
||||
include_manual: bool = Query(True),
|
||||
include_blocked: bool = Query(True),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[WorkplanQueueItem]:
|
||||
result = await session.execute(select(Workplan))
|
||||
workstreams = [
|
||||
ws for ws in result.scalars().all()
|
||||
if normalize_workplan_status(ws.status) not in CLOSED_WORKPLAN_STATUSES
|
||||
]
|
||||
ws_by_id = {ws.id: ws for ws in workstreams}
|
||||
ws_status = {ws.id: normalize_workplan_status(ws.status) for ws in workstreams}
|
||||
|
||||
dep_result = await session.execute(select(WorkplanDependency))
|
||||
ws_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
|
||||
task_deps: dict[uuid.UUID, list[uuid.UUID]] = {}
|
||||
for dep in dep_result.scalars().all():
|
||||
if dep.to_workplan_id is not None:
|
||||
ws_deps.setdefault(dep.from_workplan_id, []).append(dep.to_workplan_id)
|
||||
if dep.to_task_id is not None:
|
||||
task_deps.setdefault(dep.from_workplan_id, []).append(dep.to_task_id)
|
||||
|
||||
task_ids = [task_id for ids in task_deps.values() for task_id in ids]
|
||||
task_status: dict[uuid.UUID, str] = {}
|
||||
if task_ids:
|
||||
task_result = await session.execute(select(Task).where(Task.id.in_(task_ids)))
|
||||
task_status = {task.id: _task_status(task.status) for task in task_result.scalars().all()}
|
||||
|
||||
items: list[WorkplanQueueItem] = []
|
||||
for ws in workstreams:
|
||||
if not include_manual and ws.execution_state == "manual":
|
||||
continue
|
||||
lifecycle_status = normalize_workplan_status(ws.status)
|
||||
blocked_ws = [
|
||||
blocker for blocker in workplan_blockers(ws.id, ws_deps, ws_status)
|
||||
if blocker in ws_by_id or blocker in ws_status
|
||||
]
|
||||
blocked_tasks = [
|
||||
task_id for task_id in task_deps.get(ws.id, [])
|
||||
if task_status.get(task_id) not in {"done", "cancel"}
|
||||
]
|
||||
eligible = lifecycle_status != "blocked" and not blocked_ws and not blocked_tasks
|
||||
if not include_blocked and not eligible:
|
||||
continue
|
||||
sort_key = queue_sort_key(ws, eligible=eligible)
|
||||
items.append(WorkplanQueueItem(
|
||||
workplan_id=ws.id,
|
||||
slug=ws.slug,
|
||||
title=ws.title,
|
||||
status=lifecycle_status,
|
||||
repo_id=ws.repo_id,
|
||||
planning_priority=ws.planning_priority,
|
||||
planning_order=ws.planning_order,
|
||||
execution_state=ws.execution_state,
|
||||
launch_mode=ws.launch_mode,
|
||||
concurrency_mode=ws.concurrency_mode,
|
||||
queue_rank=ws.queue_rank,
|
||||
execution_group=ws.execution_group,
|
||||
scheduled_for=ws.scheduled_for,
|
||||
eligible=eligible,
|
||||
blocked_by_workplan_ids=blocked_ws,
|
||||
blocked_by_task_ids=blocked_tasks,
|
||||
sort_key=sort_key,
|
||||
))
|
||||
return sorted(items, key=lambda item: item.sort_key)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/launch-requests",
|
||||
status_code=status.HTTP_410_GONE,
|
||||
)
|
||||
async def create_launch_request(
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: LaunchRequestCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
del body # Request shape stays documented while the retired route returns 410.
|
||||
await retire_legacy_route(
|
||||
session=session,
|
||||
request=request,
|
||||
response=response,
|
||||
interface_key="rest_api:POST /execution/launch-requests",
|
||||
replacement_ref="repo file queue or activity-core ActivityDefinition + ops_run",
|
||||
detail=(
|
||||
"State Hub workplan launch requests are retired: no consumer picks up these rows. "
|
||||
"Queue development work in the authoritative repository file; use an "
|
||||
"ActivityDefinition and activity-core ops_run only for recurring or operational fires."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/launch-requests", response_model=list[LaunchRequestRead])
|
||||
async def list_launch_requests(
|
||||
request: Request,
|
||||
response: Response,
|
||||
workplan_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
request_status: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[WorkplanLaunchRequest]:
|
||||
if workstream_id is not None and workplan_id is None:
|
||||
await meter_legacy_query_param(
|
||||
session=session,
|
||||
request=request,
|
||||
response=response,
|
||||
method="GET",
|
||||
route="/execution/launch-requests",
|
||||
replacement_ref="/execution/launch-requests?workplan_id=<workplan_id>",
|
||||
)
|
||||
q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc())
|
||||
scope_id = workplan_id or workstream_id
|
||||
if scope_id:
|
||||
q = q.where(WorkplanLaunchRequest.workplan_id == scope_id)
|
||||
if request_status:
|
||||
q = q.where(WorkplanLaunchRequest.status == request_status)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
def _intent_read(ws: Workplan) -> ExecutionIntentRead:
|
||||
return ExecutionIntentRead(
|
||||
workplan_id=ws.id,
|
||||
execution_state=ws.execution_state,
|
||||
launch_mode=ws.launch_mode,
|
||||
concurrency_mode=ws.concurrency_mode,
|
||||
queue_rank=ws.queue_rank,
|
||||
execution_group=ws.execution_group,
|
||||
scheduled_for=ws.scheduled_for,
|
||||
)
|
||||
|
||||
|
||||
def _task_status(status_value: TaskStatus | str) -> str:
|
||||
if hasattr(status_value, "value"):
|
||||
return status_value.value
|
||||
return str(status_value or "").strip().lower()
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.domain import Domain
|
||||
from api.models.extension_point import EPStatus, ExtensionPoint
|
||||
from api.schemas.extension_point import EPCreate, EPRead, EPUpdate
|
||||
|
||||
router = APIRouter(prefix="/extension-points", tags=["extension-points"])
|
||||
|
||||
|
||||
async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
|
||||
"""Resolve a domain slug to its UUID, raising 422 if unknown."""
|
||||
row = await session.execute(
|
||||
select(Domain.id).where(Domain.slug == slug, Domain.status == "active")
|
||||
)
|
||||
domain_id = row.scalar_one_or_none()
|
||||
if domain_id is None:
|
||||
valid = [r[0] for r in (await session.execute(
|
||||
select(Domain.slug).where(Domain.status == "active")
|
||||
)).all()]
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}",
|
||||
)
|
||||
return domain_id
|
||||
|
||||
|
||||
@router.get("/", response_model=list[EPRead])
|
||||
async def list_eps(
|
||||
domain: str | None = None,
|
||||
status: EPStatus | None = None,
|
||||
ep_type: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ExtensionPoint]:
|
||||
q = select(ExtensionPoint)
|
||||
if domain:
|
||||
domain_id = await _resolve_domain_id(domain, session)
|
||||
q = q.where(ExtensionPoint.domain_id == domain_id)
|
||||
if status:
|
||||
q = q.where(ExtensionPoint.status == status)
|
||||
if ep_type:
|
||||
q = q.where(ExtensionPoint.ep_type == ep_type)
|
||||
q = q.order_by(ExtensionPoint.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=EPRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_ep(
|
||||
body: EPCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ExtensionPoint:
|
||||
domain_id = await _resolve_domain_id(body.domain, session)
|
||||
data = body.model_dump(exclude={"domain"})
|
||||
data["domain_id"] = domain_id
|
||||
ep = ExtensionPoint(**data)
|
||||
session.add(ep)
|
||||
await session.commit()
|
||||
await session.refresh(ep)
|
||||
return ep
|
||||
|
||||
|
||||
@router.get("/{ep_id}", response_model=EPRead)
|
||||
async def get_ep(
|
||||
ep_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ExtensionPoint:
|
||||
ep = await session.get(ExtensionPoint, ep_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Extension point not found")
|
||||
return ep
|
||||
|
||||
|
||||
@router.patch("/{ep_id}", response_model=EPRead)
|
||||
async def update_ep(
|
||||
ep_id: uuid.UUID,
|
||||
body: EPUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ExtensionPoint:
|
||||
ep = await session.get(ExtensionPoint, ep_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Extension point not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(ep, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(ep)
|
||||
return ep
|
||||
|
||||
|
||||
@router.delete("/{ep_id}", response_model=EPRead)
|
||||
async def defer_ep(
|
||||
ep_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ExtensionPoint:
|
||||
ep = await session.get(ExtensionPoint, ep_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Extension point not found")
|
||||
ep.status = EPStatus.deferred
|
||||
await session.commit()
|
||||
await session.refresh(ep)
|
||||
return ep
|
||||
|
|
@ -0,0 +1,514 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode
|
||||
from api.schemas.fabric_graph import (
|
||||
FabricGraphActivationRequest,
|
||||
FabricGraphActivationResult,
|
||||
FabricGraphEdgeRead,
|
||||
FabricGraphImportRead,
|
||||
FabricGraphIngestResult,
|
||||
FabricGraphNodeRead,
|
||||
FabricGraphPullRequest,
|
||||
FabricGraphSummary,
|
||||
)
|
||||
from api.services.fabric_graph import (
|
||||
FabricGraphValidationError,
|
||||
activate_fabric_graph_import,
|
||||
ingest_fabric_graph_export,
|
||||
record_fabric_graph_error,
|
||||
split_graph_ingest_body,
|
||||
)
|
||||
from api.services.repository_aliases import resolve_repository_slug
|
||||
|
||||
router = APIRouter(prefix="/fabric", tags=["fabric"])
|
||||
|
||||
|
||||
@router.post("/graph-exports", response_model=FabricGraphIngestResult)
|
||||
async def ingest_graph_export(
|
||||
body: dict[str, Any] = Body(...),
|
||||
source_repo_slug: str | None = Query(None),
|
||||
source_url: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FabricGraphIngestResult:
|
||||
graph, metadata = split_graph_ingest_body(body)
|
||||
effective_source_repo_slug = (
|
||||
source_repo_slug
|
||||
or metadata.get("source_repo_slug")
|
||||
or metadata.get("repo_slug")
|
||||
or "railiance-fabric"
|
||||
)
|
||||
effective_source_url = source_url or metadata.get("source_url")
|
||||
requested_by = str(metadata.get("requested_by") or "api")
|
||||
try:
|
||||
import_run, created, idempotent = await ingest_fabric_graph_export(
|
||||
session,
|
||||
graph,
|
||||
source_repo_slug=str(effective_source_repo_slug),
|
||||
source_url=str(effective_source_url) if effective_source_url else None,
|
||||
requested_by=requested_by,
|
||||
)
|
||||
except FabricGraphValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=exc.detail) from exc
|
||||
return FabricGraphIngestResult(
|
||||
import_run=FabricGraphImportRead.model_validate(import_run),
|
||||
created=created,
|
||||
idempotent=idempotent,
|
||||
node_count=import_run.node_count,
|
||||
edge_count=import_run.edge_count,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/graph-exports/pull", response_model=FabricGraphIngestResult)
|
||||
async def pull_graph_export(
|
||||
body: FabricGraphPullRequest | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FabricGraphIngestResult:
|
||||
request = body or FabricGraphPullRequest()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.get(request.source_url)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except Exception as exc:
|
||||
await record_fabric_graph_error(
|
||||
session,
|
||||
"Fabric graph export pull failed.",
|
||||
source_repo_slug=request.source_repo_slug,
|
||||
source_url=request.source_url,
|
||||
error=str(exc),
|
||||
requested_by=request.requested_by,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=f"Fabric graph export pull failed: {exc}") from exc
|
||||
|
||||
try:
|
||||
import_run, created, idempotent = await ingest_fabric_graph_export(
|
||||
session,
|
||||
payload,
|
||||
source_repo_slug=request.source_repo_slug,
|
||||
source_url=request.source_url,
|
||||
requested_by=request.requested_by,
|
||||
)
|
||||
except FabricGraphValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=exc.detail) from exc
|
||||
return FabricGraphIngestResult(
|
||||
import_run=FabricGraphImportRead.model_validate(import_run),
|
||||
created=created,
|
||||
idempotent=idempotent,
|
||||
node_count=import_run.node_count,
|
||||
edge_count=import_run.edge_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/graph-exports", response_model=list[FabricGraphImportRead])
|
||||
async def list_graph_imports(
|
||||
source_repo_slug: str | None = None,
|
||||
validation_status: str | None = None,
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[FabricGraphImportRead]:
|
||||
query = select(FabricGraphImport)
|
||||
if source_repo_slug:
|
||||
query = query.where(FabricGraphImport.source_repo_slug == source_repo_slug)
|
||||
if validation_status:
|
||||
query = query.where(FabricGraphImport.validation_status == validation_status)
|
||||
query = query.order_by(FabricGraphImport.created_at.desc()).limit(limit)
|
||||
result = await session.execute(query)
|
||||
return [FabricGraphImportRead.model_validate(row) for row in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/graph-exports/latest", response_model=FabricGraphImportRead)
|
||||
async def latest_graph_import(
|
||||
source_repo_slug: str = "railiance-fabric",
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FabricGraphImportRead:
|
||||
import_run = await _latest_valid_import(session, source_repo_slug)
|
||||
if import_run is None:
|
||||
raise HTTPException(status_code=404, detail=f"No valid Fabric graph import for '{source_repo_slug}'")
|
||||
return FabricGraphImportRead.model_validate(import_run)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/graph-exports/{import_id}/activate",
|
||||
response_model=FabricGraphActivationResult,
|
||||
)
|
||||
async def activate_graph_import(
|
||||
import_id: uuid.UUID,
|
||||
body: FabricGraphActivationRequest | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FabricGraphActivationResult:
|
||||
request = body or FabricGraphActivationRequest()
|
||||
try:
|
||||
import_run, previous_import_id, activated = await activate_fabric_graph_import(
|
||||
session,
|
||||
import_id,
|
||||
requested_by=request.requested_by,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if import_run is None:
|
||||
raise HTTPException(status_code=404, detail=f"Fabric graph import '{import_id}' not found")
|
||||
return FabricGraphActivationResult(
|
||||
import_run=FabricGraphImportRead.model_validate(import_run),
|
||||
previous_import_id=previous_import_id,
|
||||
activated=activated,
|
||||
idempotent=not activated,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/graph/nodes", response_model=list[FabricGraphNodeRead])
|
||||
async def list_graph_nodes(
|
||||
source_repo_slug: str = "railiance-fabric",
|
||||
domain: str | None = None,
|
||||
repo: str | None = None,
|
||||
canonical_category: str | None = None,
|
||||
fabric_id: str | None = None,
|
||||
subfabric_id: str | None = None,
|
||||
owner_actor_id: str | None = None,
|
||||
owner_role: str | None = None,
|
||||
ownership_resolution: str | None = None,
|
||||
cost_center_id: str | None = None,
|
||||
profit_center_id: str | None = None,
|
||||
evidence_state: str | None = None,
|
||||
evidence_review_state: str | None = None,
|
||||
mapping_fit: str | None = None,
|
||||
kind: str | None = None,
|
||||
unresolved_ownership: bool | None = None,
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[FabricGraphNodeRead]:
|
||||
import_run = await _latest_valid_import_or_404(session, source_repo_slug)
|
||||
query = select(FabricGraphNode).where(FabricGraphNode.import_id == import_run.id)
|
||||
if domain:
|
||||
query = query.where(FabricGraphNode.domain_slug == domain)
|
||||
if repo:
|
||||
resolution = await resolve_repository_slug(session, repo, required=False)
|
||||
query = query.where(
|
||||
FabricGraphNode.repo_slug.in_(
|
||||
resolution.slug_values if resolution else (repo,)
|
||||
)
|
||||
)
|
||||
if canonical_category:
|
||||
query = query.where(FabricGraphNode.canon_category == canonical_category)
|
||||
if fabric_id:
|
||||
query = query.where(FabricGraphNode.fabric_id == fabric_id)
|
||||
if subfabric_id:
|
||||
query = query.where(FabricGraphNode.subfabric_id == subfabric_id)
|
||||
if owner_actor_id:
|
||||
query = query.where(FabricGraphNode.owner_actor_id == owner_actor_id)
|
||||
if owner_role:
|
||||
query = query.where(FabricGraphNode.owner_role == owner_role)
|
||||
if ownership_resolution:
|
||||
query = query.where(FabricGraphNode.ownership_resolution == ownership_resolution)
|
||||
if cost_center_id:
|
||||
query = query.where(FabricGraphNode.cost_center_id == cost_center_id)
|
||||
if profit_center_id:
|
||||
query = query.where(FabricGraphNode.profit_center_id == profit_center_id)
|
||||
if evidence_state:
|
||||
query = query.where(FabricGraphNode.evidence_state == evidence_state)
|
||||
if evidence_review_state:
|
||||
query = query.where(FabricGraphNode.evidence_review_state == evidence_review_state)
|
||||
if mapping_fit:
|
||||
query = query.where(FabricGraphNode.mapping_fit == mapping_fit)
|
||||
if kind:
|
||||
query = query.where(FabricGraphNode.kind == kind)
|
||||
if unresolved_ownership is True:
|
||||
query = query.where(
|
||||
or_(
|
||||
FabricGraphNode.owner_actor_id.is_(None),
|
||||
FabricGraphNode.ownership_resolution.in_(("unresolved", "ambiguous")),
|
||||
)
|
||||
)
|
||||
elif unresolved_ownership is False:
|
||||
query = query.where(
|
||||
FabricGraphNode.owner_actor_id.is_not(None),
|
||||
~FabricGraphNode.ownership_resolution.in_(("unresolved", "ambiguous")),
|
||||
)
|
||||
query = query.order_by(FabricGraphNode.graph_id).limit(limit)
|
||||
result = await session.execute(query)
|
||||
return [FabricGraphNodeRead.model_validate(row) for row in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/graph/edges", response_model=list[FabricGraphEdgeRead])
|
||||
async def list_graph_edges(
|
||||
source_repo_slug: str = "railiance-fabric",
|
||||
canonical_relationship: str | None = None,
|
||||
edge_type: str | None = None,
|
||||
relationship_category: str | None = None,
|
||||
provider_owner_actor_id: str | None = None,
|
||||
consumer_owner_actor_id: str | None = None,
|
||||
provider_fabric_id: str | None = None,
|
||||
consumer_fabric_id: str | None = None,
|
||||
provider_subfabric_id: str | None = None,
|
||||
consumer_subfabric_id: str | None = None,
|
||||
crosses_fabric_boundary: bool | None = None,
|
||||
crosses_subfabric_boundary: bool | None = None,
|
||||
utility_type: str | None = None,
|
||||
utility_business_model: str | None = None,
|
||||
utility_payment_schema_id: str | None = None,
|
||||
cost_center_id: str | None = None,
|
||||
profit_center_id: str | None = None,
|
||||
provider_profit_center_id: str | None = None,
|
||||
consumer_cost_center_id: str | None = None,
|
||||
evidence_state: str | None = None,
|
||||
evidence_review_state: str | None = None,
|
||||
mapping_fit: str | None = None,
|
||||
display_only: bool | None = None,
|
||||
missing_payment_schema: bool | None = None,
|
||||
from_graph_id: str | None = None,
|
||||
to_graph_id: str | None = None,
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[FabricGraphEdgeRead]:
|
||||
import_run = await _latest_valid_import_or_404(session, source_repo_slug)
|
||||
query = select(FabricGraphEdge).where(FabricGraphEdge.import_id == import_run.id)
|
||||
if canonical_relationship:
|
||||
query = query.where(FabricGraphEdge.canonical_type == canonical_relationship)
|
||||
if edge_type:
|
||||
query = query.where(FabricGraphEdge.edge_type == edge_type)
|
||||
if relationship_category:
|
||||
query = query.where(FabricGraphEdge.relationship_category == relationship_category)
|
||||
if provider_owner_actor_id:
|
||||
query = query.where(FabricGraphEdge.provider_owner_actor_id == provider_owner_actor_id)
|
||||
if consumer_owner_actor_id:
|
||||
query = query.where(FabricGraphEdge.consumer_owner_actor_id == consumer_owner_actor_id)
|
||||
if provider_fabric_id:
|
||||
query = query.where(FabricGraphEdge.provider_fabric_id == provider_fabric_id)
|
||||
if consumer_fabric_id:
|
||||
query = query.where(FabricGraphEdge.consumer_fabric_id == consumer_fabric_id)
|
||||
if provider_subfabric_id:
|
||||
query = query.where(FabricGraphEdge.provider_subfabric_id == provider_subfabric_id)
|
||||
if consumer_subfabric_id:
|
||||
query = query.where(FabricGraphEdge.consumer_subfabric_id == consumer_subfabric_id)
|
||||
if crosses_fabric_boundary is not None:
|
||||
query = query.where(FabricGraphEdge.crosses_fabric_boundary == crosses_fabric_boundary)
|
||||
if crosses_subfabric_boundary is not None:
|
||||
query = query.where(FabricGraphEdge.crosses_subfabric_boundary == crosses_subfabric_boundary)
|
||||
if utility_type:
|
||||
query = query.where(FabricGraphEdge.utility_type == utility_type)
|
||||
if utility_business_model:
|
||||
query = query.where(FabricGraphEdge.utility_business_model == utility_business_model)
|
||||
if utility_payment_schema_id:
|
||||
query = query.where(FabricGraphEdge.utility_payment_schema_id == utility_payment_schema_id)
|
||||
if cost_center_id:
|
||||
query = query.where(FabricGraphEdge.cost_center_id == cost_center_id)
|
||||
if profit_center_id:
|
||||
query = query.where(FabricGraphEdge.profit_center_id == profit_center_id)
|
||||
if provider_profit_center_id:
|
||||
query = query.where(FabricGraphEdge.provider_profit_center_id == provider_profit_center_id)
|
||||
if consumer_cost_center_id:
|
||||
query = query.where(FabricGraphEdge.consumer_cost_center_id == consumer_cost_center_id)
|
||||
if evidence_state:
|
||||
query = query.where(FabricGraphEdge.evidence_state == evidence_state)
|
||||
if evidence_review_state:
|
||||
query = query.where(FabricGraphEdge.evidence_review_state == evidence_review_state)
|
||||
if mapping_fit:
|
||||
query = query.where(FabricGraphEdge.mapping_fit == mapping_fit)
|
||||
if display_only is not None:
|
||||
query = query.where(FabricGraphEdge.display_only == display_only)
|
||||
if missing_payment_schema is True:
|
||||
query = query.where(
|
||||
FabricGraphEdge.relationship_category == "utility",
|
||||
FabricGraphEdge.utility_payment_schema_id.is_(None),
|
||||
)
|
||||
elif missing_payment_schema is False:
|
||||
query = query.where(
|
||||
or_(
|
||||
FabricGraphEdge.relationship_category != "utility",
|
||||
FabricGraphEdge.utility_payment_schema_id.is_not(None),
|
||||
)
|
||||
)
|
||||
if from_graph_id:
|
||||
query = query.where(FabricGraphEdge.from_graph_id == from_graph_id)
|
||||
if to_graph_id:
|
||||
query = query.where(FabricGraphEdge.to_graph_id == to_graph_id)
|
||||
query = query.order_by(FabricGraphEdge.from_graph_id, FabricGraphEdge.to_graph_id, FabricGraphEdge.edge_type).limit(limit)
|
||||
result = await session.execute(query)
|
||||
return [FabricGraphEdgeRead.model_validate(row) for row in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/graph/summary", response_model=FabricGraphSummary)
|
||||
async def graph_summary(
|
||||
source_repo_slug: str = "railiance-fabric",
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FabricGraphSummary:
|
||||
import_run = await _latest_valid_import(session, source_repo_slug)
|
||||
if import_run is None:
|
||||
return FabricGraphSummary(
|
||||
source_repo_slug=source_repo_slug,
|
||||
latest_import=None,
|
||||
node_count=0,
|
||||
edge_count=0,
|
||||
schema_version=None,
|
||||
netkingdom_id=None,
|
||||
actor_count=0,
|
||||
fabric_count=0,
|
||||
unresolved_count=0,
|
||||
nodes_by_domain={},
|
||||
nodes_by_repo={},
|
||||
nodes_by_canon_category={},
|
||||
nodes_by_fabric={},
|
||||
nodes_by_subfabric={},
|
||||
nodes_by_owner_actor={},
|
||||
nodes_by_owner_role={},
|
||||
nodes_by_ownership_resolution={},
|
||||
edges_by_canonical_type={},
|
||||
edges_by_relationship_category={},
|
||||
utility_edges_by_provider_owner={},
|
||||
utility_edges_by_consumer_owner={},
|
||||
utility_edges_by_business_model={},
|
||||
nodes_by_evidence_state={},
|
||||
edges_by_evidence_state={},
|
||||
nodes_by_mapping_fit={},
|
||||
edges_by_mapping_fit={},
|
||||
tenant_utilities_without_payment_schema=0,
|
||||
nodes_without_accounting_attribution=0,
|
||||
unresolved_ownership_count=0,
|
||||
unresolved_accounting_count=0,
|
||||
example_nodes=[],
|
||||
example_edges=[],
|
||||
)
|
||||
|
||||
return FabricGraphSummary(
|
||||
source_repo_slug=source_repo_slug,
|
||||
latest_import=FabricGraphImportRead.model_validate(import_run),
|
||||
node_count=import_run.node_count,
|
||||
edge_count=import_run.edge_count,
|
||||
schema_version=import_run.schema_version,
|
||||
netkingdom_id=import_run.netkingdom_id,
|
||||
actor_count=import_run.actor_count,
|
||||
fabric_count=import_run.fabric_count,
|
||||
unresolved_count=import_run.unresolved_count,
|
||||
nodes_by_domain=await _counts(session, FabricGraphNode.domain_slug, import_run.id),
|
||||
nodes_by_repo=await _counts(session, FabricGraphNode.repo_slug, import_run.id),
|
||||
nodes_by_canon_category=await _counts(session, FabricGraphNode.canon_category, import_run.id),
|
||||
nodes_by_fabric=await _counts(session, FabricGraphNode.fabric_id, import_run.id),
|
||||
nodes_by_subfabric=await _counts(session, FabricGraphNode.subfabric_id, import_run.id),
|
||||
nodes_by_owner_actor=await _counts(session, FabricGraphNode.owner_actor_id, import_run.id),
|
||||
nodes_by_owner_role=await _counts(session, FabricGraphNode.owner_role, import_run.id),
|
||||
nodes_by_ownership_resolution=await _counts(session, FabricGraphNode.ownership_resolution, import_run.id),
|
||||
edges_by_canonical_type=await _counts(session, FabricGraphEdge.canonical_type, import_run.id),
|
||||
edges_by_relationship_category=await _counts(session, FabricGraphEdge.relationship_category, import_run.id),
|
||||
utility_edges_by_provider_owner=await _counts(
|
||||
session,
|
||||
FabricGraphEdge.provider_owner_actor_id,
|
||||
import_run.id,
|
||||
FabricGraphEdge.relationship_category == "utility",
|
||||
),
|
||||
utility_edges_by_consumer_owner=await _counts(
|
||||
session,
|
||||
FabricGraphEdge.consumer_owner_actor_id,
|
||||
import_run.id,
|
||||
FabricGraphEdge.relationship_category == "utility",
|
||||
),
|
||||
utility_edges_by_business_model=await _counts(
|
||||
session,
|
||||
FabricGraphEdge.utility_business_model,
|
||||
import_run.id,
|
||||
FabricGraphEdge.relationship_category == "utility",
|
||||
),
|
||||
nodes_by_evidence_state=await _counts(session, FabricGraphNode.evidence_state, import_run.id),
|
||||
edges_by_evidence_state=await _counts(session, FabricGraphEdge.evidence_state, import_run.id),
|
||||
nodes_by_mapping_fit=await _counts(session, FabricGraphNode.mapping_fit, import_run.id),
|
||||
edges_by_mapping_fit=await _counts(session, FabricGraphEdge.mapping_fit, import_run.id),
|
||||
tenant_utilities_without_payment_schema=await _count_rows(
|
||||
session,
|
||||
FabricGraphEdge,
|
||||
import_run.id,
|
||||
FabricGraphEdge.relationship_category == "utility",
|
||||
FabricGraphEdge.utility_business_model == "tenant_utility",
|
||||
FabricGraphEdge.utility_payment_schema_id.is_(None),
|
||||
),
|
||||
nodes_without_accounting_attribution=await _count_rows(
|
||||
session,
|
||||
FabricGraphNode,
|
||||
import_run.id,
|
||||
FabricGraphNode.cost_center_id.is_(None),
|
||||
FabricGraphNode.profit_center_id.is_(None),
|
||||
),
|
||||
unresolved_ownership_count=await _count_rows(
|
||||
session,
|
||||
FabricGraphNode,
|
||||
import_run.id,
|
||||
or_(
|
||||
FabricGraphNode.owner_actor_id.is_(None),
|
||||
FabricGraphNode.ownership_resolution.in_(("unresolved", "ambiguous")),
|
||||
),
|
||||
),
|
||||
unresolved_accounting_count=await _count_rows(
|
||||
session,
|
||||
FabricGraphEdge,
|
||||
import_run.id,
|
||||
FabricGraphEdge.relationship_category == "utility",
|
||||
FabricGraphEdge.utility_payment_schema_id.is_(None),
|
||||
),
|
||||
example_nodes=await _example_nodes(session, import_run.id),
|
||||
example_edges=await _example_edges(session, import_run.id),
|
||||
)
|
||||
|
||||
|
||||
async def _latest_valid_import(session: AsyncSession, source_repo_slug: str) -> FabricGraphImport | None:
|
||||
result = await session.execute(
|
||||
select(FabricGraphImport)
|
||||
.where(
|
||||
FabricGraphImport.source_repo_slug == source_repo_slug,
|
||||
FabricGraphImport.validation_status == "valid",
|
||||
FabricGraphImport.is_latest.is_(True),
|
||||
)
|
||||
.order_by(FabricGraphImport.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _latest_valid_import_or_404(session: AsyncSession, source_repo_slug: str) -> FabricGraphImport:
|
||||
import_run = await _latest_valid_import(session, source_repo_slug)
|
||||
if import_run is None:
|
||||
raise HTTPException(status_code=404, detail=f"No valid Fabric graph import for '{source_repo_slug}'")
|
||||
return import_run
|
||||
|
||||
|
||||
async def _counts(session: AsyncSession, column: Any, import_id: Any, *conditions: Any) -> dict[str, int]:
|
||||
table = column.class_
|
||||
result = await session.execute(
|
||||
select(column, func.count())
|
||||
.select_from(table)
|
||||
.where(table.import_id == import_id, column.is_not(None), *conditions)
|
||||
.group_by(column)
|
||||
)
|
||||
return {str(key): int(count) for key, count in result.all() if key is not None}
|
||||
|
||||
|
||||
async def _count_rows(session: AsyncSession, table: Any, import_id: Any, *conditions: Any) -> int:
|
||||
result = await session.execute(
|
||||
select(func.count()).select_from(table).where(table.import_id == import_id, *conditions)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def _example_nodes(session: AsyncSession, import_id: Any) -> list[FabricGraphNodeRead]:
|
||||
result = await session.execute(
|
||||
select(FabricGraphNode)
|
||||
.where(FabricGraphNode.import_id == import_id)
|
||||
.order_by(FabricGraphNode.graph_id)
|
||||
.limit(5)
|
||||
)
|
||||
return [FabricGraphNodeRead.model_validate(row) for row in result.scalars().all()]
|
||||
|
||||
|
||||
async def _example_edges(session: AsyncSession, import_id: Any) -> list[FabricGraphEdgeRead]:
|
||||
result = await session.execute(
|
||||
select(FabricGraphEdge)
|
||||
.where(FabricGraphEdge.import_id == import_id)
|
||||
.order_by(FabricGraphEdge.from_graph_id, FabricGraphEdge.to_graph_id, FabricGraphEdge.edge_type)
|
||||
.limit(5)
|
||||
)
|
||||
return [FabricGraphEdgeRead.model_validate(row) for row in result.scalars().all()]
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.flow_defs import (
|
||||
assertion_result_to_dict,
|
||||
create_flow_engine,
|
||||
flow_result_to_dict,
|
||||
load_flow,
|
||||
)
|
||||
from api.models.capability_request import CapabilityRequest
|
||||
from api.models.contribution import Contribution
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.services.lifecycle import transition_task_status, transition_workplan_status
|
||||
from api.workplan_status import normalize_workplan_status
|
||||
|
||||
router = APIRouter(prefix="/flows", tags=["flows"])
|
||||
|
||||
_WORKPLAN_FLOW_ENTITY_TYPES = frozenset({"workplan", "workstream"})
|
||||
|
||||
|
||||
def _is_workplan_flow(entity_type: str) -> bool:
|
||||
return entity_type in _WORKPLAN_FLOW_ENTITY_TYPES
|
||||
|
||||
|
||||
@router.get("/definitions")
|
||||
async def list_flow_definitions() -> list[dict[str, Any]]:
|
||||
flows = [
|
||||
load_flow(entity_type)
|
||||
for entity_type in (
|
||||
"workplan",
|
||||
"workstream",
|
||||
"task",
|
||||
"contribution",
|
||||
"capability_request",
|
||||
)
|
||||
]
|
||||
return [
|
||||
{
|
||||
"id": flow.id,
|
||||
"entity_type": flow.entity_type,
|
||||
"workstations": [
|
||||
{
|
||||
"name": workstation.name,
|
||||
"description": workstation.description,
|
||||
"entry_assertion_count": len(workstation.entry_assertions),
|
||||
"exit_assertion_count": len(workstation.exit_assertions),
|
||||
}
|
||||
for workstation in flow.workstations
|
||||
],
|
||||
}
|
||||
for flow in flows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{entity_type}/{entity_id}")
|
||||
async def get_flow_state(
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
obj = await _flow_object(entity_type, entity_id, session)
|
||||
flow = load_flow(entity_type)
|
||||
result = create_flow_engine().evaluate(obj, flow)
|
||||
return flow_result_to_dict(result)
|
||||
|
||||
|
||||
@router.post("/{entity_type}/{entity_id}/advance/{target_workstation}")
|
||||
async def advance_workstation(
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
target_workstation: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
obj = await _flow_object(entity_type, entity_id, session)
|
||||
flow = load_flow(entity_type)
|
||||
engine = create_flow_engine()
|
||||
can_reach, failures = engine.can_reach(obj, flow, target_workstation)
|
||||
if not can_reach:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": (
|
||||
f"Cannot advance {entity_type} '{entity_id}' "
|
||||
f"to '{target_workstation}'."
|
||||
),
|
||||
"blocking_assertions": [
|
||||
assertion_result_to_dict(item) for item in failures
|
||||
],
|
||||
"flow_result": flow_result_to_dict(engine.evaluate(obj, flow)),
|
||||
},
|
||||
)
|
||||
|
||||
entity = await _entity(entity_type, entity_id, session)
|
||||
if _is_workplan_flow(entity_type):
|
||||
transition_workplan_status(entity, target_workstation)
|
||||
elif entity_type == "task":
|
||||
parent = await session.get(Workplan, entity.workplan_id)
|
||||
transition_task_status(
|
||||
entity,
|
||||
target_workstation,
|
||||
parent_workstream=parent,
|
||||
status_coercer=TaskStatus,
|
||||
)
|
||||
else:
|
||||
entity.status = target_workstation
|
||||
await session.commit()
|
||||
await session.refresh(entity)
|
||||
return await get_flow_state(entity_type, entity_id, session)
|
||||
|
||||
|
||||
async def _flow_object(
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> dict[str, Any]:
|
||||
entity = await _entity(entity_type, entity_id, session)
|
||||
status = _value(entity.status)
|
||||
current_status = (
|
||||
normalize_workplan_status(status) if _is_workplan_flow(entity_type) else status
|
||||
)
|
||||
obj: dict[str, Any] = {
|
||||
"id": str(entity.id),
|
||||
"status": current_status,
|
||||
"workstation": current_status,
|
||||
"previous_workstation": current_status,
|
||||
}
|
||||
|
||||
if _is_workplan_flow(entity_type):
|
||||
tasks = list((await session.execute(
|
||||
select(Task).where(Task.workplan_id == entity_id)
|
||||
)).scalars().all())
|
||||
deps = list((await session.execute(
|
||||
select(WorkplanDependency).where(
|
||||
WorkplanDependency.from_workplan_id == entity_id
|
||||
)
|
||||
)).scalars().all())
|
||||
dependency_ids = [dep.to_workplan_id for dep in deps]
|
||||
dependency_workstations: list[dict[str, Any]] = []
|
||||
if dependency_ids:
|
||||
dep_ws = list((await session.execute(
|
||||
select(Workplan).where(Workplan.id.in_(dependency_ids))
|
||||
)).scalars().all())
|
||||
dependency_workstations = [
|
||||
{"id": str(ws.id), "workstation": normalize_workplan_status(ws.status)}
|
||||
for ws in dep_ws
|
||||
]
|
||||
obj.update({
|
||||
"tasks": [{"id": str(task.id), "status": _value(task.status)} for task in tasks],
|
||||
"dependencies": dependency_workstations,
|
||||
})
|
||||
elif entity_type == "task":
|
||||
obj.update({
|
||||
"needs_human": entity.needs_human,
|
||||
"blocking_reason": entity.blocking_reason,
|
||||
})
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
async def _entity(
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
):
|
||||
model_by_type = {
|
||||
"workplan": Workplan,
|
||||
"workstream": Workplan,
|
||||
"task": Task,
|
||||
"contribution": Contribution,
|
||||
"capability_request": CapabilityRequest,
|
||||
}
|
||||
model = model_by_type.get(entity_type)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown flow entity type '{entity_type}'")
|
||||
entity = await session.get(model, entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail=f"{entity_type} '{entity_id}' not found")
|
||||
return entity
|
||||
|
||||
|
||||
def _value(item):
|
||||
return item.value if hasattr(item, "value") else item
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.config import settings
|
||||
from api.schemas.identifier_migration import (
|
||||
IdentifierMigrationApplySubmit,
|
||||
SealedProjectionRepairReceiptRead,
|
||||
SealedProjectionRepairSubmit,
|
||||
)
|
||||
from api.services.work_record_identifier_migration import (
|
||||
IdentifierMigrationError,
|
||||
apply_repository_identifier_migration,
|
||||
repair_absent_prederivation_projection,
|
||||
reverse_repository_identifier_migration,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/identifier-migrations", tags=["identifier-migrations"])
|
||||
|
||||
|
||||
@router.post("/repositories/{repo_slug}/apply")
|
||||
async def apply_identifier_migration(
|
||||
repo_slug: str,
|
||||
body: IdentifierMigrationApplySubmit,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Apply one sealed repository-atomic identifier convergence transaction."""
|
||||
if settings.state_hub_instance_role != "primary":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "identifier migration writes require the primary State Hub",
|
||||
"instance_role": settings.state_hub_instance_role,
|
||||
"instance_label": settings.state_hub_instance_label,
|
||||
},
|
||||
)
|
||||
if not body.primary_confirmed:
|
||||
raise HTTPException(status_code=409, detail="explicit primary confirmation is required")
|
||||
if body.plan.get("plan_sha256") != body.expected_plan_sha256:
|
||||
raise HTTPException(status_code=409, detail="explicit plan SHA-256 does not match sealed plan")
|
||||
try:
|
||||
result = await apply_repository_identifier_migration(session, body.plan, repo_slug)
|
||||
except IdentifierMigrationError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
from api.routers.workstreams import _invalidate_workplan_index_cache
|
||||
|
||||
_invalidate_workplan_index_cache()
|
||||
return {
|
||||
"schema": "state-hub.identifier-migration-apply.v1",
|
||||
"instance_role": settings.state_hub_instance_role,
|
||||
"instance_label": settings.state_hub_instance_label,
|
||||
"result": asdict(result),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/repositories/{repo_slug}/reverse")
|
||||
async def reverse_identifier_migration(
|
||||
repo_slug: str,
|
||||
body: IdentifierMigrationApplySubmit,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Reverse one sealed repository migration after a failed file phase."""
|
||||
if settings.state_hub_instance_role != "primary":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "identifier migration writes require the primary State Hub",
|
||||
"instance_role": settings.state_hub_instance_role,
|
||||
"instance_label": settings.state_hub_instance_label,
|
||||
},
|
||||
)
|
||||
if not body.primary_confirmed:
|
||||
raise HTTPException(status_code=409, detail="explicit primary confirmation is required")
|
||||
if body.plan.get("plan_sha256") != body.expected_plan_sha256:
|
||||
raise HTTPException(status_code=409, detail="explicit plan SHA-256 does not match sealed plan")
|
||||
try:
|
||||
result = await reverse_repository_identifier_migration(session, body.plan, repo_slug)
|
||||
except IdentifierMigrationError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
from api.routers.workstreams import _invalidate_workplan_index_cache
|
||||
|
||||
_invalidate_workplan_index_cache()
|
||||
return {
|
||||
"schema": "state-hub.identifier-migration-reverse.v1",
|
||||
"instance_role": settings.state_hub_instance_role,
|
||||
"instance_label": settings.state_hub_instance_label,
|
||||
"result": asdict(result),
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sealed-projection-repairs",
|
||||
response_model=SealedProjectionRepairReceiptRead,
|
||||
)
|
||||
async def repair_sealed_projection(
|
||||
body: SealedProjectionRepairSubmit,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Restore one exact pre-derivation workplan unit into an absent projection."""
|
||||
try:
|
||||
receipt = await repair_absent_prederivation_projection(
|
||||
session,
|
||||
body.plan,
|
||||
body.repo_slug,
|
||||
body.unit,
|
||||
expected_plan_sha256=body.expected_plan_sha256,
|
||||
source_revision=body.source_revision,
|
||||
source_fingerprint=body.source_fingerprint,
|
||||
source_clean=body.source_clean,
|
||||
source_synchronized=body.source_synchronized,
|
||||
primary_confirmed=body.primary_confirmed,
|
||||
projection_identity=body.projection_identity,
|
||||
)
|
||||
except IdentifierMigrationError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return asdict(receipt)
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.intake import Intake, IntakeNote, IntakeOutcome, IntakeStatus
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.schemas.intake import (
|
||||
IntakeClose,
|
||||
IntakeCreate,
|
||||
IntakeNoteCreate,
|
||||
IntakeRead,
|
||||
IntakeRoute,
|
||||
IntakeUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/intakes", tags=["intakes"])
|
||||
|
||||
_ALLOWED_ROUTE_FROM = {IntakeStatus.open, IntakeStatus.vetted}
|
||||
_ALLOWED_CLOSE_FROM = {IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed}
|
||||
|
||||
|
||||
def _reject_status(intake: Intake, allowed: set[IntakeStatus], action: str) -> None:
|
||||
if intake.status not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
f"Cannot {action} intake in status '{intake.status.value}'; "
|
||||
f"allowed from: {sorted(s.value for s in allowed)}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[IntakeRead])
|
||||
async def list_intakes(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
workplan_id: uuid.UUID | None = None,
|
||||
repo_id: uuid.UUID | None = None,
|
||||
status_: IntakeStatus | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Intake]:
|
||||
q = select(Intake)
|
||||
if topic_id:
|
||||
q = q.where(Intake.topic_id == topic_id)
|
||||
if workplan_id:
|
||||
q = q.where(Intake.workplan_id == workplan_id)
|
||||
if repo_id:
|
||||
q = q.where(Intake.repo_id == repo_id)
|
||||
if status_:
|
||||
q = q.where(Intake.status == status_)
|
||||
q = q.order_by(Intake.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=IntakeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_intake(
|
||||
body: IntakeCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
intake = Intake(**body.model_dump())
|
||||
session.add(intake)
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
return intake
|
||||
|
||||
|
||||
@router.get("/{intake_id}", response_model=IntakeRead)
|
||||
async def get_intake(
|
||||
intake_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
return intake
|
||||
|
||||
|
||||
@router.patch("/{intake_id}", response_model=IntakeRead)
|
||||
async def update_intake(
|
||||
intake_id: uuid.UUID,
|
||||
body: IntakeUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(intake, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
return intake
|
||||
|
||||
|
||||
@router.post("/{intake_id}/route", response_model=IntakeRead)
|
||||
async def route_intake(
|
||||
intake_id: uuid.UUID,
|
||||
body: IntakeRoute,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
"""Move an intake into `routed` — eligible for the promotion transition."""
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
_reject_status(intake, _ALLOWED_ROUTE_FROM, "route")
|
||||
|
||||
intake.status = IntakeStatus.routed
|
||||
if body.routed_note:
|
||||
intake.routed_note = body.routed_note
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
return intake
|
||||
|
||||
|
||||
@router.post("/{intake_id}/close", response_model=IntakeRead)
|
||||
async def close_intake(
|
||||
intake_id: uuid.UUID,
|
||||
body: IntakeClose,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
"""Close an intake with an outcome. `outcome=promoted` requires
|
||||
`promoted_to` (the canonical id of the record it became) — this is
|
||||
normally called by the promotion transition (CUST-WP-0061-T03), not by
|
||||
hand, but a manual close (declined/absorbed, or a promotion recorded
|
||||
after the fact) is supported directly."""
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
_reject_status(intake, _ALLOWED_CLOSE_FROM, "close")
|
||||
|
||||
intake.status = IntakeStatus.closed
|
||||
intake.outcome = body.outcome
|
||||
intake.closed_at = datetime.now(tz=timezone.utc)
|
||||
if body.promoted_to:
|
||||
intake.promoted_to = body.promoted_to
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
|
||||
event = ProgressEvent(
|
||||
topic_id=intake.topic_id,
|
||||
workplan_id=intake.workplan_id,
|
||||
event_type="intake_closed",
|
||||
summary=f"Intake closed ({body.outcome.value}): {intake.title}",
|
||||
detail={
|
||||
"intake_id": str(intake.id),
|
||||
"outcome": body.outcome.value,
|
||||
"promoted_to": body.promoted_to,
|
||||
"note": body.note,
|
||||
},
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
|
||||
return intake
|
||||
|
||||
|
||||
@router.post("/{intake_id}/notes", response_model=IntakeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def add_intake_note(
|
||||
intake_id: uuid.UUID,
|
||||
body: IntakeNoteCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Intake:
|
||||
intake = await session.get(Intake, intake_id)
|
||||
if intake is None:
|
||||
raise HTTPException(status_code=404, detail="Intake not found")
|
||||
note = IntakeNote(intake_id=intake.id, author=body.author, content=body.content)
|
||||
session.add(note)
|
||||
await session.commit()
|
||||
await session.refresh(intake)
|
||||
return intake
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.agent_message import AgentMessage
|
||||
from api.models.interface_change import InterfaceChange
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.schemas.interface_change import (
|
||||
InterfaceChangeCreate,
|
||||
InterfaceChangePatch,
|
||||
InterfaceChangeRead,
|
||||
)
|
||||
from api.services.repository_aliases import (
|
||||
affected_slug_predicate,
|
||||
canonicalize_repository_slug,
|
||||
resolve_repository_slug,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/interface-changes", tags=["interface-changes"])
|
||||
|
||||
_VALID_INTERFACE_TYPES = {"rest_api", "mcp_tool", "cli", "schema", "capability"}
|
||||
_VALID_CHANGE_TYPES = {"breaking", "additive", "deprecation", "removal"}
|
||||
|
||||
|
||||
@router.post("/", response_model=InterfaceChangeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_interface_change(
|
||||
body: InterfaceChangeCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
if body.interface_type not in _VALID_INTERFACE_TYPES:
|
||||
raise HTTPException(status_code=422, detail=f"interface_type must be one of {sorted(_VALID_INTERFACE_TYPES)}")
|
||||
if body.change_type not in _VALID_CHANGE_TYPES:
|
||||
raise HTTPException(status_code=422, detail=f"change_type must be one of {sorted(_VALID_CHANGE_TYPES)}")
|
||||
|
||||
resolution = await resolve_repository_slug(session, body.repo_slug)
|
||||
repo = resolution.repo
|
||||
affected_repo_slugs = [
|
||||
await canonicalize_repository_slug(session, slug)
|
||||
for slug in body.affected_repo_slugs
|
||||
]
|
||||
change = InterfaceChange(
|
||||
repo_id=repo.id,
|
||||
interface_type=body.interface_type,
|
||||
change_type=body.change_type,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
affected_paths=body.affected_paths,
|
||||
affected_repo_slugs=affected_repo_slugs,
|
||||
planned_for=body.planned_for,
|
||||
author=body.author,
|
||||
status="draft",
|
||||
)
|
||||
session.add(change)
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[InterfaceChangeRead])
|
||||
async def list_interface_changes(
|
||||
repo_slug: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
change_type: str | None = Query(None),
|
||||
affected_repo: str | None = Query(None, description="Return changes that affect this repo slug"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[InterfaceChangeRead]:
|
||||
q = select(InterfaceChange).order_by(InterfaceChange.created_at.desc())
|
||||
if repo_slug:
|
||||
repo = await _repo_by_slug(repo_slug, session)
|
||||
q = q.where(InterfaceChange.repo_id == repo.id)
|
||||
if status:
|
||||
q = q.where(InterfaceChange.status == status)
|
||||
if change_type:
|
||||
q = q.where(InterfaceChange.change_type == change_type)
|
||||
if affected_repo:
|
||||
resolution = await resolve_repository_slug(
|
||||
session, affected_repo, required=False
|
||||
)
|
||||
values = resolution.slug_values if resolution else (affected_repo,)
|
||||
q = q.where(affected_slug_predicate(InterfaceChange.affected_repo_slugs, values))
|
||||
result = await session.execute(q)
|
||||
return [InterfaceChangeRead.from_orm_with_slug(c) for c in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{change_id}", response_model=InterfaceChangeRead)
|
||||
async def get_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.patch("/{change_id}", response_model=InterfaceChangeRead)
|
||||
async def patch_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
body: InterfaceChangePatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
if change.status != "draft":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot edit a change with status '{change.status}'. Only draft records are mutable.",
|
||||
)
|
||||
payload = body.model_dump(exclude_unset=True)
|
||||
if payload.get("affected_repo_slugs") is not None:
|
||||
payload["affected_repo_slugs"] = [
|
||||
await canonicalize_repository_slug(session, slug)
|
||||
for slug in payload["affected_repo_slugs"]
|
||||
]
|
||||
for field, value in payload.items():
|
||||
setattr(change, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.post("/{change_id}/publish", response_model=InterfaceChangeRead)
|
||||
async def publish_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
if change.status != "draft":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot publish a change with status '{change.status}'. Must be 'draft'.",
|
||||
)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
change.status = "published"
|
||||
change.published_at = now
|
||||
|
||||
# Send inbox notifications to agents of affected repos
|
||||
affected = change.affected_repo_slugs or []
|
||||
for slug in affected:
|
||||
target_slug = await canonicalize_repository_slug(session, slug)
|
||||
paths_summary = ", ".join(change.affected_paths[:5]) if change.affected_paths else "see description"
|
||||
if len(change.affected_paths) > 5:
|
||||
paths_summary += f" (+{len(change.affected_paths) - 5} more)"
|
||||
msg = AgentMessage(
|
||||
from_agent=change.repo.slug,
|
||||
to_agent=target_slug,
|
||||
subject=f"[{change.change_type.upper()}] {change.title}",
|
||||
body=(
|
||||
f"**Interface change published by `{change.repo.slug}`**\n\n"
|
||||
f"- Type: `{change.interface_type}` / `{change.change_type}`\n"
|
||||
f"- Affected paths: {paths_summary}\n\n"
|
||||
f"{change.description}\n\n"
|
||||
f"Change ID: `{change.id}` — resolve with "
|
||||
f"`POST /interface-changes/{change.id}/resolve` once adapted."
|
||||
),
|
||||
)
|
||||
session.add(msg)
|
||||
|
||||
# Progress event on the originating repo
|
||||
session.add(ProgressEvent(
|
||||
event_type="milestone",
|
||||
summary=f"Interface change published: {change.title}",
|
||||
detail={
|
||||
"change_id": str(change.id),
|
||||
"change_type": change.change_type,
|
||||
"interface_type": change.interface_type,
|
||||
"affected_repos": affected,
|
||||
"notifications_sent": len(affected),
|
||||
},
|
||||
author=change.author,
|
||||
))
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.post("/{change_id}/resolve", response_model=InterfaceChangeRead)
|
||||
async def resolve_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
if change.status != "published":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot resolve a change with status '{change.status}'. Must be 'published'.",
|
||||
)
|
||||
change.status = "resolved"
|
||||
change.resolved_at = datetime.now(tz=timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
async def _repo_by_slug(slug: str, session: AsyncSession):
|
||||
resolution = await resolve_repository_slug(session, slug)
|
||||
return resolution.repo
|
||||
|
||||
|
||||
async def _get_or_404(change_id: uuid.UUID, session: AsyncSession) -> InterfaceChange:
|
||||
result = await session.execute(
|
||||
select(InterfaceChange).where(InterfaceChange.id == change_id)
|
||||
)
|
||||
change = result.scalar_one_or_none()
|
||||
if change is None:
|
||||
raise HTTPException(status_code=404, detail=f"InterfaceChange '{change_id}' not found")
|
||||
return change
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.legacy_meter import LegacyInterface
|
||||
from api.schemas.legacy_meter import (
|
||||
LegacyInterfacePatch,
|
||||
LegacyInterfaceRead,
|
||||
LegacyInterfaceRegister,
|
||||
LegacyUsageRecord,
|
||||
LegacyUsageSummary,
|
||||
LegacyWeeklyReview,
|
||||
)
|
||||
from api.services.legacy_meter import (
|
||||
LegacyUsageIdentity,
|
||||
get_legacy_interface_by_key,
|
||||
legacy_usage_summary,
|
||||
legacy_weekly_review,
|
||||
list_legacy_interfaces,
|
||||
patch_legacy_interface,
|
||||
record_legacy_usage,
|
||||
register_legacy_interface,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/legacy-meter", tags=["legacy-meter"])
|
||||
|
||||
|
||||
def _review_window(
|
||||
*,
|
||||
days: int | None,
|
||||
hours: int | None,
|
||||
window_start: datetime | None,
|
||||
window_end: datetime | None,
|
||||
) -> tuple[datetime, datetime]:
|
||||
end = window_end or datetime.now(tz=timezone.utc)
|
||||
if window_start is not None:
|
||||
return window_start, end
|
||||
if hours is not None:
|
||||
return end - timedelta(hours=hours), end
|
||||
return end - timedelta(days=days or 7), end
|
||||
|
||||
|
||||
@router.post(
|
||||
"/interfaces",
|
||||
response_model=LegacyInterfaceRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def register_interface(
|
||||
body: LegacyInterfaceRegister,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LegacyInterface:
|
||||
try:
|
||||
return await register_legacy_interface(session, **body.model_dump())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/interfaces", response_model=list[LegacyInterfaceRead])
|
||||
async def list_interfaces(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[LegacyInterface]:
|
||||
return await list_legacy_interfaces(session)
|
||||
|
||||
|
||||
@router.get("/interfaces/by-key", response_model=LegacyInterfaceRead)
|
||||
async def get_interface_by_key(
|
||||
interface_key: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LegacyInterface:
|
||||
interface = await get_legacy_interface_by_key(session, interface_key)
|
||||
if interface is None:
|
||||
raise HTTPException(status_code=404, detail=f"Legacy interface '{interface_key}' not found")
|
||||
return interface
|
||||
|
||||
|
||||
@router.patch("/interfaces/{interface_id}", response_model=LegacyInterfaceRead)
|
||||
async def patch_interface(
|
||||
interface_id: uuid.UUID,
|
||||
body: LegacyInterfacePatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LegacyInterface:
|
||||
interface = await session.get(LegacyInterface, interface_id)
|
||||
if interface is None:
|
||||
raise HTTPException(status_code=404, detail=f"Legacy interface '{interface_id}' not found")
|
||||
try:
|
||||
return await patch_legacy_interface(
|
||||
session,
|
||||
interface,
|
||||
body.model_dump(exclude_unset=True),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/usage", response_model=LegacyInterfaceRead, status_code=status.HTTP_201_CREATED)
|
||||
async def record_usage(
|
||||
body: LegacyUsageRecord,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LegacyInterface:
|
||||
try:
|
||||
return await record_legacy_usage(
|
||||
session,
|
||||
interface_key=body.interface_key,
|
||||
interface_kind=body.interface_kind,
|
||||
replacement_ref=body.replacement_ref,
|
||||
owner_component=body.owner_component,
|
||||
replacement_verified=body.replacement_verified,
|
||||
identity=LegacyUsageIdentity(
|
||||
tenant_key=body.tenant_key or "unknown",
|
||||
user_key=body.user_key or "unknown",
|
||||
component_key=body.component_key or "unknown",
|
||||
),
|
||||
observed_at=body.observed_at,
|
||||
call_count=body.call_count,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/summary", response_model=LegacyUsageSummary)
|
||||
async def usage_summary(
|
||||
days: int | None = Query(None, ge=1, le=366),
|
||||
hours: int | None = Query(None, ge=1, le=8784),
|
||||
window_start: datetime | None = None,
|
||||
window_end: datetime | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LegacyUsageSummary:
|
||||
if days is not None and hours is not None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Pass either days or hours, not both",
|
||||
)
|
||||
start, end = _review_window(
|
||||
days=days,
|
||||
hours=hours,
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
)
|
||||
return await legacy_usage_summary(session, window_start=start, window_end=end)
|
||||
|
||||
|
||||
@router.get("/weekly-review", response_model=LegacyWeeklyReview)
|
||||
async def weekly_review(
|
||||
days: int | None = Query(None, ge=1, le=366),
|
||||
hours: int | None = Query(None, ge=1, le=8784),
|
||||
window_start: datetime | None = None,
|
||||
window_end: datetime | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LegacyWeeklyReview:
|
||||
if days is not None and hours is not None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Pass either days or hours, not both",
|
||||
)
|
||||
start, end = _review_window(
|
||||
days=days,
|
||||
hours=hours,
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
)
|
||||
return await legacy_weekly_review(session, window_start=start, window_end=end)
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
"""Notification intake for informed-decision delivery ids.
|
||||
|
||||
Intentionally absent from the 14-day write-idempotency allowlist: retention
|
||||
is this receipt row, and a conflicting reuse must stay readable by GET.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.memo_notification import MemoNotificationReceipt
|
||||
from api.services.informed_decision_contract import (
|
||||
MAX_NOTIFICATION_BYTES,
|
||||
ContractError,
|
||||
parse_delivery_id,
|
||||
parse_notification,
|
||||
)
|
||||
from api.services.memo_notification import NotificationConflict, accept_notification
|
||||
|
||||
router = APIRouter(prefix="/informed-decision", tags=["informed-decision-notifications"])
|
||||
|
||||
|
||||
def _error(status: int, code: str) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={"error": code},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
def _payload(receipt: MemoNotificationReceipt, *, status: int, replay: bool = False) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={
|
||||
"delivery_id": receipt.delivery_id,
|
||||
"memo_id": receipt.memo_id,
|
||||
"question": receipt.question,
|
||||
"memo_class": receipt.memo_class,
|
||||
"submitter": receipt.submitter,
|
||||
"message_id": str(receipt.message_id),
|
||||
"status": "accepted",
|
||||
"authoritative_review": receipt.authoritative_review,
|
||||
},
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
**({"X-StateHub-Idempotency-Replay": "true"} if replay else {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/notifications")
|
||||
async def post_notification(
|
||||
request: Request, session: AsyncSession = Depends(get_session)
|
||||
) -> JSONResponse:
|
||||
raw = await request.body()
|
||||
if len(raw) > MAX_NOTIFICATION_BYTES:
|
||||
return _error(413, "notification_too_large")
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if content_type.split(";")[0].strip().lower() != "application/json":
|
||||
return _error(415, "json_required")
|
||||
try:
|
||||
delivery_id = parse_delivery_id(request.headers.get("idempotency-key"))
|
||||
notice = parse_notification(json.loads(raw))
|
||||
except ContractError as exc:
|
||||
return _error(exc.status, exc.code)
|
||||
except (json.JSONDecodeError, UnicodeError):
|
||||
return _error(400, "invalid_json")
|
||||
try:
|
||||
receipt, replay = await accept_notification(session, delivery_id, notice)
|
||||
except NotificationConflict as exc:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"error": "notification_conflict",
|
||||
"delivery_id": exc.receipt.delivery_id,
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return _payload(receipt, status=200 if replay else 201, replay=replay)
|
||||
|
||||
|
||||
@router.get("/notifications/{delivery_id}")
|
||||
async def get_notification(
|
||||
delivery_id: str, session: AsyncSession = Depends(get_session)
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
checked = parse_delivery_id(delivery_id)
|
||||
except ContractError as exc:
|
||||
return _error(exc.status, exc.code)
|
||||
receipt = await session.get(MemoNotificationReceipt, checked)
|
||||
if receipt is None:
|
||||
return _error(404, "notification_not_found")
|
||||
return _payload(receipt, status=200)
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
"""Inbox change feed (STATE-WP-0098-T05).
|
||||
|
||||
Read-only pull feed for derived inbox copies (hub-core). Spec:
|
||||
docs/inbox-change-feed-contract.md §2, §3, §5. Revisions are assigned by the
|
||||
database trigger from migration c4d5e6f7a8b9; one write transaction = one
|
||||
revision, and an advisory lock makes commit order equal revision order, so the
|
||||
maximum visible revision is a safe watermark.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.config import settings
|
||||
from api.database import get_session
|
||||
from api.schemas.inbox_feed import (
|
||||
CONTRACT,
|
||||
CursorError,
|
||||
FeedChange,
|
||||
FeedPage,
|
||||
FeedSnapshot,
|
||||
ForeignCursorError,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/messages/feed", tags=["messages", "inbox-feed"])
|
||||
|
||||
# Contract D1: tombstones are served for 90 days.
|
||||
TOMBSTONE_HORIZON_DAYS = 90
|
||||
DEFAULT_LIMIT = 500
|
||||
MAX_LIMIT = 5000
|
||||
NO_STORE = {"Cache-Control": "no-store"}
|
||||
|
||||
_MESSAGE_COLUMNS = (
|
||||
"id, from_agent, to_agent, subject, body, thread_id, read_at, archived_at, "
|
||||
"kind, expires_at, supersedes_id, created_at, revision"
|
||||
)
|
||||
_RECEIPT_COLUMNS = "message_id, agent, delivered_at, read_at, acknowledged_at, revision"
|
||||
_TOMBSTONE_COLUMNS = "message_id, revision, deleted_at, reason"
|
||||
|
||||
# Revisions <= this value carry tombstones that fell out of the horizon; a
|
||||
# cursor below it can no longer be served losslessly (410).
|
||||
_COMPACTED_SQL = f"""
|
||||
SELECT coalesce(max(revision), 0) FROM message_tombstones
|
||||
WHERE deleted_at < now() - interval '{TOMBSTONE_HORIZON_DAYS} days'
|
||||
"""
|
||||
_HIGH_WATER_SQL = """
|
||||
SELECT greatest(
|
||||
(SELECT coalesce(max(revision), 0) FROM agent_messages),
|
||||
(SELECT coalesce(max(revision), 0) FROM message_receipts),
|
||||
(SELECT coalesce(max(revision), 0) FROM message_tombstones)
|
||||
)
|
||||
"""
|
||||
_REVISIONS_AFTER_SQL = """
|
||||
SELECT revision FROM agent_messages WHERE revision > :after
|
||||
UNION ALL
|
||||
SELECT revision FROM message_receipts WHERE revision > :after
|
||||
UNION ALL
|
||||
SELECT revision FROM message_tombstones WHERE revision > :after AND revision > :compacted
|
||||
"""
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
if isinstance(value, uuid.UUID):
|
||||
return str(value)
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def _row(mapping) -> dict[str, Any]:
|
||||
return {key: _jsonable(value) for key, value in mapping.items()}
|
||||
|
||||
|
||||
def receipt_key(state: dict[str, Any]) -> str:
|
||||
return f"{state['message_id']}:{state['agent']}"
|
||||
|
||||
|
||||
def content_hash(messages, receipts, tombstones) -> str:
|
||||
payload = json.dumps(
|
||||
{"messages": messages, "receipts": receipts, "tombstones": tombstones},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(payload.encode()).hexdigest()
|
||||
|
||||
|
||||
async def _begin_consistent_read(session: AsyncSession) -> None:
|
||||
"""Start one REPEATABLE READ READ ONLY transaction for the whole response."""
|
||||
await session.connection(
|
||||
execution_options={"isolation_level": "REPEATABLE READ", "postgresql_readonly": True}
|
||||
)
|
||||
|
||||
|
||||
async def _scalar(session: AsyncSession, sql: str, **params) -> Any:
|
||||
return (await session.execute(text(sql), params)).scalar_one()
|
||||
|
||||
|
||||
async def _rows(session: AsyncSession, sql: str, **params) -> list[dict[str, Any]]:
|
||||
return [_row(m) for m in (await session.execute(text(sql), params)).mappings().all()]
|
||||
|
||||
|
||||
@router.get("/snapshot", response_model=FeedSnapshot)
|
||||
async def feed_snapshot(
|
||||
response: Response, session: AsyncSession = Depends(get_session)
|
||||
) -> FeedSnapshot:
|
||||
response.headers.update(NO_STORE)
|
||||
await _begin_consistent_read(session)
|
||||
source_time = await _scalar(session, "SELECT now()")
|
||||
compacted = await _scalar(session, _COMPACTED_SQL)
|
||||
high_water = await _scalar(session, _HIGH_WATER_SQL)
|
||||
messages = await _rows(
|
||||
session, f"SELECT {_MESSAGE_COLUMNS} FROM agent_messages ORDER BY id"
|
||||
)
|
||||
receipts = await _rows(
|
||||
session,
|
||||
f"SELECT {_RECEIPT_COLUMNS} FROM message_receipts ORDER BY message_id, agent",
|
||||
)
|
||||
tombstones = await _rows(
|
||||
session,
|
||||
f"SELECT {_TOMBSTONE_COLUMNS} FROM message_tombstones "
|
||||
"WHERE revision > :compacted ORDER BY message_id",
|
||||
compacted=compacted,
|
||||
)
|
||||
return FeedSnapshot(
|
||||
source_instance=settings.state_hub_instance_label,
|
||||
source_time=source_time,
|
||||
cursor=encode_cursor(high_water),
|
||||
compacted_before=encode_cursor(compacted),
|
||||
messages=messages,
|
||||
receipts=receipts,
|
||||
tombstones=tombstones,
|
||||
count=len(messages),
|
||||
content_hash=content_hash(messages, receipts, tombstones),
|
||||
)
|
||||
|
||||
|
||||
def _error(status_code: int, detail: str, **extra) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={"contract": CONTRACT, "detail": detail, **extra},
|
||||
headers=NO_STORE,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=FeedPage,
|
||||
responses={400: {}, 409: {}, 410: {}},
|
||||
)
|
||||
async def feed_page(
|
||||
response: Response,
|
||||
after: str = Query(..., description="Cursor from a snapshot or previous page"),
|
||||
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
|
||||
source_instance: str | None = Query(
|
||||
None, description="source_instance the cursor came from; 409 if foreign"
|
||||
),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
response.headers.update(NO_STORE)
|
||||
instance = settings.state_hub_instance_label
|
||||
try:
|
||||
after_rev = decode_cursor(after)
|
||||
except ForeignCursorError as exc:
|
||||
return _error(409, str(exc), resnapshot_required=True)
|
||||
except CursorError as exc:
|
||||
return _error(400, str(exc))
|
||||
if source_instance is not None and source_instance != instance:
|
||||
return _error(
|
||||
409,
|
||||
f"cursor from source_instance {source_instance!r}, this is {instance!r}",
|
||||
resnapshot_required=True,
|
||||
source_instance=instance,
|
||||
)
|
||||
|
||||
await _begin_consistent_read(session)
|
||||
source_time = await _scalar(session, "SELECT now()")
|
||||
compacted = await _scalar(session, _COMPACTED_SQL)
|
||||
high_water = await _scalar(session, _HIGH_WATER_SQL)
|
||||
if after_rev > high_water:
|
||||
# A cursor from the future: another instance, or a restored source.
|
||||
return _error(
|
||||
409,
|
||||
"cursor is ahead of this source's high_water; re-snapshot",
|
||||
resnapshot_required=True,
|
||||
source_instance=instance,
|
||||
high_water=encode_cursor(high_water),
|
||||
)
|
||||
if after_rev < compacted:
|
||||
return _error(
|
||||
410,
|
||||
"cursor is older than the tombstone horizon; re-snapshot",
|
||||
resnapshot_required=True,
|
||||
source_instance=instance,
|
||||
compacted_before=encode_cursor(compacted),
|
||||
)
|
||||
|
||||
params = {"after": after_rev, "compacted": compacted}
|
||||
boundary = await _scalar(
|
||||
session,
|
||||
f"SELECT max(revision) FROM (SELECT revision FROM ({_REVISIONS_AFTER_SQL}) r "
|
||||
"ORDER BY revision LIMIT :limit) page",
|
||||
limit=limit,
|
||||
**params,
|
||||
)
|
||||
changes: list[FeedChange] = []
|
||||
has_more = False
|
||||
if boundary is not None:
|
||||
# A page never splits a revision: take every change up to the boundary.
|
||||
window = {**params, "upto": boundary}
|
||||
for state in await _rows(
|
||||
session,
|
||||
f"SELECT {_MESSAGE_COLUMNS} FROM agent_messages "
|
||||
"WHERE revision > :after AND revision <= :upto "
|
||||
"ORDER BY revision, created_at, id",
|
||||
**window,
|
||||
):
|
||||
changes.append(
|
||||
FeedChange(
|
||||
revision=state["revision"], entity="message",
|
||||
key=state["id"], op="upsert", state=state,
|
||||
)
|
||||
)
|
||||
for state in await _rows(
|
||||
session,
|
||||
f"SELECT {_RECEIPT_COLUMNS} FROM message_receipts "
|
||||
"WHERE revision > :after AND revision <= :upto "
|
||||
"ORDER BY revision, message_id, agent",
|
||||
**window,
|
||||
):
|
||||
changes.append(
|
||||
FeedChange(
|
||||
revision=state["revision"], entity="receipt",
|
||||
key=receipt_key(state), op="upsert", state=state,
|
||||
)
|
||||
)
|
||||
for state in await _rows(
|
||||
session,
|
||||
f"SELECT {_TOMBSTONE_COLUMNS} FROM message_tombstones "
|
||||
"WHERE revision > :after AND revision > :compacted AND revision <= :upto "
|
||||
"ORDER BY revision, message_id",
|
||||
**window,
|
||||
):
|
||||
changes.append(
|
||||
FeedChange(
|
||||
revision=state["revision"], entity="tombstone",
|
||||
key=state["message_id"], op="delete", state=state,
|
||||
)
|
||||
)
|
||||
# Stable sort: revision order; within a revision messages, receipts, tombstones.
|
||||
changes.sort(key=lambda change: change.revision)
|
||||
has_more = (
|
||||
await _scalar(
|
||||
session,
|
||||
f"SELECT exists(SELECT 1 FROM ({_REVISIONS_AFTER_SQL}) r "
|
||||
"WHERE revision > :upto)",
|
||||
**{**params, "upto": boundary},
|
||||
)
|
||||
)
|
||||
return FeedPage(
|
||||
source_instance=instance,
|
||||
source_time=source_time,
|
||||
high_water=encode_cursor(high_water),
|
||||
next_cursor=encode_cursor(boundary if boundary is not None else after_rev),
|
||||
has_more=has_more,
|
||||
compacted_before=encode_cursor(compacted),
|
||||
changes=changes,
|
||||
)
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.agent_message import BROADCAST, MESSAGE_KINDS, AgentMessage
|
||||
from api.schemas.agent_message import (
|
||||
MessageAck,
|
||||
MessageCreate,
|
||||
MessageMarkRead,
|
||||
MessageRead,
|
||||
MessageReply,
|
||||
NoticeStatus,
|
||||
)
|
||||
from api.services.legacy_meter import identity_from_request, record_legacy_usage
|
||||
from api.services.message_receipts import (
|
||||
NEWS_DEFAULT_TTL,
|
||||
broadcast_unread_clause,
|
||||
is_broadcast,
|
||||
notice_statuses,
|
||||
receipts_for,
|
||||
upsert_receipt,
|
||||
utcnow,
|
||||
)
|
||||
from api.services.repository_aliases import (
|
||||
canonicalize_repository_slug,
|
||||
resolve_repository_slug,
|
||||
)
|
||||
from hub_core.message_identity import resolve_message_reference
|
||||
from hub_core.models.message_identity_alias import MessageIdentityAlias
|
||||
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
|
||||
UNATTRIBUTED_BROADCAST_READ_KEY = "rest_api:PATCH /messages/{id}/read broadcast-without-reader"
|
||||
UNATTRIBUTED_BROADCAST_READ_REPLACEMENT = "PATCH /messages/{id}/read?reader=<agent>"
|
||||
UNATTRIBUTED_BROADCAST_WARNING = '299 - "broadcast mark-read needs ?reader=<agent>"'
|
||||
|
||||
|
||||
async def _reader_values(session: AsyncSession, reader: str) -> tuple[str, tuple[str, ...]]:
|
||||
"""Canonical reader slug plus every historical slug it answers to."""
|
||||
resolution = await resolve_repository_slug(session, reader, required=False)
|
||||
if resolution is None:
|
||||
return reader, (reader,)
|
||||
return resolution.canonical_slug, tuple(resolution.slug_values)
|
||||
|
||||
|
||||
async def _read_view(
|
||||
session: AsyncSession,
|
||||
message: AgentMessage,
|
||||
reader_values: tuple[str, ...] | None,
|
||||
reader: str | None,
|
||||
) -> MessageRead:
|
||||
view = MessageRead.model_validate(message)
|
||||
if reader_values is None or not is_broadcast(message):
|
||||
return view
|
||||
state = (await receipts_for(session, [message.id], reader_values)).get(message.id, {})
|
||||
return view.model_copy(
|
||||
update={
|
||||
"reader": reader,
|
||||
"delivered_at": state.get("delivered_at"),
|
||||
"read_at": state.get("read_at"),
|
||||
"acknowledged_at": state.get("acknowledged_at"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _get_message(reference: str, session: AsyncSession) -> AgentMessage:
|
||||
message_id = await resolve_message_reference(
|
||||
session, reference, alias_model=MessageIdentityAlias
|
||||
)
|
||||
if message_id is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Message reference {reference!r} not found"
|
||||
)
|
||||
message = await session.get(AgentMessage, message_id)
|
||||
if message is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Message reference {reference!r} not found"
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
|
||||
async def send_message(
|
||||
body: MessageCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
if body.thread_id and await session.get(AgentMessage, body.thread_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found")
|
||||
payload = body.model_dump()
|
||||
payload["from_agent"] = await canonicalize_repository_slug(session, body.from_agent)
|
||||
payload["to_agent"] = await canonicalize_repository_slug(session, body.to_agent)
|
||||
if payload["to_agent"] == BROADCAST:
|
||||
kind = body.kind or "news"
|
||||
if kind == "message":
|
||||
kind = "news"
|
||||
if kind not in MESSAGE_KINDS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"kind must be one of {', '.join(MESSAGE_KINDS[1:])} for broadcasts",
|
||||
)
|
||||
payload["kind"] = kind
|
||||
if kind == "news" and body.expires_at is None:
|
||||
payload["expires_at"] = utcnow() + NEWS_DEFAULT_TTL
|
||||
if body.supersedes_id is not None:
|
||||
predecessor = await session.get(AgentMessage, body.supersedes_id)
|
||||
if predecessor is None or not is_broadcast(predecessor):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Superseded broadcast {body.supersedes_id} not found",
|
||||
)
|
||||
if predecessor.archived_at is None:
|
||||
predecessor.archived_at = utcnow()
|
||||
else:
|
||||
if body.kind not in (None, "message"):
|
||||
raise HTTPException(
|
||||
status_code=422, detail="kind news/standing is only valid for broadcasts"
|
||||
)
|
||||
if body.expires_at is not None or body.supersedes_id is not None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="expires_at and supersedes_id are only valid for broadcasts",
|
||||
)
|
||||
payload["kind"] = "message"
|
||||
message = AgentMessage(**payload)
|
||||
session.add(message)
|
||||
await session.commit()
|
||||
await session.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
@router.get("/", response_model=list[MessageRead])
|
||||
async def list_messages(
|
||||
to_agent: str | None = None,
|
||||
from_agent: str | None = None,
|
||||
unread_only: bool = False,
|
||||
limit: int = 50,
|
||||
offset: int = Query(0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[MessageRead]:
|
||||
now = utcnow()
|
||||
query = select(AgentMessage).where(AgentMessage.archived_at.is_(None))
|
||||
reader: str | None = None
|
||||
reader_values: tuple[str, ...] | None = None
|
||||
if to_agent:
|
||||
reader, reader_values = await _reader_values(session, to_agent)
|
||||
direct = AgentMessage.to_agent.in_(reader_values)
|
||||
if unread_only:
|
||||
query = query.where(
|
||||
or_(
|
||||
and_(direct, AgentMessage.read_at.is_(None)),
|
||||
broadcast_unread_clause(reader_values, now),
|
||||
)
|
||||
)
|
||||
else:
|
||||
query = query.where(
|
||||
or_(
|
||||
direct,
|
||||
and_(
|
||||
AgentMessage.to_agent == BROADCAST,
|
||||
or_(AgentMessage.expires_at.is_(None), AgentMessage.expires_at > now),
|
||||
),
|
||||
)
|
||||
)
|
||||
elif unread_only:
|
||||
query = query.where(AgentMessage.read_at.is_(None))
|
||||
if from_agent:
|
||||
resolution = await resolve_repository_slug(session, from_agent, required=False)
|
||||
values = resolution.slug_values if resolution else (from_agent,)
|
||||
query = query.where(AgentMessage.from_agent.in_(values))
|
||||
result = await session.execute(
|
||||
# ``id`` breaks created_at ties so offset pages are stable (STATE-WP-0094-T03).
|
||||
query.order_by(AgentMessage.created_at.desc(), AgentMessage.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
messages = list(result.scalars().all())
|
||||
if reader_values is None or reader is None:
|
||||
return [MessageRead.model_validate(m) for m in messages]
|
||||
|
||||
broadcast_ids = [m.id for m in messages if is_broadcast(m)]
|
||||
if unread_only and broadcast_ids and reader != BROADCAST:
|
||||
# D3 (founder-approved 2026-09-22): the orientation inbox call records
|
||||
# an idempotent delivery receipt for each broadcast it returns.
|
||||
await upsert_receipt(session, broadcast_ids, reader, delivered=True, at=now)
|
||||
await session.commit()
|
||||
receipts = await receipts_for(session, broadcast_ids, reader_values)
|
||||
views = []
|
||||
for message in messages:
|
||||
view = MessageRead.model_validate(message)
|
||||
if is_broadcast(message):
|
||||
state = receipts.get(message.id, {})
|
||||
view = view.model_copy(
|
||||
update={
|
||||
"reader": reader,
|
||||
"delivered_at": state.get("delivered_at"),
|
||||
"read_at": state.get("read_at"),
|
||||
"acknowledged_at": state.get("acknowledged_at"),
|
||||
}
|
||||
)
|
||||
views.append(view)
|
||||
return views
|
||||
|
||||
|
||||
@router.get("/notices", response_model=list[NoticeStatus])
|
||||
async def list_notices(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[NoticeStatus]:
|
||||
"""Live standing notices with acknowledged / delivered-only / unreached repos."""
|
||||
return await notice_statuses(session)
|
||||
|
||||
|
||||
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
|
||||
async def get_thread(
|
||||
thread_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AgentMessage]:
|
||||
resolved = await resolve_message_reference(
|
||||
session, thread_id, alias_model=MessageIdentityAlias
|
||||
)
|
||||
if resolved is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Message reference {thread_id!r} not found"
|
||||
)
|
||||
result = await session.execute(
|
||||
select(AgentMessage)
|
||||
.where(or_(AgentMessage.id == resolved, AgentMessage.thread_id == resolved))
|
||||
.order_by(AgentMessage.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.patch("/{message_id}/read", response_model=MessageRead)
|
||||
async def mark_read(
|
||||
message_id: str,
|
||||
request: Request,
|
||||
response: Response,
|
||||
reader: str | None = None,
|
||||
ack: bool = False,
|
||||
body: MessageMarkRead | None = Body(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage | MessageRead:
|
||||
message = await _get_message(message_id, session)
|
||||
if not is_broadcast(message):
|
||||
if message.read_at is None:
|
||||
message.read_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(message)
|
||||
return message
|
||||
|
||||
reader = reader or (body.reader if body else None)
|
||||
ack = ack or bool(body and body.ack)
|
||||
if not reader:
|
||||
view = MessageRead.model_validate(message)
|
||||
# D2: an unattributed mark-read never hides a broadcast from anyone.
|
||||
response.headers["Deprecation"] = "true"
|
||||
response.headers["Warning"] = UNATTRIBUTED_BROADCAST_WARNING
|
||||
response.headers["X-StateHub-Replacement"] = UNATTRIBUTED_BROADCAST_READ_REPLACEMENT
|
||||
try:
|
||||
await record_legacy_usage(
|
||||
session,
|
||||
interface_key=UNATTRIBUTED_BROADCAST_READ_KEY,
|
||||
interface_kind="rest_api",
|
||||
replacement_ref=UNATTRIBUTED_BROADCAST_READ_REPLACEMENT,
|
||||
owner_component="state-hub.api",
|
||||
replacement_verified=True,
|
||||
identity=identity_from_request(request),
|
||||
)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
return view
|
||||
|
||||
canonical, values = await _reader_values(session, reader)
|
||||
await upsert_receipt(session, [message.id], canonical, read=True, acknowledged=ack)
|
||||
await session.commit()
|
||||
return await _read_view(session, message, values, canonical)
|
||||
|
||||
|
||||
@router.post("/{message_id}/ack", response_model=MessageRead)
|
||||
async def acknowledge_message(
|
||||
message_id: str,
|
||||
body: MessageAck,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MessageRead:
|
||||
"""Acknowledge a broadcast (clears a standing notice for that agent)."""
|
||||
message = await _get_message(message_id, session)
|
||||
if not is_broadcast(message):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ack applies to broadcasts only; use PATCH /messages/{id}/read",
|
||||
)
|
||||
canonical, values = await _reader_values(session, body.agent)
|
||||
await upsert_receipt(session, [message.id], canonical, acknowledged=True)
|
||||
await session.commit()
|
||||
return await _read_view(session, message, values, canonical)
|
||||
|
||||
|
||||
@router.patch("/{message_id}/archive", response_model=MessageRead)
|
||||
async def archive_message(
|
||||
message_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
message = await _get_message(message_id, session)
|
||||
# First write wins (inbox feed contract §6): re-archiving keeps archived_at.
|
||||
if message.archived_at is None:
|
||||
message.archived_at = datetime.now(timezone.utc)
|
||||
# Broadcast archive is a global withdrawal; it no longer stamps read_at.
|
||||
if message.read_at is None and not is_broadcast(message):
|
||||
message.read_at = message.archived_at
|
||||
await session.commit()
|
||||
await session.refresh(message)
|
||||
return message
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{message_id}/reply",
|
||||
response_model=MessageRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def reply_to_message(
|
||||
message_id: str,
|
||||
body: MessageReply,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
original = await _get_message(message_id, session)
|
||||
replier = await canonicalize_repository_slug(session, body.from_agent)
|
||||
if is_broadcast(original):
|
||||
await upsert_receipt(session, [original.id], replier, read=True)
|
||||
elif original.read_at is None:
|
||||
original.read_at = datetime.now(timezone.utc)
|
||||
reply = AgentMessage(
|
||||
from_agent=replier,
|
||||
to_agent=await canonicalize_repository_slug(session, original.from_agent),
|
||||
subject=f"Re: {original.subject}",
|
||||
body=body.body,
|
||||
thread_id=original.thread_id or original.id,
|
||||
)
|
||||
session.add(reply)
|
||||
await session.commit()
|
||||
await session.refresh(reply)
|
||||
return reply
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from api.schemas.ops_run import OpsRunProjection
|
||||
from api.services.ops_run_projection import get_ops_run_projection
|
||||
|
||||
|
||||
router = APIRouter(prefix="/ops-runs", tags=["ops-runs"])
|
||||
|
||||
|
||||
@router.get("/summary", response_model=OpsRunProjection)
|
||||
async def get_ops_runs_summary(refresh: bool = False) -> OpsRunProjection:
|
||||
"""Project activity-core queue health; State Hub never claims ops runs."""
|
||||
return await get_ops_run_projection(refresh=refresh)
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException
|
||||
from hub_core.routers.policy import create_policy_router
|
||||
from hub_core.schemas.policy import PolicyRead
|
||||
|
||||
POLICY_DIR = Path(__file__).parent.parent.parent / "policies"
|
||||
_VALID_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||||
|
||||
|
||||
def _policy_path(name: str) -> Path:
|
||||
if not _VALID_NAME.match(name):
|
||||
raise HTTPException(status_code=400, detail="Invalid policy name")
|
||||
path = POLICY_DIR / f"{name}.md"
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Policy '{name}' not found")
|
||||
return path
|
||||
|
||||
|
||||
def _load_policy(name: str) -> PolicyRead:
|
||||
path = _policy_path(name)
|
||||
return PolicyRead(name=name, content=path.read_text())
|
||||
|
||||
|
||||
def _update_policy(name: str, content: str) -> PolicyRead:
|
||||
path = _policy_path(name)
|
||||
path.write_text(content)
|
||||
return PolicyRead(name=name, content=content)
|
||||
|
||||
|
||||
router = create_policy_router(_load_policy, update_policy=_update_policy)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
from typing import Any
|
||||
|
||||
from fastapi import Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead
|
||||
from api.services.legacy_compat import meter_legacy_body_field, meter_legacy_query_param
|
||||
from hub_core.routers.progress import create_progress_router
|
||||
|
||||
|
||||
async def _meter_progress_workstream_id_query(
|
||||
session: AsyncSession,
|
||||
request: Request,
|
||||
response: Response,
|
||||
) -> None:
|
||||
await meter_legacy_query_param(
|
||||
session=session,
|
||||
request=request,
|
||||
response=response,
|
||||
method="GET",
|
||||
route="/progress/",
|
||||
replacement_ref="/progress/?workplan_id=<workplan_id>",
|
||||
)
|
||||
|
||||
|
||||
async def _meter_progress_workstream_id_body(
|
||||
session: AsyncSession,
|
||||
request: Request,
|
||||
response: Response,
|
||||
) -> None:
|
||||
await meter_legacy_body_field(
|
||||
session=session,
|
||||
request=request,
|
||||
response=response,
|
||||
method="POST",
|
||||
route="/progress/",
|
||||
field="workstream_id",
|
||||
replacement_ref="POST /progress/ with workplan_id",
|
||||
)
|
||||
|
||||
|
||||
def _progress_body_uses_legacy_workstream_id(body: Any) -> bool:
|
||||
return bool(getattr(body, "used_legacy_workstream_id", False))
|
||||
|
||||
|
||||
router = create_progress_router(
|
||||
get_session,
|
||||
progress_model=ProgressEvent,
|
||||
progress_create_schema=ProgressEventCreate,
|
||||
progress_read_schema=ProgressEventRead,
|
||||
meter_legacy_workstream_id=_meter_progress_workstream_id_query,
|
||||
progress_body_uses_legacy_workstream_id=_progress_body_uses_legacy_workstream_id,
|
||||
meter_legacy_workstream_id_body=_meter_progress_workstream_id_body,
|
||||
)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
from api.database import get_session
|
||||
from api.schemas.recently_on_scope import (
|
||||
RecentlyOnScopeGenerate,
|
||||
RecentlyOnScopeGeneratedReport,
|
||||
RecentlyOnScopeHourlyGenerate,
|
||||
RecentlyOnScopeHourlyRun,
|
||||
RecentlyOnScopeReportMetadata,
|
||||
)
|
||||
from api.services.markitect_templates import MarkitectRenderError, MarkitectUnavailable
|
||||
from api.services.recently_on_scope import (
|
||||
generate_report,
|
||||
generate_hourly_reports,
|
||||
list_reports,
|
||||
read_report,
|
||||
resolve_window,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/domains/{slug}/recently-on-scope", tags=["recently-on-scope"])
|
||||
hourly_router = APIRouter(prefix="/recently-on-scope", tags=["recently-on-scope"])
|
||||
|
||||
|
||||
@hourly_router.post("/hourly", response_model=RecentlyOnScopeHourlyRun, status_code=status.HTTP_201_CREATED)
|
||||
async def generate_hourly_recently_on_scope(
|
||||
body: RecentlyOnScopeHourlyGenerate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RecentlyOnScopeHourlyRun:
|
||||
try:
|
||||
window = resolve_window(body.range, body.since, body.until)
|
||||
return await generate_hourly_reports(
|
||||
session,
|
||||
window,
|
||||
active_only=body.active_only,
|
||||
include_attention=body.include_attention,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/", response_model=RecentlyOnScopeGeneratedReport, status_code=status.HTTP_201_CREATED)
|
||||
async def generate_recently_on_scope(
|
||||
slug: str,
|
||||
body: RecentlyOnScopeGenerate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RecentlyOnScopeGeneratedReport:
|
||||
try:
|
||||
window = resolve_window(body.range, body.since, body.until)
|
||||
metadata, markdown = await generate_report(session, slug, window)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except MarkitectUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except MarkitectRenderError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return RecentlyOnScopeGeneratedReport(**metadata.model_dump(), markdown=markdown)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[RecentlyOnScopeReportMetadata])
|
||||
async def list_recently_on_scope(slug: str) -> list[RecentlyOnScopeReportMetadata]:
|
||||
return list_reports(slug)
|
||||
|
||||
|
||||
@router.get("/{report_id}", response_class=PlainTextResponse)
|
||||
async def get_recently_on_scope_report(slug: str, report_id: str) -> PlainTextResponse:
|
||||
try:
|
||||
markdown = read_report(slug, report_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Report not found") from exc
|
||||
return PlainTextResponse(markdown, media_type="text/markdown")
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.agent_message import AgentMessage
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task
|
||||
from api.models.task import TaskStatus
|
||||
from api.models.workplan import Workplan
|
||||
from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse
|
||||
from api.services.lifecycle import (
|
||||
should_activate_parent_for_task_start,
|
||||
status_value,
|
||||
transition_task_status,
|
||||
transition_workplan_status,
|
||||
)
|
||||
from api.task_status import TERMINAL_TASK_STATUSES
|
||||
from api.services.reconciliation import (
|
||||
ReconciliationClass,
|
||||
StateChangeClassification,
|
||||
classify_task_status_change,
|
||||
classify_workplan_status_change,
|
||||
)
|
||||
from api.services.workplan_files import (
|
||||
find_workplan_for_workplan,
|
||||
patch_task_status,
|
||||
patch_workplan_status,
|
||||
resolve_repo_path,
|
||||
task_block_status,
|
||||
task_block_linked,
|
||||
workplan_status,
|
||||
)
|
||||
from api.workplan_status import normalize_workplan_status
|
||||
|
||||
router = APIRouter(prefix="/reconciliation", tags=["reconciliation"])
|
||||
|
||||
|
||||
def _bool_or_default(value: bool | None, default: bool) -> bool:
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def _conflict(reason: str, follow_up: str) -> StateChangeClassification:
|
||||
return StateChangeClassification(
|
||||
ReconciliationClass.DEFERRED,
|
||||
reason,
|
||||
follow_up,
|
||||
)
|
||||
|
||||
|
||||
async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool:
|
||||
result = await session.execute(select(Task.status).where(Task.workplan_id == workstream_id))
|
||||
statuses = [status_value(row[0]) for row in result.all()]
|
||||
return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses)
|
||||
|
||||
|
||||
def _deferred_message(
|
||||
*,
|
||||
body: StateChangeRequest,
|
||||
current_status: str,
|
||||
target_status: str,
|
||||
classification: ReconciliationClass,
|
||||
reason: str,
|
||||
follow_up: str,
|
||||
workplan_path: str | None,
|
||||
conflict: bool = False,
|
||||
) -> AgentMessage:
|
||||
subject = f"Reconcile {body.target_type} state change: {current_status} -> {target_status}"
|
||||
lines = [
|
||||
"A UI-originated state change could not be written through immediately.",
|
||||
"",
|
||||
f"target_type: {body.target_type}",
|
||||
f"target_id: {body.target_id}",
|
||||
f"actor: {body.actor}",
|
||||
f"intent: {body.intent or ''}",
|
||||
f"expected_current_status: {body.expected_current_status or ''}",
|
||||
f"current_status: {current_status}",
|
||||
f"target_status: {target_status}",
|
||||
f"reconciliation_class: {classification.value}",
|
||||
f"conflict: {str(conflict).lower()}",
|
||||
f"reason: {reason}",
|
||||
f"follow_up: {follow_up}",
|
||||
f"workplan_path: {workplan_path or ''}",
|
||||
]
|
||||
return AgentMessage(
|
||||
from_agent=body.actor,
|
||||
to_agent="state-hub",
|
||||
subject=subject,
|
||||
body="\n".join(lines),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/state-change", response_model=StateChangeResponse)
|
||||
async def classify_state_change(
|
||||
body: StateChangeRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> StateChangeResponse:
|
||||
if body.target_type == "workstream":
|
||||
ws = await session.get(Workplan, body.target_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
|
||||
repo = await session.get(ManagedRepo, ws.repo_id) if ws.repo_id else None
|
||||
repo_path = resolve_repo_path(repo)
|
||||
workplan_ref = find_workplan_for_workplan(repo, ws.id) if repo_path else None
|
||||
actual_file_backed = workplan_ref is not None
|
||||
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
||||
file_backed = (
|
||||
actual_file_backed
|
||||
if body.apply
|
||||
else _bool_or_default(body.file_backed, actual_file_backed)
|
||||
)
|
||||
archived_file = (
|
||||
actual_archived_file
|
||||
if body.apply
|
||||
else _bool_or_default(body.archived_file, actual_archived_file)
|
||||
)
|
||||
tasks_terminal = (
|
||||
body.tasks_terminal
|
||||
if body.tasks_terminal is not None
|
||||
else await _workstream_tasks_terminal(session, ws.id)
|
||||
)
|
||||
current_status = normalize_workplan_status(ws.status)
|
||||
target_status = normalize_workplan_status(body.target_status)
|
||||
classification = classify_workplan_status_change(
|
||||
current_status=current_status,
|
||||
target_status=target_status,
|
||||
file_backed=file_backed,
|
||||
archived_file=archived_file,
|
||||
tasks_terminal=tasks_terminal,
|
||||
)
|
||||
write_result = "not_attempted"
|
||||
reconciliation_record_id = None
|
||||
conflict = False
|
||||
if body.apply:
|
||||
expected_status = (
|
||||
normalize_workplan_status(body.expected_current_status)
|
||||
if body.expected_current_status is not None
|
||||
else None
|
||||
)
|
||||
if expected_status is not None and expected_status != current_status:
|
||||
classification = _conflict(
|
||||
f"cached workstream status changed from expected {expected_status!r} to {current_status!r}",
|
||||
"refresh the dashboard and retry the state change if it is still intended",
|
||||
)
|
||||
conflict = True
|
||||
elif repo is not None and repo_path is None:
|
||||
classification = _conflict(
|
||||
"repo host path is unavailable for this State Hub host",
|
||||
"register a host path for this machine or retry from a host with the repo checkout",
|
||||
)
|
||||
conflict = True
|
||||
elif classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH and workplan_ref:
|
||||
file_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||
if file_status and file_status != current_status:
|
||||
classification = _conflict(
|
||||
f"workplan file status {file_status!r} differs from cached DB status {current_status!r}",
|
||||
"run consistency repair or refresh State Hub from files before retrying",
|
||||
)
|
||||
conflict = True
|
||||
else:
|
||||
try:
|
||||
patch_workplan_status(workplan_ref.path, target_status)
|
||||
patched_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||
except OSError as exc:
|
||||
classification = _conflict(
|
||||
f"workplan file write failed: {exc}",
|
||||
"fix repo file access and retry the reconciliation",
|
||||
)
|
||||
conflict = True
|
||||
else:
|
||||
if patched_status != target_status:
|
||||
classification = _conflict(
|
||||
f"workplan file status could not be patched to {target_status!r}",
|
||||
"inspect the workplan frontmatter format before retrying",
|
||||
)
|
||||
conflict = True
|
||||
else:
|
||||
transition_workplan_status(ws, target_status)
|
||||
await session.commit()
|
||||
write_result = "applied"
|
||||
|
||||
if write_result != "applied":
|
||||
msg = _deferred_message(
|
||||
body=body,
|
||||
current_status=current_status,
|
||||
target_status=target_status,
|
||||
classification=classification.reconciliation_class,
|
||||
reason=classification.reason,
|
||||
follow_up=classification.follow_up,
|
||||
workplan_path=workplan_ref.relative_path if workplan_ref else None,
|
||||
conflict=conflict,
|
||||
)
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
reconciliation_record_id = msg.id
|
||||
write_result = "not_applicable"
|
||||
return StateChangeResponse(
|
||||
target_type=body.target_type,
|
||||
target_id=body.target_id,
|
||||
actor=body.actor,
|
||||
intent=body.intent,
|
||||
current_status=current_status,
|
||||
target_status=target_status,
|
||||
file_backed=file_backed,
|
||||
archived_file=archived_file,
|
||||
tasks_terminal=tasks_terminal,
|
||||
reconciliation_class=classification.reconciliation_class,
|
||||
reason=classification.reason,
|
||||
follow_up=classification.follow_up,
|
||||
write_through_result=write_result,
|
||||
workplan_path=workplan_ref.relative_path if workplan_ref else None,
|
||||
reconciliation_record_id=reconciliation_record_id,
|
||||
conflict=conflict,
|
||||
)
|
||||
|
||||
task = await session.get(Task, body.target_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
ws = await session.get(Workplan, task.workplan_id)
|
||||
repo = await session.get(ManagedRepo, ws.repo_id) if ws and ws.repo_id else None
|
||||
repo_path = resolve_repo_path(repo)
|
||||
workplan_ref = find_workplan_for_workplan(repo, ws.id) if ws and repo_path else None
|
||||
actual_file_backed = workplan_ref is not None
|
||||
actual_archived_file = bool(workplan_ref and workplan_ref.archived)
|
||||
file_backed = (
|
||||
actual_file_backed
|
||||
if body.apply
|
||||
else _bool_or_default(body.file_backed, actual_file_backed)
|
||||
)
|
||||
archived_file = (
|
||||
actual_archived_file
|
||||
if body.apply
|
||||
else _bool_or_default(body.archived_file, actual_archived_file)
|
||||
)
|
||||
actual_task_linked = bool(workplan_ref and task_block_linked(workplan_ref.path, task.id))
|
||||
task_linked = (
|
||||
actual_task_linked
|
||||
if body.apply
|
||||
else _bool_or_default(body.task_linked, actual_task_linked)
|
||||
)
|
||||
current_status = status_value(task.status)
|
||||
target_status = status_value(body.target_status)
|
||||
classification = classify_task_status_change(
|
||||
current_status=current_status,
|
||||
target_status=target_status,
|
||||
file_backed=file_backed,
|
||||
archived_file=archived_file,
|
||||
task_linked=task_linked,
|
||||
blocking_reason=body.blocking_reason,
|
||||
)
|
||||
write_result = "not_attempted"
|
||||
reconciliation_record_id = None
|
||||
conflict = False
|
||||
if body.apply:
|
||||
expected_status = (
|
||||
status_value(body.expected_current_status)
|
||||
if body.expected_current_status is not None
|
||||
else None
|
||||
)
|
||||
if expected_status is not None and expected_status != current_status:
|
||||
classification = _conflict(
|
||||
f"cached task status changed from expected {expected_status!r} to {current_status!r}",
|
||||
"refresh the dashboard and retry the state change if it is still intended",
|
||||
)
|
||||
conflict = True
|
||||
elif repo is not None and repo_path is None:
|
||||
classification = _conflict(
|
||||
"repo host path is unavailable for this State Hub host",
|
||||
"register a host path for this machine or retry from a host with the repo checkout",
|
||||
)
|
||||
conflict = True
|
||||
elif (
|
||||
classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH
|
||||
and workplan_ref
|
||||
and actual_task_linked
|
||||
):
|
||||
file_status = status_value(task_block_status(workplan_ref.path, task.id))
|
||||
if file_status and file_status != current_status:
|
||||
classification = _conflict(
|
||||
f"workplan task status {file_status!r} differs from cached DB status {current_status!r}",
|
||||
"run consistency repair or refresh State Hub from files before retrying",
|
||||
)
|
||||
conflict = True
|
||||
else:
|
||||
original_text = None
|
||||
parent_will_activate = should_activate_parent_for_task_start(
|
||||
previous_task_status=current_status,
|
||||
new_task_status=target_status,
|
||||
parent_workplan_status=ws.status if ws else None,
|
||||
)
|
||||
try:
|
||||
original_text = workplan_ref.path.read_text(encoding="utf-8")
|
||||
patch_task_status(workplan_ref.path, task.id, target_status)
|
||||
patched_status = status_value(task_block_status(workplan_ref.path, task.id))
|
||||
if parent_will_activate:
|
||||
patch_workplan_status(workplan_ref.path, "active")
|
||||
parent_status = normalize_workplan_status(workplan_status(workplan_ref.path))
|
||||
if parent_status != "active":
|
||||
if original_text is not None:
|
||||
workplan_ref.path.write_text(original_text, encoding="utf-8")
|
||||
classification = _conflict(
|
||||
"parent workplan status could not be patched to 'active'",
|
||||
"inspect the workplan frontmatter format before retrying",
|
||||
)
|
||||
conflict = True
|
||||
except OSError as exc:
|
||||
if original_text is not None:
|
||||
workplan_ref.path.write_text(original_text, encoding="utf-8")
|
||||
classification = _conflict(
|
||||
f"workplan task write failed: {exc}",
|
||||
"fix repo file access and retry the reconciliation",
|
||||
)
|
||||
conflict = True
|
||||
else:
|
||||
if conflict:
|
||||
pass
|
||||
elif patched_status != target_status:
|
||||
if original_text is not None:
|
||||
workplan_ref.path.write_text(original_text, encoding="utf-8")
|
||||
classification = _conflict(
|
||||
f"workplan task block could not be patched to {target_status!r}",
|
||||
"inspect the task block format before retrying",
|
||||
)
|
||||
conflict = True
|
||||
else:
|
||||
transition = transition_task_status(
|
||||
task,
|
||||
target_status,
|
||||
parent_workstream=ws,
|
||||
previous_task_status=current_status,
|
||||
status_coercer=TaskStatus,
|
||||
)
|
||||
if body.blocking_reason is not None:
|
||||
task.blocking_reason = body.blocking_reason
|
||||
await session.commit()
|
||||
write_result = "applied"
|
||||
|
||||
if write_result != "applied":
|
||||
msg = _deferred_message(
|
||||
body=body,
|
||||
current_status=current_status,
|
||||
target_status=target_status,
|
||||
classification=classification.reconciliation_class,
|
||||
reason=classification.reason,
|
||||
follow_up=classification.follow_up,
|
||||
workplan_path=workplan_ref.relative_path if workplan_ref else None,
|
||||
conflict=conflict,
|
||||
)
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
reconciliation_record_id = msg.id
|
||||
write_result = "not_applicable"
|
||||
return StateChangeResponse(
|
||||
target_type=body.target_type,
|
||||
target_id=body.target_id,
|
||||
actor=body.actor,
|
||||
intent=body.intent,
|
||||
current_status=current_status,
|
||||
target_status=target_status,
|
||||
file_backed=file_backed,
|
||||
archived_file=archived_file,
|
||||
task_linked=task_linked,
|
||||
reconciliation_class=classification.reconciliation_class,
|
||||
reason=classification.reason,
|
||||
follow_up=classification.follow_up,
|
||||
write_through_result=write_result,
|
||||
workplan_path=workplan_ref.relative_path if workplan_ref else None,
|
||||
reconciliation_record_id=reconciliation_record_id,
|
||||
conflict=conflict,
|
||||
)
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal, RepoGoalStatus
|
||||
from api.schemas.repo_goal import RepoGoalCreate, RepoGoalRead, RepoGoalUpdate
|
||||
from api.services.repository_aliases import resolve_repository_slug
|
||||
|
||||
router = APIRouter(prefix="/repo-goals", tags=["repo-goals"])
|
||||
|
||||
|
||||
async def _resolve_repo(repo_slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
resolution = await resolve_repository_slug(session, repo_slug)
|
||||
return resolution.repo
|
||||
|
||||
|
||||
@router.get("/", response_model=list[RepoGoalRead])
|
||||
async def list_repo_goals(
|
||||
repo_slug: str | None = None,
|
||||
domain_goal_id: uuid.UUID | None = None,
|
||||
status: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[RepoGoal]:
|
||||
q = select(RepoGoal)
|
||||
if repo_slug:
|
||||
repo = await _resolve_repo(repo_slug, session)
|
||||
q = q.where(RepoGoal.repo_id == repo.id)
|
||||
if domain_goal_id:
|
||||
q = q.where(RepoGoal.domain_goal_id == domain_goal_id)
|
||||
if status:
|
||||
q = q.where(RepoGoal.status == status)
|
||||
q = q.order_by(RepoGoal.priority.asc(), RepoGoal.created_at.asc())
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=RepoGoalRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_repo_goal(
|
||||
body: RepoGoalCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RepoGoal:
|
||||
goal = RepoGoal(**body.model_dump())
|
||||
session.add(goal)
|
||||
await session.commit()
|
||||
await session.refresh(goal)
|
||||
return goal
|
||||
|
||||
|
||||
@router.get("/{goal_id}", response_model=RepoGoalRead)
|
||||
async def get_repo_goal(
|
||||
goal_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RepoGoal:
|
||||
goal = await session.get(RepoGoal, goal_id)
|
||||
if goal is None:
|
||||
raise HTTPException(status_code=404, detail="Repo goal not found")
|
||||
return goal
|
||||
|
||||
|
||||
@router.patch("/{goal_id}", response_model=RepoGoalRead)
|
||||
async def update_repo_goal(
|
||||
goal_id: uuid.UUID,
|
||||
body: RepoGoalUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RepoGoal:
|
||||
goal = await session.get(RepoGoal, goal_id)
|
||||
if goal is None:
|
||||
raise HTTPException(status_code=404, detail="Repo goal not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(goal, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(goal)
|
||||
return goal
|
||||
Loading…
Add table
Add a link
Reference in a new issue