forked from Rockachopa/Timmy-time-dashboard
Compare commits
1 Commits
feature/mo
...
kimi/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3eb5c31bf |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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 (0–max) | yes | Current health |
|
||||
| `health.max` | int (>0) | yes | Maximum health |
|
||||
| `nearby_entities` | array | yes | Entities within perception radius (may be empty) |
|
||||
| `inventory_summary` | object | yes | Lightweight inventory overview |
|
||||
| `active_quests` | array | yes | Currently tracked quests |
|
||||
| `environment` | object | yes | World-state flags |
|
||||
| `raw_engine_data` | object | no | Opaque engine-specific blob (not relied upon by heartbeat) |
|
||||
|
||||
### 3.2 Entity Types
|
||||
|
||||
The `entity_type` field uses a controlled vocabulary:
|
||||
|
||||
| Value | Description |
|
||||
| ---------- | ------------------------ |
|
||||
| `npc` | Non-player character |
|
||||
| `creature` | Hostile or neutral mob |
|
||||
| `item` | Pickup-able world item |
|
||||
| `door` | Door or transition |
|
||||
| `container`| Lootable container |
|
||||
|
||||
---
|
||||
|
||||
## 4. Command Input Schema
|
||||
|
||||
Sent via `POST /command`. Represents a single action the agent wants to take in the world.
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol_version": "1.0.0",
|
||||
"timestamp": "2026-03-21T14:30:01Z",
|
||||
"agent_id": "timmy",
|
||||
"command": "move_to",
|
||||
"params": {
|
||||
"target_cell": "Balmora",
|
||||
"target_x": 1050.0,
|
||||
"target_y": -500.0
|
||||
},
|
||||
"reasoning": "Moving closer to Caius Cosades to begin the main quest dialogue.",
|
||||
"episode_id": "ep_20260321_001",
|
||||
"context": {
|
||||
"perception_timestamp": "2026-03-21T14:30:00Z",
|
||||
"heartbeat_cycle": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.1 Field Reference
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------------------ | ----------------- | -------- | ------------------------------------------------------- |
|
||||
| `protocol_version` | string | yes | Protocol SemVer |
|
||||
| `timestamp` | ISO 8601 datetime | yes | When the command was issued |
|
||||
| `agent_id` | string | yes | Which agent is issuing the command |
|
||||
| `command` | string (enum) | yes | Command type (see §4.2) |
|
||||
| `params` | object | yes | Command-specific parameters (may be empty `{}`) |
|
||||
| `reasoning` | string | yes | Natural-language explanation of *why* this command |
|
||||
| `episode_id` | string | no | Groups commands into training episodes |
|
||||
| `context` | object | no | Metadata linking command to its triggering perception |
|
||||
|
||||
### 4.2 Command Types
|
||||
|
||||
| Command | Description | Key Params |
|
||||
| --------------- | ---------------------------------------- | ---------------------------------- |
|
||||
| `move_to` | Navigate to coordinates or entity | `target_cell`, `target_x/y/z` |
|
||||
| `interact` | Interact with entity (talk, activate) | `entity_id`, `interaction_type` |
|
||||
| `use_item` | Use an inventory item | `item_id`, `target_entity_id?` |
|
||||
| `wait` | Wait/idle for a duration | `duration_seconds` |
|
||||
| `combat_action` | Perform a combat action | `action_type`, `target_entity_id` |
|
||||
| `dialogue` | Choose a dialogue option | `entity_id`, `topic`, `choice_idx` |
|
||||
| `journal_note` | Write an internal journal observation | `content`, `tags` |
|
||||
| `noop` | Heartbeat tick with no action | — |
|
||||
|
||||
---
|
||||
|
||||
## 5. API Contracts
|
||||
|
||||
### 5.1 `GET /perception`
|
||||
|
||||
Returns the latest perception snapshot.
|
||||
|
||||
**Response:** `200 OK` with `PerceptionOutput` JSON body.
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Description |
|
||||
| ------ | ------------------- | ----------------------------------- |
|
||||
| 503 | `BRIDGE_UNAVAILABLE`| Game bridge is not connected |
|
||||
| 504 | `PERCEPTION_TIMEOUT`| Bridge did not respond in time |
|
||||
| 422 | `SCHEMA_MISMATCH` | Bridge returned incompatible schema |
|
||||
|
||||
### 5.2 `POST /command`
|
||||
|
||||
Submit a command for the agent to execute.
|
||||
|
||||
**Request:** `CommandInput` JSON body.
|
||||
|
||||
**Response:** `202 Accepted`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"command_id": "cmd_abc123",
|
||||
"logged": true
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Code | Description |
|
||||
| ------ | -------------------- | ----------------------------------- |
|
||||
| 400 | `INVALID_COMMAND` | Command type not recognized |
|
||||
| 400 | `VALIDATION_ERROR` | Payload fails Pydantic validation |
|
||||
| 409 | `COMMAND_CONFLICT` | Agent is busy executing another cmd |
|
||||
| 503 | `BRIDGE_UNAVAILABLE` | Game bridge is not connected |
|
||||
|
||||
### 5.3 `GET /morrowind/status`
|
||||
|
||||
Health-check endpoint for the Morrowind bridge.
|
||||
|
||||
**Response:** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"bridge_connected": true,
|
||||
"engine": "tes3mp",
|
||||
"protocol_version": "1.0.0",
|
||||
"uptime_seconds": 3600,
|
||||
"last_perception_at": "2026-03-21T14:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Engine-Swap Documentation (The Falsework Rule)
|
||||
|
||||
### What Changes
|
||||
|
||||
| Component | Changes on Engine Swap? | Notes |
|
||||
| ---------------------- | ----------------------- | --------------------------------------------- |
|
||||
| Bridge process | **YES** — replaced | New bridge speaks same protocol to new engine |
|
||||
| Perception Lua script | **YES** — replaced | New engine's scripting language/API |
|
||||
| `PerceptionOutput` | NO | Schema is engine-agnostic |
|
||||
| `CommandInput` | NO | Schema is engine-agnostic |
|
||||
| Heartbeat loop | NO | Consumes `PerceptionOutput`, emits `Command` |
|
||||
| Reasoning/LLM layer | NO | Operates on abstract perception data |
|
||||
| Journal system | NO | Writes `journal_note` commands |
|
||||
| Command log + training | NO | Logs all commands regardless of engine |
|
||||
| Dashboard WebSocket | NO | Separate protocol (`src/infrastructure/protocol.py`) |
|
||||
|
||||
### Swap Procedure
|
||||
|
||||
1. Implement new bridge that serves `GET /perception` and accepts `POST /command`.
|
||||
2. Update `raw_engine_data` field documentation for the new engine.
|
||||
3. Extend `entity_type` enum if the new engine has novel entity categories.
|
||||
4. Bump `protocol_version` minor (or major if schema changes are required).
|
||||
5. Run integration tests against the new bridge.
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Handling Specification
|
||||
|
||||
### 7.1 Error Response Format
|
||||
|
||||
All error responses follow a consistent structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "BRIDGE_UNAVAILABLE",
|
||||
"message": "Human-readable error description",
|
||||
"details": {},
|
||||
"timestamp": "2026-03-21T14:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Error Codes
|
||||
|
||||
| Code | HTTP Status | Retry? | Description |
|
||||
| -------------------- | ----------- | ------ | ---------------------------------------- |
|
||||
| `BRIDGE_UNAVAILABLE` | 503 | yes | Bridge process not connected |
|
||||
| `PERCEPTION_TIMEOUT` | 504 | yes | Bridge did not respond within deadline |
|
||||
| `SCHEMA_MISMATCH` | 422 | no | Protocol version incompatibility |
|
||||
| `INVALID_COMMAND` | 400 | no | Unknown command type |
|
||||
| `VALIDATION_ERROR` | 400 | no | Pydantic validation failed |
|
||||
| `COMMAND_CONFLICT` | 409 | yes | Agent busy — retry after current command |
|
||||
| `INTERNAL_ERROR` | 500 | yes | Unexpected server error |
|
||||
|
||||
### 7.3 Retry Policy
|
||||
|
||||
Clients SHOULD implement exponential backoff for retryable errors:
|
||||
- Initial delay: 100ms
|
||||
- Max delay: 5s
|
||||
- Max retries: 5
|
||||
- Jitter: ±50ms
|
||||
|
||||
---
|
||||
|
||||
## 8. Appendix: Pydantic Model Reference
|
||||
|
||||
The canonical Pydantic v2 models live in `src/infrastructure/morrowind/schemas.py`.
|
||||
These models serve as both runtime validation and living documentation of this spec.
|
||||
Any change to this spec document MUST be reflected in the Pydantic models, and vice versa.
|
||||
@@ -20,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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.0–1.0)"
|
||||
)
|
||||
|
||||
|
||||
class QuestInfo(BaseModel):
|
||||
"""A currently tracked quest."""
|
||||
|
||||
quest_id: str = Field(..., description="Quest identifier")
|
||||
name: str = Field(..., description="Quest display name")
|
||||
stage: int = Field(0, ge=0, description="Current quest stage")
|
||||
|
||||
|
||||
class Environment(BaseModel):
|
||||
"""World-state flags."""
|
||||
|
||||
time_of_day: str = Field("unknown", description="Time period (morning, afternoon, etc.)")
|
||||
weather: str = Field("clear", description="Current weather condition")
|
||||
is_combat: bool = Field(False, description="Whether the agent is in combat")
|
||||
is_dialogue: bool = Field(False, description="Whether the agent is in dialogue")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Top-level schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PerceptionOutput(BaseModel):
|
||||
"""Complete perception snapshot returned by ``GET /perception``.
|
||||
|
||||
This is the engine-agnostic view of the game world consumed by the
|
||||
heartbeat loop and reasoning layer.
|
||||
"""
|
||||
|
||||
protocol_version: str = Field(
|
||||
default=PROTOCOL_VERSION,
|
||||
description="Protocol SemVer string",
|
||||
)
|
||||
timestamp: datetime = Field(..., description="When the snapshot was taken")
|
||||
agent_id: str = Field(..., description="Which agent this perception belongs to")
|
||||
|
||||
location: Location
|
||||
health: HealthStatus
|
||||
nearby_entities: list[NearbyEntity] = Field(default_factory=list)
|
||||
inventory_summary: InventorySummary = Field(default_factory=InventorySummary)
|
||||
active_quests: list[QuestInfo] = Field(default_factory=list)
|
||||
environment: Environment = Field(default_factory=Environment)
|
||||
|
||||
raw_engine_data: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Opaque engine-specific blob — not relied upon by heartbeat",
|
||||
)
|
||||
|
||||
|
||||
class CommandContext(BaseModel):
|
||||
"""Metadata linking a command to its triggering perception."""
|
||||
|
||||
perception_timestamp: datetime | None = Field(
|
||||
None, description="Timestamp of the perception that triggered this command"
|
||||
)
|
||||
heartbeat_cycle: int | None = Field(
|
||||
None, ge=0, description="Heartbeat cycle number"
|
||||
)
|
||||
|
||||
|
||||
class CommandInput(BaseModel):
|
||||
"""Command payload sent via ``POST /command``.
|
||||
|
||||
Every command includes a ``reasoning`` field so the command log
|
||||
captures the agent's intent — critical for training-data export.
|
||||
"""
|
||||
|
||||
protocol_version: str = Field(
|
||||
default=PROTOCOL_VERSION,
|
||||
description="Protocol SemVer string",
|
||||
)
|
||||
timestamp: datetime = Field(..., description="When the command was issued")
|
||||
agent_id: str = Field(..., description="Which agent is issuing the command")
|
||||
|
||||
command: CommandType = Field(..., description="Command type")
|
||||
params: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Command-specific parameters"
|
||||
)
|
||||
reasoning: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Natural-language explanation of why this command was chosen",
|
||||
)
|
||||
|
||||
episode_id: str | None = Field(
|
||||
None, description="Groups commands into training episodes"
|
||||
)
|
||||
context: CommandContext | None = Field(
|
||||
None, description="Metadata linking command to its triggering perception"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user