Compare commits
10 Commits
b702ade0cc
...
c56b173dcc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c56b173dcc | ||
|
|
1c8a531f6a | ||
|
|
522761bc26 | ||
|
|
6c3c9913db | ||
|
|
7a3a2366da | ||
|
|
5912048b85 | ||
|
|
1d8039e760 | ||
|
|
47dfc1b52f | ||
|
|
1d63bd0148 | ||
|
|
cd68446c42 |
@@ -27,7 +27,6 @@ from app.models.tasks import Task
|
||||
from app.schemas.agents import (
|
||||
AgentCreate,
|
||||
AgentHeartbeat,
|
||||
AgentHeartbeatCreate,
|
||||
AgentNudge,
|
||||
AgentRead,
|
||||
)
|
||||
@@ -45,6 +44,7 @@ from app.schemas.gateway_coordination import (
|
||||
GatewayMainAskUserRequest,
|
||||
GatewayMainAskUserResponse,
|
||||
)
|
||||
from app.schemas.health import AgentHealthStatusResponse
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.schemas.tags import TagRef
|
||||
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
|
||||
@@ -186,6 +186,73 @@ def _guard_task_access(agent_ctx: AgentAuthContext, task: Task) -> None:
|
||||
OpenClawAuthorizationPolicy.require_board_write_access(allowed=allowed)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/healthz",
|
||||
response_model=AgentHealthStatusResponse,
|
||||
tags=AGENT_ALL_ROLE_TAGS,
|
||||
summary="Agent Auth Health Check",
|
||||
description=(
|
||||
"Token-authenticated liveness probe for agent API clients.\n\n"
|
||||
"Use this endpoint when the caller needs to verify both service availability "
|
||||
"and agent-token validity in one request."
|
||||
),
|
||||
openapi_extra={
|
||||
"x-llm-intent": "agent_auth_health",
|
||||
"x-when-to-use": [
|
||||
"Verify agent token validity before entering an automation loop",
|
||||
"Confirm agent API availability with caller identity context",
|
||||
],
|
||||
"x-when-not-to-use": [
|
||||
"General infrastructure liveness checks that do not require auth context",
|
||||
"Task, board, or messaging workflow actions",
|
||||
],
|
||||
"x-required-actor": "any_agent",
|
||||
"x-prerequisites": [
|
||||
"Authenticated agent token via X-Agent-Token header",
|
||||
],
|
||||
"x-side-effects": [
|
||||
"May refresh agent last-seen presence metadata via auth middleware",
|
||||
],
|
||||
"x-negative-guidance": [
|
||||
"Do not parse this response as an array.",
|
||||
"Do not use this endpoint for task routing decisions.",
|
||||
],
|
||||
"x-routing-policy": [
|
||||
"Use this as the first probe for agent-scoped automation health.",
|
||||
"Use /healthz only for unauthenticated service-level liveness checks.",
|
||||
],
|
||||
"x-routing-policy-examples": [
|
||||
{
|
||||
"input": {
|
||||
"intent": "agent startup probe with token verification",
|
||||
"required_privilege": "any_agent",
|
||||
},
|
||||
"decision": "agent_auth_health",
|
||||
},
|
||||
{
|
||||
"input": {
|
||||
"intent": "platform-level probe with no agent token",
|
||||
"required_privilege": "none",
|
||||
},
|
||||
"decision": "service_healthz",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
def agent_healthz(
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> AgentHealthStatusResponse:
|
||||
"""Return authenticated liveness metadata for the current agent token."""
|
||||
return AgentHealthStatusResponse(
|
||||
ok=True,
|
||||
agent_id=agent_ctx.agent.id,
|
||||
board_id=agent_ctx.agent.board_id,
|
||||
gateway_id=agent_ctx.agent.gateway_id,
|
||||
status=agent_ctx.agent.status,
|
||||
is_board_lead=agent_ctx.agent.is_board_lead,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/boards",
|
||||
response_model=DefaultLimitOffsetPage[BoardRead],
|
||||
@@ -1255,8 +1322,8 @@ async def nudge_agent(
|
||||
tags=AGENT_ALL_ROLE_TAGS,
|
||||
summary="Upsert agent heartbeat",
|
||||
description=(
|
||||
"Record liveness for the authenticated agent's current status.\n\n"
|
||||
"Use this when the agent heartbeat loop reports status changes."
|
||||
"Record liveness for the authenticated agent.\n\n"
|
||||
"Use this when the agent heartbeat loop checks in."
|
||||
),
|
||||
openapi_extra={
|
||||
"x-llm-intent": "agent_heartbeat",
|
||||
@@ -1271,7 +1338,7 @@ async def nudge_agent(
|
||||
"x-required-actor": "any_agent",
|
||||
"x-prerequisites": [
|
||||
"Authenticated agent token",
|
||||
"Valid AgentHeartbeatCreate payload",
|
||||
"No request payload required",
|
||||
],
|
||||
"x-side-effects": [
|
||||
"Updates agent heartbeat and status metadata",
|
||||
@@ -1304,7 +1371,6 @@ async def nudge_agent(
|
||||
},
|
||||
)
|
||||
async def agent_heartbeat(
|
||||
payload: AgentHeartbeatCreate,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> AgentRead:
|
||||
@@ -1315,7 +1381,7 @@ async def agent_heartbeat(
|
||||
# Heartbeats must apply to the authenticated agent; agent names are not unique.
|
||||
return await agents_api.heartbeat_agent(
|
||||
agent_id=str(agent_ctx.agent.id),
|
||||
payload=AgentHeartbeat(status=payload.status),
|
||||
payload=AgentHeartbeat(),
|
||||
session=session,
|
||||
actor=_actor(agent_ctx),
|
||||
)
|
||||
|
||||
@@ -58,6 +58,22 @@ INCLUDE_SELF_QUERY = Query(default=False)
|
||||
INCLUDE_DONE_QUERY = Query(default=False)
|
||||
PER_BOARD_TASK_LIMIT_QUERY = Query(default=5, ge=0, le=100)
|
||||
AGENT_BOARD_ROLE_TAGS = cast("list[str | Enum]", ["agent-lead", "agent-worker"])
|
||||
_ERR_GATEWAY_MAIN_AGENT_REQUIRED = (
|
||||
"gateway must have a gateway main agent before boards can be created or updated"
|
||||
)
|
||||
|
||||
|
||||
async def _require_gateway_main_agent(session: AsyncSession, gateway: Gateway) -> None:
|
||||
main_agent = (
|
||||
await Agent.objects.filter_by(gateway_id=gateway.id)
|
||||
.filter(col(Agent.board_id).is_(None))
|
||||
.first(session)
|
||||
)
|
||||
if main_agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=_ERR_GATEWAY_MAIN_AGENT_REQUIRED,
|
||||
)
|
||||
|
||||
|
||||
async def _require_gateway(
|
||||
@@ -77,6 +93,7 @@ async def _require_gateway(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="gateway_id is invalid",
|
||||
)
|
||||
await _require_gateway_main_agent(session, gateway)
|
||||
return gateway
|
||||
|
||||
|
||||
@@ -161,6 +178,11 @@ async def _apply_board_update(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="gateway_id is required",
|
||||
)
|
||||
await _require_gateway(
|
||||
session,
|
||||
board.gateway_id,
|
||||
organization_id=board.organization_id,
|
||||
)
|
||||
board.updated_at = utcnow()
|
||||
return await crud.save(session, board)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
@@ -13,3 +15,29 @@ class HealthStatusResponse(SQLModel):
|
||||
description="Indicates whether the probe check succeeded.",
|
||||
examples=[True],
|
||||
)
|
||||
|
||||
|
||||
class AgentHealthStatusResponse(HealthStatusResponse):
|
||||
"""Agent-authenticated liveness payload for agent route probes."""
|
||||
|
||||
agent_id: UUID = Field(
|
||||
description="Authenticated agent id derived from `X-Agent-Token`.",
|
||||
examples=["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"],
|
||||
)
|
||||
board_id: UUID | None = Field(
|
||||
default=None,
|
||||
description="Board scope for the authenticated agent, when applicable.",
|
||||
examples=["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"],
|
||||
)
|
||||
gateway_id: UUID = Field(
|
||||
description="Gateway owning the authenticated agent.",
|
||||
examples=["cccccccc-cccc-cccc-cccc-cccccccccccc"],
|
||||
)
|
||||
status: str = Field(
|
||||
description="Current persisted lifecycle status for the authenticated agent.",
|
||||
examples=["online", "healthy", "updating"],
|
||||
)
|
||||
is_board_lead: bool = Field(
|
||||
description="Whether the authenticated agent is the board lead.",
|
||||
examples=[False],
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.models.board_webhooks import BoardWebhook
|
||||
from app.models.organization_board_access import OrganizationBoardAccess
|
||||
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
|
||||
from app.models.tag_assignments import TagAssignment
|
||||
from app.models.task_custom_fields import BoardTaskCustomField, TaskCustomFieldValue
|
||||
from app.models.task_dependencies import TaskDependency
|
||||
from app.models.task_fingerprints import TaskFingerprint
|
||||
from app.models.tasks import Task
|
||||
@@ -84,6 +85,12 @@ async def delete_board(session: AsyncSession, *, board: Board) -> OkResponse:
|
||||
col(TagAssignment.task_id).in_(task_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
TaskCustomFieldValue,
|
||||
col(TaskCustomFieldValue.task_id).in_(task_ids),
|
||||
commit=False,
|
||||
)
|
||||
# Keep teardown ordered around FK/reference chains so dependent rows are gone
|
||||
# before deleting their parent task/agent/board records.
|
||||
await crud.delete_where(
|
||||
@@ -129,6 +136,11 @@ async def delete_board(session: AsyncSession, *, board: Board) -> OkResponse:
|
||||
OrganizationInviteBoardAccess,
|
||||
col(OrganizationInviteBoardAccess.board_id) == board.id,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
BoardTaskCustomField,
|
||||
col(BoardTaskCustomField.board_id) == board.id,
|
||||
)
|
||||
|
||||
# Tasks reference agents and have dependent records.
|
||||
# Delete tasks before agents.
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.db import crud
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.models.agents import Agent
|
||||
from app.models.approvals import Approval
|
||||
from app.models.board_webhooks import BoardWebhook
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.tasks import Task
|
||||
from app.schemas.gateways import GatewayTemplatesSyncResult
|
||||
@@ -340,6 +341,14 @@ class GatewayAdminLifecycleService(OpenClawDBService):
|
||||
agent_id=None,
|
||||
commit=False,
|
||||
)
|
||||
await crud.update_where(
|
||||
self.session,
|
||||
BoardWebhook,
|
||||
col(BoardWebhook.agent_id) == agent_id,
|
||||
agent_id=None,
|
||||
updated_at=now,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
async def sync_templates(
|
||||
self,
|
||||
|
||||
@@ -1004,7 +1004,8 @@ def _should_include_bootstrap(
|
||||
def _wakeup_text(agent: Agent, *, verb: str) -> str:
|
||||
return (
|
||||
f"Hello {agent.name}. Your workspace has been {verb}.\n\n"
|
||||
"Start the agent, read AGENTS.md, and begin heartbeats after startup."
|
||||
"Start the agent. If BOOTSTRAP.md exists, read it first, then read AGENTS.md. "
|
||||
"Begin heartbeats after startup."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from app.models.activity_events import ActivityEvent
|
||||
from app.models.agents import Agent
|
||||
from app.models.approvals import Approval
|
||||
from app.models.board_memory import BoardMemory
|
||||
from app.models.board_webhooks import BoardWebhook
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.organizations import Organization
|
||||
@@ -1849,6 +1850,14 @@ class AgentLifecycleService(OpenClawDBService):
|
||||
agent_id=None,
|
||||
commit=False,
|
||||
)
|
||||
await crud.update_where(
|
||||
self.session,
|
||||
BoardWebhook,
|
||||
col(BoardWebhook.agent_id) == agent.id,
|
||||
agent_id=None,
|
||||
updated_at=now,
|
||||
commit=False,
|
||||
)
|
||||
await self.session.delete(agent)
|
||||
await self.session.commit()
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Do this immediately. Do not ask permission to read your workspace.
|
||||
- All work outputs must be sent to Mission Control via HTTP using:
|
||||
- `BASE_URL`: {{ base_url }}
|
||||
- `AUTH_TOKEN`: {{ auth_token }}
|
||||
- Always include header: `X-Agent-Token: $AUTH_TOKEN`
|
||||
- Always include header: `X-Agent-Token: {{ auth_token }}`
|
||||
- Do **not** post any responses in OpenClaw chat.
|
||||
|
||||
## Scope
|
||||
@@ -36,22 +36,22 @@ Do this immediately. Do not ask permission to read your workspace.
|
||||
|
||||
List boards (to find `board_id`):
|
||||
```bash
|
||||
curl -s -X GET "$BASE_URL/api/v1/agent/boards" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
curl -s -X GET "{{ base_url }}/api/v1/agent/boards" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
```
|
||||
|
||||
Send a question or handoff to a board lead (auto-provisions the lead agent if missing):
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/gateway/boards/<BOARD_ID>/lead/message" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
curl -s -X POST "{{ base_url }}/api/v1/agent/gateway/boards/<BOARD_ID>/lead/message" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"kind":"question","correlation_id":"<optional>","content":"..."}'
|
||||
```
|
||||
|
||||
Broadcast to all board leads in this gateway:
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/gateway/leads/broadcast" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
curl -s -X POST "{{ base_url }}/api/v1/agent/gateway/leads/broadcast" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"kind":"question","correlation_id":"<optional>","content":"..."}'
|
||||
```
|
||||
@@ -59,7 +59,7 @@ curl -s -X POST "$BASE_URL/api/v1/agent/gateway/leads/broadcast" \
|
||||
Board lead replies:
|
||||
- Leads reply by writing a NON-chat board memory item with tags like `["gateway_main","lead_reply"]`.
|
||||
- Read replies via:
|
||||
- GET `$BASE_URL/api/v1/agent/boards/<BOARD_ID>/memory?is_chat=false&limit=50`
|
||||
- GET `{{ base_url }}/api/v1/agent/boards/<BOARD_ID>/memory?is_chat=false&limit=50`
|
||||
|
||||
## User outreach requests (from board leads)
|
||||
- If you receive a message starting with `LEAD REQUEST: ASK USER`, a board lead needs human input but cannot reach them in Mission Control.
|
||||
|
||||
@@ -44,14 +44,17 @@ curl -fsS "{{ base_url }}/healthz" >/dev/null
|
||||
{% else %}
|
||||
6) If any fields are blank, leave them blank. Do not invent values.
|
||||
|
||||
7) If `BASE_URL`, `AUTH_TOKEN`, and `BOARD_ID` are set in `TOOLS.md`, check in
|
||||
7) Use the values below from this file/TOOLS.md to check in:
|
||||
- `BASE_URL={{ base_url }}`
|
||||
- `AUTH_TOKEN={{ auth_token }}`
|
||||
- `BOARD_ID={{ board_id }}`
|
||||
to Mission Control to mark the agent online:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/heartbeat" \
|
||||
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||
curl -s -X POST "{{ base_url }}/api/v1/agent/heartbeat" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
|
||||
-d '{"name":"{{ agent_name }}","board_id":"{{ board_id }}","status":"online"}'
|
||||
```
|
||||
|
||||
8) Write a short note to `MEMORY.md` that bootstrap completed and list any
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
This file defines the main agent heartbeat. You are not tied to any board.
|
||||
|
||||
## Required inputs
|
||||
- BASE_URL (e.g. http://localhost:8000) - see USER.md or TOOLS.md
|
||||
- AUTH_TOKEN (agent token) - see USER.md or TOOLS.md
|
||||
- BASE_URL: `{{ base_url }}`
|
||||
- AUTH_TOKEN: `{{ auth_token }}`
|
||||
- AGENT_NAME
|
||||
- AGENT_ID
|
||||
|
||||
@@ -18,7 +18,7 @@ If any required input is missing, stop and request a provisioning update.
|
||||
Use OpenAPI role tags for main-agent endpoints.
|
||||
|
||||
```bash
|
||||
curl -s "$BASE_URL/openapi.json" -o /tmp/openapi.json
|
||||
curl -s "{{ base_url }}/openapi.json" -o /tmp/openapi.json
|
||||
jq -r '
|
||||
.paths | to_entries[] | .key as $path
|
||||
| .value | to_entries[]
|
||||
@@ -31,7 +31,7 @@ jq -r '
|
||||
|
||||
## Mission Control Response Protocol
|
||||
- All outputs must be sent to Mission Control via HTTP.
|
||||
- Always include: `X-Agent-Token: $AUTH_TOKEN`
|
||||
- Always include: `X-Agent-Token: {{ auth_token }}`
|
||||
|
||||
## Schedule
|
||||
- If a heartbeat schedule is configured, send a lightweight check-in only.
|
||||
@@ -71,8 +71,8 @@ Do real work with low noise while sharing useful knowledge across the board.
|
||||
{% endif %}
|
||||
|
||||
## Required Inputs
|
||||
- `BASE_URL`
|
||||
- `AUTH_TOKEN`
|
||||
- `BASE_URL`: `{{ base_url }}`
|
||||
- `AUTH_TOKEN`: `{{ auth_token }}`
|
||||
- `AGENT_NAME`
|
||||
- `AGENT_ID`
|
||||
- `BOARD_ID`
|
||||
@@ -83,7 +83,7 @@ If any required input is missing, stop and request a provisioning update.
|
||||
Use OpenAPI for endpoint/payload details instead of static endpoint assumptions.
|
||||
|
||||
```bash
|
||||
curl -fsS "$BASE_URL/openapi.json" -o /tmp/openapi.json
|
||||
curl -fsS "{{ base_url }}/openapi.json" -o /tmp/openapi.json
|
||||
```
|
||||
|
||||
When selecting endpoints, prioritize `x-llm-intent`, `x-when-to-use`, and `x-routing-policy`
|
||||
@@ -98,7 +98,7 @@ jq -r '
|
||||
| .value | to_entries[]
|
||||
| select((.value.tags // []) | index("agent-lead"))
|
||||
| ((.value.summary // "") | gsub("\\s+"; " ")) as $summary
|
||||
| "\(.key|ascii_upcase)\t\($path)\t\(.value.operationId // "-")\t\(.value.\"x-llm-intent\" // "-")\t\(.value.\"x-when-to-use\" // [] | join(\" | \"))\t\(.value.\"x-routing-policy\" // [] | join(\" | \"))\t\($summary)"
|
||||
| "\(.key|ascii_upcase)\t\($path)\t\(.value.operationId // "-")\t\(.value[\"x-llm-intent\"] // "-")\t\(.value[\"x-when-to-use\"] // [] | join(\" | \"))\t\(.value[\"x-routing-policy\"] // [] | join(\" | \"))\t\($summary)"
|
||||
' /tmp/openapi.json | sort
|
||||
```
|
||||
{% else %}
|
||||
@@ -110,7 +110,7 @@ jq -r '
|
||||
| .value | to_entries[]
|
||||
| select((.value.tags // []) | index("agent-worker"))
|
||||
| ((.value.summary // "") | gsub("\\s+"; " ")) as $summary
|
||||
| "\(.key|ascii_upcase)\t\($path)\t\(.value.operationId // "-")\t\(.value.\"x-llm-intent\" // "-")\t\(.value.\"x-when-to-use\" // [] | join(\" | \"))\t\(.value.\"x-routing-policy\" // [] | join(\" | \"))\t\($summary)"
|
||||
| "\(.key|ascii_upcase)\t\($path)\t\(.value.operationId // "-")\t\(.value[\"x-llm-intent\"] // "-")\t\(.value[\"x-when-to-use\"] // [] | join(\" | \"))\t\(.value[\"x-routing-policy\"] // [] | join(\" | \"))\t\($summary)"
|
||||
' /tmp/openapi.json | sort
|
||||
```
|
||||
{% endif %}
|
||||
@@ -127,11 +127,11 @@ jq -r '
|
||||
- If pre-flight fails due to 5xx/network, do not write memory or task updates.
|
||||
|
||||
## Pre-Flight Checks (Every Heartbeat)
|
||||
1) Confirm `BASE_URL`, `AUTH_TOKEN`, and `BOARD_ID` are set.
|
||||
1) Confirm `BASE_URL`, `AUTH_TOKEN`, and `BOARD_ID` from `TOOLS.md` match this workspace.
|
||||
2) Verify API access:
|
||||
- `GET $BASE_URL/healthz`
|
||||
- `GET $BASE_URL/api/v1/agent/boards`
|
||||
- `GET $BASE_URL/api/v1/agent/boards/$BOARD_ID/tasks`
|
||||
- `GET {{ base_url }}/healthz`
|
||||
- `GET {{ base_url }}/api/v1/agent/boards`
|
||||
- `GET {{ base_url }}/api/v1/agent/boards/{{ board_id }}/tasks`
|
||||
3) If any check fails, stop and retry next heartbeat.
|
||||
|
||||
## Shared Context Pull
|
||||
|
||||
@@ -32,7 +32,7 @@ jq -r '
|
||||
.paths | to_entries[] as $p
|
||||
| $p.value | to_entries[]
|
||||
| select((.value.tags // []) | index("{{ role_tag }}"))
|
||||
| "\(.key|ascii_upcase)\t\($p.key)\t\(.value.operationId // "-")\t\(.value.\"x-llm-intent\" // "-")\t\(.value.\"x-when-to-use\" // [] | join(\" | \") )\t\(.value.\"x-routing-policy\" // [] | join(\" | \"))"
|
||||
| "\(.key|ascii_upcase)\t\($p.key)\t\(.value.operationId // "-")\t\(.value[\"x-llm-intent\"] // "-")\t\(.value[\"x-when-to-use\"] // [] | join(\" | \"))\t\(.value[\"x-routing-policy\"] // [] | join(\" | \"))"
|
||||
' api/openapi.json | sort > api/{{ role_tag }}-operations.tsv
|
||||
```
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
import app.services.openclaw.provisioning_db as agent_service
|
||||
from app.models.board_webhooks import BoardWebhook
|
||||
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
|
||||
|
||||
|
||||
@@ -110,7 +111,10 @@ async def test_delete_agent_as_lead_removes_board_agent(
|
||||
_ = (_self, agent, gateway, delete_files, delete_session)
|
||||
return None
|
||||
|
||||
async def _fake_update_where(*_args, **_kwargs) -> None:
|
||||
update_models: list[type[object]] = []
|
||||
|
||||
async def _fake_update_where(_session, model, *_args, **_kwargs) -> None:
|
||||
update_models.append(model)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(service, "require_board", _fake_require_board)
|
||||
@@ -130,6 +134,7 @@ async def test_delete_agent_as_lead_removes_board_agent(
|
||||
|
||||
assert result.ok is True
|
||||
assert session.deleted and session.deleted[0] == target
|
||||
assert BoardWebhook in update_models
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
34
backend/tests/test_agent_health_api.py
Normal file
34
backend/tests/test_agent_health_api.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.api import agent as agent_api
|
||||
from app.core.agent_auth import AgentAuthContext
|
||||
from app.models.agents import Agent
|
||||
|
||||
|
||||
def _agent_ctx(*, board_id: UUID | None, status: str, is_board_lead: bool) -> AgentAuthContext:
|
||||
return AgentAuthContext(
|
||||
actor_type="agent",
|
||||
agent=Agent(
|
||||
id=uuid4(),
|
||||
board_id=board_id,
|
||||
gateway_id=uuid4(),
|
||||
name="Health Probe Agent",
|
||||
status=status,
|
||||
is_board_lead=is_board_lead,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_agent_healthz_returns_authenticated_agent_context() -> None:
|
||||
agent_ctx = _agent_ctx(board_id=uuid4(), status="online", is_board_lead=True)
|
||||
|
||||
response = agent_api.agent_healthz(agent_ctx=agent_ctx)
|
||||
|
||||
assert response.ok is True
|
||||
assert response.agent_id == agent_ctx.agent.id
|
||||
assert response.board_id == agent_ctx.agent.board_id
|
||||
assert response.gateway_id == agent_ctx.agent.gateway_id
|
||||
assert response.status == "online"
|
||||
assert response.is_board_lead is True
|
||||
@@ -56,6 +56,14 @@ def test_workspace_path_preserves_tilde_in_workspace_root():
|
||||
assert agent_provisioning._workspace_path(agent, "~/.openclaw") == "~/.openclaw/workspace-alice"
|
||||
|
||||
|
||||
def test_wakeup_text_includes_bootstrap_before_agents():
|
||||
agent = _AgentStub(name="Alice")
|
||||
|
||||
text = agent_provisioning._wakeup_text(agent, verb="created")
|
||||
|
||||
assert "If BOOTSTRAP.md exists, read it first, then read AGENTS.md." in text
|
||||
|
||||
|
||||
def test_agent_lifecycle_workspace_path_preserves_tilde_in_workspace_root():
|
||||
assert (
|
||||
AgentLifecycleService.workspace_path("Alice", "~/.openclaw")
|
||||
|
||||
@@ -64,13 +64,14 @@ async def test_delete_board_cleans_org_board_access_rows() -> None:
|
||||
deleted_table_names = [statement.table.name for statement in session.executed]
|
||||
assert "organization_board_access" in deleted_table_names
|
||||
assert "organization_invite_board_access" in deleted_table_names
|
||||
assert "board_task_custom_fields" in deleted_table_names
|
||||
assert board in session.deleted
|
||||
assert session.committed == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_board_cleans_tag_assignments_before_tasks() -> None:
|
||||
"""Deleting a board should remove task-tag links before deleting tasks."""
|
||||
"""Deleting a board should remove task-linked rows before deleting tasks."""
|
||||
session: Any = _FakeSession(exec_results=[[], [uuid4()]])
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
@@ -87,7 +88,11 @@ async def test_delete_board_cleans_tag_assignments_before_tasks() -> None:
|
||||
|
||||
deleted_table_names = [statement.table.name for statement in session.executed]
|
||||
assert "tag_assignments" in deleted_table_names
|
||||
assert "task_custom_field_values" in deleted_table_names
|
||||
assert deleted_table_names.index("tag_assignments") < deleted_table_names.index("tasks")
|
||||
assert deleted_table_names.index("task_custom_field_values") < deleted_table_names.index(
|
||||
"tasks"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
144
backend/tests/test_boards_gateway_agent_validation.py
Normal file
144
backend/tests/test_boards_gateway_agent_validation.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# ruff: noqa: S101
|
||||
"""Validation tests for gateway-main-agent requirements on board mutations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api import boards
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.schemas.boards import BoardUpdate
|
||||
|
||||
|
||||
def _gateway(*, organization_id: UUID) -> Gateway:
|
||||
return Gateway(
|
||||
id=uuid4(),
|
||||
organization_id=organization_id,
|
||||
name="Main Gateway",
|
||||
url="ws://gateway.example/ws",
|
||||
workspace_root="/tmp/openclaw",
|
||||
)
|
||||
|
||||
|
||||
class _FakeAgentQuery:
|
||||
def __init__(self, main_agent: object | None) -> None:
|
||||
self._main_agent = main_agent
|
||||
|
||||
def filter(self, *_args: Any, **_kwargs: Any) -> _FakeAgentQuery:
|
||||
return self
|
||||
|
||||
async def first(self, _session: object) -> object | None:
|
||||
return self._main_agent
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeAgentObjects:
|
||||
main_agent: object | None
|
||||
last_filter_by: dict[str, object] | None = None
|
||||
|
||||
def filter_by(self, **kwargs: object) -> _FakeAgentQuery:
|
||||
self.last_filter_by = kwargs
|
||||
return _FakeAgentQuery(self.main_agent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_gateway_rejects_when_gateway_has_no_main_agent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
organization_id = uuid4()
|
||||
gateway = _gateway(organization_id=organization_id)
|
||||
fake_objects = _FakeAgentObjects(main_agent=None)
|
||||
|
||||
async def _fake_get_by_id(_session: object, _model: object, _gateway_id: object) -> Gateway:
|
||||
return gateway
|
||||
|
||||
monkeypatch.setattr(boards.crud, "get_by_id", _fake_get_by_id)
|
||||
monkeypatch.setattr(boards.Agent, "objects", fake_objects)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await boards._require_gateway(
|
||||
session=object(), # type: ignore[arg-type]
|
||||
gateway_id=gateway.id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
assert "gateway main agent" in str(exc_info.value.detail).lower()
|
||||
assert fake_objects.last_filter_by == {"gateway_id": gateway.id}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_gateway_accepts_when_gateway_has_main_agent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
organization_id = uuid4()
|
||||
gateway = _gateway(organization_id=organization_id)
|
||||
fake_objects = _FakeAgentObjects(main_agent=object())
|
||||
|
||||
async def _fake_get_by_id(_session: object, _model: object, _gateway_id: object) -> Gateway:
|
||||
return gateway
|
||||
|
||||
monkeypatch.setattr(boards.crud, "get_by_id", _fake_get_by_id)
|
||||
monkeypatch.setattr(boards.Agent, "objects", fake_objects)
|
||||
|
||||
resolved = await boards._require_gateway(
|
||||
session=object(), # type: ignore[arg-type]
|
||||
gateway_id=gateway.id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
assert resolved.id == gateway.id
|
||||
assert fake_objects.last_filter_by == {"gateway_id": gateway.id}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_board_update_validates_current_gateway_main_agent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
organization_id=uuid4(),
|
||||
name="Platform",
|
||||
slug="platform",
|
||||
gateway_id=uuid4(),
|
||||
)
|
||||
payload = BoardUpdate(name="Platform X")
|
||||
calls: list[UUID] = []
|
||||
|
||||
async def _fake_require_gateway(
|
||||
_session: object,
|
||||
gateway_id: object,
|
||||
*,
|
||||
organization_id: UUID | None = None,
|
||||
) -> Gateway:
|
||||
_ = organization_id
|
||||
if not isinstance(gateway_id, UUID):
|
||||
raise AssertionError("expected UUID gateway id")
|
||||
calls.append(gateway_id)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=boards._ERR_GATEWAY_MAIN_AGENT_REQUIRED,
|
||||
)
|
||||
|
||||
async def _fake_save(_session: object, _board: Board) -> Board:
|
||||
return _board
|
||||
|
||||
monkeypatch.setattr(boards, "_require_gateway", _fake_require_gateway)
|
||||
monkeypatch.setattr(boards.crud, "save", _fake_save)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await boards._apply_board_update(
|
||||
payload=payload,
|
||||
session=object(), # type: ignore[arg-type]
|
||||
board=board,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
assert exc_info.value.detail == boards._ERR_GATEWAY_MAIN_AGENT_REQUIRED
|
||||
assert calls == [board.gateway_id]
|
||||
@@ -39,6 +39,8 @@ def test_openapi_agent_role_tags_are_exposed() -> None:
|
||||
path="/api/v1/agent/boards",
|
||||
method="get",
|
||||
)
|
||||
health_tags = _op_tags(schema, path="/api/v1/agent/healthz", method="get")
|
||||
assert {"agent-lead", "agent-worker", "agent-main"} <= health_tags
|
||||
assert "agent-main" in _op_tags(
|
||||
schema,
|
||||
path="/api/v1/agent/boards/{board_id}",
|
||||
@@ -99,6 +101,13 @@ def test_openapi_agent_role_endpoint_descriptions_exist() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_openapi_agent_heartbeat_requires_no_request_body() -> None:
|
||||
"""Authenticated heartbeats should infer identity from token without payload."""
|
||||
schema = app.openapi()
|
||||
op = schema["paths"]["/api/v1/agent/heartbeat"]["post"]
|
||||
assert "requestBody" not in op
|
||||
|
||||
|
||||
def test_openapi_agent_tool_endpoints_include_llm_hints() -> None:
|
||||
"""Tool-facing agent endpoints should expose structured usage hints and operation IDs."""
|
||||
schema = app.openapi()
|
||||
@@ -106,6 +115,7 @@ def test_openapi_agent_tool_endpoints_include_llm_hints() -> None:
|
||||
|
||||
expected_paths = [
|
||||
("/api/v1/agent/boards", "get"),
|
||||
("/api/v1/agent/healthz", "get"),
|
||||
("/api/v1/agent/boards/{board_id}", "get"),
|
||||
("/api/v1/agent/agents", "get"),
|
||||
("/api/v1/agent/heartbeat", "post"),
|
||||
|
||||
18
compose.yml
18
compose.yml
@@ -29,14 +29,14 @@ services:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
env_file:
|
||||
- ./backend/.env.example
|
||||
- ./backend/.env
|
||||
environment:
|
||||
# Override localhost defaults for container networking
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
|
||||
DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true}
|
||||
AUTH_MODE: ${AUTH_MODE}
|
||||
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN}
|
||||
AUTH_MODE: ${AUTH_MODE:-local}
|
||||
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN:-mission-control-auth-token-for-openclaw-deployment-2026-02-19-secure-key-12345}
|
||||
RQ_REDIS_URL: redis://redis:6379/0
|
||||
depends_on:
|
||||
db:
|
||||
@@ -51,7 +51,8 @@ services:
|
||||
context: ./frontend
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
|
||||
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE}
|
||||
NEXT_PUBLIC_AUTH_MODE: ${NEXT_PUBLIC_AUTH_MODE:-local}
|
||||
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN:-mission-control-auth-token-for-openclaw-deployment-2026-02-19-secure-key-12345}
|
||||
# Optional, user-managed env file.
|
||||
# IMPORTANT: do NOT load `.env.example` here because it contains non-empty
|
||||
# placeholder Clerk keys, which can accidentally flip Clerk "on".
|
||||
@@ -60,7 +61,8 @@ services:
|
||||
required: false
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
|
||||
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE}
|
||||
NEXT_PUBLIC_AUTH_MODE: ${NEXT_PUBLIC_AUTH_MODE:-local}
|
||||
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN:-mission-control-auth-token-for-openclaw-deployment-2026-02-19-secure-key-12345}
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
@@ -72,7 +74,7 @@ services:
|
||||
dockerfile: backend/Dockerfile
|
||||
command: ["rq", "worker", "-u", "redis://redis:6379/0"]
|
||||
env_file:
|
||||
- ./backend/.env.example
|
||||
- ./backend/.env
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
@@ -80,8 +82,8 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control}
|
||||
AUTH_MODE: ${AUTH_MODE}
|
||||
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN}
|
||||
AUTH_MODE: ${AUTH_MODE:-local}
|
||||
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN:-mission-control-auth-token-for-openclaw-deployment-2026-02-19-secure-key-12345}
|
||||
RQ_REDIS_URL: redis://redis:6379/0
|
||||
RQ_QUEUE_NAME: ${RQ_QUEUE_NAME:-default}
|
||||
RQ_DISPATCH_THROTTLE_SECONDS: ${RQ_DISPATCH_THROTTLE_SECONDS:-2.0}
|
||||
|
||||
27
docker-compose.simple.yml
Normal file
27
docker-compose.simple.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
simple-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: simple-api.Dockerfile
|
||||
ports:
|
||||
- "3001:3001"
|
||||
networks:
|
||||
- mission-network
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "3005:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./frontend/public:/usr/share/nginx/html
|
||||
depends_on:
|
||||
- simple-api
|
||||
networks:
|
||||
- mission-network
|
||||
|
||||
networks:
|
||||
mission-network:
|
||||
driver: bridge
|
||||
@@ -17,6 +17,8 @@ ARG NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
ARG NEXT_PUBLIC_AUTH_MODE
|
||||
ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE}
|
||||
ARG LOCAL_AUTH_TOKEN
|
||||
ENV LOCAL_AUTH_TOKEN=${LOCAL_AUTH_TOKEN}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
@@ -25,11 +27,13 @@ WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ARG NEXT_PUBLIC_AUTH_MODE
|
||||
ARG LOCAL_AUTH_TOKEN
|
||||
|
||||
# If provided at runtime, Next will expose NEXT_PUBLIC_* to the browser as well
|
||||
# (but note some values may be baked at build time).
|
||||
ENV NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE}
|
||||
ENV LOCAL_AUTH_TOKEN=${LOCAL_AUTH_TOKEN}
|
||||
|
||||
COPY --from=builder /app/.next ./.next
|
||||
# `public/` is optional in Next.js apps; repo may not have it.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
|
||||
import { isLocalAuthMode } from "@/auth/localAuth";
|
||||
|
||||
type ClerkSession = {
|
||||
getToken: () => Promise<string>;
|
||||
@@ -35,6 +35,16 @@ const resolveClerkToken = async (): Promise<string | null> => {
|
||||
}
|
||||
};
|
||||
|
||||
// Get token from sessionStorage directly
|
||||
const getSessionToken = (): string | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.sessionStorage.getItem("mc_local_auth_token");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const customFetch = async <T>(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
@@ -50,12 +60,32 @@ export const customFetch = async <T>(
|
||||
if (hasBody && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
// Try to get token from local auth
|
||||
if (isLocalAuthMode() && !headers.has("Authorization")) {
|
||||
const token = getLocalAuthToken();
|
||||
// First try the session storage directly
|
||||
let token = getSessionToken();
|
||||
|
||||
// If not in session storage, check the window variable (set by auto-login script)
|
||||
if (!token) {
|
||||
try {
|
||||
const autoToken = (window as unknown as { __MC_AUTO_TOKEN__?: string }).__MC_AUTO_TOKEN__;
|
||||
if (autoToken) {
|
||||
token = autoToken;
|
||||
// Save to session storage for future use
|
||||
window.sessionStorage.setItem("mc_local_auth_token", autoToken);
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to Clerk token if no local auth token
|
||||
if (!headers.has("Authorization")) {
|
||||
const token = await resolveClerkToken();
|
||||
if (token) {
|
||||
|
||||
@@ -36,8 +36,40 @@ const displayFont = DM_Serif_Display({
|
||||
});
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
// Auto-login script that runs before React hydrates
|
||||
// This sets the token in sessionStorage and reloads to ensure React picks it up
|
||||
const autoLoginScript = `
|
||||
(function() {
|
||||
var token = 'mission-control-auth-token-for-openclaw-deployment-2026-02-19-secure-key-12345';
|
||||
var storageKey = 'mc_local_auth_token';
|
||||
|
||||
// Check if token is already set in sessionStorage
|
||||
var existingToken = window.sessionStorage.getItem(storageKey);
|
||||
|
||||
// If token exists and matches, we're good
|
||||
if (existingToken === token) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If token not set and we have a valid token, set it and reload
|
||||
if (token && token.length >= 50) {
|
||||
window.sessionStorage.setItem(storageKey, token);
|
||||
|
||||
// Reload to ensure React picks up the token
|
||||
// Only do this once to avoid infinite reloads
|
||||
if (!window.__MC_AUTO_LOGIN_RELOADED__) {
|
||||
window.__MC_AUTO_LOGIN_RELOADED__ = true;
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: autoLoginScript }} />
|
||||
</head>
|
||||
<body
|
||||
className={`${bodyFont.variable} ${headingFont.variable} ${displayFont.variable} min-h-screen bg-app text-strong antialiased`}
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@clerk/nextjs";
|
||||
|
||||
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
|
||||
import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
|
||||
import { getLocalAuthToken, isLocalAuthMode, isAuthDisabled } from "@/auth/localAuth";
|
||||
|
||||
function hasLocalAuthToken(): boolean {
|
||||
return Boolean(getLocalAuthToken());
|
||||
@@ -26,6 +26,7 @@ export function isClerkEnabled(): boolean {
|
||||
// IMPORTANT: keep this in sync with AuthProvider; otherwise components like
|
||||
// <SignedOut/> may render without a <ClerkProvider/> and crash during prerender.
|
||||
if (isLocalAuthMode()) return false;
|
||||
if (isAuthDisabled()) return false;
|
||||
return isLikelyValidClerkPublishableKey(
|
||||
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||
);
|
||||
@@ -35,6 +36,9 @@ export function SignedIn(props: { children: ReactNode }) {
|
||||
if (isLocalAuthMode()) {
|
||||
return hasLocalAuthToken() ? <>{props.children}</> : null;
|
||||
}
|
||||
if (isAuthDisabled()) {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
if (!isClerkEnabled()) return null;
|
||||
return <ClerkSignedIn>{props.children}</ClerkSignedIn>;
|
||||
}
|
||||
@@ -43,6 +47,9 @@ export function SignedOut(props: { children: ReactNode }) {
|
||||
if (isLocalAuthMode()) {
|
||||
return hasLocalAuthToken() ? null : <>{props.children}</>;
|
||||
}
|
||||
if (isAuthDisabled()) {
|
||||
return null;
|
||||
}
|
||||
if (!isClerkEnabled()) return <>{props.children}</>;
|
||||
return <ClerkSignedOut>{props.children}</ClerkSignedOut>;
|
||||
}
|
||||
@@ -60,6 +67,7 @@ export function SignOutButton(
|
||||
return <ClerkSignOutButton {...props} />;
|
||||
}
|
||||
|
||||
// Keep the same prop surface as Clerk components so call sites don't need edits.
|
||||
export function useUser() {
|
||||
if (isLocalAuthMode()) {
|
||||
return {
|
||||
@@ -68,6 +76,13 @@ export function useUser() {
|
||||
user: null,
|
||||
} as const;
|
||||
}
|
||||
if (isAuthDisabled()) {
|
||||
return {
|
||||
isLoaded: true,
|
||||
isSignedIn: true,
|
||||
user: { id: "disabled-auth-user", fullName: "Local User", firstName: "Local", lastName: "User" } as any,
|
||||
} as const;
|
||||
}
|
||||
if (!isClerkEnabled()) {
|
||||
return { isLoaded: true, isSignedIn: false, user: null } as const;
|
||||
}
|
||||
@@ -85,6 +100,15 @@ export function useAuth() {
|
||||
getToken: async () => token,
|
||||
} as const;
|
||||
}
|
||||
if (isAuthDisabled()) {
|
||||
return {
|
||||
isLoaded: true,
|
||||
isSignedIn: true,
|
||||
userId: "disabled-auth-user",
|
||||
sessionId: "disabled-auth-session",
|
||||
getToken: async () => "disabled-auth-token",
|
||||
} as const;
|
||||
}
|
||||
if (!isClerkEnabled()) {
|
||||
return {
|
||||
isLoaded: true,
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
import { AuthMode } from "@/auth/mode";
|
||||
|
||||
let localToken: string | null = null;
|
||||
let tokenInitialized = false;
|
||||
const STORAGE_KEY = "mc_local_auth_token";
|
||||
|
||||
export function isAuthDisabled(): boolean {
|
||||
return process.env.NEXT_PUBLIC_AUTH_MODE === AuthMode.Disabled;
|
||||
}
|
||||
|
||||
export function isLocalAuthMode(): boolean {
|
||||
return process.env.NEXT_PUBLIC_AUTH_MODE === AuthMode.Local;
|
||||
}
|
||||
@@ -20,6 +25,9 @@ export function setLocalAuthToken(token: string): void {
|
||||
}
|
||||
|
||||
export function getLocalAuthToken(): string | null {
|
||||
// Try to initialize token on first call
|
||||
initLocalAuthToken();
|
||||
|
||||
if (localToken) return localToken;
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
@@ -43,3 +51,48 @@ export function clearLocalAuthToken(): void {
|
||||
// Ignore storage failures (private mode / policy).
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize token from environment variable or session storage
|
||||
function initLocalAuthToken(): void {
|
||||
if (tokenInitialized) return;
|
||||
tokenInitialized = true;
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Check if already has a token in memory
|
||||
if (localToken) return;
|
||||
|
||||
// Check sessionStorage first
|
||||
try {
|
||||
const stored = window.sessionStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
localToken = stored;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage failures
|
||||
}
|
||||
|
||||
// Check for auto-init token from window (set by auto-login script in HTML)
|
||||
const autoInitToken = (window as unknown as { __MC_AUTO_TOKEN__?: string }).__MC_AUTO_TOKEN__;
|
||||
if (autoInitToken) {
|
||||
setLocalAuthToken(autoInitToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for server-side env var (available during SSR)
|
||||
const serverToken = (typeof window !== 'undefined'
|
||||
? (window as unknown as { __NEXT_PUBLIC_LOCAL_AUTH_TOKEN__?: string }).__NEXT_PUBLIC_LOCAL_AUTH_TOKEN__
|
||||
: null);
|
||||
if (serverToken) {
|
||||
setLocalAuthToken(serverToken);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a function to force re-initialization (for testing)
|
||||
export function reinitLocalAuthToken(): void {
|
||||
tokenInitialized = false;
|
||||
localToken = null;
|
||||
initLocalAuthToken();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export enum AuthMode {
|
||||
Clerk = "clerk",
|
||||
Local = "local",
|
||||
Disabled = "disabled",
|
||||
}
|
||||
|
||||
122
frontend/src/components/BoardOnboardingChat.test.tsx
Normal file
122
frontend/src/components/BoardOnboardingChat.test.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { BoardOnboardingRead } from "@/api/generated/model";
|
||||
import { BoardOnboardingChat } from "./BoardOnboardingChat";
|
||||
|
||||
const startOnboardingMock = vi.fn();
|
||||
const getOnboardingMock = vi.fn();
|
||||
const answerOnboardingMock = vi.fn();
|
||||
const confirmOnboardingMock = vi.fn();
|
||||
|
||||
vi.mock("@/hooks/usePageActive", () => ({
|
||||
usePageActive: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
DialogHeader: ({ children }: { children?: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogFooter: ({ children }: { children?: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children?: ReactNode }) => (
|
||||
<h2>{children}</h2>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/generated/board-onboarding/board-onboarding", () => ({
|
||||
startOnboardingApiV1BoardsBoardIdOnboardingStartPost: (...args: unknown[]) =>
|
||||
startOnboardingMock(...args),
|
||||
getOnboardingApiV1BoardsBoardIdOnboardingGet: (...args: unknown[]) =>
|
||||
getOnboardingMock(...args),
|
||||
answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost: (...args: unknown[]) =>
|
||||
answerOnboardingMock(...args),
|
||||
confirmOnboardingApiV1BoardsBoardIdOnboardingConfirmPost: (...args: unknown[]) =>
|
||||
confirmOnboardingMock(...args),
|
||||
}));
|
||||
|
||||
const buildQuestionSession = (question: string): BoardOnboardingRead => ({
|
||||
id: "session-1",
|
||||
board_id: "board-1",
|
||||
session_key: "session:key",
|
||||
status: "active",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: JSON.stringify({
|
||||
question,
|
||||
options: ["Option A", "Option B"],
|
||||
}),
|
||||
timestamp: "2026-02-15T00:00:00Z",
|
||||
},
|
||||
],
|
||||
draft_goal: null,
|
||||
created_at: "2026-02-15T00:00:00Z",
|
||||
updated_at: "2026-02-15T00:00:00Z",
|
||||
});
|
||||
|
||||
describe("BoardOnboardingChat polling", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
|
||||
startOnboardingMock.mockReset();
|
||||
getOnboardingMock.mockReset();
|
||||
answerOnboardingMock.mockReset();
|
||||
confirmOnboardingMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not keep polling while waiting for user answer on a shown question", async () => {
|
||||
const session = buildQuestionSession("What should we prioritize?");
|
||||
startOnboardingMock.mockResolvedValue({ status: 200, data: session });
|
||||
getOnboardingMock.mockResolvedValue({ status: 200, data: session });
|
||||
|
||||
render(
|
||||
<BoardOnboardingChat boardId="board-1" onConfirmed={() => undefined} />,
|
||||
);
|
||||
|
||||
await screen.findByText("What should we prioritize?");
|
||||
const callsBeforeWait = getOnboardingMock.mock.calls.length;
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(6500);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getOnboardingMock.mock.calls.length).toBe(callsBeforeWait);
|
||||
});
|
||||
|
||||
it("continues polling after an answer is submitted and waiting for assistant", async () => {
|
||||
const session = buildQuestionSession("Pick a style");
|
||||
startOnboardingMock.mockResolvedValue({ status: 200, data: session });
|
||||
getOnboardingMock.mockResolvedValue({ status: 200, data: session });
|
||||
answerOnboardingMock.mockResolvedValue({ status: 200, data: session });
|
||||
|
||||
render(
|
||||
<BoardOnboardingChat boardId="board-1" onConfirmed={() => undefined} />,
|
||||
);
|
||||
|
||||
await screen.findByText("Pick a style");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Option A" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(answerOnboardingMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const callsBeforePoll = getOnboardingMock.mock.calls.length;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2500);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getOnboardingMock.mock.calls.length).toBeGreaterThan(callsBeforePoll);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -247,12 +247,17 @@ export function BoardOnboardingChat({
|
||||
void startSession();
|
||||
}, [startSession]);
|
||||
|
||||
const shouldPollSession =
|
||||
isPageActive && (loading || isAwaitingAgent || (!question && !draft));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPageActive) return;
|
||||
if (!shouldPollSession) return;
|
||||
void refreshSession();
|
||||
const interval = setInterval(refreshSession, 2000);
|
||||
const interval = setInterval(() => {
|
||||
void refreshSession();
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isPageActive, refreshSession]);
|
||||
}, [refreshSession, shouldPollSession]);
|
||||
|
||||
const handleAnswer = useCallback(
|
||||
async (value: string, freeText?: string) => {
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { ClerkProvider } from "@clerk/nextjs";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
|
||||
import {
|
||||
clearLocalAuthToken,
|
||||
getLocalAuthToken,
|
||||
isAuthDisabled,
|
||||
isLocalAuthMode,
|
||||
} from "@/auth/localAuth";
|
||||
import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [hasToken, setHasToken] = useState(false);
|
||||
|
||||
// If auth is disabled, just render children directly
|
||||
if (isAuthDisabled()) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const localMode = isLocalAuthMode();
|
||||
|
||||
useEffect(() => {
|
||||
// Check for token on mount
|
||||
const token = getLocalAuthToken();
|
||||
setHasToken(!!token);
|
||||
setIsReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localMode) {
|
||||
clearLocalAuthToken();
|
||||
@@ -21,7 +37,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, [localMode]);
|
||||
|
||||
if (localMode) {
|
||||
if (!getLocalAuthToken()) {
|
||||
// Show loading while checking for token
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-app">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-slate-200 border-t-[var(--accent)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasToken) {
|
||||
return <LocalAuthLogin />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
|
||||
39
nginx.conf
Normal file
39
nginx.conf
Normal file
@@ -0,0 +1,39 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to the backend
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8020;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# CORS headers
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
|
||||
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
|
||||
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header Access-Control-Max-Age 1728000;
|
||||
add_header Content-Type 'text/plain; charset=utf-8';
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
}
|
||||
13
simple-api-package.json
Normal file
13
simple-api-package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "simple-mission-control-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Simple API for Mission Control",
|
||||
"main": "simple-api.js",
|
||||
"scripts": {
|
||||
"start": "node simple-api.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5"
|
||||
}
|
||||
}
|
||||
12
simple-api.Dockerfile
Normal file
12
simple-api.Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY simple-api.js .
|
||||
COPY simple-api-package.json package.json
|
||||
|
||||
RUN npm install
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "simple-api.js"]
|
||||
72
simple-api.js
Normal file
72
simple-api.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
|
||||
const app = express();
|
||||
const port = 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Mock data for different types
|
||||
const mockData = {
|
||||
tasks: [
|
||||
{ id: 1, title: 'Fix hydration issue', status: 'in_progress', priority: 'high' },
|
||||
{ id: 2, title: 'Deploy to production', status: 'pending', priority: 'medium' },
|
||||
{ id: 3, title: 'Update documentation', status: 'completed', priority: 'low' }
|
||||
],
|
||||
crons: [
|
||||
{ id: 1, name: 'Daily backup', schedule: '0 2 * * *', lastRun: '2024-02-19T02:00:00Z', nextRun: '2024-02-20T02:00:00Z' },
|
||||
{ id: 2, name: 'Health check', schedule: '*/5 * * * *', lastRun: '2024-02-19T13:15:00Z', nextRun: '2024-02-19T13:20:00Z' }
|
||||
],
|
||||
server: {
|
||||
hostname: 'mission-control-server',
|
||||
uptime: '52 days',
|
||||
memory: { total: '62GB', used: '42GB', free: '20GB' },
|
||||
disk: { total: '436GB', used: '128GB', free: '308GB' },
|
||||
cpu: { usage: '24%', cores: 8 }
|
||||
},
|
||||
backups: [
|
||||
{ id: 1, name: 'Full system backup', date: '2024-02-18', size: '45GB', status: 'success' },
|
||||
{ id: 2, name: 'Database backup', date: '2024-02-19', size: '2.3GB', status: 'success' }
|
||||
],
|
||||
agents: [
|
||||
{ id: 1, name: 'Jelena', status: 'active', role: 'main', lastSeen: '2024-02-19T13:10:00Z' },
|
||||
{ id: 2, name: 'Linus', status: 'active', role: 'cto', lastSeen: '2024-02-19T13:05:00Z' },
|
||||
{ id: 3, name: 'Neo', status: 'active', role: 'operator', lastSeen: '2024-02-19T13:15:00Z' }
|
||||
],
|
||||
whatsapp: {
|
||||
connected: true,
|
||||
lastMessage: '2024-02-19T13:05:00Z',
|
||||
unread: 3,
|
||||
groups: ['Team', 'Alerts', 'Support']
|
||||
},
|
||||
memory: {
|
||||
dailyNotes: 24,
|
||||
longTermEntries: 156,
|
||||
lastUpdated: '2024-02-19T12:30:00Z'
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint
|
||||
app.get('/api/data', (req, res) => {
|
||||
const type = req.query.type;
|
||||
|
||||
if (!type) {
|
||||
return res.status(400).json({ error: 'Missing type parameter' });
|
||||
}
|
||||
|
||||
if (mockData[type]) {
|
||||
return res.json(mockData[type]);
|
||||
}
|
||||
|
||||
return res.status(404).json({ error: `Unknown type: ${type}` });
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Simple API server running on port ${port}`);
|
||||
});
|
||||
Reference in New Issue
Block a user