init
This commit is contained in:
1
.github/copilot-instructions.md
vendored
Normal file
1
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Use the instructions under the `instructions` dir wisely to guide your current task. These will greatly improve your context and help you to complete the task faster/with better quality.
|
||||
46
.github/instructions/frontend-design.instructions.md
vendored
Normal file
46
.github/instructions/frontend-design.instructions.md
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
applyTo: '**'
|
||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this instruction when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
||||
---
|
||||
This instruction guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||
|
||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||
|
||||
## Design Thinking
|
||||
|
||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||
|
||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
|
||||
## Frontend Aesthetics Guidelines
|
||||
|
||||
Focus on:
|
||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||
|
||||
NEVER use:
|
||||
* generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts),
|
||||
* cliched color schemes (particularly purple gradients on white backgrounds),
|
||||
* predictable layouts and component patterns,
|
||||
* unnecessary gradients out of context,
|
||||
* too many emojis scattered throughout the UI (use proper icons/SVGs instead),
|
||||
* and cookie-cutter design that lacks context-specific character.
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||
|
||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||
|
||||
Remember: You are capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Data
|
||||
data/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
server.log
|
||||
server_err.log
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
3
AGENTS.md
Normal file
3
AGENTS.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## Notes on calling tools
|
||||
* About `run_in_terminal` or some other similar tools that can make calls to external programs, be very careful about what programs/commands you call - some are blocking indefinitely by nature, like running servers, long-running monitors, etc. If you would like to execute such commands, make sure to run them in a non-blocking way, for example run/spawn them in another temp terminal session. Otherwise, your agent will be hung on the waiting indefinitely.
|
||||
* About `AskUser`, `get_user_request` or other similar tools that can retrieve latest user input, if available, make sure to call them **AGGRESSIVELY** once you finished all tasks at hand, so as to AVOID ending the current task/work/session prematurely. NEVER end the current task/work/session on your own, the user will keep adding new instructions into this kind of tools in the background. You can be missing out CRITICAL instructions from user when you failed to call them aggressively. Also, sometimes these tools can fail to return any results due to technical issues. Just try to call again when that happens.
|
||||
577
README.md
Normal file
577
README.md
Normal file
@@ -0,0 +1,577 @@
|
||||
# local-mcp
|
||||
|
||||
`local-mcp` is a localhost-first MCP server whose primary responsibility is to deliver the latest user instruction to an agent through the `get_user_request` tool, while also providing a responsive web UI for managing the instruction queue and monitoring server/agent activity.
|
||||
|
||||
This document is the implementation plan for the project.
|
||||
|
||||
## 1. Goals
|
||||
|
||||
- Provide a single MCP tool, `get_user_request`, that returns at most one instruction per call.
|
||||
- Give the user a polished local web UI to add, edit, remove, review, and monitor instructions.
|
||||
- Preserve queue integrity so consumed instructions are clearly visible but no longer editable/deletable.
|
||||
- Support configurable waiting/default-response behavior when no instruction is available.
|
||||
- Show live server status and inferred agent connectivity in the UI.
|
||||
- Keep the stack lightweight, maintainable, debuggable, and friendly to local development.
|
||||
|
||||
## 2. Recommended Tech Stack
|
||||
|
||||
### Backend
|
||||
|
||||
- **Language/runtime:** Python 3.11+
|
||||
- **MCP integration:** official Python MCP SDK
|
||||
- **HTTP server/API layer:** FastAPI
|
||||
- **ASGI server:** Uvicorn
|
||||
- **Persistence:** SQLite via Python standard library `sqlite3`
|
||||
- **Concurrency/state coordination:** `asyncio` + standard library synchronization primitives where needed
|
||||
- **Logging/error handling:** Python `logging`, structured request logs, centralized exception handling
|
||||
- **Configuration:** environment variables + small local config file (`.json` or `.toml`)
|
||||
|
||||
### Why this backend stack
|
||||
|
||||
- The MCP SDK is the correct dependency for exposing the MCP tool cleanly.
|
||||
- FastAPI + Uvicorn is a small, pragmatic backend stack that simplifies routing, validation, health endpoints, and server-sent updates without introducing a heavy framework.
|
||||
- SQLite keeps the system local-first, dependency-light, and durable enough for instruction history and settings.
|
||||
- Most supporting concerns remain in the Python standard library, which keeps third-party dependencies minimal.
|
||||
|
||||
### Frontend
|
||||
|
||||
- **UI technology:** plain HTML, CSS, and JavaScript only
|
||||
- **Realtime updates:** Server-Sent Events (preferred) with polling fallback if necessary
|
||||
- **Styling:** local CSS files with design tokens and component-specific stylesheets
|
||||
- **Client architecture:** modular vanilla JS organized by feature (`api.js`, `state.js`, `events.js`, `instructions.js`, etc.)
|
||||
- **Assets:** all fonts/icons/scripts/styles stored locally in the repository; no CDN usage
|
||||
|
||||
### Mandatory frontend implementation instruction
|
||||
|
||||
Any future frontend implementation work **must first read and follow**:
|
||||
|
||||
- `.github/instructions/frontend-design.instructions.md`
|
||||
|
||||
This instruction file is mandatory for the UI because it requires a distinctive, production-grade, non-generic frontend. The implementation should not default to generic dashboard aesthetics.
|
||||
|
||||
## 3. Product/Architecture Plan
|
||||
|
||||
### Core backend responsibilities
|
||||
|
||||
1. Expose the MCP tool `get_user_request`.
|
||||
2. Maintain an instruction queue with durable storage.
|
||||
3. Mark instructions as consumed atomically when delivered to an agent.
|
||||
4. Expose local HTTP endpoints for the web UI.
|
||||
5. Stream status/instruction updates to the browser in real time.
|
||||
6. Infer agent connectivity from recent MCP tool activity.
|
||||
7. Persist and serve server configuration such as wait timeout and default empty response.
|
||||
|
||||
### Core frontend responsibilities
|
||||
|
||||
1. Show queued and consumed instructions in separate, clearly labeled sections.
|
||||
2. Allow add/edit/delete only for instructions that are still pending.
|
||||
3. Cross out and grey out consumed instructions.
|
||||
4. Show server status, inferred agent status, last fetch time, and configuration values.
|
||||
5. Update live as instruction state changes.
|
||||
6. Remain usable and visually polished on desktop and smaller screens.
|
||||
|
||||
### Suggested repository layout
|
||||
|
||||
```text
|
||||
local-mcp/
|
||||
├─ main.py
|
||||
├─ README.md
|
||||
├─ requirements.txt
|
||||
├─ app/
|
||||
│ ├─ __init__.py
|
||||
│ ├─ config.py
|
||||
│ ├─ database.py
|
||||
│ ├─ logging_setup.py
|
||||
│ ├─ models.py
|
||||
│ ├─ services/
|
||||
│ │ ├─ instruction_service.py
|
||||
│ │ ├─ status_service.py
|
||||
│ │ └─ event_service.py
|
||||
│ ├─ api/
|
||||
│ │ ├─ routes_instructions.py
|
||||
│ │ ├─ routes_status.py
|
||||
│ │ └─ routes_config.py
|
||||
│ └─ mcp_server.py
|
||||
├─ static/
|
||||
│ ├─ index.html
|
||||
│ ├─ css/
|
||||
│ │ ├─ base.css
|
||||
│ │ ├─ layout.css
|
||||
│ │ └─ components.css
|
||||
│ ├─ js/
|
||||
│ │ ├─ api.js
|
||||
│ │ ├─ app.js
|
||||
│ │ ├─ events.js
|
||||
│ │ ├─ instructions.js
|
||||
│ │ └─ status.js
|
||||
│ └─ assets/
|
||||
└─ data/
|
||||
└─ local_mcp.sqlite3
|
||||
```
|
||||
|
||||
## 4. Data Model Plan
|
||||
|
||||
### `instructions`
|
||||
|
||||
- `id` - string/UUID primary key
|
||||
- `content` - text, required
|
||||
- `status` - enum: `pending`, `consumed`
|
||||
- `created_at` - datetime
|
||||
- `updated_at` - datetime
|
||||
- `consumed_at` - nullable datetime
|
||||
- `consumed_by_agent_id` - nullable string
|
||||
- `position` - integer for stable queue order
|
||||
|
||||
### `settings`
|
||||
|
||||
- `default_wait_seconds` - integer — **minimum** seconds the tool waits before returning empty/default response; agents may request longer but not shorter
|
||||
- `default_empty_response` - text, nullable
|
||||
- `agent_stale_after_seconds` - integer
|
||||
|
||||
### `agent_activity`
|
||||
|
||||
- `agent_id` - string primary key
|
||||
- `last_seen_at` - datetime
|
||||
- `last_fetch_at` - datetime
|
||||
- `last_result_type` - enum: `instruction`, `empty`, `default_response`
|
||||
|
||||
## 5. Detailed API Design
|
||||
|
||||
All routes are local-only and intended for `localhost` usage.
|
||||
|
||||
### 5.1 MCP tool contract
|
||||
|
||||
#### Tool: `get_user_request`
|
||||
|
||||
**Purpose**
|
||||
|
||||
- Return the next pending instruction, if one exists.
|
||||
- If none exists, wait for a configurable duration, then return either an empty response or a configured default response.
|
||||
- Record agent activity so the UI can infer whether an agent is currently connected/recently active.
|
||||
|
||||
**Suggested input schema**
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "optional-string",
|
||||
"wait_seconds": "optional-integer",
|
||||
"default_response_override": "optional-string"
|
||||
}
|
||||
```
|
||||
|
||||
**Suggested output schema when an instruction is delivered**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"result_type": "instruction",
|
||||
"instruction": {
|
||||
"id": "uuid",
|
||||
"content": "user instruction text",
|
||||
"consumed_at": "ISO-8601 timestamp"
|
||||
},
|
||||
"remaining_pending": 3,
|
||||
"waited_seconds": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Suggested output schema when queue is empty**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"result_type": "empty",
|
||||
"instruction": null,
|
||||
"response": "",
|
||||
"remaining_pending": 0,
|
||||
"waited_seconds": 10
|
||||
}
|
||||
```
|
||||
|
||||
**Suggested output schema when a default response is returned**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"result_type": "default_response",
|
||||
"instruction": null,
|
||||
"response": "No new instructions available.",
|
||||
"remaining_pending": 0,
|
||||
"waited_seconds": 10
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior rules**
|
||||
|
||||
- Deliver the oldest pending instruction first.
|
||||
- Mark the delivered instruction as consumed in the same transaction used to claim it.
|
||||
- Never return more than one instruction per call.
|
||||
- `default_wait_seconds` is a **minimum** enforced by the server. The actual wait is `max(wait_seconds or 0, server_minimum)`. Agents may request longer waits but cannot go below the floor — this prevents busy-polling.
|
||||
- Clamp `actual_wait` to an absolute server maximum (300 s).
|
||||
- Update the agent activity record on every call, even when no instruction is returned.
|
||||
- The UI should infer "agent connected" if the latest activity is within `agent_stale_after_seconds`.
|
||||
- Agent implementations should continue calling this tool instead of ending their work session on their own, so they can pick up newly added instructions without missing critical follow-up requests.
|
||||
|
||||
### 5.2 HTTP API for the web UI
|
||||
|
||||
#### `GET /healthz`
|
||||
|
||||
Returns service health.
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"server_time": "ISO-8601 timestamp"
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/status`
|
||||
|
||||
Returns current server and agent summary.
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"status": "up",
|
||||
"started_at": "ISO-8601 timestamp"
|
||||
},
|
||||
"agent": {
|
||||
"connected": true,
|
||||
"last_seen_at": "ISO-8601 timestamp",
|
||||
"last_fetch_at": "ISO-8601 timestamp",
|
||||
"agent_id": "copilot-agent"
|
||||
},
|
||||
"queue": {
|
||||
"pending_count": 2,
|
||||
"consumed_count": 8
|
||||
},
|
||||
"settings": {
|
||||
"default_wait_seconds": 10,
|
||||
"default_empty_response": "No new instructions available.",
|
||||
"agent_stale_after_seconds": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/instructions`
|
||||
|
||||
Returns all instructions in queue order.
|
||||
|
||||
**Query params**
|
||||
|
||||
- `status=pending|consumed|all` (default `all`)
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"content": "Implement logging",
|
||||
"status": "pending",
|
||||
"created_at": "ISO-8601 timestamp",
|
||||
"updated_at": "ISO-8601 timestamp",
|
||||
"consumed_at": null,
|
||||
"consumed_by_agent_id": null,
|
||||
"position": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /api/instructions`
|
||||
|
||||
Creates a new pending instruction.
|
||||
|
||||
**Request**
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "Add a new status indicator"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**: `201 Created`
|
||||
|
||||
```json
|
||||
{
|
||||
"item": {
|
||||
"id": "uuid",
|
||||
"content": "Add a new status indicator",
|
||||
"status": "pending",
|
||||
"created_at": "ISO-8601 timestamp",
|
||||
"updated_at": "ISO-8601 timestamp",
|
||||
"consumed_at": null,
|
||||
"consumed_by_agent_id": null,
|
||||
"position": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `PATCH /api/instructions/{instruction_id}`
|
||||
|
||||
Edits a pending instruction only.
|
||||
|
||||
**Request**
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "Reword an existing pending instruction"
|
||||
}
|
||||
```
|
||||
|
||||
**Rules**
|
||||
|
||||
- Return `409 Conflict` if the instruction has already been consumed.
|
||||
- Return `404 Not Found` if the instruction does not exist.
|
||||
|
||||
#### `DELETE /api/instructions/{instruction_id}`
|
||||
|
||||
Deletes a pending instruction only.
|
||||
|
||||
**Rules**
|
||||
|
||||
- Return `409 Conflict` if the instruction has already been consumed.
|
||||
- Return `204 No Content` on success.
|
||||
|
||||
#### `GET /api/config`
|
||||
|
||||
Returns editable runtime settings.
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"default_wait_seconds": 10,
|
||||
"default_empty_response": "No new instructions available.",
|
||||
"agent_stale_after_seconds": 30
|
||||
}
|
||||
```
|
||||
|
||||
#### `PATCH /api/config`
|
||||
|
||||
Updates runtime settings.
|
||||
|
||||
**Request**
|
||||
|
||||
```json
|
||||
{
|
||||
"default_wait_seconds": 15,
|
||||
"default_empty_response": "",
|
||||
"agent_stale_after_seconds": 45
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/events`
|
||||
|
||||
Server-Sent Events endpoint for live UI updates.
|
||||
|
||||
**Event types**
|
||||
|
||||
- `instruction.created`
|
||||
- `instruction.updated`
|
||||
- `instruction.deleted`
|
||||
- `instruction.consumed`
|
||||
- `status.changed`
|
||||
- `config.updated`
|
||||
|
||||
**SSE payload example**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "instruction.consumed",
|
||||
"timestamp": "ISO-8601 timestamp",
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"consumed_by_agent_id": "copilot-agent"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. UI/UX Plan
|
||||
|
||||
### Layout priorities
|
||||
|
||||
- A strong local-control dashboard feel rather than a generic admin template
|
||||
- Clear separation between pending work and already-consumed history
|
||||
- High-visibility connection/status strip for server and agent state
|
||||
- Fast creation flow for new instructions
|
||||
- Mobile-friendly stacking without losing queue readability
|
||||
|
||||
### Required screens/sections
|
||||
|
||||
- Header with project identity and server status
|
||||
- Agent activity panel with last seen/fetch information
|
||||
- Composer form for new instructions
|
||||
- Pending instructions list with edit/delete actions
|
||||
- Consumed instructions list with crossed-out styling and metadata
|
||||
- Settings panel for wait timeout/default response behavior
|
||||
|
||||
### Frontend quality bar
|
||||
|
||||
- Follow `.github/instructions/frontend-design.instructions.md` before implementing any UI.
|
||||
- Use only local assets.
|
||||
- Build a visually distinctive interface with careful typography, color, spacing, motion, and responsive behavior.
|
||||
- Keep accessibility in scope: semantic HTML, keyboard support, visible focus states, sufficient contrast.
|
||||
|
||||
## 7. Logging, Reliability, and Error Handling Plan
|
||||
|
||||
- Log startup, shutdown, configuration load, database initialization, and MCP registration.
|
||||
- Log each instruction lifecycle event: created, updated, deleted, consumed.
|
||||
- Log each `get_user_request` call with agent id, wait time, and result type.
|
||||
- Return structured JSON errors for API failures.
|
||||
- Protect queue consumption with transactions/locking so two simultaneous fetches cannot consume the same instruction.
|
||||
- Validate payloads and reject empty or whitespace-only instructions.
|
||||
- Handle browser reconnects for SSE cleanly.
|
||||
|
||||
## 8. Todo List
|
||||
|
||||
- [x] **Project setup**
|
||||
- [x] Create the backend package structure under `app/`.
|
||||
- [x] Add `requirements.txt` with only the required dependencies.
|
||||
- [x] Replace the placeholder contents of `main.py` with the application entrypoint.
|
||||
- [x] Add a local configuration strategy for defaults and runtime overrides.
|
||||
|
||||
- [x] **Data layer**
|
||||
- [x] Create SQLite schema for `instructions`, `settings`, and `agent_activity`.
|
||||
- [x] Add startup migration/initialization logic.
|
||||
- [x] Implement queue ordering and atomic consumption behavior.
|
||||
- [x] Seed default settings on first run.
|
||||
|
||||
- [x] **MCP server**
|
||||
- [x] Register the `get_user_request` tool using the official MCP Python SDK.
|
||||
- [x] Implement one-at-a-time delivery semantics.
|
||||
- [x] Implement wait-until-timeout behavior when the queue is empty.
|
||||
- [x] Return empty/default responses based on configuration.
|
||||
- [x] Record agent activity on every tool call.
|
||||
|
||||
- [x] **HTTP API**
|
||||
- [x] Implement `GET /healthz`.
|
||||
- [x] Implement `GET /api/status`.
|
||||
- [x] Implement `GET /api/instructions`.
|
||||
- [x] Implement `POST /api/instructions`.
|
||||
- [x] Implement `PATCH /api/instructions/{instruction_id}`.
|
||||
- [x] Implement `DELETE /api/instructions/{instruction_id}`.
|
||||
- [x] Implement `GET /api/config`.
|
||||
- [x] Implement `PATCH /api/config`.
|
||||
- [x] Implement `GET /api/events` for SSE.
|
||||
|
||||
- [x] **Frontend**
|
||||
- [x] Read and follow `.github/instructions/frontend-design.instructions.md` before starting UI work.
|
||||
- [x] Create `static/index.html` and split CSS/JS into separate folders/files.
|
||||
- [x] Build the instruction composer.
|
||||
- [x] Build the pending instruction list with edit/delete controls.
|
||||
- [x] Build the consumed instruction list with crossed-out/greyed-out styling.
|
||||
- [x] Build the live server/agent status panel.
|
||||
- [x] Build the settings editor for timeout/default-response behavior.
|
||||
- [x] Wire SSE updates into the UI so changes appear in real time.
|
||||
- [x] Make the interface responsive and keyboard accessible.
|
||||
|
||||
- [x] **Observability and robustness**
|
||||
- [x] Add centralized logging configuration.
|
||||
- [x] Add structured error responses and exception handling.
|
||||
- [x] Add queue-consumption concurrency protection.
|
||||
- [x] Add validation for invalid edits/deletes of consumed instructions.
|
||||
- [ ] Add tests for empty-queue, timeout, and consume-once behavior.
|
||||
|
||||
- [x] **Improvements (post-launch)**
|
||||
- [x] Replace 1-second polling wait loop with `asyncio.Event`-based immediate wakeup.
|
||||
- [x] Min-wait is a floor only when the queue is empty — a new instruction immediately wakes any waiting tool call (verified with timing test in `tests/test_wakeup.py`).
|
||||
- [x] Enrich SSE events with full item payloads (no extra re-fetch round-trips).
|
||||
- [x] Auto-refresh relative timestamps in the UI every 20 s.
|
||||
- [x] Document title badge showing pending instruction count.
|
||||
- [x] SSE reconnecting indicator in the header.
|
||||
- [x] Dark / light theme toggle defaulting to OS colour-scheme preference.
|
||||
- [x] `default_wait_seconds` changed to a server-enforced minimum (agents may request longer).
|
||||
- [x] Non-blocking `server.ps1` management script (start / stop / restart / status / logs).
|
||||
- [x] MCP stateless/stateful mode configurable via `MCP_STATELESS` env var (default `true`).
|
||||
- [x] Per-agent generation counter prevents abandoned (timed-out) coroutines from silently consuming instructions meant for newer calls.
|
||||
- [x] `tests/test_wakeup.py` covers both immediate-wakeup timing and concurrent-call generation safety.
|
||||
|
||||
- [ ] **Documentation and developer experience**
|
||||
- [x] Document local run instructions.
|
||||
- [x] Document the MCP tool contract clearly.
|
||||
- [x] Document the HTTP API with request/response examples.
|
||||
- [x] Document how agent connectivity is inferred.
|
||||
- [x] Document how the frontend design instruction must be used during UI implementation.
|
||||
|
||||
## 9. Running the Server
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- pip
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Start the server
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
Or use the included management script (recommended — non-blocking):
|
||||
|
||||
```powershell
|
||||
.\server.ps1 start # start in background, logs to logs/
|
||||
.\server.ps1 stop # graceful stop
|
||||
.\server.ps1 restart # stop + start
|
||||
.\server.ps1 status # PID, memory, tail logs
|
||||
.\server.ps1 logs # show last 40 stdout lines
|
||||
.\server.ps1 logs -f # follow logs live
|
||||
.\server.ps1 logs 100 # show last 100 lines
|
||||
```
|
||||
|
||||
The server starts on `http://localhost:8000` by default.
|
||||
|
||||
| URL | Description |
|
||||
|-----|-------------|
|
||||
| `http://localhost:8000/` | Web UI |
|
||||
| `http://localhost:8000/mcp` | MCP streamable-HTTP endpoint |
|
||||
| `http://localhost:8000/docs` | FastAPI interactive API docs |
|
||||
|
||||
### Environment variable overrides
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `HOST` | `0.0.0.0` | Bind address |
|
||||
| `HTTP_PORT` | `8000` | HTTP port |
|
||||
| `DB_PATH` | `data/local_mcp.sqlite3` | SQLite database path |
|
||||
| `LOG_LEVEL` | `INFO` | Logging level |
|
||||
| `DEFAULT_WAIT_SECONDS` | `10` | Default tool wait timeout |
|
||||
| `DEFAULT_EMPTY_RESPONSE` | _(empty)_ | Default response when queue is empty |
|
||||
| `AGENT_STALE_AFTER_SECONDS` | `30` | Seconds of inactivity before agent shown as idle |
|
||||
| `MCP_STATELESS` | `true` | `true` for stateless sessions (survives restarts, recommended); `false` for stateful |
|
||||
|
||||
### Configuring an MCP client (agent)
|
||||
|
||||
Point the agent's MCP client to the streamable-HTTP transport:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"local-mcp": {
|
||||
"url": "http://localhost:8000/mcp",
|
||||
"transport": "streamable-http"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The agent should call `get_user_request` aggressively and continuously — **do not end the working session**. Every call returns the next pending instruction (if any). When the queue is empty the tool waits up to `wait_seconds` before returning an empty/default response, so the agent should loop and call again.
|
||||
|
||||
## 10. Implementation Notes for Future Work
|
||||
|
||||
- Prefer small, explicit modules over monolithic files.
|
||||
- Keep the project localhost-first and avoid remote asset dependencies.
|
||||
- Treat the MCP tool and the web UI as two views over the same instruction queue.
|
||||
- Optimize for correctness of queue semantics first, then refine the visual and realtime experience.
|
||||
2
app/__init__.py
Normal file
2
app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# app package
|
||||
|
||||
2
app/api/__init__.py
Normal file
2
app/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# api package
|
||||
|
||||
41
app/api/routes_config.py
Normal file
41
app/api/routes_config.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
app/api/routes_config.py
|
||||
HTTP endpoints for reading and updating runtime configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.models import ConfigResponse, UpdateConfigRequest
|
||||
from app.services import config_service, event_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||
|
||||
|
||||
@router.get("", response_model=ConfigResponse)
|
||||
def get_config():
|
||||
return config_service.get_config()
|
||||
|
||||
|
||||
@router.patch("", response_model=ConfigResponse)
|
||||
def update_config(body: UpdateConfigRequest):
|
||||
cfg = config_service.update_config(
|
||||
default_wait_seconds=body.default_wait_seconds,
|
||||
default_empty_response=body.default_empty_response,
|
||||
agent_stale_after_seconds=body.agent_stale_after_seconds,
|
||||
)
|
||||
event_service.broadcast(
|
||||
"config.updated",
|
||||
{
|
||||
"default_wait_seconds": cfg.default_wait_seconds,
|
||||
"default_empty_response": cfg.default_empty_response,
|
||||
"agent_stale_after_seconds": cfg.agent_stale_after_seconds,
|
||||
},
|
||||
)
|
||||
return cfg
|
||||
|
||||
72
app/api/routes_instructions.py
Normal file
72
app/api/routes_instructions.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
app/api/routes_instructions.py
|
||||
HTTP endpoints for instruction CRUD.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.models import (
|
||||
CreateInstructionRequest,
|
||||
InstructionCreateResponse,
|
||||
InstructionListResponse,
|
||||
UpdateInstructionRequest,
|
||||
)
|
||||
from app.services import event_service, instruction_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/instructions", tags=["instructions"])
|
||||
|
||||
|
||||
@router.get("", response_model=InstructionListResponse)
|
||||
def list_instructions(
|
||||
status: str = Query(default="all", pattern="^(pending|consumed|all)$")
|
||||
):
|
||||
items = instruction_service.list_instructions(status_filter=status)
|
||||
return InstructionListResponse(items=items)
|
||||
|
||||
|
||||
@router.post("", response_model=InstructionCreateResponse, status_code=201)
|
||||
def create_instruction(body: CreateInstructionRequest):
|
||||
item = instruction_service.create_instruction(body.content)
|
||||
event_service.broadcast(
|
||||
"instruction.created",
|
||||
{"item": item.model_dump(mode="json")},
|
||||
)
|
||||
return InstructionCreateResponse(item=item)
|
||||
|
||||
|
||||
@router.patch("/{instruction_id}", response_model=InstructionCreateResponse)
|
||||
def update_instruction(instruction_id: str, body: UpdateInstructionRequest):
|
||||
try:
|
||||
item = instruction_service.update_instruction(instruction_id, body.content)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Instruction not found")
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
|
||||
event_service.broadcast(
|
||||
"instruction.updated",
|
||||
{"item": item.model_dump(mode="json")},
|
||||
)
|
||||
return InstructionCreateResponse(item=item)
|
||||
|
||||
|
||||
@router.delete("/{instruction_id}", status_code=204)
|
||||
def delete_instruction(instruction_id: str):
|
||||
try:
|
||||
instruction_service.delete_instruction(instruction_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Instruction not found")
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
|
||||
event_service.broadcast("instruction.deleted", {"id": instruction_id})
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
77
app/api/routes_status.py
Normal file
77
app/api/routes_status.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
app/api/routes_status.py
|
||||
HTTP endpoints for server/agent status and SSE events.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.models import (
|
||||
AgentInfo,
|
||||
HealthResponse,
|
||||
QueueCounts,
|
||||
ServerInfo,
|
||||
StatusResponse,
|
||||
)
|
||||
from app.services import config_service, event_service, instruction_service, status_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["status"])
|
||||
|
||||
|
||||
@router.get("/healthz", response_model=HealthResponse)
|
||||
def health():
|
||||
return HealthResponse(status="ok", server_time=datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@router.get("/api/status", response_model=StatusResponse)
|
||||
def get_status():
|
||||
agent_row = status_service.get_latest_agent_activity()
|
||||
connected = status_service.is_agent_connected()
|
||||
|
||||
agent_info = AgentInfo(
|
||||
connected=connected,
|
||||
last_seen_at=datetime.fromisoformat(agent_row["last_seen_at"]) if agent_row else None,
|
||||
last_fetch_at=datetime.fromisoformat(agent_row["last_fetch_at"]) if agent_row else None,
|
||||
agent_id=agent_row["agent_id"] if agent_row else None,
|
||||
)
|
||||
|
||||
counts = instruction_service.get_queue_counts()
|
||||
cfg = config_service.get_config()
|
||||
|
||||
return StatusResponse(
|
||||
server=ServerInfo(
|
||||
status="up",
|
||||
started_at=status_service.server_started_at(),
|
||||
),
|
||||
agent=agent_info,
|
||||
queue=QueueCounts(**counts),
|
||||
settings=cfg,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/events")
|
||||
async def sse_events():
|
||||
q = event_service.subscribe()
|
||||
|
||||
async def stream():
|
||||
async for chunk in event_service.event_generator(q):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
62
app/config.py
Normal file
62
app/config.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
app/config.py
|
||||
Runtime configuration loaded from environment variables with sensible defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
http_port: int = 8000
|
||||
|
||||
# Database
|
||||
db_path: str = "data/local_mcp.sqlite3"
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
|
||||
# MCP / queue behaviour (runtime-editable values are stored in DB; these are defaults for first run)
|
||||
default_wait_seconds: int = 10
|
||||
default_empty_response: str = ""
|
||||
agent_stale_after_seconds: int = 30
|
||||
|
||||
# MCP server name
|
||||
mcp_server_name: str = "local-mcp"
|
||||
|
||||
# MCP transport — stateless=True means no session IDs, survives server restarts.
|
||||
# Set MCP_STATELESS=false to use stateful sessions (needed for multi-turn MCP flows).
|
||||
mcp_stateless: bool = True
|
||||
|
||||
|
||||
def _parse_bool(value: str, default: bool) -> bool:
|
||||
if value.lower() in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
if value.lower() in ("0", "false", "no", "off"):
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
"""Load settings from environment variables, falling back to defaults."""
|
||||
return Settings(
|
||||
host=os.getenv("HOST", "0.0.0.0"),
|
||||
http_port=int(os.getenv("HTTP_PORT", "8000")),
|
||||
db_path=os.getenv("DB_PATH", "data/local_mcp.sqlite3"),
|
||||
log_level=os.getenv("LOG_LEVEL", "INFO"),
|
||||
default_wait_seconds=int(os.getenv("DEFAULT_WAIT_SECONDS", "10")),
|
||||
default_empty_response=os.getenv("DEFAULT_EMPTY_RESPONSE", ""),
|
||||
agent_stale_after_seconds=int(os.getenv("AGENT_STALE_AFTER_SECONDS", "30")),
|
||||
mcp_server_name=os.getenv("MCP_SERVER_NAME", "local-mcp"),
|
||||
mcp_stateless=_parse_bool(os.getenv("MCP_STATELESS", "true"), default=True),
|
||||
)
|
||||
|
||||
|
||||
# Singleton – imported and used throughout the app
|
||||
settings: Settings = load_settings()
|
||||
|
||||
124
app/database.py
Normal file
124
app/database.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
app/database.py
|
||||
SQLite database initialisation, schema management, and low-level connection helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level lock for write serialisation (consume atomicity, settings writes, etc.)
|
||||
_write_lock = threading.Lock()
|
||||
|
||||
_db_path: str = ""
|
||||
|
||||
|
||||
def init_db(db_path: str) -> None:
|
||||
"""Initialise the database: create directory, schema, and seed defaults."""
|
||||
global _db_path
|
||||
_db_path = db_path
|
||||
|
||||
os.makedirs(os.path.dirname(db_path) if os.path.dirname(db_path) else ".", exist_ok=True)
|
||||
|
||||
with _connect() as conn:
|
||||
_create_schema(conn)
|
||||
_seed_defaults(conn)
|
||||
|
||||
logger.info("Database initialised at %s", db_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@contextmanager
|
||||
def get_conn() -> Generator[sqlite3.Connection, None, None]:
|
||||
"""Yield a short-lived read-write connection. Commits on success, rolls back on error."""
|
||||
conn = _connect()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_write_conn() -> Generator[sqlite3.Connection, None, None]:
|
||||
"""Yield a connection protected by the module write lock (for atomic operations)."""
|
||||
with _write_lock:
|
||||
with get_conn() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(_db_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS instructions (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
consumed_at TEXT,
|
||||
consumed_by_agent_id TEXT,
|
||||
position INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_instructions_status_position
|
||||
ON instructions (status, position);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_activity (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
last_fetch_at TEXT NOT NULL,
|
||||
last_result_type TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
_DEFAULT_SETTINGS = {
|
||||
"default_wait_seconds": "10",
|
||||
"default_empty_response": "",
|
||||
"agent_stale_after_seconds": "30",
|
||||
}
|
||||
|
||||
|
||||
def _create_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.executescript(_SCHEMA)
|
||||
conn.commit()
|
||||
logger.debug("Schema ensured")
|
||||
|
||||
|
||||
def _seed_defaults(conn: sqlite3.Connection) -> None:
|
||||
for key, value in _DEFAULT_SETTINGS.items():
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
||||
(key, value),
|
||||
)
|
||||
conn.commit()
|
||||
logger.debug("Default settings seeded")
|
||||
|
||||
41
app/logging_setup.py
Normal file
41
app/logging_setup.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
app/logging_setup.py
|
||||
Centralised logging configuration for the application.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def configure_logging(settings: "Settings") -> None:
|
||||
"""Configure root logger and suppress noisy third-party loggers."""
|
||||
level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
# Avoid duplicate handlers if called multiple times
|
||||
if not root.handlers:
|
||||
root.addHandler(handler)
|
||||
else:
|
||||
root.handlers[0] = handler
|
||||
|
||||
# Quieten noisy third-party loggers
|
||||
for noisy in ("uvicorn.access", "uvicorn.error", "httpx", "httpcore"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||
|
||||
201
app/mcp_server.py
Normal file
201
app/mcp_server.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
app/mcp_server.py
|
||||
FastMCP server definition with the get_user_request tool.
|
||||
The Starlette app returned by mcp.streamable_http_app() is mounted into
|
||||
the main FastAPI application at /mcp.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from app.config import settings
|
||||
from app.services import config_service, event_service, instruction_service, status_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
mcp = FastMCP(
|
||||
settings.mcp_server_name,
|
||||
streamable_http_path="/",
|
||||
stateless_http=settings.mcp_stateless,
|
||||
)
|
||||
|
||||
# Build the ASGI app eagerly so that session_manager is created and can be
|
||||
# started explicitly inside the FastAPI lifespan (see main.py).
|
||||
mcp_asgi_app = mcp.streamable_http_app()
|
||||
|
||||
# Maximum wait the client is allowed to request (guards against runaway holds)
|
||||
# Set very high — the wait is always interruptible by new instructions via the
|
||||
# asyncio.Event wakeup, so there is no practical danger in long waits.
|
||||
_MAX_WAIT_SECONDS = 86400 # 24 hours
|
||||
|
||||
# Per-agent generation counter — incremented on every new call.
|
||||
# The wait loop only consumes an instruction when it holds the latest generation,
|
||||
# preventing abandoned (timed-out) coroutines from silently consuming queue items.
|
||||
_agent_generations: dict[str, int] = {}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_user_request(
|
||||
agent_id: str = "unknown",
|
||||
wait_seconds: Optional[int] = None,
|
||||
default_response_override: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Fetch the next pending user instruction from the queue.
|
||||
|
||||
The server enforces a minimum wait time (configurable via the web UI).
|
||||
The agent may request a longer wait via `wait_seconds`, but cannot go
|
||||
below that minimum — this prevents busy-polling when the queue is empty.
|
||||
actual_wait = max(wait_seconds or 0, server_min_wait_seconds).
|
||||
|
||||
Args:
|
||||
agent_id: An identifier for this agent instance (used to track connectivity).
|
||||
wait_seconds: Desired wait when queue is empty. Actual wait is
|
||||
max(this, server minimum). Omit to use server minimum only.
|
||||
default_response_override: Override the server-default empty response text
|
||||
for this single call.
|
||||
|
||||
Returns:
|
||||
A dict with keys: status, result_type, instruction, response,
|
||||
remaining_pending, waited_seconds.
|
||||
"""
|
||||
cfg = config_service.get_config()
|
||||
|
||||
# default_wait_seconds is the server-enforced MINIMUM wait time.
|
||||
client_requested = wait_seconds if wait_seconds is not None else 0
|
||||
actual_wait = min(
|
||||
max(client_requested, cfg.default_wait_seconds), # enforce floor
|
||||
_MAX_WAIT_SECONDS,
|
||||
)
|
||||
|
||||
# Register this call as the newest for this agent. Any older coroutines
|
||||
# still lingering (e.g. client timed-out and retried) will see a stale
|
||||
# generation and skip the consume step, leaving the instruction for us.
|
||||
my_gen = _agent_generations.get(agent_id, 0) + 1
|
||||
_agent_generations[agent_id] = my_gen
|
||||
|
||||
def _i_am_active() -> bool:
|
||||
"""True if no newer call has arrived for this agent since we started."""
|
||||
return _agent_generations.get(agent_id) == my_gen
|
||||
|
||||
# --- Attempt immediate dequeue ---
|
||||
item = instruction_service.consume_next(agent_id=agent_id)
|
||||
if item is not None:
|
||||
counts = instruction_service.get_queue_counts()
|
||||
status_service.record_agent_activity(agent_id, "instruction")
|
||||
event_service.broadcast(
|
||||
"instruction.consumed",
|
||||
{"item": item.model_dump(mode="json"), "consumed_by_agent_id": agent_id},
|
||||
)
|
||||
event_service.broadcast("status.changed", {"queue": counts})
|
||||
logger.info(
|
||||
"get_user_request: instruction delivered id=%s agent=%s", item.id, agent_id
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"result_type": "instruction",
|
||||
"instruction": {
|
||||
"id": item.id,
|
||||
"content": item.content,
|
||||
"consumed_at": item.consumed_at.isoformat() if item.consumed_at else None,
|
||||
},
|
||||
"response": None,
|
||||
"remaining_pending": counts["pending_count"],
|
||||
"waited_seconds": 0,
|
||||
}
|
||||
|
||||
# --- Wait loop (event-driven, not polling) ---
|
||||
wakeup = instruction_service.get_wakeup_event()
|
||||
loop = asyncio.get_event_loop()
|
||||
start = loop.time()
|
||||
|
||||
while True:
|
||||
elapsed = loop.time() - start
|
||||
remaining = actual_wait - elapsed
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
# If a newer call for this agent arrived, step aside without consuming.
|
||||
if not _i_am_active():
|
||||
logger.debug(
|
||||
"get_user_request: superseded by newer call agent=%s gen=%d", agent_id, my_gen
|
||||
)
|
||||
break
|
||||
|
||||
# Clear the event BEFORE checking the queue so we never miss a
|
||||
# wake-up that arrives between the DB check and event.wait().
|
||||
if wakeup is not None:
|
||||
wakeup.clear()
|
||||
|
||||
item = instruction_service.consume_next(agent_id=agent_id)
|
||||
if item is not None:
|
||||
counts = instruction_service.get_queue_counts()
|
||||
status_service.record_agent_activity(agent_id, "instruction")
|
||||
event_service.broadcast(
|
||||
"instruction.consumed",
|
||||
{"item": item.model_dump(mode="json"), "consumed_by_agent_id": agent_id},
|
||||
)
|
||||
event_service.broadcast("status.changed", {"queue": counts})
|
||||
waited = int(loop.time() - start)
|
||||
logger.info(
|
||||
"get_user_request: instruction delivered (after %ds wait) id=%s agent=%s gen=%d",
|
||||
waited, item.id, agent_id, my_gen,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"result_type": "instruction",
|
||||
"instruction": {
|
||||
"id": item.id,
|
||||
"content": item.content,
|
||||
"consumed_at": item.consumed_at.isoformat() if item.consumed_at else None,
|
||||
},
|
||||
"response": None,
|
||||
"remaining_pending": counts["pending_count"],
|
||||
"waited_seconds": waited,
|
||||
}
|
||||
|
||||
# Sleep until woken by a new instruction or 1 s elapses (safety net)
|
||||
wait_for = min(remaining, 1.0)
|
||||
if wakeup is not None:
|
||||
try:
|
||||
await asyncio.wait_for(wakeup.wait(), timeout=wait_for)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
else:
|
||||
await asyncio.sleep(wait_for)
|
||||
|
||||
waited = int(loop.time() - start)
|
||||
|
||||
# --- Nothing available after waiting (or superseded) ---
|
||||
if _i_am_active():
|
||||
# Only record/broadcast when we're the active caller
|
||||
status_service.record_agent_activity(agent_id, "empty")
|
||||
event_service.broadcast("status.changed", {})
|
||||
|
||||
empty_response = (
|
||||
default_response_override
|
||||
if default_response_override is not None
|
||||
else cfg.default_empty_response
|
||||
)
|
||||
result_type = "default_response" if empty_response else "empty"
|
||||
|
||||
if _i_am_active():
|
||||
logger.info(
|
||||
"get_user_request: empty result_type=%s waited=%ds agent=%s gen=%d",
|
||||
result_type, waited, agent_id, my_gen,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"result_type": result_type,
|
||||
"instruction": None,
|
||||
"response": empty_response,
|
||||
"remaining_pending": 0,
|
||||
"waited_seconds": waited,
|
||||
}
|
||||
|
||||
146
app/models.py
Normal file
146
app/models.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
app/models.py
|
||||
Pydantic request/response models used by the HTTP API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enumerations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class InstructionStatus(str, Enum):
|
||||
pending = "pending"
|
||||
consumed = "consumed"
|
||||
|
||||
|
||||
class ResultType(str, Enum):
|
||||
instruction = "instruction"
|
||||
empty = "empty"
|
||||
default_response = "default_response"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Instruction models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class InstructionItem(BaseModel):
|
||||
id: str
|
||||
content: str
|
||||
status: InstructionStatus
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
consumed_at: Optional[datetime] = None
|
||||
consumed_by_agent_id: Optional[str] = None
|
||||
position: int
|
||||
|
||||
|
||||
class InstructionListResponse(BaseModel):
|
||||
items: list[InstructionItem]
|
||||
|
||||
|
||||
class InstructionCreateResponse(BaseModel):
|
||||
item: InstructionItem
|
||||
|
||||
|
||||
class CreateInstructionRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
@field_validator("content")
|
||||
@classmethod
|
||||
def content_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("content must not be blank")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class UpdateInstructionRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
@field_validator("content")
|
||||
@classmethod
|
||||
def content_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("content must not be blank")
|
||||
return v.strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ServerInfo(BaseModel):
|
||||
status: str
|
||||
started_at: datetime
|
||||
|
||||
|
||||
class AgentInfo(BaseModel):
|
||||
connected: bool
|
||||
last_seen_at: Optional[datetime] = None
|
||||
last_fetch_at: Optional[datetime] = None
|
||||
agent_id: Optional[str] = None
|
||||
|
||||
|
||||
class QueueCounts(BaseModel):
|
||||
pending_count: int
|
||||
consumed_count: int
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
server: ServerInfo
|
||||
agent: AgentInfo
|
||||
queue: QueueCounts
|
||||
settings: "ConfigResponse"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ConfigResponse(BaseModel):
|
||||
default_wait_seconds: int
|
||||
default_empty_response: str
|
||||
agent_stale_after_seconds: int
|
||||
|
||||
|
||||
class UpdateConfigRequest(BaseModel):
|
||||
default_wait_seconds: Optional[int] = None
|
||||
default_empty_response: Optional[str] = None
|
||||
agent_stale_after_seconds: Optional[int] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
server_time: datetime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP tool response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class InstructionPayload(BaseModel):
|
||||
id: str
|
||||
content: str
|
||||
consumed_at: datetime
|
||||
|
||||
|
||||
class GetUserRequestResponse(BaseModel):
|
||||
status: str
|
||||
result_type: ResultType
|
||||
instruction: Optional[InstructionPayload] = None
|
||||
response: Optional[str] = None
|
||||
remaining_pending: int
|
||||
waited_seconds: int
|
||||
|
||||
|
||||
2
app/services/__init__.py
Normal file
2
app/services/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# services package
|
||||
|
||||
52
app/services/config_service.py
Normal file
52
app/services/config_service.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
app/services/config_service.py
|
||||
Read and write runtime settings stored in the SQLite settings table.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.database import get_conn, get_write_conn
|
||||
from app.models import ConfigResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SETTING_KEYS = {"default_wait_seconds", "default_empty_response", "agent_stale_after_seconds"}
|
||||
|
||||
|
||||
def get_config() -> ConfigResponse:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT key, value FROM settings").fetchall()
|
||||
data = {r["key"]: r["value"] for r in rows}
|
||||
return ConfigResponse(
|
||||
default_wait_seconds=int(data.get("default_wait_seconds", 10)),
|
||||
default_empty_response=data.get("default_empty_response", ""),
|
||||
agent_stale_after_seconds=int(data.get("agent_stale_after_seconds", 30)),
|
||||
)
|
||||
|
||||
|
||||
def update_config(
|
||||
default_wait_seconds: int | None = None,
|
||||
default_empty_response: str | None = None,
|
||||
agent_stale_after_seconds: int | None = None,
|
||||
) -> ConfigResponse:
|
||||
updates: dict[str, str] = {}
|
||||
if default_wait_seconds is not None:
|
||||
updates["default_wait_seconds"] = str(default_wait_seconds)
|
||||
if default_empty_response is not None:
|
||||
updates["default_empty_response"] = default_empty_response
|
||||
if agent_stale_after_seconds is not None:
|
||||
updates["agent_stale_after_seconds"] = str(agent_stale_after_seconds)
|
||||
|
||||
if updates:
|
||||
with get_write_conn() as conn:
|
||||
for key, value in updates.items():
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||
(key, value),
|
||||
)
|
||||
logger.info("Config updated: %s", list(updates.keys()))
|
||||
|
||||
return get_config()
|
||||
|
||||
75
app/services/event_service.py
Normal file
75
app/services/event_service.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
app/services/event_service.py
|
||||
Server-Sent Events (SSE) broadcaster.
|
||||
Maintains a set of subscriber asyncio queues and fans out events to all of them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import AsyncGenerator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# All active SSE subscriber queues
|
||||
_subscribers: set[asyncio.Queue] = set()
|
||||
|
||||
|
||||
def subscribe() -> asyncio.Queue:
|
||||
"""Register a new SSE subscriber and return its queue."""
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=64)
|
||||
_subscribers.add(q)
|
||||
logger.debug("SSE subscriber added, total=%d", len(_subscribers))
|
||||
return q
|
||||
|
||||
|
||||
def unsubscribe(q: asyncio.Queue) -> None:
|
||||
"""Remove a subscriber queue."""
|
||||
_subscribers.discard(q)
|
||||
logger.debug("SSE subscriber removed, total=%d", len(_subscribers))
|
||||
|
||||
|
||||
def broadcast(event_type: str, data: dict) -> None:
|
||||
"""
|
||||
Fan out an event to all current subscribers.
|
||||
Safe to call from synchronous code – uses put_nowait and discards slow consumers.
|
||||
"""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"type": event_type,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
dead: list[asyncio.Queue] = []
|
||||
for q in list(_subscribers):
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning("SSE subscriber queue full, dropping event type=%s", event_type)
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
_subscribers.discard(q)
|
||||
|
||||
|
||||
async def event_generator(q: asyncio.Queue) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Async generator that yields SSE-formatted strings from a subscriber queue.
|
||||
Yields a keep-alive comment every 15 seconds when idle.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
payload = await asyncio.wait_for(q.get(), timeout=15.0)
|
||||
yield f"data: {payload}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
# Keep-alive ping so browsers don't close the connection
|
||||
yield ": ping\n\n"
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
unsubscribe(q)
|
||||
|
||||
203
app/services/instruction_service.py
Normal file
203
app/services/instruction_service.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
app/services/instruction_service.py
|
||||
Business logic for managing the instruction queue.
|
||||
All write operations that affect queue integrity use the write lock via get_write_conn().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_conn, get_write_conn
|
||||
from app.models import InstructionItem, InstructionStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wakeup event – lets get_user_request react instantly when a new instruction
|
||||
# is enqueued instead of sleeping for a full second.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_wakeup_event: asyncio.Event | None = None
|
||||
_event_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
async def init_wakeup() -> None:
|
||||
"""Create the wakeup event. Must be called from async context (lifespan)."""
|
||||
global _wakeup_event, _event_loop
|
||||
_wakeup_event = asyncio.Event()
|
||||
_event_loop = asyncio.get_running_loop()
|
||||
logger.debug("Instruction wakeup event initialised")
|
||||
|
||||
|
||||
def get_wakeup_event() -> asyncio.Event | None:
|
||||
return _wakeup_event
|
||||
|
||||
|
||||
def _trigger_wakeup() -> None:
|
||||
"""Thread-safe: schedule event.set() on the running event loop."""
|
||||
if _wakeup_event is None or _event_loop is None:
|
||||
return
|
||||
_event_loop.call_soon_threadsafe(_wakeup_event.set)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _row_to_item(row: sqlite3.Row) -> InstructionItem:
|
||||
return InstructionItem(
|
||||
id=row["id"],
|
||||
content=row["content"],
|
||||
status=InstructionStatus(row["status"]),
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
consumed_at=row["consumed_at"],
|
||||
consumed_by_agent_id=row["consumed_by_agent_id"],
|
||||
position=row["position"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_instructions(status_filter: Optional[str] = None) -> list[InstructionItem]:
|
||||
query = "SELECT * FROM instructions"
|
||||
params: tuple = ()
|
||||
if status_filter and status_filter != "all":
|
||||
query += " WHERE status = ?"
|
||||
params = (status_filter,)
|
||||
query += " ORDER BY position ASC"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [_row_to_item(r) for r in rows]
|
||||
|
||||
|
||||
def get_instruction(instruction_id: str) -> Optional[InstructionItem]:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM instructions WHERE id = ?", (instruction_id,)
|
||||
).fetchone()
|
||||
return _row_to_item(row) if row else None
|
||||
|
||||
|
||||
def get_queue_counts() -> dict[str, int]:
|
||||
with get_conn() as conn:
|
||||
pending = conn.execute(
|
||||
"SELECT COUNT(*) FROM instructions WHERE status = 'pending'"
|
||||
).fetchone()[0]
|
||||
consumed = conn.execute(
|
||||
"SELECT COUNT(*) FROM instructions WHERE status = 'consumed'"
|
||||
).fetchone()[0]
|
||||
return {"pending_count": pending, "consumed_count": consumed}
|
||||
|
||||
|
||||
def _next_position(conn: sqlite3.Connection) -> int:
|
||||
row = conn.execute("SELECT MAX(position) FROM instructions").fetchone()
|
||||
return (row[0] or 0) + 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Writes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_instruction(content: str) -> InstructionItem:
|
||||
instruction_id = str(uuid.uuid4())
|
||||
now = _now_iso()
|
||||
|
||||
with get_write_conn() as conn:
|
||||
pos = _next_position(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO instructions (id, content, status, created_at, updated_at, position)
|
||||
VALUES (?, ?, 'pending', ?, ?, ?)
|
||||
""",
|
||||
(instruction_id, content, now, now, pos),
|
||||
)
|
||||
|
||||
logger.info("Instruction created id=%s pos=%d", instruction_id, pos)
|
||||
item = get_instruction(instruction_id)
|
||||
assert item is not None
|
||||
_trigger_wakeup() # wake up any waiting get_user_request calls immediately
|
||||
return item
|
||||
|
||||
|
||||
def update_instruction(instruction_id: str, content: str) -> InstructionItem:
|
||||
"""Update content of a pending instruction. Raises ValueError if consumed or not found."""
|
||||
with get_write_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT status FROM instructions WHERE id = ?", (instruction_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(instruction_id)
|
||||
if row["status"] != "pending":
|
||||
raise PermissionError(f"Instruction {instruction_id} is already consumed")
|
||||
|
||||
now = _now_iso()
|
||||
conn.execute(
|
||||
"UPDATE instructions SET content = ?, updated_at = ? WHERE id = ?",
|
||||
(content, now, instruction_id),
|
||||
)
|
||||
|
||||
logger.info("Instruction updated id=%s", instruction_id)
|
||||
item = get_instruction(instruction_id)
|
||||
assert item is not None
|
||||
return item
|
||||
|
||||
|
||||
def delete_instruction(instruction_id: str) -> None:
|
||||
"""Delete a pending instruction. Raises ValueError if consumed or not found."""
|
||||
with get_write_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT status FROM instructions WHERE id = ?", (instruction_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(instruction_id)
|
||||
if row["status"] != "pending":
|
||||
raise PermissionError(f"Instruction {instruction_id} is already consumed")
|
||||
|
||||
conn.execute("DELETE FROM instructions WHERE id = ?", (instruction_id,))
|
||||
|
||||
logger.info("Instruction deleted id=%s", instruction_id)
|
||||
|
||||
|
||||
def consume_next(agent_id: str = "unknown") -> Optional[InstructionItem]:
|
||||
"""
|
||||
Atomically claim and return the next pending instruction.
|
||||
Uses the write lock to prevent two concurrent callers from consuming the same item.
|
||||
Returns None if the queue is empty.
|
||||
"""
|
||||
with get_write_conn() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id FROM instructions
|
||||
WHERE status = 'pending'
|
||||
ORDER BY position ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
instruction_id = row["id"]
|
||||
now = _now_iso()
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE instructions
|
||||
SET status = 'consumed', consumed_at = ?, consumed_by_agent_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now, agent_id, now, instruction_id),
|
||||
)
|
||||
|
||||
logger.info("Instruction consumed id=%s agent=%s", instruction_id, agent_id)
|
||||
return get_instruction(instruction_id)
|
||||
|
||||
79
app/services/status_service.py
Normal file
79
app/services/status_service.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
app/services/status_service.py
|
||||
Tracks server startup time and agent activity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_conn, get_write_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_server_started_at: datetime = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def server_started_at() -> datetime:
|
||||
return _server_started_at
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent activity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def record_agent_activity(agent_id: str, result_type: str) -> None:
|
||||
"""Upsert agent activity record on every tool call."""
|
||||
now = _now_iso()
|
||||
with get_write_conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_activity (agent_id, last_seen_at, last_fetch_at, last_result_type)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(agent_id) DO UPDATE SET
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
last_fetch_at = excluded.last_fetch_at,
|
||||
last_result_type = excluded.last_result_type
|
||||
""",
|
||||
(agent_id, now, now, result_type),
|
||||
)
|
||||
logger.debug("Agent activity recorded agent=%s result=%s", agent_id, result_type)
|
||||
|
||||
|
||||
def get_latest_agent_activity() -> Optional[sqlite3.Row]:
|
||||
"""Return the most recently active agent row, or None."""
|
||||
with get_conn() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM agent_activity ORDER BY last_seen_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
|
||||
|
||||
def get_agent_stale_seconds() -> int:
|
||||
"""Read agent_stale_after_seconds from settings table."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM settings WHERE key = 'agent_stale_after_seconds'"
|
||||
).fetchone()
|
||||
return int(row["value"]) if row else 30
|
||||
|
||||
|
||||
def is_agent_connected() -> bool:
|
||||
"""True if the most recent agent activity is within the stale threshold."""
|
||||
row = get_latest_agent_activity()
|
||||
if row is None:
|
||||
return False
|
||||
stale_seconds = get_agent_stale_seconds()
|
||||
last_seen = datetime.fromisoformat(row["last_seen_at"])
|
||||
now = datetime.now(timezone.utc)
|
||||
if last_seen.tzinfo is None:
|
||||
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
||||
delta = (now - last_seen).total_seconds()
|
||||
return delta <= stale_seconds
|
||||
|
||||
102
main.py
Normal file
102
main.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
main.py
|
||||
Application entrypoint.
|
||||
Starts the FastAPI HTTP server (web UI + REST API) and mounts the FastMCP
|
||||
streamable-HTTP endpoint under /mcp.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api import routes_config, routes_instructions, routes_status
|
||||
from app.config import settings
|
||||
from app.database import init_db
|
||||
from app.logging_setup import configure_logging
|
||||
from app.mcp_server import mcp, mcp_asgi_app
|
||||
from app.services import instruction_service
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging must be configured before any module emits log messages
|
||||
# ---------------------------------------------------------------------------
|
||||
configure_logging(settings)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build the FastAPI application
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
logger.info("local-mcp starting up – initialising database …")
|
||||
init_db(settings.db_path)
|
||||
await instruction_service.init_wakeup()
|
||||
logger.info(
|
||||
"local-mcp ready http://%s:%d | MCP http://%s:%d/mcp (stateless=%s)",
|
||||
settings.host, settings.http_port,
|
||||
settings.host, settings.http_port,
|
||||
settings.mcp_stateless,
|
||||
)
|
||||
# Run the MCP session manager for the duration of the app lifetime
|
||||
async with mcp.session_manager.run():
|
||||
yield
|
||||
# Shutdown
|
||||
logger.info("local-mcp shutting down")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="local-mcp",
|
||||
description="Localhost MCP server with instruction queue management UI",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# --- Global exception handler ---
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
logger.exception("Unhandled exception for %s %s", request.method, request.url)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error", "error": str(exc)},
|
||||
)
|
||||
|
||||
# --- API routers ---
|
||||
app.include_router(routes_status.router)
|
||||
app.include_router(routes_instructions.router)
|
||||
app.include_router(routes_config.router)
|
||||
|
||||
# --- MCP streamable-HTTP transport mounted at /mcp ---
|
||||
# mcp_asgi_app was pre-built in mcp_server.py; session manager is
|
||||
# started explicitly in the lifespan above.
|
||||
app.mount("/mcp", mcp_asgi_app)
|
||||
|
||||
# --- Static files for the web UI ---
|
||||
app.mount("/static", StaticFiles(directory="static", html=False), name="static")
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def serve_index():
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=settings.host,
|
||||
port=settings.http_port,
|
||||
log_level=settings.log_level.lower(),
|
||||
reload=False,
|
||||
)
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
mcp[cli]>=1.26.0
|
||||
fastapi>=0.135.0
|
||||
uvicorn>=0.42.0
|
||||
sse-starlette>=3.3.0
|
||||
|
||||
229
server.ps1
Normal file
229
server.ps1
Normal file
@@ -0,0 +1,229 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Non-blocking server management script for local-mcp.
|
||||
|
||||
.DESCRIPTION
|
||||
Manages the local-mcp server process without blocking the terminal.
|
||||
The server runs as a detached background process; stdout/stderr are
|
||||
written to logs/server.log.
|
||||
|
||||
.USAGE
|
||||
.\server.ps1 start # Start (no-op if already running)
|
||||
.\server.ps1 stop # Kill the running server
|
||||
.\server.ps1 restart # Stop then start
|
||||
.\server.ps1 status # Show PID, port state, and tail 20 log lines
|
||||
.\server.ps1 logs [N] # Tail last N lines of the log (default 40)
|
||||
.\server.ps1 logs -f # Follow log live (Ctrl-C to quit)
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[ValidateSet("start", "stop", "restart", "status", "logs")]
|
||||
[string]$Command = "status",
|
||||
|
||||
[Parameter(Position = 1)]
|
||||
[string]$Arg = ""
|
||||
)
|
||||
|
||||
# ── Paths ─────────────────────────────────────────────────────────────────
|
||||
|
||||
$Root = $PSScriptRoot
|
||||
$Python = Join-Path $Root ".venv\Scripts\python.exe"
|
||||
$Entry = Join-Path $Root "main.py"
|
||||
$LogDir = Join-Path $Root "logs"
|
||||
$LogOut = Join-Path $LogDir "server.log"
|
||||
$LogErr = Join-Path $LogDir "server.err.log"
|
||||
$PidFile = Join-Path $LogDir "server.pid"
|
||||
$Port = 8000
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function EnsureLogDir {
|
||||
if (-not (Test-Path $LogDir)) {
|
||||
New-Item -ItemType Directory -Path $LogDir | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function GetServerPid {
|
||||
# Trust the PID file first; verify the process is actually alive
|
||||
if (Test-Path $PidFile) {
|
||||
$stored = Get-Content $PidFile -ErrorAction SilentlyContinue
|
||||
if ($stored -match '^\d+$') {
|
||||
$proc = Get-Process -Id ([int]$stored) -ErrorAction SilentlyContinue
|
||||
if ($proc) { return [int]$stored }
|
||||
}
|
||||
Remove-Item $PidFile -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Fallback: find python process listening on the port
|
||||
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($conn) { return $conn.OwningProcess }
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function IsRunning {
|
||||
return $null -ne (GetServerPid)
|
||||
}
|
||||
|
||||
function WriteLog([string]$msg, [string]$color = "White") {
|
||||
Write-Host $msg -ForegroundColor $color
|
||||
}
|
||||
|
||||
function MergedLogTail([int]$n) {
|
||||
# Merge stdout + stderr logs sorted by content order and tail n lines
|
||||
$lines = @()
|
||||
if (Test-Path $LogOut) { $lines += Get-Content $LogOut -Tail ($n * 2) }
|
||||
if (Test-Path $LogErr) { $lines += Get-Content $LogErr -Tail ($n * 2) }
|
||||
# Return last $n lines (simple approach — interleaving is approximate)
|
||||
$lines | Select-Object -Last $n
|
||||
}
|
||||
|
||||
# ── Commands ──────────────────────────────────────────────────────────────
|
||||
|
||||
function Start-Server {
|
||||
if (IsRunning) {
|
||||
$pid_ = GetServerPid
|
||||
WriteLog "Server already running (PID $pid_ http://localhost:$Port)" "Green"
|
||||
return
|
||||
}
|
||||
|
||||
if (-not (Test-Path $Python)) {
|
||||
WriteLog "ERROR: Python venv not found at $Python" "Red"
|
||||
WriteLog "Run: python -m venv .venv && .venv\Scripts\pip install -r requirements.txt" "Yellow"
|
||||
exit 1
|
||||
}
|
||||
|
||||
EnsureLogDir
|
||||
# Stamp both log files so they exist and have a separator
|
||||
$stamp = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [server.ps1] ---- starting ----"
|
||||
Add-Content $LogOut $stamp
|
||||
Add-Content $LogErr $stamp
|
||||
|
||||
# Start python with stdout -> LogOut, stderr -> LogErr (separate files required on Windows)
|
||||
$proc = Start-Process `
|
||||
-FilePath $Python `
|
||||
-ArgumentList "-u `"$Entry`"" `
|
||||
-WorkingDirectory $Root `
|
||||
-RedirectStandardOutput $LogOut `
|
||||
-RedirectStandardError $LogErr `
|
||||
-WindowStyle Hidden `
|
||||
-PassThru
|
||||
|
||||
$proc.Id | Set-Content $PidFile
|
||||
|
||||
# Wait up to 6 s for the port to open
|
||||
$deadline = (Get-Date).AddSeconds(6)
|
||||
$ready = $false
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Milliseconds 400
|
||||
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
|
||||
if ($conn) { $ready = $true; break }
|
||||
}
|
||||
|
||||
if ($ready) {
|
||||
WriteLog "Server started (PID $($proc.Id) http://localhost:$Port)" "Green"
|
||||
} else {
|
||||
WriteLog "Server process launched (PID $($proc.Id)) but port $Port not yet open." "Yellow"
|
||||
WriteLog "Check logs: .\server.ps1 logs" "Yellow"
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-Server {
|
||||
$pid_ = GetServerPid
|
||||
if (-not $pid_) {
|
||||
WriteLog "Server is not running." "Yellow"
|
||||
return
|
||||
}
|
||||
|
||||
Stop-Process -Id $pid_ -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $PidFile -ErrorAction SilentlyContinue
|
||||
|
||||
# Wait up to 4 s for the port to free
|
||||
$deadline = (Get-Date).AddSeconds(4)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Milliseconds 300
|
||||
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
|
||||
if (-not $conn) { break }
|
||||
}
|
||||
|
||||
WriteLog "Server stopped (was PID $pid_)" "Yellow"
|
||||
}
|
||||
|
||||
function Restart-Server {
|
||||
Stop-Server
|
||||
Start-Sleep -Milliseconds 500
|
||||
Start-Server
|
||||
}
|
||||
|
||||
function Show-Status {
|
||||
$pid_ = GetServerPid
|
||||
if ($pid_) {
|
||||
$proc = Get-Process -Id $pid_ -ErrorAction SilentlyContinue
|
||||
$mem = if ($proc) { "{0:N0} MB" -f ($proc.WorkingSet64 / 1MB) } else { "?" }
|
||||
WriteLog ""
|
||||
WriteLog " Status : RUNNING" "Green"
|
||||
WriteLog " PID : $pid_"
|
||||
WriteLog " Memory : $mem"
|
||||
WriteLog " URL : http://localhost:$Port"
|
||||
WriteLog " Logs : $LogOut"
|
||||
WriteLog " $LogErr"
|
||||
} else {
|
||||
WriteLog ""
|
||||
WriteLog " Status : STOPPED" "Red"
|
||||
}
|
||||
|
||||
WriteLog ""
|
||||
WriteLog "--- Last 20 log lines (stdout) ---" "DarkGray"
|
||||
if (Test-Path $LogOut) {
|
||||
Get-Content $LogOut -Tail 20 | ForEach-Object { WriteLog $_ "DarkGray" }
|
||||
} else {
|
||||
WriteLog " (no log file yet)" "DarkGray"
|
||||
}
|
||||
|
||||
if (Test-Path $LogErr) {
|
||||
$errLines = Get-Content $LogErr -Tail 5 | Where-Object { $_ -match "ERROR|Exception|Traceback" }
|
||||
if ($errLines) {
|
||||
WriteLog ""
|
||||
WriteLog "--- Recent errors (stderr) ---" "Red"
|
||||
$errLines | ForEach-Object { WriteLog $_ "Red" }
|
||||
}
|
||||
}
|
||||
WriteLog ""
|
||||
}
|
||||
|
||||
function Tail-Logs {
|
||||
EnsureLogDir
|
||||
|
||||
if ($Arg -eq "-f") {
|
||||
if (-not (Test-Path $LogOut)) {
|
||||
WriteLog "No log file yet. Start the server first." "Yellow"
|
||||
return
|
||||
}
|
||||
WriteLog "Following $LogOut (Ctrl-C to stop)" "Cyan"
|
||||
Get-Content $LogOut -Wait -Tail 30
|
||||
} else {
|
||||
$n = if ($Arg -match '^\d+$') { [int]$Arg } else { 40 }
|
||||
WriteLog "--- stdout (last $n lines) ---" "Cyan"
|
||||
if (Test-Path $LogOut) { Get-Content $LogOut -Tail $n } else { WriteLog " (empty)" "DarkGray" }
|
||||
WriteLog ""
|
||||
WriteLog "--- stderr (last 20 lines) ---" "Yellow"
|
||||
if (Test-Path $LogErr) { Get-Content $LogErr -Tail 20 } else { WriteLog " (empty)" "DarkGray" }
|
||||
}
|
||||
}
|
||||
|
||||
# ── Dispatch ──────────────────────────────────────────────────────────────
|
||||
|
||||
switch ($Command) {
|
||||
"start" { Start-Server }
|
||||
"stop" { Stop-Server }
|
||||
"restart" { Restart-Server }
|
||||
"status" { Show-Status }
|
||||
"logs" { Tail-Logs }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
12
static/assets/favicon.svg
Normal file
12
static/assets/favicon.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<!-- Dark background -->
|
||||
<rect width="32" height="32" rx="6" fill="#0d1117"/>
|
||||
<!-- Monitor icon (scaled from 24×24 to ~20×20, centered) -->
|
||||
<g transform="translate(6, 6) scale(0.833)" stroke="#00d4ff" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 533 B |
BIN
static/assets/fonts/jetbrains-mono.woff2
Normal file
BIN
static/assets/fonts/jetbrains-mono.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/syne.woff2
Normal file
BIN
static/assets/fonts/syne.woff2
Normal file
Binary file not shown.
192
static/css/base.css
Normal file
192
static/css/base.css
Normal file
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* static/css/base.css
|
||||
* Design tokens, reset, and typographic foundation.
|
||||
*
|
||||
* Aesthetic: Industrial Ops Terminal
|
||||
* Dark graphite workspace with electric cyan as the primary action colour
|
||||
* and amber for warnings/consumed state. Syne for UI chrome, JetBrains Mono
|
||||
* for instruction content. Strong structure, decisive whitespace.
|
||||
*/
|
||||
|
||||
/* ── Fonts ─────────────────────────────────────────────────────────────── */
|
||||
@font-face {
|
||||
font-family: 'Syne';
|
||||
src: url('/static/assets/fonts/syne.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/static/assets/fonts/jetbrains-mono.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ── Design tokens ──────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--bg-void: #080b0d;
|
||||
--bg-base: #0d1117;
|
||||
--bg-raised: #131920;
|
||||
--bg-overlay: #1a2230;
|
||||
--bg-hover: #1f2a3a;
|
||||
--bg-active: #243040;
|
||||
|
||||
/* Borders */
|
||||
--border-subtle: #1e2c3a;
|
||||
--border-muted: #253345;
|
||||
--border-strong: #2e4058;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #e8edf2;
|
||||
--text-secondary:#8da0b3;
|
||||
--text-muted: #4a6175;
|
||||
--text-disabled: #2e4a5e;
|
||||
|
||||
/* Brand / accent */
|
||||
--cyan: #00d4ff;
|
||||
--cyan-dim: #007fa8;
|
||||
--cyan-glow: rgba(0, 212, 255, 0.15);
|
||||
|
||||
/* Status */
|
||||
--green: #00e676;
|
||||
--green-dim: #00703a;
|
||||
--green-glow: rgba(0, 230, 118, 0.15);
|
||||
|
||||
--amber: #ffbe0b;
|
||||
--amber-dim: #8a6500;
|
||||
--amber-glow: rgba(255, 190, 11, 0.12);
|
||||
|
||||
--red: #ff4f4f;
|
||||
--red-dim: #8a0000;
|
||||
--red-glow: rgba(255, 79, 79, 0.15);
|
||||
|
||||
/* Typography */
|
||||
--font-ui: 'Syne', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
|
||||
--text-xs: 0.6875rem; /* 11px */
|
||||
--text-sm: 0.75rem; /* 12px */
|
||||
--text-base: 0.875rem; /* 14px */
|
||||
--text-md: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.375rem; /* 22px */
|
||||
--text-2xl: 1.75rem; /* 28px */
|
||||
--text-3xl: 2.25rem; /* 36px */
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4);
|
||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.5);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.6);
|
||||
--shadow-cyan: 0 0 20px var(--cyan-glow), 0 0 40px rgba(0,212,255,0.06);
|
||||
|
||||
/* Animation */
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--duration-fast: 120ms;
|
||||
--duration-normal: 240ms;
|
||||
--duration-slow: 400ms;
|
||||
}
|
||||
|
||||
/* ── Light theme overrides ───────────────────────────────────────────────── */
|
||||
/* Applied when <html data-theme="light"> is set by theme.js */
|
||||
[data-theme="light"] {
|
||||
--bg-void: #e8e8e3;
|
||||
--bg-base: #f2f2ed;
|
||||
--bg-raised: #ffffff;
|
||||
--bg-overlay: #ebebe6;
|
||||
--bg-hover: #e2e2dc;
|
||||
--bg-active: #d8d8d2;
|
||||
|
||||
--border-subtle: #d0d0ca;
|
||||
--border-muted: #c0c0b8;
|
||||
--border-strong: #a8a8a0;
|
||||
|
||||
--text-primary: #1a1f2e;
|
||||
--text-secondary:#4a5568;
|
||||
--text-muted: #718096;
|
||||
--text-disabled: #a0aec0;
|
||||
|
||||
/* Slightly desaturated accents so they work on a light surface */
|
||||
--cyan: #007fa8;
|
||||
--cyan-dim: #005c7a;
|
||||
--cyan-glow: rgba(0, 127, 168, 0.14);
|
||||
|
||||
--green: #166534;
|
||||
--green-dim: #14532d;
|
||||
--green-glow: rgba(22, 101, 52, 0.12);
|
||||
|
||||
--amber: #92400e;
|
||||
--amber-dim: #78350f;
|
||||
--amber-glow: rgba(146, 64, 14, 0.1);
|
||||
|
||||
--red: #b91c1c;
|
||||
--red-dim: #7f1d1d;
|
||||
--red-glow: rgba(185, 28, 28, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
|
||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.1);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.12);
|
||||
--shadow-cyan: 0 0 20px rgba(0,127,168,0.1);
|
||||
}
|
||||
|
||||
/* ── Reset ──────────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-base);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-base); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* Focus ring */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--cyan);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Typography helpers ──────────────────────────────────────────────────── */
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.label { font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
.muted { color: var(--text-muted); }
|
||||
.subtle { color: var(--text-secondary); }
|
||||
|
||||
482
static/css/components.css
Normal file
482
static/css/components.css
Normal file
@@ -0,0 +1,482 @@
|
||||
/*
|
||||
* static/css/components.css
|
||||
* Reusable UI components: buttons, inputs, badges, status indicators,
|
||||
* instruction cards, and animations.
|
||||
*/
|
||||
|
||||
/* ── Status indicator (LED dot) ─────────────────────────────────────────── */
|
||||
|
||||
.led {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.led__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.led--green .led__dot { background: var(--green); box-shadow: 0 0 6px var(--green), 0 0 12px var(--green-glow); }
|
||||
.led--amber .led__dot { background: var(--amber); box-shadow: 0 0 6px var(--amber), 0 0 12px var(--amber-glow); }
|
||||
.led--red .led__dot { background: var(--red); box-shadow: 0 0 6px var(--red), 0 0 12px var(--red-glow); }
|
||||
.led--muted .led__dot { background: var(--text-muted); box-shadow: none; }
|
||||
.led--cyan .led__dot { background: var(--cyan); box-shadow: 0 0 6px var(--cyan), 0 0 12px var(--cyan-glow); }
|
||||
|
||||
.led--green .led__label { color: var(--green); }
|
||||
.led--amber .led__label { color: var(--amber); }
|
||||
.led--red .led__label { color: var(--red); }
|
||||
.led--muted .led__label { color: var(--text-muted); }
|
||||
.led--cyan .led__label { color: var(--cyan); }
|
||||
|
||||
/* Pulse for "connected" / "active" */
|
||||
.led--pulse .led__dot {
|
||||
animation: pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.85); }
|
||||
}
|
||||
|
||||
/* ── Queue count badge ───────────────────────────────────────────────────── */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.badge--cyan { background: var(--cyan-glow); color: var(--cyan); border: 1px solid rgba(0,212,255,0.3); }
|
||||
.badge--amber { background: var(--amber-glow); color: var(--amber); border: 1px solid rgba(255,190,11,0.3); }
|
||||
.badge--muted { background: var(--bg-overlay); color: var(--text-muted); border: 1px solid var(--border-subtle); }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background var(--duration-fast) var(--ease-in-out),
|
||||
border-color var(--duration-fast) var(--ease-in-out),
|
||||
box-shadow var(--duration-fast) var(--ease-in-out),
|
||||
transform var(--duration-fast) var(--ease-in-out);
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* Primary – cyan */
|
||||
.btn--primary {
|
||||
background: var(--cyan);
|
||||
border-color: var(--cyan);
|
||||
color: #000;
|
||||
}
|
||||
.btn--primary:hover {
|
||||
background: #1de9ff;
|
||||
box-shadow: 0 0 16px var(--cyan-glow);
|
||||
}
|
||||
|
||||
/* Ghost */
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border-muted);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn--ghost:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Danger */
|
||||
.btn--danger {
|
||||
background: transparent;
|
||||
border-color: var(--red-dim);
|
||||
color: var(--red);
|
||||
}
|
||||
.btn--danger:hover {
|
||||
background: var(--red-glow);
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
/* Icon-only */
|
||||
.btn--icon {
|
||||
padding: var(--space-2);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Small */
|
||||
.btn--sm {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* ── Form elements ──────────────────────────────────────────────────────── */
|
||||
|
||||
.input, .textarea {
|
||||
width: 100%;
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-muted);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
transition:
|
||||
border-color var(--duration-fast) var(--ease-in-out),
|
||||
box-shadow var(--duration-fast) var(--ease-in-out);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.input::placeholder, .textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input:focus, .textarea:focus {
|
||||
border-color: var(--cyan-dim);
|
||||
box-shadow: 0 0 0 3px rgba(0,212,255,0.08);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 88px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.form-row .textarea { flex: 1; }
|
||||
|
||||
/* ── Instruction card ─────────────────────────────────────────────────────── */
|
||||
|
||||
.instruction-card {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
align-items: start;
|
||||
transition: background var(--duration-fast) var(--ease-in-out);
|
||||
animation: card-in var(--duration-slow) var(--ease-out-expo) both;
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.instruction-card:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.instruction-card:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.instruction-card__meta {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.instruction-card__pos {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--cyan);
|
||||
background: var(--cyan-glow);
|
||||
border: 1px solid rgba(0,212,255,0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 6px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.instruction-card__content {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.65;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.instruction-card__time {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.instruction-card__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Edit mode */
|
||||
.instruction-card__edit-area {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-end;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.instruction-card__edit-area .textarea {
|
||||
flex: 1;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
/* Consumed card */
|
||||
.instruction-card--consumed {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.instruction-card--consumed .instruction-card__content {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--amber-dim);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.instruction-card--consumed .instruction-card__pos {
|
||||
background: var(--amber-glow);
|
||||
border-color: rgba(255,190,11,0.2);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.instruction-card--consumed:hover {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.instruction-card__consumed-by {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--amber);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.empty-state {
|
||||
padding: var(--space-10) var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--space-3);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* ── Status panel items ──────────────────────────────────────────────────── */
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.stat-row:last-child { border-bottom: none; }
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-value--cyan { color: var(--cyan); }
|
||||
.stat-value--amber { color: var(--amber); }
|
||||
|
||||
/* ── Config form ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.config-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.config-field:last-child { margin-bottom: 0; }
|
||||
|
||||
.config-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.config-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.input--sm {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ── Toast notification ──────────────────────────────────────────────────── */
|
||||
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
bottom: var(--space-6);
|
||||
right: var(--space-6);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: toast-in var(--duration-normal) var(--ease-out-expo) both;
|
||||
pointer-events: auto;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.toast--success { border-color: var(--green-dim); color: var(--green); }
|
||||
.toast--error { border-color: var(--red-dim); color: var(--red); }
|
||||
.toast--info { border-color: var(--cyan-dim); color: var(--cyan); }
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(4px) scale(0.97); }
|
||||
}
|
||||
|
||||
/* ── Theme toggle (header) ────────────────────────────────────────────────── */
|
||||
|
||||
#theme-toggle {
|
||||
color: var(--text-secondary);
|
||||
border-color: transparent;
|
||||
transition: color var(--duration-fast) var(--ease-in-out),
|
||||
background var(--duration-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
#theme-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
/* ── SVG icons ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
stroke-width: 2;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.icon--sm { width: 12px; height: 12px; }
|
||||
|
||||
/* ── Page load animation ─────────────────────────────────────────────────── */
|
||||
|
||||
.fade-in {
|
||||
animation: fade-in var(--duration-slow) var(--ease-out-expo) both;
|
||||
}
|
||||
|
||||
.fade-in--delay-1 { animation-delay: 60ms; }
|
||||
.fade-in--delay-2 { animation-delay: 120ms; }
|
||||
.fade-in--delay-3 { animation-delay: 200ms; }
|
||||
.fade-in--delay-4 { animation-delay: 300ms; }
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Loading spinner ─────────────────────────────────────────────────────── */
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border-muted);
|
||||
border-top-color: var(--cyan);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
131
static/css/layout.css
Normal file
131
static/css/layout.css
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* static/css/layout.css
|
||||
* Page-level layout: header, main grid, panels.
|
||||
*/
|
||||
|
||||
/* ── Page structure ─────────────────────────────────────────────────────── */
|
||||
|
||||
.page-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-void);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 0 var(--space-6);
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.header-brand-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-brand-name span {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.header-indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
/* ── Main layout ────────────────────────────────────────────────────────── */
|
||||
|
||||
.page-main {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-8) var(--space-6) var(--space-12);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
grid-template-rows: auto;
|
||||
gap: var(--space-6);
|
||||
grid-template-areas:
|
||||
"composer sidebar"
|
||||
"queue sidebar"
|
||||
"history sidebar";
|
||||
}
|
||||
|
||||
.area-composer { grid-area: composer; }
|
||||
.area-queue { grid-area: queue; }
|
||||
.area-history { grid-area: history; }
|
||||
.area-sidebar { grid-area: sidebar; display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
|
||||
/* ── Panel container ─────────────────────────────────────────────────────── */
|
||||
|
||||
.panel {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.panel-body--flush {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-main {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"sidebar"
|
||||
"composer"
|
||||
"queue"
|
||||
"history";
|
||||
}
|
||||
|
||||
.area-sidebar {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.area-sidebar > .panel {
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page-header { padding: 0 var(--space-4); }
|
||||
.page-main { padding: var(--space-5) var(--space-4) var(--space-10); gap: var(--space-4); }
|
||||
}
|
||||
|
||||
186
static/index.html
Normal file
186
static/index.html
Normal file
@@ -0,0 +1,186 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>local-mcp — Instruction Queue</title>
|
||||
<meta name="description" content="Localhost MCP instruction queue management" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/assets/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/static/assets/favicon.svg" />
|
||||
<link rel="stylesheet" href="/static/css/base.css" />
|
||||
<link rel="stylesheet" href="/static/css/layout.css" />
|
||||
<link rel="stylesheet" href="/static/css/components.css" />
|
||||
<!-- Apply theme before paint to avoid flash of wrong theme -->
|
||||
<script>
|
||||
(function(){
|
||||
var t = localStorage.getItem('local-mcp-theme') ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.dataset.theme = t;
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
||||
<header class="page-header">
|
||||
<div class="header-brand">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--cyan)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<span class="header-brand-name">local<span>-mcp</span></span>
|
||||
</div>
|
||||
|
||||
<div class="header-indicators">
|
||||
<div id="led-server" class="led led--muted">
|
||||
<span class="led__dot"></span>
|
||||
<span class="led__label">Connecting…</span>
|
||||
</div>
|
||||
<div id="led-agent" class="led led--muted">
|
||||
<span class="led__dot"></span>
|
||||
<span class="led__label">Agent Idle</span>
|
||||
</div>
|
||||
<button id="theme-toggle" class="btn btn--ghost btn--icon" title="Toggle theme" aria-label="Toggle theme">
|
||||
<!-- icon injected by theme.js -->
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── Main ────────────────────────────────────────────────────────────── -->
|
||||
<main class="page-main">
|
||||
|
||||
<!-- Composer -->
|
||||
<section class="area-composer fade-in">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New Instruction
|
||||
</span>
|
||||
<span class="subtle" style="font-size:var(--text-xs)">Enter to send · Shift+Enter for newline</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form id="composer-form">
|
||||
<div class="form-row">
|
||||
<textarea
|
||||
id="composer-input"
|
||||
class="textarea"
|
||||
placeholder="Type an instruction… (Enter to send, Shift+Enter for newline)"
|
||||
rows="3"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<button id="composer-submit" type="submit" class="btn btn--primary" style="height:fit-content;align-self:flex-end">
|
||||
<svg class="icon" viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pending queue -->
|
||||
<section class="area-queue fade-in fade-in--delay-1">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
Pending Queue
|
||||
</span>
|
||||
<span id="pending-badge" class="badge badge--muted">0</span>
|
||||
</div>
|
||||
<div class="panel-body panel-body--flush">
|
||||
<div id="pending-list">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon">◈</div>
|
||||
Loading…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Consumed history -->
|
||||
<section class="area-history fade-in fade-in--delay-2">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
Consumed
|
||||
</span>
|
||||
<span id="consumed-badge" class="badge badge--muted">0</span>
|
||||
</div>
|
||||
<div class="panel-body panel-body--flush">
|
||||
<div id="consumed-list">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon">◈</div>
|
||||
Loading…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="area-sidebar">
|
||||
|
||||
<!-- Server / Agent Status -->
|
||||
<div class="panel fade-in fade-in--delay-1">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
Status
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-body" id="status-panel-body">
|
||||
<div class="stat-row"><span class="stat-label">Loading…</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config -->
|
||||
<div class="panel fade-in fade-in--delay-2">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Settings
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form id="config-form">
|
||||
<div class="config-field">
|
||||
<label class="config-label" for="cfg-wait">Min Wait (sec)</label>
|
||||
<input id="cfg-wait" class="input input--sm" type="number" min="0" max="300" value="10" />
|
||||
<span class="config-hint">Server enforces this as the minimum wait before returning an empty response. Agent may request longer but never shorter.</span>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<label class="config-label" for="cfg-empty">Empty Response</label>
|
||||
<input id="cfg-empty" class="input input--sm" type="text" placeholder="Leave blank for no response" />
|
||||
<span class="config-hint">Default text returned when queue is empty</span>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<label class="config-label" for="cfg-stale">Agent Stale After (sec)</label>
|
||||
<input id="cfg-stale" class="input input--sm" type="number" min="5" max="600" value="30" />
|
||||
<span class="config-hint">Inactivity before agent shown as idle</span>
|
||||
</div>
|
||||
<button id="cfg-save" type="submit" class="btn btn--ghost btn--sm" style="width:100%">
|
||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
Save Settings
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Toast container -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
43
static/js/api.js
Normal file
43
static/js/api.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* static/js/api.js
|
||||
* Thin fetch wrappers for all HTTP API endpoints.
|
||||
*/
|
||||
|
||||
const BASE = '';
|
||||
|
||||
async function request(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
|
||||
const res = await fetch(BASE + path, opts);
|
||||
if (res.status === 204) return null;
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const msg = data?.detail || `HTTP ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Health
|
||||
health: () => request('GET', '/healthz'),
|
||||
|
||||
// Status
|
||||
status: () => request('GET', '/api/status'),
|
||||
|
||||
// Instructions
|
||||
listInstructions: (status='all') => request('GET', `/api/instructions?status=${status}`),
|
||||
createInstruction: (content) => request('POST', '/api/instructions', { content }),
|
||||
updateInstruction: (id, content) => request('PATCH', `/api/instructions/${id}`, { content }),
|
||||
deleteInstruction: (id) => request('DELETE', `/api/instructions/${id}`),
|
||||
|
||||
// Config
|
||||
getConfig: () => request('GET', '/api/config'),
|
||||
updateConfig: (patch) => request('PATCH', '/api/config', patch),
|
||||
};
|
||||
|
||||
108
static/js/app.js
Normal file
108
static/js/app.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* static/js/app.js
|
||||
* Application bootstrap – initialises all modules, kicks off data fetching,
|
||||
* and exports shared utilities like `toast`.
|
||||
*/
|
||||
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
import { connectSSE } from './events.js';
|
||||
import { initInstructions, initComposer, refreshTimestamps } from './instructions.js';
|
||||
import { initStatus, initConfig, refreshStatusTimestamps } from './status.js';
|
||||
import { initTheme } from './theme.js';
|
||||
|
||||
// ── Toast notification ────────────────────────────────────────────────────
|
||||
|
||||
const _toastContainer = document.getElementById('toast-container');
|
||||
|
||||
export function toast(message, type = 'info') {
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast toast--${type}`;
|
||||
el.textContent = message;
|
||||
_toastContainer.appendChild(el);
|
||||
setTimeout(() => {
|
||||
el.style.animation = 'toast-out 240ms cubic-bezier(0.4,0,1,1) forwards';
|
||||
el.addEventListener('animationend', () => el.remove());
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ── Document title badge ──────────────────────────────────────────────────
|
||||
|
||||
function updateTitle(instructions) {
|
||||
const pending = (instructions || []).filter(i => i.status === 'pending').length;
|
||||
document.title = pending > 0 ? `(${pending}) local-mcp` : 'local-mcp';
|
||||
}
|
||||
|
||||
// ── Reconnecting indicator ────────────────────────────────────────────────
|
||||
|
||||
function initReconnectingIndicator() {
|
||||
const serverLed = document.getElementById('led-server');
|
||||
state.subscribe('sseReconnecting', (reconnecting) => {
|
||||
if (!serverLed) return;
|
||||
if (reconnecting) {
|
||||
serverLed.className = 'led led--amber led--pulse';
|
||||
serverLed.querySelector('.led__label').textContent = 'Reconnecting…';
|
||||
}
|
||||
// The full status update (onopen) will re-set the correct class
|
||||
});
|
||||
}
|
||||
|
||||
// ── Startup data load ─────────────────────────────────────────────────────
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
const [instructions, status, config] = await Promise.all([
|
||||
api.listInstructions('all'),
|
||||
api.status(),
|
||||
api.getConfig(),
|
||||
]);
|
||||
state.set('instructions', instructions.items);
|
||||
state.set('status', status);
|
||||
state.set('config', config);
|
||||
state.set('serverOnline', true);
|
||||
state.set('sseReconnecting', false);
|
||||
} catch (err) {
|
||||
console.error('Failed to load initial data:', err);
|
||||
state.set('serverOnline', false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Periodic refresh (fallback for SSE gaps) ──────────────────────────────
|
||||
|
||||
function startPolling() {
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const s = await api.status();
|
||||
state.set('status', s);
|
||||
state.set('serverOnline', true);
|
||||
} catch {
|
||||
state.set('serverOnline', false);
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
// Refresh relative timestamps every 20 seconds
|
||||
setInterval(() => { refreshTimestamps(); refreshStatusTimestamps(); }, 20_000);
|
||||
}
|
||||
|
||||
// ── Subscribe to state changes ────────────────────────────────────────────
|
||||
|
||||
function initGlobalSubscriptions() {
|
||||
state.subscribe('instructions', updateTitle);
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
initTheme(); // must run first – sets button icon
|
||||
initReconnectingIndicator();
|
||||
initStatus();
|
||||
initConfig();
|
||||
initInstructions();
|
||||
initComposer();
|
||||
initGlobalSubscriptions();
|
||||
|
||||
await loadInitialData();
|
||||
connectSSE();
|
||||
startPolling();
|
||||
});
|
||||
|
||||
119
static/js/events.js
Normal file
119
static/js/events.js
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* static/js/events.js
|
||||
* Server-Sent Events client – connects to /api/events and dispatches
|
||||
* updates into the central state store.
|
||||
* Uses full item payloads embedded in events to avoid extra re-fetch round-trips.
|
||||
*/
|
||||
|
||||
import { state } from './state.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
let _es = null;
|
||||
let _reconnectTimer = null;
|
||||
let _reconnecting = false;
|
||||
const RECONNECT_DELAY_MS = 3000;
|
||||
|
||||
export function connectSSE() {
|
||||
if (_es) return;
|
||||
_connect();
|
||||
}
|
||||
|
||||
function _connect() {
|
||||
_es = new EventSource('/api/events');
|
||||
|
||||
_es.onopen = () => {
|
||||
console.debug('[SSE] connected');
|
||||
if (_reconnecting) {
|
||||
_reconnecting = false;
|
||||
state.set('sseReconnecting', false);
|
||||
// Full refresh after reconnect to catch anything we missed
|
||||
_fullRefresh();
|
||||
}
|
||||
state.set('serverOnline', true);
|
||||
};
|
||||
|
||||
_es.onmessage = (e) => {
|
||||
try {
|
||||
const event = JSON.parse(e.data);
|
||||
_handleEvent(event);
|
||||
} catch (err) {
|
||||
console.warn('[SSE] parse error', err);
|
||||
}
|
||||
};
|
||||
|
||||
_es.onerror = () => {
|
||||
console.warn('[SSE] connection lost – reconnecting in', RECONNECT_DELAY_MS, 'ms');
|
||||
state.set('serverOnline', false);
|
||||
_reconnecting = true;
|
||||
state.set('sseReconnecting', true);
|
||||
_es.close();
|
||||
_es = null;
|
||||
clearTimeout(_reconnectTimer);
|
||||
_reconnectTimer = setTimeout(_connect, RECONNECT_DELAY_MS);
|
||||
};
|
||||
}
|
||||
|
||||
async function _fullRefresh() {
|
||||
try {
|
||||
const [instructions, status, config] = await Promise.all([
|
||||
api.listInstructions('all'),
|
||||
api.status(),
|
||||
api.getConfig(),
|
||||
]);
|
||||
state.set('instructions', instructions.items);
|
||||
state.set('status', status);
|
||||
state.set('config', config);
|
||||
} catch (e) {
|
||||
console.error('[SSE] full refresh failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
function _applyInstructionPatch(item) {
|
||||
const current = state.get('instructions') || [];
|
||||
const idx = current.findIndex(i => i.id === item.id);
|
||||
if (idx === -1) {
|
||||
// New item – append and re-sort by position
|
||||
const next = [...current, item].sort((a, b) => a.position - b.position);
|
||||
state.set('instructions', next);
|
||||
} else {
|
||||
// Replace in-place, preserving order
|
||||
const next = [...current];
|
||||
next[idx] = item;
|
||||
state.set('instructions', next.sort((a, b) => a.position - b.position));
|
||||
}
|
||||
}
|
||||
|
||||
function _handleEvent(event) {
|
||||
switch (event.type) {
|
||||
case 'instruction.created':
|
||||
case 'instruction.updated':
|
||||
case 'instruction.consumed': {
|
||||
if (event.data?.item) {
|
||||
_applyInstructionPatch(event.data.item);
|
||||
} else {
|
||||
// Fallback: full refresh
|
||||
api.listInstructions('all').then(d => state.set('instructions', d.items)).catch(console.error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'instruction.deleted': {
|
||||
const id = event.data?.id;
|
||||
if (id) {
|
||||
const next = (state.get('instructions') || []).filter(i => i.id !== id);
|
||||
state.set('instructions', next);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'status.changed': {
|
||||
api.status().then(s => state.set('status', s)).catch(console.error);
|
||||
break;
|
||||
}
|
||||
case 'config.updated': {
|
||||
api.getConfig().then(c => state.set('config', c)).catch(console.error);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.debug('[SSE] unknown event type', event.type);
|
||||
}
|
||||
}
|
||||
|
||||
258
static/js/instructions.js
Normal file
258
static/js/instructions.js
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* static/js/instructions.js
|
||||
* Renders the pending and consumed instruction panels.
|
||||
* Handles add, edit, delete interactions.
|
||||
*/
|
||||
|
||||
import { state } from './state.js';
|
||||
import { api } from './api.js';
|
||||
import { toast } from './app.js';
|
||||
|
||||
// ── SVG icon helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function iconEdit() {
|
||||
return `<svg class="icon" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`;
|
||||
}
|
||||
|
||||
function iconDelete() {
|
||||
return `<svg class="icon" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`;
|
||||
}
|
||||
|
||||
function iconCheck() {
|
||||
return `<svg class="icon" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||||
}
|
||||
|
||||
function iconX() {
|
||||
return `<svg class="icon" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||||
}
|
||||
|
||||
// ── Time formatters ───────────────────────────────────────────────────────
|
||||
|
||||
function fmtRelativeTime(isoStr) {
|
||||
if (!isoStr) return '–';
|
||||
const d = new Date(isoStr);
|
||||
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (diff < 5) return 'just now';
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function fmtAbsTime(isoStr) {
|
||||
if (!isoStr) return '–';
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
/** Refresh all relative-time spans in the lists (called by app.js on a timer). */
|
||||
export function refreshTimestamps() {
|
||||
document.querySelectorAll('[data-ts]').forEach(el => {
|
||||
el.textContent = fmtRelativeTime(el.dataset.ts);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Card renderer ─────────────────────────────────────────────────────────
|
||||
|
||||
function renderPendingCard(item, index) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'instruction-card';
|
||||
card.dataset.id = item.id;
|
||||
card.style.animationDelay = `${index * 30}ms`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="instruction-card__meta">
|
||||
<span class="instruction-card__pos">#${item.position}</span>
|
||||
<span class="instruction-card__time" data-ts="${item.created_at}">${fmtRelativeTime(item.created_at)}</span>
|
||||
</div>
|
||||
<div class="instruction-card__content">${escapeHtml(item.content)}</div>
|
||||
<div class="instruction-card__actions">
|
||||
<button class="btn btn--ghost btn--icon btn--edit" title="Edit instruction" aria-label="Edit">${iconEdit()}</button>
|
||||
<button class="btn btn--danger btn--icon btn--delete" title="Delete instruction" aria-label="Delete">${iconDelete()}</button>
|
||||
</div>
|
||||
<div class="instruction-card__edit-area" style="display:none; grid-column:1/-1;">
|
||||
<textarea class="textarea edit-textarea" rows="3">${escapeHtml(item.content)}</textarea>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
<button class="btn btn--primary btn--sm btn--save" title="Save">${iconCheck()}</button>
|
||||
<button class="btn btn--ghost btn--sm btn--cancel" title="Cancel">${iconX()}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const editBtn = card.querySelector('.btn--edit');
|
||||
const deleteBtn = card.querySelector('.btn--delete');
|
||||
const cancelBtn = card.querySelector('.btn--cancel');
|
||||
const saveBtn = card.querySelector('.btn--save');
|
||||
const editArea = card.querySelector('.instruction-card__edit-area');
|
||||
const content = card.querySelector('.instruction-card__content');
|
||||
const actions = card.querySelector('.instruction-card__actions');
|
||||
const editTA = card.querySelector('.edit-textarea');
|
||||
|
||||
function showEdit() {
|
||||
editTA.value = item.content;
|
||||
editArea.style.display = 'flex';
|
||||
content.style.display = 'none';
|
||||
actions.style.display = 'none';
|
||||
editTA.focus();
|
||||
editTA.setSelectionRange(editTA.value.length, editTA.value.length);
|
||||
}
|
||||
|
||||
function hideEdit() {
|
||||
editArea.style.display = 'none';
|
||||
content.style.display = '';
|
||||
actions.style.display = '';
|
||||
}
|
||||
|
||||
editBtn.addEventListener('click', showEdit);
|
||||
cancelBtn.addEventListener('click', hideEdit);
|
||||
|
||||
editTA.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); hideEdit(); }
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); saveBtn.click(); }
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
const newContent = editTA.value.trim();
|
||||
if (!newContent) { toast('Content cannot be empty', 'error'); return; }
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
await api.updateInstruction(item.id, newContent);
|
||||
toast('Instruction updated', 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!confirm('Delete this instruction?')) return;
|
||||
deleteBtn.disabled = true;
|
||||
try {
|
||||
await api.deleteInstruction(item.id);
|
||||
toast('Instruction deleted', 'info');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
deleteBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderConsumedCard(item) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'instruction-card instruction-card--consumed';
|
||||
card.dataset.id = item.id;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="instruction-card__meta">
|
||||
<span class="instruction-card__pos">#${item.position}</span>
|
||||
<span class="instruction-card__time">${fmtAbsTime(item.consumed_at)}</span>
|
||||
${item.consumed_by_agent_id
|
||||
? `<span class="instruction-card__consumed-by">→ ${escapeHtml(item.consumed_by_agent_id)}</span>`
|
||||
: ''}
|
||||
</div>
|
||||
<div class="instruction-card__content">${escapeHtml(item.content)}</div>
|
||||
<div></div>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── List renderers ────────────────────────────────────────────────────────
|
||||
|
||||
export function initInstructions() {
|
||||
const pendingList = document.getElementById('pending-list');
|
||||
const pendingBadge = document.getElementById('pending-badge');
|
||||
const consumedList = document.getElementById('consumed-list');
|
||||
const consumedBadge = document.getElementById('consumed-badge');
|
||||
|
||||
function render(instructions) {
|
||||
if (!instructions) return;
|
||||
const pending = instructions.filter(i => i.status === 'pending');
|
||||
const consumed = instructions.filter(i => i.status === 'consumed').reverse();
|
||||
|
||||
// Pending
|
||||
pendingList.innerHTML = '';
|
||||
if (pending.length === 0) {
|
||||
pendingList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon">◈</div>
|
||||
Queue is empty – add an instruction above
|
||||
</div>`;
|
||||
} else {
|
||||
pending.forEach((item, i) => pendingList.appendChild(renderPendingCard(item, i)));
|
||||
}
|
||||
pendingBadge.textContent = pending.length;
|
||||
pendingBadge.className = `badge ${pending.length > 0 ? 'badge--cyan' : 'badge--muted'}`;
|
||||
|
||||
// Consumed
|
||||
consumedList.innerHTML = '';
|
||||
if (consumed.length === 0) {
|
||||
consumedList.innerHTML = `<div class="empty-state"><div class="empty-state__icon">◈</div>No consumed instructions yet</div>`;
|
||||
} else {
|
||||
consumed.forEach(item => consumedList.appendChild(renderConsumedCard(item)));
|
||||
}
|
||||
consumedBadge.textContent = consumed.length;
|
||||
consumedBadge.className = `badge ${consumed.length > 0 ? 'badge--amber' : 'badge--muted'}`;
|
||||
}
|
||||
|
||||
state.subscribe('instructions', render);
|
||||
}
|
||||
|
||||
// ── Composer ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function initComposer() {
|
||||
const form = document.getElementById('composer-form');
|
||||
const textarea = document.getElementById('composer-input');
|
||||
const btn = document.getElementById('composer-submit');
|
||||
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// Plain Enter → submit (like a chat box)
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
// Shift+Enter → default browser behaviour (newline)
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const content = textarea.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
btn.disabled = true;
|
||||
const original = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner"></span>';
|
||||
|
||||
try {
|
||||
await api.createInstruction(content);
|
||||
textarea.value = '';
|
||||
textarea.style.height = '';
|
||||
toast('Instruction queued', 'success');
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
textarea.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-resize textarea
|
||||
textarea.addEventListener('input', () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Utility ───────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
34
static/js/state.js
Normal file
34
static/js/state.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* static/js/state.js
|
||||
* Centralised reactive state store.
|
||||
* Components subscribe to state slices and are notified on change.
|
||||
*/
|
||||
|
||||
const _state = {
|
||||
instructions: [], // InstructionItem[]
|
||||
status: null, // StatusResponse | null
|
||||
config: null, // ConfigResponse | null
|
||||
serverOnline: false,
|
||||
};
|
||||
|
||||
const _listeners = {}; // key -> Set<fn>
|
||||
|
||||
export const state = {
|
||||
get(key) {
|
||||
return _state[key];
|
||||
},
|
||||
|
||||
set(key, value) {
|
||||
_state[key] = value;
|
||||
(_listeners[key] || new Set()).forEach(fn => fn(value));
|
||||
},
|
||||
|
||||
subscribe(key, fn) {
|
||||
if (!_listeners[key]) _listeners[key] = new Set();
|
||||
_listeners[key].add(fn);
|
||||
// Immediately call with current value
|
||||
fn(_state[key]);
|
||||
return () => _listeners[key].delete(fn); // unsubscribe
|
||||
},
|
||||
};
|
||||
|
||||
157
static/js/status.js
Normal file
157
static/js/status.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* static/js/status.js
|
||||
* Renders the server status and agent activity panels,
|
||||
* and the config settings panel.
|
||||
*/
|
||||
|
||||
import { state } from './state.js';
|
||||
import { api } from './api.js';
|
||||
import { toast } from './app.js';
|
||||
|
||||
// ── Time helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function fmtTime(isoStr) {
|
||||
if (!isoStr) return '–';
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtRelative(isoStr) {
|
||||
if (!isoStr) return '–';
|
||||
const d = new Date(isoStr);
|
||||
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (diff < 5) return 'just now';
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
return `${Math.floor(diff / 3600)}h ago`;
|
||||
}
|
||||
|
||||
// ── Server online indicator (header) ─────────────────────────────────────
|
||||
|
||||
function updateHeaderLeds(serverOnline, status) {
|
||||
const serverLed = document.getElementById('led-server');
|
||||
const agentLed = document.getElementById('led-agent');
|
||||
|
||||
if (!serverLed || !agentLed) return;
|
||||
|
||||
// Don't overwrite reconnecting state – events.js sets that
|
||||
if (serverOnline && !state.get('sseReconnecting')) {
|
||||
serverLed.className = 'led led--green led--pulse';
|
||||
serverLed.querySelector('.led__label').textContent = 'Server Online';
|
||||
} else if (!serverOnline) {
|
||||
serverLed.className = 'led led--red';
|
||||
serverLed.querySelector('.led__label').textContent = 'Server Offline';
|
||||
}
|
||||
|
||||
if (status?.agent?.connected) {
|
||||
agentLed.className = 'led led--cyan led--pulse';
|
||||
agentLed.querySelector('.led__label').textContent = 'Agent Connected';
|
||||
} else {
|
||||
agentLed.className = 'led led--muted';
|
||||
agentLed.querySelector('.led__label').textContent = 'Agent Idle';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status sidebar panel ──────────────────────────────────────────────────
|
||||
|
||||
function renderStatusPanel(status) {
|
||||
const el = document.getElementById('status-panel-body');
|
||||
if (!el || !status) return;
|
||||
|
||||
const agent = status.agent;
|
||||
const queue = status.queue;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Server Up</span>
|
||||
<span class="stat-value">${fmtTime(status.server?.started_at)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Pending</span>
|
||||
<span class="stat-value stat-value--cyan">${queue.pending_count}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Consumed</span>
|
||||
<span class="stat-value stat-value--amber">${queue.consumed_count}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Agent</span>
|
||||
<span class="stat-value">${agent.agent_id ? escapeHtml(agent.agent_id) : '–'}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Last Seen</span>
|
||||
<span class="stat-value" data-ts-rel="${agent.last_seen_at || ''}">${fmtRelative(agent.last_seen_at)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Last Fetch</span>
|
||||
<span class="stat-value" data-ts-rel="${agent.last_fetch_at || ''}">${fmtRelative(agent.last_fetch_at)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Called by app.js on a timer to keep relative times fresh. */
|
||||
export function refreshStatusTimestamps() {
|
||||
document.querySelectorAll('[data-ts-rel]').forEach(el => {
|
||||
const iso = el.dataset.tsRel;
|
||||
if (iso) el.textContent = fmtRelative(iso);
|
||||
});
|
||||
}
|
||||
|
||||
export function initStatus() {
|
||||
state.subscribe('serverOnline', (online) => {
|
||||
updateHeaderLeds(online, state.get('status'));
|
||||
});
|
||||
|
||||
state.subscribe('status', (status) => {
|
||||
updateHeaderLeds(state.get('serverOnline'), status);
|
||||
renderStatusPanel(status);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Config panel ──────────────────────────────────────────────────────────
|
||||
|
||||
export function initConfig() {
|
||||
const form = document.getElementById('config-form');
|
||||
const waitInput = document.getElementById('cfg-wait');
|
||||
const emptyInput = document.getElementById('cfg-empty');
|
||||
const staleInput = document.getElementById('cfg-stale');
|
||||
const saveBtn = document.getElementById('cfg-save');
|
||||
|
||||
// Populate from state
|
||||
state.subscribe('config', (cfg) => {
|
||||
if (!cfg) return;
|
||||
waitInput.value = cfg.default_wait_seconds;
|
||||
emptyInput.value = cfg.default_empty_response;
|
||||
staleInput.value = cfg.agent_stale_after_seconds;
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
saveBtn.disabled = true;
|
||||
const original = saveBtn.innerHTML;
|
||||
saveBtn.innerHTML = '<span class="spinner"></span>';
|
||||
|
||||
try {
|
||||
const cfg = await api.updateConfig({
|
||||
default_wait_seconds: parseInt(waitInput.value, 10) || 10,
|
||||
default_empty_response: emptyInput.value,
|
||||
agent_stale_after_seconds: parseInt(staleInput.value, 10) || 30,
|
||||
});
|
||||
state.set('config', cfg);
|
||||
toast('Settings saved', 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = original;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
67
static/js/theme.js
Normal file
67
static/js/theme.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* static/js/theme.js
|
||||
* Dark / light theme toggle.
|
||||
* - Defaults to the OS/browser colour-scheme preference.
|
||||
* - User override is persisted in localStorage.
|
||||
* - Applies via <html data-theme="dark|light">.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'local-mcp-theme';
|
||||
|
||||
function systemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function storedTheme() {
|
||||
return localStorage.getItem(STORAGE_KEY); // 'dark' | 'light' | null
|
||||
}
|
||||
|
||||
function iconSun() {
|
||||
return `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function iconMoon() {
|
||||
return `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (!btn) return;
|
||||
const isDark = theme === 'dark';
|
||||
btn.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode');
|
||||
btn.setAttribute('title', isDark ? 'Switch to light mode' : 'Switch to dark mode');
|
||||
btn.innerHTML = isDark ? iconMoon() : iconSun();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
const current = document.documentElement.dataset.theme || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
applyTheme(next);
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
// Apply immediately (before paint) based on stored or system preference
|
||||
const theme = storedTheme() || systemTheme();
|
||||
applyTheme(theme);
|
||||
|
||||
// Follow system changes only when the user hasn't manually overridden
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!storedTheme()) {
|
||||
applyTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('theme-toggle')?.addEventListener('click', toggle);
|
||||
}
|
||||
|
||||
115
tests/test_wakeup.py
Normal file
115
tests/test_wakeup.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
tests/test_wakeup.py
|
||||
Timing and correctness tests for the get_user_request wait loop.
|
||||
|
||||
Test 1 – Immediate wakeup:
|
||||
Verifies the asyncio.Event fires within ~10 ms of a new instruction being
|
||||
enqueued, even when min_wait_seconds has not elapsed yet.
|
||||
|
||||
Test 2 – Generation safety (concurrent calls):
|
||||
Simulates two overlapping calls for the same agent_id. The OLDER call must
|
||||
NOT consume the instruction; only the NEWER (active) call should receive it.
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
from app.database import init_db
|
||||
from app.services import instruction_service
|
||||
from app.services.config_service import update_config, get_config
|
||||
|
||||
WAKEUP_DELAY = 1.5
|
||||
MIN_WAIT = 8
|
||||
PASS_THRESH = 4.0
|
||||
|
||||
|
||||
def run():
|
||||
init_db("data/local_mcp.sqlite3")
|
||||
update_config(default_wait_seconds=MIN_WAIT)
|
||||
cfg = get_config()
|
||||
print(f"min_wait_seconds = {cfg.default_wait_seconds} (wakeup in {WAKEUP_DELAY}s)")
|
||||
print()
|
||||
|
||||
# ── Test 1: Immediate wakeup ───────────────────────────────────────────
|
||||
|
||||
async def _test1():
|
||||
await instruction_service.init_wakeup()
|
||||
t0 = time.monotonic()
|
||||
|
||||
def _add():
|
||||
time.sleep(WAKEUP_DELAY)
|
||||
item = instruction_service.create_instruction("Wakeup-timing-test")
|
||||
print(f"[T1 thread] instruction added t={time.monotonic()-t0:.2f}s")
|
||||
|
||||
threading.Thread(target=_add, daemon=True).start()
|
||||
|
||||
from app.mcp_server import get_user_request
|
||||
result = await get_user_request(agent_id="timing-test", wait_seconds=0)
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
print(f"[T1] Tool returned t={elapsed:.2f}s result_type={result['result_type']}")
|
||||
if elapsed < PASS_THRESH:
|
||||
print(f"[T1] PASS woke up at {elapsed:.2f}s (min_wait={MIN_WAIT}s)")
|
||||
else:
|
||||
print(f"[T1] FAIL took {elapsed:.2f}s — wakeup did not fire in time")
|
||||
sys.exit(1)
|
||||
|
||||
asyncio.run(_test1())
|
||||
print()
|
||||
|
||||
# ── Test 2: Generation safety ──────────────────────────────────────────
|
||||
# Call 1 (old) starts waiting. Before any instruction arrives, Call 2
|
||||
# (new) also starts. Then an instruction is added. Only Call 2 should
|
||||
# receive it; Call 1 should step aside and return empty.
|
||||
|
||||
async def _test2():
|
||||
await instruction_service.init_wakeup()
|
||||
t0 = time.monotonic()
|
||||
|
||||
from app.mcp_server import get_user_request, _agent_generations
|
||||
|
||||
results = {}
|
||||
|
||||
async def _call1():
|
||||
r = await get_user_request(agent_id="gen-test", wait_seconds=0)
|
||||
results["call1"] = r
|
||||
|
||||
async def _call2():
|
||||
# Slight delay so Call 1 starts first and registers gen=1
|
||||
await asyncio.sleep(0.2)
|
||||
r = await get_user_request(agent_id="gen-test", wait_seconds=0)
|
||||
results["call2"] = r
|
||||
|
||||
def _add():
|
||||
time.sleep(1.5)
|
||||
instruction_service.create_instruction("Generation-safety-test")
|
||||
print(f"[T2 thread] instruction added t={time.monotonic()-t0:.2f}s")
|
||||
|
||||
threading.Thread(target=_add, daemon=True).start()
|
||||
|
||||
await asyncio.gather(_call1(), _call2())
|
||||
|
||||
r1 = results.get("call1", {})
|
||||
r2 = results.get("call2", {})
|
||||
print(f"[T2] call1 result_type={r1.get('result_type')} waited={r1.get('waited_seconds')}s")
|
||||
print(f"[T2] call2 result_type={r2.get('result_type')} waited={r2.get('waited_seconds')}s")
|
||||
|
||||
if r2.get("result_type") == "instruction" and r1.get("result_type") != "instruction":
|
||||
print("[T2] PASS only the newest call received the instruction")
|
||||
else:
|
||||
print("[T2] FAIL unexpected result distribution")
|
||||
sys.exit(1)
|
||||
|
||||
asyncio.run(_test2())
|
||||
|
||||
# Reset config
|
||||
update_config(default_wait_seconds=10)
|
||||
print("\nAll tests passed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
|
||||
Reference in New Issue
Block a user