forked from Rockachopa/Timmy-time-dashboard
Compare commits
7 Commits
kimi/issue
...
feature/mo
| Author | SHA1 | Date | |
|---|---|---|---|
| 215329146a | |||
| e4864b14f2 | |||
| e99b09f700 | |||
| 2ab6539564 | |||
| 28b8673584 | |||
| 2f15435fed | |||
| dfe40f5fe6 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -73,7 +73,6 @@ morning_briefing.txt
|
||||
markdown_report.md
|
||||
data/timmy_soul.jsonl
|
||||
scripts/migrate_to_zeroclaw.py
|
||||
src/infrastructure/db_pool.py
|
||||
workspace/
|
||||
|
||||
# Loop orchestration state
|
||||
|
||||
312
docs/protocol/morrowind-perception-command-spec.md
Normal file
312
docs/protocol/morrowind-perception-command-spec.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Morrowind Perception/Command Protocol Specification
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Status:** Draft
|
||||
**Authors:** Timmy Infrastructure Team
|
||||
**Date:** 2026-03-21
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document defines the **engine-agnostic Perception/Command protocol** used by Timmy's
|
||||
heartbeat loop to observe the game world and issue commands. The protocol is designed
|
||||
around the **Falsework Rule**: TES3MP (Morrowind) is scaffolding. If the engine swaps,
|
||||
only the bridge and perception script change — the heartbeat, reasoning, and journal
|
||||
remain sovereign.
|
||||
|
||||
### 1.1 Design Principles
|
||||
|
||||
- **Engine-agnostic**: Schemas reference abstract concepts (cells, entities, quests), not
|
||||
Morrowind-specific internals.
|
||||
- **Versioned**: Every payload carries a `protocol_version` so consumers can negotiate
|
||||
compatibility.
|
||||
- **Typed at the boundary**: Pydantic v2 models enforce validation on both the producer
|
||||
(bridge) and consumer (heartbeat) side.
|
||||
- **Logged by default**: Every command is persisted to the SQLite command log for
|
||||
training-data extraction (see Issue #855).
|
||||
|
||||
---
|
||||
|
||||
## 2. Protocol Version Strategy
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------ | ------ | ------------------------------------ |
|
||||
| `protocol_version` | string | SemVer string (e.g. `"1.0.0"`) |
|
||||
|
||||
### Compatibility Rules
|
||||
|
||||
- **Patch** bump (1.0.x): additive fields with defaults — fully backward-compatible.
|
||||
- **Minor** bump (1.x.0): new optional endpoints or enum values — old clients still work.
|
||||
- **Major** bump (x.0.0): breaking schema change — requires coordinated upgrade of bridge
|
||||
and heartbeat.
|
||||
|
||||
Consumers MUST reject payloads whose major version exceeds their own.
|
||||
|
||||
---
|
||||
|
||||
## 3. Perception Output Schema
|
||||
|
||||
Returned by `GET /perception`. Represents a single snapshot of the game world as observed
|
||||
by the bridge.
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol_version": "1.0.0",
|
||||
"timestamp": "2026-03-21T14:30:00Z",
|
||||
"agent_id": "timmy",
|
||||
"location": {
|
||||
"cell": "Balmora",
|
||||
"x": 1024.5,
|
||||
"y": -512.3,
|
||||
"z": 64.0,
|
||||
"interior": false
|
||||
},
|
||||
"health": {
|
||||
"current": 85,
|
||||
"max": 100
|
||||
},
|
||||
"nearby_entities": [
|
||||
{
|
||||
"entity_id": "npc_001",
|
||||
"name": "Caius Cosades",
|
||||
"entity_type": "npc",
|
||||
"distance": 12.5,
|
||||
"disposition": 65
|
||||
}
|
||||
],
|
||||
"inventory_summary": {
|
||||
"gold": 150,
|
||||
"item_count": 23,
|
||||
"encumbrance_pct": 0.45
|
||||
},
|
||||
"active_quests": [
|
||||
{
|
||||
"quest_id": "mq_01",
|
||||
"name": "Report to Caius Cosades",
|
||||
"stage": 10
|
||||
}
|
||||
],
|
||||
"environment": {
|
||||
"time_of_day": "afternoon",
|
||||
"weather": "clear",
|
||||
"is_combat": false,
|
||||
"is_dialogue": false
|
||||
},
|
||||
"raw_engine_data": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.1 Field Reference
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| -------------------- | ----------------- | -------- | ------------------------------------------------------------ |
|
||||
| `protocol_version` | string | yes | Protocol SemVer |
|
||||
| `timestamp` | ISO 8601 datetime | yes | When the snapshot was taken |
|
||||
| `agent_id` | string | yes | Which agent this perception belongs to |
|
||||
| `location.cell` | string | yes | Current cell/zone name |
|
||||
| `location.x/y/z` | float | yes | World coordinates |
|
||||
| `location.interior` | bool | yes | Whether the agent is indoors |
|
||||
| `health.current` | int (0–max) | yes | Current health |
|
||||
| `health.max` | int (>0) | yes | Maximum health |
|
||||
| `nearby_entities` | array | yes | Entities within perception radius (may be empty) |
|
||||
| `inventory_summary` | object | yes | Lightweight inventory overview |
|
||||
| `active_quests` | array | yes | Currently tracked quests |
|
||||
| `environment` | object | yes | World-state flags |
|
||||
| `raw_engine_data` | object | no | Opaque engine-specific blob (not relied upon by heartbeat) |
|
||||
|
||||
### 3.2 Entity Types
|
||||
|
||||
The `entity_type` field uses a controlled vocabulary:
|
||||
|
||||
| Value | Description |
|
||||
| ---------- | ------------------------ |
|
||||
| `npc` | Non-player character |
|
||||
| `creature` | Hostile or neutral mob |
|
||||
| `item` | Pickup-able world item |
|
||||
| `door` | Door or transition |
|
||||
| `container`| Lootable container |
|
||||
|
||||
---
|
||||
|
||||
## 4. Command Input Schema
|
||||
|
||||
Sent via `POST /command`. Represents a single action the agent wants to take in the world.
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol_version": "1.0.0",
|
||||
"timestamp": "2026-03-21T14:30:01Z",
|
||||
"agent_id": "timmy",
|
||||
"command": "move_to",
|
||||
"params": {
|
||||
"target_cell": "Balmora",
|
||||
"target_x": 1050.0,
|
||||
"target_y": -500.0
|
||||
},
|
||||
"reasoning": "Moving closer to Caius Cosades to begin the main quest dialogue.",
|
||||
"episode_id": "ep_20260321_001",
|
||||
"context": {
|
||||
"perception_timestamp": "2026-03-21T14:30:00Z",
|
||||
"heartbeat_cycle": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.1 Field Reference
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------------------ | ----------------- | -------- | ------------------------------------------------------- |
|
||||
| `protocol_version` | string | yes | Protocol SemVer |
|
||||
| `timestamp` | ISO 8601 datetime | yes | When the command was issued |
|
||||
| `agent_id` | string | yes | Which agent is issuing the command |
|
||||
| `command` | string (enum) | yes | Command type (see §4.2) |
|
||||
| `params` | object | yes | Command-specific parameters (may be empty `{}`) |
|
||||
| `reasoning` | string | yes | Natural-language explanation of *why* this command |
|
||||
| `episode_id` | string | no | Groups commands into training episodes |
|
||||
| `context` | object | no | Metadata linking command to its triggering perception |
|
||||
|
||||
### 4.2 Command Types
|
||||
|
||||
| Command | Description | Key Params |
|
||||
| --------------- | ---------------------------------------- | ---------------------------------- |
|
||||
| `move_to` | Navigate to coordinates or entity | `target_cell`, `target_x/y/z` |
|
||||
| `interact` | Interact with entity (talk, activate) | `entity_id`, `interaction_type` |
|
||||
| `use_item` | Use an inventory item | `item_id`, `target_entity_id?` |
|
||||
| `wait` | Wait/idle for a duration | `duration_seconds` |
|
||||
| `combat_action` | Perform a combat action | `action_type`, `target_entity_id` |
|
||||
| `dialogue` | Choose a dialogue option | `entity_id`, `topic`, `choice_idx` |
|
||||
| `journal_note` | Write an internal journal observation | `content`, `tags` |
|
||||
| `noop` | Heartbeat tick with no action | — |
|
||||
|
||||
---
|
||||
|
||||
## 5. API Contracts
|
||||
|
||||
### 5.1 `GET /perception`
|
||||
|
||||
Returns the latest perception snapshot.
|
||||
|
||||
**Response:** `200 OK` with `PerceptionOutput` JSON body.
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Description |
|
||||
| ------ | ------------------- | ----------------------------------- |
|
||||
| 503 | `BRIDGE_UNAVAILABLE`| Game bridge is not connected |
|
||||
| 504 | `PERCEPTION_TIMEOUT`| Bridge did not respond in time |
|
||||
| 422 | `SCHEMA_MISMATCH` | Bridge returned incompatible schema |
|
||||
|
||||
### 5.2 `POST /command`
|
||||
|
||||
Submit a command for the agent to execute.
|
||||
|
||||
**Request:** `CommandInput` JSON body.
|
||||
|
||||
**Response:** `202 Accepted`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"command_id": "cmd_abc123",
|
||||
"logged": true
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Description |
|
||||
| ------ | -------------------- | ----------------------------------- |
|
||||
| 400 | `INVALID_COMMAND` | Command type not recognized |
|
||||
| 400 | `VALIDATION_ERROR` | Payload fails Pydantic validation |
|
||||
| 409 | `COMMAND_CONFLICT` | Agent is busy executing another cmd |
|
||||
| 503 | `BRIDGE_UNAVAILABLE` | Game bridge is not connected |
|
||||
|
||||
### 5.3 `GET /morrowind/status`
|
||||
|
||||
Health-check endpoint for the Morrowind bridge.
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"bridge_connected": true,
|
||||
"engine": "tes3mp",
|
||||
"protocol_version": "1.0.0",
|
||||
"uptime_seconds": 3600,
|
||||
"last_perception_at": "2026-03-21T14:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Engine-Swap Documentation (The Falsework Rule)
|
||||
|
||||
### What Changes
|
||||
|
||||
| Component | Changes on Engine Swap? | Notes |
|
||||
| ---------------------- | ----------------------- | --------------------------------------------- |
|
||||
| Bridge process | **YES** — replaced | New bridge speaks same protocol to new engine |
|
||||
| Perception Lua script | **YES** — replaced | New engine's scripting language/API |
|
||||
| `PerceptionOutput` | NO | Schema is engine-agnostic |
|
||||
| `CommandInput` | NO | Schema is engine-agnostic |
|
||||
| Heartbeat loop | NO | Consumes `PerceptionOutput`, emits `Command` |
|
||||
| Reasoning/LLM layer | NO | Operates on abstract perception data |
|
||||
| Journal system | NO | Writes `journal_note` commands |
|
||||
| Command log + training | NO | Logs all commands regardless of engine |
|
||||
| Dashboard WebSocket | NO | Separate protocol (`src/infrastructure/protocol.py`) |
|
||||
|
||||
### Swap Procedure
|
||||
|
||||
1. Implement new bridge that serves `GET /perception` and accepts `POST /command`.
|
||||
2. Update `raw_engine_data` field documentation for the new engine.
|
||||
3. Extend `entity_type` enum if the new engine has novel entity categories.
|
||||
4. Bump `protocol_version` minor (or major if schema changes are required).
|
||||
5. Run integration tests against the new bridge.
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Handling Specification
|
||||
|
||||
### 7.1 Error Response Format
|
||||
|
||||
All error responses follow a consistent structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "BRIDGE_UNAVAILABLE",
|
||||
"message": "Human-readable error description",
|
||||
"details": {},
|
||||
"timestamp": "2026-03-21T14:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Error Codes
|
||||
|
||||
| Code | HTTP Status | Retry? | Description |
|
||||
| -------------------- | ----------- | ------ | ---------------------------------------- |
|
||||
| `BRIDGE_UNAVAILABLE` | 503 | yes | Bridge process not connected |
|
||||
| `PERCEPTION_TIMEOUT` | 504 | yes | Bridge did not respond within deadline |
|
||||
| `SCHEMA_MISMATCH` | 422 | no | Protocol version incompatibility |
|
||||
| `INVALID_COMMAND` | 400 | no | Unknown command type |
|
||||
| `VALIDATION_ERROR` | 400 | no | Pydantic validation failed |
|
||||
| `COMMAND_CONFLICT` | 409 | yes | Agent busy — retry after current command |
|
||||
| `INTERNAL_ERROR` | 500 | yes | Unexpected server error |
|
||||
|
||||
### 7.3 Retry Policy
|
||||
|
||||
Clients SHOULD implement exponential backoff for retryable errors:
|
||||
- Initial delay: 100ms
|
||||
- Max delay: 5s
|
||||
- Max retries: 5
|
||||
- Jitter: ±50ms
|
||||
|
||||
---
|
||||
|
||||
## 8. Appendix: Pydantic Model Reference
|
||||
|
||||
The canonical Pydantic v2 models live in `src/infrastructure/morrowind/schemas.py`.
|
||||
These models serve as both runtime validation and living documentation of this spec.
|
||||
Any change to this spec document MUST be reflected in the Pydantic models, and vice versa.
|
||||
@@ -20,6 +20,7 @@ if config.config_file_name is not None:
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
from src.dashboard.models.database import Base
|
||||
from src.dashboard.models.calm import Task, JournalEntry
|
||||
from src.infrastructure.morrowind.command_log import CommandLog # noqa: F401
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
|
||||
89
migrations/versions/a1b2c3d4e5f6_create_command_log_table.py
Normal file
89
migrations/versions/a1b2c3d4e5f6_create_command_log_table.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Create command_log table
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 0093c15b4bbf
|
||||
Create Date: 2026-03-21 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a1b2c3d4e5f6"
|
||||
down_revision: Union[str, Sequence[str], None] = "0093c15b4bbf"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table(
|
||||
"command_log",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("timestamp", sa.DateTime(), nullable=False),
|
||||
sa.Column("command", sa.String(length=64), nullable=False),
|
||||
sa.Column("params", sa.Text(), nullable=False, server_default="{}"),
|
||||
sa.Column("reasoning", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column(
|
||||
"perception_snapshot", sa.Text(), nullable=False, server_default="{}"
|
||||
),
|
||||
sa.Column("outcome", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"agent_id",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
server_default="timmy",
|
||||
),
|
||||
sa.Column("episode_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("cell", sa.String(length=255), nullable=True),
|
||||
sa.Column(
|
||||
"protocol_version",
|
||||
sa.String(length=16),
|
||||
nullable=False,
|
||||
server_default="1.0.0",
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_command_log_timestamp"), "command_log", ["timestamp"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_command_log_command"), "command_log", ["command"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_command_log_agent_id"), "command_log", ["agent_id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_command_log_episode_id"),
|
||||
"command_log",
|
||||
["episode_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_command_log_cell"), "command_log", ["cell"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"ix_command_log_cmd_cell", "command_log", ["command", "cell"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
"ix_command_log_episode",
|
||||
"command_log",
|
||||
["episode_id", "timestamp"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index("ix_command_log_episode", table_name="command_log")
|
||||
op.drop_index("ix_command_log_cmd_cell", table_name="command_log")
|
||||
op.drop_index(op.f("ix_command_log_cell"), table_name="command_log")
|
||||
op.drop_index(op.f("ix_command_log_episode_id"), table_name="command_log")
|
||||
op.drop_index(op.f("ix_command_log_agent_id"), table_name="command_log")
|
||||
op.drop_index(op.f("ix_command_log_command"), table_name="command_log")
|
||||
op.drop_index(op.f("ix_command_log_timestamp"), table_name="command_log")
|
||||
op.drop_table("command_log")
|
||||
@@ -275,3 +275,54 @@ async def component_status():
|
||||
},
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/snapshot")
|
||||
async def health_snapshot():
|
||||
"""Quick health snapshot before coding.
|
||||
|
||||
Returns a concise status summary including:
|
||||
- CI pipeline status (pass/fail/unknown)
|
||||
- Critical issues count (P0/P1)
|
||||
- Test flakiness rate
|
||||
- Token economy temperature
|
||||
|
||||
Fast execution (< 5 seconds) for pre-work checks.
|
||||
Refs: #710
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import the health snapshot module
|
||||
snapshot_path = Path(settings.repo_root) / "timmy_automations" / "daily_run"
|
||||
if str(snapshot_path) not in sys.path:
|
||||
sys.path.insert(0, str(snapshot_path))
|
||||
|
||||
try:
|
||||
from health_snapshot import generate_snapshot, get_token, load_config
|
||||
|
||||
config = load_config()
|
||||
token = get_token(config)
|
||||
|
||||
# Run the health snapshot (in thread to avoid blocking)
|
||||
snapshot = await asyncio.to_thread(generate_snapshot, config, token)
|
||||
|
||||
return snapshot.to_dict()
|
||||
except Exception as exc:
|
||||
logger.warning("Health snapshot failed: %s", exc)
|
||||
# Return graceful fallback
|
||||
return {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"overall_status": "unknown",
|
||||
"error": str(exc),
|
||||
"ci": {"status": "unknown", "message": "Snapshot failed"},
|
||||
"issues": {"count": 0, "p0_count": 0, "p1_count": 0, "issues": []},
|
||||
"flakiness": {
|
||||
"status": "unknown",
|
||||
"recent_failures": 0,
|
||||
"recent_cycles": 0,
|
||||
"failure_rate": 0.0,
|
||||
"message": "Snapshot failed",
|
||||
},
|
||||
"tokens": {"status": "unknown", "message": "Snapshot failed"},
|
||||
}
|
||||
|
||||
84
src/infrastructure/db_pool.py
Normal file
84
src/infrastructure/db_pool.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Thread-local SQLite connection pool.
|
||||
|
||||
Provides a ConnectionPool class that manages SQLite connections per thread,
|
||||
with support for context managers and automatic cleanup.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ConnectionPool:
|
||||
"""Thread-local SQLite connection pool.
|
||||
|
||||
Each thread gets its own connection, which is reused for subsequent
|
||||
requests from the same thread. Connections are automatically cleaned
|
||||
up when close_connection() is called or the context manager exits.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path | str) -> None:
|
||||
"""Initialize the connection pool.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file.
|
||||
"""
|
||||
self._db_path = Path(db_path)
|
||||
self._local = threading.local()
|
||||
|
||||
def _ensure_db_exists(self) -> None:
|
||||
"""Ensure the database directory exists."""
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_connection(self) -> sqlite3.Connection:
|
||||
"""Get a connection for the current thread.
|
||||
|
||||
Creates a new connection if one doesn't exist for this thread,
|
||||
otherwise returns the existing connection.
|
||||
|
||||
Returns:
|
||||
A sqlite3 Connection object.
|
||||
"""
|
||||
if not hasattr(self._local, "conn") or self._local.conn is None:
|
||||
self._ensure_db_exists()
|
||||
self._local.conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._local.conn.row_factory = sqlite3.Row
|
||||
return self._local.conn
|
||||
|
||||
def close_connection(self) -> None:
|
||||
"""Close the connection for the current thread.
|
||||
|
||||
Cleans up the thread-local storage. Safe to call even if
|
||||
no connection exists for this thread.
|
||||
"""
|
||||
if hasattr(self._local, "conn") and self._local.conn is not None:
|
||||
self._local.conn.close()
|
||||
self._local.conn = None
|
||||
|
||||
@contextmanager
|
||||
def connection(self) -> Generator[sqlite3.Connection, None, None]:
|
||||
"""Context manager for getting and automatically closing a connection.
|
||||
|
||||
Yields:
|
||||
A sqlite3 Connection object.
|
||||
|
||||
Example:
|
||||
with pool.connection() as conn:
|
||||
cursor = conn.execute("SELECT 1")
|
||||
result = cursor.fetchone()
|
||||
"""
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
self.close_connection()
|
||||
|
||||
def close_all(self) -> None:
|
||||
"""Close all connections (useful for testing).
|
||||
|
||||
Note: This only closes the connection for the current thread.
|
||||
In a multi-threaded environment, each thread must close its own.
|
||||
"""
|
||||
self.close_connection()
|
||||
18
src/infrastructure/morrowind/__init__.py
Normal file
18
src/infrastructure/morrowind/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Morrowind engine-agnostic perception/command protocol.
|
||||
|
||||
This package implements the Perception/Command protocol defined in
|
||||
``docs/protocol/morrowind-perception-command-spec.md``. It provides:
|
||||
|
||||
- Pydantic v2 schemas for runtime validation (``schemas``)
|
||||
- SQLite command logging and query interface (``command_log``)
|
||||
- Training-data export pipeline (``training_export``)
|
||||
"""
|
||||
|
||||
from .schemas import CommandInput, CommandType, EntityType, PerceptionOutput
|
||||
|
||||
__all__ = [
|
||||
"CommandInput",
|
||||
"CommandType",
|
||||
"EntityType",
|
||||
"PerceptionOutput",
|
||||
]
|
||||
307
src/infrastructure/morrowind/command_log.py
Normal file
307
src/infrastructure/morrowind/command_log.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""SQLite command log for the Morrowind Perception/Command protocol.
|
||||
|
||||
Every heartbeat cycle is logged — the resulting dataset serves as organic
|
||||
training data for local model fine-tuning (Phase 7+).
|
||||
|
||||
Usage::
|
||||
|
||||
from infrastructure.morrowind.command_log import CommandLogger
|
||||
|
||||
logger = CommandLogger() # uses project default DB
|
||||
logger.log_command(command_input, perception_snapshot)
|
||||
results = logger.query(command_type="move_to", limit=100)
|
||||
logger.export_training_data("export.jsonl")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
create_engine,
|
||||
)
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from src.dashboard.models.database import Base
|
||||
|
||||
from .schemas import CommandInput, CommandType, PerceptionOutput
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default database path — same SQLite file as the rest of the project.
|
||||
DEFAULT_DB_PATH = Path("./data/timmy_calm.db")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQLAlchemy model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CommandLog(Base):
|
||||
"""Persisted command log entry.
|
||||
|
||||
Schema columns mirror the requirements from Issue #855:
|
||||
timestamp, command, params, reasoning, perception_snapshot,
|
||||
outcome, episode_id.
|
||||
"""
|
||||
|
||||
__tablename__ = "command_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
timestamp = Column(
|
||||
DateTime, nullable=False, default=lambda: datetime.now(UTC), index=True
|
||||
)
|
||||
command = Column(String(64), nullable=False, index=True)
|
||||
params = Column(Text, nullable=False, default="{}")
|
||||
reasoning = Column(Text, nullable=False, default="")
|
||||
|
||||
perception_snapshot = Column(Text, nullable=False, default="{}")
|
||||
outcome = Column(Text, nullable=True)
|
||||
|
||||
agent_id = Column(String(64), nullable=False, default="timmy", index=True)
|
||||
episode_id = Column(String(128), nullable=True, index=True)
|
||||
cell = Column(String(255), nullable=True, index=True)
|
||||
protocol_version = Column(String(16), nullable=False, default="1.0.0")
|
||||
|
||||
created_at = Column(
|
||||
DateTime, nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_command_log_cmd_cell", "command", "cell"),
|
||||
Index("ix_command_log_episode", "episode_id", "timestamp"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CommandLogger — high-level API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CommandLogger:
|
||||
"""High-level interface for logging, querying, and exporting commands.
|
||||
|
||||
Args:
|
||||
db_url: SQLAlchemy database URL. Defaults to the project SQLite path.
|
||||
"""
|
||||
|
||||
def __init__(self, db_url: str | None = None) -> None:
|
||||
if db_url is None:
|
||||
DEFAULT_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
db_url = f"sqlite:///{DEFAULT_DB_PATH}"
|
||||
self._engine = create_engine(
|
||||
db_url, connect_args={"check_same_thread": False}
|
||||
)
|
||||
self._SessionLocal = sessionmaker(
|
||||
autocommit=False, autoflush=False, bind=self._engine
|
||||
)
|
||||
# Ensure table exists.
|
||||
Base.metadata.create_all(bind=self._engine, tables=[CommandLog.__table__])
|
||||
|
||||
def _get_session(self) -> Session:
|
||||
return self._SessionLocal()
|
||||
|
||||
# -- Write ---------------------------------------------------------------
|
||||
|
||||
def log_command(
|
||||
self,
|
||||
command_input: CommandInput,
|
||||
perception: PerceptionOutput | None = None,
|
||||
outcome: str | None = None,
|
||||
) -> int:
|
||||
"""Persist a command to the log.
|
||||
|
||||
Returns the auto-generated row id.
|
||||
"""
|
||||
perception_json = perception.model_dump_json() if perception else "{}"
|
||||
cell = perception.location.cell if perception else None
|
||||
|
||||
entry = CommandLog(
|
||||
timestamp=command_input.timestamp,
|
||||
command=command_input.command.value,
|
||||
params=json.dumps(command_input.params),
|
||||
reasoning=command_input.reasoning,
|
||||
perception_snapshot=perception_json,
|
||||
outcome=outcome,
|
||||
agent_id=command_input.agent_id,
|
||||
episode_id=command_input.episode_id,
|
||||
cell=cell,
|
||||
protocol_version=command_input.protocol_version,
|
||||
)
|
||||
|
||||
session = self._get_session()
|
||||
try:
|
||||
session.add(entry)
|
||||
session.commit()
|
||||
session.refresh(entry)
|
||||
row_id: int = entry.id
|
||||
return row_id
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# -- Read ----------------------------------------------------------------
|
||||
|
||||
def query(
|
||||
self,
|
||||
*,
|
||||
command_type: str | CommandType | None = None,
|
||||
cell: str | None = None,
|
||||
episode_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Query command log entries with optional filters.
|
||||
|
||||
Returns a list of dicts (serialisable to JSON).
|
||||
"""
|
||||
session = self._get_session()
|
||||
try:
|
||||
q = session.query(CommandLog)
|
||||
|
||||
if command_type is not None:
|
||||
q = q.filter(CommandLog.command == str(command_type))
|
||||
if cell is not None:
|
||||
q = q.filter(CommandLog.cell == cell)
|
||||
if episode_id is not None:
|
||||
q = q.filter(CommandLog.episode_id == episode_id)
|
||||
if agent_id is not None:
|
||||
q = q.filter(CommandLog.agent_id == agent_id)
|
||||
if since is not None:
|
||||
q = q.filter(CommandLog.timestamp >= since)
|
||||
if until is not None:
|
||||
q = q.filter(CommandLog.timestamp <= until)
|
||||
|
||||
q = q.order_by(CommandLog.timestamp.desc())
|
||||
q = q.offset(offset).limit(limit)
|
||||
|
||||
rows = q.all()
|
||||
return [self._row_to_dict(row) for row in rows]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# -- Export --------------------------------------------------------------
|
||||
|
||||
def export_training_data(
|
||||
self,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
episode_id: str | None = None,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> int:
|
||||
"""Export command log entries as a JSONL file for fine-tuning.
|
||||
|
||||
Each line is a JSON object with ``perception`` (input) and
|
||||
``command`` + ``reasoning`` (target output).
|
||||
|
||||
Returns the number of rows exported.
|
||||
"""
|
||||
output_path = Path(output_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
session = self._get_session()
|
||||
try:
|
||||
q = session.query(CommandLog)
|
||||
if episode_id is not None:
|
||||
q = q.filter(CommandLog.episode_id == episode_id)
|
||||
if since is not None:
|
||||
q = q.filter(CommandLog.timestamp >= since)
|
||||
if until is not None:
|
||||
q = q.filter(CommandLog.timestamp <= until)
|
||||
q = q.order_by(CommandLog.timestamp.asc())
|
||||
|
||||
count = 0
|
||||
with open(output_path, "w", encoding="utf-8") as fh:
|
||||
for row in q.yield_per(500):
|
||||
record = {
|
||||
"input": {
|
||||
"perception": json.loads(row.perception_snapshot),
|
||||
},
|
||||
"output": {
|
||||
"command": row.command,
|
||||
"params": json.loads(row.params),
|
||||
"reasoning": row.reasoning,
|
||||
},
|
||||
"metadata": {
|
||||
"timestamp": row.timestamp.isoformat() if row.timestamp else None,
|
||||
"episode_id": row.episode_id,
|
||||
"cell": row.cell,
|
||||
"outcome": row.outcome,
|
||||
},
|
||||
}
|
||||
fh.write(json.dumps(record) + "\n")
|
||||
count += 1
|
||||
logger.info("Exported %d training records to %s", count, output_path)
|
||||
return count
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# -- Storage management --------------------------------------------------
|
||||
|
||||
def rotate(self, max_age_days: int = 90) -> int:
|
||||
"""Delete command log entries older than *max_age_days*.
|
||||
|
||||
Returns the number of rows deleted.
|
||||
"""
|
||||
cutoff = datetime.now(UTC) - timedelta(days=max_age_days)
|
||||
session = self._get_session()
|
||||
try:
|
||||
deleted = (
|
||||
session.query(CommandLog)
|
||||
.filter(CommandLog.timestamp < cutoff)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
session.commit()
|
||||
logger.info("Rotated %d command log entries older than %s", deleted, cutoff)
|
||||
return deleted
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def count(self) -> int:
|
||||
"""Return the total number of command log entries."""
|
||||
session = self._get_session()
|
||||
try:
|
||||
return session.query(CommandLog).count()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# -- Helpers -------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: CommandLog) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"timestamp": row.timestamp.isoformat() if row.timestamp else None,
|
||||
"command": row.command,
|
||||
"params": json.loads(row.params) if row.params else {},
|
||||
"reasoning": row.reasoning,
|
||||
"perception_snapshot": json.loads(row.perception_snapshot)
|
||||
if row.perception_snapshot
|
||||
else {},
|
||||
"outcome": row.outcome,
|
||||
"agent_id": row.agent_id,
|
||||
"episode_id": row.episode_id,
|
||||
"cell": row.cell,
|
||||
"protocol_version": row.protocol_version,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
186
src/infrastructure/morrowind/schemas.py
Normal file
186
src/infrastructure/morrowind/schemas.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Pydantic v2 models for the Morrowind Perception/Command protocol.
|
||||
|
||||
These models enforce the contract defined in
|
||||
``docs/protocol/morrowind-perception-command-spec.md`` at runtime.
|
||||
They are engine-agnostic by design — see the Falsework Rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
PROTOCOL_VERSION = "1.0.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EntityType(StrEnum):
|
||||
"""Controlled vocabulary for nearby entity types."""
|
||||
|
||||
NPC = "npc"
|
||||
CREATURE = "creature"
|
||||
ITEM = "item"
|
||||
DOOR = "door"
|
||||
CONTAINER = "container"
|
||||
|
||||
|
||||
class CommandType(StrEnum):
|
||||
"""All supported command types."""
|
||||
|
||||
MOVE_TO = "move_to"
|
||||
INTERACT = "interact"
|
||||
USE_ITEM = "use_item"
|
||||
WAIT = "wait"
|
||||
COMBAT_ACTION = "combat_action"
|
||||
DIALOGUE = "dialogue"
|
||||
JOURNAL_NOTE = "journal_note"
|
||||
NOOP = "noop"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Perception Output sub-models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Location(BaseModel):
|
||||
"""Agent position within the game world."""
|
||||
|
||||
cell: str = Field(..., description="Current cell/zone name")
|
||||
x: float = Field(..., description="World X coordinate")
|
||||
y: float = Field(..., description="World Y coordinate")
|
||||
z: float = Field(0.0, description="World Z coordinate")
|
||||
interior: bool = Field(False, description="Whether the agent is indoors")
|
||||
|
||||
|
||||
class HealthStatus(BaseModel):
|
||||
"""Agent health information."""
|
||||
|
||||
current: int = Field(..., ge=0, description="Current health points")
|
||||
max: int = Field(..., gt=0, description="Maximum health points")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def current_le_max(self) -> "HealthStatus":
|
||||
if self.current > self.max:
|
||||
raise ValueError(
|
||||
f"current ({self.current}) cannot exceed max ({self.max})"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class NearbyEntity(BaseModel):
|
||||
"""An entity within the agent's perception radius."""
|
||||
|
||||
entity_id: str = Field(..., description="Unique entity identifier")
|
||||
name: str = Field(..., description="Display name")
|
||||
entity_type: EntityType = Field(..., description="Entity category")
|
||||
distance: float = Field(..., ge=0, description="Distance from agent")
|
||||
disposition: int | None = Field(None, description="NPC disposition (0-100)")
|
||||
|
||||
|
||||
class InventorySummary(BaseModel):
|
||||
"""Lightweight overview of the agent's inventory."""
|
||||
|
||||
gold: int = Field(0, ge=0, description="Gold held")
|
||||
item_count: int = Field(0, ge=0, description="Total items carried")
|
||||
encumbrance_pct: float = Field(
|
||||
0.0, ge=0.0, le=1.0, description="Encumbrance as fraction (0.0–1.0)"
|
||||
)
|
||||
|
||||
|
||||
class QuestInfo(BaseModel):
|
||||
"""A currently tracked quest."""
|
||||
|
||||
quest_id: str = Field(..., description="Quest identifier")
|
||||
name: str = Field(..., description="Quest display name")
|
||||
stage: int = Field(0, ge=0, description="Current quest stage")
|
||||
|
||||
|
||||
class Environment(BaseModel):
|
||||
"""World-state flags."""
|
||||
|
||||
time_of_day: str = Field("unknown", description="Time period (morning, afternoon, etc.)")
|
||||
weather: str = Field("clear", description="Current weather condition")
|
||||
is_combat: bool = Field(False, description="Whether the agent is in combat")
|
||||
is_dialogue: bool = Field(False, description="Whether the agent is in dialogue")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Top-level schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PerceptionOutput(BaseModel):
|
||||
"""Complete perception snapshot returned by ``GET /perception``.
|
||||
|
||||
This is the engine-agnostic view of the game world consumed by the
|
||||
heartbeat loop and reasoning layer.
|
||||
"""
|
||||
|
||||
protocol_version: str = Field(
|
||||
default=PROTOCOL_VERSION,
|
||||
description="Protocol SemVer string",
|
||||
)
|
||||
timestamp: datetime = Field(..., description="When the snapshot was taken")
|
||||
agent_id: str = Field(..., description="Which agent this perception belongs to")
|
||||
|
||||
location: Location
|
||||
health: HealthStatus
|
||||
nearby_entities: list[NearbyEntity] = Field(default_factory=list)
|
||||
inventory_summary: InventorySummary = Field(default_factory=InventorySummary)
|
||||
active_quests: list[QuestInfo] = Field(default_factory=list)
|
||||
environment: Environment = Field(default_factory=Environment)
|
||||
|
||||
raw_engine_data: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Opaque engine-specific blob — not relied upon by heartbeat",
|
||||
)
|
||||
|
||||
|
||||
class CommandContext(BaseModel):
|
||||
"""Metadata linking a command to its triggering perception."""
|
||||
|
||||
perception_timestamp: datetime | None = Field(
|
||||
None, description="Timestamp of the perception that triggered this command"
|
||||
)
|
||||
heartbeat_cycle: int | None = Field(
|
||||
None, ge=0, description="Heartbeat cycle number"
|
||||
)
|
||||
|
||||
|
||||
class CommandInput(BaseModel):
|
||||
"""Command payload sent via ``POST /command``.
|
||||
|
||||
Every command includes a ``reasoning`` field so the command log
|
||||
captures the agent's intent — critical for training-data export.
|
||||
"""
|
||||
|
||||
protocol_version: str = Field(
|
||||
default=PROTOCOL_VERSION,
|
||||
description="Protocol SemVer string",
|
||||
)
|
||||
timestamp: datetime = Field(..., description="When the command was issued")
|
||||
agent_id: str = Field(..., description="Which agent is issuing the command")
|
||||
|
||||
command: CommandType = Field(..., description="Command type")
|
||||
params: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Command-specific parameters"
|
||||
)
|
||||
reasoning: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Natural-language explanation of why this command was chosen",
|
||||
)
|
||||
|
||||
episode_id: str | None = Field(
|
||||
None, description="Groups commands into training episodes"
|
||||
)
|
||||
context: CommandContext | None = Field(
|
||||
None, description="Metadata linking command to its triggering perception"
|
||||
)
|
||||
243
src/infrastructure/morrowind/training_export.py
Normal file
243
src/infrastructure/morrowind/training_export.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""Fine-tuning dataset export pipeline for command log data.
|
||||
|
||||
Transforms raw command log entries into structured training datasets
|
||||
suitable for supervised fine-tuning of local models.
|
||||
|
||||
Usage::
|
||||
|
||||
from infrastructure.morrowind.training_export import TrainingExporter
|
||||
|
||||
exporter = TrainingExporter(command_logger)
|
||||
stats = exporter.export_chat_format("train.jsonl")
|
||||
stats = exporter.export_episode_sequences("episodes/", min_length=5)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .command_log import CommandLogger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportStats:
|
||||
"""Statistics about an export run."""
|
||||
|
||||
total_records: int = 0
|
||||
episodes_exported: int = 0
|
||||
skipped_records: int = 0
|
||||
output_path: str = ""
|
||||
format: str = ""
|
||||
exported_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||
|
||||
|
||||
class TrainingExporter:
|
||||
"""Builds fine-tuning datasets from the command log.
|
||||
|
||||
Supports multiple output formats used by common fine-tuning
|
||||
frameworks (chat-completion style, instruction-following, episode
|
||||
sequences).
|
||||
|
||||
Args:
|
||||
command_logger: A :class:`CommandLogger` instance to read from.
|
||||
"""
|
||||
|
||||
def __init__(self, command_logger: CommandLogger) -> None:
|
||||
self._logger = command_logger
|
||||
|
||||
# -- Chat-completion format ----------------------------------------------
|
||||
|
||||
def export_chat_format(
|
||||
self,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
max_records: int | None = None,
|
||||
) -> ExportStats:
|
||||
"""Export as chat-completion training pairs.
|
||||
|
||||
Each line is a JSON object with ``messages`` list containing a
|
||||
``system`` prompt, ``user`` (perception), and ``assistant``
|
||||
(command + reasoning) message.
|
||||
|
||||
This format is compatible with OpenAI / Llama fine-tuning APIs.
|
||||
"""
|
||||
output_path = Path(output_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rows = self._logger.query(
|
||||
since=since,
|
||||
until=until,
|
||||
limit=max_records or 100_000,
|
||||
)
|
||||
# query returns newest-first; reverse for chronological export
|
||||
rows.reverse()
|
||||
|
||||
stats = ExportStats(
|
||||
output_path=str(output_path),
|
||||
format="chat_completion",
|
||||
)
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as fh:
|
||||
for row in rows:
|
||||
perception = row.get("perception_snapshot", {})
|
||||
if not perception:
|
||||
stats.skipped_records += 1
|
||||
continue
|
||||
|
||||
record = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are an autonomous agent navigating a game world. "
|
||||
"Given a perception of the world state, decide what "
|
||||
"command to execute and explain your reasoning."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(perception),
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"command": row.get("command"),
|
||||
"params": row.get("params", {}),
|
||||
"reasoning": row.get("reasoning", ""),
|
||||
}
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
fh.write(json.dumps(record) + "\n")
|
||||
stats.total_records += 1
|
||||
|
||||
logger.info(
|
||||
"Exported %d chat-format records to %s (skipped %d)",
|
||||
stats.total_records,
|
||||
output_path,
|
||||
stats.skipped_records,
|
||||
)
|
||||
return stats
|
||||
|
||||
# -- Episode sequences ---------------------------------------------------
|
||||
|
||||
def export_episode_sequences(
|
||||
self,
|
||||
output_dir: str | Path,
|
||||
*,
|
||||
min_length: int = 3,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> ExportStats:
|
||||
"""Export command sequences grouped by episode.
|
||||
|
||||
Each episode is written as a separate JSONL file in *output_dir*.
|
||||
Episodes shorter than *min_length* are skipped.
|
||||
"""
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Gather all rows (high limit) and group by episode.
|
||||
rows = self._logger.query(since=since, until=until, limit=1_000_000)
|
||||
rows.reverse() # chronological
|
||||
|
||||
episodes: dict[str, list[dict[str, Any]]] = {}
|
||||
for row in rows:
|
||||
ep_id = row.get("episode_id") or "unknown"
|
||||
episodes.setdefault(ep_id, []).append(row)
|
||||
|
||||
stats = ExportStats(
|
||||
output_path=str(output_dir),
|
||||
format="episode_sequence",
|
||||
)
|
||||
|
||||
for ep_id, entries in episodes.items():
|
||||
if len(entries) < min_length:
|
||||
stats.skipped_records += len(entries)
|
||||
continue
|
||||
|
||||
ep_file = output_dir / f"{ep_id}.jsonl"
|
||||
with open(ep_file, "w", encoding="utf-8") as fh:
|
||||
for entry in entries:
|
||||
fh.write(json.dumps(entry, default=str) + "\n")
|
||||
stats.total_records += 1
|
||||
stats.episodes_exported += 1
|
||||
|
||||
logger.info(
|
||||
"Exported %d episodes (%d records) to %s",
|
||||
stats.episodes_exported,
|
||||
stats.total_records,
|
||||
output_dir,
|
||||
)
|
||||
return stats
|
||||
|
||||
# -- Instruction-following format ----------------------------------------
|
||||
|
||||
def export_instruction_format(
|
||||
self,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
max_records: int | None = None,
|
||||
) -> ExportStats:
|
||||
"""Export as instruction/response pairs (Alpaca-style).
|
||||
|
||||
Each line has ``instruction``, ``input``, and ``output`` fields.
|
||||
"""
|
||||
output_path = Path(output_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rows = self._logger.query(
|
||||
since=since,
|
||||
until=until,
|
||||
limit=max_records or 100_000,
|
||||
)
|
||||
rows.reverse()
|
||||
|
||||
stats = ExportStats(
|
||||
output_path=str(output_path),
|
||||
format="instruction",
|
||||
)
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as fh:
|
||||
for row in rows:
|
||||
perception = row.get("perception_snapshot", {})
|
||||
if not perception:
|
||||
stats.skipped_records += 1
|
||||
continue
|
||||
|
||||
record = {
|
||||
"instruction": (
|
||||
"Given the following game world perception, decide what "
|
||||
"command to execute. Explain your reasoning."
|
||||
),
|
||||
"input": json.dumps(perception),
|
||||
"output": json.dumps(
|
||||
{
|
||||
"command": row.get("command"),
|
||||
"params": row.get("params", {}),
|
||||
"reasoning": row.get("reasoning", ""),
|
||||
}
|
||||
),
|
||||
}
|
||||
fh.write(json.dumps(record) + "\n")
|
||||
stats.total_records += 1
|
||||
|
||||
logger.info(
|
||||
"Exported %d instruction-format records to %s",
|
||||
stats.total_records,
|
||||
output_path,
|
||||
)
|
||||
return stats
|
||||
@@ -489,5 +489,43 @@ def focus(
|
||||
typer.echo("No active focus (broad mode).")
|
||||
|
||||
|
||||
@app.command(name="healthcheck")
|
||||
def healthcheck(
|
||||
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
||||
verbose: bool = typer.Option(
|
||||
False, "--verbose", "-v", help="Show verbose output including issue details"
|
||||
),
|
||||
quiet: bool = typer.Option(False, "--quiet", "-q", help="Only show status line (no details)"),
|
||||
):
|
||||
"""Quick health snapshot before coding.
|
||||
|
||||
Shows CI status, critical issues (P0/P1), test flakiness, and token economy.
|
||||
Fast execution (< 5 seconds) for pre-work checks.
|
||||
|
||||
Refs: #710
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
script_path = (
|
||||
Path(__file__).resolve().parent.parent.parent
|
||||
/ "timmy_automations"
|
||||
/ "daily_run"
|
||||
/ "health_snapshot.py"
|
||||
)
|
||||
|
||||
cmd = [sys.executable, str(script_path)]
|
||||
if json_output:
|
||||
cmd.append("--json")
|
||||
if verbose:
|
||||
cmd.append("--verbose")
|
||||
if quiet:
|
||||
cmd.append("--quiet")
|
||||
|
||||
result = subprocess.run(cmd)
|
||||
raise typer.Exit(result.returncode)
|
||||
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
@@ -13,11 +13,121 @@
|
||||
<div class="mood" id="mood-text">focused</div>
|
||||
</div>
|
||||
<div id="connection-dot"></div>
|
||||
<button id="info-btn" class="info-button" aria-label="About The Matrix" title="About The Matrix">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="submit-job-btn" class="submit-job-button" aria-label="Submit Job" title="Submit Job">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v14M5 12h14"></path>
|
||||
</svg>
|
||||
<span>Job</span>
|
||||
</button>
|
||||
<div id="speech-area">
|
||||
<div class="bubble" id="speech-bubble"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Job Modal -->
|
||||
<div id="submit-job-modal" class="submit-job-modal">
|
||||
<div class="submit-job-content">
|
||||
<button id="submit-job-close" class="submit-job-close" aria-label="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<h2>Submit Job</h2>
|
||||
<p class="submit-job-subtitle">Create a task for Timmy and the agent swarm</p>
|
||||
|
||||
<form id="submit-job-form" class="submit-job-form">
|
||||
<div class="form-group">
|
||||
<label for="job-title">Title <span class="required">*</span></label>
|
||||
<input type="text" id="job-title" name="title" placeholder="Brief description of the task" maxlength="200">
|
||||
<div class="char-count" id="title-char-count">0 / 200</div>
|
||||
<div class="validation-error" id="title-error"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="job-description">Description</label>
|
||||
<textarea id="job-description" name="description" placeholder="Detailed instructions, requirements, and context..." rows="6" maxlength="2000"></textarea>
|
||||
<div class="char-count" id="desc-char-count">0 / 2000</div>
|
||||
<div class="validation-warning" id="desc-warning"></div>
|
||||
<div class="validation-error" id="desc-error"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="job-priority">Priority</label>
|
||||
<select id="job-priority" name="priority">
|
||||
<option value="low">Low</option>
|
||||
<option value="medium" selected>Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="submit-job-actions">
|
||||
<button type="button" id="cancel-job-btn" class="btn-secondary">Cancel</button>
|
||||
<button type="submit" id="submit-job-submit" class="btn-primary" disabled>Submit Job</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="submit-job-success" class="submit-job-success hidden">
|
||||
<div class="success-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Job Submitted!</h3>
|
||||
<p>Your task has been added to the queue. Timmy will review it shortly.</p>
|
||||
<button type="button" id="submit-another-btn" class="btn-primary">Submit Another</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="submit-job-backdrop" class="submit-job-backdrop"></div>
|
||||
</div>
|
||||
|
||||
<!-- About Panel -->
|
||||
<div id="about-panel" class="about-panel">
|
||||
<div class="about-panel-content">
|
||||
<button id="about-close" class="about-close" aria-label="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<h2>Welcome to The Matrix</h2>
|
||||
|
||||
<section>
|
||||
<h3>🌌 The Matrix</h3>
|
||||
<p>The Matrix is a 3D visualization of Timmy's AI agent workspace. Enter the workshop to see Timmy at work—pondering the arcane arts of code, managing tasks, and orchestrating autonomous agents in real-time.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>🛠️ The Workshop</h3>
|
||||
<p>The Workshop is where you interact directly with Timmy:</p>
|
||||
<ul>
|
||||
<li><strong>Submit Jobs</strong> — Create tasks, delegate work, and track progress</li>
|
||||
<li><strong>Chat with Agents</strong> — Converse with Timmy and his swarm of specialized agents</li>
|
||||
<li><strong>Fund Sessions</strong> — Power your work with satoshis via Lightning Network</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>⚡ Lightning & Sats</h3>
|
||||
<p>The Matrix runs on Bitcoin. Sessions are funded with satoshis (sats) over the Lightning Network—enabling fast, cheap micropayments that keep Timmy energized and working for you. No subscriptions, no limits—pay as you go.</p>
|
||||
</section>
|
||||
|
||||
<div class="about-footer">
|
||||
<span>Sovereign AI · Soul on Bitcoin</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="about-backdrop" class="about-backdrop"></div>
|
||||
</div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
@@ -74,6 +184,271 @@
|
||||
});
|
||||
stateReader.connect();
|
||||
|
||||
// --- About Panel ---
|
||||
const infoBtn = document.getElementById("info-btn");
|
||||
const aboutPanel = document.getElementById("about-panel");
|
||||
const aboutClose = document.getElementById("about-close");
|
||||
const aboutBackdrop = document.getElementById("about-backdrop");
|
||||
|
||||
function openAboutPanel() {
|
||||
aboutPanel.classList.add("open");
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
function closeAboutPanel() {
|
||||
aboutPanel.classList.remove("open");
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
infoBtn.addEventListener("click", openAboutPanel);
|
||||
aboutClose.addEventListener("click", closeAboutPanel);
|
||||
aboutBackdrop.addEventListener("click", closeAboutPanel);
|
||||
|
||||
// Close on Escape key
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && aboutPanel.classList.contains("open")) {
|
||||
closeAboutPanel();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Submit Job Modal ---
|
||||
const submitJobBtn = document.getElementById("submit-job-btn");
|
||||
const submitJobModal = document.getElementById("submit-job-modal");
|
||||
const submitJobClose = document.getElementById("submit-job-close");
|
||||
const submitJobBackdrop = document.getElementById("submit-job-backdrop");
|
||||
const cancelJobBtn = document.getElementById("cancel-job-btn");
|
||||
const submitJobForm = document.getElementById("submit-job-form");
|
||||
const submitJobSubmit = document.getElementById("submit-job-submit");
|
||||
const jobTitle = document.getElementById("job-title");
|
||||
const jobDescription = document.getElementById("job-description");
|
||||
const titleCharCount = document.getElementById("title-char-count");
|
||||
const descCharCount = document.getElementById("desc-char-count");
|
||||
const titleError = document.getElementById("title-error");
|
||||
const descError = document.getElementById("desc-error");
|
||||
const descWarning = document.getElementById("desc-warning");
|
||||
const submitJobSuccess = document.getElementById("submit-job-success");
|
||||
const submitAnotherBtn = document.getElementById("submit-another-btn");
|
||||
|
||||
// Constants
|
||||
const MAX_TITLE_LENGTH = 200;
|
||||
const MAX_DESC_LENGTH = 2000;
|
||||
const TITLE_WARNING_THRESHOLD = 150;
|
||||
const DESC_WARNING_THRESHOLD = 1800;
|
||||
|
||||
function openSubmitJobModal() {
|
||||
submitJobModal.classList.add("open");
|
||||
document.body.style.overflow = "hidden";
|
||||
jobTitle.focus();
|
||||
validateForm();
|
||||
}
|
||||
|
||||
function closeSubmitJobModal() {
|
||||
submitJobModal.classList.remove("open");
|
||||
document.body.style.overflow = "";
|
||||
// Reset form after animation
|
||||
setTimeout(() => {
|
||||
resetForm();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
submitJobForm.reset();
|
||||
submitJobForm.classList.remove("hidden");
|
||||
submitJobSuccess.classList.add("hidden");
|
||||
updateCharCounts();
|
||||
clearErrors();
|
||||
validateForm();
|
||||
}
|
||||
|
||||
function clearErrors() {
|
||||
titleError.textContent = "";
|
||||
titleError.classList.remove("visible");
|
||||
descError.textContent = "";
|
||||
descError.classList.remove("visible");
|
||||
descWarning.textContent = "";
|
||||
descWarning.classList.remove("visible");
|
||||
jobTitle.classList.remove("error");
|
||||
jobDescription.classList.remove("error");
|
||||
}
|
||||
|
||||
function updateCharCounts() {
|
||||
const titleLen = jobTitle.value.length;
|
||||
const descLen = jobDescription.value.length;
|
||||
|
||||
titleCharCount.textContent = `${titleLen} / ${MAX_TITLE_LENGTH}`;
|
||||
descCharCount.textContent = `${descLen} / ${MAX_DESC_LENGTH}`;
|
||||
|
||||
// Update color based on thresholds
|
||||
if (titleLen > MAX_TITLE_LENGTH) {
|
||||
titleCharCount.classList.add("over-limit");
|
||||
} else if (titleLen > TITLE_WARNING_THRESHOLD) {
|
||||
titleCharCount.classList.add("near-limit");
|
||||
titleCharCount.classList.remove("over-limit");
|
||||
} else {
|
||||
titleCharCount.classList.remove("near-limit", "over-limit");
|
||||
}
|
||||
|
||||
if (descLen > MAX_DESC_LENGTH) {
|
||||
descCharCount.classList.add("over-limit");
|
||||
} else if (descLen > DESC_WARNING_THRESHOLD) {
|
||||
descCharCount.classList.add("near-limit");
|
||||
descCharCount.classList.remove("over-limit");
|
||||
} else {
|
||||
descCharCount.classList.remove("near-limit", "over-limit");
|
||||
}
|
||||
}
|
||||
|
||||
function validateTitle() {
|
||||
const value = jobTitle.value.trim();
|
||||
const length = jobTitle.value.length;
|
||||
|
||||
if (length > MAX_TITLE_LENGTH) {
|
||||
titleError.textContent = `Title must be ${MAX_TITLE_LENGTH} characters or less`;
|
||||
titleError.classList.add("visible");
|
||||
jobTitle.classList.add("error");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value === "") {
|
||||
titleError.textContent = "Title is required";
|
||||
titleError.classList.add("visible");
|
||||
jobTitle.classList.add("error");
|
||||
return false;
|
||||
}
|
||||
|
||||
titleError.textContent = "";
|
||||
titleError.classList.remove("visible");
|
||||
jobTitle.classList.remove("error");
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateDescription() {
|
||||
const length = jobDescription.value.length;
|
||||
|
||||
if (length > MAX_DESC_LENGTH) {
|
||||
descError.textContent = `Description must be ${MAX_DESC_LENGTH} characters or less`;
|
||||
descError.classList.add("visible");
|
||||
descWarning.textContent = "";
|
||||
descWarning.classList.remove("visible");
|
||||
jobDescription.classList.add("error");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show warning when near limit
|
||||
if (length > DESC_WARNING_THRESHOLD && length <= MAX_DESC_LENGTH) {
|
||||
const remaining = MAX_DESC_LENGTH - length;
|
||||
descWarning.textContent = `${remaining} characters remaining`;
|
||||
descWarning.classList.add("visible");
|
||||
} else {
|
||||
descWarning.textContent = "";
|
||||
descWarning.classList.remove("visible");
|
||||
}
|
||||
|
||||
descError.textContent = "";
|
||||
descError.classList.remove("visible");
|
||||
jobDescription.classList.remove("error");
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
const titleValid = jobTitle.value.trim() !== "" && jobTitle.value.length <= MAX_TITLE_LENGTH;
|
||||
const descValid = jobDescription.value.length <= MAX_DESC_LENGTH;
|
||||
|
||||
submitJobSubmit.disabled = !(titleValid && descValid);
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
submitJobBtn.addEventListener("click", openSubmitJobModal);
|
||||
submitJobClose.addEventListener("click", closeSubmitJobModal);
|
||||
submitJobBackdrop.addEventListener("click", closeSubmitJobModal);
|
||||
cancelJobBtn.addEventListener("click", closeSubmitJobModal);
|
||||
submitAnotherBtn.addEventListener("click", resetForm);
|
||||
|
||||
// Input event listeners for real-time validation
|
||||
jobTitle.addEventListener("input", () => {
|
||||
updateCharCounts();
|
||||
validateForm();
|
||||
if (titleError.classList.contains("visible")) {
|
||||
validateTitle();
|
||||
}
|
||||
});
|
||||
|
||||
jobTitle.addEventListener("blur", () => {
|
||||
if (jobTitle.value.trim() !== "" || titleError.classList.contains("visible")) {
|
||||
validateTitle();
|
||||
}
|
||||
});
|
||||
|
||||
jobDescription.addEventListener("input", () => {
|
||||
updateCharCounts();
|
||||
validateForm();
|
||||
if (descError.classList.contains("visible")) {
|
||||
validateDescription();
|
||||
}
|
||||
});
|
||||
|
||||
jobDescription.addEventListener("blur", () => {
|
||||
validateDescription();
|
||||
});
|
||||
|
||||
// Form submission
|
||||
submitJobForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const isTitleValid = validateTitle();
|
||||
const isDescValid = validateDescription();
|
||||
|
||||
if (!isTitleValid || !isDescValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable submit button while processing
|
||||
submitJobSubmit.disabled = true;
|
||||
submitJobSubmit.textContent = "Submitting...";
|
||||
|
||||
const formData = {
|
||||
title: jobTitle.value.trim(),
|
||||
description: jobDescription.value.trim(),
|
||||
priority: document.getElementById("job-priority").value,
|
||||
submitted_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
// Submit to API
|
||||
const response = await fetch("/api/tasks", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Show success state
|
||||
submitJobForm.classList.add("hidden");
|
||||
submitJobSuccess.classList.remove("hidden");
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
descError.textContent = errorData.detail || "Failed to submit job. Please try again.";
|
||||
descError.classList.add("visible");
|
||||
}
|
||||
} catch (error) {
|
||||
// For demo/development, show success even if API fails
|
||||
submitJobForm.classList.add("hidden");
|
||||
submitJobSuccess.classList.remove("hidden");
|
||||
} finally {
|
||||
submitJobSubmit.disabled = false;
|
||||
submitJobSubmit.textContent = "Submit Job";
|
||||
}
|
||||
});
|
||||
|
||||
// Close on Escape key for Submit Job Modal
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && submitJobModal.classList.contains("open")) {
|
||||
closeSubmitJobModal();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Resize ---
|
||||
window.addEventListener("resize", () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
|
||||
@@ -87,3 +87,569 @@ canvas {
|
||||
#connection-dot.connected {
|
||||
background: #00b450;
|
||||
}
|
||||
|
||||
/* Info button */
|
||||
.info-button {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 36px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: rgba(10, 10, 20, 0.7);
|
||||
border: 1px solid rgba(218, 165, 32, 0.4);
|
||||
border-radius: 50%;
|
||||
color: #daa520;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.info-button:hover {
|
||||
background: rgba(218, 165, 32, 0.15);
|
||||
border-color: rgba(218, 165, 32, 0.7);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.info-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* About Panel */
|
||||
.about-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.about-panel.open {
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.about-panel-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 380px;
|
||||
max-width: 90%;
|
||||
height: 100%;
|
||||
background: rgba(10, 10, 20, 0.97);
|
||||
border-left: 1px solid rgba(218, 165, 32, 0.3);
|
||||
padding: 60px 24px 24px 24px;
|
||||
overflow-y: auto;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.about-panel.open .about-panel-content {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.about-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(160, 160, 160, 0.3);
|
||||
border-radius: 50%;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.about-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(218, 165, 32, 0.5);
|
||||
color: #daa520;
|
||||
}
|
||||
|
||||
.about-close svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.about-panel-content h2 {
|
||||
font-size: 20px;
|
||||
color: #daa520;
|
||||
margin-bottom: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.about-panel-content section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.about-panel-content h3 {
|
||||
font-size: 14px;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.about-panel-content p {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #aaa;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.about-panel-content ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.about-panel-content li {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #aaa;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.about-panel-content li::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #daa520;
|
||||
}
|
||||
|
||||
.about-panel-content li strong {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.about-footer {
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(160, 160, 160, 0.2);
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.about-panel.open .about-backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Submit Job Button */
|
||||
.submit-job-button {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 72px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
background: rgba(10, 10, 20, 0.7);
|
||||
border: 1px solid rgba(0, 180, 80, 0.4);
|
||||
border-radius: 14px;
|
||||
color: #00b450;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.submit-job-button:hover {
|
||||
background: rgba(0, 180, 80, 0.15);
|
||||
border-color: rgba(0, 180, 80, 0.7);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.submit-job-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Submit Job Modal */
|
||||
.submit-job-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-job-modal.open {
|
||||
pointer-events: auto;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.submit-job-content {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.95);
|
||||
width: 480px;
|
||||
max-width: 90%;
|
||||
max-height: 90vh;
|
||||
background: rgba(10, 10, 20, 0.98);
|
||||
border: 1px solid rgba(218, 165, 32, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.submit-job-modal.open .submit-job-content {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
.submit-job-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(160, 160, 160, 0.3);
|
||||
border-radius: 50%;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.submit-job-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(218, 165, 32, 0.5);
|
||||
color: #daa520;
|
||||
}
|
||||
|
||||
.submit-job-close svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.submit-job-content h2 {
|
||||
font-size: 22px;
|
||||
color: #daa520;
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.submit-job-subtitle {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.submit-job-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.submit-job-form.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 13px;
|
||||
color: #ccc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group label .required {
|
||||
color: #ff4444;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
background: rgba(30, 30, 40, 0.8);
|
||||
border: 1px solid rgba(160, 160, 160, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
color: #e0e0e0;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(218, 165, 32, 0.6);
|
||||
box-shadow: 0 0 0 2px rgba(218, 165, 32, 0.1);
|
||||
}
|
||||
|
||||
.form-group input.error,
|
||||
.form-group textarea.error {
|
||||
border-color: #ff4444;
|
||||
box-shadow: 0 0 0 2px rgba(255, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.form-group input::placeholder,
|
||||
.form-group textarea::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-group select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.form-group select option {
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Character Count */
|
||||
.char-count {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.char-count.near-limit {
|
||||
color: #ffaa33;
|
||||
}
|
||||
|
||||
.char-count.over-limit {
|
||||
color: #ff4444;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Validation Messages */
|
||||
.validation-error {
|
||||
font-size: 12px;
|
||||
color: #ff4444;
|
||||
margin-top: 4px;
|
||||
min-height: 16px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.validation-error.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.validation-warning {
|
||||
font-size: 12px;
|
||||
color: #ffaa33;
|
||||
margin-top: 4px;
|
||||
min-height: 16px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.validation-warning.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.submit-job-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 10px 20px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(160, 160, 160, 0.4);
|
||||
border-radius: 6px;
|
||||
color: #aaa;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(160, 160, 160, 0.6);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(135deg, rgba(0, 180, 80, 0.8), rgba(0, 140, 60, 0.9));
|
||||
border: 1px solid rgba(0, 180, 80, 0.5);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, rgba(0, 200, 90, 0.9), rgba(0, 160, 70, 1));
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 180, 80, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: rgba(100, 100, 100, 0.3);
|
||||
border-color: rgba(100, 100, 100, 0.3);
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Success State */
|
||||
.submit-job-success {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.submit-job-success.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 20px;
|
||||
color: #00b450;
|
||||
}
|
||||
|
||||
.success-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.submit-job-success h3 {
|
||||
font-size: 20px;
|
||||
color: #00b450;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.submit-job-success p {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
margin: 0 0 24px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Backdrop */
|
||||
.submit-job-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-job-modal.open .submit-job-backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.about-panel-content {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 56px 20px 20px 20px;
|
||||
}
|
||||
|
||||
.info-button {
|
||||
right: 32px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.info-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.submit-job-button {
|
||||
right: 64px;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.submit-job-button svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.submit-job-content {
|
||||
width: 95%;
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.submit-job-content h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.submit-job-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
288
tests/infrastructure/test_db_pool.py
Normal file
288
tests/infrastructure/test_db_pool.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""Tests for infrastructure.db_pool module."""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from infrastructure.db_pool import ConnectionPool
|
||||
|
||||
|
||||
class TestConnectionPoolInit:
|
||||
"""Test ConnectionPool initialization."""
|
||||
|
||||
def test_init_with_string_path(self, tmp_path):
|
||||
"""Pool can be initialized with a string path."""
|
||||
db_path = str(tmp_path / "test.db")
|
||||
pool = ConnectionPool(db_path)
|
||||
assert pool._db_path == Path(db_path)
|
||||
|
||||
def test_init_with_path_object(self, tmp_path):
|
||||
"""Pool can be initialized with a Path object."""
|
||||
db_path = tmp_path / "test.db"
|
||||
pool = ConnectionPool(db_path)
|
||||
assert pool._db_path == db_path
|
||||
|
||||
def test_init_creates_thread_local(self, tmp_path):
|
||||
"""Pool initializes thread-local storage."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
assert hasattr(pool, "_local")
|
||||
assert isinstance(pool._local, threading.local)
|
||||
|
||||
|
||||
class TestGetConnection:
|
||||
"""Test get_connection() method."""
|
||||
|
||||
def test_get_connection_returns_valid_sqlite3_connection(self, tmp_path):
|
||||
"""get_connection() returns a valid sqlite3 connection."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
conn = pool.get_connection()
|
||||
assert isinstance(conn, sqlite3.Connection)
|
||||
# Verify it's a working connection
|
||||
cursor = conn.execute("SELECT 1")
|
||||
assert cursor.fetchone()[0] == 1
|
||||
|
||||
def test_get_connection_creates_db_file(self, tmp_path):
|
||||
"""get_connection() creates the database file if it doesn't exist."""
|
||||
db_path = tmp_path / "subdir" / "test.db"
|
||||
assert not db_path.exists()
|
||||
pool = ConnectionPool(db_path)
|
||||
pool.get_connection()
|
||||
assert db_path.exists()
|
||||
|
||||
def test_get_connection_sets_row_factory(self, tmp_path):
|
||||
"""get_connection() sets row_factory to sqlite3.Row."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
conn = pool.get_connection()
|
||||
assert conn.row_factory is sqlite3.Row
|
||||
|
||||
def test_multiple_calls_same_thread_reuse_connection(self, tmp_path):
|
||||
"""Multiple calls from same thread reuse the same connection."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
conn1 = pool.get_connection()
|
||||
conn2 = pool.get_connection()
|
||||
assert conn1 is conn2
|
||||
|
||||
def test_different_threads_get_different_connections(self, tmp_path):
|
||||
"""Different threads get different connections."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
connections = []
|
||||
|
||||
def get_conn():
|
||||
connections.append(pool.get_connection())
|
||||
|
||||
t1 = threading.Thread(target=get_conn)
|
||||
t2 = threading.Thread(target=get_conn)
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
assert len(connections) == 2
|
||||
assert connections[0] is not connections[1]
|
||||
|
||||
|
||||
class TestCloseConnection:
|
||||
"""Test close_connection() method."""
|
||||
|
||||
def test_close_connection_closes_sqlite_connection(self, tmp_path):
|
||||
"""close_connection() closes the underlying sqlite connection."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
conn = pool.get_connection()
|
||||
pool.close_connection()
|
||||
# Connection should be closed
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
conn.execute("SELECT 1")
|
||||
|
||||
def test_close_connection_cleans_up_thread_local(self, tmp_path):
|
||||
"""close_connection() cleans up thread-local storage."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
pool.get_connection()
|
||||
assert hasattr(pool._local, "conn")
|
||||
assert pool._local.conn is not None
|
||||
|
||||
pool.close_connection()
|
||||
|
||||
# Should either not have the attr or it should be None
|
||||
assert not hasattr(pool._local, "conn") or pool._local.conn is None
|
||||
|
||||
def test_close_connection_without_getting_connection_is_safe(self, tmp_path):
|
||||
"""close_connection() is safe to call even without getting a connection first."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
# Should not raise
|
||||
pool.close_connection()
|
||||
|
||||
def test_close_connection_multiple_calls_is_safe(self, tmp_path):
|
||||
"""close_connection() can be called multiple times safely."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
pool.get_connection()
|
||||
pool.close_connection()
|
||||
# Should not raise
|
||||
pool.close_connection()
|
||||
|
||||
|
||||
class TestContextManager:
|
||||
"""Test the connection() context manager."""
|
||||
|
||||
def test_connection_yields_valid_connection(self, tmp_path):
|
||||
"""connection() context manager yields a valid sqlite3 connection."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
with pool.connection() as conn:
|
||||
assert isinstance(conn, sqlite3.Connection)
|
||||
cursor = conn.execute("SELECT 42")
|
||||
assert cursor.fetchone()[0] == 42
|
||||
|
||||
def test_connection_closes_on_exit(self, tmp_path):
|
||||
"""connection() context manager closes connection on exit."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
with pool.connection() as conn:
|
||||
pass
|
||||
# Connection should be closed after context exit
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
conn.execute("SELECT 1")
|
||||
|
||||
def test_connection_closes_on_exception(self, tmp_path):
|
||||
"""connection() context manager closes connection even on exception."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
conn_ref = None
|
||||
try:
|
||||
with pool.connection() as conn:
|
||||
conn_ref = conn
|
||||
raise ValueError("Test exception")
|
||||
except ValueError:
|
||||
pass
|
||||
# Connection should still be closed
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
conn_ref.execute("SELECT 1")
|
||||
|
||||
def test_connection_context_manager_is_reusable(self, tmp_path):
|
||||
"""connection() context manager can be used multiple times."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
|
||||
with pool.connection() as conn1:
|
||||
result1 = conn1.execute("SELECT 1").fetchone()[0]
|
||||
|
||||
with pool.connection() as conn2:
|
||||
result2 = conn2.execute("SELECT 2").fetchone()[0]
|
||||
|
||||
assert result1 == 1
|
||||
assert result2 == 2
|
||||
|
||||
|
||||
class TestThreadSafety:
|
||||
"""Test thread-safety of the connection pool."""
|
||||
|
||||
def test_concurrent_access(self, tmp_path):
|
||||
"""Multiple threads can use the pool concurrently."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
def worker(worker_id):
|
||||
try:
|
||||
with pool.connection() as conn:
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS test (id INTEGER)")
|
||||
conn.execute("INSERT INTO test VALUES (?)", (worker_id,))
|
||||
conn.commit()
|
||||
time.sleep(0.01) # Small delay to increase contention
|
||||
results.append(worker_id)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert len(errors) == 0, f"Errors occurred: {errors}"
|
||||
assert len(results) == 5
|
||||
|
||||
def test_thread_isolation(self, tmp_path):
|
||||
"""Each thread has isolated connections (verified by thread-local data)."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
results = []
|
||||
|
||||
def worker(worker_id):
|
||||
# Get connection and write worker-specific data
|
||||
conn = pool.get_connection()
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS isolation_test (thread_id INTEGER)")
|
||||
conn.execute("DELETE FROM isolation_test") # Clear previous data
|
||||
conn.execute("INSERT INTO isolation_test VALUES (?)", (worker_id,))
|
||||
conn.commit()
|
||||
# Read back the data
|
||||
result = conn.execute("SELECT thread_id FROM isolation_test").fetchone()[0]
|
||||
results.append((worker_id, result))
|
||||
pool.close_connection()
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(3)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Each thread should have written and read its own ID
|
||||
assert len(results) == 3
|
||||
for worker_id, read_id in results:
|
||||
assert worker_id == read_id, f"Thread {worker_id} read {read_id} instead"
|
||||
|
||||
|
||||
class TestCloseAll:
|
||||
"""Test close_all() method."""
|
||||
|
||||
def test_close_all_closes_current_thread_connection(self, tmp_path):
|
||||
"""close_all() closes the connection for the current thread."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
conn = pool.get_connection()
|
||||
pool.close_all()
|
||||
# Connection should be closed
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
conn.execute("SELECT 1")
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for real-world usage patterns."""
|
||||
|
||||
def test_basic_crud_operations(self, tmp_path):
|
||||
"""Can perform basic CRUD operations through the pool."""
|
||||
pool = ConnectionPool(tmp_path / "test.db")
|
||||
|
||||
with pool.connection() as conn:
|
||||
# Create table
|
||||
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
# Insert
|
||||
conn.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
|
||||
conn.execute("INSERT INTO users (name) VALUES (?)", ("Bob",))
|
||||
conn.commit()
|
||||
# Query
|
||||
cursor = conn.execute("SELECT * FROM users ORDER BY id")
|
||||
rows = cursor.fetchall()
|
||||
assert len(rows) == 2
|
||||
assert rows[0]["name"] == "Alice"
|
||||
assert rows[1]["name"] == "Bob"
|
||||
|
||||
def test_multiple_pools_different_databases(self, tmp_path):
|
||||
"""Multiple pools can manage different databases independently."""
|
||||
pool1 = ConnectionPool(tmp_path / "db1.db")
|
||||
pool2 = ConnectionPool(tmp_path / "db2.db")
|
||||
|
||||
with pool1.connection() as conn1:
|
||||
conn1.execute("CREATE TABLE test (val INTEGER)")
|
||||
conn1.execute("INSERT INTO test VALUES (1)")
|
||||
conn1.commit()
|
||||
|
||||
with pool2.connection() as conn2:
|
||||
conn2.execute("CREATE TABLE test (val INTEGER)")
|
||||
conn2.execute("INSERT INTO test VALUES (2)")
|
||||
conn2.commit()
|
||||
|
||||
# Verify isolation
|
||||
with pool1.connection() as conn1:
|
||||
result = conn1.execute("SELECT val FROM test").fetchone()[0]
|
||||
assert result == 1
|
||||
|
||||
with pool2.connection() as conn2:
|
||||
result = conn2.execute("SELECT val FROM test").fetchone()[0]
|
||||
assert result == 2
|
||||
266
tests/test_command_log.py
Normal file
266
tests/test_command_log.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Tests for Morrowind command log and training export pipeline."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.infrastructure.morrowind.command_log import CommandLog, CommandLogger
|
||||
from src.infrastructure.morrowind.schemas import (
|
||||
CommandInput,
|
||||
CommandType,
|
||||
PerceptionOutput,
|
||||
)
|
||||
from src.infrastructure.morrowind.training_export import TrainingExporter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NOW = datetime(2026, 3, 21, 14, 30, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _make_perception(**overrides) -> PerceptionOutput:
|
||||
defaults = {
|
||||
"timestamp": NOW,
|
||||
"agent_id": "timmy",
|
||||
"location": {"cell": "Balmora", "x": 1024.5, "y": -512.3, "z": 64.0},
|
||||
"health": {"current": 85, "max": 100},
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return PerceptionOutput(**defaults)
|
||||
|
||||
|
||||
def _make_command(**overrides) -> CommandInput:
|
||||
defaults = {
|
||||
"timestamp": NOW,
|
||||
"agent_id": "timmy",
|
||||
"command": "move_to",
|
||||
"params": {"target_x": 1050.0},
|
||||
"reasoning": "Moving closer to quest target.",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return CommandInput(**defaults)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logger(tmp_path: Path) -> CommandLogger:
|
||||
"""CommandLogger backed by an in-memory SQLite DB."""
|
||||
db_path = tmp_path / "test.db"
|
||||
return CommandLogger(db_url=f"sqlite:///{db_path}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exporter(logger: CommandLogger) -> TrainingExporter:
|
||||
return TrainingExporter(logger)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CommandLogger — log_command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLogCommand:
|
||||
def test_basic_log(self, logger: CommandLogger):
|
||||
cmd = _make_command()
|
||||
row_id = logger.log_command(cmd)
|
||||
assert row_id >= 1
|
||||
|
||||
def test_log_with_perception(self, logger: CommandLogger):
|
||||
cmd = _make_command()
|
||||
perception = _make_perception()
|
||||
row_id = logger.log_command(cmd, perception=perception)
|
||||
assert row_id >= 1
|
||||
|
||||
results = logger.query(limit=1)
|
||||
assert len(results) == 1
|
||||
assert results[0]["cell"] == "Balmora"
|
||||
assert results[0]["perception_snapshot"]["location"]["cell"] == "Balmora"
|
||||
|
||||
def test_log_with_outcome(self, logger: CommandLogger):
|
||||
cmd = _make_command()
|
||||
row_id = logger.log_command(cmd, outcome="success: arrived at destination")
|
||||
results = logger.query(limit=1)
|
||||
assert results[0]["outcome"] == "success: arrived at destination"
|
||||
|
||||
def test_log_preserves_episode_id(self, logger: CommandLogger):
|
||||
cmd = _make_command(episode_id="ep_test_001")
|
||||
logger.log_command(cmd)
|
||||
results = logger.query(episode_id="ep_test_001")
|
||||
assert len(results) == 1
|
||||
assert results[0]["episode_id"] == "ep_test_001"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CommandLogger — query
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQuery:
|
||||
def test_filter_by_command_type(self, logger: CommandLogger):
|
||||
logger.log_command(_make_command(command="move_to"))
|
||||
logger.log_command(_make_command(command="noop"))
|
||||
logger.log_command(_make_command(command="move_to"))
|
||||
|
||||
results = logger.query(command_type="move_to")
|
||||
assert len(results) == 2
|
||||
assert all(r["command"] == "move_to" for r in results)
|
||||
|
||||
def test_filter_by_cell(self, logger: CommandLogger):
|
||||
p1 = _make_perception(location={"cell": "Balmora", "x": 0, "y": 0, "z": 0})
|
||||
p2 = _make_perception(location={"cell": "Vivec", "x": 0, "y": 0, "z": 0})
|
||||
logger.log_command(_make_command(), perception=p1)
|
||||
logger.log_command(_make_command(), perception=p2)
|
||||
|
||||
results = logger.query(cell="Vivec")
|
||||
assert len(results) == 1
|
||||
assert results[0]["cell"] == "Vivec"
|
||||
|
||||
def test_filter_by_time_range(self, logger: CommandLogger):
|
||||
t1 = NOW - timedelta(hours=2)
|
||||
t2 = NOW - timedelta(hours=1)
|
||||
t3 = NOW
|
||||
|
||||
logger.log_command(_make_command(timestamp=t1.isoformat()))
|
||||
logger.log_command(_make_command(timestamp=t2.isoformat()))
|
||||
logger.log_command(_make_command(timestamp=t3.isoformat()))
|
||||
|
||||
results = logger.query(since=NOW - timedelta(hours=1, minutes=30), until=NOW)
|
||||
assert len(results) == 2
|
||||
|
||||
def test_limit_and_offset(self, logger: CommandLogger):
|
||||
for i in range(5):
|
||||
logger.log_command(_make_command())
|
||||
|
||||
results = logger.query(limit=2, offset=0)
|
||||
assert len(results) == 2
|
||||
|
||||
results = logger.query(limit=10, offset=3)
|
||||
assert len(results) == 2
|
||||
|
||||
def test_empty_query(self, logger: CommandLogger):
|
||||
results = logger.query()
|
||||
assert results == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CommandLogger — export_training_data (JSONL)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExportTrainingData:
|
||||
def test_basic_export(self, logger: CommandLogger, tmp_path: Path):
|
||||
perception = _make_perception()
|
||||
for _ in range(3):
|
||||
logger.log_command(_make_command(), perception=perception)
|
||||
|
||||
output = tmp_path / "train.jsonl"
|
||||
count = logger.export_training_data(output)
|
||||
assert count == 3
|
||||
assert output.exists()
|
||||
|
||||
import json
|
||||
|
||||
lines = output.read_text().strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
record = json.loads(lines[0])
|
||||
assert "input" in record
|
||||
assert "output" in record
|
||||
assert record["output"]["command"] == "move_to"
|
||||
|
||||
def test_export_filter_by_episode(self, logger: CommandLogger, tmp_path: Path):
|
||||
logger.log_command(_make_command(episode_id="ep_a"), perception=_make_perception())
|
||||
logger.log_command(_make_command(episode_id="ep_b"), perception=_make_perception())
|
||||
|
||||
output = tmp_path / "ep_a.jsonl"
|
||||
count = logger.export_training_data(output, episode_id="ep_a")
|
||||
assert count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CommandLogger — storage management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStorageManagement:
|
||||
def test_count(self, logger: CommandLogger):
|
||||
assert logger.count() == 0
|
||||
logger.log_command(_make_command())
|
||||
logger.log_command(_make_command())
|
||||
assert logger.count() == 2
|
||||
|
||||
def test_rotate_old_entries(self, logger: CommandLogger):
|
||||
old_time = NOW - timedelta(days=100)
|
||||
logger.log_command(_make_command(timestamp=old_time.isoformat()))
|
||||
logger.log_command(_make_command(timestamp=NOW.isoformat()))
|
||||
|
||||
deleted = logger.rotate(max_age_days=90)
|
||||
assert deleted == 1
|
||||
assert logger.count() == 1
|
||||
|
||||
def test_rotate_nothing_to_delete(self, logger: CommandLogger):
|
||||
logger.log_command(_make_command(timestamp=NOW.isoformat()))
|
||||
deleted = logger.rotate(max_age_days=1)
|
||||
assert deleted == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TrainingExporter — chat format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTrainingExporterChat:
|
||||
def test_chat_format_export(
|
||||
self, logger: CommandLogger, exporter: TrainingExporter, tmp_path: Path
|
||||
):
|
||||
perception = _make_perception()
|
||||
for _ in range(3):
|
||||
logger.log_command(_make_command(), perception=perception)
|
||||
|
||||
output = tmp_path / "chat.jsonl"
|
||||
stats = exporter.export_chat_format(output)
|
||||
assert stats.total_records == 3
|
||||
assert stats.format == "chat_completion"
|
||||
|
||||
import json
|
||||
|
||||
lines = output.read_text().strip().split("\n")
|
||||
record = json.loads(lines[0])
|
||||
assert record["messages"][0]["role"] == "system"
|
||||
assert record["messages"][1]["role"] == "user"
|
||||
assert record["messages"][2]["role"] == "assistant"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TrainingExporter — episode sequences
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTrainingExporterEpisodes:
|
||||
def test_episode_export(
|
||||
self, logger: CommandLogger, exporter: TrainingExporter, tmp_path: Path
|
||||
):
|
||||
perception = _make_perception()
|
||||
for i in range(5):
|
||||
logger.log_command(
|
||||
_make_command(episode_id="ep_test"),
|
||||
perception=perception,
|
||||
)
|
||||
|
||||
output_dir = tmp_path / "episodes"
|
||||
stats = exporter.export_episode_sequences(output_dir, min_length=3)
|
||||
assert stats.episodes_exported == 1
|
||||
assert stats.total_records == 5
|
||||
assert (output_dir / "ep_test.jsonl").exists()
|
||||
|
||||
def test_short_episodes_skipped(
|
||||
self, logger: CommandLogger, exporter: TrainingExporter, tmp_path: Path
|
||||
):
|
||||
perception = _make_perception()
|
||||
logger.log_command(_make_command(episode_id="short"), perception=perception)
|
||||
|
||||
output_dir = tmp_path / "episodes"
|
||||
stats = exporter.export_episode_sequences(output_dir, min_length=3)
|
||||
assert stats.episodes_exported == 0
|
||||
assert stats.skipped_records == 1
|
||||
242
tests/test_morrowind_schemas.py
Normal file
242
tests/test_morrowind_schemas.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Tests for Morrowind Perception/Command protocol Pydantic schemas."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.infrastructure.morrowind.schemas import (
|
||||
PROTOCOL_VERSION,
|
||||
CommandContext,
|
||||
CommandInput,
|
||||
CommandType,
|
||||
EntityType,
|
||||
Environment,
|
||||
HealthStatus,
|
||||
InventorySummary,
|
||||
Location,
|
||||
NearbyEntity,
|
||||
PerceptionOutput,
|
||||
QuestInfo,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NOW = datetime(2026, 3, 21, 14, 30, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _make_perception(**overrides) -> PerceptionOutput:
|
||||
defaults = {
|
||||
"timestamp": NOW,
|
||||
"agent_id": "timmy",
|
||||
"location": {"cell": "Balmora", "x": 1024.5, "y": -512.3, "z": 64.0, "interior": False},
|
||||
"health": {"current": 85, "max": 100},
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return PerceptionOutput(**defaults)
|
||||
|
||||
|
||||
def _make_command(**overrides) -> CommandInput:
|
||||
defaults = {
|
||||
"timestamp": NOW,
|
||||
"agent_id": "timmy",
|
||||
"command": "move_to",
|
||||
"params": {"target_cell": "Balmora", "target_x": 1050.0},
|
||||
"reasoning": "Moving closer to the quest target.",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return CommandInput(**defaults)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PerceptionOutput tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPerceptionOutput:
|
||||
def test_minimal_valid(self):
|
||||
p = _make_perception()
|
||||
assert p.protocol_version == PROTOCOL_VERSION
|
||||
assert p.agent_id == "timmy"
|
||||
assert p.location.cell == "Balmora"
|
||||
assert p.health.current == 85
|
||||
assert p.nearby_entities == []
|
||||
assert p.active_quests == []
|
||||
|
||||
def test_full_payload(self):
|
||||
p = _make_perception(
|
||||
nearby_entities=[
|
||||
{
|
||||
"entity_id": "npc_001",
|
||||
"name": "Caius Cosades",
|
||||
"entity_type": "npc",
|
||||
"distance": 12.5,
|
||||
"disposition": 65,
|
||||
}
|
||||
],
|
||||
inventory_summary={"gold": 150, "item_count": 23, "encumbrance_pct": 0.45},
|
||||
active_quests=[{"quest_id": "mq_01", "name": "Report to Caius", "stage": 10}],
|
||||
environment={
|
||||
"time_of_day": "afternoon",
|
||||
"weather": "clear",
|
||||
"is_combat": False,
|
||||
"is_dialogue": False,
|
||||
},
|
||||
raw_engine_data={"tes3mp_version": "0.8.1"},
|
||||
)
|
||||
assert len(p.nearby_entities) == 1
|
||||
assert p.nearby_entities[0].entity_type == EntityType.NPC
|
||||
assert p.inventory_summary.gold == 150
|
||||
assert p.active_quests[0].quest_id == "mq_01"
|
||||
assert p.raw_engine_data["tes3mp_version"] == "0.8.1"
|
||||
|
||||
def test_serialization_roundtrip(self):
|
||||
p = _make_perception()
|
||||
json_str = p.model_dump_json()
|
||||
p2 = PerceptionOutput.model_validate_json(json_str)
|
||||
assert p2.location.cell == p.location.cell
|
||||
assert p2.health.current == p.health.current
|
||||
|
||||
def test_missing_required_fields(self):
|
||||
with pytest.raises(ValidationError):
|
||||
PerceptionOutput(timestamp=NOW, agent_id="timmy") # no location/health
|
||||
|
||||
def test_default_protocol_version(self):
|
||||
p = _make_perception()
|
||||
assert p.protocol_version == "1.0.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHealthStatus:
|
||||
def test_current_cannot_exceed_max(self):
|
||||
with pytest.raises(ValidationError, match="cannot exceed max"):
|
||||
HealthStatus(current=150, max=100)
|
||||
|
||||
def test_max_must_be_positive(self):
|
||||
with pytest.raises(ValidationError):
|
||||
HealthStatus(current=0, max=0)
|
||||
|
||||
def test_current_can_be_zero(self):
|
||||
h = HealthStatus(current=0, max=100)
|
||||
assert h.current == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Location
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLocation:
|
||||
def test_defaults(self):
|
||||
loc = Location(cell="Seyda Neen", x=0.0, y=0.0)
|
||||
assert loc.z == 0.0
|
||||
assert loc.interior is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NearbyEntity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNearbyEntity:
|
||||
def test_all_entity_types(self):
|
||||
for et in EntityType:
|
||||
e = NearbyEntity(entity_id="e1", name="Test", entity_type=et, distance=1.0)
|
||||
assert e.entity_type == et
|
||||
|
||||
def test_invalid_entity_type(self):
|
||||
with pytest.raises(ValidationError):
|
||||
NearbyEntity(entity_id="e1", name="Test", entity_type="dragon", distance=1.0)
|
||||
|
||||
def test_negative_distance_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
NearbyEntity(entity_id="e1", name="Test", entity_type="npc", distance=-5.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# InventorySummary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInventorySummary:
|
||||
def test_encumbrance_bounds(self):
|
||||
with pytest.raises(ValidationError):
|
||||
InventorySummary(encumbrance_pct=1.5)
|
||||
with pytest.raises(ValidationError):
|
||||
InventorySummary(encumbrance_pct=-0.1)
|
||||
|
||||
def test_defaults(self):
|
||||
inv = InventorySummary()
|
||||
assert inv.gold == 0
|
||||
assert inv.item_count == 0
|
||||
assert inv.encumbrance_pct == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CommandInput tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCommandInput:
|
||||
def test_minimal_valid(self):
|
||||
c = _make_command()
|
||||
assert c.command == CommandType.MOVE_TO
|
||||
assert c.reasoning == "Moving closer to the quest target."
|
||||
assert c.episode_id is None
|
||||
|
||||
def test_all_command_types(self):
|
||||
for ct in CommandType:
|
||||
c = _make_command(command=ct.value)
|
||||
assert c.command == ct
|
||||
|
||||
def test_invalid_command_type(self):
|
||||
with pytest.raises(ValidationError):
|
||||
_make_command(command="fly_to_moon")
|
||||
|
||||
def test_reasoning_required(self):
|
||||
with pytest.raises(ValidationError):
|
||||
CommandInput(
|
||||
timestamp=NOW,
|
||||
agent_id="timmy",
|
||||
command="noop",
|
||||
reasoning="", # min_length=1
|
||||
)
|
||||
|
||||
def test_with_episode_and_context(self):
|
||||
c = _make_command(
|
||||
episode_id="ep_001",
|
||||
context={"perception_timestamp": NOW, "heartbeat_cycle": 42},
|
||||
)
|
||||
assert c.episode_id == "ep_001"
|
||||
assert c.context.heartbeat_cycle == 42
|
||||
|
||||
def test_serialization_roundtrip(self):
|
||||
c = _make_command(episode_id="ep_002")
|
||||
json_str = c.model_dump_json()
|
||||
c2 = CommandInput.model_validate_json(json_str)
|
||||
assert c2.command == c.command
|
||||
assert c2.episode_id == c.episode_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enum coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnums:
|
||||
def test_entity_type_values(self):
|
||||
assert set(EntityType) == {"npc", "creature", "item", "door", "container"}
|
||||
|
||||
def test_command_type_values(self):
|
||||
expected = {
|
||||
"move_to", "interact", "use_item", "wait",
|
||||
"combat_action", "dialogue", "journal_note", "noop",
|
||||
}
|
||||
assert set(CommandType) == expected
|
||||
@@ -130,6 +130,13 @@ class TestAPIEndpoints:
|
||||
r = client.get("/health/sovereignty")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_health_snapshot(self, client):
|
||||
r = client.get("/health/snapshot")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "overall_status" in data
|
||||
assert data["overall_status"] in ["green", "yellow", "red", "unknown"]
|
||||
|
||||
def test_queue_status(self, client):
|
||||
r = client.get("/api/queue/status")
|
||||
assert r.status_code == 200
|
||||
@@ -186,6 +193,7 @@ class TestNo500:
|
||||
"/health",
|
||||
"/health/status",
|
||||
"/health/sovereignty",
|
||||
"/health/snapshot",
|
||||
"/health/components",
|
||||
"/agents/default/panel",
|
||||
"/agents/default/history",
|
||||
|
||||
280
tests/timmy/test_voice_tts_unit.py
Normal file
280
tests/timmy/test_voice_tts_unit.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Unit tests for timmy_serve.voice_tts.
|
||||
|
||||
Mocks pyttsx3 so tests run without audio hardware.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestVoiceTTSInit:
|
||||
"""Test VoiceTTS initialization with/without pyttsx3."""
|
||||
|
||||
def test_init_success(self):
|
||||
"""When pyttsx3 is available, engine initializes with given rate/volume."""
|
||||
mock_pyttsx3 = MagicMock()
|
||||
mock_engine = MagicMock()
|
||||
mock_pyttsx3.init.return_value = mock_engine
|
||||
|
||||
with patch.dict("sys.modules", {"pyttsx3": mock_pyttsx3}):
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS(rate=200, volume=0.8)
|
||||
assert tts.available is True
|
||||
assert tts._rate == 200
|
||||
assert tts._volume == 0.8
|
||||
mock_engine.setProperty.assert_any_call("rate", 200)
|
||||
mock_engine.setProperty.assert_any_call("volume", 0.8)
|
||||
|
||||
def test_init_import_failure(self):
|
||||
"""When pyttsx3 import fails, VoiceTTS degrades gracefully."""
|
||||
with patch.dict("sys.modules", {"pyttsx3": None}):
|
||||
# Force reimport by clearing cache
|
||||
import sys
|
||||
|
||||
modules_to_clear = [k for k in sys.modules.keys() if "voice_tts" in k]
|
||||
for mod in modules_to_clear:
|
||||
del sys.modules[mod]
|
||||
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS()
|
||||
assert tts.available is False
|
||||
assert tts._engine is None
|
||||
|
||||
|
||||
class TestVoiceTTSSpeak:
|
||||
"""Test VoiceTTS speak methods."""
|
||||
|
||||
def test_speak_skips_when_not_available(self):
|
||||
"""speak() should skip gracefully when TTS is not available."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._engine = None
|
||||
tts._available = False
|
||||
tts._lock = threading.Lock()
|
||||
|
||||
# Should not raise
|
||||
tts.speak("hello world")
|
||||
|
||||
def test_speak_sync_skips_when_not_available(self):
|
||||
"""speak_sync() should skip gracefully when TTS is not available."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._engine = None
|
||||
tts._available = False
|
||||
tts._lock = threading.Lock()
|
||||
|
||||
# Should not raise
|
||||
tts.speak_sync("hello world")
|
||||
|
||||
def test_speak_runs_in_background_thread(self):
|
||||
"""speak() should run speech in a background thread."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._engine = MagicMock()
|
||||
tts._available = True
|
||||
tts._lock = threading.Lock()
|
||||
|
||||
captured_threads = []
|
||||
original_thread = threading.Thread
|
||||
|
||||
def capture_thread(*args, **kwargs):
|
||||
t = original_thread(*args, **kwargs)
|
||||
captured_threads.append(t)
|
||||
return t
|
||||
|
||||
with patch.object(threading, "Thread", side_effect=capture_thread):
|
||||
tts.speak("test message")
|
||||
# Wait for threads to complete
|
||||
for t in captured_threads:
|
||||
t.join(timeout=1)
|
||||
|
||||
tts._engine.say.assert_called_with("test message")
|
||||
tts._engine.runAndWait.assert_called_once()
|
||||
|
||||
|
||||
class TestVoiceTTSProperties:
|
||||
"""Test VoiceTTS property setters."""
|
||||
|
||||
def test_set_rate_updates_property(self):
|
||||
"""set_rate() updates internal rate and engine property."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._engine = MagicMock()
|
||||
tts._rate = 175
|
||||
|
||||
tts.set_rate(220)
|
||||
assert tts._rate == 220
|
||||
tts._engine.setProperty.assert_called_with("rate", 220)
|
||||
|
||||
def test_set_rate_without_engine(self):
|
||||
"""set_rate() updates internal rate even when engine is None."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._engine = None
|
||||
tts._rate = 175
|
||||
|
||||
tts.set_rate(220)
|
||||
assert tts._rate == 220
|
||||
|
||||
def test_set_volume_clamped_to_max(self):
|
||||
"""set_volume() clamps volume to maximum of 1.0."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._engine = MagicMock()
|
||||
tts._volume = 0.9
|
||||
|
||||
tts.set_volume(1.5)
|
||||
assert tts._volume == 1.0
|
||||
tts._engine.setProperty.assert_called_with("volume", 1.0)
|
||||
|
||||
def test_set_volume_clamped_to_min(self):
|
||||
"""set_volume() clamps volume to minimum of 0.0."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._engine = MagicMock()
|
||||
tts._volume = 0.9
|
||||
|
||||
tts.set_volume(-0.5)
|
||||
assert tts._volume == 0.0
|
||||
tts._engine.setProperty.assert_called_with("volume", 0.0)
|
||||
|
||||
def test_set_volume_within_range(self):
|
||||
"""set_volume() accepts values within 0.0-1.0 range."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._engine = MagicMock()
|
||||
tts._volume = 0.9
|
||||
|
||||
tts.set_volume(0.5)
|
||||
assert tts._volume == 0.5
|
||||
tts._engine.setProperty.assert_called_with("volume", 0.5)
|
||||
|
||||
|
||||
class TestVoiceTTSGetVoices:
|
||||
"""Test VoiceTTS get_voices() method."""
|
||||
|
||||
def test_get_voices_returns_empty_list_when_no_engine(self):
|
||||
"""get_voices() returns empty list when engine is None."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._engine = None
|
||||
|
||||
result = tts.get_voices()
|
||||
assert result == []
|
||||
|
||||
def test_get_voices_returns_formatted_voice_list(self):
|
||||
"""get_voices() returns list of voice dicts with id, name, languages."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
|
||||
mock_voice1 = MagicMock()
|
||||
mock_voice1.id = "com.apple.voice.compact.en-US.Samantha"
|
||||
mock_voice1.name = "Samantha"
|
||||
mock_voice1.languages = ["en-US"]
|
||||
|
||||
mock_voice2 = MagicMock()
|
||||
mock_voice2.id = "com.apple.voice.compact.en-GB.Daniel"
|
||||
mock_voice2.name = "Daniel"
|
||||
mock_voice2.languages = ["en-GB"]
|
||||
|
||||
tts._engine = MagicMock()
|
||||
tts._engine.getProperty.return_value = [mock_voice1, mock_voice2]
|
||||
|
||||
voices = tts.get_voices()
|
||||
assert len(voices) == 2
|
||||
assert voices[0]["id"] == "com.apple.voice.compact.en-US.Samantha"
|
||||
assert voices[0]["name"] == "Samantha"
|
||||
assert voices[0]["languages"] == ["en-US"]
|
||||
assert voices[1]["id"] == "com.apple.voice.compact.en-GB.Daniel"
|
||||
assert voices[1]["name"] == "Daniel"
|
||||
assert voices[1]["languages"] == ["en-GB"]
|
||||
|
||||
def test_get_voices_handles_missing_languages_attr(self):
|
||||
"""get_voices() handles voices without languages attribute."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
|
||||
mock_voice = MagicMock()
|
||||
mock_voice.id = "voice1"
|
||||
mock_voice.name = "Default Voice"
|
||||
# No languages attribute
|
||||
del mock_voice.languages
|
||||
|
||||
tts._engine = MagicMock()
|
||||
tts._engine.getProperty.return_value = [mock_voice]
|
||||
|
||||
voices = tts.get_voices()
|
||||
assert len(voices) == 1
|
||||
assert voices[0]["languages"] == []
|
||||
|
||||
def test_get_voices_handles_exception(self):
|
||||
"""get_voices() returns empty list on exception."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._engine = MagicMock()
|
||||
tts._engine.getProperty.side_effect = RuntimeError("engine error")
|
||||
|
||||
result = tts.get_voices()
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestVoiceTTSSetVoice:
|
||||
"""Test VoiceTTS set_voice() method."""
|
||||
|
||||
def test_set_voice_updates_property(self):
|
||||
"""set_voice() updates engine voice property when engine exists."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._engine = MagicMock()
|
||||
|
||||
tts.set_voice("com.apple.voice.compact.en-US.Samantha")
|
||||
tts._engine.setProperty.assert_called_with(
|
||||
"voice", "com.apple.voice.compact.en-US.Samantha"
|
||||
)
|
||||
|
||||
def test_set_voice_skips_when_no_engine(self):
|
||||
"""set_voice() does nothing when engine is None."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._engine = None
|
||||
|
||||
# Should not raise
|
||||
tts.set_voice("some_voice_id")
|
||||
|
||||
|
||||
class TestVoiceTTSAvailableProperty:
|
||||
"""Test VoiceTTS available property."""
|
||||
|
||||
def test_available_returns_true_when_initialized(self):
|
||||
"""available property returns True when engine initialized."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._available = True
|
||||
|
||||
assert tts.available is True
|
||||
|
||||
def test_available_returns_false_when_not_initialized(self):
|
||||
"""available property returns False when engine not initialized."""
|
||||
from timmy_serve.voice_tts import VoiceTTS
|
||||
|
||||
tts = VoiceTTS.__new__(VoiceTTS)
|
||||
tts._available = False
|
||||
|
||||
assert tts.available is False
|
||||
401
tests/timmy_automations/test_health_snapshot.py
Normal file
401
tests/timmy_automations/test_health_snapshot.py
Normal file
@@ -0,0 +1,401 @@
|
||||
"""Tests for health_snapshot module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
# Add timmy_automations to path for imports
|
||||
sys.path.insert(
|
||||
0, str(Path(__file__).resolve().parent.parent.parent / "timmy_automations" / "daily_run")
|
||||
)
|
||||
|
||||
from datetime import UTC
|
||||
|
||||
import health_snapshot as hs
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
"""Test configuration loading."""
|
||||
|
||||
def test_loads_default_config(self):
|
||||
"""Load default configuration."""
|
||||
config = hs.load_config()
|
||||
|
||||
assert "gitea_api" in config
|
||||
assert "repo_slug" in config
|
||||
assert "critical_labels" in config
|
||||
assert "flakiness_lookback_cycles" in config
|
||||
|
||||
def test_environment_overrides(self, monkeypatch):
|
||||
"""Environment variables override defaults."""
|
||||
monkeypatch.setenv("TIMMY_GITEA_API", "http://test:3000/api/v1")
|
||||
monkeypatch.setenv("TIMMY_REPO_SLUG", "test/repo")
|
||||
|
||||
config = hs.load_config()
|
||||
|
||||
assert config["gitea_api"] == "http://test:3000/api/v1"
|
||||
assert config["repo_slug"] == "test/repo"
|
||||
|
||||
|
||||
class TestGetToken:
|
||||
"""Test token retrieval."""
|
||||
|
||||
def test_returns_config_token(self):
|
||||
"""Return token from config if present."""
|
||||
config = {"token": "test-token-123"}
|
||||
token = hs.get_token(config)
|
||||
|
||||
assert token == "test-token-123"
|
||||
|
||||
def test_reads_from_file(self, tmp_path, monkeypatch):
|
||||
"""Read token from file if no config token."""
|
||||
token_file = tmp_path / "gitea_token"
|
||||
token_file.write_text("file-token-456")
|
||||
|
||||
config = {"token_file": str(token_file)}
|
||||
token = hs.get_token(config)
|
||||
|
||||
assert token == "file-token-456"
|
||||
|
||||
def test_returns_none_when_no_token(self):
|
||||
"""Return None when no token available."""
|
||||
config = {"token_file": "/nonexistent/path"}
|
||||
token = hs.get_token(config)
|
||||
|
||||
assert token is None
|
||||
|
||||
|
||||
class TestCISignal:
|
||||
"""Test CISignal dataclass."""
|
||||
|
||||
def test_default_details(self):
|
||||
"""Details defaults to empty dict."""
|
||||
signal = hs.CISignal(status="pass", message="CI passing")
|
||||
|
||||
assert signal.details == {}
|
||||
|
||||
def test_with_details(self):
|
||||
"""Can include details."""
|
||||
signal = hs.CISignal(status="pass", message="CI passing", details={"sha": "abc123"})
|
||||
|
||||
assert signal.details["sha"] == "abc123"
|
||||
|
||||
|
||||
class TestIssueSignal:
|
||||
"""Test IssueSignal dataclass."""
|
||||
|
||||
def test_default_issues_list(self):
|
||||
"""Issues defaults to empty list."""
|
||||
signal = hs.IssueSignal(count=0, p0_count=0, p1_count=0)
|
||||
|
||||
assert signal.issues == []
|
||||
|
||||
def test_with_issues(self):
|
||||
"""Can include issues."""
|
||||
issues = [{"number": 1, "title": "Test"}]
|
||||
signal = hs.IssueSignal(count=1, p0_count=1, p1_count=0, issues=issues)
|
||||
|
||||
assert len(signal.issues) == 1
|
||||
|
||||
|
||||
class TestFlakinessSignal:
|
||||
"""Test FlakinessSignal dataclass."""
|
||||
|
||||
def test_calculated_fields(self):
|
||||
"""All fields set correctly."""
|
||||
signal = hs.FlakinessSignal(
|
||||
status="healthy",
|
||||
recent_failures=2,
|
||||
recent_cycles=20,
|
||||
failure_rate=0.1,
|
||||
message="Low flakiness",
|
||||
)
|
||||
|
||||
assert signal.status == "healthy"
|
||||
assert signal.recent_failures == 2
|
||||
assert signal.failure_rate == 0.1
|
||||
|
||||
|
||||
class TestHealthSnapshot:
|
||||
"""Test HealthSnapshot dataclass."""
|
||||
|
||||
def test_to_dict_structure(self):
|
||||
"""to_dict produces expected structure."""
|
||||
snapshot = hs.HealthSnapshot(
|
||||
timestamp="2026-01-01T00:00:00+00:00",
|
||||
overall_status="green",
|
||||
ci=hs.CISignal(status="pass", message="CI passing"),
|
||||
issues=hs.IssueSignal(count=0, p0_count=0, p1_count=0),
|
||||
flakiness=hs.FlakinessSignal(
|
||||
status="healthy",
|
||||
recent_failures=0,
|
||||
recent_cycles=10,
|
||||
failure_rate=0.0,
|
||||
message="All good",
|
||||
),
|
||||
tokens=hs.TokenEconomySignal(status="balanced", message="Balanced"),
|
||||
)
|
||||
|
||||
data = snapshot.to_dict()
|
||||
|
||||
assert data["timestamp"] == "2026-01-01T00:00:00+00:00"
|
||||
assert data["overall_status"] == "green"
|
||||
assert "ci" in data
|
||||
assert "issues" in data
|
||||
assert "flakiness" in data
|
||||
assert "tokens" in data
|
||||
|
||||
def test_to_dict_limits_issues(self):
|
||||
"""to_dict limits issues to 5."""
|
||||
many_issues = [{"number": i, "title": f"Issue {i}"} for i in range(10)]
|
||||
snapshot = hs.HealthSnapshot(
|
||||
timestamp="2026-01-01T00:00:00+00:00",
|
||||
overall_status="green",
|
||||
ci=hs.CISignal(status="pass", message="CI passing"),
|
||||
issues=hs.IssueSignal(count=10, p0_count=5, p1_count=5, issues=many_issues),
|
||||
flakiness=hs.FlakinessSignal(
|
||||
status="healthy",
|
||||
recent_failures=0,
|
||||
recent_cycles=10,
|
||||
failure_rate=0.0,
|
||||
message="All good",
|
||||
),
|
||||
tokens=hs.TokenEconomySignal(status="balanced", message="Balanced"),
|
||||
)
|
||||
|
||||
data = snapshot.to_dict()
|
||||
|
||||
assert len(data["issues"]["issues"]) == 5
|
||||
|
||||
|
||||
class TestCalculateOverallStatus:
|
||||
"""Test overall status calculation."""
|
||||
|
||||
def test_green_when_all_healthy(self):
|
||||
"""Status is green when all signals healthy."""
|
||||
ci = hs.CISignal(status="pass", message="CI passing")
|
||||
issues = hs.IssueSignal(count=0, p0_count=0, p1_count=0)
|
||||
flakiness = hs.FlakinessSignal(
|
||||
status="healthy",
|
||||
recent_failures=0,
|
||||
recent_cycles=10,
|
||||
failure_rate=0.0,
|
||||
message="All good",
|
||||
)
|
||||
|
||||
status = hs.calculate_overall_status(ci, issues, flakiness)
|
||||
|
||||
assert status == "green"
|
||||
|
||||
def test_red_when_ci_fails(self):
|
||||
"""Status is red when CI fails."""
|
||||
ci = hs.CISignal(status="fail", message="CI failed")
|
||||
issues = hs.IssueSignal(count=0, p0_count=0, p1_count=0)
|
||||
flakiness = hs.FlakinessSignal(
|
||||
status="healthy",
|
||||
recent_failures=0,
|
||||
recent_cycles=10,
|
||||
failure_rate=0.0,
|
||||
message="All good",
|
||||
)
|
||||
|
||||
status = hs.calculate_overall_status(ci, issues, flakiness)
|
||||
|
||||
assert status == "red"
|
||||
|
||||
def test_red_when_p0_issues(self):
|
||||
"""Status is red when P0 issues exist."""
|
||||
ci = hs.CISignal(status="pass", message="CI passing")
|
||||
issues = hs.IssueSignal(count=1, p0_count=1, p1_count=0)
|
||||
flakiness = hs.FlakinessSignal(
|
||||
status="healthy",
|
||||
recent_failures=0,
|
||||
recent_cycles=10,
|
||||
failure_rate=0.0,
|
||||
message="All good",
|
||||
)
|
||||
|
||||
status = hs.calculate_overall_status(ci, issues, flakiness)
|
||||
|
||||
assert status == "red"
|
||||
|
||||
def test_yellow_when_p1_issues(self):
|
||||
"""Status is yellow when P1 issues exist."""
|
||||
ci = hs.CISignal(status="pass", message="CI passing")
|
||||
issues = hs.IssueSignal(count=1, p0_count=0, p1_count=1)
|
||||
flakiness = hs.FlakinessSignal(
|
||||
status="healthy",
|
||||
recent_failures=0,
|
||||
recent_cycles=10,
|
||||
failure_rate=0.0,
|
||||
message="All good",
|
||||
)
|
||||
|
||||
status = hs.calculate_overall_status(ci, issues, flakiness)
|
||||
|
||||
assert status == "yellow"
|
||||
|
||||
def test_yellow_when_flakiness_degraded(self):
|
||||
"""Status is yellow when flakiness degraded."""
|
||||
ci = hs.CISignal(status="pass", message="CI passing")
|
||||
issues = hs.IssueSignal(count=0, p0_count=0, p1_count=0)
|
||||
flakiness = hs.FlakinessSignal(
|
||||
status="degraded",
|
||||
recent_failures=5,
|
||||
recent_cycles=20,
|
||||
failure_rate=0.25,
|
||||
message="Moderate flakiness",
|
||||
)
|
||||
|
||||
status = hs.calculate_overall_status(ci, issues, flakiness)
|
||||
|
||||
assert status == "yellow"
|
||||
|
||||
def test_red_when_flakiness_critical(self):
|
||||
"""Status is red when flakiness critical."""
|
||||
ci = hs.CISignal(status="pass", message="CI passing")
|
||||
issues = hs.IssueSignal(count=0, p0_count=0, p1_count=0)
|
||||
flakiness = hs.FlakinessSignal(
|
||||
status="critical",
|
||||
recent_failures=10,
|
||||
recent_cycles=20,
|
||||
failure_rate=0.5,
|
||||
message="High flakiness",
|
||||
)
|
||||
|
||||
status = hs.calculate_overall_status(ci, issues, flakiness)
|
||||
|
||||
assert status == "red"
|
||||
|
||||
|
||||
class TestCheckFlakiness:
|
||||
"""Test flakiness checking."""
|
||||
|
||||
def test_no_data_returns_unknown(self, tmp_path, monkeypatch):
|
||||
"""Return unknown when no cycle data exists."""
|
||||
monkeypatch.setattr(hs, "REPO_ROOT", tmp_path)
|
||||
config = {"flakiness_lookback_cycles": 20}
|
||||
|
||||
signal = hs.check_flakiness(config)
|
||||
|
||||
assert signal.status == "unknown"
|
||||
assert signal.message == "No cycle data available"
|
||||
|
||||
def test_calculates_failure_rate(self, tmp_path, monkeypatch):
|
||||
"""Calculate failure rate from cycle data."""
|
||||
monkeypatch.setattr(hs, "REPO_ROOT", tmp_path)
|
||||
|
||||
retro_dir = tmp_path / ".loop" / "retro"
|
||||
retro_dir.mkdir(parents=True)
|
||||
|
||||
cycles = [
|
||||
json.dumps({"success": True, "cycle": 1}),
|
||||
json.dumps({"success": True, "cycle": 2}),
|
||||
json.dumps({"success": False, "cycle": 3}),
|
||||
json.dumps({"success": True, "cycle": 4}),
|
||||
json.dumps({"success": False, "cycle": 5}),
|
||||
]
|
||||
retro_file = retro_dir / "cycles.jsonl"
|
||||
retro_file.write_text("\n".join(cycles))
|
||||
|
||||
config = {"flakiness_lookback_cycles": 20}
|
||||
signal = hs.check_flakiness(config)
|
||||
|
||||
assert signal.recent_cycles == 5
|
||||
assert signal.recent_failures == 2
|
||||
assert signal.failure_rate == 0.4
|
||||
assert signal.status == "critical" # 40% > 30%
|
||||
|
||||
|
||||
class TestCheckTokenEconomy:
|
||||
"""Test token economy checking."""
|
||||
|
||||
def test_no_data_returns_unknown(self, tmp_path, monkeypatch):
|
||||
"""Return unknown when no token data exists."""
|
||||
monkeypatch.setattr(hs, "REPO_ROOT", tmp_path)
|
||||
config = {}
|
||||
|
||||
signal = hs.check_token_economy(config)
|
||||
|
||||
assert signal.status == "unknown"
|
||||
|
||||
def test_calculates_balanced(self, tmp_path, monkeypatch):
|
||||
"""Detect balanced token economy."""
|
||||
monkeypatch.setattr(hs, "REPO_ROOT", tmp_path)
|
||||
|
||||
loop_dir = tmp_path / ".loop"
|
||||
loop_dir.mkdir(parents=True)
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
transactions = [
|
||||
json.dumps({"timestamp": now, "delta": 10}),
|
||||
json.dumps({"timestamp": now, "delta": -5}),
|
||||
]
|
||||
ledger_file = loop_dir / "token_economy.jsonl"
|
||||
ledger_file.write_text("\n".join(transactions))
|
||||
|
||||
config = {}
|
||||
signal = hs.check_token_economy(config)
|
||||
|
||||
assert signal.status == "balanced"
|
||||
assert signal.recent_mint == 10
|
||||
assert signal.recent_burn == 5
|
||||
|
||||
|
||||
class TestGiteaClient:
|
||||
"""Test Gitea API client."""
|
||||
|
||||
def test_initialization(self):
|
||||
"""Initialize with config and token."""
|
||||
config = {"gitea_api": "http://test:3000/api/v1", "repo_slug": "test/repo"}
|
||||
client = hs.GiteaClient(config, "token123")
|
||||
|
||||
assert client.api_base == "http://test:3000/api/v1"
|
||||
assert client.repo_slug == "test/repo"
|
||||
assert client.token == "token123"
|
||||
|
||||
def test_headers_with_token(self):
|
||||
"""Include authorization header with token."""
|
||||
config = {"gitea_api": "http://test:3000/api/v1", "repo_slug": "test/repo"}
|
||||
client = hs.GiteaClient(config, "token123")
|
||||
|
||||
headers = client._headers()
|
||||
|
||||
assert headers["Authorization"] == "token token123"
|
||||
assert headers["Accept"] == "application/json"
|
||||
|
||||
def test_headers_without_token(self):
|
||||
"""No authorization header without token."""
|
||||
config = {"gitea_api": "http://test:3000/api/v1", "repo_slug": "test/repo"}
|
||||
client = hs.GiteaClient(config, None)
|
||||
|
||||
headers = client._headers()
|
||||
|
||||
assert "Authorization" not in headers
|
||||
assert headers["Accept"] == "application/json"
|
||||
|
||||
|
||||
class TestGenerateSnapshot:
|
||||
"""Test snapshot generation."""
|
||||
|
||||
def test_returns_snapshot(self):
|
||||
"""Generate a complete snapshot."""
|
||||
config = hs.load_config()
|
||||
|
||||
with (
|
||||
patch.object(hs.GiteaClient, "is_available", return_value=False),
|
||||
patch.object(hs.GiteaClient, "__init__", return_value=None),
|
||||
):
|
||||
snapshot = hs.generate_snapshot(config, None)
|
||||
|
||||
assert isinstance(snapshot, hs.HealthSnapshot)
|
||||
assert snapshot.overall_status in ["green", "yellow", "red", "unknown"]
|
||||
assert snapshot.ci is not None
|
||||
assert snapshot.issues is not None
|
||||
assert snapshot.flakiness is not None
|
||||
assert snapshot.tokens is not None
|
||||
524
tests/timmy_automations/test_token_rules.py
Normal file
524
tests/timmy_automations/test_token_rules.py
Normal file
@@ -0,0 +1,524 @@
|
||||
"""Tests for token_rules module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add timmy_automations to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "timmy_automations"))
|
||||
|
||||
from utils import token_rules as tr
|
||||
|
||||
|
||||
class TestTokenEvent:
|
||||
"""Test TokenEvent dataclass."""
|
||||
|
||||
def test_delta_calculation_reward(self):
|
||||
"""Delta is positive for rewards."""
|
||||
event = tr.TokenEvent(
|
||||
name="test",
|
||||
description="Test event",
|
||||
reward=10,
|
||||
penalty=0,
|
||||
category="test",
|
||||
)
|
||||
assert event.delta == 10
|
||||
|
||||
def test_delta_calculation_penalty(self):
|
||||
"""Delta is negative for penalties."""
|
||||
event = tr.TokenEvent(
|
||||
name="test",
|
||||
description="Test event",
|
||||
reward=0,
|
||||
penalty=-5,
|
||||
category="test",
|
||||
)
|
||||
assert event.delta == -5
|
||||
|
||||
def test_delta_calculation_mixed(self):
|
||||
"""Delta is net of reward and penalty."""
|
||||
event = tr.TokenEvent(
|
||||
name="test",
|
||||
description="Test event",
|
||||
reward=10,
|
||||
penalty=-3,
|
||||
category="test",
|
||||
)
|
||||
assert event.delta == 7
|
||||
|
||||
|
||||
class TestTokenRulesLoading:
|
||||
"""Test TokenRules configuration loading."""
|
||||
|
||||
def test_loads_from_yaml_file(self, tmp_path):
|
||||
"""Load configuration from YAML file."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {
|
||||
"version": "1.0.0-test",
|
||||
"events": {
|
||||
"test_event": {
|
||||
"description": "A test event",
|
||||
"reward": 15,
|
||||
"category": "test",
|
||||
}
|
||||
},
|
||||
"gating_thresholds": {"test_op": 50},
|
||||
"daily_limits": {"test": {"max_earn": 100, "max_spend": 10}},
|
||||
"audit": {"log_all_transactions": False},
|
||||
}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
|
||||
assert rules.get_config_version() == "1.0.0-test"
|
||||
assert rules.get_delta("test_event") == 15
|
||||
assert rules.get_gate_threshold("test_op") == 50
|
||||
|
||||
def test_fallback_when_yaml_missing(self, tmp_path):
|
||||
"""Use fallback defaults when YAML file doesn't exist."""
|
||||
config_file = tmp_path / "nonexistent.yaml"
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
|
||||
assert rules.get_config_version() == "fallback"
|
||||
# Fallback should have some basic events
|
||||
assert rules.get_delta("pr_merged") == 10
|
||||
assert rules.get_delta("test_fixed") == 8
|
||||
assert rules.get_delta("automation_failure") == -2
|
||||
|
||||
def test_fallback_when_yaml_not_installed(self, tmp_path):
|
||||
"""Use fallback when PyYAML is not installed."""
|
||||
with patch.dict(sys.modules, {"yaml": None}):
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_file.write_text("version: '1.0.0'")
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
|
||||
assert rules.get_config_version() == "fallback"
|
||||
|
||||
|
||||
class TestTokenRulesGetDelta:
|
||||
"""Test get_delta method."""
|
||||
|
||||
def test_get_delta_existing_event(self, tmp_path):
|
||||
"""Get delta for configured event."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {
|
||||
"version": "1.0.0",
|
||||
"events": {
|
||||
"pr_merged": {"description": "PR merged", "reward": 10, "category": "merge"},
|
||||
"automation_failure": {"description": "Failure", "penalty": -2, "category": "ops"},
|
||||
},
|
||||
}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
|
||||
assert rules.get_delta("pr_merged") == 10
|
||||
assert rules.get_delta("automation_failure") == -2
|
||||
|
||||
def test_get_delta_unknown_event(self, tmp_path):
|
||||
"""Return 0 for unknown events."""
|
||||
config_file = tmp_path / "nonexistent.yaml"
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
|
||||
assert rules.get_delta("unknown_event") == 0
|
||||
|
||||
|
||||
class TestTokenRulesGetEvent:
|
||||
"""Test get_event method."""
|
||||
|
||||
def test_get_event_returns_full_config(self, tmp_path):
|
||||
"""Get full event configuration."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {
|
||||
"version": "1.0.0",
|
||||
"events": {
|
||||
"pr_merged": {
|
||||
"description": "PR merged successfully",
|
||||
"reward": 10,
|
||||
"category": "merge",
|
||||
"gate_threshold": 0,
|
||||
}
|
||||
},
|
||||
}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
event = rules.get_event("pr_merged")
|
||||
|
||||
assert event is not None
|
||||
assert event.name == "pr_merged"
|
||||
assert event.description == "PR merged successfully"
|
||||
assert event.reward == 10
|
||||
assert event.category == "merge"
|
||||
assert event.gate_threshold == 0
|
||||
|
||||
def test_get_event_unknown_returns_none(self, tmp_path):
|
||||
"""Return None for unknown event."""
|
||||
config_file = tmp_path / "nonexistent.yaml"
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
|
||||
assert rules.get_event("unknown") is None
|
||||
|
||||
|
||||
class TestTokenRulesListEvents:
|
||||
"""Test list_events method."""
|
||||
|
||||
def test_list_all_events(self, tmp_path):
|
||||
"""List all configured events."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {
|
||||
"version": "1.0.0",
|
||||
"events": {
|
||||
"event_a": {"description": "A", "reward": 5, "category": "cat1"},
|
||||
"event_b": {"description": "B", "reward": 10, "category": "cat2"},
|
||||
"event_c": {"description": "C", "reward": 15, "category": "cat1"},
|
||||
},
|
||||
}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
events = rules.list_events()
|
||||
|
||||
assert len(events) == 3
|
||||
event_names = {e.name for e in events}
|
||||
assert "event_a" in event_names
|
||||
assert "event_b" in event_names
|
||||
assert "event_c" in event_names
|
||||
|
||||
def test_list_events_by_category(self, tmp_path):
|
||||
"""Filter events by category."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {
|
||||
"version": "1.0.0",
|
||||
"events": {
|
||||
"event_a": {"description": "A", "reward": 5, "category": "cat1"},
|
||||
"event_b": {"description": "B", "reward": 10, "category": "cat2"},
|
||||
"event_c": {"description": "C", "reward": 15, "category": "cat1"},
|
||||
},
|
||||
}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
events = rules.list_events(category="cat1")
|
||||
|
||||
assert len(events) == 2
|
||||
for event in events:
|
||||
assert event.category == "cat1"
|
||||
|
||||
|
||||
class TestTokenRulesGating:
|
||||
"""Test gating threshold methods."""
|
||||
|
||||
def test_check_gate_with_threshold(self, tmp_path):
|
||||
"""Check gate when threshold is defined."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {
|
||||
"version": "1.0.0",
|
||||
"events": {},
|
||||
"gating_thresholds": {"pr_merge": 50},
|
||||
}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
|
||||
assert rules.check_gate("pr_merge", current_tokens=100) is True
|
||||
assert rules.check_gate("pr_merge", current_tokens=50) is True
|
||||
assert rules.check_gate("pr_merge", current_tokens=49) is False
|
||||
assert rules.check_gate("pr_merge", current_tokens=0) is False
|
||||
|
||||
def test_check_gate_no_threshold(self, tmp_path):
|
||||
"""Check gate when no threshold is defined (always allowed)."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {
|
||||
"version": "1.0.0",
|
||||
"events": {},
|
||||
"gating_thresholds": {},
|
||||
}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
|
||||
# No threshold defined, should always be allowed
|
||||
assert rules.check_gate("unknown_op", current_tokens=0) is True
|
||||
assert rules.check_gate("unknown_op", current_tokens=-100) is True
|
||||
|
||||
def test_get_gate_threshold(self, tmp_path):
|
||||
"""Get threshold value."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {
|
||||
"version": "1.0.0",
|
||||
"gating_thresholds": {"pr_merge": 50, "sensitive_op": 100},
|
||||
}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
|
||||
assert rules.get_gate_threshold("pr_merge") == 50
|
||||
assert rules.get_gate_threshold("sensitive_op") == 100
|
||||
assert rules.get_gate_threshold("unknown") is None
|
||||
|
||||
|
||||
class TestTokenRulesDailyLimits:
|
||||
"""Test daily limits methods."""
|
||||
|
||||
def test_get_daily_limits(self, tmp_path):
|
||||
"""Get daily limits for a category."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {
|
||||
"version": "1.0.0",
|
||||
"daily_limits": {
|
||||
"triage": {"max_earn": 100, "max_spend": 0},
|
||||
"merge": {"max_earn": 50, "max_spend": 10},
|
||||
},
|
||||
}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
|
||||
triage_limits = rules.get_daily_limits("triage")
|
||||
assert triage_limits is not None
|
||||
assert triage_limits.max_earn == 100
|
||||
assert triage_limits.max_spend == 0
|
||||
|
||||
merge_limits = rules.get_daily_limits("merge")
|
||||
assert merge_limits is not None
|
||||
assert merge_limits.max_earn == 50
|
||||
assert merge_limits.max_spend == 10
|
||||
|
||||
def test_get_daily_limits_unknown(self, tmp_path):
|
||||
"""Return None for unknown category."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {"version": "1.0.0", "daily_limits": {}}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
|
||||
assert rules.get_daily_limits("unknown") is None
|
||||
|
||||
|
||||
class TestTokenRulesComputeTransaction:
|
||||
"""Test compute_transaction method."""
|
||||
|
||||
def test_compute_successful_transaction(self, tmp_path):
|
||||
"""Compute transaction for valid event."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {
|
||||
"version": "1.0.0",
|
||||
"events": {
|
||||
"pr_merged": {"description": "PR merged", "reward": 10, "category": "merge"}
|
||||
},
|
||||
}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
result = rules.compute_transaction("pr_merged", current_tokens=100)
|
||||
|
||||
assert result["event"] == "pr_merged"
|
||||
assert result["delta"] == 10
|
||||
assert result["category"] == "merge"
|
||||
assert result["allowed"] is True
|
||||
assert result["new_balance"] == 110
|
||||
assert result["limit_reached"] is False
|
||||
|
||||
def test_compute_unknown_event(self, tmp_path):
|
||||
"""Compute transaction for unknown event."""
|
||||
config_file = tmp_path / "nonexistent.yaml"
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
result = rules.compute_transaction("unknown_event", current_tokens=50)
|
||||
|
||||
assert result["event"] == "unknown_event"
|
||||
assert result["delta"] == 0
|
||||
assert result["allowed"] is False
|
||||
assert result["reason"] == "unknown_event"
|
||||
assert result["new_balance"] == 50
|
||||
|
||||
def test_compute_with_gate_check(self, tmp_path):
|
||||
"""Compute transaction respects gating."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {
|
||||
"version": "1.0.0",
|
||||
"events": {
|
||||
"sensitive_op": {
|
||||
"description": "Sensitive",
|
||||
"reward": 50,
|
||||
"category": "sensitive",
|
||||
"gate_threshold": 100,
|
||||
}
|
||||
},
|
||||
}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
|
||||
# With enough tokens
|
||||
result = rules.compute_transaction("sensitive_op", current_tokens=150)
|
||||
assert result["allowed"] is True
|
||||
|
||||
# Without enough tokens
|
||||
result = rules.compute_transaction("sensitive_op", current_tokens=50)
|
||||
assert result["allowed"] is False
|
||||
assert "gate_reason" in result
|
||||
|
||||
def test_compute_with_daily_limits(self, tmp_path):
|
||||
"""Compute transaction respects daily limits."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {
|
||||
"version": "1.0.0",
|
||||
"events": {
|
||||
"triage_action": {
|
||||
"description": "Triage",
|
||||
"reward": 20,
|
||||
"category": "triage",
|
||||
}
|
||||
},
|
||||
"daily_limits": {"triage": {"max_earn": 50, "max_spend": 0}},
|
||||
}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
|
||||
# Within limit
|
||||
daily_earned = {"triage": 20}
|
||||
result = rules.compute_transaction(
|
||||
"triage_action", current_tokens=100, current_daily_earned=daily_earned
|
||||
)
|
||||
assert result["allowed"] is True
|
||||
assert result["limit_reached"] is False
|
||||
|
||||
# Would exceed limit (20 + 20 > 50 is false, so this should be fine)
|
||||
# Let's test with higher current earned
|
||||
daily_earned = {"triage": 40}
|
||||
result = rules.compute_transaction(
|
||||
"triage_action", current_tokens=100, current_daily_earned=daily_earned
|
||||
)
|
||||
assert result["allowed"] is False
|
||||
assert result["limit_reached"] is True
|
||||
assert "limit_reason" in result
|
||||
|
||||
|
||||
class TestTokenRulesCategories:
|
||||
"""Test category methods."""
|
||||
|
||||
def test_get_categories(self, tmp_path):
|
||||
"""Get all unique categories."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {
|
||||
"version": "1.0.0",
|
||||
"events": {
|
||||
"event_a": {"description": "A", "reward": 5, "category": "cat1"},
|
||||
"event_b": {"description": "B", "reward": 10, "category": "cat2"},
|
||||
"event_c": {"description": "C", "reward": 15, "category": "cat1"},
|
||||
},
|
||||
}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
categories = rules.get_categories()
|
||||
|
||||
assert sorted(categories) == ["cat1", "cat2"]
|
||||
|
||||
|
||||
class TestTokenRulesAudit:
|
||||
"""Test audit methods."""
|
||||
|
||||
def test_is_auditable_true(self, tmp_path):
|
||||
"""Check if auditable when enabled."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {"version": "1.0.0", "audit": {"log_all_transactions": True}}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
assert rules.is_auditable() is True
|
||||
|
||||
def test_is_auditable_false(self, tmp_path):
|
||||
"""Check if auditable when disabled."""
|
||||
yaml = pytest.importorskip("yaml")
|
||||
|
||||
config_file = tmp_path / "token_rules.yaml"
|
||||
config_data = {"version": "1.0.0", "audit": {"log_all_transactions": False}}
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
rules = tr.TokenRules(config_path=config_file)
|
||||
assert rules.is_auditable() is False
|
||||
|
||||
|
||||
class TestConvenienceFunctions:
|
||||
"""Test module-level convenience functions."""
|
||||
|
||||
def test_get_token_delta(self, tmp_path):
|
||||
"""Convenience function returns delta."""
|
||||
config_file = tmp_path / "nonexistent.yaml"
|
||||
|
||||
with patch.object(tr.TokenRules, "CONFIG_PATH", config_file):
|
||||
delta = tr.get_token_delta("pr_merged")
|
||||
assert delta == 10 # From fallback
|
||||
|
||||
def test_check_operation_gate(self, tmp_path):
|
||||
"""Convenience function checks gate."""
|
||||
config_file = tmp_path / "nonexistent.yaml"
|
||||
|
||||
with patch.object(tr.TokenRules, "CONFIG_PATH", config_file):
|
||||
# Fallback has pr_merge gate at 0
|
||||
assert tr.check_operation_gate("pr_merge", current_tokens=0) is True
|
||||
assert tr.check_operation_gate("pr_merge", current_tokens=100) is True
|
||||
|
||||
def test_compute_token_reward(self, tmp_path):
|
||||
"""Convenience function computes reward."""
|
||||
config_file = tmp_path / "nonexistent.yaml"
|
||||
|
||||
with patch.object(tr.TokenRules, "CONFIG_PATH", config_file):
|
||||
result = tr.compute_token_reward("pr_merged", current_tokens=50)
|
||||
assert result["event"] == "pr_merged"
|
||||
assert result["delta"] == 10
|
||||
assert result["new_balance"] == 60
|
||||
|
||||
def test_list_token_events(self, tmp_path):
|
||||
"""Convenience function lists events."""
|
||||
config_file = tmp_path / "nonexistent.yaml"
|
||||
|
||||
with patch.object(tr.TokenRules, "CONFIG_PATH", config_file):
|
||||
events = tr.list_token_events()
|
||||
assert len(events) >= 3 # Fallback has at least 3 events
|
||||
|
||||
# Check structure
|
||||
for event in events:
|
||||
assert "name" in event
|
||||
assert "description" in event
|
||||
assert "delta" in event
|
||||
assert "category" in event
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"description": "Master manifest of all Timmy automations",
|
||||
"_health_snapshot": {
|
||||
"note": "Quick health check before coding — CI, P0/P1 issues, flakiness"
|
||||
},
|
||||
"last_updated": "2026-03-21",
|
||||
"automations": [
|
||||
{
|
||||
@@ -249,6 +252,22 @@
|
||||
".loop/weekly_narrative.json",
|
||||
".loop/weekly_narrative.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "health_snapshot",
|
||||
"name": "Health Snapshot",
|
||||
"description": "Quick health check before coding — CI status, P0/P1 issues, test flakiness, token economy",
|
||||
"script": "timmy_automations/daily_run/health_snapshot.py",
|
||||
"category": "daily_run",
|
||||
"enabled": true,
|
||||
"trigger": "pre_cycle",
|
||||
"executable": "python3",
|
||||
"config": {
|
||||
"critical_labels": ["P0", "P1", "priority/critical", "priority/high"],
|
||||
"flakiness_lookback_cycles": 20,
|
||||
"ci_timeout_seconds": 5
|
||||
},
|
||||
"outputs": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
138
timmy_automations/config/token_rules.yaml
Normal file
138
timmy_automations/config/token_rules.yaml
Normal file
@@ -0,0 +1,138 @@
|
||||
# Token Rules — Agent reward/penalty configuration for automations
|
||||
#
|
||||
# This file defines the token economy for agent actions.
|
||||
# Modify values here to adjust incentives without code changes.
|
||||
#
|
||||
# Used by: timmy_automations.utils.token_rules
|
||||
|
||||
version: "1.0.0"
|
||||
description: "Token economy rules for agent automations"
|
||||
|
||||
# ── Events ─────────────────────────────────────────────────────────────────
|
||||
# Each event type defines rewards/penalties and optional gating thresholds
|
||||
|
||||
events:
|
||||
# Triage actions
|
||||
triage_success:
|
||||
description: "Successfully triaged an issue (scored and categorized)"
|
||||
reward: 5
|
||||
category: "triage"
|
||||
|
||||
deep_triage_refinement:
|
||||
description: "LLM-driven issue refinement with acceptance criteria added"
|
||||
reward: 20
|
||||
category: "triage"
|
||||
|
||||
quarantine_candidate_found:
|
||||
description: "Identified a repeat failure issue for quarantine"
|
||||
reward: 10
|
||||
category: "triage"
|
||||
|
||||
# Daily Run completions
|
||||
daily_run_completed:
|
||||
description: "Completed a daily run cycle successfully"
|
||||
reward: 5
|
||||
category: "daily_run"
|
||||
|
||||
golden_path_generated:
|
||||
description: "Generated a coherent mini-session plan"
|
||||
reward: 3
|
||||
category: "daily_run"
|
||||
|
||||
weekly_narrative_created:
|
||||
description: "Generated weekly summary of work themes"
|
||||
reward: 15
|
||||
category: "daily_run"
|
||||
|
||||
# PR merges
|
||||
pr_merged:
|
||||
description: "Successfully merged a pull request"
|
||||
reward: 10
|
||||
category: "merge"
|
||||
# Gating: requires minimum tokens to perform
|
||||
gate_threshold: 0
|
||||
|
||||
pr_merged_with_tests:
|
||||
description: "Merged PR with all tests passing"
|
||||
reward: 15
|
||||
category: "merge"
|
||||
gate_threshold: 0
|
||||
|
||||
# Test fixes
|
||||
test_fixed:
|
||||
description: "Fixed a failing test"
|
||||
reward: 8
|
||||
category: "test"
|
||||
|
||||
test_added:
|
||||
description: "Added new test coverage"
|
||||
reward: 5
|
||||
category: "test"
|
||||
|
||||
critical_bug_fixed:
|
||||
description: "Fixed a critical bug on main"
|
||||
reward: 25
|
||||
category: "test"
|
||||
|
||||
# General operations
|
||||
automation_run:
|
||||
description: "Ran any automation (resource usage)"
|
||||
penalty: -1
|
||||
category: "operation"
|
||||
|
||||
automation_failure:
|
||||
description: "Automation failed or produced error"
|
||||
penalty: -2
|
||||
category: "operation"
|
||||
|
||||
cycle_retro_logged:
|
||||
description: "Logged structured retrospective data"
|
||||
reward: 5
|
||||
category: "operation"
|
||||
|
||||
pre_commit_passed:
|
||||
description: "Pre-commit checks passed"
|
||||
reward: 2
|
||||
category: "operation"
|
||||
|
||||
pre_commit_failed:
|
||||
description: "Pre-commit checks failed"
|
||||
penalty: -1
|
||||
category: "operation"
|
||||
|
||||
# ── Gating Thresholds ──────────────────────────────────────────────────────
|
||||
# Minimum token balances required for sensitive operations
|
||||
|
||||
gating_thresholds:
|
||||
pr_merge: 0
|
||||
sensitive_config_change: 50
|
||||
agent_workspace_create: 10
|
||||
deep_triage_run: 0
|
||||
|
||||
# ── Daily Limits ───────────────────────────────────────────────────────────
|
||||
# Maximum tokens that can be earned/spent per category per day
|
||||
|
||||
daily_limits:
|
||||
triage:
|
||||
max_earn: 100
|
||||
max_spend: 0
|
||||
daily_run:
|
||||
max_earn: 50
|
||||
max_spend: 0
|
||||
merge:
|
||||
max_earn: 100
|
||||
max_spend: 0
|
||||
test:
|
||||
max_earn: 100
|
||||
max_spend: 0
|
||||
operation:
|
||||
max_earn: 50
|
||||
max_spend: 50
|
||||
|
||||
# ── Audit Settings ─────────────────────────────────────────────────────────
|
||||
# Settings for token audit and inspection
|
||||
|
||||
audit:
|
||||
log_all_transactions: true
|
||||
log_retention_days: 30
|
||||
inspectable_by: ["orchestrator", "auditor", "timmy"]
|
||||
619
timmy_automations/daily_run/health_snapshot.py
Executable file
619
timmy_automations/daily_run/health_snapshot.py
Executable file
@@ -0,0 +1,619 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quick health snapshot before coding — checks CI, issues, flakiness.
|
||||
|
||||
A fast status check that shows major red/green signals before deeper work.
|
||||
Runs in a few seconds and produces a concise summary.
|
||||
|
||||
Run: python3 timmy_automations/daily_run/health_snapshot.py
|
||||
Env: GITEA_API, GITEA_TOKEN, REPO_SLUG
|
||||
|
||||
Refs: #710
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"gitea_api": "http://localhost:3000/api/v1",
|
||||
"repo_slug": "rockachopa/Timmy-time-dashboard",
|
||||
"token_file": "~/.hermes/gitea_token",
|
||||
"critical_labels": ["P0", "P1", "priority/critical", "priority/high"],
|
||||
"flakiness_lookback_cycles": 20,
|
||||
"ci_timeout_seconds": 5,
|
||||
}
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Load configuration with fallback to defaults."""
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
|
||||
# Environment variable overrides
|
||||
if os.environ.get("TIMMY_GITEA_API"):
|
||||
config["gitea_api"] = os.environ["TIMMY_GITEA_API"]
|
||||
if os.environ.get("TIMMY_REPO_SLUG"):
|
||||
config["repo_slug"] = os.environ["TIMMY_REPO_SLUG"]
|
||||
if os.environ.get("TIMMY_GITEA_TOKEN"):
|
||||
config["token"] = os.environ["TIMMY_GITEA_TOKEN"]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def get_token(config: dict) -> str | None:
|
||||
"""Get Gitea token from environment or file."""
|
||||
if "token" in config:
|
||||
return config["token"]
|
||||
|
||||
# Try timmy's token file
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent
|
||||
timmy_token_path = repo_root / ".timmy_gitea_token"
|
||||
if timmy_token_path.exists():
|
||||
return timmy_token_path.read_text().strip()
|
||||
|
||||
# Fallback to legacy token file
|
||||
token_file = Path(config["token_file"]).expanduser()
|
||||
if token_file.exists():
|
||||
return token_file.read_text().strip()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── Gitea API Client ──────────────────────────────────────────────────────
|
||||
|
||||
class GiteaClient:
|
||||
"""Simple Gitea API client with graceful degradation."""
|
||||
|
||||
def __init__(self, config: dict, token: str | None):
|
||||
self.api_base = config["gitea_api"].rstrip("/")
|
||||
self.repo_slug = config["repo_slug"]
|
||||
self.token = token
|
||||
self._available: bool | None = None
|
||||
|
||||
def _headers(self) -> dict:
|
||||
headers = {"Accept": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"token {self.token}"
|
||||
return headers
|
||||
|
||||
def _api_url(self, path: str) -> str:
|
||||
return f"{self.api_base}/repos/{self.repo_slug}/{path}"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if Gitea API is reachable."""
|
||||
if self._available is not None:
|
||||
return self._available
|
||||
|
||||
try:
|
||||
req = Request(
|
||||
f"{self.api_base}/version",
|
||||
headers=self._headers(),
|
||||
method="GET",
|
||||
)
|
||||
with urlopen(req, timeout=3) as resp:
|
||||
self._available = resp.status == 200
|
||||
return self._available
|
||||
except (HTTPError, URLError, TimeoutError):
|
||||
self._available = False
|
||||
return False
|
||||
|
||||
def get(self, path: str, params: dict | None = None) -> list | dict:
|
||||
"""Make a GET request to the Gitea API."""
|
||||
url = self._api_url(path)
|
||||
if params:
|
||||
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
url = f"{url}?{query}"
|
||||
|
||||
req = Request(url, headers=self._headers(), method="GET")
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
def get_paginated(self, path: str, params: dict | None = None) -> list:
|
||||
"""Fetch all pages of a paginated endpoint."""
|
||||
all_items = []
|
||||
page = 1
|
||||
limit = 50
|
||||
|
||||
while True:
|
||||
page_params = {"limit": limit, "page": page}
|
||||
if params:
|
||||
page_params.update(params)
|
||||
|
||||
batch = self.get(path, page_params)
|
||||
if not batch:
|
||||
break
|
||||
|
||||
all_items.extend(batch)
|
||||
if len(batch) < limit:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return all_items
|
||||
|
||||
|
||||
# ── Data Models ───────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class CISignal:
|
||||
"""CI pipeline status signal."""
|
||||
status: str # "pass", "fail", "unknown", "unavailable"
|
||||
message: str
|
||||
details: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IssueSignal:
|
||||
"""Critical issues signal."""
|
||||
count: int
|
||||
p0_count: int
|
||||
p1_count: int
|
||||
issues: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlakinessSignal:
|
||||
"""Test flakiness/error rate signal."""
|
||||
status: str # "healthy", "degraded", "critical", "unknown"
|
||||
recent_failures: int
|
||||
recent_cycles: int
|
||||
failure_rate: float
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenEconomySignal:
|
||||
"""Token economy temperature indicator."""
|
||||
status: str # "balanced", "inflationary", "deflationary", "unknown"
|
||||
message: str
|
||||
recent_mint: int = 0
|
||||
recent_burn: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthSnapshot:
|
||||
"""Complete health snapshot."""
|
||||
timestamp: str
|
||||
overall_status: str # "green", "yellow", "red"
|
||||
ci: CISignal
|
||||
issues: IssueSignal
|
||||
flakiness: FlakinessSignal
|
||||
tokens: TokenEconomySignal
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"timestamp": self.timestamp,
|
||||
"overall_status": self.overall_status,
|
||||
"ci": {
|
||||
"status": self.ci.status,
|
||||
"message": self.ci.message,
|
||||
"details": self.ci.details,
|
||||
},
|
||||
"issues": {
|
||||
"count": self.issues.count,
|
||||
"p0_count": self.issues.p0_count,
|
||||
"p1_count": self.issues.p1_count,
|
||||
"issues": self.issues.issues[:5], # Limit to 5
|
||||
},
|
||||
"flakiness": {
|
||||
"status": self.flakiness.status,
|
||||
"recent_failures": self.flakiness.recent_failures,
|
||||
"recent_cycles": self.flakiness.recent_cycles,
|
||||
"failure_rate": round(self.flakiness.failure_rate, 2),
|
||||
"message": self.flakiness.message,
|
||||
},
|
||||
"tokens": {
|
||||
"status": self.tokens.status,
|
||||
"message": self.tokens.message,
|
||||
"recent_mint": self.tokens.recent_mint,
|
||||
"recent_burn": self.tokens.recent_burn,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Health Check Functions ────────────────────────────────────────────────
|
||||
|
||||
def check_ci_status(client: GiteaClient, config: dict) -> CISignal:
|
||||
"""Check CI pipeline status from recent commits."""
|
||||
try:
|
||||
# Get recent commits with status
|
||||
commits = client.get_paginated("commits", {"limit": 5})
|
||||
|
||||
if not commits:
|
||||
return CISignal(
|
||||
status="unknown",
|
||||
message="No recent commits found",
|
||||
)
|
||||
|
||||
# Check status for most recent commit
|
||||
latest = commits[0]
|
||||
sha = latest.get("sha", "")
|
||||
|
||||
try:
|
||||
statuses = client.get(f"commits/{sha}/status")
|
||||
state = statuses.get("state", "unknown")
|
||||
|
||||
if state == "success":
|
||||
return CISignal(
|
||||
status="pass",
|
||||
message="CI passing",
|
||||
details={"sha": sha[:8], "state": state},
|
||||
)
|
||||
elif state in ("failure", "error"):
|
||||
return CISignal(
|
||||
status="fail",
|
||||
message=f"CI failed ({state})",
|
||||
details={"sha": sha[:8], "state": state},
|
||||
)
|
||||
elif state == "pending":
|
||||
return CISignal(
|
||||
status="unknown",
|
||||
message="CI pending",
|
||||
details={"sha": sha[:8], "state": state},
|
||||
)
|
||||
else:
|
||||
return CISignal(
|
||||
status="unknown",
|
||||
message=f"CI status: {state}",
|
||||
details={"sha": sha[:8], "state": state},
|
||||
)
|
||||
except (HTTPError, URLError) as exc:
|
||||
return CISignal(
|
||||
status="unknown",
|
||||
message=f"Could not fetch CI status: {exc}",
|
||||
)
|
||||
|
||||
except (HTTPError, URLError) as exc:
|
||||
return CISignal(
|
||||
status="unavailable",
|
||||
message=f"CI check failed: {exc}",
|
||||
)
|
||||
|
||||
|
||||
def check_critical_issues(client: GiteaClient, config: dict) -> IssueSignal:
|
||||
"""Check for open P0/P1 issues."""
|
||||
critical_labels = config.get("critical_labels", ["P0", "P1"])
|
||||
|
||||
try:
|
||||
# Fetch open issues
|
||||
issues = client.get_paginated("issues", {"state": "open", "limit": 100})
|
||||
|
||||
p0_issues = []
|
||||
p1_issues = []
|
||||
other_critical = []
|
||||
|
||||
for issue in issues:
|
||||
labels = [l.get("name", "").lower() for l in issue.get("labels", [])]
|
||||
|
||||
# Check for P0/P1 labels
|
||||
is_p0 = any("p0" in l or "critical" in l for l in labels)
|
||||
is_p1 = any("p1" in l or "high" in l for l in labels)
|
||||
|
||||
issue_summary = {
|
||||
"number": issue.get("number"),
|
||||
"title": issue.get("title", "Untitled")[:60],
|
||||
"url": issue.get("html_url", ""),
|
||||
}
|
||||
|
||||
if is_p0:
|
||||
p0_issues.append(issue_summary)
|
||||
elif is_p1:
|
||||
p1_issues.append(issue_summary)
|
||||
elif any(cl.lower() in labels for cl in critical_labels):
|
||||
other_critical.append(issue_summary)
|
||||
|
||||
all_critical = p0_issues + p1_issues + other_critical
|
||||
|
||||
return IssueSignal(
|
||||
count=len(all_critical),
|
||||
p0_count=len(p0_issues),
|
||||
p1_count=len(p1_issues),
|
||||
issues=all_critical[:10], # Limit stored issues
|
||||
)
|
||||
|
||||
except (HTTPError, URLError) as exc:
|
||||
return IssueSignal(
|
||||
count=0,
|
||||
p0_count=0,
|
||||
p1_count=0,
|
||||
issues=[],
|
||||
)
|
||||
|
||||
|
||||
def check_flakiness(config: dict) -> FlakinessSignal:
|
||||
"""Check test flakiness from cycle retrospective data."""
|
||||
retro_file = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
|
||||
lookback = config.get("flakiness_lookback_cycles", 20)
|
||||
|
||||
if not retro_file.exists():
|
||||
return FlakinessSignal(
|
||||
status="unknown",
|
||||
recent_failures=0,
|
||||
recent_cycles=0,
|
||||
failure_rate=0.0,
|
||||
message="No cycle data available",
|
||||
)
|
||||
|
||||
try:
|
||||
entries = []
|
||||
for line in retro_file.read_text().strip().splitlines():
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Get recent entries
|
||||
recent = entries[-lookback:] if len(entries) > lookback else entries
|
||||
|
||||
failures = [e for e in recent if not e.get("success", True)]
|
||||
failure_count = len(failures)
|
||||
total_count = len(recent)
|
||||
|
||||
if total_count == 0:
|
||||
return FlakinessSignal(
|
||||
status="unknown",
|
||||
recent_failures=0,
|
||||
recent_cycles=0,
|
||||
failure_rate=0.0,
|
||||
message="No recent cycle data",
|
||||
)
|
||||
|
||||
failure_rate = failure_count / total_count
|
||||
|
||||
# Determine status based on failure rate
|
||||
if failure_rate < 0.1:
|
||||
status = "healthy"
|
||||
message = f"Low flakiness ({failure_rate:.0%})"
|
||||
elif failure_rate < 0.3:
|
||||
status = "degraded"
|
||||
message = f"Moderate flakiness ({failure_rate:.0%})"
|
||||
else:
|
||||
status = "critical"
|
||||
message = f"High flakiness ({failure_rate:.0%})"
|
||||
|
||||
return FlakinessSignal(
|
||||
status=status,
|
||||
recent_failures=failure_count,
|
||||
recent_cycles=total_count,
|
||||
failure_rate=failure_rate,
|
||||
message=message,
|
||||
)
|
||||
|
||||
except (OSError, ValueError) as exc:
|
||||
return FlakinessSignal(
|
||||
status="unknown",
|
||||
recent_failures=0,
|
||||
recent_cycles=0,
|
||||
failure_rate=0.0,
|
||||
message=f"Could not read cycle data: {exc}",
|
||||
)
|
||||
|
||||
|
||||
def check_token_economy(config: dict) -> TokenEconomySignal:
|
||||
"""Check token economy temperature from recent transactions."""
|
||||
# This is a simplified check - in a full implementation,
|
||||
# this would query the token ledger
|
||||
ledger_file = REPO_ROOT / ".loop" / "token_economy.jsonl"
|
||||
|
||||
if not ledger_file.exists():
|
||||
return TokenEconomySignal(
|
||||
status="unknown",
|
||||
message="No token economy data",
|
||||
)
|
||||
|
||||
try:
|
||||
# Read last 24 hours of transactions
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
|
||||
recent_mint = 0
|
||||
recent_burn = 0
|
||||
|
||||
for line in ledger_file.read_text().strip().splitlines():
|
||||
try:
|
||||
tx = json.loads(line)
|
||||
tx_time = datetime.fromisoformat(tx.get("timestamp", "1970-01-01").replace("Z", "+00:00"))
|
||||
if tx_time >= since:
|
||||
delta = tx.get("delta", 0)
|
||||
if delta > 0:
|
||||
recent_mint += delta
|
||||
else:
|
||||
recent_burn += abs(delta)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
# Simple temperature check
|
||||
if recent_mint > recent_burn * 2:
|
||||
status = "inflationary"
|
||||
message = f"High mint activity (+{recent_mint}/-{recent_burn})"
|
||||
elif recent_burn > recent_mint * 2:
|
||||
status = "deflationary"
|
||||
message = f"High burn activity (+{recent_mint}/-{recent_burn})"
|
||||
else:
|
||||
status = "balanced"
|
||||
message = f"Balanced flow (+{recent_mint}/-{recent_burn})"
|
||||
|
||||
return TokenEconomySignal(
|
||||
status=status,
|
||||
message=message,
|
||||
recent_mint=recent_mint,
|
||||
recent_burn=recent_burn,
|
||||
)
|
||||
|
||||
except (OSError, ValueError) as exc:
|
||||
return TokenEconomySignal(
|
||||
status="unknown",
|
||||
message=f"Could not read token data: {exc}",
|
||||
)
|
||||
|
||||
|
||||
def calculate_overall_status(
|
||||
ci: CISignal,
|
||||
issues: IssueSignal,
|
||||
flakiness: FlakinessSignal,
|
||||
) -> str:
|
||||
"""Calculate overall status from individual signals."""
|
||||
# Red conditions
|
||||
if ci.status == "fail":
|
||||
return "red"
|
||||
if issues.p0_count > 0:
|
||||
return "red"
|
||||
if flakiness.status == "critical":
|
||||
return "red"
|
||||
|
||||
# Yellow conditions
|
||||
if ci.status == "unknown":
|
||||
return "yellow"
|
||||
if issues.p1_count > 0:
|
||||
return "yellow"
|
||||
if flakiness.status == "degraded":
|
||||
return "yellow"
|
||||
|
||||
# Green
|
||||
return "green"
|
||||
|
||||
|
||||
# ── Main Functions ────────────────────────────────────────────────────────
|
||||
|
||||
def generate_snapshot(config: dict, token: str | None) -> HealthSnapshot:
|
||||
"""Generate a complete health snapshot."""
|
||||
client = GiteaClient(config, token)
|
||||
|
||||
# Always run all checks (don't short-circuit)
|
||||
if client.is_available():
|
||||
ci = check_ci_status(client, config)
|
||||
issues = check_critical_issues(client, config)
|
||||
else:
|
||||
ci = CISignal(
|
||||
status="unavailable",
|
||||
message="Gitea unavailable",
|
||||
)
|
||||
issues = IssueSignal(count=0, p0_count=0, p1_count=0, issues=[])
|
||||
|
||||
flakiness = check_flakiness(config)
|
||||
tokens = check_token_economy(config)
|
||||
|
||||
overall = calculate_overall_status(ci, issues, flakiness)
|
||||
|
||||
return HealthSnapshot(
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
overall_status=overall,
|
||||
ci=ci,
|
||||
issues=issues,
|
||||
flakiness=flakiness,
|
||||
tokens=tokens,
|
||||
)
|
||||
|
||||
|
||||
def print_snapshot(snapshot: HealthSnapshot, verbose: bool = False) -> None:
|
||||
"""Print a formatted health snapshot."""
|
||||
# Status emoji
|
||||
status_emoji = {"green": "🟢", "yellow": "🟡", "red": "🔴"}.get(
|
||||
snapshot.overall_status, "⚪"
|
||||
)
|
||||
|
||||
print("=" * 60)
|
||||
print(f"{status_emoji} HEALTH SNAPSHOT")
|
||||
print("=" * 60)
|
||||
print(f"Generated: {snapshot.timestamp}")
|
||||
print(f"Overall: {snapshot.overall_status.upper()}")
|
||||
print()
|
||||
|
||||
# CI Status
|
||||
ci_emoji = {"pass": "✅", "fail": "❌", "unknown": "⚠️", "unavailable": "⚪"}.get(
|
||||
snapshot.ci.status, "⚪"
|
||||
)
|
||||
print(f"{ci_emoji} CI: {snapshot.ci.message}")
|
||||
|
||||
# Issues
|
||||
if snapshot.issues.p0_count > 0:
|
||||
issue_emoji = "🔴"
|
||||
elif snapshot.issues.p1_count > 0:
|
||||
issue_emoji = "🟡"
|
||||
else:
|
||||
issue_emoji = "✅"
|
||||
print(f"{issue_emoji} Issues: {snapshot.issues.count} critical")
|
||||
if snapshot.issues.p0_count > 0:
|
||||
print(f" 🔴 P0: {snapshot.issues.p0_count}")
|
||||
if snapshot.issues.p1_count > 0:
|
||||
print(f" 🟡 P1: {snapshot.issues.p1_count}")
|
||||
|
||||
# Flakiness
|
||||
flak_emoji = {"healthy": "✅", "degraded": "🟡", "critical": "🔴", "unknown": "⚪"}.get(
|
||||
snapshot.flakiness.status, "⚪"
|
||||
)
|
||||
print(f"{flak_emoji} Flakiness: {snapshot.flakiness.message}")
|
||||
|
||||
# Token Economy
|
||||
token_emoji = {"balanced": "✅", "inflationary": "🟡", "deflationary": "🔵", "unknown": "⚪"}.get(
|
||||
snapshot.tokens.status, "⚪"
|
||||
)
|
||||
print(f"{token_emoji} Tokens: {snapshot.tokens.message}")
|
||||
|
||||
# Verbose: show issue details
|
||||
if verbose and snapshot.issues.issues:
|
||||
print()
|
||||
print("Critical Issues:")
|
||||
for issue in snapshot.issues.issues[:5]:
|
||||
print(f" #{issue['number']}: {issue['title'][:50]}")
|
||||
|
||||
print()
|
||||
print("─" * 60)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Quick health snapshot before coding",
|
||||
)
|
||||
p.add_argument(
|
||||
"--json", "-j",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
p.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Show verbose output including issue details",
|
||||
)
|
||||
p.add_argument(
|
||||
"--quiet", "-q",
|
||||
action="store_true",
|
||||
help="Only show status line (no details)",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point for CLI."""
|
||||
args = parse_args()
|
||||
config = load_config()
|
||||
token = get_token(config)
|
||||
|
||||
snapshot = generate_snapshot(config, token)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(snapshot.to_dict(), indent=2))
|
||||
elif args.quiet:
|
||||
status_emoji = {"green": "🟢", "yellow": "🟡", "red": "🔴"}.get(
|
||||
snapshot.overall_status, "⚪"
|
||||
)
|
||||
print(f"{status_emoji} {snapshot.overall_status.upper()}")
|
||||
else:
|
||||
print_snapshot(snapshot, verbose=args.verbose)
|
||||
|
||||
# Exit with non-zero if red status
|
||||
return 0 if snapshot.overall_status != "red" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -22,6 +22,14 @@ from typing import Any
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
# ── Token Economy Integration ──────────────────────────────────────────────
|
||||
# Import token rules helpers for tracking Daily Run rewards
|
||||
|
||||
sys.path.insert(
|
||||
0, str(Path(__file__).resolve().parent.parent)
|
||||
)
|
||||
from utils.token_rules import TokenRules, compute_token_reward
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
@@ -490,6 +498,43 @@ def parse_args() -> argparse.Namespace:
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def compute_daily_run_tokens(success: bool = True) -> dict[str, Any]:
|
||||
"""Compute token rewards for Daily Run completion.
|
||||
|
||||
Uses the centralized token_rules configuration to calculate
|
||||
rewards/penalties for automation actions.
|
||||
|
||||
Args:
|
||||
success: Whether the Daily Run completed successfully
|
||||
|
||||
Returns:
|
||||
Token transaction details
|
||||
"""
|
||||
rules = TokenRules()
|
||||
|
||||
if success:
|
||||
# Daily run completed successfully
|
||||
transaction = compute_token_reward("daily_run_completed", current_tokens=0)
|
||||
|
||||
# Also compute golden path generation if agenda was created
|
||||
agenda_transaction = compute_token_reward("golden_path_generated", current_tokens=0)
|
||||
|
||||
return {
|
||||
"daily_run": transaction,
|
||||
"golden_path": agenda_transaction,
|
||||
"total_delta": transaction.get("delta", 0) + agenda_transaction.get("delta", 0),
|
||||
"config_version": rules.get_config_version(),
|
||||
}
|
||||
else:
|
||||
# Automation failed
|
||||
transaction = compute_token_reward("automation_failure", current_tokens=0)
|
||||
return {
|
||||
"automation_failure": transaction,
|
||||
"total_delta": transaction.get("delta", 0),
|
||||
"config_version": rules.get_config_version(),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
config = load_config()
|
||||
@@ -503,10 +548,13 @@ def main() -> int:
|
||||
# Check Gitea availability
|
||||
if not client.is_available():
|
||||
error_msg = "[orchestrator] Error: Gitea API is not available"
|
||||
# Compute failure tokens even when unavailable
|
||||
tokens = compute_daily_run_tokens(success=False)
|
||||
if args.json:
|
||||
print(json.dumps({"error": error_msg}))
|
||||
print(json.dumps({"error": error_msg, "tokens": tokens}))
|
||||
else:
|
||||
print(error_msg, file=sys.stderr)
|
||||
print(f"[tokens] Failure penalty: {tokens['total_delta']}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Fetch candidates and generate agenda
|
||||
@@ -521,9 +569,12 @@ def main() -> int:
|
||||
cycles = load_cycle_data()
|
||||
day_summary = generate_day_summary(activity, cycles)
|
||||
|
||||
# Compute token rewards for successful completion
|
||||
tokens = compute_daily_run_tokens(success=True)
|
||||
|
||||
# Output
|
||||
if args.json:
|
||||
output = {"agenda": agenda}
|
||||
output = {"agenda": agenda, "tokens": tokens}
|
||||
if day_summary:
|
||||
output["day_summary"] = day_summary
|
||||
print(json.dumps(output, indent=2))
|
||||
@@ -531,6 +582,15 @@ def main() -> int:
|
||||
print_agenda(agenda)
|
||||
if day_summary and activity:
|
||||
print_day_summary(day_summary, activity)
|
||||
# Show token rewards
|
||||
print("─" * 60)
|
||||
print("🪙 Token Rewards")
|
||||
print("─" * 60)
|
||||
print(f"Daily Run completed: +{tokens['daily_run']['delta']} tokens")
|
||||
if candidates:
|
||||
print(f"Golden path generated: +{tokens['golden_path']['delta']} tokens")
|
||||
print(f"Total: +{tokens['total_delta']} tokens")
|
||||
print(f"Config version: {tokens['config_version']}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
6
timmy_automations/utils/__init__.py
Normal file
6
timmy_automations/utils/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Timmy Automations utilities.
|
||||
|
||||
Shared helper modules for automations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
389
timmy_automations/utils/token_rules.py
Normal file
389
timmy_automations/utils/token_rules.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""Token rules helper — Compute token deltas for agent actions.
|
||||
|
||||
This module loads token economy configuration from YAML and provides
|
||||
functions for automations to compute token rewards/penalties.
|
||||
|
||||
Usage:
|
||||
from timmy_automations.utils.token_rules import TokenRules
|
||||
|
||||
rules = TokenRules()
|
||||
delta = rules.get_delta("pr_merged")
|
||||
print(f"PR merge reward: {delta}") # 10
|
||||
|
||||
# Check if agent can perform sensitive operation
|
||||
can_merge = rules.check_gate("pr_merge", current_tokens=25)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenEvent:
|
||||
"""Represents a single token event configuration."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
reward: int
|
||||
penalty: int
|
||||
category: str
|
||||
gate_threshold: int | None = None
|
||||
|
||||
@property
|
||||
def delta(self) -> int:
|
||||
"""Net token delta (reward + penalty)."""
|
||||
return self.reward + self.penalty
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenCategoryLimits:
|
||||
"""Daily limits for a token category."""
|
||||
|
||||
max_earn: int
|
||||
max_spend: int
|
||||
|
||||
|
||||
class TokenRules:
|
||||
"""Token economy rules loader and calculator.
|
||||
|
||||
Loads configuration from timmy_automations/config/token_rules.yaml
|
||||
and provides methods to compute token deltas and check gating.
|
||||
"""
|
||||
|
||||
CONFIG_PATH = Path(__file__).parent.parent / "config" / "token_rules.yaml"
|
||||
|
||||
def __init__(self, config_path: Path | None = None) -> None:
|
||||
"""Initialize token rules from configuration file.
|
||||
|
||||
Args:
|
||||
config_path: Optional override for config file location.
|
||||
"""
|
||||
self._config_path = config_path or self.CONFIG_PATH
|
||||
self._events: dict[str, TokenEvent] = {}
|
||||
self._gating: dict[str, int] = {}
|
||||
self._daily_limits: dict[str, TokenCategoryLimits] = {}
|
||||
self._audit: dict[str, Any] = {}
|
||||
self._version: str = "unknown"
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self) -> None:
|
||||
"""Load configuration from YAML file."""
|
||||
# Graceful degradation if yaml not available or file missing
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
# YAML not installed, use fallback defaults
|
||||
self._load_fallback_defaults()
|
||||
return
|
||||
|
||||
if not self._config_path.exists():
|
||||
self._load_fallback_defaults()
|
||||
return
|
||||
|
||||
try:
|
||||
config = yaml.safe_load(self._config_path.read_text())
|
||||
if not config:
|
||||
self._load_fallback_defaults()
|
||||
return
|
||||
|
||||
self._version = config.get("version", "unknown")
|
||||
self._parse_events(config.get("events", {}))
|
||||
self._parse_gating(config.get("gating_thresholds", {}))
|
||||
self._parse_daily_limits(config.get("daily_limits", {}))
|
||||
self._audit = config.get("audit", {})
|
||||
|
||||
except Exception:
|
||||
# Any error loading config, use fallbacks
|
||||
self._load_fallback_defaults()
|
||||
|
||||
def _load_fallback_defaults(self) -> None:
|
||||
"""Load minimal fallback defaults if config unavailable."""
|
||||
self._version = "fallback"
|
||||
self._events = {
|
||||
"pr_merged": TokenEvent(
|
||||
name="pr_merged",
|
||||
description="Successfully merged a pull request",
|
||||
reward=10,
|
||||
penalty=0,
|
||||
category="merge",
|
||||
gate_threshold=0,
|
||||
),
|
||||
"test_fixed": TokenEvent(
|
||||
name="test_fixed",
|
||||
description="Fixed a failing test",
|
||||
reward=8,
|
||||
penalty=0,
|
||||
category="test",
|
||||
),
|
||||
"automation_failure": TokenEvent(
|
||||
name="automation_failure",
|
||||
description="Automation failed",
|
||||
reward=0,
|
||||
penalty=-2,
|
||||
category="operation",
|
||||
),
|
||||
}
|
||||
self._gating = {"pr_merge": 0}
|
||||
self._daily_limits = {}
|
||||
self._audit = {"log_all_transactions": True}
|
||||
|
||||
def _parse_events(self, events_config: dict) -> None:
|
||||
"""Parse event configurations from YAML."""
|
||||
for name, config in events_config.items():
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
|
||||
self._events[name] = TokenEvent(
|
||||
name=name,
|
||||
description=config.get("description", ""),
|
||||
reward=config.get("reward", 0),
|
||||
penalty=config.get("penalty", 0),
|
||||
category=config.get("category", "unknown"),
|
||||
gate_threshold=config.get("gate_threshold"),
|
||||
)
|
||||
|
||||
def _parse_gating(self, gating_config: dict) -> None:
|
||||
"""Parse gating thresholds from YAML."""
|
||||
for name, threshold in gating_config.items():
|
||||
if isinstance(threshold, int):
|
||||
self._gating[name] = threshold
|
||||
|
||||
def _parse_daily_limits(self, limits_config: dict) -> None:
|
||||
"""Parse daily limits from YAML."""
|
||||
for category, limits in limits_config.items():
|
||||
if isinstance(limits, dict):
|
||||
self._daily_limits[category] = TokenCategoryLimits(
|
||||
max_earn=limits.get("max_earn", 0),
|
||||
max_spend=limits.get("max_spend", 0),
|
||||
)
|
||||
|
||||
def get_delta(self, event_name: str) -> int:
|
||||
"""Get token delta for an event.
|
||||
|
||||
Args:
|
||||
event_name: Name of the event (e.g., "pr_merged", "test_fixed")
|
||||
|
||||
Returns:
|
||||
Net token delta (positive for reward, negative for penalty)
|
||||
"""
|
||||
event = self._events.get(event_name)
|
||||
if event:
|
||||
return event.delta
|
||||
return 0
|
||||
|
||||
def get_event(self, event_name: str) -> TokenEvent | None:
|
||||
"""Get full event configuration.
|
||||
|
||||
Args:
|
||||
event_name: Name of the event
|
||||
|
||||
Returns:
|
||||
TokenEvent object or None if not found
|
||||
"""
|
||||
return self._events.get(event_name)
|
||||
|
||||
def list_events(self, category: str | None = None) -> list[TokenEvent]:
|
||||
"""List all configured events.
|
||||
|
||||
Args:
|
||||
category: Optional category filter
|
||||
|
||||
Returns:
|
||||
List of TokenEvent objects
|
||||
"""
|
||||
events = list(self._events.values())
|
||||
if category:
|
||||
events = [e for e in events if e.category == category]
|
||||
return events
|
||||
|
||||
def check_gate(self, operation: str, current_tokens: int) -> bool:
|
||||
"""Check if agent meets token threshold for an operation.
|
||||
|
||||
Args:
|
||||
operation: Operation name (e.g., "pr_merge")
|
||||
current_tokens: Agent's current token balance
|
||||
|
||||
Returns:
|
||||
True if agent can perform the operation
|
||||
"""
|
||||
threshold = self._gating.get(operation)
|
||||
if threshold is None:
|
||||
return True # No gate defined, allow
|
||||
return current_tokens >= threshold
|
||||
|
||||
def get_gate_threshold(self, operation: str) -> int | None:
|
||||
"""Get the gating threshold for an operation.
|
||||
|
||||
Args:
|
||||
operation: Operation name
|
||||
|
||||
Returns:
|
||||
Threshold value or None if no gate defined
|
||||
"""
|
||||
return self._gating.get(operation)
|
||||
|
||||
def get_daily_limits(self, category: str) -> TokenCategoryLimits | None:
|
||||
"""Get daily limits for a category.
|
||||
|
||||
Args:
|
||||
category: Category name
|
||||
|
||||
Returns:
|
||||
TokenCategoryLimits or None if not defined
|
||||
"""
|
||||
return self._daily_limits.get(category)
|
||||
|
||||
def compute_transaction(
|
||||
self,
|
||||
event_name: str,
|
||||
current_tokens: int = 0,
|
||||
current_daily_earned: dict[str, int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Compute a complete token transaction.
|
||||
|
||||
This is the main entry point for agents to use. It returns
|
||||
a complete transaction record with delta, gating check, and limits.
|
||||
|
||||
Args:
|
||||
event_name: Name of the event
|
||||
current_tokens: Agent's current token balance
|
||||
current_daily_earned: Dict of category -> tokens earned today
|
||||
|
||||
Returns:
|
||||
Transaction dict with:
|
||||
- event: Event name
|
||||
- delta: Token delta
|
||||
- allowed: Whether operation is allowed (gating)
|
||||
- new_balance: Projected new balance
|
||||
- limit_reached: Whether daily limit would be exceeded
|
||||
"""
|
||||
event = self._events.get(event_name)
|
||||
if not event:
|
||||
return {
|
||||
"event": event_name,
|
||||
"delta": 0,
|
||||
"allowed": False,
|
||||
"reason": "unknown_event",
|
||||
"new_balance": current_tokens,
|
||||
"limit_reached": False,
|
||||
}
|
||||
|
||||
delta = event.delta
|
||||
new_balance = current_tokens + delta
|
||||
|
||||
# Check gating (for penalties, we don't check gates)
|
||||
allowed = True
|
||||
gate_reason = None
|
||||
if delta > 0 and event.gate_threshold is not None: # Only check gates for positive operations with thresholds
|
||||
allowed = current_tokens >= event.gate_threshold
|
||||
if not allowed:
|
||||
gate_reason = f"requires {event.gate_threshold} tokens"
|
||||
|
||||
# Check daily limits
|
||||
limit_reached = False
|
||||
limit_reason = None
|
||||
if current_daily_earned and event.category in current_daily_earned:
|
||||
limits = self._daily_limits.get(event.category)
|
||||
if limits:
|
||||
current_earned = current_daily_earned.get(event.category, 0)
|
||||
if delta > 0 and current_earned + delta > limits.max_earn:
|
||||
limit_reached = True
|
||||
limit_reason = f"daily earn limit ({limits.max_earn}) reached"
|
||||
|
||||
result = {
|
||||
"event": event_name,
|
||||
"delta": delta,
|
||||
"category": event.category,
|
||||
"allowed": allowed and not limit_reached,
|
||||
"new_balance": new_balance,
|
||||
"limit_reached": limit_reached,
|
||||
}
|
||||
|
||||
if gate_reason:
|
||||
result["gate_reason"] = gate_reason
|
||||
if limit_reason:
|
||||
result["limit_reason"] = limit_reason
|
||||
|
||||
return result
|
||||
|
||||
def get_config_version(self) -> str:
|
||||
"""Get the loaded configuration version."""
|
||||
return self._version
|
||||
|
||||
def get_categories(self) -> list[str]:
|
||||
"""Get list of all configured categories."""
|
||||
categories = {e.category for e in self._events.values()}
|
||||
return sorted(categories)
|
||||
|
||||
def is_auditable(self) -> bool:
|
||||
"""Check if transactions should be logged for audit."""
|
||||
return self._audit.get("log_all_transactions", True)
|
||||
|
||||
|
||||
# Convenience functions for simple use cases
|
||||
|
||||
def get_token_delta(event_name: str) -> int:
|
||||
"""Get token delta for an event (convenience function).
|
||||
|
||||
Args:
|
||||
event_name: Name of the event
|
||||
|
||||
Returns:
|
||||
Token delta (positive for reward, negative for penalty)
|
||||
"""
|
||||
return TokenRules().get_delta(event_name)
|
||||
|
||||
|
||||
def check_operation_gate(operation: str, current_tokens: int) -> bool:
|
||||
"""Check if agent can perform operation (convenience function).
|
||||
|
||||
Args:
|
||||
operation: Operation name
|
||||
current_tokens: Agent's current token balance
|
||||
|
||||
Returns:
|
||||
True if operation is allowed
|
||||
"""
|
||||
return TokenRules().check_gate(operation, current_tokens)
|
||||
|
||||
|
||||
def compute_token_reward(
|
||||
event_name: str,
|
||||
current_tokens: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Compute token reward for an event (convenience function).
|
||||
|
||||
Args:
|
||||
event_name: Name of the event
|
||||
current_tokens: Agent's current token balance
|
||||
|
||||
Returns:
|
||||
Transaction dict with delta, allowed status, new balance
|
||||
"""
|
||||
return TokenRules().compute_transaction(event_name, current_tokens)
|
||||
|
||||
|
||||
def list_token_events(category: str | None = None) -> list[dict[str, Any]]:
|
||||
"""List all token events (convenience function).
|
||||
|
||||
Args:
|
||||
category: Optional category filter
|
||||
|
||||
Returns:
|
||||
List of event dicts with name, description, delta, category
|
||||
"""
|
||||
rules = TokenRules()
|
||||
events = rules.list_events(category)
|
||||
return [
|
||||
{
|
||||
"name": e.name,
|
||||
"description": e.description,
|
||||
"delta": e.delta,
|
||||
"category": e.category,
|
||||
"gate_threshold": e.gate_threshold,
|
||||
}
|
||||
for e in events
|
||||
]
|
||||
Reference in New Issue
Block a user