Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4d82e5ff2 | |||
| e8e4f6f7c7 | |||
| 5f0fc0d540 | |||
| 244ad6a914 | |||
| 29904609da | |||
| 3067b067d2 | |||
| 212551df9a | |||
| f1b8bbdfa1 | |||
| 08918f9a8a | |||
| 9af02831ea |
+6
-2
@@ -18,7 +18,7 @@
|
|||||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
# /git-gate-entrypoint.sh docker-cp'd at start time
|
||||||
# /git-gate/creds/* docker-cp'd at start time
|
# /git-gate/creds/* docker-cp'd at start time
|
||||||
# /git/* bare repos, populated at runtime
|
# /git/* bare repos, populated at runtime
|
||||||
# /run/supervise/queue/ bind-mounted at run time
|
# /run/supervise/bot-bottle.db bind-mounted at run time
|
||||||
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
||||||
#
|
#
|
||||||
# Exposed ports inside the container:
|
# Exposed ports inside the container:
|
||||||
@@ -66,6 +66,10 @@ COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
|
|||||||
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
||||||
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
||||||
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
||||||
|
COPY bot_bottle/migrations.py /app/migrations.py
|
||||||
|
COPY bot_bottle/db_store.py /app/db_store.py
|
||||||
|
COPY bot_bottle/queue_store.py /app/queue_store.py
|
||||||
|
COPY bot_bottle/audit_store.py /app/audit_store.py
|
||||||
COPY bot_bottle/supervise.py /app/supervise.py
|
COPY bot_bottle/supervise.py /app/supervise.py
|
||||||
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
||||||
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
||||||
@@ -81,7 +85,7 @@ RUN mkdir -p \
|
|||||||
/etc/git-gate \
|
/etc/git-gate \
|
||||||
/git-gate/creds \
|
/git-gate/creds \
|
||||||
/git \
|
/git \
|
||||||
/run/supervise/queue \
|
/run/supervise \
|
||||||
/home/mitmproxy/.mitmproxy
|
/home/mitmproxy/.mitmproxy
|
||||||
|
|
||||||
# Documentation only — the compose renderer publishes whichever
|
# Documentation only — the compose renderer publishes whichever
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""SQLite-backed audit store for supervise (PRD 0013)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .supervise import AuditEntry
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .db_store import DbStore
|
||||||
|
from .migrations import TableMigrations
|
||||||
|
except ImportError:
|
||||||
|
from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
|
from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
|
|
||||||
|
|
||||||
|
def get_supervise_mod() -> object:
|
||||||
|
"""Lazy import of supervise to avoid a circular-import at module init time.
|
||||||
|
Mirrors our own module identity so patches on supervise.bot_bottle_root
|
||||||
|
propagate correctly in both flat (sidecar / sys.path-injection tests) and
|
||||||
|
package contexts."""
|
||||||
|
import sys
|
||||||
|
sv_name = "supervise" if __name__ == "audit_store" else "bot_bottle.supervise"
|
||||||
|
if sv_name in sys.modules:
|
||||||
|
return sys.modules[sv_name]
|
||||||
|
try:
|
||||||
|
import bot_bottle.supervise as _m
|
||||||
|
except ImportError:
|
||||||
|
import supervise as _m # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
|
return _m
|
||||||
|
|
||||||
|
|
||||||
|
# One entry per schema version: _MIGRATIONS.migrations[0] brings a fresh DB
|
||||||
|
# to version 1, [1] to version 2, and so on. Add new migrations at the end;
|
||||||
|
# never edit existing ones.
|
||||||
|
_MIGRATIONS = TableMigrations("audit_store", [
|
||||||
|
# v1 — initial schema
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS supervise_audit_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
bottle_slug TEXT NOT NULL,
|
||||||
|
component TEXT NOT NULL,
|
||||||
|
operator_action TEXT NOT NULL,
|
||||||
|
operator_notes TEXT NOT NULL,
|
||||||
|
justification TEXT NOT NULL,
|
||||||
|
diff TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
class AuditStore(DbStore):
|
||||||
|
"""SQLite-backed persistent store for supervise audit entries."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path | None = None) -> None:
|
||||||
|
resolved = db_path or get_supervise_mod().host_db_path() # type: ignore[attr-defined]
|
||||||
|
super().__init__(resolved, _MIGRATIONS)
|
||||||
|
|
||||||
|
def write_audit_entry(self, entry: AuditEntry) -> Path:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO supervise_audit_entries (
|
||||||
|
timestamp, bottle_slug, component, operator_action,
|
||||||
|
operator_notes, justification, diff
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
entry.timestamp,
|
||||||
|
entry.bottle_slug,
|
||||||
|
entry.component,
|
||||||
|
entry.operator_action,
|
||||||
|
entry.operator_notes,
|
||||||
|
entry.justification,
|
||||||
|
entry.diff,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._chmod()
|
||||||
|
return self.db_path
|
||||||
|
|
||||||
|
def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]:
|
||||||
|
if not self.db_path.is_file():
|
||||||
|
return []
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM supervise_audit_entries
|
||||||
|
WHERE component = ? AND bottle_slug = ?
|
||||||
|
ORDER BY id
|
||||||
|
""",
|
||||||
|
(component, slug),
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_entry(row) for row in rows]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_entry(row: sqlite3.Row) -> AuditEntry:
|
||||||
|
m = get_supervise_mod()
|
||||||
|
return m.AuditEntry( # type: ignore[attr-defined]
|
||||||
|
timestamp=row["timestamp"],
|
||||||
|
bottle_slug=row["bottle_slug"],
|
||||||
|
component=row["component"],
|
||||||
|
operator_action=row["operator_action"],
|
||||||
|
operator_notes=row["operator_notes"],
|
||||||
|
justification=row["justification"],
|
||||||
|
diff=row["diff"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AuditStore"]
|
||||||
@@ -34,7 +34,7 @@ from ...egress import (
|
|||||||
from ...git_gate import GIT_GATE_HOSTNAME
|
from ...git_gate import GIT_GATE_HOSTNAME
|
||||||
from ...log import die, warn
|
from ...log import die, warn
|
||||||
from ...supervise import (
|
from ...supervise import (
|
||||||
QUEUE_DIR_IN_CONTAINER,
|
DB_PATH_IN_CONTAINER,
|
||||||
SUPERVISE_HOSTNAME,
|
SUPERVISE_HOSTNAME,
|
||||||
SUPERVISE_PORT,
|
SUPERVISE_PORT,
|
||||||
)
|
)
|
||||||
@@ -163,16 +163,15 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
if sp is not None:
|
if sp is not None:
|
||||||
env += [
|
env += [
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||||
]
|
]
|
||||||
volumes.append({
|
volumes.append({
|
||||||
"type": "bind",
|
"type": "bind",
|
||||||
"source": str(sp.queue_dir),
|
"source": str(sp.db_path),
|
||||||
"target": QUEUE_DIR_IN_CONTAINER,
|
"target": DB_PATH_IN_CONTAINER,
|
||||||
"read_only": False,
|
"read_only": False,
|
||||||
})
|
})
|
||||||
|
|
||||||
internal_aliases = [EGRESS_HOSTNAME]
|
internal_aliases = [EGRESS_HOSTNAME]
|
||||||
if gp.upstreams:
|
if gp.upstreams:
|
||||||
internal_aliases.append(GIT_GATE_HOSTNAME)
|
internal_aliases.append(GIT_GATE_HOSTNAME)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from ...git_gate import (
|
|||||||
revoke_git_gate_provisioned_keys,
|
revoke_git_gate_provisioned_keys,
|
||||||
)
|
)
|
||||||
from ...log import die, info, warn
|
from ...log import die, info, warn
|
||||||
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
|
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
|
||||||
from ...util import expand_tilde
|
from ...util import expand_tilde
|
||||||
from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
|
from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
|
||||||
from ..docker.git_gate import (
|
from ..docker.git_gate import (
|
||||||
@@ -379,7 +379,7 @@ def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
|||||||
if plan.supervise_plan is not None:
|
if plan.supervise_plan is not None:
|
||||||
env += [
|
env += [
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||||
]
|
]
|
||||||
return tuple(env)
|
return tuple(env)
|
||||||
@@ -405,7 +405,7 @@ def _sidecar_mounts(
|
|||||||
|
|
||||||
sp = plan.supervise_plan
|
sp = plan.supervise_plan
|
||||||
if sp is not None:
|
if sp is not None:
|
||||||
mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
mounts.append((str(sp.db_path), DB_PATH_IN_CONTAINER, False))
|
||||||
|
|
||||||
return tuple(mounts)
|
return tuple(mounts)
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from ...egress import (
|
|||||||
egress_resolve_token_values,
|
egress_resolve_token_values,
|
||||||
egress_sidecar_env_entries,
|
egress_sidecar_env_entries,
|
||||||
)
|
)
|
||||||
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
|
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
|
||||||
from ...util import expand_tilde
|
from ...util import expand_tilde
|
||||||
from ..docker import util as docker_mod
|
from ..docker import util as docker_mod
|
||||||
from ..docker.egress import (
|
from ..docker.egress import (
|
||||||
@@ -369,10 +369,10 @@ def _bundle_launch_spec(
|
|||||||
daemons.append("supervise")
|
daemons.append("supervise")
|
||||||
env += [
|
env += [
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||||
]
|
]
|
||||||
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
volumes.append((str(sp.db_path), DB_PATH_IN_CONTAINER, False))
|
||||||
|
|
||||||
# Container ports the agent reaches from the smolvm guest —
|
# Container ports the agent reaches from the smolvm guest —
|
||||||
# published on host loopback so the guest can dial via TSI +
|
# published on host loopback so the guest can dial via TSI +
|
||||||
|
|||||||
@@ -284,9 +284,8 @@ def git_gate_state_dir(identity: str) -> Path:
|
|||||||
|
|
||||||
def supervise_state_dir(identity: str) -> Path:
|
def supervise_state_dir(identity: str) -> Path:
|
||||||
"""State subdir reserved for supervise sidecar bind-mount sources.
|
"""State subdir reserved for supervise sidecar bind-mount sources.
|
||||||
The queue dir is intentionally NOT under here — it lives at
|
Runtime queue/audit rows live in the host-level bot-bottle SQLite
|
||||||
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it
|
database, so they survive state-dir cleanup."""
|
||||||
survives state-dir cleanup."""
|
|
||||||
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
|
return bottle_state_dir(identity) / _SUPERVISE_SUBDIR
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ from ..supervise import (
|
|||||||
TOOL_EGRESS_BLOCK,
|
TOOL_EGRESS_BLOCK,
|
||||||
TOOL_GITLEAKS_ALLOW,
|
TOOL_GITLEAKS_ALLOW,
|
||||||
TOOL_EGRESS_TOKEN_ALLOW,
|
TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
list_pending_proposals,
|
list_all_pending_proposals,
|
||||||
render_diff,
|
render_diff,
|
||||||
write_audit_entry,
|
write_audit_entry,
|
||||||
write_response,
|
write_response,
|
||||||
@@ -63,10 +63,9 @@ _REPORT_ONLY_TOOLS: tuple[str, ...] = (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_AL
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class QueuedProposal:
|
class QueuedProposal:
|
||||||
"""A pending proposal plus the queue dir it was found in."""
|
"""A pending proposal from the supervise queue."""
|
||||||
|
|
||||||
proposal: Proposal
|
proposal: Proposal
|
||||||
queue_dir: Path
|
|
||||||
|
|
||||||
|
|
||||||
# Errors any remediation engine may raise. Caught by the TUI key
|
# Errors any remediation engine may raise. Caught by the TUI key
|
||||||
@@ -86,16 +85,11 @@ def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def discover_pending() -> list[QueuedProposal]:
|
def discover_pending() -> list[QueuedProposal]:
|
||||||
"""Walk ~/.bot-bottle/queue/* and collect pending proposals."""
|
"""Collect pending proposals across bottles."""
|
||||||
queue_root = _supervise.bot_bottle_root() / "queue"
|
out = [
|
||||||
if not queue_root.is_dir():
|
QueuedProposal(proposal=proposal)
|
||||||
return []
|
for proposal in list_all_pending_proposals()
|
||||||
out: list[QueuedProposal] = []
|
]
|
||||||
for slug_dir in sorted(queue_root.iterdir()):
|
|
||||||
if not slug_dir.is_dir():
|
|
||||||
continue
|
|
||||||
for proposal in list_pending_proposals(slug_dir):
|
|
||||||
out.append(QueuedProposal(proposal=proposal, queue_dir=slug_dir))
|
|
||||||
out.sort(key=lambda q: q.proposal.arrival_timestamp)
|
out.sort(key=lambda q: q.proposal.arrival_timestamp)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -118,7 +112,6 @@ def _detail_lines(
|
|||||||
(f"tool: {p.tool}", 0),
|
(f"tool: {p.tool}", 0),
|
||||||
(f"id: {p.id}", 0),
|
(f"id: {p.id}", 0),
|
||||||
(f"arrived: {p.arrival_timestamp}", 0),
|
(f"arrived: {p.arrival_timestamp}", 0),
|
||||||
(f"queue: {qp.queue_dir}", 0),
|
|
||||||
("", 0),
|
("", 0),
|
||||||
("justification:", 0),
|
("justification:", 0),
|
||||||
]
|
]
|
||||||
@@ -165,7 +158,7 @@ def approve(
|
|||||||
notes=notes,
|
notes=notes,
|
||||||
final_file=final_file,
|
final_file=final_file,
|
||||||
)
|
)
|
||||||
write_response(qp.queue_dir, response)
|
write_response(qp.proposal.bottle_slug, response)
|
||||||
_write_audit(
|
_write_audit(
|
||||||
qp, action=status, notes=notes,
|
qp, action=status, notes=notes,
|
||||||
diff_before=diff_before, diff_after=diff_after,
|
diff_before=diff_before, diff_after=diff_after,
|
||||||
@@ -179,7 +172,7 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
|
|||||||
notes=reason,
|
notes=reason,
|
||||||
final_file=None,
|
final_file=None,
|
||||||
)
|
)
|
||||||
write_response(qp.queue_dir, response)
|
write_response(qp.proposal.bottle_slug, response)
|
||||||
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
|
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
"""Scoped forge wrapper: read-anywhere / write-scoped access control.
|
|
||||||
|
|
||||||
`ScopedForge` wraps any forge object and restricts write operations to
|
|
||||||
the set of issue/PR numbers the agent is explicitly assigned to. Read
|
|
||||||
operations always pass through unconditionally.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
class ScopedForge:
|
|
||||||
"""Delegates all forge calls to an inner forge, raising `PermissionError`
|
|
||||||
on write calls for numbers outside the assigned scope."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
forge: Any,
|
|
||||||
*,
|
|
||||||
assigned_issue: int,
|
|
||||||
assigned_prs: list[int],
|
|
||||||
) -> None:
|
|
||||||
self._forge = forge
|
|
||||||
self._allowed_writes: frozenset[int] = frozenset({assigned_issue, *assigned_prs})
|
|
||||||
|
|
||||||
def _check_write(self, number: int) -> None:
|
|
||||||
if number not in self._allowed_writes:
|
|
||||||
raise PermissionError(
|
|
||||||
f"write to #{number} is outside the assigned scope "
|
|
||||||
f"(allowed: {sorted(self._allowed_writes)})"
|
|
||||||
)
|
|
||||||
|
|
||||||
def is_org_member(self, org: str, username: str) -> bool:
|
|
||||||
return self._forge.is_org_member(org, username)
|
|
||||||
|
|
||||||
def read_issue(self, number: int) -> dict[str, Any]:
|
|
||||||
return self._forge.read_issue(number)
|
|
||||||
|
|
||||||
def read_pr(self, number: int) -> dict[str, Any]:
|
|
||||||
return self._forge.read_pr(number)
|
|
||||||
|
|
||||||
def read_comments(self, number: int) -> list[dict[str, Any]]:
|
|
||||||
return self._forge.read_comments(number)
|
|
||||||
|
|
||||||
def post_comment(self, number: int, body: str) -> None:
|
|
||||||
self._check_write(number)
|
|
||||||
self._forge.post_comment(number, body)
|
|
||||||
|
|
||||||
def update_description(self, number: int, body: str) -> None:
|
|
||||||
self._check_write(number)
|
|
||||||
self._forge.update_description(number, body)
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"""Gitea API client and forge adapter (PRD prd-new: fold orchestrator).
|
|
||||||
|
|
||||||
`GiteaClient` is a thin HTTP wrapper (stdlib `urllib.request` only — no
|
|
||||||
new runtime dependencies). `GiteaForge` composes a client and exposes
|
|
||||||
the forge protocol used by the orchestrator's sidecar and lifecycle.
|
|
||||||
|
|
||||||
Required Gitea token scopes:
|
|
||||||
- Repository: Read & Write (issues, comments, PR descriptions)
|
|
||||||
- Organization: Read (org membership check)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
_TIMEOUT_SECS = 30
|
|
||||||
|
|
||||||
|
|
||||||
class GiteaClient:
|
|
||||||
"""Low-level HTTP wrapper for the Gitea REST API."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, *, api_url: str, owner: str, repo: str, token: str
|
|
||||||
) -> None:
|
|
||||||
self._base = api_url.rstrip("/")
|
|
||||||
self._owner = owner
|
|
||||||
self._repo = repo
|
|
||||||
self._headers = {
|
|
||||||
"Authorization": f"token {token}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
body: dict[str, Any] | None = None,
|
|
||||||
) -> Any:
|
|
||||||
url = f"{self._base}{path}"
|
|
||||||
data = json.dumps(body).encode() if body is not None else None
|
|
||||||
req = urllib.request.Request(
|
|
||||||
url, data=data, headers=self._headers, method=method
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=_TIMEOUT_SECS) as resp:
|
|
||||||
raw = resp.read()
|
|
||||||
return json.loads(raw) if raw else None
|
|
||||||
|
|
||||||
def is_org_member(self, org: str, username: str) -> bool:
|
|
||||||
url = f"{self._base}/orgs/{org}/members/{username}"
|
|
||||||
req = urllib.request.Request(url, headers=self._headers, method="GET")
|
|
||||||
try:
|
|
||||||
urllib.request.urlopen(req, timeout=_TIMEOUT_SECS).close()
|
|
||||||
return True
|
|
||||||
except urllib.error.HTTPError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_issue(self, number: int) -> dict[str, Any]:
|
|
||||||
return self._request("GET", f"/repos/{self._owner}/{self._repo}/issues/{number}")
|
|
||||||
|
|
||||||
def get_pull(self, number: int) -> dict[str, Any]:
|
|
||||||
return self._request("GET", f"/repos/{self._owner}/{self._repo}/pulls/{number}")
|
|
||||||
|
|
||||||
def list_comments(self, number: int) -> list[dict[str, Any]]:
|
|
||||||
return self._request("GET", f"/repos/{self._owner}/{self._repo}/issues/{number}/comments")
|
|
||||||
|
|
||||||
def create_comment(self, number: int, body: str) -> None:
|
|
||||||
self._request(
|
|
||||||
"POST",
|
|
||||||
f"/repos/{self._owner}/{self._repo}/issues/{number}/comments",
|
|
||||||
{"body": body},
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_issue(self, number: int, body: str) -> None:
|
|
||||||
self._request(
|
|
||||||
"PATCH",
|
|
||||||
f"/repos/{self._owner}/{self._repo}/issues/{number}",
|
|
||||||
{"body": body},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GiteaForge:
|
|
||||||
"""Adapts `GiteaClient` to the forge protocol expected by the orchestrator.
|
|
||||||
|
|
||||||
The forge protocol is duck-typed: any object with `is_org_member`,
|
|
||||||
`read_issue`, `read_pr`, `read_comments`, `post_comment`, and
|
|
||||||
`update_description` methods satisfies it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, client: GiteaClient) -> None:
|
|
||||||
self._client = client
|
|
||||||
|
|
||||||
def is_org_member(self, org: str, username: str) -> bool:
|
|
||||||
return self._client.is_org_member(org, username)
|
|
||||||
|
|
||||||
def read_issue(self, number: int) -> dict[str, Any]:
|
|
||||||
return self._client.get_issue(number)
|
|
||||||
|
|
||||||
def read_pr(self, number: int) -> dict[str, Any]:
|
|
||||||
return self._client.get_pull(number)
|
|
||||||
|
|
||||||
def read_comments(self, number: int) -> list[dict[str, Any]]:
|
|
||||||
return self._client.list_comments(number)
|
|
||||||
|
|
||||||
def post_comment(self, number: int, body: str) -> None:
|
|
||||||
self._client.create_comment(number, body)
|
|
||||||
|
|
||||||
def update_description(self, number: int, body: str) -> None:
|
|
||||||
self._client.update_issue(number, body)
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"""Forge state persistence for the orchestrator (PRD prd-new: fold orchestrator).
|
|
||||||
|
|
||||||
`ForgeState` is a dataclass that mirrors the orchestrator's `RunRecord`
|
|
||||||
field-for-field, held here so the store implementation is in bot-bottle
|
|
||||||
where the Gitea contrib lives.
|
|
||||||
|
|
||||||
`SqliteForgeStateStore` backs it with a single SQLite table. The DB path
|
|
||||||
is optional; passing `None` uses `:memory:` (useful for tests and status
|
|
||||||
commands that don't need persistence).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ForgeState:
|
|
||||||
"""Persisted state for one forge-targeted issue's bottle lifecycle."""
|
|
||||||
|
|
||||||
owner: str
|
|
||||||
repo: str
|
|
||||||
issue_number: int
|
|
||||||
slug: str
|
|
||||||
agent_name: str
|
|
||||||
bottle_names: list[str] = field(default_factory=list)
|
|
||||||
backend_name: str = ""
|
|
||||||
agent_git_user: str = ""
|
|
||||||
pr_number: int | None = None
|
|
||||||
status: str = ""
|
|
||||||
last_checkin_at: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
_DDL = """
|
|
||||||
CREATE TABLE IF NOT EXISTS forge_state (
|
|
||||||
owner TEXT NOT NULL,
|
|
||||||
repo TEXT NOT NULL,
|
|
||||||
issue_number INTEGER NOT NULL,
|
|
||||||
slug TEXT NOT NULL,
|
|
||||||
agent_name TEXT NOT NULL,
|
|
||||||
bottle_names TEXT NOT NULL DEFAULT '[]',
|
|
||||||
backend_name TEXT NOT NULL DEFAULT '',
|
|
||||||
agent_git_user TEXT NOT NULL DEFAULT '',
|
|
||||||
pr_number INTEGER,
|
|
||||||
status TEXT NOT NULL DEFAULT '',
|
|
||||||
last_checkin_at TEXT NOT NULL DEFAULT '',
|
|
||||||
PRIMARY KEY (owner, repo, issue_number)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class SqliteForgeStateStore:
|
|
||||||
"""SQLite-backed `ForgeState` store.
|
|
||||||
|
|
||||||
Thread-safety: a single connection is used; callers that share a
|
|
||||||
store across threads must serialise access externally.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, db_path: Path | None) -> None:
|
|
||||||
path = str(db_path) if db_path is not None else ":memory:"
|
|
||||||
self._conn = sqlite3.connect(path, check_same_thread=False)
|
|
||||||
self._conn.row_factory = sqlite3.Row
|
|
||||||
self._conn.execute(_DDL)
|
|
||||||
self._conn.commit()
|
|
||||||
|
|
||||||
def upsert(self, state: ForgeState) -> None:
|
|
||||||
self._conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO forge_state
|
|
||||||
(owner, repo, issue_number, slug, agent_name,
|
|
||||||
bottle_names, backend_name, agent_git_user,
|
|
||||||
pr_number, status, last_checkin_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(owner, repo, issue_number) DO UPDATE SET
|
|
||||||
slug = excluded.slug,
|
|
||||||
agent_name = excluded.agent_name,
|
|
||||||
bottle_names = excluded.bottle_names,
|
|
||||||
backend_name = excluded.backend_name,
|
|
||||||
agent_git_user = excluded.agent_git_user,
|
|
||||||
pr_number = excluded.pr_number,
|
|
||||||
status = excluded.status,
|
|
||||||
last_checkin_at = excluded.last_checkin_at
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
state.owner,
|
|
||||||
state.repo,
|
|
||||||
state.issue_number,
|
|
||||||
state.slug,
|
|
||||||
state.agent_name,
|
|
||||||
json.dumps(state.bottle_names),
|
|
||||||
state.backend_name,
|
|
||||||
state.agent_git_user,
|
|
||||||
state.pr_number,
|
|
||||||
state.status,
|
|
||||||
state.last_checkin_at,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self._conn.commit()
|
|
||||||
|
|
||||||
def get(self, owner: str, repo: str, issue_number: int) -> ForgeState | None:
|
|
||||||
row = self._conn.execute(
|
|
||||||
"SELECT * FROM forge_state WHERE owner=? AND repo=? AND issue_number=?",
|
|
||||||
(owner, repo, issue_number),
|
|
||||||
).fetchone()
|
|
||||||
return _row_to_state(row) if row is not None else None
|
|
||||||
|
|
||||||
def delete(self, owner: str, repo: str, issue_number: int) -> None:
|
|
||||||
self._conn.execute(
|
|
||||||
"DELETE FROM forge_state WHERE owner=? AND repo=? AND issue_number=?",
|
|
||||||
(owner, repo, issue_number),
|
|
||||||
)
|
|
||||||
self._conn.commit()
|
|
||||||
|
|
||||||
def all(self) -> list[ForgeState]:
|
|
||||||
rows = self._conn.execute(
|
|
||||||
"SELECT * FROM forge_state ORDER BY owner, repo, issue_number"
|
|
||||||
).fetchall()
|
|
||||||
return [_row_to_state(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def _row_to_state(row: sqlite3.Row) -> ForgeState:
|
|
||||||
return ForgeState(
|
|
||||||
owner=row["owner"],
|
|
||||||
repo=row["repo"],
|
|
||||||
issue_number=row["issue_number"],
|
|
||||||
slug=row["slug"],
|
|
||||||
agent_name=row["agent_name"],
|
|
||||||
bottle_names=json.loads(row["bottle_names"]),
|
|
||||||
backend_name=row["backend_name"],
|
|
||||||
agent_git_user=row["agent_git_user"],
|
|
||||||
pr_number=row["pr_number"],
|
|
||||||
status=row["status"],
|
|
||||||
last_checkin_at=row["last_checkin_at"],
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Shared SQLite-backed store base class for bot-bottle (PRD 0013)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .migrations import TableMigrations
|
||||||
|
except ImportError:
|
||||||
|
from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
|
|
||||||
|
|
||||||
|
class DbStore:
|
||||||
|
"""Base for SQLite-backed stores. Subclasses resolve db_path then call super().__init__."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path, migrations: TableMigrations) -> None:
|
||||||
|
self.db_path = db_path
|
||||||
|
self._migrations = migrations
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._init()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _init(self) -> None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
self._migrations.apply(conn)
|
||||||
|
self._chmod()
|
||||||
|
|
||||||
|
def _chmod(self) -> None:
|
||||||
|
try:
|
||||||
|
self.db_path.chmod(0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["DbStore"]
|
||||||
@@ -79,14 +79,13 @@ class EgressAddon:
|
|||||||
# only — a restart re-prompts. Mutated only from the asyncio loop that
|
# only — a restart re-prompts. Mutated only from the asyncio loop that
|
||||||
# runs the addon hooks, so no lock is needed.
|
# runs the addon hooks, so no lock is needed.
|
||||||
self.safe_tokens: set[str] = set()
|
self.safe_tokens: set[str] = set()
|
||||||
self._supervise_queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "").strip()
|
|
||||||
self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
|
self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
|
||||||
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
|
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
|
||||||
self._reload(initial=True)
|
self._reload(initial=True)
|
||||||
self._install_sighup()
|
self._install_sighup()
|
||||||
|
|
||||||
def _supervise_available(self) -> bool:
|
def _supervise_available(self) -> bool:
|
||||||
return bool(self._supervise_queue_dir and self._supervise_slug)
|
return bool(self._supervise_slug)
|
||||||
|
|
||||||
def _reload(self, *, initial: bool = False) -> None:
|
def _reload(self, *, initial: bool = False) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -393,9 +392,8 @@ class EgressAddon:
|
|||||||
justification=_TOKEN_ALLOW_JUSTIFICATION,
|
justification=_TOKEN_ALLOW_JUSTIFICATION,
|
||||||
current_file_hash=_sv.sha256_hex(payload),
|
current_file_hash=_sv.sha256_hex(payload),
|
||||||
)
|
)
|
||||||
queue_dir = Path(self._supervise_queue_dir)
|
|
||||||
try:
|
try:
|
||||||
_sv.write_proposal(queue_dir, proposal)
|
_sv.write_proposal(proposal)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"egress: could not queue token-allow proposal: {e}; "
|
f"egress: could not queue token-allow proposal: {e}; "
|
||||||
@@ -411,8 +409,8 @@ class EgressAddon:
|
|||||||
**self._req_ctx(flow),
|
**self._req_ctx(flow),
|
||||||
}) + "\n")
|
}) + "\n")
|
||||||
|
|
||||||
response = await self._await_token_response(queue_dir, proposal.id)
|
response = await self._await_token_response(proposal.id)
|
||||||
_sv.archive_proposal(queue_dir, proposal.id)
|
_sv.archive_proposal(self._supervise_slug, proposal.id)
|
||||||
|
|
||||||
if response is not None and response.status in (
|
if response is not None and response.status in (
|
||||||
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
|
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
|
||||||
@@ -439,16 +437,15 @@ class EgressAddon:
|
|||||||
|
|
||||||
async def _await_token_response(
|
async def _await_token_response(
|
||||||
self,
|
self,
|
||||||
queue_dir: Path,
|
|
||||||
proposal_id: str,
|
proposal_id: str,
|
||||||
) -> "_sv.Response | None":
|
) -> "_sv.Response | None":
|
||||||
"""Poll the queue dir for the operator's response without blocking the
|
"""Poll the DB for the operator's response without blocking the
|
||||||
proxy event loop. Returns the Response, or None on timeout."""
|
proxy event loop. Returns the Response, or None on timeout."""
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
deadline = loop.time() + self._token_allow_timeout
|
deadline = loop.time() + self._token_allow_timeout
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
return _sv.read_response(queue_dir, proposal_id)
|
return _sv.read_response(self._supervise_slug, proposal_id)
|
||||||
except (OSError, ValueError, KeyError):
|
except (OSError, ValueError, KeyError):
|
||||||
# Not written yet, or a partial/malformed write — retry until
|
# Not written yet, or a partial/malformed write — retry until
|
||||||
# the deadline, then fail closed.
|
# the deadline, then fail closed.
|
||||||
|
|||||||
@@ -234,13 +234,13 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bot_bottle import supervise as _sv
|
||||||
|
|
||||||
report_path = Path(sys.argv[1])
|
report_path = Path(sys.argv[1])
|
||||||
queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "")
|
|
||||||
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
||||||
if not queue_dir or not slug:
|
if not slug:
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -277,31 +277,19 @@ for i, finding in enumerate(raw, 1):
|
|||||||
])
|
])
|
||||||
|
|
||||||
payload = "\n".join(lines).rstrip() + "\n"
|
payload = "\n".join(lines).rstrip() + "\n"
|
||||||
proposal_id = str(uuid.uuid4())
|
proposal = _sv.Proposal.new(
|
||||||
proposal = {
|
bottle_slug=slug,
|
||||||
"id": proposal_id,
|
tool=_sv.TOOL_GITLEAKS_ALLOW,
|
||||||
"bottle_slug": slug,
|
proposed_file=payload,
|
||||||
"tool": "gitleaks-allow",
|
justification=(
|
||||||
"proposed_file": payload,
|
|
||||||
"justification": (
|
|
||||||
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
|
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
|
||||||
"approve only for dummy test fixtures or confirmed false positives"
|
"approve only for dummy test fixtures or confirmed false positives"
|
||||||
),
|
),
|
||||||
"arrival_timestamp": datetime.datetime.now(
|
current_file_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
||||||
datetime.timezone.utc
|
now=datetime.datetime.now(datetime.timezone.utc),
|
||||||
).isoformat(),
|
)
|
||||||
"current_file_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
_sv.write_proposal(proposal)
|
||||||
}
|
print(proposal.id)
|
||||||
queue = Path(queue_dir)
|
|
||||||
queue.mkdir(parents=True, exist_ok=True)
|
|
||||||
path = queue / f"{proposal_id}.proposal.json"
|
|
||||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
||||||
with tmp.open("w", encoding="utf-8") as f:
|
|
||||||
json.dump(proposal, f, indent=2)
|
|
||||||
f.write("\n")
|
|
||||||
os.chmod(tmp, 0o600)
|
|
||||||
os.replace(tmp, path)
|
|
||||||
print(proposal_id)
|
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
rc=$?
|
rc=$?
|
||||||
@@ -314,8 +302,7 @@ PY
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
queue_dir=${SUPERVISE_QUEUE_DIR:-}
|
slug=${SUPERVISE_BOTTLE_SLUG:-}
|
||||||
response_file="$queue_dir/${proposal_id}.response.json"
|
|
||||||
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
|
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
|
||||||
case "$timeout" in
|
case "$timeout" in
|
||||||
''|*[!0-9]*)
|
''|*[!0-9]*)
|
||||||
@@ -327,26 +314,35 @@ PY
|
|||||||
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
|
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
|
||||||
waited=0
|
waited=0
|
||||||
while [ "$waited" -lt "$timeout" ]; do
|
while [ "$waited" -lt "$timeout" ]; do
|
||||||
if [ -f "$response_file" ]; then
|
status=$(python3 - "$slug" "$proposal_id" <<'PY'
|
||||||
status=$(python3 - "$response_file" <<'PY'
|
|
||||||
import json
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from bot_bottle import supervise as _sv
|
||||||
|
|
||||||
|
slug = sys.argv[1]
|
||||||
try:
|
try:
|
||||||
with open(sys.argv[1], encoding="utf-8") as f:
|
response = _sv.read_response(slug, sys.argv[2])
|
||||||
raw = json.load(f)
|
except FileNotFoundError:
|
||||||
except (OSError, json.JSONDecodeError):
|
sys.exit(2)
|
||||||
sys.exit(1)
|
print(response.status)
|
||||||
status = raw.get("status")
|
|
||||||
if not isinstance(status, str):
|
|
||||||
sys.exit(1)
|
|
||||||
print(status)
|
|
||||||
PY
|
PY
|
||||||
) || status=""
|
)
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -eq 2 ]; then
|
||||||
|
status=""
|
||||||
|
elif [ "$rc" -ne 0 ]; then
|
||||||
|
status="invalid"
|
||||||
|
fi
|
||||||
|
if [ -n "$status" ]; then
|
||||||
case "$status" in
|
case "$status" in
|
||||||
approved|modified)
|
approved|modified)
|
||||||
mkdir -p "$queue_dir/processed"
|
python3 - "$slug" "$proposal_id" <<'PY' || true
|
||||||
mv -f "$queue_dir/${proposal_id}.proposal.json" "$queue_dir/processed/" 2>/dev/null || true
|
import sys
|
||||||
mv -f "$queue_dir/${proposal_id}.response.json" "$queue_dir/processed/" 2>/dev/null || true
|
|
||||||
|
from bot_bottle import supervise as _sv
|
||||||
|
|
||||||
|
_sv.archive_proposal(sys.argv[1], sys.argv[2])
|
||||||
|
PY
|
||||||
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
|
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
@@ -499,4 +495,3 @@ if ! git -C "$repo_dir" rev-parse --verify HEAD >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""SQLite migration runner for bot-bottle stores."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
|
||||||
|
class TableMigrations:
|
||||||
|
"""Runs a sequential list of DDL migrations tracked by schema_key in schema_versions."""
|
||||||
|
|
||||||
|
def __init__(self, schema_key: str, migrations: list[str]) -> None:
|
||||||
|
self.schema_key = schema_key
|
||||||
|
self.migrations = migrations
|
||||||
|
|
||||||
|
def apply(self, conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_versions (
|
||||||
|
module TEXT PRIMARY KEY,
|
||||||
|
version INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT version FROM schema_versions WHERE module = ?",
|
||||||
|
(self.schema_key,),
|
||||||
|
).fetchone()
|
||||||
|
version = row[0] if row else 0
|
||||||
|
for i, sql in enumerate(self.migrations[version:], start=version + 1):
|
||||||
|
conn.execute(sql)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO schema_versions (module, version) VALUES (?, ?)",
|
||||||
|
(self.schema_key, i),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["TableMigrations"]
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"""bot-bottle-orchestrator: forge-native orchestration for bot-bottle.
|
|
||||||
|
|
||||||
The package is stdlib-only. The core (events, targeting, lifecycle,
|
|
||||||
watchdog, sidecar, webhook) depends on its collaborators — a forge, a
|
|
||||||
state store, a bottle runner — through duck-typed interfaces, so it runs
|
|
||||||
and tests without bot-bottle installed. `bootstrap` is the single module
|
|
||||||
that imports `bot_bottle` and wires the concrete implementations.
|
|
||||||
"""
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"""CLI entry point: `python -m bot_bottle.orchestrator <command>`.
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
run start the webhook server + watchdog + done-signal relay
|
|
||||||
status print the tracked runs (issue -> slug, status)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from .config import Config
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(prog="python -m bot_bottle.orchestrator")
|
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
|
||||||
sub.add_parser("run", help="start the webhook server, watchdog, and relay")
|
|
||||||
sub.add_parser("status", help="list tracked runs")
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
config = Config.from_env()
|
|
||||||
|
|
||||||
if args.command == "run":
|
|
||||||
from . import bootstrap # pylint: disable=import-outside-toplevel
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"orchestrator listening on "
|
|
||||||
f"http://{config.webhook_host}:{config.webhook_port}/webhook",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
bootstrap.run(config)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.command == "status":
|
|
||||||
from .bootstrap import ( # pylint: disable=import-outside-toplevel
|
|
||||||
BotBottleStateStore,
|
|
||||||
)
|
|
||||||
|
|
||||||
store = BotBottleStateStore(config.db_path)
|
|
||||||
for r in store.all():
|
|
||||||
pr = f"PR#{r.pr_number}" if r.pr_number else "-"
|
|
||||||
print(f"{r.owner}/{r.repo}#{r.issue_number}\t{r.slug}\t{r.status}\t{pr}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
return 2
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
"""Wire the concrete bot-bottle implementations into the core.
|
|
||||||
|
|
||||||
This is the ONLY module that imports from `bot_bottle.contrib`. It adapts
|
|
||||||
`SqliteForgeStateStore` to our `StateStore`, builds `GiteaForge`s (and
|
|
||||||
scope-wrapped forges for sidecars), constructs the `Orchestrator`, and
|
|
||||||
runs the webhook server + watchdog + done-signal relay.
|
|
||||||
|
|
||||||
Imports are direct (no lazy loading) because the orchestrator is now part
|
|
||||||
of the same package installation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from ..contrib.forge.base import ScopedForge
|
|
||||||
from ..contrib.gitea.client import GiteaClient, GiteaForge
|
|
||||||
from ..contrib.gitea.forge_state import ForgeState, SqliteForgeStateStore
|
|
||||||
from .config import Config
|
|
||||||
from .lifecycle import Orchestrator
|
|
||||||
from .model import RunRecord
|
|
||||||
from .runner import ProgrammaticBottleRunner
|
|
||||||
from .sidecar import ForgeSidecar, OpLog, drain_done_events
|
|
||||||
from .watchdog import Watchdog
|
|
||||||
from .webhook import WebhookServer
|
|
||||||
|
|
||||||
_RELAY_TICK_SECS = 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def _token() -> str:
|
|
||||||
tok = os.environ.get("GITEA_TOKEN") or os.environ.get("FORGE_GITEA_TOKEN")
|
|
||||||
if not tok:
|
|
||||||
raise RuntimeError("set GITEA_TOKEN (or FORGE_GITEA_TOKEN)")
|
|
||||||
return tok
|
|
||||||
|
|
||||||
|
|
||||||
class BotBottleStateStore:
|
|
||||||
"""Adapts `SqliteForgeStateStore` to our `StateStore`, translating
|
|
||||||
`RunRecord` <-> `ForgeState` field-for-field."""
|
|
||||||
|
|
||||||
def __init__(self, db_path: Path | None) -> None:
|
|
||||||
self._inner = SqliteForgeStateStore(db_path)
|
|
||||||
|
|
||||||
def upsert(self, record: RunRecord) -> None:
|
|
||||||
self._inner.upsert(_to_forge_state(record))
|
|
||||||
|
|
||||||
def get(self, owner: str, repo: str, issue_number: int) -> RunRecord | None:
|
|
||||||
state = self._inner.get(owner, repo, issue_number)
|
|
||||||
return _to_record(state) if state is not None else None
|
|
||||||
|
|
||||||
def delete(self, owner: str, repo: str, issue_number: int) -> None:
|
|
||||||
self._inner.delete(owner, repo, issue_number)
|
|
||||||
|
|
||||||
def all(self) -> list[RunRecord]:
|
|
||||||
return [_to_record(s) for s in self._inner.all()]
|
|
||||||
|
|
||||||
|
|
||||||
def _to_forge_state(r: RunRecord) -> ForgeState:
|
|
||||||
return ForgeState(
|
|
||||||
owner=r.owner, repo=r.repo, issue_number=r.issue_number, slug=r.slug,
|
|
||||||
agent_name=r.agent_name, bottle_names=list(r.bottle_names),
|
|
||||||
backend_name=r.backend_name, agent_git_user=r.agent_git_user,
|
|
||||||
pr_number=r.pr_number, status=r.status, last_checkin_at=r.last_checkin_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_record(s: ForgeState) -> RunRecord:
|
|
||||||
return RunRecord(
|
|
||||||
owner=s.owner, repo=s.repo, issue_number=s.issue_number, slug=s.slug,
|
|
||||||
agent_name=s.agent_name, bottle_names=list(s.bottle_names),
|
|
||||||
backend_name=s.backend_name, agent_git_user=s.agent_git_user,
|
|
||||||
pr_number=s.pr_number, status=s.status, last_checkin_at=s.last_checkin_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_forge(config: Config, owner: str, repo: str) -> Any:
|
|
||||||
"""A `GiteaForge` bound to one repo."""
|
|
||||||
client = GiteaClient(
|
|
||||||
api_url=config.gitea_api, owner=owner, repo=repo, token=_token()
|
|
||||||
)
|
|
||||||
return GiteaForge(client)
|
|
||||||
|
|
||||||
|
|
||||||
def make_sidecar(
|
|
||||||
config: Config, owner: str, repo: str, issue_number: int, assigned_prs: list[int]
|
|
||||||
) -> ForgeSidecar:
|
|
||||||
"""A scope-enforced sidecar for one run (read-anywhere / write-scoped)."""
|
|
||||||
scoped = ScopedForge(
|
|
||||||
make_forge(config, owner, repo),
|
|
||||||
assigned_issue=issue_number,
|
|
||||||
assigned_prs=assigned_prs,
|
|
||||||
)
|
|
||||||
op_log = OpLog(config.queue_dir / f"{owner}-{repo}-{issue_number}.oplog.jsonl")
|
|
||||||
return ForgeSidecar(
|
|
||||||
forge=scoped,
|
|
||||||
op_log=op_log,
|
|
||||||
queue_dir=config.queue_dir,
|
|
||||||
run_key=(owner, repo, issue_number),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build(config: Config) -> tuple[WebhookServer, Watchdog, Orchestrator]:
|
|
||||||
store = BotBottleStateStore(config.db_path)
|
|
||||||
runner = ProgrammaticBottleRunner()
|
|
||||||
membership_forge = make_forge(config, "_", "_")
|
|
||||||
orchestrator = Orchestrator(
|
|
||||||
forge=membership_forge,
|
|
||||||
store=store,
|
|
||||||
runner=runner,
|
|
||||||
org=config.forge_org,
|
|
||||||
gitea_api=config.gitea_api,
|
|
||||||
forge_env_base={
|
|
||||||
"GITEA_TOKEN": _token(),
|
|
||||||
"FORGE_QUEUE_DIR": str(config.queue_dir),
|
|
||||||
"FORGE_SIDECAR_SOCKET": str(config.sidecar_socket),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
watchdog = Watchdog(
|
|
||||||
store=store, runner=runner, timeout_secs=config.watchdog_timeout_secs
|
|
||||||
)
|
|
||||||
server = WebhookServer(
|
|
||||||
(config.webhook_host, config.webhook_port),
|
|
||||||
orchestrator=orchestrator,
|
|
||||||
store=store,
|
|
||||||
)
|
|
||||||
return server, watchdog, orchestrator
|
|
||||||
|
|
||||||
|
|
||||||
def _relay_loop(config: Config, orchestrator: Orchestrator, stop: threading.Event) -> None:
|
|
||||||
while not stop.wait(_RELAY_TICK_SECS):
|
|
||||||
for ev in drain_done_events(config.queue_dir):
|
|
||||||
orchestrator.on_done_signal(
|
|
||||||
ev["owner"], ev["repo"], int(ev["issue_number"]),
|
|
||||||
str(ev.get("status", "")), str(ev.get("summary", "")),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run(config: Config) -> None:
|
|
||||||
"""Blocking run: webhook server + watchdog + done-signal relay."""
|
|
||||||
server, watchdog, orchestrator = build(config)
|
|
||||||
watchdog.start()
|
|
||||||
stop = threading.Event()
|
|
||||||
relay = threading.Thread(
|
|
||||||
target=_relay_loop, args=(config, orchestrator, stop), daemon=True
|
|
||||||
)
|
|
||||||
relay.start()
|
|
||||||
try:
|
|
||||||
server.serve_forever()
|
|
||||||
finally:
|
|
||||||
stop.set()
|
|
||||||
watchdog.stop()
|
|
||||||
server.server_close()
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Configuration, loaded from the environment (stdlib `os` only).
|
|
||||||
|
|
||||||
Everything the orchestrator needs to run is an env var so a deploy is a
|
|
||||||
process with an environment, no config file to manage. `FORGE_*` names
|
|
||||||
match the bot-bottle forge-native PRD.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# The label that marks an issue as agent-targeted: `bot-bottle:<agent>`.
|
|
||||||
LABEL_PREFIX = "bot-bottle:"
|
|
||||||
# Optional bottle override: `bot-bottle-bottle:<name>`.
|
|
||||||
BOTTLE_LABEL_PREFIX = "bot-bottle-bottle:"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Config:
|
|
||||||
"""Resolved orchestrator configuration."""
|
|
||||||
|
|
||||||
forge_org: str
|
|
||||||
gitea_api: str
|
|
||||||
watchdog_timeout_secs: int
|
|
||||||
webhook_host: str
|
|
||||||
webhook_port: int
|
|
||||||
queue_dir: Path
|
|
||||||
sidecar_socket: Path
|
|
||||||
db_path: Path | None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_env(env: dict[str, str] | None = None) -> "Config":
|
|
||||||
e = os.environ if env is None else env
|
|
||||||
home = Path(e.get("HOME", str(Path.home())))
|
|
||||||
default_root = home / ".bot-bottle"
|
|
||||||
db = e.get("FORGE_DB_PATH")
|
|
||||||
return Config(
|
|
||||||
forge_org=e.get("FORGE_ORG", "bot-bottle"),
|
|
||||||
gitea_api=e.get("FORGE_GITEA_API", ""),
|
|
||||||
watchdog_timeout_secs=int(e.get("FORGE_WATCHDOG_TIMEOUT", "1800")),
|
|
||||||
webhook_host=e.get("FORGE_WEBHOOK_HOST", "127.0.0.1"),
|
|
||||||
webhook_port=int(e.get("FORGE_WEBHOOK_PORT", "8477")),
|
|
||||||
queue_dir=Path(e.get("FORGE_QUEUE_DIR", str(default_root / "forge-queue"))),
|
|
||||||
sidecar_socket=Path(
|
|
||||||
e.get("FORGE_SIDECAR_SOCKET", str(default_root / "forge-sidecar.sock"))
|
|
||||||
),
|
|
||||||
db_path=Path(db) if db else None,
|
|
||||||
)
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"""Parse Gitea webhook payloads into typed `ForgeEvent`s.
|
|
||||||
|
|
||||||
Only the fields the orchestrator acts on are extracted; unknown payloads
|
|
||||||
and event types return None so the webhook layer can ignore them.
|
|
||||||
|
|
||||||
Gitea sends the event kind in the `X-Gitea-Event` header and the payload
|
|
||||||
as JSON. The relevant kinds:
|
|
||||||
|
|
||||||
- `issues` with `action == "assigned"` -> IssueAssigned
|
|
||||||
- `issue_comment` with `action == "created"` -> CommentCreated
|
|
||||||
- `pull_request` with `action == "closed"` -> PullRequestClosed
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .model import CommentCreated, ForgeEvent, IssueAssigned, PullRequestClosed
|
|
||||||
|
|
||||||
|
|
||||||
def _repo_owner(payload: dict[str, Any]) -> tuple[str, str]:
|
|
||||||
repo = payload.get("repository") or {}
|
|
||||||
owner = (repo.get("owner") or {}).get("login", "")
|
|
||||||
return str(owner), str(repo.get("name", ""))
|
|
||||||
|
|
||||||
|
|
||||||
def parse_event(event_kind: str, payload: dict[str, Any]) -> ForgeEvent | None:
|
|
||||||
"""Map (X-Gitea-Event, payload) to a `ForgeEvent`, or None to ignore."""
|
|
||||||
if event_kind == "issues":
|
|
||||||
return _parse_issue(payload)
|
|
||||||
if event_kind == "issue_comment":
|
|
||||||
return _parse_comment(payload)
|
|
||||||
if event_kind == "pull_request":
|
|
||||||
return _parse_pull_request(payload)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_issue(payload: dict[str, Any]) -> IssueAssigned | None:
|
|
||||||
if payload.get("action") != "assigned":
|
|
||||||
return None
|
|
||||||
owner, repo = _repo_owner(payload)
|
|
||||||
issue = payload.get("issue") or {}
|
|
||||||
assignees = tuple(
|
|
||||||
str(a.get("login", "")) for a in (issue.get("assignees") or [])
|
|
||||||
)
|
|
||||||
labels = tuple(str(l.get("name", "")) for l in (issue.get("labels") or []))
|
|
||||||
return IssueAssigned(
|
|
||||||
owner=owner,
|
|
||||||
repo=repo,
|
|
||||||
issue_number=int(issue.get("number", 0)),
|
|
||||||
title=str(issue.get("title", "")),
|
|
||||||
body=str(issue.get("body", "") or ""),
|
|
||||||
assignees=assignees,
|
|
||||||
labels=labels,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_comment(payload: dict[str, Any]) -> CommentCreated | None:
|
|
||||||
if payload.get("action") != "created":
|
|
||||||
return None
|
|
||||||
owner, repo = _repo_owner(payload)
|
|
||||||
issue = payload.get("issue") or {}
|
|
||||||
comment = payload.get("comment") or {}
|
|
||||||
return CommentCreated(
|
|
||||||
owner=owner,
|
|
||||||
repo=repo,
|
|
||||||
issue_number=int(issue.get("number", 0)),
|
|
||||||
comment_id=int(comment.get("id", 0)),
|
|
||||||
author=str((comment.get("user") or {}).get("login", "")),
|
|
||||||
body=str(comment.get("body", "") or ""),
|
|
||||||
is_pull=bool(issue.get("pull_request")),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_pull_request(payload: dict[str, Any]) -> PullRequestClosed | None:
|
|
||||||
if payload.get("action") != "closed":
|
|
||||||
return None
|
|
||||||
owner, repo = _repo_owner(payload)
|
|
||||||
pr = payload.get("pull_request") or {}
|
|
||||||
return PullRequestClosed(
|
|
||||||
owner=owner,
|
|
||||||
repo=repo,
|
|
||||||
pr_number=int(pr.get("number", 0)),
|
|
||||||
merged=bool(pr.get("merged", False)),
|
|
||||||
)
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
"""The orchestration lifecycle: forge events -> bottle transitions.
|
|
||||||
|
|
||||||
`Orchestrator.handle(event)` is the single entry point the webhook layer
|
|
||||||
calls. `on_done_signal(...)` is called by the sidecar relay when an agent
|
|
||||||
signals completion. All collaborators (forge, store, runner) are
|
|
||||||
injected and duck-typed; `now` and `label_for` are injectable for tests.
|
|
||||||
|
|
||||||
Transitions:
|
|
||||||
IssueAssigned (targeted, new) -> start bottle, record = running
|
|
||||||
signal_done (running) -> freeze bottle, record = frozen
|
|
||||||
CommentCreated (frozen) -> resume bottle, record = running
|
|
||||||
PullRequestClosed (tracked) -> destroy bottle, record removed
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from .model import (
|
|
||||||
STATUS_DESTROYED,
|
|
||||||
STATUS_FROZEN,
|
|
||||||
STATUS_RUNNING,
|
|
||||||
CommentCreated,
|
|
||||||
ForgeEvent,
|
|
||||||
IssueAssigned,
|
|
||||||
PullRequestClosed,
|
|
||||||
RunRecord,
|
|
||||||
)
|
|
||||||
from .runner import BottleRunner
|
|
||||||
from .store import StateStore
|
|
||||||
from .targeting import Membership, Target, resolve_target
|
|
||||||
|
|
||||||
|
|
||||||
def _iso_now() -> str:
|
|
||||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
||||||
|
|
||||||
|
|
||||||
def _default_label(agent: str, event: IssueAssigned) -> str:
|
|
||||||
# Embed the issue identity so slugs are unique per issue and never
|
|
||||||
# get renamed on collision.
|
|
||||||
return f"{agent}-{event.owner}-{event.repo}-{event.issue_number}"
|
|
||||||
|
|
||||||
|
|
||||||
class Orchestrator:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
forge: Membership,
|
|
||||||
store: StateStore,
|
|
||||||
runner: BottleRunner,
|
|
||||||
org: str,
|
|
||||||
gitea_api: str = "",
|
|
||||||
forge_env_base: dict[str, str] | None = None,
|
|
||||||
now: Callable[[], str] = _iso_now,
|
|
||||||
label_for: Callable[[str, IssueAssigned], str] = _default_label,
|
|
||||||
) -> None:
|
|
||||||
self._forge = forge
|
|
||||||
self._store = store
|
|
||||||
self._runner = runner
|
|
||||||
self._org = org
|
|
||||||
self._gitea_api = gitea_api
|
|
||||||
self._forge_env_base = forge_env_base or {}
|
|
||||||
self._now = now
|
|
||||||
self._label_for = label_for
|
|
||||||
|
|
||||||
# --- entry points ------------------------------------------------------
|
|
||||||
|
|
||||||
def handle(self, event: ForgeEvent) -> None:
|
|
||||||
if isinstance(event, IssueAssigned):
|
|
||||||
self._on_issue_assigned(event)
|
|
||||||
elif isinstance(event, CommentCreated):
|
|
||||||
self._on_comment(event)
|
|
||||||
else:
|
|
||||||
self._on_pr_closed(event)
|
|
||||||
|
|
||||||
def on_done_signal( # pylint: disable=unused-argument
|
|
||||||
self, owner: str, repo: str, issue_number: int, status: str, summary: str
|
|
||||||
) -> None:
|
|
||||||
"""Sidecar relay: an agent signalled completion. Freeze the bottle.
|
|
||||||
`status`/`summary` are recorded by provenance (via the op log), not
|
|
||||||
acted on here."""
|
|
||||||
record = self._store.get(owner, repo, issue_number)
|
|
||||||
if record is None or record.status != STATUS_RUNNING:
|
|
||||||
return
|
|
||||||
self._runner.freeze(record.slug)
|
|
||||||
record.status = STATUS_FROZEN
|
|
||||||
record.last_checkin_at = self._now()
|
|
||||||
self._store.upsert(record)
|
|
||||||
|
|
||||||
def link_pr(self, owner: str, repo: str, issue_number: int, pr_number: int) -> None:
|
|
||||||
"""Record the PR a tracked issue produced, so PR comments and the
|
|
||||||
PR-close event route back to this record."""
|
|
||||||
record = self._store.get(owner, repo, issue_number)
|
|
||||||
if record is not None:
|
|
||||||
record.pr_number = pr_number
|
|
||||||
self._store.upsert(record)
|
|
||||||
|
|
||||||
# --- handlers ----------------------------------------------------------
|
|
||||||
|
|
||||||
def _on_issue_assigned(self, event: IssueAssigned) -> None:
|
|
||||||
target = resolve_target(event, self._forge, self._org)
|
|
||||||
if target is None:
|
|
||||||
return
|
|
||||||
# Idempotent: a webhook redelivery must not launch a second bottle.
|
|
||||||
if self._store.get(event.owner, event.repo, event.issue_number) is not None:
|
|
||||||
return
|
|
||||||
self._launch(event, target)
|
|
||||||
|
|
||||||
def _launch(self, event: IssueAssigned, target: Target) -> None:
|
|
||||||
label = self._label_for(target.agent_name, event)
|
|
||||||
bottles = [target.bottle_override] if target.bottle_override else []
|
|
||||||
slug = self._runner.start(
|
|
||||||
agent=target.agent_name,
|
|
||||||
bottles=bottles,
|
|
||||||
label=label,
|
|
||||||
prompt=event.body,
|
|
||||||
forge_env=self._forge_env(event.owner, event.repo, event.issue_number),
|
|
||||||
)
|
|
||||||
self._store.upsert(
|
|
||||||
RunRecord(
|
|
||||||
owner=event.owner,
|
|
||||||
repo=event.repo,
|
|
||||||
issue_number=event.issue_number,
|
|
||||||
slug=slug,
|
|
||||||
agent_name=target.agent_name,
|
|
||||||
bottle_names=bottles,
|
|
||||||
status=STATUS_RUNNING,
|
|
||||||
last_checkin_at=self._now(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_comment(self, event: CommentCreated) -> None:
|
|
||||||
record = self._route_comment(event)
|
|
||||||
if record is None or record.status != STATUS_FROZEN:
|
|
||||||
return
|
|
||||||
# Echo-loop guard: ignore the agent's own comments.
|
|
||||||
if record.agent_git_user and event.author == record.agent_git_user:
|
|
||||||
return
|
|
||||||
self._runner.resume(record.slug, event.body)
|
|
||||||
record.status = STATUS_RUNNING
|
|
||||||
record.last_checkin_at = self._now()
|
|
||||||
self._store.upsert(record)
|
|
||||||
|
|
||||||
def _route_comment(self, event: CommentCreated) -> RunRecord | None:
|
|
||||||
# A comment on the issue routes by issue number; a comment on a PR
|
|
||||||
# routes by the recorded pr_number.
|
|
||||||
direct = self._store.get(event.owner, event.repo, event.issue_number)
|
|
||||||
if direct is not None:
|
|
||||||
return direct
|
|
||||||
if event.is_pull:
|
|
||||||
return self._find_by_pr(event.owner, event.repo, event.issue_number)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _on_pr_closed(self, event: PullRequestClosed) -> None:
|
|
||||||
record = self._find_by_pr(event.owner, event.repo, event.pr_number)
|
|
||||||
if record is None:
|
|
||||||
return
|
|
||||||
self._runner.destroy(record.slug)
|
|
||||||
record.status = STATUS_DESTROYED
|
|
||||||
self._store.delete(record.owner, record.repo, record.issue_number)
|
|
||||||
|
|
||||||
def _find_by_pr(self, owner: str, repo: str, pr_number: int) -> RunRecord | None:
|
|
||||||
for record in self._store.all():
|
|
||||||
if (
|
|
||||||
record.owner == owner
|
|
||||||
and record.repo == repo
|
|
||||||
and record.pr_number == pr_number
|
|
||||||
):
|
|
||||||
return record
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _forge_env(self, owner: str, repo: str, issue_number: int) -> dict[str, str]:
|
|
||||||
env = dict(self._forge_env_base)
|
|
||||||
if self._gitea_api:
|
|
||||||
env["FORGE_GITEA_API"] = self._gitea_api
|
|
||||||
env["FORGE_OWNER"] = owner
|
|
||||||
env["FORGE_REPO"] = repo
|
|
||||||
env["FORGE_ISSUE_NUMBER"] = str(issue_number)
|
|
||||||
return env
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
"""Domain model: run records, forge events, provenance.
|
|
||||||
|
|
||||||
These are the orchestrator's own dataclasses. `RunRecord` mirrors
|
|
||||||
bot-bottle's `ForgeState` field-for-field so the bootstrap adapter can
|
|
||||||
translate between them with no loss; keeping our own copy is what lets
|
|
||||||
the core stay import-free of bot-bottle.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
# Run lifecycle. A bottle is launched (running), frozen on the done
|
|
||||||
# signal, and destroyed when the PR closes.
|
|
||||||
STATUS_RUNNING = "running"
|
|
||||||
STATUS_FROZEN = "frozen"
|
|
||||||
STATUS_DESTROYED = "destroyed"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RunRecord:
|
|
||||||
"""One forge-targeted issue's bottle lifecycle record."""
|
|
||||||
|
|
||||||
owner: str
|
|
||||||
repo: str
|
|
||||||
issue_number: int
|
|
||||||
slug: str
|
|
||||||
agent_name: str
|
|
||||||
bottle_names: list[str] = field(default_factory=list)
|
|
||||||
backend_name: str = ""
|
|
||||||
agent_git_user: str = ""
|
|
||||||
pr_number: int | None = None
|
|
||||||
status: str = STATUS_RUNNING
|
|
||||||
last_checkin_at: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
# --- Forge events (parsed webhook payloads) --------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class IssueAssigned:
|
|
||||||
"""An issue gained an assignee — the trigger to consider a launch."""
|
|
||||||
|
|
||||||
owner: str
|
|
||||||
repo: str
|
|
||||||
issue_number: int
|
|
||||||
title: str
|
|
||||||
body: str
|
|
||||||
assignees: tuple[str, ...]
|
|
||||||
labels: tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CommentCreated:
|
|
||||||
"""A comment was posted on an issue or PR — a rehydrate trigger."""
|
|
||||||
|
|
||||||
owner: str
|
|
||||||
repo: str
|
|
||||||
issue_number: int
|
|
||||||
comment_id: int
|
|
||||||
author: str
|
|
||||||
body: str
|
|
||||||
is_pull: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PullRequestClosed:
|
|
||||||
"""A PR closed (merged or not) — the teardown trigger."""
|
|
||||||
|
|
||||||
owner: str
|
|
||||||
repo: str
|
|
||||||
pr_number: int
|
|
||||||
merged: bool
|
|
||||||
|
|
||||||
|
|
||||||
# Union of everything the webhook layer can emit.
|
|
||||||
ForgeEvent = IssueAssigned | CommentCreated | PullRequestClosed
|
|
||||||
|
|
||||||
|
|
||||||
# --- Provenance ------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ForgeOp:
|
|
||||||
"""One semantic forge operation the sidecar recorded."""
|
|
||||||
|
|
||||||
at: str # ISO timestamp
|
|
||||||
op: str # e.g. "post_comment", "read_pr", "signal_done"
|
|
||||||
target: int | None
|
|
||||||
detail: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Provenance:
|
|
||||||
"""The audit record for one run, served by the provenance API. Never
|
|
||||||
posted into the forge."""
|
|
||||||
|
|
||||||
slug: str
|
|
||||||
owner: str
|
|
||||||
repo: str
|
|
||||||
issue_number: int
|
|
||||||
agent_name: str
|
|
||||||
bottle_names: tuple[str, ...]
|
|
||||||
started_at: str
|
|
||||||
finished_at: str
|
|
||||||
exit_code: int | None
|
|
||||||
watchdog_fired: bool
|
|
||||||
ops: tuple[ForgeOp, ...]
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""Provenance assembly + serialization.
|
|
||||||
|
|
||||||
Provenance is the run's audit record: the `RunRecord` metadata plus the
|
|
||||||
sidecar's semantic operation log. It is exposed through the provenance
|
|
||||||
API (see `webhook.ProvenanceHandler`) and deliberately never posted back
|
|
||||||
into the forge — a mutable PR comment is not an audit record.
|
|
||||||
|
|
||||||
This module only assembles and serializes; retention/signing of the
|
|
||||||
record is a control-plane concern out of scope here.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .model import ForgeOp, Provenance, RunRecord
|
|
||||||
|
|
||||||
|
|
||||||
def ops_from_log(entries: list[dict[str, Any]]) -> tuple[ForgeOp, ...]:
|
|
||||||
return tuple(
|
|
||||||
ForgeOp(
|
|
||||||
at=str(e.get("at", "")),
|
|
||||||
op=str(e.get("op", "")),
|
|
||||||
target=e.get("target"),
|
|
||||||
detail=str(e.get("detail", "")),
|
|
||||||
)
|
|
||||||
for e in entries
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_provenance(
|
|
||||||
record: RunRecord,
|
|
||||||
*,
|
|
||||||
ops: tuple[ForgeOp, ...],
|
|
||||||
started_at: str,
|
|
||||||
finished_at: str,
|
|
||||||
exit_code: int | None,
|
|
||||||
watchdog_fired: bool,
|
|
||||||
) -> Provenance:
|
|
||||||
return Provenance(
|
|
||||||
slug=record.slug,
|
|
||||||
owner=record.owner,
|
|
||||||
repo=record.repo,
|
|
||||||
issue_number=record.issue_number,
|
|
||||||
agent_name=record.agent_name,
|
|
||||||
bottle_names=tuple(record.bottle_names),
|
|
||||||
started_at=started_at,
|
|
||||||
finished_at=finished_at,
|
|
||||||
exit_code=exit_code,
|
|
||||||
watchdog_fired=watchdog_fired,
|
|
||||||
ops=ops,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def provenance_to_dict(p: Provenance) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"slug": p.slug,
|
|
||||||
"owner": p.owner,
|
|
||||||
"repo": p.repo,
|
|
||||||
"issue_number": p.issue_number,
|
|
||||||
"agent": p.agent_name,
|
|
||||||
"bottles": list(p.bottle_names),
|
|
||||||
"started_at": p.started_at,
|
|
||||||
"finished_at": p.finished_at,
|
|
||||||
"exit_code": p.exit_code,
|
|
||||||
"watchdog_fired": p.watchdog_fired,
|
|
||||||
"ops": [
|
|
||||||
{"at": o.at, "op": o.op, "target": o.target, "detail": o.detail}
|
|
||||||
for o in p.ops
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"""Bottle runner: drive bot_bottle to manage a bottle's life.
|
|
||||||
|
|
||||||
`BottleRunner` is the interface the lifecycle depends on;
|
|
||||||
`ProgrammaticBottleRunner` calls into the bot_bottle Python API directly
|
|
||||||
(no subprocess). The slug returned by `start` is the actual slug minted
|
|
||||||
at launch time — not a post-hoc derivation from the label — so it is
|
|
||||||
authoritative even if bot-bottle's slugification logic changes.
|
|
||||||
|
|
||||||
`slugify` is retained for `FakeRunner` (tests) and for the label scheme
|
|
||||||
the orchestrator uses to predict collision-free slugs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class BottleRunner(Protocol):
|
|
||||||
def start(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
agent: str,
|
|
||||||
bottles: Sequence[str],
|
|
||||||
label: str,
|
|
||||||
prompt: str,
|
|
||||||
forge_env: dict[str, str],
|
|
||||||
) -> str: ...
|
|
||||||
|
|
||||||
def freeze(self, slug: str) -> None: ...
|
|
||||||
|
|
||||||
def resume(self, slug: str, prompt: str) -> None: ...
|
|
||||||
|
|
||||||
def destroy(self, slug: str) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
|
||||||
|
|
||||||
|
|
||||||
def slugify(label: str) -> str:
|
|
||||||
"""Lowercase, collapse non-alphanumerics to single hyphens, strip
|
|
||||||
leading/trailing hyphens — matches bot-bottle's slug rule."""
|
|
||||||
return _SLUG_RE.sub("-", label.lower()).strip("-")
|
|
||||||
|
|
||||||
|
|
||||||
class ProgrammaticBottleRunner:
|
|
||||||
"""Calls into the bot_bottle Python API directly — no subprocess.
|
|
||||||
|
|
||||||
Imports are deferred to call time so tests can inject a mock into
|
|
||||||
sys.modules['bot_bottle.api'] before calling runner methods.
|
|
||||||
bot_bottle.api is added in the forge-native-integration PR (#318),
|
|
||||||
which merges before this one."""
|
|
||||||
|
|
||||||
def start(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
agent: str,
|
|
||||||
bottles: Sequence[str],
|
|
||||||
label: str,
|
|
||||||
prompt: str,
|
|
||||||
forge_env: dict[str, str],
|
|
||||||
) -> str:
|
|
||||||
from bot_bottle import api # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
|
||||||
return api.start_headless(
|
|
||||||
agent,
|
|
||||||
prompt=prompt,
|
|
||||||
bottles=list(bottles) or None,
|
|
||||||
label=label,
|
|
||||||
forge_env=forge_env,
|
|
||||||
)
|
|
||||||
|
|
||||||
def freeze(self, slug: str) -> None:
|
|
||||||
from bot_bottle import api # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
|
||||||
api.freeze(slug)
|
|
||||||
|
|
||||||
def resume(self, slug: str, prompt: str) -> None:
|
|
||||||
from bot_bottle import api # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
|
||||||
api.resume_headless(slug, prompt=prompt)
|
|
||||||
|
|
||||||
def destroy(self, slug: str) -> None:
|
|
||||||
from bot_bottle import api # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
|
||||||
api.destroy(slug)
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
"""Forge sidecar: the agent's only door to the forge.
|
|
||||||
|
|
||||||
The agent calls the sidecar over a line-delimited JSON-RPC AF_UNIX
|
|
||||||
socket; the sidecar dispatches to an injected `forge` (already
|
|
||||||
scope-wrapped by bootstrap) and holds the token, so the agent never sees
|
|
||||||
a credential or a forge endpoint. Every call is appended to a semantic
|
|
||||||
operation log (the provenance raw material). `signal_done` additionally
|
|
||||||
drops an event file in the queue dir the orchestrator drains.
|
|
||||||
|
|
||||||
`dispatch` is pure and testable; `serve` wraps it in a socket server.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import json
|
|
||||||
import socketserver
|
|
||||||
import uuid
|
|
||||||
from collections.abc import Callable
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
_READ_METHODS = {"read_issue", "read_pr", "read_comments"}
|
|
||||||
_WRITE_METHODS = {"post_comment", "update_description"}
|
|
||||||
|
|
||||||
|
|
||||||
def _iso_now() -> str:
|
|
||||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
||||||
|
|
||||||
|
|
||||||
def _jsonable(value: Any) -> Any:
|
|
||||||
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
||||||
return dataclasses.asdict(value)
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [_jsonable(v) for v in value]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class OpLog:
|
|
||||||
"""Append-only JSONL log of semantic forge operations."""
|
|
||||||
|
|
||||||
def __init__(self, path: Path, *, now: Callable[[], str] = _iso_now) -> None:
|
|
||||||
self._path = path
|
|
||||||
self._now = now
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
def record(self, op: str, target: int | None, detail: str) -> None:
|
|
||||||
entry = {"at": self._now(), "op": op, "target": target, "detail": detail}
|
|
||||||
with self._path.open("a", encoding="utf-8") as fh:
|
|
||||||
fh.write(json.dumps(entry) + "\n")
|
|
||||||
|
|
||||||
def read(self) -> list[dict[str, Any]]:
|
|
||||||
if not self._path.exists():
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
json.loads(line)
|
|
||||||
for line in self._path.read_text(encoding="utf-8").splitlines()
|
|
||||||
if line.strip()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def write_done_event(queue_dir: Path, event: dict[str, Any]) -> Path:
|
|
||||||
"""Atomically drop a done-signal event file in the queue dir."""
|
|
||||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
path = queue_dir / f"done-{uuid.uuid4().hex}.json"
|
|
||||||
tmp = path.with_suffix(".json.tmp")
|
|
||||||
tmp.write_text(json.dumps(event), encoding="utf-8")
|
|
||||||
tmp.replace(path)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def drain_done_events(queue_dir: Path) -> list[dict[str, Any]]:
|
|
||||||
"""Read and remove every queued done-signal event."""
|
|
||||||
if not queue_dir.is_dir():
|
|
||||||
return []
|
|
||||||
events: list[dict[str, Any]] = []
|
|
||||||
for path in sorted(queue_dir.glob("done-*.json")):
|
|
||||||
try:
|
|
||||||
events.append(json.loads(path.read_text(encoding="utf-8")))
|
|
||||||
except (OSError, ValueError):
|
|
||||||
continue
|
|
||||||
finally:
|
|
||||||
path.unlink(missing_ok=True)
|
|
||||||
return events
|
|
||||||
|
|
||||||
|
|
||||||
class ForgeSidecar:
|
|
||||||
"""Dispatches sidecar protocol calls to the forge, logging each and
|
|
||||||
relaying `signal_done` to the queue dir. `run_key` is the
|
|
||||||
(owner, repo, issue_number) the run is bound to."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
forge: object,
|
|
||||||
op_log: OpLog,
|
|
||||||
queue_dir: Path,
|
|
||||||
run_key: tuple[str, str, int],
|
|
||||||
) -> None:
|
|
||||||
self._forge = forge
|
|
||||||
self._log = op_log
|
|
||||||
self._queue_dir = queue_dir
|
|
||||||
self._owner, self._repo, self._issue = run_key
|
|
||||||
|
|
||||||
def dispatch(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
result = self._invoke(method, params)
|
|
||||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
|
||||||
self._log.record(method, params.get("number"), f"error: {exc}")
|
|
||||||
return {"ok": False, "error": str(exc)}
|
|
||||||
return {"ok": True, "result": result}
|
|
||||||
|
|
||||||
def _invoke(self, method: str, params: dict[str, Any]) -> Any:
|
|
||||||
if method in _READ_METHODS:
|
|
||||||
number = int(params["number"])
|
|
||||||
result = getattr(self._forge, method)(number)
|
|
||||||
self._log.record(method, number, "ok")
|
|
||||||
return _jsonable(result)
|
|
||||||
if method in _WRITE_METHODS:
|
|
||||||
number = int(params["number"])
|
|
||||||
getattr(self._forge, method)(number, params["body"])
|
|
||||||
self._log.record(method, number, "ok")
|
|
||||||
return None
|
|
||||||
if method == "signal_done":
|
|
||||||
status = str(params.get("status", ""))
|
|
||||||
summary = str(params.get("summary", ""))
|
|
||||||
self._log.record("signal_done", None, f"{status}: {summary}")
|
|
||||||
write_done_event(
|
|
||||||
self._queue_dir,
|
|
||||||
{
|
|
||||||
"owner": self._owner,
|
|
||||||
"repo": self._repo,
|
|
||||||
"issue_number": self._issue,
|
|
||||||
"status": status,
|
|
||||||
"summary": summary,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
raise ValueError(f"unknown method: {method}")
|
|
||||||
|
|
||||||
|
|
||||||
class _Handler(socketserver.StreamRequestHandler):
|
|
||||||
def handle(self) -> None:
|
|
||||||
line = self.rfile.readline()
|
|
||||||
if not line:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
req = json.loads(line)
|
|
||||||
except ValueError:
|
|
||||||
self.wfile.write(b'{"ok": false, "error": "invalid json"}\n')
|
|
||||||
return
|
|
||||||
resp = self.server.sidecar.dispatch( # type: ignore[attr-defined]
|
|
||||||
str(req.get("method", "")), dict(req.get("params", {}))
|
|
||||||
)
|
|
||||||
self.wfile.write((json.dumps(resp) + "\n").encode())
|
|
||||||
|
|
||||||
|
|
||||||
class _Server(socketserver.ThreadingUnixStreamServer):
|
|
||||||
def __init__(self, socket_path: str, sidecar: ForgeSidecar) -> None:
|
|
||||||
super().__init__(socket_path, _Handler)
|
|
||||||
self.sidecar = sidecar
|
|
||||||
|
|
||||||
|
|
||||||
def serve(sidecar: ForgeSidecar, socket_path: Path) -> _Server:
|
|
||||||
"""Bind a threaded AF_UNIX server for `sidecar`. Caller runs
|
|
||||||
`serve_forever()` (or `handle_request()` in tests) and closes it."""
|
|
||||||
if socket_path.exists():
|
|
||||||
socket_path.unlink()
|
|
||||||
socket_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
return _Server(str(socket_path), sidecar)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""State store interface + an in-memory implementation.
|
|
||||||
|
|
||||||
The orchestrator persists one `RunRecord` per forge-targeted issue. At
|
|
||||||
runtime `bootstrap` supplies an adapter over bot-bottle's
|
|
||||||
`SqliteForgeStateStore`; the in-memory store here backs tests and a
|
|
||||||
`--no-bot-bottle` dry mode.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
from .model import RunRecord
|
|
||||||
|
|
||||||
|
|
||||||
class StateStore(Protocol):
|
|
||||||
"""Thin CRUD surface. Mirrors bot-bottle's `ForgeStateStore` so the
|
|
||||||
bootstrap adapter is a straight pass-through."""
|
|
||||||
|
|
||||||
def upsert(self, record: RunRecord) -> None: ...
|
|
||||||
|
|
||||||
def get(self, owner: str, repo: str, issue_number: int) -> RunRecord | None: ...
|
|
||||||
|
|
||||||
def delete(self, owner: str, repo: str, issue_number: int) -> None: ...
|
|
||||||
|
|
||||||
def all(self) -> list[RunRecord]: ...
|
|
||||||
|
|
||||||
|
|
||||||
class InMemoryStateStore:
|
|
||||||
"""Dict-backed `StateStore`, keyed by (owner, repo, issue_number)."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._by_key: dict[tuple[str, str, int], RunRecord] = {}
|
|
||||||
|
|
||||||
def upsert(self, record: RunRecord) -> None:
|
|
||||||
self._by_key[(record.owner, record.repo, record.issue_number)] = record
|
|
||||||
|
|
||||||
def get(self, owner: str, repo: str, issue_number: int) -> RunRecord | None:
|
|
||||||
return self._by_key.get((owner, repo, issue_number))
|
|
||||||
|
|
||||||
def delete(self, owner: str, repo: str, issue_number: int) -> None:
|
|
||||||
self._by_key.pop((owner, repo, issue_number), None)
|
|
||||||
|
|
||||||
def all(self) -> list[RunRecord]:
|
|
||||||
return sorted(
|
|
||||||
self._by_key.values(),
|
|
||||||
key=lambda r: (r.owner, r.repo, r.issue_number),
|
|
||||||
)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"""Decide whether an assigned issue is agent-targeted, and for whom.
|
|
||||||
|
|
||||||
An issue is forge-targeted when BOTH hold:
|
|
||||||
- it carries a `bot-bottle:<agent>` label naming the agent, and
|
|
||||||
- at least one assignee is a member of the configured org.
|
|
||||||
|
|
||||||
An optional `bot-bottle-bottle:<name>` label overrides bottle selection.
|
|
||||||
The forge is duck-typed: any object with `is_org_member(org, user)`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
from .config import BOTTLE_LABEL_PREFIX, LABEL_PREFIX
|
|
||||||
from .model import IssueAssigned
|
|
||||||
|
|
||||||
|
|
||||||
class Membership(Protocol):
|
|
||||||
def is_org_member(self, org: str, username: str) -> bool: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Target:
|
|
||||||
agent_name: str
|
|
||||||
bottle_override: str | None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_labels(labels: tuple[str, ...]) -> tuple[str | None, str | None]:
|
|
||||||
"""Return (agent_name, bottle_override) parsed from labels."""
|
|
||||||
agent: str | None = None
|
|
||||||
bottle: str | None = None
|
|
||||||
for label in labels:
|
|
||||||
if label.startswith(BOTTLE_LABEL_PREFIX):
|
|
||||||
bottle = label[len(BOTTLE_LABEL_PREFIX):] or None
|
|
||||||
elif label.startswith(LABEL_PREFIX):
|
|
||||||
agent = label[len(LABEL_PREFIX):] or None
|
|
||||||
return agent, bottle
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_target(
|
|
||||||
event: IssueAssigned, forge: Membership, org: str
|
|
||||||
) -> Target | None:
|
|
||||||
"""Return the `Target` for a forge-targeted issue, or None to ignore."""
|
|
||||||
agent, bottle = parse_labels(event.labels)
|
|
||||||
if not agent:
|
|
||||||
return None
|
|
||||||
if not any(forge.is_org_member(org, a) for a in event.assignees):
|
|
||||||
return None
|
|
||||||
return Target(agent_name=agent, bottle_override=bottle)
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"""Watchdog: freeze runs whose agent exited without signalling done.
|
|
||||||
|
|
||||||
`sweep(now)` is the pure, testable core: any `running` record whose
|
|
||||||
`last_checkin_at` is older than the timeout is frozen as
|
|
||||||
done-without-self-report and returned so provenance can flag it.
|
|
||||||
`Watchdog.start()` runs `sweep` on a daemon thread once a minute.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from .model import STATUS_FROZEN, STATUS_RUNNING, RunRecord
|
|
||||||
from .runner import BottleRunner
|
|
||||||
from .store import StateStore
|
|
||||||
|
|
||||||
_TICK_SECS = 60.0
|
|
||||||
|
|
||||||
|
|
||||||
def _parse(ts: str) -> datetime | None:
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(ts)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class Watchdog:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
store: StateStore,
|
|
||||||
runner: BottleRunner,
|
|
||||||
timeout_secs: int,
|
|
||||||
) -> None:
|
|
||||||
self._store = store
|
|
||||||
self._runner = runner
|
|
||||||
self._timeout = timedelta(seconds=timeout_secs)
|
|
||||||
self._stop = threading.Event()
|
|
||||||
self._thread: threading.Thread | None = None
|
|
||||||
|
|
||||||
def sweep(self, now: datetime) -> list[RunRecord]:
|
|
||||||
"""Freeze stale running records. Returns the ones fired."""
|
|
||||||
fired: list[RunRecord] = []
|
|
||||||
for record in self._store.all():
|
|
||||||
if record.status != STATUS_RUNNING:
|
|
||||||
continue
|
|
||||||
checkin = _parse(record.last_checkin_at)
|
|
||||||
if checkin is None or now - checkin <= self._timeout:
|
|
||||||
continue
|
|
||||||
self._runner.freeze(record.slug)
|
|
||||||
record.status = STATUS_FROZEN
|
|
||||||
self._store.upsert(record)
|
|
||||||
fired.append(record)
|
|
||||||
return fired
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
self._stop.set()
|
|
||||||
if self._thread is not None:
|
|
||||||
self._thread.join(timeout=_TICK_SECS)
|
|
||||||
|
|
||||||
def _loop(self) -> None:
|
|
||||||
while not self._stop.wait(_TICK_SECS):
|
|
||||||
self.sweep(datetime.now().astimezone())
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
"""HTTP surface: the Gitea webhook receiver and the provenance API.
|
|
||||||
|
|
||||||
`POST /webhook` — a Gitea event; parsed and dispatched to the orchestrator.
|
|
||||||
`GET /healthz` — liveness.
|
|
||||||
`GET /provenance?owner=&repo=&issue=` — the run's audit record (never
|
|
||||||
posted to the forge).
|
|
||||||
|
|
||||||
Webhook signature verification is optional: set a secret and the handler
|
|
||||||
rejects bodies whose `X-Gitea-Signature` HMAC-SHA256 does not match.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
from collections.abc import Callable
|
|
||||||
from hashlib import sha256
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
from .events import parse_event
|
|
||||||
from .lifecycle import Orchestrator
|
|
||||||
from .provenance import build_provenance, ops_from_log, provenance_to_dict
|
|
||||||
from .store import StateStore
|
|
||||||
|
|
||||||
# (record) -> that run's op-log entries, injected by bootstrap.
|
|
||||||
OpLogReader = Callable[[Any], list[dict[str, Any]]]
|
|
||||||
|
|
||||||
|
|
||||||
class WebhookServer(ThreadingHTTPServer):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
address: tuple[str, int],
|
|
||||||
*,
|
|
||||||
orchestrator: Orchestrator,
|
|
||||||
store: StateStore,
|
|
||||||
secret: bytes | None = None,
|
|
||||||
op_log_reader: OpLogReader | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(address, _Handler)
|
|
||||||
self.orchestrator = orchestrator
|
|
||||||
self.store = store
|
|
||||||
self.secret = secret
|
|
||||||
self.op_log_reader = op_log_reader
|
|
||||||
|
|
||||||
|
|
||||||
def verify_signature(secret: bytes, body: bytes, signature: str) -> bool:
|
|
||||||
expected = hmac.new(secret, body, sha256).hexdigest()
|
|
||||||
return hmac.compare_digest(expected, signature or "")
|
|
||||||
|
|
||||||
|
|
||||||
class _Handler(BaseHTTPRequestHandler):
|
|
||||||
server: WebhookServer # type: ignore[assignment]
|
|
||||||
|
|
||||||
def log_message( # pylint: disable=redefined-builtin
|
|
||||||
self, format: str, *args: Any
|
|
||||||
) -> None: # quiet by default
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _send(self, code: int, payload: dict[str, Any]) -> None:
|
|
||||||
body = json.dumps(payload).encode()
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
||||||
if urlparse(self.path).path != "/webhook":
|
|
||||||
self._send(404, {"error": "not found"})
|
|
||||||
return
|
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
|
||||||
body = self.rfile.read(length)
|
|
||||||
if self.server.secret is not None:
|
|
||||||
sig = self.headers.get("X-Gitea-Signature", "")
|
|
||||||
if not verify_signature(self.server.secret, body, sig):
|
|
||||||
self._send(401, {"error": "bad signature"})
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
payload = json.loads(body)
|
|
||||||
except ValueError:
|
|
||||||
self._send(400, {"error": "invalid json"})
|
|
||||||
return
|
|
||||||
kind = self.headers.get("X-Gitea-Event", "")
|
|
||||||
event = parse_event(kind, payload)
|
|
||||||
if event is not None:
|
|
||||||
self.server.orchestrator.handle(event)
|
|
||||||
self._send(200, {"ok": True, "handled": event is not None})
|
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802 # pylint: disable=invalid-name
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
if parsed.path == "/healthz":
|
|
||||||
self._send(200, {"ok": True})
|
|
||||||
return
|
|
||||||
if parsed.path == "/provenance":
|
|
||||||
self._provenance(parse_qs(parsed.query))
|
|
||||||
return
|
|
||||||
self._send(404, {"error": "not found"})
|
|
||||||
|
|
||||||
def _provenance(self, query: dict[str, list[str]]) -> None:
|
|
||||||
try:
|
|
||||||
owner = query["owner"][0]
|
|
||||||
repo = query["repo"][0]
|
|
||||||
issue = int(query["issue"][0])
|
|
||||||
except (KeyError, IndexError, ValueError):
|
|
||||||
self._send(400, {"error": "owner, repo, issue required"})
|
|
||||||
return
|
|
||||||
record = self.server.store.get(owner, repo, issue)
|
|
||||||
if record is None:
|
|
||||||
self._send(404, {"error": "no such run"})
|
|
||||||
return
|
|
||||||
reader = self.server.op_log_reader
|
|
||||||
ops = ops_from_log(reader(record) if reader is not None else [])
|
|
||||||
prov = build_provenance(
|
|
||||||
record,
|
|
||||||
ops=ops,
|
|
||||||
started_at="",
|
|
||||||
finished_at=record.last_checkin_at,
|
|
||||||
exit_code=None,
|
|
||||||
watchdog_fired=False,
|
|
||||||
)
|
|
||||||
self._send(200, provenance_to_dict(prov))
|
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""SQLite-backed queue store for supervise proposals and responses (PRD 0013)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .supervise import Proposal, Response
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .db_store import DbStore
|
||||||
|
from .migrations import TableMigrations
|
||||||
|
except ImportError:
|
||||||
|
from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
|
from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
|
|
||||||
|
|
||||||
|
def get_supervise_mod() -> object:
|
||||||
|
"""Lazy import of supervise to avoid a circular-import at module init time.
|
||||||
|
By the time any QueueStore method is called, both modules are fully loaded.
|
||||||
|
|
||||||
|
Mirrors our own module identity: when we are 'queue_store' (sidecar flat
|
||||||
|
context or tests that inject bot_bottle/ into sys.path) we use the flat
|
||||||
|
'supervise' module so that patches on supervise.bot_bottle_root propagate
|
||||||
|
correctly. When we are 'bot_bottle.queue_store' we use 'bot_bottle.supervise'."""
|
||||||
|
import sys
|
||||||
|
sv_name = "supervise" if __name__ == "queue_store" else "bot_bottle.supervise"
|
||||||
|
if sv_name in sys.modules:
|
||||||
|
return sys.modules[sv_name]
|
||||||
|
try:
|
||||||
|
import bot_bottle.supervise as _m
|
||||||
|
except ImportError:
|
||||||
|
import supervise as _m # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
|
return _m
|
||||||
|
|
||||||
|
|
||||||
|
# One entry per schema version: _MIGRATIONS.migrations[0] brings a fresh DB
|
||||||
|
# to version 1, [1] to version 2, and so on. Add new migrations at the end;
|
||||||
|
# never edit existing ones.
|
||||||
|
_MIGRATIONS = TableMigrations("queue_store", [
|
||||||
|
# v1 — proposals table
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS supervise_proposals (
|
||||||
|
queue_key TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
bottle_slug TEXT NOT NULL,
|
||||||
|
tool TEXT NOT NULL,
|
||||||
|
proposed_file TEXT NOT NULL,
|
||||||
|
justification TEXT NOT NULL,
|
||||||
|
arrival_timestamp TEXT NOT NULL,
|
||||||
|
current_file_hash TEXT NOT NULL,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (queue_key, id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
# v2 — responses table
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS supervise_responses (
|
||||||
|
queue_key TEXT NOT NULL,
|
||||||
|
proposal_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
notes TEXT NOT NULL,
|
||||||
|
final_file TEXT,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (queue_key, proposal_id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
class QueueStore(DbStore):
|
||||||
|
"""SQLite-backed persistent store for supervise proposals and responses."""
|
||||||
|
|
||||||
|
def __init__(self, queue_key: str, db_path: Path | None = None) -> None:
|
||||||
|
self.queue_key = queue_key
|
||||||
|
if db_path is not None:
|
||||||
|
resolved = db_path
|
||||||
|
else:
|
||||||
|
# In the sidecar container SUPERVISE_DB_PATH points at the
|
||||||
|
# bind-mounted host DB. On the host this env var is never set,
|
||||||
|
# so we always fall through to host_db_path().
|
||||||
|
env_path = os.environ.get("SUPERVISE_DB_PATH", "").strip()
|
||||||
|
resolved = Path(env_path) if env_path else get_supervise_mod().host_db_path() # type: ignore[attr-defined]
|
||||||
|
super().__init__(resolved, _MIGRATIONS)
|
||||||
|
|
||||||
|
def write_proposal(self, proposal: Proposal) -> Path:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO supervise_proposals (
|
||||||
|
queue_key, id, bottle_slug, tool, proposed_file, justification,
|
||||||
|
arrival_timestamp, current_file_hash, archived
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
self.queue_key,
|
||||||
|
proposal.id,
|
||||||
|
proposal.bottle_slug,
|
||||||
|
proposal.tool,
|
||||||
|
proposal.proposed_file,
|
||||||
|
proposal.justification,
|
||||||
|
proposal.arrival_timestamp,
|
||||||
|
proposal.current_file_hash,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._chmod()
|
||||||
|
return self.db_path
|
||||||
|
|
||||||
|
def read_proposal(self, proposal_id: str) -> Proposal:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM supervise_proposals
|
||||||
|
WHERE queue_key = ? AND id = ? AND archived = 0
|
||||||
|
""",
|
||||||
|
(self.queue_key, proposal_id),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise FileNotFoundError(proposal_id)
|
||||||
|
return self._row_to_proposal(row)
|
||||||
|
|
||||||
|
def list_pending_proposals(self) -> list[Proposal]:
|
||||||
|
if not self.db_path.is_file():
|
||||||
|
return []
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT p.* FROM supervise_proposals p
|
||||||
|
WHERE p.archived = 0
|
||||||
|
AND p.queue_key = ?
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM supervise_responses r
|
||||||
|
WHERE r.queue_key = p.queue_key
|
||||||
|
AND r.proposal_id = p.id
|
||||||
|
AND r.archived = 0
|
||||||
|
)
|
||||||
|
ORDER BY p.arrival_timestamp, p.id
|
||||||
|
""",
|
||||||
|
(self.queue_key,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_proposal(row) for row in rows]
|
||||||
|
|
||||||
|
def list_all_pending_proposals(self) -> list[Proposal]:
|
||||||
|
if not self.db_path.is_file():
|
||||||
|
return []
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT p.* FROM supervise_proposals p
|
||||||
|
WHERE p.archived = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM supervise_responses r
|
||||||
|
WHERE r.queue_key = p.queue_key
|
||||||
|
AND r.proposal_id = p.id
|
||||||
|
AND r.archived = 0
|
||||||
|
)
|
||||||
|
ORDER BY p.arrival_timestamp, p.id
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_proposal(row) for row in rows]
|
||||||
|
|
||||||
|
def write_response(self, response: Response) -> Path:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO supervise_responses (
|
||||||
|
queue_key, proposal_id, status, notes, final_file, archived
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 0)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
self.queue_key,
|
||||||
|
response.proposal_id,
|
||||||
|
response.status,
|
||||||
|
response.notes,
|
||||||
|
response.final_file,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._chmod()
|
||||||
|
return self.db_path
|
||||||
|
|
||||||
|
def read_response(self, proposal_id: str) -> Response:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM supervise_responses
|
||||||
|
WHERE queue_key = ? AND proposal_id = ? AND archived = 0
|
||||||
|
""",
|
||||||
|
(self.queue_key, proposal_id),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise FileNotFoundError(proposal_id)
|
||||||
|
return self._row_to_response(row)
|
||||||
|
|
||||||
|
def archive_proposal(self, proposal_id: str) -> None:
|
||||||
|
if not self.db_path.is_file():
|
||||||
|
return
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE supervise_proposals SET archived = 1
|
||||||
|
WHERE queue_key = ? AND id = ?
|
||||||
|
""",
|
||||||
|
(self.queue_key, proposal_id),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE supervise_responses SET archived = 1
|
||||||
|
WHERE queue_key = ? AND proposal_id = ?
|
||||||
|
""",
|
||||||
|
(self.queue_key, proposal_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_proposal(row: sqlite3.Row) -> Proposal:
|
||||||
|
m = get_supervise_mod()
|
||||||
|
return m.Proposal( # type: ignore[attr-defined]
|
||||||
|
id=row["id"],
|
||||||
|
bottle_slug=row["bottle_slug"],
|
||||||
|
tool=row["tool"],
|
||||||
|
proposed_file=row["proposed_file"],
|
||||||
|
justification=row["justification"],
|
||||||
|
arrival_timestamp=row["arrival_timestamp"],
|
||||||
|
current_file_hash=row["current_file_hash"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_response(row: sqlite3.Row) -> Response:
|
||||||
|
m = get_supervise_mod()
|
||||||
|
return m.Response( # type: ignore[attr-defined]
|
||||||
|
proposal_id=row["proposal_id"],
|
||||||
|
status=row["status"],
|
||||||
|
notes=row["notes"],
|
||||||
|
final_file=row["final_file"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["QueueStore"]
|
||||||
+72
-203
@@ -9,15 +9,14 @@ calls when it needs an operator-reviewed egress change:
|
|||||||
|
|
||||||
Each tool call: the agent passes the full proposed file plus a
|
Each tool call: the agent passes the full proposed file plus a
|
||||||
justification text. The sidecar validates the proposal syntactically,
|
justification text. The sidecar validates the proposal syntactically,
|
||||||
writes it to the host's per-bottle queue dir, and holds the tool-call
|
writes it to the host SQLite queue table, and holds the tool-call
|
||||||
connection open. The operator's supervise TUI
|
connection open. The operator's supervise TUI
|
||||||
(bot_bottle.cli.supervise) sees the proposal, accepts
|
(bot_bottle.cli.supervise) sees the proposal, accepts
|
||||||
approve / modify / reject, and writes a response file alongside the
|
approve / modify / reject, and writes a response row. The sidecar sees
|
||||||
proposal. The sidecar sees the response and returns `{status, notes}`
|
the response and returns `{status, notes}` to the agent.
|
||||||
to the agent.
|
|
||||||
|
|
||||||
This module defines the host-side library: dataclasses for the queue
|
This module defines the host-side library: dataclasses for the queue
|
||||||
file shapes, queue read/write helpers, the audit log writer, and the
|
record shapes, queue read/write helpers, the audit log writer, and the
|
||||||
diff renderer. The in-container sidecar lives in
|
diff renderer. The in-container sidecar lives in
|
||||||
bot_bottle/supervise_server.py; the supervise daemon's container
|
bot_bottle/supervise_server.py; the supervise daemon's container
|
||||||
lifecycle is owned by the sidecar bundle (PRD 0024).
|
lifecycle is owned by the sidecar bundle (PRD 0024).
|
||||||
@@ -34,8 +33,6 @@ from __future__ import annotations
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import difflib
|
import difflib
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
@@ -86,8 +83,9 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
|
|||||||
# `routes edit <bottle>` verb writes entries with this action.
|
# `routes edit <bottle>` verb writes entries with this action.
|
||||||
ACTION_OPERATOR_EDIT = "operator-edit"
|
ACTION_OPERATOR_EDIT = "operator-edit"
|
||||||
|
|
||||||
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
|
DB_PATH_IN_CONTAINER = "/run/supervise/bot-bottle.db"
|
||||||
DEFAULT_POLL_INTERVAL_SEC = 0.5
|
DEFAULT_POLL_INTERVAL_SEC = 0.5
|
||||||
|
HOST_DB_FILENAME = "bot-bottle.db"
|
||||||
|
|
||||||
|
|
||||||
# --- Paths -----------------------------------------------------------------
|
# --- Paths -----------------------------------------------------------------
|
||||||
@@ -97,10 +95,6 @@ def bot_bottle_root() -> Path:
|
|||||||
return Path.home() / ".bot-bottle"
|
return Path.home() / ".bot-bottle"
|
||||||
|
|
||||||
|
|
||||||
def queue_dir_for_slug(slug: str) -> Path:
|
|
||||||
return bot_bottle_root() / "queue" / slug
|
|
||||||
|
|
||||||
|
|
||||||
def audit_dir() -> Path:
|
def audit_dir() -> Path:
|
||||||
return bot_bottle_root() / "audit"
|
return bot_bottle_root() / "audit"
|
||||||
|
|
||||||
@@ -109,14 +103,16 @@ def audit_log_path(component: str, slug: str) -> Path:
|
|||||||
return audit_dir() / f"{component}-{slug}.log"
|
return audit_dir() / f"{component}-{slug}.log"
|
||||||
|
|
||||||
|
|
||||||
|
def host_db_path() -> Path:
|
||||||
|
return bot_bottle_root() / HOST_DB_FILENAME
|
||||||
|
|
||||||
|
|
||||||
# --- Dataclasses -----------------------------------------------------------
|
# --- Dataclasses -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Proposal:
|
class Proposal:
|
||||||
"""One pending tool-call from the agent. The sidecar writes one
|
"""One pending tool-call from the agent."""
|
||||||
of these to the queue dir on a tool call; the operator's TUI
|
|
||||||
reads them; the sidecar polls for a matching Response."""
|
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
bottle_slug: str
|
bottle_slug: str
|
||||||
@@ -170,7 +166,7 @@ class Proposal:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Response:
|
class Response:
|
||||||
"""The operator's decision on a proposal. The TUI writes one of
|
"""The operator's decision on a proposal. The TUI writes one of
|
||||||
these to the queue dir; the sidecar reads it and returns the
|
these to the queue table; the sidecar reads it and returns the
|
||||||
`{status, notes}` pair to the agent's tool call.
|
`{status, notes}` pair to the agent's tool call.
|
||||||
|
|
||||||
`final_file` carries the file content the supervisor will
|
`final_file` carries the file content the supervisor will
|
||||||
@@ -223,90 +219,50 @@ class AuditEntry:
|
|||||||
return dataclasses.asdict(self)
|
return dataclasses.asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .queue_store import QueueStore
|
||||||
|
from .audit_store import AuditStore
|
||||||
|
except ImportError:
|
||||||
|
# Sidecar bundle: files are flat-copied under /app, not a package.
|
||||||
|
from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
|
from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||||
|
|
||||||
|
|
||||||
# --- Queue I/O -------------------------------------------------------------
|
# --- Queue I/O -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _proposal_filename(proposal_id: str) -> str:
|
def write_proposal(proposal: Proposal) -> Path:
|
||||||
return f"{proposal_id}.proposal.json"
|
"""Persist `proposal` in the queue database, mode 0o600.
|
||||||
|
|
||||||
|
|
||||||
def _response_filename(proposal_id: str) -> str:
|
|
||||||
return f"{proposal_id}.response.json"
|
|
||||||
|
|
||||||
|
|
||||||
def _id_from_proposal_filename(path: Path) -> str | None:
|
|
||||||
name = path.name
|
|
||||||
if not name.endswith(".proposal.json"):
|
|
||||||
return None
|
|
||||||
return name[: -len(".proposal.json")]
|
|
||||||
|
|
||||||
|
|
||||||
def write_proposal(queue_dir: Path, proposal: Proposal) -> Path:
|
|
||||||
"""Persist `proposal` as JSON in the queue dir, mode 0o600.
|
|
||||||
Directory is created if missing."""
|
Directory is created if missing."""
|
||||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
return QueueStore(proposal.bottle_slug).write_proposal(proposal)
|
||||||
path = queue_dir / _proposal_filename(proposal.id)
|
|
||||||
payload = json.dumps(proposal.to_dict(), indent=2) + "\n"
|
|
||||||
_atomic_write(path, payload, mode=0o600)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def read_proposal(queue_dir: Path, proposal_id: str) -> Proposal:
|
def read_proposal(bottle_slug: str, proposal_id: str) -> Proposal:
|
||||||
path = queue_dir / _proposal_filename(proposal_id)
|
return QueueStore(bottle_slug).read_proposal(proposal_id)
|
||||||
with path.open() as f:
|
|
||||||
raw = json.load(f)
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise ValueError(f"{path}: top-level must be an object")
|
|
||||||
return Proposal.from_dict(raw)
|
|
||||||
|
|
||||||
|
|
||||||
def list_pending_proposals(queue_dir: Path) -> list[Proposal]:
|
def list_pending_proposals(bottle_slug: str) -> list[Proposal]:
|
||||||
"""All proposals in `queue_dir` that do not yet have a matching
|
"""All proposals for `bottle_slug` that do not yet have a matching
|
||||||
response file. Sorted by `arrival_timestamp` so the operator
|
response. Sorted by `arrival_timestamp` so the operator
|
||||||
sees the queue FIFO."""
|
sees the queue FIFO."""
|
||||||
if not queue_dir.is_dir():
|
return QueueStore(bottle_slug).list_pending_proposals()
|
||||||
return []
|
|
||||||
out: list[Proposal] = []
|
|
||||||
for path in sorted(queue_dir.glob("*.proposal.json")):
|
|
||||||
proposal_id = _id_from_proposal_filename(path)
|
|
||||||
if proposal_id is None:
|
|
||||||
continue
|
|
||||||
if (queue_dir / _response_filename(proposal_id)).exists():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
with path.open() as f:
|
|
||||||
raw = json.load(f)
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
continue
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
out.append(Proposal.from_dict(raw))
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
continue
|
|
||||||
out.sort(key=lambda p: p.arrival_timestamp)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def write_response(queue_dir: Path, response: Response) -> Path:
|
def list_all_pending_proposals() -> list[Proposal]:
|
||||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
"""All pending proposals across bottles, sorted FIFO."""
|
||||||
path = queue_dir / _response_filename(response.proposal_id)
|
return QueueStore("").list_all_pending_proposals()
|
||||||
payload = json.dumps(response.to_dict(), indent=2) + "\n"
|
|
||||||
_atomic_write(path, payload, mode=0o600)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def read_response(queue_dir: Path, proposal_id: str) -> Response:
|
def write_response(bottle_slug: str, response: Response) -> Path:
|
||||||
path = queue_dir / _response_filename(proposal_id)
|
return QueueStore(bottle_slug).write_response(response)
|
||||||
with path.open() as f:
|
|
||||||
raw = json.load(f)
|
|
||||||
if not isinstance(raw, dict):
|
def read_response(bottle_slug: str, proposal_id: str) -> Response:
|
||||||
raise ValueError(f"{path}: top-level must be an object")
|
return QueueStore(bottle_slug).read_response(proposal_id)
|
||||||
return Response.from_dict(raw)
|
|
||||||
|
|
||||||
|
|
||||||
def wait_for_response(
|
def wait_for_response(
|
||||||
queue_dir: Path,
|
bottle_slug: str,
|
||||||
proposal_id: str,
|
proposal_id: str,
|
||||||
*,
|
*,
|
||||||
poll_interval: float = DEFAULT_POLL_INTERVAL_SEC,
|
poll_interval: float = DEFAULT_POLL_INTERVAL_SEC,
|
||||||
@@ -317,90 +273,35 @@ def wait_for_response(
|
|||||||
which the wait raises TimeoutError. None waits forever — the
|
which the wait raises TimeoutError. None waits forever — the
|
||||||
natural shape, since the operator's response time is unbounded.
|
natural shape, since the operator's response time is unbounded.
|
||||||
|
|
||||||
Polls the filesystem so the implementation stays portable and
|
Polls SQLite so the implementation stays portable and stdlib-only."""
|
||||||
stdlib-only."""
|
store = QueueStore(bottle_slug)
|
||||||
path = queue_dir / _response_filename(proposal_id)
|
|
||||||
while True:
|
while True:
|
||||||
if path.exists():
|
try:
|
||||||
try:
|
return store.read_response(proposal_id)
|
||||||
with path.open() as f:
|
except FileNotFoundError:
|
||||||
raw = json.load(f)
|
pass
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
raw = None
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
try:
|
|
||||||
return Response.from_dict(raw)
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
pass
|
|
||||||
if deadline is not None and time.monotonic() >= deadline:
|
if deadline is not None and time.monotonic() >= deadline:
|
||||||
raise TimeoutError(f"no response for proposal {proposal_id!r}")
|
raise TimeoutError(f"no response for proposal {proposal_id!r}")
|
||||||
time.sleep(poll_interval)
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
|
||||||
def archive_proposal(queue_dir: Path, proposal_id: str) -> None:
|
def archive_proposal(bottle_slug: str, proposal_id: str) -> None:
|
||||||
"""Move both proposal and response files to `<queue_dir>/processed/`.
|
"""Mark both proposal and response rows processed.
|
||||||
Idempotent — missing files are silently skipped."""
|
Idempotent — missing rows are silently skipped."""
|
||||||
processed = queue_dir / "processed"
|
QueueStore(bottle_slug).archive_proposal(proposal_id)
|
||||||
processed.mkdir(parents=True, exist_ok=True)
|
|
||||||
for name in (_proposal_filename(proposal_id), _response_filename(proposal_id)):
|
|
||||||
src = queue_dir / name
|
|
||||||
if src.exists():
|
|
||||||
src.rename(processed / name)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Audit log -------------------------------------------------------------
|
# --- Audit log -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def write_audit_entry(entry: AuditEntry) -> Path:
|
def write_audit_entry(entry: AuditEntry) -> Path:
|
||||||
"""Append `entry` as one JSON-Lines record to the per-bottle
|
"""Append `entry` to the host supervise audit table."""
|
||||||
audit log. Acquires an advisory exclusive lock so concurrent
|
return AuditStore().write_audit_entry(entry)
|
||||||
writers don't interleave bytes."""
|
|
||||||
path = audit_log_path(entry.component, entry.bottle_slug)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
line = json.dumps(entry.to_dict(), sort_keys=False) + "\n"
|
|
||||||
fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
||||||
try:
|
|
||||||
_try_flock(fd)
|
|
||||||
try:
|
|
||||||
os.write(fd, line.encode("utf-8"))
|
|
||||||
finally:
|
|
||||||
_try_funlock(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def read_audit_entries(component: str, slug: str) -> list[AuditEntry]:
|
def read_audit_entries(component: str, slug: str) -> list[AuditEntry]:
|
||||||
"""Load all audit entries for the given component+slug. Empty
|
"""Load all audit entries for the given component+slug."""
|
||||||
list if the log doesn't exist."""
|
return AuditStore().read_audit_entries(component, slug)
|
||||||
path = audit_log_path(component, slug)
|
|
||||||
if not path.is_file():
|
|
||||||
return []
|
|
||||||
out: list[AuditEntry] = []
|
|
||||||
with path.open() as f:
|
|
||||||
for raw_line in f:
|
|
||||||
raw_line = raw_line.strip()
|
|
||||||
if not raw_line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
raw = json.loads(raw_line)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
out.append(AuditEntry(
|
|
||||||
timestamp=_require_str(raw, "timestamp"),
|
|
||||||
bottle_slug=_require_str(raw, "bottle_slug"),
|
|
||||||
component=_require_str(raw, "component"),
|
|
||||||
operator_action=_require_str(raw, "operator_action"),
|
|
||||||
operator_notes=_require_str(raw, "operator_notes"),
|
|
||||||
justification=_require_str(raw, "justification"),
|
|
||||||
diff=_require_str(raw, "diff"),
|
|
||||||
))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# --- Diff rendering --------------------------------------------------------
|
# --- Diff rendering --------------------------------------------------------
|
||||||
@@ -433,35 +334,34 @@ def sha256_hex(content: str) -> str:
|
|||||||
class SupervisePlan:
|
class SupervisePlan:
|
||||||
"""Output of Supervise.prepare; consumed by .start.
|
"""Output of Supervise.prepare; consumed by .start.
|
||||||
|
|
||||||
`queue_dir` is the host directory bind-mounted into the sidecar
|
`db_path` is the host database bind-mounted into the sidecar at
|
||||||
at /run/supervise/queue. `internal_network` is empty at prepare
|
/run/supervise/bot-bottle.db. `internal_network` is empty at
|
||||||
time; the backend's launch step fills it via dataclasses.replace
|
prepare time; the backend's launch step fills it via
|
||||||
before calling .start."""
|
dataclasses.replace before calling .start."""
|
||||||
|
|
||||||
slug: str
|
slug: str
|
||||||
queue_dir: Path
|
db_path: Path
|
||||||
internal_network: str = ""
|
internal_network: str = ""
|
||||||
|
|
||||||
|
|
||||||
class Supervise(ABC):
|
class Supervise(ABC):
|
||||||
"""Per-bottle supervise sidecar. Encapsulates the host-side
|
"""Per-bottle supervise sidecar. Encapsulates host-side database
|
||||||
prepare (queue dir staging); the sidecar's start/stop lifecycle
|
staging; the sidecar's start/stop lifecycle is backend-specific."""
|
||||||
is backend-specific."""
|
|
||||||
|
|
||||||
def prepare(
|
def prepare(
|
||||||
self,
|
self,
|
||||||
slug: str,
|
slug: str,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
) -> SupervisePlan:
|
) -> SupervisePlan:
|
||||||
"""Stage the per-bottle queue dir on the host. Returns the
|
"""Stage the host database. Returns the plan; `internal_network`
|
||||||
plan; `internal_network` must be set by the launch step before
|
must be set by the launch step before .start runs."""
|
||||||
.start runs."""
|
|
||||||
del stage_dir
|
del stage_dir
|
||||||
queue_dir = queue_dir_for_slug(slug)
|
db_path = host_db_path()
|
||||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
QueueStore(slug)
|
||||||
|
AuditStore(db_path)
|
||||||
return SupervisePlan(
|
return SupervisePlan(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
queue_dir=queue_dir,
|
db_path=db_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Helpers ---------------------------------------------------------------
|
# --- Helpers ---------------------------------------------------------------
|
||||||
@@ -474,47 +374,15 @@ def _require_str(raw: dict[str, object], key: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _atomic_write(path: Path, content: str, *, mode: int) -> None:
|
|
||||||
"""Atomic: write to a sibling tmp file, fsync, rename."""
|
|
||||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
||||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
|
|
||||||
try:
|
|
||||||
os.write(fd, content.encode("utf-8"))
|
|
||||||
os.fsync(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
os.replace(tmp, path)
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
import fcntl as _fcntl
|
|
||||||
|
|
||||||
def _try_flock(fd: int) -> None: # type: ignore[reportRedeclaration]
|
|
||||||
try:
|
|
||||||
_fcntl.flock(fd, _fcntl.LOCK_EX)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _try_funlock(fd: int) -> None: # type: ignore[reportRedeclaration]
|
|
||||||
try:
|
|
||||||
_fcntl.flock(fd, _fcntl.LOCK_UN)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
except ImportError: # pragma: no cover — Windows path
|
|
||||||
def _try_flock(fd: int) -> None: # noqa: F841 — Windows fallback
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _try_funlock(fd: int) -> None: # noqa: F841 — Windows fallback
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ACTION_OPERATOR_EDIT",
|
"ACTION_OPERATOR_EDIT",
|
||||||
"AuditEntry",
|
"AuditEntry",
|
||||||
|
"AuditStore",
|
||||||
"COMPONENT_FOR_TOOL",
|
"COMPONENT_FOR_TOOL",
|
||||||
"DEFAULT_POLL_INTERVAL_SEC",
|
"DEFAULT_POLL_INTERVAL_SEC",
|
||||||
|
"DB_PATH_IN_CONTAINER",
|
||||||
"Proposal",
|
"Proposal",
|
||||||
"QUEUE_DIR_IN_CONTAINER",
|
"QueueStore",
|
||||||
"Response",
|
"Response",
|
||||||
"STATUSES",
|
"STATUSES",
|
||||||
"STATUS_APPROVED",
|
"STATUS_APPROVED",
|
||||||
@@ -536,8 +404,9 @@ __all__ = [
|
|||||||
"audit_dir",
|
"audit_dir",
|
||||||
"audit_log_path",
|
"audit_log_path",
|
||||||
"bot_bottle_root",
|
"bot_bottle_root",
|
||||||
|
"host_db_path",
|
||||||
"list_pending_proposals",
|
"list_pending_proposals",
|
||||||
"queue_dir_for_slug",
|
"list_all_pending_proposals",
|
||||||
"read_audit_entries",
|
"read_audit_entries",
|
||||||
"read_proposal",
|
"read_proposal",
|
||||||
"read_response",
|
"read_response",
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ config changes when stuck. The tools are `egress-allow`,
|
|||||||
Each queued tool call:
|
Each queued tool call:
|
||||||
|
|
||||||
1. Validates the proposed file syntactically.
|
1. Validates the proposed file syntactically.
|
||||||
2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from
|
2. Writes a Proposal to the host SQLite database.
|
||||||
the host's ~/.bot-bottle/queue/<slug>/).
|
3. Blocks polling for a matching Response row.
|
||||||
3. Blocks polling for a matching Response file.
|
|
||||||
4. Returns the operator's `{status, notes}` to the agent.
|
4. Returns the operator's `{status, notes}` to the agent.
|
||||||
|
|
||||||
The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at
|
The bottle slug arrives via SUPERVISE_BOTTLE_SLUG env (stamped at
|
||||||
container creation by the backend's start step). The queue dir comes
|
container creation by the backend's start step). SUPERVISE_DB_PATH
|
||||||
from SUPERVISE_QUEUE_DIR (default `/run/supervise/queue`).
|
points at the bind-mounted host database.
|
||||||
|
|
||||||
Speaks MCP over HTTP+JSON-RPC. Methods handled:
|
Speaks MCP over HTTP+JSON-RPC. Methods handled:
|
||||||
|
|
||||||
@@ -42,7 +41,6 @@ import typing
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Same-directory imports inside the bundle container; these files are
|
# Same-directory imports inside the bundle container; these files are
|
||||||
@@ -277,7 +275,6 @@ def validate_proposed_file(tool: str, content: str) -> None:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ServerConfig:
|
class ServerConfig:
|
||||||
bottle_slug: str
|
bottle_slug: str
|
||||||
queue_dir: Path
|
|
||||||
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS
|
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
|
||||||
@@ -376,7 +373,7 @@ def handle_tools_call(
|
|||||||
current_file_hash=_sv.sha256_hex(proposed_file),
|
current_file_hash=_sv.sha256_hex(proposed_file),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_sv.write_proposal(config.queue_dir, proposal)
|
_sv.write_proposal(proposal)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e
|
raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
@@ -387,7 +384,7 @@ def handle_tools_call(
|
|||||||
deadline = time.monotonic() + config.response_timeout_seconds
|
deadline = time.monotonic() + config.response_timeout_seconds
|
||||||
try:
|
try:
|
||||||
response = _sv.wait_for_response(
|
response = _sv.wait_for_response(
|
||||||
config.queue_dir,
|
config.bottle_slug,
|
||||||
proposal.id,
|
proposal.id,
|
||||||
poll_interval=MIN_RESPONSE_POLL_INTERVAL_SECONDS,
|
poll_interval=MIN_RESPONSE_POLL_INTERVAL_SECONDS,
|
||||||
deadline=deadline,
|
deadline=deadline,
|
||||||
@@ -399,7 +396,7 @@ def handle_tools_call(
|
|||||||
"isError": False,
|
"isError": False,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
_sv.archive_proposal(config.queue_dir, proposal.id)
|
_sv.archive_proposal(config.bottle_slug, proposal.id)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise _RpcInternalError(f"failed to archive proposal: {e}") from e
|
raise _RpcInternalError(f"failed to archive proposal: {e}") from e
|
||||||
|
|
||||||
@@ -539,7 +536,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||||
allow_reuse_address = True
|
allow_reuse_address = True
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
config: ServerConfig = ServerConfig(bottle_slug="", queue_dir=Path())
|
config: ServerConfig = ServerConfig(bottle_slug="")
|
||||||
|
|
||||||
|
|
||||||
# --- Entry point -----------------------------------------------------------
|
# --- Entry point -----------------------------------------------------------
|
||||||
@@ -548,21 +545,18 @@ class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|||||||
def serve(
|
def serve(
|
||||||
*,
|
*,
|
||||||
bottle_slug: str,
|
bottle_slug: str,
|
||||||
queue_dir: Path,
|
|
||||||
port: int = _sv.SUPERVISE_PORT,
|
port: int = _sv.SUPERVISE_PORT,
|
||||||
bind: str = "0.0.0.0",
|
bind: str = "0.0.0.0",
|
||||||
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
|
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
|
||||||
) -> typing.NoReturn:
|
) -> typing.NoReturn:
|
||||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
server = MCPServer((bind, port), MCPHandler)
|
server = MCPServer((bind, port), MCPHandler)
|
||||||
server.config = ServerConfig(
|
server.config = ServerConfig(
|
||||||
bottle_slug=bottle_slug,
|
bottle_slug=bottle_slug,
|
||||||
queue_dir=queue_dir,
|
|
||||||
response_timeout_seconds=response_timeout_seconds,
|
response_timeout_seconds=response_timeout_seconds,
|
||||||
)
|
)
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"supervise listening on {bind}:{port}; "
|
f"supervise listening on {bind}:{port}; "
|
||||||
f"slug={bottle_slug!r}; queue={queue_dir}; "
|
f"slug={bottle_slug!r}; "
|
||||||
f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type]
|
f"tools: {', '.join(t['name'] for t in TOOL_DEFINITIONS)}\n" # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
@@ -581,7 +575,6 @@ def main(argv: list[str]) -> int:
|
|||||||
if not bottle_slug:
|
if not bottle_slug:
|
||||||
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
|
sys.stderr.write("supervise: SUPERVISE_BOTTLE_SLUG env is unset\n")
|
||||||
return 2
|
return 2
|
||||||
queue_dir = Path(os.environ.get("SUPERVISE_QUEUE_DIR", _sv.QUEUE_DIR_IN_CONTAINER))
|
|
||||||
port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT)))
|
port = int(os.environ.get("SUPERVISE_PORT", str(_sv.SUPERVISE_PORT)))
|
||||||
bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0")
|
bind = os.environ.get("SUPERVISE_BIND", "0.0.0.0")
|
||||||
try:
|
try:
|
||||||
@@ -591,7 +584,6 @@ def main(argv: list[str]) -> int:
|
|||||||
return 2
|
return 2
|
||||||
serve(
|
serve(
|
||||||
bottle_slug=bottle_slug,
|
bottle_slug=bottle_slug,
|
||||||
queue_dir=queue_dir,
|
|
||||||
port=port,
|
port=port,
|
||||||
bind=bind,
|
bind=bind,
|
||||||
response_timeout_seconds=response_timeout_seconds,
|
response_timeout_seconds=response_timeout_seconds,
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
# PRD prd-new: Fold bot-bottle-orchestrator into this repo
|
|
||||||
|
|
||||||
- **Status:** Active
|
|
||||||
- **Author:** didericis
|
|
||||||
- **Created:** 2026-07-01
|
|
||||||
- **Issue:** #321
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Move the `bot-bottle-orchestrator` binary into `bot_bottle/orchestrator/` as a
|
|
||||||
first-class subpackage. `pip install bot-bottle` gets you everything; the
|
|
||||||
orchestrator's entry point becomes `python -m bot_bottle.orchestrator run`. The
|
|
||||||
cross-repo CLI contract becomes an internal boundary, and the forge integration
|
|
||||||
layer (`GiteaClient`, `ScopedForge`, `SqliteForgeStateStore`) is promoted to
|
|
||||||
`bot_bottle/contrib/` where it belongs.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The orchestrator and bot-bottle are tightly coupled:
|
|
||||||
- It always deploys on the same host.
|
|
||||||
- It imports from `bot_bottle` for the forge/state layer.
|
|
||||||
- Its runner shims (`start --headless`, `commit`, `resume`) map 1:1 to CLI
|
|
||||||
commands in `cli.py` — a breaking CLI change silently breaks the orchestrator
|
|
||||||
with no CI signal.
|
|
||||||
- Two repos means two version pins, two CI pipelines, and two install steps
|
|
||||||
every time the deploy environment is rebuilt.
|
|
||||||
|
|
||||||
## Goals / Success Criteria
|
|
||||||
|
|
||||||
- All orchestrator modules live under `bot_bottle/orchestrator/` and the package
|
|
||||||
is importable as `from bot_bottle.orchestrator import ...`.
|
|
||||||
- `python -m bot_bottle.orchestrator run` starts the webhook server.
|
|
||||||
- `python -m bot_bottle.orchestrator status` prints tracked runs.
|
|
||||||
- The forge integration layer (`GiteaClient`, `GiteaForge`, `ScopedForge`,
|
|
||||||
`ForgeState`, `SqliteForgeStateStore`) lives in `bot_bottle/contrib/` and is
|
|
||||||
covered by tests in `tests/unit/orchestrator/`.
|
|
||||||
- All orchestrator unit tests pass under bot-bottle's existing CI
|
|
||||||
(`python -m unittest discover -s tests/unit`).
|
|
||||||
- No functional change to the orchestrator's external behaviour: same
|
|
||||||
HTTP surface, same webhook protocol, same env-var config, same CLI flags.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- Replacing `SubprocessBottleRunner` with a direct programmatic runner — the
|
|
||||||
subprocess shim stays; the `BottleRunner` protocol remains the internal
|
|
||||||
abstraction point.
|
|
||||||
- Merging the orchestrator's SQLite DB with any other bot-bottle state store.
|
|
||||||
- Archiving `bot-bottle-orchestrator` (that happens after this ships and the
|
|
||||||
deploy is updated; out of scope for this PR).
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Package layout
|
|
||||||
|
|
||||||
```
|
|
||||||
bot_bottle/
|
|
||||||
orchestrator/
|
|
||||||
__init__.py
|
|
||||||
__main__.py # python -m bot_bottle.orchestrator
|
|
||||||
bootstrap.py # wires contrib modules → orchestrator core
|
|
||||||
config.py
|
|
||||||
events.py
|
|
||||||
lifecycle.py
|
|
||||||
model.py
|
|
||||||
provenance.py
|
|
||||||
runner.py
|
|
||||||
sidecar.py
|
|
||||||
store.py
|
|
||||||
targeting.py
|
|
||||||
watchdog.py
|
|
||||||
webhook.py
|
|
||||||
contrib/
|
|
||||||
forge/
|
|
||||||
__init__.py
|
|
||||||
base.py # ScopedForge: read-anywhere / write-scoped wrapper
|
|
||||||
gitea/
|
|
||||||
client.py # GiteaClient (urllib.request), GiteaForge
|
|
||||||
forge_state.py # ForgeState dataclass + SqliteForgeStateStore
|
|
||||||
|
|
||||||
tests/unit/orchestrator/
|
|
||||||
__init__.py
|
|
||||||
_fakes.py
|
|
||||||
test_config.py
|
|
||||||
test_events.py
|
|
||||||
test_lifecycle.py
|
|
||||||
test_provenance.py
|
|
||||||
test_runner.py
|
|
||||||
test_sidecar.py
|
|
||||||
test_store.py
|
|
||||||
test_targeting.py
|
|
||||||
test_watchdog.py
|
|
||||||
test_webhook.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Module moves
|
|
||||||
|
|
||||||
Every `orchestrator/` source file moves verbatim into `bot_bottle/orchestrator/`.
|
|
||||||
Internal imports are already relative (`from .config import Config`) so no
|
|
||||||
changes are needed inside the orchestrator modules themselves.
|
|
||||||
|
|
||||||
`bootstrap.py` is the only file that changes meaningfully: the lazy `bot_bottle`
|
|
||||||
imports become direct relative imports (`from ..contrib.gitea.client import …`),
|
|
||||||
and the `_require_bot_bottle()` guard is removed since the package is always
|
|
||||||
present.
|
|
||||||
|
|
||||||
### New contrib modules
|
|
||||||
|
|
||||||
**`bot_bottle/contrib/forge/base.py` — `ScopedForge`**
|
|
||||||
|
|
||||||
Wraps any forge object and enforces read-anywhere / write-scoped access: reads
|
|
||||||
pass through unconditionally; `post_comment` and `update_description` raise
|
|
||||||
`PermissionError` for issue/PR numbers outside the assigned set.
|
|
||||||
|
|
||||||
**`bot_bottle/contrib/gitea/client.py` — `GiteaClient`, `GiteaForge`**
|
|
||||||
|
|
||||||
`GiteaClient` is a thin `urllib.request`-only HTTP wrapper (no new Python
|
|
||||||
dependencies). `GiteaForge` composes a client and exposes the forge protocol:
|
|
||||||
`is_org_member`, `read_issue`, `read_pr`, `read_comments`, `post_comment`,
|
|
||||||
`update_description`.
|
|
||||||
|
|
||||||
**`bot_bottle/contrib/gitea/forge_state.py` — `ForgeState`, `SqliteForgeStateStore`**
|
|
||||||
|
|
||||||
`ForgeState` is a dataclass mirroring `RunRecord` field-for-field. `SqliteForgeStateStore`
|
|
||||||
backs it with SQLite (stdlib `sqlite3`): a single `forge_state` table with one
|
|
||||||
row per (owner, repo, issue\_number).
|
|
||||||
|
|
||||||
### Test migration
|
|
||||||
|
|
||||||
All orchestrator test files move to `tests/unit/orchestrator/` with absolute
|
|
||||||
imports updated from `orchestrator.X` to `bot_bottle.orchestrator.X`. The unit
|
|
||||||
discovery command (`-s tests/unit`) picks them up automatically — no CI changes
|
|
||||||
required.
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# PRD prd-new: SQLite local storage
|
||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** codex
|
||||||
|
- **Created:** 2026-07-01
|
||||||
|
- **Issue:** #319
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a small stdlib SQLite storage layer for bot-bottle host runtime state,
|
||||||
|
starting with the supervise queue and audit log. This replaces scattered JSON
|
||||||
|
queue files and JSONL audit logs with structured tables while preserving the
|
||||||
|
existing public supervise helper functions and sidecar queue mount contract.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Bot-bottle currently stores supervise proposals and responses as individual JSON
|
||||||
|
files under `~/.bot-bottle/queue/<slug>/`, and audit entries as JSONL files
|
||||||
|
under `~/.bot-bottle/audit/`. That worked for the original interactive TUI, but
|
||||||
|
new forge-native orchestration needs durable, queryable local state for queues,
|
||||||
|
audit trails, watchdogs, and lifecycle records. PR #318 started introducing
|
||||||
|
SQLite-shaped boilerplate for forge state; the storage foundation should live in
|
||||||
|
its own PR so forge work can build on the shared runtime store instead of adding
|
||||||
|
one-off persistence.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
1. Supervise proposals and responses are persisted through SQLite.
|
||||||
|
2. Audit entries are persisted through SQLite.
|
||||||
|
3. Supervise queue helpers use the bottle slug / queue key instead of a queue
|
||||||
|
directory path.
|
||||||
|
4. The sidecar receives the host database mount across docker, smolmachines,
|
||||||
|
and macOS-container backends.
|
||||||
|
5. The implementation stays stdlib-only.
|
||||||
|
6. Schema migrations use a `PRAGMA user_version` runner — no third-party deps.
|
||||||
|
7. Unit tests cover queue round-trips, pending discovery, response waits,
|
||||||
|
archive semantics, audit round-trips, and path creation.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Migrating old JSON queue files or JSONL audit logs.
|
||||||
|
- Adding forge orchestration state tables.
|
||||||
|
- Adding egress metering or budget tables.
|
||||||
|
- Changing the supervise TUI workflow or remediation behavior.
|
||||||
|
- Introducing a third-party ORM or migration library.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Database locations
|
||||||
|
|
||||||
|
Queue and audit state use the host-level local database:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.bot-bottle/bot-bottle.db
|
||||||
|
```
|
||||||
|
|
||||||
|
The supervise sidecar receives that database as a writable bind mount at
|
||||||
|
`/run/supervise/bot-bottle.db` and gets the path through `SUPERVISE_DB_PATH`.
|
||||||
|
No per-slug queue directory is mounted into the sidecar. This creates the shared
|
||||||
|
host database that later forge/native lifecycle work can extend in separate
|
||||||
|
PRDs.
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
`supervise_proposals` lives in the host database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE supervise_proposals (
|
||||||
|
queue_key TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
bottle_slug TEXT NOT NULL,
|
||||||
|
tool TEXT NOT NULL,
|
||||||
|
proposed_file TEXT NOT NULL,
|
||||||
|
justification TEXT NOT NULL,
|
||||||
|
arrival_timestamp TEXT NOT NULL,
|
||||||
|
current_file_hash TEXT NOT NULL,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (queue_key, id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`supervise_responses` lives in the host database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE supervise_responses (
|
||||||
|
queue_key TEXT NOT NULL,
|
||||||
|
proposal_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
notes TEXT NOT NULL,
|
||||||
|
final_file TEXT,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (queue_key, proposal_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`supervise_audit_entries` lives in the host database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE supervise_audit_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
bottle_slug TEXT NOT NULL,
|
||||||
|
component TEXT NOT NULL,
|
||||||
|
operator_action TEXT NOT NULL,
|
||||||
|
operator_notes TEXT NOT NULL,
|
||||||
|
justification TEXT NOT NULL,
|
||||||
|
diff TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
The queue helpers take a bottle slug / queue key and perform equivalent
|
||||||
|
operations against `~/.bot-bottle/bot-bottle.db`:
|
||||||
|
|
||||||
|
- `list_pending_proposals` returns non-archived proposals without a non-archived
|
||||||
|
response, sorted by arrival time.
|
||||||
|
- `archive_proposal` marks matching proposal/response rows archived instead of
|
||||||
|
moving files into `processed/`.
|
||||||
|
- `wait_for_response` keeps the current polling behavior but polls SQLite.
|
||||||
|
|
||||||
|
The old audit path helpers (`audit_dir`, `audit_log_path`) stay available for
|
||||||
|
compatibility. `audit_log_path` no longer describes the active storage location;
|
||||||
|
callers should use `read_audit_entries`.
|
||||||
|
|
||||||
|
## Implementation chunks
|
||||||
|
|
||||||
|
1. Add SQLite store helpers for supervise queue and audit state.
|
||||||
|
2. Rewire `bot_bottle.supervise` queue/audit functions to the store.
|
||||||
|
3. Update supervise CLI discovery tests and queue/audit unit tests.
|
||||||
|
4. Run unit tests, pyright, and pylint for touched modules.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"""Shared test doubles: a duck-typed forge and bottle runner."""
|
|
||||||
|
|
||||||
# Test doubles mirror an API shape; some params are intentionally unused.
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.runner import slugify
|
|
||||||
|
|
||||||
|
|
||||||
class FakeForge:
|
|
||||||
def __init__(self, members: tuple[str, ...] = ()) -> None:
|
|
||||||
self.members = set(members)
|
|
||||||
self.comments: list[tuple[int, str]] = []
|
|
||||||
self.descriptions: list[tuple[int, str]] = []
|
|
||||||
self.scope_denied: set[int] = set()
|
|
||||||
|
|
||||||
def is_org_member(self, org: str, username: str) -> bool:
|
|
||||||
return username in self.members
|
|
||||||
|
|
||||||
def read_issue(self, number: int) -> dict[str, object]:
|
|
||||||
return {"number": number, "kind": "issue"}
|
|
||||||
|
|
||||||
def read_pr(self, number: int) -> dict[str, object]:
|
|
||||||
return {"number": number, "merged": False}
|
|
||||||
|
|
||||||
def read_comments(self, number: int) -> list[dict[str, object]]:
|
|
||||||
return [{"id": 1, "user": "alice", "body": "hi"}]
|
|
||||||
|
|
||||||
def post_comment(self, number: int, body: str) -> None:
|
|
||||||
if number in self.scope_denied:
|
|
||||||
raise PermissionError(f"write to #{number} denied")
|
|
||||||
self.comments.append((number, body))
|
|
||||||
|
|
||||||
def update_description(self, number: int, body: str) -> None:
|
|
||||||
if number in self.scope_denied:
|
|
||||||
raise PermissionError(f"write to #{number} denied")
|
|
||||||
self.descriptions.append((number, body))
|
|
||||||
|
|
||||||
|
|
||||||
class FakeRunner:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls: list[tuple[object, ...]] = []
|
|
||||||
|
|
||||||
def start(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
agent: str,
|
|
||||||
bottles: Sequence[str],
|
|
||||||
label: str,
|
|
||||||
prompt: str,
|
|
||||||
forge_env: dict[str, str],
|
|
||||||
) -> str:
|
|
||||||
self.calls.append(("start", agent, tuple(bottles), label, prompt, dict(forge_env)))
|
|
||||||
return slugify(label)
|
|
||||||
|
|
||||||
def freeze(self, slug: str) -> None:
|
|
||||||
self.calls.append(("freeze", slug))
|
|
||||||
|
|
||||||
def resume(self, slug: str, prompt: str) -> None:
|
|
||||||
self.calls.append(("resume", slug, prompt))
|
|
||||||
|
|
||||||
def destroy(self, slug: str) -> None:
|
|
||||||
self.calls.append(("destroy", slug))
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
"""Unit: BotBottleStateStore, _token, conversions, make_forge/make_sidecar, build."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.bootstrap import (
|
|
||||||
BotBottleStateStore,
|
|
||||||
_to_forge_state,
|
|
||||||
_to_record,
|
|
||||||
_token,
|
|
||||||
build,
|
|
||||||
make_forge,
|
|
||||||
make_sidecar,
|
|
||||||
)
|
|
||||||
from bot_bottle.orchestrator.config import Config
|
|
||||||
from bot_bottle.orchestrator.model import RunRecord
|
|
||||||
|
|
||||||
|
|
||||||
def _config(tmp: str) -> Config:
|
|
||||||
return Config(
|
|
||||||
forge_org="org",
|
|
||||||
gitea_api="http://g/api/v1",
|
|
||||||
watchdog_timeout_secs=1800,
|
|
||||||
webhook_host="127.0.0.1",
|
|
||||||
webhook_port=0,
|
|
||||||
queue_dir=Path(tmp) / "q",
|
|
||||||
sidecar_socket=Path(tmp) / "s.sock",
|
|
||||||
db_path=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _record(**kw: object) -> RunRecord:
|
|
||||||
defaults: dict[str, object] = {
|
|
||||||
"owner": "o", "repo": "r", "issue_number": 1, "slug": "s1", "agent_name": "a",
|
|
||||||
"bottle_names": ["claude"], "backend_name": "docker", "agent_git_user": "bot",
|
|
||||||
"pr_number": 5, "status": "running", "last_checkin_at": "2026-01-01T00:00:00+00:00",
|
|
||||||
}
|
|
||||||
defaults.update(kw)
|
|
||||||
return RunRecord(**defaults) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
class TokenTest(unittest.TestCase):
|
|
||||||
def test_gitea_token_env(self):
|
|
||||||
with patch.dict(os.environ, {"GITEA_TOKEN": "tok123"}):
|
|
||||||
self.assertEqual("tok123", _token())
|
|
||||||
|
|
||||||
def test_forge_gitea_token_fallback(self):
|
|
||||||
clean = {k: v for k, v in os.environ.items()
|
|
||||||
if k not in ("GITEA_TOKEN", "FORGE_GITEA_TOKEN")}
|
|
||||||
with patch.dict(os.environ, {**clean, "FORGE_GITEA_TOKEN": "tok456"}, clear=True):
|
|
||||||
self.assertEqual("tok456", _token())
|
|
||||||
|
|
||||||
def test_missing_token_raises(self):
|
|
||||||
clean = {k: v for k, v in os.environ.items()
|
|
||||||
if k not in ("GITEA_TOKEN", "FORGE_GITEA_TOKEN")}
|
|
||||||
with patch.dict(os.environ, clean, clear=True):
|
|
||||||
with self.assertRaises(RuntimeError):
|
|
||||||
_token()
|
|
||||||
|
|
||||||
|
|
||||||
class ConversionRoundTripTest(unittest.TestCase):
|
|
||||||
def test_record_survives_forge_state_roundtrip(self):
|
|
||||||
rec = _record()
|
|
||||||
result = _to_record(_to_forge_state(rec))
|
|
||||||
self.assertEqual(rec.owner, result.owner)
|
|
||||||
self.assertEqual(rec.repo, result.repo)
|
|
||||||
self.assertEqual(rec.issue_number, result.issue_number)
|
|
||||||
self.assertEqual(rec.slug, result.slug)
|
|
||||||
self.assertEqual(rec.agent_name, result.agent_name)
|
|
||||||
self.assertEqual(rec.bottle_names, result.bottle_names)
|
|
||||||
self.assertEqual(rec.backend_name, result.backend_name)
|
|
||||||
self.assertEqual(rec.agent_git_user, result.agent_git_user)
|
|
||||||
self.assertEqual(rec.pr_number, result.pr_number)
|
|
||||||
self.assertEqual(rec.status, result.status)
|
|
||||||
self.assertEqual(rec.last_checkin_at, result.last_checkin_at)
|
|
||||||
|
|
||||||
def test_none_pr_number_preserved(self):
|
|
||||||
rec = _record(pr_number=None)
|
|
||||||
result = _to_record(_to_forge_state(rec))
|
|
||||||
self.assertIsNone(result.pr_number)
|
|
||||||
|
|
||||||
|
|
||||||
class BotBottleStateStoreTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.store = BotBottleStateStore(None)
|
|
||||||
|
|
||||||
def test_upsert_and_get(self):
|
|
||||||
self.store.upsert(_record())
|
|
||||||
got = self.store.get("o", "r", 1)
|
|
||||||
assert got is not None
|
|
||||||
self.assertEqual("s1", got.slug)
|
|
||||||
|
|
||||||
def test_get_missing(self):
|
|
||||||
self.assertIsNone(self.store.get("o", "r", 99))
|
|
||||||
|
|
||||||
def test_upsert_replaces(self):
|
|
||||||
self.store.upsert(_record())
|
|
||||||
self.store.upsert(_record(slug="new-slug"))
|
|
||||||
got = self.store.get("o", "r", 1)
|
|
||||||
assert got is not None
|
|
||||||
self.assertEqual("new-slug", got.slug)
|
|
||||||
|
|
||||||
def test_delete(self):
|
|
||||||
self.store.upsert(_record())
|
|
||||||
self.store.delete("o", "r", 1)
|
|
||||||
self.assertIsNone(self.store.get("o", "r", 1))
|
|
||||||
|
|
||||||
def test_all_returns_all_records(self):
|
|
||||||
self.store.upsert(_record(issue_number=1, slug="s1"))
|
|
||||||
self.store.upsert(_record(issue_number=2, slug="s2"))
|
|
||||||
recs = self.store.all()
|
|
||||||
self.assertEqual(2, len(recs))
|
|
||||||
slugs = {r.slug for r in recs}
|
|
||||||
self.assertEqual({"s1", "s2"}, slugs)
|
|
||||||
|
|
||||||
def test_all_empty(self):
|
|
||||||
self.assertEqual([], self.store.all())
|
|
||||||
|
|
||||||
def test_bottle_names_preserved(self):
|
|
||||||
self.store.upsert(_record(bottle_names=["claude", "dev"]))
|
|
||||||
got = self.store.get("o", "r", 1)
|
|
||||||
assert got is not None
|
|
||||||
self.assertEqual(["claude", "dev"], got.bottle_names)
|
|
||||||
|
|
||||||
|
|
||||||
class MakeForgeTest(unittest.TestCase):
|
|
||||||
def test_returns_gitea_forge(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
config = _config(tmp)
|
|
||||||
with patch.dict(os.environ, {"GITEA_TOKEN": "tok"}):
|
|
||||||
forge = make_forge(config, "owner", "repo")
|
|
||||||
from bot_bottle.contrib.gitea.client import GiteaForge
|
|
||||||
self.assertIsInstance(forge, GiteaForge)
|
|
||||||
|
|
||||||
|
|
||||||
class MakeSidecarTest(unittest.TestCase):
|
|
||||||
def test_returns_forge_sidecar(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
config = _config(tmp)
|
|
||||||
with patch.dict(os.environ, {"GITEA_TOKEN": "tok"}):
|
|
||||||
sidecar = make_sidecar(config, "owner", "repo", 1, [])
|
|
||||||
from bot_bottle.orchestrator.sidecar import ForgeSidecar
|
|
||||||
self.assertIsInstance(sidecar, ForgeSidecar)
|
|
||||||
|
|
||||||
|
|
||||||
class BuildTest(unittest.TestCase):
|
|
||||||
def test_returns_server_watchdog_orchestrator(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
config = _config(tmp)
|
|
||||||
with patch.dict(os.environ, {"GITEA_TOKEN": "tok"}):
|
|
||||||
server, watchdog, orch = build(config)
|
|
||||||
server.server_close()
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.lifecycle import Orchestrator
|
|
||||||
from bot_bottle.orchestrator.watchdog import Watchdog
|
|
||||||
from bot_bottle.orchestrator.webhook import WebhookServer
|
|
||||||
self.assertIsInstance(server, WebhookServer)
|
|
||||||
self.assertIsInstance(watchdog, Watchdog)
|
|
||||||
self.assertIsInstance(orch, Orchestrator)
|
|
||||||
|
|
||||||
def test_server_binds_to_configured_host(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
config = _config(tmp)
|
|
||||||
with patch.dict(os.environ, {"GITEA_TOKEN": "tok"}):
|
|
||||||
server, _, _ = build(config)
|
|
||||||
addr = server.server_address
|
|
||||||
server.server_close()
|
|
||||||
self.assertEqual("127.0.0.1", addr[0])
|
|
||||||
self.assertGreater(addr[1], 0)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"""Unit: Config.from_env."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.config import Config
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigTest(unittest.TestCase):
|
|
||||||
def test_defaults(self):
|
|
||||||
c = Config.from_env({"HOME": "/home/x"})
|
|
||||||
self.assertEqual("bot-bottle", c.forge_org)
|
|
||||||
self.assertEqual(1800, c.watchdog_timeout_secs)
|
|
||||||
self.assertEqual("127.0.0.1", c.webhook_host)
|
|
||||||
self.assertEqual(8477, c.webhook_port)
|
|
||||||
self.assertEqual(Path("/home/x/.bot-bottle/forge-queue"), c.queue_dir)
|
|
||||||
self.assertIsNone(c.db_path)
|
|
||||||
|
|
||||||
def test_overrides(self):
|
|
||||||
c = Config.from_env({
|
|
||||||
"HOME": "/home/x",
|
|
||||||
"FORGE_ORG": "agents",
|
|
||||||
"FORGE_WATCHDOG_TIMEOUT": "60",
|
|
||||||
"FORGE_GITEA_API": "https://g.example/api/v1",
|
|
||||||
"FORGE_WEBHOOK_PORT": "9000",
|
|
||||||
"FORGE_DB_PATH": "/data/bb.db",
|
|
||||||
})
|
|
||||||
self.assertEqual("agents", c.forge_org)
|
|
||||||
self.assertEqual(60, c.watchdog_timeout_secs)
|
|
||||||
self.assertEqual("https://g.example/api/v1", c.gitea_api)
|
|
||||||
self.assertEqual(9000, c.webhook_port)
|
|
||||||
self.assertEqual(Path("/data/bb.db"), c.db_path)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"""Unit: webhook payload parsing."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.events import parse_event
|
|
||||||
from bot_bottle.orchestrator.model import CommentCreated, IssueAssigned, PullRequestClosed
|
|
||||||
|
|
||||||
_REPO = {"repository": {"name": "bot-bottle", "owner": {"login": "didericis"}}}
|
|
||||||
|
|
||||||
|
|
||||||
class ParseEventTest(unittest.TestCase):
|
|
||||||
def test_issue_assigned(self):
|
|
||||||
payload = {
|
|
||||||
**_REPO,
|
|
||||||
"action": "assigned",
|
|
||||||
"issue": {
|
|
||||||
"number": 17,
|
|
||||||
"title": "Fix it",
|
|
||||||
"body": "please",
|
|
||||||
"assignees": [{"login": "agent-bot"}],
|
|
||||||
"labels": [{"name": "bot-bottle:implementer"}],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
ev = parse_event("issues", payload)
|
|
||||||
self.assertIsInstance(ev, IssueAssigned)
|
|
||||||
assert isinstance(ev, IssueAssigned)
|
|
||||||
self.assertEqual(("didericis", "bot-bottle", 17), (ev.owner, ev.repo, ev.issue_number))
|
|
||||||
self.assertEqual(("agent-bot",), ev.assignees)
|
|
||||||
self.assertEqual(("bot-bottle:implementer",), ev.labels)
|
|
||||||
|
|
||||||
def test_issue_non_assigned_ignored(self):
|
|
||||||
self.assertIsNone(parse_event("issues", {**_REPO, "action": "opened", "issue": {}}))
|
|
||||||
|
|
||||||
def test_comment_created(self):
|
|
||||||
payload = {
|
|
||||||
**_REPO,
|
|
||||||
"action": "created",
|
|
||||||
"issue": {"number": 42, "pull_request": {"x": 1}},
|
|
||||||
"comment": {"id": 5, "user": {"login": "reviewer"}, "body": "redo"},
|
|
||||||
}
|
|
||||||
ev = parse_event("issue_comment", payload)
|
|
||||||
assert isinstance(ev, CommentCreated)
|
|
||||||
self.assertEqual(42, ev.issue_number)
|
|
||||||
self.assertEqual("reviewer", ev.author)
|
|
||||||
self.assertTrue(ev.is_pull)
|
|
||||||
|
|
||||||
def test_pull_request_closed(self):
|
|
||||||
payload = {**_REPO, "action": "closed", "pull_request": {"number": 8, "merged": True}}
|
|
||||||
ev = parse_event("pull_request", payload)
|
|
||||||
assert isinstance(ev, PullRequestClosed)
|
|
||||||
self.assertEqual(8, ev.pr_number)
|
|
||||||
self.assertTrue(ev.merged)
|
|
||||||
|
|
||||||
def test_pull_request_non_closed_ignored(self):
|
|
||||||
self.assertIsNone(parse_event("pull_request", {**_REPO, "action": "opened"}))
|
|
||||||
|
|
||||||
def test_comment_non_created_action_ignored(self):
|
|
||||||
payload = {**_REPO, "action": "edited", "issue": {}, "comment": {}}
|
|
||||||
self.assertIsNone(parse_event("issue_comment", payload))
|
|
||||||
|
|
||||||
def test_unknown_kind_ignored(self):
|
|
||||||
self.assertIsNone(parse_event("push", {**_REPO}))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""Unit: ForgeState + SqliteForgeStateStore."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from bot_bottle.contrib.gitea.forge_state import ForgeState, SqliteForgeStateStore
|
|
||||||
|
|
||||||
|
|
||||||
def _state(**kw: object) -> ForgeState:
|
|
||||||
defaults: dict[str, object] = dict(
|
|
||||||
owner="alice", repo="myrepo", issue_number=1,
|
|
||||||
slug="impl-alice-myrepo-1", agent_name="impl",
|
|
||||||
)
|
|
||||||
defaults.update(kw)
|
|
||||||
return ForgeState(**defaults) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
class ForgeStateStoreTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.store = SqliteForgeStateStore(None)
|
|
||||||
|
|
||||||
def test_upsert_and_get(self):
|
|
||||||
s = _state()
|
|
||||||
self.store.upsert(s)
|
|
||||||
got = self.store.get("alice", "myrepo", 1)
|
|
||||||
assert got is not None
|
|
||||||
self.assertEqual("impl-alice-myrepo-1", got.slug)
|
|
||||||
self.assertEqual("impl", got.agent_name)
|
|
||||||
|
|
||||||
def test_get_missing(self):
|
|
||||||
self.assertIsNone(self.store.get("alice", "myrepo", 99))
|
|
||||||
|
|
||||||
def test_upsert_replaces(self):
|
|
||||||
self.store.upsert(_state(status="running"))
|
|
||||||
self.store.upsert(_state(status="frozen"))
|
|
||||||
got = self.store.get("alice", "myrepo", 1)
|
|
||||||
assert got is not None
|
|
||||||
self.assertEqual("frozen", got.status)
|
|
||||||
|
|
||||||
def test_delete(self):
|
|
||||||
self.store.upsert(_state())
|
|
||||||
self.store.delete("alice", "myrepo", 1)
|
|
||||||
self.assertIsNone(self.store.get("alice", "myrepo", 1))
|
|
||||||
|
|
||||||
def test_delete_missing_no_error(self):
|
|
||||||
self.store.delete("alice", "myrepo", 99)
|
|
||||||
|
|
||||||
def test_all_sorted(self):
|
|
||||||
self.store.upsert(_state(owner="z", issue_number=2))
|
|
||||||
self.store.upsert(_state(owner="a", issue_number=1))
|
|
||||||
rows = self.store.all()
|
|
||||||
self.assertEqual(("a", "z"), (rows[0].owner, rows[1].owner))
|
|
||||||
|
|
||||||
def test_bottle_names_roundtrip(self):
|
|
||||||
self.store.upsert(_state(bottle_names=["claude", "dev"]))
|
|
||||||
got = self.store.get("alice", "myrepo", 1)
|
|
||||||
assert got is not None
|
|
||||||
self.assertEqual(["claude", "dev"], got.bottle_names)
|
|
||||||
|
|
||||||
def test_pr_number_none_roundtrip(self):
|
|
||||||
self.store.upsert(_state(pr_number=None))
|
|
||||||
got = self.store.get("alice", "myrepo", 1)
|
|
||||||
assert got is not None
|
|
||||||
self.assertIsNone(got.pr_number)
|
|
||||||
|
|
||||||
def test_pr_number_int_roundtrip(self):
|
|
||||||
self.store.upsert(_state(pr_number=42))
|
|
||||||
got = self.store.get("alice", "myrepo", 1)
|
|
||||||
assert got is not None
|
|
||||||
self.assertEqual(42, got.pr_number)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
"""Unit: the orchestration lifecycle."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.lifecycle import Orchestrator
|
|
||||||
from bot_bottle.orchestrator.model import (
|
|
||||||
STATUS_FROZEN,
|
|
||||||
STATUS_RUNNING,
|
|
||||||
CommentCreated,
|
|
||||||
IssueAssigned,
|
|
||||||
PullRequestClosed,
|
|
||||||
)
|
|
||||||
from bot_bottle.orchestrator.store import InMemoryStateStore
|
|
||||||
|
|
||||||
from ._fakes import FakeForge, FakeRunner
|
|
||||||
|
|
||||||
|
|
||||||
def _assigned(
|
|
||||||
labels: tuple[str, ...] = ("bot-bottle:impl",),
|
|
||||||
assignees: tuple[str, ...] = ("agent-bot",),
|
|
||||||
) -> IssueAssigned:
|
|
||||||
return IssueAssigned(
|
|
||||||
owner="didericis", repo="bot-bottle", issue_number=17,
|
|
||||||
title="t", body="the task", assignees=tuple(assignees), labels=tuple(labels),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LifecycleTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.forge = FakeForge(members=("agent-bot",))
|
|
||||||
self.store = InMemoryStateStore()
|
|
||||||
self.runner = FakeRunner()
|
|
||||||
self.orch = Orchestrator(
|
|
||||||
forge=self.forge, store=self.store, runner=self.runner,
|
|
||||||
org="bot-bottle", gitea_api="https://g/api/v1",
|
|
||||||
now=lambda: "2026-07-01T00:00:00-04:00",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _record(self):
|
|
||||||
return self.store.get("didericis", "bot-bottle", 17)
|
|
||||||
|
|
||||||
def test_assigned_targeted_launches(self):
|
|
||||||
self.orch.handle(_assigned())
|
|
||||||
rec = self._record()
|
|
||||||
assert rec is not None
|
|
||||||
self.assertEqual(STATUS_RUNNING, rec.status)
|
|
||||||
self.assertEqual("impl-didericis-bot-bottle-17", rec.slug)
|
|
||||||
self.assertEqual("start", self.runner.calls[0][0])
|
|
||||||
# forge context injected into the child env.
|
|
||||||
env = cast("dict[str, str]", self.runner.calls[0][5])
|
|
||||||
self.assertEqual("didericis", env["FORGE_OWNER"])
|
|
||||||
self.assertEqual("17", env["FORGE_ISSUE_NUMBER"])
|
|
||||||
|
|
||||||
def test_untargeted_ignored(self):
|
|
||||||
self.orch.handle(_assigned(labels=("bug",)))
|
|
||||||
self.assertIsNone(self._record())
|
|
||||||
self.assertEqual([], self.runner.calls)
|
|
||||||
|
|
||||||
def test_assigned_is_idempotent(self):
|
|
||||||
self.orch.handle(_assigned())
|
|
||||||
self.orch.handle(_assigned()) # redelivery
|
|
||||||
starts = [c for c in self.runner.calls if c[0] == "start"]
|
|
||||||
self.assertEqual(1, len(starts))
|
|
||||||
|
|
||||||
def test_done_signal_freezes(self):
|
|
||||||
self.orch.handle(_assigned())
|
|
||||||
self.orch.on_done_signal("didericis", "bot-bottle", 17, "success", "done")
|
|
||||||
rec = self._record()
|
|
||||||
assert rec is not None
|
|
||||||
self.assertEqual(STATUS_FROZEN, rec.status)
|
|
||||||
self.assertIn(("freeze", "impl-didericis-bot-bottle-17"), self.runner.calls)
|
|
||||||
|
|
||||||
def test_done_signal_ignored_when_not_running(self):
|
|
||||||
# No record yet -> no freeze.
|
|
||||||
self.orch.on_done_signal("didericis", "bot-bottle", 17, "s", "")
|
|
||||||
self.assertEqual([], self.runner.calls)
|
|
||||||
|
|
||||||
def test_comment_on_frozen_resumes(self):
|
|
||||||
self.orch.handle(_assigned())
|
|
||||||
self.orch.on_done_signal("didericis", "bot-bottle", 17, "s", "")
|
|
||||||
self.orch.handle(CommentCreated(
|
|
||||||
owner="didericis", repo="bot-bottle", issue_number=17,
|
|
||||||
comment_id=1, author="reviewer", body="please redo", is_pull=False,
|
|
||||||
))
|
|
||||||
rec = self._record()
|
|
||||||
assert rec is not None
|
|
||||||
self.assertEqual(STATUS_RUNNING, rec.status)
|
|
||||||
self.assertIn(("resume", "impl-didericis-bot-bottle-17", "please redo"),
|
|
||||||
self.runner.calls)
|
|
||||||
|
|
||||||
def test_comment_echo_guard(self):
|
|
||||||
self.orch.handle(_assigned())
|
|
||||||
self.orch.on_done_signal("didericis", "bot-bottle", 17, "s", "")
|
|
||||||
rec = self._record()
|
|
||||||
assert rec is not None
|
|
||||||
rec.agent_git_user = "agent-bot"
|
|
||||||
self.store.upsert(rec)
|
|
||||||
self.orch.handle(CommentCreated(
|
|
||||||
owner="didericis", repo="bot-bottle", issue_number=17,
|
|
||||||
comment_id=2, author="agent-bot", body="I finished", is_pull=False,
|
|
||||||
))
|
|
||||||
# Still frozen, no resume triggered by the agent's own comment.
|
|
||||||
self.assertEqual(STATUS_FROZEN, self._record().status) # type: ignore[union-attr]
|
|
||||||
self.assertNotIn("resume", [c[0] for c in self.runner.calls])
|
|
||||||
|
|
||||||
def test_comment_on_running_ignored(self):
|
|
||||||
self.orch.handle(_assigned()) # running
|
|
||||||
self.orch.handle(CommentCreated(
|
|
||||||
owner="didericis", repo="bot-bottle", issue_number=17,
|
|
||||||
comment_id=1, author="reviewer", body="hi", is_pull=False,
|
|
||||||
))
|
|
||||||
self.assertNotIn("resume", [c[0] for c in self.runner.calls])
|
|
||||||
|
|
||||||
def test_pr_comment_routes_via_link(self):
|
|
||||||
self.orch.handle(_assigned())
|
|
||||||
self.orch.on_done_signal("didericis", "bot-bottle", 17, "s", "")
|
|
||||||
self.orch.link_pr("didericis", "bot-bottle", 17, 42)
|
|
||||||
# Comment arrives on PR #42 (issue_number == PR number in Gitea).
|
|
||||||
self.orch.handle(CommentCreated(
|
|
||||||
owner="didericis", repo="bot-bottle", issue_number=42,
|
|
||||||
comment_id=9, author="reviewer", body="fix", is_pull=True,
|
|
||||||
))
|
|
||||||
self.assertIn(("resume", "impl-didericis-bot-bottle-17", "fix"),
|
|
||||||
self.runner.calls)
|
|
||||||
|
|
||||||
def test_pr_closed_destroys_and_removes(self):
|
|
||||||
self.orch.handle(_assigned())
|
|
||||||
self.orch.link_pr("didericis", "bot-bottle", 17, 42)
|
|
||||||
self.orch.handle(PullRequestClosed(
|
|
||||||
owner="didericis", repo="bot-bottle", pr_number=42, merged=True,
|
|
||||||
))
|
|
||||||
self.assertIn(("destroy", "impl-didericis-bot-bottle-17"), self.runner.calls)
|
|
||||||
self.assertIsNone(self._record())
|
|
||||||
|
|
||||||
def test_comment_on_untracked_issue_ignored(self):
|
|
||||||
# No record in store and is_pull=False -> _route_comment returns None.
|
|
||||||
self.orch.handle(CommentCreated(
|
|
||||||
owner="didericis", repo="bot-bottle", issue_number=99,
|
|
||||||
comment_id=1, author="reviewer", body="hi", is_pull=False,
|
|
||||||
))
|
|
||||||
self.assertEqual([], self.runner.calls)
|
|
||||||
|
|
||||||
def test_pr_closed_untracked_pr_ignored(self):
|
|
||||||
# _find_by_pr finds nothing -> _on_pr_closed exits early.
|
|
||||||
self.orch.handle(PullRequestClosed(
|
|
||||||
owner="didericis", repo="bot-bottle", pr_number=999, merged=True,
|
|
||||||
))
|
|
||||||
self.assertEqual([], self.runner.calls)
|
|
||||||
|
|
||||||
|
|
||||||
class IsoNowTest(unittest.TestCase):
|
|
||||||
def test_returns_iso_string(self):
|
|
||||||
from bot_bottle.orchestrator.lifecycle import _iso_now
|
|
||||||
ts = _iso_now()
|
|
||||||
self.assertIsInstance(ts, str)
|
|
||||||
self.assertIn("T", ts)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"""Unit: __main__ CLI entry points (run and status commands)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.__main__ import main
|
|
||||||
from bot_bottle.orchestrator.config import Config
|
|
||||||
from bot_bottle.orchestrator.model import RunRecord
|
|
||||||
|
|
||||||
|
|
||||||
def _config() -> Config:
|
|
||||||
return Config.from_env({"HOME": "/tmp"})
|
|
||||||
|
|
||||||
|
|
||||||
class MainRunTest(unittest.TestCase):
|
|
||||||
def test_run_delegates_to_bootstrap(self):
|
|
||||||
config = _config()
|
|
||||||
with patch.object(Config, "from_env", return_value=config), \
|
|
||||||
patch("bot_bottle.orchestrator.bootstrap.run") as mock_run:
|
|
||||||
rc = main(["run"])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
mock_run.assert_called_once_with(config)
|
|
||||||
|
|
||||||
def test_run_prints_listen_address_to_stderr(self):
|
|
||||||
config = _config()
|
|
||||||
err = io.StringIO()
|
|
||||||
with patch.object(Config, "from_env", return_value=config), \
|
|
||||||
patch("bot_bottle.orchestrator.bootstrap.run"), \
|
|
||||||
patch("sys.stderr", err):
|
|
||||||
main(["run"])
|
|
||||||
self.assertIn(str(config.webhook_port), err.getvalue())
|
|
||||||
|
|
||||||
|
|
||||||
class MainStatusTest(unittest.TestCase):
|
|
||||||
def test_status_empty_store(self):
|
|
||||||
config = _config()
|
|
||||||
with patch.object(Config, "from_env", return_value=config), \
|
|
||||||
patch("bot_bottle.orchestrator.bootstrap.BotBottleStateStore") as MockStore:
|
|
||||||
MockStore.return_value.all.return_value = []
|
|
||||||
rc = main(["status"])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
|
|
||||||
def test_status_prints_records(self):
|
|
||||||
config = _config()
|
|
||||||
rec = RunRecord(
|
|
||||||
owner="o", repo="r", issue_number=1, slug="my-slug",
|
|
||||||
agent_name="a", pr_number=7, status="frozen",
|
|
||||||
)
|
|
||||||
out = io.StringIO()
|
|
||||||
with patch.object(Config, "from_env", return_value=config), \
|
|
||||||
patch("bot_bottle.orchestrator.bootstrap.BotBottleStateStore") as MockStore, \
|
|
||||||
patch("sys.stdout", out):
|
|
||||||
MockStore.return_value.all.return_value = [rec]
|
|
||||||
rc = main(["status"])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
self.assertIn("my-slug", out.getvalue())
|
|
||||||
self.assertIn("PR#7", out.getvalue())
|
|
||||||
|
|
||||||
def test_status_no_pr_prints_dash(self):
|
|
||||||
config = _config()
|
|
||||||
rec = RunRecord(
|
|
||||||
owner="o", repo="r", issue_number=2, slug="s2",
|
|
||||||
agent_name="a", pr_number=None, status="running",
|
|
||||||
)
|
|
||||||
out = io.StringIO()
|
|
||||||
with patch.object(Config, "from_env", return_value=config), \
|
|
||||||
patch("bot_bottle.orchestrator.bootstrap.BotBottleStateStore") as MockStore, \
|
|
||||||
patch("sys.stdout", out):
|
|
||||||
MockStore.return_value.all.return_value = [rec]
|
|
||||||
main(["status"])
|
|
||||||
self.assertIn("-", out.getvalue())
|
|
||||||
|
|
||||||
|
|
||||||
class MainArgparseTest(unittest.TestCase):
|
|
||||||
def test_no_command_exits(self):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
main([])
|
|
||||||
|
|
||||||
def test_unknown_command_exits(self):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
main(["bogus"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""Unit: provenance assembly + serialization."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.model import RunRecord
|
|
||||||
from bot_bottle.orchestrator.provenance import build_provenance, ops_from_log, provenance_to_dict
|
|
||||||
|
|
||||||
|
|
||||||
def _record() -> RunRecord:
|
|
||||||
return RunRecord(
|
|
||||||
owner="didericis", repo="bot-bottle", issue_number=17,
|
|
||||||
slug="impl-17", agent_name="impl", bottle_names=["claude"],
|
|
||||||
last_checkin_at="2026-07-01T00:05:00-04:00",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProvenanceTest(unittest.TestCase):
|
|
||||||
def test_ops_from_log(self):
|
|
||||||
ops = ops_from_log([
|
|
||||||
{"at": "T1", "op": "read_pr", "target": 5, "detail": "ok"},
|
|
||||||
{"at": "T2", "op": "signal_done", "target": None, "detail": "success: done"},
|
|
||||||
])
|
|
||||||
self.assertEqual(2, len(ops))
|
|
||||||
self.assertEqual("read_pr", ops[0].op)
|
|
||||||
self.assertIsNone(ops[1].target)
|
|
||||||
|
|
||||||
def test_build_and_serialize(self):
|
|
||||||
ops = ops_from_log([{"at": "T1", "op": "post_comment", "target": 17, "detail": "ok"}])
|
|
||||||
prov = build_provenance(
|
|
||||||
_record(), ops=ops, started_at="2026-07-01T00:00:00-04:00",
|
|
||||||
finished_at="2026-07-01T00:05:00-04:00", exit_code=0, watchdog_fired=False,
|
|
||||||
)
|
|
||||||
d = provenance_to_dict(prov)
|
|
||||||
self.assertEqual("impl-17", d["slug"])
|
|
||||||
self.assertEqual("didericis", d["owner"])
|
|
||||||
self.assertEqual(["claude"], d["bottles"])
|
|
||||||
self.assertEqual(0, d["exit_code"])
|
|
||||||
self.assertFalse(d["watchdog_fired"])
|
|
||||||
self.assertEqual(1, len(d["ops"]))
|
|
||||||
self.assertEqual("post_comment", d["ops"][0]["op"])
|
|
||||||
|
|
||||||
def test_watchdog_flag_serialized(self):
|
|
||||||
prov = build_provenance(
|
|
||||||
_record(), ops=(), started_at="", finished_at="",
|
|
||||||
exit_code=None, watchdog_fired=True,
|
|
||||||
)
|
|
||||||
self.assertTrue(provenance_to_dict(prov)["watchdog_fired"])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"""Unit: ProgrammaticBottleRunner + slugify."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
import unittest
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.runner import ProgrammaticBottleRunner, slugify
|
|
||||||
|
|
||||||
|
|
||||||
class SlugifyTest(unittest.TestCase):
|
|
||||||
def test_basic(self):
|
|
||||||
self.assertEqual("impl-didericis-bot-bottle-17",
|
|
||||||
slugify("impl-didericis-bot-bottle-17"))
|
|
||||||
|
|
||||||
def test_collapses_and_strips(self):
|
|
||||||
self.assertEqual("a-b-c", slugify(" A_B/C!! "))
|
|
||||||
|
|
||||||
|
|
||||||
def _make_api_stub(**overrides: object) -> Any:
|
|
||||||
"""Return a mock bot_bottle.api module with sensible defaults."""
|
|
||||||
stub: Any = types.ModuleType("bot_bottle.api")
|
|
||||||
stub.start_headless = MagicMock(return_value="impl-r-17")
|
|
||||||
stub.freeze = MagicMock()
|
|
||||||
stub.resume_headless = MagicMock()
|
|
||||||
stub.destroy = MagicMock()
|
|
||||||
for k, v in overrides.items():
|
|
||||||
setattr(stub, k, v)
|
|
||||||
return stub
|
|
||||||
|
|
||||||
|
|
||||||
class ProgrammaticRunnerTest(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self._api: Any = _make_api_stub()
|
|
||||||
sys.modules["bot_bottle.api"] = self._api
|
|
||||||
self.runner = ProgrammaticBottleRunner()
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
|
||||||
sys.modules.pop("bot_bottle.api", None)
|
|
||||||
|
|
||||||
def test_start_returns_slug_from_api(self) -> None:
|
|
||||||
slug = self.runner.start(
|
|
||||||
agent="impl", bottles=["claude", "dev"], label="impl-r-17",
|
|
||||||
prompt="do it", forge_env={"FORGE_OWNER": "didericis"},
|
|
||||||
)
|
|
||||||
self.assertEqual("impl-r-17", slug)
|
|
||||||
|
|
||||||
def test_start_forwards_all_args(self) -> None:
|
|
||||||
self.runner.start(
|
|
||||||
agent="impl", bottles=["claude", "dev"], label="impl-r-17",
|
|
||||||
prompt="do it", forge_env={"FORGE_OWNER": "didericis"},
|
|
||||||
)
|
|
||||||
self._api.start_headless.assert_called_once_with(
|
|
||||||
"impl",
|
|
||||||
prompt="do it",
|
|
||||||
bottles=["claude", "dev"],
|
|
||||||
label="impl-r-17",
|
|
||||||
forge_env={"FORGE_OWNER": "didericis"},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_start_no_bottles_passes_none(self) -> None:
|
|
||||||
self.runner.start(agent="impl", bottles=[], label="l", prompt="p", forge_env={})
|
|
||||||
call_kwargs = self._api.start_headless.call_args[1]
|
|
||||||
self.assertIsNone(call_kwargs["bottles"])
|
|
||||||
|
|
||||||
def test_freeze_delegates_to_api(self) -> None:
|
|
||||||
self.runner.freeze("slug-1")
|
|
||||||
self._api.freeze.assert_called_once_with("slug-1")
|
|
||||||
|
|
||||||
def test_freeze_returns_none(self) -> None:
|
|
||||||
result = self.runner.freeze("slug-1")
|
|
||||||
self.assertIsNone(result)
|
|
||||||
|
|
||||||
def test_resume_delegates_to_api(self) -> None:
|
|
||||||
self.runner.resume("slug-1", "address review")
|
|
||||||
self._api.resume_headless.assert_called_once_with("slug-1", prompt="address review")
|
|
||||||
|
|
||||||
def test_resume_returns_none(self) -> None:
|
|
||||||
result = self.runner.resume("slug-1", "p")
|
|
||||||
self.assertIsNone(result)
|
|
||||||
|
|
||||||
def test_destroy_delegates_to_api(self) -> None:
|
|
||||||
self.runner.destroy("slug-7")
|
|
||||||
self._api.destroy.assert_called_once_with("slug-7")
|
|
||||||
|
|
||||||
def test_destroy_returns_none(self) -> None:
|
|
||||||
result = self.runner.destroy("slug-7")
|
|
||||||
self.assertIsNone(result)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""Unit: ScopedForge — read-anywhere / write-scoped access control."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from bot_bottle.contrib.forge.base import ScopedForge
|
|
||||||
|
|
||||||
from ._fakes import FakeForge
|
|
||||||
|
|
||||||
|
|
||||||
class ScopedForgeTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.inner = FakeForge()
|
|
||||||
self.scoped = ScopedForge(
|
|
||||||
self.inner, assigned_issue=10, assigned_prs=[20, 30]
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- reads always pass through -----------------------------------------
|
|
||||||
|
|
||||||
def test_read_issue_allowed_anywhere(self):
|
|
||||||
for number in (10, 20, 99):
|
|
||||||
result = self.scoped.read_issue(number)
|
|
||||||
self.assertEqual(number, result["number"])
|
|
||||||
|
|
||||||
def test_read_pr_allowed_anywhere(self):
|
|
||||||
for number in (10, 20, 99):
|
|
||||||
result = self.scoped.read_pr(number)
|
|
||||||
self.assertEqual(number, result["number"])
|
|
||||||
|
|
||||||
def test_read_comments_allowed_anywhere(self):
|
|
||||||
comments = self.scoped.read_comments(99)
|
|
||||||
self.assertTrue(len(comments) > 0)
|
|
||||||
|
|
||||||
def test_is_org_member_passes_through(self):
|
|
||||||
inner = FakeForge(members=("alice",))
|
|
||||||
scoped = ScopedForge(inner, assigned_issue=1, assigned_prs=[])
|
|
||||||
self.assertTrue(scoped.is_org_member("org", "alice"))
|
|
||||||
self.assertFalse(scoped.is_org_member("org", "bob"))
|
|
||||||
|
|
||||||
# --- writes: assigned numbers allowed ----------------------------------
|
|
||||||
|
|
||||||
def test_post_comment_on_assigned_issue(self):
|
|
||||||
self.scoped.post_comment(10, "hi")
|
|
||||||
self.assertIn((10, "hi"), self.inner.comments)
|
|
||||||
|
|
||||||
def test_post_comment_on_assigned_pr(self):
|
|
||||||
self.scoped.post_comment(20, "lgtm")
|
|
||||||
self.assertIn((20, "lgtm"), self.inner.comments)
|
|
||||||
|
|
||||||
def test_update_description_on_assigned(self):
|
|
||||||
self.scoped.update_description(30, "updated")
|
|
||||||
self.assertIn((30, "updated"), self.inner.descriptions)
|
|
||||||
|
|
||||||
# --- writes: unassigned numbers denied ---------------------------------
|
|
||||||
|
|
||||||
def test_post_comment_denied_for_unassigned(self):
|
|
||||||
with self.assertRaises(PermissionError):
|
|
||||||
self.scoped.post_comment(99, "nope")
|
|
||||||
self.assertEqual([], self.inner.comments)
|
|
||||||
|
|
||||||
def test_update_description_denied_for_unassigned(self):
|
|
||||||
with self.assertRaises(PermissionError):
|
|
||||||
self.scoped.update_description(99, "nope")
|
|
||||||
self.assertEqual([], self.inner.descriptions)
|
|
||||||
|
|
||||||
def test_error_message_names_number(self):
|
|
||||||
try:
|
|
||||||
self.scoped.post_comment(99, "nope")
|
|
||||||
except PermissionError as exc:
|
|
||||||
self.assertIn("99", str(exc))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
"""Unit: forge sidecar dispatch, op log, queue relay, socket server."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.sidecar import (
|
|
||||||
ForgeSidecar,
|
|
||||||
OpLog,
|
|
||||||
_jsonable,
|
|
||||||
drain_done_events,
|
|
||||||
serve,
|
|
||||||
write_done_event,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ._fakes import FakeForge
|
|
||||||
|
|
||||||
|
|
||||||
class SidecarDispatchTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) # pylint: disable=consider-using-with
|
|
||||||
self.forge = FakeForge()
|
|
||||||
self.log = OpLog(self.tmp / "ops.jsonl", now=lambda: "T")
|
|
||||||
self.queue = self.tmp / "queue"
|
|
||||||
self.sc = ForgeSidecar(
|
|
||||||
forge=self.forge, op_log=self.log, queue_dir=self.queue,
|
|
||||||
run_key=("o", "r", 17),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_read_pr_ok_and_logged(self):
|
|
||||||
resp = self.sc.dispatch("read_pr", {"number": 5})
|
|
||||||
self.assertTrue(resp["ok"])
|
|
||||||
self.assertEqual(5, resp["result"]["number"])
|
|
||||||
self.assertEqual([("read_pr", 5, "ok")],
|
|
||||||
[(o["op"], o["target"], o["detail"]) for o in self.log.read()])
|
|
||||||
|
|
||||||
def test_post_comment_writes_and_logs(self):
|
|
||||||
resp = self.sc.dispatch("post_comment", {"number": 17, "body": "done"})
|
|
||||||
self.assertTrue(resp["ok"])
|
|
||||||
self.assertEqual([(17, "done")], self.forge.comments)
|
|
||||||
|
|
||||||
def test_scope_denied_write_returns_error_and_audits_rejection(self):
|
|
||||||
self.forge.scope_denied.add(999)
|
|
||||||
resp = self.sc.dispatch("post_comment", {"number": 999, "body": "x"})
|
|
||||||
self.assertFalse(resp["ok"])
|
|
||||||
self.assertIn("denied", resp["error"])
|
|
||||||
# The rejection is recorded in the op log, not just the allows.
|
|
||||||
self.assertIn("error", self.log.read()[-1]["detail"])
|
|
||||||
self.assertEqual([], self.forge.comments)
|
|
||||||
|
|
||||||
def test_signal_done_queues_event(self):
|
|
||||||
resp = self.sc.dispatch("signal_done", {"status": "success", "summary": "ok"})
|
|
||||||
self.assertTrue(resp["ok"])
|
|
||||||
events = drain_done_events(self.queue)
|
|
||||||
self.assertEqual(1, len(events))
|
|
||||||
self.assertEqual(("o", "r", 17, "success"),
|
|
||||||
(events[0]["owner"], events[0]["repo"],
|
|
||||||
events[0]["issue_number"], events[0]["status"]))
|
|
||||||
|
|
||||||
def test_unknown_method(self):
|
|
||||||
resp = self.sc.dispatch("delete_repo", {})
|
|
||||||
self.assertFalse(resp["ok"])
|
|
||||||
|
|
||||||
|
|
||||||
class JsonableTest(unittest.TestCase):
|
|
||||||
def test_plain_value_passthrough(self):
|
|
||||||
self.assertEqual(42, _jsonable(42))
|
|
||||||
self.assertEqual("s", _jsonable("s"))
|
|
||||||
|
|
||||||
def test_dataclass_converted_to_dict(self):
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class Thing:
|
|
||||||
x: int
|
|
||||||
y: str = "hi"
|
|
||||||
self.assertEqual({"x": 99, "y": "hi"}, _jsonable(Thing(x=99)))
|
|
||||||
|
|
||||||
def test_list_recursed(self):
|
|
||||||
self.assertEqual([1, 2, 3], _jsonable([1, 2, 3]))
|
|
||||||
|
|
||||||
def test_list_of_dataclasses(self):
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class Item:
|
|
||||||
v: int
|
|
||||||
result = _jsonable([Item(v=1), Item(v=2)])
|
|
||||||
self.assertEqual([{"v": 1}, {"v": 2}], result)
|
|
||||||
|
|
||||||
|
|
||||||
class QueueTest(unittest.TestCase):
|
|
||||||
def test_drain_removes_events(self):
|
|
||||||
tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) # pylint: disable=consider-using-with
|
|
||||||
write_done_event(tmp, {"owner": "o", "repo": "r", "issue_number": 1})
|
|
||||||
self.assertEqual(1, len(drain_done_events(tmp)))
|
|
||||||
self.assertEqual([], drain_done_events(tmp)) # drained
|
|
||||||
|
|
||||||
def test_drain_missing_dir(self):
|
|
||||||
self.assertEqual([], drain_done_events(Path("/nonexistent/queue")))
|
|
||||||
|
|
||||||
def test_drain_skips_corrupted_file(self):
|
|
||||||
tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) # pylint: disable=consider-using-with
|
|
||||||
(tmp / "done-bad.json").write_text("not json", encoding="utf-8")
|
|
||||||
events = drain_done_events(tmp)
|
|
||||||
self.assertEqual([], events)
|
|
||||||
# The corrupted file is removed by the finally block.
|
|
||||||
self.assertFalse((tmp / "done-bad.json").exists())
|
|
||||||
|
|
||||||
|
|
||||||
class OpLogReadTest(unittest.TestCase):
|
|
||||||
def test_read_missing_file_returns_empty(self):
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
log = OpLog(Path(tmp) / "sub" / "ops.jsonl")
|
|
||||||
# File not written yet — read() should return [].
|
|
||||||
self.assertEqual([], log.read())
|
|
||||||
|
|
||||||
|
|
||||||
class SocketServerTest(unittest.TestCase):
|
|
||||||
def _make_server(self, tmp: Path):
|
|
||||||
sock = tmp / "s.sock"
|
|
||||||
if len(str(sock)) > 100:
|
|
||||||
self.skipTest("temp socket path too long for AF_UNIX")
|
|
||||||
sidecar = ForgeSidecar(
|
|
||||||
forge=FakeForge(), op_log=OpLog(tmp / "ops.jsonl"),
|
|
||||||
queue_dir=tmp / "q", run_key=("o", "r", 1),
|
|
||||||
)
|
|
||||||
return serve(sidecar, sock), sock
|
|
||||||
|
|
||||||
def test_round_trip_over_unix_socket(self):
|
|
||||||
tmp = tempfile.mkdtemp()
|
|
||||||
sock = Path(tmp) / "s.sock"
|
|
||||||
if len(str(sock)) > 100: # AF_UNIX path limit; skip on long tmp paths
|
|
||||||
self.skipTest("temp socket path too long for AF_UNIX")
|
|
||||||
sidecar = ForgeSidecar(
|
|
||||||
forge=FakeForge(), op_log=OpLog(Path(tmp) / "ops.jsonl"),
|
|
||||||
queue_dir=Path(tmp) / "q", run_key=("o", "r", 1),
|
|
||||||
)
|
|
||||||
srv = serve(sidecar, sock)
|
|
||||||
t = threading.Thread(target=srv.handle_request, daemon=True)
|
|
||||||
t.start()
|
|
||||||
try:
|
|
||||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
client.connect(str(sock))
|
|
||||||
client.sendall(b'{"method": "read_issue", "params": {"number": 3}}\n')
|
|
||||||
line = client.makefile().readline()
|
|
||||||
client.close()
|
|
||||||
finally:
|
|
||||||
t.join(timeout=5)
|
|
||||||
srv.server_close()
|
|
||||||
resp = json.loads(line)
|
|
||||||
self.assertTrue(resp["ok"])
|
|
||||||
self.assertEqual(3, resp["result"]["number"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_handler_invalid_json_returns_error(self):
|
|
||||||
tmp = Path(tempfile.mkdtemp())
|
|
||||||
srv, sock = self._make_server(tmp)
|
|
||||||
t = threading.Thread(target=srv.handle_request, daemon=True)
|
|
||||||
t.start()
|
|
||||||
try:
|
|
||||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
client.connect(str(sock))
|
|
||||||
client.sendall(b"not valid json!\n")
|
|
||||||
line = client.makefile().readline()
|
|
||||||
client.close()
|
|
||||||
finally:
|
|
||||||
t.join(timeout=5)
|
|
||||||
srv.server_close()
|
|
||||||
resp = json.loads(line)
|
|
||||||
self.assertFalse(resp["ok"])
|
|
||||||
self.assertIn("invalid json", resp["error"])
|
|
||||||
|
|
||||||
def test_handler_empty_line_closes_silently(self):
|
|
||||||
tmp = Path(tempfile.mkdtemp())
|
|
||||||
srv, sock = self._make_server(tmp)
|
|
||||||
t = threading.Thread(target=srv.handle_request, daemon=True)
|
|
||||||
t.start()
|
|
||||||
try:
|
|
||||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
client.connect(str(sock))
|
|
||||||
client.close() # immediate EOF -> readline() returns b""
|
|
||||||
finally:
|
|
||||||
t.join(timeout=5)
|
|
||||||
srv.server_close()
|
|
||||||
|
|
||||||
def test_serve_removes_existing_socket_path(self):
|
|
||||||
tmp = Path(tempfile.mkdtemp())
|
|
||||||
sock = tmp / "existing.sock"
|
|
||||||
if len(str(sock)) > 100:
|
|
||||||
self.skipTest("temp socket path too long for AF_UNIX")
|
|
||||||
sock.touch() # pre-existing file at socket path
|
|
||||||
sidecar = ForgeSidecar(
|
|
||||||
forge=FakeForge(), op_log=OpLog(tmp / "ops.jsonl"),
|
|
||||||
queue_dir=tmp / "q", run_key=("o", "r", 1),
|
|
||||||
)
|
|
||||||
srv = serve(sidecar, sock) # should unlink the pre-existing file
|
|
||||||
srv.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Unit: InMemoryStateStore."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.model import RunRecord
|
|
||||||
from bot_bottle.orchestrator.store import InMemoryStateStore
|
|
||||||
|
|
||||||
|
|
||||||
def _rec(issue: int, owner: str = "o") -> RunRecord:
|
|
||||||
return RunRecord(owner=owner, repo="r", issue_number=issue, slug=f"s{issue}",
|
|
||||||
agent_name="a")
|
|
||||||
|
|
||||||
|
|
||||||
class InMemoryStoreTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.store = InMemoryStateStore()
|
|
||||||
|
|
||||||
def test_upsert_get(self):
|
|
||||||
self.store.upsert(_rec(1))
|
|
||||||
got = self.store.get("o", "r", 1)
|
|
||||||
assert got is not None
|
|
||||||
self.assertEqual("s1", got.slug)
|
|
||||||
|
|
||||||
def test_get_missing(self):
|
|
||||||
self.assertIsNone(self.store.get("o", "r", 99))
|
|
||||||
|
|
||||||
def test_upsert_replaces(self):
|
|
||||||
self.store.upsert(_rec(1))
|
|
||||||
r = _rec(1)
|
|
||||||
r.slug = "changed"
|
|
||||||
self.store.upsert(r)
|
|
||||||
self.assertEqual("changed", self.store.get("o", "r", 1).slug) # type: ignore[union-attr]
|
|
||||||
self.assertEqual(1, len(self.store.all()))
|
|
||||||
|
|
||||||
def test_delete(self):
|
|
||||||
self.store.upsert(_rec(1))
|
|
||||||
self.store.delete("o", "r", 1)
|
|
||||||
self.assertIsNone(self.store.get("o", "r", 1))
|
|
||||||
|
|
||||||
def test_all_sorted(self):
|
|
||||||
self.store.upsert(_rec(2, owner="b"))
|
|
||||||
self.store.upsert(_rec(1, owner="a"))
|
|
||||||
self.assertEqual([("a", 1), ("b", 2)],
|
|
||||||
[(r.owner, r.issue_number) for r in self.store.all()])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
"""Unit: targeting (labels + org membership)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.model import IssueAssigned
|
|
||||||
from bot_bottle.orchestrator.targeting import parse_labels, resolve_target
|
|
||||||
|
|
||||||
from ._fakes import FakeForge
|
|
||||||
|
|
||||||
|
|
||||||
def _issue(
|
|
||||||
assignees: tuple[str, ...] = ("agent-bot",),
|
|
||||||
labels: tuple[str, ...] = ("bot-bottle:implementer",),
|
|
||||||
) -> IssueAssigned:
|
|
||||||
return IssueAssigned(
|
|
||||||
owner="didericis", repo="bot-bottle", issue_number=17,
|
|
||||||
title="t", body="b", assignees=tuple(assignees), labels=tuple(labels),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ParseLabelsTest(unittest.TestCase):
|
|
||||||
def test_agent_label(self):
|
|
||||||
self.assertEqual(("implementer", None), parse_labels(("bot-bottle:implementer",)))
|
|
||||||
|
|
||||||
def test_bottle_override_not_confused_with_agent(self):
|
|
||||||
agent, bottle = parse_labels(("bot-bottle:impl", "bot-bottle-bottle:dev"))
|
|
||||||
self.assertEqual(("impl", "dev"), (agent, bottle))
|
|
||||||
|
|
||||||
def test_no_agent_label(self):
|
|
||||||
self.assertEqual((None, None), parse_labels(("bug", "p1")))
|
|
||||||
|
|
||||||
|
|
||||||
class ResolveTargetTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.forge = FakeForge(members=("agent-bot",))
|
|
||||||
|
|
||||||
def test_targeted(self):
|
|
||||||
target = resolve_target(_issue(), self.forge, "bot-bottle")
|
|
||||||
assert target is not None
|
|
||||||
self.assertEqual("implementer", target.agent_name)
|
|
||||||
self.assertIsNone(target.bottle_override)
|
|
||||||
|
|
||||||
def test_bottle_override(self):
|
|
||||||
ev = _issue(labels=("bot-bottle:impl", "bot-bottle-bottle:dev"))
|
|
||||||
target = resolve_target(ev, self.forge, "bot-bottle")
|
|
||||||
assert target is not None
|
|
||||||
self.assertEqual("dev", target.bottle_override)
|
|
||||||
|
|
||||||
def test_no_label_not_targeted(self):
|
|
||||||
self.assertIsNone(resolve_target(_issue(labels=("bug",)), self.forge, "bot-bottle"))
|
|
||||||
|
|
||||||
def test_non_member_assignee_not_targeted(self):
|
|
||||||
ev = _issue(assignees=("random-user",))
|
|
||||||
self.assertIsNone(resolve_target(ev, self.forge, "bot-bottle"))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""Unit: watchdog sweep."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
import unittest.mock
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.model import STATUS_FROZEN, STATUS_RUNNING, RunRecord
|
|
||||||
from bot_bottle.orchestrator.store import InMemoryStateStore
|
|
||||||
from bot_bottle.orchestrator.watchdog import Watchdog
|
|
||||||
|
|
||||||
from ._fakes import FakeRunner
|
|
||||||
|
|
||||||
_NOW = datetime(2026, 7, 1, 12, 0, 0).astimezone()
|
|
||||||
|
|
||||||
|
|
||||||
def _record(issue: int, status: str, checkin: str) -> RunRecord:
|
|
||||||
return RunRecord(
|
|
||||||
owner="o", repo="r", issue_number=issue, slug=f"s{issue}",
|
|
||||||
agent_name="a", status=status, last_checkin_at=checkin,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WatchdogSweepTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.store = InMemoryStateStore()
|
|
||||||
self.runner = FakeRunner()
|
|
||||||
self.wd = Watchdog(store=self.store, runner=self.runner, timeout_secs=1800)
|
|
||||||
|
|
||||||
def _status(self, issue: int) -> str:
|
|
||||||
rec = self.store.get("o", "r", issue)
|
|
||||||
assert rec is not None
|
|
||||||
return rec.status
|
|
||||||
|
|
||||||
def test_stale_running_is_frozen(self):
|
|
||||||
stale = (_NOW - timedelta(minutes=31)).isoformat()
|
|
||||||
self.store.upsert(_record(1, STATUS_RUNNING, stale))
|
|
||||||
fired = self.wd.sweep(_NOW)
|
|
||||||
self.assertEqual([1], [r.issue_number for r in fired])
|
|
||||||
self.assertEqual(STATUS_FROZEN, self._status(1))
|
|
||||||
self.assertIn(("freeze", "s1"), self.runner.calls)
|
|
||||||
|
|
||||||
def test_fresh_running_untouched(self):
|
|
||||||
fresh = (_NOW - timedelta(minutes=5)).isoformat()
|
|
||||||
self.store.upsert(_record(2, STATUS_RUNNING, fresh))
|
|
||||||
self.assertEqual([], self.wd.sweep(_NOW))
|
|
||||||
self.assertEqual(STATUS_RUNNING, self._status(2))
|
|
||||||
|
|
||||||
def test_non_running_ignored(self):
|
|
||||||
stale = (_NOW - timedelta(hours=2)).isoformat()
|
|
||||||
self.store.upsert(_record(3, STATUS_FROZEN, stale))
|
|
||||||
self.assertEqual([], self.wd.sweep(_NOW))
|
|
||||||
|
|
||||||
def test_unparseable_checkin_skipped(self):
|
|
||||||
self.store.upsert(_record(4, STATUS_RUNNING, "not-a-time"))
|
|
||||||
self.assertEqual([], self.wd.sweep(_NOW))
|
|
||||||
|
|
||||||
def test_start_and_stop(self):
|
|
||||||
# Exercises the daemon-thread start/stop path; stop sets the event
|
|
||||||
# so the loop's wait returns immediately.
|
|
||||||
self.wd.start()
|
|
||||||
self.wd.stop()
|
|
||||||
|
|
||||||
def test_loop_sweeps_stale_record(self):
|
|
||||||
# Patch tick to near-zero so the loop iterates quickly.
|
|
||||||
stale = (_NOW - timedelta(hours=1)).isoformat()
|
|
||||||
self.store.upsert(_record(5, STATUS_RUNNING, stale))
|
|
||||||
with unittest.mock.patch("bot_bottle.orchestrator.watchdog._TICK_SECS", 0.01):
|
|
||||||
self.wd.start()
|
|
||||||
time.sleep(0.05) # enough for several iterations at 0.01s tick
|
|
||||||
self.wd.stop()
|
|
||||||
rec = self.store.get("o", "r", 5)
|
|
||||||
assert rec is not None
|
|
||||||
self.assertEqual(STATUS_FROZEN, rec.status)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
"""Unit: webhook HTTP surface (signature + routing over a real server)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import threading
|
|
||||||
import unittest
|
|
||||||
import urllib.request
|
|
||||||
from urllib.error import HTTPError
|
|
||||||
|
|
||||||
from bot_bottle.orchestrator.model import RunRecord
|
|
||||||
from bot_bottle.orchestrator.store import InMemoryStateStore
|
|
||||||
from bot_bottle.orchestrator.webhook import WebhookServer, verify_signature
|
|
||||||
|
|
||||||
_ISSUE_ASSIGNED = {
|
|
||||||
"action": "assigned",
|
|
||||||
"repository": {"name": "bot-bottle", "owner": {"login": "didericis"}},
|
|
||||||
"issue": {
|
|
||||||
"number": 17, "title": "t", "body": "b",
|
|
||||||
"assignees": [{"login": "agent-bot"}],
|
|
||||||
"labels": [{"name": "bot-bottle:impl"}],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class _RecordingOrch:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.events: list[object] = []
|
|
||||||
|
|
||||||
def handle(self, event: object) -> None:
|
|
||||||
self.events.append(event)
|
|
||||||
|
|
||||||
|
|
||||||
class SignatureTest(unittest.TestCase):
|
|
||||||
def test_verify(self):
|
|
||||||
secret = b"s3cret"
|
|
||||||
body = b'{"x":1}'
|
|
||||||
sig = hmac.new(secret, body, hashlib.sha256).hexdigest()
|
|
||||||
self.assertTrue(verify_signature(secret, body, sig))
|
|
||||||
self.assertFalse(verify_signature(secret, body, "deadbeef"))
|
|
||||||
|
|
||||||
|
|
||||||
class WebhookServerTest(unittest.TestCase):
|
|
||||||
# _serve is the per-test setup; attributes are assigned there.
|
|
||||||
# pylint: disable=attribute-defined-outside-init
|
|
||||||
def _serve(self, **kwargs: object) -> None:
|
|
||||||
self.orch = _RecordingOrch()
|
|
||||||
kwargs.setdefault("store", InMemoryStateStore())
|
|
||||||
self.server = WebhookServer(
|
|
||||||
("127.0.0.1", 0), orchestrator=self.orch, **kwargs, # type: ignore[arg-type]
|
|
||||||
)
|
|
||||||
self.port = self.server.server_address[1]
|
|
||||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
|
||||||
self.thread.start()
|
|
||||||
self.addCleanup(self._shutdown)
|
|
||||||
|
|
||||||
def _shutdown(self) -> None:
|
|
||||||
self.server.shutdown()
|
|
||||||
self.server.server_close()
|
|
||||||
self.thread.join(timeout=5)
|
|
||||||
|
|
||||||
def _post(
|
|
||||||
self, path: str, body: bytes, headers: dict[str, str] | None = None
|
|
||||||
) -> tuple[int, dict[str, object]]:
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"http://127.0.0.1:{self.port}{path}", data=body, method="POST",
|
|
||||||
headers=headers or {},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
||||||
return resp.status, json.loads(resp.read())
|
|
||||||
|
|
||||||
def _get(self, path: str) -> tuple[int, dict[str, object]]:
|
|
||||||
with urllib.request.urlopen(f"http://127.0.0.1:{self.port}{path}", timeout=5) as r:
|
|
||||||
return r.status, json.loads(r.read())
|
|
||||||
|
|
||||||
def test_webhook_dispatches(self):
|
|
||||||
self._serve()
|
|
||||||
body = json.dumps(_ISSUE_ASSIGNED).encode()
|
|
||||||
status, payload = self._post("/webhook", body, {"X-Gitea-Event": "issues"})
|
|
||||||
self.assertEqual(200, status)
|
|
||||||
self.assertTrue(payload["handled"])
|
|
||||||
self.assertEqual(1, len(self.orch.events))
|
|
||||||
|
|
||||||
def test_unhandled_event_ok_but_not_handled(self):
|
|
||||||
self._serve()
|
|
||||||
body = json.dumps({"action": "push"}).encode()
|
|
||||||
_status, payload = self._post("/webhook", body, {"X-Gitea-Event": "push"})
|
|
||||||
self.assertFalse(payload["handled"])
|
|
||||||
self.assertEqual([], self.orch.events)
|
|
||||||
|
|
||||||
def test_invalid_json_400(self):
|
|
||||||
self._serve()
|
|
||||||
with self.assertRaises(HTTPError) as ctx:
|
|
||||||
self._post("/webhook", b"{not json", {"X-Gitea-Event": "issues"})
|
|
||||||
self.assertEqual(400, ctx.exception.code)
|
|
||||||
|
|
||||||
def test_bad_signature_rejected(self):
|
|
||||||
self._serve(secret=b"sekret")
|
|
||||||
body = json.dumps(_ISSUE_ASSIGNED).encode()
|
|
||||||
with self.assertRaises(HTTPError) as ctx:
|
|
||||||
self._post("/webhook", body,
|
|
||||||
{"X-Gitea-Event": "issues", "X-Gitea-Signature": "deadbeef"})
|
|
||||||
self.assertEqual(401, ctx.exception.code)
|
|
||||||
self.assertEqual([], self.orch.events)
|
|
||||||
|
|
||||||
def test_good_signature_accepted(self):
|
|
||||||
self._serve(secret=b"sekret")
|
|
||||||
body = json.dumps(_ISSUE_ASSIGNED).encode()
|
|
||||||
sig = hmac.new(b"sekret", body, hashlib.sha256).hexdigest()
|
|
||||||
status, _payload = self._post(
|
|
||||||
"/webhook", body, {"X-Gitea-Event": "issues", "X-Gitea-Signature": sig})
|
|
||||||
self.assertEqual(200, status)
|
|
||||||
self.assertEqual(1, len(self.orch.events))
|
|
||||||
|
|
||||||
def test_healthz(self):
|
|
||||||
self._serve()
|
|
||||||
self.assertEqual(200, self._get("/healthz")[0])
|
|
||||||
|
|
||||||
def test_unknown_path_404(self):
|
|
||||||
self._serve()
|
|
||||||
with self.assertRaises(HTTPError) as ctx:
|
|
||||||
self._post("/nope", b"{}", {"X-Gitea-Event": "issues"})
|
|
||||||
self.assertEqual(404, ctx.exception.code)
|
|
||||||
|
|
||||||
def test_provenance_returns_record_and_ops(self):
|
|
||||||
store = InMemoryStateStore()
|
|
||||||
store.upsert(RunRecord(owner="didericis", repo="bot-bottle", issue_number=17,
|
|
||||||
slug="impl-17", agent_name="impl", bottle_names=["claude"]))
|
|
||||||
|
|
||||||
def reader(rec: object) -> list[dict[str, object]]: # pylint: disable=unused-argument
|
|
||||||
return [{"at": "T", "op": "post_comment", "target": 17, "detail": "ok"}]
|
|
||||||
|
|
||||||
self._serve(store=store, op_log_reader=reader)
|
|
||||||
status, payload = self._get("/provenance?owner=didericis&repo=bot-bottle&issue=17")
|
|
||||||
self.assertEqual(200, status)
|
|
||||||
self.assertEqual("impl-17", payload["slug"])
|
|
||||||
self.assertEqual(1, len(payload["ops"])) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
def test_provenance_missing_params_400(self):
|
|
||||||
self._serve()
|
|
||||||
with self.assertRaises(HTTPError) as ctx:
|
|
||||||
self._get("/provenance?owner=didericis")
|
|
||||||
self.assertEqual(400, ctx.exception.code)
|
|
||||||
|
|
||||||
def test_provenance_unknown_run_404(self):
|
|
||||||
self._serve()
|
|
||||||
with self.assertRaises(HTTPError) as ctx:
|
|
||||||
self._get("/provenance?owner=x&repo=y&issue=1")
|
|
||||||
self.assertEqual(404, ctx.exception.code)
|
|
||||||
|
|
||||||
def test_unknown_get_path_404(self):
|
|
||||||
self._serve()
|
|
||||||
with self.assertRaises(HTTPError) as ctx:
|
|
||||||
self._get("/nope")
|
|
||||||
self.assertEqual(404, ctx.exception.code)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -107,7 +107,7 @@ def _egress_plan(
|
|||||||
def _supervise_plan() -> SupervisePlan:
|
def _supervise_plan() -> SupervisePlan:
|
||||||
return SupervisePlan(
|
return SupervisePlan(
|
||||||
slug=SLUG,
|
slug=SLUG,
|
||||||
queue_dir=STATE / "supervise" / "queue",
|
db_path=STATE / "bot-bottle.db",
|
||||||
internal_network=f"bot-bottle-net-{SLUG}",
|
internal_network=f"bot-bottle-net-{SLUG}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -392,7 +392,7 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
sc = self._render(supervise=True)["services"]["sidecars"]
|
sc = self._render(supervise=True)["services"]["sidecars"]
|
||||||
env_strings = sc["environment"]
|
env_strings = sc["environment"]
|
||||||
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings)
|
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings)
|
||||||
self.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings))
|
self.assertIn("SUPERVISE_DB_PATH=/run/supervise/bot-bottle.db", env_strings)
|
||||||
self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings))
|
self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings))
|
||||||
|
|
||||||
def test_volumes_always_includes_egress_ca(self):
|
def test_volumes_always_includes_egress_ca(self):
|
||||||
@@ -408,8 +408,7 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
self.assertIn("/etc/egress", targets)
|
self.assertIn("/etc/egress", targets)
|
||||||
self.assertIn("/git-gate-entrypoint.sh", targets)
|
self.assertIn("/git-gate-entrypoint.sh", targets)
|
||||||
self.assertIn("/git-gate/creds/upstream-known_hosts", targets)
|
self.assertIn("/git-gate/creds/upstream-known_hosts", targets)
|
||||||
self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
|
self.assertIn("/run/supervise/bot-bottle.db", targets)
|
||||||
for t in targets))
|
|
||||||
|
|
||||||
def test_extra_hosts_omitted_for_git_upstreams(self):
|
def test_extra_hosts_omitted_for_git_upstreams(self):
|
||||||
sc = self._render(with_git=True)["services"]["sidecars"]
|
sc = self._render(with_git=True)["services"]["sidecars"]
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ def _plan(
|
|||||||
if supervise:
|
if supervise:
|
||||||
supervise_plan = SupervisePlan(
|
supervise_plan = SupervisePlan(
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
queue_dir=Path("/tmp/queue"),
|
db_path=Path("/tmp/bot-bottle.db"),
|
||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ def _plan(
|
|||||||
if supervise:
|
if supervise:
|
||||||
supervise_plan = SupervisePlan(
|
supervise_plan = SupervisePlan(
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
queue_dir=Path("/tmp/queue"),
|
db_path=Path("/tmp/bot-bottle.db"),
|
||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
"""Unit: GiteaClient and GiteaForge (urllib mocked — no network)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import unittest
|
|
||||||
import urllib.error
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from bot_bottle.contrib.gitea.client import GiteaClient, GiteaForge
|
|
||||||
|
|
||||||
|
|
||||||
def _client() -> GiteaClient:
|
|
||||||
return GiteaClient(api_url="http://g/api/v1", owner="o", repo="r", token="tok")
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_response(body: bytes) -> MagicMock:
|
|
||||||
resp = MagicMock()
|
|
||||||
resp.read.return_value = body
|
|
||||||
resp.__enter__.return_value = resp
|
|
||||||
resp.__exit__.return_value = False
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
class GiteaClientTest(unittest.TestCase):
|
|
||||||
# pylint: disable=protected-access
|
|
||||||
def setUp(self):
|
|
||||||
self.client = _client()
|
|
||||||
|
|
||||||
def test_request_returns_parsed_json(self):
|
|
||||||
payload = {"number": 42}
|
|
||||||
resp = _mock_response(json.dumps(payload).encode())
|
|
||||||
with patch("urllib.request.urlopen", return_value=resp):
|
|
||||||
result = self.client._request("GET", "/repos/o/r/issues/42")
|
|
||||||
self.assertEqual(payload, result)
|
|
||||||
|
|
||||||
def test_request_empty_body_returns_none(self):
|
|
||||||
resp = _mock_response(b"")
|
|
||||||
with patch("urllib.request.urlopen", return_value=resp):
|
|
||||||
result = self.client._request("POST", "/some/path", {"x": 1})
|
|
||||||
self.assertIsNone(result)
|
|
||||||
|
|
||||||
def test_is_org_member_true_on_200(self):
|
|
||||||
mock_resp = MagicMock()
|
|
||||||
mock_resp.close = MagicMock()
|
|
||||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
|
||||||
self.assertTrue(self.client.is_org_member("myorg", "alice"))
|
|
||||||
|
|
||||||
def test_is_org_member_false_on_http_error(self):
|
|
||||||
err = urllib.error.HTTPError("url", 404, "Not Found", None, None) # type: ignore[arg-type]
|
|
||||||
with patch("urllib.request.urlopen", side_effect=err):
|
|
||||||
self.assertFalse(self.client.is_org_member("myorg", "nobody"))
|
|
||||||
|
|
||||||
def test_get_issue(self):
|
|
||||||
resp = _mock_response(json.dumps({"number": 1}).encode())
|
|
||||||
with patch("urllib.request.urlopen", return_value=resp):
|
|
||||||
result = self.client.get_issue(1)
|
|
||||||
self.assertEqual(1, result["number"])
|
|
||||||
|
|
||||||
def test_get_pull(self):
|
|
||||||
resp = _mock_response(json.dumps({"number": 7, "merged": False}).encode())
|
|
||||||
with patch("urllib.request.urlopen", return_value=resp):
|
|
||||||
result = self.client.get_pull(7)
|
|
||||||
self.assertEqual(7, result["number"])
|
|
||||||
|
|
||||||
def test_list_comments(self):
|
|
||||||
resp = _mock_response(json.dumps([{"id": 1, "body": "hi"}]).encode())
|
|
||||||
with patch("urllib.request.urlopen", return_value=resp):
|
|
||||||
result = self.client.list_comments(1)
|
|
||||||
self.assertEqual(1, len(result))
|
|
||||||
self.assertEqual(1, result[0]["id"])
|
|
||||||
|
|
||||||
def test_create_comment(self):
|
|
||||||
resp = _mock_response(b"")
|
|
||||||
with patch("urllib.request.urlopen", return_value=resp) as mock_open:
|
|
||||||
self.client.create_comment(1, "hello")
|
|
||||||
mock_open.assert_called_once()
|
|
||||||
|
|
||||||
def test_update_issue(self):
|
|
||||||
resp = _mock_response(b"")
|
|
||||||
with patch("urllib.request.urlopen", return_value=resp) as mock_open:
|
|
||||||
self.client.update_issue(1, "new body")
|
|
||||||
mock_open.assert_called_once()
|
|
||||||
|
|
||||||
def test_request_builds_correct_url(self):
|
|
||||||
import urllib.request as ureq
|
|
||||||
captured: list[ureq.Request] = []
|
|
||||||
|
|
||||||
def fake_urlopen(req: ureq.Request, timeout: float) -> MagicMock: # pylint: disable=unused-argument
|
|
||||||
captured.append(req)
|
|
||||||
return _mock_response(b"{}")
|
|
||||||
|
|
||||||
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
|
||||||
self.client.get_issue(5)
|
|
||||||
|
|
||||||
self.assertIn("/issues/5", captured[0].full_url)
|
|
||||||
|
|
||||||
def test_request_sends_auth_header(self):
|
|
||||||
import urllib.request as ureq
|
|
||||||
captured: list[ureq.Request] = []
|
|
||||||
|
|
||||||
def fake_urlopen(req: ureq.Request, timeout: float) -> MagicMock: # pylint: disable=unused-argument
|
|
||||||
captured.append(req)
|
|
||||||
return _mock_response(b"{}")
|
|
||||||
|
|
||||||
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
|
||||||
self.client.get_issue(1)
|
|
||||||
|
|
||||||
self.assertEqual("token tok", captured[0].get_header("Authorization"))
|
|
||||||
|
|
||||||
|
|
||||||
class GiteaForgeTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.client = MagicMock(spec=GiteaClient)
|
|
||||||
self.forge = GiteaForge(self.client)
|
|
||||||
|
|
||||||
def test_is_org_member_delegates(self):
|
|
||||||
self.client.is_org_member.return_value = True
|
|
||||||
self.assertTrue(self.forge.is_org_member("org", "alice"))
|
|
||||||
self.client.is_org_member.assert_called_once_with("org", "alice")
|
|
||||||
|
|
||||||
def test_is_org_member_false(self):
|
|
||||||
self.client.is_org_member.return_value = False
|
|
||||||
self.assertFalse(self.forge.is_org_member("org", "outsider"))
|
|
||||||
|
|
||||||
def test_read_issue_delegates(self):
|
|
||||||
self.client.get_issue.return_value = {"number": 3}
|
|
||||||
self.assertEqual({"number": 3}, self.forge.read_issue(3))
|
|
||||||
self.client.get_issue.assert_called_once_with(3)
|
|
||||||
|
|
||||||
def test_read_pr_delegates(self):
|
|
||||||
self.client.get_pull.return_value = {"number": 5, "merged": False}
|
|
||||||
result = self.forge.read_pr(5)
|
|
||||||
self.assertEqual(5, result["number"])
|
|
||||||
self.client.get_pull.assert_called_once_with(5)
|
|
||||||
|
|
||||||
def test_read_comments_delegates(self):
|
|
||||||
self.client.list_comments.return_value = [{"id": 1}]
|
|
||||||
comments = self.forge.read_comments(1)
|
|
||||||
self.assertEqual([{"id": 1}], comments)
|
|
||||||
self.client.list_comments.assert_called_once_with(1)
|
|
||||||
|
|
||||||
def test_post_comment_delegates(self):
|
|
||||||
self.forge.post_comment(1, "looks good")
|
|
||||||
self.client.create_comment.assert_called_once_with(1, "looks good")
|
|
||||||
|
|
||||||
def test_update_description_delegates(self):
|
|
||||||
self.forge.update_description(1, "updated body")
|
|
||||||
self.client.update_issue.assert_called_once_with(1, "updated body")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -47,7 +47,6 @@ def _addon() -> EgressAddon:
|
|||||||
a: EgressAddon = EgressAddon.__new__(EgressAddon)
|
a: EgressAddon = EgressAddon.__new__(EgressAddon)
|
||||||
a.config = Config(routes=(), log=LOG_FULL)
|
a.config = Config(routes=(), log=LOG_FULL)
|
||||||
a.safe_tokens = set()
|
a.safe_tokens = set()
|
||||||
a._supervise_queue_dir = ""
|
|
||||||
a._supervise_slug = ""
|
a._supervise_slug = ""
|
||||||
a._token_allow_timeout = 300.0
|
a._token_allow_timeout = 300.0
|
||||||
return a
|
return a
|
||||||
|
|||||||
@@ -212,7 +212,6 @@ def _addon(config: Config) -> EgressAddon:
|
|||||||
a: EgressAddon = EgressAddon.__new__(EgressAddon)
|
a: EgressAddon = EgressAddon.__new__(EgressAddon)
|
||||||
a.config = config
|
a.config = config
|
||||||
a.safe_tokens = set()
|
a.safe_tokens = set()
|
||||||
a._supervise_queue_dir = ""
|
|
||||||
a._supervise_slug = ""
|
a._supervise_slug = ""
|
||||||
a._token_allow_timeout = 300.0
|
a._token_allow_timeout = 300.0
|
||||||
a.routes_path = "/nonexistent/routes.yaml"
|
a.routes_path = "/nonexistent/routes.yaml"
|
||||||
@@ -386,10 +385,10 @@ def _fake_sv(response_status: str | None) -> types.SimpleNamespace:
|
|||||||
def _sha256_hex(_payload: Any) -> str:
|
def _sha256_hex(_payload: Any) -> str:
|
||||||
return "hash"
|
return "hash"
|
||||||
|
|
||||||
def _noop(_a: Any, _b: Any) -> None:
|
def _noop(*_args: Any) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _read_response(_qd: Any, _pid: Any) -> Any:
|
def _read_response(_slug: Any, _pid: Any) -> Any:
|
||||||
if response_status is None:
|
if response_status is None:
|
||||||
raise OSError("not written yet") # forces poll -> timeout
|
raise OSError("not written yet") # forces poll -> timeout
|
||||||
return types.SimpleNamespace(status=response_status)
|
return types.SimpleNamespace(status=response_status)
|
||||||
@@ -409,7 +408,6 @@ def _fake_sv(response_status: str | None) -> types.SimpleNamespace:
|
|||||||
class TestSuperviseBranch(unittest.TestCase):
|
class TestSuperviseBranch(unittest.TestCase):
|
||||||
def _supervised_addon(self) -> EgressAddon:
|
def _supervised_addon(self) -> EgressAddon:
|
||||||
addon = _addon(Config(routes=(Route(host="api.example.com"),)))
|
addon = _addon(Config(routes=(Route(host="api.example.com"),)))
|
||||||
addon._supervise_queue_dir = "/tmp/egress-queue"
|
|
||||||
addon._supervise_slug = "test-bottle"
|
addon._supervise_slug = "test-bottle"
|
||||||
addon._token_allow_timeout = 0.05
|
addon._token_allow_timeout = 0.05
|
||||||
return addon
|
return addon
|
||||||
@@ -632,14 +630,13 @@ class TestRedactSurfaces(unittest.TestCase):
|
|||||||
class TestSuperviseWriteFailure(unittest.TestCase):
|
class TestSuperviseWriteFailure(unittest.TestCase):
|
||||||
def test_write_proposal_oserror_blocks(self) -> None:
|
def test_write_proposal_oserror_blocks(self) -> None:
|
||||||
addon = _addon(Config(routes=(Route(host="api.example.com"),)))
|
addon = _addon(Config(routes=(Route(host="api.example.com"),)))
|
||||||
addon._supervise_queue_dir = "/tmp/egress-queue"
|
|
||||||
addon._supervise_slug = "test-bottle"
|
addon._supervise_slug = "test-bottle"
|
||||||
addon._token_allow_timeout = 0.05
|
addon._token_allow_timeout = 0.05
|
||||||
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
|
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
|
||||||
|
|
||||||
fake = _fake_sv("approved")
|
fake = _fake_sv("approved")
|
||||||
|
|
||||||
def _raise(_qd: Any, _p: Any) -> None:
|
def _raise(_p: Any) -> None:
|
||||||
raise OSError("disk full")
|
raise OSError("disk full")
|
||||||
|
|
||||||
fake.write_proposal = _raise
|
fake.write_proposal = _raise
|
||||||
|
|||||||
@@ -210,8 +210,9 @@ class TestHookRender(unittest.TestCase):
|
|||||||
# the suppressed findings for human approval.
|
# the suppressed findings for human approval.
|
||||||
self.assertIn("--ignore-gitleaks-allow", hook)
|
self.assertIn("--ignore-gitleaks-allow", hook)
|
||||||
self.assertIn("--report-format=json", hook)
|
self.assertIn("--report-format=json", hook)
|
||||||
self.assertIn('"tool": "gitleaks-allow"', hook)
|
self.assertIn("tool=_sv.TOOL_GITLEAKS_ALLOW", hook)
|
||||||
self.assertIn("SUPERVISE_QUEUE_DIR", hook)
|
self.assertIn("_sv.write_proposal", hook)
|
||||||
|
self.assertIn("_sv.read_response", hook)
|
||||||
self.assertIn("SUPERVISE_BOTTLE_SLUG", hook)
|
self.assertIn("SUPERVISE_BOTTLE_SLUG", hook)
|
||||||
self.assertIn("supervisor approved # gitleaks:allow", hook)
|
self.assertIn("supervisor approved # gitleaks:allow", hook)
|
||||||
self.assertIn("supervisor rejected # gitleaks:allow", hook)
|
self.assertIn("supervisor rejected # gitleaks:allow", hook)
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ def _plan(
|
|||||||
else:
|
else:
|
||||||
git_gate_plan = SimpleNamespace(upstreams=())
|
git_gate_plan = SimpleNamespace(upstreams=())
|
||||||
supervise_plan = (
|
supervise_plan = (
|
||||||
SimpleNamespace(queue_dir=Path("/state/supervise/queue"))
|
SimpleNamespace(
|
||||||
|
db_path=Path("/state/bot-bottle.db"),
|
||||||
|
)
|
||||||
if supervise else None
|
if supervise else None
|
||||||
)
|
)
|
||||||
agent_provision = SimpleNamespace(
|
agent_provision = SimpleNamespace(
|
||||||
@@ -137,7 +139,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
|
|||||||
argv,
|
argv,
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"type=bind,source=/state/supervise/queue,target=/run/supervise/queue",
|
"type=bind,source=/state/bot-bottle.db,target=/run/supervise/bot-bottle.db",
|
||||||
argv,
|
argv,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ def _plan(
|
|||||||
if supervise:
|
if supervise:
|
||||||
supervise_plan = SupervisePlan(
|
supervise_plan = SupervisePlan(
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
queue_dir=Path("/tmp/queue"),
|
db_path=Path("/tmp/bot-bottle.db"),
|
||||||
)
|
)
|
||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
@@ -422,6 +422,14 @@ class TestBundleLaunchSpec(unittest.TestCase):
|
|||||||
spec.environment,
|
spec.environment,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_supervise_adds_daemon_volume_and_env(self):
|
||||||
|
from bot_bottle.supervise import DB_PATH_IN_CONTAINER
|
||||||
|
plan = _plan(supervise=True)
|
||||||
|
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
||||||
|
self.assertIn("supervise", spec.daemons_csv)
|
||||||
|
self.assertIn(f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}", spec.environment)
|
||||||
|
self.assertIn(("/tmp/bot-bottle.db", DB_PATH_IN_CONTAINER, False), spec.volumes)
|
||||||
|
|
||||||
def test_canary_env_visible_to_smolvm_guest(self):
|
def test_canary_env_visible_to_smolvm_guest(self):
|
||||||
plan = _plan(canary=True)
|
plan = _plan(canary=True)
|
||||||
with patch.object(
|
with patch.object(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Unit: supervise queue + audit log + diff helpers (PRD 0013)."""
|
"""Unit: supervise queue + audit log + diff helpers (PRD 0013)."""
|
||||||
|
|
||||||
import json
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -19,7 +18,7 @@ from bot_bottle.supervise import (
|
|||||||
TOOL_EGRESS_ALLOW,
|
TOOL_EGRESS_ALLOW,
|
||||||
TOOL_GITLEAKS_ALLOW,
|
TOOL_GITLEAKS_ALLOW,
|
||||||
archive_proposal,
|
archive_proposal,
|
||||||
audit_log_path,
|
host_db_path,
|
||||||
list_pending_proposals,
|
list_pending_proposals,
|
||||||
read_audit_entries,
|
read_audit_entries,
|
||||||
read_proposal,
|
read_proposal,
|
||||||
@@ -112,32 +111,44 @@ class TestResponseRoundtrip(unittest.TestCase):
|
|||||||
class TestQueueIO(unittest.TestCase):
|
class TestQueueIO(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="bot-bottle-supervise-test.")
|
self._tmp = tempfile.TemporaryDirectory(prefix="bot-bottle-supervise-test.")
|
||||||
self.queue_dir = Path(self._tmp.name)
|
self._home_patch = self._patch_home(Path(self._tmp.name))
|
||||||
|
self.slug = "dev"
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
self._home_patch()
|
||||||
self._tmp.cleanup()
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _patch_home(self, fake_home: Path):
|
||||||
|
original = supervise.bot_bottle_root
|
||||||
|
|
||||||
|
def fake_root() -> Path:
|
||||||
|
return fake_home / ".bot-bottle"
|
||||||
|
|
||||||
|
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
|
||||||
|
return lambda: setattr(supervise, "bot_bottle_root", original)
|
||||||
|
|
||||||
def test_write_and_read_proposal(self):
|
def test_write_and_read_proposal(self):
|
||||||
p = _proposal()
|
p = _proposal()
|
||||||
path = write_proposal(self.queue_dir, p)
|
path = write_proposal(p)
|
||||||
self.assertTrue(path.exists())
|
self.assertTrue(path.exists())
|
||||||
|
self.assertEqual(host_db_path(), path)
|
||||||
self.assertEqual(0o600, path.stat().st_mode & 0o777)
|
self.assertEqual(0o600, path.stat().st_mode & 0o777)
|
||||||
loaded = read_proposal(self.queue_dir, p.id)
|
loaded = read_proposal(self.slug, p.id)
|
||||||
self.assertEqual(p, loaded)
|
self.assertEqual(p, loaded)
|
||||||
|
|
||||||
def test_list_pending_excludes_responded(self):
|
def test_list_pending_excludes_responded(self):
|
||||||
a = _proposal(justification="first")
|
a = _proposal(justification="first")
|
||||||
b = _proposal(justification="second")
|
b = _proposal(justification="second")
|
||||||
write_proposal(self.queue_dir, a)
|
write_proposal(a)
|
||||||
write_proposal(self.queue_dir, b)
|
write_proposal(b)
|
||||||
write_response(self.queue_dir, Response(
|
write_response(self.slug, Response(
|
||||||
proposal_id=a.id, status=STATUS_APPROVED, notes="",
|
proposal_id=a.id, status=STATUS_APPROVED, notes="",
|
||||||
))
|
))
|
||||||
pending = list_pending_proposals(self.queue_dir)
|
pending = list_pending_proposals(self.slug)
|
||||||
self.assertEqual([b.id], [p.id for p in pending])
|
self.assertEqual([b.id], [p.id for p in pending])
|
||||||
|
|
||||||
def test_list_pending_returns_empty_for_missing_dir(self):
|
def test_list_pending_returns_empty_for_missing_slug(self):
|
||||||
self.assertEqual([], list_pending_proposals(self.queue_dir / "nope"))
|
self.assertEqual([], list_pending_proposals("nope"))
|
||||||
|
|
||||||
def test_list_pending_sorted_by_arrival(self):
|
def test_list_pending_sorted_by_arrival(self):
|
||||||
# Fabricate two with explicit timestamps.
|
# Fabricate two with explicit timestamps.
|
||||||
@@ -154,30 +165,30 @@ class TestQueueIO(unittest.TestCase):
|
|||||||
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
|
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
|
||||||
)
|
)
|
||||||
# Write in reverse order.
|
# Write in reverse order.
|
||||||
write_proposal(self.queue_dir, b)
|
write_proposal(b)
|
||||||
write_proposal(self.queue_dir, a)
|
write_proposal(a)
|
||||||
ordered = list_pending_proposals(self.queue_dir)
|
ordered = list_pending_proposals(self.slug)
|
||||||
self.assertEqual([a.id, b.id], [p.id for p in ordered])
|
self.assertEqual([a.id, b.id], [p.id for p in ordered])
|
||||||
|
|
||||||
def test_write_and_read_response(self):
|
def test_write_and_read_response(self):
|
||||||
r = Response(proposal_id="xyz", status=STATUS_REJECTED, notes="no")
|
r = Response(proposal_id="xyz", status=STATUS_REJECTED, notes="no")
|
||||||
write_response(self.queue_dir, r)
|
write_response(self.slug, r)
|
||||||
self.assertEqual(r, read_response(self.queue_dir, "xyz"))
|
self.assertEqual(r, read_response(self.slug, "xyz"))
|
||||||
|
|
||||||
def test_wait_for_response_returns_when_file_appears(self):
|
def test_wait_for_response_returns_when_file_appears(self):
|
||||||
p = _proposal()
|
p = _proposal()
|
||||||
write_proposal(self.queue_dir, p)
|
write_proposal(p)
|
||||||
|
|
||||||
def write_after_delay():
|
def write_after_delay():
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
write_response(self.queue_dir, Response(
|
write_response(self.slug, Response(
|
||||||
proposal_id=p.id, status=STATUS_APPROVED, notes="ok",
|
proposal_id=p.id, status=STATUS_APPROVED, notes="ok",
|
||||||
))
|
))
|
||||||
|
|
||||||
t = threading.Thread(target=write_after_delay)
|
t = threading.Thread(target=write_after_delay)
|
||||||
t.start()
|
t.start()
|
||||||
try:
|
try:
|
||||||
r = wait_for_response(self.queue_dir, p.id, poll_interval=0.01)
|
r = wait_for_response(self.slug, p.id, poll_interval=0.01)
|
||||||
finally:
|
finally:
|
||||||
t.join()
|
t.join()
|
||||||
self.assertEqual(STATUS_APPROVED, r.status)
|
self.assertEqual(STATUS_APPROVED, r.status)
|
||||||
@@ -187,25 +198,24 @@ class TestQueueIO(unittest.TestCase):
|
|||||||
deadline = time.monotonic() + 0.05
|
deadline = time.monotonic() + 0.05
|
||||||
with self.assertRaises(TimeoutError):
|
with self.assertRaises(TimeoutError):
|
||||||
wait_for_response(
|
wait_for_response(
|
||||||
self.queue_dir, "never",
|
self.slug, "never",
|
||||||
poll_interval=0.01, deadline=deadline,
|
poll_interval=0.01, deadline=deadline,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_archive_proposal_moves_both_files(self):
|
def test_archive_proposal_hides_rows(self):
|
||||||
p = _proposal()
|
p = _proposal()
|
||||||
write_proposal(self.queue_dir, p)
|
write_proposal(p)
|
||||||
write_response(self.queue_dir, Response(
|
write_response(self.slug, Response(
|
||||||
proposal_id=p.id, status=STATUS_APPROVED, notes="",
|
proposal_id=p.id, status=STATUS_APPROVED, notes="",
|
||||||
))
|
))
|
||||||
archive_proposal(self.queue_dir, p.id)
|
archive_proposal(self.slug, p.id)
|
||||||
self.assertFalse((self.queue_dir / f"{p.id}.proposal.json").exists())
|
self.assertEqual([], list_pending_proposals(self.slug))
|
||||||
self.assertFalse((self.queue_dir / f"{p.id}.response.json").exists())
|
with self.assertRaises(FileNotFoundError):
|
||||||
self.assertTrue((self.queue_dir / "processed" / f"{p.id}.proposal.json").exists())
|
read_response(self.slug, p.id)
|
||||||
self.assertTrue((self.queue_dir / "processed" / f"{p.id}.response.json").exists())
|
|
||||||
|
|
||||||
def test_archive_is_idempotent_on_missing_files(self):
|
def test_archive_is_idempotent_on_missing_files(self):
|
||||||
# Should not raise.
|
# Should not raise.
|
||||||
archive_proposal(self.queue_dir, "nope")
|
archive_proposal(self.slug, "nope")
|
||||||
|
|
||||||
|
|
||||||
class TestAuditLog(unittest.TestCase):
|
class TestAuditLog(unittest.TestCase):
|
||||||
@@ -237,6 +247,7 @@ class TestAuditLog(unittest.TestCase):
|
|||||||
diff="--- before\n+++ after\n",
|
diff="--- before\n+++ after\n",
|
||||||
)
|
)
|
||||||
path = write_audit_entry(e)
|
path = write_audit_entry(e)
|
||||||
|
self.assertEqual(host_db_path(), path)
|
||||||
self.assertEqual(0o600, path.stat().st_mode & 0o777)
|
self.assertEqual(0o600, path.stat().st_mode & 0o777)
|
||||||
loaded = read_audit_entries("cred-proxy", "dev")
|
loaded = read_audit_entries("cred-proxy", "dev")
|
||||||
self.assertEqual([e], loaded)
|
self.assertEqual([e], loaded)
|
||||||
@@ -252,12 +263,13 @@ class TestAuditLog(unittest.TestCase):
|
|||||||
justification="",
|
justification="",
|
||||||
diff="",
|
diff="",
|
||||||
))
|
))
|
||||||
path = audit_log_path("egress", "dev")
|
entries = read_audit_entries("egress", "dev")
|
||||||
with path.open() as f:
|
self.assertEqual(3, len(entries))
|
||||||
lines = [line for line in f if line.strip()]
|
self.assertEqual(
|
||||||
self.assertEqual(3, len(lines))
|
["2026-05-25T12:00:00+00:00", "2026-05-25T12:00:01+00:00",
|
||||||
for line in lines:
|
"2026-05-25T12:00:02+00:00"],
|
||||||
self.assertTrue(json.loads(line)) # each line is valid JSON
|
[entry.timestamp for entry in entries],
|
||||||
|
)
|
||||||
|
|
||||||
def test_separate_logs_per_component_slug(self):
|
def test_separate_logs_per_component_slug(self):
|
||||||
write_audit_entry(AuditEntry(
|
write_audit_entry(AuditEntry(
|
||||||
@@ -379,7 +391,7 @@ class TestSupervisePrepare(unittest.TestCase):
|
|||||||
|
|
||||||
def test_prepare_creates_queue(self):
|
def test_prepare_creates_queue(self):
|
||||||
plan = _StubSupervise().prepare("dev", self.stage_dir)
|
plan = _StubSupervise().prepare("dev", self.stage_dir)
|
||||||
self.assertTrue(plan.queue_dir.is_dir())
|
self.assertTrue(plan.db_path.is_file())
|
||||||
self.assertEqual("dev", plan.slug)
|
self.assertEqual("dev", plan.slug)
|
||||||
self.assertEqual("", plan.internal_network)
|
self.assertEqual("", plan.internal_network)
|
||||||
|
|
||||||
|
|||||||
@@ -77,9 +77,7 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
|
|||||||
|
|
||||||
def test_walks_all_slug_subdirs(self):
|
def test_walks_all_slug_subdirs(self):
|
||||||
for slug in ("dev", "api"):
|
for slug in ("dev", "api"):
|
||||||
qdir = supervise.queue_dir_for_slug(slug)
|
supervise.write_proposal(_proposal(slug=slug))
|
||||||
qdir.mkdir(parents=True)
|
|
||||||
supervise.write_proposal(qdir, _proposal(slug=slug))
|
|
||||||
pending = supervise_cli.discover_pending()
|
pending = supervise_cli.discover_pending()
|
||||||
self.assertEqual({"dev", "api"}, {qp.proposal.bottle_slug for qp in pending})
|
self.assertEqual({"dev", "api"}, {qp.proposal.bottle_slug for qp in pending})
|
||||||
|
|
||||||
@@ -97,18 +95,14 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
|
|||||||
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
|
now=datetime(2026, 5, 25, 14, 0, 0, tzinfo=timezone.utc),
|
||||||
)
|
)
|
||||||
for p in (late, early):
|
for p in (late, early):
|
||||||
qdir = supervise.queue_dir_for_slug(p.bottle_slug)
|
supervise.write_proposal(p)
|
||||||
qdir.mkdir(parents=True, exist_ok=True)
|
|
||||||
supervise.write_proposal(qdir, p)
|
|
||||||
pending = supervise_cli.discover_pending()
|
pending = supervise_cli.discover_pending()
|
||||||
self.assertEqual([early.id, late.id], [qp.proposal.id for qp in pending])
|
self.assertEqual([early.id, late.id], [qp.proposal.id for qp in pending])
|
||||||
|
|
||||||
def test_excludes_already_responded(self):
|
def test_excludes_already_responded(self):
|
||||||
p = _proposal()
|
p = _proposal()
|
||||||
qdir = supervise.queue_dir_for_slug("dev")
|
supervise.write_proposal(p)
|
||||||
qdir.mkdir(parents=True)
|
supervise.write_response("dev", supervise.Response(
|
||||||
supervise.write_proposal(qdir, p)
|
|
||||||
supervise.write_response(qdir, supervise.Response(
|
|
||||||
proposal_id=p.id, status=STATUS_APPROVED, notes="",
|
proposal_id=p.id, status=STATUS_APPROVED, notes="",
|
||||||
))
|
))
|
||||||
self.assertEqual([], supervise_cli.discover_pending())
|
self.assertEqual([], supervise_cli.discover_pending())
|
||||||
@@ -123,10 +117,8 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
|
|
||||||
def _enqueue(self, tool: str = TOOL_EGRESS_ALLOW):
|
def _enqueue(self, tool: str = TOOL_EGRESS_ALLOW):
|
||||||
p = _proposal(tool=tool)
|
p = _proposal(tool=tool)
|
||||||
qdir = supervise.queue_dir_for_slug("dev")
|
supervise.write_proposal(p)
|
||||||
qdir.mkdir(parents=True, exist_ok=True)
|
return supervise_cli.QueuedProposal(proposal=p)
|
||||||
supervise.write_proposal(qdir, p)
|
|
||||||
return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
|
|
||||||
|
|
||||||
def test_approve_writes_response(self):
|
def test_approve_writes_response(self):
|
||||||
qp = self._enqueue()
|
qp = self._enqueue()
|
||||||
@@ -135,7 +127,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
return_value=("routes: []\n", "routes:\n - host: example.com\n"),
|
return_value=("routes: []\n", "routes:\n - host: example.com\n"),
|
||||||
):
|
):
|
||||||
supervise_cli.approve(qp)
|
supervise_cli.approve(qp)
|
||||||
resp = read_response(qp.queue_dir, qp.proposal.id)
|
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
|
||||||
self.assertEqual(STATUS_APPROVED, resp.status)
|
self.assertEqual(STATUS_APPROVED, resp.status)
|
||||||
self.assertIsNone(resp.final_file)
|
self.assertIsNone(resp.final_file)
|
||||||
|
|
||||||
@@ -150,7 +142,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
final_file="routes:\n - host: edited.example.com\n",
|
final_file="routes:\n - host: edited.example.com\n",
|
||||||
notes="tweaked",
|
notes="tweaked",
|
||||||
)
|
)
|
||||||
resp = read_response(qp.queue_dir, qp.proposal.id)
|
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
|
||||||
self.assertEqual(STATUS_MODIFIED, resp.status)
|
self.assertEqual(STATUS_MODIFIED, resp.status)
|
||||||
self.assertEqual("routes:\n - host: edited.example.com\n", resp.final_file)
|
self.assertEqual("routes:\n - host: edited.example.com\n", resp.final_file)
|
||||||
self.assertEqual("tweaked", resp.notes)
|
self.assertEqual("tweaked", resp.notes)
|
||||||
@@ -158,7 +150,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
def test_reject_writes_rejection(self):
|
def test_reject_writes_rejection(self):
|
||||||
qp = self._enqueue()
|
qp = self._enqueue()
|
||||||
supervise_cli.reject(qp, reason="nope")
|
supervise_cli.reject(qp, reason="nope")
|
||||||
resp = read_response(qp.queue_dir, qp.proposal.id)
|
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
|
||||||
self.assertEqual(STATUS_REJECTED, resp.status)
|
self.assertEqual(STATUS_REJECTED, resp.status)
|
||||||
self.assertEqual("nope", resp.notes)
|
self.assertEqual("nope", resp.notes)
|
||||||
|
|
||||||
@@ -181,36 +173,33 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
def test_approve_gitleaks_allow_leaves_response_for_gate(self):
|
def test_approve_gitleaks_allow_leaves_response_for_gate(self):
|
||||||
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||||
supervise_cli.approve(qp, notes="dummy fixture")
|
supervise_cli.approve(qp, notes="dummy fixture")
|
||||||
# Gate polls the queue dir for the response; TUI must not archive it.
|
# Gate polls the DB for the response; TUI must not archive it.
|
||||||
resp = read_response(qp.queue_dir, qp.proposal.id)
|
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
|
||||||
self.assertEqual(STATUS_APPROVED, resp.status)
|
self.assertEqual(STATUS_APPROVED, resp.status)
|
||||||
self.assertEqual("dummy fixture", resp.notes)
|
self.assertEqual("dummy fixture", resp.notes)
|
||||||
self.assertFalse((qp.queue_dir / "processed").exists())
|
|
||||||
|
|
||||||
def test_tui_gitleaks_allow_requires_reason(self):
|
def test_tui_gitleaks_allow_requires_reason(self):
|
||||||
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||||
with patch.object(supervise_cli, "_prompt", return_value=""):
|
with patch.object(supervise_cli, "_prompt", return_value=""):
|
||||||
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||||
self.assertEqual("approve aborted (empty reason)", status)
|
self.assertEqual("approve aborted (empty reason)", status)
|
||||||
self.assertFalse((qp.queue_dir / "processed").exists())
|
|
||||||
|
|
||||||
def test_tui_gitleaks_allow_writes_reason(self):
|
def test_tui_gitleaks_allow_writes_reason(self):
|
||||||
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||||
with patch.object(supervise_cli, "_prompt", return_value="test fixture"):
|
with patch.object(supervise_cli, "_prompt", return_value="test fixture"):
|
||||||
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||||
self.assertIn("approved gitleaks-allow", status)
|
self.assertIn("approved gitleaks-allow", status)
|
||||||
resp = read_response(qp.queue_dir, qp.proposal.id)
|
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
|
||||||
self.assertEqual("test fixture", resp.notes)
|
self.assertEqual("test fixture", resp.notes)
|
||||||
|
|
||||||
def test_approve_token_allow_leaves_response_for_egress(self):
|
def test_approve_token_allow_leaves_response_for_egress(self):
|
||||||
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
|
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
|
||||||
supervise_cli.approve(qp, notes="false positive")
|
supervise_cli.approve(qp, notes="false positive")
|
||||||
# The egress addon polls the queue dir for the response; the TUI must
|
# The egress addon polls the DB for the response; the TUI must
|
||||||
# not archive it (the addon archives after reading).
|
# not archive it (the addon archives after reading).
|
||||||
resp = read_response(qp.queue_dir, qp.proposal.id)
|
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
|
||||||
self.assertEqual(STATUS_APPROVED, resp.status)
|
self.assertEqual(STATUS_APPROVED, resp.status)
|
||||||
self.assertEqual("false positive", resp.notes)
|
self.assertEqual("false positive", resp.notes)
|
||||||
self.assertFalse((qp.queue_dir / "processed").exists())
|
|
||||||
|
|
||||||
def test_token_allow_writes_no_audit_log(self):
|
def test_token_allow_writes_no_audit_log(self):
|
||||||
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
|
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
|
||||||
@@ -222,14 +211,13 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
with patch.object(supervise_cli, "_prompt", return_value=""):
|
with patch.object(supervise_cli, "_prompt", return_value=""):
|
||||||
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||||
self.assertEqual("approve aborted (empty reason)", status)
|
self.assertEqual("approve aborted (empty reason)", status)
|
||||||
self.assertFalse((qp.queue_dir / "processed").exists())
|
|
||||||
|
|
||||||
def test_tui_token_allow_writes_reason(self):
|
def test_tui_token_allow_writes_reason(self):
|
||||||
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
|
qp = self._enqueue(tool=TOOL_EGRESS_TOKEN_ALLOW)
|
||||||
with patch.object(supervise_cli, "_prompt", return_value="legit"):
|
with patch.object(supervise_cli, "_prompt", return_value="legit"):
|
||||||
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||||
self.assertIn("approved egress-token-allow", status)
|
self.assertIn("approved egress-token-allow", status)
|
||||||
resp = read_response(qp.queue_dir, qp.proposal.id)
|
resp = read_response(qp.proposal.bottle_slug, qp.proposal.id)
|
||||||
self.assertEqual("legit", resp.notes)
|
self.assertEqual("legit", resp.notes)
|
||||||
|
|
||||||
def test_suffix_for_token_allow_is_txt(self):
|
def test_suffix_for_token_allow_is_txt(self):
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ fallback paths."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
@@ -12,14 +11,19 @@ from pathlib import Path
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle import supervise
|
from bot_bottle import supervise
|
||||||
|
from bot_bottle.audit_store import AuditStore
|
||||||
|
from bot_bottle.queue_store import QueueStore
|
||||||
from bot_bottle.supervise import (
|
from bot_bottle.supervise import (
|
||||||
|
AuditEntry,
|
||||||
Proposal,
|
Proposal,
|
||||||
|
STATUS_APPROVED,
|
||||||
TOOL_EGRESS_ALLOW,
|
TOOL_EGRESS_ALLOW,
|
||||||
list_pending_proposals,
|
list_pending_proposals,
|
||||||
read_audit_entries,
|
read_audit_entries,
|
||||||
read_proposal,
|
read_proposal,
|
||||||
read_response,
|
read_response,
|
||||||
wait_for_response,
|
wait_for_response,
|
||||||
|
write_audit_entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -37,58 +41,53 @@ class TestPathHelpers(unittest.TestCase):
|
|||||||
def test_bot_bottle_root(self) -> None:
|
def test_bot_bottle_root(self) -> None:
|
||||||
self.assertTrue(str(supervise.bot_bottle_root()).endswith(".bot-bottle"))
|
self.assertTrue(str(supervise.bot_bottle_root()).endswith(".bot-bottle"))
|
||||||
|
|
||||||
def test_queue_dir_for_slug(self) -> None:
|
|
||||||
self.assertIn("slug", str(supervise.queue_dir_for_slug("slug")))
|
|
||||||
|
|
||||||
def test_id_from_non_proposal_filename(self) -> None:
|
|
||||||
self.assertIsNone(supervise._id_from_proposal_filename(Path("x.response.json")))
|
|
||||||
|
|
||||||
|
|
||||||
class TestReadMalformed(unittest.TestCase):
|
class TestReadMalformed(unittest.TestCase):
|
||||||
def test_read_proposal_non_dict(self) -> None:
|
def test_read_proposal_missing_row(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
(Path(d) / "p.proposal.json").write_text("[]")
|
with patch.dict("os.environ", {"HOME": d}), \
|
||||||
with self.assertRaises(ValueError):
|
self.assertRaises(FileNotFoundError):
|
||||||
read_proposal(Path(d), "p")
|
read_proposal("slug", "p")
|
||||||
|
|
||||||
def test_read_response_non_dict(self) -> None:
|
def test_read_response_missing_row(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
(Path(d) / "p.response.json").write_text("[]")
|
with patch.dict("os.environ", {"HOME": d}), \
|
||||||
with self.assertRaises(ValueError):
|
self.assertRaises(FileNotFoundError):
|
||||||
read_response(Path(d), "p")
|
read_response("slug", "p")
|
||||||
|
|
||||||
def test_list_pending_skips_malformed(self) -> None:
|
def test_list_pending_reads_db_only(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
qd = Path(d)
|
with patch.dict("os.environ", {"HOME": d}):
|
||||||
(qd / "bad.proposal.json").write_text("{ not json")
|
supervise.write_proposal(_proposal())
|
||||||
(qd / "arr.proposal.json").write_text("[]")
|
pending = list_pending_proposals("slug")
|
||||||
(qd / "incomplete.proposal.json").write_text("{}") # from_dict raises
|
|
||||||
supervise.write_proposal(qd, _proposal()) # one valid
|
|
||||||
pending = list_pending_proposals(qd)
|
|
||||||
self.assertEqual(1, len(pending))
|
self.assertEqual(1, len(pending))
|
||||||
self.assertEqual("slug", pending[0].bottle_slug)
|
self.assertEqual("slug", pending[0].bottle_slug)
|
||||||
|
|
||||||
def test_list_pending_skips_when_response_present(self) -> None:
|
def test_list_pending_skips_when_response_present(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
qd = Path(d)
|
with patch.dict("os.environ", {"HOME": d}):
|
||||||
p = _proposal()
|
p = _proposal()
|
||||||
supervise.write_proposal(qd, p)
|
supervise.write_proposal(p)
|
||||||
(qd / f"{p.id}.response.json").write_text("{}") # response exists -> skipped
|
supervise.write_response("slug", supervise.Response(
|
||||||
self.assertEqual([], list_pending_proposals(qd))
|
proposal_id=p.id,
|
||||||
|
status=STATUS_APPROVED,
|
||||||
|
notes="",
|
||||||
|
))
|
||||||
|
self.assertEqual([], list_pending_proposals("slug"))
|
||||||
|
|
||||||
|
|
||||||
class TestWaitForResponse(unittest.TestCase):
|
class TestWaitForResponse(unittest.TestCase):
|
||||||
def test_malformed_response_then_timeout(self) -> None:
|
def test_missing_response_times_out(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
(Path(d) / "p.response.json").write_text("{ not json")
|
with patch.dict("os.environ", {"HOME": d}), \
|
||||||
with self.assertRaises(TimeoutError):
|
self.assertRaises(TimeoutError):
|
||||||
wait_for_response(Path(d), "p", deadline=time.monotonic())
|
wait_for_response("slug", "p", deadline=time.monotonic())
|
||||||
|
|
||||||
def test_incomplete_response_then_timeout(self) -> None:
|
def test_empty_db_response_does_not_count(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
(Path(d) / "p.response.json").write_text("{}") # dict but from_dict raises
|
with patch.dict("os.environ", {"HOME": d}), \
|
||||||
with self.assertRaises(TimeoutError):
|
self.assertRaises(TimeoutError):
|
||||||
wait_for_response(Path(d), "p", deadline=time.monotonic())
|
wait_for_response("slug", "p", deadline=time.monotonic())
|
||||||
|
|
||||||
|
|
||||||
class TestReadAuditEntries(unittest.TestCase):
|
class TestReadAuditEntries(unittest.TestCase):
|
||||||
@@ -97,35 +96,94 @@ class TestReadAuditEntries(unittest.TestCase):
|
|||||||
patch.dict("os.environ", {"HOME": home}):
|
patch.dict("os.environ", {"HOME": home}):
|
||||||
self.assertEqual([], read_audit_entries("egress", "nope"))
|
self.assertEqual([], read_audit_entries("egress", "nope"))
|
||||||
|
|
||||||
def test_skips_malformed_lines(self) -> None:
|
def test_reads_entries_from_db(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as home, \
|
with tempfile.TemporaryDirectory() as home, \
|
||||||
patch.dict("os.environ", {"HOME": home}):
|
patch.dict("os.environ", {"HOME": home}):
|
||||||
path = supervise.audit_log_path("egress", "slug")
|
write_audit_entry(AuditEntry(
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
timestamp="t",
|
||||||
valid = (
|
bottle_slug="slug",
|
||||||
'{"timestamp": "t", "bottle_slug": "slug", "component": "egress",'
|
component="egress",
|
||||||
' "operator_action": "approve", "operator_notes": "",'
|
operator_action="approve",
|
||||||
' "justification": "", "diff": ""}'
|
operator_notes="",
|
||||||
)
|
justification="",
|
||||||
path.write_text(
|
diff="",
|
||||||
"\n" # blank line skipped
|
))
|
||||||
"{ not json\n" # JSONDecodeError skipped
|
write_audit_entry(AuditEntry(
|
||||||
"[]\n" # not a dict skipped
|
timestamp="t",
|
||||||
"{}\n" # missing fields -> ValueError skipped
|
bottle_slug="other",
|
||||||
+ valid + "\n"
|
component="egress",
|
||||||
)
|
operator_action="reject",
|
||||||
|
operator_notes="",
|
||||||
|
justification="",
|
||||||
|
diff="",
|
||||||
|
))
|
||||||
entries = read_audit_entries("egress", "slug")
|
entries = read_audit_entries("egress", "slug")
|
||||||
self.assertEqual(1, len(entries))
|
self.assertEqual(1, len(entries))
|
||||||
self.assertEqual("approve", entries[0].operator_action)
|
self.assertEqual("approve", entries[0].operator_action)
|
||||||
|
|
||||||
|
def test_legacy_audit_log_file_does_not_count(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as home, \
|
||||||
|
patch.dict("os.environ", {"HOME": home}):
|
||||||
|
path = supervise.audit_log_path("egress", "slug")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
'{"timestamp": "t", "bottle_slug": "slug", "component": "egress",'
|
||||||
|
' "operator_action": "approve", "operator_notes": "",'
|
||||||
|
' "justification": "", "diff": ""}\n'
|
||||||
|
)
|
||||||
|
entries = read_audit_entries("egress", "slug")
|
||||||
|
self.assertEqual([], entries)
|
||||||
|
|
||||||
class TestFlockFallback(unittest.TestCase):
|
|
||||||
def test_flock_on_closed_fd_is_swallowed(self) -> None:
|
class TestStoreGuardBranches(unittest.TestCase):
|
||||||
# flock on a closed fd raises OSError(EBADF), which the helpers swallow.
|
"""Direct QueueStore / AuditStore construction and early-return guard branches."""
|
||||||
fd = os.open(os.devnull, os.O_RDONLY)
|
|
||||||
os.close(fd)
|
def test_queue_store_explicit_db_path(self):
|
||||||
supervise._try_flock(fd)
|
with tempfile.TemporaryDirectory() as d:
|
||||||
supervise._try_funlock(fd)
|
db = Path(d) / "q.db"
|
||||||
|
store = QueueStore("key", db_path=db)
|
||||||
|
self.assertTrue(db.is_file())
|
||||||
|
self.assertEqual(db, store.db_path)
|
||||||
|
|
||||||
|
def test_queue_store_missing_db_list_pending_returns_empty(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
db = Path(d) / "q.db"
|
||||||
|
store = QueueStore("key", db_path=db)
|
||||||
|
db.unlink()
|
||||||
|
self.assertEqual([], store.list_pending_proposals())
|
||||||
|
|
||||||
|
def test_queue_store_missing_db_list_all_returns_empty(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
db = Path(d) / "q.db"
|
||||||
|
store = QueueStore("key", db_path=db)
|
||||||
|
db.unlink()
|
||||||
|
self.assertEqual([], store.list_all_pending_proposals())
|
||||||
|
|
||||||
|
def test_queue_store_missing_db_archive_is_noop(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
db = Path(d) / "q.db"
|
||||||
|
store = QueueStore("key", db_path=db)
|
||||||
|
db.unlink()
|
||||||
|
store.archive_proposal("anything") # must not raise
|
||||||
|
|
||||||
|
def test_queue_store_chmod_oserror_is_swallowed(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
db = Path(d) / "q.db"
|
||||||
|
with patch("pathlib.Path.chmod", side_effect=OSError("ro")):
|
||||||
|
QueueStore("key", db_path=db) # must not raise
|
||||||
|
|
||||||
|
def test_audit_store_missing_db_read_returns_empty(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
db = Path(d) / "a.db"
|
||||||
|
store = AuditStore(db_path=db)
|
||||||
|
db.unlink()
|
||||||
|
self.assertEqual([], store.read_audit_entries("egress", "slug"))
|
||||||
|
|
||||||
|
def test_audit_store_chmod_oserror_is_swallowed(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
db = Path(d) / "a.db"
|
||||||
|
with patch("pathlib.Path.chmod", side_effect=OSError("ro")):
|
||||||
|
AuditStore(db_path=db) # must not raise
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class TestRpcErrorTaxonomy(unittest.TestCase):
|
|||||||
validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, "routes: nope\n")
|
validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, "routes: nope\n")
|
||||||
|
|
||||||
def test_unknown_tool_in_tools_call_is_client_error(self):
|
def test_unknown_tool_in_tools_call_is_client_error(self):
|
||||||
config = ServerConfig(bottle_slug="dev", queue_dir=Path("/unused"))
|
config = ServerConfig(bottle_slug="dev")
|
||||||
with self.assertRaises(_RpcClientError) as cm:
|
with self.assertRaises(_RpcClientError) as cm:
|
||||||
handle_tools_call({"name": "no-such-tool", "arguments": {}}, config)
|
handle_tools_call({"name": "no-such-tool", "arguments": {}}, config)
|
||||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
||||||
@@ -122,9 +122,9 @@ class TestRpcInternalErrorOnIoFailure(unittest.TestCase):
|
|||||||
def test_write_proposal_os_error_raises_internal(self):
|
def test_write_proposal_os_error_raises_internal(self):
|
||||||
config = ServerConfig(
|
config = ServerConfig(
|
||||||
bottle_slug="dev",
|
bottle_slug="dev",
|
||||||
queue_dir=Path("/dev/null/cannot-exist"),
|
|
||||||
)
|
)
|
||||||
with self.assertRaises(_RpcInternalError) as cm:
|
with patch.object(_sv, "write_proposal", side_effect=OSError("disk full")), \
|
||||||
|
self.assertRaises(_RpcInternalError) as cm:
|
||||||
handle_tools_call(
|
handle_tools_call(
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
@@ -265,21 +265,31 @@ class TestHandleToolsList(unittest.TestCase):
|
|||||||
class TestHandleToolsCall(unittest.TestCase):
|
class TestHandleToolsCall(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.")
|
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.")
|
||||||
self.queue_dir = Path(self._tmp.name)
|
self._home_patch = self._patch_home(Path(self._tmp.name))
|
||||||
self.config = ServerConfig(bottle_slug="dev", queue_dir=self.queue_dir)
|
self.config = ServerConfig(bottle_slug="dev")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
self._home_patch()
|
||||||
self._tmp.cleanup()
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _patch_home(self, fake_home: Path):
|
||||||
|
original = _sv.bot_bottle_root
|
||||||
|
|
||||||
|
def fake_root() -> Path:
|
||||||
|
return fake_home / ".bot-bottle"
|
||||||
|
|
||||||
|
_sv.bot_bottle_root = fake_root # type: ignore[assignment]
|
||||||
|
return lambda: setattr(_sv, "bot_bottle_root", original)
|
||||||
|
|
||||||
def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread:
|
def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread:
|
||||||
"""Background thread: poll the queue for a fresh proposal, write a
|
"""Background thread: poll the queue for a fresh proposal, write a
|
||||||
matching response. Returns the thread so the test can join it."""
|
matching response. Returns the thread so the test can join it."""
|
||||||
def runner():
|
def runner():
|
||||||
for _ in range(200):
|
for _ in range(200):
|
||||||
pending = _sv.list_pending_proposals(self.queue_dir)
|
pending = _sv.list_pending_proposals("dev")
|
||||||
if pending:
|
if pending:
|
||||||
p = pending[0]
|
p = pending[0]
|
||||||
_sv.write_response(self.queue_dir, _sv.Response(
|
_sv.write_response("dev", _sv.Response(
|
||||||
proposal_id=p.id, status=status, notes=notes,
|
proposal_id=p.id, status=status, notes=notes,
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
@@ -412,15 +422,11 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
responder.join()
|
responder.join()
|
||||||
# No pending proposals left after archive.
|
# No pending proposals left after archive.
|
||||||
self.assertEqual([], _sv.list_pending_proposals(self.queue_dir))
|
self.assertEqual([], _sv.list_pending_proposals("dev"))
|
||||||
# Both files moved to processed/.
|
|
||||||
processed = list((self.queue_dir / "processed").glob("*.json"))
|
|
||||||
self.assertEqual(2, len(processed))
|
|
||||||
|
|
||||||
def test_pending_response_times_out_without_archive(self):
|
def test_pending_response_times_out_without_archive(self):
|
||||||
config = ServerConfig(
|
config = ServerConfig(
|
||||||
bottle_slug="dev",
|
bottle_slug="dev",
|
||||||
queue_dir=self.queue_dir,
|
|
||||||
response_timeout_seconds=0.05,
|
response_timeout_seconds=0.05,
|
||||||
)
|
)
|
||||||
result = handle_tools_call(
|
result = handle_tools_call(
|
||||||
@@ -438,8 +444,7 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
text = result["content"][0]["text"] # type: ignore[index]
|
text = result["content"][0]["text"] # type: ignore[index]
|
||||||
self.assertIn("status: pending", text)
|
self.assertIn("status: pending", text)
|
||||||
self.assertIn("proposal remains queued", text)
|
self.assertIn("proposal remains queued", text)
|
||||||
self.assertEqual(1, len(_sv.list_pending_proposals(self.queue_dir)))
|
self.assertEqual(1, len(_sv.list_pending_proposals("dev")))
|
||||||
self.assertFalse((self.queue_dir / "processed").exists())
|
|
||||||
|
|
||||||
|
|
||||||
class TestHandleListEgressRoutes(unittest.TestCase):
|
class TestHandleListEgressRoutes(unittest.TestCase):
|
||||||
@@ -461,7 +466,7 @@ class TestHandleListEgressRoutes(unittest.TestCase):
|
|||||||
with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()):
|
with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()):
|
||||||
result = handle_list_egress_routes(
|
result = handle_list_egress_routes(
|
||||||
{},
|
{},
|
||||||
ServerConfig(bottle_slug="dev", queue_dir=Path("/unused")),
|
ServerConfig(bottle_slug="dev"),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertFalse(result["isError"]) # type: ignore[index]
|
self.assertFalse(result["isError"]) # type: ignore[index]
|
||||||
@@ -476,7 +481,7 @@ class TestHandleListEgressRoutes(unittest.TestCase):
|
|||||||
with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()):
|
with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()):
|
||||||
result = handle_list_egress_routes(
|
result = handle_list_egress_routes(
|
||||||
{},
|
{},
|
||||||
ServerConfig(bottle_slug="dev", queue_dir=Path("/unused")),
|
ServerConfig(bottle_slug="dev"),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(result["isError"]) # type: ignore[index]
|
self.assertTrue(result["isError"]) # type: ignore[index]
|
||||||
@@ -544,7 +549,6 @@ class TestHttpEndToEnd(unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-http-test.")
|
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-http-test.")
|
||||||
self.queue_dir = Path(self._tmp.name)
|
|
||||||
# Pick a random port by binding to :0 first.
|
# Pick a random port by binding to :0 first.
|
||||||
import socket
|
import socket
|
||||||
s = socket.socket()
|
s = socket.socket()
|
||||||
@@ -552,7 +556,7 @@ class TestHttpEndToEnd(unittest.TestCase):
|
|||||||
self.port = s.getsockname()[1]
|
self.port = s.getsockname()[1]
|
||||||
s.close()
|
s.close()
|
||||||
self.server = MCPServer(("127.0.0.1", self.port), MCPHandler)
|
self.server = MCPServer(("127.0.0.1", self.port), MCPHandler)
|
||||||
self.server.config = ServerConfig(bottle_slug="dev", queue_dir=self.queue_dir)
|
self.server.config = ServerConfig(bottle_slug="dev")
|
||||||
self.thread = threading.Thread(
|
self.thread = threading.Thread(
|
||||||
target=self.server.serve_forever, daemon=True,
|
target=self.server.serve_forever, daemon=True,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user