Verify offline scaffolds and preserve Forge qualifier receiving
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 29s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a10391-6355-7b41-b0fa-3e14e874f091
This commit is contained in:
tegwick 2026-10-04 02:32:37 +02:00
parent cd9dad05e8
commit 3791e608ba
5 changed files with 417 additions and 14 deletions

View file

@ -19,6 +19,7 @@ import subprocess
import tempfile
import uuid
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from typing import Any
@ -27,6 +28,10 @@ import yaml
# Same derivation as ADR-007 / repo-manager, so a forge-derived projection and a
# preliminary overlay compute identical identities for the same record.
from api.services.task_record_id_backfill import qualify_task_id
from api.work_record_flavor import (
FLAVOR_PROMOTION_REASONS, WORK_RECORD_FLAVORS,
normalize_flavor, normalize_promotion_reason,
)
_WORK_RECORD_NAMESPACE = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009")
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
@ -129,6 +134,10 @@ class DerivedTask:
needs_human: bool = False
intervention_note: str | None = None
blocking_reason: str | None = None
flavor: str | None = None
decision_id: str | None = None
blocked_on: str | None = None
wait_until: date | None = None
@dataclass
@ -142,6 +151,9 @@ class DerivedWorkplan:
owner: str | None = None
description: str | None = None
tasks: list[DerivedTask] = field(default_factory=list)
flavor: str | None = None
flavor_promotion_reason: str | None = None
flavor_promoted_from: str | None = None
@dataclass
@ -188,6 +200,9 @@ class DerivedProjection:
"status": w.status,
"owner": w.owner,
"description": w.description,
"flavor": w.flavor,
"flavor_promotion_reason": w.flavor_promotion_reason,
"flavor_promoted_from": w.flavor_promoted_from,
"relative_path": w.relative_path,
"archived": w.archived,
"tasks": [
@ -201,6 +216,10 @@ class DerivedProjection:
"needs_human": t.needs_human,
"intervention_note": t.intervention_note,
"blocking_reason": t.blocking_reason,
"flavor": t.flavor,
"decision_id": t.decision_id,
"blocked_on": t.blocked_on,
"wait_until": t.wait_until.isoformat() if t.wait_until else None,
}
for t in w.tasks
],
@ -282,6 +301,56 @@ def _clean_task_description(raw: str) -> str | None:
return "\n".join(lines).strip() or None
def _source_flavor(value: Any, field_name: str = "flavor") -> str | None:
flavor = normalize_flavor(value)
if flavor is not None and flavor not in WORK_RECORD_FLAVORS:
raise ForgeDeriveError(f"Unknown source {field_name}; no projection was applied")
return flavor
def _source_promotion_reason(value: Any) -> str | None:
reason = normalize_promotion_reason(value)
if reason is not None and reason not in FLAVOR_PROMOTION_REASONS:
raise ForgeDeriveError("Unknown source flavor_promotion_reason; no projection was applied")
return reason
def _source_wait_until(value: Any) -> date | None:
if value is None or value == "":
return None
if type(value) is date:
return value
text = str(value).strip()
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text):
try:
return date.fromisoformat(text)
except ValueError:
pass
raise ForgeDeriveError("Source wait_until must be a valid YYYY-MM-DD date; no projection was applied")
def _source_wait_text(value: Any) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise ForgeDeriveError("Source wait qualifier must be text; no projection was applied")
return value.strip() or None
def _source_needs_human(value: Any) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
text = str(value).strip().lower()
if text in {"true", "yes", "1", "on"}:
return True
if text in {"false", "no", "0", "off", ""}:
return False
raise ForgeDeriveError("Source needs_human must be boolean; no projection was applied")
def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]:
headings = [
(m.start(), len(m.group(1)), m.group(2).strip()) for m in _HEADING_RE.finditer(body)
@ -291,8 +360,10 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]:
for index, m in enumerate(task_matches):
try:
block = yaml.safe_load(m.group(1).strip()) or {}
except yaml.YAMLError:
continue
except (yaml.YAMLError, ValueError) as exc:
# PyYAML raises ValueError for impossible bare ISO dates.
# Refuse the entire derive instead of dropping a stored gate.
raise ForgeDeriveError("Malformed source task metadata; no projection was applied") from exc
if not isinstance(block, dict):
continue
rid = str(block.get("id") or "").strip()
@ -333,17 +404,21 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]:
status=(str(block["status"]).strip() if block.get("status") else None),
priority=(str(block["priority"]).strip() if block.get("priority") else None),
description=description,
needs_human=bool(block.get("needs_human", False)),
needs_human=_source_needs_human(block.get("needs_human", False)),
intervention_note=(
str(block["intervention_note"])
if block.get("intervention_note") is not None
else None
else (str(block["blocking_reason"]) if _source_needs_human(block.get("needs_human")) and block.get("blocking_reason") is not None else None)
),
blocking_reason=(
str(block["blocking_reason"])
if block.get("blocking_reason") is not None
else None
),
flavor=_source_flavor(block.get("flavor")),
decision_id=_source_wait_text(block.get("decision_id")),
blocked_on=_source_wait_text(block.get("blocked_on")),
wait_until=_source_wait_until(block.get("wait_until")),
)
)
return out
@ -397,6 +472,9 @@ def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> Derive
else _workplan_description(body)
),
tasks=_parse_tasks(body, rid),
flavor=_source_flavor(meta.get("flavor")),
flavor_promotion_reason=_source_promotion_reason(meta.get("flavor_promotion_reason")),
flavor_promoted_from=_source_flavor(meta.get("flavor_promoted_from"), "flavor_promoted_from"),
)
)
proj.workplans.sort(key=lambda w: w.record_id)
@ -611,6 +689,10 @@ def diff_against_hub(
changed = {}
if (cur.get("status") or None) != (w.status or None):
changed["status"] = {"hub": cur.get("status"), "forge": w.status}
for field_name in ("flavor", "flavor_promotion_reason", "flavor_promoted_from"):
expected = getattr(w, field_name)
if cur.get(field_name) != expected:
changed[field_name] = {"hub": cur.get(field_name), "forge": expected}
cur_path = cur.get("backing_relative_path") or None
if cur_path != w.relative_path:
changed["backing_relative_path"] = {"hub": cur_path, "forge": w.relative_path}
@ -674,11 +756,25 @@ def diff_against_hub(
)
for key, t in want.items():
cur = have.get(key)
if cur and (cur.get("status") or None) != (t.status or None):
if not cur:
continue
changed = {}
if (cur.get("status") or None) != (t.status or None):
changed["status"] = {"hub": cur.get("status"), "forge": t.status}
for field_name in ("needs_human", "intervention_note", "blocking_reason",
"flavor", "decision_id", "blocked_on", "wait_until"):
expected = getattr(t, field_name)
actual = cur.get(field_name, False if field_name == "needs_human" else None)
if isinstance(actual, date):
actual = actual.isoformat()
if isinstance(expected, date):
expected = expected.isoformat()
if actual != expected:
changed[field_name] = {"hub": actual, "forge": expected}
if changed:
d.differing.append(
{"kind": "task", "record_id": t.record_id, "uuid": t.uuid,
"workplan": w.record_id,
"changed": {"status": {"hub": cur.get("status"), "forge": t.status}}}
"workplan": w.record_id, "changed": changed}
)
return d
@ -822,6 +918,10 @@ def _sync_existing_workplan_tasks(
"needs_human": dt.needs_human,
"intervention_note": dt.intervention_note,
"blocking_reason": dt.blocking_reason,
"flavor": dt.flavor,
"decision_id": dt.decision_id,
"blocked_on": dt.blocked_on,
"wait_until": dt.wait_until,
}
st = _coerce_task_status(dt.status)
if st is not None:
@ -857,6 +957,11 @@ def _sync_existing_workplan_tasks(
if getattr(ht, "blocking_reason", None) != dt.blocking_reason:
ht.blocking_reason = dt.blocking_reason
changed = True
for field_name in ("flavor", "decision_id", "blocked_on", "wait_until"):
expected = getattr(dt, field_name)
if getattr(ht, field_name, None) != expected:
setattr(ht, field_name, expected)
changed = True
if dt.priority:
try:
task_priority = TaskPriority(dt.priority.strip().lower())
@ -1324,6 +1429,9 @@ async def reset_repository_projection(
description=w.description,
status=w.status or "proposed",
owner=w.owner,
flavor=w.flavor,
flavor_promotion_reason=w.flavor_promotion_reason,
flavor_promoted_from=w.flavor_promoted_from,
backing_filename=w.relative_path.rsplit("/", 1)[-1],
backing_relative_path=w.relative_path,
backing_archived=w.archived,
@ -1346,6 +1454,10 @@ async def reset_repository_projection(
needs_human=t.needs_human,
intervention_note=t.intervention_note,
blocking_reason=t.blocking_reason,
flavor=t.flavor,
decision_id=t.decision_id,
blocked_on=t.blocked_on,
wait_until=t.wait_until,
)
)
outcome.created.append(w.record_id)
@ -1372,6 +1484,11 @@ async def reset_repository_projection(
if w.status and row.status != w.status:
row.status = w.status
changed = True
for field_name in ("flavor", "flavor_promotion_reason", "flavor_promoted_from"):
expected = getattr(w, field_name)
if getattr(row, field_name, None) != expected:
setattr(row, field_name, expected)
changed = True
if row.backing_relative_path != w.relative_path:
row.backing_relative_path = w.relative_path
row.backing_filename = w.relative_path.rsplit("/", 1)[-1]