Compare commits
No commits in common. "main" and "feat/67-dispatch-task" have entirely different histories.
main
...
feat/67-di
44 changed files with 5 additions and 6313 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -9,10 +9,6 @@ dist/
|
|||
build/
|
||||
"<MagicMock*"
|
||||
|
||||
# packages/display (Vue/npm)
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
|
||||
# cf-orch private profiles (commit on personal/heimdall branch only)
|
||||
circuitforge_core/resources/profiles/private/
|
||||
.worktrees/
|
||||
|
|
|
|||
44
CHANGELOG.md
44
CHANGELOG.md
|
|
@ -10,50 +10,6 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||
|
||||
### Added
|
||||
|
||||
**`circuitforge_core.signal_bus`** — generic SSE event publisher for real-time signal streams (MIT, closes #58)
|
||||
|
||||
- `SignalBus` — publish/subscribe bus for real-time events over Server-Sent Events. One instance per service; `publish()` is thread-safe (safe to call from sync producer threads such as OpenCV, Meshtastic, or PyPubSub callbacks) via `loop.call_soon_threadsafe()`. `subscribe(request)` returns a FastAPI `StreamingResponse` — each subscriber gets an independent bounded `asyncio.Queue` (default 100 events) with oldest-drop-on-overflow, plus a 15s keepalive comment to keep proxies from closing idle connections.
|
||||
- `SignalEvent` — frozen dataclass (`source`, `kind`, `payload`, auto-populated ISO-8601 UTC `timestamp`) with `to_sse()` wire-format serialization.
|
||||
- New `signal-bus` extra (`fastapi>=0.110`).
|
||||
|
||||
**`circuitforge_core.video.app`** — `POST /caption/upload` endpoint
|
||||
|
||||
Accepts a multipart video file upload, writes it to a temp file, captions it via the configured backend, then deletes the temp file. Lets callers without filesystem access to the video-service node (e.g. a product on one host posting to cf-video on another over an SSH tunnel) caption a video without a shared mount. `video-service` extra now also pulls in `python-multipart`, required by FastAPI's `UploadFile` parsing.
|
||||
|
||||
### Docs
|
||||
|
||||
- `mkdocs.yml` — new blue-grey/cyan palette, wired to a central `docs/stylesheets/theme.css` for consistent theme-aware styling across the site (light and dark mode).
|
||||
|
||||
**`circuitforge_core.hardware.model_vram_estimate`** — model-to-hardware VRAM fit check (closes #64)
|
||||
|
||||
Answers "can this hardware run model X at quantization level Y?" — the missing capability noted against `cf_core.hardware`, which detects available VRAM but had no way to cross-reference model requirements. Queries the HuggingFace Hub API for parameter count (`safetensors.total`) and architecture (`config.json`: `num_hidden_layers`, `hidden_size`, `num_attention_heads`, `num_key_value_heads`), then applies the standard formula: `vram_gb = params * bytes_per_param(quant) + kv_cache_gb(ctx_len, arch) + overhead_gb`. Reference algorithm from [LLMcalc](https://github.com/Raskoll2/LLMcalc) (unlicensed upstream — algorithm reference only, no code copied, no dependency added).
|
||||
|
||||
- `model_vram_estimate(hf_model_id, quant_level, *, ctx_len=4096, available_vram_mb=None, overhead_gb=0.6, timeout=10.0) -> VramEstimate`
|
||||
- Supports common quant levels: `fp32`, `fp16`/`bf16`, `int8`/`q8`/`q8_0`, `q6_k`, `q5_k_m`/`q5_0`, `int4`/`q4`/`q4_k_m`/`q4_0`, `q3_k_m`, `q2_k`.
|
||||
- KV cache sizing accounts for GQA (`num_key_value_heads`); falls back to 0 GB when the model's `config.json` lacks standard architecture fields, rather than failing the whole estimate — weights dominate VRAM use regardless.
|
||||
- Raises `ModelVramLookupError` on HF Hub API failures or missing safetensors metadata; raises `ValueError` for unrecognized quant levels.
|
||||
- Application points noted in the ticket: Avocet preflight (verify a checkpoint fits before benchmarking), cf-orch worker assignment (match model to GPU by VRAM fit), Peregrine/Kiwi onboarding wizard ("your GPU has X GB — here are models that will run well").
|
||||
|
||||
**`circuitforge_core.retry`** — standard retry/backoff wrapper over `backon` (closes #65)
|
||||
|
||||
Standardizes retry/backoff behavior for cf-core modules and products that make external calls, replacing ad-hoc per-product retry loops. Wraps [backon](https://github.com/Llucs/backon) (MIT, zero stdlib dependencies) rather than reimplementing retry logic — chosen over `tenacity`/`backoff` per the eval in #65 for its native async support, built-in circuit breaker/hedging primitives, and a process-wide enable/disable toggle that's ideal for tests.
|
||||
|
||||
- `on_exception(exception, *, max_tries=3, max_time=30.0, **backon_kwargs)` — decorator with CF's standard defaults (full-jitter exponential backoff); extra kwargs forward to `backon.on_exception` (e.g. `sleep=` for tests, `on_backoff=` for logging hooks).
|
||||
- `retry(target, *args, exception=Exception, max_tries=3, max_time=30.0, **kwargs)` — call an already-defined callable with the same retry behavior, for use without decorator syntax.
|
||||
- `disable_retries()` / `enable_retries()` — re-exported `backon` context managers for scoping retry-disable to a test or block.
|
||||
- `disable_retries_globally()` / `enable_retries_globally()` — re-exported `backon.disable()`/`enable()` process-wide toggles, for a test-suite-level fixture.
|
||||
- New core dependency: `backon>=4.0`.
|
||||
- Scope note: only the standalone wrapper module ships in this PR. Wiring it into `llm`, `affiliates`, `reranker`, and `activitypub` (the call sites identified in #65) is left as follow-up work per module, rather than retrofitting `LLMRouter`'s existing fallback-chain error handling in the same change.
|
||||
|
||||
**`circuitforge_core.task_bridge`** — shared data contract for pushing tasks into an external scheduler (closes #66)
|
||||
|
||||
Kiwi is the pilot consumer, pushing pantry expiry-alert tasks into Ashley Venn's Focus Flow scheduler (AGPL-3.0, external project). To keep AGPL and BSL code from ever sharing a process or artifact, the task data contract lives here in cf-core (MIT); each product implements its own exporter/receiver. Design spec: `circuitforge-plans/shared/superpowers/specs/2026-07-05-focus-flow-task-bridge-design.md`.
|
||||
|
||||
- `models.py` — `ExternalTask` frozen dataclass: `schema_version`, `source_product`, `external_id`, `title`, `notes`, `due_at` (ISO 8601 UTC), `kind` (always `"flexible"` in v1 — external sources can never inject urgency into another product's UX), `status` (`"active"` / `"cancelled"`). Validates required fields and `kind`/`status` values in `__post_init__`.
|
||||
- `client.py` — `push_tasks(endpoint, token, tasks)`: thin `httpx` wrapper POSTing a batch as `{"tasks": [...]}` with a bearer token. Raises `TaskBridgeError` on transport failure or non-2xx response. No transport server, no auth/token generation — that lives on the receiving side.
|
||||
- New `task-bridge` extra (`httpx>=0.27`).
|
||||
- 19 tests: schema/validation/serialization (no network) plus a contract test against a real local `http.server` instance standing in for Focus Flow's importer, verifying the client emits a conformant payload.
|
||||
|
||||
**`circuitforge_core.tasks.dispatch_task` / `get_task_status`** — generic caller/args task dispatch (closes #67)
|
||||
|
||||
Pagepiper (and potentially other products copying its pattern) imported `dispatch_task(caller, args) -> task_id` / `get_task_status(task_id) -> dict` from `circuitforge_core.tasks`, expecting a `"product/task_name"` + kwargs-dict interface — neither function existed anywhere, so every call silently hit an `except Exception` fallback to local `BackgroundTasks`, with no visible error. This is a different API shape from the existing VRAM-budgeted `TaskScheduler` (keyed by `task_id`/`job_id`/`params` against a specific SQLite `background_tasks` table), so it's a new module rather than a `TaskScheduler` wrapper.
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -42,10 +42,8 @@ pip install circuitforge-core[tts-chatterbox] # Text-to-speech via Chatter
|
|||
pip install circuitforge-core[reranker-qwen3] # Reranking via Qwen3
|
||||
pip install circuitforge-core[video-service] # Video captioning service (Marlin-2B)
|
||||
pip install circuitforge-core[mqtt] # MQTT broker client
|
||||
pip install circuitforge-core[signal-bus] # SSE event publisher for real-time signal streams
|
||||
pip install circuitforge-core[meshtastic-service] # Meshtastic mesh radio + MQTT + FastAPI
|
||||
pip install circuitforge-core[memory] # Knowledge graph via mnemo sidecar
|
||||
pip install circuitforge-core[task-bridge] # Push tasks into an external scheduler (e.g. Focus Flow)
|
||||
pip install circuitforge-core[community] # PostgreSQL-backed community store
|
||||
pip install circuitforge-core[manage] # cf-manage CLI (Typer)
|
||||
pip install circuitforge-core[dev] # All dev dependencies
|
||||
|
|
@ -84,30 +82,12 @@ pip install circuitforge-core[dev] # All dev dependencies
|
|||
| `cloud_session` | Implemented | Cloud session management primitives |
|
||||
| `input` | Implemented | Input handling — MediaPipe gesture recognition |
|
||||
| `job_quality` | Implemented | Job listing quality scoring and signal extraction |
|
||||
| `signal_bus` | Implemented | Generic SSE event publisher for real-time signal streams |
|
||||
| `retry` | Implemented | Standard retry/backoff wrapper over `backon` for external-call modules |
|
||||
| `task_bridge` | Implemented | Shared data contract + push client for external task schedulers (e.g. Focus Flow) |
|
||||
| `vision` | Stub | Vision router (moondream2 / SigLIP dispatch — planned) |
|
||||
| `wizard` | Stub | First-run wizard base class — products subclass `BaseWizard` |
|
||||
| `pipeline` | Stub | Staging queue base — products provide concrete schema |
|
||||
|
||||
---
|
||||
|
||||
## Vue package: `@circuitforge/display`
|
||||
|
||||
Strip-display Vue 3 primitives (`packages/display/`) for products running a secondary 1920×480 landscape / 480×1920 portrait kiosk display (Turnstone, Robin). Published as a **separate npm package**, not part of this Python distribution, so products that don't use it never pull in Vue as a dependency.
|
||||
|
||||
```bash
|
||||
cd packages/display
|
||||
npm install
|
||||
npm test # 37 tests — DisplayLayout, DisplayMetric, DisplayAlert, DisplayMacroButton
|
||||
npm run build
|
||||
```
|
||||
|
||||
See `packages/display/README.md` for the component API and theming.
|
||||
|
||||
---
|
||||
|
||||
## Usage: LLM Router
|
||||
|
||||
The LLM router reads a config file at `~/.config/circuitforge/llm.yaml`, tries each backend in fallback order, and skips unreachable or disabled entries transparently.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ from .detect import detect_hardware, detect_hardware_json
|
|||
from .generator import generate_profile
|
||||
from .models import HardwareSpec, LLMBackendConfig, LLMConfig
|
||||
from .tiers import VRAM_TIERS, VramTier, select_tier
|
||||
from .vram_estimate import ModelVramLookupError, VramEstimate, model_vram_estimate
|
||||
|
||||
__all__ = [
|
||||
"detect_hardware",
|
||||
|
|
@ -28,7 +27,4 @@ __all__ = [
|
|||
"VRAM_TIERS",
|
||||
"VramTier",
|
||||
"select_tier",
|
||||
"ModelVramLookupError",
|
||||
"VramEstimate",
|
||||
"model_vram_estimate",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,185 +0,0 @@
|
|||
# circuitforge_core/hardware/vram_estimate.py
|
||||
"""
|
||||
Model VRAM fit estimation — cf-core #64.
|
||||
|
||||
`cf_core.hardware` can detect available VRAM but has no way to answer "can this
|
||||
hardware run model X at quantization level Y?". This module closes that gap by
|
||||
querying the HuggingFace Hub API for parameter count and architecture, then
|
||||
applying the standard VRAM estimation formula:
|
||||
|
||||
vram_gb = params * bytes_per_param(quant) + kv_cache_gb(ctx_len, arch) + overhead_gb
|
||||
|
||||
The formula is the reference algorithm used by LLMcalc
|
||||
(https://github.com/Raskoll2/LLMcalc, no license — algorithm reference only,
|
||||
not a dependency). No LLMcalc code is copied here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
|
||||
_HF_API_MODEL_URL = "https://huggingface.co/api/models/{model_id}"
|
||||
_HF_CONFIG_URL = "https://huggingface.co/{model_id}/resolve/main/config.json"
|
||||
|
||||
_DEFAULT_OVERHEAD_GB = 0.6 # CUDA context + activation buffers, rough constant
|
||||
_KV_CACHE_DTYPE_BYTES = 2 # KV cache is stored fp16 in the common case
|
||||
|
||||
# Bits per parameter for common quantization levels (weights-only, excludes KV cache).
|
||||
_QUANT_BITS: dict[str, float] = {
|
||||
"fp32": 32.0,
|
||||
"fp16": 16.0,
|
||||
"bf16": 16.0,
|
||||
"int8": 8.0,
|
||||
"q8": 8.0,
|
||||
"q8_0": 8.0,
|
||||
"q6_k": 6.0,
|
||||
"q5_k_m": 5.0,
|
||||
"q5_0": 5.0,
|
||||
"int4": 4.0,
|
||||
"q4": 4.0,
|
||||
"q4_k_m": 4.0,
|
||||
"q4_0": 4.0,
|
||||
"q3_k_m": 3.0,
|
||||
"q2_k": 2.0,
|
||||
}
|
||||
|
||||
|
||||
class ModelVramLookupError(RuntimeError):
|
||||
"""Raised when the HuggingFace Hub API can't supply data needed to estimate VRAM."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VramEstimate:
|
||||
"""Result of a `model_vram_estimate()` call."""
|
||||
|
||||
hf_model_id: str
|
||||
quant_level: str
|
||||
params_billions: float
|
||||
weights_gb: float
|
||||
kv_cache_gb: float
|
||||
overhead_gb: float
|
||||
total_vram_gb: float
|
||||
fits: bool | None # None when available_vram_mb wasn't supplied
|
||||
|
||||
|
||||
def _quant_bits(quant_level: str) -> float:
|
||||
key = quant_level.strip().lower()
|
||||
try:
|
||||
return _QUANT_BITS[key]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"Unknown quant_level {quant_level!r}. Known levels: {sorted(_QUANT_BITS)}"
|
||||
) from None
|
||||
|
||||
|
||||
def _fetch_param_count(hf_model_id: str, *, timeout: float) -> float:
|
||||
"""Return total parameter count via the HF Hub API's safetensors metadata."""
|
||||
url = _HF_API_MODEL_URL.format(model_id=hf_model_id)
|
||||
try:
|
||||
resp = requests.get(url, params={"expand": ["safetensors"]}, timeout=timeout)
|
||||
except requests.RequestException as exc:
|
||||
raise ModelVramLookupError(
|
||||
f"HF Hub API request failed for {hf_model_id!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise ModelVramLookupError(
|
||||
f"HF Hub API returned {resp.status_code} for {hf_model_id!r}"
|
||||
)
|
||||
|
||||
data = resp.json()
|
||||
total = (data.get("safetensors") or {}).get("total")
|
||||
if not total:
|
||||
raise ModelVramLookupError(
|
||||
f"{hf_model_id!r} has no safetensors parameter metadata on the HF Hub "
|
||||
"(model may not publish safetensors weights)"
|
||||
)
|
||||
return float(total)
|
||||
|
||||
|
||||
def _fetch_arch_config(hf_model_id: str, *, timeout: float) -> dict:
|
||||
"""Return the model's config.json (architecture fields used for KV cache sizing)."""
|
||||
url = _HF_CONFIG_URL.format(model_id=hf_model_id)
|
||||
try:
|
||||
resp = requests.get(url, timeout=timeout)
|
||||
except requests.RequestException as exc:
|
||||
raise ModelVramLookupError(
|
||||
f"config.json fetch failed for {hf_model_id!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise ModelVramLookupError(
|
||||
f"config.json unavailable for {hf_model_id!r} (HTTP {resp.status_code})"
|
||||
)
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _kv_cache_gb(config: dict, ctx_len: int) -> float:
|
||||
"""Estimate KV cache size in GB from architecture fields, 0.0 if unavailable."""
|
||||
num_layers = config.get("num_hidden_layers")
|
||||
hidden_size = config.get("hidden_size")
|
||||
num_heads = config.get("num_attention_heads")
|
||||
num_kv_heads = config.get("num_key_value_heads", num_heads)
|
||||
|
||||
if not (num_layers and hidden_size and num_heads):
|
||||
# Non-standard config (missing architecture fields) — skip the KV
|
||||
# estimate rather than fail the whole call. Weights dominate VRAM use.
|
||||
return 0.0
|
||||
|
||||
head_dim = hidden_size / num_heads
|
||||
bytes_total = 2 * num_layers * num_kv_heads * head_dim * ctx_len * _KV_CACHE_DTYPE_BYTES
|
||||
return bytes_total / 1e9
|
||||
|
||||
|
||||
def model_vram_estimate(
|
||||
hf_model_id: str,
|
||||
quant_level: str,
|
||||
*,
|
||||
ctx_len: int = 4096,
|
||||
available_vram_mb: int | None = None,
|
||||
overhead_gb: float = _DEFAULT_OVERHEAD_GB,
|
||||
timeout: float = 10.0,
|
||||
) -> VramEstimate:
|
||||
"""
|
||||
Estimate VRAM required to run `hf_model_id` at `quant_level`.
|
||||
|
||||
Args:
|
||||
hf_model_id: HuggingFace model repo ID, e.g. "Qwen/Qwen2.5-7B-Instruct".
|
||||
quant_level: One of the known quant levels (see `_QUANT_BITS`), e.g. "q4_k_m".
|
||||
ctx_len: Context length used for KV cache sizing.
|
||||
available_vram_mb: If given, populates `VramEstimate.fits`.
|
||||
overhead_gb: Fixed overhead for CUDA context / activation buffers.
|
||||
timeout: Per-request timeout in seconds for HF Hub API calls.
|
||||
|
||||
Raises:
|
||||
ModelVramLookupError: HF Hub API request failed or returned unusable data.
|
||||
ValueError: `quant_level` isn't a recognized quantization level.
|
||||
"""
|
||||
bits = _quant_bits(quant_level)
|
||||
params = _fetch_param_count(hf_model_id, timeout=timeout)
|
||||
weights_gb = (params * bits / 8) / 1e9
|
||||
|
||||
try:
|
||||
config = _fetch_arch_config(hf_model_id, timeout=timeout)
|
||||
kv_gb = _kv_cache_gb(config, ctx_len)
|
||||
except ModelVramLookupError:
|
||||
# Architecture lookup is best-effort — weights_gb alone is still useful.
|
||||
kv_gb = 0.0
|
||||
|
||||
total_gb = weights_gb + kv_gb + overhead_gb
|
||||
|
||||
fits = None
|
||||
if available_vram_mb is not None:
|
||||
fits = total_gb <= (available_vram_mb / 1024)
|
||||
|
||||
return VramEstimate(
|
||||
hf_model_id=hf_model_id,
|
||||
quant_level=quant_level,
|
||||
params_billions=params / 1e9,
|
||||
weights_gb=weights_gb,
|
||||
kv_cache_gb=kv_gb,
|
||||
overhead_gb=overhead_gb,
|
||||
total_vram_gb=total_gb,
|
||||
fits=fits,
|
||||
)
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
# circuitforge_core/retry.py
|
||||
"""
|
||||
circuitforge_core.retry — thin standard-retry wrapper over backon (cf-core #65).
|
||||
|
||||
Standardizes retry/backoff behavior across cf-core modules and products that
|
||||
make external calls (LLM endpoints, affiliate links, reranker APIs, federated
|
||||
ActivityPub delivery). Wraps `backon` (MIT, zero stdlib dependencies) rather
|
||||
than reimplementing retry logic, and rather than each module hand-rolling its
|
||||
own ad-hoc retry loop.
|
||||
|
||||
Typical usage::
|
||||
|
||||
from circuitforge_core.retry import on_exception
|
||||
import requests
|
||||
|
||||
@on_exception(requests.RequestException, max_tries=3)
|
||||
def fetch(url):
|
||||
return requests.get(url, timeout=5)
|
||||
|
||||
Tests should disable retries globally rather than waiting out real backoff
|
||||
delays::
|
||||
|
||||
from circuitforge_core.retry import disable_retries
|
||||
|
||||
def test_fetch_raises_immediately_on_failure():
|
||||
with disable_retries():
|
||||
with pytest.raises(requests.RequestException):
|
||||
fetch("http://unreachable")
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TypeVar
|
||||
|
||||
import backon
|
||||
from backon import disable as disable_retries_globally
|
||||
from backon import disable_retries, enable_retries
|
||||
from backon import enable as enable_retries_globally
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
__all__ = [
|
||||
"on_exception",
|
||||
"retry",
|
||||
"disable_retries",
|
||||
"enable_retries",
|
||||
"disable_retries_globally",
|
||||
"enable_retries_globally",
|
||||
]
|
||||
|
||||
_DEFAULT_MAX_TRIES = 3
|
||||
_DEFAULT_MAX_TIME = 30.0
|
||||
|
||||
|
||||
def on_exception(
|
||||
exception: type[Exception] | tuple[type[Exception], ...] = Exception,
|
||||
*,
|
||||
max_tries: int = _DEFAULT_MAX_TRIES,
|
||||
max_time: float | None = _DEFAULT_MAX_TIME,
|
||||
**backon_kwargs,
|
||||
) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
||||
"""
|
||||
Decorator: retry a function on `exception` using full-jitter exponential
|
||||
backoff — CF's standard defaults for external API calls.
|
||||
|
||||
Extra keyword arguments are forwarded to `backon.on_exception` (e.g.
|
||||
`on_backoff=` for a logging callback, `sleep=` to override the sleep
|
||||
function in tests).
|
||||
"""
|
||||
return backon.on_exception(
|
||||
backon.expo(),
|
||||
exception,
|
||||
max_tries=max_tries,
|
||||
max_time=max_time,
|
||||
logger="circuitforge_core.retry",
|
||||
**backon_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def retry(
|
||||
target: Callable[..., T],
|
||||
*args,
|
||||
exception: type[Exception] | tuple[type[Exception], ...] = Exception,
|
||||
max_tries: int = _DEFAULT_MAX_TRIES,
|
||||
max_time: float | None = _DEFAULT_MAX_TIME,
|
||||
**kwargs,
|
||||
) -> T:
|
||||
"""
|
||||
Call `target(*args, **kwargs)` with CF's standard retry/backoff behavior,
|
||||
for wrapping an already-defined callable without decorator syntax.
|
||||
"""
|
||||
wrapped = on_exception(exception, max_tries=max_tries, max_time=max_time)(target)
|
||||
return wrapped(*args, **kwargs)
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
"""cf-core signal_bus — generic SSE event publisher for real-time signal streams.
|
||||
|
||||
MIT licensed. Ticket: cf-core #58.
|
||||
|
||||
## Quick start
|
||||
|
||||
from circuitforge_core.signal_bus import SignalBus, SignalEvent
|
||||
from fastapi import Request
|
||||
|
||||
bus = SignalBus()
|
||||
|
||||
# Producer — safe to call from sync threads (OpenCV, Meshtastic, PyPubSub)
|
||||
bus.publish(SignalEvent(
|
||||
source="merlin",
|
||||
kind="gesture",
|
||||
payload={"name": "open_palm", "confidence": 0.94},
|
||||
))
|
||||
|
||||
# Consumer — SSE endpoint
|
||||
@app.get("/events")
|
||||
async def events(request: Request):
|
||||
return bus.subscribe(request)
|
||||
|
||||
## Notes
|
||||
|
||||
- Each subscriber gets an independent bounded asyncio.Queue (default: 100 events).
|
||||
- When full, the oldest event is dropped to make room for the newest.
|
||||
- publish() is thread-safe via loop.call_soon_threadsafe().
|
||||
- Keepalive comment sent every 15 s to keep proxies from closing idle connections.
|
||||
- Install: no extra dependencies beyond fastapi (already a core dep for service products).
|
||||
"""
|
||||
|
||||
from circuitforge_core.signal_bus.bus import SignalBus
|
||||
from circuitforge_core.signal_bus.models import SignalEvent
|
||||
|
||||
__all__ = ["SignalBus", "SignalEvent"]
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
"""SignalBus — generic SSE event publisher for real-time signal streams.
|
||||
|
||||
MIT licensed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from typing import AsyncIterator
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from circuitforge_core.signal_bus.models import SignalEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_KEEPALIVE = 15.0 # seconds between SSE keepalive comments
|
||||
|
||||
|
||||
def _enqueue_drop_oldest(q: asyncio.Queue, item: str) -> None:
|
||||
"""Put item on queue; if full, drop the oldest entry to make room.
|
||||
|
||||
Designed to run on the event loop thread via call_soon_threadsafe.
|
||||
"""
|
||||
if q.full():
|
||||
try:
|
||||
q.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(item)
|
||||
except asyncio.QueueFull:
|
||||
pass # another thread won the race; drop the new item
|
||||
|
||||
|
||||
class SignalBus:
|
||||
"""Publish-subscribe bus for real-time signal events over SSE.
|
||||
|
||||
One bus instance per service. Producers call publish() (sync-safe);
|
||||
consumers mount subscribe() as a FastAPI endpoint.
|
||||
|
||||
Args:
|
||||
queue_size: Max buffered events per subscriber before oldest is dropped.
|
||||
"""
|
||||
|
||||
def __init__(self, queue_size: int = 100, keepalive_interval: float = _DEFAULT_KEEPALIVE) -> None:
|
||||
self._queue_size = queue_size
|
||||
self._keepalive_interval = keepalive_interval
|
||||
# int(id(queue)) → queue; guarded by _lock for publish() from sync threads
|
||||
self._subscribers: dict[int, asyncio.Queue] = {}
|
||||
self._lock = threading.Lock()
|
||||
# Captured on first subscribe() call; set from the FastAPI event loop
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Producer API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def publish(self, event: SignalEvent) -> None:
|
||||
"""Publish an event to all active subscribers.
|
||||
|
||||
Thread-safe — may be called from sync threads (OpenCV, Meshtastic,
|
||||
PyPubSub callbacks, etc.). No-op when no subscribers are connected.
|
||||
"""
|
||||
loop = self._loop
|
||||
if loop is None or not loop.is_running():
|
||||
return
|
||||
|
||||
sse = event.to_sse()
|
||||
with self._lock:
|
||||
subs = list(self._subscribers.values())
|
||||
|
||||
for q in subs:
|
||||
loop.call_soon_threadsafe(_enqueue_drop_oldest, q, sse)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Consumer API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def subscribe(self, request: Request) -> StreamingResponse:
|
||||
"""Return an SSE StreamingResponse for a FastAPI endpoint.
|
||||
|
||||
Each caller gets an independent queue. The subscriber is removed
|
||||
automatically when the client disconnects.
|
||||
|
||||
Usage::
|
||||
|
||||
@app.get("/events")
|
||||
async def events(request: Request):
|
||||
return bus.subscribe(request)
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
self._loop = loop
|
||||
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=self._queue_size)
|
||||
sub_id = id(q)
|
||||
with self._lock:
|
||||
self._subscribers[sub_id] = q
|
||||
|
||||
logger.debug("signal_bus: subscriber %d connected (%d total)", sub_id, len(self._subscribers))
|
||||
|
||||
async def _generate() -> AsyncIterator[str]:
|
||||
# Starlette calls aclose() on this generator when the client disconnects,
|
||||
# injecting GeneratorExit. Polling request.is_disconnected() is unreliable
|
||||
# in ASGI test transports and adds latency in production.
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(q.get(), timeout=self._keepalive_interval)
|
||||
yield chunk
|
||||
except asyncio.TimeoutError:
|
||||
yield ": keepalive\n\n"
|
||||
finally:
|
||||
with self._lock:
|
||||
self._subscribers.pop(sub_id, None)
|
||||
logger.debug(
|
||||
"signal_bus: subscriber %d disconnected (%d remaining)",
|
||||
sub_id,
|
||||
len(self._subscribers),
|
||||
)
|
||||
|
||||
return StreamingResponse(_generate(), media_type="text/event-stream")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Introspection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def subscriber_count(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._subscribers)
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
"""Data models for the cf-core signal_bus module.
|
||||
|
||||
MIT licensed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def _utcnow() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SignalEvent:
|
||||
"""An event published to the signal bus.
|
||||
|
||||
Args:
|
||||
source: Originating service, e.g. "merlin", "linnet".
|
||||
kind: Event type, e.g. "gesture", "tone", "alpha_rising".
|
||||
payload: Arbitrary dict — contents are opaque to the bus.
|
||||
timestamp: ISO-8601 UTC. Auto-populated if not supplied.
|
||||
"""
|
||||
|
||||
source: str
|
||||
kind: str
|
||||
payload: dict
|
||||
timestamp: str = field(default_factory=_utcnow)
|
||||
|
||||
def to_sse(self) -> str:
|
||||
"""Serialize to SSE wire format: ``data: {...}\\n\\n``."""
|
||||
data = json.dumps(
|
||||
{
|
||||
"source": self.source,
|
||||
"kind": self.kind,
|
||||
"payload": self.payload,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
)
|
||||
return f"data: {data}\n\n"
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
# circuitforge_core/task_bridge/__init__.py
|
||||
"""
|
||||
task_bridge — shared MIT data contract for pushing tasks from a CF product
|
||||
into an external scheduler (pilot consumer: Kiwi -> Focus Flow, AGPL-3.0).
|
||||
|
||||
Pure data contract + reusable push helper. No transport server, no auth/token
|
||||
generation logic (that lives on the receiving side), no product-specific
|
||||
behavior — keeps AGPL and BSL code from ever sharing a process or artifact.
|
||||
|
||||
Typical usage::
|
||||
|
||||
from circuitforge_core.task_bridge import ExternalTask, push_tasks
|
||||
|
||||
task = ExternalTask(
|
||||
source_product="kiwi",
|
||||
external_id="kiwi:item:1234",
|
||||
title="Use up milk",
|
||||
due_at="2026-07-07T00:00:00Z",
|
||||
notes="Opened 2026-07-01",
|
||||
)
|
||||
push_tasks("http://127.0.0.1:8512/import/tasks", token, [task])
|
||||
|
||||
Design spec: circuitforge-plans/shared/superpowers/specs/2026-07-05-focus-flow-task-bridge-design.md
|
||||
"""
|
||||
from .client import TaskBridgeError, push_tasks
|
||||
from .models import SCHEMA_VERSION, ExternalTask
|
||||
|
||||
__all__ = [
|
||||
"ExternalTask",
|
||||
"SCHEMA_VERSION",
|
||||
"TaskBridgeError",
|
||||
"push_tasks",
|
||||
]
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
"""Push client for the cf-core task_bridge module.
|
||||
|
||||
MIT licensed. Thin httpx wrapper any CF product reuses to push a batch of
|
||||
ExternalTask records to a configured local HTTP endpoint. No transport
|
||||
server, no auth/token generation logic — that lives on the receiving side
|
||||
(e.g. Focus Flow's importer). See:
|
||||
circuitforge-plans/shared/superpowers/specs/2026-07-05-focus-flow-task-bridge-design.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import httpx
|
||||
|
||||
from circuitforge_core.task_bridge.models import ExternalTask
|
||||
|
||||
|
||||
class TaskBridgeError(RuntimeError):
|
||||
"""Raised when pushing tasks to the configured endpoint fails."""
|
||||
|
||||
|
||||
def push_tasks(
|
||||
endpoint: str,
|
||||
token: str,
|
||||
tasks: Sequence[ExternalTask],
|
||||
*,
|
||||
timeout: float = 10.0,
|
||||
) -> httpx.Response:
|
||||
"""
|
||||
POST a batch of ExternalTask records to `endpoint` as `{"tasks": [...]}`,
|
||||
authenticated with a bearer token.
|
||||
|
||||
Raises:
|
||||
TaskBridgeError: the request failed to send, or the endpoint returned
|
||||
a non-2xx status.
|
||||
"""
|
||||
payload = {"tasks": [t.to_dict() for t in tasks]}
|
||||
try:
|
||||
resp = httpx.post(
|
||||
endpoint,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=timeout,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise TaskBridgeError(f"task_bridge push to {endpoint!r} failed: {exc}") from exc
|
||||
|
||||
if resp.status_code >= 300:
|
||||
raise TaskBridgeError(
|
||||
f"task_bridge push to {endpoint!r} returned {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
return resp
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
"""Data models for the cf-core task_bridge module.
|
||||
|
||||
MIT licensed. Ticket: cf-core #66. Design spec:
|
||||
circuitforge-plans/shared/superpowers/specs/2026-07-05-focus-flow-task-bridge-design.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# v1 external tasks are always "flexible" — Focus Flow's calm task kind.
|
||||
# External sources never set "inflexible" / "critical" / "locked" / "surprise",
|
||||
# so no CF product can inject urgency into another product's UX.
|
||||
TaskKind = Literal["flexible"]
|
||||
TaskStatus = Literal["active", "cancelled"]
|
||||
|
||||
_VALID_STATUSES: frozenset[str] = frozenset({"active", "cancelled"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExternalTask:
|
||||
"""
|
||||
A task pushed from a CF product into an external scheduler (e.g. Focus Flow).
|
||||
|
||||
Pure data contract — no transport, no auth, no product-specific behavior.
|
||||
`external_id` must be stable and idempotent per source item (e.g.
|
||||
"kiwi:item:1234") so repeated pushes upsert rather than duplicate.
|
||||
"""
|
||||
|
||||
source_product: str
|
||||
external_id: str
|
||||
title: str
|
||||
due_at: str # ISO 8601 UTC, e.g. "2026-07-07T00:00:00Z"
|
||||
notes: str | None = None
|
||||
kind: TaskKind = "flexible"
|
||||
status: TaskStatus = "active"
|
||||
schema_version: int = SCHEMA_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.source_product:
|
||||
raise ValueError("source_product must not be empty")
|
||||
if not self.external_id:
|
||||
raise ValueError("external_id must not be empty")
|
||||
if not self.title:
|
||||
raise ValueError("title must not be empty")
|
||||
if self.kind != "flexible":
|
||||
raise ValueError(
|
||||
f"kind must be 'flexible' in schema v{SCHEMA_VERSION}, got {self.kind!r}"
|
||||
)
|
||||
if self.status not in _VALID_STATUSES:
|
||||
raise ValueError(
|
||||
f"status must be one of {sorted(_VALID_STATUSES)}, got {self.status!r}"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize to the wire format expected by an importer's HTTP API."""
|
||||
return asdict(self)
|
||||
|
|
@ -32,10 +32,7 @@ import logging
|
|||
import os
|
||||
from typing import Any
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from fastapi import FastAPI, HTTPException, UploadFile
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from circuitforge_core.video.backends.base import VideoBackend, make_video_backend
|
||||
|
|
@ -142,46 +139,6 @@ def find(req: FindRequest) -> FindResponse:
|
|||
)
|
||||
|
||||
|
||||
@app.post("/caption/upload", response_model=CaptionResponse)
|
||||
async def caption_upload(
|
||||
file: UploadFile,
|
||||
max_new_tokens: int = 2048,
|
||||
) -> CaptionResponse:
|
||||
"""Accept a video file upload, caption it, then delete the temp file.
|
||||
|
||||
Allows callers that don't share a filesystem with this node (e.g. Waxwing
|
||||
on Heimdall posting to cf-video on Muninn via the SSH tunnel port forward).
|
||||
"""
|
||||
if _backend is None:
|
||||
raise HTTPException(503, detail="backend not initialised")
|
||||
|
||||
suffix = f".{file.filename.rsplit('.', 1)[-1]}" if file.filename and "." in file.filename else ".mkv"
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(suffix=suffix)
|
||||
try:
|
||||
with os.fdopen(tmp_fd, "wb") as dst:
|
||||
shutil.copyfileobj(file.file, dst)
|
||||
|
||||
result = _backend.caption(tmp_path, max_new_tokens=max_new_tokens)
|
||||
except Exception as exc:
|
||||
logging.exception("caption/upload failed")
|
||||
raise HTTPException(500, detail=str(exc)) from exc
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return CaptionResponse(
|
||||
scene=result.scene,
|
||||
events=[
|
||||
VideoEventOut(start=ev.start, end=ev.end, description=ev.description)
|
||||
for ev in result.events
|
||||
],
|
||||
caption=result.caption,
|
||||
model=result.model,
|
||||
)
|
||||
|
||||
|
||||
# ── CLI entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
# @circuitforge/display — Vue package
|
||||
|
||||
`packages/display/` is the first non-Python module in circuitforge-core: Vue 3 primitives for CircuitForge products running a secondary strip display (1920×480 landscape / 480×1920 portrait kiosk). Design spec: `circuitforge-plans/circuitforge-core/superpowers/specs/2026-05-17-strip-display-spec.md`.
|
||||
|
||||
## Why a separate npm package, not `circuitforge_core`
|
||||
|
||||
Most products in the menagerie are Python-only and never touch Vue. Publishing `@circuitforge/display` as its own npm package — rather than bundling it into the Python `circuitforge-core` distribution — means:
|
||||
|
||||
- Products that don't use a strip display (most of them) never pull in Vue as a transitive dependency.
|
||||
- Versioning follows npm semver independently of the Python package's release cadence — a Vue component API change doesn't force a `circuitforge-core` PyPI bump, and vice versa.
|
||||
- The build tooling (Vite, vue-tsc, Vitest) stays isolated in `packages/display/`, not mixed into the Python `pyproject.toml`/pytest setup.
|
||||
|
||||
This mirrors the reasoning behind keeping the BSL `resources` module split into the separate `circuitforge-orch` package — different consumers, different release cycles, kept apart even though both live in adjacent repos/directories.
|
||||
|
||||
## Location and consumers
|
||||
|
||||
- Package root: `packages/display/`
|
||||
- First consumer: Turnstone's sysadmin profile (`turnstone#25`) — CPU/RAM/disk/network metrics, live alert feed, service macro buttons
|
||||
- Planned: Robin's proactive assistant overlay
|
||||
|
||||
## Working on it
|
||||
|
||||
```bash
|
||||
cd packages/display
|
||||
npm install
|
||||
npm test # Vitest — 37 tests across the four components
|
||||
npm run build # vue-tsc --emitDeclarationOnly && vite build → dist/
|
||||
```
|
||||
|
||||
Gotcha to watch for: Vite's `build.emptyOutDir` defaults to `true` and will silently wipe the `.d.ts` files `vue-tsc --emitDeclarationOnly` just wrote, since both commands target the same `dist/`. `vite.config.ts` sets `emptyOutDir: false` to prevent this — don't remove it without changing the build order.
|
||||
|
||||
See `packages/display/README.md` for the component API, theming (`src/theme.ts` is the central theme file — CSS custom properties + UnoCSS fragments for products that already run UnoCSS), and the launcher page.
|
||||
|
|
@ -49,18 +49,3 @@ Profile selection rules:
|
|||
## HardwareProfile
|
||||
|
||||
The `HardwareProfile` dataclass is written to `compose.override.yml` by `preflight.py` at product startup, making GPU capabilities available to Docker Compose without hardcoding.
|
||||
|
||||
## Model VRAM fit estimation
|
||||
|
||||
`model_vram_estimate()` answers "can this hardware run model X at quantization level Y?" by querying the HuggingFace Hub API for parameter count and architecture, then applying the standard VRAM formula (weights + KV cache + overhead). Reference algorithm from [LLMcalc](https://github.com/Raskoll2/LLMcalc) — no code copied, since LLMcalc has no published license.
|
||||
|
||||
```python
|
||||
from circuitforge_core.hardware import model_vram_estimate
|
||||
|
||||
est = model_vram_estimate("Qwen/Qwen2.5-7B-Instruct", "q4_k_m", available_vram_mb=8_000)
|
||||
print(est.total_vram_gb, est.fits) # e.g. 5.2 True
|
||||
```
|
||||
|
||||
Use cases: Avocet preflight (verify a checkpoint fits before benchmarking), cf-orch worker assignment (match model to GPU by VRAM fit), and onboarding wizards ("your GPU has X GB — here are models that will run well").
|
||||
|
||||
Raises `ModelVramLookupError` if the HF Hub API request fails or the model has no safetensors metadata; raises `ValueError` for an unrecognized `quant_level`.
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
# task_bridge
|
||||
|
||||
Shared MIT data contract for pushing tasks from a CF product into an external scheduler. Pilot consumer: Kiwi's pantry expiry alerts pushed into [Focus Flow](https://git.opensourcesolarpunk.com/Circuit-Forge/focus-flow), Ashley Venn's ADHD-first scheduler (AGPL-3.0, external project).
|
||||
|
||||
## Why this lives in cf-core
|
||||
|
||||
Focus Flow is AGPL-3.0; CF's AI features are BSL 1.1. To avoid any question of AGPL reach extending into CF's licensed code, the two sides never share a process or artifact:
|
||||
|
||||
- The **data contract** (this module) is MIT, a pure data definition with no product-specific logic.
|
||||
- Each **product's exporter/receiver** lives in that product's own repo, under its own licensing.
|
||||
- The two sides talk only over local HTTP, never a shared library or process.
|
||||
|
||||
Design spec: `circuitforge-plans/shared/superpowers/specs/2026-07-05-focus-flow-task-bridge-design.md`.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from circuitforge_core.task_bridge import ExternalTask, push_tasks
|
||||
|
||||
task = ExternalTask(
|
||||
source_product="kiwi",
|
||||
external_id="kiwi:item:1234", # stable, idempotent per source item — upserts, not duplicates
|
||||
title="Use up milk",
|
||||
due_at="2026-07-07T00:00:00Z", # ISO 8601 UTC
|
||||
notes="Opened 2026-07-01",
|
||||
)
|
||||
|
||||
push_tasks("http://127.0.0.1:8512/import/tasks", token, [task])
|
||||
```
|
||||
|
||||
`kind` is always `"flexible"` in schema v1 — Focus Flow's calm task kind. External sources can never set `"inflexible"`, `"critical"`, `"locked"`, or `"surprise"`, so no CF product can inject urgency into another product's UX.
|
||||
|
||||
`status` is `"active"` (default) or `"cancelled"` — push a `"cancelled"` record when the source item is deleted or edited away before the external scheduler marks it complete.
|
||||
|
||||
## API
|
||||
|
||||
- `ExternalTask(source_product, external_id, title, due_at, notes=None, kind="flexible", status="active")` — frozen dataclass; validates required fields and `kind`/`status` in `__post_init__`. `.to_dict()` serializes to the wire format.
|
||||
- `push_tasks(endpoint, token, tasks, *, timeout=10.0)` — POSTs `{"tasks": [...]}` with `Authorization: Bearer <token>`. Raises `TaskBridgeError` on transport failure or non-2xx response.
|
||||
|
||||
## What this module does *not* do
|
||||
|
||||
No transport server, no auth/token generation or pairing logic, no product-specific behavior (diffing, callback handling, inventory updates). Those live entirely on the consuming product's side — see Kiwi's `focus_flow` service for the reference implementation.
|
||||
|
||||
Install: `pip install circuitforge-core[task-bridge]` (pulls in `httpx`).
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
/*
|
||||
* circuitforge-core docs theme
|
||||
*
|
||||
* Central place for palette tweaks and small layout fixes on top of
|
||||
* mkdocs-material's blue grey / cyan palette (see mkdocs.yml). Keep
|
||||
* product-specific overrides out of individual markdown pages — add
|
||||
* them here so light/dark mode and mobile layout stay consistent
|
||||
* across the whole site.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--cf-accent-strong: #00acc1; /* cyan 600, matches material accent */
|
||||
--cf-code-bg-light: #eceff1; /* blue grey 50 */
|
||||
--cf-code-bg-dark: #263238; /* blue grey 900 */
|
||||
}
|
||||
|
||||
/* Slightly stronger contrast for inline code than material's default,
|
||||
in both color schemes. */
|
||||
[data-md-color-scheme="default"] {
|
||||
--md-code-bg-color: var(--cf-code-bg-light);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] {
|
||||
--md-code-bg-color: var(--cf-code-bg-dark);
|
||||
}
|
||||
|
||||
/* Module reference tables can get wide (Module | Status | Description);
|
||||
let them scroll horizontally on narrow viewports instead of overflowing
|
||||
the page or squeezing text unreadably small. */
|
||||
.md-typeset table:not([class]) {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 44.9375em) {
|
||||
.md-typeset table:not([class]) {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
14
mkdocs.yml
14
mkdocs.yml
|
|
@ -9,14 +9,14 @@ theme:
|
|||
name: material
|
||||
palette:
|
||||
- scheme: default
|
||||
primary: blue grey
|
||||
accent: cyan
|
||||
primary: deep purple
|
||||
accent: purple
|
||||
toggle:
|
||||
icon: material/brightness-7
|
||||
name: Switch to dark mode
|
||||
- scheme: slate
|
||||
primary: blue grey
|
||||
accent: cyan
|
||||
primary: deep purple
|
||||
accent: purple
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to light mode
|
||||
|
|
@ -71,18 +71,12 @@ nav:
|
|||
- stt: modules/stt.md
|
||||
- tts: modules/tts.md
|
||||
- pipeline: modules/pipeline.md
|
||||
- task_bridge: modules/task_bridge.md
|
||||
- vision: modules/vision.md
|
||||
- wizard: modules/wizard.md
|
||||
- Developer Guide:
|
||||
- Adding a Module: developer/adding-module.md
|
||||
- Editable Install Pattern: developer/editable-install.md
|
||||
- BSL vs MIT Boundaries: developer/licensing.md
|
||||
- "@circuitforge/display (Vue package)": developer/display-package.md
|
||||
- All CF Docs: https://docs.circuitforge.tech
|
||||
|
||||
extra_css:
|
||||
- stylesheets/theme.css
|
||||
|
||||
extra_javascript:
|
||||
- plausible.js
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
# @circuitforge/display
|
||||
|
||||
Vue 3 primitives for CircuitForge products that run on or alongside a secondary strip display (8.8" USB-C touch, 1920×480 landscape / 480×1920 portrait). Design spec: `circuitforge-plans/circuitforge-core/superpowers/specs/2026-05-17-strip-display-spec.md`.
|
||||
|
||||
Published as a separate npm package (not part of the Python `circuitforge-core` distribution) so products that don't use it never pull in Vue as a dependency.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @circuitforge/display
|
||||
```
|
||||
|
||||
`vue@^3.5` is a peer dependency.
|
||||
|
||||
## Usage
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { DisplayLayout, DisplayMetric, DisplayAlert, DisplayMacroButton } from '@circuitforge/display'
|
||||
import '@circuitforge/display/style.css'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DisplayLayout product="turnstone" profile="sysadmin" orientation="landscape">
|
||||
<template #metrics>
|
||||
<DisplayMetric :value="87" unit="°C" label="CPU temp" severity="warn" />
|
||||
<DisplayMetric :value="42" unit="%" label="RAM" />
|
||||
</template>
|
||||
<template #alerts>
|
||||
<DisplayAlert
|
||||
message="pacman lock detected — another process is using the database"
|
||||
timestamp="2026-01-01T14:22:00"
|
||||
severity="crit"
|
||||
/>
|
||||
</template>
|
||||
<template #macros>
|
||||
<DisplayMacroButton
|
||||
icon="🔄"
|
||||
label="Restart nginx"
|
||||
:action="{ type: 'shell', command: 'systemctl restart nginx' }"
|
||||
@trigger="handleMacro"
|
||||
/>
|
||||
</template>
|
||||
</DisplayLayout>
|
||||
</template>
|
||||
```
|
||||
|
||||
A product's `/display` route wraps its content in `DisplayLayout`, filling the `#metrics`, `#alerts`, and `#macros` slots — it doesn't manage zones/orientation manually. `?profile=` and orientation are read from the route by the consuming product and passed in as props (this package doesn't read `window.location` itself, for SSR-safety and testability).
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `DisplayLayout` | Root layout — identity zone, orientation-aware grid (landscape/portrait), theme. |
|
||||
| `DisplayMetric` | Single metric tile — value, label, optional unit/sparkline, severity colour. |
|
||||
| `DisplayAlert` | Single alert row — timestamp, message, severity-coloured left border. |
|
||||
| `DisplayMacroButton` | Large touch target (44px+ min) firing a `shell` / `url` / `api` / `display_switch` action. |
|
||||
|
||||
## Theming
|
||||
|
||||
`src/theme.ts` is the central theme file — CSS custom properties (`--cf-display-*`) with dark-theme defaults, plus `displayUnoTheme`/`displayUnoShortcuts` fragments for products that already run UnoCSS (Turnstone, Robin) to spread into their own `uno.config.ts`:
|
||||
|
||||
```ts
|
||||
// uno.config.ts
|
||||
import { defineConfig } from 'unocss'
|
||||
import { displayUnoTheme, displayUnoShortcuts } from '@circuitforge/display'
|
||||
|
||||
export default defineConfig({
|
||||
theme: { colors: { ...displayUnoTheme.colors } },
|
||||
shortcuts: { ...displayUnoShortcuts },
|
||||
})
|
||||
```
|
||||
|
||||
Dark is the default (strip displays typically sit adjacent to a bright monitor). Pass `theme="light"` to `DisplayLayout` to override.
|
||||
|
||||
## Launcher
|
||||
|
||||
`launcher/launcher.html` is a static, framework-free page listing configured product display URLs (read from a sibling `launcher.config.json`) and letting the user tap to switch the kiosk window between them. Not part of the npm package's JS API — copy it into a product's static assets or serve it directly.
|
||||
|
||||
## What this package does *not* do
|
||||
|
||||
No live metrics transport (WebSocket/SSE) — that's product-side (`DisplayDataProvider`, per the spec, lives in the consuming product). No macro execution — `DisplayMacroButton` only emits a `trigger` event with the action payload; the consuming product's backend runs it.
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CircuitForge Display Launcher</title>
|
||||
<style>
|
||||
:root {
|
||||
--cf-display-surface: #0d1117;
|
||||
--cf-display-surface-raised: #161b22;
|
||||
--cf-display-surface-border: #30363d;
|
||||
--cf-display-accent: #39d353;
|
||||
--cf-display-text-primary: #e6edf3;
|
||||
--cf-display-text-muted: #8b949e;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--cf-display-surface: #ffffff;
|
||||
--cf-display-surface-raised: #f6f8fa;
|
||||
--cf-display-surface-border: #d0d7de;
|
||||
--cf-display-accent: #1f883d;
|
||||
--cf-display-text-primary: #1f2328;
|
||||
--cf-display-text-muted: #59636e;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--cf-display-surface);
|
||||
color: var(--cf-display-text-primary);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
#app {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
.launcher-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 8rem;
|
||||
min-height: 8rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--cf-display-surface-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--cf-display-surface-raised);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.launcher-tile:hover, .launcher-tile:focus-visible {
|
||||
border-color: var(--cf-display-accent);
|
||||
outline: none;
|
||||
}
|
||||
.launcher-tile__name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.launcher-tile__profile {
|
||||
font-size: 0.75rem;
|
||||
color: var(--cf-display-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
#empty {
|
||||
color: var(--cf-display-text-muted);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
cf-core strip display launcher — no framework dependency, per the strip
|
||||
display spec. Config is a small JSON file the user edits once, fetched
|
||||
from ./launcher.config.json next to this HTML file (same-origin — this is
|
||||
meant to be served by a product's own backend, e.g. Turnstone, at a
|
||||
well-known /launcher route, or served as a static file alongside a
|
||||
kiosk browser profile).
|
||||
|
||||
{
|
||||
"products": [
|
||||
{ "name": "Turnstone", "url": "http://localhost:8600/display", "profile": "sysadmin" }
|
||||
],
|
||||
"default": 0
|
||||
}
|
||||
|
||||
Tapping a tile navigates the kiosk window to that product's display URL.
|
||||
On first boot, kiosk mode should open this launcher.
|
||||
-->
|
||||
<div id="app"></div>
|
||||
<div id="empty" style="display: none;">
|
||||
No products configured. Add entries to <code>launcher.config.json</code> next to this file.
|
||||
</div>
|
||||
<script>
|
||||
(async function () {
|
||||
const app = document.getElementById('app');
|
||||
const empty = document.getElementById('empty');
|
||||
|
||||
let config;
|
||||
try {
|
||||
const resp = await fetch('./launcher.config.json');
|
||||
config = await resp.json();
|
||||
} catch (err) {
|
||||
config = { products: [] };
|
||||
}
|
||||
|
||||
const products = Array.isArray(config.products) ? config.products : [];
|
||||
if (products.length === 0) {
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const product of products) {
|
||||
const tile = document.createElement('a');
|
||||
tile.className = 'launcher-tile';
|
||||
tile.href = product.url;
|
||||
tile.setAttribute('aria-label', `Open ${product.name}`);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'launcher-tile__name';
|
||||
name.textContent = product.name;
|
||||
tile.appendChild(name);
|
||||
|
||||
if (product.profile) {
|
||||
const profile = document.createElement('span');
|
||||
profile.className = 'launcher-tile__profile';
|
||||
profile.textContent = product.profile;
|
||||
tile.appendChild(profile);
|
||||
}
|
||||
|
||||
app.appendChild(tile);
|
||||
}
|
||||
|
||||
const defaultIndex = Number.isInteger(config.default) ? config.default : null;
|
||||
if (defaultIndex !== null && products[defaultIndex] && location.hash === '#auto') {
|
||||
location.href = products[defaultIndex].url;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
3409
packages/display/package-lock.json
generated
3409
packages/display/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,48 +0,0 @@
|
|||
{
|
||||
"name": "@circuitforge/display",
|
||||
"version": "0.1.0",
|
||||
"description": "Strip-display Vue primitives for CircuitForge products (1920x480 landscape / 480x1920 portrait kiosk displays)",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git",
|
||||
"directory": "packages/display"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"launcher"
|
||||
],
|
||||
"main": "./dist/circuitforge-display.cjs",
|
||||
"module": "./dist/circuitforge-display.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/circuitforge-display.js",
|
||||
"require": "./dist/circuitforge-display.cjs"
|
||||
},
|
||||
"./style.css": "./dist/circuitforge-display.css"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --emitDeclarationOnly && vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.2.4",
|
||||
"vue": "^3.5.25",
|
||||
"vue-tsc": "^3.1.5"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DisplaySeverity } from '../theme'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
message: string
|
||||
timestamp: string | Date
|
||||
severity?: DisplaySeverity
|
||||
}>(),
|
||||
{
|
||||
severity: 'ok',
|
||||
},
|
||||
)
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
const d = typeof props.timestamp === 'string' ? new Date(props.timestamp) : props.timestamp
|
||||
if (Number.isNaN(d.getTime())) return String(props.timestamp)
|
||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cf-display-alert" :class="`cf-display-alert--${severity}`" role="status">
|
||||
<span class="cf-display-alert__dot" aria-hidden="true" />
|
||||
<span class="cf-display-alert__time">{{ formattedTime }}</span>
|
||||
<span class="cf-display-alert__message">{{ message }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cf-display-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-left: 4px solid currentColor;
|
||||
background: var(--cf-display-surface-raised, #161b22);
|
||||
color: var(--cf-display-text-primary, #e6edf3);
|
||||
font-size: clamp(0.65rem, 1.6vw, 0.9rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cf-display-alert__dot {
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cf-display-alert__time {
|
||||
color: var(--cf-display-text-muted, #8b949e);
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cf-display-alert__message {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cf-display-alert--ok {
|
||||
color: var(--cf-display-text-muted, #8b949e);
|
||||
}
|
||||
|
||||
.cf-display-alert--warn {
|
||||
color: var(--cf-display-sev-warn, #d29922);
|
||||
}
|
||||
|
||||
.cf-display-alert--crit {
|
||||
color: var(--cf-display-sev-crit, #f85149);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DisplayOrientation, DisplayProfile } from '../theme'
|
||||
import {
|
||||
DISPLAY_THEME_DEFAULTS_DARK,
|
||||
DISPLAY_THEME_DEFAULTS_LIGHT,
|
||||
} from '../theme'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Product name shown in the identity zone; tap targets the launcher. */
|
||||
product: string
|
||||
/** Persona hint — cosmetic only, products may ignore or extend it. */
|
||||
profile?: DisplayProfile
|
||||
/** Forces a layout; omit to let CSS `@media (orientation:)` decide. */
|
||||
orientation?: DisplayOrientation
|
||||
/** Dark is the default for strip displays regardless of host OS theme. */
|
||||
theme?: 'dark' | 'light'
|
||||
}>(),
|
||||
{
|
||||
profile: 'sysadmin',
|
||||
orientation: 'landscape',
|
||||
theme: 'dark',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-launcher': []
|
||||
}>()
|
||||
|
||||
const themeVars = computed(() => {
|
||||
const vars = props.theme === 'light' ? DISPLAY_THEME_DEFAULTS_LIGHT : DISPLAY_THEME_DEFAULTS_DARK
|
||||
return vars as Record<string, string>
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="cf-display-layout"
|
||||
:class="[`cf-display-layout--${orientation}`, `cf-display-layout--profile-${profile}`]"
|
||||
:style="themeVars"
|
||||
:data-cf-display-theme="theme"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="cf-display-layout__identity"
|
||||
:aria-label="`${product} — open display launcher`"
|
||||
@click="emit('open-launcher')"
|
||||
>
|
||||
<slot name="identity">
|
||||
<span class="cf-display-layout__product">{{ product }}</span>
|
||||
<span class="cf-display-layout__profile">{{ profile }}</span>
|
||||
</slot>
|
||||
</button>
|
||||
|
||||
<div class="cf-display-layout__metrics">
|
||||
<slot name="metrics" />
|
||||
</div>
|
||||
|
||||
<div class="cf-display-layout__alerts">
|
||||
<slot name="alerts" />
|
||||
</div>
|
||||
|
||||
<div class="cf-display-layout__macros">
|
||||
<slot name="macros" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cf-display-layout {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: var(--cf-display-surface, #0d1117);
|
||||
color: var(--cf-display-text-primary, #e6edf3);
|
||||
overflow: hidden;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Landscape: 1920x480 — identity | metrics | alerts | macros, left to right. */
|
||||
.cf-display-layout--landscape {
|
||||
grid-template-columns: 128px minmax(0, 1fr) 300px 192px;
|
||||
grid-template-rows: 100%;
|
||||
grid-template-areas: 'identity metrics alerts macros';
|
||||
}
|
||||
|
||||
/* Portrait: 480x1920 — stacked top to bottom. */
|
||||
.cf-display-layout--portrait {
|
||||
grid-template-columns: 100%;
|
||||
grid-template-rows: 80px minmax(0, 1fr) auto auto;
|
||||
grid-template-areas:
|
||||
'identity'
|
||||
'metrics'
|
||||
'alerts'
|
||||
'macros';
|
||||
}
|
||||
|
||||
.cf-display-layout__identity {
|
||||
grid-area: identity;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.15rem;
|
||||
border: none;
|
||||
border-right: 1px solid var(--cf-display-surface-border, #30363d);
|
||||
background: var(--cf-display-surface-raised, #161b22);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.cf-display-layout--portrait .cf-display-layout__identity {
|
||||
flex-direction: row;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--cf-display-surface-border, #30363d);
|
||||
}
|
||||
|
||||
.cf-display-layout__product {
|
||||
font-weight: 700;
|
||||
font-size: clamp(0.8rem, 2vw, 1.1rem);
|
||||
}
|
||||
|
||||
.cf-display-layout__profile {
|
||||
font-size: 0.7rem;
|
||||
color: var(--cf-display-text-muted, #8b949e);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.cf-display-layout__metrics {
|
||||
grid-area: metrics;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cf-display-layout--portrait .cf-display-layout__metrics {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.cf-display-layout__alerts {
|
||||
grid-area: alerts;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--cf-display-surface-border, #30363d);
|
||||
}
|
||||
|
||||
.cf-display-layout--portrait .cf-display-layout__alerts {
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--cf-display-surface-border, #30363d);
|
||||
}
|
||||
|
||||
.cf-display-layout__macros {
|
||||
grid-area: macros;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.4rem;
|
||||
align-content: center;
|
||||
padding: 0.5rem;
|
||||
border-left: 1px solid var(--cf-display-surface-border, #30363d);
|
||||
}
|
||||
|
||||
.cf-display-layout--portrait .cf-display-layout__macros {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--cf-display-surface-border, #30363d);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
export type MacroAction =
|
||||
| { type: 'shell'; command: string }
|
||||
| { type: 'url'; url: string }
|
||||
| { type: 'api'; endpoint: string; method?: string; body?: unknown }
|
||||
| { type: 'display_switch'; target: string }
|
||||
|
||||
const props = defineProps<{
|
||||
icon: string
|
||||
label: string
|
||||
action: MacroAction
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
trigger: [action: MacroAction]
|
||||
}>()
|
||||
|
||||
function onActivate() {
|
||||
if (props.disabled) return
|
||||
emit('trigger', props.action)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="cf-display-macro"
|
||||
:disabled="disabled"
|
||||
:aria-label="label"
|
||||
@click="onActivate"
|
||||
>
|
||||
<span class="cf-display-macro__icon" aria-hidden="true">{{ icon }}</span>
|
||||
<span class="cf-display-macro__label">{{ label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cf-display-macro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.15em;
|
||||
/* 44px is the accessibility-minimum touch target; strip displays prefer 64px+ */
|
||||
min-width: 4rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--cf-display-surface-border, #30363d);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--cf-display-surface-raised, #161b22);
|
||||
color: var(--cf-display-text-primary, #e6edf3);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.cf-display-macro:hover:not(:disabled),
|
||||
.cf-display-macro:focus-visible {
|
||||
border-color: var(--cf-display-accent, #39d353);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cf-display-macro:active:not(:disabled) {
|
||||
background: var(--cf-display-surface, #0d1117);
|
||||
}
|
||||
|
||||
.cf-display-macro:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cf-display-macro__icon {
|
||||
font-size: clamp(1.2rem, 3vw, 1.8rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cf-display-macro__label {
|
||||
font-size: clamp(0.6rem, 1.4vw, 0.8rem);
|
||||
text-align: center;
|
||||
color: var(--cf-display-text-muted, #8b949e);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DisplaySeverity } from '../theme'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: string | number
|
||||
label: string
|
||||
unit?: string
|
||||
severity?: DisplaySeverity
|
||||
sparkline?: number[]
|
||||
}>(),
|
||||
{
|
||||
unit: '',
|
||||
severity: 'ok',
|
||||
sparkline: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const displayValue = computed(() => `${props.value}${props.unit ? props.unit : ''}`)
|
||||
|
||||
const sparklinePoints = computed(() => {
|
||||
const data = props.sparkline
|
||||
if (!data || data.length < 2) return null
|
||||
const min = Math.min(...data)
|
||||
const max = Math.max(...data)
|
||||
const range = max - min || 1
|
||||
const width = 100
|
||||
const height = 24
|
||||
const step = width / (data.length - 1)
|
||||
return data
|
||||
.map((v, i) => {
|
||||
const x = i * step
|
||||
const y = height - ((v - min) / range) * height
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cf-display-metric" :class="`cf-display-metric--${severity}`" role="group" :aria-label="label">
|
||||
<div class="cf-display-metric__value">{{ displayValue }}</div>
|
||||
<div class="cf-display-metric__label">{{ label }}</div>
|
||||
<svg
|
||||
v-if="sparklinePoints"
|
||||
class="cf-display-metric__sparkline"
|
||||
viewBox="0 0 100 24"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline :points="sparklinePoints" fill="none" stroke-width="2" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cf-display-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.15em;
|
||||
min-width: 6rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--cf-display-surface-raised, #161b22);
|
||||
color: var(--cf-display-text-primary, #e6edf3);
|
||||
}
|
||||
|
||||
.cf-display-metric__value {
|
||||
font-size: clamp(1.1rem, 4vw, 2rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.cf-display-metric__label {
|
||||
font-size: clamp(0.6rem, 1.5vw, 0.85rem);
|
||||
color: var(--cf-display-text-muted, #8b949e);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cf-display-metric__sparkline {
|
||||
width: 100%;
|
||||
height: 1.2rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.cf-display-metric__sparkline polyline {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.cf-display-metric--ok {
|
||||
color: var(--cf-display-sev-ok, #3fb950);
|
||||
}
|
||||
|
||||
.cf-display-metric--warn {
|
||||
color: var(--cf-display-sev-warn, #d29922);
|
||||
}
|
||||
|
||||
.cf-display-metric--crit {
|
||||
color: var(--cf-display-sev-crit, #f85149);
|
||||
}
|
||||
|
||||
.cf-display-metric--ok .cf-display-metric__value,
|
||||
.cf-display-metric--warn .cf-display-metric__value,
|
||||
.cf-display-metric--crit .cf-display-metric__value {
|
||||
color: currentColor;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import DisplayLayout from './components/DisplayLayout.vue'
|
||||
import DisplayMetric from './components/DisplayMetric.vue'
|
||||
import DisplayAlert from './components/DisplayAlert.vue'
|
||||
import DisplayMacroButton from './components/DisplayMacroButton.vue'
|
||||
|
||||
export { DisplayLayout, DisplayMetric, DisplayAlert, DisplayMacroButton }
|
||||
|
||||
export type { MacroAction } from './components/DisplayMacroButton.vue'
|
||||
export type {
|
||||
DisplayOrientation,
|
||||
DisplayProfile,
|
||||
DisplaySeverity,
|
||||
} from './theme'
|
||||
export {
|
||||
DISPLAY_THEME_VARS,
|
||||
DISPLAY_THEME_DEFAULTS_DARK,
|
||||
DISPLAY_THEME_DEFAULTS_LIGHT,
|
||||
DISPLAY_PROFILE_DENSITY,
|
||||
displayUnoTheme,
|
||||
displayUnoShortcuts,
|
||||
} from './theme'
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DisplayAlert from '../components/DisplayAlert.vue'
|
||||
|
||||
describe('DisplayAlert', () => {
|
||||
it('renders the message', () => {
|
||||
const wrapper = mount(DisplayAlert, {
|
||||
props: { message: 'pacman lock detected', timestamp: '2026-01-01T14:22:00' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('pacman lock detected')
|
||||
})
|
||||
|
||||
it('formats an ISO timestamp as a time string', () => {
|
||||
const wrapper = mount(DisplayAlert, {
|
||||
props: { message: 'x', timestamp: '2026-01-01T14:22:00' },
|
||||
})
|
||||
expect(wrapper.find('.cf-display-alert__time').text()).toMatch(/\d{1,2}:\d{2}/)
|
||||
})
|
||||
|
||||
it('accepts a Date object for timestamp', () => {
|
||||
const wrapper = mount(DisplayAlert, {
|
||||
props: { message: 'x', timestamp: new Date('2026-01-01T14:22:00') },
|
||||
})
|
||||
expect(wrapper.find('.cf-display-alert__time').text()).toMatch(/\d{1,2}:\d{2}/)
|
||||
})
|
||||
|
||||
it('falls back to the raw string when timestamp is unparseable', () => {
|
||||
const wrapper = mount(DisplayAlert, {
|
||||
props: { message: 'x', timestamp: 'not-a-date' },
|
||||
})
|
||||
expect(wrapper.find('.cf-display-alert__time').text()).toBe('not-a-date')
|
||||
})
|
||||
|
||||
it('defaults severity to ok', () => {
|
||||
const wrapper = mount(DisplayAlert, {
|
||||
props: { message: 'x', timestamp: '2026-01-01T00:00:00' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('cf-display-alert--ok')
|
||||
})
|
||||
|
||||
it('applies the warn severity class', () => {
|
||||
const wrapper = mount(DisplayAlert, {
|
||||
props: { message: 'x', timestamp: '2026-01-01T00:00:00', severity: 'warn' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('cf-display-alert--warn')
|
||||
})
|
||||
|
||||
it('applies the crit severity class', () => {
|
||||
const wrapper = mount(DisplayAlert, {
|
||||
props: { message: 'x', timestamp: '2026-01-01T00:00:00', severity: 'crit' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('cf-display-alert--crit')
|
||||
})
|
||||
|
||||
it('has an accessible status role', () => {
|
||||
const wrapper = mount(DisplayAlert, {
|
||||
props: { message: 'x', timestamp: '2026-01-01T00:00:00' },
|
||||
})
|
||||
expect(wrapper.attributes('role')).toBe('status')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DisplayLayout from '../components/DisplayLayout.vue'
|
||||
|
||||
describe('DisplayLayout', () => {
|
||||
it('renders the product name in the identity zone by default', () => {
|
||||
const wrapper = mount(DisplayLayout, { props: { product: 'turnstone' } })
|
||||
expect(wrapper.find('.cf-display-layout__product').text()).toBe('turnstone')
|
||||
})
|
||||
|
||||
it('defaults profile to sysadmin', () => {
|
||||
const wrapper = mount(DisplayLayout, { props: { product: 'turnstone' } })
|
||||
expect(wrapper.find('.cf-display-layout__profile').text()).toBe('sysadmin')
|
||||
expect(wrapper.classes()).toContain('cf-display-layout--profile-sysadmin')
|
||||
})
|
||||
|
||||
it('applies the given profile', () => {
|
||||
const wrapper = mount(DisplayLayout, {
|
||||
props: { product: 'robin', profile: 'casual' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('cf-display-layout--profile-casual')
|
||||
})
|
||||
|
||||
it('defaults orientation to landscape', () => {
|
||||
const wrapper = mount(DisplayLayout, { props: { product: 'turnstone' } })
|
||||
expect(wrapper.classes()).toContain('cf-display-layout--landscape')
|
||||
})
|
||||
|
||||
it('applies portrait orientation when set', () => {
|
||||
const wrapper = mount(DisplayLayout, {
|
||||
props: { product: 'turnstone', orientation: 'portrait' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('cf-display-layout--portrait')
|
||||
})
|
||||
|
||||
it('renders content passed to the metrics slot', () => {
|
||||
const wrapper = mount(DisplayLayout, {
|
||||
props: { product: 'turnstone' },
|
||||
slots: { metrics: '<div class="probe-metrics">m</div>' },
|
||||
})
|
||||
expect(wrapper.find('.cf-display-layout__metrics .probe-metrics').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders content passed to the alerts slot', () => {
|
||||
const wrapper = mount(DisplayLayout, {
|
||||
props: { product: 'turnstone' },
|
||||
slots: { alerts: '<div class="probe-alerts">a</div>' },
|
||||
})
|
||||
expect(wrapper.find('.cf-display-layout__alerts .probe-alerts').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders content passed to the macros slot', () => {
|
||||
const wrapper = mount(DisplayLayout, {
|
||||
props: { product: 'turnstone' },
|
||||
slots: { macros: '<div class="probe-macros">x</div>' },
|
||||
})
|
||||
expect(wrapper.find('.cf-display-layout__macros .probe-macros').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('overrides the identity zone with the identity slot', () => {
|
||||
const wrapper = mount(DisplayLayout, {
|
||||
props: { product: 'turnstone' },
|
||||
slots: { identity: '<span class="probe-identity">Custom</span>' },
|
||||
})
|
||||
expect(wrapper.find('.probe-identity').exists()).toBe(true)
|
||||
expect(wrapper.find('.cf-display-layout__product').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits open-launcher when the identity button is tapped', async () => {
|
||||
const wrapper = mount(DisplayLayout, { props: { product: 'turnstone' } })
|
||||
await wrapper.find('.cf-display-layout__identity').trigger('click')
|
||||
expect(wrapper.emitted('open-launcher')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('defaults to dark theme and sets the data attribute', () => {
|
||||
const wrapper = mount(DisplayLayout, { props: { product: 'turnstone' } })
|
||||
expect(wrapper.attributes('data-cf-display-theme')).toBe('dark')
|
||||
})
|
||||
|
||||
it('applies light theme override values', () => {
|
||||
const wrapper = mount(DisplayLayout, {
|
||||
props: { product: 'turnstone', theme: 'light' },
|
||||
})
|
||||
expect(wrapper.attributes('data-cf-display-theme')).toBe('light')
|
||||
expect(wrapper.attributes('style')).toContain('#ffffff')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DisplayMacroButton from '../components/DisplayMacroButton.vue'
|
||||
|
||||
describe('DisplayMacroButton', () => {
|
||||
it('renders icon and label', () => {
|
||||
const wrapper = mount(DisplayMacroButton, {
|
||||
props: { icon: '🔄', label: 'Restart nginx', action: { type: 'shell', command: 'systemctl restart nginx' } },
|
||||
})
|
||||
expect(wrapper.text()).toContain('🔄')
|
||||
expect(wrapper.text()).toContain('Restart nginx')
|
||||
})
|
||||
|
||||
it('emits trigger with the action payload on click', async () => {
|
||||
const action = { type: 'shell' as const, command: 'systemctl restart nginx' }
|
||||
const wrapper = mount(DisplayMacroButton, {
|
||||
props: { icon: '🔄', label: 'Restart nginx', action },
|
||||
})
|
||||
await wrapper.trigger('click')
|
||||
expect(wrapper.emitted('trigger')).toHaveLength(1)
|
||||
expect(wrapper.emitted('trigger')![0]).toEqual([action])
|
||||
})
|
||||
|
||||
it('does not emit trigger when disabled', async () => {
|
||||
const wrapper = mount(DisplayMacroButton, {
|
||||
props: {
|
||||
icon: '🔄',
|
||||
label: 'Restart nginx',
|
||||
action: { type: 'shell', command: 'x' },
|
||||
disabled: true,
|
||||
},
|
||||
})
|
||||
await wrapper.trigger('click')
|
||||
expect(wrapper.emitted('trigger')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets the disabled attribute on the button element', () => {
|
||||
const wrapper = mount(DisplayMacroButton, {
|
||||
props: {
|
||||
icon: '🔄',
|
||||
label: 'x',
|
||||
action: { type: 'shell', command: 'x' },
|
||||
disabled: true,
|
||||
},
|
||||
})
|
||||
expect(wrapper.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('supports url actions', async () => {
|
||||
const action = { type: 'url' as const, url: 'https://example.com' }
|
||||
const wrapper = mount(DisplayMacroButton, {
|
||||
props: { icon: '🌐', label: 'Docs', action },
|
||||
})
|
||||
await wrapper.trigger('click')
|
||||
expect(wrapper.emitted('trigger')![0]).toEqual([action])
|
||||
})
|
||||
|
||||
it('supports display_switch actions', async () => {
|
||||
const action = { type: 'display_switch' as const, target: 'robin' }
|
||||
const wrapper = mount(DisplayMacroButton, {
|
||||
props: { icon: '↔️', label: 'Switch', action },
|
||||
})
|
||||
await wrapper.trigger('click')
|
||||
expect(wrapper.emitted('trigger')![0]).toEqual([action])
|
||||
})
|
||||
|
||||
it('sets an accessible label matching the button label', () => {
|
||||
const wrapper = mount(DisplayMacroButton, {
|
||||
props: { icon: '🔄', label: 'Restart nginx', action: { type: 'shell', command: 'x' } },
|
||||
})
|
||||
expect(wrapper.attributes('aria-label')).toBe('Restart nginx')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DisplayMetric from '../components/DisplayMetric.vue'
|
||||
|
||||
describe('DisplayMetric', () => {
|
||||
it('renders value and label', () => {
|
||||
const wrapper = mount(DisplayMetric, {
|
||||
props: { value: 87, label: 'CPU temp' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('87')
|
||||
expect(wrapper.text()).toContain('CPU temp')
|
||||
})
|
||||
|
||||
it('appends unit to value when provided', () => {
|
||||
const wrapper = mount(DisplayMetric, {
|
||||
props: { value: 87, label: 'CPU temp', unit: '°C' },
|
||||
})
|
||||
expect(wrapper.find('.cf-display-metric__value').text()).toBe('87°C')
|
||||
})
|
||||
|
||||
it('does not append anything when unit is omitted', () => {
|
||||
const wrapper = mount(DisplayMetric, { props: { value: 42, label: 'Fans' } })
|
||||
expect(wrapper.find('.cf-display-metric__value').text()).toBe('42')
|
||||
})
|
||||
|
||||
it('defaults severity to ok', () => {
|
||||
const wrapper = mount(DisplayMetric, { props: { value: 1, label: 'x' } })
|
||||
expect(wrapper.classes()).toContain('cf-display-metric--ok')
|
||||
})
|
||||
|
||||
it('applies the warn severity class', () => {
|
||||
const wrapper = mount(DisplayMetric, {
|
||||
props: { value: 1, label: 'x', severity: 'warn' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('cf-display-metric--warn')
|
||||
})
|
||||
|
||||
it('applies the crit severity class', () => {
|
||||
const wrapper = mount(DisplayMetric, {
|
||||
props: { value: 1, label: 'x', severity: 'crit' },
|
||||
})
|
||||
expect(wrapper.classes()).toContain('cf-display-metric--crit')
|
||||
})
|
||||
|
||||
it('does not render a sparkline when none is given', () => {
|
||||
const wrapper = mount(DisplayMetric, { props: { value: 1, label: 'x' } })
|
||||
expect(wrapper.find('.cf-display-metric__sparkline').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render a sparkline with fewer than 2 points', () => {
|
||||
const wrapper = mount(DisplayMetric, {
|
||||
props: { value: 1, label: 'x', sparkline: [5] },
|
||||
})
|
||||
expect(wrapper.find('.cf-display-metric__sparkline').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders a sparkline polyline with one point per reading', () => {
|
||||
const wrapper = mount(DisplayMetric, {
|
||||
props: { value: 1, label: 'x', sparkline: [1, 2, 3, 2, 1] },
|
||||
})
|
||||
const polyline = wrapper.find('polyline')
|
||||
expect(polyline.exists()).toBe(true)
|
||||
const points = polyline.attributes('points')!.trim().split(' ')
|
||||
expect(points).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('sets an accessible group label matching the metric label', () => {
|
||||
const wrapper = mount(DisplayMetric, { props: { value: 1, label: 'RAM usage' } })
|
||||
expect(wrapper.attributes('aria-label')).toBe('RAM usage')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
/**
|
||||
* @circuitforge/display — central theme file.
|
||||
*
|
||||
* Single source of truth for the strip-display design tokens. Components in
|
||||
* this package read these as CSS custom properties (with fallbacks, so they
|
||||
* render correctly even in a host that hasn't wired the theme up yet).
|
||||
*
|
||||
* Consuming products that already have a UnoCSS config (Turnstone, Robin)
|
||||
* should spread `displayUnoTheme`/`displayUnoShortcuts` into their own
|
||||
* `uno.config.ts` so the strip-display route matches their product's theme
|
||||
* rather than diverging with a second, unrelated palette. See
|
||||
* packages/display/README.md for the merge snippet.
|
||||
*
|
||||
* Dark theme is the default — strip displays typically sit adjacent to a
|
||||
* bright monitor, so a light theme fights for attention. Set
|
||||
* `data-cf-display-theme="light"` on the root element to override.
|
||||
*/
|
||||
|
||||
/** CSS custom property names, gathered so components and consumers share one contract. */
|
||||
export const DISPLAY_THEME_VARS = {
|
||||
surface: '--cf-display-surface',
|
||||
surfaceRaised: '--cf-display-surface-raised',
|
||||
surfaceBorder: '--cf-display-surface-border',
|
||||
accent: '--cf-display-accent',
|
||||
accentMuted: '--cf-display-accent-muted',
|
||||
textPrimary: '--cf-display-text-primary',
|
||||
textMuted: '--cf-display-text-muted',
|
||||
textDim: '--cf-display-text-dim',
|
||||
sevOk: '--cf-display-sev-ok',
|
||||
sevWarn: '--cf-display-sev-warn',
|
||||
sevCrit: '--cf-display-sev-crit',
|
||||
} as const
|
||||
|
||||
/** Default values, dark theme. Applied as :root fallbacks by DisplayLayout. */
|
||||
export const DISPLAY_THEME_DEFAULTS_DARK: Record<string, string> = {
|
||||
[DISPLAY_THEME_VARS.surface]: '#0d1117',
|
||||
[DISPLAY_THEME_VARS.surfaceRaised]: '#161b22',
|
||||
[DISPLAY_THEME_VARS.surfaceBorder]: '#30363d',
|
||||
[DISPLAY_THEME_VARS.accent]: '#39d353',
|
||||
[DISPLAY_THEME_VARS.accentMuted]: '#1f6feb',
|
||||
[DISPLAY_THEME_VARS.textPrimary]: '#e6edf3',
|
||||
[DISPLAY_THEME_VARS.textMuted]: '#8b949e',
|
||||
[DISPLAY_THEME_VARS.textDim]: '#484f58',
|
||||
[DISPLAY_THEME_VARS.sevOk]: '#3fb950',
|
||||
[DISPLAY_THEME_VARS.sevWarn]: '#d29922',
|
||||
[DISPLAY_THEME_VARS.sevCrit]: '#f85149',
|
||||
}
|
||||
|
||||
/** Light theme override values, applied when data-cf-display-theme="light". */
|
||||
export const DISPLAY_THEME_DEFAULTS_LIGHT: Record<string, string> = {
|
||||
[DISPLAY_THEME_VARS.surface]: '#ffffff',
|
||||
[DISPLAY_THEME_VARS.surfaceRaised]: '#f6f8fa',
|
||||
[DISPLAY_THEME_VARS.surfaceBorder]: '#d0d7de',
|
||||
[DISPLAY_THEME_VARS.accent]: '#1f883d',
|
||||
[DISPLAY_THEME_VARS.accentMuted]: '#0969da',
|
||||
[DISPLAY_THEME_VARS.textPrimary]: '#1f2328',
|
||||
[DISPLAY_THEME_VARS.textMuted]: '#59636e',
|
||||
[DISPLAY_THEME_VARS.textDim]: '#8c959f',
|
||||
[DISPLAY_THEME_VARS.sevOk]: '#1a7f37',
|
||||
[DISPLAY_THEME_VARS.sevWarn]: '#9a6700',
|
||||
[DISPLAY_THEME_VARS.sevCrit]: '#cf222e',
|
||||
}
|
||||
|
||||
export type DisplayProfile = 'sysadmin' | 'gamer' | 'pro' | 'casual'
|
||||
export type DisplayOrientation = 'landscape' | 'portrait'
|
||||
export type DisplaySeverity = 'ok' | 'warn' | 'crit'
|
||||
|
||||
/** Cosmetic-only hints per profile — products may ignore or extend these. */
|
||||
export const DISPLAY_PROFILE_DENSITY: Record<DisplayProfile, 'dense' | 'bold' | 'clean' | 'spacious'> = {
|
||||
sysadmin: 'dense',
|
||||
gamer: 'bold',
|
||||
pro: 'clean',
|
||||
casual: 'spacious',
|
||||
}
|
||||
|
||||
/**
|
||||
* UnoCSS theme extension fragment. Spread into a consuming product's own
|
||||
* `uno.config.ts` theme block (`theme: { colors: { ...displayUnoTheme.colors } }`)
|
||||
* so `uno-*` utility classes referencing these tokens are available.
|
||||
*/
|
||||
export const displayUnoTheme = {
|
||||
colors: {
|
||||
cfDisplay: {
|
||||
surface: `var(${DISPLAY_THEME_VARS.surface})`,
|
||||
surfaceRaised: `var(${DISPLAY_THEME_VARS.surfaceRaised})`,
|
||||
surfaceBorder: `var(${DISPLAY_THEME_VARS.surfaceBorder})`,
|
||||
accent: `var(${DISPLAY_THEME_VARS.accent})`,
|
||||
accentMuted: `var(${DISPLAY_THEME_VARS.accentMuted})`,
|
||||
textPrimary: `var(${DISPLAY_THEME_VARS.textPrimary})`,
|
||||
textMuted: `var(${DISPLAY_THEME_VARS.textMuted})`,
|
||||
textDim: `var(${DISPLAY_THEME_VARS.textDim})`,
|
||||
sevOk: `var(${DISPLAY_THEME_VARS.sevOk})`,
|
||||
sevWarn: `var(${DISPLAY_THEME_VARS.sevWarn})`,
|
||||
sevCrit: `var(${DISPLAY_THEME_VARS.sevCrit})`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* UnoCSS shortcuts fragment, per the strip display spec's "two additions
|
||||
* needed": display-metric, display-macro, display-alert. Spread into
|
||||
* `shortcuts: { ...displayUnoShortcuts }`.
|
||||
*/
|
||||
export const displayUnoShortcuts: Record<string, string> = {
|
||||
'display-metric': 'flex flex-col items-center justify-center rounded-md px-3 py-2 min-w-24',
|
||||
// 44px is the accessibility-minimum touch target; strip displays prefer 64px+.
|
||||
'display-macro': 'flex flex-col items-center justify-center rounded-lg min-h-16 min-w-16 select-none',
|
||||
'display-alert': 'flex items-center gap-2 border-l-4 px-2 py-1 text-sm truncate',
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
||||
"exclude": ["src/tests"]
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
// vue-tsc --emitDeclarationOnly runs before this and writes .d.ts files
|
||||
// into dist/ — don't let Vite's default emptyOutDir wipe them out.
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
name: 'CircuitForgeDisplay',
|
||||
fileName: 'circuitforge-display',
|
||||
formats: ['es', 'cjs'],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['vue'],
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/tests/**/*.spec.ts'],
|
||||
},
|
||||
})
|
||||
|
|
@ -11,7 +11,6 @@ dependencies = [
|
|||
"pyyaml>=6.0",
|
||||
"requests>=2.31",
|
||||
"openai>=1.0",
|
||||
"backon>=4.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
@ -21,12 +20,6 @@ memory = [
|
|||
sync = [
|
||||
"fastapi>=0.110",
|
||||
]
|
||||
signal-bus = [
|
||||
"fastapi>=0.110",
|
||||
]
|
||||
task-bridge = [
|
||||
"httpx>=0.27",
|
||||
]
|
||||
community = [
|
||||
"psycopg2>=2.9",
|
||||
]
|
||||
|
|
@ -93,7 +86,6 @@ video-service = [
|
|||
"circuitforge-core[video-marlin]",
|
||||
"fastapi>=0.110",
|
||||
"uvicorn[standard]>=0.29",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
vision-siglip = [
|
||||
"torch>=2.0",
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
"""Tests for circuitforge_core.hardware.vram_estimate (cf-core #64)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from circuitforge_core.hardware.vram_estimate import (
|
||||
ModelVramLookupError,
|
||||
model_vram_estimate,
|
||||
)
|
||||
|
||||
_QWEN_CONFIG = {
|
||||
"num_hidden_layers": 28,
|
||||
"hidden_size": 3584,
|
||||
"num_attention_heads": 28,
|
||||
"num_key_value_heads": 4,
|
||||
}
|
||||
|
||||
|
||||
def _api_response(status_code=200, safetensors_total=7_000_000_000):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = {"safetensors": {"total": safetensors_total}} if safetensors_total else {}
|
||||
return resp
|
||||
|
||||
|
||||
def _config_response(status_code=200, config=None):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = config if config is not None else _QWEN_CONFIG
|
||||
return resp
|
||||
|
||||
|
||||
class TestModelVramEstimate:
|
||||
def test_estimates_weights_gb_for_fp16(self):
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
side_effect=[_api_response(), _config_response()],
|
||||
):
|
||||
est = model_vram_estimate("Qwen/Qwen2.5-7B-Instruct", "fp16")
|
||||
|
||||
assert est.params_billions == pytest.approx(7.0)
|
||||
# 7e9 params * 16 bits / 8 bits-per-byte / 1e9 = 14 GB
|
||||
assert est.weights_gb == pytest.approx(14.0)
|
||||
|
||||
def test_lower_bit_quant_uses_less_vram(self):
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
side_effect=[_api_response(), _config_response()],
|
||||
):
|
||||
fp16 = model_vram_estimate("Qwen/Qwen2.5-7B-Instruct", "fp16")
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
side_effect=[_api_response(), _config_response()],
|
||||
):
|
||||
q4 = model_vram_estimate("Qwen/Qwen2.5-7B-Instruct", "q4_k_m")
|
||||
|
||||
assert q4.weights_gb < fp16.weights_gb
|
||||
|
||||
def test_kv_cache_included_when_config_available(self):
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
side_effect=[_api_response(), _config_response()],
|
||||
):
|
||||
est = model_vram_estimate("Qwen/Qwen2.5-7B-Instruct", "fp16", ctx_len=8192)
|
||||
|
||||
assert est.kv_cache_gb > 0.0
|
||||
assert est.total_vram_gb == pytest.approx(
|
||||
est.weights_gb + est.kv_cache_gb + est.overhead_gb
|
||||
)
|
||||
|
||||
def test_kv_cache_zero_when_config_missing_arch_fields(self):
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
side_effect=[_api_response(), _config_response(config={"some_other_field": 1})],
|
||||
):
|
||||
est = model_vram_estimate("weird/model", "fp16")
|
||||
|
||||
assert est.kv_cache_gb == 0.0
|
||||
|
||||
def test_kv_cache_zero_when_config_fetch_fails(self):
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
side_effect=[_api_response(), _config_response(status_code=404)],
|
||||
):
|
||||
est = model_vram_estimate("Qwen/Qwen2.5-7B-Instruct", "fp16")
|
||||
|
||||
assert est.kv_cache_gb == 0.0
|
||||
|
||||
def test_fits_true_when_vram_sufficient(self):
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
side_effect=[_api_response(), _config_response()],
|
||||
):
|
||||
est = model_vram_estimate(
|
||||
"Qwen/Qwen2.5-7B-Instruct", "q4_k_m", available_vram_mb=24_000
|
||||
)
|
||||
|
||||
assert est.fits is True
|
||||
|
||||
def test_fits_false_when_vram_insufficient(self):
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
side_effect=[_api_response(), _config_response()],
|
||||
):
|
||||
est = model_vram_estimate(
|
||||
"Qwen/Qwen2.5-7B-Instruct", "fp32", available_vram_mb=4_000
|
||||
)
|
||||
|
||||
assert est.fits is False
|
||||
|
||||
def test_fits_none_when_available_vram_not_supplied(self):
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
side_effect=[_api_response(), _config_response()],
|
||||
):
|
||||
est = model_vram_estimate("Qwen/Qwen2.5-7B-Instruct", "fp16")
|
||||
|
||||
assert est.fits is None
|
||||
|
||||
def test_unknown_quant_level_raises_value_error(self):
|
||||
with pytest.raises(ValueError):
|
||||
model_vram_estimate("Qwen/Qwen2.5-7B-Instruct", "not-a-real-quant")
|
||||
|
||||
def test_quant_level_case_insensitive(self):
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
side_effect=[_api_response(), _config_response()],
|
||||
):
|
||||
est = model_vram_estimate("Qwen/Qwen2.5-7B-Instruct", "FP16")
|
||||
|
||||
assert est.weights_gb == pytest.approx(14.0)
|
||||
|
||||
def test_raises_on_non_200_from_hf_api(self):
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
return_value=_api_response(status_code=404),
|
||||
):
|
||||
with pytest.raises(ModelVramLookupError):
|
||||
model_vram_estimate("nonexistent/model", "fp16")
|
||||
|
||||
def test_raises_on_missing_safetensors_metadata(self):
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
return_value=_api_response(safetensors_total=None),
|
||||
):
|
||||
with pytest.raises(ModelVramLookupError):
|
||||
model_vram_estimate("gguf-only/model", "fp16")
|
||||
|
||||
def test_raises_on_request_exception(self):
|
||||
import requests
|
||||
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
side_effect=requests.ConnectionError("no network"),
|
||||
):
|
||||
with pytest.raises(ModelVramLookupError):
|
||||
model_vram_estimate("Qwen/Qwen2.5-7B-Instruct", "fp16")
|
||||
|
||||
def test_result_includes_hf_model_id_and_quant_level(self):
|
||||
with patch(
|
||||
"circuitforge_core.hardware.vram_estimate.requests.get",
|
||||
side_effect=[_api_response(), _config_response()],
|
||||
):
|
||||
est = model_vram_estimate("Qwen/Qwen2.5-7B-Instruct", "q4_k_m")
|
||||
|
||||
assert est.hf_model_id == "Qwen/Qwen2.5-7B-Instruct"
|
||||
assert est.quant_level == "q4_k_m"
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
"""Tests for circuitforge_core.retry (cf-core #65)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from circuitforge_core.retry import (
|
||||
disable_retries,
|
||||
enable_retries_globally,
|
||||
on_exception,
|
||||
retry,
|
||||
)
|
||||
|
||||
|
||||
class FlakyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _flaky_after(n_failures):
|
||||
"""Return a function that raises FlakyError n_failures times, then succeeds."""
|
||||
state = {"calls": 0}
|
||||
|
||||
def fn():
|
||||
state["calls"] += 1
|
||||
if state["calls"] <= n_failures:
|
||||
raise FlakyError(f"attempt {state['calls']}")
|
||||
return state["calls"]
|
||||
|
||||
fn.state = state
|
||||
return fn
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ensure_retries_enabled():
|
||||
"""Some tests disable retries; always restore the global state after."""
|
||||
yield
|
||||
enable_retries_globally()
|
||||
|
||||
|
||||
class TestOnException:
|
||||
def test_succeeds_without_retry_when_no_exception(self):
|
||||
calls = {"n": 0}
|
||||
|
||||
@on_exception(FlakyError, max_tries=3)
|
||||
def fn():
|
||||
calls["n"] += 1
|
||||
return "ok"
|
||||
|
||||
assert fn() == "ok"
|
||||
assert calls["n"] == 1
|
||||
|
||||
def test_retries_until_success(self):
|
||||
fn = _flaky_after(2)
|
||||
wrapped = on_exception(FlakyError, max_tries=5, sleep=lambda s: None)(fn)
|
||||
|
||||
assert wrapped() == 3
|
||||
assert fn.state["calls"] == 3
|
||||
|
||||
def test_gives_up_after_max_tries(self):
|
||||
fn = _flaky_after(10)
|
||||
wrapped = on_exception(FlakyError, max_tries=3, sleep=lambda s: None)(fn)
|
||||
|
||||
with pytest.raises(FlakyError):
|
||||
wrapped()
|
||||
assert fn.state["calls"] == 3
|
||||
|
||||
def test_only_retries_specified_exception_type(self):
|
||||
@on_exception(FlakyError, max_tries=3, sleep=lambda s: None)
|
||||
def fn():
|
||||
raise ValueError("not a FlakyError")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
fn()
|
||||
|
||||
def test_disable_retries_context_manager_calls_once(self):
|
||||
fn = _flaky_after(10)
|
||||
wrapped = on_exception(FlakyError, max_tries=5, sleep=lambda s: None)(fn)
|
||||
|
||||
with disable_retries():
|
||||
with pytest.raises(FlakyError):
|
||||
wrapped()
|
||||
|
||||
assert fn.state["calls"] == 1
|
||||
|
||||
def test_retries_resume_after_disable_retries_context_exits(self):
|
||||
fn = _flaky_after(2)
|
||||
wrapped = on_exception(FlakyError, max_tries=5, sleep=lambda s: None)(fn)
|
||||
|
||||
with disable_retries():
|
||||
pass # just exercise enter/exit without calling wrapped()
|
||||
|
||||
assert wrapped() == 3
|
||||
|
||||
|
||||
class TestRetry:
|
||||
def test_retry_calls_target_with_args_and_kwargs(self):
|
||||
def fn(a, b, *, c):
|
||||
return a + b + c
|
||||
|
||||
assert retry(fn, 1, 2, c=3, max_tries=1) == 6
|
||||
|
||||
def test_retry_retries_on_failure(self):
|
||||
fn = _flaky_after(2)
|
||||
|
||||
with patch("time.sleep"):
|
||||
assert retry(fn, max_tries=5) == 3
|
||||
assert fn.state["calls"] == 3
|
||||
|
||||
def test_retry_raises_after_exhausting_max_tries(self):
|
||||
fn = _flaky_after(10)
|
||||
|
||||
with patch("time.sleep"), pytest.raises(FlakyError):
|
||||
retry(fn, exception=FlakyError, max_tries=2)
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
"""Tests for SignalBus and SignalEvent."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from circuitforge_core.signal_bus import SignalBus, SignalEvent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SignalEvent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalEvent:
|
||||
def test_sse_format(self) -> None:
|
||||
ev = SignalEvent(source="merlin", kind="gesture", payload={"name": "open_palm"}, timestamp="2026-01-01T00:00:00Z")
|
||||
sse = ev.to_sse()
|
||||
assert sse.startswith("data: ")
|
||||
assert sse.endswith("\n\n")
|
||||
|
||||
def test_sse_contains_all_fields(self) -> None:
|
||||
ev = SignalEvent(source="linnet", kind="tone", payload={"label": "sarcasm"}, timestamp="2026-01-01T00:00:00Z")
|
||||
data = json.loads(ev.to_sse()[len("data: "):].strip())
|
||||
assert data["source"] == "linnet"
|
||||
assert data["kind"] == "tone"
|
||||
assert data["payload"] == {"label": "sarcasm"}
|
||||
assert data["timestamp"] == "2026-01-01T00:00:00Z"
|
||||
|
||||
def test_timestamp_auto_populated(self) -> None:
|
||||
ev = SignalEvent(source="s", kind="k", payload={})
|
||||
assert ev.timestamp # not empty
|
||||
assert "T" in ev.timestamp # ISO format
|
||||
|
||||
def test_frozen(self) -> None:
|
||||
ev = SignalEvent(source="s", kind="k", payload={})
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
ev.source = "other" # type: ignore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SignalBus — unit (direct queue inspection)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalBusUnit:
|
||||
def test_publish_noop_before_subscribe(self) -> None:
|
||||
bus = SignalBus()
|
||||
ev = SignalEvent(source="s", kind="k", payload={})
|
||||
bus.publish(ev) # must not raise
|
||||
|
||||
def test_subscriber_count_zero_initially(self) -> None:
|
||||
bus = SignalBus()
|
||||
assert bus.subscriber_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_drop_oldest_on_overflow(self) -> None:
|
||||
from circuitforge_core.signal_bus.bus import _enqueue_drop_oldest
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=2)
|
||||
_enqueue_drop_oldest(q, "a")
|
||||
_enqueue_drop_oldest(q, "b")
|
||||
_enqueue_drop_oldest(q, "c") # drops "a"
|
||||
assert q.qsize() == 2
|
||||
assert q.get_nowait() == "b"
|
||||
assert q.get_nowait() == "c"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_drop_oldest_empty_queue(self) -> None:
|
||||
from circuitforge_core.signal_bus.bus import _enqueue_drop_oldest
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=5)
|
||||
_enqueue_drop_oldest(q, "x")
|
||||
assert q.get_nowait() == "x"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SignalBus — integration via FastAPI TestClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_bus() -> SignalBus:
|
||||
return SignalBus(keepalive_interval=0.1)
|
||||
|
||||
|
||||
def _inject_subscriber(bus: SignalBus, q: "asyncio.Queue[str]") -> None:
|
||||
"""Register a pre-built queue as a subscriber (test helper)."""
|
||||
bus._loop = asyncio.get_event_loop()
|
||||
with bus._lock:
|
||||
bus._subscribers[id(q)] = q
|
||||
|
||||
|
||||
class TestSignalBusIntegration:
|
||||
"""Integration tests verifying publish/subscribe queue mechanics directly.
|
||||
|
||||
httpx.ASGITransport cannot cancel an unbounded SSE generator — the transport
|
||||
buffers outbound chunks but never delivers a disconnect signal to the ASGI
|
||||
generator, so any test that exits a client.stream() block before the generator
|
||||
finishes hangs forever. These tests bypass ASGI and inspect the subscriber
|
||||
queues directly, which is both faster and more reliable.
|
||||
|
||||
The ASGI wiring is a one-liner (StreamingResponse) verified by
|
||||
test_subscribe_returns_streaming_response below.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_delivered_to_subscriber_queue(self) -> None:
|
||||
"""publish() enqueues an SSE-formatted string to all subscriber queues."""
|
||||
bus = _make_bus()
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=10)
|
||||
_inject_subscriber(bus, q)
|
||||
|
||||
ev = SignalEvent(
|
||||
source="merlin", kind="gesture",
|
||||
payload={"name": "open_palm", "confidence": 0.94},
|
||||
timestamp="2026-01-01T00:00:00Z",
|
||||
)
|
||||
bus.publish(ev)
|
||||
await asyncio.sleep(0) # allow call_soon_threadsafe callback to run
|
||||
|
||||
assert not q.empty()
|
||||
sse = q.get_nowait()
|
||||
data = json.loads(sse[len("data: "):].strip())
|
||||
assert data["source"] == "merlin"
|
||||
assert data["kind"] == "gesture"
|
||||
assert data["payload"]["name"] == "open_palm"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_subscribers_each_receive_event(self) -> None:
|
||||
"""Every connected subscriber queue receives its own copy of the event."""
|
||||
bus = _make_bus()
|
||||
q1: asyncio.Queue = asyncio.Queue(maxsize=10)
|
||||
q2: asyncio.Queue = asyncio.Queue(maxsize=10)
|
||||
_inject_subscriber(bus, q1)
|
||||
bus._loop = asyncio.get_event_loop()
|
||||
with bus._lock:
|
||||
bus._subscribers[id(q2)] = q2
|
||||
|
||||
ev = SignalEvent(source="linnet", kind="tone", payload={}, timestamp="T")
|
||||
bus.publish(ev)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert not q1.empty()
|
||||
assert not q2.empty()
|
||||
d1 = json.loads(q1.get_nowait()[len("data: "):].strip())
|
||||
d2 = json.loads(q2.get_nowait()[len("data: "):].strip())
|
||||
assert d1["kind"] == "tone"
|
||||
assert d2["kind"] == "tone"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_safe_while_subscribed(self) -> None:
|
||||
"""Rapid publish() calls with active subscribers must not raise."""
|
||||
bus = _make_bus()
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
_inject_subscriber(bus, q)
|
||||
|
||||
errors: list[Exception] = []
|
||||
try:
|
||||
for i in range(20):
|
||||
bus.publish(SignalEvent(source="s", kind="k", payload={"i": i}, timestamp="T"))
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
await asyncio.sleep(0)
|
||||
assert not errors
|
||||
assert q.qsize() > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overflow_does_not_block_producer(self) -> None:
|
||||
"""A slow consumer with a tiny queue must not block publish()."""
|
||||
bus = SignalBus(queue_size=2, keepalive_interval=0.1)
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=2)
|
||||
_inject_subscriber(bus, q)
|
||||
|
||||
for i in range(50):
|
||||
bus.publish(SignalEvent(source="s", kind="k", payload={"i": i}, timestamp="T"))
|
||||
|
||||
await asyncio.sleep(0)
|
||||
# Drop-oldest: only the last 2 events survive
|
||||
assert q.qsize() == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_returns_streaming_response(self) -> None:
|
||||
"""subscribe() returns a StreamingResponse with text/event-stream media type."""
|
||||
bus = _make_bus()
|
||||
mock_req = MagicMock(spec=Request)
|
||||
|
||||
resp = bus.subscribe(mock_req)
|
||||
|
||||
assert isinstance(resp, StreamingResponse)
|
||||
assert resp.media_type == "text/event-stream"
|
||||
assert bus.subscriber_count == 1
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
"""Tests for circuitforge_core.task_bridge.client (cf-core #66)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from circuitforge_core.task_bridge.client import TaskBridgeError, push_tasks
|
||||
from circuitforge_core.task_bridge.models import ExternalTask
|
||||
|
||||
|
||||
def _task(**overrides) -> ExternalTask:
|
||||
fields = {
|
||||
"source_product": "kiwi",
|
||||
"external_id": "kiwi:item:1234",
|
||||
"title": "Use up milk",
|
||||
"due_at": "2026-07-07T00:00:00Z",
|
||||
}
|
||||
fields.update(overrides)
|
||||
return ExternalTask(**fields)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _mocked_httpx_post(handler):
|
||||
"""Patch httpx.post to route through an httpx.MockTransport handler."""
|
||||
transport = httpx.MockTransport(handler)
|
||||
|
||||
def fake_post(url, *, json=None, headers=None, timeout=None):
|
||||
with httpx.Client(transport=transport) as client:
|
||||
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||
|
||||
import circuitforge_core.task_bridge.client as client_module
|
||||
|
||||
original = client_module.httpx.post
|
||||
client_module.httpx.post = fake_post
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
client_module.httpx.post = original
|
||||
|
||||
|
||||
class TestPushTasksUnit:
|
||||
def test_sends_conformant_payload(self):
|
||||
captured = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = json.loads(request.content)
|
||||
captured["auth"] = request.headers.get("authorization")
|
||||
return httpx.Response(200, json={"accepted": 1})
|
||||
|
||||
with _mocked_httpx_post(handler):
|
||||
resp = push_tasks(
|
||||
"http://127.0.0.1:9999/import/tasks", "tok123", [_task()]
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert captured["auth"] == "Bearer tok123"
|
||||
assert captured["body"] == {"tasks": [_task().to_dict()]}
|
||||
|
||||
def test_pushes_multiple_tasks_in_one_batch(self):
|
||||
captured = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json={"accepted": 2})
|
||||
|
||||
tasks = [_task(external_id="kiwi:item:1"), _task(external_id="kiwi:item:2")]
|
||||
with _mocked_httpx_post(handler):
|
||||
push_tasks("http://127.0.0.1:9999/import/tasks", "tok", tasks)
|
||||
|
||||
assert len(captured["body"]["tasks"]) == 2
|
||||
|
||||
def test_raises_on_non_2xx_response(self):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, text="bad token")
|
||||
|
||||
with _mocked_httpx_post(handler):
|
||||
with pytest.raises(TaskBridgeError):
|
||||
push_tasks("http://127.0.0.1:9999/import/tasks", "bad", [_task()])
|
||||
|
||||
def test_raises_on_transport_error(self):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
with _mocked_httpx_post(handler):
|
||||
with pytest.raises(TaskBridgeError):
|
||||
push_tasks("http://127.0.0.1:9999/import/tasks", "tok", [_task()])
|
||||
|
||||
def test_empty_task_list_sends_empty_batch(self):
|
||||
captured = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json={"accepted": 0})
|
||||
|
||||
with _mocked_httpx_post(handler):
|
||||
push_tasks("http://127.0.0.1:9999/import/tasks", "tok", [])
|
||||
|
||||
assert captured["body"] == {"tasks": []}
|
||||
|
||||
|
||||
class _FakeImporterHandler(BaseHTTPRequestHandler):
|
||||
"""A minimal stand-in for Focus Flow's import API."""
|
||||
|
||||
received: list[dict] = []
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers["Content-Length"])
|
||||
body = json.loads(self.rfile.read(length))
|
||||
_FakeImporterHandler.received.append(
|
||||
{"body": body, "auth": self.headers.get("Authorization")}
|
||||
)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"accepted": len(body["tasks"])}).encode())
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # silence test output
|
||||
|
||||
|
||||
class TestPushTasksContract:
|
||||
"""Real local HTTP server standing in for Focus Flow's importer."""
|
||||
|
||||
def test_client_emits_conformant_payload_over_real_socket(self):
|
||||
_FakeImporterHandler.received = []
|
||||
server = HTTPServer(("127.0.0.1", 0), _FakeImporterHandler)
|
||||
port = server.server_address[1]
|
||||
thread = threading.Thread(target=server.handle_request, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
task = _task(notes="Opened 2026-07-01")
|
||||
resp = push_tasks(
|
||||
f"http://127.0.0.1:{port}/import/tasks", "pairing-token-abc", [task]
|
||||
)
|
||||
thread.join(timeout=5)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"accepted": 1}
|
||||
assert len(_FakeImporterHandler.received) == 1
|
||||
received = _FakeImporterHandler.received[0]
|
||||
assert received["auth"] == "Bearer pairing-token-abc"
|
||||
assert received["body"] == {"tasks": [task.to_dict()]}
|
||||
finally:
|
||||
server.server_close()
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
"""Tests for circuitforge_core.task_bridge.models (cf-core #66). No network."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from circuitforge_core.task_bridge.models import SCHEMA_VERSION, ExternalTask
|
||||
|
||||
|
||||
def _task(**overrides) -> ExternalTask:
|
||||
fields = {
|
||||
"source_product": "kiwi",
|
||||
"external_id": "kiwi:item:1234",
|
||||
"title": "Use up milk",
|
||||
"due_at": "2026-07-07T00:00:00Z",
|
||||
}
|
||||
fields.update(overrides)
|
||||
return ExternalTask(**fields)
|
||||
|
||||
|
||||
class TestExternalTaskDefaults:
|
||||
def test_default_kind_is_flexible(self):
|
||||
assert _task().kind == "flexible"
|
||||
|
||||
def test_default_status_is_active(self):
|
||||
assert _task().status == "active"
|
||||
|
||||
def test_default_schema_version(self):
|
||||
assert _task().schema_version == SCHEMA_VERSION == 1
|
||||
|
||||
def test_notes_defaults_to_none(self):
|
||||
assert _task().notes is None
|
||||
|
||||
def test_frozen(self):
|
||||
task = _task()
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
task.title = "other" # type: ignore
|
||||
|
||||
|
||||
class TestExternalTaskValidation:
|
||||
def test_rejects_empty_source_product(self):
|
||||
with pytest.raises(ValueError):
|
||||
_task(source_product="")
|
||||
|
||||
def test_rejects_empty_external_id(self):
|
||||
with pytest.raises(ValueError):
|
||||
_task(external_id="")
|
||||
|
||||
def test_rejects_empty_title(self):
|
||||
with pytest.raises(ValueError):
|
||||
_task(title="")
|
||||
|
||||
def test_rejects_non_flexible_kind(self):
|
||||
with pytest.raises(ValueError):
|
||||
_task(kind="critical")
|
||||
|
||||
def test_rejects_unknown_status(self):
|
||||
with pytest.raises(ValueError):
|
||||
_task(status="done")
|
||||
|
||||
def test_accepts_cancelled_status(self):
|
||||
assert _task(status="cancelled").status == "cancelled"
|
||||
|
||||
|
||||
class TestExternalTaskToDict:
|
||||
def test_to_dict_contains_all_fields(self):
|
||||
task = _task(notes="Opened 2026-07-01")
|
||||
d = task.to_dict()
|
||||
assert d == {
|
||||
"source_product": "kiwi",
|
||||
"external_id": "kiwi:item:1234",
|
||||
"title": "Use up milk",
|
||||
"due_at": "2026-07-07T00:00:00Z",
|
||||
"notes": "Opened 2026-07-01",
|
||||
"kind": "flexible",
|
||||
"status": "active",
|
||||
"schema_version": 1,
|
||||
}
|
||||
|
||||
def test_to_dict_is_json_serializable(self):
|
||||
import json
|
||||
|
||||
task = _task()
|
||||
json.dumps(task.to_dict()) # must not raise
|
||||
|
|
@ -7,8 +7,6 @@ so a zero-byte placeholder is sufficient.
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
|
@ -236,116 +234,3 @@ def test_find_rejects_max_new_tokens_above_max(client, video_file):
|
|||
json={"video_path": video_file, "event": "wave", "max_new_tokens": 99999},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ── /caption/upload ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_caption_upload_returns_200(client):
|
||||
resp = client.post(
|
||||
"/caption/upload",
|
||||
files={"file": ("sample.mp4", b"\x00" * 16, "video/mp4")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_caption_upload_response_matches_caption_shape(client):
|
||||
data = client.post(
|
||||
"/caption/upload",
|
||||
files={"file": ("sample.mp4", b"\x00" * 16, "video/mp4")},
|
||||
).json()
|
||||
assert isinstance(data["scene"], str) and data["scene"]
|
||||
assert isinstance(data["events"], list) and len(data["events"]) >= 1
|
||||
assert isinstance(data["caption"], str) and data["caption"]
|
||||
assert isinstance(data["model"], str)
|
||||
|
||||
|
||||
def test_caption_upload_preserves_file_extension(client, monkeypatch):
|
||||
seen_paths: list[str] = []
|
||||
original_caption = MockVideoBackend.caption
|
||||
|
||||
def _spy_caption(self, video_path, *, max_new_tokens=2048):
|
||||
seen_paths.append(video_path)
|
||||
return original_caption(self, video_path, max_new_tokens=max_new_tokens)
|
||||
|
||||
monkeypatch.setattr(MockVideoBackend, "caption", _spy_caption)
|
||||
|
||||
resp = client.post(
|
||||
"/caption/upload",
|
||||
files={"file": ("sample.mkv", b"\x00" * 16, "video/x-matroska")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert seen_paths and seen_paths[0].endswith(".mkv")
|
||||
|
||||
|
||||
def test_caption_upload_defaults_extension_when_filename_has_none(client, monkeypatch):
|
||||
seen_paths: list[str] = []
|
||||
original_caption = MockVideoBackend.caption
|
||||
|
||||
def _spy_caption(self, video_path, *, max_new_tokens=2048):
|
||||
seen_paths.append(video_path)
|
||||
return original_caption(self, video_path, max_new_tokens=max_new_tokens)
|
||||
|
||||
monkeypatch.setattr(MockVideoBackend, "caption", _spy_caption)
|
||||
|
||||
resp = client.post(
|
||||
"/caption/upload",
|
||||
files={"file": ("sample", b"\x00" * 16, "application/octet-stream")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert seen_paths and seen_paths[0].endswith(".mkv")
|
||||
|
||||
|
||||
def test_caption_upload_removes_temp_file_after_request(client, monkeypatch):
|
||||
captured_paths: list[str] = []
|
||||
original_caption = MockVideoBackend.caption
|
||||
|
||||
def _spy_caption(self, video_path, *, max_new_tokens=2048):
|
||||
captured_paths.append(video_path)
|
||||
return original_caption(self, video_path, max_new_tokens=max_new_tokens)
|
||||
|
||||
monkeypatch.setattr(MockVideoBackend, "caption", _spy_caption)
|
||||
|
||||
resp = client.post(
|
||||
"/caption/upload",
|
||||
files={"file": ("sample.mp4", b"\x00" * 16, "video/mp4")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert captured_paths
|
||||
assert not os.path.exists(captured_paths[0])
|
||||
|
||||
|
||||
def test_caption_upload_503_when_no_backend(client):
|
||||
video_app._backend = None
|
||||
resp = client.post(
|
||||
"/caption/upload",
|
||||
files={"file": ("sample.mp4", b"\x00" * 16, "video/mp4")},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
def test_caption_upload_500_and_cleans_up_on_backend_error(client, monkeypatch):
|
||||
captured_paths: list[str] = []
|
||||
|
||||
def _raise(self, video_path, *, max_new_tokens=2048):
|
||||
captured_paths.append(video_path)
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(MockVideoBackend, "caption", _raise)
|
||||
|
||||
resp = client.post(
|
||||
"/caption/upload",
|
||||
files={"file": ("sample.mp4", b"\x00" * 16, "video/mp4")},
|
||||
)
|
||||
assert resp.status_code == 500
|
||||
assert captured_paths
|
||||
assert not os.path.exists(captured_paths[0])
|
||||
|
||||
|
||||
def test_caption_upload_custom_max_new_tokens(client):
|
||||
resp = client.post(
|
||||
"/caption/upload",
|
||||
params={"max_new_tokens": 512},
|
||||
files={"file": ("sample.mp4", b"\x00" * 16, "video/mp4")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
|
|
|||
Loading…
Reference in a new issue