Compare commits

..

1 Commits

Author SHA1 Message Date
kimi
d3eb5c31bf feat: Centralize agent token rules and hooks for automations (#711)
- Add token_rules.yaml config defining events, rewards, penalties, and gating thresholds
- Create token_rules.py helper module for loading config and computing token deltas
- Update orchestrator.py to compute token rewards for Daily Run completion
- Add comprehensive tests for token_rules module

The token economy is now configurable without code changes:
- Events: triage actions, Daily Run completions, PR merges, test fixes
- Rewards/penalties per event type
- Gating thresholds for sensitive operations
- Daily limits per category
- Audit settings for transaction logging

Fixes #711
2026-03-21 17:42:43 -04:00
21 changed files with 1 additions and 4393 deletions

1
.gitignore vendored
View File

@@ -73,6 +73,7 @@ morning_briefing.txt
markdown_report.md
data/timmy_soul.jsonl
scripts/migrate_to_zeroclaw.py
src/infrastructure/db_pool.py
workspace/
# Loop orchestration state

View File

@@ -1,312 +0,0 @@
# 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 (0max) | 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.

View File

@@ -20,7 +20,6 @@ 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,

View File

@@ -1,89 +0,0 @@
"""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")

View File

@@ -275,54 +275,3 @@ 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"},
}

View File

@@ -1,84 +0,0 @@
"""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()

View File

@@ -1,18 +0,0 @@
"""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",
]

View File

@@ -1,307 +0,0 @@
"""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,
}

View File

@@ -1,186 +0,0 @@
"""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.01.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"
)

View File

@@ -1,243 +0,0 @@
"""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

View File

@@ -489,43 +489,5 @@ 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()

View File

@@ -13,121 +13,11 @@
<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": {
@@ -184,271 +74,6 @@
});
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;

View File

@@ -87,569 +87,3 @@ 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%;
}
}

View File

@@ -1,288 +0,0 @@
"""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

View File

@@ -1,266 +0,0 @@
"""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

View File

@@ -1,242 +0,0 @@
"""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

View File

@@ -130,13 +130,6 @@ 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
@@ -193,7 +186,6 @@ class TestNo500:
"/health",
"/health/status",
"/health/sovereignty",
"/health/snapshot",
"/health/components",
"/agents/default/panel",
"/agents/default/history",

View File

@@ -1,280 +0,0 @@
"""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

View File

@@ -1,401 +0,0 @@
"""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

View File

@@ -1,9 +1,6 @@
{
"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": [
{
@@ -252,22 +249,6 @@
".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": []
}
]
}

View File

@@ -1,619 +0,0 @@
#!/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())