Compare commits

...

10 Commits

Author SHA1 Message Date
Neo
c56b173dcc Mission Control with OpenClaw hook - added simple API and updated configs
Some checks failed
CI / check (push) Has been cancelled
CI / installer (push) Has been cancelled
CI / e2e (push) Has been cancelled
2026-02-20 12:13:36 +00:00
Abhimanyu Saharan
1c8a531f6a feat: update JSON key access syntax in BOARD_HEARTBEAT.md.j2 and BOARD_TOOLS.md.j2 2026-02-16 23:42:38 +05:30
Abhimanyu Saharan
522761bc26 feat: improve session polling logic in BoardOnboardingChat component 2026-02-16 01:46:12 +05:30
Abhimanyu Saharan
6c3c9913db feat: update agent heartbeat endpoint to require no request payload 2026-02-16 01:46:06 +05:30
Abhimanyu Saharan
7a3a2366da feat: update wakeup text to include reading BOOTSTRAP.md before AGENTS.md 2026-02-16 01:45:02 +05:30
Abhimanyu Saharan
5912048b85 feat: add validation for gateway main agent requirement on board mutations 2026-02-16 01:25:44 +05:30
Abhimanyu Saharan
1d8039e760 feat: update documentation to use template variables for BASE_URL and AUTH_TOKEN 2026-02-16 01:25:39 +05:30
Abhimanyu Saharan
47dfc1b52f feat: ensure deletion of custom fields and values when a board is deleted 2026-02-16 00:44:40 +05:30
Abhimanyu Saharan
1d63bd0148 feat: add health check endpoint for agent authentication status 2026-02-16 00:42:15 +05:30
Abhimanyu Saharan
cd68446c42 feat: add BoardWebhook updates on agent deletion 2026-02-16 00:21:21 +05:30
32 changed files with 868 additions and 51 deletions

View File

@@ -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),
)

View File

@@ -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)

View File

@@ -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],
)

View File

@@ -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.

View File

@@ -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,

View File

@@ -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."
)

View File

@@ -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()

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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
```

View File

@@ -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

View 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

View File

@@ -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")

View File

@@ -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

View 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]

View File

@@ -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"),

View File

@@ -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
View 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

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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`}
>

View File

@@ -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,

View File

@@ -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();
}

View File

@@ -1,4 +1,5 @@
export enum AuthMode {
Clerk = "clerk",
Local = "local",
Disabled = "disabled",
}

View 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);
});
});
});

View File

@@ -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) => {

View File

@@ -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
View 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
View 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
View 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
View 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}`);
});