723 lines
28 KiB
Python
723 lines
28 KiB
Python
"""Owner-controlled CAS-bound execution-envelope rebind for Online Agent Tasks.
|
|
|
|
This service exists for a narrow recovery case: a Task was projected/claimed with a read-only
|
|
source_capture envelope before its already-registered deterministic plan became authoritative.
|
|
|
|
It never creates Tasks/Jobs/branches and never accepts arbitrary metadata patches. It only replaces
|
|
metadata.execution + the derived execution_projection for the same governed binding, after an
|
|
official NEEDS_REWORK transition has returned the Task to QUEUED with no active lease.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import hmac
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import sqlite3
|
|
from typing import Any, Callable, Mapping, Sequence
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from aios_online_agent_execution import (
|
|
GovernedExecutionBinding,
|
|
validate_execution_envelope,
|
|
)
|
|
|
|
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
|
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,159}$")
|
|
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
MAX_BODY = 64 * 1024
|
|
AUTHORIZATION_ID_ENV = "AIOS_AGENT_TASK_EXECUTION_REBIND_AUTHORIZATION_ID"
|
|
|
|
|
|
class ExecutionRebindError(RuntimeError):
|
|
def __init__(self, code: str, status: int = 409) -> None:
|
|
super().__init__(code)
|
|
self.code = code
|
|
self.status = status
|
|
|
|
|
|
def _canonical(value: object) -> bytes:
|
|
return json.dumps(
|
|
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
).encode("utf-8")
|
|
|
|
|
|
def _sha(value: object) -> str:
|
|
return hashlib.sha256(_canonical(value)).hexdigest()
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _require_string(
|
|
value: object,
|
|
code: str,
|
|
pattern: re.Pattern[str] | None = None,
|
|
) -> str:
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ExecutionRebindError(code, 400)
|
|
value = value.strip()
|
|
if pattern is not None and pattern.fullmatch(value) is None:
|
|
raise ExecutionRebindError(code, 400)
|
|
return value
|
|
|
|
|
|
def _required_authorization_id() -> str:
|
|
value = os.environ.get(AUTHORIZATION_ID_ENV, "").strip()
|
|
if not value:
|
|
raise RuntimeError("TASK_EXECUTION_REBIND_AUTHORIZATION_ID_MISSING")
|
|
if KEY_RE.fullmatch(value) is None:
|
|
raise RuntimeError("TASK_EXECUTION_REBIND_AUTHORIZATION_ID_INVALID")
|
|
return value
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExecutionRebindRequest:
|
|
expected_status: str
|
|
expected_revision: int
|
|
expected_attempt: int
|
|
previous_verification_sha256: str
|
|
expected_old_execution_digest: str
|
|
expected_new_execution_digest: str
|
|
authorization_id: str
|
|
idempotency_key: str
|
|
reason: str
|
|
|
|
@classmethod
|
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ExecutionRebindRequest":
|
|
allowed = {
|
|
"expected_status",
|
|
"expected_revision",
|
|
"expected_attempt",
|
|
"previous_verification_sha256",
|
|
"expected_old_execution_digest",
|
|
"expected_new_execution_digest",
|
|
"authorization_id",
|
|
"idempotency_key",
|
|
"reason",
|
|
}
|
|
if set(value) != allowed:
|
|
raise ExecutionRebindError("EXECUTION_REBIND_FIELDS_INVALID", 400)
|
|
|
|
status = _require_string(value["expected_status"], "EXPECTED_STATUS_INVALID")
|
|
if status != "QUEUED":
|
|
raise ExecutionRebindError("EXPECTED_STATUS_INVALID", 400)
|
|
|
|
revision = value["expected_revision"]
|
|
attempt = value["expected_attempt"]
|
|
if (
|
|
not isinstance(revision, int)
|
|
or isinstance(revision, bool)
|
|
or revision < 0
|
|
or not isinstance(attempt, int)
|
|
or isinstance(attempt, bool)
|
|
or attempt < 1
|
|
):
|
|
raise ExecutionRebindError("EXECUTION_REBIND_INTEGER_INVALID", 400)
|
|
|
|
verification = _require_string(
|
|
value["previous_verification_sha256"], "VERIFICATION_SHA256_INVALID"
|
|
)
|
|
old_digest = _require_string(
|
|
value["expected_old_execution_digest"], "OLD_EXECUTION_DIGEST_INVALID"
|
|
)
|
|
new_digest = _require_string(
|
|
value["expected_new_execution_digest"], "NEW_EXECUTION_DIGEST_INVALID"
|
|
)
|
|
for digest, code in (
|
|
(verification, "VERIFICATION_SHA256_INVALID"),
|
|
(old_digest, "OLD_EXECUTION_DIGEST_INVALID"),
|
|
(new_digest, "NEW_EXECUTION_DIGEST_INVALID"),
|
|
):
|
|
if SHA_RE.fullmatch(digest) is None:
|
|
raise ExecutionRebindError(code, 400)
|
|
if old_digest == new_digest:
|
|
raise ExecutionRebindError("EXECUTION_REBIND_DIGEST_UNCHANGED", 400)
|
|
|
|
authorization = _require_string(
|
|
value["authorization_id"], "AUTHORIZATION_ID_INVALID", KEY_RE
|
|
)
|
|
key = _require_string(value["idempotency_key"], "IDEMPOTENCY_KEY_INVALID", KEY_RE)
|
|
reason = _require_string(value["reason"], "EXECUTION_REBIND_REASON_INVALID")
|
|
if len(reason) > 512:
|
|
raise ExecutionRebindError("EXECUTION_REBIND_REASON_INVALID", 400)
|
|
|
|
return cls(
|
|
status,
|
|
revision,
|
|
attempt,
|
|
verification,
|
|
old_digest,
|
|
new_digest,
|
|
authorization,
|
|
key,
|
|
reason,
|
|
)
|
|
|
|
def public(self) -> dict[str, Any]:
|
|
return {
|
|
"expected_status": self.expected_status,
|
|
"expected_revision": self.expected_revision,
|
|
"expected_attempt": self.expected_attempt,
|
|
"previous_verification_sha256": self.previous_verification_sha256,
|
|
"expected_old_execution_digest": self.expected_old_execution_digest,
|
|
"expected_new_execution_digest": self.expected_new_execution_digest,
|
|
"authorization_id": self.authorization_id,
|
|
"idempotency_key": self.idempotency_key,
|
|
"reason": self.reason,
|
|
}
|
|
|
|
|
|
class CanonicalBridgeExecutionResolver:
|
|
"""Resolve the already-registered plan from the canonical governed Bridge."""
|
|
|
|
def __init__(self, base_url: str, control_token: str) -> None:
|
|
self.base_url = base_url.rstrip("/")
|
|
self.control_token = control_token
|
|
|
|
def __call__(
|
|
self,
|
|
task_id: str,
|
|
required_capabilities: Sequence[str],
|
|
) -> dict[str, Any]:
|
|
body = json.dumps(
|
|
{
|
|
"task_id": task_id,
|
|
"required_capabilities": list(required_capabilities),
|
|
},
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
request = urllib.request.Request(
|
|
self.base_url + "/v2/execution-envelope",
|
|
method="POST",
|
|
data=body,
|
|
headers={
|
|
"Authorization": "Bearer " + self.control_token,
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=15) as response:
|
|
raw = response.read()
|
|
status = response.status
|
|
except urllib.error.HTTPError as exc:
|
|
raw = exc.read()
|
|
status = exc.code
|
|
try:
|
|
value = json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise ExecutionRebindError("CANONICAL_EXECUTION_RESPONSE_INVALID", 502) from exc
|
|
if not isinstance(value, Mapping):
|
|
raise ExecutionRebindError("CANONICAL_EXECUTION_RESPONSE_INVALID", 502)
|
|
if status != 200:
|
|
raise ExecutionRebindError(
|
|
str(value.get("error") or "CANONICAL_EXECUTION_RESOLUTION_FAILED"),
|
|
502 if status >= 500 else 409,
|
|
)
|
|
return dict(value)
|
|
|
|
|
|
class ExecutionRebindStore:
|
|
"""Official owner-controlled mutation with CAS, plan resolution and durable audit."""
|
|
|
|
def __init__(
|
|
self,
|
|
db_path: Path,
|
|
resolver: Callable[[str, Sequence[str]], Mapping[str, Any]],
|
|
) -> None:
|
|
self.db_path = Path(db_path)
|
|
self.resolver = resolver
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
connection = sqlite3.connect(self.db_path, timeout=30.0)
|
|
connection.row_factory = sqlite3.Row
|
|
connection.execute("PRAGMA foreign_keys=ON")
|
|
connection.execute("PRAGMA busy_timeout=30000")
|
|
return connection
|
|
|
|
@staticmethod
|
|
def _columns(
|
|
connection: sqlite3.Connection,
|
|
table: str,
|
|
) -> dict[str, sqlite3.Row]:
|
|
return {
|
|
str(row["name"]): row
|
|
for row in connection.execute(f'PRAGMA table_info("{table}")')
|
|
}
|
|
|
|
@staticmethod
|
|
def _json_value(raw: object, code: str) -> dict[str, Any]:
|
|
if isinstance(raw, Mapping):
|
|
return dict(raw)
|
|
if not isinstance(raw, str):
|
|
raise ExecutionRebindError(code, 409)
|
|
try:
|
|
value = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise ExecutionRebindError(code, 409) from exc
|
|
if not isinstance(value, Mapping):
|
|
raise ExecutionRebindError(code, 409)
|
|
return dict(value)
|
|
|
|
@staticmethod
|
|
def _verification_hash(raw: object) -> str:
|
|
if raw in (None, ""):
|
|
value: object = {}
|
|
elif isinstance(raw, str):
|
|
try:
|
|
value = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise ExecutionRebindError("TASK_VERIFICATION_INVALID", 409) from exc
|
|
else:
|
|
value = raw
|
|
return _sha(value)
|
|
|
|
@staticmethod
|
|
def _metadata_column(columns: Mapping[str, sqlite3.Row]) -> str:
|
|
for name in ("metadata_json", "metadata"):
|
|
if name in columns:
|
|
return name
|
|
raise ExecutionRebindError("TASK_METADATA_SCHEMA_UNSUPPORTED", 503)
|
|
|
|
@staticmethod
|
|
def _required_capabilities(
|
|
task: sqlite3.Row,
|
|
columns: Mapping[str, sqlite3.Row],
|
|
old_execution: Mapping[str, Any],
|
|
) -> list[str]:
|
|
for field in ("required_capabilities_json", "required_capabilities"):
|
|
if field not in columns:
|
|
continue
|
|
raw = task[field]
|
|
if raw in (None, ""):
|
|
continue
|
|
if isinstance(raw, str):
|
|
try:
|
|
value = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
value = [item for item in raw.split(",") if item.strip()]
|
|
else:
|
|
value = raw
|
|
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
return [item.strip() for item in value if item.strip()]
|
|
value = old_execution.get("capability_scope")
|
|
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
return [item for item in value if item]
|
|
raise ExecutionRebindError("TASK_CAPABILITIES_UNAVAILABLE", 503)
|
|
|
|
@staticmethod
|
|
def _event_table(
|
|
connection: sqlite3.Connection,
|
|
) -> tuple[str, dict[str, sqlite3.Row]]:
|
|
tables = {
|
|
row["name"]
|
|
for row in connection.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
)
|
|
}
|
|
for name in ("task_events", "events"):
|
|
if name not in tables:
|
|
continue
|
|
columns = ExecutionRebindStore._columns(connection, name)
|
|
if "task_id" in columns and ("event_type" in columns or "type" in columns):
|
|
return name, columns
|
|
raise ExecutionRebindError("TASK_EVENT_TABLE_UNSUPPORTED", 503)
|
|
|
|
@staticmethod
|
|
def _insert_event(
|
|
connection: sqlite3.Connection,
|
|
task_id: str,
|
|
payload: Mapping[str, Any],
|
|
) -> None:
|
|
table, columns = ExecutionRebindStore._event_table(connection)
|
|
values: dict[str, Any] = {"task_id": task_id}
|
|
if "event_type" in columns:
|
|
values["event_type"] = "TASK_EXECUTION_REBIND_AUTHORIZED"
|
|
else:
|
|
values["type"] = "TASK_EXECUTION_REBIND_AUTHORIZED"
|
|
payload_text = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
if "payload_json" in columns:
|
|
values["payload_json"] = payload_text
|
|
elif "payload" in columns:
|
|
values["payload"] = payload_text
|
|
else:
|
|
raise ExecutionRebindError("TASK_EVENT_PAYLOAD_UNSUPPORTED", 503)
|
|
if "created_at" in columns:
|
|
values["created_at"] = _now()
|
|
if "agent_id" in columns:
|
|
values["agent_id"] = "owner-control"
|
|
if "actor_id" in columns:
|
|
values["actor_id"] = "owner-control"
|
|
names = list(values)
|
|
quoted = ",".join('"' + name + '"' for name in names)
|
|
placeholders = ",".join("?" for _ in names)
|
|
connection.execute(
|
|
f'INSERT INTO "{table}" ({quoted}) VALUES ({placeholders})',
|
|
tuple(values[name] for name in names),
|
|
)
|
|
|
|
@staticmethod
|
|
def _ensure_rebind_table(connection: sqlite3.Connection) -> None:
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS task_execution_rebinds (
|
|
task_id TEXT NOT NULL,
|
|
idempotency_key TEXT NOT NULL,
|
|
request_sha256 TEXT NOT NULL,
|
|
authorization_id TEXT NOT NULL,
|
|
previous_verification_sha256 TEXT NOT NULL,
|
|
old_execution_digest TEXT NOT NULL,
|
|
new_execution_digest TEXT NOT NULL,
|
|
expected_revision INTEGER NOT NULL,
|
|
applied_revision INTEGER NOT NULL,
|
|
attempt INTEGER NOT NULL,
|
|
reason TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY(task_id,idempotency_key)
|
|
)
|
|
"""
|
|
)
|
|
|
|
def _load_and_validate(
|
|
self,
|
|
connection: sqlite3.Connection,
|
|
task_id: str,
|
|
request: ExecutionRebindRequest,
|
|
) -> tuple[sqlite3.Row, dict[str, sqlite3.Row], str, dict[str, Any], dict[str, Any]]:
|
|
columns = self._columns(connection, "tasks")
|
|
required = {
|
|
"task_id",
|
|
"status",
|
|
"attempt",
|
|
"revision",
|
|
"claimed_by",
|
|
"lease_token_hash",
|
|
"lease_expires_at",
|
|
"verification_json",
|
|
}
|
|
if not required.issubset(columns):
|
|
raise ExecutionRebindError("TASK_SCHEMA_EXECUTION_REBIND_UNSUPPORTED", 503)
|
|
metadata_column = self._metadata_column(columns)
|
|
task = connection.execute(
|
|
"SELECT * FROM tasks WHERE task_id=?",
|
|
(task_id,),
|
|
).fetchone()
|
|
if task is None:
|
|
raise ExecutionRebindError("TASK_NOT_FOUND", 404)
|
|
|
|
if (
|
|
str(task["status"]) != request.expected_status
|
|
or int(task["revision"]) != request.expected_revision
|
|
or int(task["attempt"]) != request.expected_attempt
|
|
):
|
|
raise ExecutionRebindError("EXECUTION_REBIND_CAS_MISMATCH", 409)
|
|
if task["lease_token_hash"] not in (None, "") or task["lease_expires_at"] not in (None, ""):
|
|
raise ExecutionRebindError("EXECUTION_REBIND_ACTIVE_LEASE_DENY", 409)
|
|
|
|
verification_sha = self._verification_hash(task["verification_json"])
|
|
if verification_sha != request.previous_verification_sha256:
|
|
raise ExecutionRebindError("EXECUTION_REBIND_VERIFICATION_DRIFT", 409)
|
|
|
|
metadata = self._json_value(
|
|
task[metadata_column],
|
|
"TASK_METADATA_INVALID",
|
|
)
|
|
execution = metadata.get("execution")
|
|
if not isinstance(execution, Mapping):
|
|
raise ExecutionRebindError("TASK_EXECUTION_METADATA_MISSING", 409)
|
|
old_execution = validate_execution_envelope(dict(execution))
|
|
if old_execution["execution_digest"] != request.expected_old_execution_digest:
|
|
raise ExecutionRebindError("EXECUTION_REBIND_OLD_DIGEST_DRIFT", 409)
|
|
if old_execution.get("action") != "source_capture":
|
|
raise ExecutionRebindError("EXECUTION_REBIND_SOURCE_CAPTURE_REQUIRED", 409)
|
|
|
|
projection = metadata.get("execution_projection")
|
|
if isinstance(projection, Mapping):
|
|
projected_digest = str(projection.get("execution_digest") or "")
|
|
if projected_digest and projected_digest != request.expected_old_execution_digest:
|
|
raise ExecutionRebindError("EXECUTION_REBIND_PROJECTION_DRIFT", 409)
|
|
return task, columns, metadata_column, metadata, old_execution
|
|
|
|
def rebind(
|
|
self,
|
|
task_id: str,
|
|
request: ExecutionRebindRequest,
|
|
) -> dict[str, Any]:
|
|
task_id = _require_string(task_id, "TASK_ID_INVALID", TASK_RE)
|
|
required_authorization = _required_authorization_id()
|
|
if request.authorization_id != required_authorization:
|
|
raise ExecutionRebindError("EXECUTION_REBIND_AUTHORIZATION_ID_MISMATCH", 403)
|
|
|
|
request_sha = _sha(request.public())
|
|
|
|
# Idempotent replay must win before CAS validation because a successful
|
|
# rebind necessarily increments the Task revision and changes the persisted envelope.
|
|
with self._connect() as connection:
|
|
self._ensure_rebind_table(connection)
|
|
replay = connection.execute(
|
|
"SELECT * FROM task_execution_rebinds WHERE task_id=? AND idempotency_key=?",
|
|
(task_id, request.idempotency_key),
|
|
).fetchone()
|
|
if replay is not None:
|
|
if replay["request_sha256"] != request_sha:
|
|
raise ExecutionRebindError("EXECUTION_REBIND_IDEMPOTENCY_CONFLICT", 409)
|
|
connection.commit()
|
|
return {
|
|
"task_id": task_id,
|
|
"status": "QUEUED",
|
|
"attempt": int(replay["attempt"]),
|
|
"revision": int(replay["applied_revision"]),
|
|
"old_execution_digest": replay["old_execution_digest"],
|
|
"new_execution_digest": replay["new_execution_digest"],
|
|
"authorization_id": replay["authorization_id"],
|
|
"idempotent": True,
|
|
}
|
|
|
|
# Read/CAS snapshot first. Canonical Bridge resolution happens outside the DB write lock.
|
|
with self._connect() as connection:
|
|
task, columns, _, _, old_execution = self._load_and_validate(
|
|
connection, task_id, request
|
|
)
|
|
capabilities = self._required_capabilities(task, columns, old_execution)
|
|
|
|
binding = GovernedExecutionBinding.from_mapping(old_execution)
|
|
resolved = dict(self.resolver(task_id, capabilities))
|
|
new_execution = validate_execution_envelope(resolved, binding=binding)
|
|
if new_execution.get("action") != "deterministic":
|
|
raise ExecutionRebindError("EXECUTION_REBIND_DETERMINISTIC_PLAN_REQUIRED", 409)
|
|
if new_execution["execution_digest"] != request.expected_new_execution_digest:
|
|
raise ExecutionRebindError("EXECUTION_REBIND_NEW_DIGEST_DRIFT", 409)
|
|
if new_execution["execution_digest"] == old_execution["execution_digest"]:
|
|
raise ExecutionRebindError("EXECUTION_REBIND_DIGEST_UNCHANGED", 409)
|
|
|
|
# Re-read under BEGIN IMMEDIATE and apply only if the exact snapshot is unchanged.
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
self._ensure_rebind_table(connection)
|
|
|
|
replay = connection.execute(
|
|
"SELECT * FROM task_execution_rebinds WHERE task_id=? AND idempotency_key=?",
|
|
(task_id, request.idempotency_key),
|
|
).fetchone()
|
|
if replay is not None:
|
|
if replay["request_sha256"] != request_sha:
|
|
raise ExecutionRebindError("EXECUTION_REBIND_IDEMPOTENCY_CONFLICT", 409)
|
|
connection.commit()
|
|
return {
|
|
"task_id": task_id,
|
|
"status": "QUEUED",
|
|
"attempt": int(replay["attempt"]),
|
|
"revision": int(replay["applied_revision"]),
|
|
"old_execution_digest": replay["old_execution_digest"],
|
|
"new_execution_digest": replay["new_execution_digest"],
|
|
"authorization_id": replay["authorization_id"],
|
|
"idempotent": True,
|
|
}
|
|
|
|
task, columns, metadata_column, metadata, current_old = self._load_and_validate(
|
|
connection, task_id, request
|
|
)
|
|
if current_old["execution_digest"] != old_execution["execution_digest"]:
|
|
raise ExecutionRebindError("EXECUTION_REBIND_OLD_DIGEST_DRIFT", 409)
|
|
|
|
new_metadata = dict(metadata)
|
|
new_metadata["execution"] = new_execution
|
|
new_metadata["execution_projection"] = {
|
|
"schema": "aios.online-agent.task-projection.v1",
|
|
"task_id": binding.task_id,
|
|
"job_id": binding.job_id,
|
|
"execution_digest": new_execution["execution_digest"],
|
|
}
|
|
applied_revision = request.expected_revision + 1
|
|
previous_claimed_by = task["claimed_by"]
|
|
|
|
cursor = connection.execute(
|
|
f"""
|
|
UPDATE tasks
|
|
SET "{metadata_column}"=?, revision=?, claimed_by=NULL,
|
|
lease_token_hash=NULL, lease_expires_at=NULL
|
|
WHERE task_id=? AND status=? AND revision=? AND attempt=?
|
|
AND claimed_by IS ? AND lease_token_hash IS NULL AND lease_expires_at IS NULL
|
|
""",
|
|
(
|
|
json.dumps(new_metadata, sort_keys=True, separators=(",", ":")),
|
|
applied_revision,
|
|
task_id,
|
|
request.expected_status,
|
|
request.expected_revision,
|
|
request.expected_attempt,
|
|
previous_claimed_by,
|
|
),
|
|
)
|
|
if cursor.rowcount != 1:
|
|
raise ExecutionRebindError("EXECUTION_REBIND_CAS_MISMATCH", 409)
|
|
|
|
event_payload = {
|
|
"schema": "aios.task-execution-rebind.event.v1",
|
|
"task_id": task_id,
|
|
"job_id": binding.job_id,
|
|
"authorization_id": request.authorization_id,
|
|
"idempotency_key": request.idempotency_key,
|
|
"reason": request.reason,
|
|
"previous_verification_sha256": request.previous_verification_sha256,
|
|
"old_execution_digest": old_execution["execution_digest"],
|
|
"new_execution_digest": new_execution["execution_digest"],
|
|
"old_action": old_execution.get("action"),
|
|
"new_action": new_execution.get("action"),
|
|
"previous_revision": request.expected_revision,
|
|
"applied_revision": applied_revision,
|
|
"attempt": request.expected_attempt,
|
|
"request_sha256": request_sha,
|
|
"previous_claimed_by": previous_claimed_by,
|
|
"stale_claim_normalized": previous_claimed_by not in (None, ""),
|
|
}
|
|
self._insert_event(connection, task_id, event_payload)
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO task_execution_rebinds(
|
|
task_id,idempotency_key,request_sha256,authorization_id,
|
|
previous_verification_sha256,old_execution_digest,new_execution_digest,
|
|
expected_revision,applied_revision,attempt,reason,created_at
|
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
|
""",
|
|
(
|
|
task_id,
|
|
request.idempotency_key,
|
|
request_sha,
|
|
request.authorization_id,
|
|
request.previous_verification_sha256,
|
|
old_execution["execution_digest"],
|
|
new_execution["execution_digest"],
|
|
request.expected_revision,
|
|
applied_revision,
|
|
request.expected_attempt,
|
|
request.reason,
|
|
_now(),
|
|
),
|
|
)
|
|
connection.commit()
|
|
return {
|
|
"task_id": task_id,
|
|
"status": "QUEUED",
|
|
"attempt": request.expected_attempt,
|
|
"revision": applied_revision,
|
|
"old_execution_digest": old_execution["execution_digest"],
|
|
"new_execution_digest": new_execution["execution_digest"],
|
|
"authorization_id": request.authorization_id,
|
|
"idempotent": False,
|
|
}
|
|
|
|
|
|
class ExecutionRebindHandler(BaseHTTPRequestHandler):
|
|
store: ExecutionRebindStore | None = None
|
|
control_token: str = ""
|
|
|
|
def log_message(self, *_args: Any) -> None:
|
|
return
|
|
|
|
def _json(self, value: Mapping[str, Any], status: int) -> None:
|
|
data = json.dumps(
|
|
dict(value), sort_keys=True, separators=(",", ":")
|
|
).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def _authorized(self) -> bool:
|
|
header = self.headers.get("Authorization", "")
|
|
return (
|
|
header.startswith("Bearer ")
|
|
and bool(self.control_token)
|
|
and hmac.compare_digest(header[7:], self.control_token)
|
|
)
|
|
|
|
def do_GET(self) -> None:
|
|
if self.path == "/health":
|
|
self._json(
|
|
{"status": "ok", "service": "aios-agent-task-execution-rebind"},
|
|
200,
|
|
)
|
|
else:
|
|
self._json({"error": "NOT_FOUND"}, 404)
|
|
|
|
def do_POST(self) -> None:
|
|
match = re.fullmatch(r"/v1/tasks/([^/]+)/execution-rebind", self.path)
|
|
if match is None:
|
|
self._json({"error": "NOT_FOUND"}, 404)
|
|
return
|
|
if not self._authorized():
|
|
self._json({"error": "UNAUTHORIZED"}, 401)
|
|
return
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
except ValueError:
|
|
self._json({"error": "CONTENT_LENGTH_INVALID"}, 400)
|
|
return
|
|
if length < 1 or length > MAX_BODY:
|
|
self._json({"error": "REQUEST_SIZE_INVALID"}, 413)
|
|
return
|
|
try:
|
|
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
if not isinstance(value, Mapping):
|
|
raise ValueError
|
|
request = ExecutionRebindRequest.from_mapping(value)
|
|
if self.store is None:
|
|
raise ExecutionRebindError("RUNTIME_UNAVAILABLE", 503)
|
|
result = self.store.rebind(match.group(1), request)
|
|
self._json(result, 200 if result["idempotent"] else 201)
|
|
except ExecutionRebindError as exc:
|
|
self._json({"error": exc.code}, exc.status)
|
|
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
self._json({"error": "REQUEST_INVALID"}, 400)
|
|
|
|
|
|
def load_control_token(path: Path) -> str:
|
|
if path.is_symlink() or not path.is_file():
|
|
raise RuntimeError("TASK_EXECUTION_REBIND_CONTROL_CREDENTIAL_UNAVAILABLE")
|
|
value = path.read_text(encoding="utf-8").strip()
|
|
if len(value) < 32:
|
|
raise RuntimeError("TASK_EXECUTION_REBIND_CONTROL_CREDENTIAL_INVALID")
|
|
return value
|
|
|
|
|
|
def serve() -> None:
|
|
db = Path(os.environ.get("AIOS_AGENT_TASK_DB", "/var/lib/aios-agent-task/state.sqlite3"))
|
|
_required_authorization_id()
|
|
token_path = Path(
|
|
os.environ.get(
|
|
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
|
str(
|
|
Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
|
/ "agent_task_control_token"
|
|
),
|
|
)
|
|
)
|
|
token = load_control_token(token_path)
|
|
bridge = os.environ.get(
|
|
"AIOS_GOVERNED_WRITE_API_URL",
|
|
"http://127.0.0.1:18892",
|
|
)
|
|
host = os.environ.get("AIOS_TASK_EXECUTION_REBIND_HOST", "127.0.0.1")
|
|
port = int(os.environ.get("AIOS_TASK_EXECUTION_REBIND_PORT", "18942"))
|
|
ExecutionRebindHandler.store = ExecutionRebindStore(
|
|
db,
|
|
CanonicalBridgeExecutionResolver(bridge, token),
|
|
)
|
|
ExecutionRebindHandler.control_token = token
|
|
ThreadingHTTPServer((host, port), ExecutionRebindHandler).serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
serve()
|