mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(content): task structured-note fields + orchestration-marker accessors
This commit is contained in:
@@ -235,6 +235,7 @@ class TaskUpdate(BaseModel):
|
||||
dev_notes: str | None = None
|
||||
qa_notes: str | None = None
|
||||
auditor_notes: str | None = None
|
||||
pr_reviewer_notes: str | None = None
|
||||
quick_context: str | None = None
|
||||
|
||||
# Lifecycle override — privileged/admin only. Applied by the route as an
|
||||
@@ -333,7 +334,10 @@ class TaskResponse(BaseModel):
|
||||
dev_notes: str | None
|
||||
qa_notes: str | None
|
||||
auditor_notes: str | None = None
|
||||
pr_reviewer_notes: str | None = None
|
||||
quick_context: str | None
|
||||
notes_structured: dict | None = None
|
||||
orchestration_markers: dict | None = None
|
||||
|
||||
# Review Status
|
||||
self_verified: bool
|
||||
|
||||
@@ -339,6 +339,17 @@ class TaskTable(Base):
|
||||
JSON, nullable=True
|
||||
)
|
||||
|
||||
# Structured content (migration 041). notes_structured is the typed source
|
||||
# of truth for every role's note; the TEXT note columns above are a derived
|
||||
# mirror. orchestration_markers holds the machine markers split out of
|
||||
# quick_context (never human-facing). pr_reviewer_notes is the reviewer's
|
||||
# own slot so a review no longer overwrites qa_notes / dev_notes.
|
||||
pr_reviewer_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
notes_structured: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
orchestration_markers: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSON, nullable=True
|
||||
)
|
||||
|
||||
# Gateway coordination (added in migration 006_gateway_columns).
|
||||
# active_claimant_id + last_heartbeat_at implement the single-claimant
|
||||
# invariant: only one agent holds a task at a time and they prove
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Typed accessors for ``Task.orchestration_markers``.
|
||||
|
||||
The machine markers that used to be string-packed into the human
|
||||
``quick_context`` blob (``original_developer:<uuid>``, ``documenter:<uuid>``,
|
||||
``required_cells:``, ``external_pr_head=``, ``self_heal_fp=``, ``dismissed=1``,
|
||||
``external_pr_supersede ...``) live in the ``orchestration_markers`` JSON column
|
||||
after migration 041. These accessors are the single read/write surface for them.
|
||||
|
||||
Writes REASSIGN the dict (``task.orchestration_markers = {...}``) rather than
|
||||
mutate in place, so SQLAlchemy's change tracking flags the column dirty (a plain
|
||||
JSON column does not detect in-place mutation).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class HasMarkers(Protocol):
|
||||
"""Anything carrying the markers column (the ORM task row or domain model)."""
|
||||
|
||||
orchestration_markers: dict[str, Any] | None
|
||||
|
||||
|
||||
# Marker keys — the single source of the vocabulary.
|
||||
ORIGINAL_DEVELOPER = "original_developer"
|
||||
DOCUMENTER = "documenter"
|
||||
REQUIRED_CELLS = "required_cells"
|
||||
EXTERNAL_PR_HEAD = "external_pr_head"
|
||||
EXTERNAL_PR_SUPERSEDE = "external_pr_supersede"
|
||||
SELF_HEAL_FP = "self_heal_fp"
|
||||
DISMISSED = "dismissed"
|
||||
|
||||
|
||||
def get_marker(task: HasMarkers, key: str, default: Any = None) -> Any:
|
||||
return (task.orchestration_markers or {}).get(key, default)
|
||||
|
||||
|
||||
def set_marker(task: HasMarkers, key: str, value: Any) -> None:
|
||||
markers = dict(task.orchestration_markers or {})
|
||||
markers[key] = value
|
||||
task.orchestration_markers = markers
|
||||
|
||||
|
||||
def clear_marker(task: HasMarkers, key: str) -> None:
|
||||
current = task.orchestration_markers or {}
|
||||
if key not in current:
|
||||
return
|
||||
markers = dict(current)
|
||||
del markers[key]
|
||||
task.orchestration_markers = markers or None
|
||||
|
||||
|
||||
# --- original developer ---------------------------------------------------- #
|
||||
|
||||
|
||||
def get_original_developer(task: HasMarkers) -> str | None:
|
||||
val = get_marker(task, ORIGINAL_DEVELOPER)
|
||||
return str(val) if val else None
|
||||
|
||||
|
||||
def set_original_developer(task: HasMarkers, agent_id: Any) -> None:
|
||||
set_marker(task, ORIGINAL_DEVELOPER, str(agent_id))
|
||||
|
||||
|
||||
# --- documenter ------------------------------------------------------------ #
|
||||
|
||||
|
||||
def get_documenter(task: HasMarkers) -> str | None:
|
||||
val = get_marker(task, DOCUMENTER)
|
||||
return str(val) if val else None
|
||||
|
||||
|
||||
def set_documenter(task: HasMarkers, agent_id: Any) -> None:
|
||||
set_marker(task, DOCUMENTER, str(agent_id))
|
||||
|
||||
|
||||
# --- required cells -------------------------------------------------------- #
|
||||
|
||||
|
||||
def get_required_cells(task: HasMarkers) -> list[str]:
|
||||
val = get_marker(task, REQUIRED_CELLS, [])
|
||||
return [str(c) for c in val] if isinstance(val, list) else []
|
||||
|
||||
|
||||
def set_required_cells(task: HasMarkers, cells: list[str]) -> None:
|
||||
set_marker(task, REQUIRED_CELLS, [str(c) for c in cells])
|
||||
|
||||
|
||||
# --- self-heal fingerprint ------------------------------------------------- #
|
||||
|
||||
|
||||
def get_self_heal_fingerprint(task: HasMarkers) -> str | None:
|
||||
val = get_marker(task, SELF_HEAL_FP)
|
||||
return str(val) if val else None
|
||||
|
||||
|
||||
def set_self_heal_fingerprint(task: HasMarkers, fingerprint: str) -> None:
|
||||
set_marker(task, SELF_HEAL_FP, fingerprint)
|
||||
|
||||
|
||||
# --- external PR head ------------------------------------------------------ #
|
||||
|
||||
|
||||
def get_external_pr_head(task: HasMarkers) -> str | None:
|
||||
val = get_marker(task, EXTERNAL_PR_HEAD)
|
||||
return str(val) if val else None
|
||||
|
||||
|
||||
def set_external_pr_head(task: HasMarkers, head_sha: str) -> None:
|
||||
set_marker(task, EXTERNAL_PR_HEAD, head_sha)
|
||||
|
||||
|
||||
# --- external PR supersede ------------------------------------------------- #
|
||||
|
||||
|
||||
def get_external_pr_supersede(task: HasMarkers) -> str | None:
|
||||
val = get_marker(task, EXTERNAL_PR_SUPERSEDE)
|
||||
return str(val) if val else None
|
||||
|
||||
|
||||
def set_external_pr_supersede(task: HasMarkers, marker: str) -> None:
|
||||
set_marker(task, EXTERNAL_PR_SUPERSEDE, marker)
|
||||
|
||||
|
||||
# --- dismissed ------------------------------------------------------------- #
|
||||
|
||||
|
||||
def is_dismissed(task: HasMarkers) -> bool:
|
||||
return bool(get_marker(task, DISMISSED, False))
|
||||
|
||||
|
||||
def mark_dismissed(task: HasMarkers) -> None:
|
||||
set_marker(task, DISMISSED, True)
|
||||
@@ -247,6 +247,19 @@ class Task(TimestampMixin):
|
||||
description="RAG context: similar tasks, learnings, patterns, standards",
|
||||
)
|
||||
|
||||
# Structured content (migration 041)
|
||||
pr_reviewer_notes: str | None = Field(
|
||||
default=None, description="PR reviewer's rendered verdict (own slot)"
|
||||
)
|
||||
notes_structured: dict | None = Field(
|
||||
default=None,
|
||||
description="Typed structured note payloads — the source of truth",
|
||||
)
|
||||
orchestration_markers: dict | None = Field(
|
||||
default=None,
|
||||
description="Machine markers split out of quick_context (not human-facing)",
|
||||
)
|
||||
|
||||
# Gateway coordination (added in migration 006_gateway_columns).
|
||||
active_claimant_id: UUID | None = Field(
|
||||
default=None,
|
||||
@@ -374,7 +387,9 @@ class TaskUpdate(RobocoBase):
|
||||
estimated_complexity: Complexity | None = None
|
||||
dev_notes: str | None = None
|
||||
qa_notes: str | None = None
|
||||
auditor_notes: str | None = None
|
||||
quick_context: str | None = None
|
||||
pr_reviewer_notes: str | None = None
|
||||
|
||||
# Git fields
|
||||
task_type: TaskType | None = None
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""The note fields are first-class on the task update + response schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.api.schemas.tasks import TaskResponse
|
||||
from roboco.api.schemas.tasks import TaskUpdate as ApiTaskUpdate
|
||||
from roboco.models.task import TaskUpdate as DomainTaskUpdate
|
||||
|
||||
|
||||
def test_api_task_update_accepts_auditor_and_pr_reviewer_notes() -> None:
|
||||
u = ApiTaskUpdate(auditor_notes="audit", pr_reviewer_notes="review")
|
||||
assert u.auditor_notes == "audit"
|
||||
assert u.pr_reviewer_notes == "review"
|
||||
|
||||
|
||||
def test_domain_task_update_accepts_new_notes() -> None:
|
||||
u = DomainTaskUpdate(auditor_notes="audit", pr_reviewer_notes="review")
|
||||
assert u.auditor_notes == "audit"
|
||||
assert u.pr_reviewer_notes == "review"
|
||||
|
||||
|
||||
def test_task_response_exposes_structured_fields() -> None:
|
||||
fields = set(TaskResponse.model_fields)
|
||||
assert {
|
||||
"pr_reviewer_notes",
|
||||
"notes_structured",
|
||||
"orchestration_markers",
|
||||
} <= fields
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for the orchestration-marker accessors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from roboco.foundation.policy.content import markers as m
|
||||
|
||||
|
||||
def _task(om: dict | None = None) -> SimpleNamespace:
|
||||
return SimpleNamespace(orchestration_markers=om)
|
||||
|
||||
|
||||
def test_original_developer_roundtrip() -> None:
|
||||
t = _task()
|
||||
assert m.get_original_developer(t) is None
|
||||
m.set_original_developer(t, "00000000-0000-0000-0001-000000000002")
|
||||
assert m.get_original_developer(t) == "00000000-0000-0000-0001-000000000002"
|
||||
|
||||
|
||||
def test_required_cells_roundtrip() -> None:
|
||||
t = _task()
|
||||
assert m.get_required_cells(t) == []
|
||||
m.set_required_cells(t, ["backend", "frontend"])
|
||||
assert m.get_required_cells(t) == ["backend", "frontend"]
|
||||
|
||||
|
||||
def test_dismissed_flag() -> None:
|
||||
t = _task()
|
||||
assert m.is_dismissed(t) is False
|
||||
m.mark_dismissed(t)
|
||||
assert m.is_dismissed(t) is True
|
||||
|
||||
|
||||
def test_set_marker_reassigns_dict_for_orm_dirty_tracking() -> None:
|
||||
t = _task({"a": 1})
|
||||
before = t.orchestration_markers
|
||||
m.set_marker(t, "b", 2)
|
||||
# A new dict object — SQLAlchemy only flags JSON columns dirty on reassign.
|
||||
assert t.orchestration_markers is not before
|
||||
assert t.orchestration_markers == {"a": 1, "b": 2}
|
||||
|
||||
|
||||
def test_clear_marker_nulls_when_empty() -> None:
|
||||
t = _task({"x": 1})
|
||||
m.clear_marker(t, "x")
|
||||
assert t.orchestration_markers is None
|
||||
# Clearing an absent key is a no-op.
|
||||
m.clear_marker(t, "missing")
|
||||
assert t.orchestration_markers is None
|
||||
|
||||
|
||||
def test_documenter_self_heal_head_supersede() -> None:
|
||||
t = _task()
|
||||
m.set_documenter(t, "doc-uuid")
|
||||
m.set_self_heal_fingerprint(t, "deadbeef")
|
||||
m.set_external_pr_head(t, "sha123")
|
||||
m.set_external_pr_supersede(t, "pr=1 review=2 closed=1")
|
||||
assert m.get_documenter(t) == "doc-uuid"
|
||||
assert m.get_self_heal_fingerprint(t) == "deadbeef"
|
||||
assert m.get_external_pr_head(t) == "sha123"
|
||||
assert m.get_external_pr_supersede(t) == "pr=1 review=2 closed=1"
|
||||
Reference in New Issue
Block a user