From cc002f4fd1bd93308506246041a648844e5a3be0 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 20:40:57 +0000 Subject: [PATCH 01/28] docs(tasks): clarify timestamp parsing and dependency reconciliation --- backend/app/api/tasks.py | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index f43522b7..957f7a27 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -144,22 +144,41 @@ async def has_valid_recent_comment( def _parse_since(value: str | None) -> datetime | None: + """Parse an optional ISO-8601 timestamp into a naive UTC `datetime`. + + The API accepts either naive timestamps (treated as UTC) or timezone-aware values. + Returning naive UTC simplifies SQLModel comparisons against stored naive UTC values. + """ + if not value: return None + normalized = value.strip() if not normalized: return None + + # Allow common ISO-8601 `Z` suffix (UTC) even though `datetime.fromisoformat` expects `+00:00`. normalized = normalized.replace("Z", "+00:00") + try: parsed = datetime.fromisoformat(normalized) except ValueError: return None + if parsed.tzinfo is not None: return parsed.astimezone(UTC).replace(tzinfo=None) + + # No tzinfo: interpret as UTC for consistency with other API timestamps. return parsed def _coerce_task_items(items: Sequence[object]) -> list[Task]: + """Validate/convert paginated query results to a concrete `list[Task]`. + + SQLModel pagination helpers return `Sequence[object]`; we validate types early so the + rest of the route logic can assume real `Task` instances. + """ + tasks: list[Task] = [] for item in items: if not isinstance(item, Task): @@ -172,6 +191,15 @@ def _coerce_task_items(items: Sequence[object]) -> list[Task]: def _coerce_task_event_rows( items: Sequence[object], ) -> list[tuple[ActivityEvent, Task | None]]: + """Normalize DB rows into `(ActivityEvent, Task | None)` tuples. + + Depending on the SQLAlchemy/SQLModel execution path, result rows may arrive as: + - real Python tuples, or + - row-like objects supporting `__len__` and `__getitem__`. + + This helper centralizes validation so SSE/event-stream logic can assume a stable shape. + """ + rows: list[tuple[ActivityEvent, Task | None]] = [] for item in items: first: object @@ -208,6 +236,12 @@ async def _lead_was_mentioned( task: Task, lead: Agent, ) -> bool: + """Return `True` if the lead agent is mentioned in any comment on the task. + + This is used to avoid redundant lead pings (especially in auto-created tasks) while still + ensuring escalation happens when explicitly requested. + """ + statement = ( select(ActivityEvent.message) .where(col(ActivityEvent.task_id) == task.id) @@ -224,6 +258,8 @@ async def _lead_was_mentioned( def _lead_created_task(task: Task, lead: Agent) -> bool: + """Return `True` if `task` was auto-created by the lead agent.""" + if not task.auto_created or not task.auto_reason: return False return task.auto_reason == f"lead_agent:{lead.id}" @@ -237,6 +273,13 @@ async def _reconcile_dependents_for_dependency_toggle( previous_status: str, actor_agent_id: UUID | None, ) -> None: + """Apply dependency side-effects when a dependency task toggles done/undone. + + The UI models dependencies as a DAG: when a dependency is reopened, dependents that were + previously marked done may need to be reopened or flagged. This helper keeps dependent state + consistent with the dependency graph without duplicating logic across endpoints. + """ + done_toggled = (previous_status == "done") != (dependency_task.status == "done") if not done_toggled: return From b7ad378655cdd2c38071134fd72f60ddeb36c4bb Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 20:43:32 +0000 Subject: [PATCH 02/28] docs(provisioning): document workspace paths and config patching --- backend/app/services/openclaw/provisioning.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/backend/app/services/openclaw/provisioning.py b/backend/app/services/openclaw/provisioning.py index 96becad5..16b134d8 100644 --- a/backend/app/services/openclaw/provisioning.py +++ b/backend/app/services/openclaw/provisioning.py @@ -89,27 +89,47 @@ def _heartbeat_config(agent: Agent) -> dict[str, Any]: def _channel_heartbeat_visibility_patch(config_data: dict[str, Any]) -> dict[str, Any] | None: + """Build a minimal patch ensuring channel default heartbeat visibility is configured. + + Gateways may have existing channel config; we only want to fill missing keys rather than + overwrite operator intent. + + Returns: + - `None` if no change is needed + - a shallow patch dict suitable for a config merge otherwise + """ + channels = config_data.get("channels") if not isinstance(channels, dict): return {"defaults": {"heartbeat": DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY.copy()}} + defaults = channels.get("defaults") if not isinstance(defaults, dict): return {"defaults": {"heartbeat": DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY.copy()}} + heartbeat = defaults.get("heartbeat") if not isinstance(heartbeat, dict): return {"defaults": {"heartbeat": DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY.copy()}} + merged = dict(heartbeat) changed = False for key, value in DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY.items(): if key not in merged: merged[key] = value changed = True + if not changed: return None + return {"defaults": {"heartbeat": merged}} def _template_env() -> Environment: + """Create the Jinja environment used for gateway template rendering. + + Note: we intentionally disable auto-escaping so markdown/plaintext templates render verbatim. + """ + return Environment( loader=FileSystemLoader(_templates_root()), # Render markdown verbatim (HTML escaping makes it harder for agents to read). @@ -124,19 +144,34 @@ def _heartbeat_template_name(agent: Agent) -> str: def _workspace_path(agent: Agent, workspace_root: str) -> str: + """Return the absolute on-disk workspace directory for an agent. + + Why this exists: + - We derive the folder name from a stable *agent key* (ultimately rooted in ids/session keys) + rather than display names to avoid collisions. + - We preserve a historical gateway-main naming quirk to avoid moving existing directories. + + This path is later interpolated into template files (TOOLS.md, etc.) that agents treat as the + source of truth for where to read/write. + """ + if not workspace_root: msg = "gateway_workspace_root is required" raise ValueError(msg) + root = workspace_root.rstrip("/") + # Use agent key derived from session key when possible. This prevents collisions for # lead agents (session key includes board id) even if multiple boards share the same # display name (e.g. "Lead Agent"). key = _agent_key(agent) + # Backwards-compat: gateway-main agents historically used session keys that encoded # "gateway-" while the gateway agent id is "mc-gateway-". # Keep the on-disk workspace path stable so existing provisioned files aren't moved. if key.startswith("mc-gateway-"): key = key.removeprefix("mc-") + return f"{root}/workspace-{slugify(key)}" From bf7b992cca9f3239586439094eb1e2eea295a7f5 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 21:45:53 +0000 Subject: [PATCH 03/28] docs(auth): clarify bearer parsing and Clerk claim extraction --- backend/app/core/auth.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 256e2bd8..7007b876 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -66,6 +66,13 @@ class AuthContext: def _extract_bearer_token(authorization: str | None) -> str | None: + """Extract the bearer token from an `Authorization` header. + + Returns `None` for missing/empty headers or non-bearer schemes. + + Note: we do *not* validate the token here; this helper is only responsible for parsing. + """ + if not authorization: return None value = authorization.strip() @@ -92,6 +99,14 @@ def _normalize_email(value: object) -> str | None: def _extract_claim_email(claims: dict[str, object]) -> str | None: + """Best-effort extraction of an email address from Clerk/JWT-like claims. + + Clerk payloads vary depending on token type and SDK version. We try common flat keys first, + then fall back to an `email_addresses` list (either strings or dict-like entries). + + Returns a normalized lowercase email or `None`. + """ + for key in ("email", "email_address", "primary_email_address"): email = _normalize_email(claims.get(key)) if email: @@ -119,10 +134,13 @@ def _extract_claim_email(claims: dict[str, object]) -> str | None: return candidate if fallback_email is None: fallback_email = candidate + return fallback_email def _extract_claim_name(claims: dict[str, object]) -> str | None: + """Best-effort extraction of a display name from Clerk/JWT-like claims.""" + for key in ("name", "full_name"): text = _non_empty_str(claims.get(key)) if text: @@ -137,6 +155,17 @@ def _extract_claim_name(claims: dict[str, object]) -> str | None: def _extract_clerk_profile(profile: ClerkUser | None) -> tuple[str | None, str | None]: + """Extract `(email, name)` from a Clerk user profile. + + The Clerk SDK surface is not perfectly consistent across environments: + - some fields may be absent, + - email addresses may be represented as strings or objects, + - the "primary" email may be identified by id. + + This helper implements a defensive, best-effort extraction strategy and returns `(None, None)` + when the profile is unavailable. + """ + if profile is None: return None, None From ea7a5df1ae93b625118aa65944a989f1e2ff8e94 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 20:47:06 +0000 Subject: [PATCH 04/28] test(e2e): add boards list + board task flows (stubbed) --- frontend/cypress/e2e/board_tasks.cy.ts | 140 +++++++++++++++++++++++++ frontend/cypress/e2e/boards_list.cy.ts | 68 ++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 frontend/cypress/e2e/board_tasks.cy.ts create mode 100644 frontend/cypress/e2e/boards_list.cy.ts diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts new file mode 100644 index 00000000..937682f8 --- /dev/null +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -0,0 +1,140 @@ +/// + +describe("/boards/:id task board", () => { + const apiBase = "**/api/v1"; + const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + + const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); + + beforeEach(() => { + Cypress.config("defaultCommandTimeout", 20_000); + }); + + afterEach(() => { + Cypress.config("defaultCommandTimeout", originalDefaultCommandTimeout); + }); + + function stubEmptySse() { + // Any SSE endpoint should not hang the UI in tests. + cy.intercept("GET", `${apiBase}/**/stream*`, { + statusCode: 200, + headers: { "content-type": "text/event-stream" }, + body: "", + }); + } + + it("auth negative: signed-out user is redirected to sign-in", () => { + cy.visit("/boards/b1"); + cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); + }); + + it("happy path: renders tasks from snapshot and can create a task (stubbed)", () => { + stubEmptySse(); + + cy.intercept("GET", `${apiBase}/boards/b1/snapshot*`, { + statusCode: 200, + body: { + board: { + id: "b1", + name: "Demo Board", + slug: "demo-board", + description: "Demo", + gateway_id: "g1", + board_group_id: null, + board_type: "general", + objective: null, + success_metrics: null, + target_date: null, + goal_confirmed: true, + goal_source: "test", + organization_id: "o1", + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:00Z", + }, + tasks: [ + { + id: "t1", + board_id: "b1", + title: "Inbox task", + description: "", + status: "inbox", + priority: "medium", + due_at: null, + assigned_agent_id: null, + depends_on_task_ids: [], + created_by_user_id: null, + in_progress_at: null, + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:00Z", + blocked_by_task_ids: [], + is_blocked: false, + assignee: null, + approvals_count: 0, + approvals_pending_count: 0, + }, + ], + agents: [], + approvals: [], + chat_messages: [], + pending_approvals_count: 0, + }, + }).as("snapshot"); + + cy.intercept("GET", `${apiBase}/boards/b1/group-snapshot*`, { + statusCode: 200, + body: { group: null, boards: [] }, + }); + + cy.intercept("POST", `${apiBase}/boards/b1/tasks`, (req) => { + // Minimal assertion the UI sends expected fields. + expect(req.body).to.have.property("title"); + req.reply({ + statusCode: 200, + body: { + id: "t2", + board_id: "b1", + title: req.body.title, + description: req.body.description ?? "", + status: "inbox", + priority: req.body.priority ?? "medium", + due_at: null, + assigned_agent_id: null, + depends_on_task_ids: [], + created_by_user_id: null, + in_progress_at: null, + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:00Z", + blocked_by_task_ids: [], + is_blocked: false, + assignee: null, + approvals_count: 0, + approvals_pending_count: 0, + }, + }); + }).as("createTask"); + + cy.visit("/sign-in"); + cy.clerkLoaded(); + cy.clerkSignIn({ strategy: "email_code", identifier: email }); + + cy.visit("/boards/b1"); + cy.waitForAppLoaded(); + + cy.wait(["@snapshot"]); + + // Existing task visible. + cy.contains("Inbox task").should("be.visible"); + + // Open create task flow. + cy.contains("button", /create task|new task|add task|\+/i) + .first() + .click({ force: true }); + + cy.get("input").filter('[placeholder*="Title"], [name*="title"], [id*="title"], input[type="text"]').first().type("New task"); + + cy.contains("button", /create|save|add/i).click({ force: true }); + cy.wait(["@createTask"]); + + cy.contains("New task").should("be.visible"); + }); +}); diff --git a/frontend/cypress/e2e/boards_list.cy.ts b/frontend/cypress/e2e/boards_list.cy.ts new file mode 100644 index 00000000..e8a01387 --- /dev/null +++ b/frontend/cypress/e2e/boards_list.cy.ts @@ -0,0 +1,68 @@ +/// + +describe("/boards", () => { + const apiBase = "**/api/v1"; + const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + + const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); + + beforeEach(() => { + Cypress.config("defaultCommandTimeout", 20_000); + }); + + afterEach(() => { + Cypress.config("defaultCommandTimeout", originalDefaultCommandTimeout); + }); + + it("auth negative: signed-out user is redirected to sign-in", () => { + cy.visit("/boards"); + cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); + }); + + it("happy path: signed-in user sees boards list", () => { + cy.intercept("GET", `${apiBase}/boards*`, { + statusCode: 200, + body: { + items: [ + { + id: "b1", + name: "Demo Board", + slug: "demo-board", + description: "Demo", + gateway_id: "g1", + board_group_id: null, + board_type: "general", + objective: null, + success_metrics: null, + target_date: null, + goal_confirmed: true, + goal_source: "test", + organization_id: "o1", + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:00Z", + }, + ], + total: 1, + limit: 200, + offset: 0, + }, + }).as("boards"); + + cy.intercept("GET", `${apiBase}/board-groups*`, { + statusCode: 200, + body: { items: [], total: 0, limit: 200, offset: 0 }, + }).as("boardGroups"); + + cy.visit("/sign-in"); + cy.clerkLoaded(); + cy.clerkSignIn({ strategy: "email_code", identifier: email }); + + cy.visit("/boards"); + cy.waitForAppLoaded(); + + cy.wait(["@boards", "@boardGroups"]); + + cy.contains(/boards/i).should("be.visible"); + cy.contains("Demo Board").should("be.visible"); + }); +}); From 584dac18552cedef1d9d50b5f5348c24387bb498 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 23:42:26 +0000 Subject: [PATCH 05/28] test(e2e): cover task status update + delete (stubbed) --- frontend/cypress/e2e/board_tasks.cy.ts | 70 ++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index 937682f8..aedc6f10 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -2,7 +2,8 @@ describe("/boards/:id task board", () => { const apiBase = "**/api/v1"; - const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + const email = + Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); @@ -28,7 +29,7 @@ describe("/boards/:id task board", () => { cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); }); - it("happy path: renders tasks from snapshot and can create a task (stubbed)", () => { + it("happy path: renders tasks from snapshot and supports create + status update + delete (stubbed)", () => { stubEmptySse(); cy.intercept("GET", `${apiBase}/boards/b1/snapshot*`, { @@ -113,6 +114,38 @@ describe("/boards/:id task board", () => { }); }).as("createTask"); + cy.intercept("PATCH", `${apiBase}/boards/b1/tasks/t1`, (req) => { + expect(req.body).to.have.property("status"); + req.reply({ + statusCode: 200, + body: { + id: "t1", + board_id: "b1", + title: "Inbox task", + description: "", + status: req.body.status, + priority: "medium", + due_at: null, + assigned_agent_id: null, + depends_on_task_ids: [], + created_by_user_id: null, + in_progress_at: null, + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:01Z", + blocked_by_task_ids: [], + is_blocked: false, + assignee: null, + approvals_count: 0, + approvals_pending_count: 0, + }, + }); + }).as("updateTask"); + + cy.intercept("DELETE", `${apiBase}/boards/b1/tasks/t1`, { + statusCode: 200, + body: { ok: true }, + }).as("deleteTask"); + cy.visit("/sign-in"); cy.clerkLoaded(); cy.clerkSignIn({ strategy: "email_code", identifier: email }); @@ -130,11 +163,42 @@ describe("/boards/:id task board", () => { .first() .click({ force: true }); - cy.get("input").filter('[placeholder*="Title"], [name*="title"], [id*="title"], input[type="text"]').first().type("New task"); + cy.get("input") + .filter( + '[placeholder*="Title"], [name*="title"], [id*="title"], input[type="text"]', + ) + .first() + .type("New task"); cy.contains("button", /create|save|add/i).click({ force: true }); cy.wait(["@createTask"]); cy.contains("New task").should("be.visible"); + + // Open edit task dialog. + cy.contains("Inbox task").click({ force: true }); + cy.contains("Edit task").should("be.visible"); + + // Change status via Status select. + cy.contains("label", "Status") + .parent() + .within(() => { + cy.get("button").first().click({ force: true }); + }); + + cy.contains("In progress").click({ force: true }); + + cy.contains("button", /save changes/i).click({ force: true }); + cy.wait(["@updateTask"]); + + // Delete task via delete dialog. + cy.contains("button", /^Delete task$/).click({ force: true }); + cy.get('[aria-label="Delete task"]').should("be.visible"); + cy.get('[aria-label="Delete task"]').within(() => { + cy.contains("button", /^Delete task$/).click({ force: true }); + }); + cy.wait(["@deleteTask"]); + + cy.contains("Inbox task").should("not.exist"); }); }); From 4a6f77eed36ed81459fc0efda99a9adf3f0e2aeb Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 12 Feb 2026 09:10:42 +0000 Subject: [PATCH 06/28] test(e2e): select New task button by aria-label --- frontend/cypress/e2e/board_tasks.cy.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index aedc6f10..464dce21 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -159,9 +159,8 @@ describe("/boards/:id task board", () => { cy.contains("Inbox task").should("be.visible"); // Open create task flow. - cy.contains("button", /create task|new task|add task|\+/i) - .first() - .click({ force: true }); + // Board page uses an icon-only button with aria-label="New task". + cy.get('button[aria-label="New task"]').click({ force: true }); cy.get("input") .filter( From 93161d3800302e2c2ce245286f67affc207825b2 Mon Sep 17 00:00:00 2001 From: Simone Date: Sun, 22 Feb 2026 22:20:53 +0100 Subject: [PATCH 07/28] fix: add Redis healthcheck and security response headers - Add healthcheck to Redis service in compose.yml and upgrade depends_on from service_started to service_healthy so backend and webhook-worker wait for Redis readiness. - Add HTTP security headers middleware (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy) to the FastAPI backend. Co-Authored-By: Claude Opus 4.6 --- backend/app/main.py | 13 ++++++++++++- compose.yml | 9 +++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 0499eddb..d922241a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from __future__ import annotations from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any -from fastapi import APIRouter, FastAPI, status +from fastapi import APIRouter, FastAPI, Request, Response, status from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi from fastapi_pagination import add_pagination @@ -467,6 +467,17 @@ else: install_error_handling(app) +@app.middleware("http") +async def security_headers(request: Request, call_next: Any) -> Response: + """Inject standard security headers into every response.""" + response: Response = await call_next(request) + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + return response + + @app.get( "/health", tags=["health"], diff --git a/compose.yml b/compose.yml index 85930802..ddde29eb 100644 --- a/compose.yml +++ b/compose.yml @@ -21,6 +21,11 @@ services: image: redis:7-alpine ports: - "${REDIS_PORT:-6379}:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 backend: build: @@ -42,7 +47,7 @@ services: db: condition: service_healthy redis: - condition: service_started + condition: service_healthy ports: - "${BACKEND_PORT:-8000}:8000" @@ -75,7 +80,7 @@ services: - ./backend/.env.example depends_on: redis: - condition: service_started + condition: service_healthy db: condition: service_healthy environment: From 3fd5fe5f8ca3b85f59935b1a8e7a38e8209ca7bc Mon Sep 17 00:00:00 2001 From: Simone Date: Tue, 24 Feb 2026 17:36:44 +0100 Subject: [PATCH 08/28] fix: make security headers configurable and add tests --- backend/.env.example | 5 + backend/app/core/config.py | 5 + backend/app/core/security_headers.py | 81 ++++++++++++ backend/app/main.py | 21 ++-- .../tests/test_security_headers_middleware.py | 119 ++++++++++++++++++ 5 files changed, 219 insertions(+), 12 deletions(-) create mode 100644 backend/app/core/security_headers.py create mode 100644 backend/tests/test_security_headers_middleware.py diff --git a/backend/.env.example b/backend/.env.example index dd3f52c6..098f36b1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,6 +7,11 @@ REQUEST_LOG_INCLUDE_HEALTH=false DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_control CORS_ORIGINS=http://localhost:3000 BASE_URL= +# Security response headers (blank values disable each header). +SECURITY_HEADER_X_CONTENT_TYPE_OPTIONS= +SECURITY_HEADER_X_FRAME_OPTIONS= +SECURITY_HEADER_REFERRER_POLICY= +SECURITY_HEADER_PERMISSIONS_POLICY= # Auth mode: clerk or local. AUTH_MODE=local diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a6df6a01..67822b57 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -49,6 +49,11 @@ class Settings(BaseSettings): cors_origins: str = "" base_url: str = "" + # Security response headers (blank disables header injection) + security_header_x_content_type_options: str = "" + security_header_x_frame_options: str = "" + security_header_referrer_policy: str = "" + security_header_permissions_policy: str = "" # Database lifecycle db_auto_migrate: bool = False diff --git a/backend/app/core/security_headers.py b/backend/app/core/security_headers.py new file mode 100644 index 00000000..3aa3a78e --- /dev/null +++ b/backend/app/core/security_headers.py @@ -0,0 +1,81 @@ +"""ASGI middleware for configurable security response headers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from starlette.types import ASGIApp, Message, Receive, Scope, Send + + +class SecurityHeadersMiddleware: + """Inject configured security headers into every HTTP response.""" + + _X_CONTENT_TYPE_OPTIONS = b"X-Content-Type-Options" + _X_FRAME_OPTIONS = b"X-Frame-Options" + _REFERRER_POLICY = b"Referrer-Policy" + _PERMISSIONS_POLICY = b"Permissions-Policy" + + def __init__( + self, + app: ASGIApp, + *, + x_content_type_options: str = "", + x_frame_options: str = "", + referrer_policy: str = "", + permissions_policy: str = "", + ) -> None: + self._app = app + self._configured_headers = self._build_configured_headers( + x_content_type_options=x_content_type_options, + x_frame_options=x_frame_options, + referrer_policy=referrer_policy, + permissions_policy=permissions_policy, + ) + + @classmethod + def _build_configured_headers( + cls, + *, + x_content_type_options: str, + x_frame_options: str, + referrer_policy: str, + permissions_policy: str, + ) -> tuple[tuple[bytes, bytes, bytes], ...]: + configured: list[tuple[bytes, bytes, bytes]] = [] + for header_name, value in ( + (cls._X_CONTENT_TYPE_OPTIONS, x_content_type_options), + (cls._X_FRAME_OPTIONS, x_frame_options), + (cls._REFERRER_POLICY, referrer_policy), + (cls._PERMISSIONS_POLICY, permissions_policy), + ): + normalized = value.strip() + if not normalized: + continue + configured.append( + ( + header_name.lower(), + header_name, + normalized.encode("latin-1"), + ) + ) + return tuple(configured) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Append configured security headers unless already present.""" + if scope["type"] != "http" or not self._configured_headers: + await self._app(scope, receive, send) + return + + async def send_with_security_headers(message: Message) -> None: + if message["type"] == "http.response.start": + # Starlette uses `list[tuple[bytes, bytes]]` for raw headers. + headers: list[tuple[bytes, bytes]] = message.setdefault("headers", []) + existing = {key.lower() for key, _ in headers} + for key_lower, key, value in self._configured_headers: + if key_lower not in existing: + headers.append((key, value)) + existing.add(key_lower) + await send(message) + + await self._app(scope, receive, send_with_security_headers) diff --git a/backend/app/main.py b/backend/app/main.py index d922241a..4761c24f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from __future__ import annotations from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any -from fastapi import APIRouter, FastAPI, Request, Response, status +from fastapi import APIRouter, FastAPI, status from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi from fastapi_pagination import add_pagination @@ -34,6 +34,7 @@ from app.api.users import router as users_router from app.core.config import settings from app.core.error_handling import install_error_handling from app.core.logging import configure_logging, get_logger +from app.core.security_headers import SecurityHeadersMiddleware from app.db.session import init_db from app.schemas.health import HealthStatusResponse @@ -464,20 +465,16 @@ if origins: else: logger.info("app.cors.disabled") +app.add_middleware( + SecurityHeadersMiddleware, + x_content_type_options=settings.security_header_x_content_type_options, + x_frame_options=settings.security_header_x_frame_options, + referrer_policy=settings.security_header_referrer_policy, + permissions_policy=settings.security_header_permissions_policy, +) install_error_handling(app) -@app.middleware("http") -async def security_headers(request: Request, call_next: Any) -> Response: - """Inject standard security headers into every response.""" - response: Response = await call_next(request) - response.headers.setdefault("X-Content-Type-Options", "nosniff") - response.headers.setdefault("X-Frame-Options", "DENY") - response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") - response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()") - return response - - @app.get( "/health", tags=["health"], diff --git a/backend/tests/test_security_headers_middleware.py b/backend/tests/test_security_headers_middleware.py new file mode 100644 index 00000000..978b6c19 --- /dev/null +++ b/backend/tests/test_security_headers_middleware.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import pytest +from fastapi import FastAPI, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient + +from app.core.security_headers import SecurityHeadersMiddleware + + +@pytest.mark.asyncio +async def test_security_headers_middleware_passes_through_non_http_scope() -> None: + called = False + + async def app(scope, receive, send): # type: ignore[no-untyped-def] + _ = receive + _ = send + nonlocal called + called = scope["type"] == "websocket" + + middleware = SecurityHeadersMiddleware(app, x_frame_options="SAMEORIGIN") + await middleware({"type": "websocket", "headers": []}, lambda: None, lambda _: None) + + assert called is True + + +def test_security_headers_middleware_injects_configured_headers() -> None: + app = FastAPI() + app.add_middleware( + SecurityHeadersMiddleware, + x_content_type_options="nosniff", + x_frame_options="SAMEORIGIN", + referrer_policy="strict-origin-when-cross-origin", + permissions_policy="camera=(), microphone=(), geolocation=()", + ) + + @app.get("/ok") + def ok() -> dict[str, bool]: + return {"ok": True} + + response = TestClient(app).get("/ok") + + assert response.status_code == 200 + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["x-frame-options"] == "SAMEORIGIN" + assert response.headers["referrer-policy"] == "strict-origin-when-cross-origin" + assert response.headers["permissions-policy"] == "camera=(), microphone=(), geolocation=()" + + +def test_security_headers_middleware_does_not_override_existing_values() -> None: + app = FastAPI() + app.add_middleware( + SecurityHeadersMiddleware, + x_content_type_options="nosniff", + x_frame_options="SAMEORIGIN", + referrer_policy="strict-origin-when-cross-origin", + permissions_policy="camera=(), microphone=(), geolocation=()", + ) + + @app.get("/already-set") + def already_set(response: Response) -> dict[str, bool]: + response.headers["X-Frame-Options"] = "ALLOWALL" + response.headers["Referrer-Policy"] = "unsafe-url" + return {"ok": True} + + response = TestClient(app).get("/already-set") + + assert response.status_code == 200 + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["x-frame-options"] == "ALLOWALL" + assert response.headers["referrer-policy"] == "unsafe-url" + assert response.headers["permissions-policy"] == "camera=(), microphone=(), geolocation=()" + + +def test_security_headers_middleware_includes_headers_on_cors_preflight() -> None: + app = FastAPI() + app.add_middleware( + CORSMiddleware, + allow_origins=["https://example.com"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + app.add_middleware( + SecurityHeadersMiddleware, + x_content_type_options="nosniff", + ) + + @app.get("/ok") + def ok() -> dict[str, bool]: + return {"ok": True} + + response = TestClient(app).options( + "/ok", + headers={ + "Origin": "https://example.com", + "Access-Control-Request-Method": "GET", + }, + ) + + assert response.status_code == 200 + assert response.headers["x-content-type-options"] == "nosniff" + + +def test_security_headers_middleware_skips_blank_config_values() -> None: + app = FastAPI() + app.add_middleware(SecurityHeadersMiddleware) + + @app.get("/ok") + def ok() -> dict[str, bool]: + return {"ok": True} + + response = TestClient(app).get("/ok") + + assert response.status_code == 200 + assert response.headers.get("x-content-type-options") is None + assert response.headers.get("x-frame-options") is None + assert response.headers.get("referrer-policy") is None + assert response.headers.get("permissions-policy") is None From 0795f78eff5bc82f8708c61116b361187fa18c56 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 00:34:04 +0530 Subject: [PATCH 09/28] feat: implement unified agent lifecycle orchestration and metadata tracking --- backend/app/models/agents.py | 5 + .../app/services/openclaw/admin_service.py | 71 ++----- backend/app/services/openclaw/constants.py | 5 + .../openclaw/lifecycle_orchestrator.py | 167 +++++++++++++++ .../app/services/openclaw/lifecycle_queue.py | 122 +++++++++++ .../services/openclaw/lifecycle_reconcile.py | 140 +++++++++++++ .../app/services/openclaw/provisioning_db.py | 193 +++++++++--------- backend/app/services/queue.py | 26 +++ backend/app/services/queue_worker.py | 17 +- ...f6_add_agent_lifecycle_metadata_columns.py | 45 ++++ backend/templates/BOARD_BOOTSTRAP.md.j2 | 11 +- backend/templates/BOARD_HEARTBEAT.md.j2 | 11 +- .../tests/test_lifecycle_reconcile_queue.py | 126 ++++++++++++ .../tests/test_lifecycle_reconcile_state.py | 53 +++++ .../test_queue_worker_lifecycle_handler.py | 11 + docs/README.md | 1 + docs/troubleshooting/README.md | 2 +- .../gateway-agent-provisioning.md | 107 ++++++++++ 18 files changed, 957 insertions(+), 156 deletions(-) create mode 100644 backend/app/services/openclaw/lifecycle_orchestrator.py create mode 100644 backend/app/services/openclaw/lifecycle_queue.py create mode 100644 backend/app/services/openclaw/lifecycle_reconcile.py create mode 100644 backend/migrations/versions/e3a1b2c4d5f6_add_agent_lifecycle_metadata_columns.py create mode 100644 backend/tests/test_lifecycle_reconcile_queue.py create mode 100644 backend/tests/test_lifecycle_reconcile_state.py create mode 100644 backend/tests/test_queue_worker_lifecycle_handler.py create mode 100644 docs/troubleshooting/gateway-agent-provisioning.md diff --git a/backend/app/models/agents.py b/backend/app/models/agents.py index 1648e98f..9c97e970 100644 --- a/backend/app/models/agents.py +++ b/backend/app/models/agents.py @@ -43,6 +43,11 @@ class Agent(QueryModel, table=True): delete_requested_at: datetime | None = Field(default=None) delete_confirm_token_hash: str | None = Field(default=None, index=True) last_seen_at: datetime | None = Field(default=None) + lifecycle_generation: int = Field(default=0) + wake_attempts: int = Field(default=0) + last_wake_sent_at: datetime | None = Field(default=None) + checkin_deadline_at: datetime | None = Field(default=None) + last_provision_error: str | None = Field(default=None, sa_column=Column(Text)) is_board_lead: bool = Field(default=False, index=True) created_at: datetime = Field(default_factory=utcnow) updated_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/services/openclaw/admin_service.py b/backend/app/services/openclaw/admin_service.py index ee7235f5..5a58e18f 100644 --- a/backend/app/services/openclaw/admin_service.py +++ b/backend/app/services/openclaw/admin_service.py @@ -21,24 +21,18 @@ from app.models.gateways import Gateway from app.models.tasks import Task from app.schemas.gateways import GatewayTemplatesSyncResult from app.services.openclaw.constants import DEFAULT_HEARTBEAT_CONFIG -from app.services.openclaw.db_agent_state import ( - mark_provision_complete, - mark_provision_requested, - mint_agent_token, -) from app.services.openclaw.db_service import OpenClawDBService from app.services.openclaw.error_messages import normalize_gateway_error_message from app.services.openclaw.gateway_compat import check_gateway_version_compatibility from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call -from app.services.openclaw.provisioning import OpenClawGatewayProvisioner +from app.services.openclaw.lifecycle_orchestrator import AgentLifecycleOrchestrator from app.services.openclaw.provisioning_db import ( GatewayTemplateSyncOptions, OpenClawProvisioningService, ) from app.services.openclaw.session_service import GatewayTemplateSyncQuery from app.services.openclaw.shared import GatewayAgentIdentity -from app.services.organizations import get_org_owner_user if TYPE_CHECKING: from sqlmodel.ext.asyncio.session import AsyncSession @@ -222,69 +216,38 @@ class GatewayAdminLifecycleService(OpenClawDBService): action: str, notify: bool, ) -> Agent: - template_user = user or await get_org_owner_user( - self.session, - organization_id=gateway.organization_id, - ) - if template_user is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail="Organization owner not found (required for gateway agent USER.md rendering).", - ) - raw_token = mint_agent_token(agent) - mark_provision_requested( - agent, - action=action, - status="updating" if action == "update" else "provisioning", - ) - await self.add_commit_refresh(agent) - if not gateway.url: - return agent - + orchestrator = AgentLifecycleOrchestrator(self.session) try: - await OpenClawGatewayProvisioner().apply_agent_lifecycle( - agent=agent, + provisioned = await orchestrator.run_lifecycle( gateway=gateway, + agent_id=agent.id, board=None, - auth_token=raw_token, - user=template_user, + user=user, action=action, + auth_token=None, + force_bootstrap=False, + reset_session=False, wake=notify, deliver_wakeup=True, + wakeup_verb=None, + clear_confirm_token=False, + raise_gateway_errors=True, ) - except OpenClawGatewayError as exc: + except HTTPException: self.logger.error( - "gateway.main_agent.provision_failed_gateway gateway_id=%s agent_id=%s error=%s", + "gateway.main_agent.provision_failed gateway_id=%s agent_id=%s action=%s", gateway.id, agent.id, - str(exc), + action, ) - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"Gateway {action} failed: {exc}", - ) from exc - except (OSError, RuntimeError, ValueError) as exc: - self.logger.error( - "gateway.main_agent.provision_failed gateway_id=%s agent_id=%s error=%s", - gateway.id, - agent.id, - str(exc), - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Unexpected error {action}ing gateway provisioning.", - ) from exc - - mark_provision_complete(agent, status="online") - await self.add_commit_refresh(agent) - + raise self.logger.info( "gateway.main_agent.provision_success gateway_id=%s agent_id=%s action=%s", gateway.id, - agent.id, + provisioned.id, action, ) - return agent + return provisioned async def ensure_main_agent( self, diff --git a/backend/app/services/openclaw/constants.py b/backend/app/services/openclaw/constants.py index e7c3e433..48816537 100644 --- a/backend/app/services/openclaw/constants.py +++ b/backend/app/services/openclaw/constants.py @@ -18,6 +18,11 @@ DEFAULT_HEARTBEAT_CONFIG: dict[str, Any] = { } OFFLINE_AFTER = timedelta(minutes=10) +# Provisioning convergence policy: +# - require first heartbeat/check-in within 30s of wake +# - allow up to 3 wake attempts before giving up +CHECKIN_DEADLINE_AFTER_WAKE = timedelta(seconds=30) +MAX_WAKE_ATTEMPTS_WITHOUT_CHECKIN = 3 AGENT_SESSION_PREFIX = "agent" DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY: dict[str, bool] = { diff --git a/backend/app/services/openclaw/lifecycle_orchestrator.py b/backend/app/services/openclaw/lifecycle_orchestrator.py new file mode 100644 index 00000000..64bc9466 --- /dev/null +++ b/backend/app/services/openclaw/lifecycle_orchestrator.py @@ -0,0 +1,167 @@ +"""Unified agent lifecycle orchestration. + +This module centralizes DB-backed lifecycle transitions so call sites do not +duplicate provisioning/wake/state logic. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from fastapi import HTTPException, status +from sqlmodel import col, select + +from app.core.time import utcnow +from app.models.agents import Agent +from app.models.boards import Board +from app.models.gateways import Gateway +from app.services.openclaw.constants import CHECKIN_DEADLINE_AFTER_WAKE +from app.services.openclaw.db_agent_state import ( + mark_provision_complete, + mark_provision_requested, + mint_agent_token, +) +from app.services.openclaw.db_service import OpenClawDBService +from app.services.openclaw.gateway_rpc import OpenClawGatewayError +from app.services.openclaw.lifecycle_queue import ( + QueuedAgentLifecycleReconcile, + enqueue_lifecycle_reconcile, +) +from app.services.openclaw.provisioning import OpenClawGatewayProvisioner +from app.services.organizations import get_org_owner_user + +if TYPE_CHECKING: + from sqlmodel.ext.asyncio.session import AsyncSession + + from app.models.users import User + + +class AgentLifecycleOrchestrator(OpenClawDBService): + """Single lifecycle writer for agent provision/update transitions.""" + + def __init__(self, session: AsyncSession) -> None: + super().__init__(session) + + async def _lock_agent(self, *, agent_id: UUID) -> Agent: + statement = select(Agent).where(col(Agent.id) == agent_id).with_for_update() + agent = (await self.session.exec(statement)).first() + if agent is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found") + return agent + + async def run_lifecycle( + self, + *, + gateway: Gateway, + agent_id: UUID, + board: Board | None, + user: User | None, + action: str, + auth_token: str | None = None, + force_bootstrap: bool = False, + reset_session: bool = False, + wake: bool = True, + deliver_wakeup: bool = True, + wakeup_verb: str | None = None, + clear_confirm_token: bool = False, + raise_gateway_errors: bool = True, + ) -> Agent: + """Provision or update any agent under a per-agent lock.""" + + locked = await self._lock_agent(agent_id=agent_id) + template_user = user + if board is None and template_user is None: + template_user = await get_org_owner_user( + self.session, + organization_id=gateway.organization_id, + ) + if template_user is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=( + "Organization owner not found " + "(required for gateway agent USER.md rendering)." + ), + ) + + raw_token = auth_token or mint_agent_token(locked) + mark_provision_requested( + locked, + action=action, + status="updating" if action == "update" else "provisioning", + ) + locked.lifecycle_generation += 1 + locked.last_provision_error = None + locked.checkin_deadline_at = utcnow() + CHECKIN_DEADLINE_AFTER_WAKE if wake else None + if wake: + locked.wake_attempts += 1 + locked.last_wake_sent_at = utcnow() + self.session.add(locked) + await self.session.flush() + + if not gateway.url: + await self.session.commit() + await self.session.refresh(locked) + return locked + + try: + await OpenClawGatewayProvisioner().apply_agent_lifecycle( + agent=locked, + gateway=gateway, + board=board, + auth_token=raw_token, + user=template_user, + action=action, + force_bootstrap=force_bootstrap, + reset_session=reset_session, + wake=wake, + deliver_wakeup=deliver_wakeup, + wakeup_verb=wakeup_verb, + ) + except OpenClawGatewayError as exc: + locked.last_provision_error = str(exc) + locked.updated_at = utcnow() + self.session.add(locked) + await self.session.commit() + await self.session.refresh(locked) + if raise_gateway_errors: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Gateway {action} failed: {exc}", + ) from exc + return locked + except (OSError, RuntimeError, ValueError) as exc: + locked.last_provision_error = str(exc) + locked.updated_at = utcnow() + self.session.add(locked) + await self.session.commit() + await self.session.refresh(locked) + if raise_gateway_errors: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Unexpected error {action}ing gateway provisioning.", + ) from exc + return locked + + mark_provision_complete( + locked, + status="online", + clear_confirm_token=clear_confirm_token, + ) + locked.last_provision_error = None + locked.checkin_deadline_at = utcnow() + CHECKIN_DEADLINE_AFTER_WAKE if wake else None + self.session.add(locked) + await self.session.commit() + await self.session.refresh(locked) + if wake and locked.checkin_deadline_at is not None: + enqueue_lifecycle_reconcile( + QueuedAgentLifecycleReconcile( + agent_id=locked.id, + gateway_id=locked.gateway_id, + board_id=locked.board_id, + generation=locked.lifecycle_generation, + checkin_deadline_at=locked.checkin_deadline_at, + ) + ) + return locked diff --git a/backend/app/services/openclaw/lifecycle_queue.py b/backend/app/services/openclaw/lifecycle_queue.py new file mode 100644 index 00000000..3a7f2b54 --- /dev/null +++ b/backend/app/services/openclaw/lifecycle_queue.py @@ -0,0 +1,122 @@ +"""Queue payload helpers for stuck-agent lifecycle reconciliation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any +from uuid import UUID + +from app.core.config import settings +from app.core.logging import get_logger +from app.core.time import utcnow +from app.services.queue import QueuedTask, enqueue_task_with_delay +from app.services.queue import requeue_if_failed as generic_requeue_if_failed + +logger = get_logger(__name__) +TASK_TYPE = "agent_lifecycle_reconcile" + + +@dataclass(frozen=True) +class QueuedAgentLifecycleReconcile: + """Queued payload metadata for lifecycle reconciliation checks.""" + + agent_id: UUID + gateway_id: UUID + board_id: UUID | None + generation: int + checkin_deadline_at: datetime + attempts: int = 0 + + +def _task_from_payload(payload: QueuedAgentLifecycleReconcile) -> QueuedTask: + return QueuedTask( + task_type=TASK_TYPE, + payload={ + "agent_id": str(payload.agent_id), + "gateway_id": str(payload.gateway_id), + "board_id": str(payload.board_id) if payload.board_id is not None else None, + "generation": payload.generation, + "checkin_deadline_at": payload.checkin_deadline_at.isoformat(), + }, + created_at=utcnow(), + attempts=payload.attempts, + ) + + +def decode_lifecycle_task(task: QueuedTask) -> QueuedAgentLifecycleReconcile: + if task.task_type not in {TASK_TYPE, "legacy"}: + raise ValueError(f"Unexpected task_type={task.task_type!r}; expected {TASK_TYPE!r}") + payload: dict[str, Any] = task.payload + raw_board_id = payload.get("board_id") + board_id = UUID(raw_board_id) if isinstance(raw_board_id, str) and raw_board_id else None + raw_deadline = payload.get("checkin_deadline_at") + if not isinstance(raw_deadline, str): + raise ValueError("checkin_deadline_at is required") + return QueuedAgentLifecycleReconcile( + agent_id=UUID(str(payload["agent_id"])), + gateway_id=UUID(str(payload["gateway_id"])), + board_id=board_id, + generation=int(payload["generation"]), + checkin_deadline_at=datetime.fromisoformat(raw_deadline), + attempts=int(payload.get("attempts", task.attempts)), + ) + + +def enqueue_lifecycle_reconcile(payload: QueuedAgentLifecycleReconcile) -> bool: + """Enqueue a delayed reconcile check keyed to the expected check-in deadline.""" + now = utcnow() + delay_seconds = max(0.0, (payload.checkin_deadline_at - now).total_seconds()) + queued = _task_from_payload(payload) + ok = enqueue_task_with_delay( + queued, + settings.rq_queue_name, + delay_seconds=delay_seconds, + redis_url=settings.rq_redis_url, + ) + if ok: + logger.info( + "lifecycle.queue.enqueued", + extra={ + "agent_id": str(payload.agent_id), + "generation": payload.generation, + "delay_seconds": delay_seconds, + "attempt": payload.attempts, + }, + ) + return ok + + +def defer_lifecycle_reconcile( + task: QueuedTask, + *, + delay_seconds: float, +) -> bool: + """Defer a reconcile task without incrementing retry attempts.""" + payload = decode_lifecycle_task(task) + deferred = QueuedAgentLifecycleReconcile( + agent_id=payload.agent_id, + gateway_id=payload.gateway_id, + board_id=payload.board_id, + generation=payload.generation, + checkin_deadline_at=payload.checkin_deadline_at, + attempts=task.attempts, + ) + queued = _task_from_payload(deferred) + return enqueue_task_with_delay( + queued, + settings.rq_queue_name, + delay_seconds=max(0.0, delay_seconds), + redis_url=settings.rq_redis_url, + ) + + +def requeue_lifecycle_queue_task(task: QueuedTask, *, delay_seconds: float = 0) -> bool: + """Requeue a failed lifecycle task with capped retries.""" + return generic_requeue_if_failed( + task, + settings.rq_queue_name, + max_retries=settings.rq_dispatch_max_retries, + redis_url=settings.rq_redis_url, + delay_seconds=max(0.0, delay_seconds), + ) diff --git a/backend/app/services/openclaw/lifecycle_reconcile.py b/backend/app/services/openclaw/lifecycle_reconcile.py new file mode 100644 index 00000000..6d1aebeb --- /dev/null +++ b/backend/app/services/openclaw/lifecycle_reconcile.py @@ -0,0 +1,140 @@ +"""Worker handlers for lifecycle reconciliation tasks.""" + +from __future__ import annotations + +import asyncio + +from app.core.logging import get_logger +from app.core.time import utcnow +from app.db.session import async_session_maker +from app.models.agents import Agent +from app.models.boards import Board +from app.models.gateways import Gateway +from app.services.openclaw.constants import MAX_WAKE_ATTEMPTS_WITHOUT_CHECKIN +from app.services.openclaw.lifecycle_orchestrator import AgentLifecycleOrchestrator +from app.services.openclaw.lifecycle_queue import decode_lifecycle_task, defer_lifecycle_reconcile +from app.services.queue import QueuedTask + +logger = get_logger(__name__) +_RECONCILE_TIMEOUT_SECONDS = 60.0 + + +def _has_checked_in_since_wake(agent: Agent) -> bool: + if agent.last_seen_at is None: + return False + if agent.last_wake_sent_at is None: + return True + return agent.last_seen_at >= agent.last_wake_sent_at + + +async def process_lifecycle_queue_task(task: QueuedTask) -> None: + """Re-run lifecycle provisioning when an agent misses post-provision check-in.""" + payload = decode_lifecycle_task(task) + now = utcnow() + + async with async_session_maker() as session: + agent = await Agent.objects.by_id(payload.agent_id).first(session) + if agent is None: + logger.info( + "lifecycle.reconcile.skip_missing_agent", + extra={"agent_id": str(payload.agent_id)}, + ) + return + + # Ignore stale queue messages after a newer lifecycle generation. + if agent.lifecycle_generation != payload.generation: + logger.info( + "lifecycle.reconcile.skip_stale_generation", + extra={ + "agent_id": str(agent.id), + "queued_generation": payload.generation, + "current_generation": agent.lifecycle_generation, + }, + ) + return + + if _has_checked_in_since_wake(agent): + logger.info( + "lifecycle.reconcile.skip_not_stuck", + extra={"agent_id": str(agent.id), "status": agent.status}, + ) + return + + deadline = agent.checkin_deadline_at or payload.checkin_deadline_at + if agent.status == "deleting": + logger.info( + "lifecycle.reconcile.skip_deleting", + extra={"agent_id": str(agent.id)}, + ) + return + + if now < deadline: + delay = max(0.0, (deadline - now).total_seconds()) + if not defer_lifecycle_reconcile(task, delay_seconds=delay): + msg = "Failed to defer lifecycle reconcile task" + raise RuntimeError(msg) + logger.info( + "lifecycle.reconcile.deferred", + extra={"agent_id": str(agent.id), "delay_seconds": delay}, + ) + return + + if agent.wake_attempts >= MAX_WAKE_ATTEMPTS_WITHOUT_CHECKIN: + agent.status = "offline" + agent.checkin_deadline_at = None + agent.last_provision_error = ( + "Agent did not check in after wake; max wake attempts reached" + ) + agent.updated_at = utcnow() + session.add(agent) + await session.commit() + logger.warning( + "lifecycle.reconcile.max_attempts_reached", + extra={ + "agent_id": str(agent.id), + "wake_attempts": agent.wake_attempts, + "max_attempts": MAX_WAKE_ATTEMPTS_WITHOUT_CHECKIN, + }, + ) + return + + gateway = await Gateway.objects.by_id(agent.gateway_id).first(session) + if gateway is None: + logger.warning( + "lifecycle.reconcile.skip_missing_gateway", + extra={"agent_id": str(agent.id), "gateway_id": str(agent.gateway_id)}, + ) + return + board: Board | None = None + if agent.board_id is not None: + board = await Board.objects.by_id(agent.board_id).first(session) + if board is None: + logger.warning( + "lifecycle.reconcile.skip_missing_board", + extra={"agent_id": str(agent.id), "board_id": str(agent.board_id)}, + ) + return + + orchestrator = AgentLifecycleOrchestrator(session) + await asyncio.wait_for( + orchestrator.run_lifecycle( + gateway=gateway, + agent_id=agent.id, + board=board, + user=None, + action="update", + auth_token=None, + force_bootstrap=False, + reset_session=True, + wake=True, + deliver_wakeup=True, + wakeup_verb="updated", + clear_confirm_token=True, + raise_gateway_errors=True, + ), + timeout=_RECONCILE_TIMEOUT_SECONDS, + ) + logger.info( + "lifecycle.reconcile.retriggered", + extra={"agent_id": str(agent.id), "generation": payload.generation}, + ) diff --git a/backend/app/services/openclaw/provisioning_db.py b/backend/app/services/openclaw/provisioning_db.py index 90c50da7..15954a56 100644 --- a/backend/app/services/openclaw/provisioning_db.py +++ b/backend/app/services/openclaw/provisioning_db.py @@ -52,8 +52,6 @@ from app.services.openclaw.constants import ( OFFLINE_AFTER, ) from app.services.openclaw.db_agent_state import ( - mark_provision_complete, - mark_provision_requested, mint_agent_token, ) from app.services.openclaw.db_service import OpenClawDBService @@ -74,6 +72,7 @@ from app.services.openclaw.internal.session_keys import ( board_agent_session_key, board_lead_session_key, ) +from app.services.openclaw.lifecycle_orchestrator import AgentLifecycleOrchestrator from app.services.openclaw.policies import OpenClawAuthorizationPolicy from app.services.openclaw.provisioning import ( OpenClawGatewayControlPlane, @@ -143,7 +142,6 @@ class OpenClawProvisioningService(OpenClawDBService): def __init__(self, session: AsyncSession) -> None: super().__init__(session) - self._gateway = OpenClawGatewayProvisioner() @staticmethod def lead_session_key(board: Board) -> str: @@ -213,25 +211,25 @@ class OpenClawProvisioningService(OpenClawDBService): openclaw_session_id=self.lead_session_key(board), ) raw_token = mint_agent_token(agent) - mark_provision_requested(agent, action=config_options.action, status="provisioning") await self.add_commit_refresh(agent) # Strict behavior: provisioning errors surface to the caller. The DB row exists # so a later retry can succeed with the same deterministic identity/session key. - await self._gateway.apply_agent_lifecycle( - agent=agent, + agent = await AgentLifecycleOrchestrator(self.session).run_lifecycle( gateway=request.gateway, + agent_id=agent.id, board=board, - auth_token=raw_token, user=request.user, action=config_options.action, + auth_token=raw_token, + force_bootstrap=False, + reset_session=False, wake=True, deliver_wakeup=True, + wakeup_verb=None, + clear_confirm_token=False, + raise_gateway_errors=True, ) - - mark_provision_complete(agent, status="online") - await self.add_commit_refresh(agent) - return agent, True async def sync_gateway_templates( @@ -298,7 +296,6 @@ class OpenClawProvisioningService(OpenClawDBService): control_plane=control_plane, backoff=GatewayBackoff(timeout_s=10 * 60, timeout_context="template sync"), options=options, - provisioner=self._gateway, ) if not await _ping_gateway(ctx, result): return result @@ -352,7 +349,6 @@ class _SyncContext: control_plane: OpenClawGatewayControlPlane backoff: GatewayBackoff options: GatewayTemplateSyncOptions - provisioner: OpenClawGatewayProvisioner def _parse_tools_md(content: str) -> dict[str, str]: @@ -584,18 +580,26 @@ async def _sync_one_agent( try: async def _do_provision() -> bool: - await ctx.provisioner.apply_agent_lifecycle( - agent=agent, - gateway=ctx.gateway, - board=board, - auth_token=auth_token, - user=ctx.options.user, - action="update", - force_bootstrap=ctx.options.force_bootstrap, - overwrite=ctx.options.overwrite, - reset_session=ctx.options.reset_sessions, - wake=False, - ) + try: + await AgentLifecycleOrchestrator(ctx.session).run_lifecycle( + gateway=ctx.gateway, + agent_id=agent.id, + board=board, + user=ctx.options.user, + action="update", + auth_token=auth_token, + force_bootstrap=ctx.options.force_bootstrap, + reset_session=ctx.options.reset_sessions, + wake=False, + deliver_wakeup=False, + wakeup_verb="updated", + clear_confirm_token=False, + raise_gateway_errors=True, + ) + except HTTPException as exc: + if exc.status_code == status.HTTP_502_BAD_GATEWAY: + raise OpenClawGatewayError(str(exc.detail)) from exc + raise return True await ctx.backoff.run(_do_provision) @@ -613,6 +617,15 @@ async def _sync_one_agent( message=f"Failed to sync templates: {exc}", ) return False + except HTTPException as exc: + result.agents_skipped += 1 + _append_sync_error( + result, + agent=agent, + board=board, + message=f"Failed to sync templates: {exc.detail}", + ) + return False else: return False @@ -655,18 +668,26 @@ async def _sync_main_agent( try: async def _do_provision_main() -> bool: - await ctx.provisioner.apply_agent_lifecycle( - agent=main_agent, - gateway=ctx.gateway, - board=None, - auth_token=token, - user=ctx.options.user, - action="update", - force_bootstrap=ctx.options.force_bootstrap, - overwrite=ctx.options.overwrite, - reset_session=ctx.options.reset_sessions, - wake=False, - ) + try: + await AgentLifecycleOrchestrator(ctx.session).run_lifecycle( + gateway=ctx.gateway, + agent_id=main_agent.id, + board=None, + user=ctx.options.user, + action="update", + auth_token=token, + force_bootstrap=ctx.options.force_bootstrap, + reset_session=ctx.options.reset_sessions, + wake=False, + deliver_wakeup=False, + wakeup_verb="updated", + clear_confirm_token=False, + raise_gateway_errors=True, + ) + except HTTPException as exc: + if exc.status_code == status.HTTP_502_BAD_GATEWAY: + raise OpenClawGatewayError(str(exc.detail)) from exc + raise return True await ctx.backoff.run(_do_provision_main) @@ -679,6 +700,12 @@ async def _sync_main_agent( agent=main_agent, message=f"Failed to sync gateway agent templates: {exc}", ) + except HTTPException as exc: + _append_sync_error( + result, + agent=main_agent, + message=f"Failed to sync gateway agent templates: {exc.detail}", + ) else: result.main_updated = True return stop_sync @@ -1038,7 +1065,6 @@ class AgentLifecycleService(OpenClawDBService): ) -> tuple[Agent, str]: agent = Agent.model_validate(data) raw_token = mint_agent_token(agent) - mark_provision_requested(agent, action="provision", status="provisioning") agent.openclaw_session_id = self.resolve_session_key(agent) await self.add_commit_refresh(agent) return agent, raw_token @@ -1068,92 +1094,63 @@ class AgentLifecycleService(OpenClawDBService): status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="board is required for non-main agent provisioning", ) - template_user = user - if target.is_main_agent and template_user is None: - template_user = await get_org_owner_user( - self.session, - organization_id=target.gateway.organization_id, - ) - if template_user is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=( - "User context is required to provision the gateway main agent " - "(org owner not found)." - ), - ) - await OpenClawGatewayProvisioner().apply_agent_lifecycle( - agent=agent, + provisioned = await AgentLifecycleOrchestrator(self.session).run_lifecycle( gateway=target.gateway, + agent_id=agent.id, board=target.board if not target.is_main_agent else None, - auth_token=auth_token, - user=template_user, + user=user, action=action, + auth_token=auth_token, force_bootstrap=force_bootstrap, reset_session=True, wake=True, deliver_wakeup=True, wakeup_verb=wakeup_verb, + clear_confirm_token=True, + raise_gateway_errors=raise_gateway_errors, ) - mark_provision_complete(agent, status="online", clear_confirm_token=True) - self.session.add(agent) - await self.session.commit() record_activity( self.session, event_type=f"agent.{action}.direct", - message=f"{action.capitalize()}d directly for {agent.name}.", - agent_id=agent.id, + message=f"{action.capitalize()}d directly for {provisioned.name}.", + agent_id=provisioned.id, ) record_activity( self.session, event_type="agent.wakeup.sent", - message=f"Wakeup message sent to {agent.name}.", - agent_id=agent.id, + message=f"Wakeup message sent to {provisioned.name}.", + agent_id=provisioned.id, ) await self.session.commit() self.logger.info( "agent.provision.success action=%s agent_id=%s", action, - agent.id, + provisioned.id, ) - except OpenClawGatewayError as exc: + except HTTPException as exc: self.record_instruction_failure( self.session, agent, - str(exc), + str(exc.detail), action, ) await self.session.commit() - self.logger.error( - "agent.provision.gateway_error action=%s agent_id=%s error=%s", - action, - agent.id, - str(exc), - ) + if exc.status_code == status.HTTP_502_BAD_GATEWAY: + self.logger.error( + "agent.provision.gateway_error action=%s agent_id=%s error=%s", + action, + agent.id, + str(exc.detail), + ) + else: + self.logger.critical( + "agent.provision.runtime_error action=%s agent_id=%s error=%s", + action, + agent.id, + str(exc.detail), + ) if raise_gateway_errors: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"Gateway {action} failed: {exc}", - ) from exc - except (OSError, RuntimeError, ValueError) as exc: # pragma: no cover - self.record_instruction_failure( - self.session, - agent, - str(exc), - action, - ) - await self.session.commit() - self.logger.critical( - "agent.provision.runtime_error action=%s agent_id=%s error=%s", - action, - agent.id, - str(exc), - ) - if raise_gateway_errors: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Unexpected error {action}ing agent provisioning.", - ) from exc + raise async def provision_new_agent( self, @@ -1315,7 +1312,6 @@ class AgentLifecycleService(OpenClawDBService): @staticmethod def mark_agent_update_pending(agent: Agent) -> str: raw_token = mint_agent_token(agent) - mark_provision_requested(agent, action="update", status="updating") return raw_token async def provision_updated_agent( @@ -1390,7 +1386,6 @@ class AgentLifecycleService(OpenClawDBService): return raw_token = mint_agent_token(agent) - mark_provision_requested(agent, action="provision", status="provisioning") await self.add_commit_refresh(agent) board = await self.require_board( str(agent.board_id) if agent.board_id else None, @@ -1436,6 +1431,10 @@ class AgentLifecycleService(OpenClawDBService): elif agent.status == "provisioning": agent.status = "online" agent.last_seen_at = utcnow() + # Successful check-in ends the current wake escalation cycle. + agent.wake_attempts = 0 + agent.checkin_deadline_at = None + agent.last_provision_error = None agent.updated_at = utcnow() self.record_heartbeat(self.session, agent) self.session.add(agent) diff --git a/backend/app/services/queue.py b/backend/app/services/queue.py index 04529763..e0d6a1a1 100644 --- a/backend/app/services/queue.py +++ b/backend/app/services/queue.py @@ -150,6 +150,32 @@ def enqueue_task( return False +def enqueue_task_with_delay( + task: QueuedTask, + queue_name: str, + *, + delay_seconds: float, + redis_url: str | None = None, +) -> bool: + """Enqueue a task immediately or schedule it for delayed delivery.""" + delay = max(0.0, float(delay_seconds)) + if delay == 0: + return enqueue_task(task, queue_name, redis_url=redis_url) + try: + return _schedule_for_later(task, queue_name, delay, redis_url=redis_url) + except Exception as exc: + logger.warning( + "rq.queue.schedule_failed", + extra={ + "task_type": task.task_type, + "queue_name": queue_name, + "delay_seconds": delay, + "error": str(exc), + }, + ) + return False + + def _coerce_datetime(raw: object | None) -> datetime: if raw is None: return datetime.now(UTC) diff --git a/backend/app/services/queue_worker.py b/backend/app/services/queue_worker.py index c8761f7d..61eb0332 100644 --- a/backend/app/services/queue_worker.py +++ b/backend/app/services/queue_worker.py @@ -9,6 +9,11 @@ from dataclasses import dataclass from app.core.config import settings from app.core.logging import get_logger +from app.services.openclaw.lifecycle_queue import TASK_TYPE as LIFECYCLE_RECONCILE_TASK_TYPE +from app.services.openclaw.lifecycle_queue import ( + requeue_lifecycle_queue_task, +) +from app.services.openclaw.lifecycle_reconcile import process_lifecycle_queue_task from app.services.queue import QueuedTask, dequeue_task from app.services.webhooks.dispatch import ( process_webhook_queue_task, @@ -17,6 +22,7 @@ from app.services.webhooks.dispatch import ( from app.services.webhooks.queue import TASK_TYPE as WEBHOOK_TASK_TYPE logger = get_logger(__name__) +_WORKER_BLOCK_TIMEOUT_SECONDS = 5.0 @dataclass(frozen=True) @@ -27,6 +33,14 @@ class _TaskHandler: _TASK_HANDLERS: dict[str, _TaskHandler] = { + LIFECYCLE_RECONCILE_TASK_TYPE: _TaskHandler( + handler=process_lifecycle_queue_task, + attempts_to_delay=lambda attempts: min( + settings.rq_dispatch_retry_base_seconds * (2 ** max(0, attempts)), + settings.rq_dispatch_retry_max_seconds, + ), + requeue=lambda task, delay: requeue_lifecycle_queue_task(task, delay_seconds=delay), + ), WEBHOOK_TASK_TYPE: _TaskHandler( handler=process_webhook_queue_task, attempts_to_delay=lambda attempts: min( @@ -115,7 +129,8 @@ async def _run_worker_loop() -> None: try: await flush_queue( block=True, - block_timeout=0, + # Keep a finite timeout so scheduled tasks are periodically drained. + block_timeout=_WORKER_BLOCK_TIMEOUT_SECONDS, ) except Exception: logger.exception( diff --git a/backend/migrations/versions/e3a1b2c4d5f6_add_agent_lifecycle_metadata_columns.py b/backend/migrations/versions/e3a1b2c4d5f6_add_agent_lifecycle_metadata_columns.py new file mode 100644 index 00000000..a179fcbb --- /dev/null +++ b/backend/migrations/versions/e3a1b2c4d5f6_add_agent_lifecycle_metadata_columns.py @@ -0,0 +1,45 @@ +"""Add agent lifecycle metadata columns. + +Revision ID: e3a1b2c4d5f6 +Revises: b497b348ebb4 +Create Date: 2026-02-24 00:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "e3a1b2c4d5f6" +down_revision = "b497b348ebb4" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Add lifecycle generation, wake tracking, and failure metadata.""" + op.add_column( + "agents", + sa.Column("lifecycle_generation", sa.Integer(), nullable=False, server_default="0"), + ) + op.add_column( + "agents", + sa.Column("wake_attempts", sa.Integer(), nullable=False, server_default="0"), + ) + op.add_column("agents", sa.Column("last_wake_sent_at", sa.DateTime(), nullable=True)) + op.add_column("agents", sa.Column("checkin_deadline_at", sa.DateTime(), nullable=True)) + op.add_column("agents", sa.Column("last_provision_error", sa.Text(), nullable=True)) + op.alter_column("agents", "lifecycle_generation", server_default=None) + op.alter_column("agents", "wake_attempts", server_default=None) + + +def downgrade() -> None: + """Remove lifecycle generation, wake tracking, and failure metadata.""" + op.drop_column("agents", "last_provision_error") + op.drop_column("agents", "checkin_deadline_at") + op.drop_column("agents", "last_wake_sent_at") + op.drop_column("agents", "wake_attempts") + op.drop_column("agents", "lifecycle_generation") + diff --git a/backend/templates/BOARD_BOOTSTRAP.md.j2 b/backend/templates/BOARD_BOOTSTRAP.md.j2 index ca847b26..63f2fd49 100644 --- a/backend/templates/BOARD_BOOTSTRAP.md.j2 +++ b/backend/templates/BOARD_BOOTSTRAP.md.j2 @@ -35,12 +35,19 @@ curl -fsS "{{ base_url }}/healthz" >/dev/null 5) Ensure today's daily file exists: `memory/YYYY-MM-DD.md`. {% if is_lead %} -6) Initialize current delivery status in `MEMORY.md`: +6) Immediately check in to Mission Control (do this before any task orchestration): + +```bash +curl -s -X POST "{{ base_url }}/api/v1/agent/heartbeat" \ + -H "X-Agent-Token: {{ auth_token }}" +``` + +7) Initialize current delivery status in `MEMORY.md`: - set objective if missing - set state to `Working` (or `Waiting` if external dependency exists) - set one concrete next step -7) Add one line to `MEMORY.md` noting bootstrap completion date. +8) Add one line to `MEMORY.md` noting bootstrap completion date. {% else %} 6) If any fields are blank, leave them blank. Do not invent values. diff --git a/backend/templates/BOARD_HEARTBEAT.md.j2 b/backend/templates/BOARD_HEARTBEAT.md.j2 index 3a56483e..2c6ab234 100644 --- a/backend/templates/BOARD_HEARTBEAT.md.j2 +++ b/backend/templates/BOARD_HEARTBEAT.md.j2 @@ -35,12 +35,20 @@ jq -r ' ## Schedule - If a heartbeat schedule is configured, send a lightweight check-in only. +- On first cycle after wake/bootstrap, run heartbeat check-in immediately (do not wait for cadence). - Do not claim or move board tasks unless explicitly instructed by Mission Control. - If you have any pending `LEAD REQUEST: ASK USER` messages in OpenClaw chat, handle them promptly (see AGENTS.md). ## Heartbeat checklist -1) Check in: +1) Check in immediately: - Use the `agent-main` heartbeat endpoint (`POST /api/v1/agent/heartbeat`). +- Startup check-in example: + +```bash +curl -s -X POST "{{ base_url }}/api/v1/agent/heartbeat" \ + -H "X-Agent-Token: {{ auth_token }}" +``` + - If check-in fails due to 5xx/network, stop and retry next heartbeat. - During that failure window, do **not** write memory updates (`MEMORY.md`, daily memory files). @@ -117,6 +125,7 @@ jq -r ' ## Schedule - Heartbeat cadence is controlled by gateway heartbeat config. +- On first cycle after wake/bootstrap, run heartbeat check-in immediately (do not wait for cadence). - Keep cadence conservative unless there is a clear latency need. ## Non-Negotiable Rules diff --git a/backend/tests/test_lifecycle_reconcile_queue.py b/backend/tests/test_lifecycle_reconcile_queue.py new file mode 100644 index 00000000..ab181cbc --- /dev/null +++ b/backend/tests/test_lifecycle_reconcile_queue.py @@ -0,0 +1,126 @@ +# ruff: noqa: INP001 +"""Queue payload helpers for lifecycle reconcile tasks.""" + +from __future__ import annotations + +from datetime import timedelta +from uuid import uuid4 + +import pytest + +from app.core.time import utcnow +from app.services.openclaw.lifecycle_queue import ( + QueuedAgentLifecycleReconcile, + decode_lifecycle_task, + defer_lifecycle_reconcile, + enqueue_lifecycle_reconcile, +) +from app.services.queue import QueuedTask + + +def test_enqueue_lifecycle_reconcile_uses_delayed_enqueue( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_enqueue_with_delay( + task: QueuedTask, + queue_name: str, + *, + delay_seconds: float, + redis_url: str | None = None, + ) -> bool: + captured["task"] = task + captured["queue_name"] = queue_name + captured["delay_seconds"] = delay_seconds + captured["redis_url"] = redis_url + return True + + monkeypatch.setattr( + "app.services.openclaw.lifecycle_queue.enqueue_task_with_delay", + _fake_enqueue_with_delay, + ) + + payload = QueuedAgentLifecycleReconcile( + agent_id=uuid4(), + gateway_id=uuid4(), + board_id=uuid4(), + generation=7, + checkin_deadline_at=utcnow() + timedelta(seconds=30), + attempts=0, + ) + + assert enqueue_lifecycle_reconcile(payload) is True + task = captured["task"] + assert isinstance(task, QueuedTask) + assert task.task_type == "agent_lifecycle_reconcile" + assert float(captured["delay_seconds"]) > 0 + + +def test_defer_lifecycle_reconcile_keeps_attempt_count( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def _fake_enqueue_with_delay( + task: QueuedTask, + queue_name: str, + *, + delay_seconds: float, + redis_url: str | None = None, + ) -> bool: + captured["task"] = task + captured["queue_name"] = queue_name + captured["delay_seconds"] = delay_seconds + captured["redis_url"] = redis_url + return True + + monkeypatch.setattr( + "app.services.openclaw.lifecycle_queue.enqueue_task_with_delay", + _fake_enqueue_with_delay, + ) + deadline = utcnow() + timedelta(minutes=1) + task = QueuedTask( + task_type="agent_lifecycle_reconcile", + payload={ + "agent_id": str(uuid4()), + "gateway_id": str(uuid4()), + "board_id": None, + "generation": 3, + "checkin_deadline_at": deadline.isoformat(), + }, + created_at=utcnow(), + attempts=2, + ) + assert defer_lifecycle_reconcile(task, delay_seconds=12) is True + deferred_task = captured["task"] + assert isinstance(deferred_task, QueuedTask) + assert deferred_task.attempts == 2 + assert float(captured["delay_seconds"]) == 12 + + +def test_decode_lifecycle_task_roundtrip() -> None: + deadline = utcnow() + timedelta(minutes=3) + agent_id = uuid4() + gateway_id = uuid4() + board_id = uuid4() + task = QueuedTask( + task_type="agent_lifecycle_reconcile", + payload={ + "agent_id": str(agent_id), + "gateway_id": str(gateway_id), + "board_id": str(board_id), + "generation": 5, + "checkin_deadline_at": deadline.isoformat(), + }, + created_at=utcnow(), + attempts=1, + ) + + decoded = decode_lifecycle_task(task) + assert decoded.agent_id == agent_id + assert decoded.gateway_id == gateway_id + assert decoded.board_id == board_id + assert decoded.generation == 5 + assert decoded.checkin_deadline_at == deadline + assert decoded.attempts == 1 diff --git a/backend/tests/test_lifecycle_reconcile_state.py b/backend/tests/test_lifecycle_reconcile_state.py new file mode 100644 index 00000000..b1c36cba --- /dev/null +++ b/backend/tests/test_lifecycle_reconcile_state.py @@ -0,0 +1,53 @@ +# ruff: noqa: INP001 +"""Lifecycle reconcile state helpers.""" + +from __future__ import annotations + +from datetime import timedelta +from uuid import uuid4 + +from app.core.time import utcnow +from app.models.agents import Agent +from app.services.openclaw.constants import ( + CHECKIN_DEADLINE_AFTER_WAKE, + MAX_WAKE_ATTEMPTS_WITHOUT_CHECKIN, +) +from app.services.openclaw.lifecycle_reconcile import _has_checked_in_since_wake + + +def _agent(*, last_seen_offset_s: int | None, last_wake_offset_s: int | None) -> Agent: + now = utcnow() + return Agent( + name="reconcile-test", + gateway_id=uuid4(), + last_seen_at=( + (now + timedelta(seconds=last_seen_offset_s)) + if last_seen_offset_s is not None + else None + ), + last_wake_sent_at=( + (now + timedelta(seconds=last_wake_offset_s)) + if last_wake_offset_s is not None + else None + ), + ) + + +def test_checked_in_since_wake_when_last_seen_after_wake() -> None: + agent = _agent(last_seen_offset_s=5, last_wake_offset_s=0) + assert _has_checked_in_since_wake(agent) is True + + +def test_not_checked_in_since_wake_when_last_seen_before_wake() -> None: + agent = _agent(last_seen_offset_s=-5, last_wake_offset_s=0) + assert _has_checked_in_since_wake(agent) is False + + +def test_not_checked_in_since_wake_when_missing_last_seen() -> None: + agent = _agent(last_seen_offset_s=None, last_wake_offset_s=0) + assert _has_checked_in_since_wake(agent) is False + + +def test_lifecycle_convergence_policy_constants() -> None: + assert CHECKIN_DEADLINE_AFTER_WAKE == timedelta(seconds=30) + assert MAX_WAKE_ATTEMPTS_WITHOUT_CHECKIN == 3 diff --git a/backend/tests/test_queue_worker_lifecycle_handler.py b/backend/tests/test_queue_worker_lifecycle_handler.py new file mode 100644 index 00000000..8d4b8740 --- /dev/null +++ b/backend/tests/test_queue_worker_lifecycle_handler.py @@ -0,0 +1,11 @@ +# ruff: noqa: INP001 +"""Queue worker registration tests for lifecycle reconcile tasks.""" + +from __future__ import annotations + +from app.services.openclaw.lifecycle_queue import TASK_TYPE as LIFECYCLE_TASK_TYPE +from app.services.queue_worker import _TASK_HANDLERS + + +def test_worker_registers_lifecycle_reconcile_handler() -> None: + assert LIFECYCLE_TASK_TYPE in _TASK_HANDLERS diff --git a/docs/README.md b/docs/README.md index 378e12b2..99fc18e8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ This folder is the starting point for Mission Control documentation. - [Deployment](./deployment/README.md) - [Production notes](./production/README.md) - [Troubleshooting](./troubleshooting/README.md) +- [Gateway agent provisioning and check-in troubleshooting](./troubleshooting/gateway-agent-provisioning.md) - [Gateway WebSocket protocol](./openclaw_gateway_ws.md) - [OpenClaw baseline configuration](./openclaw_baseline_config.md) diff --git a/docs/troubleshooting/README.md b/docs/troubleshooting/README.md index 1b897489..ad2526ee 100644 --- a/docs/troubleshooting/README.md +++ b/docs/troubleshooting/README.md @@ -1,3 +1,3 @@ # Troubleshooting -Placeholder. +- [Gateway agent provisioning and check-in](./gateway-agent-provisioning.md) diff --git a/docs/troubleshooting/gateway-agent-provisioning.md b/docs/troubleshooting/gateway-agent-provisioning.md new file mode 100644 index 00000000..212cb5de --- /dev/null +++ b/docs/troubleshooting/gateway-agent-provisioning.md @@ -0,0 +1,107 @@ +# Gateway Agent Provisioning and Check-In Troubleshooting + +This guide explains how agent provisioning converges to a healthy state, and how to debug when an agent appears stuck. + +## Fast Convergence Policy + +Mission Control now uses a fast convergence policy for wake/check-in: + +- Check-in deadline after each wake: **30 seconds** +- Maximum wake attempts without check-in: **3** +- If no check-in after the third attempt: agent is marked **offline** and provisioning escalation stops + +This applies to both gateway-main and board agents. + +## Expected Lifecycle + +1. Mission Control provisions/updates the agent and sends wake. +2. A delayed reconcile task is queued for the check-in deadline. +3. Agent should call heartbeat quickly after startup/bootstrap. +4. If heartbeat arrives: + - `last_seen_at` is updated + - wake escalation state is reset (`wake_attempts=0`, check-in deadline cleared) +5. If heartbeat does not arrive by deadline: + - reconcile re-runs lifecycle (wake again) + - up to 3 total wake attempts +6. If still no heartbeat after 3 attempts: + - agent status becomes `offline` + - `last_provision_error` is set + +## Startup Check-In Behavior + +Templates now explicitly require immediate first-cycle check-in: + +- Main agent heartbeat instructions require immediate check-in after wake/bootstrap. +- Board lead bootstrap requires heartbeat check-in before orchestration. +- Board worker bootstrap already included immediate check-in. + +If a gateway still has older templates, run template sync and reprovision/wake. + +## What You Should See in Logs + +Healthy flow usually includes: + +- `lifecycle.queue.enqueued` +- `queue.worker.success` (for lifecycle tasks) +- `lifecycle.reconcile.skip_not_stuck` (after heartbeat lands) + +If agent is not checking in: + +- `lifecycle.reconcile.deferred` (before deadline) +- `lifecycle.reconcile.retriggered` (retry wake) +- `lifecycle.reconcile.max_attempts_reached` (final fail-safe at attempt 3) + +If you do not see lifecycle events at all, verify queue worker health first. + +## Common Failure Modes + +### Wake was sent, but no check-in arrived + +Possible causes: + +- Agent process never started or crashed during bootstrap +- Agent ignored startup instructions due to stale templates +- Heartbeat call failed (network/auth/base URL mismatch) + +Actions: + +1. Confirm current templates were synced to gateway. +2. Re-run provisioning/update to trigger a fresh wake. +3. Verify agent can reach Mission Control API and send heartbeat with `X-Agent-Token`. + +### Agent stays provisioning/updating with no retries + +Possible causes: + +- Queue worker not running +- Queue/Redis mismatch between API process and worker process + +Actions: + +1. Verify worker process is running continuously. +2. Verify `rq_redis_url` and `rq_queue_name` are identical for API and worker. +3. Check worker logs for dequeue/handler errors. + +### Agent ended offline quickly + +This is expected when no check-in is received after 3 wake attempts. The system fails fast by design. + +Actions: + +1. Fix check-in path first (startup, network, token, API reachability). +2. Re-run provisioning/update to start a new attempt cycle. + +## Operator Recovery Checklist + +1. Ensure queue worker is running. +2. Sync templates for the gateway. +3. Trigger agent update/provision from Mission Control. +4. Watch logs for: + - `lifecycle.queue.enqueued` + - `lifecycle.reconcile.retriggered` (if needed) + - heartbeat activity / `skip_not_stuck` +5. If still failing, capture: + - gateway logs around bootstrap + - worker logs around lifecycle events + - agent `last_provision_error`, `wake_attempts`, `last_seen_at` + From 1d50e486096ac9a733160622e47371520628fbb2 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 00:49:35 +0530 Subject: [PATCH 10/28] chore: update CI configuration and improve README instructions --- .github/workflows/ci.yml | 3 +-- Makefile | 4 ++-- backend/README.md | 7 +++++-- docs/troubleshooting/gateway-agent-provisioning.md | 1 - 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d545ce7a..8b96b859 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,6 @@ jobs: CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} run: | make backend-lint - make backend-typecheck make backend-coverage - name: Run frontend checks @@ -125,7 +124,7 @@ jobs: make frontend-build - - name: Docs quality gates (lint + relative link check) + - name: Docs quality gates run: | make docs-check diff --git a/Makefile b/Makefile index f254fb52..452cb6e6 100644 --- a/Makefile +++ b/Makefile @@ -55,10 +55,10 @@ frontend-format-check: frontend-tooling ## Check frontend formatting (prettier) $(NODE_WRAP) --cwd $(FRONTEND_DIR) npx prettier --check "src/**/*.{ts,tsx,js,jsx,json,css,md}" "*.{ts,js,json,md,mdx}" .PHONY: lint -lint: backend-lint frontend-lint ## Lint backend + frontend +lint: backend-lint frontend-lint docs-lint ## Lint backend + frontend + docs .PHONY: backend-lint -backend-lint: ## Lint backend (flake8) +backend-lint: backend-format-check backend-typecheck ## Lint backend (isort/black checks + flake8 + mypy) cd $(BACKEND_DIR) && uv run flake8 --config .flake8 .PHONY: frontend-lint diff --git a/backend/README.md b/backend/README.md index 9f6a48e9..03684367 100644 --- a/backend/README.md +++ b/backend/README.md @@ -101,17 +101,20 @@ Notes: From repo root (recommended): ```bash -make backend-test make backend-lint -make backend-typecheck +make backend-test make backend-coverage ``` +`make backend-lint` runs backend format checks (`isort`, `black`), lint (`flake8`), and typecheck (`mypy`) in one command. + Or from `backend/`: ```bash cd backend uv run pytest +uv run isort . --check-only --diff +uv run black . --check --diff uv run flake8 --config .flake8 uv run mypy ``` diff --git a/docs/troubleshooting/gateway-agent-provisioning.md b/docs/troubleshooting/gateway-agent-provisioning.md index 212cb5de..695b35ab 100644 --- a/docs/troubleshooting/gateway-agent-provisioning.md +++ b/docs/troubleshooting/gateway-agent-provisioning.md @@ -104,4 +104,3 @@ Actions: - gateway logs around bootstrap - worker logs around lifecycle events - agent `last_provision_error`, `wake_attempts`, `last_seen_at` - From c6dedc93b3c8374a0cc31e95298d8d43820847ee Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 01:29:04 +0530 Subject: [PATCH 11/28] feat: enhance task editing and loading states in board tasks and boards list tests --- frontend/cypress/e2e/board_tasks.cy.ts | 106 +++++++++++++++++++++---- frontend/cypress/e2e/boards_list.cy.ts | 37 ++++++++- 2 files changed, 126 insertions(+), 17 deletions(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index 464dce21..13e3f7c5 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -24,6 +24,14 @@ describe("/boards/:id task board", () => { }); } + function openEditTaskDialog() { + cy.get('button[title="Edit task"]', { timeout: 20_000 }) + .should("be.visible") + .and("not.be.disabled") + .click({ force: true }); + cy.get('[aria-label="Edit task"]', { timeout: 20_000 }).should("be.visible"); + } + it("auth negative: signed-out user is redirected to sign-in", () => { cy.visit("/boards/b1"); cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); @@ -32,6 +40,51 @@ describe("/boards/:id task board", () => { it("happy path: renders tasks from snapshot and supports create + status update + delete (stubbed)", () => { stubEmptySse(); + cy.intercept("GET", `${apiBase}/organizations/me/member*`, { + statusCode: 200, + body: { + id: "m1", + organization_id: "o1", + user_id: "u1", + role: "owner", + all_boards_read: true, + all_boards_write: true, + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:00Z", + board_access: [{ board_id: "b1", can_read: true, can_write: true }], + }, + }).as("membership"); + + cy.intercept("GET", `${apiBase}/users/me*`, { + statusCode: 200, + body: { + id: "u1", + clerk_user_id: "clerk_u1", + email, + name: "Jane Test", + preferred_name: "Jane", + timezone: "America/New_York", + is_super_admin: false, + }, + }).as("me"); + + cy.intercept("GET", `${apiBase}/organizations/me/list*`, { + statusCode: 200, + body: [ + { id: "o1", name: "Personal", role: "owner", is_active: true }, + ], + }).as("organizations"); + + cy.intercept("GET", `${apiBase}/tags*`, { + statusCode: 200, + body: { items: [], total: 0, limit: 200, offset: 0 }, + }).as("tags"); + + cy.intercept("GET", `${apiBase}/organizations/me/custom-fields*`, { + statusCode: 200, + body: [], + }).as("customFields"); + cy.intercept("GET", `${apiBase}/boards/b1/snapshot*`, { statusCode: 200, body: { @@ -146,6 +199,11 @@ describe("/boards/:id task board", () => { body: { ok: true }, }).as("deleteTask"); + cy.intercept("GET", `${apiBase}/boards/b1/tasks/t1/comments*`, { + statusCode: 200, + body: { items: [], total: 0, limit: 200, offset: 0 }, + }).as("taskComments"); + cy.visit("/sign-in"); cy.clerkLoaded(); cy.clerkSignIn({ strategy: "email_code", identifier: email }); @@ -153,7 +211,14 @@ describe("/boards/:id task board", () => { cy.visit("/boards/b1"); cy.waitForAppLoaded(); - cy.wait(["@snapshot"]); + cy.wait([ + "@snapshot", + "@membership", + "@me", + "@organizations", + "@tags", + "@customFields", + ]); // Existing task visible. cy.contains("Inbox task").should("be.visible"); @@ -162,36 +227,45 @@ describe("/boards/:id task board", () => { // Board page uses an icon-only button with aria-label="New task". cy.get('button[aria-label="New task"]').click({ force: true }); - cy.get("input") - .filter( - '[placeholder*="Title"], [name*="title"], [id*="title"], input[type="text"]', - ) - .first() - .type("New task"); - - cy.contains("button", /create|save|add/i).click({ force: true }); + cy.contains('[role="dialog"]', "New task") + .should("be.visible") + .within(() => { + cy.contains("label", "Title").parent().find("input").type("New task"); + cy.contains("button", /^Create task$/).click({ force: true }); + }); cy.wait(["@createTask"]); cy.contains("New task").should("be.visible"); // Open edit task dialog. cy.contains("Inbox task").click({ force: true }); - cy.contains("Edit task").should("be.visible"); + cy.wait(["@taskComments"]); + cy.contains(/task detail/i).should("be.visible"); + openEditTaskDialog(); // Change status via Status select. - cy.contains("label", "Status") - .parent() - .within(() => { - cy.get("button").first().click({ force: true }); - }); + cy.get('[aria-label="Edit task"]').within(() => { + cy.contains("label", "Status") + .parent() + .within(() => { + cy.get('[role="combobox"]').first().click({ force: true }); + }); + }); cy.contains("In progress").click({ force: true }); cy.contains("button", /save changes/i).click({ force: true }); cy.wait(["@updateTask"]); + cy.get('[aria-label="Edit task"]').should("not.exist"); + + // Save closes the edit dialog; reopen it from task detail. + cy.contains(/task detail/i).should("be.visible"); + openEditTaskDialog(); // Delete task via delete dialog. - cy.contains("button", /^Delete task$/).click({ force: true }); + cy.get('[aria-label="Edit task"]').within(() => { + cy.contains("button", /^Delete task$/).click({ force: true }); + }); cy.get('[aria-label="Delete task"]').should("be.visible"); cy.get('[aria-label="Delete task"]').within(() => { cy.contains("button", /^Delete task$/).click({ force: true }); diff --git a/frontend/cypress/e2e/boards_list.cy.ts b/frontend/cypress/e2e/boards_list.cy.ts index e8a01387..c68a8ab8 100644 --- a/frontend/cypress/e2e/boards_list.cy.ts +++ b/frontend/cypress/e2e/boards_list.cy.ts @@ -20,6 +20,41 @@ describe("/boards", () => { }); it("happy path: signed-in user sees boards list", () => { + cy.intercept("GET", `${apiBase}/organizations/me/member*`, { + statusCode: 200, + body: { + id: "m1", + organization_id: "o1", + user_id: "u1", + role: "owner", + all_boards_read: true, + all_boards_write: true, + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:00Z", + board_access: [], + }, + }).as("membership"); + + cy.intercept("GET", `${apiBase}/users/me*`, { + statusCode: 200, + body: { + id: "u1", + clerk_user_id: "clerk_u1", + email, + name: "Jane Test", + preferred_name: "Jane", + timezone: "America/New_York", + is_super_admin: false, + }, + }).as("me"); + + cy.intercept("GET", `${apiBase}/organizations/me/list*`, { + statusCode: 200, + body: [ + { id: "o1", name: "Personal", role: "owner", is_active: true }, + ], + }).as("organizations"); + cy.intercept("GET", `${apiBase}/boards*`, { statusCode: 200, body: { @@ -60,7 +95,7 @@ describe("/boards", () => { cy.visit("/boards"); cy.waitForAppLoaded(); - cy.wait(["@boards", "@boardGroups"]); + cy.wait(["@membership", "@me", "@organizations", "@boards", "@boardGroups"]); cy.contains(/boards/i).should("be.visible"); cy.contains("Demo Board").should("be.visible"); From 1045267d4ce3b26d42cc00f8338c810b528f6aeb Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 01:39:04 +0530 Subject: [PATCH 12/28] feat: add groupSnapshot alias and handle hydration mismatch in E2E tests --- frontend/cypress/e2e/board_tasks.cy.ts | 3 ++- frontend/cypress/support/e2e.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index 13e3f7c5..441b65cf 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -137,7 +137,7 @@ describe("/boards/:id task board", () => { cy.intercept("GET", `${apiBase}/boards/b1/group-snapshot*`, { statusCode: 200, body: { group: null, boards: [] }, - }); + }).as("groupSnapshot"); cy.intercept("POST", `${apiBase}/boards/b1/tasks`, (req) => { // Minimal assertion the UI sends expected fields. @@ -213,6 +213,7 @@ describe("/boards/:id task board", () => { cy.wait([ "@snapshot", + "@groupSnapshot", "@membership", "@me", "@organizations", diff --git a/frontend/cypress/support/e2e.ts b/frontend/cypress/support/e2e.ts index 369182a5..a1b252f3 100644 --- a/frontend/cypress/support/e2e.ts +++ b/frontend/cypress/support/e2e.ts @@ -5,6 +5,15 @@ import { addClerkCommands } from "@clerk/testing/cypress"; +// Clerk/Next.js occasionally throws a non-deterministic hydration mismatch +// on /sign-in. Ignore this known UI noise so E2E assertions can proceed. +Cypress.on("uncaught:exception", (err) => { + if (err?.message?.includes("Hydration failed")) { + return false; + } + return true; +}); + addClerkCommands({ Cypress, cy }); import "./commands"; From e3404d859006d56426258ff04e0ae481578c70ef Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:24:51 +0530 Subject: [PATCH 13/28] feat: implement local authentication flow and update related tests --- .github/workflows/ci.yml | 25 +- backend/.env.test | 38 +++ frontend/cypress.config.ts | 14 -- frontend/cypress/e2e/activity_feed.cy.ts | 57 ++--- frontend/cypress/e2e/activity_smoke.cy.ts | 6 +- frontend/cypress/e2e/clerk_login.cy.ts | 16 -- frontend/cypress/e2e/local_auth_login.cy.ts | 49 ++++ frontend/cypress/e2e/organizations.cy.ts | 98 ++++++-- frontend/cypress/support/commands.ts | 216 ++---------------- frontend/cypress/support/e2e.ts | 11 +- frontend/src/app/sign-in/[[...rest]]/page.tsx | 7 + frontend/src/lib/gateway-form.test.ts | 4 +- 12 files changed, 245 insertions(+), 296 deletions(-) create mode 100644 backend/.env.test delete mode 100644 frontend/cypress/e2e/clerk_login.cy.ts create mode 100644 frontend/cypress/e2e/local_auth_login.cy.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b96b859..773db02a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,9 +102,8 @@ jobs: - name: Run backend checks env: # Keep CI builds deterministic. - NEXT_TELEMETRY_DISABLED: "1" - AUTH_MODE: "clerk" - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + AUTH_MODE: "local" + LOCAL_AUTH_TOKEN: "ci-local-auth-token-0123456789-0123456789-0123456789x" run: | make backend-lint make backend-coverage @@ -113,10 +112,8 @@ jobs: env: # Keep CI builds deterministic. NEXT_TELEMETRY_DISABLED: "1" - NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} - NEXT_PUBLIC_AUTH_MODE: "clerk" - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + NEXT_PUBLIC_API_URL: "http://localhost:8000" + NEXT_PUBLIC_AUTH_MODE: "local" run: | make frontend-lint make frontend-typecheck @@ -218,11 +215,9 @@ jobs: - name: Start frontend (dev server) env: - NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} - NEXT_PUBLIC_AUTH_MODE: "clerk" + NEXT_PUBLIC_API_URL: "http://localhost:8000" + NEXT_PUBLIC_AUTH_MODE: "local" NEXT_TELEMETRY_DISABLED: "1" - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} run: | cd frontend npm run dev -- --hostname 0.0.0.0 --port 3000 & @@ -235,13 +230,9 @@ jobs: - name: Run Cypress E2E env: - NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} - NEXT_PUBLIC_AUTH_MODE: "clerk" + NEXT_PUBLIC_API_URL: "http://localhost:8000" + NEXT_PUBLIC_AUTH_MODE: "local" NEXT_TELEMETRY_DISABLED: "1" - # Clerk testing tokens (official @clerk/testing Cypress integration) - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - # Also set for the app itself. - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} run: | cd frontend npm run e2e -- --browser chrome diff --git a/backend/.env.test b/backend/.env.test new file mode 100644 index 00000000..70f9a7ee --- /dev/null +++ b/backend/.env.test @@ -0,0 +1,38 @@ +# Commit-safe backend test environment. +# Usage: +# cd backend +# uv run --env-file .env.test uvicorn app.main:app --reload --port 8000 + +ENVIRONMENT=dev +LOG_LEVEL=INFO +LOG_FORMAT=text +LOG_USE_UTC=false +REQUEST_LOG_SLOW_MS=1000 +REQUEST_LOG_INCLUDE_HEALTH=false + +# Local backend -> local Postgres (adjust host/port if needed) +DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_control_test +CORS_ORIGINS=http://localhost:3000 +BASE_URL= + +# Auth mode: local for test/dev +AUTH_MODE=local +# Must be non-placeholder and >= 50 chars +LOCAL_AUTH_TOKEN=test-local-token-0123456789-0123456789-0123456789x + +# Clerk settings kept empty in local auth mode +CLERK_SECRET_KEY= +CLERK_API_URL=https://api.clerk.com +CLERK_VERIFY_IAT=true +CLERK_LEEWAY=10.0 + +# Database +DB_AUTO_MIGRATE=true + +# Queue / dispatch +RQ_REDIS_URL=redis://localhost:6379/0 +RQ_QUEUE_NAME=default +RQ_DISPATCH_THROTTLE_SECONDS=15.0 +RQ_DISPATCH_MAX_RETRIES=3 + +GATEWAY_MIN_VERSION=2026.02.9 diff --git a/frontend/cypress.config.ts b/frontend/cypress.config.ts index 18baa6bb..46b1c779 100644 --- a/frontend/cypress.config.ts +++ b/frontend/cypress.config.ts @@ -1,28 +1,14 @@ import { defineConfig } from "cypress"; -import { clerkSetup } from "@clerk/testing/cypress"; export default defineConfig({ - env: { - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: - process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, - // Optional overrides. - CLERK_ORIGIN: process.env.CYPRESS_CLERK_ORIGIN, - CLERK_TEST_EMAIL: process.env.CYPRESS_CLERK_TEST_EMAIL, - CLERK_TEST_OTP: process.env.CYPRESS_CLERK_TEST_OTP, - }, e2e: { baseUrl: "http://localhost:3000", specPattern: "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}", supportFile: "cypress/support/e2e.ts", - // Clerk helpers perform async work inside `cy.then()`. CI can be slow enough - // that Cypress' 4s default command timeout flakes. defaultCommandTimeout: 20_000, retries: { runMode: 2, openMode: 0, }, - setupNodeEvents(on, config) { - return clerkSetup({ config }); - }, }, }); diff --git a/frontend/cypress/e2e/activity_feed.cy.ts b/frontend/cypress/e2e/activity_feed.cy.ts index 77a06bf1..4245b4a1 100644 --- a/frontend/cypress/e2e/activity_feed.cy.ts +++ b/frontend/cypress/e2e/activity_feed.cy.ts @@ -1,22 +1,11 @@ /// -// Clerk/Next.js occasionally triggers a hydration mismatch on the SignIn route in CI. -// This is non-deterministic UI noise for these tests; ignore it so assertions can proceed. -Cypress.on("uncaught:exception", (err) => { - if (err.message?.includes("Hydration failed")) { - return false; - } - return true; -}); - describe("/activity feed", () => { const apiBase = "**/api/v1"; - const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); beforeEach(() => { - // Clerk's Cypress helpers perform async work inside `cy.then()`. // CI can be slow enough that the default 4s command timeout flakes. Cypress.config("defaultCommandTimeout", 20_000); }); @@ -49,6 +38,30 @@ describe("/activity feed", () => { function stubBoardBootstrap() { // Some app bootstraps happen before we get to the /activity call. // Keep these stable so the page always reaches the activity request. + cy.intercept("GET", `${apiBase}/users/me*`, { + statusCode: 200, + body: { + id: "u1", + clerk_user_id: "local-auth-user", + email: "local@example.com", + name: "Local User", + preferred_name: "Local User", + timezone: "UTC", + }, + }).as("usersMe"); + + cy.intercept("GET", `${apiBase}/organizations/me/list*`, { + statusCode: 200, + body: [ + { + id: "org1", + name: "Testing Org", + is_active: true, + role: "owner", + }, + ], + }).as("orgsList"); + cy.intercept("GET", `${apiBase}/organizations/me/member*`, { statusCode: 200, body: { organization_id: "org1", role: "owner" }, @@ -77,10 +90,11 @@ describe("/activity feed", () => { cy.contains(/live feed/i).should("be.visible"); } - it("auth negative: signed-out user is redirected to sign-in", () => { - // SignedOutPanel runs in redirect mode on this page. + it("auth negative: signed-out user sees auth prompt", () => { cy.visit("/activity"); - cy.location("pathname", { timeout: 20_000 }).should("match", /\/sign-in/); + cy.contains(/sign in to view the feed|local authentication/i, { + timeout: 20_000, + }).should("be.visible"); }); it("happy path: renders task comment cards", () => { @@ -107,10 +121,7 @@ describe("/activity feed", () => { stubStreamsEmpty(); - cy.visit("/sign-in"); - cy.clerkLoaded(); - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - + cy.loginWithLocalAuth(); cy.visit("/activity"); assertSignedInAndLanded(); cy.wait("@activityList", { timeout: 20_000 }); @@ -131,10 +142,7 @@ describe("/activity feed", () => { stubStreamsEmpty(); - cy.visit("/sign-in"); - cy.clerkLoaded(); - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - + cy.loginWithLocalAuth(); cy.visit("/activity"); assertSignedInAndLanded(); cy.wait("@activityList", { timeout: 20_000 }); @@ -152,10 +160,7 @@ describe("/activity feed", () => { stubStreamsEmpty(); - cy.visit("/sign-in"); - cy.clerkLoaded(); - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - + cy.loginWithLocalAuth(); cy.visit("/activity"); assertSignedInAndLanded(); cy.wait("@activityList", { timeout: 20_000 }); diff --git a/frontend/cypress/e2e/activity_smoke.cy.ts b/frontend/cypress/e2e/activity_smoke.cy.ts index ae60a51a..4da0469e 100644 --- a/frontend/cypress/e2e/activity_smoke.cy.ts +++ b/frontend/cypress/e2e/activity_smoke.cy.ts @@ -1,6 +1,8 @@ describe("/activity page", () => { - it("signed-out user is redirected to sign-in", () => { + it("signed-out user sees an auth prompt", () => { cy.visit("/activity"); - cy.location("pathname", { timeout: 20_000 }).should("match", /\/sign-in/); + cy.contains(/local authentication|sign in to mission control/i, { + timeout: 20_000, + }).should("be.visible"); }); }); diff --git a/frontend/cypress/e2e/clerk_login.cy.ts b/frontend/cypress/e2e/clerk_login.cy.ts deleted file mode 100644 index 2f928ac0..00000000 --- a/frontend/cypress/e2e/clerk_login.cy.ts +++ /dev/null @@ -1,16 +0,0 @@ -describe("Clerk login", () => { - it("user can sign in via Clerk testing commands", () => { - const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; - - // Prereq per Clerk docs: visit a non-protected page that loads Clerk. - cy.visit("/sign-in"); - cy.clerkLoaded(); - - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - - // After login, user should be able to access protected route. - cy.visit("/activity"); - cy.waitForAppLoaded(); - cy.contains(/live feed/i).should("be.visible"); - }); -}); diff --git a/frontend/cypress/e2e/local_auth_login.cy.ts b/frontend/cypress/e2e/local_auth_login.cy.ts new file mode 100644 index 00000000..7df170ed --- /dev/null +++ b/frontend/cypress/e2e/local_auth_login.cy.ts @@ -0,0 +1,49 @@ +describe("Local auth login", () => { + it("user with local auth token can access protected route", () => { + cy.intercept("GET", "**/api/v1/users/me*", { + statusCode: 200, + body: { + id: "u1", + clerk_user_id: "local-auth-user", + email: "local@example.com", + name: "Local User", + preferred_name: "Local User", + timezone: "UTC", + }, + }).as("usersMe"); + + cy.intercept("GET", "**/api/v1/organizations/me/list*", { + statusCode: 200, + body: [ + { + id: "org1", + name: "Testing Org", + is_active: true, + role: "owner", + }, + ], + }).as("orgsList"); + + cy.intercept("GET", "**/api/v1/organizations/me/member*", { + statusCode: 200, + body: { organization_id: "org1", role: "owner" }, + }).as("orgMeMember"); + + cy.intercept("GET", "**/api/v1/boards*", { + statusCode: 200, + body: { + items: [{ id: "b1", name: "Testing", updated_at: "2026-02-07T00:00:00Z" }], + }, + }).as("boardsList"); + + cy.intercept("GET", "**/api/v1/boards/b1/snapshot*", { + statusCode: 200, + body: { tasks: [], agents: [], approvals: [], chat_messages: [] }, + }).as("boardSnapshot"); + + cy.loginWithLocalAuth(); + cy.visit("/activity"); + cy.waitForAppLoaded(); + cy.contains(/live feed/i).should("be.visible"); + }); +}); diff --git a/frontend/cypress/e2e/organizations.cy.ts b/frontend/cypress/e2e/organizations.cy.ts index c2ab5303..f302d252 100644 --- a/frontend/cypress/e2e/organizations.cy.ts +++ b/frontend/cypress/e2e/organizations.cy.ts @@ -1,36 +1,88 @@ describe("Organizations (PR #61)", () => { - const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + const apiBase = "**/api/v1"; - it("negative: signed-out user is redirected to sign-in when opening /organization", () => { + function stubOrganizationApis() { + cy.intercept("GET", `${apiBase}/users/me*`, { + statusCode: 200, + body: { + id: "u1", + clerk_user_id: "local-auth-user", + email: "local@example.com", + name: "Local User", + preferred_name: "Local User", + timezone: "UTC", + }, + }).as("usersMe"); + + cy.intercept("GET", `${apiBase}/organizations/me/list*`, { + statusCode: 200, + body: [ + { + id: "org1", + name: "Testing Org", + is_active: true, + role: "member", + }, + ], + }).as("orgsList"); + + cy.intercept("GET", `${apiBase}/organizations/me/member*`, { + statusCode: 200, + body: { + id: "membership-1", + user_id: "u1", + organization_id: "org1", + role: "member", + }, + }).as("orgMembership"); + + cy.intercept("GET", `${apiBase}/organizations/me`, { + statusCode: 200, + body: { id: "org1", name: "Testing Org" }, + }).as("orgMe"); + + cy.intercept("GET", `${apiBase}/organizations/me/members*`, { + statusCode: 200, + body: { + items: [ + { + id: "membership-1", + user_id: "u1", + role: "member", + user: { + id: "u1", + email: "local@example.com", + name: "Local User", + preferred_name: "Local User", + }, + }, + ], + }, + }).as("orgMembers"); + + cy.intercept("GET", `${apiBase}/boards*`, { + statusCode: 200, + body: { items: [] }, + }).as("boardsList"); + } + + it("negative: signed-out user sees auth prompt when opening /organization", () => { cy.visit("/organization"); - cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); + cy.contains(/sign in to manage your organization|local authentication/i, { + timeout: 30_000, + }).should("be.visible"); }); it("positive: signed-in user can view /organization and sees correct invite permissions", () => { - // Story (positive): a signed-in user can reach the organization page. - // Story (negative within flow): non-admin users cannot invite members. - cy.visit("/sign-in"); - cy.clerkLoaded(); - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - + stubOrganizationApis(); + cy.loginWithLocalAuth(); cy.visit("/organization"); cy.waitForAppLoaded(); cy.contains(/members\s*&\s*invites/i).should("be.visible"); - - // Deterministic assertion across roles: - // - if user is admin: invite button enabled - // - else: invite button disabled with the correct tooltip cy.contains("button", /invite member/i) .should("be.visible") - .then(($btn) => { - const isDisabled = $btn.is(":disabled"); - if (isDisabled) { - cy.wrap($btn) - .should("have.attr", "title") - .and("match", /only organization admins can invite/i); - } else { - cy.wrap($btn).should("not.be.disabled"); - } - }); + .should("be.disabled") + .and("have.attr", "title") + .and("match", /only organization admins can invite/i); }); }); diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 0848d34a..83eae8e4 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -1,46 +1,9 @@ /// -type ClerkOtpLoginOptions = { - clerkOrigin: string; - email: string; - otp: string; -}; - const APP_LOAD_TIMEOUT_MS = 30_000; - -function getEnv(name: string, fallback?: string): string { - const value = Cypress.env(name) as string | undefined; - if (value) return value; - if (fallback !== undefined) return fallback; - throw new Error( - `Missing Cypress env var ${name}. ` + - `Set it via CYPRESS_${name}=... in CI/local before running Clerk login tests.`, - ); -} - -function clerkOriginFromPublishableKey(): string { - const key = getEnv("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"); - - // pk_test_ OR pk_live_<...> - const m = /^pk_(?:test|live)_(.+)$/.exec(key); - if (!m) throw new Error(`Unexpected Clerk publishable key format: ${key}`); - - const decoded = atob(m[1]); // e.g. beloved-ghost-73.clerk.accounts.dev$ - const domain = decoded.replace(/\$$/, ""); - - // Some flows redirect to *.accounts.dev (no clerk. subdomain) - const normalized = domain.replace(".clerk.accounts.dev", ".accounts.dev"); - return `https://${normalized}`; -} - -function normalizeOrigin(value: string): string { - try { - const url = new URL(value); - return url.origin; - } catch { - return value.replace(/\/$/, ""); - } -} +const LOCAL_AUTH_STORAGE_KEY = "mc_local_auth_token"; +const DEFAULT_LOCAL_AUTH_TOKEN = + "cypress-local-auth-token-0123456789-0123456789-0123456789x"; Cypress.Commands.add("waitForAppLoaded", () => { cy.get("[data-cy='route-loader']", { @@ -52,153 +15,19 @@ Cypress.Commands.add("waitForAppLoaded", () => { }).should("have.attr", "aria-hidden", "true"); }); -Cypress.Commands.add("loginWithClerkOtp", () => { - const clerkOrigin = normalizeOrigin( - getEnv("CLERK_ORIGIN", clerkOriginFromPublishableKey()), - ); - const email = getEnv("CLERK_TEST_EMAIL", "jane+clerk_test@example.com"); - const otp = getEnv("CLERK_TEST_OTP", "424242"); +Cypress.Commands.add("loginWithLocalAuth", (token = DEFAULT_LOCAL_AUTH_TOKEN) => { + cy.visit("/", { + onBeforeLoad(win) { + win.sessionStorage.setItem(LOCAL_AUTH_STORAGE_KEY, token); + }, + }); +}); - const opts: ClerkOtpLoginOptions = { clerkOrigin, email, otp }; - - // Navigate to a dedicated sign-in route that renders Clerk SignIn top-level. - // Cypress cannot reliably drive Clerk modal/iframe flows. - cy.visit("/sign-in"); - - const emailSelector = - 'input[type="email"], input[name="identifier"], input[autocomplete="email"]'; - const otpSelector = - 'input[autocomplete="one-time-code"], input[name*="code"], input[name^="code"], input[name^="code."], input[inputmode="numeric"]'; - const continueSelector = 'button[type="submit"], button'; - const methodSelector = /email|code|otp|send code|verification|verify|use email/i; - - const fillEmailStep = (email: string) => { - cy.get(emailSelector, { timeout: 20_000 }) - .first() - .clear() - .type(email, { delay: 10 }); - - cy.contains(continueSelector, /continue|sign in|send|next/i, { timeout: 20_000 }) - .should("be.visible") - .click({ force: true }); - }; - - const maybeSelectEmailCodeMethod = () => { - cy.get("body").then(($body) => { - const hasOtp = $body.find(otpSelector).length > 0; - if (hasOtp) return; - - const candidates = $body - .find("button,a") - .toArray() - .filter((el) => methodSelector.test((el.textContent || "").trim())); - - if (candidates.length > 0) { - cy.wrap(candidates[0]).click({ force: true }); - } - }); - }; - - const waitForOtpOrMethod = () => { - cy.get("body", { timeout: 60_000 }).should(($body) => { - const hasOtp = $body.find(otpSelector).length > 0; - const hasMethod = $body - .find("button,a") - .toArray() - .some((el) => methodSelector.test((el.textContent || "").trim())); - expect( - hasOtp || hasMethod, - "waiting for OTP input or verification method UI", - ).to.equal(true); - }); - }; - - const fillOtpAndSubmit = (otp: string) => { - waitForOtpOrMethod(); - maybeSelectEmailCodeMethod(); - - cy.get(otpSelector, { timeout: 60_000 }).first().clear().type(otp, { delay: 10 }); - - cy.get("body").then(($body) => { - const hasSubmit = $body - .find(continueSelector) - .toArray() - .some((el) => /verify|continue|sign in|confirm/i.test(el.textContent || "")); - if (hasSubmit) { - cy.contains(continueSelector, /verify|continue|sign in|confirm/i, { timeout: 20_000 }) - .should("be.visible") - .click({ force: true }); - } - }); - }; - - // Clerk SignIn can start on our app origin and then redirect to Clerk-hosted UI. - // Do email step first, then decide where the OTP step lives based on the *current* origin. - fillEmailStep(opts.email); - - cy.location("origin", { timeout: 60_000 }).then((origin) => { - const current = normalizeOrigin(origin); - if (current === opts.clerkOrigin) { - cy.origin( - opts.clerkOrigin, - { args: { otp: opts.otp } }, - ({ otp }) => { - const otpSelector = - 'input[autocomplete="one-time-code"], input[name*="code"], input[name^="code"], input[name^="code."], input[inputmode="numeric"]'; - const continueSelector = 'button[type="submit"], button'; - const methodSelector = /email|code|otp|send code|verification|verify|use email/i; - - const maybeSelectEmailCodeMethod = () => { - cy.get("body").then(($body) => { - const hasOtp = $body.find(otpSelector).length > 0; - if (hasOtp) return; - - const candidates = $body - .find("button,a") - .toArray() - .filter((el) => methodSelector.test((el.textContent || "").trim())); - - if (candidates.length > 0) { - cy.wrap(candidates[0]).click({ force: true }); - } - }); - }; - - const waitForOtpOrMethod = () => { - cy.get("body", { timeout: 60_000 }).should(($body) => { - const hasOtp = $body.find(otpSelector).length > 0; - const hasMethod = $body - .find("button,a") - .toArray() - .some((el) => methodSelector.test((el.textContent || "").trim())); - expect( - hasOtp || hasMethod, - "waiting for OTP input or verification method UI", - ).to.equal(true); - }); - }; - - waitForOtpOrMethod(); - maybeSelectEmailCodeMethod(); - - cy.get(otpSelector, { timeout: 60_000 }).first().clear().type(otp, { delay: 10 }); - - cy.get("body").then(($body) => { - const hasSubmit = $body - .find(continueSelector) - .toArray() - .some((el) => /verify|continue|sign in|confirm/i.test(el.textContent || "")); - if (hasSubmit) { - cy.contains(continueSelector, /verify|continue|sign in|confirm/i, { timeout: 20_000 }) - .should("be.visible") - .click({ force: true }); - } - }); - }, - ); - } else { - fillOtpAndSubmit(opts.otp); - } +Cypress.Commands.add("logoutLocalAuth", () => { + cy.visit("/", { + onBeforeLoad(win) { + win.sessionStorage.removeItem(LOCAL_AUTH_STORAGE_KEY); + }, }); }); @@ -212,15 +41,14 @@ declare global { waitForAppLoaded(): Chainable; /** - * Logs in via the real Clerk SignIn page using deterministic OTP credentials. - * - * Optional env vars (CYPRESS_*): - * - CLERK_ORIGIN (e.g. https://.accounts.dev) - * - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY (used to derive origin when CLERK_ORIGIN not set) - * - CLERK_TEST_EMAIL (default: jane+clerk_test@example.com) - * - CLERK_TEST_OTP (default: 424242) + * Seeds session storage with a local auth token for local-auth mode. */ - loginWithClerkOtp(): Chainable; + loginWithLocalAuth(token?: string): Chainable; + + /** + * Clears local auth token from session storage. + */ + logoutLocalAuth(): Chainable; } } } diff --git a/frontend/cypress/support/e2e.ts b/frontend/cypress/support/e2e.ts index 369182a5..bf3c9830 100644 --- a/frontend/cypress/support/e2e.ts +++ b/frontend/cypress/support/e2e.ts @@ -3,8 +3,13 @@ /// -import { addClerkCommands } from "@clerk/testing/cypress"; - -addClerkCommands({ Cypress, cy }); +// Next.js hydration mismatch can happen non-deterministically in CI/dev mode. +// Ignore this specific runtime error so E2E assertions can continue. +Cypress.on("uncaught:exception", (err) => { + if (err.message?.includes("Hydration failed")) { + return false; + } + return true; +}); import "./commands"; diff --git a/frontend/src/app/sign-in/[[...rest]]/page.tsx b/frontend/src/app/sign-in/[[...rest]]/page.tsx index 196d303a..ce55bcda 100644 --- a/frontend/src/app/sign-in/[[...rest]]/page.tsx +++ b/frontend/src/app/sign-in/[[...rest]]/page.tsx @@ -3,10 +3,17 @@ import { useSearchParams } from "next/navigation"; import { SignIn } from "@clerk/nextjs"; +import { isLocalAuthMode } from "@/auth/localAuth"; import { resolveSignInRedirectUrl } from "@/auth/redirects"; +import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin"; export default function SignInPage() { const searchParams = useSearchParams(); + + if (isLocalAuthMode()) { + return ; + } + const forceRedirectUrl = resolveSignInRedirectUrl( searchParams.get("redirect_url"), ); diff --git a/frontend/src/lib/gateway-form.test.ts b/frontend/src/lib/gateway-form.test.ts index 80164d5e..bca3c16a 100644 --- a/frontend/src/lib/gateway-form.test.ts +++ b/frontend/src/lib/gateway-form.test.ts @@ -46,7 +46,9 @@ describe("validateGatewayUrl", () => { }); it("accepts userinfo URLs with explicit port", () => { - expect(validateGatewayUrl("ws://user:pass@gateway.example.com:8080")).toBeNull(); + expect( + validateGatewayUrl("ws://user:pass@gateway.example.com:8080"), + ).toBeNull(); }); it("accepts userinfo URLs with IPv6 host and explicit port", () => { From 42b361ddd95ee9357d897f5e4938cdcf419a4030 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:32:42 +0530 Subject: [PATCH 14/28] test(frontend): stabilize auth boundary tests for local auth mode --- frontend/src/app/activity/page.test.tsx | 30 +++++++++++++++++------- frontend/src/app/approvals/page.test.tsx | 30 +++++++++++++++++------- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/activity/page.test.tsx b/frontend/src/app/activity/page.test.tsx index 981934c1..388a5826 100644 --- a/frontend/src/app/activity/page.test.tsx +++ b/frontend/src/app/activity/page.test.tsx @@ -62,19 +62,33 @@ vi.mock("@clerk/nextjs", () => { describe("/activity auth boundary", () => { it("renders without ClerkProvider runtime errors when publishable key is a placeholder", () => { + const previousAuthMode = process.env.NEXT_PUBLIC_AUTH_MODE; + const previousPublishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + // Simulate CI/secretless env where an arbitrary placeholder value may be present. // AuthProvider should treat this as disabled, and the auth wrappers must not render // Clerk SignedOut/SignedIn components. + process.env.NEXT_PUBLIC_AUTH_MODE = "local"; process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = "placeholder"; + window.sessionStorage.clear(); - render( - - - - - , - ); + try { + render( + + + + + , + ); - expect(screen.getByText(/sign in to view the feed/i)).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: /local authentication/i }), + ).toBeInTheDocument(); + expect(screen.getByLabelText(/access token/i)).toBeInTheDocument(); + } finally { + process.env.NEXT_PUBLIC_AUTH_MODE = previousAuthMode; + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = previousPublishableKey; + window.sessionStorage.clear(); + } }); }); diff --git a/frontend/src/app/approvals/page.test.tsx b/frontend/src/app/approvals/page.test.tsx index f747b5bf..cb812fac 100644 --- a/frontend/src/app/approvals/page.test.tsx +++ b/frontend/src/app/approvals/page.test.tsx @@ -59,16 +59,30 @@ vi.mock("@clerk/nextjs", () => { describe("/approvals auth boundary", () => { it("renders without ClerkProvider runtime errors when publishable key is a placeholder", () => { + const previousAuthMode = process.env.NEXT_PUBLIC_AUTH_MODE; + const previousPublishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + + process.env.NEXT_PUBLIC_AUTH_MODE = "local"; process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = "placeholder"; + window.sessionStorage.clear(); - render( - - - - - , - ); + try { + render( + + + + + , + ); - expect(screen.getByText(/sign in to view approvals/i)).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: /local authentication/i }), + ).toBeInTheDocument(); + expect(screen.getByLabelText(/access token/i)).toBeInTheDocument(); + } finally { + process.env.NEXT_PUBLIC_AUTH_MODE = previousAuthMode; + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = previousPublishableKey; + window.sessionStorage.clear(); + } }); }); From 08341d26acdc89979946d21398482e5a73bc44f5 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:39:53 +0530 Subject: [PATCH 15/28] test(frontend): stabilize onboarding polling assertion --- frontend/src/components/BoardOnboardingChat.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/components/BoardOnboardingChat.test.tsx b/frontend/src/components/BoardOnboardingChat.test.tsx index 20487678..88dd5b9a 100644 --- a/frontend/src/components/BoardOnboardingChat.test.tsx +++ b/frontend/src/components/BoardOnboardingChat.test.tsx @@ -86,6 +86,9 @@ describe("BoardOnboardingChat polling", () => { ); await screen.findByText("What should we prioritize?"); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Option A" })).toBeEnabled(); + }); const callsBeforeWait = getOnboardingMock.mock.calls.length; await act(async () => { From b35980618d46ed43d7374e9b3753da82681cb679 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:40:54 +0530 Subject: [PATCH 16/28] test(e2e): switch board task spec to local auth --- frontend/cypress/e2e/board_tasks.cy.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index 441b65cf..d368d587 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -2,8 +2,7 @@ describe("/boards/:id task board", () => { const apiBase = "**/api/v1"; - const email = - Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + const email = "local-auth-user@example.com"; const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); @@ -32,9 +31,11 @@ describe("/boards/:id task board", () => { cy.get('[aria-label="Edit task"]', { timeout: 20_000 }).should("be.visible"); } - it("auth negative: signed-out user is redirected to sign-in", () => { + it("auth negative: signed-out user is shown local auth login", () => { cy.visit("/boards/b1"); - cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); + cy.contains("h1", /local authentication/i, { timeout: 30_000 }).should( + "be.visible", + ); }); it("happy path: renders tasks from snapshot and supports create + status update + delete (stubbed)", () => { @@ -204,10 +205,7 @@ describe("/boards/:id task board", () => { body: { items: [], total: 0, limit: 200, offset: 0 }, }).as("taskComments"); - cy.visit("/sign-in"); - cy.clerkLoaded(); - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - + cy.loginWithLocalAuth(); cy.visit("/boards/b1"); cy.waitForAppLoaded(); From c03affe3e0a3954c09b3c7b728d1f9489bc71e5f Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:50:11 +0530 Subject: [PATCH 17/28] test(e2e): switch boards list spec to local auth --- frontend/cypress/e2e/boards_list.cy.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/cypress/e2e/boards_list.cy.ts b/frontend/cypress/e2e/boards_list.cy.ts index c68a8ab8..b1585cd2 100644 --- a/frontend/cypress/e2e/boards_list.cy.ts +++ b/frontend/cypress/e2e/boards_list.cy.ts @@ -2,7 +2,7 @@ describe("/boards", () => { const apiBase = "**/api/v1"; - const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + const email = "local-auth-user@example.com"; const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); @@ -14,9 +14,11 @@ describe("/boards", () => { Cypress.config("defaultCommandTimeout", originalDefaultCommandTimeout); }); - it("auth negative: signed-out user is redirected to sign-in", () => { + it("auth negative: signed-out user is shown local auth login", () => { cy.visit("/boards"); - cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); + cy.contains("h1", /local authentication/i, { timeout: 30_000 }).should( + "be.visible", + ); }); it("happy path: signed-in user sees boards list", () => { @@ -88,10 +90,7 @@ describe("/boards", () => { body: { items: [], total: 0, limit: 200, offset: 0 }, }).as("boardGroups"); - cy.visit("/sign-in"); - cy.clerkLoaded(); - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - + cy.loginWithLocalAuth(); cy.visit("/boards"); cy.waitForAppLoaded(); From aa2ceba576e2dd551706d78527ecbfc4a5d136f6 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:50:57 +0530 Subject: [PATCH 18/28] test(e2e): scope board task SSE stubs to known endpoints --- frontend/cypress/e2e/board_tasks.cy.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index d368d587..cc397e71 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -15,12 +15,23 @@ describe("/boards/:id task board", () => { }); function stubEmptySse() { - // Any SSE endpoint should not hang the UI in tests. - cy.intercept("GET", `${apiBase}/**/stream*`, { + // Keep known board-related SSE endpoints quiet in tests. + const emptySse = { statusCode: 200, headers: { "content-type": "text/event-stream" }, body: "", - }); + }; + + cy.intercept("GET", `${apiBase}/boards/*/tasks/stream*`, emptySse).as( + "tasksStream", + ); + cy.intercept("GET", `${apiBase}/boards/*/approvals/stream*`, emptySse).as( + "approvalsStream", + ); + cy.intercept("GET", `${apiBase}/boards/*/memory/stream*`, emptySse).as( + "memoryStream", + ); + cy.intercept("GET", `${apiBase}/agents/stream*`, emptySse).as("agentsStream"); } function openEditTaskDialog() { From 33b705a9356d090363b85972793a0a751b61d1d8 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:59:15 +0530 Subject: [PATCH 19/28] test(e2e): remove force click for edit-task actionability --- frontend/cypress/e2e/board_tasks.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index cc397e71..567523a0 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -38,7 +38,7 @@ describe("/boards/:id task board", () => { cy.get('button[title="Edit task"]', { timeout: 20_000 }) .should("be.visible") .and("not.be.disabled") - .click({ force: true }); + .click(); cy.get('[aria-label="Edit task"]', { timeout: 20_000 }).should("be.visible"); } From 8b7c728f32aac15d57a5c53b9476457156ab97c0 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 03:01:15 +0530 Subject: [PATCH 20/28] test(e2e): replace forced clicks with actionability checks --- frontend/cypress/e2e/board_tasks.cy.ts | 31 +++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index 567523a0..5c5d9145 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -235,20 +235,26 @@ describe("/boards/:id task board", () => { // Open create task flow. // Board page uses an icon-only button with aria-label="New task". - cy.get('button[aria-label="New task"]').click({ force: true }); + cy.get('button[aria-label="New task"]') + .should("be.visible") + .and("not.be.disabled") + .click(); cy.contains('[role="dialog"]', "New task") .should("be.visible") .within(() => { cy.contains("label", "Title").parent().find("input").type("New task"); - cy.contains("button", /^Create task$/).click({ force: true }); + cy.contains("button", /^Create task$/) + .should("be.visible") + .and("not.be.disabled") + .click(); }); cy.wait(["@createTask"]); cy.contains("New task").should("be.visible"); // Open edit task dialog. - cy.contains("Inbox task").click({ force: true }); + cy.contains("Inbox task").scrollIntoView().should("be.visible").click(); cy.wait(["@taskComments"]); cy.contains(/task detail/i).should("be.visible"); openEditTaskDialog(); @@ -258,13 +264,16 @@ describe("/boards/:id task board", () => { cy.contains("label", "Status") .parent() .within(() => { - cy.get('[role="combobox"]').first().click({ force: true }); + cy.get('[role="combobox"]').first().should("be.visible").click(); }); }); - cy.contains("In progress").click({ force: true }); + cy.contains("In progress").should("be.visible").click(); - cy.contains("button", /save changes/i).click({ force: true }); + cy.contains("button", /save changes/i) + .should("be.visible") + .and("not.be.disabled") + .click(); cy.wait(["@updateTask"]); cy.get('[aria-label="Edit task"]').should("not.exist"); @@ -274,11 +283,17 @@ describe("/boards/:id task board", () => { // Delete task via delete dialog. cy.get('[aria-label="Edit task"]').within(() => { - cy.contains("button", /^Delete task$/).click({ force: true }); + cy.contains("button", /^Delete task$/) + .should("be.visible") + .and("not.be.disabled") + .click(); }); cy.get('[aria-label="Delete task"]').should("be.visible"); cy.get('[aria-label="Delete task"]').within(() => { - cy.contains("button", /^Delete task$/).click({ force: true }); + cy.contains("button", /^Delete task$/) + .should("be.visible") + .and("not.be.disabled") + .click(); }); cy.wait(["@deleteTask"]); From 9e4be7192e568cf12061f39d7dc7d824c9169f45 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 03:14:56 +0530 Subject: [PATCH 21/28] test(e2e): ensure delete task button is visible and scrolls into view --- frontend/cypress/e2e/board_tasks.cy.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index 5c5d9145..c347c077 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -284,6 +284,7 @@ describe("/boards/:id task board", () => { // Delete task via delete dialog. cy.get('[aria-label="Edit task"]').within(() => { cy.contains("button", /^Delete task$/) + .scrollIntoView() .should("be.visible") .and("not.be.disabled") .click(); @@ -291,6 +292,7 @@ describe("/boards/:id task board", () => { cy.get('[aria-label="Delete task"]').should("be.visible"); cy.get('[aria-label="Delete task"]').within(() => { cy.contains("button", /^Delete task$/) + .scrollIntoView() .should("be.visible") .and("not.be.disabled") .click(); From 842a0770a8ccc55a1561679161aa6b54346efbe6 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 03:37:05 +0530 Subject: [PATCH 22/28] Update backend/app/services/openclaw/provisioning.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/app/services/openclaw/provisioning.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/backend/app/services/openclaw/provisioning.py b/backend/app/services/openclaw/provisioning.py index ed709970..f80a72a4 100644 --- a/backend/app/services/openclaw/provisioning.py +++ b/backend/app/services/openclaw/provisioning.py @@ -113,13 +113,8 @@ def _channel_heartbeat_visibility_patch(config_data: dict[str, Any]) -> dict[str """Build a minimal patch ensuring channel default heartbeat visibility is configured. Gateways may have existing channel config; we only want to fill missing keys rather than - overwrite operator intent. - - Returns: - - `None` if no change is needed - - a shallow patch dict suitable for a config merge otherwise - """ - + overwrite operator intent. Returns `None` if no change is needed, otherwise returns a shallow + patch dict suitable for a config merge.""" channels = config_data.get("channels") if not isinstance(channels, dict): return {"defaults": {"heartbeat": DEFAULT_CHANNEL_HEARTBEAT_VISIBILITY.copy()}} From 58db8be1173fd8369f44659c76d067d155e52241 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 03:39:49 +0530 Subject: [PATCH 23/28] fix(security): update security header names to lowercase and add tests --- backend/app/core/security_headers.py | 8 +++--- .../tests/test_security_headers_middleware.py | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/backend/app/core/security_headers.py b/backend/app/core/security_headers.py index 3aa3a78e..758c36b4 100644 --- a/backend/app/core/security_headers.py +++ b/backend/app/core/security_headers.py @@ -11,10 +11,10 @@ if TYPE_CHECKING: # pragma: no cover class SecurityHeadersMiddleware: """Inject configured security headers into every HTTP response.""" - _X_CONTENT_TYPE_OPTIONS = b"X-Content-Type-Options" - _X_FRAME_OPTIONS = b"X-Frame-Options" - _REFERRER_POLICY = b"Referrer-Policy" - _PERMISSIONS_POLICY = b"Permissions-Policy" + _X_CONTENT_TYPE_OPTIONS = b"x-content-type-options" + _X_FRAME_OPTIONS = b"x-frame-options" + _REFERRER_POLICY = b"referrer-policy" + _PERMISSIONS_POLICY = b"permissions-policy" def __init__( self, diff --git a/backend/tests/test_security_headers_middleware.py b/backend/tests/test_security_headers_middleware.py index 978b6c19..844aeccf 100644 --- a/backend/tests/test_security_headers_middleware.py +++ b/backend/tests/test_security_headers_middleware.py @@ -24,6 +24,32 @@ async def test_security_headers_middleware_passes_through_non_http_scope() -> No assert called is True +@pytest.mark.asyncio +async def test_security_headers_middleware_appends_lowercase_raw_header_names() -> None: + sent_messages: list[dict[str, object]] = [] + + async def app(scope, receive, send): # type: ignore[no-untyped-def] + _ = scope + _ = receive + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"", "more_body": False}) + + async def capture(message): # type: ignore[no-untyped-def] + sent_messages.append(message) + + middleware = SecurityHeadersMiddleware(app, x_frame_options="SAMEORIGIN") + await middleware({"type": "http", "method": "GET", "path": "/", "headers": []}, lambda: None, capture) + + response_start = next( + message for message in sent_messages if message.get("type") == "http.response.start" + ) + headers = response_start.get("headers") + assert isinstance(headers, list) + header_names = {name for name, _value in headers} + assert b"x-frame-options" in header_names + assert b"X-Frame-Options" not in header_names + + def test_security_headers_middleware_injects_configured_headers() -> None: app = FastAPI() app.add_middleware( From 5b0ddcc7cd6e4dad8881c17fc11add8149f043f8 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 03:41:03 +0530 Subject: [PATCH 24/28] refactor(tests): format code for better readability in test files --- backend/tests/test_security_headers_middleware.py | 4 +++- frontend/src/app/activity/page.test.tsx | 3 ++- frontend/src/app/approvals/page.test.tsx | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_security_headers_middleware.py b/backend/tests/test_security_headers_middleware.py index 844aeccf..000e03c8 100644 --- a/backend/tests/test_security_headers_middleware.py +++ b/backend/tests/test_security_headers_middleware.py @@ -38,7 +38,9 @@ async def test_security_headers_middleware_appends_lowercase_raw_header_names() sent_messages.append(message) middleware = SecurityHeadersMiddleware(app, x_frame_options="SAMEORIGIN") - await middleware({"type": "http", "method": "GET", "path": "/", "headers": []}, lambda: None, capture) + await middleware( + {"type": "http", "method": "GET", "path": "/", "headers": []}, lambda: None, capture + ) response_start = next( message for message in sent_messages if message.get("type") == "http.response.start" diff --git a/frontend/src/app/activity/page.test.tsx b/frontend/src/app/activity/page.test.tsx index 388a5826..cabeeb78 100644 --- a/frontend/src/app/activity/page.test.tsx +++ b/frontend/src/app/activity/page.test.tsx @@ -63,7 +63,8 @@ vi.mock("@clerk/nextjs", () => { describe("/activity auth boundary", () => { it("renders without ClerkProvider runtime errors when publishable key is a placeholder", () => { const previousAuthMode = process.env.NEXT_PUBLIC_AUTH_MODE; - const previousPublishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + const previousPublishableKey = + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; // Simulate CI/secretless env where an arbitrary placeholder value may be present. // AuthProvider should treat this as disabled, and the auth wrappers must not render diff --git a/frontend/src/app/approvals/page.test.tsx b/frontend/src/app/approvals/page.test.tsx index cb812fac..1438ba58 100644 --- a/frontend/src/app/approvals/page.test.tsx +++ b/frontend/src/app/approvals/page.test.tsx @@ -60,7 +60,8 @@ vi.mock("@clerk/nextjs", () => { describe("/approvals auth boundary", () => { it("renders without ClerkProvider runtime errors when publishable key is a placeholder", () => { const previousAuthMode = process.env.NEXT_PUBLIC_AUTH_MODE; - const previousPublishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + const previousPublishableKey = + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; process.env.NEXT_PUBLIC_AUTH_MODE = "local"; process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = "placeholder"; From adad72373c723991786a97a1acd19bfec2686a2c Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 18:29:59 +0530 Subject: [PATCH 25/28] feat(tasks): add notification messages for task assignment and rework --- backend/app/api/tasks.py | 239 ++++++++++++++-- backend/tests/test_task_agent_permissions.py | 275 ++++++++++++++++++- 2 files changed, 489 insertions(+), 25 deletions(-) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 61b51c18..d846b8f8 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -576,6 +576,75 @@ async def _send_agent_task_message( ) +def _assignment_notification_message(*, board: Board, task: Task, agent: Agent) -> str: + description = _truncate_snippet(task.description or "") + details = [ + f"Board: {board.name}", + f"Task: {task.title}", + f"Task ID: {task.id}", + f"Status: {task.status}", + ] + if description: + details.append(f"Description: {description}") + if task.status == "review" and agent.is_board_lead: + action = ( + "Take action: review the deliverables now. " + "Approve by moving to done or return to inbox with clear feedback." + ) + return "TASK READY FOR LEAD REVIEW\n" + "\n".join(details) + f"\n\n{action}" + return ( + "TASK ASSIGNED\n" + + "\n".join(details) + + ("\n\nTake action: open the task and begin work. " "Post updates as task comments.") + ) + + +def _rework_notification_message( + *, + board: Board, + task: Task, + feedback: str | None, +) -> str: + description = _truncate_snippet(task.description or "") + details = [ + f"Board: {board.name}", + f"Task: {task.title}", + f"Task ID: {task.id}", + f"Status: {task.status}", + ] + if description: + details.append(f"Description: {description}") + requested_changes = ( + _truncate_snippet(feedback) + if feedback and feedback.strip() + else "Lead requested changes. Review latest task comments for exact required updates." + ) + return ( + "CHANGES REQUESTED\n" + + "\n".join(details) + + "\n\nRequested changes:\n" + + requested_changes + + "\n\nTake action: address the requested changes, then move the task back to review." + ) + + +async def _latest_task_comment_by_agent( + session: AsyncSession, + *, + task_id: UUID, + agent_id: UUID, +) -> str | None: + statement = ( + select(col(ActivityEvent.message)) + .where(col(ActivityEvent.task_id) == task_id) + .where(col(ActivityEvent.event_type) == "task.comment") + .where(col(ActivityEvent.agent_id) == agent_id) + .order_by(desc(col(ActivityEvent.created_at))) + .limit(1) + ) + return (await session.exec(statement)).first() + + async def _notify_agent_on_task_assign( *, session: AsyncSession, @@ -589,20 +658,7 @@ async def _notify_agent_on_task_assign( config = await dispatch.optional_gateway_config_for_board(board) if config is None: return - description = _truncate_snippet(task.description or "") - details = [ - f"Board: {board.name}", - f"Task: {task.title}", - f"Task ID: {task.id}", - f"Status: {task.status}", - ] - if description: - details.append(f"Description: {description}") - message = ( - "TASK ASSIGNED\n" - + "\n".join(details) - + ("\n\nTake action: open the task and begin work. " "Post updates as task comments.") - ) + message = _assignment_notification_message(board=board, task=task, agent=agent) error = await _send_agent_task_message( dispatch=dispatch, session_key=agent.openclaw_session_id, @@ -630,6 +686,57 @@ async def _notify_agent_on_task_assign( await session.commit() +async def _notify_agent_on_task_rework( + *, + session: AsyncSession, + board: Board, + task: Task, + agent: Agent, + lead: Agent, +) -> None: + if not agent.openclaw_session_id: + return + dispatch = GatewayDispatchService(session) + config = await dispatch.optional_gateway_config_for_board(board) + if config is None: + return + feedback = await _latest_task_comment_by_agent( + session, + task_id=task.id, + agent_id=lead.id, + ) + message = _rework_notification_message( + board=board, + task=task, + feedback=feedback, + ) + error = await _send_agent_task_message( + dispatch=dispatch, + session_key=agent.openclaw_session_id, + config=config, + agent_name=agent.name, + message=message, + ) + if error is None: + record_activity( + session, + event_type="task.rework_notified", + message=f"Assignee notified about requested changes: {agent.name}.", + agent_id=agent.id, + task_id=task.id, + ) + await session.commit() + else: + record_activity( + session, + event_type="task.rework_notify_failed", + message=f"Rework notify failed: {error}", + agent_id=agent.id, + task_id=task.id, + ) + await session.commit() + + async def notify_agent_on_task_assign( *, session: AsyncSession, @@ -1948,7 +2055,39 @@ async def _lead_apply_assignment( update.task.assigned_agent_id = agent.id -def _lead_apply_status(update: _TaskUpdateInput) -> None: +async def _last_worker_who_moved_task_to_review( + session: AsyncSession, + *, + task_id: UUID, + board_id: UUID, + lead_agent_id: UUID, +) -> UUID | None: + statement = ( + select(col(ActivityEvent.agent_id)) + .where(col(ActivityEvent.task_id) == task_id) + .where(col(ActivityEvent.event_type) == "task.status_changed") + .where(col(ActivityEvent.message).like("Task moved to review:%")) + .where(col(ActivityEvent.agent_id).is_not(None)) + .order_by(desc(col(ActivityEvent.created_at))) + ) + candidate_ids = list(await session.exec(statement)) + for candidate_id in candidate_ids: + if candidate_id is None or candidate_id == lead_agent_id: + continue + candidate = await Agent.objects.by_id(candidate_id).first(session) + if candidate is None: + continue + if candidate.board_id != board_id or candidate.is_board_lead: + continue + return candidate.id + return None + + +async def _lead_apply_status( + session: AsyncSession, + *, + update: _TaskUpdateInput, +) -> None: if "status" not in update.updates: return if update.task.status != "review": @@ -1969,7 +2108,12 @@ def _lead_apply_status(update: _TaskUpdateInput) -> None: ), ) if target_status == "inbox": - update.task.assigned_agent_id = None + update.task.assigned_agent_id = await _last_worker_who_moved_task_to_review( + session, + task_id=update.task.id, + board_id=update.board_id, + lead_agent_id=update.actor.agent.id, + ) update.task.in_progress_at = None update.task.status = target_status @@ -2001,6 +2145,21 @@ async def _lead_notify_new_assignee( else None ) if board: + if ( + update.previous_status == "review" + and update.task.status == "inbox" + and update.actor.actor_type == "agent" + and update.actor.agent + and update.actor.agent.is_board_lead + ): + await _notify_agent_on_task_rework( + session=session, + board=board, + task=update.task, + agent=assigned_agent, + lead=update.actor.agent, + ) + return await _notify_agent_on_task_assign( session=session, board=board, @@ -2037,7 +2196,7 @@ async def _apply_lead_task_update( raise _blocked_task_error(blocked_by) await _lead_apply_assignment(session, update=update) - _lead_apply_status(update) + await _lead_apply_status(session, update=update) await _require_no_pending_approval_for_status_change_when_enabled( session, board_id=update.board_id, @@ -2306,6 +2465,23 @@ async def _record_task_update_activity( await session.commit() +async def _assign_review_task_to_lead( + session: AsyncSession, + *, + update: _TaskUpdateInput, +) -> None: + if update.task.status != "review" or update.previous_status == "review": + return + lead = ( + await Agent.objects.filter_by(board_id=update.board_id) + .filter(col(Agent.is_board_lead).is_(True)) + .first(session) + ) + if lead is None: + return + update.task.assigned_agent_id = lead.id + + async def _notify_task_update_assignment_changes( session: AsyncSession, *, @@ -2333,12 +2509,6 @@ async def _notify_task_update_assignment_changes( or update.task.assigned_agent_id == update.previous_assigned ): return - if ( - update.actor.actor_type == "agent" - and update.actor.agent - and update.task.assigned_agent_id == update.actor.agent.id - ): - return assigned_agent = await Agent.objects.by_id(update.task.assigned_agent_id).first( session, ) @@ -2349,6 +2519,28 @@ async def _notify_task_update_assignment_changes( if update.task.board_id else None ) + if ( + update.previous_status == "review" + and update.task.status == "inbox" + and update.actor.actor_type == "agent" + and update.actor.agent + and update.actor.agent.is_board_lead + ): + if board: + await _notify_agent_on_task_rework( + session=session, + board=board, + task=update.task, + agent=assigned_agent, + lead=update.actor.agent, + ) + return + if ( + update.actor.actor_type == "agent" + and update.actor.agent + and update.task.assigned_agent_id == update.actor.agent.id + ): + return if board: await _notify_agent_on_task_assign( session=session, @@ -2406,6 +2598,7 @@ async def _finalize_updated_task( review_comment_since, ): raise _comment_validation_error() + await _assign_review_task_to_lead(session, update=update) if update.tag_ids is not None: normalized = ( diff --git a/backend/tests/test_task_agent_permissions.py b/backend/tests/test_task_agent_permissions.py index 44b3b6e2..60811f5f 100644 --- a/backend/tests/test_task_agent_permissions.py +++ b/backend/tests/test_task_agent_permissions.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Any from uuid import uuid4 import pytest @@ -11,6 +12,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from app.api import tasks as tasks_api from app.api.deps import ActorContext from app.core.time import utcnow +from app.models.activity_events import ActivityEvent from app.models.agents import Agent from app.models.boards import Board from app.models.gateways import Gateway @@ -326,7 +328,7 @@ async def test_non_lead_agent_forbidden_for_lead_only_patch_fields() -> None: @pytest.mark.asyncio -async def test_non_lead_agent_moves_task_to_review_and_task_unassigns() -> None: +async def test_non_lead_agent_moves_task_to_review_and_reassigns_to_lead() -> None: engine = await _make_engine() try: async with await _make_session(engine) as session: @@ -334,6 +336,7 @@ async def test_non_lead_agent_moves_task_to_review_and_task_unassigns() -> None: board_id = uuid4() gateway_id = uuid4() worker_id = uuid4() + lead_id = uuid4() task_id = uuid4() in_progress_at = utcnow() @@ -365,6 +368,16 @@ async def test_non_lead_agent_moves_task_to_review_and_task_unassigns() -> None: status="online", ), ) + session.add( + Agent( + id=lead_id, + name="Lead Agent", + board_id=board_id, + gateway_id=gateway_id, + status="online", + is_board_lead=True, + ), + ) session.add( Task( id=task_id, @@ -391,7 +404,7 @@ async def test_non_lead_agent_moves_task_to_review_and_task_unassigns() -> None: ) assert updated.status == "review" - assert updated.assigned_agent_id is None + assert updated.assigned_agent_id == lead_id assert updated.in_progress_at is None refreshed_task = ( @@ -399,6 +412,264 @@ async def test_non_lead_agent_moves_task_to_review_and_task_unassigns() -> None: ).first() assert refreshed_task is not None assert refreshed_task.previous_in_progress_at == in_progress_at + assert refreshed_task.assigned_agent_id == lead_id + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_non_lead_agent_move_to_review_reassigns_to_lead_and_sends_review_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + org_id = uuid4() + board_id = uuid4() + gateway_id = uuid4() + worker_id = uuid4() + lead_id = uuid4() + task_id = uuid4() + + session.add(Organization(id=org_id, name="org")) + session.add( + Gateway( + id=gateway_id, + organization_id=org_id, + name="gateway", + url="https://gateway.local", + workspace_root="/tmp/workspace", + ), + ) + session.add( + Board( + id=board_id, + organization_id=org_id, + name="board", + slug="board", + gateway_id=gateway_id, + ), + ) + session.add( + Agent( + id=worker_id, + name="worker", + board_id=board_id, + gateway_id=gateway_id, + status="online", + ), + ) + session.add( + Agent( + id=lead_id, + name="Lead Agent", + board_id=board_id, + gateway_id=gateway_id, + status="online", + is_board_lead=True, + openclaw_session_id="lead-session", + ), + ) + session.add( + Task( + id=task_id, + board_id=board_id, + title="assigned task", + description="done and ready", + status="in_progress", + assigned_agent_id=worker_id, + in_progress_at=utcnow(), + ), + ) + await session.commit() + + sent: dict[str, str] = {} + + class _FakeDispatch: + def __init__(self, _session: AsyncSession) -> None: + pass + + async def optional_gateway_config_for_board(self, _board: Board) -> object: + return object() + + async def _fake_send_agent_task_message( + *, + dispatch: Any, + session_key: str, + config: Any, + agent_name: str, + message: str, + ) -> None: + _ = dispatch, config + sent["session_key"] = session_key + sent["agent_name"] = agent_name + sent["message"] = message + return None + + monkeypatch.setattr(tasks_api, "GatewayDispatchService", _FakeDispatch) + monkeypatch.setattr(tasks_api, "_send_agent_task_message", _fake_send_agent_task_message) + + task = (await session.exec(select(Task).where(col(Task.id) == task_id))).first() + assert task is not None + actor = (await session.exec(select(Agent).where(col(Agent.id) == worker_id))).first() + assert actor is not None + + updated = await tasks_api.update_task( + payload=TaskUpdate(status="review", comment="Moving to review."), + task=task, + session=session, + actor=ActorContext(actor_type="agent", agent=actor), + ) + + assert updated.status == "review" + assert updated.assigned_agent_id == lead_id + assert sent["session_key"] == "lead-session" + assert sent["agent_name"] == "Lead Agent" + assert "TASK READY FOR LEAD REVIEW" in sent["message"] + assert "review the deliverables" in sent["message"] + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_lead_moves_review_task_to_inbox_and_reassigns_last_worker_with_rework_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + org_id = uuid4() + board_id = uuid4() + gateway_id = uuid4() + worker_id = uuid4() + lead_id = uuid4() + task_id = uuid4() + + session.add(Organization(id=org_id, name="org")) + session.add( + Gateway( + id=gateway_id, + organization_id=org_id, + name="gateway", + url="https://gateway.local", + workspace_root="/tmp/workspace", + ), + ) + session.add( + Board( + id=board_id, + organization_id=org_id, + name="board", + slug="board", + gateway_id=gateway_id, + ), + ) + session.add( + Agent( + id=worker_id, + name="worker", + board_id=board_id, + gateway_id=gateway_id, + status="online", + openclaw_session_id="worker-session", + ), + ) + session.add( + Agent( + id=lead_id, + name="Lead Agent", + board_id=board_id, + gateway_id=gateway_id, + status="online", + is_board_lead=True, + openclaw_session_id="lead-session", + ), + ) + session.add( + Task( + id=task_id, + board_id=board_id, + title="assigned task", + description="ready", + status="in_progress", + assigned_agent_id=worker_id, + in_progress_at=utcnow(), + ), + ) + await session.commit() + + sent: list[dict[str, str]] = [] + + class _FakeDispatch: + def __init__(self, _session: AsyncSession) -> None: + pass + + async def optional_gateway_config_for_board(self, _board: Board) -> object: + return object() + + async def _fake_send_agent_task_message( + *, + dispatch: Any, + session_key: str, + config: Any, + agent_name: str, + message: str, + ) -> None: + _ = dispatch, config + sent.append( + { + "session_key": session_key, + "agent_name": agent_name, + "message": message, + }, + ) + return None + + monkeypatch.setattr(tasks_api, "GatewayDispatchService", _FakeDispatch) + monkeypatch.setattr(tasks_api, "_send_agent_task_message", _fake_send_agent_task_message) + + task = (await session.exec(select(Task).where(col(Task.id) == task_id))).first() + assert task is not None + worker = (await session.exec(select(Agent).where(col(Agent.id) == worker_id))).first() + assert worker is not None + lead = (await session.exec(select(Agent).where(col(Agent.id) == lead_id))).first() + assert lead is not None + + moved_to_review = await tasks_api.update_task( + payload=TaskUpdate(status="review", comment="Ready for review."), + task=task, + session=session, + actor=ActorContext(actor_type="agent", agent=worker), + ) + assert moved_to_review.status == "review" + assert moved_to_review.assigned_agent_id == lead_id + + session.add( + ActivityEvent( + event_type="task.comment", + task_id=task_id, + agent_id=lead_id, + message="Please update error handling and add tests for edge cases.", + ), + ) + await session.commit() + + review_task = (await session.exec(select(Task).where(col(Task.id) == task_id))).first() + assert review_task is not None + reverted = await tasks_api.update_task( + payload=TaskUpdate(status="inbox"), + task=review_task, + session=session, + actor=ActorContext(actor_type="agent", agent=lead), + ) + + assert reverted.status == "inbox" + assert reverted.assigned_agent_id == worker_id + worker_messages = [item for item in sent if item["session_key"] == "worker-session"] + assert worker_messages + final_message = worker_messages[-1]["message"] + assert "CHANGES REQUESTED" in final_message + assert "Please update error handling and add tests for edge cases." in final_message finally: await engine.dispose() From 6b21ea6f9978648e136697dff50b6310549919fd Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 26 Feb 2026 00:31:47 +0530 Subject: [PATCH 26/28] feat(boards): add 'comment_required_for_review' rule and update related logic --- backend/app/api/tasks.py | 27 +++++- backend/app/models/boards.py | 1 + backend/app/schemas/boards.py | 2 + backend/app/services/openclaw/provisioning.py | 1 + ..._comment_required_for_review_board_rule.py | 43 ++++++++++ backend/templates/BOARD_HEARTBEAT.md.j2 | 1 + backend/templates/README.md | 1 + backend/tests/test_board_schema.py | 3 + backend/tests/test_task_agent_permissions.py | 85 ++++++++++++++++++- .../src/api/generated/model/boardCreate.ts | 1 + frontend/src/api/generated/model/boardRead.ts | 1 + .../src/api/generated/model/boardUpdate.ts | 1 + .../src/app/boards/[boardId]/edit/page.tsx | 41 +++++++++ 13 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/versions/f1b2c3d4e5a6_add_comment_required_for_review_board_rule.py diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index d846b8f8..e5014c5f 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -259,6 +259,19 @@ async def _require_review_before_done_when_enabled( raise _review_required_for_done_error() +async def _require_comment_for_review_when_enabled( + session: AsyncSession, + *, + board_id: UUID, +) -> bool: + requires_comment = ( + await session.exec( + select(col(Board.comment_required_for_review)).where(col(Board.id) == board_id), + ) + ).first() + return bool(requires_comment) + + async def _require_no_pending_approval_for_status_change_when_enabled( session: AsyncSession, *, @@ -2088,6 +2101,9 @@ async def _lead_apply_status( *, update: _TaskUpdateInput, ) -> None: + if update.actor.actor_type != "agent" or update.actor.agent is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + lead_agent = update.actor.agent if "status" not in update.updates: return if update.task.status != "review": @@ -2112,7 +2128,7 @@ async def _lead_apply_status( session, task_id=update.task.id, board_id=update.board_id, - lead_agent_id=update.actor.agent.id, + lead_agent_id=lead_agent.id, ) update.task.in_progress_at = None update.task.status = target_status @@ -2581,9 +2597,12 @@ async def _finalize_updated_task( update.task.updated_at = utcnow() status_raw = update.updates.get("status") - # Entering review requires either a new comment or a valid recent one to - # ensure reviewers get context on readiness. - if status_raw == "review": + # Entering review can require a new comment or valid recent context when + # the board-level rule is enabled. + if status_raw == "review" and await _require_comment_for_review_when_enabled( + session, + board_id=update.board_id, + ): comment_text = (update.comment or "").strip() review_comment_author = update.task.assigned_agent_id or update.previous_assigned review_comment_since = ( diff --git a/backend/app/models/boards.py b/backend/app/models/boards.py index 5d26340b..2023c9c3 100644 --- a/backend/app/models/boards.py +++ b/backend/app/models/boards.py @@ -41,6 +41,7 @@ class Board(TenantScoped, table=True): goal_source: str | None = None require_approval_for_done: bool = Field(default=True) require_review_before_done: bool = Field(default=False) + comment_required_for_review: bool = Field(default=False) block_status_changes_with_pending_approval: bool = Field(default=False) only_lead_can_change_status: bool = Field(default=False) max_agents: int = Field(default=1) diff --git a/backend/app/schemas/boards.py b/backend/app/schemas/boards.py index c954a1d5..3d2cdef2 100644 --- a/backend/app/schemas/boards.py +++ b/backend/app/schemas/boards.py @@ -31,6 +31,7 @@ class BoardBase(SQLModel): goal_source: str | None = None require_approval_for_done: bool = True require_review_before_done: bool = False + comment_required_for_review: bool = False block_status_changes_with_pending_approval: bool = False only_lead_can_change_status: bool = False max_agents: int = Field(default=1, ge=0) @@ -75,6 +76,7 @@ class BoardUpdate(SQLModel): goal_source: str | None = None require_approval_for_done: bool | None = None require_review_before_done: bool | None = None + comment_required_for_review: bool | None = None block_status_changes_with_pending_approval: bool | None = None only_lead_can_change_status: bool | None = None max_agents: int | None = Field(default=None, ge=0) diff --git a/backend/app/services/openclaw/provisioning.py b/backend/app/services/openclaw/provisioning.py index f80a72a4..a1665c63 100644 --- a/backend/app/services/openclaw/provisioning.py +++ b/backend/app/services/openclaw/provisioning.py @@ -363,6 +363,7 @@ def _build_context( "board_goal_confirmed": str(board.goal_confirmed).lower(), "board_rule_require_approval_for_done": str(board.require_approval_for_done).lower(), "board_rule_require_review_before_done": str(board.require_review_before_done).lower(), + "board_rule_comment_required_for_review": str(board.comment_required_for_review).lower(), "board_rule_block_status_changes_with_pending_approval": str( board.block_status_changes_with_pending_approval ).lower(), diff --git a/backend/migrations/versions/f1b2c3d4e5a6_add_comment_required_for_review_board_rule.py b/backend/migrations/versions/f1b2c3d4e5a6_add_comment_required_for_review_board_rule.py new file mode 100644 index 00000000..ff530d45 --- /dev/null +++ b/backend/migrations/versions/f1b2c3d4e5a6_add_comment_required_for_review_board_rule.py @@ -0,0 +1,43 @@ +"""add comment-required-for-review board rule + +Revision ID: f1b2c3d4e5a6 +Revises: e3a1b2c4d5f6 +Create Date: 2026-02-25 00:00:00.000000 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "f1b2c3d4e5a6" +down_revision = "e3a1b2c4d5f6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + board_columns = {column["name"] for column in inspector.get_columns("boards")} + if "comment_required_for_review" not in board_columns: + op.add_column( + "boards", + sa.Column( + "comment_required_for_review", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + board_columns = {column["name"] for column in inspector.get_columns("boards")} + if "comment_required_for_review" in board_columns: + op.drop_column("boards", "comment_required_for_review") diff --git a/backend/templates/BOARD_HEARTBEAT.md.j2 b/backend/templates/BOARD_HEARTBEAT.md.j2 index 2c6ab234..5e9ef6bd 100644 --- a/backend/templates/BOARD_HEARTBEAT.md.j2 +++ b/backend/templates/BOARD_HEARTBEAT.md.j2 @@ -162,6 +162,7 @@ Before execution: ### Board Rule Snapshot - `require_review_before_done`: `{{ board_rule_require_review_before_done }}` - `require_approval_for_done`: `{{ board_rule_require_approval_for_done }}` +- `comment_required_for_review`: `{{ board_rule_comment_required_for_review }}` - `block_status_changes_with_pending_approval`: `{{ board_rule_block_status_changes_with_pending_approval }}` - `only_lead_can_change_status`: `{{ board_rule_only_lead_can_change_status }}` - `max_agents`: `{{ board_rule_max_agents }}` diff --git a/backend/templates/README.md b/backend/templates/README.md index dff27cf1..819f36eb 100644 --- a/backend/templates/README.md +++ b/backend/templates/README.md @@ -133,6 +133,7 @@ This avoids relying on startup hooks to populate `api/openapi.json`. - `workspace_path` - `board_rule_require_approval_for_done` - `board_rule_require_review_before_done` +- `board_rule_comment_required_for_review` - `board_rule_block_status_changes_with_pending_approval` - `board_rule_only_lead_can_change_status` - `board_rule_max_agents` diff --git a/backend/tests/test_board_schema.py b/backend/tests/test_board_schema.py index f0aa4516..a66a07f5 100644 --- a/backend/tests/test_board_schema.py +++ b/backend/tests/test_board_schema.py @@ -86,6 +86,7 @@ def test_board_rule_toggles_have_expected_defaults() -> None: ) assert created.require_approval_for_done is True assert created.require_review_before_done is False + assert created.comment_required_for_review is False assert created.block_status_changes_with_pending_approval is False assert created.only_lead_can_change_status is False assert created.max_agents == 1 @@ -93,12 +94,14 @@ def test_board_rule_toggles_have_expected_defaults() -> None: updated = BoardUpdate( require_approval_for_done=False, require_review_before_done=True, + comment_required_for_review=True, block_status_changes_with_pending_approval=True, only_lead_can_change_status=True, max_agents=3, ) assert updated.require_approval_for_done is False assert updated.require_review_before_done is True + assert updated.comment_required_for_review is True assert updated.block_status_changes_with_pending_approval is True assert updated.only_lead_can_change_status is True assert updated.max_agents == 3 diff --git a/backend/tests/test_task_agent_permissions.py b/backend/tests/test_task_agent_permissions.py index 60811f5f..44e3e7a8 100644 --- a/backend/tests/test_task_agent_permissions.py +++ b/backend/tests/test_task_agent_permissions.py @@ -756,7 +756,89 @@ async def test_non_lead_agent_comment_in_review_without_status_does_not_reassign @pytest.mark.asyncio -async def test_non_lead_agent_moves_to_review_without_comment_or_recent_comment_fails() -> None: +async def test_non_lead_agent_moves_to_review_without_comment_when_rule_disabled() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + org_id = uuid4() + board_id = uuid4() + gateway_id = uuid4() + worker_id = uuid4() + lead_id = uuid4() + task_id = uuid4() + + session.add(Organization(id=org_id, name="org")) + session.add( + Gateway( + id=gateway_id, + organization_id=org_id, + name="gateway", + url="https://gateway.local", + workspace_root="/tmp/workspace", + ), + ) + session.add( + Board( + id=board_id, + organization_id=org_id, + name="board", + slug="board", + gateway_id=gateway_id, + comment_required_for_review=False, + ), + ) + session.add( + Agent( + id=worker_id, + name="worker", + board_id=board_id, + gateway_id=gateway_id, + status="online", + ), + ) + session.add( + Agent( + id=lead_id, + name="Lead Agent", + board_id=board_id, + gateway_id=gateway_id, + status="online", + is_board_lead=True, + ), + ) + session.add( + Task( + id=task_id, + board_id=board_id, + title="assigned task", + description="", + status="in_progress", + assigned_agent_id=worker_id, + in_progress_at=utcnow(), + ), + ) + await session.commit() + + task = (await session.exec(select(Task).where(col(Task.id) == task_id))).first() + assert task is not None + actor = (await session.exec(select(Agent).where(col(Agent.id) == worker_id))).first() + assert actor is not None + + updated = await tasks_api.update_task( + payload=TaskUpdate(status="review"), + task=task, + session=session, + actor=ActorContext(actor_type="agent", agent=actor), + ) + + assert updated.status == "review" + assert updated.assigned_agent_id == lead_id + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_non_lead_agent_moves_to_review_without_comment_or_recent_comment_fails_when_rule_enabled() -> None: engine = await _make_engine() try: async with await _make_session(engine) as session: @@ -783,6 +865,7 @@ async def test_non_lead_agent_moves_to_review_without_comment_or_recent_comment_ name="board", slug="board", gateway_id=gateway_id, + comment_required_for_review=True, ), ) session.add( diff --git a/frontend/src/api/generated/model/boardCreate.ts b/frontend/src/api/generated/model/boardCreate.ts index 44163dac..b518bd82 100644 --- a/frontend/src/api/generated/model/boardCreate.ts +++ b/frontend/src/api/generated/model/boardCreate.ts @@ -23,6 +23,7 @@ export interface BoardCreate { goal_source?: string | null; require_approval_for_done?: boolean; require_review_before_done?: boolean; + comment_required_for_review?: boolean; block_status_changes_with_pending_approval?: boolean; only_lead_can_change_status?: boolean; /** @minimum 0 */ diff --git a/frontend/src/api/generated/model/boardRead.ts b/frontend/src/api/generated/model/boardRead.ts index f7482496..15f49b9a 100644 --- a/frontend/src/api/generated/model/boardRead.ts +++ b/frontend/src/api/generated/model/boardRead.ts @@ -23,6 +23,7 @@ export interface BoardRead { goal_source?: string | null; require_approval_for_done?: boolean; require_review_before_done?: boolean; + comment_required_for_review?: boolean; block_status_changes_with_pending_approval?: boolean; only_lead_can_change_status?: boolean; /** @minimum 0 */ diff --git a/frontend/src/api/generated/model/boardUpdate.ts b/frontend/src/api/generated/model/boardUpdate.ts index 19c57fed..3a1b359f 100644 --- a/frontend/src/api/generated/model/boardUpdate.ts +++ b/frontend/src/api/generated/model/boardUpdate.ts @@ -23,6 +23,7 @@ export interface BoardUpdate { goal_source?: string | null; require_approval_for_done?: boolean | null; require_review_before_done?: boolean | null; + comment_required_for_review?: boolean | null; block_status_changes_with_pending_approval?: boolean | null; only_lead_can_change_status?: boolean | null; max_agents?: number | null; diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx index 966a0702..2248a681 100644 --- a/frontend/src/app/boards/[boardId]/edit/page.tsx +++ b/frontend/src/app/boards/[boardId]/edit/page.tsx @@ -291,6 +291,9 @@ export default function EditBoardPage() { const [requireReviewBeforeDone, setRequireReviewBeforeDone] = useState< boolean | undefined >(undefined); + const [commentRequiredForReview, setCommentRequiredForReview] = useState< + boolean | undefined + >(undefined); const [ blockStatusChangesWithPendingApproval, setBlockStatusChangesWithPendingApproval, @@ -504,6 +507,8 @@ export default function EditBoardPage() { requireApprovalForDone ?? baseBoard?.require_approval_for_done ?? true; const resolvedRequireReviewBeforeDone = requireReviewBeforeDone ?? baseBoard?.require_review_before_done ?? false; + const resolvedCommentRequiredForReview = + commentRequiredForReview ?? baseBoard?.comment_required_for_review ?? false; const resolvedBlockStatusChangesWithPendingApproval = blockStatusChangesWithPendingApproval ?? baseBoard?.block_status_changes_with_pending_approval ?? @@ -588,6 +593,7 @@ export default function EditBoardPage() { setObjective(updated.objective ?? ""); setRequireApprovalForDone(updated.require_approval_for_done ?? true); setRequireReviewBeforeDone(updated.require_review_before_done ?? false); + setCommentRequiredForReview(updated.comment_required_for_review ?? false); setBlockStatusChangesWithPendingApproval( updated.block_status_changes_with_pending_approval ?? false, ); @@ -656,6 +662,7 @@ export default function EditBoardPage() { : resolvedObjective.trim() || null, require_approval_for_done: resolvedRequireApprovalForDone, require_review_before_done: resolvedRequireReviewBeforeDone, + comment_required_for_review: resolvedCommentRequiredForReview, block_status_changes_with_pending_approval: resolvedBlockStatusChangesWithPendingApproval, only_lead_can_change_status: resolvedOnlyLeadCanChangeStatus, @@ -1016,6 +1023,40 @@ export default function EditBoardPage() { +
+ + + + Require comment for review + + + Require a task comment when moving status to{" "} + review. + + +