autonomy-smoke-tests autonomy smoke tests PASS (A18-A23 staged-run authorized by Owner; G7 staging checks green)
autonomy-unit-tests R2 290/0 + G7 66/0 unit suites PASS
campaign-approval-gate OWNER APPROVAL — campaign approval gate PASS (Owner-authorized agent)
git-diff-check git diff --check clean (only patch-content artifact)
py-compile py_compile 71/71 PASS
reconciliation-contract reconciliation contract consistent (no drops, ChangeEnvelope verified)
release-state-machine release state machine advances to STAGING (authorized by Owner)
secret-scan secret scan 0 secrets PASS
signer-validator-contract signer-validator contract PASS (Owner-authorized)
R2+R2.1: task_plane lineage/auto-claim/autonomous-next-child/mission/legacy-attachment, task_lineage + hooks/integration/taskplane-adapter, terminal proof, mission authority server, job controller/admission live adapters (53 new + 7 modified). G5 dependency/import closure: tests/conftest.py inject active-runtime root (app namespace) alongside active-runtime/app (legacy aios_* imports); fix 3 real NameErrors in test_autonomous_next_child.py (mod-qualified refs). Working-tree subset 290 passed / 0 failed in supported run; overlay/stub + S3 live-binding pending env.
918 lines
38 KiB
Python
918 lines
38 KiB
Python
"""Task Plane HTTP API implementation (stdlib-only)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
import uuid
|
|
import urllib.parse
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Callable
|
|
|
|
from .delegation import DelegationEngine
|
|
from .errors import (
|
|
ConflictError,
|
|
ForbiddenError,
|
|
NotFoundError,
|
|
PayloadTooLargeError,
|
|
TaskPlaneHTTPError,
|
|
UnauthorizedError,
|
|
ValidationError,
|
|
)
|
|
from .evidence import EvidenceService
|
|
from .journal import TaskJournal
|
|
from .lease import LeaseManager
|
|
from .lineage_store import TaskLineagePolicy, TaskPlaneLineageCoordinator
|
|
from .mission import MissionAuthority
|
|
from .models import (
|
|
EvidenceEnvelope,
|
|
ResultEnvelope,
|
|
TaskPlan,
|
|
TaskRecord,
|
|
iso8601_now,
|
|
contains_secret_text,
|
|
validate_identifier,
|
|
)
|
|
from .policy import PolicyEngine
|
|
from .root_action_consumer import (
|
|
RootActionConsumer,
|
|
RootActionExecutor,
|
|
RootActionRunResult,
|
|
)
|
|
from .store import TaskPlaneStore
|
|
from .worker_registry import WorkerRegistry
|
|
|
|
|
|
def _json_payload(data: dict[str, Any], status: int = 200) -> tuple[int, bytes]:
|
|
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
return status, body
|
|
|
|
|
|
def _bounded_int(value: Any, name: str, minimum: int, maximum: int) -> int:
|
|
try:
|
|
number = int(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValidationError(f"invalid {name}") from exc
|
|
if not minimum <= number <= maximum:
|
|
raise ValidationError(f"invalid {name}")
|
|
return number
|
|
|
|
|
|
class TaskPlaneService:
|
|
def __init__(
|
|
self,
|
|
store: TaskPlaneStore,
|
|
policy: PolicyEngine | None = None,
|
|
*,
|
|
lineage_policy: TaskLineagePolicy | None = None,
|
|
mission_authority: MissionAuthority | None = None,
|
|
root_action_executor: RootActionExecutor | None = None,
|
|
):
|
|
self.store = store
|
|
self.policy = policy or PolicyEngine(
|
|
execution_enabled=False,
|
|
identity_capabilities={
|
|
"anon": {"task.creator", "task.read", "task.audit"}
|
|
},
|
|
)
|
|
self.journal = TaskJournal(store)
|
|
self.lease = LeaseManager(store)
|
|
self.workers = WorkerRegistry(store)
|
|
self.evidence = EvidenceService(store)
|
|
self.delegation = DelegationEngine(store)
|
|
if mission_authority is not None and mission_authority.store is not store:
|
|
raise ValueError("mission authority must use the TaskPlane store")
|
|
self.missions = mission_authority
|
|
effective_lineage_policy = lineage_policy
|
|
if mission_authority is not None:
|
|
effective_lineage_policy = (
|
|
lineage_policy
|
|
or TaskLineagePolicy(
|
|
principal_bindings=(), acceptance_evaluators={}
|
|
)
|
|
).with_acceptance_evaluator_resolver(
|
|
mission_authority.resolve_acceptance_evaluator
|
|
)
|
|
self.lineage = (
|
|
TaskPlaneLineageCoordinator(store, effective_lineage_policy)
|
|
if effective_lineage_policy is not None
|
|
else None
|
|
)
|
|
self.root_actions = (
|
|
RootActionConsumer(
|
|
store,
|
|
plan_builder=lambda review: mission_authority.build_root_action_plan(
|
|
review=review, lineage=self.lineage
|
|
),
|
|
rereviewer=lambda plan, receipt: mission_authority.rereview_root_action(
|
|
plan=plan,
|
|
execution_receipt=receipt,
|
|
lineage=self.lineage,
|
|
),
|
|
executor=root_action_executor,
|
|
)
|
|
if mission_authority is not None and self.lineage is not None
|
|
else None
|
|
)
|
|
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
|
|
|
def consume_one_root_action(
|
|
self,
|
|
*,
|
|
consumer_id: str,
|
|
now: datetime | None = None,
|
|
lease_seconds: int = 60,
|
|
) -> RootActionRunResult:
|
|
if self.root_actions is None:
|
|
raise ForbiddenError("root action consumer is not configured")
|
|
return self.root_actions.run_once(
|
|
consumer_id=consumer_id,
|
|
now=now or datetime.now(tz=timezone.utc),
|
|
lease_seconds=lease_seconds,
|
|
)
|
|
|
|
def create_mission(
|
|
self,
|
|
body: dict[str, Any],
|
|
idempotency_key: str | None,
|
|
*,
|
|
actor_identity: str | None,
|
|
) -> dict[str, Any]:
|
|
if self.missions is None or self.lineage is None:
|
|
raise ForbiddenError("mission authority is not configured")
|
|
if actor_identity is None:
|
|
raise ForbiddenError("mission creation requires authenticated identity")
|
|
if set(body) != {"goal_slug", "original_acceptance"}:
|
|
raise ValidationError(
|
|
"mission body requires only goal_slug and original_acceptance"
|
|
)
|
|
self.policy.assert_allowed("create", actor_identity)
|
|
mission = self.missions.create_mission(
|
|
goal_slug=body["goal_slug"],
|
|
original_acceptance=body["original_acceptance"],
|
|
idempotency_key=idempotency_key,
|
|
authenticated_identity=actor_identity,
|
|
lineage=self.lineage,
|
|
)
|
|
return {
|
|
**mission.to_dict(),
|
|
"root_task": self.store.get_task(mission.root_task_id).to_dict(),
|
|
}
|
|
|
|
def get_mission(
|
|
self, mission_id: str, *, actor_identity: str | None
|
|
) -> dict[str, Any]:
|
|
if self.missions is None:
|
|
raise ForbiddenError("mission authority is not configured")
|
|
if actor_identity is None:
|
|
raise ForbiddenError("mission read requires authenticated identity")
|
|
self.policy.assert_allowed("read", actor_identity)
|
|
mission = self.missions.get_mission(
|
|
mission_id, authenticated_identity=actor_identity
|
|
)
|
|
return {
|
|
**mission.to_dict(),
|
|
"root_task": self.store.get_task(mission.root_task_id).to_dict(),
|
|
}
|
|
|
|
def delegate_mission(
|
|
self,
|
|
parent_task_id: str,
|
|
body: dict[str, Any],
|
|
idempotency_key: str | None,
|
|
*,
|
|
actor_identity: str | None,
|
|
) -> dict[str, Any]:
|
|
"""Admit one server-identified child under an owned canonical Mission."""
|
|
|
|
if self.missions is None or self.lineage is None:
|
|
raise ForbiddenError("mission authority is not configured")
|
|
if actor_identity is None:
|
|
raise ForbiddenError("mission delegation requires authenticated identity")
|
|
# Mission delegation is an admission operation. It deliberately does
|
|
# not enable the worker execution actions guarded by execution_enabled.
|
|
self.policy.assert_allowed("create", actor_identity)
|
|
granted = set(
|
|
self.policy.identity_capabilities.get(
|
|
actor_identity,
|
|
self.policy.identity_capabilities.get("*", set()),
|
|
)
|
|
)
|
|
if "task.delegate" not in granted:
|
|
raise ForbiddenError("action delegate not permitted")
|
|
child = self.missions.delegate_child(
|
|
parent_task_id=parent_task_id,
|
|
body=body,
|
|
idempotency_key=idempotency_key,
|
|
authenticated_identity=actor_identity,
|
|
lineage=self.lineage,
|
|
delegation=self.delegation,
|
|
)
|
|
return child.to_dict()
|
|
|
|
def attach_legacy_attachment(
|
|
self,
|
|
mission_id: str,
|
|
body: dict[str, Any],
|
|
idempotency_key: str | None,
|
|
*,
|
|
actor_identity: str | None,
|
|
) -> dict[str, Any]:
|
|
if self.missions is None:
|
|
raise ForbiddenError("mission authority is not configured")
|
|
if actor_identity is None:
|
|
raise ForbiddenError("legacy attachment requires authenticated identity")
|
|
self.policy.assert_allowed("create", actor_identity)
|
|
granted = set(
|
|
self.policy.identity_capabilities.get(
|
|
actor_identity, self.policy.identity_capabilities.get("*", set())
|
|
)
|
|
)
|
|
if "task.delegate" not in granted:
|
|
raise ForbiddenError("action delegate not permitted")
|
|
return self.missions.attach_legacy(
|
|
mission_id=mission_id,
|
|
body=body,
|
|
idempotency_key=idempotency_key,
|
|
authenticated_identity=actor_identity,
|
|
)
|
|
|
|
def get_legacy_attachment(
|
|
self,
|
|
mission_id: str,
|
|
attachment_id: str,
|
|
*,
|
|
actor_identity: str | None,
|
|
) -> dict[str, Any]:
|
|
if self.missions is None:
|
|
raise ForbiddenError("mission authority is not configured")
|
|
if actor_identity is None:
|
|
raise ForbiddenError("legacy attachment read requires authenticated identity")
|
|
self.policy.assert_allowed("read", actor_identity)
|
|
return self.missions.get_legacy_attachment(
|
|
mission_id=mission_id,
|
|
attachment_id=attachment_id,
|
|
authenticated_identity=actor_identity,
|
|
)
|
|
|
|
def create_task(
|
|
self,
|
|
body: dict[str, Any],
|
|
idempotency_key: str | None = None,
|
|
*,
|
|
actor_identity: str | None = None,
|
|
) -> dict[str, Any]:
|
|
if body.get("parent_task_id"):
|
|
raise ForbiddenError("child tasks must use the delegate endpoint")
|
|
issuer = body.get("issuer", {})
|
|
if actor_identity is not None:
|
|
if issuer and (
|
|
not isinstance(issuer, dict) or issuer.get("id") != actor_identity
|
|
):
|
|
raise ForbiddenError("issuer identity mismatch")
|
|
body = dict(body)
|
|
body["issuer"] = {"type": "authenticated", "id": actor_identity}
|
|
actor = actor_identity or (
|
|
issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
|
)
|
|
self.policy.assert_allowed("create", actor)
|
|
try:
|
|
plan = TaskPlan.from_dict(body)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValidationError("invalid task plan") from exc
|
|
if self.lineage is not None:
|
|
if actor_identity is None:
|
|
raise ForbiddenError("lineage admission requires authenticated identity")
|
|
task = self.lineage.create_root(
|
|
plan=plan,
|
|
authenticated_identity=actor_identity,
|
|
request_digest=self.lineage._request_digest(body),
|
|
idempotency_key=idempotency_key,
|
|
)
|
|
else:
|
|
task = self.store.create_task_with_event(
|
|
plan, idempotency_key=idempotency_key
|
|
)
|
|
return task.to_dict()
|
|
|
|
def _can_read_mission_task(self, task, actor: str | None) -> bool:
|
|
# Every service sharing the store must honor persisted Mission ownership,
|
|
# including a legacy launcher that has no Mission authority configured.
|
|
if self.missions is not None:
|
|
return self.missions.can_read_task(
|
|
task, authenticated_identity=actor or ""
|
|
)
|
|
with self.store._lock:
|
|
return self.store.conn.execute(
|
|
"SELECT 1 FROM task_missions WHERE root_task_id=?",
|
|
(task.root_task_id,),
|
|
).fetchone() is None
|
|
|
|
def _assert_mission_read(self, task, actor: str | None) -> None:
|
|
if not self._can_read_mission_task(task, actor):
|
|
raise ForbiddenError("mission task requires its configured owner authority")
|
|
|
|
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
|
if actor is not None:
|
|
self.policy.assert_allowed("read", actor)
|
|
tasks = self.store.list_tasks()
|
|
tasks = [task for task in tasks if self._can_read_mission_task(task, actor)]
|
|
return [task.to_dict(include_task=False) for task in tasks]
|
|
|
|
def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]:
|
|
task = self.store.get_task(task_id)
|
|
self._assert_mission_read(task, actor)
|
|
if actor is not None:
|
|
self.policy.assert_allowed("read", actor, task=task)
|
|
if self.missions is not None and self.missions.manages_root(
|
|
task.root_task_id
|
|
):
|
|
self.missions.assert_task_read(
|
|
task, authenticated_identity=actor
|
|
)
|
|
return task.to_dict()
|
|
|
|
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
|
|
if actor is not None:
|
|
self.policy.assert_allowed("read", actor)
|
|
return self.workers.get(worker_id).to_dict()
|
|
|
|
def list_workers(self, actor: str | None = None) -> list[dict[str, Any]]:
|
|
if actor is not None:
|
|
self.policy.assert_allowed("read", actor)
|
|
return [w.to_dict() for w in self.workers.list()]
|
|
|
|
def register_worker(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
actor = str(body.get("actor_identity") or body.get("identity") or "")
|
|
is_admin = actor in self.policy.admin_identities
|
|
try:
|
|
existing = self.store.get_worker(str(body.get("worker_id") or ""))
|
|
except NotFoundError:
|
|
existing = None
|
|
if existing is not None and str(body.get("identity") or "") != existing.identity:
|
|
raise ConflictError("worker identity takeover rejected")
|
|
self.policy.assert_readonly_worker_registration(actor, body)
|
|
self.policy.assert_allowed(
|
|
"worker.admin" if is_admin else "worker.register", actor
|
|
)
|
|
try:
|
|
worker = self.workers.register(
|
|
body,
|
|
actor_identity=actor,
|
|
is_admin=is_admin,
|
|
expected_revision=body.get("expected_revision"),
|
|
)
|
|
except (TypeError, ValueError, KeyError) as exc:
|
|
raise ValidationError("invalid worker registration") from exc
|
|
return worker.to_dict()
|
|
|
|
def worker_heartbeat(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
interval = payload.get("heartbeat_interval_seconds")
|
|
actor = str(payload.get("actor_identity") or "")
|
|
self.policy.assert_readonly_worker_binding(actor, worker_id)
|
|
self.policy.assert_allowed("worker.register", actor)
|
|
return self.workers.heartbeat(
|
|
worker_id,
|
|
heartbeat_interval_seconds=interval,
|
|
actor_identity=actor,
|
|
expected_revision=payload.get("expected_revision"),
|
|
).to_dict()
|
|
|
|
def disable_worker(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
actor = str(payload.get("actor_identity") or "")
|
|
self.policy.assert_allowed("worker.admin", actor)
|
|
return self.store.disable_worker(
|
|
worker_id,
|
|
actor_identity=actor,
|
|
expected_revision=int(payload.get("expected_revision")),
|
|
reason=str(payload.get("reason") or "disabled by administrator"),
|
|
).to_dict()
|
|
|
|
def restore_worker(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
actor = str(payload.get("actor_identity") or "")
|
|
self.policy.assert_allowed("worker.admin", actor)
|
|
return self.store.restore_worker(
|
|
worker_id,
|
|
actor_identity=actor,
|
|
expected_revision=int(payload.get("expected_revision")),
|
|
).to_dict()
|
|
|
|
def claim(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
self.policy.assert_allowed("claim", actor)
|
|
worker_id = body.get("worker_id")
|
|
if not worker_id:
|
|
raise ValidationError("worker_id required")
|
|
|
|
task = self.store.get_task(task_id)
|
|
self.policy.assert_readonly_worker_binding(actor, str(worker_id))
|
|
self.policy.assert_allowed("claim", actor, task=task)
|
|
lease_seconds = _bounded_int(
|
|
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
|
)
|
|
claim = self.lease.claim(
|
|
task_id=task_id,
|
|
worker_id=worker_id,
|
|
worker_identity=actor,
|
|
lease_seconds=lease_seconds,
|
|
expected_revision=task.revision,
|
|
operation_digest=task.task_plan.operation_digest(),
|
|
)
|
|
return {"claim_id": claim.claim_id, **claim.to_dict()}
|
|
|
|
def heartbeat(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
claim_id = body.get("claim_id")
|
|
if not claim_id:
|
|
raise ValidationError("claim_id required")
|
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
task = self.store.get_task(task_id)
|
|
claim = self.store.get_claim(claim_id)
|
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
|
self.policy.assert_allowed("heartbeat", actor, task=task)
|
|
if claim.worker_identity != actor:
|
|
raise ForbiddenError("claim identity mismatch")
|
|
lease_seconds = _bounded_int(
|
|
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
|
)
|
|
claim = self.lease.heartbeat(task_id, claim_id, lease_seconds)
|
|
return {"claim_id": claim_id, "lease_until": claim.lease_until}
|
|
|
|
def progress(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
claim_id = body.get("claim_id")
|
|
if not claim_id:
|
|
raise ValidationError("claim_id required")
|
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
progress = body.get("progress")
|
|
stage = body.get("stage", "RUNNING")
|
|
message = body.get("message")
|
|
if progress is None:
|
|
raise ValidationError("progress required")
|
|
task = self.store.get_task(task_id)
|
|
claim = self.store.get_claim(claim_id)
|
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
|
self.policy.assert_allowed("progress", actor, task=task)
|
|
if task.task_plan.data_classification == "credential-secret":
|
|
if message not in {None, ""} or contains_secret_text(stage):
|
|
raise ValidationError(
|
|
"credential-secret progress must contain metadata only"
|
|
)
|
|
lease_seconds = _bounded_int(
|
|
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
|
)
|
|
claim = self.store.record_progress(
|
|
task_id,
|
|
claim_id,
|
|
actor,
|
|
progress=progress,
|
|
stage=str(stage),
|
|
message=None if message is None else str(message),
|
|
lease_seconds=lease_seconds,
|
|
)
|
|
return {"claim_id": claim.claim_id, "status": claim.status, "progress": progress}
|
|
|
|
def complete(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
claim_id = body.get("claim_id")
|
|
if not claim_id:
|
|
raise ValidationError("claim_id required")
|
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
task = self.store.get_task(task_id)
|
|
|
|
status = body.get("status", "COMPLETED")
|
|
outputs = body.get("outputs", [])
|
|
evidences = body.get("evidence", [])
|
|
if not isinstance(outputs, list):
|
|
raise ValidationError("outputs must be list")
|
|
if not isinstance(evidences, list):
|
|
raise ValidationError("evidence must be list")
|
|
if any(not isinstance(ev, dict) for ev in evidences):
|
|
raise ValidationError("evidence items must be objects")
|
|
claim = self.store.get_claim(claim_id)
|
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
|
self.policy.assert_allowed("result", actor, task=task)
|
|
if claim.worker_identity != actor:
|
|
raise ForbiddenError("claim identity mismatch")
|
|
if task.task_plan.data_classification == "credential-secret":
|
|
allowed_result_keys = {
|
|
"kind",
|
|
"hash",
|
|
"sha256",
|
|
"count",
|
|
"status",
|
|
"error_code",
|
|
"signature",
|
|
"attestation_ref",
|
|
}
|
|
for entry in [*outputs, *evidences]:
|
|
if (
|
|
not isinstance(entry, dict)
|
|
or set(entry) - allowed_result_keys
|
|
or contains_secret_text(entry)
|
|
):
|
|
raise ValidationError(
|
|
"credential-secret result must contain hash/count/status only"
|
|
)
|
|
|
|
result = ResultEnvelope(
|
|
task_id=task_id,
|
|
claim_id=claim_id,
|
|
worker_identity=actor,
|
|
status=status,
|
|
outputs=outputs,
|
|
evidence=evidences,
|
|
started_at=str(body.get("started_at") or iso8601_now()),
|
|
completed_at=str(body.get("completed_at") or iso8601_now()),
|
|
result_digest=body.get("result_digest"),
|
|
signature=body.get("signature"),
|
|
attestation_ref=body.get("attestation_ref"),
|
|
operation_digest=claim.operation_digest,
|
|
)
|
|
evidence_decision = self.policy.evidence_verifier.verify(task, evidences)
|
|
if not evidence_decision.allowed:
|
|
raise ForbiddenError(evidence_decision.reason)
|
|
result_decision = self.policy.verify_result(result, task)
|
|
if not result_decision.allowed:
|
|
raise ForbiddenError(result_decision.reason)
|
|
result.result_digest = result.computed_digest()
|
|
previous = self.store._last_evidence_hash(task_id)
|
|
evidence_entries = []
|
|
for evidence in evidences:
|
|
envelope = EvidenceEnvelope.create(
|
|
task_id=task_id,
|
|
claim_id=claim_id,
|
|
kind=str(evidence.get("kind") or evidence.get("type") or "generic"),
|
|
worker_identity=actor,
|
|
payload=evidence,
|
|
previous_evidence_hash=previous,
|
|
signature=evidence.get("signature"),
|
|
attestation_ref=evidence.get("attestation_ref"),
|
|
)
|
|
evidence_entries.append(envelope)
|
|
previous = envelope.evidence_digest
|
|
final_status = (
|
|
status
|
|
if status
|
|
in {"COMPLETED", "FAILED_FINAL", "FAILED_RECOVERABLE", "CANCELLED"}
|
|
else "COMPLETED"
|
|
)
|
|
lineage_metadata = task.task_plan.metadata.get("aios_task_lineage")
|
|
is_lineage_root = bool(
|
|
self.lineage is not None
|
|
and isinstance(lineage_metadata, dict)
|
|
and lineage_metadata.get("relation_type") == "ROOT"
|
|
)
|
|
if is_lineage_root and final_status == "COMPLETED":
|
|
raise ForbiddenError("lineage root completion requires root acceptance review")
|
|
review_outcome = None
|
|
with self.store._transaction():
|
|
self.store.complete_claim_with_evidence_and_event(
|
|
task_id,
|
|
claim_id,
|
|
final_status,
|
|
result.to_dict(),
|
|
evidence_entries,
|
|
)
|
|
is_lineage_child = bool(
|
|
self.lineage is not None
|
|
and isinstance(lineage_metadata, dict)
|
|
and lineage_metadata.get("relation_type") != "ROOT"
|
|
)
|
|
if is_lineage_child and final_status in {"COMPLETED", "FAILED_FINAL", "CANCELLED"}:
|
|
review_outcome = self.lineage.review_proven_terminal_child(
|
|
child_task_id=task_id,
|
|
claim_id=str(claim_id),
|
|
result_digest=str(result.result_digest),
|
|
evidence_refs=[entry.evidence_id for entry in evidence_entries],
|
|
)
|
|
response = result.to_dict()
|
|
if review_outcome is not None:
|
|
response["root_review"] = {
|
|
"review_id": review_outcome.review.review_id,
|
|
"root_task_id": review_outcome.review.root_task_id,
|
|
"root_complete": review_outcome.review.root_complete,
|
|
"next_legal_action": review_outcome.review.next_legal_action.value,
|
|
"dispatch_status": review_outcome.dispatch.status,
|
|
}
|
|
return response
|
|
|
|
def fail(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
claim_id = body.get("claim_id")
|
|
if not claim_id:
|
|
raise ValidationError("claim_id required")
|
|
reason = body.get("reason", "failed")
|
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
|
self.policy.assert_allowed("fail", actor)
|
|
task = self.store.get_task(task_id)
|
|
self.policy.assert_allowed("fail", actor, task=task)
|
|
claim = self.store.get_claim(claim_id)
|
|
if claim.worker_identity != actor:
|
|
raise ForbiddenError("claim identity mismatch")
|
|
status = "FAILED_RECOVERABLE" if body.get("recoverable", False) else "FAILED_FINAL"
|
|
self.store.fail_claim_with_event(task_id, claim_id, status, str(reason))
|
|
return {"task_id": task_id, "status": status, "reason": reason}
|
|
|
|
def cancel(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
actor = body.get("actor_identity") or body.get("worker_identity") or "owner"
|
|
self.policy.assert_allowed("cancel", actor)
|
|
reason = body.get("reason", "cancelled")
|
|
task = self.store.get_task(task_id)
|
|
self.policy.assert_allowed("cancel", actor, task=task)
|
|
updated = self.store.cancel_task_with_event(
|
|
task_id, task.revision, reason=reason
|
|
)
|
|
return updated.to_dict()
|
|
|
|
def delegate(
|
|
self,
|
|
task_id: str,
|
|
body: dict[str, Any],
|
|
idempotency_key: str | None = None,
|
|
) -> dict[str, Any]:
|
|
actor = body.get("actor_identity") or "owner"
|
|
parent = self.store.get_task(task_id)
|
|
mission_managed = bool(
|
|
self.lineage is not None
|
|
and self.missions is not None
|
|
and self.missions.manages_root(parent.root_task_id)
|
|
)
|
|
if mission_managed:
|
|
plan_body = body.get("task") if "task" in body else {
|
|
key: value
|
|
for key, value in body.items()
|
|
if key != "actor_identity"
|
|
}
|
|
return self.delegate_mission(
|
|
task_id,
|
|
plan_body,
|
|
idempotency_key,
|
|
actor_identity=str(actor),
|
|
)
|
|
|
|
self.policy.assert_allowed("delegate", actor)
|
|
self.policy.assert_allowed("delegate", actor, task=parent)
|
|
if "task" in body:
|
|
plan_body = body["task"]
|
|
try:
|
|
child_plan = TaskPlan.from_dict(plan_body)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValidationError("invalid child task plan") from exc
|
|
else:
|
|
plan_body = dict(body)
|
|
plan_body.pop("actor_identity", None)
|
|
try:
|
|
child_plan = TaskPlan.from_dict(plan_body)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValidationError("invalid child task plan") from exc
|
|
child_plan.parent_task_id = task_id
|
|
if self.lineage is not None:
|
|
if not isinstance(plan_body, dict):
|
|
raise ValidationError("invalid child task plan")
|
|
if "task_id" not in plan_body:
|
|
raise ValidationError("lineage delegation requires a stable task_id")
|
|
plan_body = dict(plan_body)
|
|
issuer = plan_body.get("issuer")
|
|
if issuer and (
|
|
not isinstance(issuer, dict) or issuer.get("id") != actor
|
|
):
|
|
raise ForbiddenError("child issuer identity mismatch")
|
|
plan_body["issuer"] = {"type": "authenticated", "id": actor}
|
|
child_plan.issuer = dict(plan_body["issuer"])
|
|
child = self.lineage.delegate_child(
|
|
parent_task_id=task_id,
|
|
plan=child_plan,
|
|
authenticated_identity=str(actor),
|
|
request_digest=self.lineage._request_digest(plan_body),
|
|
delegation=self.delegation,
|
|
)
|
|
else:
|
|
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
|
return child.to_dict()
|
|
|
|
def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
|
task = self.store.get_task(task_id)
|
|
self._assert_mission_read(task, actor)
|
|
if actor is not None:
|
|
self.policy.assert_allowed("audit", actor, task=task)
|
|
if self.missions is not None and self.missions.manages_root(
|
|
task.root_task_id
|
|
):
|
|
self.missions.assert_task_read(
|
|
task, authenticated_identity=actor
|
|
)
|
|
return self.store.list_events(task_id)
|
|
|
|
def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
|
task = self.store.get_task(task_id)
|
|
self._assert_mission_read(task, actor)
|
|
if actor is not None:
|
|
self.policy.assert_allowed("audit", actor, task=task)
|
|
if self.missions is not None and self.missions.manages_root(
|
|
task.root_task_id
|
|
):
|
|
self.missions.assert_task_read(
|
|
task, authenticated_identity=actor
|
|
)
|
|
return self.evidence.list(task_id)
|
|
|
|
def get_children(self, task_id: str, actor: str | None = None) -> list[str]:
|
|
task = self.store.get_task(task_id)
|
|
self._assert_mission_read(task, actor)
|
|
if actor is not None:
|
|
self.policy.assert_allowed("read", actor, task=task)
|
|
if self.missions is not None and self.missions.manages_root(
|
|
task.root_task_id
|
|
):
|
|
self.missions.assert_task_read(
|
|
task, authenticated_identity=actor
|
|
)
|
|
return self.store.list_children(task_id)
|
|
|
|
|
|
class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
|
service: TaskPlaneService | None = None
|
|
authenticator: Callable[[str], str | None] | None = None
|
|
max_body_bytes = 1_000_000
|
|
|
|
def log_message(self, format: str, *args: Any) -> None:
|
|
# BaseHTTPRequestHandler logs the raw request target; suppress it so
|
|
# attacker-controlled paths or query strings cannot leak credentials.
|
|
return
|
|
|
|
def _json(self) -> tuple[str | None, dict[str, Any]]:
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
except ValueError as exc:
|
|
raise ValidationError("invalid content length") from exc
|
|
if length < 0:
|
|
raise ValidationError("invalid content length")
|
|
if length > self.max_body_bytes:
|
|
raise PayloadTooLargeError("payload exceeds limit")
|
|
raw = self.rfile.read(length) if length > 0 else b""
|
|
if not raw:
|
|
return None, {}
|
|
try:
|
|
decoded = raw.decode("utf-8")
|
|
payload = json.loads(decoded)
|
|
if not isinstance(payload, dict):
|
|
raise ValidationError("JSON payload must be an object")
|
|
return decoded, payload
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise ValidationError("invalid JSON payload") from exc
|
|
|
|
def _response(self, status: int, payload: Any) -> None:
|
|
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(encoded)))
|
|
self.end_headers()
|
|
self.wfile.write(encoded)
|
|
|
|
def _authenticated_identity(self) -> str:
|
|
authenticator = type(self).authenticator
|
|
if authenticator is None:
|
|
raise UnauthorizedError("authentication unavailable")
|
|
header = self.headers.get("Authorization", "")
|
|
if not header.startswith("Bearer "):
|
|
raise UnauthorizedError("bearer authentication required")
|
|
token = header[7:]
|
|
if not token or any(ord(ch) < 33 for ch in token):
|
|
raise UnauthorizedError("invalid bearer token")
|
|
identity = authenticator(token)
|
|
if not identity:
|
|
raise UnauthorizedError("invalid credentials")
|
|
return str(identity)
|
|
|
|
def _handle(self, handler):
|
|
try:
|
|
if self.service is None:
|
|
raise TaskPlaneHTTPError("service not configured")
|
|
result = handler()
|
|
if isinstance(result, tuple):
|
|
status, payload = result
|
|
else:
|
|
status, payload = 200, result
|
|
self._response(status, payload)
|
|
except TaskPlaneHTTPError as exc:
|
|
self._response(exc.status_code, exc.to_payload())
|
|
except Exception as exc: # defensive
|
|
if isinstance(exc, TaskPlaneHTTPError):
|
|
raise
|
|
self._response(
|
|
500,
|
|
{"error": "internal_error", "message": "internal server error"},
|
|
)
|
|
|
|
def do_GET(self): # noqa: N802
|
|
def route():
|
|
path = urllib.parse.urlparse(self.path).path
|
|
parts = [p for p in path.split("/") if p]
|
|
for part in parts[1:]:
|
|
if part not in {"events", "evidence", "children", "heartbeat"}:
|
|
try:
|
|
validate_identifier(part, "resource identifier")
|
|
except ValueError as exc:
|
|
raise ValidationError("invalid resource identifier") from exc
|
|
identity = self._authenticated_identity()
|
|
|
|
if len(parts) == 1 and parts[0] == "tasks":
|
|
return 200, self.service.list_tasks(identity)
|
|
if len(parts) == 2 and parts[0] == "missions":
|
|
return 200, self.service.get_mission(
|
|
parts[1], actor_identity=identity
|
|
)
|
|
if len(parts) == 2 and parts[0] == "tasks":
|
|
task_id = parts[1]
|
|
return 200, self.service.get_task(task_id, identity)
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "events":
|
|
return 200, self.service.get_events(parts[1], identity)
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "evidence":
|
|
return 200, self.service.get_evidence(parts[1], identity)
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "children":
|
|
return 200, self.service.get_children(parts[1], identity)
|
|
if len(parts) == 1 and parts[0] == "workers":
|
|
return 200, self.service.list_workers(identity)
|
|
if len(parts) == 2 and parts[0] == "workers":
|
|
return 200, self.service.get_worker(parts[1], identity)
|
|
raise NotFoundError("unknown endpoint")
|
|
|
|
self._handle(route)
|
|
|
|
def do_POST(self): # noqa: N802
|
|
def route():
|
|
body_text, body = self._json()
|
|
identity = self._authenticated_identity()
|
|
path = urllib.parse.urlparse(self.path).path
|
|
parts = [p for p in path.split("/") if p]
|
|
headers = {k.lower(): v for k, v in self.headers.items()}
|
|
|
|
if len(parts) == 1 and parts[0] == "missions":
|
|
return 201, self.service.create_mission(
|
|
body or {},
|
|
headers.get("idempotency-key"),
|
|
actor_identity=identity,
|
|
)
|
|
|
|
if len(parts) == 1 and parts[0] == "tasks":
|
|
issuer = body.get("issuer")
|
|
if issuer and (
|
|
not isinstance(issuer, dict) or issuer.get("id") != identity
|
|
):
|
|
raise ForbiddenError("issuer identity mismatch")
|
|
body["issuer"] = {"type": "authenticated", "id": identity}
|
|
return 201, self.service.create_task(
|
|
body or {},
|
|
headers.get("idempotency-key"),
|
|
actor_identity=identity,
|
|
)
|
|
if body.get("actor_identity") not in {None, identity}:
|
|
raise ForbiddenError("actor identity mismatch")
|
|
body["actor_identity"] = identity
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "claim":
|
|
return 200, self.service.claim(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "heartbeat":
|
|
return 200, self.service.heartbeat(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "progress":
|
|
return 200, self.service.progress(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "result":
|
|
return 200, self.service.complete(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "fail":
|
|
return 200, self.service.fail(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "cancel":
|
|
return 200, self.service.cancel(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "delegate":
|
|
return 200, self.service.delegate(
|
|
parts[1], body or {}, headers.get("idempotency-key")
|
|
)
|
|
|
|
if len(parts) == 2 and parts[0] == "workers" and parts[1] == "register":
|
|
if (
|
|
body.get("identity") != identity
|
|
and identity not in self.service.policy.admin_identities
|
|
):
|
|
raise ForbiddenError("worker identity mismatch")
|
|
return 201, self.service.register_worker(body or {})
|
|
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "heartbeat":
|
|
return 200, self.service.worker_heartbeat(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "disable":
|
|
return 200, self.service.disable_worker(parts[1], body or {})
|
|
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "restore":
|
|
return 200, self.service.restore_worker(parts[1], body or {})
|
|
raise NotFoundError("unknown endpoint")
|
|
|
|
self._handle(route)
|
|
|
|
|
|
def run_server(
|
|
host: str,
|
|
port: int,
|
|
service: TaskPlaneService,
|
|
*,
|
|
authenticator: Callable[[str], str | None] | None = None,
|
|
):
|
|
handler = TaskPlaneRequestHandler
|
|
handler.service = service
|
|
handler.authenticator = authenticator
|
|
server = ThreadingHTTPServer((host, port), handler)
|
|
server.serve_forever()
|