fix(retirement): close projection and launch contract gaps
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02b7c-1c49-76a0-955a-49e7b3ddfc0d
This commit is contained in:
parent
c43266f626
commit
b0e1af24f9
13 changed files with 1717 additions and 50 deletions
|
|
@ -24,6 +24,7 @@ from api.routers import execution
|
|||
from api.routers import fabric
|
||||
from api.routers import legacy_meter
|
||||
from api.routers import review_contracts
|
||||
from api.routers import identifier_migrations
|
||||
|
||||
|
||||
class ETagMiddleware(BaseHTTPMiddleware):
|
||||
|
|
@ -136,6 +137,7 @@ app.include_router(execution.router)
|
|||
app.include_router(fabric.router)
|
||||
app.include_router(legacy_meter.router)
|
||||
app.include_router(review_contracts.router)
|
||||
app.include_router(identifier_migrations.router)
|
||||
app.include_router(state.router)
|
||||
app.include_router(ops_runs.router)
|
||||
app.include_router(policy.router)
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ from api.services.execution_queue import (
|
|||
ACTIVITY_CORE_RESPONSIBILITIES,
|
||||
CONCURRENCY_MODES,
|
||||
EXECUTION_STATES,
|
||||
EXECUTION_REPLACEMENTS,
|
||||
LAUNCH_MODES,
|
||||
STATE_HUB_RESPONSIBILITIES,
|
||||
execution_state_for_launch,
|
||||
queue_sort_key,
|
||||
workplan_blockers,
|
||||
)
|
||||
|
|
@ -43,6 +43,8 @@ async def execution_semantics() -> ExecutionSemantics:
|
|||
concurrency_modes=CONCURRENCY_MODES,
|
||||
state_hub_responsibility=STATE_HUB_RESPONSIBILITIES,
|
||||
activity_core_responsibility=ACTIVITY_CORE_RESPONSIBILITIES,
|
||||
launch_requests_accepted=False,
|
||||
replacements=EXECUTION_REPLACEMENTS,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -161,37 +163,27 @@ async def workplan_stack(
|
|||
|
||||
@router.post(
|
||||
"/launch-requests",
|
||||
response_model=LaunchRequestRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
status_code=status.HTTP_410_GONE,
|
||||
)
|
||||
async def create_launch_request(
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: LaunchRequestCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkplanLaunchRequest:
|
||||
ws = await session.get(Workplan, body.workplan_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workplan not found")
|
||||
|
||||
launch_request = WorkplanLaunchRequest(
|
||||
workplan_id=ws.id,
|
||||
requested_by=body.requested_by,
|
||||
requested_actor=body.requested_actor,
|
||||
launch_mode=body.launch_mode,
|
||||
concurrency_mode=body.concurrency_mode,
|
||||
priority=body.priority or ws.planning_priority,
|
||||
repo_id=body.repo_id or ws.repo_id,
|
||||
branch_preference=body.branch_preference,
|
||||
immediate_pickup=body.immediate_pickup,
|
||||
notes=body.notes,
|
||||
request_metadata=body.request_metadata,
|
||||
) -> 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."
|
||||
),
|
||||
)
|
||||
ws.launch_mode = body.launch_mode
|
||||
ws.concurrency_mode = body.concurrency_mode
|
||||
ws.execution_state = execution_state_for_launch(body.launch_mode, body.immediate_pickup)
|
||||
session.add(launch_request)
|
||||
await session.commit()
|
||||
await session.refresh(launch_request)
|
||||
return launch_request
|
||||
|
||||
|
||||
@router.get("/launch-requests", response_model=list[LaunchRequestRead])
|
||||
|
|
|
|||
44
api/routers/identifier_migrations.py
Normal file
44
api/routers/identifier_migrations.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.schemas.identifier_migration import (
|
||||
SealedProjectionRepairReceiptRead,
|
||||
SealedProjectionRepairSubmit,
|
||||
)
|
||||
from api.services.work_record_identifier_migration import (
|
||||
IdentifierMigrationError,
|
||||
repair_absent_prederivation_projection,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/identifier-migrations", tags=["identifier-migrations"])
|
||||
|
||||
|
||||
@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)
|
||||
|
|
@ -104,3 +104,5 @@ class ExecutionSemantics(BaseModel):
|
|||
concurrency_modes: dict[str, str]
|
||||
state_hub_responsibility: list[str]
|
||||
activity_core_responsibility: list[str]
|
||||
launch_requests_accepted: bool = False
|
||||
replacements: dict[str, str] = Field(default_factory=dict)
|
||||
|
|
|
|||
33
api/schemas/identifier_migration.py
Normal file
33
api/schemas/identifier_migration.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SealedProjectionRepairSubmit(BaseModel):
|
||||
plan: dict[str, Any]
|
||||
repo_slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,99}$")
|
||||
unit: dict[str, Any]
|
||||
expected_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
source_revision: str = Field(pattern=r"^[0-9a-f]{40}$")
|
||||
source_fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
source_clean: bool
|
||||
source_synchronized: bool
|
||||
primary_confirmed: bool
|
||||
projection_identity: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class SealedProjectionRepairReceiptRead(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
schema_version: str = Field(alias="schema")
|
||||
outcome: str
|
||||
repo_slug: str
|
||||
repository_id: str
|
||||
workplan_record_id: str
|
||||
workplan_old_id: str
|
||||
task_records: list[tuple[str, str]]
|
||||
plan_sha256: str
|
||||
source_revision: str
|
||||
source_fingerprint: str
|
||||
projection_identity: str
|
||||
observed_at: str
|
||||
|
|
@ -9,7 +9,7 @@ EXECUTION_STATES = {
|
|||
"manual": "Not queued for autonomous pickup; humans or agents may still work manually.",
|
||||
"queued": "Candidate for ordered pickup when dependencies and concurrency allow it.",
|
||||
"scheduled": "Waiting for an external launch window; State Hub stores the requested time.",
|
||||
"launching": "A launch request asks for immediate pickup or has been handed off.",
|
||||
"launching": "Legacy state only; it does not prove that any consumer accepted pickup.",
|
||||
"paused": "Temporarily held outside the pickup stack.",
|
||||
"completed": "Execution intent is closed; lifecycle status remains authoritative.",
|
||||
"cancelled": "Execution intent was cancelled without changing lifecycle status.",
|
||||
|
|
@ -19,7 +19,7 @@ LAUNCH_MODES = {
|
|||
"manual": "Do not request automation; keep intent visible only.",
|
||||
"queued": "Place in the prioritized stack for later pickup.",
|
||||
"scheduled": "Request pickup at or after a selected time.",
|
||||
"immediate": "Request prompt activity-core or agent pickup.",
|
||||
"immediate": "Legacy intent only; workplan launch-request pickup is retired.",
|
||||
}
|
||||
|
||||
CONCURRENCY_MODES = {
|
||||
|
|
@ -30,16 +30,26 @@ CONCURRENCY_MODES = {
|
|||
STATE_HUB_RESPONSIBILITIES = [
|
||||
"store lifecycle status separately from execution intent",
|
||||
"rank candidate workplans and expose dependency-aware eligibility",
|
||||
"record launch requests and handoff metadata durably",
|
||||
"surface manual, queued, scheduled, and immediate intent to operators",
|
||||
"preserve historical launch-request and execution-intent rows during retirement",
|
||||
"reject new workplan launch requests because no pickup consumer exists",
|
||||
]
|
||||
|
||||
ACTIVITY_CORE_RESPONSIBILITIES = [
|
||||
"own schedules, wakeups, and recurring automation",
|
||||
"dispatch coding agents and coordinate parallel execution",
|
||||
"acknowledge, run, and complete launch requests when available",
|
||||
"claim and complete ops runs created by ActivityDefinition fires",
|
||||
"do not consume State Hub workplan launch requests",
|
||||
]
|
||||
|
||||
EXECUTION_REPLACEMENTS = {
|
||||
"POST /execution/launch-requests": (
|
||||
"Queue work in the authoritative repository file; for recurring or operational "
|
||||
"fires use an ActivityDefinition and activity-core ops_run"
|
||||
),
|
||||
"GET /execution/launch-requests": "GET /ops-runs on activity-core for automation history",
|
||||
"GET /execution/workplan-stack": "Repo Manager work index or hub-core projection",
|
||||
"PATCH /execution/workplans/{id}/intent": "Edit the authoritative workplan file",
|
||||
}
|
||||
|
||||
EXECUTION_STATE_RANK = {
|
||||
"launching": 0,
|
||||
"queued": 1,
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ from dataclasses import dataclass
|
|||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task, TaskPriority, TaskStatus
|
||||
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
|
||||
from api.models.workplan import Workplan
|
||||
|
||||
PLAN_SCHEMA = "repo-manager.identifier-migration-plan.v1"
|
||||
FLEET_NAMESPACE = "helixforge"
|
||||
|
|
@ -34,6 +37,24 @@ class IdentifierMigrationResult:
|
|||
assignments_deferred: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SealedProjectionRepairReceipt:
|
||||
"""Non-secret evidence for one bounded pre-derivation projection repair."""
|
||||
|
||||
schema: str
|
||||
outcome: str
|
||||
repo_slug: str
|
||||
repository_id: str
|
||||
workplan_record_id: str
|
||||
workplan_old_id: str
|
||||
task_records: tuple[tuple[str, str], ...]
|
||||
plan_sha256: str
|
||||
source_revision: str
|
||||
source_fingerprint: str
|
||||
projection_identity: str
|
||||
observed_at: str
|
||||
|
||||
|
||||
def verify_plan(plan: dict[str, Any]) -> str:
|
||||
"""Validate the immutable plan envelope and deterministic UUID mappings."""
|
||||
if plan.get("schema") != PLAN_SCHEMA:
|
||||
|
|
@ -117,6 +138,328 @@ def _repository_mappings(
|
|||
return replacements, assignments
|
||||
|
||||
|
||||
def _repository_entry(plan: dict[str, Any], repo_slug: str) -> dict[str, Any]:
|
||||
matches = [item for item in plan.get("repositories", []) if item.get("repo") == repo_slug]
|
||||
if len(matches) != 1:
|
||||
raise IdentifierMigrationError(
|
||||
f"repository {repo_slug!r} must occur exactly once in plan"
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _repair_unit_mappings(
|
||||
plan: dict[str, Any], repo_slug: str, unit: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
replacements, _assignments = _repository_mappings(plan, repo_slug)
|
||||
workplan = unit.get("workplan")
|
||||
tasks = unit.get("tasks")
|
||||
if not isinstance(workplan, dict) or not isinstance(tasks, list):
|
||||
raise IdentifierMigrationError("repair unit requires one workplan and a task list")
|
||||
|
||||
workplan_record_id = workplan.get("record_id")
|
||||
workplan_matches = [
|
||||
mapping
|
||||
for mapping in replacements
|
||||
if mapping["kind"] == "workplan" and mapping["record_id"] == workplan_record_id
|
||||
]
|
||||
if len(workplan_matches) != 1:
|
||||
raise IdentifierMigrationError(
|
||||
"repair workplan must occur exactly once as a replacement in the sealed plan"
|
||||
)
|
||||
workplan_mapping = workplan_matches[0]
|
||||
raw_workplan_mapping = next(
|
||||
raw
|
||||
for raw in _repository_entry(plan, repo_slug).get("mappings", [])
|
||||
if raw.get("kind") == "workplan" and raw.get("record_id") == workplan_record_id
|
||||
)
|
||||
authoritative_path = raw_workplan_mapping.get("path")
|
||||
if not isinstance(authoritative_path, str) or not authoritative_path:
|
||||
raise IdentifierMigrationError("sealed workplan mapping has no authoritative path")
|
||||
|
||||
task_record_ids = [task.get("record_id") for task in tasks if isinstance(task, dict)]
|
||||
if len(task_record_ids) != len(tasks) or len(set(task_record_ids)) != len(tasks):
|
||||
raise IdentifierMigrationError("repair task record ids must be explicit and unique")
|
||||
task_mappings = [
|
||||
mapping
|
||||
for mapping in replacements
|
||||
if mapping["kind"] == "task" and mapping["record_id"] in task_record_ids
|
||||
]
|
||||
mapped_task_ids = {mapping["record_id"] for mapping in task_mappings}
|
||||
if mapped_task_ids != set(task_record_ids):
|
||||
raise IdentifierMigrationError(
|
||||
"repair tasks do not exactly match replacement mappings in the sealed plan"
|
||||
)
|
||||
|
||||
raw_unit_task_ids = {
|
||||
raw.get("record_id")
|
||||
for raw in _repository_entry(plan, repo_slug).get("mappings", [])
|
||||
if raw.get("kind") == "task"
|
||||
and raw.get("action") == "replace"
|
||||
and raw.get("path") == authoritative_path
|
||||
}
|
||||
if raw_unit_task_ids != set(task_record_ids):
|
||||
raise IdentifierMigrationError(
|
||||
"repair must include every replaced task in the sealed workplan unit"
|
||||
)
|
||||
return workplan_mapping, task_mappings
|
||||
|
||||
|
||||
def _uuid_field(value: Any, label: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise IdentifierMigrationError(f"invalid UUID for {label}") from exc
|
||||
|
||||
|
||||
def _repair_payloads(
|
||||
unit: dict[str, Any],
|
||||
workplan_mapping: dict[str, Any],
|
||||
task_mappings: list[dict[str, Any]],
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
raw_workplan = unit["workplan"]
|
||||
if _uuid_field(raw_workplan.get("id"), "workplan") != workplan_mapping["old_id"]:
|
||||
raise IdentifierMigrationError("repair workplan UUID does not match sealed old UUID")
|
||||
slug = raw_workplan.get("slug")
|
||||
title = raw_workplan.get("title")
|
||||
status = raw_workplan.get("status")
|
||||
if not isinstance(slug, str) or not slug or not isinstance(title, str) or not title:
|
||||
raise IdentifierMigrationError("repair workplan requires explicit slug and title")
|
||||
if status not in {
|
||||
"proposed", "ready", "active", "blocked", "backlog", "finished", "archived"
|
||||
}:
|
||||
raise IdentifierMigrationError(f"unsupported repair workplan status {status!r}")
|
||||
workplan_payload = {
|
||||
"id": workplan_mapping["old_id"],
|
||||
"topic_id": _uuid_field(raw_workplan.get("topic_id"), "workplan topic"),
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"description": raw_workplan.get("description"),
|
||||
"status": status,
|
||||
"owner": raw_workplan.get("owner"),
|
||||
"planning_priority": raw_workplan.get("planning_priority"),
|
||||
"planning_order": raw_workplan.get("planning_order"),
|
||||
}
|
||||
|
||||
mappings_by_record = {mapping["record_id"]: mapping for mapping in task_mappings}
|
||||
task_payloads: list[dict[str, Any]] = []
|
||||
for raw_task in unit["tasks"]:
|
||||
mapping = mappings_by_record[raw_task["record_id"]]
|
||||
if _uuid_field(raw_task.get("id"), raw_task["record_id"]) != mapping["old_id"]:
|
||||
raise IdentifierMigrationError(
|
||||
f"repair task UUID does not match sealed old UUID for {raw_task['record_id']}"
|
||||
)
|
||||
title = raw_task.get("title")
|
||||
if not isinstance(title, str) or not title:
|
||||
raise IdentifierMigrationError(
|
||||
f"repair task {raw_task['record_id']} requires an explicit title"
|
||||
)
|
||||
try:
|
||||
status_value = TaskStatus(raw_task.get("status", "todo"))
|
||||
priority_value = TaskPriority(raw_task.get("priority", "medium"))
|
||||
except ValueError as exc:
|
||||
raise IdentifierMigrationError(
|
||||
f"invalid task state for {raw_task['record_id']}"
|
||||
) from exc
|
||||
parent_record_id = raw_task.get("parent_record_id")
|
||||
if parent_record_id is not None and parent_record_id not in mappings_by_record:
|
||||
raise IdentifierMigrationError(
|
||||
f"parent task for {raw_task['record_id']} is outside the sealed repair unit"
|
||||
)
|
||||
task_payloads.append(
|
||||
{
|
||||
"record_id": raw_task["record_id"],
|
||||
"id": mapping["old_id"],
|
||||
"title": title,
|
||||
"description": raw_task.get("description"),
|
||||
"status": status_value,
|
||||
"priority": priority_value,
|
||||
"assignee": raw_task.get("assignee"),
|
||||
"blocking_reason": raw_task.get("blocking_reason"),
|
||||
"needs_human": bool(raw_task.get("needs_human", False)),
|
||||
"intervention_note": raw_task.get("intervention_note"),
|
||||
"parent_task_id": (
|
||||
mappings_by_record[parent_record_id]["old_id"]
|
||||
if parent_record_id is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
if task_payloads[-1]["needs_human"] and not task_payloads[-1]["intervention_note"]:
|
||||
raise IdentifierMigrationError(
|
||||
f"repair task {raw_task['record_id']} needs an intervention note"
|
||||
)
|
||||
if (
|
||||
task_payloads[-1]["status"] == TaskStatus.wait
|
||||
and task_payloads[-1]["needs_human"]
|
||||
and not task_payloads[-1]["blocking_reason"]
|
||||
):
|
||||
raise IdentifierMigrationError(
|
||||
f"repair task {raw_task['record_id']} needs a blocking reason"
|
||||
)
|
||||
|
||||
pending = list(task_payloads)
|
||||
ordered: list[dict[str, Any]] = []
|
||||
emitted: set[uuid.UUID] = set()
|
||||
while pending:
|
||||
ready = [
|
||||
task
|
||||
for task in pending
|
||||
if task["parent_task_id"] is None or task["parent_task_id"] in emitted
|
||||
]
|
||||
if not ready:
|
||||
raise IdentifierMigrationError("repair task parentage contains a cycle")
|
||||
for task in ready:
|
||||
pending.remove(task)
|
||||
ordered.append(task)
|
||||
emitted.add(task["id"])
|
||||
return workplan_payload, ordered
|
||||
|
||||
|
||||
def _same_workplan(row: Workplan, expected: dict[str, Any], repo_id: uuid.UUID) -> bool:
|
||||
fields = (
|
||||
"id", "topic_id", "slug", "title", "description", "status", "owner",
|
||||
"planning_priority", "planning_order",
|
||||
)
|
||||
return row.repo_id == repo_id and all(
|
||||
getattr(row, field) == expected[field] for field in fields
|
||||
)
|
||||
|
||||
|
||||
def _same_task(row: Task, expected: dict[str, Any], workplan_id: uuid.UUID) -> bool:
|
||||
fields = (
|
||||
"id", "title", "description", "status", "priority", "assignee",
|
||||
"blocking_reason", "needs_human", "intervention_note", "parent_task_id",
|
||||
)
|
||||
return row.workplan_id == workplan_id and all(
|
||||
getattr(row, field) == expected[field] for field in fields
|
||||
)
|
||||
|
||||
|
||||
async def repair_absent_prederivation_projection(
|
||||
session: AsyncSession,
|
||||
plan: dict[str, Any],
|
||||
repo_slug: str,
|
||||
unit: dict[str, Any],
|
||||
*,
|
||||
expected_plan_sha256: str,
|
||||
source_revision: str,
|
||||
source_fingerprint: str,
|
||||
source_clean: bool,
|
||||
source_synchronized: bool,
|
||||
primary_confirmed: bool,
|
||||
projection_identity: str,
|
||||
observed_at: datetime | None = None,
|
||||
) -> SealedProjectionRepairReceipt:
|
||||
"""Restore one exact random-ID workplan unit into an absent projection.
|
||||
|
||||
This is intentionally separate from ordinary consistency repair. The caller
|
||||
must independently prove Git cleanliness/synchronization and provide the
|
||||
authoritative unit parsed from the pinned source revision.
|
||||
"""
|
||||
plan_sha256 = verify_plan(plan)
|
||||
if plan_sha256 != expected_plan_sha256:
|
||||
raise IdentifierMigrationError("explicit plan SHA-256 does not match sealed plan")
|
||||
if not primary_confirmed:
|
||||
raise IdentifierMigrationError("projection repair requires explicit primary confirmation")
|
||||
if not source_clean or not source_synchronized:
|
||||
raise IdentifierMigrationError("projection repair requires a clean synchronized source")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", source_revision):
|
||||
raise IdentifierMigrationError("source revision must be a full Git SHA")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", source_fingerprint):
|
||||
raise IdentifierMigrationError("source fingerprint must be SHA-256")
|
||||
if not projection_identity.strip():
|
||||
raise IdentifierMigrationError("projection identity is required")
|
||||
|
||||
repository_entry = _repository_entry(plan, repo_slug)
|
||||
if source_revision != repository_entry.get("planned_head_sha"):
|
||||
raise IdentifierMigrationError("source revision drifted from sealed plan")
|
||||
if source_fingerprint != repository_entry.get("source_fingerprint"):
|
||||
raise IdentifierMigrationError("source fingerprint drifted from sealed plan")
|
||||
workplan_mapping, task_mappings = _repair_unit_mappings(plan, repo_slug, unit)
|
||||
workplan_payload, task_payloads = _repair_payloads(
|
||||
unit, workplan_mapping, task_mappings
|
||||
)
|
||||
if session.in_transaction():
|
||||
raise IdentifierMigrationError("projection repair requires a fresh database session")
|
||||
|
||||
outcome = "repaired"
|
||||
async with session.begin():
|
||||
repo = await session.scalar(
|
||||
select(ManagedRepo).where(ManagedRepo.slug == repo_slug).with_for_update()
|
||||
)
|
||||
if repo is None:
|
||||
raise IdentifierMigrationError(f"repository projection is absent: {repo_slug}")
|
||||
if repo.topic_id != workplan_payload["topic_id"]:
|
||||
raise IdentifierMigrationError("repair topic does not match repository projection")
|
||||
|
||||
old_ids = [workplan_mapping["old_id"], *(m["old_id"] for m in task_mappings)]
|
||||
new_workplan = await session.get(Workplan, workplan_mapping["new_id"])
|
||||
new_tasks = [await session.get(Task, mapping["new_id"]) for mapping in task_mappings]
|
||||
if new_workplan is not None or any(task is not None for task in new_tasks):
|
||||
raise IdentifierMigrationError("derived target presence blocks projection repair")
|
||||
alias_count = await session.scalar(
|
||||
select(func.count()).select_from(WorkRecordIdentifierAlias).where(
|
||||
WorkRecordIdentifierAlias.old_id.in_(old_ids)
|
||||
)
|
||||
)
|
||||
if alias_count:
|
||||
raise IdentifierMigrationError("durable alias presence blocks projection repair")
|
||||
|
||||
old_workplan = await session.get(Workplan, workplan_mapping["old_id"])
|
||||
old_tasks = [await session.get(Task, mapping["old_id"]) for mapping in task_mappings]
|
||||
present_count = int(old_workplan is not None) + sum(task is not None for task in old_tasks)
|
||||
expected_count = 1 + len(task_mappings)
|
||||
if present_count not in {0, expected_count}:
|
||||
raise IdentifierMigrationError("partial old projection presence blocks repair")
|
||||
|
||||
if present_count == expected_count:
|
||||
if not _same_workplan(old_workplan, workplan_payload, repo.id):
|
||||
raise IdentifierMigrationError(
|
||||
"existing old workplan differs from sealed repair unit"
|
||||
)
|
||||
expected_tasks = {task["id"]: task for task in task_payloads}
|
||||
if any(
|
||||
not _same_task(task, expected_tasks[task.id], workplan_mapping["old_id"])
|
||||
for task in old_tasks
|
||||
):
|
||||
raise IdentifierMigrationError("existing old tasks differ from sealed repair unit")
|
||||
outcome = "verified_noop"
|
||||
else:
|
||||
slug_conflict = await session.scalar(
|
||||
select(Workplan.id).where(Workplan.slug == workplan_payload["slug"])
|
||||
)
|
||||
if slug_conflict is not None:
|
||||
raise IdentifierMigrationError("workplan slug is already projected")
|
||||
session.add(
|
||||
Workplan(repo_id=repo.id, **workplan_payload)
|
||||
)
|
||||
await session.flush()
|
||||
for task_payload in task_payloads:
|
||||
values = {key: value for key, value in task_payload.items() if key != "record_id"}
|
||||
session.add(Task(workplan_id=workplan_mapping["old_id"], **values))
|
||||
await session.flush()
|
||||
|
||||
timestamp = observed_at or datetime.now(UTC)
|
||||
if timestamp.tzinfo is None:
|
||||
raise IdentifierMigrationError("receipt observation time must be timezone-aware")
|
||||
return SealedProjectionRepairReceipt(
|
||||
schema="state-hub.sealed-prederivation-projection-repair-receipt.v1",
|
||||
outcome=outcome,
|
||||
repo_slug=repo_slug,
|
||||
repository_id=str(repo.id),
|
||||
workplan_record_id=workplan_mapping["record_id"],
|
||||
workplan_old_id=str(workplan_mapping["old_id"]),
|
||||
task_records=tuple(
|
||||
(mapping["record_id"], str(mapping["old_id"])) for mapping in task_mappings
|
||||
),
|
||||
plan_sha256=plan_sha256,
|
||||
source_revision=source_revision,
|
||||
source_fingerprint=source_fingerprint,
|
||||
projection_identity=projection_identity,
|
||||
observed_at=timestamp.astimezone(UTC).isoformat().replace("+00:00", "Z"),
|
||||
)
|
||||
|
||||
|
||||
async def _assert_projection_preconditions(
|
||||
session: AsyncSession,
|
||||
repo_slug: str,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue