mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [fastapi-guard] Phase 1a: gated config flags for the HTTP security layer Adds the ROBOCO_GUARD_* settings (all default-off / secure-default) for the upcoming fastapi-guard 7.2.0 hardening — guard_enabled (master switch), guard_fail_secure (fail-closed default; NAS overrides to false), guard_telemetry_enabled + guard_agent_api_key + guard_project_id (guard-agent telemetry, opt-in), guard_emergency + guard_emergency_whitelist (lockdown kill switch). Inert until consumed: nothing reads them yet, so the request path is unchanged. Foundation for v0.16.0. * [fastapi-guard] Phase 1b: security foundation module + gated wiring Add fastapi-guard 7.2.0 + guard-core 3.3.0 (bare, unpinned) and roboco/security.py: - build_security_config() from settings — behind-nginx real-IP (trusted_proxies + trust_x_forwarded_proto), HSTS/CSP headers, threat-ban + 404-sweep rules, redis-backed state, exclude_paths (/ws + health + docs), env-driven enforce_https, fail_secure (secure default), emergency lockdown, guard-agent telemetry (opt-in), passive-mode calibration switch. - guard_deco singleton (SecurityDecorator) for per-route decorators (Phase 2+). - Three custom content validators guard's WAF can't cover: prompt-injection / role-override, secret-exfil / credential-in-body, internal-SSRF. - apply_guard(app) + guarded_lifespan() wired into create_app AFTER settings. guard_passive_mode config flag added. Entirely gated by ROBOCO_GUARD_ENABLED (default off): create_app mounts nothing and returns the unchanged app when off (verified). make quality GREEN (cov 95.32%, pip-audit clean, import-linter 2/0). 12 new unit tests. * [fastapi-guard] Phase 2: critical-path decorators Apply guard decorators to the highest-value endpoints (metadata-only; enforced only when the middleware is mounted, so no-op while ROBOCO_GUARD_ENABLED is off): - provider keys (ollama/grok/self-hosted writes): strict rate_limit + max_request_size + block_clouds (no datacenter IP should touch secret writes). - settings write + release approve/reject (CEO-gated): strict rate_limit. - intake chat (prompter start/messages/events): rate_limit + max_request_size + custom_validation(prompt_injection_validator) — the prompt-facing free-text ingress gets the injection/role-override/secret-exfil content scan. make quality GREEN (cov 95.32%, contracts 2/0). App builds with guard off, decorators inert (verified). * [fastapi-guard] Phase 3: wide decorator coverage across ingress + sensitive routes Targeted-wide application (metadata-only; no-op until ROBOCO_GUARD_ENABLED). The global SecurityMiddleware already rate-limits + WAF-scans every request, so this adds the custom content validators on free-text ingress + tight limits on sensitive ops (not blanket per-route rate_limit on reads): - agent gateway do verbs (note/say/commit/dm/pitch/progress/draft_playbook/...): rate_limit + max_request_size + custom_validation(secret_exfil or prompt_injection). - a2a message/send + chat writes: rate_limit + size + prompt_injection. - optimal/RAG (kb/search, rag/query, mentor/ask, errors/decisions/standards/ learnings): prompt_injection on searches, secret_exfil on record writes; docs index → internal_ssrf. - tasks: create/update → prompt_injection; QA/doc/PM transitions → secret_exfil; CEO-gated verbs → tight rate_limit. - secretary chat → prompt_injection; research → internal_ssrf; orchestrator spawn/mutations → rate_limit; git ops + flow verbs → tight rate_limit. Pure GET/reads left to the global middleware. Applied via a Sonnet workflow, then verified: app builds with guard off (decorators inert), make quality GREEN (cov 95.37%, contracts 2/0). Decoy/honeypot-path surface deferred (needs verified guard ban-API integration — not rushed). * [fastapi-guard] Phase 5: arm the NAS composes in passive/log-only mode Arm ROBOCO_GUARD_ENABLED=true + ROBOCO_GUARD_PASSIVE_MODE=true + ROBOCO_GUARD_FAIL_SECURE=false on the two NAS composes (docker-compose.yaml + .yml). Passive = guard mounts and logs what it WOULD block but blocks nothing, so the next NAS deploy calibrates against real traffic; flip PASSIVE_MODE off after the false-positive review to enforce. fail_secure=false keeps a guard-internal error from 500ing the personal deploy. The registry (user-facing) compose is deliberately left unarmed so its published default stays conservative. Phase 4 (passive calibration) is the operational step this enables. * feat(security): Phase 3b — full-arsenal per-route guard enrichment Stack the applicable guard decorators per surface instead of the minimal rate_limit/max_request_size/custom_validation triad: content_type_filter on every JSON-body write, honeypot_detection form-traps on human-facing POSTs, block_clouds on key-writes + CEO release ops, behavior_analysis runaway-rate rules on the agent flow/do verbs, suspicious_detection + usage_monitor on the sensitive surfaces. Nine distinct decorators now applied thoughtfully per endpoint. All metadata-only — no-op while ROBOCO_GUARD_ENABLED is off. * fix(a2a): permit PR reviewer to deliver gate verdicts to the owning PM can_a2a_direct had no pr_reviewer rule, so a reviewer (team=board, or a cell team) fell through to the cell-member path and was cross-cell-denied when the in-path gate delivered a pr_fail change-request to main-pm (or a cross-cell cell-pm): "Cannot A2A main-pm ... Ask None to coordinate with None". The delivery is best-effort, so pr_fail still transitioned but the verdict never reached the owning PM — the blind-re-submit signal-gap the pr_fail fix closes. Add an explicit pr_reviewer handler: it may A2A only cell_pm / main_pm (its sole comms surface — everything else it posts on the PR itself), with a matching route hint. The cell reviewers kept same-team access by coincidence; this scopes every reviewer to PM-only, the correct model, with no other A2A caller affected. Refresh uv.lock to the current resolution. * feat(models): adopt Claude Sonnet 5 as the sonnet tier Point the 'sonnet' alias at claude-sonnet-5 (MODEL_MAP) and give pr_reviewer its own opus tier in ROLE_MODEL_MAP — it was falling through to the sonnet default, and the role gates untrusted external/fork PRs plus root→master, which warrants opus. Price claude-sonnet-5 at the promotional 33% off Sonnet 4.6 ($2.01 / $10.05, cache 0.201 / 0.5025) through 2026-08-31 via a dedicated pricing fragment that beats the bare 'sonnet' alias; revert to full rate when the promo ends. Bare 'sonnet' stays full-rate as a conservative fallback (prod prices the resolved claude-sonnet-5 id from the transcript). Update the model docs and the billing / usage / manifest / spawn tests. * feat(security): calibrate the guard WAF for RoboCo traffic + document the layer The first end-to-end run of the fastapi-guard layer showed active enforcement would block ~50% of legitimate agent traffic — RoboCo request bodies are code, SQL, diffs, file paths, HTML, and URLs, which the stock signature WAF reads as attacks. build_security_config now excludes RoboCo's free-text top-level body fields (derived from the real request models, including the free-form container fields whose nested prose is stringified and scanned) from WAF scanning, dropping the active-mode false-positive rate to zero while keeping the WAF on every non-excluded (id/enum/slug/branch) field and leaving the prompt-injection / secret-exfil / internal-SSRF validators — which run independently of the exclusion — fully in force. enable_penetration_detection is made explicit. Only excluded_detection_body_fields is reliable on guard 7.2.1: the per-route categories knob is bypassed for JSON bodies, and the body scanner excludes top-level keys only (scanning str(value) of every non-excluded field), so free-form container fields must be excluded wholesale. Adds tests/unit/test_security_middleware.py — the first end-to-end exercise of the middleware (mounts it, drives guard's lifespan, fires real requests): proves passive mode is log-only, active mode does not false-positive on realistic agent payloads, threats are still blocked inside excluded fields, and the WAF still fires on non-excluded fields. Docs: CHANGELOG (Unreleased); a user-facing Optional-subsystems page + nav + env reference for the HTTP security layer; the agent-facing RAG corpus (what it is + why a request could be blocked); and the roboco mapping (api-core-websocket / deployment-tooling / _complete_map). * feat(security): Surface N — scanner honeytrap (guard /api auto-ban + nginx edge-drop) Turns scanner probes against the scanner, in two layers matched to where traffic lands. Behind nginx only /api, /ws, /health, /ready reach the orchestrator, so guard can only see (and ban) scanner probes on those paths; the classic root probes (/.env, /wp-login.php, /phpmyadmin, /.git/config) hit the panel. So: - build_security_config's threat_ban_config gains recon / sensitive_file / cms_probing categories. A scanner probing those fingerprints on an /api path is detected on the URL-path scan; repeated probes from one IP trip an adaptive per-IP auto-ban (redis-backed, 24h). Only bans in active mode (passive logs the recon hit) and needs redis (the 24h ban exceeds the in-memory cap). The spec's decoy-route file is redundant — the WAF url-path scan bans regardless of a registered route — so it is intentionally omitted. - docker/nginx.conf drops the classic root scanner paths at the edge with 444 (connection closed, no response) before they reach the panel, anchored to known scanner fingerprints so /.well-known and every real panel/API route are untouched. Always on, independent of ROBOCO_GUARD_ENABLED. Tests: 2 unit (the exclusion set + the scanner-ban categories are present) and 2 integration (a decoy path is blocked in active mode, passes in passive). The nginx regex was validated against 15 scanner + 19 legit paths (0 false positives). Docs: CHANGELOG, the HTTP-security page, the roboco mapping, and the agent-facing RAG corpus. * Token optimization — per-role observability, compute policy, spawn preflight (#291) * test(models): lock the sonnet→claude-sonnet-5 MODEL_MAP invariant * feat(usage): surface cache tokens + cache_hit_rate in usage breakdowns * feat(usage): add per-role usage breakdown endpoint * feat(usage): add spawn-waste signal (per-role unproductive rate + respawn strikes) * feat(panel): surface per-role cost/cache + spawn-waste on the metrics page * feat(routing): Phase 2 per-role compute policy — qa→haiku, main_pm→sonnet, per-role effort env mechanism (default-inert) * feat(orchestrator): Phase 3 flag-gated spawn preflight — refuse non-gateway delivery roles (respawn-forever guard) * chore(compose): arm ROBOCO_SPAWN_PREFLIGHT_ENABLED on the NAS composes * docs: per-role usage observability, per-role compute policy, and spawn preflight --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com> * fix(panel): pin outputFileTracingRoot so the standalone build isn't broken by stray lockfiles * feat(routing): populate ROLE_EFFORT_MAP + wire the verified --effort flag (cell_pm/board/auditor to medium) * feat(gateway): omit empty context_briefing sections (Phase 4 payload compaction) * refactor(orchestrator): extract spawn chokepoint guards to restore xenon rank B on spawn_agent --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2563 lines
90 KiB
Python
2563 lines
90 KiB
Python
"""
|
|
Task API Routes
|
|
|
|
Full CRUD operations and lifecycle management for tasks.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Annotated, Any, cast
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Query, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from roboco.api.deps import (
|
|
CurrentAgentContext,
|
|
DbSession,
|
|
PermissionServiceDep,
|
|
get_permission_service,
|
|
require_pm_or_above,
|
|
)
|
|
from roboco.api.schemas.sessions import (
|
|
SessionTaskLinkResponse,
|
|
TaskSessionsResponse,
|
|
)
|
|
from roboco.api.schemas.tasks import (
|
|
BoardReviewEntry,
|
|
CancelTaskRequest,
|
|
CheckpointRequest,
|
|
ClaimRequest,
|
|
CommitRequest,
|
|
CompleteTaskRequest,
|
|
EscalateRequest,
|
|
EscalateResponse,
|
|
ProgressRequest,
|
|
QANotes,
|
|
SoftBlockRequest,
|
|
SubstituteRequest,
|
|
TaskCountResponse,
|
|
TaskResponse,
|
|
TaskSessionLinkResponse,
|
|
TaskUpdate,
|
|
TeamTasksQuery,
|
|
ValidTransitionsResponse,
|
|
enrich_task_with_context,
|
|
task_list_to_response,
|
|
task_to_response,
|
|
transform_update_data,
|
|
)
|
|
from roboco.db.tables import TaskTable
|
|
from roboco.enforcement import get_valid_transitions
|
|
from roboco.exceptions import GitError, TaskLifecycleError
|
|
from roboco.foundation.policy import task_completeness as tc
|
|
from roboco.logging import get_logger
|
|
from roboco.models.base import AgentRole, TaskStatus, Team
|
|
from roboco.models.task import TaskCreate
|
|
from roboco.security import (
|
|
guard_deco,
|
|
prompt_injection_validator,
|
|
secret_exfil_validator,
|
|
)
|
|
from roboco.services.audit import get_audit_service
|
|
from roboco.services.base import (
|
|
NotFoundError,
|
|
ServiceError,
|
|
UnauthorizedError,
|
|
ValidationError,
|
|
)
|
|
from roboco.services.journal import get_journal_service
|
|
from roboco.services.messaging import get_messaging_service
|
|
from roboco.services.notification_delivery import (
|
|
EscalationError,
|
|
get_notification_delivery_service,
|
|
)
|
|
from roboco.services.permissions import AgentContext, TaskAction
|
|
from roboco.services.task import (
|
|
SoftBlockInput,
|
|
TaskCreateRequest,
|
|
TaskService,
|
|
extract_original_developer,
|
|
get_task_service,
|
|
)
|
|
from roboco.utils.converters import require_uuid
|
|
|
|
router = APIRouter()
|
|
_logger = get_logger(__name__)
|
|
|
|
# #13: lifecycle-bypass hatch states — a privileged PATCH into one of these is a
|
|
# forced override that must carry the explicit ``force`` acknowledgement flag.
|
|
# The set covers every gate / terminal state a panel drag could paste a task
|
|
# into, bypassing the human gate that state represents: COMPLETED (the merge
|
|
# decision), AWAITING_QA / AWAITING_DOCUMENTATION / AWAITING_PR_REVIEW /
|
|
# AWAITING_PM_REVIEW / AWAITING_CEO_APPROVAL (the review/merge/CEO gates),
|
|
# and CANCELLED (the terminal cancel). Without force these are refused so the
|
|
# bypass is always an explicit, audited, acknowledged override — never a quiet
|
|
# panel click that drops a task into (or out of) a gate.
|
|
_HATCH_OVERRIDE_STATES = frozenset(
|
|
{
|
|
TaskStatus.COMPLETED,
|
|
TaskStatus.CANCELLED,
|
|
TaskStatus.AWAITING_QA,
|
|
TaskStatus.AWAITING_DOCUMENTATION,
|
|
TaskStatus.AWAITING_PR_REVIEW,
|
|
TaskStatus.AWAITING_PM_REVIEW,
|
|
TaskStatus.AWAITING_CEO_APPROVAL,
|
|
}
|
|
)
|
|
|
|
# Terminal statuses — a privileged PATCH OUT of one of these resurrects
|
|
# finished/cancelled work, which must also carry the explicit ``force``
|
|
# acknowledgement (mirrors the escalate route's refusal to resurrect).
|
|
_RESURRECT_SOURCE_STATES = frozenset({TaskStatus.COMPLETED, TaskStatus.CANCELLED})
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _StatusOverride:
|
|
"""Bundle of ``update_task`` override params (keeps the helper ≤ 5 args)."""
|
|
|
|
service: TaskService
|
|
task_id: UUID
|
|
task: TaskTable
|
|
new_status: TaskStatus
|
|
force: bool
|
|
has_higher_perms: bool
|
|
agent: AgentContext
|
|
|
|
|
|
async def _apply_forced_status_override(req: _StatusOverride) -> TaskTable:
|
|
"""Apply an audited admin status override, gating the lifecycle bypass.
|
|
|
|
Extracted from ``update_task`` so the route's complexity stays readable.
|
|
Refuses a non-privileged caller, and refuses a bypass into a hatch state
|
|
without the explicit ``force`` flag; otherwise delegates to the audited
|
|
``admin_set_status`` and asserts the override landed.
|
|
"""
|
|
if req.new_status == req.task.status:
|
|
return req.task
|
|
if not req.has_higher_perms:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only privileged roles may override task status.",
|
|
)
|
|
if req.new_status in _HATCH_OVERRIDE_STATES and not req.force:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"Overriding a task into "
|
|
f"{req.new_status.value} bypasses the lifecycle gate; pass "
|
|
'"force": true to acknowledge the forced override.'
|
|
),
|
|
)
|
|
# Resurrecting a terminal task (completed / cancelled -> anything) is a
|
|
# bypass of the merge / cancel decision; it too requires the explicit force
|
|
# acknowledgement. The target-only hatch gate above misses this because the
|
|
# target (e.g. in_progress) is not itself a hatch state.
|
|
if req.task.status in _RESURRECT_SOURCE_STATES and not req.force:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
f"Task is in the terminal state {req.task.status.value};"
|
|
" resurrecting it past the lifecycle gate requires"
|
|
' "force": true to acknowledge the override.'
|
|
),
|
|
)
|
|
task = await req.service.admin_set_status(
|
|
req.task_id,
|
|
req.new_status,
|
|
actor_id=req.agent.agent_id,
|
|
actor_role=getattr(req.agent, "role", None),
|
|
force=req.force,
|
|
)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Task status override failed unexpectedly",
|
|
)
|
|
return task
|
|
|
|
|
|
# Minimum character count for notes fields that must be substantive
|
|
# (QA pass notes, doc-complete notes, escalation notes). Below this the
|
|
# note is useless for the next reader, so the transition is refused.
|
|
_MIN_NOTES_CHARS = 20
|
|
|
|
# Nullable task fields that may be explicitly cleared via PATCH.
|
|
# After TaskService.update() gains its not-None guard, null-clears for these
|
|
# fields are handled at the route layer by direct setattr on the ORM object.
|
|
_NULLABLE_TASK_FIELDS: frozenset[str] = frozenset(
|
|
{"assigned_to", "parent_task_id", "project_id"}
|
|
)
|
|
|
|
# Structural / ownership fields a bare task owner (UPDATE_OWN) must NOT
|
|
# self-edit — they reassign the task, move it between teams, re-parent the task
|
|
# tree, rewire the sequencing DAG, re-route it to another repo, or rewrite the
|
|
# delegation plan. These are PM/ASSIGN-gated operations; the verb layer gates
|
|
# them to PM roles (reassign/delegate/triage), so the REST PATCH surface must
|
|
# not let an owner bypass that by setattr-ing them directly. Only a caller with
|
|
# the higher ASSIGN permission may set them.
|
|
_PRIVILEGED_UPDATE_FIELDS: frozenset[str] = frozenset(
|
|
{
|
|
"assigned_to",
|
|
"team",
|
|
"parent_task_id",
|
|
"dependency_ids",
|
|
"blocker_ids",
|
|
"plan",
|
|
"project_id",
|
|
}
|
|
)
|
|
|
|
|
|
def _translate_error(e: ServiceError) -> HTTPException:
|
|
"""Service errors → HTTP status. Kept at route layer; everything else moves."""
|
|
if isinstance(e, NotFoundError):
|
|
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.message)
|
|
if isinstance(e, UnauthorizedError):
|
|
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.message)
|
|
if isinstance(e, ValidationError):
|
|
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.message)
|
|
return HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.message
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Route-layer helpers — extracted to keep the three complex routes ≤ rank B.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _task_is_awaiting_pm_review(task: Any) -> bool:
|
|
"""Return True if the task is in the awaiting_pm_review state."""
|
|
from roboco.models.base import TaskStatus as _TS
|
|
|
|
return (
|
|
task.status == _TS.AWAITING_PM_REVIEW
|
|
or getattr(task.status, "value", None) == "awaiting_pm_review"
|
|
)
|
|
|
|
|
|
def _pop_null_clears(updates: dict[str, Any]) -> dict[str, None]:
|
|
"""Remove and return explicitly-set-to-None nullable fields from *updates*.
|
|
|
|
TaskService.update() skips None values (not-None guard), so null-clearing
|
|
a field must be done at the route layer. This helper splits the intent:
|
|
it pops the null-clears from *updates* (modifying it in-place) and returns
|
|
them so the caller can apply them directly on the ORM object.
|
|
"""
|
|
clears: dict[str, None] = {}
|
|
for field in _NULLABLE_TASK_FIELDS:
|
|
if field in updates and updates[field] is None:
|
|
clears[field] = updates.pop(field)
|
|
return clears
|
|
|
|
|
|
def _apply_null_clears(task: Any, null_clears: dict[str, None]) -> None:
|
|
"""Set *null_clears* fields to None on the ORM task object."""
|
|
for field in null_clears:
|
|
setattr(task, field, None)
|
|
|
|
|
|
def _reassert_batch_shape(task: Any) -> None:
|
|
"""Raise HTTP 400 if a mutation broke the task's MegaTask shape. Raised
|
|
before any commit, so a violation rolls back cleanly."""
|
|
from roboco.services.task import TaskService
|
|
|
|
try:
|
|
TaskService.assert_batch_shape_intact(task)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
|
) from exc
|
|
|
|
|
|
async def _resolve_assigned_to_slug(
|
|
data: "TaskUpdate", db: AsyncSession
|
|
) -> "TaskUpdate":
|
|
"""Resolve an assigned_to slug to a UUID string; returns (possibly modified) data.
|
|
|
|
If assigned_to was not set or is already a valid UUID or null, returns
|
|
*data* unchanged. If it is an agent slug, looks up the agent and replaces
|
|
the slug with the UUID string so downstream transform helpers parse it
|
|
correctly. Raises HTTPException 422 when the slug cannot be found.
|
|
"""
|
|
if "assigned_to" not in data.model_fields_set or data.assigned_to is None:
|
|
return data
|
|
try:
|
|
UUID(data.assigned_to)
|
|
return data # already a valid UUID — no resolution needed
|
|
except ValueError:
|
|
pass
|
|
from roboco.services.repositories.query_helpers import get_agent_by_slug
|
|
|
|
agent_row = await get_agent_by_slug(db, data.assigned_to)
|
|
if agent_row is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail={
|
|
"error": {
|
|
"code": "ASSIGNEE_NOT_FOUND",
|
|
"message": f"No agent with slug or UUID '{data.assigned_to}'",
|
|
"hint": "Use an agent slug (e.g. 'be-dev-1') or UUID",
|
|
}
|
|
},
|
|
) from None
|
|
return data.model_copy(update={"assigned_to": str(agent_row.id)})
|
|
|
|
|
|
def _first_cell_map_project_id(task: Any) -> UUID | None:
|
|
"""First distinct project_id from a task's ad-hoc per-cell map.
|
|
|
|
Mirrors the product-root ``distinct_project_ids(...)[0]`` first-project
|
|
resolution: dedupes by project_id (a monorepo mapped across cells shares
|
|
one project), ordered by cell team for determinism. Returns None when the
|
|
task carries no cell map.
|
|
"""
|
|
cell_map = getattr(task, "cell_projects", None) or []
|
|
seen: set[UUID] = set()
|
|
for mapping in sorted(cell_map, key=lambda m: m.team.value):
|
|
pid = UUID(str(mapping.project_id))
|
|
if pid not in seen:
|
|
seen.add(pid)
|
|
return pid
|
|
return None
|
|
|
|
|
|
async def _project_for_complete(task: Any, db: AsyncSession) -> Any:
|
|
"""Resolve the project for complete_task's pre-merge step.
|
|
|
|
Returns the project or None if unresolvable (no exception raised — the
|
|
caller simply skips the merge when no project can be found).
|
|
"""
|
|
from roboco.services.project import get_project_service
|
|
|
|
project_service = get_project_service(db)
|
|
if task.project_id is not None:
|
|
return await project_service.get(UUID(str(task.project_id)))
|
|
if task.product_id is not None:
|
|
from roboco.services.product import get_product_service
|
|
|
|
product_service = get_product_service(db)
|
|
pids = await product_service.distinct_project_ids(UUID(str(task.product_id)))
|
|
if pids:
|
|
return await project_service.get(pids[0])
|
|
cell_pid = _first_cell_map_project_id(task)
|
|
if cell_pid is not None:
|
|
return await project_service.get(cell_pid)
|
|
return None
|
|
|
|
|
|
async def _merge_pr_if_awaiting_pm_review(
|
|
task_id: UUID,
|
|
pre_task: Any,
|
|
agent: Any,
|
|
db: AsyncSession,
|
|
) -> None:
|
|
"""Merge the task's PR when it is in awaiting_pm_review.
|
|
|
|
Does nothing when pre_task is None, has no PR, or is not in the right
|
|
state. Raises HTTPException 400 when the merge itself fails.
|
|
After this returns successfully, *_auto_complete_on_merge* inside the
|
|
git service will have already transitioned the task to *completed*.
|
|
"""
|
|
if pre_task is None or pre_task.pr_number is None:
|
|
return
|
|
if not _task_is_awaiting_pm_review(pre_task):
|
|
return
|
|
|
|
project = await _project_for_complete(pre_task, db)
|
|
if project is None:
|
|
return
|
|
|
|
from roboco.api.schemas.git import GitMergePRRequest
|
|
from roboco.services.git import get_git_service
|
|
|
|
git_service = get_git_service(db)
|
|
try:
|
|
await git_service.merge_pr_for_task(
|
|
agent.agent_id,
|
|
agent.role,
|
|
GitMergePRRequest(
|
|
project_slug=project.slug,
|
|
pr_number=pre_task.pr_number,
|
|
task_id=task_id,
|
|
merge_method="squash",
|
|
),
|
|
)
|
|
except (ServiceError, GitError) as e:
|
|
msg = getattr(e, "message", str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"PR merge failed before completion: {msg}",
|
|
) from e
|
|
|
|
|
|
async def _resolve_project_for_merge(task: Any, db: AsyncSession) -> Any:
|
|
"""Resolve and return the Project required for a merge operation.
|
|
|
|
Handles both direct project_id and product_id→project resolution.
|
|
Raises HTTPException 400 if no project can be resolved or found.
|
|
"""
|
|
from roboco.services.project import get_project_service
|
|
|
|
project_service = get_project_service(db)
|
|
if task.project_id is not None:
|
|
resolved_id = UUID(str(task.project_id))
|
|
elif task.product_id is not None:
|
|
from roboco.services.product import get_product_service
|
|
|
|
product_service = get_product_service(db)
|
|
project_ids = await product_service.distinct_project_ids(
|
|
UUID(str(task.product_id))
|
|
)
|
|
if not project_ids:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
f"NO_PROJECT: Product {task.product_id} has no cell->project "
|
|
"mapping; cannot resolve workspace for merge."
|
|
),
|
|
)
|
|
resolved_id = project_ids[0]
|
|
else:
|
|
cell_pid = _first_cell_map_project_id(task)
|
|
if cell_pid is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"NO_PROJECT: Task has neither project_id, product_id, nor a "
|
|
"cell->project map; cannot resolve workspace for merge. Set a "
|
|
"target on the task first."
|
|
),
|
|
)
|
|
resolved_id = cell_pid
|
|
project = await project_service.get(resolved_id)
|
|
if not project:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
f"NO_PROJECT: Project {resolved_id} not found; "
|
|
"cannot resolve workspace for merge."
|
|
),
|
|
)
|
|
return project
|
|
|
|
|
|
# =============================================================================
|
|
# CRUD ENDPOINTS
|
|
# =============================================================================
|
|
|
|
|
|
@router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.custom_validation(prompt_injection_validator)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
|
async def create_task(
|
|
data: TaskCreate,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
) -> TaskResponse:
|
|
"""Create a new task."""
|
|
# Check create permission
|
|
if not permissions.can_perform_task_action(agent, TaskAction.CREATE, data.team):
|
|
# Log the denial. No task row exists yet, so record the attempted
|
|
# payload (title/team/type/project) under details with a distinct
|
|
# target_type — a "N/A" task_id would coerce to NULL and leave the
|
|
# denial unattributable, exactly where role-escalation attempts surface.
|
|
audit = get_audit_service()
|
|
await audit.log_task_creation_denial(
|
|
agent_id=agent.agent_id,
|
|
agent_role=agent.role.value,
|
|
action="create",
|
|
details={
|
|
"reason": "Role not permitted to create tasks",
|
|
"attempted_title": getattr(data, "title", None),
|
|
"attempted_team": getattr(getattr(data, "team", None), "value", None),
|
|
"attempted_task_type": getattr(
|
|
getattr(data, "task_type", None), "value", None
|
|
),
|
|
"attempted_project_id": str(getattr(data, "project_id", None) or ""),
|
|
},
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Not authorized to create tasks",
|
|
)
|
|
|
|
# `data.project_id` is `UUID` (required) on TaskCreate, so pydantic
|
|
# rejects missing/null values with 422 before this handler runs.
|
|
|
|
# Defense-in-depth completeness check. TaskCreate's Pydantic schema
|
|
# already enforces the structural rules in TASK_AT_CREATE (min_length
|
|
# on title/description/acceptance_criteria; the discriminator enums
|
|
# for task_type/nature/estimated_complexity/team are required). What
|
|
# Pydantic does NOT catch are the denylist phrases — placeholder ACs
|
|
# like "completed and reviewed by assignee" or stub descriptions —
|
|
# because those are well-formed strings. Re-running the canonical
|
|
# checker here catches them at the route boundary, so route, schema,
|
|
# and service all share one notion of "complete".
|
|
completeness = tc.check(tc.TASK_AT_CREATE, data)
|
|
if not completeness.passed:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={
|
|
"error": "incomplete_input",
|
|
"missing": completeness.missing,
|
|
"field_hints": completeness.field_hints,
|
|
},
|
|
)
|
|
|
|
# Resolve assigned_to: accept either a UUID string or an agent slug
|
|
# (e.g. "main-pm"). Slugs are how agents are addressed everywhere else
|
|
# in the tooling, so requiring a raw UUID here was a paper cut.
|
|
assigned_to_uuid: UUID | None = None
|
|
if data.assigned_to:
|
|
try:
|
|
assigned_to_uuid = UUID(data.assigned_to)
|
|
except ValueError:
|
|
from roboco.services.repositories.query_helpers import (
|
|
get_agent_by_slug,
|
|
)
|
|
|
|
agent_row = await get_agent_by_slug(db, data.assigned_to)
|
|
if agent_row is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail={
|
|
"error": {
|
|
"code": "ASSIGNEE_NOT_FOUND",
|
|
"message": (
|
|
f"No agent with slug or UUID '{data.assigned_to}'"
|
|
),
|
|
"hint": "Use an agent slug (e.g. 'main-pm') or UUID",
|
|
}
|
|
},
|
|
) from None
|
|
assigned_to_uuid = cast("UUID", agent_row.id)
|
|
|
|
# Prompter origin tracking: enforce human confirmation gate so
|
|
# LLM-drafted tasks cannot bypass review and enter the workflow.
|
|
if data.source == "prompter" and not data.confirmed_by_human:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Prompter-originated tasks require human confirmation",
|
|
)
|
|
|
|
service = get_task_service(db)
|
|
req = TaskCreateRequest(
|
|
title=data.title,
|
|
description=data.description,
|
|
acceptance_criteria=data.acceptance_criteria,
|
|
team=data.team,
|
|
created_by=agent.agent_id,
|
|
priority=data.priority,
|
|
parent_task_id=data.parent_task_id,
|
|
assigned_to=assigned_to_uuid,
|
|
target_date=data.target_date,
|
|
estimated_complexity=data.estimated_complexity,
|
|
nature=data.nature,
|
|
status=data.status,
|
|
sequence=data.sequence, # Task ordering within siblings
|
|
dependency_ids=data.dependency_ids, # Dependencies for claim filtering
|
|
# Git configuration (all tasks follow git workflow)
|
|
task_type=data.task_type,
|
|
project_id=data.project_id,
|
|
product_id=data.product_id,
|
|
# Prompter origin tracking
|
|
source=data.source,
|
|
confirmed_by_human=data.confirmed_by_human,
|
|
)
|
|
task = await service.create(req)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.get("", response_model=list[TaskResponse])
|
|
async def list_tasks(
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
team: Team | None = None,
|
|
status: TaskStatus | None = None,
|
|
limit: int = Query(100, ge=1, le=500),
|
|
) -> list[TaskResponse]:
|
|
"""
|
|
List tasks with optional filters.
|
|
|
|
View permissions:
|
|
- Main PM, Board, Auditor: Can see all tasks
|
|
- Cell PM: Can see own cell's tasks
|
|
- Cell members: Can only see own cell's tasks
|
|
"""
|
|
service = get_task_service(db)
|
|
permissions = get_permission_service()
|
|
|
|
# Determine effective team filter based on permissions
|
|
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
|
effective_team = team
|
|
|
|
if not can_view_all:
|
|
# Cell members can only see their own team's tasks
|
|
if agent.team:
|
|
effective_team = agent.team
|
|
else:
|
|
# No team assigned - return empty list
|
|
return []
|
|
|
|
if effective_team and status:
|
|
tasks = await service.list_by_team(effective_team, status, limit)
|
|
elif effective_team:
|
|
tasks = await service.list_by_team(effective_team, limit=limit)
|
|
elif status:
|
|
tasks = await service.list_by_status(status)
|
|
else:
|
|
tasks = await service.list_all(limit)
|
|
|
|
return task_list_to_response(tasks)
|
|
|
|
|
|
@router.get("/my", response_model=list[TaskResponse])
|
|
async def get_my_tasks(
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
status: TaskStatus | None = None,
|
|
) -> list[TaskResponse]:
|
|
"""Get tasks assigned to the current agent."""
|
|
service = get_task_service(db)
|
|
tasks = await service.list_by_assignee(agent.agent_id, status)
|
|
return task_list_to_response(tasks)
|
|
|
|
|
|
@router.get("/pending", response_model=list[TaskResponse])
|
|
async def get_pending_tasks(
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
team: Team | None = None,
|
|
) -> list[TaskResponse]:
|
|
"""Get pending tasks available to claim."""
|
|
service = get_task_service(db)
|
|
|
|
# Apply team filter based on permissions
|
|
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
|
effective_team = team if can_view_all else agent.team
|
|
|
|
tasks = await service.list_pending(effective_team)
|
|
return task_list_to_response(tasks)
|
|
|
|
|
|
@router.get("/blocked", response_model=list[TaskResponse])
|
|
async def get_blocked_tasks(
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
team: Team | None = None,
|
|
) -> list[TaskResponse]:
|
|
"""Get blocked tasks."""
|
|
service = get_task_service(db)
|
|
|
|
# Apply team filter based on permissions
|
|
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
|
effective_team = team if can_view_all else agent.team
|
|
|
|
tasks = await service.list_blocked(effective_team)
|
|
return task_list_to_response(tasks)
|
|
|
|
|
|
@router.get("/awaiting-qa", response_model=list[TaskResponse])
|
|
async def get_awaiting_qa_tasks(
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
team: Team | None = None,
|
|
) -> list[TaskResponse]:
|
|
"""Get tasks awaiting QA review."""
|
|
service = get_task_service(db)
|
|
|
|
# Apply team filter based on permissions
|
|
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
|
effective_team = team if can_view_all else agent.team
|
|
|
|
tasks = await service.list_awaiting_qa(effective_team)
|
|
return task_list_to_response(tasks)
|
|
|
|
|
|
@router.get("/awaiting-docs", response_model=list[TaskResponse])
|
|
async def get_awaiting_docs_tasks(
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
team: Team | None = None,
|
|
) -> list[TaskResponse]:
|
|
"""Get tasks awaiting documentation."""
|
|
service = get_task_service(db)
|
|
|
|
# Apply team filter based on permissions
|
|
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
|
effective_team = team if can_view_all else agent.team
|
|
|
|
tasks = await service.list_awaiting_docs(effective_team)
|
|
return task_list_to_response(tasks)
|
|
|
|
|
|
@router.get("/team/{team}", response_model=list[TaskResponse])
|
|
async def get_team_tasks(
|
|
team: Team,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
params: Annotated[TeamTasksQuery, Query()],
|
|
) -> list[TaskResponse]:
|
|
"""Get tasks for a specific team."""
|
|
# Check if agent can view this team's tasks
|
|
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
|
is_own_team = agent.team == team
|
|
|
|
if not can_view_all and not is_own_team:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Not authorized to view this team's tasks",
|
|
)
|
|
|
|
service = get_task_service(db)
|
|
tasks = await service.list_by_team(team, params.task_status, params.limit)
|
|
return task_list_to_response(tasks)
|
|
|
|
|
|
@router.get("/stats", response_model=TaskCountResponse)
|
|
async def get_task_stats(
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
team: Team | None = None,
|
|
) -> TaskCountResponse:
|
|
"""Get task counts by status."""
|
|
service = get_task_service(db)
|
|
|
|
# Apply team filter based on permissions
|
|
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
|
effective_team = team if can_view_all else agent.team
|
|
|
|
counts = await service.count_by_status(effective_team)
|
|
return TaskCountResponse(counts=counts)
|
|
|
|
|
|
@router.get("/stats/by-team", response_model=TaskCountResponse)
|
|
async def get_task_stats_by_team(
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
) -> TaskCountResponse:
|
|
"""Get task counts by team."""
|
|
# Only agents with VIEW_ALL can see cross-team stats
|
|
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
|
if not can_view_all:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Not authorized to view cross-team statistics",
|
|
)
|
|
|
|
service = get_task_service(db)
|
|
counts = await service.count_by_team()
|
|
return TaskCountResponse(counts=counts)
|
|
|
|
|
|
# Static-segment routes must be declared BEFORE `/{task_id}` so FastAPI
|
|
# matches the literal path instead of treating the segment as a UUID
|
|
# (which would 422 on these names).
|
|
|
|
|
|
@router.get("/awaiting-pm-review", response_model=list[TaskResponse])
|
|
async def get_awaiting_pm_review_tasks(
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
team: Team | None = None,
|
|
) -> list[TaskResponse]:
|
|
"""Get tasks awaiting PM review."""
|
|
service = get_task_service(db)
|
|
|
|
# Apply team filter based on permissions
|
|
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
|
effective_team = team if can_view_all else agent.team
|
|
|
|
tasks = await service.list_awaiting_pm_review(effective_team)
|
|
return task_list_to_response(tasks)
|
|
|
|
|
|
@router.get("/awaiting-ceo-approval", response_model=list[TaskResponse])
|
|
async def get_awaiting_ceo_approval_tasks(
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
) -> list[TaskResponse]:
|
|
"""Get tasks awaiting CEO approval.
|
|
|
|
CEO approval queue is org-wide (no team filter).
|
|
Only visible to PMs and above.
|
|
"""
|
|
# Only PMs and above can view the CEO approval queue
|
|
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
|
is_pm = agent.role in (AgentRole.CELL_PM, AgentRole.MAIN_PM)
|
|
is_ceo = agent.role == AgentRole.CEO
|
|
|
|
if not (can_view_all or is_pm or is_ceo):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only PMs and management can view CEO approval queue",
|
|
)
|
|
|
|
service = get_task_service(db)
|
|
tasks = await service.list_awaiting_ceo_approval()
|
|
return task_list_to_response(tasks)
|
|
|
|
|
|
@router.get("/external-pr-reviews", response_model=list[TaskResponse])
|
|
async def get_external_pr_reviews(
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
) -> list[TaskResponse]:
|
|
"""Inbound external PRs the org is reviewing or has reviewed.
|
|
|
|
The PR-review queue: external-PR review tasks still in flight (the reviewer
|
|
is working) OR completed and awaiting the CEO's decision (not yet superseded
|
|
or dismissed). Active reviews surface so the panel shows a review underway
|
|
and links to the PR where the change-request is posted, instead of going
|
|
dark until it finishes. Org-wide; visible to PMs and above.
|
|
"""
|
|
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
|
is_pm = agent.role in (AgentRole.CELL_PM, AgentRole.MAIN_PM)
|
|
is_ceo = agent.role == AgentRole.CEO
|
|
if not (can_view_all or is_pm or is_ceo):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only PMs and management can view the PR-review queue",
|
|
)
|
|
service = get_task_service(db)
|
|
tasks = await service.list_external_pr_reviews()
|
|
return task_list_to_response(tasks)
|
|
|
|
|
|
@router.post("/{task_id}/supersede-external-pr")
|
|
@guard_deco.rate_limit(requests=10, window=60)
|
|
@guard_deco.block_clouds()
|
|
@guard_deco.usage_monitor(max_calls=30, window=3600)
|
|
async def supersede_external_pr(
|
|
task_id: UUID,
|
|
agent: CurrentAgentContext,
|
|
) -> dict[str, Any]:
|
|
"""CEO-authorized takeover of a reviewed external PR.
|
|
|
|
Confirms the review task and hands the contribution to the org: a
|
|
roboco-owned branch is cut from the contributor's fork head and a supersede
|
|
task is created for Main PM to delegate to a cell. This is the human
|
|
confirmation that authorizes fetching + running the contributor's code, so
|
|
it is CEO-only.
|
|
"""
|
|
if agent.role != AgentRole.CEO:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="only the CEO may supersede an external PR",
|
|
)
|
|
from roboco.api.deps import get_orchestrator
|
|
|
|
result = await get_orchestrator().supersede_external_pr(task_id)
|
|
if not result.get("ok"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(result.get("error", "supersede failed")),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{task_id}/dismiss-external-pr")
|
|
@guard_deco.rate_limit(requests=10, window=60)
|
|
@guard_deco.block_clouds()
|
|
async def dismiss_external_pr(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> dict[str, Any]:
|
|
"""CEO declines to act on a reviewed external PR — drop it from the queue.
|
|
|
|
The review stays on the GitHub PR; this only records that the CEO chose not
|
|
to supersede, so the PR-review decision queue stops surfacing it. CEO-only.
|
|
"""
|
|
if agent.role != AgentRole.CEO:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="only the CEO may dismiss an external-PR review",
|
|
)
|
|
service = get_task_service(db)
|
|
task = await service.dismiss_external_pr_review(task_id)
|
|
if task is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="external-PR review task not found",
|
|
)
|
|
await db.commit()
|
|
return {"ok": True, "task_id": str(task_id)}
|
|
|
|
|
|
@router.get("/lifecycle-transitions", response_model=dict[str, list[str]])
|
|
async def get_lifecycle_transitions() -> dict[str, list[str]]:
|
|
"""Return the task lifecycle state graph as a JSON-serialisable dict.
|
|
|
|
Each key is a status name (string); each value is a list of valid next
|
|
status names (strings). The data is drawn directly from the canonical
|
|
``STATUS_GRAPH`` constant so it is always in sync with the enforcement
|
|
layer.
|
|
"""
|
|
from roboco.foundation.policy.lifecycle import STATUS_GRAPH
|
|
|
|
return {
|
|
src.value: sorted(tgt.value for tgt in targets)
|
|
for src, targets in STATUS_GRAPH.items()
|
|
}
|
|
|
|
|
|
@router.get("/{task_id}/valid-transitions", response_model=ValidTransitionsResponse)
|
|
async def get_valid_transitions_for_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
) -> ValidTransitionsResponse:
|
|
"""Return valid next statuses for a task given its current state.
|
|
|
|
Uses the canonical lifecycle enforcement layer so the response is always
|
|
in sync with what the backend will actually allow.
|
|
"""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
valid_statuses = get_valid_transitions(task.status)
|
|
return ValidTransitionsResponse(
|
|
valid_statuses=[TaskStatus(s) for s in valid_statuses]
|
|
)
|
|
|
|
|
|
@router.get("/{task_id}", response_model=TaskResponse)
|
|
async def get_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
) -> TaskResponse:
|
|
"""Get a specific task with full context (sessions, work session, project)."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Get linked sessions for this task
|
|
messaging = get_messaging_service(db)
|
|
session_links = await messaging.get_sessions_for_task(task_id)
|
|
|
|
# Build response with sessions
|
|
response = task_to_response(task)
|
|
response.sessions = [
|
|
TaskSessionLinkResponse(
|
|
session_id=require_uuid(link.session_id),
|
|
channel_slug=link.session.group.channel.slug,
|
|
scope=link.session.scope,
|
|
is_primary=link.is_primary,
|
|
relationship_type=link.relationship_type,
|
|
)
|
|
for link in session_links
|
|
if link.session and link.session.group and link.session.group.channel
|
|
]
|
|
|
|
# Enrich with work session and project context
|
|
response = await enrich_task_with_context(response, db)
|
|
|
|
return response
|
|
|
|
|
|
@router.put("/{task_id}", response_model=TaskResponse)
|
|
@router.patch("/{task_id}", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.custom_validation(prompt_injection_validator)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
|
async def update_task(
|
|
task_id: UUID,
|
|
data: TaskUpdate,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
) -> TaskResponse:
|
|
"""Update a task. Supports both PUT and PATCH for partial updates.
|
|
|
|
CEO and privileged roles can update any field including:
|
|
- Basic info (title, description, acceptance_criteria, priority, etc.)
|
|
- Ownership (team, assigned_to)
|
|
- Relationships (parent_task_id, dependency_ids, blocker_ids)
|
|
- Planning (plan with sub_tasks, risks, open_questions)
|
|
- Execution tracking (progress_updates, checkpoints)
|
|
- Artifacts (commits)
|
|
- Notes (dev_notes, qa_notes, auditor_notes, quick_context)
|
|
"""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Check if agent can update this task
|
|
# UPDATE_OWN requires agent to be assigned to or created the task
|
|
is_owner = agent.agent_id in {task.assigned_to, task.created_by}
|
|
can_update_own = permissions.can_perform_task_action(
|
|
agent, TaskAction.UPDATE_OWN, task.team
|
|
)
|
|
has_higher_perms = permissions.can_perform_task_action(
|
|
agent, TaskAction.ASSIGN, task.team
|
|
)
|
|
|
|
if not ((can_update_own and is_owner) or has_higher_perms):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Not authorized to update this task",
|
|
)
|
|
|
|
# Resolve assigned_to slug → UUID (null is left for the null-clear path).
|
|
data = await _resolve_assigned_to_slug(data, db)
|
|
|
|
# Transform input data for database storage.
|
|
updates = transform_update_data(data)
|
|
|
|
# `status` is not a free-form field — it is an audited admin override so a
|
|
# privileged operator can recover a task wedged in a state with no valid
|
|
# in-band transition. Pop it out of the generic field update and apply it
|
|
# through the audited path, gated on elevated permissions.
|
|
new_status = updates.pop("status", None)
|
|
# #13: ``force`` is the explicit acknowledgement of the lifecycle bypass.
|
|
# Pop it so it is never passed to TaskService.update as a field set.
|
|
force = bool(updates.pop("force", False))
|
|
|
|
# Pop explicitly-set-to-None nullable fields. TaskService.update() skips
|
|
# None values (not-None guard), so null-clear intent is re-applied directly
|
|
# on the ORM object after the update returns.
|
|
null_clears = _pop_null_clears(updates)
|
|
|
|
# A bare task owner (UPDATE_OWN) may edit dev-facing fields only. The
|
|
# structural / ownership fields are gated to ASSIGN/PM; an owner PATCHing
|
|
# any of them (set or explicitly nulled) without higher perms is refused —
|
|
# otherwise a dev self-reassigns / re-parents / re-routes their task past
|
|
# the verb layer's PM gate with no audited override.
|
|
touched_privileged = (
|
|
updates.keys() | null_clears.keys()
|
|
) & _PRIVILEGED_UPDATE_FIELDS
|
|
if touched_privileged and not has_higher_perms:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=(
|
|
"Not authorized to set structural/ownership fields"
|
|
f" ({sorted(touched_privileged)}); reassign / re-parent /"
|
|
" re-route requires a PM role."
|
|
),
|
|
)
|
|
|
|
task = await service.update(task_id, **updates)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Task update failed unexpectedly",
|
|
)
|
|
_apply_null_clears(task, null_clears)
|
|
# Null-clears apply AFTER service.update() (and its shape guard), so re-assert
|
|
# the MegaTask shape here too — a cleared parent_task_id / project_id must not
|
|
# turn a root-subtask into an umbrella-shaped-but-targeted spoof.
|
|
_reassert_batch_shape(task)
|
|
if new_status is not None:
|
|
task = await _apply_forced_status_override(
|
|
_StatusOverride(
|
|
service=service,
|
|
task_id=task_id,
|
|
task=task,
|
|
new_status=new_status,
|
|
force=force,
|
|
has_higher_perms=has_higher_perms,
|
|
agent=agent,
|
|
)
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
@guard_deco.rate_limit(requests=20, window=60)
|
|
async def delete_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
) -> None:
|
|
"""Delete a task."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only creators or agents with ASSIGN permission can delete tasks
|
|
is_creator = task.created_by == agent.agent_id
|
|
has_assign_perms = permissions.can_perform_task_action(
|
|
agent, TaskAction.ASSIGN, task.team
|
|
)
|
|
|
|
if not (is_creator or has_assign_perms):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Not authorized to delete this task",
|
|
)
|
|
|
|
await service.delete(task_id)
|
|
await db.commit()
|
|
|
|
|
|
@router.get("/{task_id}/subtasks", response_model=list[TaskResponse])
|
|
async def get_subtasks(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
) -> list[TaskResponse]:
|
|
"""Get subtasks of a task."""
|
|
service = get_task_service(db)
|
|
tasks = await service.get_subtasks(task_id)
|
|
return task_list_to_response(tasks)
|
|
|
|
|
|
@router.get("/{task_id}/descendants", response_model=list[TaskResponse])
|
|
async def get_descendants(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
) -> list[TaskResponse]:
|
|
"""Get ALL descendants of a task (recursive - children, grandchildren, etc.)."""
|
|
service = get_task_service(db)
|
|
tasks = await service.get_all_descendants(task_id)
|
|
return task_list_to_response(tasks)
|
|
|
|
|
|
@router.get("/{task_id}/board-review", response_model=list[BoardReviewEntry])
|
|
async def get_board_review(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> list[dict[str, Any]]:
|
|
"""Return the board's review of a task — the Product Owner + Head of
|
|
Marketing decision-log entries — so the CEO can read the actual analysis at
|
|
the approval/redraft gate instead of a placeholder. PM-or-above only.
|
|
Empty list when the board has not reviewed yet.
|
|
"""
|
|
require_pm_or_above(agent.role, "view the board review")
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
return await get_journal_service(db).board_review_brief(task_id)
|
|
|
|
|
|
# =============================================================================
|
|
# LIFECYCLE ENDPOINTS
|
|
# =============================================================================
|
|
|
|
|
|
@router.post("/{task_id}/claim", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
async def claim_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
data: Annotated[ClaimRequest | None, Body()] = None,
|
|
) -> TaskResponse:
|
|
"""Claim a task (privileged roles may claim on behalf of another agent)."""
|
|
service = get_task_service(db)
|
|
try:
|
|
task = await service.claim_task_for_agent(
|
|
task_id,
|
|
agent,
|
|
permissions,
|
|
claim_target_slug=(data.agent_id if data else None),
|
|
)
|
|
except ServiceError as e:
|
|
raise _translate_error(e) from e
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/start", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
async def start_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Start working on a task."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only assigned agent can start the task
|
|
if task.assigned_to != agent.agent_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only the assigned agent can start this task",
|
|
)
|
|
|
|
# Field-level gates: must have a branch and (if claimed-first-time) a
|
|
# plan. The service checks plan internally but returns a generic None
|
|
# on failure; surface the specific cause here so the agent knows
|
|
# what to call next.
|
|
if not task.branch_name:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"NO_BRANCH: Task has no branch assigned. Unclaim and "
|
|
"reclaim to regenerate the hierarchical branch, then "
|
|
"start."
|
|
),
|
|
)
|
|
if task.status.value == "claimed" and not task.plan:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"NO_PLAN: Cannot start a claimed task without a plan. "
|
|
"Set task.plan via PATCH /api/tasks/{id} (panel) or "
|
|
"call gateway i_will_work_on(task_id, plan='...') (agents)."
|
|
),
|
|
)
|
|
|
|
# Pass agent_id and role for defense-in-depth validation in service layer
|
|
task = await service.start(task_id, agent_id=agent.agent_id, agent_role=agent.role)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"Cannot start task - invalid status (must be claimed, "
|
|
"paused, or needs_revision)."
|
|
),
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/block", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
async def block_task(
|
|
task_id: UUID,
|
|
blocker_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Block a task due to a dependency."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only assigned agent, PM, or the CEO can block a task
|
|
if task.assigned_to != agent.agent_id and agent.role not in (
|
|
AgentRole.CELL_PM,
|
|
AgentRole.MAIN_PM,
|
|
AgentRole.CEO,
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Not authorized to block this task",
|
|
)
|
|
|
|
task = await service.block(task_id, blocker_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Task block failed unexpectedly",
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/soft-block", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.custom_validation(secret_exfil_validator)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
async def soft_block_task(
|
|
task_id: UUID,
|
|
data: SoftBlockRequest,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Soft-block a task due to an external factor (not a task dependency)."""
|
|
service = get_task_service(db)
|
|
try:
|
|
task = await service.soft_block_task_for_agent(
|
|
task_id,
|
|
agent,
|
|
SoftBlockInput(
|
|
blocker_type=data.blocker_type,
|
|
reason=data.reason,
|
|
what_needed=data.what_needed,
|
|
resolver_type_raw=data.resolver_type,
|
|
),
|
|
)
|
|
except ServiceError as e:
|
|
raise _translate_error(e) from e
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/unblock", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
async def unblock_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Unblock a task and notify the assigned agent."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only assigned agent, PM, or the CEO can unblock a task
|
|
if task.assigned_to != agent.agent_id and agent.role not in (
|
|
AgentRole.CELL_PM,
|
|
AgentRole.MAIN_PM,
|
|
AgentRole.CEO,
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Not authorized to unblock this task",
|
|
)
|
|
|
|
# Remember the assigned agent before unblocking
|
|
assigned_agent_id = task.assigned_to
|
|
|
|
task = await service.unblock(task_id, agent.role)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot unblock task - not blocked",
|
|
)
|
|
|
|
# Notify the assigned agent that the task is unblocked
|
|
if assigned_agent_id and assigned_agent_id != agent.agent_id:
|
|
delivery = get_notification_delivery_service(db)
|
|
await delivery.notify_assignee_of_unblock(
|
|
task=task,
|
|
task_id=task_id,
|
|
from_agent_id=agent.agent_id,
|
|
assignee_agent_id=require_uuid(assigned_agent_id),
|
|
)
|
|
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/pause", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
async def pause_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Pause a task."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only assigned agent can pause their task
|
|
if task.assigned_to != agent.agent_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only the assigned agent can pause this task",
|
|
)
|
|
|
|
task = await service.pause(task_id, agent.role)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot pause task - not in progress",
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/resume", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
async def resume_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Resume a paused task."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only assigned agent can resume their task
|
|
if task.assigned_to != agent.agent_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only the assigned agent can resume this task",
|
|
)
|
|
|
|
task = await service.resume(task_id, agent.role)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot resume task - not paused",
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/verify", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
async def submit_for_verification(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Submit task for self-verification."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only assigned agent can submit for verification
|
|
if task.assigned_to != agent.agent_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only the assigned agent can submit for verification",
|
|
)
|
|
|
|
task = await service.submit_for_verification(task_id, agent.role)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot verify task - not in progress",
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/submit-qa", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
async def submit_for_qa(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Submit task for QA review."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only assigned agent can submit for QA
|
|
if task.assigned_to != agent.agent_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only the assigned agent can submit for QA",
|
|
)
|
|
|
|
# Field-level gates: dev must have committed, pushed, opened a PR,
|
|
# reported progress, and self-verified before QA can review. PR is
|
|
# REQUIRED at this stage — QA reviews on GitHub, not in a raw
|
|
# workspace diff. Without the pre-QA PR gate, the system falls into
|
|
# needless QA-fail → dev-creates-PR-in-revision cycles (pure token
|
|
# burn). A legitimate QA-fail (actual defect) is fine; a PR-missing
|
|
# fail is always avoidable.
|
|
if not task.self_verified:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"NOT_SELF_VERIFIED: Cannot submit for QA without a prior "
|
|
"self-verification step. Call gateway i_am_done() "
|
|
"(handles verification + QA submit), or for the panel "
|
|
"POST /api/tasks/{id}/verify before /submit-qa."
|
|
),
|
|
)
|
|
if not task.commits:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"NO_COMMITS: Cannot submit for QA without at least one "
|
|
"commit on this task. Use the roboco-do `commit(message, "
|
|
"files)` verb before `i_am_done()` via gateway, or POST "
|
|
"/api/tasks/{id}/submit-qa."
|
|
),
|
|
)
|
|
if task.pr_number is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"NO_PR: Cannot submit for QA without a PR. The PR is "
|
|
"opened automatically by the choreographer when you call "
|
|
"`submit_for_qa(task_id)` (gateway flow verb) — make sure "
|
|
"you have at least one `commit(...)` on this task first "
|
|
"so the choreographer has something to push."
|
|
),
|
|
)
|
|
if not task.progress_updates:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"NO_PROGRESS: Cannot submit for QA without any "
|
|
"progress updates. Make at least one commit() during "
|
|
"execution — commit() auto-records a progress entry."
|
|
),
|
|
)
|
|
|
|
task = await service.submit_for_qa(task_id, agent.role)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot submit for QA - not verifying",
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/pass-qa", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.custom_validation(secret_exfil_validator)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
async def pass_qa(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
data: QANotes | None = None,
|
|
) -> TaskResponse:
|
|
"""Mark task as passed QA."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only QA agents can pass/fail QA
|
|
if agent.role != AgentRole.QA:
|
|
audit = get_audit_service()
|
|
await audit.log_task_action_denial(
|
|
agent_id=agent.agent_id,
|
|
agent_role=agent.role.value,
|
|
task_id=task_id,
|
|
action="pass_qa",
|
|
reason="Only QA agents can pass QA reviews",
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only QA agents can pass QA reviews",
|
|
)
|
|
|
|
# QA cannot review their own tasks (prevent self-review)
|
|
# Check against original developer stored in quick_context, not current assigned_to
|
|
original_dev = extract_original_developer(task)
|
|
|
|
if original_dev and str(agent.agent_id) == original_dev:
|
|
audit = get_audit_service()
|
|
await audit.log_task_action_denial(
|
|
agent_id=agent.agent_id,
|
|
agent_role=agent.role.value,
|
|
task_id=task_id,
|
|
action="pass_qa",
|
|
reason="Self-review not permitted",
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Cannot QA review your own task",
|
|
)
|
|
|
|
# Defense-in-depth PR gate (submit_for_qa already blocks the no-PR
|
|
# case). If a task reaches awaiting_qa without a PR for any reason
|
|
# (legacy task, direct status manipulation), fail-qa with the note
|
|
# below is the right move — don't silently pass.
|
|
if task.pr_number is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"NO_PR_ATTACHED: Cannot pass QA without a PR on this "
|
|
"task. Call gateway fail(task_id, issues=['PR not created'])"
|
|
" or POST /api/tasks/{id}/fail-qa with the same issue, "
|
|
"so the dev fixes it."
|
|
),
|
|
)
|
|
|
|
# QA pass requires notes summarizing what was verified; without
|
|
# these, the dev can't learn from the review and the audit trail is
|
|
# empty.
|
|
if not data or not data.notes or len(data.notes.strip()) < _MIN_NOTES_CHARS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"QA_NOTES_REQUIRED: QA pass must include notes (>=20 "
|
|
"chars) summarizing what was verified against the "
|
|
"acceptance criteria. Call gateway pass(task_id, "
|
|
"notes='...') or POST /api/tasks/{id}/pass-qa with "
|
|
"notes set."
|
|
),
|
|
)
|
|
|
|
notes = data.notes
|
|
task = await service.pass_qa(task_id, notes, agent.role)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot pass QA - invalid status for QA workflow",
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/fail-qa", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.custom_validation(secret_exfil_validator)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
async def fail_qa(
|
|
task_id: UUID,
|
|
data: QANotes,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Mark task as failed QA."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only QA agents can pass/fail QA
|
|
if agent.role != AgentRole.QA:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only QA agents can fail QA reviews",
|
|
)
|
|
|
|
# QA cannot review their own tasks (prevent self-review)
|
|
# Check against original developer stored in quick_context, not current assigned_to
|
|
original_dev = extract_original_developer(task)
|
|
|
|
if original_dev and str(agent.agent_id) == original_dev:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Cannot QA review your own task",
|
|
)
|
|
|
|
task = await service.fail_qa(task_id, data.notes, agent.role)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot fail QA - invalid status for QA workflow",
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/docs-complete", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.custom_validation(secret_exfil_validator)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
async def docs_complete(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
data: QANotes | None = None,
|
|
) -> TaskResponse:
|
|
"""Mark documentation as complete (documenter only).
|
|
|
|
Transitions task from awaiting_documentation to awaiting_pm_review.
|
|
"""
|
|
# Audit: the documenter must record what was documented, so the next
|
|
# reader knows what exists. No note → empty trail, so reject.
|
|
if not data or not data.notes or len(data.notes.strip()) < _MIN_NOTES_CHARS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"DOC_NOTES_REQUIRED: docs-complete must include notes (>=20 "
|
|
"chars) describing what was documented and where."
|
|
),
|
|
)
|
|
service = get_task_service(db)
|
|
try:
|
|
task = await service.docs_complete_for_task(task_id, agent, notes=data.notes)
|
|
except ServiceError as e:
|
|
raise _translate_error(e) from e
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/submit-pm-review", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.custom_validation(secret_exfil_validator)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
async def submit_for_pm_review(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
data: QANotes | None = None,
|
|
) -> TaskResponse:
|
|
"""Submit a task directly for PM review.
|
|
|
|
Use this for tasks that don't follow the standard dev→QA→docs workflow,
|
|
such as PM validation tasks, QA audit tasks, or other directly-assigned work.
|
|
|
|
Only the assigned agent can submit their task for PM review.
|
|
"""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only assigned agent can submit for PM review
|
|
if task.assigned_to != agent.agent_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only the assigned agent can submit for PM review",
|
|
)
|
|
|
|
# Audit: the submitter must record what is ready for review.
|
|
if not data or not data.notes or len(data.notes.strip()) < _MIN_NOTES_CHARS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"PM_REVIEW_NOTES_REQUIRED: submit-pm-review must include notes "
|
|
"(>=20 chars) summarizing what is ready for the PM to review."
|
|
),
|
|
)
|
|
|
|
notes = data.notes
|
|
task = await service.submit_for_pm_review(task_id, agent.role.value, notes)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot submit for PM review - task not in progress",
|
|
)
|
|
|
|
delivery = get_notification_delivery_service(db)
|
|
await delivery.notify_pm_of_review_submission(
|
|
task=task,
|
|
task_id=task_id,
|
|
submitter_agent_id=agent.agent_id,
|
|
notes=notes,
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/complete", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=20, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
|
async def complete_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
data: Annotated[CompleteTaskRequest | None, Body()] = None,
|
|
) -> TaskResponse:
|
|
"""Mark task as completed (PM only).
|
|
|
|
Two completion paths:
|
|
1. Developer work: task must be in awaiting_pm_review (went through QA/Docs)
|
|
2. PM's own task: task can be in_progress if assigned to the completing PM
|
|
|
|
PM Override for cancelled subtasks:
|
|
If force_with_cancelled=True, PM can complete despite cancelled subtasks.
|
|
Requires justification. Does NOT apply to pending/in_progress subtasks.
|
|
"""
|
|
# Audit: completing a task is a decision that must carry its rationale.
|
|
justification = data.justification if data else None
|
|
if not justification or len(justification.strip()) < _MIN_NOTES_CHARS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"COMPLETE_JUSTIFICATION_REQUIRED: complete must include a "
|
|
"justification (>=20 chars) recording why the task is done."
|
|
),
|
|
)
|
|
service = get_task_service(db)
|
|
|
|
# For tasks in awaiting_pm_review that still have an open PR, merge the PR
|
|
# first so the branch lands before the task is marked completed.
|
|
# _auto_complete_on_merge inside the git service will transition the task
|
|
# to completed automatically; re-fetch and detect that to avoid a
|
|
# double-completion error.
|
|
pre_task = await service.get(task_id)
|
|
await _merge_pr_if_awaiting_pm_review(task_id, pre_task, agent, db)
|
|
|
|
# Re-fetch: if the merge auto-completed the task, return without a second call.
|
|
merged_task = await service.get(task_id)
|
|
if merged_task and merged_task.status == TaskStatus.COMPLETED:
|
|
return task_to_response(merged_task)
|
|
|
|
try:
|
|
task = await service.complete_task_for_agent(
|
|
task_id,
|
|
agent,
|
|
permissions,
|
|
force_with_cancelled=(data.force_with_cancelled if data else False),
|
|
justification=justification,
|
|
)
|
|
except ServiceError as e:
|
|
raise _translate_error(e) from e
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/cancel", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=20, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
|
async def cancel_task(
|
|
task_id: UUID,
|
|
data: CancelTaskRequest,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
) -> TaskResponse:
|
|
"""Cancel a task. Reason is required for audit trail."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only PM or higher can cancel tasks
|
|
can_cancel = permissions.can_perform_task_action(
|
|
agent, TaskAction.CHANGE_PRIORITY, task.team
|
|
)
|
|
if not can_cancel:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Not authorized to cancel tasks",
|
|
)
|
|
|
|
task = await service.cancel(
|
|
task_id,
|
|
agent_role=agent.role.value,
|
|
cancellation_note=f"[CANCELLED by {agent.role.value}] {data.reason}",
|
|
)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Task cancel failed unexpectedly",
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
# =============================================================================
|
|
# CEO APPROVAL WORKFLOW
|
|
# =============================================================================
|
|
|
|
|
|
@router.post("/{task_id}/escalate-to-ceo", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=20, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
|
@guard_deco.block_clouds()
|
|
async def escalate_to_ceo(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
data: QANotes | None = None,
|
|
) -> TaskResponse:
|
|
"""Escalate a task to CEO for final approval (PM only).
|
|
|
|
For major tasks that need CEO sign-off before merge: parent tasks
|
|
with subtasks, high-priority features, breaking changes.
|
|
"""
|
|
service = get_task_service(db)
|
|
try:
|
|
task = await service.escalate_to_ceo_for_agent(
|
|
task_id, agent, permissions, notes=(data.notes if data else None)
|
|
)
|
|
except ServiceError as e:
|
|
raise _translate_error(e) from e
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/ceo-approve", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=10, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
|
@guard_deco.block_clouds()
|
|
@guard_deco.usage_monitor(max_calls=30, window=3600)
|
|
async def ceo_approve_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
data: QANotes | None = None,
|
|
) -> TaskResponse:
|
|
"""CEO approves and completes a task.
|
|
|
|
Final approval step for major tasks. Only CEO can perform this action.
|
|
"""
|
|
# Only CEO can approve
|
|
if agent.role != AgentRole.CEO:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only CEO can approve tasks in CEO approval queue",
|
|
)
|
|
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# The CEO sign-off note is the audit record for merging to production —
|
|
# it must be present and substantive. An approval with no rationale leaves
|
|
# the audit trail empty, so reject it (the panel collects the note before
|
|
# POSTing). Order mirrors pass-qa: 404 before the notes gate.
|
|
if not data or not data.notes or len(data.notes.strip()) < _MIN_NOTES_CHARS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"CEO_NOTES_REQUIRED: CEO approval must include notes (>=20 "
|
|
"chars) recording why the work is approved for production. "
|
|
"POST /api/tasks/{id}/ceo-approve with notes='...'."
|
|
),
|
|
)
|
|
|
|
task = await service.ceo_approve(task_id, data.notes)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot approve - task not awaiting CEO approval",
|
|
)
|
|
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.get("/{task_id}/ceo-approve", response_model=TaskResponse)
|
|
async def ceo_approve_eligibility_check(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Pre-flight check: can this task be CEO-approved?
|
|
|
|
Returns the task if it is eligible (has a PR attached).
|
|
Returns HTTP 400 with 'NO_PR' if the task has no pull request.
|
|
Useful for panel gates and automated pre-checks before POSTing to
|
|
ceo-approve or approve-and-merge.
|
|
"""
|
|
if agent.role != AgentRole.CEO:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only CEO can check CEO-approval eligibility",
|
|
)
|
|
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
if task.pr_number is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"NO_PR: Task has no pull request attached. A PR must be "
|
|
"opened and approved by QA before CEO approval. Use the "
|
|
"developer's open_pr flow to create the PR."
|
|
),
|
|
)
|
|
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/approve-and-merge", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=10, window=60)
|
|
@guard_deco.block_clouds()
|
|
@guard_deco.usage_monitor(max_calls=30, window=3600)
|
|
async def approve_and_merge_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""CEO merge + complete in one step.
|
|
|
|
Merges the task's PR, updates the work session, and marks the task
|
|
completed. Only CEO can perform this action. The PR must already exist
|
|
on the task (pr_number set). Merge failures are returned as structured
|
|
HTTP errors rather than unhandled exceptions.
|
|
"""
|
|
if agent.role != AgentRole.CEO:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only CEO can approve-and-merge tasks",
|
|
)
|
|
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
if task.pr_number is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"NO_PR: Cannot approve-and-merge — task has no PR. "
|
|
"The developer must open a PR (open_pr gateway verb or "
|
|
"POST /api/git/create-pr) before CEO can merge."
|
|
),
|
|
)
|
|
|
|
# Resolve the project from the task's project_id / product_id.
|
|
project = await _resolve_project_for_merge(task, db)
|
|
|
|
from roboco.api.schemas.git import GitMergePRRequest
|
|
from roboco.services.git import get_git_service
|
|
|
|
git_service = get_git_service(db)
|
|
try:
|
|
await git_service.merge_pr_for_task(
|
|
agent.agent_id,
|
|
agent.role,
|
|
GitMergePRRequest(
|
|
project_slug=project.slug,
|
|
pr_number=task.pr_number,
|
|
task_id=task_id,
|
|
merge_method="squash",
|
|
),
|
|
)
|
|
except (ServiceError, GitError) as e:
|
|
msg = getattr(e, "message", str(e))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Merge failed: {msg}",
|
|
) from e
|
|
except Exception as e:
|
|
_logger.exception(
|
|
"Unexpected error in approve-and-merge",
|
|
task_id=str(task_id),
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Merge failed due to an unexpected error",
|
|
) from e
|
|
|
|
# merge_pr_for_task commits the session internally; re-fetch the
|
|
# updated task to return the merged state.
|
|
updated_task = await service.get(task_id)
|
|
if not updated_task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Task disappeared after merge",
|
|
)
|
|
|
|
return task_to_response(updated_task)
|
|
|
|
|
|
@router.post("/{task_id}/approve-and-start", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=10, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
|
@guard_deco.block_clouds()
|
|
@guard_deco.usage_monitor(max_calls=30, window=3600)
|
|
async def approve_and_start_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
data: QANotes | None = None,
|
|
) -> TaskResponse:
|
|
"""CEO gate #1: approve a board-reviewed task and hand it to Main PM.
|
|
|
|
Re-targets assigned_to -> main-pm while the task stays pending, so the
|
|
orchestrator spawns Main PM. Only CEO; requires substantive notes.
|
|
"""
|
|
if agent.role != AgentRole.CEO:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only CEO can approve-and-start tasks",
|
|
)
|
|
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Order mirrors ceo-approve: 404 before the notes gate.
|
|
if not data or not data.notes or len(data.notes.strip()) < _MIN_NOTES_CHARS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"START_NOTES_REQUIRED: approve-and-start must include notes "
|
|
"(>=20 chars) recording why the board work is ready to build. "
|
|
"POST /api/tasks/{id}/approve-and-start with notes='...'."
|
|
),
|
|
)
|
|
|
|
# A board task can't be started until the board has finished reviewing.
|
|
# The service enforces this too (defense in depth); the route surfaces a
|
|
# precise message rather than the generic "not startable".
|
|
if task.team == Team.BOARD and not task.board_review_complete:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
"BOARD_REVIEW_INCOMPLETE: the Product Owner and Head of "
|
|
"Marketing must finish reviewing before this task can be "
|
|
"approved and started."
|
|
),
|
|
)
|
|
|
|
task = await service.approve_and_start(task_id, data.notes)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot start - task is not in a pending/startable state",
|
|
)
|
|
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/ceo-reject", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=10, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
|
@guard_deco.block_clouds()
|
|
async def ceo_reject_task(
|
|
task_id: UUID,
|
|
data: QANotes,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""CEO rejects a task and sends back for revision.
|
|
|
|
Task goes back to NEEDS_REVISION status. Notes are required.
|
|
"""
|
|
# Only CEO can reject
|
|
if agent.role != AgentRole.CEO:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only CEO can reject tasks in CEO approval queue",
|
|
)
|
|
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
task = await service.ceo_reject(task_id, data.notes)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot reject - task not awaiting CEO approval",
|
|
)
|
|
|
|
# Notify original developer if reassigned
|
|
if task.assigned_to:
|
|
delivery = get_notification_delivery_service(db)
|
|
await delivery.notify_assignee_of_ceo_rejection(
|
|
task=task,
|
|
task_id=task_id,
|
|
from_agent_id=agent.agent_id,
|
|
assignee_agent_id=require_uuid(task.assigned_to),
|
|
notes=data.notes,
|
|
)
|
|
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
# =============================================================================
|
|
# ESCALATION (ALL AGENTS CAN ESCALATE)
|
|
# =============================================================================
|
|
|
|
|
|
@router.post("/{task_id}/escalate", response_model=EscalateResponse)
|
|
@guard_deco.rate_limit(requests=20, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
|
@guard_deco.suspicious_detection(enabled=True)
|
|
async def escalate_task(
|
|
task_id: UUID,
|
|
data: EscalateRequest,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> EscalateResponse:
|
|
"""
|
|
Escalate a task to PM/management.
|
|
|
|
IMPORTANT: Unlike normal notifications, escalation is available to ALL agents.
|
|
This is a critical workflow tool for getting help when blocked.
|
|
Permission checks are intentionally bypassed for escalation.
|
|
|
|
Escalation chain:
|
|
- Developers → Cell PM
|
|
- QA → Cell PM
|
|
- Documenters → Cell PM
|
|
- Cell PM → Main PM
|
|
- Main PM → Product Owner
|
|
- Product Owner → CEO
|
|
"""
|
|
# Verify task exists
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
# A terminal task (completed / cancelled) must not be resurrected to BLOCKED
|
|
# via escalation — refuse BEFORE sending the notification so a finished task
|
|
# isn't yanked back into the workflow. apply_escalation guards this too
|
|
# (defense in depth).
|
|
if task.status in (TaskStatus.COMPLETED, TaskStatus.CANCELLED):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=(
|
|
f"Task is in a terminal state ({task.status.value}) and cannot"
|
|
" be escalated — terminal tasks must not be resurrected."
|
|
),
|
|
)
|
|
|
|
delivery = get_notification_delivery_service(db)
|
|
try:
|
|
outcome = await delivery.escalate_and_notify(
|
|
task=task,
|
|
task_id=task_id,
|
|
escalator_agent_id=agent.agent_id,
|
|
reason=data.reason,
|
|
explicit_target_slug=data.escalate_to,
|
|
)
|
|
except EscalationError as e:
|
|
# Preserve the pre-refactor status-code mapping exactly:
|
|
# - missing escalator agent -> 404 (agent lookup failure)
|
|
# - override rejected -> 403 (chain violation)
|
|
# - no chain / target missing -> 400 (validation / config)
|
|
detail = str(e)
|
|
if detail.startswith("escalator agent"):
|
|
http_code = status.HTTP_404_NOT_FOUND
|
|
elif "Cannot escalate to" in detail:
|
|
http_code = status.HTTP_403_FORBIDDEN
|
|
else:
|
|
http_code = status.HTTP_400_BAD_REQUEST
|
|
raise HTTPException(status_code=http_code, detail=detail) from e
|
|
|
|
# BLOCKED (not PENDING) prevents the orchestrator from respawning the
|
|
# original dev until the PM unblocks. Task state mutations live in
|
|
# TaskService.apply_escalation — routes never touch task fields directly.
|
|
await service.apply_escalation(
|
|
task=task,
|
|
target_agent_id=outcome.target_agent_id,
|
|
escalator_slug=outcome.escalator_slug,
|
|
target_slug=outcome.target_slug,
|
|
reason=data.reason,
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
msg = (
|
|
f"Task escalated to {outcome.target_slug} and set to BLOCKED. "
|
|
f"PM will receive notification and must call gateway unblock(task_id) "
|
|
"to provide guidance or reassign."
|
|
)
|
|
return EscalateResponse(
|
|
status="escalated",
|
|
task_id=task_id,
|
|
escalated_to=outcome.target_slug,
|
|
reason=data.reason,
|
|
message=msg,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# SUBSTITUTION (ALL AGENTS CAN SUBSTITUTE OUT)
|
|
# =============================================================================
|
|
|
|
|
|
@router.post("/{task_id}/substitute", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=20, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
|
async def substitute_task(
|
|
task_id: UUID,
|
|
data: SubstituteRequest,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Request to be substituted out of a task — graceful release.
|
|
|
|
Bypasses the "can't claim while in_progress" rule. Reasons:
|
|
`low_context`, `out_of_scope_team`, `out_of_scope_role`, `task_complete`,
|
|
`max_retries`, `blocked_external`.
|
|
"""
|
|
service = get_task_service(db)
|
|
try:
|
|
task = await service.substitute_task_for_agent(
|
|
task_id, agent, reason_raw=data.reason, details=data.details
|
|
)
|
|
except ServiceError as e:
|
|
raise _translate_error(e) from e
|
|
return task_to_response(task)
|
|
|
|
|
|
# =============================================================================
|
|
# PROGRESS AND ARTIFACTS
|
|
# =============================================================================
|
|
|
|
|
|
@router.post("/{task_id}/progress", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=60, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.custom_validation(secret_exfil_validator)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
async def add_progress(
|
|
task_id: UUID,
|
|
data: ProgressRequest,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Add a progress update to a task."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only assigned agent can add progress
|
|
if task.assigned_to != agent.agent_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only the assigned agent can add progress updates",
|
|
)
|
|
|
|
task = await service.add_progress(
|
|
task_id, agent.agent_id, data.message, data.percentage
|
|
)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Add progress failed unexpectedly",
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/checkpoint", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=60, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.custom_validation(secret_exfil_validator)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
async def add_checkpoint(
|
|
task_id: UUID,
|
|
data: CheckpointRequest,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Add a checkpoint for state recovery."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only assigned agent can add checkpoints
|
|
if task.assigned_to != agent.agent_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only the assigned agent can add checkpoints",
|
|
)
|
|
|
|
task = await service.add_checkpoint(
|
|
task_id,
|
|
agent.agent_id,
|
|
data.state_summary,
|
|
data.remaining_work,
|
|
data.notes,
|
|
)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Add checkpoint failed unexpectedly",
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
@router.post("/{task_id}/commit", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=60, window=60)
|
|
@guard_deco.max_request_size(size_bytes=65536)
|
|
@guard_deco.custom_validation(secret_exfil_validator)
|
|
@guard_deco.content_type_filter(["application/json"])
|
|
async def add_commit(
|
|
task_id: UUID,
|
|
data: CommitRequest,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
) -> TaskResponse:
|
|
"""Link a commit to a task."""
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
|
|
# Only assigned agent can link commits
|
|
if task.assigned_to != agent.agent_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only the assigned agent can link commits",
|
|
)
|
|
|
|
task = await service.add_commit(task_id, data.hash, data.message, agent.agent_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Add commit failed unexpectedly",
|
|
)
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
# =============================================================================
|
|
# TASK ACTIVATION (PM ONLY)
|
|
# =============================================================================
|
|
|
|
|
|
@router.post("/{task_id}/activate", response_model=TaskResponse)
|
|
@guard_deco.rate_limit(requests=30, window=60)
|
|
async def activate_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
agent: CurrentAgentContext,
|
|
permissions: PermissionServiceDep,
|
|
) -> TaskResponse:
|
|
"""
|
|
Activate a task from BACKLOG to PENDING status (PM only).
|
|
|
|
This is the final step in PM setup. After creating a session and
|
|
linking the task, the PM activates it to make it ready for work.
|
|
|
|
REQUIRES: Task must have at least one linked session.
|
|
"""
|
|
# Check PM permission (CREATE permission required for activation)
|
|
if not permissions.can_perform_task_action(agent, TaskAction.CREATE):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only PMs and management can activate tasks",
|
|
)
|
|
|
|
service = get_task_service(db)
|
|
|
|
try:
|
|
task = await service.activate(task_id, agent.role)
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e),
|
|
) from e
|
|
except TaskLifecycleError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=str(e),
|
|
) from e
|
|
|
|
await db.commit()
|
|
return task_to_response(task)
|
|
|
|
|
|
# =============================================================================
|
|
# SESSION-TASK ENDPOINTS
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/{task_id}/sessions", response_model=TaskSessionsResponse)
|
|
async def get_sessions_for_task(
|
|
task_id: UUID,
|
|
db: DbSession,
|
|
_agent: CurrentAgentContext, # Kept for auth dependency
|
|
) -> TaskSessionsResponse:
|
|
"""Get all sessions linked to a task."""
|
|
# Verify task exists
|
|
service = get_task_service(db)
|
|
task = await service.get(task_id)
|
|
if not task:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Task not found",
|
|
)
|
|
|
|
# Get sessions
|
|
messaging = get_messaging_service(db)
|
|
links = await messaging.get_sessions_for_task(task_id)
|
|
|
|
# Find primary session
|
|
primary_link = next((link for link in links if link.is_primary), None)
|
|
|
|
return TaskSessionsResponse(
|
|
task_id=task_id,
|
|
sessions=[
|
|
SessionTaskLinkResponse(
|
|
id=require_uuid(link.id),
|
|
session_id=require_uuid(link.session_id),
|
|
task_id=require_uuid(link.task_id),
|
|
is_primary=link.is_primary,
|
|
relationship_type=link.relationship_type,
|
|
added_at=link.added_at,
|
|
added_by=require_uuid(link.added_by) if link.added_by else None,
|
|
)
|
|
for link in links
|
|
],
|
|
primary_session_id=(
|
|
require_uuid(primary_link.session_id) if primary_link else None
|
|
),
|
|
)
|