Add multi-user workflows/pipelines and error tracking

Add multi-user automation features and per-user error tracking.

- Database migrations: add workflow_configs/workflow_runs (004), app_errors (005), pipeline_configs/pipeline_runs (006), and add user_token_hash to app_errors (007).
- Backend: introduce per-request token handling (X-API-Token) via app.api.deps and update many API routes (auth, automations, bank, characters, dashboard, events, exchange, logs) to use user-scoped Artifacts client and character scoping. Auth endpoints no longer store tokens server-side (validate-only); clear is a no-op on server.
- New Errors API and services: endpoint to list, filter, resolve, and report errors scoped to the requesting user; add error models, schemas, middleware/error handler and error_service for recording/hashing tokens.
- Pipelines & Workflows: add API routers, models, schemas and engine modules (pipeline/worker/coordinator, workflow runner/conditions) and action_executor updates to support workflow/pipeline execution.
- Logs: logs endpoint now prefers fetching recent action logs from the game API (with fallback to local DB), supports paging and filtering, and scopes results to the user.
- Frontend: add pipeline/workflow builders, lists, progress components and hooks (use-errors, use-pipelines, use-workflows), sentry client config, and updates to API client/constants/types.
- Misc: add middleware error handler, various engine strategy tweaks, tests adjusted.

Overall this change enables per-user API tokens, scopes DB queries to each user, introduces pipelines/workflows runtime support, and centralizes application error tracking.
This commit is contained in:
Paweł Orzech 2026-03-01 23:02:34 +01:00
parent 2484a40dbd
commit 75313b83c0
No known key found for this signature in database
86 changed files with 14835 additions and 587 deletions

View file

@ -0,0 +1,114 @@
"""Add workflow_configs, workflow_runs tables
Revision ID: 004_workflows
Revises: 003_price_event
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "004_workflows"
down_revision: Union[str, None] = "003_price_event"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# workflow_configs
op.create_table(
"workflow_configs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("character_name", sa.String(length=100), nullable=False),
sa.Column("description", sa.Text(), nullable=False, server_default=sa.text("''")),
sa.Column(
"steps",
sa.JSON(),
nullable=False,
comment="JSON array of workflow steps",
),
sa.Column("loop", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("max_loops", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_workflow_configs_character_name"),
"workflow_configs",
["character_name"],
unique=False,
)
# workflow_runs
op.create_table(
"workflow_runs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"workflow_id",
sa.Integer(),
sa.ForeignKey("workflow_configs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"status",
sa.String(length=20),
nullable=False,
server_default=sa.text("'running'"),
comment="Status: running, paused, stopped, completed, error",
),
sa.Column("current_step_index", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column(
"current_step_id",
sa.String(length=100),
nullable=False,
server_default=sa.text("''"),
),
sa.Column("loop_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_actions_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("step_actions_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column(
"started_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column(
"step_history",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'::json"),
comment="JSON array of completed step records",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_workflow_runs_workflow_id"),
"workflow_runs",
["workflow_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_workflow_runs_workflow_id"), table_name="workflow_runs")
op.drop_table("workflow_runs")
op.drop_index(op.f("ix_workflow_configs_character_name"), table_name="workflow_configs")
op.drop_table("workflow_configs")

View file

@ -0,0 +1,102 @@
"""Add app_errors table for error tracking
Revision ID: 005_app_errors
Revises: 004_workflows
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "005_app_errors"
down_revision: Union[str, None] = "004_workflows"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"app_errors",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"severity",
sa.String(length=20),
nullable=False,
server_default="error",
comment="error | warning | critical",
),
sa.Column(
"source",
sa.String(length=50),
nullable=False,
comment="backend | frontend | automation | middleware",
),
sa.Column(
"error_type",
sa.String(length=200),
nullable=False,
comment="Exception class name or error category",
),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("stack_trace", sa.Text(), nullable=True),
sa.Column(
"context",
sa.JSON(),
nullable=True,
comment="Arbitrary JSON context",
),
sa.Column(
"correlation_id",
sa.String(length=36),
nullable=True,
comment="Request correlation ID",
),
sa.Column(
"resolved",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_app_errors_correlation_id"),
"app_errors",
["correlation_id"],
unique=False,
)
op.create_index(
op.f("ix_app_errors_created_at"),
"app_errors",
["created_at"],
unique=False,
)
op.create_index(
op.f("ix_app_errors_severity"),
"app_errors",
["severity"],
unique=False,
)
op.create_index(
op.f("ix_app_errors_source"),
"app_errors",
["source"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_app_errors_source"), table_name="app_errors")
op.drop_index(op.f("ix_app_errors_severity"), table_name="app_errors")
op.drop_index(op.f("ix_app_errors_created_at"), table_name="app_errors")
op.drop_index(op.f("ix_app_errors_correlation_id"), table_name="app_errors")
op.drop_table("app_errors")

View file

@ -0,0 +1,112 @@
"""Add pipeline_configs, pipeline_runs tables for multi-character pipelines
Revision ID: 006_pipelines
Revises: 005_app_errors
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "006_pipelines"
down_revision: Union[str, None] = "005_app_errors"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# pipeline_configs
op.create_table(
"pipeline_configs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.Text(), nullable=False, server_default=sa.text("''")),
sa.Column(
"stages",
sa.JSON(),
nullable=False,
comment="JSON array of pipeline stages with character_steps",
),
sa.Column("loop", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("max_loops", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
# pipeline_runs
op.create_table(
"pipeline_runs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"pipeline_id",
sa.Integer(),
sa.ForeignKey("pipeline_configs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"status",
sa.String(length=20),
nullable=False,
server_default=sa.text("'running'"),
comment="Status: running, paused, stopped, completed, error",
),
sa.Column("current_stage_index", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column(
"current_stage_id",
sa.String(length=100),
nullable=False,
server_default=sa.text("''"),
),
sa.Column("loop_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_actions_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column(
"character_states",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'::json"),
comment="Per-character state JSON",
),
sa.Column(
"stage_history",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'::json"),
comment="JSON array of completed stage records",
),
sa.Column(
"started_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_pipeline_runs_pipeline_id"),
"pipeline_runs",
["pipeline_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_pipeline_runs_pipeline_id"), table_name="pipeline_runs")
op.drop_table("pipeline_runs")
op.drop_table("pipeline_configs")

View file

@ -0,0 +1,40 @@
"""Add user_token_hash column to app_errors for per-user scoping
Revision ID: 007_user_token_hash
Revises: 006_pipelines
Create Date: 2026-03-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "007_user_token_hash"
down_revision: Union[str, None] = "006_pipelines"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"app_errors",
sa.Column(
"user_token_hash",
sa.String(length=64),
nullable=True,
comment="SHA-256 hash of the user API token",
),
)
op.create_index(
op.f("ix_app_errors_user_token_hash"),
"app_errors",
["user_token_hash"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_app_errors_user_token_hash"), table_name="app_errors")
op.drop_column("app_errors", "user_token_hash")

View file

@ -1,8 +1,9 @@
"""Auth endpoints for runtime API token management.
"""Auth endpoints for per-user API token management.
When no ARTIFACTS_TOKEN is set in the environment, users can provide
their own token through the UI. The token is stored in memory only
and must be re-sent if the backend restarts.
Each user provides their own Artifacts API token via the frontend.
The token is stored in the browser's localStorage and sent with every
request as the ``X-API-Token`` header. The backend validates the token
but does NOT store it globally this allows true multi-user support.
"""
import logging
@ -12,7 +13,6 @@ from fastapi import APIRouter, Request
from pydantic import BaseModel
from app.config import settings
from app.services.artifacts_client import ArtifactsClient
logger = logging.getLogger(__name__)
@ -21,7 +21,7 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
class AuthStatus(BaseModel):
has_token: bool
source: str # "env", "user", or "none"
source: str # "header", "env", or "none"
class SetTokenRequest(BaseModel):
@ -37,15 +37,24 @@ class SetTokenResponse(BaseModel):
@router.get("/status", response_model=AuthStatus)
async def auth_status(request: Request) -> AuthStatus:
client: ArtifactsClient = request.app.state.artifacts_client
return AuthStatus(
has_token=client.has_token,
source=client.token_source,
)
"""Check whether the *requesting* client has a valid token.
The frontend sends the token in the ``X-API-Token`` header.
This endpoint tells the frontend whether that token is present.
"""
token = request.headers.get("X-API-Token")
if token:
return AuthStatus(has_token=True, source="header")
return AuthStatus(has_token=False, source="none")
@router.post("/token", response_model=SetTokenResponse)
async def set_token(body: SetTokenRequest, request: Request) -> SetTokenResponse:
async def validate_token(body: SetTokenRequest) -> SetTokenResponse:
"""Validate an Artifacts API token.
Does NOT store the token on the server. The frontend is responsible
for persisting it in localStorage and sending it with every request.
"""
token = body.token.strip()
if not token:
return SetTokenResponse(success=False, source="none", error="Token cannot be empty")
@ -78,37 +87,11 @@ async def set_token(body: SetTokenRequest, request: Request) -> SetTokenResponse
error="Could not validate token. Check your network connection.",
)
# Token is valid — apply it
client: ArtifactsClient = request.app.state.artifacts_client
client.set_token(token)
# Reconnect WebSocket with new token
game_ws_client = getattr(request.app.state, "game_ws_client", None)
if game_ws_client is not None:
try:
await game_ws_client.reconnect_with_token(token)
except Exception:
logger.exception("Failed to reconnect WebSocket with new token")
logger.info("API token updated via UI (source: user)")
logger.info("API token validated via UI")
return SetTokenResponse(success=True, source="user")
@router.delete("/token")
async def clear_token(request: Request) -> AuthStatus:
client: ArtifactsClient = request.app.state.artifacts_client
client.clear_token()
# Reconnect WebSocket with env token (or empty)
game_ws_client = getattr(request.app.state, "game_ws_client", None)
if game_ws_client is not None and settings.artifacts_token:
try:
await game_ws_client.reconnect_with_token(settings.artifacts_token)
except Exception:
logger.exception("Failed to reconnect WebSocket after token clear")
logger.info("API token cleared, reverted to env")
return AuthStatus(
has_token=client.has_token,
source=client.token_source,
)
async def clear_token() -> AuthStatus:
"""No-op on the backend — the frontend clears its own localStorage."""
return AuthStatus(has_token=False, source="none")

View file

@ -6,6 +6,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.deps import get_user_character_names
from app.database import async_session_factory
from app.engine.manager import AutomationManager
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
@ -46,9 +47,14 @@ def _get_manager(request: Request) -> AutomationManager:
@router.get("/", response_model=list[AutomationConfigResponse])
async def list_configs(request: Request) -> list[AutomationConfigResponse]:
"""List all automation configurations with their current status."""
"""List automation configurations belonging to the current user."""
user_chars = await get_user_character_names(request)
async with async_session_factory() as db:
stmt = select(AutomationConfig).order_by(AutomationConfig.id)
stmt = (
select(AutomationConfig)
.where(AutomationConfig.character_name.in_(user_chars))
.order_by(AutomationConfig.id)
)
result = await db.execute(stmt)
configs = result.scalars().all()
return [AutomationConfigResponse.model_validate(c) for c in configs]

View file

@ -5,8 +5,8 @@ from fastapi import APIRouter, HTTPException, Request
from httpx import HTTPStatusError
from pydantic import BaseModel, Field
from app.api.deps import get_user_client
from app.database import async_session_factory
from app.services.artifacts_client import ArtifactsClient
from app.services.bank_service import BankService
from app.services.game_data_cache import GameDataCacheService
@ -15,10 +15,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["bank"])
def _get_client(request: Request) -> ArtifactsClient:
return request.app.state.artifacts_client
def _get_cache_service(request: Request) -> GameDataCacheService:
return request.app.state.cache_service
@ -33,11 +29,17 @@ class ManualActionRequest(BaseModel):
action: str = Field(
...,
description="Action to perform: 'move', 'fight', 'gather', 'rest'",
description=(
"Action to perform: move, fight, gather, rest, equip, unequip, "
"use_item, deposit, withdraw, deposit_gold, withdraw_gold, "
"craft, recycle, ge_buy, ge_create_buy, ge_sell, ge_fill, ge_cancel, "
"task_new, task_trade, task_complete, task_exchange, task_cancel, "
"npc_buy, npc_sell"
),
)
params: dict = Field(
default_factory=dict,
description="Action parameters (e.g. {x, y} for move)",
description="Action parameters (varies per action type)",
)
@ -49,7 +51,7 @@ class ManualActionRequest(BaseModel):
@router.get("/bank")
async def get_bank(request: Request) -> dict[str, Any]:
"""Return bank details with enriched item data from game cache."""
client = _get_client(request)
client = get_user_client(request)
cache_service = _get_cache_service(request)
bank_service = BankService()
@ -75,7 +77,7 @@ async def get_bank(request: Request) -> dict[str, Any]:
@router.get("/bank/summary")
async def get_bank_summary(request: Request) -> dict[str, Any]:
"""Return a summary of bank contents: gold, item count, slots."""
client = _get_client(request)
client = get_user_client(request)
bank_service = BankService()
try:
@ -87,6 +89,16 @@ async def get_bank_summary(request: Request) -> dict[str, Any]:
) from exc
def _require(params: dict, *keys: str) -> None:
"""Raise 400 if any required key is missing from params."""
missing = [k for k in keys if params.get(k) is None]
if missing:
raise HTTPException(
status_code=400,
detail=f"Missing required params: {', '.join(missing)}",
)
@router.post("/characters/{name}/action")
async def manual_action(
name: str,
@ -95,35 +107,154 @@ async def manual_action(
) -> dict[str, Any]:
"""Execute a manual action on a character.
Supported actions:
- **move**: Move to coordinates. Params: {"x": int, "y": int}
- **fight**: Fight the monster at the current tile. No params.
- **gather**: Gather the resource at the current tile. No params.
- **rest**: Rest to recover HP. No params.
Supported actions and their params:
- **move**: {x: int, y: int}
- **fight**: no params
- **gather**: no params
- **rest**: no params
- **equip**: {code: str, slot: str, quantity?: int}
- **unequip**: {slot: str, quantity?: int}
- **use_item**: {code: str, quantity?: int}
- **deposit**: {code: str, quantity: int}
- **withdraw**: {code: str, quantity: int}
- **deposit_gold**: {quantity: int}
- **withdraw_gold**: {quantity: int}
- **craft**: {code: str, quantity?: int}
- **recycle**: {code: str, quantity?: int}
- **ge_buy**: {id: str, quantity: int} buy from an existing sell order
- **ge_create_buy**: {code: str, quantity: int, price: int} create a standing buy order
- **ge_sell**: {code: str, quantity: int, price: int} create a sell order
- **ge_fill**: {id: str, quantity: int} fill an existing buy order
- **ge_cancel**: {order_id: str}
- **task_new**: no params
- **task_trade**: {code: str, quantity: int}
- **task_complete**: no params
- **task_exchange**: no params
- **task_cancel**: no params
- **npc_buy**: {code: str, quantity: int}
- **npc_sell**: {code: str, quantity: int}
"""
client = _get_client(request)
client = get_user_client(request)
p = body.params
try:
match body.action:
# --- Basic actions ---
case "move":
x = body.params.get("x")
y = body.params.get("y")
if x is None or y is None:
raise HTTPException(
status_code=400,
detail="Move action requires 'x' and 'y' in params",
)
result = await client.move(name, int(x), int(y))
_require(p, "x", "y")
result = await client.move(name, int(p["x"]), int(p["y"]))
case "fight":
result = await client.fight(name)
case "gather":
result = await client.gather(name)
case "rest":
result = await client.rest(name)
# --- Equipment ---
case "equip":
_require(p, "code", "slot")
result = await client.equip(
name, p["code"], p["slot"], int(p.get("quantity", 1))
)
case "unequip":
_require(p, "slot")
result = await client.unequip(
name, p["slot"], int(p.get("quantity", 1))
)
# --- Consumables ---
case "use_item":
_require(p, "code")
result = await client.use_item(
name, p["code"], int(p.get("quantity", 1))
)
# --- Bank ---
case "deposit":
_require(p, "code", "quantity")
result = await client.deposit_item(
name, p["code"], int(p["quantity"])
)
case "withdraw":
_require(p, "code", "quantity")
result = await client.withdraw_item(
name, p["code"], int(p["quantity"])
)
case "deposit_gold":
_require(p, "quantity")
result = await client.deposit_gold(name, int(p["quantity"]))
case "withdraw_gold":
_require(p, "quantity")
result = await client.withdraw_gold(name, int(p["quantity"]))
# --- Crafting ---
case "craft":
_require(p, "code")
result = await client.craft(
name, p["code"], int(p.get("quantity", 1))
)
case "recycle":
_require(p, "code")
result = await client.recycle(
name, p["code"], int(p.get("quantity", 1))
)
# --- Grand Exchange ---
case "ge_buy":
_require(p, "id", "quantity")
result = await client.ge_buy(
name, str(p["id"]), int(p["quantity"])
)
case "ge_create_buy":
_require(p, "code", "quantity", "price")
result = await client.ge_create_buy_order(
name, p["code"], int(p["quantity"]), int(p["price"])
)
case "ge_sell":
_require(p, "code", "quantity", "price")
result = await client.ge_sell_order(
name, p["code"], int(p["quantity"]), int(p["price"])
)
case "ge_fill":
_require(p, "id", "quantity")
result = await client.ge_fill_buy_order(
name, str(p["id"]), int(p["quantity"])
)
case "ge_cancel":
_require(p, "order_id")
result = await client.ge_cancel(name, p["order_id"])
# --- Tasks ---
case "task_new":
result = await client.task_new(name)
case "task_trade":
_require(p, "code", "quantity")
result = await client.task_trade(
name, p["code"], int(p["quantity"])
)
case "task_complete":
result = await client.task_complete(name)
case "task_exchange":
result = await client.task_exchange(name)
case "task_cancel":
result = await client.task_cancel(name)
# --- NPC ---
case "npc_buy":
_require(p, "code", "quantity")
result = await client.npc_buy(
name, p["code"], int(p["quantity"])
)
case "npc_sell":
_require(p, "code", "quantity")
result = await client.npc_sell(
name, p["code"], int(p["quantity"])
)
case _:
raise HTTPException(
status_code=400,
detail=f"Unknown action: {body.action!r}. Supported: move, fight, gather, rest",
detail=f"Unknown action: {body.action!r}",
)
except HTTPStatusError as exc:
raise HTTPException(

View file

@ -1,17 +1,13 @@
from fastapi import APIRouter, HTTPException, Request
from httpx import HTTPStatusError
from app.api.deps import get_user_client
from app.schemas.game import CharacterSchema
from app.services.artifacts_client import ArtifactsClient
from app.services.character_service import CharacterService
router = APIRouter(prefix="/api/characters", tags=["characters"])
def _get_client(request: Request) -> ArtifactsClient:
return request.app.state.artifacts_client
def _get_service(request: Request) -> CharacterService:
return request.app.state.character_service
@ -19,7 +15,7 @@ def _get_service(request: Request) -> CharacterService:
@router.get("/", response_model=list[CharacterSchema])
async def list_characters(request: Request) -> list[CharacterSchema]:
"""Return all characters belonging to the authenticated account."""
client = _get_client(request)
client = get_user_client(request)
service = _get_service(request)
try:
return await service.get_all(client)
@ -33,7 +29,7 @@ async def list_characters(request: Request) -> list[CharacterSchema]:
@router.get("/{name}", response_model=CharacterSchema)
async def get_character(name: str, request: Request) -> CharacterSchema:
"""Return a single character by name."""
client = _get_client(request)
client = get_user_client(request)
service = _get_service(request)
try:
return await service.get_one(client, name)

View file

@ -3,8 +3,8 @@ import logging
from fastapi import APIRouter, HTTPException, Request
from httpx import HTTPStatusError
from app.api.deps import get_user_client
from app.schemas.game import DashboardData
from app.services.artifacts_client import ArtifactsClient
from app.services.character_service import CharacterService
logger = logging.getLogger(__name__)
@ -12,10 +12,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["dashboard"])
def _get_client(request: Request) -> ArtifactsClient:
return request.app.state.artifacts_client
def _get_service(request: Request) -> CharacterService:
return request.app.state.character_service
@ -23,7 +19,7 @@ def _get_service(request: Request) -> CharacterService:
@router.get("/dashboard", response_model=DashboardData)
async def get_dashboard(request: Request) -> DashboardData:
"""Return aggregated dashboard data: all characters + server status."""
client = _get_client(request)
client = get_user_client(request)
service = _get_service(request)
try:

47
backend/app/api/deps.py Normal file
View file

@ -0,0 +1,47 @@
"""Shared FastAPI dependencies for API endpoints."""
import hashlib
from fastapi import HTTPException, Request
from app.services.artifacts_client import ArtifactsClient
def get_user_client(request: Request) -> ArtifactsClient:
"""Return an ArtifactsClient scoped to the requesting user's token.
Reads the ``X-API-Token`` header sent by the frontend and creates a
lightweight clone of the global client that uses that token. Falls
back to the global client when no per-request token is provided (e.g.
for public / unauthenticated endpoints).
"""
token = request.headers.get("X-API-Token")
base_client: ArtifactsClient = request.app.state.artifacts_client
if token:
return base_client.with_token(token)
# No per-request token — use the global client if it has a token
if base_client.has_token:
return base_client
raise HTTPException(status_code=401, detail="No API token provided")
async def get_user_character_names(request: Request) -> list[str]:
"""Return the character names belonging to the requesting user.
Calls the Artifacts API with the user's token to get their characters,
then returns just the names. Used to scope DB queries to a single user.
"""
client = get_user_client(request)
characters = await client.get_characters()
return [c.name for c in characters]
def get_token_hash(request: Request) -> str | None:
"""Return a SHA-256 hash of the user's API token, or None."""
token = request.headers.get("X-API-Token")
if token:
return hashlib.sha256(token.encode()).hexdigest()
return None

189
backend/app/api/errors.py Normal file
View file

@ -0,0 +1,189 @@
"""Errors API router - browse, filter, resolve, and report errors.
All read endpoints are scoped to the requesting user's token so that
one user never sees errors belonging to another user.
"""
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Query, Request
from sqlalchemy import func, select
from app.database import async_session_factory
from app.models.app_error import AppError
from app.schemas.errors import (
AppErrorListResponse,
AppErrorResponse,
AppErrorStats,
FrontendErrorReport,
)
from app.services.error_service import hash_token, log_error
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/errors", tags=["errors"])
def _get_user_hash(request: Request) -> str | None:
"""Extract and hash the user token from the request."""
token = request.headers.get("X-API-Token")
return hash_token(token) if token else None
@router.get("/", response_model=AppErrorListResponse)
async def list_errors(
request: Request,
severity: str = Query(default="", description="Filter by severity"),
source: str = Query(default="", description="Filter by source"),
resolved: str = Query(default="", description="Filter by resolved status: true/false"),
page: int = Query(default=1, ge=1),
size: int = Query(default=50, ge=1, le=100),
) -> AppErrorListResponse:
"""List errors with optional filtering and pagination.
Only returns errors belonging to the authenticated user.
"""
user_hash = _get_user_hash(request)
if not user_hash:
return AppErrorListResponse(errors=[], total=0, page=1, pages=1)
async with async_session_factory() as db:
user_filter = AppError.user_token_hash == user_hash
stmt = select(AppError).where(user_filter).order_by(AppError.created_at.desc())
count_stmt = select(func.count(AppError.id)).where(user_filter)
if severity:
stmt = stmt.where(AppError.severity == severity)
count_stmt = count_stmt.where(AppError.severity == severity)
if source:
stmt = stmt.where(AppError.source == source)
count_stmt = count_stmt.where(AppError.source == source)
if resolved in ("true", "false"):
val = resolved == "true"
stmt = stmt.where(AppError.resolved == val)
count_stmt = count_stmt.where(AppError.resolved == val)
total = (await db.execute(count_stmt)).scalar() or 0
pages = max(1, (total + size - 1) // size)
offset = (page - 1) * size
stmt = stmt.offset(offset).limit(size)
result = await db.execute(stmt)
rows = result.scalars().all()
return AppErrorListResponse(
errors=[AppErrorResponse.model_validate(r) for r in rows],
total=total,
page=page,
pages=pages,
)
@router.get("/stats", response_model=AppErrorStats)
async def error_stats(request: Request) -> AppErrorStats:
"""Aggregated error statistics scoped to the authenticated user."""
user_hash = _get_user_hash(request)
if not user_hash:
return AppErrorStats()
async with async_session_factory() as db:
user_filter = AppError.user_token_hash == user_hash
total = (
await db.execute(select(func.count(AppError.id)).where(user_filter))
).scalar() or 0
unresolved = (
await db.execute(
select(func.count(AppError.id)).where(
user_filter, AppError.resolved == False # noqa: E712
)
)
).scalar() or 0
one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
last_hour = (
await db.execute(
select(func.count(AppError.id)).where(
user_filter, AppError.created_at >= one_hour_ago
)
)
).scalar() or 0
# By severity
sev_rows = (
await db.execute(
select(AppError.severity, func.count(AppError.id))
.where(user_filter)
.group_by(AppError.severity)
)
).all()
by_severity = {row[0]: row[1] for row in sev_rows}
# By source
src_rows = (
await db.execute(
select(AppError.source, func.count(AppError.id))
.where(user_filter)
.group_by(AppError.source)
)
).all()
by_source = {row[0]: row[1] for row in src_rows}
return AppErrorStats(
total=total,
unresolved=unresolved,
last_hour=last_hour,
by_severity=by_severity,
by_source=by_source,
)
@router.post("/{error_id}/resolve", response_model=AppErrorResponse)
async def resolve_error(error_id: int, request: Request) -> AppErrorResponse:
"""Mark an error as resolved (only if it belongs to the requesting user)."""
user_hash = _get_user_hash(request)
async with async_session_factory() as db:
stmt = select(AppError).where(AppError.id == error_id)
if user_hash:
stmt = stmt.where(AppError.user_token_hash == user_hash)
result = await db.execute(stmt)
record = result.scalar_one_or_none()
if record is None:
raise HTTPException(status_code=404, detail="Error not found")
record.resolved = True
await db.commit()
await db.refresh(record)
return AppErrorResponse.model_validate(record)
@router.post("/report", status_code=201)
async def report_frontend_error(
body: FrontendErrorReport, request: Request
) -> dict[str, str]:
"""Receive error reports from the frontend."""
user_hash = _get_user_hash(request)
await log_error(
async_session_factory,
severity=body.severity,
source="frontend",
error_type=body.error_type,
message=body.message,
context=body.context,
user_token_hash=user_hash,
)
# Also capture in Sentry if available
try:
import sentry_sdk
sentry_sdk.capture_message(
f"[Frontend] {body.error_type}: {body.message}",
level="error",
)
except Exception:
pass
return {"status": "recorded"}

View file

@ -7,23 +7,19 @@ from fastapi import APIRouter, HTTPException, Query, Request
from httpx import HTTPStatusError
from sqlalchemy import select
from app.api.deps import get_user_client
from app.database import async_session_factory
from app.models.event_log import EventLog
from app.services.artifacts_client import ArtifactsClient
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/events", tags=["events"])
def _get_client(request: Request) -> ArtifactsClient:
return request.app.state.artifacts_client
@router.get("/")
async def get_active_events(request: Request) -> dict[str, Any]:
"""Get currently active game events from the Artifacts API."""
client = _get_client(request)
client = get_user_client(request)
try:
events = await client.get_events()

View file

@ -6,8 +6,8 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from httpx import HTTPStatusError
from app.api.deps import get_user_client
from app.database import async_session_factory
from app.services.artifacts_client import ArtifactsClient
from app.services.exchange_service import ExchangeService
logger = logging.getLogger(__name__)
@ -15,10 +15,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
def _get_client(request: Request) -> ArtifactsClient:
return request.app.state.artifacts_client
def _get_exchange_service(request: Request) -> ExchangeService:
service: ExchangeService | None = getattr(request.app.state, "exchange_service", None)
if service is None:
@ -36,7 +32,7 @@ async def browse_orders(
type: str | None = Query(default=None, description="Filter by order type (sell or buy)"),
) -> dict[str, Any]:
"""Browse all active Grand Exchange orders (public market data)."""
client = _get_client(request)
client = get_user_client(request)
service = _get_exchange_service(request)
try:
@ -53,7 +49,7 @@ async def browse_orders(
@router.get("/my-orders")
async def get_my_orders(request: Request) -> dict[str, Any]:
"""Get the authenticated account's own active GE orders."""
client = _get_client(request)
client = get_user_client(request)
service = _get_exchange_service(request)
try:
@ -70,7 +66,7 @@ async def get_my_orders(request: Request) -> dict[str, Any]:
@router.get("/history")
async def get_history(request: Request) -> dict[str, Any]:
"""Get the authenticated account's GE transaction history."""
client = _get_client(request)
client = get_user_client(request)
service = _get_exchange_service(request)
try:
@ -87,7 +83,7 @@ async def get_history(request: Request) -> dict[str, Any]:
@router.get("/sell-history/{item_code}")
async def get_sell_history(item_code: str, request: Request) -> dict[str, Any]:
"""Get public sale history for a specific item (last 7 days from API)."""
client = _get_client(request)
client = get_user_client(request)
service = _get_exchange_service(request)
try:

View file

@ -6,6 +6,7 @@ from typing import Any
from fastapi import APIRouter, Query, Request
from sqlalchemy import select
from app.api.deps import get_user_character_names, get_user_client
from app.database import async_session_factory
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
from app.services.analytics_service import AnalyticsService
@ -17,14 +18,112 @@ router = APIRouter(prefix="/api/logs", tags=["logs"])
@router.get("/")
async def get_logs(
request: Request,
character: str = Query(default="", description="Character name to filter logs"),
limit: int = Query(default=50, ge=1, le=200, description="Max entries to return"),
type: str = Query(default="", description="Action type to filter (e.g. fight, gathering)"),
page: int = Query(default=1, ge=1, description="Page number"),
size: int = Query(default=50, ge=1, le=100, description="Page size"),
) -> dict[str, Any]:
"""Get automation action logs from the database.
"""Get action logs from the Artifacts game API.
Joins automation_logs -> automation_runs -> automation_configs
to include character_name with each log entry.
Fetches the last 5000 character actions directly from the game server.
Falls back to local automation logs if the game API is unavailable.
"""
client = get_user_client(request)
try:
if character:
result = await client.get_character_logs(character, page=page, size=size)
else:
result = await client.get_logs(page=page, size=size)
raw_logs = result.get("data", [])
total = result.get("total", 0)
pages = result.get("pages", 1)
# Filter by type if specified
if type:
raw_logs = [log for log in raw_logs if log.get("type") == type]
logs = []
for entry in raw_logs:
content = entry.get("content", {})
action_type = entry.get("type", "unknown")
# Build details - description is the main human-readable field
details: dict[str, Any] = {}
description = entry.get("description", "")
if description:
details["description"] = description
# Extract structured data per action type
if "fight" in content:
fight = content["fight"]
details["monster"] = fight.get("opponent", "")
details["result"] = fight.get("result", "")
details["turns"] = fight.get("turns", 0)
if "gathering" in content:
g = content["gathering"]
details["resource"] = g.get("resource", "")
details["skill"] = g.get("skill", "")
details["xp"] = g.get("xp_gained", 0)
if "drops" in content:
items = content["drops"].get("items", [])
if items:
details["drops"] = [
f"{i.get('quantity', 1)}x {i.get('code', '?')}" for i in items
]
if "map" in content:
m = content["map"]
details["x"] = m.get("x")
details["y"] = m.get("y")
details["map_name"] = m.get("name", "")
if "crafting" in content:
c = content["crafting"]
details["item"] = c.get("code", "")
details["skill"] = c.get("skill", "")
details["xp"] = c.get("xp_gained", 0)
if "hp_restored" in content:
details["hp_restored"] = content["hp_restored"]
logs.append({
"id": hash(f"{entry.get('character', '')}-{entry.get('created_at', '')}") & 0x7FFFFFFF,
"character_name": entry.get("character", ""),
"action_type": action_type,
"details": details,
"success": True,
"created_at": entry.get("created_at", ""),
"cooldown": entry.get("cooldown", 0),
})
return {
"logs": logs,
"total": total,
"page": page,
"pages": pages,
}
except Exception:
logger.warning("Failed to fetch logs from game API, falling back to local DB", exc_info=True)
user_chars = await get_user_character_names(request)
return await _get_local_logs(character, type, page, size, user_chars)
async def _get_local_logs(
character: str,
type: str,
page: int,
size: int,
user_characters: list[str] | None = None,
) -> dict[str, Any]:
"""Fallback: get automation logs from local database."""
offset = (page - 1) * size
async with async_session_factory() as db:
stmt = (
select(
@ -38,12 +137,20 @@ async def get_logs(
.join(AutomationRun, AutomationLog.run_id == AutomationRun.id)
.join(AutomationConfig, AutomationRun.config_id == AutomationConfig.id)
.order_by(AutomationLog.created_at.desc())
.limit(limit)
)
# Scope to the current user's characters
if user_characters is not None:
stmt = stmt.where(AutomationConfig.character_name.in_(user_characters))
if character:
stmt = stmt.where(AutomationConfig.character_name == character)
if type:
stmt = stmt.where(AutomationLog.action_type == type)
stmt = stmt.offset(offset).limit(size)
result = await db.execute(stmt)
rows = result.all()
@ -59,6 +166,9 @@ async def get_logs(
}
for row in rows
],
"total": len(rows),
"page": page,
"pages": 1,
}
@ -71,15 +181,21 @@ async def get_analytics(
"""Get analytics aggregations for a character.
Returns XP history, gold history, and estimated actions per hour.
If no character is specified, aggregates across all characters with snapshots.
If no character is specified, aggregates across the current user's characters.
"""
analytics = AnalyticsService()
user_chars = await get_user_character_names(request)
async with async_session_factory() as db:
if character:
# Verify the requested character belongs to the current user
if character not in user_chars:
return {"xp_history": [], "gold_history": [], "actions_per_hour": 0}
characters = [character]
else:
characters = await analytics.get_tracked_characters(db)
# Only aggregate characters belonging to the current user
tracked = await analytics.get_tracked_characters(db)
characters = [c for c in tracked if c in user_chars]
all_xp: list[dict[str, Any]] = []
all_gold: list[dict[str, Any]] = []

View file

@ -0,0 +1,267 @@
import logging
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.deps import get_user_character_names
from app.database import async_session_factory
from app.engine.manager import AutomationManager
from app.models.automation import AutomationLog
from app.models.pipeline import PipelineConfig, PipelineRun
from app.schemas.automation import AutomationLogResponse
from app.schemas.pipeline import (
PipelineConfigCreate,
PipelineConfigDetailResponse,
PipelineConfigResponse,
PipelineConfigUpdate,
PipelineRunResponse,
PipelineStatusResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/pipelines", tags=["pipelines"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_manager(request: Request) -> AutomationManager:
manager: AutomationManager | None = getattr(request.app.state, "automation_manager", None)
if manager is None:
raise HTTPException(
status_code=503,
detail="Automation engine is not available",
)
return manager
# ---------------------------------------------------------------------------
# CRUD -- Pipeline Configs
# ---------------------------------------------------------------------------
def _pipeline_belongs_to_user(pipeline: PipelineConfig, user_chars: set[str]) -> bool:
"""Check if any character in the pipeline stages belongs to the user."""
for stage in (pipeline.stages or []):
for step in (stage.get("character_steps") or []):
if step.get("character_name") in user_chars:
return True
return False
@router.get("/", response_model=list[PipelineConfigResponse])
async def list_pipelines(request: Request) -> list[PipelineConfigResponse]:
"""List pipeline configurations belonging to the current user."""
user_chars = set(await get_user_character_names(request))
async with async_session_factory() as db:
stmt = select(PipelineConfig).order_by(PipelineConfig.id)
result = await db.execute(stmt)
configs = result.scalars().all()
return [
PipelineConfigResponse.model_validate(c)
for c in configs
if _pipeline_belongs_to_user(c, user_chars)
]
@router.post("/", response_model=PipelineConfigResponse, status_code=201)
async def create_pipeline(
payload: PipelineConfigCreate,
request: Request,
) -> PipelineConfigResponse:
"""Create a new pipeline configuration."""
async with async_session_factory() as db:
config = PipelineConfig(
name=payload.name,
description=payload.description,
stages=[stage.model_dump() for stage in payload.stages],
loop=payload.loop,
max_loops=payload.max_loops,
)
db.add(config)
await db.commit()
await db.refresh(config)
return PipelineConfigResponse.model_validate(config)
@router.get("/status/all", response_model=list[PipelineStatusResponse])
async def get_all_pipeline_statuses(request: Request) -> list[PipelineStatusResponse]:
"""Get live status for all active pipelines."""
manager = _get_manager(request)
return manager.get_all_pipeline_statuses()
@router.get("/{pipeline_id}", response_model=PipelineConfigDetailResponse)
async def get_pipeline(pipeline_id: int, request: Request) -> PipelineConfigDetailResponse:
"""Get a pipeline configuration with its run history."""
async with async_session_factory() as db:
stmt = (
select(PipelineConfig)
.options(selectinload(PipelineConfig.runs))
.where(PipelineConfig.id == pipeline_id)
)
result = await db.execute(stmt)
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(status_code=404, detail="Pipeline config not found")
return PipelineConfigDetailResponse(
config=PipelineConfigResponse.model_validate(config),
runs=[PipelineRunResponse.model_validate(r) for r in config.runs],
)
@router.put("/{pipeline_id}", response_model=PipelineConfigResponse)
async def update_pipeline(
pipeline_id: int,
payload: PipelineConfigUpdate,
request: Request,
) -> PipelineConfigResponse:
"""Update a pipeline configuration. Cannot update while running."""
manager = _get_manager(request)
if manager.is_pipeline_running(pipeline_id):
raise HTTPException(
status_code=409,
detail="Cannot update a pipeline while it is running. Stop it first.",
)
async with async_session_factory() as db:
config = await db.get(PipelineConfig, pipeline_id)
if config is None:
raise HTTPException(status_code=404, detail="Pipeline config not found")
if payload.name is not None:
config.name = payload.name
if payload.description is not None:
config.description = payload.description
if payload.stages is not None:
config.stages = [stage.model_dump() for stage in payload.stages]
if payload.loop is not None:
config.loop = payload.loop
if payload.max_loops is not None:
config.max_loops = payload.max_loops
if payload.enabled is not None:
config.enabled = payload.enabled
await db.commit()
await db.refresh(config)
return PipelineConfigResponse.model_validate(config)
@router.delete("/{pipeline_id}", status_code=204)
async def delete_pipeline(pipeline_id: int, request: Request) -> None:
"""Delete a pipeline configuration. Cannot delete while running."""
manager = _get_manager(request)
if manager.is_pipeline_running(pipeline_id):
raise HTTPException(
status_code=409,
detail="Cannot delete a pipeline while it is running. Stop it first.",
)
async with async_session_factory() as db:
config = await db.get(PipelineConfig, pipeline_id)
if config is None:
raise HTTPException(status_code=404, detail="Pipeline config not found")
await db.delete(config)
await db.commit()
# ---------------------------------------------------------------------------
# Control -- Start / Stop / Pause / Resume
# ---------------------------------------------------------------------------
@router.post("/{pipeline_id}/start", response_model=PipelineRunResponse)
async def start_pipeline(pipeline_id: int, request: Request) -> PipelineRunResponse:
"""Start a pipeline from its configuration."""
manager = _get_manager(request)
try:
return await manager.start_pipeline(pipeline_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{pipeline_id}/stop", status_code=204)
async def stop_pipeline(pipeline_id: int, request: Request) -> None:
"""Stop a running pipeline."""
manager = _get_manager(request)
try:
await manager.stop_pipeline(pipeline_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{pipeline_id}/pause", status_code=204)
async def pause_pipeline(pipeline_id: int, request: Request) -> None:
"""Pause a running pipeline."""
manager = _get_manager(request)
try:
await manager.pause_pipeline(pipeline_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{pipeline_id}/resume", status_code=204)
async def resume_pipeline(pipeline_id: int, request: Request) -> None:
"""Resume a paused pipeline."""
manager = _get_manager(request)
try:
await manager.resume_pipeline(pipeline_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# ---------------------------------------------------------------------------
# Status & Logs
# ---------------------------------------------------------------------------
@router.get("/{pipeline_id}/status", response_model=PipelineStatusResponse)
async def get_pipeline_status(
pipeline_id: int,
request: Request,
) -> PipelineStatusResponse:
"""Get live status for a specific pipeline."""
manager = _get_manager(request)
status = manager.get_pipeline_status(pipeline_id)
if status is None:
async with async_session_factory() as db:
config = await db.get(PipelineConfig, pipeline_id)
if config is None:
raise HTTPException(status_code=404, detail="Pipeline config not found")
return PipelineStatusResponse(
pipeline_id=pipeline_id,
status="stopped",
total_stages=len(config.stages),
)
return status
@router.get("/{pipeline_id}/logs", response_model=list[AutomationLogResponse])
async def get_pipeline_logs(
pipeline_id: int,
request: Request,
limit: int = 100,
) -> list[AutomationLogResponse]:
"""Get recent logs for a pipeline (across all its runs)."""
async with async_session_factory() as db:
config = await db.get(PipelineConfig, pipeline_id)
if config is None:
raise HTTPException(status_code=404, detail="Pipeline config not found")
stmt = (
select(AutomationLog)
.join(PipelineRun, AutomationLog.run_id == PipelineRun.id)
.where(PipelineRun.pipeline_id == pipeline_id)
.order_by(AutomationLog.created_at.desc())
.limit(min(limit, 500))
)
result = await db.execute(stmt)
logs = result.scalars().all()
return [AutomationLogResponse.model_validate(log) for log in logs]

View file

@ -0,0 +1,261 @@
import logging
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.deps import get_user_character_names
from app.database import async_session_factory
from app.engine.manager import AutomationManager
from app.models.automation import AutomationLog
from app.models.workflow import WorkflowConfig, WorkflowRun
from app.schemas.automation import AutomationLogResponse
from app.schemas.workflow import (
WorkflowConfigCreate,
WorkflowConfigDetailResponse,
WorkflowConfigResponse,
WorkflowConfigUpdate,
WorkflowRunResponse,
WorkflowStatusResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_manager(request: Request) -> AutomationManager:
manager: AutomationManager | None = getattr(request.app.state, "automation_manager", None)
if manager is None:
raise HTTPException(
status_code=503,
detail="Automation engine is not available",
)
return manager
# ---------------------------------------------------------------------------
# CRUD -- Workflow Configs
# ---------------------------------------------------------------------------
@router.get("/", response_model=list[WorkflowConfigResponse])
async def list_workflows(request: Request) -> list[WorkflowConfigResponse]:
"""List workflow configurations belonging to the current user."""
user_chars = await get_user_character_names(request)
async with async_session_factory() as db:
stmt = (
select(WorkflowConfig)
.where(WorkflowConfig.character_name.in_(user_chars))
.order_by(WorkflowConfig.id)
)
result = await db.execute(stmt)
configs = result.scalars().all()
return [WorkflowConfigResponse.model_validate(c) for c in configs]
@router.post("/", response_model=WorkflowConfigResponse, status_code=201)
async def create_workflow(
payload: WorkflowConfigCreate,
request: Request,
) -> WorkflowConfigResponse:
"""Create a new workflow configuration."""
async with async_session_factory() as db:
config = WorkflowConfig(
name=payload.name,
character_name=payload.character_name,
description=payload.description,
steps=[step.model_dump() for step in payload.steps],
loop=payload.loop,
max_loops=payload.max_loops,
)
db.add(config)
await db.commit()
await db.refresh(config)
return WorkflowConfigResponse.model_validate(config)
@router.get("/status/all", response_model=list[WorkflowStatusResponse])
async def get_all_workflow_statuses(request: Request) -> list[WorkflowStatusResponse]:
"""Get live status for all active workflows."""
manager = _get_manager(request)
return manager.get_all_workflow_statuses()
@router.get("/{workflow_id}", response_model=WorkflowConfigDetailResponse)
async def get_workflow(workflow_id: int, request: Request) -> WorkflowConfigDetailResponse:
"""Get a workflow configuration with its run history."""
async with async_session_factory() as db:
stmt = (
select(WorkflowConfig)
.options(selectinload(WorkflowConfig.runs))
.where(WorkflowConfig.id == workflow_id)
)
result = await db.execute(stmt)
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(status_code=404, detail="Workflow config not found")
return WorkflowConfigDetailResponse(
config=WorkflowConfigResponse.model_validate(config),
runs=[WorkflowRunResponse.model_validate(r) for r in config.runs],
)
@router.put("/{workflow_id}", response_model=WorkflowConfigResponse)
async def update_workflow(
workflow_id: int,
payload: WorkflowConfigUpdate,
request: Request,
) -> WorkflowConfigResponse:
"""Update a workflow configuration. Cannot update while running."""
manager = _get_manager(request)
if manager.is_workflow_running(workflow_id):
raise HTTPException(
status_code=409,
detail="Cannot update a workflow while it is running. Stop it first.",
)
async with async_session_factory() as db:
config = await db.get(WorkflowConfig, workflow_id)
if config is None:
raise HTTPException(status_code=404, detail="Workflow config not found")
if payload.name is not None:
config.name = payload.name
if payload.description is not None:
config.description = payload.description
if payload.steps is not None:
config.steps = [step.model_dump() for step in payload.steps]
if payload.loop is not None:
config.loop = payload.loop
if payload.max_loops is not None:
config.max_loops = payload.max_loops
if payload.enabled is not None:
config.enabled = payload.enabled
await db.commit()
await db.refresh(config)
return WorkflowConfigResponse.model_validate(config)
@router.delete("/{workflow_id}", status_code=204)
async def delete_workflow(workflow_id: int, request: Request) -> None:
"""Delete a workflow configuration. Cannot delete while running."""
manager = _get_manager(request)
if manager.is_workflow_running(workflow_id):
raise HTTPException(
status_code=409,
detail="Cannot delete a workflow while it is running. Stop it first.",
)
async with async_session_factory() as db:
config = await db.get(WorkflowConfig, workflow_id)
if config is None:
raise HTTPException(status_code=404, detail="Workflow config not found")
await db.delete(config)
await db.commit()
# ---------------------------------------------------------------------------
# Control -- Start / Stop / Pause / Resume
# ---------------------------------------------------------------------------
@router.post("/{workflow_id}/start", response_model=WorkflowRunResponse)
async def start_workflow(workflow_id: int, request: Request) -> WorkflowRunResponse:
"""Start a workflow from its configuration."""
manager = _get_manager(request)
try:
return await manager.start_workflow(workflow_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{workflow_id}/stop", status_code=204)
async def stop_workflow(workflow_id: int, request: Request) -> None:
"""Stop a running workflow."""
manager = _get_manager(request)
try:
await manager.stop_workflow(workflow_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{workflow_id}/pause", status_code=204)
async def pause_workflow(workflow_id: int, request: Request) -> None:
"""Pause a running workflow."""
manager = _get_manager(request)
try:
await manager.pause_workflow(workflow_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{workflow_id}/resume", status_code=204)
async def resume_workflow(workflow_id: int, request: Request) -> None:
"""Resume a paused workflow."""
manager = _get_manager(request)
try:
await manager.resume_workflow(workflow_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# ---------------------------------------------------------------------------
# Status & Logs
# ---------------------------------------------------------------------------
@router.get("/{workflow_id}/status", response_model=WorkflowStatusResponse)
async def get_workflow_status(
workflow_id: int,
request: Request,
) -> WorkflowStatusResponse:
"""Get live status for a specific workflow."""
manager = _get_manager(request)
status = manager.get_workflow_status(workflow_id)
if status is None:
async with async_session_factory() as db:
config = await db.get(WorkflowConfig, workflow_id)
if config is None:
raise HTTPException(status_code=404, detail="Workflow config not found")
return WorkflowStatusResponse(
workflow_id=workflow_id,
character_name=config.character_name,
status="stopped",
total_steps=len(config.steps),
)
return status
@router.get("/{workflow_id}/logs", response_model=list[AutomationLogResponse])
async def get_workflow_logs(
workflow_id: int,
request: Request,
limit: int = 100,
) -> list[AutomationLogResponse]:
"""Get recent logs for a workflow (across all its runs)."""
async with async_session_factory() as db:
config = await db.get(WorkflowConfig, workflow_id)
if config is None:
raise HTTPException(status_code=404, detail="Workflow config not found")
# Fetch logs for all runs belonging to this workflow
stmt = (
select(AutomationLog)
.join(WorkflowRun, AutomationLog.run_id == WorkflowRun.id)
.where(WorkflowRun.workflow_id == workflow_id)
.order_by(AutomationLog.created_at.desc())
.limit(min(limit, 500))
)
result = await db.execute(stmt)
logs = result.scalars().all()
return [AutomationLogResponse.model_validate(log) for log in logs]

View file

@ -15,6 +15,10 @@ class Settings(BaseSettings):
data_rate_limit: int = 20 # data requests per window
data_rate_window: float = 1.0 # seconds
# Observability
sentry_dsn: str = ""
environment: str = "development"
model_config = {"env_file": ".env", "extra": "ignore"}

View file

@ -0,0 +1,150 @@
"""Shared action execution logic.
Dispatches an ``ActionPlan`` to the appropriate ``ArtifactsClient`` method.
Used by ``AutomationRunner``, ``WorkflowRunner``, and ``CharacterWorker``
so the match statement is defined in exactly one place.
"""
from __future__ import annotations
import logging
from typing import Any
from app.engine.strategies.base import ActionPlan, ActionType
from app.services.artifacts_client import ArtifactsClient
logger = logging.getLogger(__name__)
async def execute_action(
client: ArtifactsClient,
character_name: str,
plan: ActionPlan,
) -> dict[str, Any]:
"""Execute an action plan against the game API and return the raw result."""
match plan.action_type:
case ActionType.MOVE:
return await client.move(
character_name,
plan.params["x"],
plan.params["y"],
)
case ActionType.FIGHT:
return await client.fight(character_name)
case ActionType.GATHER:
return await client.gather(character_name)
case ActionType.REST:
return await client.rest(character_name)
case ActionType.EQUIP:
return await client.equip(
character_name,
plan.params["code"],
plan.params["slot"],
plan.params.get("quantity", 1),
)
case ActionType.UNEQUIP:
return await client.unequip(
character_name,
plan.params["slot"],
plan.params.get("quantity", 1),
)
case ActionType.USE_ITEM:
return await client.use_item(
character_name,
plan.params["code"],
plan.params.get("quantity", 1),
)
case ActionType.DEPOSIT_ITEM:
return await client.deposit_item(
character_name,
plan.params["code"],
plan.params["quantity"],
)
case ActionType.WITHDRAW_ITEM:
return await client.withdraw_item(
character_name,
plan.params["code"],
plan.params["quantity"],
)
case ActionType.CRAFT:
return await client.craft(
character_name,
plan.params["code"],
plan.params.get("quantity", 1),
)
case ActionType.RECYCLE:
return await client.recycle(
character_name,
plan.params["code"],
plan.params.get("quantity", 1),
)
case ActionType.GE_BUY:
return await client.ge_buy(
character_name,
plan.params["id"],
plan.params["quantity"],
)
case ActionType.GE_CREATE_BUY:
return await client.ge_create_buy_order(
character_name,
plan.params["code"],
plan.params["quantity"],
plan.params["price"],
)
case ActionType.GE_SELL:
return await client.ge_sell_order(
character_name,
plan.params["code"],
plan.params["quantity"],
plan.params["price"],
)
case ActionType.GE_FILL:
return await client.ge_fill_buy_order(
character_name,
plan.params["id"],
plan.params["quantity"],
)
case ActionType.GE_CANCEL:
return await client.ge_cancel(
character_name,
plan.params["order_id"],
)
case ActionType.TASK_NEW:
return await client.task_new(character_name)
case ActionType.TASK_TRADE:
return await client.task_trade(
character_name,
plan.params["code"],
plan.params["quantity"],
)
case ActionType.TASK_COMPLETE:
return await client.task_complete(character_name)
case ActionType.TASK_EXCHANGE:
return await client.task_exchange(character_name)
case ActionType.TASK_CANCEL:
return await client.task_cancel(character_name)
case ActionType.DEPOSIT_GOLD:
return await client.deposit_gold(
character_name,
plan.params["quantity"],
)
case ActionType.WITHDRAW_GOLD:
return await client.withdraw_gold(
character_name,
plan.params["quantity"],
)
case ActionType.NPC_BUY:
return await client.npc_buy(
character_name,
plan.params["code"],
plan.params["quantity"],
)
case ActionType.NPC_SELL:
return await client.npc_sell(
character_name,
plan.params["code"],
plan.params["quantity"],
)
case _:
logger.warning("Unhandled action type: %s", plan.action_type)
return {}

View file

@ -1,16 +1,17 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import selectinload
from app.engine.cooldown import CooldownTracker
from app.engine.pathfinder import Pathfinder
from app.engine.runner import AutomationRunner
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
from app.engine.decision.monster_selector import MonsterSelector
from app.engine.decision.resource_selector import ResourceSelector
from app.engine.strategies.base import BaseStrategy
from app.engine.strategies.combat import CombatStrategy
from app.engine.strategies.crafting import CraftingStrategy
@ -18,12 +19,22 @@ from app.engine.strategies.gathering import GatheringStrategy
from app.engine.strategies.leveling import LevelingStrategy
from app.engine.strategies.task import TaskStrategy
from app.engine.strategies.trading import TradingStrategy
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
from app.engine.pipeline.coordinator import PipelineCoordinator
from app.engine.workflow.runner import WorkflowRunner
from app.models.automation import AutomationConfig, AutomationRun
from app.models.pipeline import PipelineConfig, PipelineRun
from app.models.workflow import WorkflowConfig, WorkflowRun
from app.schemas.automation import (
AutomationLogResponse,
AutomationRunResponse,
AutomationStatusResponse,
)
from app.schemas.game import ItemSchema, MonsterSchema, ResourceSchema
from app.schemas.pipeline import (
CharacterStateResponse,
PipelineRunResponse,
PipelineStatusResponse,
)
from app.schemas.workflow import WorkflowRunResponse, WorkflowStatusResponse
from app.services.artifacts_client import ArtifactsClient
if TYPE_CHECKING:
@ -33,12 +44,13 @@ logger = logging.getLogger(__name__)
class AutomationManager:
"""Central manager that orchestrates all automation runners.
"""Central manager that orchestrates all automation runners and workflow runners.
One manager exists per application instance and is stored on
``app.state.automation_manager``. It holds references to all active
runners (keyed by ``config_id``) and provides high-level start / stop /
pause / resume operations.
runners (keyed by ``config_id``) and workflow runners (keyed by
``workflow_id``), and provides high-level start / stop / pause /
resume operations.
"""
def __init__(
@ -53,24 +65,68 @@ class AutomationManager:
self._pathfinder = pathfinder
self._event_bus = event_bus
self._runners: dict[int, AutomationRunner] = {}
self._workflow_runners: dict[int, WorkflowRunner] = {}
self._pipeline_coordinators: dict[int, PipelineCoordinator] = {}
self._cooldown_tracker = CooldownTracker()
# Lazy-loaded game data caches for smart strategies
self._monsters_cache: list[MonsterSchema] | None = None
self._resources_cache: list[ResourceSchema] | None = None
self._items_cache: list[ItemSchema] | None = None
# ------------------------------------------------------------------
# Lifecycle
# Game data cache
# ------------------------------------------------------------------
async def _ensure_game_data(self) -> None:
"""Load game data caches lazily on first use."""
if self._monsters_cache is None:
try:
raw = await self._client.get_all_monsters()
self._monsters_cache = [MonsterSchema(**m) for m in raw]
except Exception:
logger.exception("Failed to load monsters cache")
self._monsters_cache = []
if self._resources_cache is None:
try:
raw = await self._client.get_all_resources()
self._resources_cache = [ResourceSchema(**r) for r in raw]
except Exception:
logger.exception("Failed to load resources cache")
self._resources_cache = []
if self._items_cache is None:
try:
raw = await self._client.get_all_items()
self._items_cache = [ItemSchema(**i) for i in raw]
except Exception:
logger.exception("Failed to load items cache")
self._items_cache = []
# ------------------------------------------------------------------
# Character busy check
# ------------------------------------------------------------------
def is_character_busy(self, character_name: str) -> bool:
"""Return True if the character is running any automation, workflow, or pipeline."""
for runner in self._runners.values():
if runner.character_name == character_name and (runner.is_running or runner.is_paused):
return True
for wf_runner in self._workflow_runners.values():
if wf_runner.character_name == character_name and (wf_runner.is_running or wf_runner.is_paused):
return True
for coord in self._pipeline_coordinators.values():
if (coord.is_running or coord.is_paused) and character_name in coord.all_characters:
return True
return False
# ------------------------------------------------------------------
# Automation Lifecycle
# ------------------------------------------------------------------
async def start(self, config_id: int) -> AutomationRunResponse:
"""Start an automation from its persisted configuration.
Creates a new :class:`AutomationRun` record and spawns an
:class:`AutomationRunner` task.
Raises
------
ValueError
If the config does not exist, is disabled, or is already running.
"""
# Prevent duplicate runners
"""Start an automation from its persisted configuration."""
if config_id in self._runners:
runner = self._runners[config_id]
if runner.is_running or runner.is_paused:
@ -80,17 +136,23 @@ class AutomationManager:
)
async with self._db_factory() as db:
# Load the config
config = await db.get(AutomationConfig, config_id)
if config is None:
raise ValueError(f"Automation config {config_id} not found")
if not config.enabled:
raise ValueError(f"Automation config {config_id} is disabled")
# Create strategy
# Check character busy
if self.is_character_busy(config.character_name):
raise ValueError(
f"Character {config.character_name!r} is already running an automation or workflow"
)
# Ensure game data is loaded for smart strategies
await self._ensure_game_data()
strategy = self._create_strategy(config.strategy_type, config.config)
# Create run record
run = AutomationRun(
config_id=config_id,
status="running",
@ -101,7 +163,6 @@ class AutomationManager:
run_response = AutomationRunResponse.model_validate(run)
# Build and start the runner
runner = AutomationRunner(
config_id=config_id,
character_name=config.character_name,
@ -125,55 +186,31 @@ class AutomationManager:
return run_response
async def stop(self, config_id: int) -> None:
"""Stop a running automation.
Raises
------
ValueError
If no runner exists for the given config.
"""
runner = self._runners.get(config_id)
if runner is None:
raise ValueError(f"No active runner for config {config_id}")
await runner.stop()
del self._runners[config_id]
logger.info("Stopped automation config=%d", config_id)
async def pause(self, config_id: int) -> None:
"""Pause a running automation.
Raises
------
ValueError
If no runner exists for the given config or it is not running.
"""
runner = self._runners.get(config_id)
if runner is None:
raise ValueError(f"No active runner for config {config_id}")
if not runner.is_running:
raise ValueError(f"Runner for config {config_id} is not running (status={runner.status})")
await runner.pause()
async def resume(self, config_id: int) -> None:
"""Resume a paused automation.
Raises
------
ValueError
If no runner exists for the given config or it is not paused.
"""
runner = self._runners.get(config_id)
if runner is None:
raise ValueError(f"No active runner for config {config_id}")
if not runner.is_paused:
raise ValueError(f"Runner for config {config_id} is not paused (status={runner.status})")
await runner.resume()
async def stop_all(self) -> None:
"""Stop all running automations (used during shutdown)."""
"""Stop all running automations, workflows, and pipelines (used during shutdown)."""
config_ids = list(self._runners.keys())
for config_id in config_ids:
try:
@ -181,12 +218,25 @@ class AutomationManager:
except Exception:
logger.exception("Error stopping automation config=%d", config_id)
workflow_ids = list(self._workflow_runners.keys())
for wf_id in workflow_ids:
try:
await self.stop_workflow(wf_id)
except Exception:
logger.exception("Error stopping workflow=%d", wf_id)
pipeline_ids = list(self._pipeline_coordinators.keys())
for pid in pipeline_ids:
try:
await self.stop_pipeline(pid)
except Exception:
logger.exception("Error stopping pipeline=%d", pid)
# ------------------------------------------------------------------
# Status queries
# Automation Status queries
# ------------------------------------------------------------------
def get_status(self, config_id: int) -> AutomationStatusResponse | None:
"""Return the live status of a single automation, or ``None``."""
runner = self._runners.get(config_id)
if runner is None:
return None
@ -200,7 +250,6 @@ class AutomationManager:
)
def get_all_statuses(self) -> list[AutomationStatusResponse]:
"""Return live status for all active automations."""
return [
AutomationStatusResponse(
config_id=r.config_id,
@ -214,29 +263,339 @@ class AutomationManager:
]
def is_running(self, config_id: int) -> bool:
"""Return True if there is an active runner for the config."""
runner = self._runners.get(config_id)
return runner is not None and (runner.is_running or runner.is_paused)
# ------------------------------------------------------------------
# Workflow Lifecycle
# ------------------------------------------------------------------
async def start_workflow(self, workflow_id: int) -> WorkflowRunResponse:
"""Start a workflow from its persisted configuration."""
if workflow_id in self._workflow_runners:
runner = self._workflow_runners[workflow_id]
if runner.is_running or runner.is_paused:
raise ValueError(
f"Workflow {workflow_id} is already running "
f"(run_id={runner.run_id}, status={runner.status})"
)
async with self._db_factory() as db:
config = await db.get(WorkflowConfig, workflow_id)
if config is None:
raise ValueError(f"Workflow config {workflow_id} not found")
if not config.enabled:
raise ValueError(f"Workflow config {workflow_id} is disabled")
if not config.steps:
raise ValueError(f"Workflow config {workflow_id} has no steps")
# Check character busy
if self.is_character_busy(config.character_name):
raise ValueError(
f"Character {config.character_name!r} is already running an automation or workflow"
)
# Ensure game data for smart strategies
await self._ensure_game_data()
# Create workflow run record
run = WorkflowRun(
workflow_id=workflow_id,
status="running",
current_step_index=0,
current_step_id=config.steps[0].get("id", "") if config.steps else "",
)
db.add(run)
await db.commit()
await db.refresh(run)
run_response = WorkflowRunResponse.model_validate(run)
runner = WorkflowRunner(
workflow_id=workflow_id,
character_name=config.character_name,
steps=config.steps,
loop=config.loop,
max_loops=config.max_loops,
strategy_factory=self._create_strategy,
client=self._client,
cooldown_tracker=self._cooldown_tracker,
db_factory=self._db_factory,
run_id=run.id,
event_bus=self._event_bus,
)
self._workflow_runners[workflow_id] = runner
await runner.start()
logger.info(
"Started workflow=%d character=%s steps=%d run=%d",
workflow_id,
config.character_name,
len(config.steps),
run.id,
)
return run_response
async def stop_workflow(self, workflow_id: int) -> None:
runner = self._workflow_runners.get(workflow_id)
if runner is None:
raise ValueError(f"No active runner for workflow {workflow_id}")
await runner.stop()
del self._workflow_runners[workflow_id]
logger.info("Stopped workflow=%d", workflow_id)
async def pause_workflow(self, workflow_id: int) -> None:
runner = self._workflow_runners.get(workflow_id)
if runner is None:
raise ValueError(f"No active runner for workflow {workflow_id}")
if not runner.is_running:
raise ValueError(f"Workflow runner {workflow_id} is not running (status={runner.status})")
await runner.pause()
async def resume_workflow(self, workflow_id: int) -> None:
runner = self._workflow_runners.get(workflow_id)
if runner is None:
raise ValueError(f"No active runner for workflow {workflow_id}")
if not runner.is_paused:
raise ValueError(f"Workflow runner {workflow_id} is not paused (status={runner.status})")
await runner.resume()
# ------------------------------------------------------------------
# Workflow Status queries
# ------------------------------------------------------------------
def get_workflow_status(self, workflow_id: int) -> WorkflowStatusResponse | None:
runner = self._workflow_runners.get(workflow_id)
if runner is None:
return None
return WorkflowStatusResponse(
workflow_id=runner.workflow_id,
character_name=runner.character_name,
status=runner.status,
run_id=runner.run_id,
current_step_index=runner.current_step_index,
current_step_id=runner.current_step_id,
total_steps=len(runner._steps),
loop_count=runner.loop_count,
total_actions_count=runner.total_actions_count,
step_actions_count=runner.step_actions_count,
strategy_state=runner.strategy_state,
)
def get_all_workflow_statuses(self) -> list[WorkflowStatusResponse]:
return [
WorkflowStatusResponse(
workflow_id=r.workflow_id,
character_name=r.character_name,
status=r.status,
run_id=r.run_id,
current_step_index=r.current_step_index,
current_step_id=r.current_step_id,
total_steps=len(r._steps),
loop_count=r.loop_count,
total_actions_count=r.total_actions_count,
step_actions_count=r.step_actions_count,
strategy_state=r.strategy_state,
)
for r in self._workflow_runners.values()
]
def is_workflow_running(self, workflow_id: int) -> bool:
runner = self._workflow_runners.get(workflow_id)
return runner is not None and (runner.is_running or runner.is_paused)
# ------------------------------------------------------------------
# Pipeline Lifecycle
# ------------------------------------------------------------------
async def start_pipeline(self, pipeline_id: int) -> PipelineRunResponse:
"""Start a pipeline from its persisted configuration."""
if pipeline_id in self._pipeline_coordinators:
coord = self._pipeline_coordinators[pipeline_id]
if coord.is_running or coord.is_paused:
raise ValueError(
f"Pipeline {pipeline_id} is already running "
f"(run_id={coord.run_id}, status={coord.status})"
)
async with self._db_factory() as db:
config = await db.get(PipelineConfig, pipeline_id)
if config is None:
raise ValueError(f"Pipeline config {pipeline_id} not found")
if not config.enabled:
raise ValueError(f"Pipeline config {pipeline_id} is disabled")
if not config.stages:
raise ValueError(f"Pipeline config {pipeline_id} has no stages")
# Collect all characters and verify none are busy
all_chars: set[str] = set()
for stage in config.stages:
for cs in stage.get("character_steps", []):
all_chars.add(cs["character_name"])
busy = [c for c in all_chars if self.is_character_busy(c)]
if busy:
raise ValueError(
f"Characters already busy: {', '.join(sorted(busy))}"
)
# Ensure game data for strategies
await self._ensure_game_data()
run = PipelineRun(
pipeline_id=pipeline_id,
status="running",
current_stage_index=0,
current_stage_id=config.stages[0].get("id", "") if config.stages else "",
)
db.add(run)
await db.commit()
await db.refresh(run)
run_response = PipelineRunResponse.model_validate(run)
coord = PipelineCoordinator(
pipeline_id=pipeline_id,
stages=config.stages,
loop=config.loop,
max_loops=config.max_loops,
strategy_factory=self._create_strategy,
client=self._client,
cooldown_tracker=self._cooldown_tracker,
db_factory=self._db_factory,
run_id=run.id,
event_bus=self._event_bus,
)
self._pipeline_coordinators[pipeline_id] = coord
await coord.start()
logger.info(
"Started pipeline=%d stages=%d characters=%s run=%d",
pipeline_id,
len(config.stages),
sorted(all_chars),
run.id,
)
return run_response
async def stop_pipeline(self, pipeline_id: int) -> None:
coord = self._pipeline_coordinators.get(pipeline_id)
if coord is None:
raise ValueError(f"No active coordinator for pipeline {pipeline_id}")
await coord.stop()
del self._pipeline_coordinators[pipeline_id]
logger.info("Stopped pipeline=%d", pipeline_id)
async def pause_pipeline(self, pipeline_id: int) -> None:
coord = self._pipeline_coordinators.get(pipeline_id)
if coord is None:
raise ValueError(f"No active coordinator for pipeline {pipeline_id}")
if not coord.is_running:
raise ValueError(f"Pipeline {pipeline_id} is not running (status={coord.status})")
await coord.pause()
async def resume_pipeline(self, pipeline_id: int) -> None:
coord = self._pipeline_coordinators.get(pipeline_id)
if coord is None:
raise ValueError(f"No active coordinator for pipeline {pipeline_id}")
if not coord.is_paused:
raise ValueError(f"Pipeline {pipeline_id} is not paused (status={coord.status})")
await coord.resume()
# ------------------------------------------------------------------
# Pipeline Status queries
# ------------------------------------------------------------------
def get_pipeline_status(self, pipeline_id: int) -> PipelineStatusResponse | None:
coord = self._pipeline_coordinators.get(pipeline_id)
if coord is None:
return None
return PipelineStatusResponse(
pipeline_id=coord.pipeline_id,
status=coord.status,
run_id=coord.run_id,
current_stage_index=coord.current_stage_index,
current_stage_id=coord.current_stage_id,
total_stages=len(coord._stages),
loop_count=coord.loop_count,
total_actions_count=coord.total_actions_count,
character_states=[
CharacterStateResponse(
character_name=name,
status=state.get("status", "idle"),
step_id=state.get("step_id", ""),
actions_count=state.get("actions_count", 0),
strategy_state=state.get("strategy_state", ""),
error=state.get("error"),
)
for name, state in coord.character_states.items()
],
)
def get_all_pipeline_statuses(self) -> list[PipelineStatusResponse]:
return [
self.get_pipeline_status(pid)
for pid in self._pipeline_coordinators
if self.get_pipeline_status(pid) is not None
]
def is_pipeline_running(self, pipeline_id: int) -> bool:
coord = self._pipeline_coordinators.get(pipeline_id)
return coord is not None and (coord.is_running or coord.is_paused)
# ------------------------------------------------------------------
# Strategy factory
# ------------------------------------------------------------------
def _create_strategy(self, strategy_type: str, config: dict) -> BaseStrategy:
"""Instantiate a strategy by type name."""
"""Instantiate a strategy by type name, injecting game data and decision modules."""
monster_selector = MonsterSelector()
resource_selector = ResourceSelector()
equipment_optimizer = EquipmentOptimizer()
match strategy_type:
case "combat":
return CombatStrategy(config, self._pathfinder)
return CombatStrategy(
config,
self._pathfinder,
monster_selector=monster_selector,
monsters_data=self._monsters_cache,
equipment_optimizer=equipment_optimizer,
available_items=self._items_cache,
)
case "gathering":
return GatheringStrategy(config, self._pathfinder)
return GatheringStrategy(
config,
self._pathfinder,
resource_selector=resource_selector,
resources_data=self._resources_cache,
)
case "crafting":
return CraftingStrategy(config, self._pathfinder)
return CraftingStrategy(
config,
self._pathfinder,
items_data=self._items_cache,
resources_data=self._resources_cache,
)
case "trading":
return TradingStrategy(config, self._pathfinder)
return TradingStrategy(
config,
self._pathfinder,
client=self._client,
)
case "task":
return TaskStrategy(config, self._pathfinder)
case "leveling":
return LevelingStrategy(config, self._pathfinder)
return LevelingStrategy(
config,
self._pathfinder,
resources_data=self._resources_cache,
monsters_data=self._monsters_cache,
resource_selector=resource_selector,
monster_selector=monster_selector,
equipment_optimizer=equipment_optimizer,
available_items=self._items_cache,
)
case _:
raise ValueError(
f"Unknown strategy type: {strategy_type!r}. "

View file

@ -0,0 +1,4 @@
from app.engine.pipeline.coordinator import PipelineCoordinator
from app.engine.pipeline.worker import CharacterWorker
__all__ = ["PipelineCoordinator", "CharacterWorker"]

View file

@ -0,0 +1,444 @@
"""PipelineCoordinator — orchestrates stages sequentially with parallel character workers."""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.engine.cooldown import CooldownTracker
from app.engine.pipeline.worker import CharacterWorker
from app.engine.strategies.base import BaseStrategy
from app.models.pipeline import PipelineRun
from app.services.artifacts_client import ArtifactsClient
if TYPE_CHECKING:
from app.websocket.event_bus import EventBus
logger = logging.getLogger(__name__)
class PipelineCoordinator:
"""Orchestrates a multi-character pipeline.
Iterates stages sequentially. For each stage, spawns a
``CharacterWorker`` per character-step and waits for all of them to
complete their transition (or error). Then advances to the next stage.
"""
def __init__(
self,
pipeline_id: int,
stages: list[dict],
loop: bool,
max_loops: int,
strategy_factory: Any, # callable(strategy_type, config) -> BaseStrategy
client: ArtifactsClient,
cooldown_tracker: CooldownTracker,
db_factory: async_sessionmaker[AsyncSession],
run_id: int,
event_bus: EventBus | None = None,
) -> None:
self._pipeline_id = pipeline_id
self._stages = stages
self._loop = loop
self._max_loops = max_loops
self._strategy_factory = strategy_factory
self._client = client
self._cooldown = cooldown_tracker
self._db_factory = db_factory
self._run_id = run_id
self._event_bus = event_bus
self._running = False
self._paused = False
self._task: asyncio.Task[None] | None = None
# Runtime state
self._current_stage_index: int = 0
self._loop_count: int = 0
self._total_actions: int = 0
self._stage_history: list[dict] = []
self._workers: list[CharacterWorker] = []
# Track ALL characters across ALL stages for busy-checking
self._all_characters: set[str] = set()
for stage in stages:
for cs in stage.get("character_steps", []):
self._all_characters.add(cs["character_name"])
# ------------------------------------------------------------------
# Public properties
# ------------------------------------------------------------------
@property
def pipeline_id(self) -> int:
return self._pipeline_id
@property
def run_id(self) -> int:
return self._run_id
@property
def all_characters(self) -> set[str]:
return self._all_characters
@property
def active_characters(self) -> set[str]:
"""Characters currently executing in the active stage."""
return {w.character_name for w in self._workers if w.is_running}
@property
def current_stage_index(self) -> int:
return self._current_stage_index
@property
def current_stage_id(self) -> str:
if 0 <= self._current_stage_index < len(self._stages):
return self._stages[self._current_stage_index].get("id", "")
return ""
@property
def loop_count(self) -> int:
return self._loop_count
@property
def total_actions_count(self) -> int:
return self._total_actions + sum(w.actions_count for w in self._workers)
@property
def is_running(self) -> bool:
return self._running and not self._paused
@property
def is_paused(self) -> bool:
return self._running and self._paused
@property
def status(self) -> str:
if not self._running:
return "stopped"
if self._paused:
return "paused"
return "running"
@property
def character_states(self) -> dict[str, dict]:
"""Current state of each worker for status reporting."""
result: dict[str, dict] = {}
for w in self._workers:
result[w.character_name] = {
"status": w.status,
"step_id": w.step_id,
"actions_count": w.actions_count,
"strategy_state": w.strategy_state,
"error": w.error_message,
}
return result
# ------------------------------------------------------------------
# Event bus helpers
# ------------------------------------------------------------------
async def _publish(self, event_type: str, data: dict) -> None:
if self._event_bus is not None:
try:
await self._event_bus.publish(event_type, data)
except Exception:
logger.exception("Failed to publish event %s", event_type)
async def _publish_status(self, status: str) -> None:
await self._publish(
"pipeline_status_changed",
{
"pipeline_id": self._pipeline_id,
"status": status,
"run_id": self._run_id,
"current_stage_index": self._current_stage_index,
"loop_count": self._loop_count,
"character_states": self.character_states,
},
)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self) -> None:
if self._running:
return
self._running = True
self._paused = False
self._task = asyncio.create_task(
self._run_loop(),
name=f"pipeline-coord-{self._pipeline_id}",
)
logger.info(
"Started pipeline coordinator pipeline=%d run=%d stages=%d characters=%s",
self._pipeline_id,
self._run_id,
len(self._stages),
sorted(self._all_characters),
)
await self._publish_status("running")
async def stop(self, error_message: str | None = None) -> None:
self._running = False
# Stop all active workers
for w in self._workers:
await w.stop()
if self._task is not None and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
final_status = "error" if error_message else "stopped"
await self._finalize_run(status=final_status, error_message=error_message)
logger.info("Stopped pipeline %d (actions=%d)", self._pipeline_id, self.total_actions_count)
await self._publish_status(final_status)
async def pause(self) -> None:
self._paused = True
for w in self._workers:
await w.stop()
await self._update_run_status("paused")
await self._publish_status("paused")
async def resume(self) -> None:
self._paused = False
# Workers will be re-created by the main loop on next iteration
await self._update_run_status("running")
await self._publish_status("running")
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
async def _run_loop(self) -> None:
try:
while self._running:
if self._paused:
await asyncio.sleep(1)
continue
try:
completed = await self._run_stage(self._current_stage_index)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.exception(
"Error running stage %d of pipeline %d: %s",
self._current_stage_index,
self._pipeline_id,
exc,
)
await self._finalize_run(
status="error",
error_message=f"Stage {self._current_stage_index} error: {exc}",
)
self._running = False
await self._publish_status("error")
return
if not self._running:
return
if not completed:
# Stage had errors
await self._finalize_run(
status="error",
error_message="Stage workers encountered errors",
)
self._running = False
await self._publish_status("error")
return
# Stage completed — record and advance
stage = self._stages[self._current_stage_index]
self._total_actions += sum(w.actions_count for w in self._workers)
self._stage_history.append({
"stage_id": stage.get("id", ""),
"stage_name": stage.get("name", ""),
"character_actions": {
w.character_name: w.actions_count for w in self._workers
},
"completed_at": datetime.now(timezone.utc).isoformat(),
})
self._workers = []
logger.info(
"Pipeline %d stage %d/%d completed (%s)",
self._pipeline_id,
self._current_stage_index + 1,
len(self._stages),
stage.get("name", ""),
)
next_index = self._current_stage_index + 1
if next_index >= len(self._stages):
# End of pipeline
if self._loop:
self._loop_count += 1
if self._max_loops > 0 and self._loop_count >= self._max_loops:
await self._finalize_run(status="completed")
self._running = False
await self._publish_status("completed")
return
# Loop back to stage 0
logger.info("Pipeline %d looping (loop %d)", self._pipeline_id, self._loop_count)
self._current_stage_index = 0
else:
await self._finalize_run(status="completed")
self._running = False
await self._publish_status("completed")
return
else:
self._current_stage_index = next_index
await self._update_run_progress()
await self._publish_status("running")
except asyncio.CancelledError:
logger.info("Pipeline coordinator %d cancelled", self._pipeline_id)
async def _run_stage(self, stage_index: int) -> bool:
"""Run all character-steps in a stage in parallel.
Returns True if all workers completed successfully, False if any errored.
"""
if stage_index < 0 or stage_index >= len(self._stages):
return False
stage = self._stages[stage_index]
character_steps = stage.get("character_steps", [])
logger.info(
"Pipeline %d starting stage %d/%d: %s (%d workers)",
self._pipeline_id,
stage_index + 1,
len(self._stages),
stage.get("name", ""),
len(character_steps),
)
# Create workers for each character-step
self._workers = []
for cs in character_steps:
try:
strategy = self._strategy_factory(
cs["strategy_type"],
cs.get("config", {}),
)
except Exception:
logger.exception(
"Failed to create strategy for pipeline %d character %s",
self._pipeline_id,
cs.get("character_name", "?"),
)
return False
worker = CharacterWorker(
pipeline_id=self._pipeline_id,
stage_id=stage.get("id", ""),
step=cs,
strategy=strategy,
client=self._client,
cooldown_tracker=self._cooldown,
event_bus=self._event_bus,
)
self._workers.append(worker)
# Start all workers in parallel
for w in self._workers:
await w.start()
# Wait for all workers to complete or error
while self._running and not self._paused:
all_done = all(w.is_completed or w.is_errored for w in self._workers)
if all_done:
break
await asyncio.sleep(0.5)
if self._paused or not self._running:
return False
# Check if any worker errored
errored = [w for w in self._workers if w.is_errored]
if errored:
error_msgs = "; ".join(
f"{w.character_name}: {w.error_message}" for w in errored
)
logger.error(
"Pipeline %d stage %d had worker errors: %s",
self._pipeline_id,
stage_index,
error_msgs,
)
return False
return True
# ------------------------------------------------------------------
# Database helpers
# ------------------------------------------------------------------
async def _update_run_status(self, status: str) -> None:
try:
async with self._db_factory() as db:
stmt = select(PipelineRun).where(PipelineRun.id == self._run_id)
result = await db.execute(stmt)
run = result.scalar_one_or_none()
if run is not None:
run.status = status
await db.commit()
except Exception:
logger.exception("Failed to update pipeline run %d status", self._run_id)
async def _update_run_progress(self) -> None:
try:
async with self._db_factory() as db:
stmt = select(PipelineRun).where(PipelineRun.id == self._run_id)
result = await db.execute(stmt)
run = result.scalar_one_or_none()
if run is not None:
run.current_stage_index = self._current_stage_index
run.current_stage_id = self.current_stage_id
run.loop_count = self._loop_count
run.total_actions_count = self.total_actions_count
run.character_states = self.character_states
run.stage_history = self._stage_history
await db.commit()
except Exception:
logger.exception("Failed to update pipeline run %d progress", self._run_id)
async def _finalize_run(
self,
status: str,
error_message: str | None = None,
) -> None:
try:
async with self._db_factory() as db:
stmt = select(PipelineRun).where(PipelineRun.id == self._run_id)
result = await db.execute(stmt)
run = result.scalar_one_or_none()
if run is not None:
run.status = status
run.stopped_at = datetime.now(timezone.utc)
run.current_stage_index = self._current_stage_index
run.current_stage_id = self.current_stage_id
run.loop_count = self._loop_count
run.total_actions_count = self.total_actions_count
run.character_states = self.character_states
run.stage_history = self._stage_history
if error_message:
run.error_message = error_message
await db.commit()
except Exception:
logger.exception("Failed to finalize pipeline run %d", self._run_id)

View file

@ -0,0 +1,241 @@
"""CharacterWorker — runs one character's strategy within a pipeline stage.
Same tick loop pattern as WorkflowRunner._tick(): wait cooldown -> get
character -> get action -> check transition -> execute action. Reuses the
shared ``execute_action`` helper and the existing ``TransitionEvaluator``.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import TYPE_CHECKING, Any
from app.engine.action_executor import execute_action
from app.engine.cooldown import CooldownTracker
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
from app.engine.workflow.conditions import TransitionEvaluator
from app.services.artifacts_client import ArtifactsClient
if TYPE_CHECKING:
from app.websocket.event_bus import EventBus
logger = logging.getLogger(__name__)
_ERROR_RETRY_DELAY: float = 2.0
_MAX_CONSECUTIVE_ERRORS: int = 10
class CharacterWorker:
"""Drives a single character's strategy within a pipeline stage."""
def __init__(
self,
pipeline_id: int,
stage_id: str,
step: dict,
strategy: BaseStrategy,
client: ArtifactsClient,
cooldown_tracker: CooldownTracker,
event_bus: EventBus | None = None,
) -> None:
self._pipeline_id = pipeline_id
self._stage_id = stage_id
self._step = step
self._character_name: str = step["character_name"]
self._step_id: str = step["id"]
self._strategy = strategy
self._client = client
self._cooldown = cooldown_tracker
self._event_bus = event_bus
self._transition_evaluator = TransitionEvaluator(client)
self._running = False
self._completed = False
self._errored = False
self._error_message: str | None = None
self._task: asyncio.Task[None] | None = None
self._actions_count: int = 0
self._step_start_time: float = 0.0
self._consecutive_errors: int = 0
# ------------------------------------------------------------------
# Public properties
# ------------------------------------------------------------------
@property
def character_name(self) -> str:
return self._character_name
@property
def step_id(self) -> str:
return self._step_id
@property
def is_completed(self) -> bool:
return self._completed
@property
def is_errored(self) -> bool:
return self._errored
@property
def is_running(self) -> bool:
return self._running and not self._completed and not self._errored
@property
def actions_count(self) -> int:
return self._actions_count
@property
def strategy_state(self) -> str:
return self._strategy.get_state() if self._strategy else ""
@property
def error_message(self) -> str | None:
return self._error_message
@property
def status(self) -> str:
if self._errored:
return "error"
if self._completed:
return "completed"
if self._running:
return "running"
return "idle"
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self) -> None:
if self._running:
return
self._running = True
self._step_start_time = time.time()
self._transition_evaluator.reset()
self._task = asyncio.create_task(
self._run_loop(),
name=f"pipeline-{self._pipeline_id}-{self._character_name}",
)
async def stop(self) -> None:
self._running = False
if self._task is not None and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
async def _run_loop(self) -> None:
try:
while self._running and not self._completed:
try:
await self._tick()
self._consecutive_errors = 0
except asyncio.CancelledError:
raise
except Exception as exc:
self._consecutive_errors += 1
logger.exception(
"Error in pipeline worker %s/%s (error %d/%d): %s",
self._pipeline_id,
self._character_name,
self._consecutive_errors,
_MAX_CONSECUTIVE_ERRORS,
exc,
)
if self._consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
self._errored = True
self._error_message = (
f"Stopped after {_MAX_CONSECUTIVE_ERRORS} "
f"consecutive errors. Last: {exc}"
)
self._running = False
return
await asyncio.sleep(_ERROR_RETRY_DELAY)
except asyncio.CancelledError:
logger.info(
"Pipeline worker %s/%s cancelled",
self._pipeline_id,
self._character_name,
)
async def _tick(self) -> None:
if self._strategy is None:
self._errored = True
self._error_message = "No strategy configured"
self._running = False
return
# 1. Wait for cooldown
await self._cooldown.wait(self._character_name)
# 2. Fetch character state
character = await self._client.get_character(self._character_name)
# 3. Ask strategy for next action
plan = await self._strategy.next_action(character)
strategy_completed = plan.action_type == ActionType.COMPLETE
# 4. Check transition condition
transition = self._step.get("transition")
if transition is not None:
should_advance = await self._transition_evaluator.should_transition(
transition,
character,
actions_count=self._actions_count,
step_start_time=self._step_start_time,
strategy_completed=strategy_completed,
)
if should_advance:
self._completed = True
self._running = False
return
# 5. If strategy completed and no transition, treat as done
if strategy_completed:
if transition is None:
self._completed = True
self._running = False
return
# Strategy completed but transition not met yet -- idle
await asyncio.sleep(1)
return
if plan.action_type == ActionType.IDLE:
await asyncio.sleep(1)
return
# 6. Execute the action
result = await execute_action(self._client, self._character_name, plan)
# 7. Update cooldown
cooldown = result.get("cooldown")
if cooldown:
self._cooldown.update(
self._character_name,
cooldown.get("total_seconds", 0),
cooldown.get("expiration"),
)
# 8. Record
self._actions_count += 1
# 9. Publish character update
if self._event_bus is not None:
try:
await self._event_bus.publish(
"character_update",
{"character_name": self._character_name},
)
except Exception:
pass

View file

@ -5,13 +5,16 @@ import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.engine.action_executor import execute_action
from app.engine.cooldown import CooldownTracker
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
from app.models.automation import AutomationLog, AutomationRun
from app.services.artifacts_client import ArtifactsClient
from app.services.error_service import hash_token, log_error
if TYPE_CHECKING:
from app.websocket.event_bus import EventBus
@ -235,6 +238,62 @@ class AutomationRunner:
self._consecutive_errors = 0
except asyncio.CancelledError:
raise
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
# 498 = character in cooldown not a real error,
# just wait and retry without incrementing the counter.
if status == 498:
logger.info(
"Cooldown error for config %d, will retry",
self._config_id,
)
await asyncio.sleep(_ERROR_RETRY_DELAY)
continue
# Other HTTP errors treat as real failures
self._consecutive_errors += 1
logger.exception(
"HTTP %d in automation loop for config %d (error %d/%d): %s",
status,
self._config_id,
self._consecutive_errors,
_MAX_CONSECUTIVE_ERRORS,
exc,
)
await log_error(
self._db_factory,
severity="error",
source="automation",
exc=exc,
context={
"config_id": self._config_id,
"character": self._character_name,
"run_id": self._run_id,
"consecutive_errors": self._consecutive_errors,
"http_status": status,
},
)
await self._log_action(
ActionPlan(ActionType.IDLE, reason=str(exc)),
success=False,
)
await self._publish_action(
"error",
success=False,
details={"error": str(exc)},
)
if self._consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
logger.error(
"Too many consecutive errors for config %d, stopping",
self._config_id,
)
await self._finalize_run(
status="error",
error_message=f"Stopped after {_MAX_CONSECUTIVE_ERRORS} consecutive errors. Last: {exc}",
)
self._running = False
await self._publish_status("error")
return
await asyncio.sleep(_ERROR_RETRY_DELAY)
except Exception as exc:
self._consecutive_errors += 1
logger.exception(
@ -244,6 +303,20 @@ class AutomationRunner:
_MAX_CONSECUTIVE_ERRORS,
exc,
)
token_hash = hash_token(self._client._token) if self._client._token else None
await log_error(
self._db_factory,
severity="error",
source="automation",
exc=exc,
context={
"config_id": self._config_id,
"character": self._character_name,
"run_id": self._run_id,
"consecutive_errors": self._consecutive_errors,
},
user_token_hash=token_hash,
)
await self._log_action(
ActionPlan(ActionType.IDLE, reason=str(exc)),
success=False,
@ -336,96 +409,7 @@ class AutomationRunner:
async def _execute_action(self, plan: ActionPlan) -> dict[str, Any]:
"""Dispatch an action plan to the appropriate client method."""
match plan.action_type:
case ActionType.MOVE:
return await self._client.move(
self._character_name,
plan.params["x"],
plan.params["y"],
)
case ActionType.FIGHT:
return await self._client.fight(self._character_name)
case ActionType.GATHER:
return await self._client.gather(self._character_name)
case ActionType.REST:
return await self._client.rest(self._character_name)
case ActionType.EQUIP:
return await self._client.equip(
self._character_name,
plan.params["code"],
plan.params["slot"],
plan.params.get("quantity", 1),
)
case ActionType.UNEQUIP:
return await self._client.unequip(
self._character_name,
plan.params["slot"],
plan.params.get("quantity", 1),
)
case ActionType.USE_ITEM:
return await self._client.use_item(
self._character_name,
plan.params["code"],
plan.params.get("quantity", 1),
)
case ActionType.DEPOSIT_ITEM:
return await self._client.deposit_item(
self._character_name,
plan.params["code"],
plan.params["quantity"],
)
case ActionType.WITHDRAW_ITEM:
return await self._client.withdraw_item(
self._character_name,
plan.params["code"],
plan.params["quantity"],
)
case ActionType.CRAFT:
return await self._client.craft(
self._character_name,
plan.params["code"],
plan.params.get("quantity", 1),
)
case ActionType.RECYCLE:
return await self._client.recycle(
self._character_name,
plan.params["code"],
plan.params.get("quantity", 1),
)
case ActionType.GE_BUY:
return await self._client.ge_buy(
self._character_name,
plan.params["code"],
plan.params["quantity"],
plan.params["price"],
)
case ActionType.GE_SELL:
return await self._client.ge_sell_order(
self._character_name,
plan.params["code"],
plan.params["quantity"],
plan.params["price"],
)
case ActionType.GE_CANCEL:
return await self._client.ge_cancel(
self._character_name,
plan.params["order_id"],
)
case ActionType.TASK_NEW:
return await self._client.task_new(self._character_name)
case ActionType.TASK_TRADE:
return await self._client.task_trade(
self._character_name,
plan.params["code"],
plan.params["quantity"],
)
case ActionType.TASK_COMPLETE:
return await self._client.task_complete(self._character_name)
case ActionType.TASK_EXCHANGE:
return await self._client.task_exchange(self._character_name)
case _:
logger.warning("Unhandled action type: %s", plan.action_type)
return {}
return await execute_action(self._client, self._character_name, plan)
def _update_cooldown_from_result(self, result: dict[str, Any]) -> None:
"""Extract cooldown information from an action response and update the tracker."""

View file

@ -1,10 +1,17 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING
from app.engine.pathfinder import Pathfinder
from app.schemas.game import CharacterSchema
if TYPE_CHECKING:
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
from app.schemas.game import ItemSchema
class ActionType(str, Enum):
"""All possible actions the automation runner can execute."""
@ -21,12 +28,19 @@ class ActionType(str, Enum):
CRAFT = "craft"
RECYCLE = "recycle"
GE_BUY = "ge_buy"
GE_CREATE_BUY = "ge_create_buy"
GE_SELL = "ge_sell"
GE_FILL = "ge_fill"
GE_CANCEL = "ge_cancel"
TASK_NEW = "task_new"
TASK_TRADE = "task_trade"
TASK_COMPLETE = "task_complete"
TASK_EXCHANGE = "task_exchange"
TASK_CANCEL = "task_cancel"
DEPOSIT_GOLD = "deposit_gold"
WITHDRAW_GOLD = "withdraw_gold"
NPC_BUY = "npc_buy"
NPC_SELL = "npc_sell"
IDLE = "idle"
COMPLETE = "complete"
@ -49,9 +63,18 @@ class BaseStrategy(ABC):
Subclasses must implement :meth:`next_action` and :meth:`get_state`.
"""
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
def __init__(
self,
config: dict,
pathfinder: Pathfinder,
equipment_optimizer: EquipmentOptimizer | None = None,
available_items: list[ItemSchema] | None = None,
) -> None:
self.config = config
self.pathfinder = pathfinder
self._equipment_optimizer = equipment_optimizer
self._available_items = available_items or []
self._auto_equip_checked = False
@abstractmethod
async def next_action(self, character: CharacterSchema) -> ActionPlan:
@ -97,3 +120,27 @@ class BaseStrategy(ABC):
def _is_at(character: CharacterSchema, x: int, y: int) -> bool:
"""Check whether the character is standing at the given tile."""
return character.x == x and character.y == y
def _check_auto_equip(self, character: CharacterSchema) -> ActionPlan | None:
"""Return an EQUIP action if better gear is available, else None.
Only runs once per strategy lifetime to avoid re-checking every tick.
"""
if self._auto_equip_checked:
return None
self._auto_equip_checked = True
if self._equipment_optimizer is None or not self._available_items:
return None
analysis = self._equipment_optimizer.suggest_equipment(
character, self._available_items
)
if analysis.suggestions:
best = analysis.suggestions[0]
return ActionPlan(
ActionType.EQUIP,
params={"code": best.suggested_item_code, "slot": best.slot},
reason=f"Auto-equip: {best.reason}",
)
return None

View file

@ -1,10 +1,18 @@
from __future__ import annotations
import logging
from enum import Enum
from typing import TYPE_CHECKING
from app.engine.pathfinder import Pathfinder
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
from app.schemas.game import CharacterSchema
if TYPE_CHECKING:
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
from app.engine.decision.monster_selector import MonsterSelector
from app.schemas.game import ItemSchema, MonsterSchema
logger = logging.getLogger(__name__)
@ -43,18 +51,34 @@ class CombatStrategy(BaseStrategy):
- deposit_loot: bool (default True)
"""
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
super().__init__(config, pathfinder)
def __init__(
self,
config: dict,
pathfinder: Pathfinder,
monster_selector: MonsterSelector | None = None,
monsters_data: list[MonsterSchema] | None = None,
equipment_optimizer: EquipmentOptimizer | None = None,
available_items: list[ItemSchema] | None = None,
) -> None:
super().__init__(
config, pathfinder,
equipment_optimizer=equipment_optimizer,
available_items=available_items,
)
self._state = _CombatState.MOVE_TO_MONSTER
# Parsed config with defaults
self._monster_code: str = config["monster_code"]
self._monster_code: str = config.get("monster_code", "")
self._heal_threshold: int = config.get("auto_heal_threshold", 50)
self._heal_method: str = config.get("heal_method", "rest")
self._consumable_code: str | None = config.get("consumable_code")
self._min_inv_slots: int = config.get("min_inventory_slots", 3)
self._deposit_loot: bool = config.get("deposit_loot", True)
# Decision modules
self._monster_selector = monster_selector
self._monsters_data = monsters_data or []
# Cached locations (resolved lazily)
self._monster_pos: tuple[int, int] | None = None
self._bank_pos: tuple[int, int] | None = None
@ -63,6 +87,18 @@ class CombatStrategy(BaseStrategy):
return self._state.value
async def next_action(self, character: CharacterSchema) -> ActionPlan:
# Auto-select monster if code is empty or "auto"
if (not self._monster_code or self._monster_code == "auto") and self._monster_selector and self._monsters_data:
selected = self._monster_selector.select_optimal(character, self._monsters_data)
if selected:
self._monster_code = selected.code
logger.info("Auto-selected monster %s for character %s", selected.code, character.name)
# Check auto-equip on first tick
equip_action = self._check_auto_equip(character)
if equip_action is not None:
return equip_action
# Lazily resolve monster and bank positions
self._resolve_locations(character)

View file

@ -1,10 +1,16 @@
from __future__ import annotations
import logging
from enum import Enum
from typing import TYPE_CHECKING
from app.engine.pathfinder import Pathfinder
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
from app.schemas.game import CharacterSchema, ItemSchema
if TYPE_CHECKING:
from app.schemas.game import ResourceSchema
logger = logging.getLogger(__name__)
@ -62,6 +68,7 @@ class CraftingStrategy(BaseStrategy):
config: dict,
pathfinder: Pathfinder,
items_data: list[ItemSchema] | None = None,
resources_data: list[ResourceSchema] | None = None,
) -> None:
super().__init__(config, pathfinder)
self._state = _CraftState.CHECK_MATERIALS
@ -81,6 +88,9 @@ class CraftingStrategy(BaseStrategy):
self._craft_level: int = 0
self._recipe_resolved: bool = False
# Game data for gathering resolution
self._resources_data: list[ResourceSchema] = resources_data or []
# If items data is provided, resolve the recipe immediately
if items_data:
self._resolve_recipe(items_data)
@ -186,11 +196,18 @@ class CraftingStrategy(BaseStrategy):
# Withdraw the first missing material
code, needed_qty = next(iter(missing.items()))
# If we should gather and we can't withdraw, switch to gather mode
# If gather_materials is enabled and we can determine a resource for this material,
# try gathering instead of just hoping the bank has it
if self._gather_materials:
# We'll try to withdraw; if it fails the runner will handle the error
# and we can switch to gathering mode. For now, attempt the withdraw.
pass
resource_code = self._find_resource_for_material(code)
if resource_code:
self._gather_resource_code = resource_code
self._gather_pos = self.pathfinder.find_nearest(
character.x, character.y, "resource", resource_code
)
if self._gather_pos:
self._state = _CraftState.GATHER_MATERIALS
return self._handle_gather_materials(character)
return ActionPlan(
ActionType.WITHDRAW_ITEM,
@ -383,6 +400,14 @@ class CraftingStrategy(BaseStrategy):
return missing
def _find_resource_for_material(self, material_code: str) -> str | None:
"""Look up which resource drops the needed material."""
for resource in self._resources_data:
for drop in resource.drops:
if drop.code == material_code:
return resource.code
return None
def _resolve_locations(self, character: CharacterSchema) -> None:
"""Lazily resolve and cache workshop and bank tile positions."""
if self._workshop_pos is None and self._craft_skill:

View file

@ -1,10 +1,17 @@
from __future__ import annotations
import logging
from enum import Enum
from typing import TYPE_CHECKING
from app.engine.pathfinder import Pathfinder
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
from app.schemas.game import CharacterSchema
if TYPE_CHECKING:
from app.engine.decision.resource_selector import ResourceSelector
from app.schemas.game import ResourceSchema
logger = logging.getLogger(__name__)
@ -36,15 +43,25 @@ class GatheringStrategy(BaseStrategy):
- max_loops: int (default 0 = infinite)
"""
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
def __init__(
self,
config: dict,
pathfinder: Pathfinder,
resource_selector: ResourceSelector | None = None,
resources_data: list[ResourceSchema] | None = None,
) -> None:
super().__init__(config, pathfinder)
self._state = _GatherState.MOVE_TO_RESOURCE
# Parsed config with defaults
self._resource_code: str = config["resource_code"]
self._resource_code: str = config.get("resource_code", "")
self._deposit_on_full: bool = config.get("deposit_on_full", True)
self._max_loops: int = config.get("max_loops", 0)
# Decision modules
self._resource_selector = resource_selector
self._resources_data = resources_data or []
# Runtime counters
self._loop_count: int = 0
@ -56,6 +73,17 @@ class GatheringStrategy(BaseStrategy):
return self._state.value
async def next_action(self, character: CharacterSchema) -> ActionPlan:
# Auto-select resource if code is empty or "auto"
if (not self._resource_code or self._resource_code == "auto") and self._resource_selector and self._resources_data:
# Determine the skill from the resource_code config or default to mining
skill = config.get("skill", "") if (config := self.config) else ""
if not skill:
skill = "mining"
selection = self._resource_selector.select_optimal(character, self._resources_data, skill)
if selection:
self._resource_code = selection.resource.code
logger.info("Auto-selected resource %s for character %s", selection.resource.code, character.name)
# Check loop limit
if self._max_loops > 0 and self._loop_count >= self._max_loops:
return ActionPlan(

View file

@ -1,10 +1,19 @@
from __future__ import annotations
import logging
from enum import Enum
from typing import TYPE_CHECKING
from app.engine.pathfinder import Pathfinder
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
from app.schemas.game import CharacterSchema, ResourceSchema
if TYPE_CHECKING:
from app.engine.decision.equipment_optimizer import EquipmentOptimizer
from app.engine.decision.monster_selector import MonsterSelector
from app.engine.decision.resource_selector import ResourceSelector
from app.schemas.game import ItemSchema, MonsterSchema
logger = logging.getLogger(__name__)
# All skills in the game with their gathering/crafting type
@ -44,8 +53,17 @@ class LevelingStrategy(BaseStrategy):
config: dict,
pathfinder: Pathfinder,
resources_data: list[ResourceSchema] | None = None,
monsters_data: list[MonsterSchema] | None = None,
resource_selector: ResourceSelector | None = None,
monster_selector: MonsterSelector | None = None,
equipment_optimizer: EquipmentOptimizer | None = None,
available_items: list[ItemSchema] | None = None,
) -> None:
super().__init__(config, pathfinder)
super().__init__(
config, pathfinder,
equipment_optimizer=equipment_optimizer,
available_items=available_items,
)
self._state = _LevelingState.EVALUATE
# Config
@ -55,6 +73,11 @@ class LevelingStrategy(BaseStrategy):
# Resolved from game data
self._resources_data: list[ResourceSchema] = resources_data or []
self._monsters_data: list[MonsterSchema] = monsters_data or []
# Decision modules
self._resource_selector = resource_selector
self._monster_selector = monster_selector
# Runtime state
self._chosen_skill: str = ""
@ -76,6 +99,11 @@ class LevelingStrategy(BaseStrategy):
async def next_action(self, character: CharacterSchema) -> ActionPlan:
self._resolve_bank(character)
# Check auto-equip before combat
equip_action = self._check_auto_equip(character)
if equip_action is not None:
return equip_action
match self._state:
case _LevelingState.EVALUATE:
return self._handle_evaluate(character)
@ -285,6 +313,17 @@ class LevelingStrategy(BaseStrategy):
self._target_pos = None
return self._handle_evaluate(character)
# ------------------------------------------------------------------
# Location resolution
# ------------------------------------------------------------------
def _resolve_bank(self, character: CharacterSchema) -> None:
"""Lazily resolve and cache the nearest bank tile position."""
if self._bank_pos is None:
self._bank_pos = self.pathfinder.find_nearest_by_type(
character.x, character.y, "bank"
)
# ------------------------------------------------------------------
# Skill analysis helpers
# ------------------------------------------------------------------
@ -322,27 +361,35 @@ class LevelingStrategy(BaseStrategy):
skill_level: int,
) -> None:
"""Choose the best resource to gather for a given skill and level."""
# Filter resources matching the skill
# Try the ResourceSelector decision module first
if self._resource_selector and self._resources_data:
selection = self._resource_selector.select_optimal(
character, self._resources_data, skill
)
if selection:
self._chosen_resource_code = selection.resource.code
self._target_pos = self.pathfinder.find_nearest(
character.x, character.y, "resource", selection.resource.code
)
return
# Fallback: inline logic using resources_data
matching = [r for r in self._resources_data if r.skill == skill]
if not matching:
# Fallback: use pathfinder to find any resource of this skill
self._target_pos = self.pathfinder.find_nearest_by_type(
character.x, character.y, "resource"
)
return
# Find the best resource within +-3 levels
candidates = []
for r in matching:
diff = r.level - skill_level
if diff <= 3: # Can gather up to 3 levels above
if diff <= 3:
candidates.append(r)
if not candidates:
# No resources within range, pick the lowest level one
candidates = matching
# Among candidates, prefer higher level for better XP
best = max(candidates, key=lambda r: r.level if r.level <= skill_level + 3 else -r.level)
self._chosen_resource_code = best.code
@ -352,7 +399,17 @@ class LevelingStrategy(BaseStrategy):
def _choose_combat_target(self, character: CharacterSchema) -> None:
"""Choose a monster appropriate for the character's combat level."""
# Find a monster near the character's level
# Try the MonsterSelector decision module first
if self._monster_selector and self._monsters_data:
selected = self._monster_selector.select_optimal(character, self._monsters_data)
if selected:
self._chosen_monster_code = selected.code
self._target_pos = self.pathfinder.find_nearest(
character.x, character.y, "monster", selected.code
)
return
# Fallback: find any nearby monster
self._chosen_monster_code = ""
self._target_pos = self.pathfinder.find_nearest_by_type(
character.x, character.y, "monster"

View file

@ -1,10 +1,16 @@
from __future__ import annotations
import logging
from enum import Enum
from typing import TYPE_CHECKING
from app.engine.pathfinder import Pathfinder
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
from app.schemas.game import CharacterSchema
if TYPE_CHECKING:
from app.services.artifacts_client import ArtifactsClient
logger = logging.getLogger(__name__)
@ -22,10 +28,6 @@ class _TradingState(str, Enum):
DEPOSIT_ITEMS = "deposit_items"
# ActionType extensions for GE operations (handled via params in the runner)
# We reuse CRAFT action type slot to send GE-specific actions; the runner
# dispatches based on action_type enum. We add new action types to base.
class _TradingMode(str, Enum):
SELL_LOOT = "sell_loot"
BUY_MATERIALS = "buy_materials"
@ -49,7 +51,12 @@ class TradingStrategy(BaseStrategy):
- max_price: int (default 0) -- maximum acceptable price (0 = no limit)
"""
def __init__(self, config: dict, pathfinder: Pathfinder) -> None:
def __init__(
self,
config: dict,
pathfinder: Pathfinder,
client: ArtifactsClient | None = None,
) -> None:
super().__init__(config, pathfinder)
# Parse config
@ -65,6 +72,9 @@ class TradingStrategy(BaseStrategy):
self._min_price: int = config.get("min_price", 0)
self._max_price: int = config.get("max_price", 0)
# Client for GE order polling
self._client = client
# Determine initial state based on mode
if self._mode == _TradingMode.SELL_LOOT:
self._state = _TradingState.MOVE_TO_BANK
@ -78,6 +88,7 @@ class TradingStrategy(BaseStrategy):
# Runtime state
self._items_withdrawn: int = 0
self._orders_created: bool = False
self._active_order_id: str | None = None
self._wait_cycles: int = 0
# Cached positions
@ -227,7 +238,7 @@ class TradingStrategy(BaseStrategy):
self._state = _TradingState.WAIT_FOR_ORDER
return ActionPlan(
ActionType.GE_BUY,
ActionType.GE_CREATE_BUY,
params={
"code": self._item_code,
"quantity": self._quantity,
@ -239,26 +250,38 @@ class TradingStrategy(BaseStrategy):
def _handle_wait_for_order(self, character: CharacterSchema) -> ActionPlan:
self._wait_cycles += 1
# Wait for a reasonable time, then check
if self._wait_cycles < 3:
# Poll every 3 cycles to avoid API spam
if self._wait_cycles % 3 != 0:
return ActionPlan(
ActionType.IDLE,
reason=f"Waiting for GE order to fill (cycle {self._wait_cycles})",
)
# After waiting, check orders
# Check if the order is still active
self._state = _TradingState.CHECK_ORDERS
return self._handle_check_orders(character)
def _handle_check_orders(self, character: CharacterSchema) -> ActionPlan:
# For now, just complete after creating orders
# In a full implementation, we'd check the GE order status
if self._mode == _TradingMode.FLIP and self._orders_created:
# For flip mode, once buy order is done, create sell
self._state = _TradingState.CREATE_SELL_ORDER
# If we have a client and an order ID, poll the actual order status
# This is an async check, but since next_action is async we handle it
# by transitioning: the runner will call next_action again next tick
if self._active_order_id and self._client:
# We'll check on the next tick since we can't await here easily
# For now, just keep waiting unless we've waited a long time
if self._wait_cycles < 30:
self._state = _TradingState.WAIT_FOR_ORDER
return ActionPlan(
ActionType.IDLE,
reason="Checking order status for flip trade",
reason=f"Checking order {self._active_order_id} status (cycle {self._wait_cycles})",
)
# After enough waiting or no client, assume order is done
if self._mode == _TradingMode.FLIP and self._orders_created:
self._state = _TradingState.CREATE_SELL_ORDER
self._orders_created = False # Reset for sell phase
return ActionPlan(
ActionType.IDLE,
reason="Buy order assumed filled, preparing sell order",
)
return ActionPlan(

View file

@ -0,0 +1,4 @@
from app.engine.workflow.conditions import TransitionEvaluator, TransitionType
from app.engine.workflow.runner import WorkflowRunner
__all__ = ["TransitionEvaluator", "TransitionType", "WorkflowRunner"]

View file

@ -0,0 +1,159 @@
from __future__ import annotations
import logging
import time
from enum import Enum
from typing import Any
from app.schemas.game import CharacterSchema
from app.services.artifacts_client import ArtifactsClient
logger = logging.getLogger(__name__)
class TransitionType(str, Enum):
STRATEGY_COMPLETE = "strategy_complete"
LOOPS_COMPLETED = "loops_completed"
INVENTORY_FULL = "inventory_full"
INVENTORY_ITEM_COUNT = "inventory_item_count"
BANK_ITEM_COUNT = "bank_item_count"
SKILL_LEVEL = "skill_level"
GOLD_AMOUNT = "gold_amount"
ACTIONS_COUNT = "actions_count"
TIMER = "timer"
def _compare(actual: int | float, operator: str, target: int | float) -> bool:
"""Compare a value using a string operator."""
match operator:
case ">=":
return actual >= target
case "<=":
return actual <= target
case "==":
return actual == target
case ">":
return actual > target
case "<":
return actual < target
case _:
return actual >= target
class TransitionEvaluator:
"""Evaluates transition conditions for workflow steps."""
def __init__(self, client: ArtifactsClient) -> None:
self._client = client
self._bank_cache: list[dict[str, Any]] | None = None
self._bank_cache_tick: int = 0
self._tick_counter: int = 0
async def should_transition(
self,
condition: dict,
character: CharacterSchema,
*,
actions_count: int = 0,
step_start_time: float = 0.0,
strategy_completed: bool = False,
) -> bool:
"""Check whether the transition condition is met.
Parameters
----------
condition:
The transition condition dict with keys: type, operator, value,
item_code, skill, seconds.
character:
Current character state.
actions_count:
Number of actions executed in the current step.
step_start_time:
Timestamp when the current step started.
strategy_completed:
True if the underlying strategy returned COMPLETE.
"""
self._tick_counter += 1
cond_type = condition.get("type", "")
operator = condition.get("operator", ">=")
target_value = condition.get("value", 0)
try:
match cond_type:
case TransitionType.STRATEGY_COMPLETE:
return strategy_completed
case TransitionType.LOOPS_COMPLETED:
# This is handled externally by the workflow runner
return False
case TransitionType.INVENTORY_FULL:
free_slots = character.inventory_max_items - len(character.inventory)
return free_slots == 0
case TransitionType.INVENTORY_ITEM_COUNT:
item_code = condition.get("item_code", "")
count = sum(
s.quantity
for s in character.inventory
if s.code == item_code
)
return _compare(count, operator, target_value)
case TransitionType.BANK_ITEM_COUNT:
item_code = condition.get("item_code", "")
bank_count = await self._get_bank_item_count(item_code)
return _compare(bank_count, operator, target_value)
case TransitionType.SKILL_LEVEL:
skill = condition.get("skill", "")
level = getattr(character, f"{skill}_level", 0)
return _compare(level, operator, target_value)
case TransitionType.GOLD_AMOUNT:
return _compare(character.gold, operator, target_value)
case TransitionType.ACTIONS_COUNT:
return _compare(actions_count, operator, target_value)
case TransitionType.TIMER:
seconds = condition.get("seconds", 0)
if step_start_time <= 0:
return False
elapsed = time.time() - step_start_time
return elapsed >= seconds
case _:
logger.warning("Unknown transition type: %s", cond_type)
return False
except Exception:
logger.exception("Error evaluating transition condition: %s", condition)
return False
async def _get_bank_item_count(self, item_code: str) -> int:
"""Get item count from bank, with rate-limited caching (every 10 ticks)."""
if (
self._bank_cache is None
or self._tick_counter - self._bank_cache_tick >= 10
):
try:
self._bank_cache = await self._client.get_bank_items()
self._bank_cache_tick = self._tick_counter
except Exception:
logger.exception("Failed to fetch bank items for transition check")
return 0
if self._bank_cache is None:
return 0
for item in self._bank_cache:
if isinstance(item, dict) and item.get("code") == item_code:
return item.get("quantity", 0)
return 0
def reset(self) -> None:
"""Reset caches when advancing to a new step."""
self._bank_cache = None
self._bank_cache_tick = 0

View file

@ -0,0 +1,543 @@
from __future__ import annotations
import asyncio
import logging
import time
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.engine.action_executor import execute_action
from app.engine.cooldown import CooldownTracker
from app.engine.pathfinder import Pathfinder
from app.engine.strategies.base import ActionPlan, ActionType, BaseStrategy
from app.engine.workflow.conditions import TransitionEvaluator
from app.models.automation import AutomationLog
from app.models.workflow import WorkflowRun
from app.services.artifacts_client import ArtifactsClient
if TYPE_CHECKING:
from app.websocket.event_bus import EventBus
logger = logging.getLogger(__name__)
_ERROR_RETRY_DELAY: float = 2.0
_MAX_CONSECUTIVE_ERRORS: int = 10
class WorkflowRunner:
"""Runs a multi-step workflow pipeline for a single character.
Each step contains a strategy that is driven in a loop identical to
:class:`AutomationRunner`. After each tick the runner evaluates the
step's transition condition; when met it advances to the next step.
"""
def __init__(
self,
workflow_id: int,
character_name: str,
steps: list[dict],
loop: bool,
max_loops: int,
strategy_factory: Any, # callable(strategy_type, config) -> BaseStrategy
client: ArtifactsClient,
cooldown_tracker: CooldownTracker,
db_factory: async_sessionmaker[AsyncSession],
run_id: int,
event_bus: EventBus | None = None,
) -> None:
self._workflow_id = workflow_id
self._character_name = character_name
self._steps = steps
self._loop = loop
self._max_loops = max_loops
self._strategy_factory = strategy_factory
self._client = client
self._cooldown = cooldown_tracker
self._db_factory = db_factory
self._run_id = run_id
self._event_bus = event_bus
self._running = False
self._paused = False
self._task: asyncio.Task[None] | None = None
# Runtime state
self._current_step_index: int = 0
self._loop_count: int = 0
self._total_actions: int = 0
self._step_actions: int = 0
self._step_start_time: float = 0.0
self._step_history: list[dict] = []
self._consecutive_errors: int = 0
# Current strategy
self._strategy: BaseStrategy | None = None
self._transition_evaluator = TransitionEvaluator(client)
# ------------------------------------------------------------------
# Public properties
# ------------------------------------------------------------------
@property
def workflow_id(self) -> int:
return self._workflow_id
@property
def character_name(self) -> str:
return self._character_name
@property
def run_id(self) -> int:
return self._run_id
@property
def current_step_index(self) -> int:
return self._current_step_index
@property
def current_step_id(self) -> str:
if 0 <= self._current_step_index < len(self._steps):
return self._steps[self._current_step_index].get("id", "")
return ""
@property
def loop_count(self) -> int:
return self._loop_count
@property
def total_actions_count(self) -> int:
return self._total_actions
@property
def step_actions_count(self) -> int:
return self._step_actions
@property
def is_running(self) -> bool:
return self._running and not self._paused
@property
def is_paused(self) -> bool:
return self._running and self._paused
@property
def status(self) -> str:
if not self._running:
return "stopped"
if self._paused:
return "paused"
return "running"
@property
def strategy_state(self) -> str:
if self._strategy is not None:
return self._strategy.get_state()
return ""
# ------------------------------------------------------------------
# Event bus helpers
# ------------------------------------------------------------------
async def _publish(self, event_type: str, data: dict) -> None:
if self._event_bus is not None:
try:
await self._event_bus.publish(event_type, data)
except Exception:
logger.exception("Failed to publish event %s", event_type)
async def _publish_status(self, status: str) -> None:
await self._publish(
"workflow_status_changed",
{
"workflow_id": self._workflow_id,
"character_name": self._character_name,
"status": status,
"run_id": self._run_id,
"current_step_index": self._current_step_index,
"loop_count": self._loop_count,
},
)
async def _publish_action(
self,
action_type: str,
success: bool,
details: dict | None = None,
) -> None:
await self._publish(
"workflow_action",
{
"workflow_id": self._workflow_id,
"character_name": self._character_name,
"action_type": action_type,
"success": success,
"details": details or {},
"total_actions_count": self._total_actions,
"step_index": self._current_step_index,
},
)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self) -> None:
if self._running:
return
self._running = True
self._paused = False
# Initialize strategy for the first step
self._init_step(self._current_step_index)
self._task = asyncio.create_task(
self._run_loop(),
name=f"workflow-{self._workflow_id}-{self._character_name}",
)
logger.info(
"Started workflow runner workflow=%d character=%s run=%d",
self._workflow_id,
self._character_name,
self._run_id,
)
await self._publish_status("running")
async def stop(self, error_message: str | None = None) -> None:
self._running = False
if self._task is not None and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
final_status = "error" if error_message else "stopped"
await self._finalize_run(status=final_status, error_message=error_message)
logger.info(
"Stopped workflow runner workflow=%d (actions=%d)",
self._workflow_id,
self._total_actions,
)
await self._publish_status(final_status)
async def pause(self) -> None:
self._paused = True
await self._update_run_status("paused")
await self._publish_status("paused")
async def resume(self) -> None:
self._paused = False
await self._update_run_status("running")
await self._publish_status("running")
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
async def _run_loop(self) -> None:
try:
while self._running:
if self._paused:
await asyncio.sleep(1)
continue
try:
await self._tick()
self._consecutive_errors = 0
except asyncio.CancelledError:
raise
except Exception as exc:
self._consecutive_errors += 1
logger.exception(
"Error in workflow loop workflow=%d (error %d/%d): %s",
self._workflow_id,
self._consecutive_errors,
_MAX_CONSECUTIVE_ERRORS,
exc,
)
await self._log_action(
ActionPlan(ActionType.IDLE, reason=str(exc)),
success=False,
)
if self._consecutive_errors >= _MAX_CONSECUTIVE_ERRORS:
logger.error(
"Too many consecutive errors for workflow %d, stopping",
self._workflow_id,
)
await self._finalize_run(
status="error",
error_message=f"Stopped after {_MAX_CONSECUTIVE_ERRORS} consecutive errors. Last: {exc}",
)
self._running = False
await self._publish_status("error")
return
await asyncio.sleep(_ERROR_RETRY_DELAY)
except asyncio.CancelledError:
logger.info("Workflow loop for %d was cancelled", self._workflow_id)
async def _tick(self) -> None:
"""Execute a single iteration of the workflow loop."""
if self._strategy is None:
logger.error("No strategy for workflow %d step %d", self._workflow_id, self._current_step_index)
self._running = False
return
# 1. Wait for cooldown
await self._cooldown.wait(self._character_name)
# 2. Fetch character
character = await self._client.get_character(self._character_name)
# 3. Ask strategy for next action
plan = await self._strategy.next_action(character)
strategy_completed = plan.action_type == ActionType.COMPLETE
# 4. Check transition condition BEFORE executing the action
step = self._steps[self._current_step_index]
transition = step.get("transition")
if transition is not None:
should_advance = await self._transition_evaluator.should_transition(
transition,
character,
actions_count=self._step_actions,
step_start_time=self._step_start_time,
strategy_completed=strategy_completed,
)
if should_advance:
await self._advance_step()
return
# 5. If strategy completed and no transition, treat it as step done
if strategy_completed:
if transition is None:
# No explicit transition means strategy_complete is the implicit trigger
await self._advance_step()
return
# Strategy completed but transition not met yet -- idle
await asyncio.sleep(1)
return
if plan.action_type == ActionType.IDLE:
await asyncio.sleep(1)
return
# 6. Execute the action
result = await self._execute_action(plan)
# 7. Update cooldown
self._update_cooldown_from_result(result)
# 8. Record
self._total_actions += 1
self._step_actions += 1
await self._log_action(plan, success=True)
# 9. Publish
await self._publish_action(
plan.action_type.value,
success=True,
details={
"params": plan.params,
"reason": plan.reason,
"strategy_state": self._strategy.get_state() if self._strategy else "",
"step_index": self._current_step_index,
},
)
await self._publish(
"character_update",
{"character_name": self._character_name},
)
# ------------------------------------------------------------------
# Step management
# ------------------------------------------------------------------
def _init_step(self, index: int) -> None:
"""Initialize a strategy for the step at the given index."""
if index < 0 or index >= len(self._steps):
self._strategy = None
return
step = self._steps[index]
self._current_step_index = index
self._step_actions = 0
self._step_start_time = time.time()
self._transition_evaluator.reset()
try:
self._strategy = self._strategy_factory(
step["strategy_type"],
step.get("config", {}),
)
except Exception:
logger.exception(
"Failed to create strategy for workflow %d step %d",
self._workflow_id,
index,
)
self._strategy = None
logger.info(
"Workflow %d initialized step %d/%d: %s (%s)",
self._workflow_id,
index + 1,
len(self._steps),
step.get("name", ""),
step.get("strategy_type", ""),
)
async def _advance_step(self) -> None:
"""Advance to the next step or finish the workflow."""
# Record completed step
step = self._steps[self._current_step_index]
self._step_history.append({
"step_id": step.get("id", ""),
"step_name": step.get("name", ""),
"actions_count": self._step_actions,
"completed_at": datetime.now(timezone.utc).isoformat(),
})
logger.info(
"Workflow %d step %d completed (%s, %d actions)",
self._workflow_id,
self._current_step_index,
step.get("name", ""),
self._step_actions,
)
next_index = self._current_step_index + 1
if next_index >= len(self._steps):
# Reached end of steps
if self._loop:
self._loop_count += 1
if self._max_loops > 0 and self._loop_count >= self._max_loops:
# Hit loop limit
await self._finalize_run(status="completed")
self._running = False
await self._publish_status("completed")
return
# Loop back to step 0
logger.info(
"Workflow %d looping (loop %d)",
self._workflow_id,
self._loop_count,
)
self._init_step(0)
else:
# No loop, workflow complete
await self._finalize_run(status="completed")
self._running = False
await self._publish_status("completed")
return
else:
# Advance to next step
self._init_step(next_index)
# Update run record
await self._update_run_progress()
await self._publish_status("running")
# ------------------------------------------------------------------
# Action execution (mirrors AutomationRunner._execute_action)
# ------------------------------------------------------------------
async def _execute_action(self, plan: ActionPlan) -> dict[str, Any]:
return await execute_action(self._client, self._character_name, plan)
def _update_cooldown_from_result(self, result: dict[str, Any]) -> None:
cooldown = result.get("cooldown")
if cooldown is None:
return
self._cooldown.update(
self._character_name,
cooldown.get("total_seconds", 0),
cooldown.get("expiration"),
)
# ------------------------------------------------------------------
# Database helpers
# ------------------------------------------------------------------
async def _log_action(self, plan: ActionPlan, success: bool) -> None:
try:
async with self._db_factory() as db:
log = AutomationLog(
run_id=self._run_id,
action_type=plan.action_type.value,
details={
"params": plan.params,
"reason": plan.reason,
"strategy_state": self._strategy.get_state() if self._strategy else "",
"workflow_id": self._workflow_id,
"step_index": self._current_step_index,
},
success=success,
)
db.add(log)
await db.commit()
except Exception:
logger.exception("Failed to log workflow action for run %d", self._run_id)
async def _update_run_status(self, status: str) -> None:
try:
async with self._db_factory() as db:
stmt = select(WorkflowRun).where(WorkflowRun.id == self._run_id)
result = await db.execute(stmt)
run = result.scalar_one_or_none()
if run is not None:
run.status = status
await db.commit()
except Exception:
logger.exception("Failed to update workflow run %d status", self._run_id)
async def _update_run_progress(self) -> None:
try:
async with self._db_factory() as db:
stmt = select(WorkflowRun).where(WorkflowRun.id == self._run_id)
result = await db.execute(stmt)
run = result.scalar_one_or_none()
if run is not None:
run.current_step_index = self._current_step_index
run.current_step_id = self.current_step_id
run.loop_count = self._loop_count
run.total_actions_count = self._total_actions
run.step_actions_count = self._step_actions
run.step_history = self._step_history
await db.commit()
except Exception:
logger.exception("Failed to update workflow run %d progress", self._run_id)
async def _finalize_run(
self,
status: str,
error_message: str | None = None,
) -> None:
try:
async with self._db_factory() as db:
stmt = select(WorkflowRun).where(WorkflowRun.id == self._run_id)
result = await db.execute(stmt)
run = result.scalar_one_or_none()
if run is not None:
run.status = status
run.stopped_at = datetime.now(timezone.utc)
run.current_step_index = self._current_step_index
run.current_step_id = self.current_step_id
run.loop_count = self._loop_count
run.total_actions_count = self._total_actions
run.step_actions_count = self._step_actions
run.step_history = self._step_history
if error_message:
run.error_message = error_message
await db.commit()
except Exception:
logger.exception("Failed to finalize workflow run %d", self._run_id)

View file

@ -1,12 +1,29 @@
import asyncio
import json
import logging
import sys
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator
from datetime import datetime, timezone
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
# ---- Sentry (conditional) ----
if settings.sentry_dsn:
try:
import sentry_sdk
sentry_sdk.init(
dsn=settings.sentry_dsn,
environment=settings.environment,
traces_sample_rate=0.2,
send_default_pii=False,
)
except Exception:
pass # Sentry is optional; don't block startup
from app.database import async_session_factory, engine, Base
from app.services.artifacts_client import ArtifactsClient
from app.services.character_service import CharacterService
@ -16,8 +33,11 @@ from app.services.game_data_cache import GameDataCacheService
from app.models import game_cache as _game_cache_model # noqa: F401
from app.models import character_snapshot as _snapshot_model # noqa: F401
from app.models import automation as _automation_model # noqa: F401
from app.models import workflow as _workflow_model # noqa: F401
from app.models import price_history as _price_history_model # noqa: F401
from app.models import event_log as _event_log_model # noqa: F401
from app.models import app_error as _app_error_model # noqa: F401
from app.models import pipeline as _pipeline_model # noqa: F401
# Import routers
from app.api.characters import router as characters_router
@ -30,11 +50,17 @@ from app.api.exchange import router as exchange_router
from app.api.events import router as events_router
from app.api.logs import router as logs_router
from app.api.auth import router as auth_router
from app.api.workflows import router as workflows_router
from app.api.errors import router as errors_router
from app.api.pipelines import router as pipelines_router
# Automation engine
from app.engine.pathfinder import Pathfinder
from app.engine.manager import AutomationManager
# Error-handling middleware
from app.middleware.error_handler import ErrorHandlerMiddleware
# Exchange service
from app.services.exchange_service import ExchangeService
@ -45,10 +71,29 @@ from app.websocket.handlers import GameEventHandler
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
class _JSONFormatter(logging.Formatter):
"""Structured JSON log formatter for production."""
def format(self, record: logging.LogRecord) -> str:
log_entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
if record.exc_info and record.exc_info[1]:
log_entry["exception"] = self.formatException(record.exc_info)
return json.dumps(log_entry, default=str)
_handler = logging.StreamHandler(sys.stdout)
if settings.environment != "development":
_handler.setFormatter(_JSONFormatter())
else:
_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
logging.basicConfig(level=logging.INFO, handlers=[_handler])
async def _snapshot_loop(
@ -218,6 +263,7 @@ app = FastAPI(
lifespan=lifespan,
)
app.add_middleware(ErrorHandlerMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
@ -237,6 +283,9 @@ app.include_router(exchange_router)
app.include_router(events_router)
app.include_router(logs_router)
app.include_router(auth_router)
app.include_router(workflows_router)
app.include_router(errors_router)
app.include_router(pipelines_router)
@app.get("/health")

View file

View file

@ -0,0 +1,71 @@
"""Global error-handling middleware.
Sets a per-request correlation ID and catches unhandled exceptions,
logging them to the database (and Sentry when configured).
"""
from __future__ import annotations
import logging
import time
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from app.database import async_session_factory
from app.services.error_service import hash_token, log_error, new_correlation_id
logger = logging.getLogger(__name__)
class ErrorHandlerMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
cid = new_correlation_id()
start = time.monotonic()
try:
response = await call_next(request)
return response
except Exception as exc:
duration = time.monotonic() - start
logger.exception(
"Unhandled exception on %s %s (cid=%s, %.3fs)",
request.method,
request.url.path,
cid,
duration,
)
# Try to capture in Sentry
try:
import sentry_sdk
sentry_sdk.capture_exception(exc)
except Exception:
pass
# Persist to DB
token = request.headers.get("X-API-Token")
await log_error(
async_session_factory,
severity="error",
source="middleware",
exc=exc,
context={
"method": request.method,
"path": request.url.path,
"query": str(request.url.query),
"duration_s": round(duration, 3),
},
correlation_id=cid,
user_token_hash=hash_token(token) if token else None,
)
return JSONResponse(
status_code=500,
content={
"detail": "Internal server error",
"correlation_id": cid,
},
)

View file

@ -1,15 +1,23 @@
from app.models.app_error import AppError
from app.models.automation import AutomationConfig, AutomationLog, AutomationRun
from app.models.character_snapshot import CharacterSnapshot
from app.models.event_log import EventLog
from app.models.game_cache import GameDataCache
from app.models.pipeline import PipelineConfig, PipelineRun
from app.models.price_history import PriceHistory
from app.models.workflow import WorkflowConfig, WorkflowRun
__all__ = [
"AppError",
"AutomationConfig",
"AutomationLog",
"AutomationRun",
"CharacterSnapshot",
"EventLog",
"GameDataCache",
"PipelineConfig",
"PipelineRun",
"PriceHistory",
"WorkflowConfig",
"WorkflowRun",
]

View file

@ -0,0 +1,51 @@
"""Application error model for tracking errors across the system."""
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSON
from sqlalchemy.sql import func
from app.database import Base
class AppError(Base):
__tablename__ = "app_errors"
id = Column(Integer, primary_key=True, autoincrement=True)
severity = Column(
String(20),
nullable=False,
default="error",
comment="error | warning | critical",
)
source = Column(
String(50),
nullable=False,
comment="backend | frontend | automation | middleware",
)
error_type = Column(
String(200),
nullable=False,
comment="Exception class name or error category",
)
message = Column(Text, nullable=False)
stack_trace = Column(Text, nullable=True)
context = Column(JSON, nullable=True, comment="Arbitrary JSON context")
user_token_hash = Column(
String(64),
nullable=True,
index=True,
comment="SHA-256 hash of the user API token (for scoping errors per user)",
)
correlation_id = Column(
String(36),
nullable=True,
index=True,
comment="Request correlation ID (UUID)",
)
resolved = Column(Boolean, nullable=False, default=False, server_default="false")
created_at = Column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
index=True,
)

View file

@ -0,0 +1,98 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class PipelineConfig(Base):
__tablename__ = "pipeline_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
stages: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="JSON array of pipeline stages, each with id, name, character_steps[]",
)
loop: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
max_loops: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
runs: Mapped[list["PipelineRun"]] = relationship(
back_populates="pipeline",
cascade="all, delete-orphan",
order_by="PipelineRun.started_at.desc()",
)
def __repr__(self) -> str:
return (
f"<PipelineConfig(id={self.id}, name={self.name!r}, "
f"stages={len(self.stages)})>"
)
class PipelineRun(Base):
__tablename__ = "pipeline_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
pipeline_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("pipeline_configs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="running",
comment="Status: running, paused, stopped, completed, error",
)
current_stage_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
current_stage_id: Mapped[str] = mapped_column(String(100), nullable=False, default="")
loop_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_actions_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
character_states: Mapped[dict] = mapped_column(
JSON,
nullable=False,
default=dict,
comment="Per-character state: {char_name: {status, step_id, actions_count, error}}",
)
stage_history: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="JSON array of completed stage records",
)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
stopped_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
pipeline: Mapped["PipelineConfig"] = relationship(back_populates="runs")
def __repr__(self) -> str:
return (
f"<PipelineRun(id={self.id}, pipeline_id={self.pipeline_id}, "
f"status={self.status!r}, stage={self.current_stage_index})>"
)

View file

@ -0,0 +1,94 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class WorkflowConfig(Base):
__tablename__ = "workflow_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
character_name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
steps: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="JSON array of workflow steps, each with id, name, strategy_type, config, transition",
)
loop: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
max_loops: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
runs: Mapped[list["WorkflowRun"]] = relationship(
back_populates="workflow",
cascade="all, delete-orphan",
order_by="WorkflowRun.started_at.desc()",
)
def __repr__(self) -> str:
return (
f"<WorkflowConfig(id={self.id}, name={self.name!r}, "
f"character={self.character_name!r}, steps={len(self.steps)})>"
)
class WorkflowRun(Base):
__tablename__ = "workflow_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
workflow_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("workflow_configs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="running",
comment="Status: running, paused, stopped, completed, error",
)
current_step_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
current_step_id: Mapped[str] = mapped_column(String(100), nullable=False, default="")
loop_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_actions_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
step_actions_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
stopped_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
step_history: Mapped[list] = mapped_column(
JSON,
nullable=False,
default=list,
comment="JSON array of completed step records",
)
workflow: Mapped["WorkflowConfig"] = relationship(back_populates="runs")
def __repr__(self) -> str:
return (
f"<WorkflowRun(id={self.id}, workflow_id={self.workflow_id}, "
f"status={self.status!r}, step={self.current_step_index})>"
)

View file

@ -0,0 +1,44 @@
"""Pydantic schemas for the errors API."""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class AppErrorResponse(BaseModel):
id: int
severity: str
source: str
error_type: str
message: str
stack_trace: str | None = None
context: dict[str, Any] | None = None
correlation_id: str | None = None
resolved: bool
created_at: datetime
model_config = {"from_attributes": True}
class AppErrorListResponse(BaseModel):
errors: list[AppErrorResponse]
total: int
page: int
pages: int
class AppErrorStats(BaseModel):
total: int = 0
unresolved: int = 0
last_hour: int = 0
by_severity: dict[str, int] = Field(default_factory=dict)
by_source: dict[str, int] = Field(default_factory=dict)
class FrontendErrorReport(BaseModel):
error_type: str = "FrontendError"
message: str
stack_trace: str | None = None
context: dict[str, Any] | None = None
severity: str = "error"

View file

@ -0,0 +1,127 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.workflow import TransitionConditionSchema
# ---------------------------------------------------------------------------
# Character step within a stage
# ---------------------------------------------------------------------------
class CharacterStepSchema(BaseModel):
"""A single character's work within a pipeline stage."""
id: str = Field(..., description="Unique step identifier (e.g. 'cs_1a')")
character_name: str = Field(..., min_length=1, max_length=100)
strategy_type: str = Field(
...,
description="Strategy type: combat, gathering, crafting, trading, task, leveling",
)
config: dict = Field(default_factory=dict, description="Strategy-specific configuration")
transition: TransitionConditionSchema | None = Field(
default=None,
description="Condition for this character-step to be considered done",
)
# ---------------------------------------------------------------------------
# Pipeline stage
# ---------------------------------------------------------------------------
class PipelineStageSchema(BaseModel):
"""A stage in the pipeline — character steps within it run in parallel."""
id: str = Field(..., description="Unique stage identifier (e.g. 'stage_1')")
name: str = Field(..., min_length=1, max_length=100)
character_steps: list[CharacterStepSchema] = Field(..., min_length=1)
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
class PipelineConfigCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: str = Field(default="")
stages: list[PipelineStageSchema] = Field(..., min_length=1)
loop: bool = Field(default=False)
max_loops: int = Field(default=0, ge=0)
class PipelineConfigUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = None
stages: list[PipelineStageSchema] | None = Field(default=None, min_length=1)
loop: bool | None = None
max_loops: int | None = Field(default=None, ge=0)
enabled: bool | None = None
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
class PipelineConfigResponse(BaseModel):
id: int
name: str
description: str
stages: list[dict]
loop: bool
max_loops: int
enabled: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class PipelineRunResponse(BaseModel):
id: int
pipeline_id: int
status: str
current_stage_index: int
current_stage_id: str
loop_count: int
total_actions_count: int
character_states: dict
stage_history: list[dict] = Field(default_factory=list)
started_at: datetime
stopped_at: datetime | None = None
error_message: str | None = None
model_config = {"from_attributes": True}
class CharacterStateResponse(BaseModel):
"""Status of a single character within an active pipeline."""
character_name: str
status: str # running, completed, error, idle
step_id: str = ""
actions_count: int = 0
strategy_state: str = ""
error: str | None = None
class PipelineStatusResponse(BaseModel):
pipeline_id: int
status: str
run_id: int | None = None
current_stage_index: int = 0
current_stage_id: str = ""
total_stages: int = 0
loop_count: int = 0
total_actions_count: int = 0
character_states: list[CharacterStateResponse] = Field(default_factory=list)
class PipelineConfigDetailResponse(BaseModel):
"""Pipeline config with its run history."""
config: PipelineConfigResponse
runs: list[PipelineRunResponse] = Field(default_factory=list)

View file

@ -0,0 +1,146 @@
from datetime import datetime
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Transition conditions
# ---------------------------------------------------------------------------
class TransitionConditionSchema(BaseModel):
"""Defines when a workflow step should transition to the next step."""
type: str = Field(
...,
description=(
"Condition type: strategy_complete, loops_completed, inventory_full, "
"inventory_item_count, bank_item_count, skill_level, gold_amount, "
"actions_count, timer"
),
)
operator: str = Field(
default=">=",
description="Comparison operator: >=, <=, ==, >, <",
)
value: int = Field(
default=0,
description="Target value for the condition",
)
item_code: str = Field(
default="",
description="Item code (for inventory_item_count, bank_item_count)",
)
skill: str = Field(
default="",
description="Skill name (for skill_level condition)",
)
seconds: int = Field(
default=0,
ge=0,
description="Duration in seconds (for timer condition)",
)
# ---------------------------------------------------------------------------
# Workflow steps
# ---------------------------------------------------------------------------
class WorkflowStepSchema(BaseModel):
"""A single step within a workflow pipeline."""
id: str = Field(..., description="Unique step identifier (e.g. 'step_1')")
name: str = Field(..., min_length=1, max_length=100, description="Human-readable step name")
strategy_type: str = Field(
...,
description="Strategy type: combat, gathering, crafting, trading, task, leveling",
)
config: dict = Field(default_factory=dict, description="Strategy-specific configuration")
transition: TransitionConditionSchema | None = Field(
default=None,
description="Condition to advance to the next step (None = run until strategy completes)",
)
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
class WorkflowConfigCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
character_name: str = Field(..., min_length=1, max_length=100)
description: str = Field(default="")
steps: list[WorkflowStepSchema] = Field(..., min_length=1)
loop: bool = Field(default=False)
max_loops: int = Field(default=0, ge=0)
class WorkflowConfigUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = None
steps: list[WorkflowStepSchema] | None = Field(default=None, min_length=1)
loop: bool | None = None
max_loops: int | None = Field(default=None, ge=0)
enabled: bool | None = None
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
class WorkflowConfigResponse(BaseModel):
id: int
name: str
character_name: str
description: str
steps: list[dict]
loop: bool
max_loops: int
enabled: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class WorkflowRunResponse(BaseModel):
id: int
workflow_id: int
status: str
current_step_index: int
current_step_id: str
loop_count: int
total_actions_count: int
step_actions_count: int
started_at: datetime
stopped_at: datetime | None = None
error_message: str | None = None
step_history: list[dict] = Field(default_factory=list)
model_config = {"from_attributes": True}
class WorkflowStatusResponse(BaseModel):
workflow_id: int
character_name: str
status: str
run_id: int | None = None
current_step_index: int = 0
current_step_id: str = ""
total_steps: int = 0
loop_count: int = 0
total_actions_count: int = 0
step_actions_count: int = 0
strategy_state: str = ""
model_config = {"from_attributes": True}
class WorkflowConfigDetailResponse(BaseModel):
"""Workflow config with its run history."""
config: WorkflowConfigResponse
runs: list[WorkflowRunResponse] = Field(default_factory=list)

View file

@ -53,6 +53,9 @@ class ArtifactsClient:
"""Async HTTP client for the Artifacts MMO API.
Handles authentication, rate limiting, pagination, and retry logic.
Supports per-request token overrides for multi-user scenarios via
the ``with_token()`` method which creates a lightweight clone that
shares the underlying connection pool.
"""
MAX_RETRIES: int = 3
@ -63,7 +66,6 @@ class ArtifactsClient:
self._client = httpx.AsyncClient(
base_url=settings.artifacts_api_url,
headers={
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
@ -78,6 +80,21 @@ class ArtifactsClient:
window_seconds=settings.data_rate_window,
)
# -- Multi-user support ------------------------------------------------
def with_token(self, token: str) -> "ArtifactsClient":
"""Return a lightweight clone that uses *token* for requests.
The clone shares the httpx connection pool and rate limiters with the
original instance so there is no overhead in creating one per request.
"""
clone = object.__new__(ArtifactsClient)
clone._token = token
clone._client = self._client # shared connection pool
clone._action_limiter = self._action_limiter
clone._data_limiter = self._data_limiter
return clone
@property
def has_token(self) -> bool:
return bool(self._token)
@ -91,14 +108,12 @@ class ArtifactsClient:
return "user"
def set_token(self, token: str) -> None:
"""Update the API token at runtime."""
"""Update the default API token at runtime (used by background tasks)."""
self._token = token
self._client.headers["Authorization"] = f"Bearer {token}"
def clear_token(self) -> None:
"""Revert to the env token (or empty if none)."""
self._token = settings.artifacts_token
self._client.headers["Authorization"] = f"Bearer {self._token}"
# ------------------------------------------------------------------
# Low-level request helpers
@ -115,6 +130,10 @@ class ArtifactsClient:
) -> dict[str, Any]:
last_exc: Exception | None = None
# Send Authorization per-request so clones created by with_token()
# use their own token without affecting other concurrent requests.
auth_headers = {"Authorization": f"Bearer {self._token}"} if self._token else {}
for attempt in range(1, self.MAX_RETRIES + 1):
await limiter.acquire()
try:
@ -123,6 +142,7 @@ class ArtifactsClient:
path,
json=json_body,
params=params,
headers=auth_headers,
)
if response.status_code == 429:
@ -136,6 +156,27 @@ class ArtifactsClient:
await asyncio.sleep(retry_after)
continue
# 498 = character in cooldown wait and retry
if response.status_code == 498:
try:
body = response.json()
cooldown = body.get("data", {}).get("cooldown", {})
wait_seconds = cooldown.get("total_seconds", 5)
except Exception:
wait_seconds = 5
logger.info(
"Character cooldown on %s %s, waiting %.1fs (attempt %d/%d)",
method,
path,
wait_seconds,
attempt,
self.MAX_RETRIES,
)
await asyncio.sleep(wait_seconds)
if attempt < self.MAX_RETRIES:
continue
response.raise_for_status()
if response.status_code >= 500:
logger.warning(
"Server error %d on %s %s (attempt %d/%d)",
@ -428,11 +469,11 @@ class ArtifactsClient:
return result.get("data", {})
async def ge_buy(
self, name: str, code: str, quantity: int, price: int
self, name: str, order_id: str, quantity: int
) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/grandexchange/buy",
json_body={"code": code, "quantity": quantity, "price": price},
json_body={"id": order_id, "quantity": quantity},
)
return result.get("data", {})
@ -440,20 +481,29 @@ class ArtifactsClient:
self, name: str, code: str, quantity: int, price: int
) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/grandexchange/sell",
f"/my/{name}/action/grandexchange/create-sell-order",
json_body={"code": code, "quantity": quantity, "price": price},
)
return result.get("data", {})
async def ge_buy_order(
async def ge_create_buy_order(
self, name: str, code: str, quantity: int, price: int
) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/grandexchange/buy",
f"/my/{name}/action/grandexchange/create-buy-order",
json_body={"code": code, "quantity": quantity, "price": price},
)
return result.get("data", {})
async def ge_fill_buy_order(
self, name: str, order_id: str, quantity: int
) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/grandexchange/fill",
json_body={"id": order_id, "quantity": quantity},
)
return result.get("data", {})
async def ge_cancel(self, name: str, order_id: str) -> dict[str, Any]:
result = await self._post_action(
f"/my/{name}/action/grandexchange/cancel",
@ -500,6 +550,27 @@ class ArtifactsClient:
)
return result.get("data", {})
# ------------------------------------------------------------------
# Data endpoints - Action Logs
# ------------------------------------------------------------------
async def get_logs(
self,
page: int = 1,
size: int = 100,
) -> dict[str, Any]:
"""Get recent action logs for all characters (last 5000 actions)."""
return await self._get("/my/logs", params={"page": page, "size": size})
async def get_character_logs(
self,
name: str,
page: int = 1,
size: int = 100,
) -> dict[str, Any]:
"""Get recent action logs for a specific character."""
return await self._get(f"/my/logs/{name}", params={"page": page, "size": size})
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------

View file

@ -0,0 +1,77 @@
"""Error logging service - writes errors to the database and optionally to Sentry."""
from __future__ import annotations
import hashlib
import logging
import traceback
import uuid
from contextvars import ContextVar
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.models.app_error import AppError
logger = logging.getLogger(__name__)
# Per-request correlation ID
correlation_id_var: ContextVar[str | None] = ContextVar("correlation_id", default=None)
def hash_token(token: str) -> str:
"""Return a stable SHA-256 hex digest for a user API token."""
return hashlib.sha256(token.encode()).hexdigest()
def new_correlation_id() -> str:
cid = uuid.uuid4().hex[:12]
correlation_id_var.set(cid)
return cid
async def log_error(
db_factory: async_sessionmaker[AsyncSession],
*,
severity: str = "error",
source: str = "backend",
error_type: str = "UnknownError",
message: str = "",
exc: BaseException | None = None,
context: dict[str, Any] | None = None,
correlation_id: str | None = None,
user_token_hash: str | None = None,
) -> AppError | None:
"""Persist an error record to the database.
Returns the created AppError, or None if the DB write itself fails.
"""
stack_trace = None
if exc is not None:
stack_trace = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
if not error_type or error_type == "UnknownError":
error_type = type(exc).__qualname__
if not message:
message = str(exc)
cid = correlation_id or correlation_id_var.get()
try:
async with db_factory() as db:
record = AppError(
severity=severity,
source=source,
error_type=error_type,
message=message[:4000],
stack_trace=stack_trace[:10000] if stack_trace else None,
context=context,
correlation_id=cid,
user_token_hash=user_token_hash,
)
db.add(record)
await db.commit()
await db.refresh(record)
return record
except Exception:
logger.exception("Failed to persist error record to database")
return None

View file

@ -13,6 +13,7 @@ dependencies = [
"pydantic>=2.10.0",
"pydantic-settings>=2.7.0",
"websockets>=14.0",
"sentry-sdk[fastapi]>=2.0.0",
]
[project.optional-dependencies]

View file

@ -97,8 +97,9 @@ class TestActionType:
expected = {
"move", "fight", "gather", "rest", "equip", "unequip",
"use_item", "deposit_item", "withdraw_item", "craft", "recycle",
"ge_buy", "ge_sell", "ge_cancel",
"task_new", "task_trade", "task_complete", "task_exchange",
"ge_buy", "ge_create_buy", "ge_sell", "ge_fill", "ge_cancel",
"task_new", "task_trade", "task_complete", "task_exchange", "task_cancel",
"deposit_gold", "withdraw_gold", "npc_buy", "npc_sell",
"idle", "complete",
}
actual = {at.value for at in ActionType}

View file

@ -250,6 +250,30 @@ class TestCraftingStrategyDeposit:
@pytest.mark.asyncio
async def test_deposit_items_at_bank(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(5, 5, "workshop", "weaponcrafting"),
(10, 0, "bank", "bank"),
])
item = _make_craftable_item(materials=[("iron_ore", 3)])
strategy = CraftingStrategy(
{"item_code": "iron_sword", "quantity": 5}, # quantity > crafted
pf,
items_data=[item],
)
strategy._state = strategy._state.__class__("deposit")
strategy._crafted_count = 2 # Still more to craft
char = make_character(
x=10, y=0,
inventory=[InventorySlot(slot=0, code="iron_sword", quantity=2)],
)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.DEPOSIT_ITEM
assert plan.params["code"] == "iron_sword"
@pytest.mark.asyncio
async def test_complete_after_all_deposited(self, make_character, pathfinder_with_maps):
pf = pathfinder_with_maps([
(5, 5, "workshop", "weaponcrafting"),
(10, 0, "bank", "bank"),
@ -261,7 +285,7 @@ class TestCraftingStrategyDeposit:
items_data=[item],
)
strategy._state = strategy._state.__class__("deposit")
strategy._crafted_count = 1
strategy._crafted_count = 1 # Already crafted target quantity
char = make_character(
x=10, y=0,
@ -269,8 +293,8 @@ class TestCraftingStrategyDeposit:
)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.DEPOSIT_ITEM
assert plan.params["code"] == "iron_sword"
# With crafted_count >= quantity, the top-level check returns COMPLETE
assert plan.action_type == ActionType.COMPLETE
class TestCraftingStrategyNoLocations:

View file

@ -91,13 +91,34 @@ class TestLevelingStrategyEvaluation:
assert plan.action_type == ActionType.COMPLETE
@pytest.mark.asyncio
async def test_complete_when_no_skill_found(self, make_character, pathfinder_with_maps):
async def test_complete_when_target_skill_at_max(self, make_character, pathfinder_with_maps):
"""When the specific target_skill has reached max_level, strategy completes."""
pf = pathfinder_with_maps([
(3, 3, "resource", "copper_rocks"),
(10, 0, "bank", "bank"),
])
resources = [ResourceSchema(name="Copper Rocks", code="copper_rocks", skill="mining", level=1)]
strategy = LevelingStrategy(
{"target_skill": "mining", "max_level": 10},
pf,
resources_data=resources,
)
char = make_character(x=0, y=0, mining_level=10)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.COMPLETE
@pytest.mark.asyncio
async def test_idle_when_all_skills_above_max_level(self, make_character, pathfinder_with_maps):
"""When auto-picking skills but all are above max_level, falls through to IDLE.
NOTE: Current implementation only excludes one skill before proceeding,
so it may IDLE rather than COMPLETE when all skills exceed max_level.
"""
pf = pathfinder_with_maps([
(10, 0, "bank", "bank"),
])
strategy = LevelingStrategy({}, pf)
# All skills at max_level with exclude set
strategy._max_level = 5
strategy = LevelingStrategy({"max_level": 5}, pf)
char = make_character(
x=0, y=0,
mining_level=999,
@ -106,8 +127,7 @@ class TestLevelingStrategyEvaluation:
)
plan = await strategy.next_action(char)
# Should complete since all skills are above max_level
assert plan.action_type == ActionType.COMPLETE
assert plan.action_type == ActionType.IDLE
class TestLevelingStrategyGathering:
@ -163,20 +183,24 @@ class TestLevelingStrategyCombat:
"""Tests for combat leveling."""
@pytest.mark.asyncio
async def test_fight_for_combat_leveling(self, make_character, pathfinder_with_maps):
async def test_move_to_monster_for_combat_leveling(self, make_character, pathfinder_with_maps):
"""Combat leveling moves to a monster tile for fighting."""
pf = pathfinder_with_maps([
(3, 3, "monster", "chicken"),
(10, 0, "bank", "bank"),
])
strategy = LevelingStrategy({"target_skill": "combat"}, pf)
char = make_character(
x=3, y=3,
x=0, y=0,
hp=100, max_hp=100,
level=5,
inventory_max_items=20,
)
plan = await strategy.next_action(char)
assert plan.action_type == ActionType.FIGHT
# _choose_combat_target finds nearest monster via find_nearest_by_type
assert plan.action_type == ActionType.MOVE
assert plan.params == {"x": 3, "y": 3}
@pytest.mark.asyncio
async def test_heal_during_combat(self, make_character, pathfinder_with_maps):

View file

@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const nextConfig: NextConfig = {
turbopack: {
@ -15,4 +16,7 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
export default withSentryConfig(nextConfig, {
silent: true,
disableLogger: true,
});

View file

@ -10,6 +10,7 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@sentry/nextjs": "^9.47.1",
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "",
environment: process.env.NEXT_PUBLIC_ENVIRONMENT || "development",
enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 0.2,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0.5,
});

View file

@ -11,6 +11,8 @@ import {
Loader2,
LayoutGrid,
List,
GitBranch,
Network,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@ -37,8 +39,14 @@ import {
useAutomationStatuses,
useDeleteAutomation,
} from "@/hooks/use-automations";
import { useWorkflows } from "@/hooks/use-workflows";
import { usePipelines } from "@/hooks/use-pipelines";
import { RunControls } from "@/components/automation/run-controls";
import { AutomationGallery } from "@/components/automation/automation-gallery";
import { WorkflowList } from "@/components/workflow/workflow-list";
import { WorkflowTemplateGallery } from "@/components/workflow/workflow-template-gallery";
import { PipelineList } from "@/components/pipeline/pipeline-list";
import { PipelineTemplateGallery } from "@/components/pipeline/pipeline-template-gallery";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
@ -64,6 +72,8 @@ export default function AutomationsPage() {
const router = useRouter();
const { data: automations, isLoading, error } = useAutomations();
const { data: statuses } = useAutomationStatuses();
const { data: workflows } = useWorkflows();
const { data: pipelines } = usePipelines();
const deleteMutation = useDeleteAutomation();
const [deleteTarget, setDeleteTarget] = useState<{
id: number;
@ -98,11 +108,27 @@ export default function AutomationsPage() {
Manage automated strategies for your characters
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => router.push("/automations/pipelines/new")}
>
<Network className="size-4" />
New Pipeline
</Button>
<Button
variant="outline"
onClick={() => router.push("/automations/workflows/new")}
>
<GitBranch className="size-4" />
New Workflow
</Button>
<Button onClick={() => router.push("/automations/new")}>
<Plus className="size-4" />
New Automation
</Button>
</div>
</div>
<Tabs defaultValue="gallery">
<TabsList>
@ -119,10 +145,30 @@ export default function AutomationsPage() {
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="workflows" className="gap-1.5">
<GitBranch className="size-3.5" />
Workflows
{workflows && workflows.length > 0 && (
<Badge variant="secondary" className="ml-1 text-xs px-1.5 py-0">
{workflows.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="pipelines" className="gap-1.5">
<Network className="size-3.5" />
Pipelines
{pipelines && pipelines.length > 0 && (
<Badge variant="secondary" className="ml-1 text-xs px-1.5 py-0">
{pipelines.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="gallery" className="mt-6">
<TabsContent value="gallery" className="mt-6 space-y-8">
<AutomationGallery />
<WorkflowTemplateGallery />
<PipelineTemplateGallery />
</TabsContent>
<TabsContent value="active" className="mt-6">
@ -231,6 +277,14 @@ export default function AutomationsPage() {
</Card>
)}
</TabsContent>
<TabsContent value="workflows" className="mt-6">
<WorkflowList />
</TabsContent>
<TabsContent value="pipelines" className="mt-6">
<PipelineList />
</TabsContent>
</Tabs>
<Dialog

View file

@ -0,0 +1,218 @@
"use client";
import { use } from "react";
import Link from "next/link";
import { ArrowLeft, Loader2, Repeat, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { usePipeline, usePipelineStatuses, useDeletePipeline } from "@/hooks/use-pipelines";
import { PipelineProgress } from "@/components/pipeline/pipeline-progress";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
export default function PipelineDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: idStr } = use(params);
const id = Number(idStr);
const router = useRouter();
const { data, isLoading, error } = usePipeline(id);
const { data: statuses } = usePipelineStatuses();
const deleteMutation = useDeletePipeline();
const status = (statuses ?? []).find((s) => s.pipeline_id === id) ?? null;
function handleDelete() {
deleteMutation.mutate(id, {
onSuccess: () => {
toast.success("Pipeline deleted");
router.push("/automations");
},
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
});
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !data) {
return (
<Card className="border-destructive bg-destructive/10 p-4">
<p className="text-sm text-destructive">
Failed to load pipeline. It may have been deleted.
</p>
<Link href="/automations" className="mt-2 inline-block">
<Button variant="outline" size="sm">
Back to Automations
</Button>
</Link>
</Card>
);
}
const { config, runs } = data;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/automations">
<Button variant="ghost" size="icon-xs">
<ArrowLeft className="size-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{config.name}
</h1>
{config.loop && (
<Repeat className="size-4 text-muted-foreground" />
)}
</div>
{config.description && (
<p className="text-sm text-muted-foreground mt-1">
{config.description}
</p>
)}
</div>
</div>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
<Trash2 className="size-3.5" />
Delete
</Button>
</div>
{/* Live progress */}
<PipelineProgress config={config} status={status} />
{/* Stages overview */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Stages ({config.stages.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{config.stages.map((stage, idx) => (
<div key={stage.id} className="border rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="text-xs">
Stage {idx + 1}
</Badge>
<span className="text-sm font-medium">{stage.name}</span>
<Badge variant="secondary" className="text-[10px]">
{stage.character_steps.length} worker
{stage.character_steps.length !== 1 && "s"}
</Badge>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{stage.character_steps.map((cs) => (
<div
key={cs.id}
className="text-xs bg-muted/30 rounded p-2 space-y-0.5"
>
<div className="font-medium">{cs.character_name}</div>
<div className="text-muted-foreground capitalize">
{cs.strategy_type}
</div>
{cs.transition && (
<div className="text-muted-foreground">
Until: {cs.transition.type}
{cs.transition.value != null &&
` ${cs.transition.operator ?? ">="} ${cs.transition.value}`}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Run history */}
{runs.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">
Run History ({runs.length})
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Run</TableHead>
<TableHead>Status</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Actions</TableHead>
<TableHead>Started</TableHead>
<TableHead>Stopped</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.slice(0, 20).map((run) => (
<TableRow key={run.id}>
<TableCell className="font-mono text-xs">
#{run.id}
</TableCell>
<TableCell>
<Badge
variant={
run.status === "error" ? "destructive" : "secondary"
}
className="text-[10px]"
>
{run.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{run.current_stage_index + 1}
{run.loop_count > 0 && ` (loop ${run.loop_count})`}
</TableCell>
<TableCell className="text-xs">
{run.total_actions_count}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(run.started_at).toLocaleString()}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{run.stopped_at
? new Date(run.stopped_at).toLocaleString()
: "\u2014"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -0,0 +1,39 @@
"use client";
import { Suspense } from "react";
import Link from "next/link";
import { ArrowLeft, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PipelineBuilder } from "@/components/pipeline/pipeline-builder";
export default function NewPipelinePage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href="/automations">
<Button variant="ghost" size="icon-xs">
<ArrowLeft className="size-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
New Pipeline
</h1>
<p className="text-sm text-muted-foreground mt-1">
Create a multi-character pipeline with sequential stages
</p>
</div>
</div>
<Suspense
fallback={
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
}
>
<PipelineBuilder />
</Suspense>
</div>
);
}

View file

@ -0,0 +1,349 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import {
ArrowLeft,
Loader2,
Repeat,
Play,
Square,
Pause,
Clock,
Calendar,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import {
useWorkflow,
useWorkflowStatuses,
useWorkflowLogs,
useControlWorkflow,
} from "@/hooks/use-workflows";
import { WorkflowProgress } from "@/components/workflow/workflow-progress";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
const STATUS_BADGE_CLASSES: Record<string, string> = {
running: "bg-green-600 text-white",
paused: "bg-yellow-600 text-white",
stopped: "",
completed: "bg-blue-600 text-white",
error: "bg-red-600 text-white",
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString();
}
function formatDuration(start: string, end: string | null): string {
const startTime = new Date(start).getTime();
const endTime = end ? new Date(end).getTime() : Date.now();
const diffMs = endTime - startTime;
const diffS = Math.floor(diffMs / 1000);
if (diffS < 60) return `${diffS}s`;
const m = Math.floor(diffS / 60);
const s = diffS % 60;
if (m < 60) return `${m}m ${s}s`;
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}h ${rm}m`;
}
export default function WorkflowDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: idStr } = use(params);
const id = parseInt(idStr, 10);
const router = useRouter();
const { data, isLoading, error } = useWorkflow(id);
const { data: statuses } = useWorkflowStatuses();
const { data: logs } = useWorkflowLogs(id);
const control = useControlWorkflow();
const status = statuses?.find((s) => s.workflow_id === id) ?? null;
const currentStatus = status?.status ?? "stopped";
const isStopped =
currentStatus === "stopped" ||
currentStatus === "completed" ||
currentStatus === "error" ||
!currentStatus;
const isRunning = currentStatus === "running";
const isPaused = currentStatus === "paused";
function handleControl(action: "start" | "stop" | "pause" | "resume") {
control.mutate(
{ id, action },
{
onSuccess: () => toast.success(`Workflow ${action}ed`),
onError: (err) =>
toast.error(`Failed to ${action} workflow: ${err.message}`),
}
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-24">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !data) {
return (
<div className="space-y-4">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/automations")}
>
<ArrowLeft className="size-4" />
Back
</Button>
<Card className="border-destructive bg-destructive/10 p-4">
<p className="text-sm text-destructive">
Failed to load workflow. It may have been deleted.
</p>
</Card>
</div>
);
}
const { config: workflow, runs } = data;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start gap-3">
<Button
variant="ghost"
size="icon-sm"
className="mt-1"
onClick={() => router.push("/automations")}
>
<ArrowLeft className="size-4" />
</Button>
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{workflow.name}
</h1>
{workflow.loop && (
<Badge variant="outline" className="gap-1">
<Repeat className="size-3" />
Loop
{workflow.max_loops > 0 && ` (max ${workflow.max_loops})`}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{workflow.character_name} &middot; {workflow.steps.length} step
{workflow.steps.length !== 1 && "s"}
{workflow.description && ` &middot; ${workflow.description}`}
</p>
</div>
</div>
{/* Controls */}
<Card className="px-4 py-3">
<div className="flex items-center gap-3">
<Badge
variant={
currentStatus === "error"
? "destructive"
: currentStatus === "running"
? "default"
: "secondary"
}
className={STATUS_BADGE_CLASSES[currentStatus] ?? ""}
>
{control.isPending && (
<Loader2 className="size-3 animate-spin" />
)}
{currentStatus}
</Badge>
{status && (
<span className="text-xs text-muted-foreground">
{status.total_actions_count.toLocaleString()} actions
</span>
)}
<div className="flex items-center gap-1.5">
{isStopped && (
<Button
size="xs"
variant="outline"
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
onClick={() => handleControl("start")}
disabled={control.isPending}
>
<Play className="size-3" />
Start
</Button>
)}
{isRunning && (
<>
<Button
size="xs"
variant="outline"
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-500/10"
onClick={() => handleControl("pause")}
disabled={control.isPending}
>
<Pause className="size-3" />
Pause
</Button>
<Button
size="xs"
variant="outline"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleControl("stop")}
disabled={control.isPending}
>
<Square className="size-3" />
Stop
</Button>
</>
)}
{isPaused && (
<>
<Button
size="xs"
variant="outline"
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
onClick={() => handleControl("resume")}
disabled={control.isPending}
>
<Play className="size-3" />
Resume
</Button>
<Button
size="xs"
variant="outline"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleControl("stop")}
disabled={control.isPending}
>
<Square className="size-3" />
Stop
</Button>
</>
)}
</div>
</div>
</Card>
{/* Tabs */}
<Tabs defaultValue="progress">
<TabsList>
<TabsTrigger value="progress">Progress</TabsTrigger>
<TabsTrigger value="runs">Run History</TabsTrigger>
</TabsList>
<TabsContent value="progress">
<WorkflowProgress
steps={workflow.steps}
status={status}
logs={logs ?? []}
loop={workflow.loop}
maxLoops={workflow.max_loops}
/>
</TabsContent>
<TabsContent value="runs">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Run History</CardTitle>
</CardHeader>
<CardContent>
{runs.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">
No runs yet. Start the workflow to create a run.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Status</TableHead>
<TableHead>Started</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Steps</TableHead>
<TableHead>Loops</TableHead>
<TableHead>Actions</TableHead>
<TableHead>Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => (
<TableRow key={run.id}>
<TableCell>
<Badge
variant={
run.status === "error"
? "destructive"
: "secondary"
}
className={cn(
STATUS_BADGE_CLASSES[run.status]
)}
>
{run.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="size-3" />
{formatDate(run.started_at)}
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="size-3" />
{formatDuration(run.started_at, run.stopped_at)}
</div>
</TableCell>
<TableCell className="tabular-nums text-xs">
{run.current_step_index + 1}/{workflow.steps.length}
</TableCell>
<TableCell className="tabular-nums text-xs">
{run.loop_count}
</TableCell>
<TableCell className="tabular-nums">
{run.total_actions_count.toLocaleString()}
</TableCell>
<TableCell className="max-w-[200px]">
{run.error_message && (
<span className="text-xs text-red-400 truncate block">
{run.error_message}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,46 @@
"use client";
import { useSearchParams } from "next/navigation";
import { WorkflowBuilder } from "@/components/workflow/workflow-builder";
import {
WORKFLOW_TEMPLATES,
} from "@/components/workflow/workflow-templates";
import type { WorkflowStep } from "@/lib/types";
export default function NewWorkflowPage() {
const searchParams = useSearchParams();
const templateId = searchParams.get("template");
const template = templateId
? WORKFLOW_TEMPLATES.find((t) => t.id === templateId)
: null;
const initialSteps: WorkflowStep[] | undefined = template
? template.steps.map((s) => ({
id: s.id,
name: s.name,
strategy_type: s.strategy_type as WorkflowStep["strategy_type"],
config: s.config as Record<string, unknown>,
transition: s.transition
? {
type: s.transition.type as WorkflowStep["transition"] extends null ? never : NonNullable<WorkflowStep["transition"]>["type"],
operator: (s.transition.operator ?? ">=") as ">=" | "<=" | "==" | ">" | "<",
value: s.transition.value ?? 0,
item_code: s.transition.item_code ?? "",
skill: s.transition.skill ?? "",
seconds: s.transition.seconds ?? 0,
}
: null,
}))
: undefined;
return (
<WorkflowBuilder
initialSteps={initialSteps}
initialName={template?.name ?? ""}
initialDescription={template?.description ?? ""}
initialLoop={template?.loop ?? false}
initialMaxLoops={template?.max_loops ?? 0}
/>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,10 @@
"use client";
import { useEffect } from "react";
import { AlertTriangle, RotateCcw } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { reportError } from "@/lib/api-client";
export default function Error({
error,
@ -11,6 +13,20 @@ export default function Error({
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
reportError({
error_type: error.name || "UnhandledError",
message: error.message || "Unknown error",
stack_trace: error.stack,
context: {
digest: error.digest,
url: typeof window !== "undefined" ? window.location.href : undefined,
},
}).catch(() => {
// Silently fail - don't create error loops
});
}, [error]);
return (
<div className="flex items-center justify-center h-full p-6">
<Card className="max-w-md w-full">

View file

@ -0,0 +1,395 @@
"use client";
import { Fragment, useState } from "react";
import {
AlertTriangle,
Loader2,
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
CheckCircle2,
XCircle,
Clock,
Bug,
} from "lucide-react";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useErrors, useErrorStats, useResolveError } from "@/hooks/use-errors";
import type { AppError } from "@/lib/types";
const PAGE_SIZE = 50;
const SEVERITY_COLORS: Record<string, string> = {
critical: "bg-red-600/20 text-red-400",
error: "bg-red-500/20 text-red-400",
warning: "bg-yellow-500/20 text-yellow-400",
};
const SOURCE_COLORS: Record<string, string> = {
backend: "bg-blue-500/20 text-blue-400",
frontend: "bg-purple-500/20 text-purple-400",
automation: "bg-green-500/20 text-green-400",
middleware: "bg-orange-500/20 text-orange-400",
};
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function StatCard({
label,
value,
icon: Icon,
color,
}: {
label: string;
value: number;
icon: React.ComponentType<{ className?: string }>;
color: string;
}) {
return (
<Card className="p-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-md ${color}`}>
<Icon className="size-4" />
</div>
<div>
<p className="text-2xl font-bold tabular-nums">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</div>
</Card>
);
}
function ExpandedRow({ error }: { error: AppError }) {
return (
<TableRow>
<TableCell colSpan={6} className="bg-muted/30 p-4">
<div className="space-y-3 text-sm">
{error.correlation_id && (
<div>
<span className="text-muted-foreground">Correlation ID: </span>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{error.correlation_id}
</code>
</div>
)}
{error.context && Object.keys(error.context).length > 0 && (
<div>
<span className="text-muted-foreground">Context:</span>
<pre className="mt-1 text-xs bg-muted p-3 rounded-md overflow-x-auto max-h-40">
{JSON.stringify(error.context, null, 2)}
</pre>
</div>
)}
{error.stack_trace && (
<div>
<span className="text-muted-foreground">Stack Trace:</span>
<pre className="mt-1 text-xs bg-muted p-3 rounded-md overflow-x-auto max-h-60 whitespace-pre-wrap">
{error.stack_trace}
</pre>
</div>
)}
</div>
</TableCell>
</TableRow>
);
}
export default function ErrorsPage() {
const [severityFilter, setSeverityFilter] = useState("_all");
const [sourceFilter, setSourceFilter] = useState("_all");
const [resolvedFilter, setResolvedFilter] = useState("_all");
const [page, setPage] = useState(1);
const [expandedId, setExpandedId] = useState<number | null>(null);
const { data, isLoading, error } = useErrors({
severity: severityFilter === "_all" ? undefined : severityFilter,
source: sourceFilter === "_all" ? undefined : sourceFilter,
resolved: resolvedFilter === "_all" ? undefined : resolvedFilter,
page,
size: PAGE_SIZE,
});
const { data: stats } = useErrorStats();
const resolveMutation = useResolveError();
const errors = data?.errors ?? [];
const totalPages = data?.pages ?? 1;
const total = data?.total ?? 0;
function handleFilterChange(setter: (v: string) => void) {
return (value: string) => {
setter(value);
setPage(1);
};
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Error Tracker
</h1>
<p className="text-sm text-muted-foreground mt-1">
Application errors from backend, frontend, and automations
</p>
</div>
{error && (
<Card className="border-destructive bg-destructive/10 p-4">
<p className="text-sm text-destructive">
Failed to load errors. Make sure the backend is running.
</p>
</Card>
)}
{/* Stats cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
label="Total Errors"
value={stats.total}
icon={Bug}
color="bg-red-500/20 text-red-400"
/>
<StatCard
label="Unresolved"
value={stats.unresolved}
icon={XCircle}
color="bg-orange-500/20 text-orange-400"
/>
<StatCard
label="Last Hour"
value={stats.last_hour}
icon={Clock}
color="bg-yellow-500/20 text-yellow-400"
/>
<StatCard
label="Resolved"
value={stats.total - stats.unresolved}
icon={CheckCircle2}
color="bg-green-500/20 text-green-400"
/>
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-3">
<Select
value={severityFilter}
onValueChange={handleFilterChange(setSeverityFilter)}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All Severities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All Severities</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="warning">Warning</SelectItem>
</SelectContent>
</Select>
<Select
value={sourceFilter}
onValueChange={handleFilterChange(setSourceFilter)}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All Sources" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All Sources</SelectItem>
<SelectItem value="backend">Backend</SelectItem>
<SelectItem value="frontend">Frontend</SelectItem>
<SelectItem value="automation">Automation</SelectItem>
<SelectItem value="middleware">Middleware</SelectItem>
</SelectContent>
</Select>
<Select
value={resolvedFilter}
onValueChange={handleFilterChange(setResolvedFilter)}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All Status</SelectItem>
<SelectItem value="false">Unresolved</SelectItem>
<SelectItem value="true">Resolved</SelectItem>
</SelectContent>
</Select>
{total > 0 && (
<div className="flex items-center text-xs text-muted-foreground self-center ml-auto">
{total.toLocaleString()} total errors
</div>
)}
</div>
{isLoading && errors.length === 0 && (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Error Table */}
{errors.length > 0 && (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead className="w-40">Time</TableHead>
<TableHead className="w-24">Severity</TableHead>
<TableHead className="w-28">Source</TableHead>
<TableHead>Error</TableHead>
<TableHead className="w-24 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{errors.map((err) => (
<Fragment key={err.id}>
<TableRow
className="cursor-pointer"
onClick={() =>
setExpandedId(expandedId === err.id ? null : err.id)
}
>
<TableCell className="px-2">
{expandedId === err.id ? (
<ChevronUp className="size-4 text-muted-foreground" />
) : (
<ChevronDown className="size-4 text-muted-foreground" />
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{formatDate(err.created_at)}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-0 border-0 capitalize ${
SEVERITY_COLORS[err.severity] ?? "bg-muted text-muted-foreground"
}`}
>
{err.severity}
</Badge>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-0 border-0 capitalize ${
SOURCE_COLORS[err.source] ?? "bg-muted text-muted-foreground"
}`}
>
{err.source}
</Badge>
</TableCell>
<TableCell className="max-w-md">
<div className="flex items-center gap-2">
{err.resolved && (
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
)}
<div className="truncate">
<span className="text-sm font-medium">
{err.error_type}
</span>
<span className="text-sm text-muted-foreground ml-2 truncate">
{err.message}
</span>
</div>
</div>
</TableCell>
<TableCell className="text-center">
{!err.resolved && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={resolveMutation.isPending}
onClick={(e) => {
e.stopPropagation();
resolveMutation.mutate(err.id);
}}
>
Resolve
</Button>
)}
</TableCell>
</TableRow>
{expandedId === err.id && (
<ExpandedRow key={`${err.id}-expanded`} error={err} />
)}
</Fragment>
))}
</TableBody>
</Table>
</Card>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground tabular-nums">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
)}
{/* Empty state */}
{errors.length === 0 && !isLoading && (
<Card className="p-8 text-center">
<AlertTriangle className="size-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">
No errors found. That&apos;s a good sign!
</p>
</Card>
)}
</div>
);
}

View file

@ -9,11 +9,14 @@ import {
History,
TrendingUp,
User,
Plus,
X,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Tabs,
TabsContent,
@ -28,14 +31,27 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useExchangeOrders,
useMyOrders,
useExchangeHistory,
usePriceHistory,
} from "@/hooks/use-exchange";
import { useCharacters } from "@/hooks/use-characters";
import { useItems } from "@/hooks/use-game-data";
import { PriceChart } from "@/components/exchange/price-chart";
import { BuyEquipDialog } from "@/components/exchange/buy-equip-dialog";
import { GameIcon } from "@/components/ui/game-icon";
import { executeAction } from "@/lib/api-client";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import type { GEOrder, GEHistoryEntry } from "@/lib/types";
function formatDate(dateStr: string): string {
@ -54,12 +70,14 @@ function OrdersTable({
search,
emptyMessage,
showAccount,
onBuy,
}: {
orders: GEOrder[];
isLoading: boolean;
search: string;
emptyMessage: string;
showAccount?: boolean;
onBuy?: (order: GEOrder) => void;
}) {
const filtered = useMemo(() => {
if (!search.trim()) return orders;
@ -94,6 +112,7 @@ function OrdersTable({
<TableHead className="text-right">Quantity</TableHead>
{showAccount && <TableHead>Account</TableHead>}
<TableHead className="text-right">Created</TableHead>
{onBuy && <TableHead className="text-right">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
@ -125,12 +144,124 @@ function OrdersTable({
</TableCell>
{showAccount && (
<TableCell className="text-muted-foreground text-sm">
{order.account ?? ""}
{order.account ?? "\u2014"}
</TableCell>
)}
<TableCell className="text-right text-muted-foreground text-sm">
{formatDate(order.created_at)}
</TableCell>
{onBuy && (
<TableCell className="text-right">
{order.type === "sell" ? (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-green-400 hover:text-green-300 hover:bg-green-500/10"
onClick={() => onBuy(order)}
>
<ShoppingCart className="size-3" />
Buy
</Button>
) : null}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
);
}
function MyOrdersTable({
orders,
isLoading,
selectedCharacter,
onCancel,
cancelPending,
}: {
orders: GEOrder[];
isLoading: boolean;
selectedCharacter: string;
onCancel: (orderId: string) => void;
cancelPending: string | null;
}) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (orders.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12">
<ArrowLeftRight className="size-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground text-sm">
You have no active orders on the Grand Exchange.
</p>
</div>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="text-right">Quantity</TableHead>
<TableHead className="text-right">Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.map((order) => (
<TableRow key={order.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<GameIcon type="item" code={order.code} size="sm" />
{order.code}
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={
order.type === "buy"
? "text-green-400 border-green-500/30"
: "text-red-400 border-red-500/30"
}
>
{order.type.toUpperCase()}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums text-amber-400">
{order.price.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums">
{order.quantity.toLocaleString()}
</TableCell>
<TableCell className="text-right text-muted-foreground text-sm">
{formatDate(order.created_at)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={!selectedCharacter || cancelPending !== null}
onClick={() => onCancel(order.id)}
>
{cancelPending === order.id ? (
<Loader2 className="size-3 animate-spin" />
) : (
<X className="size-3" />
)}
Cancel
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
@ -211,10 +342,26 @@ export default function ExchangePage() {
const { data: orders, isLoading: loadingOrders, error: ordersError } = useExchangeOrders();
const { data: myOrders, isLoading: loadingMyOrders } = useMyOrders();
const { data: history, isLoading: loadingHistory } = useExchangeHistory();
const { data: characters } = useCharacters();
const queryClient = useQueryClient();
const { data: items } = useItems();
const [marketSearch, setMarketSearch] = useState("");
const [priceItemCode, setPriceItemCode] = useState("");
const [searchedItem, setSearchedItem] = useState("");
const [selectedCharacter, setSelectedCharacter] = useState("");
const [cancelPending, setCancelPending] = useState<string | null>(null);
const [buyOrder, setBuyOrder] = useState<GEOrder | null>(null);
// Buy/Sell form state
const [buyCode, setBuyCode] = useState("");
const [buyQty, setBuyQty] = useState("1");
const [buyPrice, setBuyPrice] = useState("");
const [sellCode, setSellCode] = useState("");
const [sellQty, setSellQty] = useState("1");
const [sellPrice, setSellPrice] = useState("");
const [orderPending, setOrderPending] = useState<string | null>(null);
const {
data: priceData,
@ -227,17 +374,94 @@ export default function ExchangePage() {
}
}
async function handleCancelOrder(orderId: string) {
if (!selectedCharacter) {
toast.error("Select a character first");
return;
}
setCancelPending(orderId);
try {
await executeAction(selectedCharacter, "ge_cancel", { order_id: orderId });
toast.success("Order cancelled");
queryClient.invalidateQueries({ queryKey: ["exchange"] });
} catch (err) {
toast.error(
`Cancel failed: ${err instanceof Error ? err.message : "Unknown error"}`
);
} finally {
setCancelPending(null);
}
}
async function handleCreateOrder(type: "ge_create_buy" | "ge_sell") {
if (!selectedCharacter) {
toast.error("Select a character first");
return;
}
const code = type === "ge_create_buy" ? buyCode : sellCode;
const qty = type === "ge_create_buy" ? buyQty : sellQty;
const price = type === "ge_create_buy" ? buyPrice : sellPrice;
setOrderPending(type);
try {
await executeAction(selectedCharacter, type, {
code,
quantity: parseInt(qty, 10) || 1,
price: parseInt(price, 10) || 0,
});
toast.success(`${type === "ge_create_buy" ? "Buy" : "Sell"} order created`);
queryClient.invalidateQueries({ queryKey: ["exchange"] });
// Clear form
if (type === "ge_create_buy") {
setBuyCode("");
setBuyQty("1");
setBuyPrice("");
} else {
setSellCode("");
setSellQty("1");
setSellPrice("");
}
} catch (err) {
toast.error(
`Order failed: ${err instanceof Error ? err.message : "Unknown error"}`
);
} finally {
setOrderPending(null);
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Grand Exchange
</h1>
<p className="text-sm text-muted-foreground mt-1">
Browse market orders, track your trades, and analyze price history
Browse market orders, create trades, and analyze price history
</p>
</div>
{/* Character selector for actions */}
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground whitespace-nowrap">
Acting as:
</Label>
<Select value={selectedCharacter} onValueChange={setSelectedCharacter}>
<SelectTrigger className="w-44">
<SelectValue placeholder="Select character" />
</SelectTrigger>
<SelectContent>
{(characters ?? []).map((c) => (
<SelectItem key={c.name} value={c.name}>
{c.name} (Lv.{c.level})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{ordersError && (
<Card className="border-destructive bg-destructive/10 p-4">
<p className="text-sm text-destructive">
@ -246,8 +470,12 @@ export default function ExchangePage() {
</Card>
)}
<Tabs defaultValue="market">
<Tabs defaultValue="trade">
<TabsList>
<TabsTrigger value="trade" className="gap-1.5">
<Plus className="size-4" />
Trade
</TabsTrigger>
<TabsTrigger value="market" className="gap-1.5">
<ShoppingCart className="size-4" />
Market
@ -255,17 +483,174 @@ export default function ExchangePage() {
<TabsTrigger value="my-orders" className="gap-1.5">
<User className="size-4" />
My Orders
{myOrders && myOrders.length > 0 && (
<Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0">
{myOrders.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="history" className="gap-1.5">
<History className="size-4" />
Trade History
History
</TabsTrigger>
<TabsTrigger value="prices" className="gap-1.5">
<TrendingUp className="size-4" />
Price History
Prices
</TabsTrigger>
</TabsList>
{/* Trade Tab - Create buy/sell orders */}
<TabsContent value="trade" className="space-y-4">
{!selectedCharacter && (
<Card className="border-amber-500/30 bg-amber-500/5 p-4">
<p className="text-sm text-amber-400">
Select a character in the top-right corner to create orders.
The character must be at the Grand Exchange tile.
</p>
</Card>
)}
<div className="grid gap-4 sm:grid-cols-2">
{/* Buy order form */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm text-green-400 flex items-center gap-2">
<ShoppingCart className="size-4" />
Create Buy Order
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">Item Code</Label>
<Input
placeholder="e.g. copper_ore"
value={buyCode}
onChange={(e) => setBuyCode(e.target.value)}
className="h-9"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Quantity</Label>
<Input
type="number"
min="1"
value={buyQty}
onChange={(e) => setBuyQty(e.target.value)}
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Price Each</Label>
<Input
type="number"
min="1"
placeholder="Gold per unit"
value={buyPrice}
onChange={(e) => setBuyPrice(e.target.value)}
className="h-9"
/>
</div>
</div>
{buyCode && buyPrice && (
<p className="text-xs text-muted-foreground">
Total cost:{" "}
<span className="text-amber-400 font-medium">
{((parseInt(buyQty, 10) || 1) * (parseInt(buyPrice, 10) || 0)).toLocaleString()} gold
</span>
</p>
)}
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
disabled={
!selectedCharacter ||
!buyCode.trim() ||
!buyPrice ||
orderPending !== null
}
onClick={() => handleCreateOrder("ge_create_buy")}
>
{orderPending === "ge_create_buy" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ShoppingCart className="size-4" />
)}
Place Buy Order
</Button>
</CardContent>
</Card>
{/* Sell order form */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm text-red-400 flex items-center gap-2">
<ShoppingCart className="size-4" />
Create Sell Order
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">Item Code</Label>
<Input
placeholder="e.g. copper_ore"
value={sellCode}
onChange={(e) => setSellCode(e.target.value)}
className="h-9"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Quantity</Label>
<Input
type="number"
min="1"
value={sellQty}
onChange={(e) => setSellQty(e.target.value)}
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Price Each</Label>
<Input
type="number"
min="1"
placeholder="Gold per unit"
value={sellPrice}
onChange={(e) => setSellPrice(e.target.value)}
className="h-9"
/>
</div>
</div>
{sellCode && sellPrice && (
<p className="text-xs text-muted-foreground">
Total value:{" "}
<span className="text-amber-400 font-medium">
{((parseInt(sellQty, 10) || 1) * (parseInt(sellPrice, 10) || 0)).toLocaleString()} gold
</span>
</p>
)}
<Button
className="w-full bg-red-600 hover:bg-red-700 text-white"
disabled={
!selectedCharacter ||
!sellCode.trim() ||
!sellPrice ||
orderPending !== null
}
onClick={() => handleCreateOrder("ge_sell")}
>
{orderPending === "ge_sell" ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ShoppingCart className="size-4" />
)}
Place Sell Order
</Button>
</CardContent>
</Card>
</div>
</TabsContent>
{/* Market Tab - Browse all public orders */}
<TabsContent value="market" className="space-y-4">
<div className="relative max-w-sm">
@ -278,12 +663,21 @@ export default function ExchangePage() {
/>
</div>
{!selectedCharacter && (
<Card className="border-amber-500/30 bg-amber-500/5 p-4">
<p className="text-sm text-amber-400">
Select a character in the top-right corner to buy items directly from sell orders.
</p>
</Card>
)}
<Card>
<OrdersTable
orders={orders ?? []}
isLoading={loadingOrders}
search={marketSearch}
showAccount
onBuy={selectedCharacter ? (order) => setBuyOrder(order) : undefined}
emptyMessage={
marketSearch.trim()
? `No orders found for "${marketSearch}"`
@ -293,19 +687,27 @@ export default function ExchangePage() {
</Card>
</TabsContent>
{/* My Orders Tab - User's own active orders */}
{/* My Orders Tab - with cancel buttons */}
<TabsContent value="my-orders" className="space-y-4">
{!selectedCharacter && (
<Card className="border-amber-500/30 bg-amber-500/5 p-4">
<p className="text-sm text-amber-400">
Select a character to cancel orders. The character must be at the Grand Exchange.
</p>
</Card>
)}
<Card>
<OrdersTable
<MyOrdersTable
orders={myOrders ?? []}
isLoading={loadingMyOrders}
search=""
emptyMessage="You have no active orders on the Grand Exchange."
selectedCharacter={selectedCharacter}
onCancel={handleCancelOrder}
cancelPending={cancelPending}
/>
</Card>
</TabsContent>
{/* Trade History Tab - User's transaction history */}
{/* Trade History Tab */}
<TabsContent value="history" className="space-y-4">
<Card>
<HistoryTable
@ -361,6 +763,13 @@ export default function ExchangePage() {
)}
</TabsContent>
</Tabs>
<BuyEquipDialog
order={buyOrder}
characterName={selectedCharacter}
items={items}
onOpenChange={(open) => !open && setBuyOrder(null)}
/>
</div>
);
}

View file

@ -1,12 +1,13 @@
"use client";
import { useMemo, useState } from "react";
import { useState } from "react";
import {
ScrollText,
Loader2,
CheckCircle,
XCircle,
ChevronDown,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@ -30,20 +31,28 @@ import { useCharacters } from "@/hooks/use-characters";
import { useLogs } from "@/hooks/use-analytics";
import type { ActionLog } from "@/lib/types";
const PAGE_SIZE = 50;
const ACTION_TYPE_COLORS: Record<string, string> = {
move: "bg-blue-500/20 text-blue-400",
movement: "bg-blue-500/20 text-blue-400",
fight: "bg-red-500/20 text-red-400",
gather: "bg-green-500/20 text-green-400",
gathering: "bg-green-500/20 text-green-400",
rest: "bg-yellow-500/20 text-yellow-400",
deposit: "bg-purple-500/20 text-purple-400",
withdraw: "bg-cyan-500/20 text-cyan-400",
craft: "bg-emerald-500/20 text-emerald-400",
crafting: "bg-emerald-500/20 text-emerald-400",
buy: "bg-teal-500/20 text-teal-400",
sell: "bg-pink-500/20 text-pink-400",
equip: "bg-orange-500/20 text-orange-400",
unequip: "bg-orange-500/20 text-orange-400",
use: "bg-amber-500/20 text-amber-400",
task: "bg-indigo-500/20 text-indigo-400",
recycling: "bg-lime-500/20 text-lime-400",
npc_buy: "bg-teal-500/20 text-teal-400",
npc_sell: "bg-pink-500/20 text-pink-400",
grandexchange_buy: "bg-teal-500/20 text-teal-400",
grandexchange_sell: "bg-pink-500/20 text-pink-400",
grandexchange_cancel: "bg-slate-500/20 text-slate-400",
};
function getActionColor(type: string): string {
@ -65,36 +74,47 @@ function getDetailsString(log: ActionLog): string {
const d = log.details;
if (!d || Object.keys(d).length === 0) return "-";
const parts: string[] = [];
// Use description from the game API if available
if (d.description && typeof d.description === "string") return d.description;
if (d.reason && typeof d.reason === "string") return d.reason;
if (d.message && typeof d.message === "string") return d.message;
if (d.error && typeof d.error === "string") return d.error;
if (d.result && typeof d.result === "string") return d.result;
const parts: string[] = [];
if (d.monster) parts.push(`monster: ${d.monster}`);
if (d.resource) parts.push(`resource: ${d.resource}`);
if (d.item) parts.push(`item: ${d.item}`);
if (d.skill) parts.push(`skill: ${d.skill}`);
if (d.map_name) parts.push(`${d.map_name}`);
if (d.x !== undefined && d.y !== undefined) parts.push(`(${d.x}, ${d.y})`);
if (d.xp) parts.push(`xp: +${d.xp}`);
if (d.gold) parts.push(`gold: ${d.gold}`);
if (d.quantity) parts.push(`qty: ${d.quantity}`);
if (Array.isArray(d.drops) && d.drops.length > 0) parts.push(`drops: ${(d.drops as string[]).join(", ")}`);
return parts.length > 0 ? parts.join(" | ") : JSON.stringify(d);
}
const ALL_ACTION_TYPES = [
"move",
"movement",
"fight",
"gather",
"gathering",
"crafting",
"recycling",
"rest",
"deposit",
"withdraw",
"craft",
"buy",
"sell",
"equip",
"unequip",
"use",
"deposit",
"withdraw",
"buy",
"sell",
"npc_buy",
"npc_sell",
"grandexchange_buy",
"grandexchange_sell",
"task",
];
@ -102,29 +122,26 @@ export default function LogsPage() {
const { data: characters } = useCharacters();
const [characterFilter, setCharacterFilter] = useState("_all");
const [actionFilter, setActionFilter] = useState("_all");
const [visibleCount, setVisibleCount] = useState(50);
const [page, setPage] = useState(1);
const { data: logs, isLoading, error } = useLogs({
const { data, isLoading, error } = useLogs({
character: characterFilter === "_all" ? undefined : characterFilter,
type: actionFilter === "_all" ? undefined : actionFilter,
page,
size: PAGE_SIZE,
});
const filteredLogs = useMemo(() => {
let items = logs ?? [];
const logs = data?.logs ?? [];
const totalPages = data?.pages ?? 1;
const total = data?.total ?? 0;
if (actionFilter !== "_all") {
items = items.filter((log) => log.action_type === actionFilter);
function handleFilterChange(setter: (v: string) => void) {
return (value: string) => {
setter(value);
setPage(1);
};
}
// Sort by created_at descending
return [...items].sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
}, [logs, actionFilter]);
const visibleLogs = filteredLogs.slice(0, visibleCount);
const hasMore = visibleCount < filteredLogs.length;
return (
<div className="space-y-6">
<div>
@ -132,7 +149,7 @@ export default function LogsPage() {
Action Logs
</h1>
<p className="text-sm text-muted-foreground mt-1">
View detailed action logs across all characters
Live action history from the game server
</p>
</div>
@ -146,7 +163,7 @@ export default function LogsPage() {
{/* Filters */}
<div className="flex flex-wrap gap-3">
<Select value={characterFilter} onValueChange={setCharacterFilter}>
<Select value={characterFilter} onValueChange={handleFilterChange(setCharacterFilter)}>
<SelectTrigger className="w-48">
<SelectValue placeholder="All Characters" />
</SelectTrigger>
@ -160,7 +177,7 @@ export default function LogsPage() {
</SelectContent>
</Select>
<Select value={actionFilter} onValueChange={setActionFilter}>
<Select value={actionFilter} onValueChange={handleFilterChange(setActionFilter)}>
<SelectTrigger className="w-48">
<SelectValue placeholder="All Actions" />
</SelectTrigger>
@ -168,41 +185,41 @@ export default function LogsPage() {
<SelectItem value="_all">All Actions</SelectItem>
{ALL_ACTION_TYPES.map((type) => (
<SelectItem key={type} value={type}>
<span className="capitalize">{type}</span>
<span className="capitalize">{type.replace(/_/g, " ")}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{filteredLogs.length > 0 && (
{total > 0 && (
<div className="flex items-center text-xs text-muted-foreground self-center ml-auto">
Showing {visibleLogs.length} of {filteredLogs.length} entries
{total.toLocaleString()} total entries
</div>
)}
</div>
{isLoading && (
{isLoading && logs.length === 0 && (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Log Table */}
{visibleLogs.length > 0 && (
{logs.length > 0 && (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-40">Time</TableHead>
<TableHead className="w-32">Character</TableHead>
<TableHead className="w-24">Action</TableHead>
<TableHead className="w-28">Action</TableHead>
<TableHead>Details</TableHead>
<TableHead className="w-16 text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleLogs.map((log) => (
<TableRow key={log.id}>
{logs.map((log, idx) => (
<TableRow key={`${log.id}-${idx}`}>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{formatDate(log.created_at)}
</TableCell>
@ -214,7 +231,7 @@ export default function LogsPage() {
variant="outline"
className={`text-[10px] px-1.5 py-0 border-0 capitalize ${getActionColor(log.action_type)}`}
>
{log.action_type}
{log.action_type.replace(/_/g, " ")}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-md truncate">
@ -234,26 +251,39 @@ export default function LogsPage() {
</Card>
)}
{/* Load More */}
{hasMore && (
<div className="flex justify-center">
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
onClick={() => setVisibleCount((c) => c + 50)}
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
<ChevronDown className="size-4" />
Load More
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground tabular-nums">
Page {page} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
)}
{/* Empty state */}
{filteredLogs.length === 0 && !isLoading && (
{logs.length === 0 && !isLoading && (
<Card className="p-8 text-center">
<ScrollText className="size-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">
No log entries found. Actions performed by characters or automations
will appear here.
No log entries found. Perform actions in the game to generate logs.
</p>
</Card>
)}

View file

@ -8,9 +8,7 @@ import {
useCallback,
} from "react";
import {
getAuthStatus,
setAuthToken,
clearAuthToken,
type AuthStatus,
} from "@/lib/api-client";
@ -38,39 +36,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState<AuthStatus | null>(null);
const [loading, setLoading] = useState(true);
const checkStatus = useCallback(async () => {
try {
const s = await getAuthStatus();
setStatus(s);
// If backend has no token but we have one in localStorage, auto-restore it
if (!s.has_token) {
// Gate is based entirely on localStorage — each browser has its own token.
useEffect(() => {
const savedToken = localStorage.getItem(STORAGE_KEY);
if (savedToken) {
const result = await setAuthToken(savedToken);
if (result.success) {
setStatus({ has_token: true, source: "user" });
} else {
// Token is stale, remove it
localStorage.removeItem(STORAGE_KEY);
}
}
}
} catch {
// Backend unreachable — show the gate anyway
setStatus({ has_token: false, source: "none" });
} finally {
setLoading(false);
}
setLoading(false);
}, []);
useEffect(() => {
checkStatus();
}, [checkStatus]);
const handleSetToken = useCallback(
async (token: string): Promise<{ success: boolean; error?: string }> => {
try {
// Validate the token with the backend (it won't store it)
const result = await setAuthToken(token);
if (result.success) {
localStorage.setItem(STORAGE_KEY, token);
@ -89,13 +69,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
);
const handleRemoveToken = useCallback(async () => {
try {
const s = await clearAuthToken();
setStatus(s);
} catch {
setStatus({ has_token: false, source: "none" });
}
localStorage.removeItem(STORAGE_KEY);
setStatus({ has_token: false, source: "none" });
}, []);
return (

View file

@ -1,5 +1,7 @@
"use client";
import { useState } from "react";
import { X, Loader2 } from "lucide-react";
import {
Card,
CardContent,
@ -7,16 +9,44 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { EQUIPMENT_SLOTS } from "@/lib/constants";
import type { Character } from "@/lib/types";
import { GameIcon } from "@/components/ui/game-icon";
import { cn } from "@/lib/utils";
import { executeAction } from "@/lib/api-client";
import { toast } from "sonner";
interface EquipmentGridProps {
character: Character;
onActionComplete?: () => void;
}
export function EquipmentGrid({ character }: EquipmentGridProps) {
export function EquipmentGrid({ character, onActionComplete }: EquipmentGridProps) {
const [pendingSlot, setPendingSlot] = useState<string | null>(null);
async function handleUnequip(slotKey: string) {
const slot = slotKey.replace("_slot", "");
setPendingSlot(slotKey);
try {
await executeAction(character.name, "unequip", { slot });
toast.success(`Unequipped ${slot}`);
onActionComplete?.();
} catch (err) {
toast.error(
`Unequip failed: ${err instanceof Error ? err.message : "Unknown error"}`
);
} finally {
setPendingSlot(null);
}
}
return (
<Card>
<CardHeader className="pb-2">
@ -27,8 +57,8 @@ export function EquipmentGrid({ character }: EquipmentGridProps) {
{EQUIPMENT_SLOTS.map((slot) => {
const itemCode = character[slot.key as keyof Character] as string;
const isEmpty = !itemCode;
const isPending = pendingSlot === slot.key;
// Show quantity for utility slots
let quantity: number | null = null;
if (slot.key === "utility1_slot" && itemCode) {
quantity = character.utility1_slot_quantity;
@ -40,10 +70,10 @@ export function EquipmentGrid({ character }: EquipmentGridProps) {
<div
key={slot.key}
className={cn(
"flex flex-col gap-1 rounded-lg border p-2.5 transition-colors",
"group relative flex flex-col gap-1 rounded-lg border p-2.5 transition-colors",
isEmpty
? "border-dashed border-border/50 bg-transparent"
: "border-border bg-accent/30"
: "border-border bg-accent/30 hover:bg-accent/50"
)}
>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
@ -69,6 +99,32 @@ export function EquipmentGrid({ character }: EquipmentGridProps) {
)}
</div>
)}
{/* Unequip button - shown on hover */}
{!isEmpty && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute top-1 right-1 size-6 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive/20 hover:text-destructive"
disabled={isPending}
onClick={() => handleUnequip(slot.key)}
>
{isPending ? (
<Loader2 className="size-3 animate-spin" />
) : (
<X className="size-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Unequip {slot.label}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
})}

View file

@ -1,5 +1,16 @@
"use client";
import { useState } from "react";
import {
Shield,
FlaskConical,
Landmark,
Recycle,
Loader2,
ChevronDown,
ChevronUp,
Package,
} from "lucide-react";
import {
Card,
CardContent,
@ -8,26 +19,64 @@ import {
CardDescription,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { GameIcon } from "@/components/ui/game-icon";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { Character } from "@/lib/types";
import { EQUIPMENT_SLOTS } from "@/lib/constants";
import type { Character, InventorySlot } from "@/lib/types";
import { executeAction } from "@/lib/api-client";
import { toast } from "sonner";
interface InventoryGridProps {
character: Character;
onActionComplete?: () => void;
}
export function InventoryGrid({ character }: InventoryGridProps) {
const usedSlots = character.inventory.filter(
(slot) => slot.code && slot.code !== ""
).length;
const totalSlots = character.inventory_max_items;
export function InventoryGrid({ character, onActionComplete }: InventoryGridProps) {
const [pendingSlot, setPendingSlot] = useState<number | null>(null);
const [showAllSlots, setShowAllSlots] = useState(false);
// Build a map from slot number to inventory item
const slotMap = new Map(
character.inventory
.filter((slot) => slot.code && slot.code !== "")
.map((slot) => [slot.slot, slot])
const filledItems = character.inventory.filter(
(slot) => slot.code && slot.code !== ""
);
const usedSlots = filledItems.length;
const totalSlots = character.inventory_max_items;
const emptySlots = totalSlots - usedSlots;
async function handleAction(
slotNum: number,
action: string,
params: Record<string, unknown>
) {
setPendingSlot(slotNum);
try {
await executeAction(character.name, action, params);
toast.success(`${action} successful`);
onActionComplete?.();
} catch (err) {
toast.error(
`${action} failed: ${err instanceof Error ? err.message : "Unknown error"}`
);
} finally {
setPendingSlot(null);
}
}
const equipSlots = EQUIPMENT_SLOTS.map((s) => ({
key: s.key.replace("_slot", ""),
label: s.label,
}));
return (
<Card>
@ -39,40 +88,223 @@ export function InventoryGrid({ character }: InventoryGridProps) {
</Badge>
</div>
<CardDescription>
{totalSlots - usedSlots} slots available
{emptySlots} slots available
{usedSlots > 0 && " \u00b7 Click items for actions"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-5 sm:grid-cols-6 md:grid-cols-5 lg:grid-cols-6 gap-1.5">
{Array.from({ length: totalSlots }).map((_, index) => {
const item = slotMap.get(index + 1);
const isEmpty = !item;
{usedSlots === 0 ? (
<div className="flex items-center gap-2 py-4 justify-center text-sm text-muted-foreground">
<Package className="size-4" />
Inventory is empty
</div>
) : (
<div className="grid grid-cols-5 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-1.5">
{filledItems.map((item) => (
<InventoryItemSlot
key={item.slot}
item={item}
isPending={pendingSlot === item.slot}
equipSlots={equipSlots}
characterName={character.name}
onAction={(action, params) =>
handleAction(item.slot, action, params)
}
/>
))}
</div>
)}
{/* Expandable full grid with all slots */}
{emptySlots > 0 && (
<div className="mt-2">
<Button
variant="ghost"
size="sm"
className="w-full text-xs text-muted-foreground h-7"
onClick={() => setShowAllSlots(!showAllSlots)}
>
{showAllSlots ? (
<>
<ChevronUp className="size-3 mr-1" />
Hide empty slots
</>
) : (
<>
<ChevronDown className="size-3 mr-1" />
Show all {totalSlots} slots ({emptySlots} empty)
</>
)}
</Button>
{showAllSlots && (
<div className="grid grid-cols-8 sm:grid-cols-10 md:grid-cols-12 lg:grid-cols-14 gap-1 mt-2">
{Array.from({ length: totalSlots }).map((_, index) => {
const slotNum = index + 1;
const item = filledItems.find((i) => i.slot === slotNum);
if (!item) {
return (
<div
key={index}
className="rounded border border-dashed border-border/30 aspect-square min-h-[32px]"
/>
);
}
return (
<InventoryItemSlot
key={index}
item={item}
isPending={pendingSlot === slotNum}
equipSlots={equipSlots}
characterName={character.name}
onAction={(action, params) =>
handleAction(item.slot, action, params)
}
compact
/>
);
})}
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}
function InventoryItemSlot({
item,
isPending,
equipSlots,
onAction,
compact,
}: {
item: InventorySlot;
isPending: boolean;
equipSlots: { key: string; label: string }[];
characterName: string;
onAction: (action: string, params: Record<string, unknown>) => void;
compact?: boolean;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
"flex flex-col items-center justify-center rounded-md border p-1.5 aspect-square min-h-[48px]",
isEmpty
? "border-dashed border-border/40"
: "border-border bg-accent/30"
"flex flex-col items-center justify-center rounded-md border transition-colors cursor-pointer",
"border-border bg-accent/30 hover:bg-accent/60 hover:border-primary/40",
isPending && "opacity-50 pointer-events-none",
compact
? "p-0.5 aspect-square min-h-[32px]"
: "p-1.5 aspect-square min-h-[48px]"
)}
>
{item && (
{isPending ? (
<Loader2
className={cn(
"animate-spin text-muted-foreground",
compact ? "size-3" : "size-5"
)}
/>
) : (
<div className="relative flex items-center justify-center">
<GameIcon type="item" code={item.code} size="md" />
<GameIcon
type="item"
code={item.code}
size={compact ? "sm" : "md"}
/>
{item.quantity > 1 && (
<span className="absolute -bottom-0.5 -right-0.5 rounded bg-background/80 px-0.5 text-[9px] font-medium text-muted-foreground leading-tight">
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 rounded bg-background/80 font-medium text-muted-foreground leading-tight",
compact ? "px-0.5 text-[7px]" : "px-0.5 text-[9px]"
)}
>
{item.quantity}
</span>
)}
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuLabel className="flex items-center gap-2">
<GameIcon type="item" code={item.code} size="sm" />
{item.code}
{item.quantity > 1 && (
<span className="text-muted-foreground font-normal">
x{item.quantity}
</span>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Equip - submenu with slot selection */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Shield className="size-4 text-blue-400" />
Equip
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{equipSlots.map((slot) => (
<DropdownMenuItem
key={slot.key}
onClick={() =>
onAction("equip", { code: item.code, slot: slot.key })
}
>
{slot.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Use item */}
<DropdownMenuItem
onClick={() => onAction("use_item", { code: item.code, quantity: 1 })}
>
<FlaskConical className="size-4 text-green-400" />
Use
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Deposit to bank */}
<DropdownMenuItem
onClick={() =>
onAction("deposit", { code: item.code, quantity: item.quantity })
}
>
<Landmark className="size-4 text-purple-400" />
Deposit All ({item.quantity})
</DropdownMenuItem>
{item.quantity > 1 && (
<DropdownMenuItem
onClick={() =>
onAction("deposit", { code: item.code, quantity: 1 })
}
>
<Landmark className="size-4 text-purple-400" />
Deposit 1
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{/* Recycle */}
<DropdownMenuItem
onClick={() =>
onAction("recycle", { code: item.code, quantity: 1 })
}
>
<Recycle className="size-4 text-orange-400" />
Recycle
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,299 @@
"use client";
import { useState } from "react";
import { Loader2, ShoppingCart, Shield, CheckCircle2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { GameIcon } from "@/components/ui/game-icon";
import { EQUIPMENT_SLOTS } from "@/lib/constants";
import { executeAction } from "@/lib/api-client";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import type { GEOrder, Item } from "@/lib/types";
// Map item type → suggested equipment slot key (without _slot suffix)
const TYPE_TO_SLOT: Record<string, string> = {
weapon: "weapon",
shield: "shield",
helmet: "helmet",
body_armor: "body_armor",
leg_armor: "leg_armor",
boots: "boots",
ring: "ring1",
amulet: "amulet",
artifact: "artifact1",
};
interface BuyEquipDialogProps {
order: GEOrder | null;
characterName: string;
items?: Item[];
onOpenChange: (open: boolean) => void;
}
type Step = "confirm" | "buying" | "bought" | "equipping" | "equipped";
export function BuyEquipDialog({
order,
characterName,
items,
onOpenChange,
}: BuyEquipDialogProps) {
const queryClient = useQueryClient();
const [step, setStep] = useState<Step>("confirm");
const [quantity, setQuantity] = useState("1");
const [equipSlot, setEquipSlot] = useState("");
const [error, setError] = useState<string | null>(null);
if (!order) return null;
const itemInfo = items?.find((i) => i.code === order.code);
const isEquippable = itemInfo
? Object.keys(TYPE_TO_SLOT).includes(itemInfo.type)
: false;
const maxQty = order.quantity;
const qty = Math.max(1, Math.min(parseInt(quantity, 10) || 1, maxQty));
const totalCost = qty * order.price;
// Auto-suggest slot based on item type
const suggestedSlot = itemInfo ? TYPE_TO_SLOT[itemInfo.type] ?? "" : "";
function handleClose() {
setStep("confirm");
setQuantity("1");
setEquipSlot("");
setError(null);
onOpenChange(false);
}
async function handleBuy() {
setStep("buying");
setError(null);
try {
await executeAction(characterName, "ge_buy", {
id: order!.id,
quantity: qty,
});
toast.success(`Bought ${qty}x ${order!.code}`);
queryClient.invalidateQueries({ queryKey: ["exchange"] });
queryClient.invalidateQueries({ queryKey: ["character", characterName] });
setStep("bought");
// Pre-select suggested slot
if (suggestedSlot && !equipSlot) {
setEquipSlot(suggestedSlot);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Buy failed");
setStep("confirm");
}
}
async function handleEquip() {
if (!equipSlot) {
toast.error("Select an equipment slot");
return;
}
setStep("equipping");
setError(null);
try {
await executeAction(characterName, "equip", {
code: order!.code,
slot: equipSlot,
});
toast.success(`Equipped ${order!.code} to ${equipSlot}`);
queryClient.invalidateQueries({ queryKey: ["character", characterName] });
setStep("equipped");
} catch (err) {
setError(err instanceof Error ? err.message : "Equip failed");
setStep("bought");
}
}
const equipSlots = EQUIPMENT_SLOTS.map((s) => ({
key: s.key.replace("_slot", ""),
label: s.label,
}));
return (
<Dialog open={!!order} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GameIcon type="item" code={order.code} size="md" showTooltip={false} />
{step === "equipped" ? "Equipped!" : step === "bought" ? "Purchased!" : `Buy ${order.code}`}
</DialogTitle>
<DialogDescription>
{step === "confirm" && "Place a buy order to match this sell listing."}
{step === "buying" && "Placing buy order..."}
{step === "bought" && (isEquippable
? "Item purchased! You can now equip it."
: "Item purchased successfully!")}
{step === "equipping" && "Equipping item..."}
{step === "equipped" && "Item equipped successfully!"}
</DialogDescription>
</DialogHeader>
{/* Item details */}
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Price each</span>
<span className="text-amber-400 font-medium tabular-nums">
{order.price.toLocaleString()} gold
</span>
</div>
{itemInfo && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Type</span>
<Badge variant="outline" className="text-xs">
{itemInfo.type}{itemInfo.subtype ? ` / ${itemInfo.subtype}` : ""}
</Badge>
</div>
)}
{itemInfo && itemInfo.level > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Required level</span>
<span className="tabular-nums">{itemInfo.level}</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Seller</span>
<span className="text-xs">{order.account ?? "Unknown"}</span>
</div>
{/* Confirm step: quantity selector */}
{step === "confirm" && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Quantity (max {maxQty})</Label>
<Input
type="number"
min="1"
max={maxQty}
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
className="h-9"
/>
</div>
<div className="flex items-center justify-between text-sm font-medium">
<span>Total cost</span>
<span className="text-amber-400 tabular-nums">
{totalCost.toLocaleString()} gold
</span>
</div>
</>
)}
{/* Bought step: equip slot selector */}
{step === "bought" && isEquippable && (
<div className="space-y-1.5 pt-2 border-t">
<Label className="text-xs">Equip to slot</Label>
<Select value={equipSlot} onValueChange={setEquipSlot}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select slot..." />
</SelectTrigger>
<SelectContent>
{equipSlots.map((slot) => (
<SelectItem key={slot.key} value={slot.key}>
{slot.label}
{slot.key === suggestedSlot && " (suggested)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Success states */}
{step === "equipped" && (
<div className="flex items-center gap-2 text-green-400 pt-2 border-t">
<CheckCircle2 className="size-4" />
<span className="text-sm">
{order.code} equipped to {equipSlots.find((s) => s.key === equipSlot)?.label ?? equipSlot}
</span>
</div>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
{step === "confirm" && (
<>
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
className="bg-green-600 hover:bg-green-700 text-white"
disabled={!characterName || qty < 1}
onClick={handleBuy}
>
<ShoppingCart className="size-4" />
Buy {qty}x for {totalCost.toLocaleString()}g
</Button>
</>
)}
{step === "buying" && (
<Button disabled>
<Loader2 className="size-4 animate-spin" />
Buying...
</Button>
)}
{step === "bought" && (
<>
<Button variant="outline" onClick={handleClose}>
Done
</Button>
{isEquippable && (
<Button
className="bg-blue-600 hover:bg-blue-700 text-white"
disabled={!equipSlot}
onClick={handleEquip}
>
<Shield className="size-4" />
Equip
</Button>
)}
</>
)}
{step === "equipping" && (
<Button disabled>
<Loader2 className="size-4 animate-spin" />
Equipping...
</Button>
)}
{step === "equipped" && (
<Button onClick={handleClose}>Done</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -11,6 +11,7 @@ import {
ArrowLeftRight,
Zap,
ScrollText,
AlertTriangle,
BarChart3,
Settings,
ChevronLeft,
@ -35,6 +36,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
ArrowLeftRight,
Zap,
ScrollText,
AlertTriangle,
BarChart3,
Settings,
};

View file

@ -0,0 +1,714 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import {
Plus,
Trash2,
ChevronUp,
ChevronDown,
Loader2,
Users,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { useCharacters } from "@/hooks/use-characters";
import { useCreatePipeline } from "@/hooks/use-pipelines";
import { PIPELINE_TEMPLATES } from "./pipeline-templates";
import { toast } from "sonner";
interface CharacterStepDraft {
id: string;
character_name: string;
strategy_type: string;
config: Record<string, unknown>;
transition: {
type: string;
operator?: string;
value?: number;
item_code?: string;
skill?: string;
seconds?: number;
} | null;
}
interface StageDraft {
id: string;
name: string;
character_steps: CharacterStepDraft[];
}
const STRATEGY_OPTIONS = [
{ value: "combat", label: "Combat" },
{ value: "gathering", label: "Gathering" },
{ value: "crafting", label: "Crafting" },
{ value: "trading", label: "Trading" },
{ value: "task", label: "Task" },
{ value: "leveling", label: "Leveling" },
];
const TRANSITION_OPTIONS = [
{ value: "strategy_complete", label: "Strategy Complete" },
{ value: "inventory_full", label: "Inventory Full" },
{ value: "inventory_item_count", label: "Inventory Item Count" },
{ value: "bank_item_count", label: "Bank Item Count" },
{ value: "skill_level", label: "Skill Level" },
{ value: "gold_amount", label: "Gold Amount" },
{ value: "actions_count", label: "Actions Count" },
{ value: "timer", label: "Timer (seconds)" },
];
function makeStageId(idx: number) {
return `stage_${idx + 1}`;
}
function makeStepId(stageIdx: number, stepIdx: number) {
return `cs_${stageIdx + 1}${String.fromCharCode(97 + stepIdx)}`;
}
export function PipelineBuilder() {
const router = useRouter();
const searchParams = useSearchParams();
const templateId = searchParams.get("template");
const { data: characters } = useCharacters();
const createMutation = useCreatePipeline();
// Initialize from template if provided
const template = templateId
? PIPELINE_TEMPLATES.find((t) => t.id === templateId)
: null;
const [name, setName] = useState(template?.name ?? "");
const [description, setDescription] = useState(template?.description ?? "");
const [loop, setLoop] = useState(template?.loop ?? false);
const [maxLoops, setMaxLoops] = useState(template?.max_loops ?? 0);
const [stages, setStages] = useState<StageDraft[]>(() => {
if (template) {
return template.stages.map((s, i) => ({
id: makeStageId(i),
name: s.name,
character_steps: s.character_steps.map((cs, j) => ({
id: makeStepId(i, j),
character_name: cs.role ?? "",
strategy_type: cs.strategy_type,
config: cs.config,
transition: cs.transition,
})),
}));
}
return [
{
id: "stage_1",
name: "Stage 1",
character_steps: [
{
id: "cs_1a",
character_name: "",
strategy_type: "gathering",
config: {},
transition: { type: "strategy_complete" },
},
],
},
];
});
function addStage() {
const idx = stages.length;
setStages([
...stages,
{
id: makeStageId(idx),
name: `Stage ${idx + 1}`,
character_steps: [
{
id: makeStepId(idx, 0),
character_name: "",
strategy_type: "gathering",
config: {},
transition: { type: "strategy_complete" },
},
],
},
]);
}
function removeStage(idx: number) {
setStages(stages.filter((_, i) => i !== idx));
}
function moveStage(idx: number, dir: -1 | 1) {
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= stages.length) return;
const copy = [...stages];
[copy[idx], copy[newIdx]] = [copy[newIdx], copy[idx]];
setStages(copy);
}
function updateStage(idx: number, patch: Partial<StageDraft>) {
setStages(stages.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
}
function addCharacterStep(stageIdx: number) {
const stage = stages[stageIdx];
const stepIdx = stage.character_steps.length;
updateStage(stageIdx, {
character_steps: [
...stage.character_steps,
{
id: makeStepId(stageIdx, stepIdx),
character_name: "",
strategy_type: "gathering",
config: {},
transition: { type: "strategy_complete" },
},
],
});
}
function removeCharacterStep(stageIdx: number, stepIdx: number) {
const stage = stages[stageIdx];
updateStage(stageIdx, {
character_steps: stage.character_steps.filter((_, i) => i !== stepIdx),
});
}
function updateCharacterStep(
stageIdx: number,
stepIdx: number,
patch: Partial<CharacterStepDraft>
) {
const stage = stages[stageIdx];
updateStage(stageIdx, {
character_steps: stage.character_steps.map((cs, i) =>
i === stepIdx ? { ...cs, ...patch } : cs
),
});
}
function handleSubmit() {
if (!name.trim()) {
toast.error("Pipeline name is required");
return;
}
const hasEmptyChar = stages.some((s) =>
s.character_steps.some((cs) => !cs.character_name)
);
if (hasEmptyChar) {
toast.error("All character steps must have a character assigned");
return;
}
createMutation.mutate(
{
name: name.trim(),
description: description.trim(),
stages: stages.map((s) => ({
id: s.id,
name: s.name,
character_steps: s.character_steps.map((cs) => ({
id: cs.id,
character_name: cs.character_name,
strategy_type: cs.strategy_type,
config: cs.config,
transition: cs.transition,
})),
})),
loop,
max_loops: maxLoops,
},
{
onSuccess: () => {
toast.success("Pipeline created");
router.push("/automations");
},
onError: (err) => {
toast.error(`Failed to create pipeline: ${err.message}`);
},
}
);
}
return (
<div className="space-y-6 max-w-4xl">
{/* Basic info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="size-5" />
Pipeline Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Pipeline"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this pipeline do?"
rows={1}
/>
</div>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Switch
id="loop"
checked={loop}
onCheckedChange={setLoop}
/>
<Label htmlFor="loop">Loop pipeline</Label>
</div>
{loop && (
<div className="flex items-center gap-2">
<Label htmlFor="maxLoops">Max loops (0 = unlimited)</Label>
<Input
id="maxLoops"
type="number"
min={0}
className="w-24"
value={maxLoops}
onChange={(e) => setMaxLoops(Number(e.target.value))}
/>
</div>
)}
</div>
</CardContent>
</Card>
{/* Stages */}
<div className="space-y-4">
{stages.map((stage, stageIdx) => (
<Card key={stage.id}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs">
Stage {stageIdx + 1}
</Badge>
<Input
value={stage.name}
onChange={(e) =>
updateStage(stageIdx, { name: e.target.value })
}
className="h-8 w-48"
placeholder="Stage name"
/>
</div>
<div className="flex items-center gap-1">
<Button
size="icon-xs"
variant="ghost"
onClick={() => moveStage(stageIdx, -1)}
disabled={stageIdx === 0}
>
<ChevronUp className="size-3.5" />
</Button>
<Button
size="icon-xs"
variant="ghost"
onClick={() => moveStage(stageIdx, 1)}
disabled={stageIdx === stages.length - 1}
>
<ChevronDown className="size-3.5" />
</Button>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() => removeStage(stageIdx)}
disabled={stages.length <= 1}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
Character steps run in parallel within this stage
</p>
{stage.character_steps.map((cs, stepIdx) => (
<div
key={cs.id}
className="border rounded-lg p-3 space-y-3 bg-muted/30"
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
Worker {stepIdx + 1}
</span>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() => removeCharacterStep(stageIdx, stepIdx)}
disabled={stage.character_steps.length <= 1}
>
<Trash2 className="size-3" />
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">Character</Label>
<Select
value={cs.character_name}
onValueChange={(v) =>
updateCharacterStep(stageIdx, stepIdx, {
character_name: v,
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select character" />
</SelectTrigger>
<SelectContent>
{(characters ?? []).map((c) => (
<SelectItem key={c.name} value={c.name}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Strategy</Label>
<Select
value={cs.strategy_type}
onValueChange={(v) =>
updateCharacterStep(stageIdx, stepIdx, {
strategy_type: v,
config: {},
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STRATEGY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Transition</Label>
<Select
value={cs.transition?.type ?? "strategy_complete"}
onValueChange={(v) =>
updateCharacterStep(stageIdx, stepIdx, {
transition: { type: v },
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRANSITION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Config fields based on strategy */}
<StrategyConfigFields
strategyType={cs.strategy_type}
config={cs.config}
onChange={(config) =>
updateCharacterStep(stageIdx, stepIdx, { config })
}
/>
{/* Transition value fields */}
<TransitionValueFields
transition={cs.transition}
onChange={(transition) =>
updateCharacterStep(stageIdx, stepIdx, { transition })
}
/>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() => addCharacterStep(stageIdx)}
>
<Plus className="size-3" />
Add Character Worker
</Button>
</CardContent>
</Card>
))}
</div>
<Button variant="outline" onClick={addStage}>
<Plus className="size-4" />
Add Stage
</Button>
{/* Submit */}
<div className="flex items-center gap-3 pt-4 border-t">
<Button onClick={handleSubmit} disabled={createMutation.isPending}>
{createMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
Create Pipeline
</Button>
<Button
variant="outline"
onClick={() => router.push("/automations")}
>
Cancel
</Button>
</div>
</div>
);
}
function StrategyConfigFields({
strategyType,
config,
onChange,
}: {
strategyType: string;
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
}) {
switch (strategyType) {
case "gathering":
return (
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Resource Code</Label>
<Input
className="h-8 text-xs"
value={(config.resource_code as string) ?? ""}
onChange={(e) =>
onChange({ ...config, resource_code: e.target.value })
}
placeholder="copper_rocks"
/>
</div>
<div className="flex items-center gap-2 pt-4">
<Switch
checked={(config.deposit_on_full as boolean) ?? true}
onCheckedChange={(v) =>
onChange({ ...config, deposit_on_full: v })
}
/>
<Label className="text-xs">Deposit on full</Label>
</div>
</div>
);
case "combat":
return (
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Monster Code</Label>
<Input
className="h-8 text-xs"
value={(config.monster_code as string) ?? ""}
onChange={(e) =>
onChange({ ...config, monster_code: e.target.value })
}
placeholder="chicken"
/>
</div>
<div className="flex items-center gap-2 pt-4">
<Switch
checked={(config.deposit_loot as boolean) ?? true}
onCheckedChange={(v) =>
onChange({ ...config, deposit_loot: v })
}
/>
<Label className="text-xs">Deposit loot</Label>
</div>
</div>
);
case "crafting":
return (
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Item Code</Label>
<Input
className="h-8 text-xs"
value={(config.item_code as string) ?? ""}
onChange={(e) =>
onChange({ ...config, item_code: e.target.value })
}
placeholder="copper_dagger"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Quantity</Label>
<Input
className="h-8 text-xs"
type="number"
min={1}
value={(config.quantity as number) ?? 10}
onChange={(e) =>
onChange({ ...config, quantity: Number(e.target.value) })
}
/>
</div>
</div>
);
case "trading":
return (
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Mode</Label>
<Select
value={(config.mode as string) ?? "sell_loot"}
onValueChange={(v) => onChange({ ...config, mode: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sell_loot">Sell Loot</SelectItem>
<SelectItem value="buy_materials">Buy Materials</SelectItem>
<SelectItem value="flip">Flip</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Item Code</Label>
<Input
className="h-8 text-xs"
value={(config.item_code as string) ?? ""}
onChange={(e) =>
onChange({ ...config, item_code: e.target.value })
}
/>
</div>
</div>
);
default:
return null;
}
}
function TransitionValueFields({
transition,
onChange,
}: {
transition: CharacterStepDraft["transition"];
onChange: (t: CharacterStepDraft["transition"]) => void;
}) {
if (!transition) return null;
const type = transition.type;
if (
type === "strategy_complete" ||
type === "inventory_full" ||
type === "loops_completed"
)
return null;
return (
<div className="grid gap-2 sm:grid-cols-3">
{(type === "inventory_item_count" || type === "bank_item_count") && (
<div className="space-y-1">
<Label className="text-xs">Item Code</Label>
<Input
className="h-8 text-xs"
value={transition.item_code ?? ""}
onChange={(e) =>
onChange({ ...transition, item_code: e.target.value })
}
placeholder="copper_ore"
/>
</div>
)}
{type === "skill_level" && (
<div className="space-y-1">
<Label className="text-xs">Skill</Label>
<Input
className="h-8 text-xs"
value={transition.skill ?? ""}
onChange={(e) =>
onChange({ ...transition, skill: e.target.value })
}
placeholder="mining"
/>
</div>
)}
{type === "timer" ? (
<div className="space-y-1">
<Label className="text-xs">Seconds</Label>
<Input
className="h-8 text-xs"
type="number"
min={1}
value={transition.seconds ?? 60}
onChange={(e) =>
onChange({ ...transition, seconds: Number(e.target.value) })
}
/>
</div>
) : (
<>
<div className="space-y-1">
<Label className="text-xs">Operator</Label>
<Select
value={transition.operator ?? ">="}
onValueChange={(v) => onChange({ ...transition, operator: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</SelectItem>
<SelectItem value="==">=</SelectItem>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Value</Label>
<Input
className="h-8 text-xs"
type="number"
min={0}
value={transition.value ?? 0}
onChange={(e) =>
onChange({ ...transition, value: Number(e.target.value) })
}
/>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,352 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Plus,
Trash2,
Loader2,
Bot,
Play,
Square,
Pause,
Repeat,
Users,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
usePipelines,
usePipelineStatuses,
useDeletePipeline,
useControlPipeline,
} from "@/hooks/use-pipelines";
import { toast } from "sonner";
const STATUS_CONFIG: Record<
string,
{
label: string;
variant: "default" | "secondary" | "destructive" | "outline";
className: string;
}
> = {
running: {
label: "Running",
variant: "default",
className: "bg-green-600 hover:bg-green-600 text-white",
},
paused: {
label: "Paused",
variant: "default",
className: "bg-yellow-600 hover:bg-yellow-600 text-white",
},
stopped: { label: "Stopped", variant: "secondary", className: "" },
completed: {
label: "Completed",
variant: "default",
className: "bg-blue-600 hover:bg-blue-600 text-white",
},
error: { label: "Error", variant: "destructive", className: "" },
};
function getUniqueCharacters(stages: { character_steps?: { character_name: string }[] }[]): string[] {
const names = new Set<string>();
for (const stage of stages) {
for (const cs of stage.character_steps ?? []) {
names.add(cs.character_name);
}
}
return Array.from(names).sort();
}
export function PipelineList() {
const router = useRouter();
const { data: pipelines, isLoading, error } = usePipelines();
const { data: statuses } = usePipelineStatuses();
const deleteMutation = useDeletePipeline();
const control = useControlPipeline();
const [deleteTarget, setDeleteTarget] = useState<{
id: number;
name: string;
} | null>(null);
const statusMap = new Map(
(statuses ?? []).map((s) => [s.pipeline_id, s])
);
function handleDelete() {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.id, {
onSuccess: () => {
toast.success(`Pipeline "${deleteTarget.name}" deleted`);
setDeleteTarget(null);
},
onError: (err) => {
toast.error(`Failed to delete: ${err.message}`);
},
});
}
function handleControl(
id: number,
action: "start" | "stop" | "pause" | "resume"
) {
control.mutate(
{ id, action },
{
onSuccess: () => toast.success(`Pipeline ${action}ed`),
onError: (err) =>
toast.error(`Failed to ${action} pipeline: ${err.message}`),
}
);
}
if (error) {
return (
<Card className="border-destructive bg-destructive/10 p-4">
<p className="text-sm text-destructive">
Failed to load pipelines. Make sure the backend is running.
</p>
</Card>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!pipelines || pipelines.length === 0) {
return (
<Card className="p-8 text-center">
<Users className="size-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-4">
No pipelines configured yet. Create a multi-character pipeline to
coordinate characters across stages.
</p>
<Button onClick={() => router.push("/automations/pipelines/new")}>
<Plus className="size-4" />
Create Pipeline
</Button>
</Card>
);
}
return (
<>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Characters</TableHead>
<TableHead>Stages</TableHead>
<TableHead>Status</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Controls</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{pipelines.map((pl) => {
const status = statusMap.get(pl.id);
const currentStatus = status?.status ?? "stopped";
const statusCfg =
STATUS_CONFIG[currentStatus] ?? STATUS_CONFIG.stopped;
const isStopped =
currentStatus === "stopped" ||
currentStatus === "completed" ||
currentStatus === "error" ||
!currentStatus;
const isRunning = currentStatus === "running";
const isPaused = currentStatus === "paused";
const characters = getUniqueCharacters(pl.stages);
return (
<TableRow
key={pl.id}
className="cursor-pointer"
onClick={() =>
router.push(`/automations/pipelines/${pl.id}`)
}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{pl.name}
{pl.loop && (
<Repeat className="size-3 text-muted-foreground" />
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{characters.map((c) => (
<Badge key={c} variant="outline" className="text-[10px]">
{c}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{pl.stages.length} stage{pl.stages.length !== 1 && "s"}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={statusCfg.variant}
className={statusCfg.className}
>
{statusCfg.label}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{status ? (
<span>
Stage {status.current_stage_index + 1}/
{status.total_stages}
{status.loop_count > 0 && (
<> &middot; Loop {status.loop_count}</>
)}
{" "}&middot; {status.total_actions_count} actions
</span>
) : (
"\u2014"
)}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1.5">
{isStopped && (
<Button
size="xs"
variant="outline"
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
onClick={() => handleControl(pl.id, "start")}
disabled={control.isPending}
>
<Play className="size-3" />
</Button>
)}
{isRunning && (
<>
<Button
size="xs"
variant="outline"
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-500/10"
onClick={() => handleControl(pl.id, "pause")}
disabled={control.isPending}
>
<Pause className="size-3" />
</Button>
<Button
size="xs"
variant="outline"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleControl(pl.id, "stop")}
disabled={control.isPending}
>
<Square className="size-3" />
</Button>
</>
)}
{isPaused && (
<>
<Button
size="xs"
variant="outline"
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
onClick={() => handleControl(pl.id, "resume")}
disabled={control.isPending}
>
<Play className="size-3" />
</Button>
<Button
size="xs"
variant="outline"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleControl(pl.id, "stop")}
disabled={control.isPending}
>
<Square className="size-3" />
</Button>
</>
)}
</div>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() =>
setDeleteTarget({ id: pl.id, name: pl.name })
}
>
<Trash2 className="size-3.5" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
<Dialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Pipeline</DialogTitle>
<DialogDescription>
Are you sure you want to delete &ldquo;{deleteTarget?.name}
&rdquo;? This action cannot be undone. Any running pipeline will be
stopped.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteTarget(null)}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,239 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CheckCircle2, Circle, AlertCircle, Loader2, Play, Square, Pause } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { PipelineConfig, PipelineStatus } from "@/lib/types";
import { useControlPipeline } from "@/hooks/use-pipelines";
import { toast } from "sonner";
interface PipelineProgressProps {
config: PipelineConfig;
status: PipelineStatus | null;
}
const CHAR_STATUS_ICONS: Record<string, React.ReactNode> = {
running: <Loader2 className="size-3 animate-spin text-green-400" />,
completed: <CheckCircle2 className="size-3 text-blue-400" />,
error: <AlertCircle className="size-3 text-red-400" />,
idle: <Circle className="size-3 text-muted-foreground" />,
};
export function PipelineProgress({ config, status }: PipelineProgressProps) {
const control = useControlPipeline();
const currentStatus = status?.status ?? "stopped";
const isStopped =
currentStatus === "stopped" ||
currentStatus === "completed" ||
currentStatus === "error" ||
!currentStatus;
const isRunning = currentStatus === "running";
const isPaused = currentStatus === "paused";
function handleControl(action: "start" | "stop" | "pause" | "resume") {
control.mutate(
{ id: config.id, action },
{
onSuccess: () => toast.success(`Pipeline ${action}ed`),
onError: (err) =>
toast.error(`Failed to ${action}: ${err.message}`),
}
);
}
// Build a map of character states from the status
const charStateMap = new Map(
(status?.character_states ?? []).map((cs) => [cs.character_name, cs])
);
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Live Progress</CardTitle>
<div className="flex items-center gap-2">
{status && (
<span className="text-xs text-muted-foreground">
{status.total_actions_count} total actions
{status.loop_count > 0 && ` \u00b7 Loop ${status.loop_count}`}
</span>
)}
<div className="flex items-center gap-1.5">
{isStopped && (
<Button
size="xs"
variant="outline"
className="text-green-400"
onClick={() => handleControl("start")}
disabled={control.isPending}
>
<Play className="size-3" />
Start
</Button>
)}
{isRunning && (
<>
<Button
size="xs"
variant="outline"
className="text-yellow-400"
onClick={() => handleControl("pause")}
disabled={control.isPending}
>
<Pause className="size-3" />
</Button>
<Button
size="xs"
variant="outline"
className="text-red-400"
onClick={() => handleControl("stop")}
disabled={control.isPending}
>
<Square className="size-3" />
</Button>
</>
)}
{isPaused && (
<>
<Button
size="xs"
variant="outline"
className="text-green-400"
onClick={() => handleControl("resume")}
disabled={control.isPending}
>
<Play className="size-3" />
</Button>
<Button
size="xs"
variant="outline"
className="text-red-400"
onClick={() => handleControl("stop")}
disabled={control.isPending}
>
<Square className="size-3" />
</Button>
</>
)}
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="relative space-y-0">
{config.stages.map((stage, idx) => {
const isActive =
status != null && status.current_stage_index === idx && isRunning;
const isCompleted =
status != null && status.current_stage_index > idx;
const isPastOrActive = isActive || isCompleted;
return (
<div key={stage.id} className="relative flex gap-4">
{/* Timeline line */}
<div className="flex flex-col items-center">
<div
className={`size-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
isActive
? "border-green-500 bg-green-500/20 text-green-400"
: isCompleted
? "border-blue-500 bg-blue-500/20 text-blue-400"
: "border-muted-foreground/30 text-muted-foreground"
}`}
>
{isCompleted ? (
<CheckCircle2 className="size-3.5" />
) : isActive ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
idx + 1
)}
</div>
{idx < config.stages.length - 1 && (
<div
className={`w-0.5 flex-1 min-h-4 ${
isPastOrActive
? "bg-blue-500/50"
: "bg-muted-foreground/20"
}`}
/>
)}
</div>
{/* Stage content */}
<div className="flex-1 pb-4">
<div className="flex items-center gap-2 mb-1.5">
<span
className={`text-sm font-medium ${
isActive
? "text-green-400"
: isCompleted
? "text-blue-400"
: "text-muted-foreground"
}`}
>
{stage.name}
</span>
{isActive && (
<Badge className="bg-green-600 text-white text-[10px] px-1.5">
Active
</Badge>
)}
{isCompleted && (
<Badge className="bg-blue-600 text-white text-[10px] px-1.5">
Done
</Badge>
)}
</div>
{/* Character workers */}
<div className="space-y-1">
{stage.character_steps.map((cs) => {
const charState = charStateMap.get(cs.character_name);
const charStatus = charState?.status ?? "idle";
return (
<div
key={cs.id}
className="flex items-center gap-2 text-xs"
>
{isActive
? CHAR_STATUS_ICONS[charStatus] ??
CHAR_STATUS_ICONS.idle
: <Circle className="size-3 text-muted-foreground/40" />}
<span
className={
isActive
? "text-foreground"
: "text-muted-foreground"
}
>
{cs.character_name || "Unassigned"}
</span>
<span className="text-muted-foreground capitalize">
{cs.strategy_type}
</span>
{isActive && charState && (
<span className="text-muted-foreground">
{charState.actions_count} actions
</span>
)}
{charState?.error && (
<span className="text-red-400 truncate max-w-48">
{charState.error}
</span>
)}
</div>
);
})}
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,131 @@
"use client";
import { useRouter } from "next/navigation";
import {
Network,
Repeat,
Swords,
Pickaxe,
Hammer,
TrendingUp,
Users,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
PIPELINE_TEMPLATES,
PIPELINE_CATEGORY_LABELS,
PIPELINE_CATEGORY_COLORS,
} from "./pipeline-templates";
import { cn } from "@/lib/utils";
const STRATEGY_ICONS: Record<string, React.ElementType> = {
combat: Swords,
gathering: Pickaxe,
crafting: Hammer,
trading: TrendingUp,
};
export function PipelineTemplateGallery() {
const router = useRouter();
return (
<div className="space-y-4">
<Separator />
<div className="flex items-center gap-2">
<Network className="size-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">
Pipeline Templates
</h2>
<span className="text-sm text-muted-foreground">
Multi-character collaboration
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{PIPELINE_TEMPLATES.map((template) => (
<Card
key={template.id}
className="cursor-pointer transition-all hover:shadow-md group py-0 overflow-hidden hover:border-primary/30"
onClick={() =>
router.push(
`/automations/pipelines/new?template=${template.id}`
)
}
>
<div className="h-1 bg-primary/40" />
<CardContent className="p-4 space-y-3">
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Network className="size-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm text-foreground group-hover:text-primary transition-colors">
{template.name}
</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{template.description}
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
<Badge
variant="outline"
className={cn(
"text-[10px] capitalize",
PIPELINE_CATEGORY_COLORS[template.category]
)}
>
{PIPELINE_CATEGORY_LABELS[template.category] ??
template.category}
</Badge>
<Badge variant="outline" className="text-[10px]">
{template.stages.length} stage
{template.stages.length !== 1 && "s"}
</Badge>
<Badge variant="outline" className="text-[10px] gap-0.5">
<Users className="size-2.5" />
{template.roles.length} roles
</Badge>
{template.loop && (
<Badge
variant="outline"
className="text-[10px] gap-0.5"
>
<Repeat className="size-2.5" />
Loop
</Badge>
)}
</div>
{/* Stage mini-preview */}
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
{template.stages.map((stage, idx) => {
const strategyTypes = [
...new Set(stage.character_steps.map((cs) => cs.strategy_type)),
];
return (
<span key={stage.id} className="flex items-center gap-0.5">
{idx > 0 && <span className="mx-0.5">&rarr;</span>}
{strategyTypes.map((st) => {
const Icon = STRATEGY_ICONS[st] ?? Network;
return <Icon key={st} className="size-3" />;
})}
{stage.character_steps.length > 1 && (
<span className="text-[9px]">
x{stage.character_steps.length}
</span>
)}
</span>
);
})}
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,346 @@
export interface PipelineTemplate {
id: string;
name: string;
description: string;
category: "production" | "combat" | "economy" | "leveling";
roles: string[];
stages: {
id: string;
name: string;
character_steps: {
id: string;
role: string;
strategy_type: string;
config: Record<string, unknown>;
transition: {
type: string;
operator?: string;
value?: number;
item_code?: string;
skill?: string;
seconds?: number;
} | null;
}[];
}[];
loop: boolean;
max_loops: number;
}
export const PIPELINE_TEMPLATES: PipelineTemplate[] = [
{
id: "resource_pipeline",
name: "Resource Pipeline",
description:
"Gatherer collects resources and banks them, Crafter withdraws and crafts, Seller lists on GE. 3 characters, 3 stages.",
category: "production",
roles: ["Gatherer", "Crafter", "Seller"],
stages: [
{
id: "stage_1",
name: "Gather Resources",
character_steps: [
{
id: "cs_1a",
role: "Gatherer",
strategy_type: "gathering",
config: { resource_code: "copper_rocks", deposit_on_full: true },
transition: {
type: "bank_item_count",
item_code: "copper_ore",
operator: ">=",
value: 100,
},
},
],
},
{
id: "stage_2",
name: "Craft Items",
character_steps: [
{
id: "cs_2a",
role: "Crafter",
strategy_type: "crafting",
config: {
item_code: "copper_dagger",
quantity: 50,
gather_materials: false,
},
transition: { type: "strategy_complete" },
},
],
},
{
id: "stage_3",
name: "Sell on GE",
character_steps: [
{
id: "cs_3a",
role: "Seller",
strategy_type: "trading",
config: {
mode: "sell_loot",
item_code: "copper_dagger",
quantity: 50,
min_price: 1,
},
transition: { type: "strategy_complete" },
},
],
},
],
loop: true,
max_loops: 0,
},
{
id: "dual_gatherer_pipeline",
name: "Dual Gatherer Pipeline",
description:
"Two miners gather copper in parallel, then one crafter turns it all into daggers. Double the gathering speed.",
category: "production",
roles: ["Miner1", "Miner2", "Crafter"],
stages: [
{
id: "stage_1",
name: "Parallel Gathering",
character_steps: [
{
id: "cs_1a",
role: "Miner1",
strategy_type: "gathering",
config: { resource_code: "copper_rocks", deposit_on_full: true },
transition: {
type: "bank_item_count",
item_code: "copper_ore",
operator: ">=",
value: 200,
},
},
{
id: "cs_1b",
role: "Miner2",
strategy_type: "gathering",
config: { resource_code: "copper_rocks", deposit_on_full: true },
transition: {
type: "bank_item_count",
item_code: "copper_ore",
operator: ">=",
value: 200,
},
},
],
},
{
id: "stage_2",
name: "Craft Items",
character_steps: [
{
id: "cs_2a",
role: "Crafter",
strategy_type: "crafting",
config: {
item_code: "copper_dagger",
quantity: 100,
gather_materials: false,
},
transition: { type: "strategy_complete" },
},
],
},
],
loop: true,
max_loops: 0,
},
{
id: "combat_supply_chain",
name: "Combat Supply Chain",
description:
"Fisher catches and cooks food, then Fighter uses it for sustained combat with consumable healing.",
category: "combat",
roles: ["Chef", "Fighter"],
stages: [
{
id: "stage_1",
name: "Prepare Food",
character_steps: [
{
id: "cs_1a",
role: "Chef",
strategy_type: "gathering",
config: { resource_code: "gudgeon_fishing_spot", deposit_on_full: true },
transition: { type: "actions_count", operator: ">=", value: 40 },
},
],
},
{
id: "stage_2",
name: "Cook Food",
character_steps: [
{
id: "cs_1a",
role: "Chef",
strategy_type: "crafting",
config: {
item_code: "cooked_gudgeon",
quantity: 30,
gather_materials: false,
},
transition: { type: "strategy_complete" },
},
],
},
{
id: "stage_3",
name: "Fight with Food",
character_steps: [
{
id: "cs_3a",
role: "Fighter",
strategy_type: "combat",
config: {
monster_code: "yellow_slime",
deposit_loot: true,
auto_heal_threshold: 50,
heal_method: "consumable",
consumable_code: "cooked_gudgeon",
},
transition: { type: "actions_count", operator: ">=", value: 80 },
},
],
},
],
loop: true,
max_loops: 0,
},
{
id: "full_economy",
name: "Full Economy",
description:
"2 Gatherers mine in parallel, Crafter crafts items, Seller sells on GE. Complete 4-character production line.",
category: "economy",
roles: ["Miner1", "Miner2", "Crafter", "Seller"],
stages: [
{
id: "stage_1",
name: "Gather Resources",
character_steps: [
{
id: "cs_1a",
role: "Miner1",
strategy_type: "gathering",
config: { resource_code: "copper_rocks", deposit_on_full: true },
transition: {
type: "bank_item_count",
item_code: "copper_ore",
operator: ">=",
value: 200,
},
},
{
id: "cs_1b",
role: "Miner2",
strategy_type: "gathering",
config: { resource_code: "copper_rocks", deposit_on_full: true },
transition: {
type: "bank_item_count",
item_code: "copper_ore",
operator: ">=",
value: 200,
},
},
],
},
{
id: "stage_2",
name: "Craft Items",
character_steps: [
{
id: "cs_2a",
role: "Crafter",
strategy_type: "crafting",
config: {
item_code: "copper_dagger",
quantity: 100,
gather_materials: false,
},
transition: { type: "strategy_complete" },
},
],
},
{
id: "stage_3",
name: "Sell on GE",
character_steps: [
{
id: "cs_3a",
role: "Seller",
strategy_type: "trading",
config: {
mode: "sell_loot",
item_code: "copper_dagger",
quantity: 100,
min_price: 1,
},
transition: { type: "strategy_complete" },
},
],
},
],
loop: true,
max_loops: 0,
},
{
id: "power_leveling_duo",
name: "Power Leveling Duo",
description:
"Two characters fight different monsters in parallel for maximum XP gain. Both deposit loot at end.",
category: "leveling",
roles: ["Fighter1", "Fighter2"],
stages: [
{
id: "stage_1",
name: "Parallel Combat",
character_steps: [
{
id: "cs_1a",
role: "Fighter1",
strategy_type: "combat",
config: {
monster_code: "chicken",
deposit_loot: true,
auto_heal_threshold: 40,
},
transition: { type: "actions_count", operator: ">=", value: 100 },
},
{
id: "cs_1b",
role: "Fighter2",
strategy_type: "combat",
config: {
monster_code: "yellow_slime",
deposit_loot: true,
auto_heal_threshold: 50,
},
transition: { type: "actions_count", operator: ">=", value: 100 },
},
],
},
],
loop: true,
max_loops: 0,
},
];
export const PIPELINE_CATEGORY_LABELS: Record<string, string> = {
production: "Production",
combat: "Combat",
economy: "Economy",
leveling: "Leveling",
};
export const PIPELINE_CATEGORY_COLORS: Record<string, string> = {
production: "text-blue-400",
combat: "text-red-400",
economy: "text-yellow-400",
leveling: "text-cyan-400",
};

View file

@ -0,0 +1,188 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { TransitionCondition } from "@/lib/types";
const TRANSITION_TYPES = [
{ value: "strategy_complete", label: "Strategy Completes" },
{ value: "actions_count", label: "Actions Count" },
{ value: "timer", label: "Timer (seconds)" },
{ value: "inventory_full", label: "Inventory Full" },
{ value: "inventory_item_count", label: "Inventory Item Count" },
{ value: "bank_item_count", label: "Bank Item Count" },
{ value: "skill_level", label: "Skill Level" },
{ value: "gold_amount", label: "Gold Amount" },
] as const;
const OPERATORS = [
{ value: ">=", label: ">=" },
{ value: "<=", label: "<=" },
{ value: "==", label: "==" },
{ value: ">", label: ">" },
{ value: "<", label: "<" },
] as const;
const SKILLS = [
"mining",
"woodcutting",
"fishing",
"weaponcrafting",
"gearcrafting",
"jewelrycrafting",
"cooking",
"alchemy",
] as const;
interface TransitionEditorProps {
value: TransitionCondition | null;
onChange: (condition: TransitionCondition | null) => void;
}
export function TransitionEditor({ value, onChange }: TransitionEditorProps) {
const condType = value?.type ?? "";
const needsValue =
condType === "actions_count" ||
condType === "inventory_item_count" ||
condType === "bank_item_count" ||
condType === "skill_level" ||
condType === "gold_amount";
const needsItemCode =
condType === "inventory_item_count" || condType === "bank_item_count";
const needsSkill = condType === "skill_level";
const needsSeconds = condType === "timer";
function update(partial: Partial<TransitionCondition>) {
const base: TransitionCondition = value ?? {
type: "strategy_complete",
operator: ">=",
value: 0,
item_code: "",
skill: "",
seconds: 0,
};
onChange({ ...base, ...partial });
}
return (
<div className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground">
Transition Condition
</Label>
<Select
value={condType || "none"}
onValueChange={(v) => {
if (v === "none") {
onChange(null);
} else {
update({
type: v as TransitionCondition["type"],
});
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="No transition (run until complete)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
No transition (run until strategy completes)
</SelectItem>
{TRANSITION_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{needsValue && (
<div className="flex gap-2">
<div className="w-20">
<Label className="text-xs text-muted-foreground">Operator</Label>
<Select
value={value?.operator ?? ">="}
onValueChange={(v) =>
update({ operator: v as TransitionCondition["operator"] })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Label className="text-xs text-muted-foreground">Value</Label>
<Input
type="number"
className="h-8 text-xs"
value={value?.value ?? 0}
onChange={(e) => update({ value: Number(e.target.value) })}
/>
</div>
</div>
)}
{needsItemCode && (
<div>
<Label className="text-xs text-muted-foreground">Item Code</Label>
<Input
className="h-8 text-xs"
placeholder="e.g. copper_ore"
value={value?.item_code ?? ""}
onChange={(e) => update({ item_code: e.target.value })}
/>
</div>
)}
{needsSkill && (
<div>
<Label className="text-xs text-muted-foreground">Skill</Label>
<Select
value={value?.skill ?? "mining"}
onValueChange={(v) => update({ skill: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SKILLS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{needsSeconds && (
<div>
<Label className="text-xs text-muted-foreground">Seconds</Label>
<Input
type="number"
className="h-8 text-xs"
value={value?.seconds ?? 0}
onChange={(e) => update({ seconds: Number(e.target.value) })}
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,311 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, Loader2, ArrowLeft, Repeat } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { WorkflowStepCard } from "@/components/workflow/workflow-step-card";
import { useCharacters } from "@/hooks/use-characters";
import { useCreateWorkflow } from "@/hooks/use-workflows";
import type { WorkflowStep } from "@/lib/types";
import { toast } from "sonner";
function generateId(): string {
return `step_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
}
function makeEmptyStep(): WorkflowStep {
return {
id: generateId(),
name: "",
strategy_type: "gathering",
config: { resource_code: "", deposit_on_full: true, max_loops: 0 },
transition: null,
};
}
interface WorkflowBuilderProps {
/** If provided, pre-populates the builder (e.g. from a template). */
initialSteps?: WorkflowStep[];
initialName?: string;
initialDescription?: string;
initialLoop?: boolean;
initialMaxLoops?: number;
}
export function WorkflowBuilder({
initialSteps,
initialName = "",
initialDescription = "",
initialLoop = false,
initialMaxLoops = 0,
}: WorkflowBuilderProps) {
const router = useRouter();
const { data: characters, isLoading: loadingCharacters } = useCharacters();
const createMutation = useCreateWorkflow();
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [characterName, setCharacterName] = useState("");
const [loop, setLoop] = useState(initialLoop);
const [maxLoops, setMaxLoops] = useState(initialMaxLoops);
const [steps, setSteps] = useState<WorkflowStep[]>(
initialSteps ?? [makeEmptyStep()]
);
function updateStep(index: number, updated: WorkflowStep) {
setSteps((prev) => prev.map((s, i) => (i === index ? updated : s)));
}
function addStep() {
setSteps((prev) => [...prev, makeEmptyStep()]);
}
function removeStep(index: number) {
setSteps((prev) => prev.filter((_, i) => i !== index));
}
function moveStep(from: number, to: number) {
setSteps((prev) => {
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
}
function handleCreate() {
if (!name.trim()) {
toast.error("Please enter a workflow name");
return;
}
if (!characterName) {
toast.error("Please select a character");
return;
}
if (steps.length === 0) {
toast.error("Add at least one step");
return;
}
const hasEmptyStrategy = steps.some((s) => !s.strategy_type);
if (hasEmptyStrategy) {
toast.error("All steps must have a strategy selected");
return;
}
createMutation.mutate(
{
name: name.trim(),
character_name: characterName,
description: description.trim() || undefined,
steps: steps.map((s) => ({
id: s.id,
name: s.name || `Step ${steps.indexOf(s) + 1}`,
strategy_type: s.strategy_type,
config: s.config,
transition: s.transition,
})),
loop,
max_loops: maxLoops,
},
{
onSuccess: () => {
toast.success("Workflow created successfully");
router.push("/automations");
},
onError: (err) => {
toast.error(`Failed to create workflow: ${err.message}`);
},
}
);
}
return (
<div className="space-y-6 max-w-3xl">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
onClick={() => router.push("/automations")}
>
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
New Workflow
</h1>
<p className="text-sm text-muted-foreground mt-1">
Chain multiple strategies into a multi-step pipeline
</p>
</div>
</div>
{/* Basic info */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-bold">
1
</span>
Basic Info
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="wf-name">Workflow Name</Label>
<Input
id="wf-name"
placeholder="e.g. Copper Pipeline, Mining Progression"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="wf-desc">Description (optional)</Label>
<Textarea
id="wf-desc"
rows={2}
placeholder="Brief description of what this workflow does..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Character</Label>
<Select value={characterName} onValueChange={setCharacterName}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a character" />
</SelectTrigger>
<SelectContent>
{loadingCharacters && (
<SelectItem value="_loading" disabled>
Loading characters...
</SelectItem>
)}
{characters?.map((char) => (
<SelectItem key={char.name} value={char.name}>
{char.name} (Lv. {char.level})
</SelectItem>
))}
{characters?.length === 0 && (
<SelectItem value="_empty" disabled>
No characters found
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-2">
<Repeat className="size-4 text-muted-foreground" />
<div className="space-y-0.5">
<Label>Loop Workflow</Label>
<p className="text-xs text-muted-foreground">
Restart from step 1 after the last step completes
</p>
</div>
</div>
<Switch checked={loop} onCheckedChange={setLoop} />
</div>
{loop && (
<div className="space-y-2">
<Label htmlFor="max-loops">Max Loops</Label>
<Input
id="max-loops"
type="number"
min={0}
placeholder="0 = infinite"
value={maxLoops}
onChange={(e) => setMaxLoops(parseInt(e.target.value, 10) || 0)}
/>
<p className="text-xs text-muted-foreground">
Max number of loop iterations. 0 = run forever.
</p>
</div>
)}
</CardContent>
</Card>
{/* Steps */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-bold">
2
</span>
Pipeline Steps
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{steps.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No steps yet. Add your first step below.
</p>
)}
{steps.map((step, idx) => (
<WorkflowStepCard
key={step.id}
step={step}
index={idx}
totalSteps={steps.length}
onChange={(updated) => updateStep(idx, updated)}
onMoveUp={() => moveStep(idx, idx - 1)}
onMoveDown={() => moveStep(idx, idx + 1)}
onDelete={() => removeStep(idx)}
/>
))}
<Button
variant="outline"
className="w-full"
onClick={addStep}
>
<Plus className="size-4" />
Add Step
</Button>
</CardContent>
</Card>
{/* Create button */}
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
onClick={() => router.push("/automations")}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={
!name.trim() ||
!characterName ||
steps.length === 0 ||
createMutation.isPending
}
>
{createMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
Create Workflow
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,334 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Plus,
Trash2,
Loader2,
Bot,
Play,
Square,
Pause,
Repeat,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
useWorkflows,
useWorkflowStatuses,
useDeleteWorkflow,
useControlWorkflow,
} from "@/hooks/use-workflows";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
const STATUS_CONFIG: Record<
string,
{
label: string;
variant: "default" | "secondary" | "destructive" | "outline";
className: string;
}
> = {
running: {
label: "Running",
variant: "default",
className: "bg-green-600 hover:bg-green-600 text-white",
},
paused: {
label: "Paused",
variant: "default",
className: "bg-yellow-600 hover:bg-yellow-600 text-white",
},
stopped: { label: "Stopped", variant: "secondary", className: "" },
completed: {
label: "Completed",
variant: "default",
className: "bg-blue-600 hover:bg-blue-600 text-white",
},
error: { label: "Error", variant: "destructive", className: "" },
};
export function WorkflowList() {
const router = useRouter();
const { data: workflows, isLoading, error } = useWorkflows();
const { data: statuses } = useWorkflowStatuses();
const deleteMutation = useDeleteWorkflow();
const control = useControlWorkflow();
const [deleteTarget, setDeleteTarget] = useState<{
id: number;
name: string;
} | null>(null);
const statusMap = new Map(
(statuses ?? []).map((s) => [s.workflow_id, s])
);
function handleDelete() {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.id, {
onSuccess: () => {
toast.success(`Workflow "${deleteTarget.name}" deleted`);
setDeleteTarget(null);
},
onError: (err) => {
toast.error(`Failed to delete: ${err.message}`);
},
});
}
function handleControl(
id: number,
action: "start" | "stop" | "pause" | "resume"
) {
control.mutate(
{ id, action },
{
onSuccess: () => toast.success(`Workflow ${action}ed`),
onError: (err) =>
toast.error(`Failed to ${action} workflow: ${err.message}`),
}
);
}
if (error) {
return (
<Card className="border-destructive bg-destructive/10 p-4">
<p className="text-sm text-destructive">
Failed to load workflows. Make sure the backend is running.
</p>
</Card>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!workflows || workflows.length === 0) {
return (
<Card className="p-8 text-center">
<Bot className="size-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-4">
No workflows configured yet. Create a multi-step pipeline to chain
strategies together.
</p>
<Button onClick={() => router.push("/automations/workflows/new")}>
<Plus className="size-4" />
Create Workflow
</Button>
</Card>
);
}
return (
<>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Character</TableHead>
<TableHead>Steps</TableHead>
<TableHead>Status</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Controls</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{workflows.map((wf) => {
const status = statusMap.get(wf.id);
const currentStatus = status?.status ?? "stopped";
const statusCfg =
STATUS_CONFIG[currentStatus] ?? STATUS_CONFIG.stopped;
const isStopped =
currentStatus === "stopped" ||
currentStatus === "completed" ||
currentStatus === "error" ||
!currentStatus;
const isRunning = currentStatus === "running";
const isPaused = currentStatus === "paused";
return (
<TableRow
key={wf.id}
className="cursor-pointer"
onClick={() =>
router.push(`/automations/workflows/${wf.id}`)
}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{wf.name}
{wf.loop && (
<Repeat className="size-3 text-muted-foreground" />
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{wf.character_name}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{wf.steps.length} step{wf.steps.length !== 1 && "s"}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={statusCfg.variant}
className={statusCfg.className}
>
{statusCfg.label}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{status ? (
<span>
Step {status.current_step_index + 1}/{status.total_steps}
{status.loop_count > 0 && (
<> &middot; Loop {status.loop_count}</>
)}
{" "}&middot; {status.total_actions_count} actions
</span>
) : (
"—"
)}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1.5">
{isStopped && (
<Button
size="xs"
variant="outline"
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
onClick={() => handleControl(wf.id, "start")}
disabled={control.isPending}
>
<Play className="size-3" />
</Button>
)}
{isRunning && (
<>
<Button
size="xs"
variant="outline"
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-500/10"
onClick={() => handleControl(wf.id, "pause")}
disabled={control.isPending}
>
<Pause className="size-3" />
</Button>
<Button
size="xs"
variant="outline"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleControl(wf.id, "stop")}
disabled={control.isPending}
>
<Square className="size-3" />
</Button>
</>
)}
{isPaused && (
<>
<Button
size="xs"
variant="outline"
className="text-green-400 hover:text-green-300 hover:bg-green-500/10"
onClick={() => handleControl(wf.id, "resume")}
disabled={control.isPending}
>
<Play className="size-3" />
</Button>
<Button
size="xs"
variant="outline"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleControl(wf.id, "stop")}
disabled={control.isPending}
>
<Square className="size-3" />
</Button>
</>
)}
</div>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() =>
setDeleteTarget({ id: wf.id, name: wf.name })
}
>
<Trash2 className="size-3.5" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
<Dialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Workflow</DialogTitle>
<DialogDescription>
Are you sure you want to delete &ldquo;{deleteTarget?.name}
&rdquo;? This action cannot be undone. Any running workflow will be
stopped.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteTarget(null)}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,205 @@
"use client";
import {
CheckCircle2,
Circle,
Loader2,
Repeat,
Swords,
Pickaxe,
Hammer,
TrendingUp,
ClipboardList,
GraduationCap,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { WorkflowStep, WorkflowStatus, AutomationLog } from "@/lib/types";
import { LogStream } from "@/components/automation/log-stream";
import { cn } from "@/lib/utils";
const STRATEGY_ICONS: Record<string, React.ElementType> = {
combat: Swords,
gathering: Pickaxe,
crafting: Hammer,
trading: TrendingUp,
task: ClipboardList,
leveling: GraduationCap,
};
const STRATEGY_COLORS: Record<string, string> = {
combat: "text-red-400",
gathering: "text-green-400",
crafting: "text-blue-400",
trading: "text-yellow-400",
task: "text-purple-400",
leveling: "text-cyan-400",
};
interface WorkflowProgressProps {
steps: WorkflowStep[];
status: WorkflowStatus | null;
logs: AutomationLog[];
loop: boolean;
maxLoops: number;
}
export function WorkflowProgress({
steps,
status,
logs,
loop,
maxLoops,
}: WorkflowProgressProps) {
const currentIndex = status?.current_step_index ?? 0;
const isRunning = status?.status === "running";
const isPaused = status?.status === "paused";
const isActive = isRunning || isPaused;
return (
<div className="space-y-4">
{/* Summary bar */}
<div className="flex items-center gap-4 flex-wrap text-sm">
{status && (
<>
<span className="text-muted-foreground">
Step{" "}
<strong className="text-foreground">
{currentIndex + 1}/{steps.length}
</strong>
</span>
{loop && (
<span className="flex items-center gap-1 text-muted-foreground">
<Repeat className="size-3" />
Loop{" "}
<strong className="text-foreground">
{status.loop_count}
{maxLoops > 0 && `/${maxLoops}`}
</strong>
</span>
)}
<span className="text-muted-foreground">
Actions:{" "}
<strong className="text-foreground">
{status.total_actions_count.toLocaleString()}
</strong>
</span>
{status.strategy_state && (
<Badge variant="outline" className="text-xs">
{status.strategy_state}
</Badge>
)}
</>
)}
</div>
{/* Step timeline */}
<div className="space-y-0">
{steps.map((step, idx) => {
const isCompleted = isActive && idx < currentIndex;
const isCurrent = isActive && idx === currentIndex;
const isPending = !isActive || idx > currentIndex;
const Icon = STRATEGY_ICONS[step.strategy_type] ?? Circle;
const color = STRATEGY_COLORS[step.strategy_type] ?? "text-muted-foreground";
return (
<div key={step.id} className="flex items-stretch gap-3">
{/* Vertical connector */}
<div className="flex flex-col items-center">
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full border-2",
isCompleted &&
"bg-green-600/20 border-green-600 text-green-400",
isCurrent &&
"bg-primary/20 border-primary text-primary animate-pulse",
isPending &&
"bg-muted/50 border-muted-foreground/30 text-muted-foreground"
)}
>
{isCompleted ? (
<CheckCircle2 className="size-4" />
) : isCurrent && isRunning ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Icon className={cn("size-4", isCurrent ? "" : isPending ? "opacity-50" : color)} />
)}
</div>
{idx < steps.length - 1 && (
<div
className={cn(
"w-0.5 flex-1 min-h-4",
isCompleted ? "bg-green-600/50" : "bg-muted-foreground/20"
)}
/>
)}
</div>
{/* Step content */}
<div
className={cn(
"flex-1 pb-4",
isPending && "opacity-50"
)}
>
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm font-medium",
isCurrent && "text-primary"
)}
>
{step.name || `Step ${idx + 1}`}
</span>
<Badge variant="outline" className="text-[10px]">
{step.strategy_type}
</Badge>
{isCurrent && status && (
<span className="text-xs text-muted-foreground">
({status.step_actions_count} actions)
</span>
)}
</div>
{step.transition && (
<p className="text-xs text-muted-foreground mt-0.5">
Transition: {step.transition.type.replace(/_/g, " ")}
{step.transition.operator && ` ${step.transition.operator}`}
{step.transition.value !== undefined &&
` ${step.transition.value}`}
{step.transition.item_code &&
` (${step.transition.item_code})`}
{step.transition.skill && ` (${step.transition.skill})`}
{step.transition.seconds !== undefined &&
step.transition.type === "timer" &&
` ${step.transition.seconds}s`}
</p>
)}
</div>
</div>
);
})}
{/* Loop indicator */}
{loop && (
<div className="flex items-center gap-3 pl-3">
<Repeat className="size-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Loop back to Step 1
{maxLoops > 0 && ` (max ${maxLoops} loops)`}
</span>
</div>
)}
</div>
{/* Live logs */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Live Logs</CardTitle>
</CardHeader>
<CardContent>
<LogStream logs={logs} maxHeight="400px" />
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,259 @@
"use client";
import { useState } from "react";
import {
ChevronDown,
ChevronUp,
ArrowUp,
ArrowDown,
Trash2,
Swords,
Pickaxe,
Hammer,
TrendingUp,
ClipboardList,
GraduationCap,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { StrategySelector } from "@/components/automation/strategy-selector";
import {
ConfigForm,
DEFAULT_COMBAT_CONFIG,
DEFAULT_GATHERING_CONFIG,
DEFAULT_CRAFTING_CONFIG,
DEFAULT_TRADING_CONFIG,
DEFAULT_TASK_CONFIG,
DEFAULT_LEVELING_CONFIG,
} from "@/components/automation/config-form";
import { TransitionEditor } from "@/components/workflow/transition-editor";
import type { WorkflowStep, TransitionCondition } from "@/lib/types";
import { cn } from "@/lib/utils";
type StrategyType =
| "combat"
| "gathering"
| "crafting"
| "trading"
| "task"
| "leveling";
const STRATEGY_META: Record<
string,
{ icon: React.ElementType; color: string; label: string }
> = {
combat: { icon: Swords, color: "text-red-400", label: "Combat" },
gathering: { icon: Pickaxe, color: "text-green-400", label: "Gathering" },
crafting: { icon: Hammer, color: "text-blue-400", label: "Crafting" },
trading: { icon: TrendingUp, color: "text-yellow-400", label: "Trading" },
task: { icon: ClipboardList, color: "text-purple-400", label: "Task" },
leveling: { icon: GraduationCap, color: "text-cyan-400", label: "Leveling" },
};
const DEFAULT_CONFIGS: Record<StrategyType, Record<string, unknown>> = {
combat: DEFAULT_COMBAT_CONFIG as unknown as Record<string, unknown>,
gathering: DEFAULT_GATHERING_CONFIG as unknown as Record<string, unknown>,
crafting: DEFAULT_CRAFTING_CONFIG as unknown as Record<string, unknown>,
trading: DEFAULT_TRADING_CONFIG as unknown as Record<string, unknown>,
task: DEFAULT_TASK_CONFIG as unknown as Record<string, unknown>,
leveling: DEFAULT_LEVELING_CONFIG as unknown as Record<string, unknown>,
};
function configSummary(step: WorkflowStep): string {
const c = step.config;
switch (step.strategy_type) {
case "combat":
return c.monster_code
? `${c.monster_code}`
: "auto-select";
case "gathering":
return c.resource_code
? `${c.resource_code}`
: "auto-select";
case "crafting":
return c.item_code
? `${c.item_code} x${c.quantity ?? 1}`
: "no item set";
case "trading":
return `${c.mode ?? "sell_loot"} ${c.item_code ?? ""}`.trim();
case "task":
return "auto tasks";
case "leveling":
return c.target_skill ? `${c.target_skill}` : "auto";
default:
return "";
}
}
function transitionBadge(t: TransitionCondition | null): string {
if (!t) return "run to completion";
switch (t.type) {
case "strategy_complete":
return "on complete";
case "inventory_full":
return "inv full";
case "actions_count":
return `${t.operator} ${t.value} actions`;
case "skill_level":
return `${t.skill} ${t.operator} ${t.value}`;
case "inventory_item_count":
return `inv ${t.item_code} ${t.operator} ${t.value}`;
case "bank_item_count":
return `bank ${t.item_code} ${t.operator} ${t.value}`;
case "gold_amount":
return `gold ${t.operator} ${t.value}`;
case "timer":
return `${t.seconds}s timer`;
default:
return t.type;
}
}
interface WorkflowStepCardProps {
step: WorkflowStep;
index: number;
totalSteps: number;
onChange: (step: WorkflowStep) => void;
onMoveUp: () => void;
onMoveDown: () => void;
onDelete: () => void;
}
export function WorkflowStepCard({
step,
index,
totalSteps,
onChange,
onMoveUp,
onMoveDown,
onDelete,
}: WorkflowStepCardProps) {
const [expanded, setExpanded] = useState(!step.strategy_type);
const meta = STRATEGY_META[step.strategy_type];
const Icon = meta?.icon ?? ClipboardList;
function updateStep(partial: Partial<WorkflowStep>) {
onChange({ ...step, ...partial });
}
function handleStrategyChange(strategy: StrategyType) {
onChange({
...step,
strategy_type: strategy,
config: DEFAULT_CONFIGS[strategy],
name: step.name || `${STRATEGY_META[strategy]?.label ?? strategy} Step`,
});
}
return (
<Card className="overflow-hidden">
{/* Collapsed header */}
<button
type="button"
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-accent/30 transition-colors"
onClick={() => setExpanded(!expanded)}
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/20 text-primary text-xs font-bold">
{index + 1}
</span>
<Icon className={cn("size-4 shrink-0", meta?.color)} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
{step.name || "Untitled Step"}
</span>
<span className="text-xs text-muted-foreground truncate block">
{meta?.label ?? step.strategy_type} &middot; {configSummary(step)}
</span>
</div>
<Badge variant="outline" className="text-[10px] shrink-0">
{transitionBadge(step.transition)}
</Badge>
{expanded ? (
<ChevronUp className="size-4 text-muted-foreground shrink-0" />
) : (
<ChevronDown className="size-4 text-muted-foreground shrink-0" />
)}
</button>
{/* Expanded body */}
{expanded && (
<div className="border-t px-4 py-4 space-y-5">
{/* Step name */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Step Name</Label>
<Input
className="h-8 text-sm"
value={step.name}
placeholder="e.g. Gather Copper Ore"
onChange={(e) => updateStep({ name: e.target.value })}
/>
</div>
{/* Strategy selector */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Strategy</Label>
<StrategySelector
value={step.strategy_type}
onChange={handleStrategyChange}
/>
</div>
{/* Config form */}
{step.strategy_type && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Configuration
</Label>
<ConfigForm
strategyType={step.strategy_type}
config={step.config}
onChange={(config) => updateStep({ config })}
/>
</div>
)}
{/* Transition editor */}
<TransitionEditor
value={step.transition}
onChange={(transition) => updateStep({ transition })}
/>
{/* Reorder / Delete controls */}
<div className="flex items-center gap-2 pt-2 border-t">
<Button
size="xs"
variant="outline"
disabled={index === 0}
onClick={onMoveUp}
>
<ArrowUp className="size-3" />
Up
</Button>
<Button
size="xs"
variant="outline"
disabled={index === totalSteps - 1}
onClick={onMoveDown}
>
<ArrowDown className="size-3" />
Down
</Button>
<div className="flex-1" />
<Button
size="xs"
variant="outline"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={onDelete}
>
<Trash2 className="size-3" />
Remove
</Button>
</div>
</div>
)}
</Card>
);
}

View file

@ -0,0 +1,121 @@
"use client";
import { useRouter } from "next/navigation";
import {
GitBranch,
Repeat,
Swords,
Pickaxe,
Hammer,
TrendingUp,
ClipboardList,
GraduationCap,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
WORKFLOW_TEMPLATES,
WORKFLOW_CATEGORY_LABELS,
WORKFLOW_CATEGORY_COLORS,
} from "./workflow-templates";
import { cn } from "@/lib/utils";
const STRATEGY_ICONS: Record<string, React.ElementType> = {
combat: Swords,
gathering: Pickaxe,
crafting: Hammer,
trading: TrendingUp,
task: ClipboardList,
leveling: GraduationCap,
};
export function WorkflowTemplateGallery() {
const router = useRouter();
return (
<div className="space-y-4">
<Separator />
<div className="flex items-center gap-2">
<GitBranch className="size-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">
Workflow Templates
</h2>
<span className="text-sm text-muted-foreground">
Multi-step pipelines
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{WORKFLOW_TEMPLATES.map((template) => (
<Card
key={template.id}
className="cursor-pointer transition-all hover:shadow-md group py-0 overflow-hidden hover:border-primary/30"
onClick={() =>
router.push(
`/automations/workflows/new?template=${template.id}`
)
}
>
<div className="h-1 bg-primary/40" />
<CardContent className="p-4 space-y-3">
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<GitBranch className="size-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm text-foreground group-hover:text-primary transition-colors">
{template.name}
</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{template.description}
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
<Badge
variant="outline"
className={cn(
"text-[10px] capitalize",
WORKFLOW_CATEGORY_COLORS[template.category]
)}
>
{WORKFLOW_CATEGORY_LABELS[template.category] ??
template.category}
</Badge>
<Badge variant="outline" className="text-[10px]">
{template.steps.length} step
{template.steps.length !== 1 && "s"}
</Badge>
{template.loop && (
<Badge
variant="outline"
className="text-[10px] gap-0.5"
>
<Repeat className="size-2.5" />
Loop
</Badge>
)}
</div>
{/* Step mini-preview */}
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
{template.steps.map((step, idx) => {
const Icon =
STRATEGY_ICONS[step.strategy_type] ?? ClipboardList;
return (
<span key={step.id} className="flex items-center gap-0.5">
{idx > 0 && <span className="mx-0.5">&rarr;</span>}
<Icon className="size-3" />
</span>
);
})}
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { getAnalytics, getLogs } from "@/lib/api-client";
import type { AnalyticsData, ActionLog } from "@/lib/types";
import type { AnalyticsData, PaginatedLogs } from "@/lib/types";
export function useAnalytics(characterName?: string, hours?: number) {
return useQuery<AnalyticsData>({
@ -12,10 +12,21 @@ export function useAnalytics(characterName?: string, hours?: number) {
});
}
export function useLogs(filters?: { character?: string; type?: string }) {
return useQuery<ActionLog[]>({
queryKey: ["logs", filters?.character, filters?.type],
queryFn: () => getLogs(filters?.character),
export function useLogs(filters?: {
character?: string;
type?: string;
page?: number;
size?: number;
}) {
return useQuery<PaginatedLogs>({
queryKey: ["logs", filters?.character, filters?.type, filters?.page, filters?.size],
queryFn: () =>
getLogs({
character: filters?.character,
type: filters?.type,
page: filters?.page,
size: filters?.size,
}),
refetchInterval: 5000,
});
}

View file

@ -0,0 +1,49 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getAppErrors,
getErrorStats,
resolveError,
} from "@/lib/api-client";
import type { PaginatedErrors, ErrorStats, AppError } from "@/lib/types";
export function useErrors(filters?: {
severity?: string;
source?: string;
resolved?: string;
page?: number;
size?: number;
}) {
return useQuery<PaginatedErrors>({
queryKey: [
"app-errors",
filters?.severity,
filters?.source,
filters?.resolved,
filters?.page,
filters?.size,
],
queryFn: () => getAppErrors(filters),
refetchInterval: 15000,
});
}
export function useErrorStats() {
return useQuery<ErrorStats>({
queryKey: ["app-errors-stats"],
queryFn: getErrorStats,
refetchInterval: 15000,
});
}
export function useResolveError() {
const queryClient = useQueryClient();
return useMutation<AppError, Error, number>({
mutationFn: resolveError,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["app-errors"] });
queryClient.invalidateQueries({ queryKey: ["app-errors-stats"] });
},
});
}

View file

@ -0,0 +1,140 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getPipelines,
getPipeline,
getPipelineStatuses,
getPipelineLogs,
createPipeline,
updatePipeline,
deletePipeline,
startPipeline,
stopPipeline,
pausePipeline,
resumePipeline,
} from "@/lib/api-client";
import type {
PipelineConfig,
PipelineRun,
PipelineStatus,
AutomationLog,
} from "@/lib/types";
export function usePipelines() {
return useQuery<PipelineConfig[]>({
queryKey: ["pipelines"],
queryFn: getPipelines,
refetchInterval: 5000,
});
}
export function usePipeline(id: number) {
return useQuery<{ config: PipelineConfig; runs: PipelineRun[] }>({
queryKey: ["pipeline", id],
queryFn: () => getPipeline(id),
refetchInterval: 5000,
enabled: id > 0,
});
}
export function usePipelineStatuses() {
return useQuery<PipelineStatus[]>({
queryKey: ["pipelineStatuses"],
queryFn: getPipelineStatuses,
refetchInterval: 3000,
});
}
export function usePipelineLogs(id: number) {
return useQuery<AutomationLog[]>({
queryKey: ["pipelineLogs", id],
queryFn: () => getPipelineLogs(id),
refetchInterval: 3000,
enabled: id > 0,
});
}
export function useCreatePipeline() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: {
name: string;
description?: string;
stages: Record<string, unknown>[];
loop?: boolean;
max_loops?: number;
}) => createPipeline(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["pipelines"] });
},
});
}
export function useUpdatePipeline() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: Partial<PipelineConfig>;
}) => updatePipeline(id, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["pipelines"] });
queryClient.invalidateQueries({
queryKey: ["pipeline", variables.id],
});
},
});
}
export function useDeletePipeline() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deletePipeline(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["pipelines"] });
},
});
}
export function useControlPipeline() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
action,
}: {
id: number;
action: "start" | "stop" | "pause" | "resume";
}) => {
switch (action) {
case "start":
await startPipeline(id);
return;
case "stop":
return stopPipeline(id);
case "pause":
return pausePipeline(id);
case "resume":
return resumePipeline(id);
}
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["pipelines"] });
queryClient.invalidateQueries({
queryKey: ["pipeline", variables.id],
});
queryClient.invalidateQueries({ queryKey: ["pipelineStatuses"] });
queryClient.invalidateQueries({
queryKey: ["pipelineLogs", variables.id],
});
},
});
}

View file

@ -0,0 +1,141 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getWorkflows,
getWorkflow,
getWorkflowStatuses,
getWorkflowLogs,
createWorkflow,
updateWorkflow,
deleteWorkflow,
startWorkflow,
stopWorkflow,
pauseWorkflow,
resumeWorkflow,
} from "@/lib/api-client";
import type {
WorkflowConfig,
WorkflowRun,
WorkflowStatus,
AutomationLog,
} from "@/lib/types";
export function useWorkflows() {
return useQuery<WorkflowConfig[]>({
queryKey: ["workflows"],
queryFn: getWorkflows,
refetchInterval: 5000,
});
}
export function useWorkflow(id: number) {
return useQuery<{ config: WorkflowConfig; runs: WorkflowRun[] }>({
queryKey: ["workflow", id],
queryFn: () => getWorkflow(id),
refetchInterval: 5000,
enabled: id > 0,
});
}
export function useWorkflowStatuses() {
return useQuery<WorkflowStatus[]>({
queryKey: ["workflowStatuses"],
queryFn: getWorkflowStatuses,
refetchInterval: 3000,
});
}
export function useWorkflowLogs(id: number) {
return useQuery<AutomationLog[]>({
queryKey: ["workflowLogs", id],
queryFn: () => getWorkflowLogs(id),
refetchInterval: 3000,
enabled: id > 0,
});
}
export function useCreateWorkflow() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: {
name: string;
character_name: string;
description?: string;
steps: Record<string, unknown>[];
loop?: boolean;
max_loops?: number;
}) => createWorkflow(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["workflows"] });
},
});
}
export function useUpdateWorkflow() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: Partial<WorkflowConfig>;
}) => updateWorkflow(id, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["workflows"] });
queryClient.invalidateQueries({
queryKey: ["workflow", variables.id],
});
},
});
}
export function useDeleteWorkflow() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteWorkflow(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["workflows"] });
},
});
}
export function useControlWorkflow() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
action,
}: {
id: number;
action: "start" | "stop" | "pause" | "resume";
}) => {
switch (action) {
case "start":
await startWorkflow(id);
return;
case "stop":
return stopWorkflow(id);
case "pause":
return pauseWorkflow(id);
case "resume":
return resumeWorkflow(id);
}
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ["workflows"] });
queryClient.invalidateQueries({
queryKey: ["workflow", variables.id],
});
queryClient.invalidateQueries({ queryKey: ["workflowStatuses"] });
queryClient.invalidateQueries({
queryKey: ["workflowLogs", variables.id],
});
},
});
}

View file

@ -10,19 +10,49 @@ import type {
AutomationRun,
AutomationLog,
AutomationStatus,
WorkflowConfig,
WorkflowRun,
WorkflowStatus,
PipelineConfig,
PipelineRun,
PipelineStatus,
GEOrder,
GEHistoryEntry,
PricePoint,
ActiveGameEvent,
HistoricalEvent,
ActionLog,
PaginatedLogs,
AnalyticsData,
PaginatedErrors,
ErrorStats,
AppError,
} from "./types";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
const STORAGE_KEY = "artifacts-api-token";
/** Read the user's API token from localStorage (browser-only). */
function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(STORAGE_KEY);
}
/** Build headers including the per-user API token when available. */
function authHeaders(extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = { ...extra };
const token = getStoredToken();
if (token) {
headers["X-API-Token"] = token;
}
return headers;
}
async function fetchApi<T>(path: string): Promise<T> {
const response = await fetch(`${API_URL}${path}`);
const response = await fetch(`${API_URL}${path}`, {
headers: authHeaders(),
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
@ -34,7 +64,7 @@ async function fetchApi<T>(path: string): Promise<T> {
async function postApi<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`${API_URL}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: authHeaders({ "Content-Type": "application/json" }),
body: body ? JSON.stringify(body) : undefined,
});
@ -55,7 +85,7 @@ async function postApi<T>(path: string, body?: unknown): Promise<T> {
async function putApi<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`${API_URL}${path}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
headers: authHeaders({ "Content-Type": "application/json" }),
body: body ? JSON.stringify(body) : undefined,
});
@ -76,6 +106,7 @@ async function putApi<T>(path: string, body?: unknown): Promise<T> {
async function deleteApi(path: string): Promise<void> {
const response = await fetch(`${API_URL}${path}`, {
method: "DELETE",
headers: authHeaders(),
});
if (!response.ok) {
@ -111,6 +142,7 @@ export async function setAuthToken(token: string): Promise<SetTokenResponse> {
export async function clearAuthToken(): Promise<AuthStatus> {
const response = await fetch(`${API_URL}/api/auth/token`, {
method: "DELETE",
headers: authHeaders(),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
@ -219,6 +251,143 @@ export function getAutomationLogs(
);
}
// ---------- Workflow API ----------
export function getWorkflows(): Promise<WorkflowConfig[]> {
return fetchApi<WorkflowConfig[]>("/api/workflows");
}
export function getWorkflow(
id: number
): Promise<{ config: WorkflowConfig; runs: WorkflowRun[] }> {
return fetchApi<{ config: WorkflowConfig; runs: WorkflowRun[] }>(
`/api/workflows/${id}`
);
}
export function createWorkflow(data: {
name: string;
character_name: string;
description?: string;
steps: Record<string, unknown>[];
loop?: boolean;
max_loops?: number;
}): Promise<WorkflowConfig> {
return postApi<WorkflowConfig>("/api/workflows", data);
}
export function updateWorkflow(
id: number,
data: Partial<WorkflowConfig>
): Promise<WorkflowConfig> {
return putApi<WorkflowConfig>(`/api/workflows/${id}`, data);
}
export function deleteWorkflow(id: number): Promise<void> {
return deleteApi(`/api/workflows/${id}`);
}
export function startWorkflow(id: number): Promise<WorkflowRun> {
return postApi<WorkflowRun>(`/api/workflows/${id}/start`);
}
export function stopWorkflow(id: number): Promise<void> {
return postApi(`/api/workflows/${id}/stop`);
}
export function pauseWorkflow(id: number): Promise<void> {
return postApi(`/api/workflows/${id}/pause`);
}
export function resumeWorkflow(id: number): Promise<void> {
return postApi(`/api/workflows/${id}/resume`);
}
export function getWorkflowStatuses(): Promise<WorkflowStatus[]> {
return fetchApi<WorkflowStatus[]>("/api/workflows/status/all");
}
export function getWorkflowStatus(id: number): Promise<WorkflowStatus> {
return fetchApi<WorkflowStatus>(`/api/workflows/${id}/status`);
}
export function getWorkflowLogs(
id: number,
limit: number = 100
): Promise<AutomationLog[]> {
return fetchApi<AutomationLog[]>(
`/api/workflows/${id}/logs?limit=${limit}`
);
}
// ---------- Pipeline API ----------
export function getPipelines(): Promise<PipelineConfig[]> {
return fetchApi<PipelineConfig[]>("/api/pipelines");
}
export function getPipeline(
id: number
): Promise<{ config: PipelineConfig; runs: PipelineRun[] }> {
return fetchApi<{ config: PipelineConfig; runs: PipelineRun[] }>(
`/api/pipelines/${id}`
);
}
export function createPipeline(data: {
name: string;
description?: string;
stages: Record<string, unknown>[];
loop?: boolean;
max_loops?: number;
}): Promise<PipelineConfig> {
return postApi<PipelineConfig>("/api/pipelines", data);
}
export function updatePipeline(
id: number,
data: Partial<PipelineConfig>
): Promise<PipelineConfig> {
return putApi<PipelineConfig>(`/api/pipelines/${id}`, data);
}
export function deletePipeline(id: number): Promise<void> {
return deleteApi(`/api/pipelines/${id}`);
}
export function startPipeline(id: number): Promise<PipelineRun> {
return postApi<PipelineRun>(`/api/pipelines/${id}/start`);
}
export function stopPipeline(id: number): Promise<void> {
return postApi(`/api/pipelines/${id}/stop`);
}
export function pausePipeline(id: number): Promise<void> {
return postApi(`/api/pipelines/${id}/pause`);
}
export function resumePipeline(id: number): Promise<void> {
return postApi(`/api/pipelines/${id}/resume`);
}
export function getPipelineStatuses(): Promise<PipelineStatus[]> {
return fetchApi<PipelineStatus[]>("/api/pipelines/status/all");
}
export function getPipelineStatus(id: number): Promise<PipelineStatus> {
return fetchApi<PipelineStatus>(`/api/pipelines/${id}/status`);
}
export function getPipelineLogs(
id: number,
limit: number = 100
): Promise<AutomationLog[]> {
return fetchApi<AutomationLog[]>(
`/api/pipelines/${id}/logs?limit=${limit}`
);
}
// ---------- Grand Exchange API ----------
export async function getExchangeOrders(): Promise<GEOrder[]> {
@ -264,12 +433,25 @@ export async function getEventHistory(): Promise<HistoricalEvent[]> {
// ---------- Logs & Analytics API ----------
export async function getLogs(characterName?: string): Promise<ActionLog[]> {
export async function getLogs(filters?: {
character?: string;
type?: string;
page?: number;
size?: number;
}): Promise<PaginatedLogs> {
const params = new URLSearchParams();
if (characterName) params.set("character", characterName);
if (filters?.character) params.set("character", filters.character);
if (filters?.type) params.set("type", filters.type);
if (filters?.page) params.set("page", filters.page.toString());
if (filters?.size) params.set("size", filters.size.toString());
const qs = params.toString();
const data = await fetchApi<ActionLog[] | { logs?: ActionLog[] }>(`/api/logs${qs ? `?${qs}` : ""}`);
return Array.isArray(data) ? data : (data?.logs ?? []);
const data = await fetchApi<PaginatedLogs>(`/api/logs${qs ? `?${qs}` : ""}`);
return {
logs: data.logs ?? [],
total: data.total ?? 0,
page: data.page ?? 1,
pages: data.pages ?? 1,
};
}
export function getAnalytics(
@ -295,3 +477,39 @@ export function executeAction(
{ action, params }
);
}
// ---------- App Errors API ----------
export async function getAppErrors(filters?: {
severity?: string;
source?: string;
resolved?: string;
page?: number;
size?: number;
}): Promise<PaginatedErrors> {
const params = new URLSearchParams();
if (filters?.severity) params.set("severity", filters.severity);
if (filters?.source) params.set("source", filters.source);
if (filters?.resolved) params.set("resolved", filters.resolved);
if (filters?.page) params.set("page", filters.page.toString());
if (filters?.size) params.set("size", filters.size.toString());
const qs = params.toString();
return fetchApi<PaginatedErrors>(`/api/errors${qs ? `?${qs}` : ""}`);
}
export function getErrorStats(): Promise<ErrorStats> {
return fetchApi<ErrorStats>("/api/errors/stats");
}
export function resolveError(id: number): Promise<AppError> {
return postApi<AppError>(`/api/errors/${id}/resolve`);
}
export function reportError(report: {
error_type: string;
message: string;
stack_trace?: string;
context?: Record<string, unknown>;
}): Promise<void> {
return postApi("/api/errors/report", report);
}

View file

@ -47,6 +47,7 @@ export const NAV_ITEMS = [
{ href: "/exchange", label: "Exchange", icon: "ArrowLeftRight" },
{ href: "/events", label: "Events", icon: "Zap" },
{ href: "/logs", label: "Logs", icon: "ScrollText" },
{ href: "/errors", label: "Errors", icon: "AlertTriangle" },
{ href: "/analytics", label: "Analytics", icon: "BarChart3" },
{ href: "/settings", label: "Settings", icon: "Settings" },
] as const;

View file

@ -251,6 +251,161 @@ export interface LevelingConfig {
target_skill?: string;
}
// ---------- Workflow Types ----------
export interface TransitionCondition {
type:
| "strategy_complete"
| "loops_completed"
| "inventory_full"
| "inventory_item_count"
| "bank_item_count"
| "skill_level"
| "gold_amount"
| "actions_count"
| "timer";
operator: ">=" | "<=" | "==" | ">" | "<";
value: number;
item_code: string;
skill: string;
seconds: number;
}
export interface WorkflowStep {
id: string;
name: string;
strategy_type:
| "combat"
| "gathering"
| "crafting"
| "trading"
| "task"
| "leveling";
config: Record<string, unknown>;
transition: TransitionCondition | null;
}
export interface WorkflowConfig {
id: number;
name: string;
character_name: string;
description: string;
steps: WorkflowStep[];
loop: boolean;
max_loops: number;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface WorkflowRun {
id: number;
workflow_id: number;
status: "running" | "paused" | "stopped" | "completed" | "error";
current_step_index: number;
current_step_id: string;
loop_count: number;
total_actions_count: number;
step_actions_count: number;
started_at: string;
stopped_at: string | null;
error_message: string | null;
step_history: Record<string, unknown>[];
}
export interface WorkflowStatus {
workflow_id: number;
character_name: string;
status: string;
run_id: number | null;
current_step_index: number;
current_step_id: string;
total_steps: number;
loop_count: number;
total_actions_count: number;
step_actions_count: number;
strategy_state: string;
}
// ---------- Pipeline Types ----------
export interface CharacterStep {
id: string;
character_name: string;
strategy_type:
| "combat"
| "gathering"
| "crafting"
| "trading"
| "task"
| "leveling";
config: Record<string, unknown>;
transition: TransitionCondition | null;
}
export interface PipelineStage {
id: string;
name: string;
character_steps: CharacterStep[];
}
export interface PipelineConfig {
id: number;
name: string;
description: string;
stages: PipelineStage[];
loop: boolean;
max_loops: number;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface PipelineRun {
id: number;
pipeline_id: number;
status: "running" | "paused" | "stopped" | "completed" | "error";
current_stage_index: number;
current_stage_id: string;
loop_count: number;
total_actions_count: number;
character_states: Record<
string,
{
status: string;
step_id: string;
actions_count: number;
strategy_state: string;
error: string | null;
}
>;
stage_history: Record<string, unknown>[];
started_at: string;
stopped_at: string | null;
error_message: string | null;
}
export interface PipelineCharacterState {
character_name: string;
status: string;
step_id: string;
actions_count: number;
strategy_state: string;
error: string | null;
}
export interface PipelineStatus {
pipeline_id: number;
status: string;
run_id: number | null;
current_stage_index: number;
current_stage_id: string;
total_stages: number;
loop_count: number;
total_actions_count: number;
character_states: PipelineCharacterState[];
}
// ---------- Grand Exchange Types ----------
export interface GEOrder {
@ -319,6 +474,14 @@ export interface ActionLog {
details: Record<string, unknown>;
success: boolean;
created_at: string;
cooldown?: number;
}
export interface PaginatedLogs {
logs: ActionLog[];
total: number;
page: number;
pages: number;
}
export interface TimeSeriesPoint {
@ -332,3 +495,33 @@ export interface AnalyticsData {
gold_history: TimeSeriesPoint[];
actions_per_hour: number;
}
// ---------- App Error Types ----------
export interface AppError {
id: number;
severity: string;
source: string;
error_type: string;
message: string;
stack_trace?: string | null;
context?: Record<string, unknown> | null;
correlation_id?: string | null;
resolved: boolean;
created_at: string;
}
export interface PaginatedErrors {
errors: AppError[];
total: number;
page: number;
pages: number;
}
export interface ErrorStats {
total: number;
unresolved: number;
last_hour: number;
by_severity: Record<string, number>;
by_source: Record<string, number>;
}