diff --git a/bwh_hive/bwh_hive/agent_api.py b/bwh_hive/bwh_hive/agent_api.py new file mode 100644 index 0000000..82c7dbc --- /dev/null +++ b/bwh_hive/bwh_hive/agent_api.py @@ -0,0 +1,182 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +"""Box → Hive callback API (specs/v2 §5.1). + +Endpoints the in-VM control plane calls to report agent lifecycle state back to a Hive +Task. Auth is the shared "Agent" bot service key (00-architecture.md §2.2): every method +asserts (a) the calling session is the Agent bot (by role), and (b) the target task is +currently assigned to that bot — so a leaked key cannot drive arbitrary tasks. + +Status changes route through `orchestrator.service.set_agent_status` so transition +validation and side effects stay centralized. + +All methods are type-annotated — Hive enforces require_type_annotated_api_methods. +""" + +import json + +import frappe + +from bwh_hive.bwh_hive.notifications.dispatcher import notify +from bwh_hive.bwh_hive.notifications.events import EventType, NotificationEvent +from bwh_hive.bwh_hive.orchestrator import service + +AGENT_BOT_ROLE = "Agent Bot" + + +def _assert_agent_caller() -> None: + """Reject callers that are not the Agent bot user (identified by role).""" + if AGENT_BOT_ROLE not in frappe.get_roles(frappe.session.user): + frappe.throw("Only the Agent bot may call agent callbacks.", frappe.PermissionError) + + +def _assert_task_assigned(task: str) -> None: + """Reject writes to a task the calling Agent bot is not assigned to.""" + if not frappe.db.exists("Hive Task", task): + frappe.throw(f"Hive Task {task} not found", frappe.DoesNotExistError) + assignees = json.loads(frappe.db.get_value("Hive Task", task, "_assign") or "[]") + if frappe.session.user not in assignees: + frappe.throw("This task is not assigned to the Agent bot.", frappe.PermissionError) + + +def _guard(task: str) -> None: + _assert_agent_caller() + _assert_task_assigned(task) + + +@frappe.whitelist(methods=["POST"]) +def report_agent_status(task: str, status: str, message: str | None = None) -> dict: + """Set a Hive Task's agent_status (box actor) and optionally append a comment.""" + _guard(task) + prev = frappe.db.get_value("Hive Task", task, "agent_status") + service.set_agent_status(task, status, actor="box", message=message) + if status == "Provisioning" and prev != "Provisioning": + notify(NotificationEvent.from_task(EventType.PROVISIONING, task)) + return {"ok": True, "agent_status": status} + + +@frappe.whitelist(methods=["POST"]) +def set_spec_ready( + task: str, + code_url: str | None = None, + site_url: str | None = None, + spec_path: str | None = None, + branch: str | None = None, +) -> dict: + """Record spec coordinates and advance the task to Spec Created.""" + _guard(task) + doc = frappe.get_doc("Hive Task", task) + updates: dict = {} + if code_url: + updates["agent_code_url"] = code_url + if site_url: + updates["agent_site_url"] = site_url + if spec_path: + updates["agent_spec_path"] = spec_path + if branch: + updates["agent_branch"] = branch + if updates: + doc.db_set(updates) + prev = doc.agent_status + service.set_agent_status(doc, "Spec Created", actor="box", message="Spec ready for review.") + if prev != "Spec Created": + notify(NotificationEvent.from_task(EventType.SPEC_CREATED, task)) + return {"ok": True, "agent_status": "Spec Created"} + + +@frappe.whitelist(methods=["POST"]) +def set_pr_ready(task: str, pr_url: str, branch: str | None = None) -> dict: + """Record the PR link and advance the task to PR Ready.""" + _guard(task) + doc = frappe.get_doc("Hive Task", task) + updates: dict = {"pr_link": pr_url} + if branch: + updates["agent_branch"] = branch + doc.db_set(updates) + prev = doc.agent_status + service.set_agent_status(doc, "PR Ready", actor="box", message=f"PR ready: {pr_url}") + if prev != "PR Ready": + notify(NotificationEvent.from_task(EventType.PR_READY, task)) + return {"ok": True, "agent_status": "PR Ready"} + + +@frappe.whitelist(methods=["POST"]) +def report_agent_error(task: str, error: str, phase: str | None = None) -> dict: + """Record a failure and move the task to Failed.""" + _guard(task) + doc = frappe.get_doc("Hive Task", task) + doc.db_set("agent_last_error", error) + msg = f"Agent error ({phase}): {error}" if phase else f"Agent error: {error}" + prev = doc.agent_status + service.set_agent_status(doc, "Failed", actor="box", message=msg) + if prev != "Failed": + notify(NotificationEvent.from_task(EventType.FAILED, task, message=error, payload={"phase": phase})) + return {"ok": True, "agent_status": "Failed"} + + +@frappe.whitelist(methods=["POST"]) +def append_agent_log(task: str, log: str, stream: str = "stdout") -> dict: + """Append a cheap, frequent log line as a task comment.""" + _guard(task) + frappe.get_doc( + { + "doctype": "Hive Task Comment", + "task": task, + "content": f"[{stream}] {log}", + "posted_by": frappe.session.user, + } + ).insert(ignore_permissions=True) + # Nudge any open task sheet to refetch its comment feed live (specs/v2 09 realtime). + frappe.publish_realtime("hive_agent_log", {"task": task}, after_commit=True) + return {"ok": True} + + +@frappe.whitelist(methods=["GET"]) +def get_task(task: str) -> dict: + """Return task context so the box can compose spec/implement prompts (read-only).""" + _guard(task) + doc = frappe.get_doc("Hive Task", task) + return { + "name": doc.name, + "title": doc.title, + "description": doc.description, + "project": doc.project, + "agent_status": doc.agent_status, + "agent_branch": doc.agent_branch, + "agent_spec_path": doc.agent_spec_path, + "github_issue_url": doc.github_issue_url, + "pr_link": doc.pr_link, + "prompts": resolve_prompts(doc.project), + } + + +# Prompt fields configurable in Hive: a project override wins over the global default; a +# blank/absent value falls through so the box uses its built-in (or SKILLS_REPO) template. +_PROMPT_FIELDS = { + "spec": "agent_spec_prompt", + "implement": "agent_implement_prompt", + "changes": "agent_changes_prompt", +} + + +def resolve_prompts(project: str | None) -> dict: + """Resolve {spec,implement,changes} prompt templates: project override → global default. + + Only non-empty values are returned; a missing key means "no Hive-configured prompt — box + falls back to its SKILLS_REPO file / shipped default" (00-architecture.md §5.1). + """ + settings = frappe.get_cached_doc("Hive Settings") + project_doc = ( + frappe.get_cached_doc("Hive Project", project) + if project and frappe.db.exists("Hive Project", project) + else None + ) + resolved: dict = {} + for key, fieldname in _PROMPT_FIELDS.items(): + value = (project_doc and (project_doc.get(fieldname) or "").strip()) or ( + settings.get(fieldname) or "" + ).strip() + if value: + resolved[key] = value + return resolved diff --git a/bwh_hive/bwh_hive/api.py b/bwh_hive/bwh_hive/api.py index fa996d5..23bb12f 100644 --- a/bwh_hive/bwh_hive/api.py +++ b/bwh_hive/bwh_hive/api.py @@ -932,3 +932,86 @@ def resolve_project_slug(slug: str): if not name: frappe.throw("Project not found", frappe.DoesNotExistError) return name + + +# --------------------------------------------------------------------------- # +# Agent surface — thin frontend-facing wrappers (specs/v2 09). +# +# The React app can't call doctype methods the way the desk does, so these wrap +# the whitelisted Hive Task agent methods with a flat (task, ...) signature the +# frontend calls via useFrappePostCall. Each underlying method re-asserts its +# own guard (identity + write permission) — these wrappers add no trust boundary. +# --------------------------------------------------------------------------- # +@frappe.whitelist(methods=["POST"]) +def agent_approve_spec(task: str, note: str | None = None): + """Approve the agent's spec (wraps Hive Task.approve_spec).""" + return frappe.get_doc("Hive Task", task).approve_spec(note=note) + + +@frappe.whitelist(methods=["POST"]) +def agent_request_changes(task: str, comment: str, path: str | None = None, line: str | None = None): + """Request another iteration, sending a single review comment as the §5.3 payload.""" + body = (comment or "").strip() + if not body: + frappe.throw("Provide a review comment.") + entry: dict = {"author": frappe.session.user, "body": body} + if path: + entry["path"] = path + if line not in (None, ""): + entry["line"] = line + return frappe.get_doc("Hive Task", task).request_agent_changes([entry]) + + +@frappe.whitelist(methods=["POST"]) +def agent_mark_merged(task: str): + """Record that the PR was merged (wraps Hive Task.mark_agent_merged).""" + return frappe.get_doc("Hive Task", task).mark_agent_merged() + + +@frappe.whitelist(methods=["POST"]) +def agent_retry(task: str): + """Re-provision a clean box for a Failed task (wraps Hive Task.retry_agent).""" + return frappe.get_doc("Hive Task", task).retry_agent() + + +@frappe.whitelist(methods=["POST"]) +def agent_cancel(task: str): + """Cancel an in-flight agent task (wraps Hive Task.cancel_agent).""" + return frappe.get_doc("Hive Task", task).cancel_agent() + + +@frappe.whitelist(methods=["POST"]) +def agent_teardown_now(task: str): + """Force-deprovision a Failed box (wraps Hive Task.teardown_agent_now).""" + return frappe.get_doc("Hive Task", task).teardown_agent_now() + + +@frappe.whitelist(methods=["POST"]) +def agent_handoff(task: str): + """Start the agent loop from the product by assigning the task to the Agent bot. + + Assigning to the Agent user is what triggers provisioning (Phase 1 _assign hook). + Re-asserts write permission — the same gate the desk assign flow enforces. + """ + from frappe.desk.form.assign_to import add as assign_add + + from bwh_hive.bwh_hive.orchestrator import service + + doc = frappe.get_doc("Hive Task", task) + doc.check_permission("write") + agent_user = service.get_agent_user() + if not agent_user: + frappe.throw("No Agent bot user is configured.") + assign_add({"doctype": "Hive Task", "name": task, "assign_to": [agent_user]}) + return {"ok": True, "agent_user": agent_user} + + +@frappe.whitelist() +def resolved_prompts(project: str | None = None): + """Return the resolved {spec,implement,changes} prompts (project override → global). + + Lets the per-project settings UI show which prompt the box would actually receive. + """ + from bwh_hive.bwh_hive.agent_api import resolve_prompts + + return resolve_prompts(project) diff --git a/bwh_hive/bwh_hive/doctype/hive_project/hive_project.json b/bwh_hive/bwh_hive/doctype/hive_project/hive_project.json index 23d3ea4..a2194f0 100644 --- a/bwh_hive/bwh_hive/doctype/hive_project/hive_project.json +++ b/bwh_hive/bwh_hive/doctype/hive_project/hive_project.json @@ -21,7 +21,19 @@ "links_section", "links", "is_archived", - "github_repo" + "github_repo", + "agent_section", + "agent_enabled", + "github_pat", + "agent_template_slug", + "target_app_name", + "target_app_repo", + "target_app_branch", + "skills_repo_override", + "agent_prompts_section", + "agent_spec_prompt", + "agent_implement_prompt", + "agent_changes_prompt" ], "fields": [ { @@ -113,6 +125,93 @@ "fieldtype": "Data", "label": "GitHub Repository", "description": "GitHub repository in owner/repo format (e.g. BuildWithHussain/hive)" + }, + { + "collapsible": 1, + "fieldname": "agent_section", + "fieldtype": "Section Break", + "label": "Agent (v2)" + }, + { + "default": "0", + "description": "Only agent-enabled projects can spawn agent boxes (specs/v2 §4.3).", + "fieldname": "agent_enabled", + "fieldtype": "Check", + "label": "Agent Enabled" + }, + { + "depends_on": "agent_enabled", + "description": "Project PAT used inside the box for gh / git push (GIT_PAT).", + "fieldname": "github_pat", + "fieldtype": "Password", + "label": "GitHub PAT" + }, + { + "depends_on": "agent_enabled", + "description": "BenchSpace template/golden to provision (defaults to Hive Settings).", + "fieldname": "agent_template_slug", + "fieldtype": "Data", + "label": "Agent Template Slug" + }, + { + "depends_on": "agent_enabled", + "description": "Frappe app to install in the box (TARGET_APP_NAME).", + "fieldname": "target_app_name", + "fieldtype": "Data", + "label": "Target App Name" + }, + { + "depends_on": "agent_enabled", + "description": "App source URL (TARGET_APP_REPO).", + "fieldname": "target_app_repo", + "fieldtype": "Data", + "label": "Target App Repo" + }, + { + "default": "develop", + "depends_on": "agent_enabled", + "description": "App branch (TARGET_APP_BRANCH).", + "fieldname": "target_app_branch", + "fieldtype": "Data", + "label": "Target App Branch" + }, + { + "depends_on": "agent_enabled", + "description": "Optional override for the global SKILLS_REPO.", + "fieldname": "skills_repo_override", + "fieldtype": "Data", + "label": "Skills Repo Override" + }, + { + "collapsible": 1, + "depends_on": "agent_enabled", + "fieldname": "agent_prompts_section", + "fieldtype": "Section Break", + "label": "Agent Prompt Overrides" + }, + { + "depends_on": "agent_enabled", + "description": "Overrides the global Spec Prompt for this project. Blank = inherit the global default.", + "fieldname": "agent_spec_prompt", + "fieldtype": "Code", + "label": "Spec Prompt Override", + "options": "Text" + }, + { + "depends_on": "agent_enabled", + "description": "Overrides the global Implement Prompt for this project. Blank = inherit the global default.", + "fieldname": "agent_implement_prompt", + "fieldtype": "Code", + "label": "Implement Prompt Override", + "options": "Text" + }, + { + "depends_on": "agent_enabled", + "description": "Overrides the global Changes Prompt for this project. Blank = inherit the global default.", + "fieldname": "agent_changes_prompt", + "fieldtype": "Code", + "label": "Changes Prompt Override", + "options": "Text" } ], "grid_page_length": 50, diff --git a/bwh_hive/bwh_hive/doctype/hive_project/hive_project.py b/bwh_hive/bwh_hive/doctype/hive_project/hive_project.py index 6545883..94919d3 100644 --- a/bwh_hive/bwh_hive/doctype/hive_project/hive_project.py +++ b/bwh_hive/bwh_hive/doctype/hive_project/hive_project.py @@ -16,13 +16,24 @@ class HiveProject(Document): if TYPE_CHECKING: from frappe.types import DF + agent_changes_prompt: DF.Code | None + agent_enabled: DF.Check + agent_implement_prompt: DF.Code | None + agent_spec_prompt: DF.Code | None + agent_template_slug: DF.Data | None client: DF.Link | None description: DF.TextEditor | None + github_pat: DF.Password | None + github_repo: DF.Data | None is_archived: DF.Check is_private: DF.Check project_type: DF.Link | None + skills_repo_override: DF.Data | None slug: DF.Data | None status: DF.Literal["Open", "Completed", "On Hold"] + target_app_branch: DF.Data | None + target_app_name: DF.Data | None + target_app_repo: DF.Data | None title: DF.Data # end: auto-generated types diff --git a/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.json b/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.json index 3140eb3..c8bcfe0 100644 --- a/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.json +++ b/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.json @@ -15,7 +15,32 @@ "github_app_private_key", "github_access_token", "github_username", - "github_authorized_at" + "github_authorized_at", + "agent_section", + "agent_orchestration_enabled", + "benchspace_api_url", + "benchspace_api_key", + "benchspace_api_secret", + "agent_callback_api_key", + "agent_callback_api_secret", + "default_agent_template_slug", + "skills_repo", + "anthropic_api_key", + "agent_prompts_section", + "agent_spec_prompt", + "agent_implement_prompt", + "agent_changes_prompt", + "agent_lifecycle_section", + "max_concurrent_agent_boxes", + "provisioning_timeout_minutes", + "spec_timeout_minutes", + "implement_timeout_minutes", + "idle_teardown_hours", + "failed_teardown_grace_hours", + "telegram_section", + "notifications_enabled", + "telegram_bot_token", + "telegram_default_chat_id" ], "fields": [ { @@ -92,6 +117,158 @@ "hidden": 1, "label": "GitHub Authorized At", "read_only": 1 + }, + { + "fieldname": "agent_section", + "fieldtype": "Section Break", + "label": "Agent Orchestration (v2)" + }, + { + "default": "0", + "description": "Master switch for v2 agent orchestration (specs/v2 §4.4).", + "fieldname": "agent_orchestration_enabled", + "fieldtype": "Check", + "label": "Agent Orchestration Enabled" + }, + { + "description": "BenchSpace site base URL, e.g. https://boxes.buildwithhussain.com", + "fieldname": "benchspace_api_url", + "fieldtype": "Data", + "label": "BenchSpace API URL" + }, + { + "fieldname": "benchspace_api_key", + "fieldtype": "Data", + "label": "BenchSpace API Key" + }, + { + "fieldname": "benchspace_api_secret", + "fieldtype": "Password", + "label": "BenchSpace API Secret" + }, + { + "description": "API key of the Hive Agent bot user; injected into every box (HIVE_API_KEY).", + "fieldname": "agent_callback_api_key", + "fieldtype": "Data", + "label": "Agent Callback API Key" + }, + { + "fieldname": "agent_callback_api_secret", + "fieldtype": "Password", + "label": "Agent Callback API Secret" + }, + { + "default": "agent-v16", + "fieldname": "default_agent_template_slug", + "fieldtype": "Data", + "label": "Default Agent Template Slug" + }, + { + "description": "Global skills/CLAUDE.md/prompts repo (SKILLS_REPO).", + "fieldname": "skills_repo", + "fieldtype": "Data", + "label": "Skills Repo" + }, + { + "description": "Injected into boxes as ANTHROPIC_API_KEY for Claude Code.", + "fieldname": "anthropic_api_key", + "fieldtype": "Password", + "label": "Anthropic API Key" + }, + { + "collapsible": 1, + "fieldname": "agent_prompts_section", + "fieldtype": "Section Break", + "label": "Agent Prompts (global defaults)" + }, + { + "description": "Global spec-generation prompt. Tokens: {title} {description} {spec_path} {project} {task_id}. Leave blank to use the box's built-in default. A project can override this.", + "fieldname": "agent_spec_prompt", + "fieldtype": "Code", + "label": "Spec Prompt", + "options": "Text" + }, + { + "description": "Global implementation prompt. Tokens: {title} {description} {spec_path} {branch} {project} {task_id} {spec}. Blank = built-in default.", + "fieldname": "agent_implement_prompt", + "fieldtype": "Code", + "label": "Implement Prompt", + "options": "Text" + }, + { + "description": "Global review-feedback prompt. Tokens: {title} {spec_path} {branch} {project} {task_id} {comments}. Blank = built-in default.", + "fieldname": "agent_changes_prompt", + "fieldtype": "Code", + "label": "Changes Prompt", + "options": "Text" + }, + { + "fieldname": "agent_lifecycle_section", + "fieldtype": "Section Break", + "label": "Agent Lifecycle & Cost Control (Phase 5)" + }, + { + "default": "5", + "description": "Hard cap on live agent boxes (non-terminal agent_status). Tasks beyond the cap stay Queued and are drained by the watchdog. specs/v2 06-phase-5.", + "fieldname": "max_concurrent_agent_boxes", + "fieldtype": "Int", + "label": "Max Concurrent Agent Boxes" + }, + { + "default": "15", + "description": "Queued/Provisioning watchdog budget (minutes); breach -> Failed. 0 disables.", + "fieldname": "provisioning_timeout_minutes", + "fieldtype": "Int", + "label": "Provisioning Timeout (minutes)" + }, + { + "default": "30", + "description": "Spec In Progress watchdog backstop (minutes); breach -> Failed. 0 disables.", + "fieldname": "spec_timeout_minutes", + "fieldtype": "Int", + "label": "Spec Timeout (minutes)" + }, + { + "default": "45", + "description": "Implementing/Changes Requested watchdog backstop (minutes); breach -> Failed. 0 disables.", + "fieldname": "implement_timeout_minutes", + "fieldtype": "Int", + "label": "Implement Timeout (minutes)" + }, + { + "default": "6", + "description": "Tear down a box parked in a human-wait state (Spec Created/Approved/PR Ready) this long (hours). 0 disables.", + "fieldname": "idle_teardown_hours", + "fieldtype": "Int", + "label": "Idle Teardown (hours)" + }, + { + "default": "2", + "description": "How long to keep a Failed box for debugging before the watchdog sweeps it (hours). 0 disables.", + "fieldname": "failed_teardown_grace_hours", + "fieldtype": "Int", + "label": "Failed Teardown Grace (hours)" + }, + { + "fieldname": "telegram_section", + "fieldtype": "Section Break", + "label": "Notifications (Telegram)" + }, + { + "default": "0", + "fieldname": "notifications_enabled", + "fieldtype": "Check", + "label": "Notifications Enabled" + }, + { + "fieldname": "telegram_bot_token", + "fieldtype": "Password", + "label": "Telegram Bot Token" + }, + { + "fieldname": "telegram_default_chat_id", + "fieldtype": "Data", + "label": "Telegram Default Chat ID" } ], "grid_page_length": 50, diff --git a/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.py b/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.py index 5435f84..27e2e70 100644 --- a/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.py +++ b/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.py @@ -15,13 +15,34 @@ class HiveSettings(Document): if TYPE_CHECKING: from frappe.types import DF + agent_callback_api_key: DF.Data | None + agent_callback_api_secret: DF.Password | None + agent_changes_prompt: DF.Code | None + agent_implement_prompt: DF.Code | None + agent_orchestration_enabled: DF.Check + agent_spec_prompt: DF.Code | None + anthropic_api_key: DF.Password | None app_name: DF.Data | None + benchspace_api_key: DF.Data | None + benchspace_api_secret: DF.Password | None + benchspace_api_url: DF.Data | None + default_agent_template_slug: DF.Data | None + failed_teardown_grace_hours: DF.Int github_app_client_id: DF.Data | None github_app_client_secret: DF.Password | None github_app_id: DF.Data | None github_app_public_link: DF.Data | None + idle_teardown_hours: DF.Int + implement_timeout_minutes: DF.Int lock_due_date_on_or_after: DF.Check + max_concurrent_agent_boxes: DF.Int + notifications_enabled: DF.Check onboarding_completed: DF.Check + provisioning_timeout_minutes: DF.Int + skills_repo: DF.Data | None + spec_timeout_minutes: DF.Int + telegram_bot_token: DF.Password | None + telegram_default_chat_id: DF.Data | None # end: auto-generated types @frappe.whitelist() diff --git a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.js b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.js index 4ae751c..3c6662f 100644 --- a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.js +++ b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.js @@ -1,8 +1,173 @@ // Copyright (c) 2026, BWH Studios and contributors // For license information, please see license.txt -// frappe.ui.form.on("Hive Task", { -// refresh(frm) { +// Minimal agent desk UX (specs/v2 §E.10). Approve/Changes/Merge buttons come online +// in Phases 3–4; for now we surface the agent state and quick links. +frappe.ui.form.on("Hive Task", { + refresh(frm) { + if (!frm.doc.agent_status) { + return; + } -// }, -// }); + const colors = { + Queued: "gray", + Provisioning: "blue", + "Spec In Progress": "blue", + "Spec Created": "orange", + "Spec Approved": "blue", + Implementing: "blue", + "PR Ready": "green", + "Changes Requested": "orange", + Merged: "green", + Cancelled: "gray", + Failed: "red", + }; + + let banner = __("Agent: {0}", [frm.doc.agent_status]); + if (frm.doc.agent_last_error) { + banner += ` — ${frappe.utils.escape_html(frm.doc.agent_last_error)}`; + } + frm.dashboard.add_indicator(banner, colors[frm.doc.agent_status] || "gray"); + + // Spec review → approval (specs/v2 04-phase-3). Approving dispatches implementation. + if (frm.doc.agent_status === "Spec Created") { + frm.add_custom_button(__("Approve Spec"), () => { + frappe.prompt( + [ + { + fieldname: "note", + fieldtype: "Small Text", + label: __("Note (optional)"), + }, + ], + (values) => { + frm.call("approve_spec", { note: values.note }).then(() => { + frappe.show_alert({ + message: __("Spec approved — implementation starting."), + indicator: "green", + }); + frm.reload_doc(); + }); + }, + __("Approve Spec"), + __("Approve") + ); + }).addClass("btn-primary"); + } + + // PR review → request changes / mark merged (specs/v2 05-phase-4 §B.9). + if (frm.doc.agent_status === "PR Ready") { + frm.add_custom_button(__("Request Changes"), () => { + frappe.prompt( + [ + { + fieldname: "body", + fieldtype: "Small Text", + label: __("Review comments"), + reqd: 1, + }, + ], + (values) => { + const comments = [{ author: frappe.session.user, body: values.body }]; + frm.call("request_agent_changes", { + comments: JSON.stringify(comments), + }).then(() => { + frappe.show_alert({ + message: __("Changes requested — agent is iterating."), + indicator: "blue", + }); + frm.reload_doc(); + }); + }, + __("Request Changes"), + __("Send to Agent") + ); + }).addClass("btn-primary"); + + frm.add_custom_button(__("Mark Merged"), () => { + frappe.confirm( + __("Confirm the PR is merged on GitHub. This marks the task Merged."), + () => { + frm.call("mark_agent_merged").then(() => { + frappe.show_alert({ + message: __("Task marked Merged."), + indicator: "green", + }); + frm.reload_doc(); + }); + } + ); + }); + } + + // Failed → recovery affordances (specs/v2 06-phase-5 step 9). + if (frm.doc.agent_status === "Failed") { + frm.add_custom_button(__("Retry"), () => { + frappe.confirm( + __( + "Tear down the old box (if any) and re-provision a fresh box for this task?" + ), + () => { + frm.call("retry_agent").then(() => { + frappe.show_alert({ + message: __("Retry queued — provisioning a fresh box."), + indicator: "blue", + }); + frm.reload_doc(); + }); + } + ); + }).addClass("btn-primary"); + + frm.add_custom_button(__("Tear Down Now"), () => { + frappe.confirm( + __("Deprovision this box now instead of waiting for the grace sweep?"), + () => { + frm.call("teardown_agent_now").then(() => { + frappe.show_alert({ + message: __("Teardown requested."), + indicator: "orange", + }); + frm.reload_doc(); + }); + } + ); + }); + } + + // Any non-terminal agent task can be cancelled (specs/v2 06-phase-5 step 2). + const TERMINAL = ["Merged", "Cancelled", "Failed"]; + if (!TERMINAL.includes(frm.doc.agent_status)) { + frm.add_custom_button(__("Cancel Agent Task"), () => { + frappe.confirm(__("Cancel this agent task? The box will be torn down."), () => { + frm.call("cancel_agent").then(() => { + frappe.show_alert({ + message: __("Task cancelled — box being torn down."), + indicator: "gray", + }); + frm.reload_doc(); + }); + }); + }); + } + + const open = (url) => url && window.open(url, "_blank"); + if (frm.doc.agent_code_url) { + frm.add_custom_button( + __("Open Code (spec)"), + () => open(frm.doc.agent_code_url), + __("Agent") + ); + } + if (frm.doc.agent_site_url) { + frm.add_custom_button( + __("Open Site"), + () => open(frm.doc.agent_site_url), + __("Agent") + ); + } + if (frm.doc.pr_link) { + frm.add_custom_button(__("Open PR"), () => open(frm.doc.pr_link), __("Agent")); + } + }, +}); diff --git a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.json b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.json index 51a96cf..6327268 100644 --- a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.json +++ b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.json @@ -28,7 +28,19 @@ "uat_status", "uat_approved_by", "uat_date", - "github_issue_url" + "github_issue_url", + "agent_section", + "agent_status", + "agent_dev_box", + "agent_box_slug", + "agent_control_url", + "agent_control_token", + "agent_site_url", + "agent_code_url", + "agent_spec_path", + "agent_branch", + "agent_last_error", + "agent_box_torn_down" ], "fields": [ { @@ -186,6 +198,86 @@ "label": "GitHub Issue URL", "options": "URL", "read_only": 1 + }, + { + "collapsible": 1, + "depends_on": "eval:doc.agent_status", + "fieldname": "agent_section", + "fieldtype": "Section Break", + "label": "Agent" + }, + { + "default": "", + "description": "Agent lifecycle state (specs/v2). Empty = not agent-managed. Separate from the kanban status.", + "fieldname": "agent_status", + "fieldtype": "Select", + "label": "Agent Status", + "options": "\nQueued\nProvisioning\nSpec In Progress\nSpec Created\nSpec Approved\nImplementing\nPR Ready\nChanges Requested\nMerged\nCancelled\nFailed", + "read_only": 1 + }, + { + "fieldname": "agent_dev_box", + "fieldtype": "Data", + "label": "Agent Dev Box", + "read_only": 1 + }, + { + "fieldname": "agent_box_slug", + "fieldtype": "Data", + "label": "Agent Box Slug", + "read_only": 1 + }, + { + "fieldname": "agent_control_url", + "fieldtype": "Data", + "label": "Agent Control URL", + "read_only": 1 + }, + { + "fieldname": "agent_control_token", + "fieldtype": "Password", + "label": "Agent Control Token", + "read_only": 1 + }, + { + "fieldname": "agent_site_url", + "fieldtype": "Data", + "label": "Agent Site URL", + "options": "URL", + "read_only": 1 + }, + { + "fieldname": "agent_code_url", + "fieldtype": "Data", + "label": "Agent Code URL", + "options": "URL", + "read_only": 1 + }, + { + "fieldname": "agent_spec_path", + "fieldtype": "Data", + "label": "Agent Spec Path", + "read_only": 1 + }, + { + "fieldname": "agent_branch", + "fieldtype": "Data", + "label": "Agent Branch", + "read_only": 1 + }, + { + "fieldname": "agent_last_error", + "fieldtype": "Small Text", + "label": "Agent Last Error", + "read_only": 1 + }, + { + "default": "0", + "description": "Set by deprovision_for_task on a successful teardown; the watchdog's terminal-teardown sweep skips boxes already torn down (Phase 5).", + "fieldname": "agent_box_torn_down", + "fieldtype": "Check", + "label": "Agent Box Torn Down", + "read_only": 1 } ], "grid_page_length": 50, diff --git a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.py b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.py index 26690cd..35b721f 100644 --- a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.py +++ b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.py @@ -45,6 +45,30 @@ class HiveTask(Document): if TYPE_CHECKING: from frappe.types import DF + agent_box_slug: DF.Data | None + agent_box_torn_down: DF.Check + agent_branch: DF.Data | None + agent_code_url: DF.Data | None + agent_control_token: DF.Password | None + agent_control_url: DF.Data | None + agent_dev_box: DF.Data | None + agent_last_error: DF.SmallText | None + agent_site_url: DF.Data | None + agent_spec_path: DF.Data | None + agent_status: DF.Literal[ + "", + "Queued", + "Provisioning", + "Spec In Progress", + "Spec Created", + "Spec Approved", + "Implementing", + "PR Ready", + "Changes Requested", + "Merged", + "Cancelled", + "Failed", + ] assigned_to: DF.Link | None completed_on: DF.Date | None depends_on: DF.Link | None @@ -180,6 +204,82 @@ def _maybe_spawn_recurrence(self): message=f"Failed to assign {assignees} to {new_task.name}", ) + @frappe.whitelist() + def approve_spec(self, note: str | None = None) -> dict: + """Human approval of the agent's spec (specs/v2 04-phase-3 §A.1). + + Routes through the orchestrator so the transition is validated and the + implementation run is dispatched. Mirrors approve_uat's desk-action shape. + """ + from bwh_hive.bwh_hive.orchestrator import service + + self._assert_agent_reviewer() + service.set_agent_status(self, "Spec Approved", actor="human", message=note) + return {"ok": True, "agent_status": "Spec Approved"} + + @frappe.whitelist() + def request_agent_changes(self, comments: str | list) -> dict: + """Human requests another iteration on a PR Ready task (specs/v2 05-phase-4 §B.6). + + `comments` is the §5.3 payload — a JSON array (or list) of {author, body, path?, line?}. + Routes through the orchestrator, which transitions the task and dispatches /changes/apply. + """ + from bwh_hive.bwh_hive.orchestrator import service + + self._assert_agent_reviewer() + parsed = comments if isinstance(comments, list) else frappe.parse_json(comments) + if not parsed: + frappe.throw("Provide at least one review comment.") + service.request_changes(self, parsed) + return {"ok": True, "agent_status": "Implementing"} + + @frappe.whitelist() + def mark_agent_merged(self) -> dict: + """Human records that the PR was merged on GitHub (specs/v2 05-phase-4 §B.8).""" + from bwh_hive.bwh_hive.orchestrator import service + + self._assert_agent_reviewer() + service.mark_merged(self) + return {"ok": True, "agent_status": "Merged"} + + @frappe.whitelist() + def cancel_agent(self) -> dict: + """Human cancels an in-flight agent task (specs/v2 06-phase-5 step 2).""" + from bwh_hive.bwh_hive.orchestrator import service + + self._assert_agent_reviewer() + service.cancel_agent_task(self.name) + return {"ok": True, "agent_status": "Cancelled"} + + @frappe.whitelist() + def retry_agent(self) -> dict: + """Re-provision a clean box for a Failed agent task (specs/v2 06-phase-5 step 8).""" + from bwh_hive.bwh_hive.orchestrator import service + + self._assert_agent_reviewer() + return service.retry_agent_task(self.name) + + @frappe.whitelist() + def teardown_agent_now(self) -> dict: + """Force-deprovision a Failed box ahead of the grace sweep (specs/v2 06-phase-5 step 9).""" + from bwh_hive.bwh_hive.orchestrator import service + + self._assert_agent_reviewer() + service.force_teardown(self.name) + return {"ok": True} + + def _assert_agent_reviewer(self) -> None: + """A human reviewer with write access — never the Agent bot itself. + + Identity check, not a role check: Administrator implicitly holds every role, so a + role test would wrongly block a legitimate admin reviewer. + """ + from bwh_hive.bwh_hive.orchestrator import service + + if frappe.session.user == service.get_agent_user(): + frappe.throw("The Agent bot cannot review its own work.", frappe.PermissionError) + self.check_permission("write") + @frappe.whitelist() def approve_uat(self): self.uat_status = "Approved" diff --git a/bwh_hive/bwh_hive/notifications/__init__.py b/bwh_hive/bwh_hive/notifications/__init__.py new file mode 100644 index 0000000..14927f5 --- /dev/null +++ b/bwh_hive/bwh_hive/notifications/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +"""Agent-lifecycle notifications (specs/v2 07-notifications.md). + +A small, object-oriented dispatcher that turns agent-lifecycle transitions into chat +alerts. Telegram is the only channel that sends in v2; Email/FrappeLog are real +subclasses left disabled by default. The orchestration/callback layer imports `notify` +from `.dispatcher`; nothing here imports back into the orchestrator. +""" diff --git a/bwh_hive/bwh_hive/notifications/base.py b/bwh_hive/bwh_hive/notifications/base.py new file mode 100644 index 0000000..6d3f375 --- /dev/null +++ b/bwh_hive/bwh_hive/notifications/base.py @@ -0,0 +1,28 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +"""The channel contract (specs/v2 07-notifications.md "base.py").""" + +from abc import ABC, abstractmethod + +from bwh_hive.bwh_hive.notifications.events import NotificationEvent + + +class NotificationChannel(ABC): + name: str # "telegram" | "email" | "frappe_log" + + def __init__(self, settings): + # cached Hive Settings doc — no per-send DB round-trip for the token + self.settings = settings + + @abstractmethod + def is_enabled(self) -> bool: + """Channel-level gate (config present + turned on). Cheap, no network.""" + + @abstractmethod + def render(self, event: NotificationEvent) -> dict: + """Turn a NotificationEvent into this channel's payload.""" + + @abstractmethod + def send(self, event: NotificationEvent) -> None: + """Deliver. Runs inside an enqueued job. May raise; the dispatcher isolates it.""" diff --git a/bwh_hive/bwh_hive/notifications/channels/__init__.py b/bwh_hive/bwh_hive/notifications/channels/__init__.py new file mode 100644 index 0000000..eeb9a8b --- /dev/null +++ b/bwh_hive/bwh_hive/notifications/channels/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt diff --git a/bwh_hive/bwh_hive/notifications/channels/email.py b/bwh_hive/bwh_hive/notifications/channels/email.py new file mode 100644 index 0000000..a5b9ab3 --- /dev/null +++ b/bwh_hive/bwh_hive/notifications/channels/email.py @@ -0,0 +1,37 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +"""Email channel — a real subclass, disabled by default (07-notifications.md step 5). + +`render`/`send` are real (reuse `frappe.sendmail`) so the abstraction is exercised, but +`is_enabled()` hard-returns False until a later phase turns email on. This keeps the +fan-out honest without doubling alerts. +""" + +import frappe + +from bwh_hive.bwh_hive.notifications.base import NotificationChannel +from bwh_hive.bwh_hive.notifications.events import NotificationEvent, render_plain_template + + +class EmailChannel(NotificationChannel): + name = "email" + + def is_enabled(self) -> bool: + # Disabled in v2. Telegram is the only sending channel. + return False + + def render(self, event: NotificationEvent) -> dict: + return { + "subject": f"[Hive Agent] {event.task}: {event.task_title}", + "message": render_plain_template(event).replace("\n", "
"), + } + + def send(self, event: NotificationEvent) -> None: + recipients = self.settings.get("agent_notify_email") or frappe.session.user + payload = self.render(event) + frappe.sendmail( + recipients=recipients, + subject=payload["subject"], + message=payload["message"], + ) diff --git a/bwh_hive/bwh_hive/notifications/channels/frappe_log.py b/bwh_hive/bwh_hive/notifications/channels/frappe_log.py new file mode 100644 index 0000000..17a5549 --- /dev/null +++ b/bwh_hive/bwh_hive/notifications/channels/frappe_log.py @@ -0,0 +1,37 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +"""Frappe Notification Log channel — a real subclass, disabled by default. + +Reuses `enqueue_create_notification` (the same path @mention alerts use) so a reviewer's +desk bell can later light up on agent events. `is_enabled()` hard-returns False in v2. +See 07-notifications.md step 5. +""" + +import frappe +from frappe.desk.doctype.notification_log.notification_log import enqueue_create_notification + +from bwh_hive.bwh_hive.notifications.base import NotificationChannel +from bwh_hive.bwh_hive.notifications.events import NotificationEvent, render_plain_template + + +class FrappeLogChannel(NotificationChannel): + name = "frappe_log" + + def is_enabled(self) -> bool: + # Disabled in v2. Telegram is the only sending channel. + return False + + def render(self, event: NotificationEvent) -> dict: + return { + "subject": render_plain_template(event).split("\n", 1)[0], + "type": "Alert", + "document_type": "Hive Task", + "document_name": event.task, + } + + def send(self, event: NotificationEvent) -> None: + notification = self.render(event) + # Route to the task assignee(s); falls back to the session user if none resolve. + recipients = [frappe.session.user] + enqueue_create_notification(recipients, notification) diff --git a/bwh_hive/bwh_hive/notifications/channels/telegram.py b/bwh_hive/bwh_hive/notifications/channels/telegram.py new file mode 100644 index 0000000..cebd46c --- /dev/null +++ b/bwh_hive/bwh_hive/notifications/channels/telegram.py @@ -0,0 +1,42 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +"""Telegram channel — the only channel that sends in v2 (07-notifications.md).""" + +import requests + +from bwh_hive.bwh_hive.notifications.base import NotificationChannel +from bwh_hive.bwh_hive.notifications.events import NotificationEvent, render_markdown_template + +API_BASE = "https://api.telegram.org" + + +class TelegramChannel(NotificationChannel): + name = "telegram" + + def is_enabled(self) -> bool: + """On only when notifications are enabled AND both token + chat id are present. + + A missing token/chat id is the graceful-off path — a silent no-op, not an error. + """ + return bool( + self.settings.notifications_enabled + and self.settings.get_password("telegram_bot_token", raise_exception=False) + and self.settings.telegram_default_chat_id + ) + + def render(self, event: NotificationEvent) -> dict: + return { + "chat_id": self.settings.telegram_default_chat_id, + "text": render_markdown_template(event), + "parse_mode": "MarkdownV2", + "disable_web_page_preview": True, + } + + def send(self, event: NotificationEvent) -> None: + token = self.settings.get_password("telegram_bot_token") + resp = requests.post(f"{API_BASE}/bot{token}/sendMessage", json=self.render(event), timeout=10) + if not resp.ok: + # Surface Telegram's 4xx body (e.g. "chat not found", "can't parse entities") + # so a bad chat id or an escaping bug is diagnosable in the Error Log. + raise requests.HTTPError(f"Telegram sendMessage {resp.status_code}: {resp.text}", response=resp) diff --git a/bwh_hive/bwh_hive/notifications/dispatcher.py b/bwh_hive/bwh_hive/notifications/dispatcher.py new file mode 100644 index 0000000..4e0533a --- /dev/null +++ b/bwh_hive/bwh_hive/notifications/dispatcher.py @@ -0,0 +1,79 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +"""The single public entry point: `notify(event)` (07-notifications.md "dispatcher.py"). + +Two layers of safety: + • `notify()` only *enqueues* (callback returns at network speed) and is itself wrapped so + even a broker hiccup logs rather than 500s the callback. + • `_deliver()` runs in the worker and catches everything, so a broken channel logs to the + Error Log instead of an alert storm or a surfaced exception. +`enqueue_after_commit=True` means a status callback that rolls back never sends a phantom ping. +""" + +import frappe + +from bwh_hive.bwh_hive.notifications.channels.email import EmailChannel +from bwh_hive.bwh_hive.notifications.channels.frappe_log import FrappeLogChannel +from bwh_hive.bwh_hive.notifications.channels.telegram import TelegramChannel +from bwh_hive.bwh_hive.notifications.events import EventType, NotificationEvent + +# Optional events fire only when listed here; the three required events always fire when +# notifications are enabled. Flip these on by adding them to the set — no other change. +CHATTY_EVENTS: set[EventType] = set() # PROVISIONING / CHANGES_REQ muted by default + +_REQUIRED_EVENTS = {EventType.SPEC_CREATED, EventType.PR_READY, EventType.FAILED} + +_CHANNEL_CLASSES = { + "telegram": TelegramChannel, + "email": EmailChannel, + "frappe_log": FrappeLogChannel, +} + + +def notify(event: NotificationEvent) -> None: + """Public API. Non-blocking: enqueues one background job per enabled channel. + + Defensive throughout — a notification must never resurface into the callback path. + """ + try: + if event.type not in _REQUIRED_EVENTS and event.type not in CHATTY_EVENTS: + return # optional event, muted + settings = frappe.get_cached_doc("Hive Settings") + if not settings.notifications_enabled: + return # global kill-switch → no-op + for channel in _enabled_channels(settings): + frappe.enqueue( + "bwh_hive.bwh_hive.notifications.dispatcher._deliver", + queue="short", + timeout=30, + enqueue_after_commit=True, # don't fire on a rolled-back callback + channel_name=channel.name, + event=event, + ) + except Exception: + frappe.log_error( + title="Notification enqueue failed", + message=frappe.get_traceback(), + ) # even the enqueue is best-effort — a broker hiccup cannot 500 a callback + + +def _enabled_channels(settings) -> list: + candidates = [cls(settings) for cls in _CHANNEL_CLASSES.values()] + return [c for c in candidates if c.is_enabled()] + + +def _build(channel_name: str): + settings = frappe.get_cached_doc("Hive Settings") + return _CHANNEL_CLASSES[channel_name](settings) + + +def _deliver(channel_name: str, event: NotificationEvent) -> None: + """Runs in the worker. One channel, fully isolated.""" + try: + _build(channel_name).send(event) + except Exception: + frappe.log_error( + title=f"Notification failed: {channel_name} / {event.type.value}", + message=frappe.get_traceback(), + ) # swallow — a broken channel must never resurface diff --git a/bwh_hive/bwh_hive/notifications/events.py b/bwh_hive/bwh_hive/notifications/events.py new file mode 100644 index 0000000..354fccb --- /dev/null +++ b/bwh_hive/bwh_hive/notifications/events.py @@ -0,0 +1,174 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +"""The `NotificationEvent` value object + event catalogue + template renderer. + +A `NotificationEvent` is an immutable description of *what happened*, independent of *how* +it is delivered. Channels render it; they never query Hive themselves (`from_task` reads +the task once, here). Telegram renders MarkdownV2; the plain-text fallback strips it. + +See specs/v2/07-notifications.md "Event catalogue" + "Message templates". +""" + +import dataclasses +from enum import Enum + +import frappe +from frappe.utils import get_url + + +class EventType(str, Enum): + PROVISIONING = "provisioning" # optional (chatty) + SPEC_CREATED = "spec_created" # required + CHANGES_REQ = "changes_requested" # optional (chatty) + PR_READY = "pr_ready" # required + FAILED = "failed" # required + + +@dataclasses.dataclass(frozen=True) +class NotificationEvent: + """Immutable, picklable (it is passed across `frappe.enqueue`).""" + + type: EventType + task: str # Hive Task name, e.g. "TASK-0007" + task_title: str + project: str | None = None + actor: str | None = None # "agent" or a User; for the footer line + message: str | None = None # free-text detail (e.g. error summary) + # deep links — populated from the task's agent_* fields where present + task_url: str | None = None # {site}/hive/tasks/{task} + code_url: str | None = None # box code-server (review spec) + site_url: str | None = None # box Frappe site (review changes) + pr_url: str | None = None # GitHub PR + payload: dict | None = None # channel-specific extras (e.g. {"phase": ...}) + + @classmethod + def from_task(cls, type: EventType, task_name: str, **overrides) -> "NotificationEvent": + """Build an event by reading the agent_* fields off a Hive Task once. + + `overrides` win over the task-derived values (e.g. `message=error`, + `payload={"phase": phase}` from `report_agent_error`). + """ + doc = frappe.get_doc("Hive Task", task_name) + base = dict( + type=type, + task=doc.name, + task_title=doc.title or doc.name, + project=doc.project, + task_url=f"{get_url()}/hive/tasks/{doc.name}", + code_url=doc.agent_code_url or None, + site_url=doc.agent_site_url or None, + pr_url=doc.pr_link or None, + ) + base.update(overrides) + return cls(**base) + + +# --------------------------------------------------------------------------- # +# MarkdownV2 rendering (Telegram) +# --------------------------------------------------------------------------- # +# Telegram MarkdownV2 reserves these; every dynamic substring must escape them. +# https://core.telegram.org/bots/api#markdownv2-style +_MD2_RESERVED = r"_*[]()~`>#+-=|{}.!" +_MD2_TABLE = {ord(c): "\\" + c for c in _MD2_RESERVED} + + +def escape_md2(text) -> str: + """Escape a string for Telegram MarkdownV2. None → empty string.""" + return str(text or "").translate(_MD2_TABLE) + + +def _link(label: str, url: str | None) -> str | None: + """A MarkdownV2 inline link, or None when the URL is absent (graceful degrade).""" + if not url: + return None + # The link *text* is still MarkdownV2 — reserved chars in the label (e.g. the parens + # in "Review spec (code)") must be escaped or Telegram rejects with 400 "can't parse + # entities". Inside the URL part only `)` and `\` need escaping. + safe_url = url.replace("\\", "\\\\").replace(")", "\\)") + return f"[{escape_md2(label)}]({safe_url})" + + +def _links_line(*links: str | None) -> str: + rendered = [link for link in links if link] + return " · ".join(rendered) + + +def render_markdown_template(event: NotificationEvent) -> str: + """Render a NotificationEvent to a Telegram MarkdownV2 message body. + + Each template leads with an emoji + headline, names the task, and ends with the + available deep links. A link is omitted when its URL field is empty, so an + early-lifecycle event degrades to just `[Open task]`. + """ + task = escape_md2(event.task) + title = escape_md2(event.task_title) + open_task = _link("Open task", event.task_url) + + if event.type is EventType.SPEC_CREATED: + links = _links_line(_link("Review spec (code)", event.code_url), open_task) + return ( + f"📝 *Spec ready* — `{task}`\n" + f"*{title}*\n" + f"The agent finished writing the spec\\. Review it before approving\\.\n\n" + f"{links}" + ) + + if event.type is EventType.PR_READY: + links = _links_line(_link("View PR", event.pr_url), _link("Review site", event.site_url), open_task) + return ( + f"✅ *PR ready* — `{task}`\n" + f"*{title}*\n" + f"The agent opened a pull request\\. Review and merge or request changes\\.\n\n" + f"{links}" + ) + + if event.type is EventType.FAILED: + phase = escape_md2((event.payload or {}).get("phase")) + error = escape_md2(event.message) + detail = f"Phase: {phase}\n" if phase else "" + if error: + detail += f"{error}\n" + return f"🔴 *Agent failed* — `{task}`\n*{title}*\n{detail}\n{_links_line(open_task)}" + + if event.type is EventType.PROVISIONING: + return ( + f"🚀 *Box provisioning* — `{task}`\n" + f"*{title}*\n" + f"A dev box is spinning up for this task\\.\n\n" + f"{_links_line(open_task)}" + ) + + if event.type is EventType.CHANGES_REQ: + links = _links_line(_link("View PR", event.pr_url), open_task) + return ( + f"🔁 *Changes requested* — `{task}`\n" + f"*{title}*\n" + f"Review comments are being applied; a new push is on the way\\.\n\n" + f"{links}" + ) + + # Unknown type — degrade to a minimal, escaped notice rather than raise. + return f"*{escape_md2(event.type)}* — `{task}`\n*{title}*" + + +def render_plain_template(event: NotificationEvent) -> str: + """A plain-text rendering (Email / Notification Log fallback). No MarkdownV2 escaping.""" + lines = { + EventType.SPEC_CREATED: "Spec ready for review", + EventType.PR_READY: "PR ready for review", + EventType.FAILED: "Agent failed", + EventType.PROVISIONING: "Box provisioning", + EventType.CHANGES_REQ: "Changes requested", + } + headline = lines.get(event.type, str(event.type)) + body = f"{headline} — {event.task}: {event.task_title}" + if event.type is EventType.FAILED: + phase = (event.payload or {}).get("phase") + if phase: + body += f"\nPhase: {phase}" + if event.message: + body += f"\n{event.message}" + if event.task_url: + body += f"\n{event.task_url}" + return body diff --git a/bwh_hive/bwh_hive/orchestrator/__init__.py b/bwh_hive/bwh_hive/orchestrator/__init__.py new file mode 100644 index 0000000..16780bb --- /dev/null +++ b/bwh_hive/bwh_hive/orchestrator/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +"""v2 agent orchestration (specs/v2/02-phase-1-hive-orchestration.md). + +This package owns the agent lifecycle on the Hive side: + +- `benchspace.BenchSpaceClient` — thin REST client to BenchSpace (provision/manage boxes). +- `service` — the core: boot-env assembly, provisioning, the `agent_status` state + machine (`set_agent_status`), control-plane dispatch, and (stub) deprovision. +- `hooks` — doc-event glue that turns "assign the Agent user" into a provision and + "unassign" into a teardown. + +Box callbacks (`bwh_hive.bwh_hive.agent_api`) and human/desk actions both route status +changes through `service.set_agent_status` so transitions stay centralized and valid. +""" diff --git a/bwh_hive/bwh_hive/orchestrator/benchspace.py b/bwh_hive/bwh_hive/orchestrator/benchspace.py new file mode 100644 index 0000000..93f0321 --- /dev/null +++ b/bwh_hive/bwh_hive/orchestrator/benchspace.py @@ -0,0 +1,71 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +"""Thin BenchSpace REST client (specs/v2 §5.2). + +Reads creds from Hive Settings and wraps the `benchspace.api.agent_box` methods with +token auth (§2.1), a short timeout, and a typed `BenchSpaceError` on any non-2xx so the +orchestrator can surface failures as `agent_last_error`. +""" + +import frappe +import requests + +TIMEOUT = 30 + + +class BenchSpaceError(frappe.ValidationError): + pass + + +class BenchSpaceClient: + def __init__(self): + settings = frappe.get_cached_doc("Hive Settings") + self.base_url = (settings.benchspace_api_url or "").rstrip("/") + self.api_key = settings.benchspace_api_key + self.api_secret = settings.get_password("benchspace_api_secret", raise_exception=False) + if not (self.base_url and self.api_key and self.api_secret): + raise BenchSpaceError("BenchSpace API credentials are not configured in Hive Settings") + + def _headers(self) -> dict: + return { + "Authorization": f"token {self.api_key}:{self.api_secret}", + "Content-Type": "application/json", + } + + def _call(self, method: str, *, http: str, payload: dict) -> dict: + url = f"{self.base_url}/api/method/{method}" + try: + if http == "GET": + resp = requests.get(url, params=payload, headers=self._headers(), timeout=TIMEOUT) + else: + resp = requests.post(url, json=payload, headers=self._headers(), timeout=TIMEOUT) + except requests.RequestException as e: + raise BenchSpaceError(f"BenchSpace unreachable calling {method}: {e}") from e + + if resp.status_code >= 400: + raise BenchSpaceError(f"{method} failed ({resp.status_code}): {resp.text[:500]}") + + try: + data = resp.json() + except ValueError as e: + raise BenchSpaceError(f"{method} returned non-JSON: {resp.text[:200]}") from e + # Frappe wraps whitelisted return values under "message". + return data.get("message", data) + + def provision(self, template: str, boot_env: dict, owner_user: str | None = None) -> dict: + return self._call( + "benchspace.api.agent_box.provision", + http="POST", + payload={"template": template, "boot_env": boot_env, "owner_user": owner_user}, + ) + + def get_box(self, name: str) -> dict: + return self._call("benchspace.api.agent_box.get_box", http="GET", payload={"name": name}) + + def list_agent_boxes(self) -> list[dict]: + result = self._call("benchspace.api.agent_box.list_agent_boxes", http="GET", payload={}) + return result if isinstance(result, list) else [] + + def deprovision(self, name: str) -> dict: + return self._call("benchspace.api.agent_box.deprovision", http="POST", payload={"name": name}) diff --git a/bwh_hive/bwh_hive/orchestrator/hooks.py b/bwh_hive/bwh_hive/orchestrator/hooks.py new file mode 100644 index 0000000..94cfdf7 --- /dev/null +++ b/bwh_hive/bwh_hive/orchestrator/hooks.py @@ -0,0 +1,42 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +"""Doc-event glue that turns Agent assignment into provisioning (specs/v2 §C.8). + +Hive assigns via Frappe's standard `assign_to.add`, which creates a **ToDo** and updates +the document's `_assign` field directly — it does NOT run the Hive Task's `on_update`. +So we hook the **ToDo** doctype (not Hive Task) to reliably observe assignment changes, +filter to Hive Task ToDos allocated to the Agent user, and route into the orchestrator. + +Idempotency lives in the orchestrator: `on_agent_assigned` no-ops if the task is already +agent-managed; `on_agent_unassigned` no-ops if it is unset or already terminal. So firing +on every relevant ToDo event is safe. +""" + +import frappe + +from bwh_hive.bwh_hive.orchestrator import service + +# ToDo statuses that mean "assignment is no longer active". +_INACTIVE_TODO_STATUS = {"Cancelled", "Closed"} + + +def on_todo_change(doc, method: str) -> None: + """React to ToDo create/update/trash for Agent-assigned Hive Tasks.""" + if doc.reference_type != "Hive Task" or not doc.reference_name: + return + + agent_user = service.get_agent_user() + if not agent_user or doc.allocated_to != agent_user: + return + + if not frappe.db.exists("Hive Task", doc.reference_name): + return + + # Assignment is "active" only on an open ToDo that still exists. + active = method != "on_trash" and (doc.status not in _INACTIVE_TODO_STATUS) + + if active: + service.on_agent_assigned(doc.reference_name) + else: + service.on_agent_unassigned(doc.reference_name) diff --git a/bwh_hive/bwh_hive/orchestrator/service.py b/bwh_hive/bwh_hive/orchestrator/service.py new file mode 100644 index 0000000..a3673d7 --- /dev/null +++ b/bwh_hive/bwh_hive/orchestrator/service.py @@ -0,0 +1,568 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +"""Agent orchestration core (specs/v2 §4, §5.3). + +Owns: boot-env assembly, async provisioning, the `agent_status` state machine, control +-plane dispatch, and (Phase 1 stub) deprovision. Box callbacks and human/desk actions +both funnel status changes through `set_agent_status`, which is the single place that +validates transitions and fires side effects (notifications, teardown, control dispatch). +""" + +import frappe +import requests +from frappe.model.document import Document + +from bwh_hive.bwh_hive.orchestrator.benchspace import BenchSpaceClient, BenchSpaceError + +AGENT_BOT_ROLE = "Agent Bot" + +# Fallback cap when Hive Settings.max_concurrent_agent_boxes is unset (specs/v2 06-phase-5 §3). +DEFAULT_MAX_CONCURRENT_BOXES = 5 + +CONTROL_TIMEOUT = 30 + +# specs/v2 §4.2 — terminal states. Merged/Cancelled tear down immediately; Failed is kept +# for a grace period (debuggability) and swept by the watchdog (06-phase-5 step 2). +TERMINAL_STATES = {"Merged", "Cancelled", "Failed"} + +# Terminal states whose `_react` tears the box down synchronously (Failed waits for the +# watchdog's grace sweep — 06-phase-5 pass D). +IMMEDIATE_TEARDOWN_STATES = {"Merged", "Cancelled"} + +# Non-terminal agent states (everything the watchdog considers "live"). Queued is live but +# owns no box yet; PROVISIONED_STATES are the ones that hold a real box against the cap. +LIVE_STATES = { + "Queued", + "Provisioning", + "Spec In Progress", + "Spec Created", + "Spec Approved", + "Implementing", + "PR Ready", + "Changes Requested", +} +PROVISIONED_STATES = LIVE_STATES - {"Queued"} + +# Allowed transitions, keyed by current state (specs/v2 §4.2). `Failed` and `Cancelled` +# are reachable from any non-terminal state and are injected below. `Failed → Queued` is +# the single sanctioned retry edge (06-phase-5 step 8). +_BASE_TRANSITIONS: dict[str, set[str]] = { + "": {"Queued"}, + "Queued": {"Provisioning"}, + "Provisioning": {"Provisioning", "Spec In Progress"}, + "Spec In Progress": {"Spec Created"}, + "Spec Created": {"Spec Approved"}, + "Spec Approved": {"Implementing"}, + "Implementing": {"PR Ready"}, + "PR Ready": {"Changes Requested", "Merged"}, + "Changes Requested": {"Implementing"}, + "Failed": {"Queued"}, # retry only +} + + +def _allowed_targets(current: str) -> set[str]: + targets = set(_BASE_TRANSITIONS.get(current, set())) + if current not in TERMINAL_STATES: + targets |= {"Failed", "Cancelled"} + return targets + + +# Which actor may drive which target (specs/v2 §4.2). The orchestrator is trusted and +# may set anything reachable; box and human are constrained. +ACTOR_TARGETS: dict[str, set[str]] = { + "box": {"Provisioning", "Spec In Progress", "Spec Created", "PR Ready", "Failed"}, + "human": {"Spec Approved", "Changes Requested", "Merged"}, + # "orchestrator" is unrestricted (handled in set_agent_status). +} + + +class InvalidAgentTransition(frappe.ValidationError): + pass + + +# --------------------------------------------------------------------------- # +# Identity helpers +# --------------------------------------------------------------------------- # +def get_agent_user() -> str | None: + """Return the enabled User carrying the Agent Bot role (specs/v2 §2.2).""" + users = frappe.get_all( + "Has Role", + filters={"role": AGENT_BOT_ROLE, "parenttype": "User"}, + pluck="parent", + ) + enabled = [u for u in users if frappe.db.get_value("User", u, "enabled")] + candidates = enabled or users + # Prefer a dedicated bot over Administrator if both somehow carry the role. + for u in candidates: + if u != "Administrator": + return u + return candidates[0] if candidates else None + + +# --------------------------------------------------------------------------- # +# Boot-env assembly (specs/v2 §3) +# --------------------------------------------------------------------------- # +def build_boot_env(task: Document) -> dict: + """Assemble the MMDS agent context from Task + Project + Settings. + + Generates a fresh per-box CONTROL_TOKEN. All values are strings (MMDS env map). + """ + project = frappe.get_doc("Hive Project", task.project) + settings = frappe.get_cached_doc("Hive Settings") + skills_repo = project.get("skills_repo_override") or settings.skills_repo + + # Box-side self-timeout budgets (seconds), kept a few minutes BELOW the Hive watchdog + # budgets so the box reports report_agent_error first and the watchdog is pure backstop + # (specs/v2 06-phase-5 step 7). 0 setting → fall back to the field default. + spec_min = settings.spec_timeout_minutes or 30 + impl_min = settings.implement_timeout_minutes or 45 + spec_run_timeout = max(spec_min - 5, 5) * 60 + impl_run_timeout = max(impl_min - 5, 5) * 60 + + env = { + "AGENT_MODE": "1", + "HIVE_BASE_URL": frappe.utils.get_url(), + "HIVE_API_KEY": settings.agent_callback_api_key or "", + "HIVE_API_SECRET": settings.get_password("agent_callback_api_secret", raise_exception=False) or "", + "HIVE_TASK_ID": task.name, + "HIVE_PROJECT": project.get("slug") or project.name, + "CONTROL_TOKEN": frappe.generate_hash(length=48), + "GIT_REPO": project.get("github_repo") or "", + "GIT_PAT": project.get_password("github_pat", raise_exception=False) or "", + "TARGET_APP_NAME": project.get("target_app_name") or "", + "TARGET_APP_REPO": project.get("target_app_repo") or "", + "TARGET_APP_BRANCH": project.get("target_app_branch") or "develop", + "SKILLS_REPO": skills_repo or "", + "ANTHROPIC_API_KEY": settings.get_password("anthropic_api_key", raise_exception=False) or "", + "SPEC_RUN_TIMEOUT": spec_run_timeout, + "IMPL_RUN_TIMEOUT": impl_run_timeout, + } + return {k: str(v) for k, v in env.items()} + + +# --------------------------------------------------------------------------- # +# Provisioning (async; enqueued from the assignment hook) +# --------------------------------------------------------------------------- # +def provision_for_task(task_name: str) -> None: + """Provision a BenchSpace box for a Queued task (idempotent, enqueued). + + Guards: orchestration enabled, project agent-enabled, not already provisioned, + under the concurrency cap. On success stores box coordinates + control token and + advances to Provisioning. On BenchSpace failure, moves the task to Failed. + """ + task = frappe.get_doc("Hive Task", task_name) + settings = frappe.get_cached_doc("Hive Settings") + + if not settings.agent_orchestration_enabled: + return + if task.agent_dev_box: + return # already provisioned — re-assignment no-op (specs/v2 decision) + if task.agent_status != "Queued": + # The task was unassigned/cancelled (reset to "" or a terminal state) between the + # enqueue and now — don't provision a box nobody is waiting for. Only a Queued task + # is a live provisioning request (06-phase-5: unassign-race guard). + return + + project = frappe.get_doc("Hive Project", task.project) + if not project.agent_enabled: + _fail(task, "Project is not agent-enabled.") + return + + cap = settings.max_concurrent_agent_boxes or DEFAULT_MAX_CONCURRENT_BOXES + live_boxes = frappe.db.count("Hive Task", {"agent_status": ["in", list(PROVISIONED_STATES)]}) + if live_boxes >= cap: + _comment(task, f"At concurrency cap ({cap} live boxes); staying Queued.") + return + + boot_env = build_boot_env(task) + control_token = boot_env["CONTROL_TOKEN"] + template = project.get("agent_template_slug") or settings.default_agent_template_slug + if not template: + _fail(task, "No agent template configured.") + return + + try: + box = BenchSpaceClient().provision(template, boot_env) + except Exception as e: # any failure routes to Failed + audit log + frappe.log_error(title=f"Agent provision failed: {task_name}", message=str(e)) + _fail(task, f"Provision failed: {e}") + return + + # Persist box coordinates + control token in a single save (encrypts the Password). + task.agent_dev_box = box.get("name") + task.agent_box_slug = box.get("slug") + task.agent_control_url = box.get("control_url") + task.agent_site_url = box.get("site_url") + task.agent_code_url = box.get("code_url") + task.agent_control_token = control_token + task.save(ignore_permissions=True) + + set_agent_status( + task, "Provisioning", actor="orchestrator", message=f"Box {box.get('name')} provisioning." + ) + + +# --------------------------------------------------------------------------- # +# State machine (specs/v2 §4.2) +# --------------------------------------------------------------------------- # +def set_agent_status(task, new_status: str, actor: str, message: str | None = None) -> None: + """Validate and apply an agent_status transition, then fire side effects. + + `actor` ∈ {"box", "human", "orchestrator"}. Raises on an illegal transition or an + actor that is not permitted to reach `new_status`. + """ + task_doc = task if isinstance(task, Document) else frappe.get_doc("Hive Task", task) + current = task_doc.agent_status or "" + + if current == new_status: + if message: + _comment(task_doc, message) + return + + if new_status not in _allowed_targets(current): + frappe.throw( + f"Invalid agent_status transition: {current or '(empty)'} → {new_status}", + InvalidAgentTransition, + ) + if actor != "orchestrator" and new_status not in ACTOR_TARGETS.get(actor, set()): + frappe.throw( + f"Actor '{actor}' may not set agent_status to {new_status}", + frappe.PermissionError, + ) + + task_doc.db_set("agent_status", new_status) + if message: + _comment(task_doc, message) + _notify(task_doc, new_status) + _publish_agent_update(task_doc, new_status) + _react(task_doc, new_status, actor) + + +def _publish_agent_update(task: Document, new_status: str) -> None: + """Push a realtime event so the Hive React frontend updates live (specs/v2 09). + + Fired from the single transition choke point, so it covers every status change — + box callbacks (spec/pr/error), human reviewer actions, and the watchdog. The payload + is intentionally minimal: clients refetch the task/list to pick up all the fields + (urls, pr_link, last_error) that a transition sets in the same transaction. + `after_commit=True` guarantees that refetch reads the committed row. Broadcast to the + whole site (all Desk users), so a reviewer sees a box's progress without a refresh. + """ + frappe.publish_realtime( + "hive_agent_update", + {"task": task.name, "project": task.project, "agent_status": new_status}, + after_commit=True, + ) + + +def _react(task: Document, new_status: str, actor: str) -> None: + """Post-transition side effects. + + Merged/Cancelled tear the box down immediately; Failed keeps its box for the grace + period and is swept by the watchdog (specs/v2 06-phase-5 step 2 / pass D). Entering + Spec Approved kicks off the implementation run (Phase 3). The Changes Requested → + /changes/apply dispatch is Phase 4. + """ + if new_status in IMMEDIATE_TEARDOWN_STATES: + if task.agent_dev_box: + frappe.enqueue( + "bwh_hive.bwh_hive.orchestrator.service.deprovision_for_task", + queue="long", + enqueue_after_commit=True, + task_name=task.name, + ) + return + if new_status in TERMINAL_STATES: # Failed — deferred teardown (grace sweep) + return + if new_status == "Spec Approved": + # Run the implement kickoff in the background: it flips the task to Implementing + # and dispatches to the box over HTTP, which must not block the approving request. + frappe.enqueue( + "bwh_hive.bwh_hive.orchestrator.service.start_implementation_for_task", + queue="long", + enqueue_after_commit=True, + task_name=task.name, + ) + # Changes Requested → Implementing + dispatch /changes/apply is handled inline in + # request_changes (it carries the comments payload and surfaces dispatch errors to the + # reviewer synchronously), so it is intentionally not enqueued from here. + + +def _notify(task: Document, new_status: str) -> None: + """State-machine notification hook — intentionally a no-op. + + Per the locked decision in 07-notifications.md ("Event-driven, not transition-driven + generically"), agent alerts are emitted explicitly from the callback methods in + `agent_api.py` (which carry the error/phase detail and know which transition matters), + NOT from this generic hook. Wiring `notify()` here as well would double-send. Kept as a + seam so the state machine stays testable without a Telegram dependency. + """ + return + + +def start_implementation_for_task(task_name: str) -> None: + """Flip an approved task to Implementing and dispatch the box (specs/v2 04-phase-3 §A.2). + + Enqueued when a task enters Spec Approved. On a dispatch failure (box unreachable) it + reverts to Spec Approved and records the error so the Phase 5 watchdog can retry. + Idempotent: a task no longer in Spec Approved is left alone. + """ + task = frappe.get_doc("Hive Task", task_name) + if task.agent_status != "Spec Approved": + return + + set_agent_status( + task, "Implementing", actor="orchestrator", message="Spec approved — dispatching implementation." + ) + try: + dispatch(task, "/implement/start", {}) + except Exception as e: + frappe.log_error(title=f"Implement dispatch failed: {task_name}", message=str(e)) + # Revert directly: Implementing → Spec Approved is not a valid forward transition, + # so go around set_agent_status. The watchdog (Phase 5) retries from here. + task.db_set("agent_last_error", f"Implement dispatch failed: {e}") + task.db_set("agent_status", "Spec Approved") + _comment(task, "Implementation dispatch failed; reverted to Spec Approved (will retry).") + + +def request_changes(task, comments: list[dict]) -> None: + """Human asks the agent for another PR iteration (specs/v2 05-phase-4 §B.6). + + PR Ready → Changes Requested (human) → Implementing (orchestrator), then dispatch the + comments to the box. Dispatch is synchronous so a box-unreachable error surfaces to the + reviewer; on failure the task rolls back to PR Ready rather than stranding in Implementing. + """ + task_doc = task if isinstance(task, Document) else frappe.get_doc("Hive Task", task) + + set_agent_status(task_doc, "Changes Requested", actor="human", message="Changes requested.") + set_agent_status( + task_doc, "Implementing", actor="orchestrator", message="Dispatching review changes to the box." + ) + try: + dispatch(task_doc, "/changes/apply", {"comments": comments}) + except Exception as e: + frappe.log_error(title=f"Changes dispatch failed: {task_doc.name}", message=str(e)) + # Revert directly: Implementing → PR Ready is not a valid forward transition, so go + # around set_agent_status. The reviewer retries. + task_doc.db_set("agent_last_error", f"Changes dispatch failed: {e}") + task_doc.db_set("agent_status", "PR Ready") + _comment(task_doc, "Changes dispatch failed; reverted to PR Ready.") + frappe.throw(f"Could not dispatch changes to the box: {e}") + + +def mark_merged(task) -> None: + """Record that the PR was merged (specs/v2 05-phase-4 §B.8). + + Only state — Merged is terminal, so the existing `_react` enqueues deprovision (the + `Merged → deprovision` reaction locked in 00-architecture §4.2). + """ + task_doc = task if isinstance(task, Document) else frappe.get_doc("Hive Task", task) + set_agent_status(task_doc, "Merged", actor="human", message="PR merged.") + + +def deprovision_for_task(task_name: str) -> None: + """The single teardown sink (specs/v2 06-phase-5 step 1). + + Idempotent: no-op if the task never had a box; BenchSpace `deprovision` is itself a + no-op on an already-deleted/missing box, so the merge transition, unassign hook, and + watchdog can all race here harmlessly. Retains the audit fields (agent_dev_box, pr_link, + branch, spec_path, URLs, last_error) and clears only the now-dead control credential. + """ + task = frappe.get_doc("Hive Task", task_name) + if not task.agent_dev_box or task.agent_box_torn_down: + return # never provisioned, or already torn down — no-op + try: + BenchSpaceClient().deprovision(task.agent_dev_box) + except Exception as e: # teardown failures are logged, not fatal + # Leave agent_box_torn_down = 0: the box may still be alive, and the watchdog's + # terminal-teardown sweep re-attempts a not-yet-torn-down box next tick + # (06-phase-5 cleanup table). + frappe.log_error(title=f"Agent deprovision failed: {task_name}", message=str(e)) + return + + # Success (incl. already-deleted box): the control token is now a dead secret (§2.3). + # Frappe keeps Password values in `__Auth` (the doc column is just a `*****` placeholder), + # so removing it requires remove_encrypted_password — db_set on the field would not clear + # the secret. Mark the box torn down so the watchdog sweep skips it (the queryable signal). + from frappe.utils.password import remove_encrypted_password + + remove_encrypted_password("Hive Task", task.name, "agent_control_token") + task.db_set({"agent_control_token": None, "agent_box_torn_down": 1}) + + +# --------------------------------------------------------------------------- # +# Control-plane dispatch (specs/v2 §5.3) +# --------------------------------------------------------------------------- # +def dispatch(task, path: str, body: dict | None = None) -> dict: + """POST to the box control plane with the per-box bearer token.""" + task_doc = task if isinstance(task, Document) else frappe.get_doc("Hive Task", task) + url = task_doc.agent_control_url + token = task_doc.get_password("agent_control_token", raise_exception=False) + if not (url and token): + raise BenchSpaceError(f"Task {task_doc.name} has no control plane URL/token") + + resp = requests.post( + f"{url.rstrip('/')}{path}", + json=body or {}, + headers={"Authorization": f"Bearer {token}"}, + timeout=CONTROL_TIMEOUT, + ) + resp.raise_for_status() + return resp.json() if resp.text else {} + + +# --------------------------------------------------------------------------- # +# Assignment reactions (called from the ToDo doc-event hook) +# --------------------------------------------------------------------------- # +def on_agent_assigned(task_name: str) -> None: + """The Agent user was assigned to a task → queue provisioning (idempotent).""" + settings = frappe.get_cached_doc("Hive Settings") + if not settings.agent_orchestration_enabled: + return + + task = frappe.get_doc("Hive Task", task_name) + if task.agent_status or task.agent_dev_box: + return # already agent-managed — one box per task + + project = frappe.get_doc("Hive Project", task.project) + if not project.agent_enabled: + return + + set_agent_status( + task, "Queued", actor="orchestrator", message="Assigned to Agent — queued for provisioning." + ) + frappe.enqueue( + "bwh_hive.bwh_hive.orchestrator.service.provision_for_task", + queue="long", + enqueue_after_commit=True, + task_name=task.name, + ) + + +def on_agent_unassigned(task_name: str) -> None: + """The Agent user was unassigned → cancel + tear down (specs/v2 §4.2). + + A task that was never provisioned (no box) just resets to "" — there's nothing to tear + down and it leaves the agent flow cleanly (06-phase-5 step 2). + """ + task = frappe.get_doc("Hive Task", task_name) + if not task.agent_status or task.agent_status in TERMINAL_STATES: + return + if not task.agent_dev_box: + task.db_set("agent_status", "") + return + set_agent_status(task, "Cancelled", actor="orchestrator", message="Agent unassigned — cancelling.") + + +# --------------------------------------------------------------------------- # +# Desk / lifecycle actions (specs/v2 06-phase-5 steps 2, 8, 9) +# --------------------------------------------------------------------------- # +def cancel_agent_task(task_name: str) -> None: + """Explicit cancel: transition to Cancelled, drop the Agent assignment, tear down. + + Routes through `set_agent_status` so `_react` enqueues teardown (Cancelled is an + immediate-teardown state). Safe at any non-terminal phase, including Queued/Provisioning. + """ + task = frappe.get_doc("Hive Task", task_name) + if not task.agent_status: + frappe.throw("Task is not agent-managed.") + if task.agent_status in TERMINAL_STATES: + return # already cancelled/merged/failed — nothing to do + set_agent_status(task, "Cancelled", actor="orchestrator", message="Cancelled by user.") + _clear_agent_assignment(task) + + +def force_teardown(task_name: str) -> None: + """Tear a box down ahead of the watchdog grace sweep ("Tear Down Now" — 06-phase-5 step 9).""" + deprovision_for_task(task_name) + + +def retry_agent_task(task_name: str) -> dict: + """Re-provision a clean box for a Failed task (specs/v2 06-phase-5 step 8). + + Tears the old box down and clears the box-binding fields BEFORE re-queuing, so + `provision_for_task`'s "already provisioned ⇒ no-op" guard can never leave two live + boxes. Goes through Queued (not straight to Provisioning) so the concurrency cap still + applies. A second rapid retry finds the task at Queued (not Failed) and is rejected. + """ + task = frappe.get_doc("Hive Task", task_name) + if task.agent_status != "Failed": + frappe.throw("Retry is only available for a Failed task.") + + deprovision_for_task(task.name) # idempotent + task.db_set( + { + "agent_dev_box": None, + "agent_box_slug": None, + "agent_control_url": None, + "agent_control_token": None, + "agent_site_url": None, + "agent_code_url": None, + "agent_last_error": None, + "agent_box_torn_down": 0, # fresh box will be tear-down-able again + } + ) + + set_agent_status(task, "Queued", actor="orchestrator", message="Retry requested.") + frappe.enqueue( + "bwh_hive.bwh_hive.orchestrator.service.provision_for_task", + queue="long", + enqueue_after_commit=True, + task_name=task.name, + ) + return {"ok": True} + + +def _clear_agent_assignment(task: Document) -> None: + """Best-effort removal of the Agent user's assignment (ToDo) on a task. + + Removing the ToDo re-fires `on_todo_change` → `on_agent_unassigned`, which no-ops on a + terminal task — so this is safe to call right after setting Cancelled. + """ + agent = get_agent_user() + if not agent: + return + try: + from frappe.desk.form.assign_to import remove + + remove("Hive Task", task.name, agent) + except Exception as e: + frappe.log_error(title=f"Clear agent assignment failed: {task.name}", message=str(e)) + + +# --------------------------------------------------------------------------- # +# Internal +# --------------------------------------------------------------------------- # +def _fail(task: Document, reason: str) -> None: + """Surface an orchestrator-side failure on agent_last_error, then move to Failed. + + The spec (specs/v2 §B.5) requires provision/orchestration errors to land on + agent_last_error, not just a comment. + """ + task.db_set("agent_last_error", reason) + set_agent_status(task, "Failed", actor="orchestrator", message=reason) + + +def mark_failed(task, reason: str) -> None: + """Public watchdog entry point: record `reason` and transition to Failed (idempotent). + + A no-op on an already-terminal task, so the watchdog passes can call it freely without + racing each other or the box's own `report_agent_error` (06-phase-5 passes A-C). + """ + doc = task if isinstance(task, Document) else frappe.get_doc("Hive Task", task) + if doc.agent_status in TERMINAL_STATES: + return + _fail(doc, reason) + + +def _comment(task: Document, content: str) -> None: + """Append a lightweight timeline comment on the task.""" + frappe.get_doc( + { + "doctype": "Hive Task Comment", + "task": task.name, + "content": content, + "posted_by": frappe.session.user, + } + ).insert(ignore_permissions=True) diff --git a/bwh_hive/hooks.py b/bwh_hive/hooks.py index 600d312..346325d 100644 --- a/bwh_hive/hooks.py +++ b/bwh_hive/hooks.py @@ -142,7 +142,14 @@ doc_events = { "User Invitation": { "on_update": "bwh_hive.bwh_hive.utils.on_user_invitation_update", - } + }, + # v2 agent orchestration: assignment to the Agent user is observed via the ToDo + # that assign_to.add creates (Hive Task's own on_update does not fire on assign). + "ToDo": { + "after_insert": "bwh_hive.bwh_hive.orchestrator.hooks.on_todo_change", + "on_update": "bwh_hive.bwh_hive.orchestrator.hooks.on_todo_change", + "on_trash": "bwh_hive.bwh_hive.orchestrator.hooks.on_todo_change", + }, } # Scheduled Tasks @@ -152,6 +159,13 @@ "daily": [ "bwh_hive.tasks.daily", ], + # v2 agent lifecycle watchdog (specs/v2 06-phase-5 §4): reconcile drift, enforce + # timeouts, sweep idle/orphaned boxes, and drain the queue every 10 minutes. + "cron": { + "*/10 * * * *": [ + "bwh_hive.tasks.reconcile_agent_tasks", + ], + }, } # Testing diff --git a/bwh_hive/install.py b/bwh_hive/install.py index 02ce96a..f6eb190 100644 --- a/bwh_hive/install.py +++ b/bwh_hive/install.py @@ -1,11 +1,15 @@ import frappe +AGENT_BOT_ROLE = "Agent Bot" +AGENT_BOT_USER = "agent@hive.local" + def after_install(): """Bootstrap Hive roles, members, and default project types.""" _ensure_roles() _bootstrap_system_managers() _ensure_default_project_types() + _ensure_agent_bot() frappe.db.commit() @@ -14,6 +18,7 @@ def after_migrate(): _ensure_roles() _bootstrap_system_managers() _ensure_default_project_types() + _ensure_agent_bot() _generate_missing_project_slugs() frappe.db.commit() @@ -57,6 +62,39 @@ def _bootstrap_system_managers(): ).insert(ignore_permissions=True) +def _ensure_agent_bot(): + """Ensure the v2 Agent bot role, user, and Hive Member exist (specs/v2 §A.4). + + The bot carries the shared callback service key (00-architecture.md §2.2) and is + identified by the `Agent Bot` role. A Hive Member of type Team makes it selectable + in assignee pickers, which is what triggers provisioning. + """ + if not frappe.db.exists("Role", AGENT_BOT_ROLE): + frappe.get_doc({"doctype": "Role", "role_name": AGENT_BOT_ROLE, "desk_access": 0}).insert( + ignore_permissions=True + ) + + if not frappe.db.exists("User", AGENT_BOT_USER): + user = frappe.get_doc( + { + "doctype": "User", + "email": AGENT_BOT_USER, + "first_name": "Agent", + "user_type": "System User", + "send_welcome_email": 0, + } + ) + user.insert(ignore_permissions=True) + + if AGENT_BOT_ROLE not in frappe.get_roles(AGENT_BOT_USER): + frappe.get_doc("User", AGENT_BOT_USER).add_roles(AGENT_BOT_ROLE) + + if not frappe.db.exists("Hive Member", {"user": AGENT_BOT_USER}): + frappe.get_doc( + {"doctype": "Hive Member", "user": AGENT_BOT_USER, "type": "Team", "is_active": 1} + ).insert(ignore_permissions=True) + + def _generate_missing_project_slugs(): """Generate slugs for any projects that don't have one yet.""" projects = frappe.get_all( diff --git a/bwh_hive/tasks.py b/bwh_hive/tasks.py index b83b45c..ef6e572 100644 --- a/bwh_hive/tasks.py +++ b/bwh_hive/tasks.py @@ -1,7 +1,9 @@ import frappe -from frappe.utils import nowdate +from frappe.utils import get_datetime, now_datetime, nowdate from bwh_hive.bwh_hive.api import _enrich_tasks_with_project_titles +from bwh_hive.bwh_hive.orchestrator import service +from bwh_hive.bwh_hive.orchestrator.benchspace import BenchSpaceClient, BenchSpaceError def daily() -> None: @@ -76,3 +78,205 @@ def send_daily_overdue_notifications() -> None: }, now=True, ) + + +# --------------------------------------------------------------------------- # +# Agent lifecycle watchdog (specs/v2 06-phase-5 §5-6) +# --------------------------------------------------------------------------- # +# Human-wait states: no human action arriving is normal, so they are not phase-timed +# (pass A). They are reclaimed by idle teardown (pass C) instead. +_IDLE_STATES = {"Spec Created", "Spec Approved", "PR Ready"} + +# Grace before the watchdog retries teardown of a Merged/Cancelled box whose synchronous +# teardown job may still be in flight (minutes). +_TEARDOWN_SETTLE_MINUTES = 5 + +_DEPROVISION = "bwh_hive.bwh_hive.orchestrator.service.deprovision_for_task" +_PROVISION = "bwh_hive.bwh_hive.orchestrator.service.provision_for_task" + + +def reconcile_agent_tasks() -> None: + """Orchestration-side lifecycle watchdog (specs/v2 06-phase-5 §5). + + Runs on a ~10-minute cron and backstops the box's own self-timeouts: drags timed-out + tasks to Failed, reconciles tasks whose box vanished, reclaims idle boxes, sweeps the + Failed-grace + terminal-teardown set and orphaned boxes, and drains the Queued backlog + under the concurrency cap. Every pass is idempotent. + """ + settings = frappe.get_cached_doc("Hive Settings") + if not settings.agent_orchestration_enabled: + return + + live = frappe.get_all( + "Hive Task", + filters={"agent_status": ["in", list(service.LIVE_STATES)]}, + fields=["name", "agent_status", "agent_dev_box", "modified"], + ) + + cap = settings.max_concurrent_agent_boxes or service.DEFAULT_MAX_CONCURRENT_BOXES + provisioned = frappe.db.count("Hive Task", {"agent_status": ["in", list(service.PROVISIONED_STATES)]}) + at_cap = provisioned >= cap + + _sweep_timeouts(live, settings, at_cap) # pass A: stuck phase -> Failed + _reconcile_vanished_boxes(live) # pass B: box gone -> Failed + _sweep_idle(live, settings) # pass C: idle human-wait -> Failed + teardown + _sweep_terminal_teardown(settings) # pass D: Failed grace + terminal retry + _sweep_orphans() # orphaned boxes whose task is gone + _drain_queue(settings, cap, provisioned) # promote Queued -> Provisioning under cap + + if not frappe.flags.in_test: + frappe.db.commit() + + +# --- pass A: phase timeouts (backstop) ------------------------------------- # +def _phase_budget(status: str, settings) -> int: + """Per-phase watchdog budget in minutes (0 = no phase timeout for this state).""" + if status in ("Queued", "Provisioning"): + return settings.provisioning_timeout_minutes or 0 + if status == "Spec In Progress": + return settings.spec_timeout_minutes or 0 + if status in ("Implementing", "Changes Requested"): + return settings.implement_timeout_minutes or 0 + return 0 + + +def _sweep_timeouts(live: list, settings, at_cap: bool) -> None: + now = now_datetime() + for t in live: + budget = _phase_budget(t.agent_status, settings) + if budget <= 0: + continue + # A Queued task held back only because the cap is full is legitimately waiting, + # not stuck — don't fail it; the queue drain will promote it when a slot frees. + if t.agent_status == "Queued" and at_cap: + continue + age_min = (now - get_datetime(t.modified)).total_seconds() / 60 + if age_min >= budget: + service.mark_failed(t.name, f"{t.agent_status} timed out after {budget} min") + + +# --- pass B: vanished boxes ------------------------------------------------ # +def _poll_box_gone(client, name: str): + """(gone, status): True/False if known, None if the poll was transient (skip this tick).""" + try: + info = client.get_box(name) + except BenchSpaceError as e: + msg = str(e) + if "(404)" in msg or "DoesNotExist" in msg or "not found" in msg.lower(): + return True, "404" + return None, None # transient (network / 5xx) — don't fail on one flaky poll + status = (info or {}).get("status") + return status in ("deleted", "error"), status + + +def _reconcile_vanished_boxes(live: list) -> None: + client = _client() + if not client: + return + for t in live: + if not t.agent_dev_box or t.agent_status not in service.PROVISIONED_STATES: + continue + gone, status = _poll_box_gone(client, t.agent_dev_box) + if gone: + service.mark_failed(t.name, f"Box disappeared (status={status})") + + +# --- pass C: idle teardown (cost) ------------------------------------------ # +def _sweep_idle(live: list, settings) -> None: + hours = settings.idle_teardown_hours or 0 + if hours <= 0: + return + now = now_datetime() + for t in live: + if t.agent_status not in _IDLE_STATES or not t.agent_dev_box: + continue + age_h = (now - get_datetime(t.modified)).total_seconds() / 3600 + if age_h >= hours: + service.mark_failed(t.name, f"Idle teardown after {hours} h") + _enqueue(_DEPROVISION, t.name) # idle overrides the Failed grace — reclaim now + + +# --- pass D: Failed grace + terminal-teardown retry ------------------------ # +def _sweep_terminal_teardown(settings) -> None: + """Tear down terminal tasks whose box has not been torn down yet. + + Failed boxes are kept `failed_teardown_grace_hours` for debugging; Merged/Cancelled + boxes should already be gone, so an un-torn-down one means the synchronous teardown + failed/raced — retry it after a short settle window. `deprovision_for_task` sets + `agent_box_torn_down` only on success, so a torn-down box drops out of this set. + """ + grace_h = settings.failed_teardown_grace_hours or 0 + rows = frappe.get_all( + "Hive Task", + filters={ + "agent_status": ["in", list(service.TERMINAL_STATES)], + "agent_dev_box": ["is", "set"], + "agent_box_torn_down": 0, + }, + fields=["name", "agent_status", "modified"], + ) + now = now_datetime() + for r in rows: + age_min = (now - get_datetime(r.modified)).total_seconds() / 60 + if r.agent_status == "Failed": + if grace_h <= 0 or age_min < grace_h * 60: + continue + elif age_min < _TEARDOWN_SETTLE_MINUTES: # Merged/Cancelled — let the async job finish + continue + _enqueue(_DEPROVISION, r.name) + + +# --- orphan sweep ---------------------------------------------------------- # +def _sweep_orphans() -> None: + """Deprovision agent boxes no Hive task references (their task was hard-deleted).""" + client = _client() + if not client: + return + try: + boxes = client.list_agent_boxes() + except BenchSpaceError as e: + frappe.log_error(title="reconcile: list_agent_boxes failed", message=str(e)) + return + if not boxes: + return + referenced = set( + frappe.get_all("Hive Task", filters={"agent_dev_box": ["is", "set"]}, pluck="agent_dev_box") + ) + for b in boxes: + name = b.get("name") + if name and name not in referenced: + try: + client.deprovision(name) + except BenchSpaceError as e: + frappe.log_error(title=f"reconcile: orphan deprovision failed {name}", message=str(e)) + + +# --- queue drain ----------------------------------------------------------- # +def _drain_queue(settings, cap: int, provisioned: int) -> None: + if cap <= 0: + return + free = cap - provisioned + if free <= 0: + return + queued = frappe.get_all( + "Hive Task", + filters={"agent_status": "Queued"}, + order_by="modified asc", # FIFO + limit=free, + pluck="name", + ) + for name in queued: + _enqueue(_PROVISION, name) # provision_for_task re-checks the cap, so it can't overshoot + + +# --- helpers --------------------------------------------------------------- # +def _client(): + try: + return BenchSpaceClient() + except BenchSpaceError as e: + frappe.log_error(title="reconcile: BenchSpace client unavailable", message=str(e)) + return None + + +def _enqueue(method: str, task_name: str) -> None: + frappe.enqueue(method, queue="long", enqueue_after_commit=True, task_name=task_name) diff --git a/frontend/index.html b/frontend/index.html index 5215906..ad67a00 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -12,7 +12,8 @@
diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx index 66f3abb..7a874e3 100644 --- a/frontend/src/components/SettingsDialog.tsx +++ b/frontend/src/components/SettingsDialog.tsx @@ -5,6 +5,7 @@ import { UserCircleIcon, Building06Icon, GitBranchIcon, + SourceCodeIcon, } from "@hugeicons/core-free-icons" import { Dialog, @@ -28,6 +29,7 @@ import { GeneralSection } from "@/components/settings/GeneralSection" import { MembersSection } from "@/components/settings/MembersSection" import { ClientsSection } from "@/components/settings/ClientsSection" import { GitHubSection } from "@/components/settings/GitHubSection" +import { AgentSection } from "@/components/settings/AgentSection" const allSections = [ { id: "profile", label: "Profile", icon: UserCircleIcon, teamOnly: false }, @@ -35,6 +37,7 @@ const allSections = [ { id: "members", label: "Members", icon: UserGroup03Icon, teamOnly: true }, { id: "clients", label: "Clients", icon: Building06Icon, teamOnly: true }, { id: "github", label: "GitHub", icon: GitBranchIcon, teamOnly: true }, + { id: "agent", label: "Agent", icon: SourceCodeIcon, teamOnly: true }, ] as const function SettingsContent({ @@ -130,6 +133,9 @@ function SettingsContent({ + + + )} @@ -152,6 +158,9 @@ function SettingsContent({ + + + )} diff --git a/frontend/src/components/TaskCommentsSection.tsx b/frontend/src/components/TaskCommentsSection.tsx index ad21192..eede355 100644 --- a/frontend/src/components/TaskCommentsSection.tsx +++ b/frontend/src/components/TaskCommentsSection.tsx @@ -9,6 +9,7 @@ import { toast } from "sonner" import { LazyTiptapEditor } from "@/components/LazyTiptapEditor" import { MemberAvatar } from "@/components/MemberAvatar" import { useUser } from "@/context/UserContext" +import { useAgentTaskEvents } from "@/hooks/useAgentEvents" import type { HiveTaskComment, HiveMember } from "@/types" interface TaskCommentsSectionProps { @@ -46,6 +47,9 @@ export function TaskCommentsSection({ taskName, members }: TaskCommentsSectionPr taskName ? undefined : null, ) + // Live-refresh the feed when the agent appends a log line (specs/v2 09 realtime). + useAgentTaskEvents(taskName, { onLog: mutate }) + const memberByEmail = useMemo(() => { const map = new Map() if (members) { diff --git a/frontend/src/components/TaskDetailSheet.tsx b/frontend/src/components/TaskDetailSheet.tsx index 6f1e5b6..604bfa9 100644 --- a/frontend/src/components/TaskDetailSheet.tsx +++ b/frontend/src/components/TaskDetailSheet.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from "react" +import { useState, useEffect, useRef, useMemo, useCallback } from "react" import { useShortcut } from "@/hooks/useShortcut" import { useFrappeUpdateDoc, useFrappePostCall, useFrappeGetDocList, useFrappeGetDoc, useFrappeGetCall } from "frappe-react-sdk" import { startOfDay, isBefore } from "date-fns" @@ -55,6 +55,8 @@ import { useUser } from "@/context/UserContext" import { TaskCommentsSection } from "@/components/TaskCommentsSection" import { TaskAttachments } from "@/components/TaskAttachments" import { LinkField } from "@/components/LinkField" +import { AgentPanel } from "@/components/task/AgentPanel" +import { useAgentTaskEvents } from "@/hooks/useAgentEvents" import { useCelebration } from "@/hooks/useTaskCelebration" import { TASK_STATUSES, TASK_PRIORITIES, TASK_SIZES, TASK_RECURRENCE_FREQUENCIES, type HiveTask, type HiveMember } from "@/types" @@ -125,8 +127,8 @@ export function TaskDetailSheet({ task, open, onOpenChange, onUpdated, hasClient task?.name ? undefined : null, ) - // Fetch project to check github_repo - const { data: projectDoc } = useFrappeGetDoc<{ github_repo: string | null }>( + // Fetch project to check github_repo + agent enablement + const { data: projectDoc } = useFrappeGetDoc<{ github_repo: string | null; agent_enabled?: 0 | 1 }>( "Hive Project", task?.project ?? "", task?.project ? undefined : null, @@ -260,6 +262,14 @@ export function TaskDetailSheet({ task, open, onOpenChange, onUpdated, hasClient const assignedMemberNames = useMemo(() => new Set(assignees.map((a) => a.member)), [assignees]) + // Live agent updates: refetch the task doc on a status transition, and the + // comment feed on a new log line (specs/v2 09 realtime). + const refetchAgent = useCallback(() => { + mutateTaskDoc() + onUpdated() + }, [mutateTaskDoc, onUpdated]) + useAgentTaskEvents(task?.name, { onUpdate: refetchAgent }) + if (!task) return null const handleStatusChange = (newStatus: string) => { @@ -423,6 +433,14 @@ export function TaskDetailSheet({ task, open, onOpenChange, onUpdated, hasClient const formContent = (
+ {/* Agent lifecycle (specs/v2 09 — surface 1) */} + + {/* Title */}
diff --git a/frontend/src/components/project/AgentSettingsTab.tsx b/frontend/src/components/project/AgentSettingsTab.tsx new file mode 100644 index 0000000..2d65b73 --- /dev/null +++ b/frontend/src/components/project/AgentSettingsTab.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from "react" +import { useFrappeGetDoc, useFrappeUpdateDoc, useFrappeGetCall } from "frappe-react-sdk" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Separator } from "@/components/ui/separator" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { + TextField, + SwitchField, + SecretField, + PromptField, +} from "@/components/settings/agent-fields" +import { PROMPT_TOKENS } from "@/lib/agent" +import type { HiveProject } from "@/types" + +interface AgentSettingsTabProps { + projectId: string + onSaved?: () => void +} + +type Form = Record + +type ResolvedPrompts = { spec?: string; implement?: string; changes?: string } + +function SectionHeading({ title, description }: { title: string; description: string }) { + return ( +
+

{title}

+

{description}

+
+ ) +} + +/** Shows the global default a blank override inherits, previewable in a popover. */ +function GlobalDefaultHint({ value }: { value?: string }) { + if (!value) { + return

Blank = inherit — no global default set, so the box uses its shipped prompt.

+ } + const lines = value.split("\n").length + return ( +

+ Blank = inherit the global default ({lines} {lines === 1 ? "line" : "lines"}).{" "} + + }> + View global + + +

{value}
+ + +

+ ) +} + +export function AgentSettingsTab({ projectId, onSaved }: AgentSettingsTabProps) { + const { data, isLoading, mutate } = useFrappeGetDoc("Hive Project", projectId) + const { updateDoc, loading: saving } = useFrappeUpdateDoc() + // Global-only resolution (no project) — the defaults a blank override inherits. + const { data: globalPrompts } = useFrappeGetCall<{ message: ResolvedPrompts }>( + "bwh_hive.bwh_hive.api.resolved_prompts", + ) + const globals = globalPrompts?.message ?? {} + + const [form, setForm] = useState
({}) + const patSet = useMemo(() => Boolean(data?.github_pat), [data]) + + useEffect(() => { + if (!data) return + // eslint-disable-next-line react-hooks/set-state-in-effect + setForm({ + agent_enabled: data.agent_enabled ?? 0, + agent_template_slug: data.agent_template_slug ?? "", + target_app_name: data.target_app_name ?? "", + target_app_repo: data.target_app_repo ?? "", + target_app_branch: data.target_app_branch ?? "", + github_repo: data.github_repo ?? "", + github_pat: "", + skills_repo_override: data.skills_repo_override ?? "", + agent_spec_prompt: data.agent_spec_prompt ?? "", + agent_implement_prompt: data.agent_implement_prompt ?? "", + agent_changes_prompt: data.agent_changes_prompt ?? "", + }) + }, [data]) + + const set = (key: string) => (value: string | number | boolean | null) => + setForm((prev) => ({ ...prev, [key]: typeof value === "boolean" ? (value ? 1 : 0) : value })) + const s = (k: string) => String(form[k] ?? "") + + const handleSave = async () => { + const payload: Record = { ...form } + if (!String(form.github_pat ?? "").trim()) delete payload.github_pat + try { + await updateDoc("Hive Project", projectId, payload) + toast.success("Agent settings saved") + mutate() + onSaved?.() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to save agent settings") + } + } + + if (isLoading) { + return ( +
+ + + +
+ ) + } + + const enabled = form.agent_enabled === 1 + + return ( +
+
+ + +
+ + {enabled && ( + <> +
+ +
+ + + +
+ + + + + +
+ + + +
+ + + +
+ + + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + )} + +
+ +
+
+ ) +} diff --git a/frontend/src/components/settings/AgentSection.tsx b/frontend/src/components/settings/AgentSection.tsx new file mode 100644 index 0000000..4e6138b --- /dev/null +++ b/frontend/src/components/settings/AgentSection.tsx @@ -0,0 +1,203 @@ +import { useEffect, useMemo, useState } from "react" +import { useFrappeGetDoc, useFrappeUpdateDoc } from "frappe-react-sdk" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Separator } from "@/components/ui/separator" +import { + TextField, + IntField, + SwitchField, + SecretField, + PromptField, +} from "@/components/settings/agent-fields" +import { PROMPT_TOKENS } from "@/lib/agent" + +interface HiveSettings { + agent_orchestration_enabled?: 0 | 1 + benchspace_api_url?: string | null + benchspace_api_key?: string | null + benchspace_api_secret?: string | null + agent_callback_api_key?: string | null + agent_callback_api_secret?: string | null + default_agent_template_slug?: string | null + skills_repo?: string | null + anthropic_api_key?: string | null + agent_spec_prompt?: string | null + agent_implement_prompt?: string | null + agent_changes_prompt?: string | null + max_concurrent_agent_boxes?: number | null + provisioning_timeout_minutes?: number | null + spec_timeout_minutes?: number | null + implement_timeout_minutes?: number | null + idle_teardown_hours?: number | null + failed_teardown_grace_hours?: number | null + notifications_enabled?: 0 | 1 + telegram_bot_token?: string | null + telegram_default_chat_id?: string | null +} + +// Password fields never round-trip their real value; input starts blank and is +// only written when non-empty. +const SECRET_FIELDS = [ + "benchspace_api_secret", + "agent_callback_api_secret", + "anthropic_api_key", + "telegram_bot_token", +] as const + +type Form = Record + +function SectionHeading({ title, description }: { title: string; description: string }) { + return ( +
+

{title}

+

{description}

+
+ ) +} + +export function AgentSection() { + const { data, isLoading, mutate } = useFrappeGetDoc("Hive Settings", "Hive Settings") + const { updateDoc, loading: saving } = useFrappeUpdateDoc() + const [form, setForm] = useState({}) + + const secretsSet = useMemo(() => { + const set: Record = {} + for (const f of SECRET_FIELDS) set[f] = Boolean(data?.[f as keyof HiveSettings]) + return set + }, [data]) + + // Seed the draft once the doc loads. Secrets seed blank (Frappe masks them). + useEffect(() => { + if (!data) return + // eslint-disable-next-line react-hooks/set-state-in-effect + setForm({ + agent_orchestration_enabled: data.agent_orchestration_enabled ?? 0, + benchspace_api_url: data.benchspace_api_url ?? "", + benchspace_api_key: data.benchspace_api_key ?? "", + benchspace_api_secret: "", + agent_callback_api_key: data.agent_callback_api_key ?? "", + agent_callback_api_secret: "", + default_agent_template_slug: data.default_agent_template_slug ?? "", + skills_repo: data.skills_repo ?? "", + anthropic_api_key: "", + agent_spec_prompt: data.agent_spec_prompt ?? "", + agent_implement_prompt: data.agent_implement_prompt ?? "", + agent_changes_prompt: data.agent_changes_prompt ?? "", + max_concurrent_agent_boxes: data.max_concurrent_agent_boxes ?? null, + provisioning_timeout_minutes: data.provisioning_timeout_minutes ?? null, + spec_timeout_minutes: data.spec_timeout_minutes ?? null, + implement_timeout_minutes: data.implement_timeout_minutes ?? null, + idle_teardown_hours: data.idle_teardown_hours ?? null, + failed_teardown_grace_hours: data.failed_teardown_grace_hours ?? null, + notifications_enabled: data.notifications_enabled ?? 0, + telegram_bot_token: "", + telegram_default_chat_id: data.telegram_default_chat_id ?? "", + }) + }, [data]) + + const set = (key: string) => (value: string | number | boolean | null) => + setForm((prev) => ({ ...prev, [key]: typeof value === "boolean" ? (value ? 1 : 0) : value })) + + const handleSave = async () => { + const payload: Record = { ...form } + // Don't wipe stored secrets on a blank submit. + for (const f of SECRET_FIELDS) { + if (!String(form[f] ?? "").trim()) delete payload[f] + } + try { + await updateDoc("Hive Settings", "Hive Settings", payload) + toast.success("Agent settings saved") + mutate() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to save agent settings") + } + } + + if (isLoading) { + return ( +
+ + + + +
+ ) + } + + const s = (k: string) => String(form[k] ?? "") + const n = (k: string) => (form[k] === null || form[k] === undefined ? null : Number(form[k])) + + return ( +
+
+ {/* Orchestration */} +
+ + + +
+ + + + + + +
+
+ + + + {/* Credentials */} +
+ + + + + + + + +
+ + + + {/* Prompts */} +
+ + + + +
+ + + + {/* Notifications */} +
+ + + + +
+
+ +
+ +
+
+ ) +} diff --git a/frontend/src/components/settings/agent-fields.tsx b/frontend/src/components/settings/agent-fields.tsx new file mode 100644 index 0000000..a58af87 --- /dev/null +++ b/frontend/src/components/settings/agent-fields.tsx @@ -0,0 +1,167 @@ +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { Textarea } from "@/components/ui/textarea" + +export function FieldLabel({ label, hint }: { label: string; hint?: string }) { + return ( +
+ {label} + {hint &&

{hint}

} +
+ ) +} + +export function TextField({ + id, + label, + hint, + value, + onChange, + placeholder, +}: { + id: string + label: string + hint?: string + value: string + onChange: (v: string) => void + placeholder?: string +}) { + return ( +
+ + {hint &&

{hint}

} + onChange(e.target.value)} placeholder={placeholder} className="h-8 text-sm" /> +
+ ) +} + +export function IntField({ + id, + label, + hint, + value, + onChange, +}: { + id: string + label: string + hint?: string + value: number | null + onChange: (v: number | null) => void +}) { + return ( +
+ + {hint &&

{hint}

} + onChange(e.target.value === "" ? null : Number(e.target.value))} + className="h-8 text-sm max-w-32" + /> +
+ ) +} + +export function SwitchField({ + id, + label, + hint, + checked, + onCheckedChange, +}: { + id: string + label: string + hint?: string + checked: boolean + onCheckedChange: (v: boolean) => void +}) { + return ( +
+ + +
+ ) +} + +/** + * A Password field. Frappe never round-trips the real secret to the client — it + * returns a masked placeholder. So the input starts empty; `isSet` reflects + * whether a value is stored. A blank submit leaves the stored secret untouched + * (the parent only writes password fields whose input is non-empty). + */ +export function SecretField({ + id, + label, + hint, + isSet, + value, + onChange, +}: { + id: string + label: string + hint?: string + isSet: boolean + value: string + onChange: (v: string) => void +}) { + return ( +
+ + {hint &&

{hint}

} + onChange(e.target.value)} + placeholder={isSet ? "•••••••• (leave blank to keep)" : "Not set"} + className="h-8 text-sm" + autoComplete="new-password" + /> +
+ ) +} + +export function PromptField({ + id, + label, + hint, + tokens, + value, + onChange, +}: { + id: string + label: string + hint?: string + tokens: string[] + value: string + onChange: (v: string) => void +}) { + return ( +
+ + {hint &&

{hint}

} +