mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Wire the long-unwired mark_waived repo method to a new auditor-only flow verb waive_finding, severity-scoped to minor/nit (blocker/major must be fixed, never waived), requiring a note, with a task.finding_waived audit event and no task status change. Add the verb to the IntentSpec table (auto-derived into the auditor manifest), the flow_auditor route, and the flow_server MCP tool. Surface open review findings (cross-task, blocking-first) on the auditor dashboard via ReviewFindingsRepository.list_open_findings and a new findings field on AuditorDashboard. Restore the panel's 4-card auditor layout with a new read-only FindingsQueuePanel as the 4th card.
470 lines
17 KiB
Python
470 lines
17 KiB
Python
"""Request schemas for /api/v1/flow/* intent verbs."""
|
|
|
|
from typing import Annotated, Any
|
|
from uuid import UUID
|
|
|
|
from pydantic import BaseModel, BeforeValidator, Field, field_validator
|
|
|
|
from roboco.foundation.policy.content.validators import coerce_str_list
|
|
from roboco.models.base import Complexity
|
|
|
|
# A ``list[str]`` field that tolerates the Claude SDK's XML-ish tool-input
|
|
# parsing: an LLM emitting a bullet list as ``<item>…</item>`` elements arrives
|
|
# nested (``[[["…"]]]`` / ``[{"item": {"$text": "…"}}, …]``), which a bare
|
|
# ``list[str]`` hard-rejects at validation time (the live ``i_will_plan`` crash:
|
|
# ``technical_considerations.1 Input should be a valid string``). The
|
|
# ``BeforeValidator`` flattens it to a flat ``list[str]`` first — same
|
|
# ``coerce_str_list`` used at the intake→DB boundary (Bug 3, MegaTask memory).
|
|
# Applied to EVERY LLM-authored list-of-strings on the flow surface so the SDK
|
|
# can't crash any of them (acceptance_criteria, issues, files, ac_verdicts,
|
|
# technical_considerations, covers_parent_criteria).
|
|
StrList = Annotated[list[str], BeforeValidator(coerce_str_list)]
|
|
|
|
# AC discipline (the 2026-07-07 task-quality defect: restated, over-long
|
|
# acceptance criteria). Mirrors roboco.foundation.policy.task_completeness
|
|
# so an over-long / over-count AC list is rejected at the request boundary.
|
|
_AC_MAX_ITEMS = 7
|
|
_AC_MAX_ITEM_CHARS = 200
|
|
|
|
|
|
class SubTaskCreate(BaseModel):
|
|
"""A PM sub_task — a delegate target AND a progress-checklist item.
|
|
|
|
Mirrors DelegateRequest title/description caps so an over-long sub_task
|
|
can't bloat the plan (the 2026-07-07 task-quality defect). The server
|
|
assigns id + order; callers supply title + description only.
|
|
"""
|
|
|
|
title: str = Field(..., min_length=1, max_length=200)
|
|
description: str = Field(..., min_length=20, max_length=600)
|
|
|
|
|
|
class RiskCreate(BaseModel):
|
|
"""A {risk, mitigation} entry — what could go wrong and how it's handled."""
|
|
|
|
risk: str = Field(..., min_length=1, max_length=300)
|
|
mitigation: str = Field(..., min_length=1, max_length=600)
|
|
|
|
|
|
class OpenQuestionCreate(BaseModel):
|
|
"""An open question the PM wants answered before/during work."""
|
|
|
|
question: str = Field(..., min_length=1, max_length=300)
|
|
answered: bool = False
|
|
|
|
|
|
class GiveMeWorkRequest(BaseModel):
|
|
"""Empty request body — agent_id comes from header."""
|
|
|
|
|
|
class IWillWorkOnRequest(BaseModel):
|
|
task_id: UUID
|
|
plan: str | None = None
|
|
# The executing developer's plan is a step checklist (same
|
|
# SubTask shape as IWillPlanRequest.sub_tasks). It is both the
|
|
# execution plan AND the progress checklist: completing a
|
|
# step advances progress. Depth is enforced server-side in
|
|
# choreographer._dev_steps_gate (a title with no real description is
|
|
# not a step). Server assigns id + order.
|
|
steps: list[dict[str, str]] = Field(
|
|
default_factory=list,
|
|
description="Ordered execution steps — list of {title, description}",
|
|
)
|
|
# Full parity with IWillPlanRequest so a dev leaf's Plan tab renders the
|
|
# same rich structure PMs author. Defaults stay permissive (NOT min_length)
|
|
# so re-entry/recovery calls that omit them still pass route validation;
|
|
# depth + presence are enforced on FRESH dev claims by
|
|
# choreographer._dev_plan_gate. The dev's `plan` doubles as the approach.
|
|
technical_considerations: StrList = Field(default_factory=list)
|
|
risks: list[dict[str, str]] = Field(default_factory=list)
|
|
open_questions: list[dict[str, str | bool]] = Field(default_factory=list)
|
|
|
|
|
|
class OpenPrRequest(BaseModel):
|
|
task_id: UUID
|
|
|
|
|
|
class ResolvedFindingInput(BaseModel):
|
|
"""One entry in ``i_am_done``'s ``resolved_findings`` — the dev's claim
|
|
that a ledger finding is addressed. ``finding_id`` is the id shown in the
|
|
rendered note/A2A body (``[F-<id8>]``) — a full UUID also matches."""
|
|
|
|
finding_id: str = Field(..., min_length=1)
|
|
commit: str | None = None
|
|
note: str | None = Field(default=None, max_length=300)
|
|
|
|
|
|
class IAmDoneRequest(BaseModel):
|
|
task_id: UUID
|
|
notes: str = ""
|
|
resolved_findings: list[ResolvedFindingInput] = Field(default_factory=list)
|
|
|
|
|
|
class IAmBlockedRequest(BaseModel):
|
|
task_id: UUID
|
|
reason: str = Field(..., min_length=1)
|
|
# Pre-gateway parity (G8 part b). The old TaskBlockInput at
|
|
# 0c3d15a:roboco/mcp/schemas/__init__.py required blocker_type and
|
|
# what_needed so PMs could triage by class. Optional here for
|
|
# back-compat with i_am_blocked(reason) callers; supplied fields are
|
|
# rendered into the struggle journal entry so the panel surfaces them.
|
|
blocker_type: str | None = Field(
|
|
default=None,
|
|
description=(
|
|
"external | internal | question | dependency. Required from "
|
|
"newly-spawned agents (per the developer.md verb table); "
|
|
"older agents that don't supply it default to 'internal'."
|
|
),
|
|
)
|
|
what_needed: str | None = Field(
|
|
default=None,
|
|
description="Concrete description of what would unblock the task.",
|
|
)
|
|
|
|
@field_validator("blocker_type", mode="before")
|
|
@classmethod
|
|
def _blocker_type_enum(cls, v: object) -> object:
|
|
if v is None:
|
|
return v
|
|
if isinstance(v, str) and v.lower() not in {
|
|
"external",
|
|
"internal",
|
|
"question",
|
|
"dependency",
|
|
}:
|
|
raise ValueError(
|
|
f"blocker_type must be one of: external | internal | "
|
|
f"question | dependency. Got {v!r}."
|
|
)
|
|
return v
|
|
|
|
|
|
class UnclaimRequest(BaseModel):
|
|
task_id: UUID
|
|
|
|
|
|
class ReassignRequest(BaseModel):
|
|
"""HTTP body for the cell_pm `reassign` verb.
|
|
|
|
``new_assignee`` is a developer slug in the caller's own cell (e.g.
|
|
``be-dev-2``). The choreographer resolves and validates it.
|
|
"""
|
|
|
|
task_id: UUID
|
|
new_assignee: str = Field(..., min_length=1)
|
|
|
|
|
|
class DeclareCoverageRequest(BaseModel):
|
|
"""HTTP body for the cell_pm/main_pm `declare_coverage` verb.
|
|
|
|
``task_id`` is the CHILD to stamp (or the caller's OWN root/coordination
|
|
task, for root-owned criteria); ``criteria`` are that task's parent's
|
|
acceptance criteria — or its own, in root-owned mode — by id or exact
|
|
text (same representation as `delegate`'s `covers_parent_criteria`). The
|
|
choreographer validates ownership + unknown criteria.
|
|
"""
|
|
|
|
task_id: UUID
|
|
criteria: StrList = Field(..., min_length=1)
|
|
|
|
|
|
class ResumeRequest(BaseModel):
|
|
task_id: UUID
|
|
|
|
|
|
class SyncBranchRequest(BaseModel):
|
|
"""HTTP body for the dev `sync_branch` verb.
|
|
|
|
Rebases the task's branch onto its resolved base (parent branch) through the
|
|
gate, so a developer whose branch has fallen behind can re-sync without raw
|
|
git (which is denied to agents). Git-only — no DB state transition.
|
|
"""
|
|
|
|
task_id: UUID
|
|
# Auto-stash (tracked + untracked) instead of refusing DIRTY_WORKSPACE;
|
|
# popped back after the rebase. Default False preserves the prior refuse
|
|
# behavior for callers that don't opt in.
|
|
stash: bool = False
|
|
|
|
|
|
class IAmIdleRequest(BaseModel):
|
|
"""Empty request body."""
|
|
|
|
|
|
class ClaimReviewRequest(BaseModel):
|
|
task_id: UUID
|
|
|
|
|
|
class PassReviewRequest(BaseModel):
|
|
task_id: UUID
|
|
notes: str = Field(..., min_length=1)
|
|
ac_verdicts: StrList | None = Field(
|
|
default=None,
|
|
description=(
|
|
"One verification entry per acceptance criterion (in criterion "
|
|
"order) stating how QA verified it. Every criterion must be "
|
|
"covered before a pass is allowed."
|
|
),
|
|
)
|
|
|
|
|
|
class FailReviewRequest(BaseModel):
|
|
task_id: UUID
|
|
# Free text — deprecated, one release. Shimmed into file-less findings.
|
|
issues: StrList = Field(default_factory=list)
|
|
findings: list[dict[str, Any]] = Field(
|
|
default_factory=list,
|
|
description=(
|
|
"Structured revision findings — each {file?, line?, severity "
|
|
"(blocker|major|minor|nit), expected, actual, fix?, evidence?}. "
|
|
"At least one of findings/issues is required."
|
|
),
|
|
)
|
|
|
|
|
|
class ClaimPrReviewRequest(BaseModel):
|
|
task_id: UUID
|
|
|
|
|
|
class PostPrReviewRequest(BaseModel):
|
|
task_id: UUID
|
|
body: str = Field(..., min_length=1)
|
|
event: str = Field(
|
|
default="REQUEST_CHANGES",
|
|
description=(
|
|
"APPROVE, REQUEST_CHANGES, or COMMENT. The verdict must match the "
|
|
"findings: REQUEST_CHANGES needs >=1 finding; APPROVE may not carry a "
|
|
"blocker/major finding. Pass APPROVE explicitly to approve a clean PR."
|
|
),
|
|
)
|
|
findings: list[dict[str, Any]] = Field(
|
|
default_factory=list,
|
|
description=(
|
|
"Structured per-criterion findings — each {file, line?, severity "
|
|
"(blocker|major|minor|nit), expected, actual}. When provided, the "
|
|
"GitHub comment is generated from them in the RoboCo format."
|
|
),
|
|
)
|
|
|
|
|
|
class ClaimGateReviewRequest(BaseModel):
|
|
task_id: UUID
|
|
|
|
|
|
class PrPassRequest(BaseModel):
|
|
task_id: UUID
|
|
notes: str = Field(..., min_length=1)
|
|
|
|
|
|
class PrFailRequest(BaseModel):
|
|
task_id: UUID
|
|
# Free text — deprecated, one release. Shimmed into file-less findings.
|
|
issues: StrList = Field(default_factory=list)
|
|
findings: list[dict[str, Any]] = Field(
|
|
default_factory=list,
|
|
description=(
|
|
"Structured revision findings — each {file?, line?, severity "
|
|
"(blocker|major|minor|nit), expected, actual, fix?, evidence?}. "
|
|
"At least one of findings/issues is required."
|
|
),
|
|
)
|
|
|
|
|
|
class ClaimDocTaskRequest(BaseModel):
|
|
task_id: UUID
|
|
|
|
|
|
class IDocumentedRequest(BaseModel):
|
|
task_id: UUID
|
|
notes: str = Field(..., min_length=1)
|
|
files: StrList = Field(..., min_length=1)
|
|
|
|
|
|
class TriageRequest(BaseModel):
|
|
"""Empty request body."""
|
|
|
|
|
|
class WaiveFindingRequest(BaseModel):
|
|
finding_id: UUID
|
|
note: str = Field(
|
|
...,
|
|
min_length=1,
|
|
description=(
|
|
"Why this finding is waived rather than fixed — recorded on the "
|
|
"ledger row and in audit. Only minor/nit findings are waivable."
|
|
),
|
|
)
|
|
|
|
|
|
class UnblockRequest(BaseModel):
|
|
task_id: UUID
|
|
reason: str = Field(
|
|
...,
|
|
min_length=10,
|
|
description=(
|
|
"Why the block is cleared — recorded as the PM's journal:decision "
|
|
"so no separate note(scope='decision') call is needed."
|
|
),
|
|
)
|
|
restore: bool = True
|
|
|
|
|
|
class CompleteRequest(BaseModel):
|
|
task_id: UUID
|
|
notes: str = Field(..., min_length=1)
|
|
|
|
|
|
class RequestChangesRequest(BaseModel):
|
|
task_id: UUID
|
|
# Free text — deprecated, one release. Shimmed into file-less findings.
|
|
issues: StrList = Field(default_factory=list)
|
|
findings: list[dict[str, Any]] = Field(
|
|
default_factory=list,
|
|
description=(
|
|
"Structured revision findings — each {file?, line?, severity "
|
|
"(blocker|major|minor|nit), expected, actual, fix?, evidence?}. "
|
|
"At least one of findings/issues is required."
|
|
),
|
|
)
|
|
|
|
|
|
class EscalateUpRequest(BaseModel):
|
|
task_id: UUID
|
|
reason: str = Field(..., min_length=1)
|
|
|
|
|
|
class EscalateToCeoRequest(BaseModel):
|
|
task_id: UUID
|
|
reason: str = Field(..., min_length=1)
|
|
|
|
|
|
class IWillPlanRequest(BaseModel):
|
|
task_id: UUID
|
|
plan: str = Field(..., min_length=1, max_length=2000)
|
|
# Pre-gateway parity: Approach is REQUIRED — agents
|
|
# could not transition claimed → in_progress without filling this in the
|
|
# pre-gateway flow. The Plan tab depends on it; smoke run 3 confirmed
|
|
# the empty default lets agents through with thin plans.
|
|
# min_length must match choreographer._impl._PM_APPROACH_MIN_LEN. Raised
|
|
# 20→150: a 20-char approach was a one-liner; the approach +
|
|
# sub_tasks are also the progress checklist, so they must be substantive.
|
|
# max_length caps the bloat defect (approach that ran to thousands of
|
|
# chars restating the description). Must match the gate ceiling.
|
|
approach: str = Field(..., min_length=150, max_length=800)
|
|
sub_tasks: list[SubTaskCreate] = Field(
|
|
default_factory=list,
|
|
description="List of {title, description} — server assigns id + order",
|
|
)
|
|
technical_considerations: StrList = Field(default_factory=list)
|
|
risks: list[RiskCreate] = Field(default_factory=list)
|
|
open_questions: list[OpenQuestionCreate] = Field(default_factory=list)
|
|
|
|
|
|
class DelegateRequest(BaseModel):
|
|
"""HTTP body for cell_pm + main_pm `delegate` verbs.
|
|
|
|
Mirrors :data:`roboco.foundation.policy.task_completeness.TASK_AT_CREATE`
|
|
so under-filled payloads fail at the request boundary with a 422 — no
|
|
silent defaults, no "code"/"medium" fallbacks. Each constraint matches
|
|
the hint string returned by the foundation policy.
|
|
"""
|
|
|
|
parent_task_id: UUID
|
|
title: str = Field(..., min_length=1, max_length=200)
|
|
# 20-char minimum mirrors TASK_AT_CREATE.description (MIN_LENGTH=20).
|
|
# Forces a real one-line summary instead of "x" or "see title".
|
|
description: str = Field(..., min_length=20)
|
|
assigned_to: str = Field(..., min_length=1)
|
|
team: str = Field(..., min_length=1)
|
|
# task_type, nature, estimated_complexity are EXPLICITLY_DECLARED in
|
|
# TASK_AT_CREATE. The 2026-05-08 trace showed agents omitting task_type
|
|
# and the old default of 'code' deadlocking the lifecycle; the same
|
|
# silent-default trap exists for nature ("technical") and complexity
|
|
# ("medium"). Force callers to declare intent.
|
|
task_type: str = Field(..., min_length=1)
|
|
nature: str = Field(..., min_length=1)
|
|
estimated_complexity: Complexity
|
|
# acceptance_criteria is required and non-empty; downstream policy
|
|
# also denylist-checks each item against placeholder phrases. The
|
|
# per-item cap (<=200 chars) + list cap (<=7) mirror the policy so an
|
|
# over-long / over-count AC list is rejected at the boundary (422), not
|
|
# only by the service-layer completeness check.
|
|
acceptance_criteria: StrList = Field(..., min_length=1, max_length=7)
|
|
|
|
@field_validator("acceptance_criteria")
|
|
@classmethod
|
|
def _ac_items_bounded(cls, v: list[str]) -> list[str]:
|
|
for i, item in enumerate(v):
|
|
if len(item.strip()) > _AC_MAX_ITEM_CHARS:
|
|
raise ValueError(
|
|
f"acceptance_criteria[{i}] is {len(item.strip())} chars "
|
|
f"(max {_AC_MAX_ITEM_CHARS}) — a criterion that long is a "
|
|
"restated description, not a verifiable outcome. Split it."
|
|
)
|
|
return v
|
|
|
|
# Optional per-subtask project override. When omitted, the choreographer
|
|
# resolves the project from the parent's Product map for this cell, then
|
|
# falls back to the parent's project. Plain optional field — no validator.
|
|
project_id: UUID | None = None
|
|
# Parent acceptance-criterion ids this subtask is responsible for. Lets the
|
|
# coverage + roll-up AC gates verify every parent AC is claimed and satisfied.
|
|
covers_parent_criteria: StrList | None = None
|
|
# Dev-task collision surface (the multi-level sequencing model — edge kind
|
|
# 3). The cell PM states what each dev task touches so the choreographer can
|
|
# run SequencingService and wire the dev-task collision DAG (file-overlap
|
|
# serializes, migration-adders chain, shared-surface edits run last).
|
|
# Optional: a delegate without surfaces joins no collision edges (parallel).
|
|
intends_to_touch: StrList | None = None
|
|
adds_migration: bool = False
|
|
touches_shared: bool = False
|
|
# Explicit dependency override (edge the surface rules would miss, e.g. a
|
|
# non-collision ordering the PM knows). Optional; wired verbatim as
|
|
# dependency_ids on the created dev task.
|
|
depends_on: list[UUID] | None = None
|
|
|
|
# Pre-gateway parity: cross-field validators that catch the most common
|
|
# LLM-vs-schema confusions. Pre-gateway lived in
|
|
# roboco/mcp/schemas/__init__.py::TaskCreateInput at 0c3d15a.
|
|
@field_validator("nature", mode="before")
|
|
@classmethod
|
|
def _nature_must_be_known(cls, v: object) -> object:
|
|
"""Reject invented nature values (e.g., 'standard') with the enum hint."""
|
|
if isinstance(v, str) and v.lower() not in {"technical", "non_technical"}:
|
|
raise ValueError(
|
|
f"nature must be one of: technical | non_technical. Got {v!r}. "
|
|
f"This was the 2026-05-11 'standard' regression — drop the "
|
|
f"invented value and use the enum."
|
|
)
|
|
return v
|
|
|
|
@field_validator("task_type", mode="before")
|
|
@classmethod
|
|
def _task_type_must_be_known(cls, v: object) -> object:
|
|
"""Reject invented task_type values with the enum hint."""
|
|
if isinstance(v, str) and v.lower() not in {
|
|
"code",
|
|
"documentation",
|
|
"research",
|
|
"planning",
|
|
"design",
|
|
"administrative",
|
|
}:
|
|
raise ValueError(
|
|
f"task_type must be one of: code | documentation | research | "
|
|
f"planning | design | administrative. Got {v!r}."
|
|
)
|
|
return v
|
|
|
|
|
|
class SubmitUpRequest(BaseModel):
|
|
task_id: UUID
|
|
notes: str = Field(..., min_length=1)
|
|
resolved_findings: list[ResolvedFindingInput] = Field(default_factory=list)
|
|
|
|
|
|
class SubmitRootRequest(BaseModel):
|
|
task_id: UUID
|
|
notes: str = Field(..., min_length=1)
|
|
resolved_findings: list[ResolvedFindingInput] = Field(default_factory=list)
|