The IR called them react-message and django-message, so a FastAPI channel had to declare a DjangoMessage. They are client-message and server-message now, and the direction words hold wherever a channel is declared: Params / ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and friends so no backend names a type itself. Django's ReactChannel and FastAPI's ReactChannel are both Channel. mizan-fastapi never registered a channels extension, so build_ir() emitted no channel at all and every payload type was invisible to codegen. It registers one now. RegistryExtension is an ABC requiring all(), which is what the IR reads — an extension that cannot enumerate its registrations no longer exists. The gate that should have caught the rename could not: tests/afi registered no channel because mizan-rust had no channel registry to register one in, so a five-package rename of the wire contract passed byte-parity without a channel byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a #[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's split; the AFI fixture now carries a channel with every slot and one with a single slot, so all three backends prove the contract byte for byte. MizanChannel held three Option<String> beside three has_*() predicates and unwrapped them with defaults; it holds an ordered slot vector, so an absent slot is absent rather than defaulted. The channels target emitted a React hooks file that a stage1-only consumer could not compile — react emits that now. The codegen's parity tests byte-compared emitted source against baselines without ever compiling it: they compile the generated crate and run its tests, import the generated Python package and call every method, and typecheck each TypeScript target against a consumer. Also fixed at source: app_visitor printed its import diagnostic to stdout, the stream export_mizan_ir writes KDL to, so a failed import silently corrupted the IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on any non-JSON-serializable field default; Django and mizan-core derived Pascal names two different ways, disagreeing on every dotted channel name. ir.py builds a document and renders templates/ir/document.kdl.j2 rather than appending KDL strings with hand-tracked indentation, and named types resolve to a fixed point — a model reachable only through a union branch was referenced by a ref that no type block ever defined. The rest is the write-gate's own classifiers run over the standing tree: relative imports, silent swallows, Protocol contracts that should be ABCs, emitters hand-rendering target source, catch-all arms over closed enums, and comments narrating the project rather than the code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
421 lines
10 KiB
Python
421 lines
10 KiB
Python
import logging
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from django.http import HttpRequest
|
|
from pydantic import BaseModel
|
|
|
|
from mizan.client import client
|
|
from mizan.channels import Channel
|
|
from mizan.setup import register
|
|
from mizan.channels import register as register_channel
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# =============================================================================
|
|
# System Info
|
|
# =============================================================================
|
|
|
|
|
|
class SystemInfoOutput(BaseModel):
|
|
os_name: str
|
|
os_version: str
|
|
python_version: str
|
|
hostname: str
|
|
username: str
|
|
home_dir: str
|
|
cwd: str
|
|
cpu_count: int
|
|
mizan_version: str
|
|
|
|
|
|
@client(websocket=True)
|
|
def system_info(request: HttpRequest) -> SystemInfoOutput:
|
|
import mizan
|
|
|
|
return SystemInfoOutput(
|
|
os_name=platform.system(),
|
|
os_version=platform.version(),
|
|
python_version=sys.version.split()[0],
|
|
hostname=platform.node(),
|
|
username=os.getenv("USER", os.getenv("USERNAME", "unknown")),
|
|
home_dir=str(Path.home()),
|
|
cwd=os.getcwd(),
|
|
cpu_count=os.cpu_count() or 1,
|
|
mizan_version=getattr(mizan, "__version__", "dev"),
|
|
)
|
|
|
|
|
|
register(system_info, "system_info")
|
|
|
|
|
|
class DiskUsageOutput(BaseModel):
|
|
path: str
|
|
total_gb: float
|
|
used_gb: float
|
|
free_gb: float
|
|
percent_used: float
|
|
|
|
|
|
@client(websocket=True)
|
|
def disk_usage(request: HttpRequest, path: str = "/") -> DiskUsageOutput:
|
|
usage = shutil.disk_usage(path)
|
|
return DiskUsageOutput(
|
|
path=path,
|
|
total_gb=round(usage.total / (1024**3), 2),
|
|
used_gb=round(usage.used / (1024**3), 2),
|
|
free_gb=round(usage.free / (1024**3), 2),
|
|
percent_used=round(usage.used / usage.total * 100, 1),
|
|
)
|
|
|
|
|
|
register(disk_usage, "disk_usage")
|
|
|
|
|
|
# =============================================================================
|
|
# File System
|
|
# =============================================================================
|
|
|
|
|
|
class FileEntry(BaseModel):
|
|
name: str
|
|
path: str
|
|
is_dir: bool
|
|
size: int
|
|
modified: str
|
|
|
|
|
|
class ListFilesOutput(BaseModel):
|
|
directory: str
|
|
entries: list[FileEntry]
|
|
parent: str | None
|
|
|
|
|
|
@client(websocket=True)
|
|
def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
|
|
dir_path = Path(directory).expanduser().resolve()
|
|
|
|
if not dir_path.is_dir():
|
|
raise ValueError(f"Not a directory: {dir_path}")
|
|
|
|
entries = []
|
|
try:
|
|
for entry in sorted(
|
|
dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())
|
|
):
|
|
try:
|
|
stat = entry.stat()
|
|
except (PermissionError, OSError) as e:
|
|
# A broken symlink or an unreadable entry drops out of the
|
|
# listing rather than failing the whole directory.
|
|
logger.warning("Skipping %s: %s", entry, e)
|
|
continue
|
|
|
|
entries.append(
|
|
FileEntry(
|
|
name=entry.name,
|
|
path=str(entry),
|
|
is_dir=entry.is_dir(),
|
|
size=stat.st_size if not entry.is_dir() else 0,
|
|
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
)
|
|
)
|
|
except PermissionError:
|
|
raise PermissionError(f"Cannot read directory: {dir_path}")
|
|
|
|
parent = str(dir_path.parent) if dir_path.parent != dir_path else None
|
|
|
|
return ListFilesOutput(
|
|
directory=str(dir_path),
|
|
entries=entries,
|
|
parent=parent,
|
|
)
|
|
|
|
|
|
register(list_files, "list_files")
|
|
|
|
|
|
class FileContentOutput(BaseModel):
|
|
path: str
|
|
content: str
|
|
size: int
|
|
modified: str
|
|
|
|
|
|
@client(websocket=True)
|
|
def read_file(request: HttpRequest, path: str) -> FileContentOutput:
|
|
file_path = Path(path).expanduser().resolve()
|
|
|
|
if not file_path.is_file():
|
|
raise FileNotFoundError(f"File not found: {file_path}")
|
|
|
|
stat = file_path.stat()
|
|
|
|
# Reads are capped at 1MB so a large binary cannot be pulled into memory.
|
|
if stat.st_size > 1_048_576:
|
|
raise ValueError(f"File too large: {stat.st_size} bytes (max 1MB)")
|
|
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8")
|
|
except UnicodeDecodeError:
|
|
raise ValueError(f"Not a text file: {file_path}")
|
|
|
|
return FileContentOutput(
|
|
path=str(file_path),
|
|
content=content,
|
|
size=stat.st_size,
|
|
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
)
|
|
|
|
|
|
register(read_file, "read_file")
|
|
|
|
|
|
class WriteFileOutput(BaseModel):
|
|
path: str
|
|
size: int
|
|
|
|
|
|
@client(websocket=True)
|
|
def write_file(request: HttpRequest, path: str, content: str) -> WriteFileOutput:
|
|
file_path = Path(path).expanduser().resolve()
|
|
|
|
home = Path.home()
|
|
if not str(file_path).startswith(str(home)):
|
|
raise PermissionError(f"Can only write files within home directory: {home}")
|
|
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
file_path.write_text(content, encoding="utf-8")
|
|
|
|
return WriteFileOutput(path=str(file_path), size=len(content.encode("utf-8")))
|
|
|
|
|
|
register(write_file, "write_file")
|
|
|
|
|
|
class DeleteFileOutput(BaseModel):
|
|
path: str
|
|
deleted: bool
|
|
|
|
|
|
@client(websocket=True)
|
|
def delete_file(request: HttpRequest, path: str) -> DeleteFileOutput:
|
|
file_path = Path(path).expanduser().resolve()
|
|
|
|
home = Path.home()
|
|
if not str(file_path).startswith(str(home)):
|
|
raise PermissionError(f"Can only delete files within home directory: {home}")
|
|
|
|
if file_path.exists():
|
|
file_path.unlink()
|
|
return DeleteFileOutput(path=str(file_path), deleted=True)
|
|
|
|
return DeleteFileOutput(path=str(file_path), deleted=False)
|
|
|
|
|
|
register(delete_file, "delete_file")
|
|
|
|
|
|
# =============================================================================
|
|
# Notes CRUD (SQLite)
|
|
# =============================================================================
|
|
|
|
|
|
class NoteOutput(BaseModel):
|
|
id: int
|
|
title: str
|
|
content: str
|
|
pinned: bool
|
|
created_at: str
|
|
updated_at: str
|
|
|
|
|
|
class NoteListOutput(BaseModel):
|
|
notes: list[NoteOutput]
|
|
count: int
|
|
|
|
|
|
def _note_to_output(note) -> NoteOutput:
|
|
return NoteOutput(
|
|
id=note.id,
|
|
title=note.title,
|
|
content=note.content,
|
|
pinned=note.pinned,
|
|
created_at=note.created_at.isoformat(),
|
|
updated_at=note.updated_at.isoformat(),
|
|
)
|
|
|
|
|
|
@client(websocket=True)
|
|
def list_notes(request: HttpRequest) -> NoteListOutput:
|
|
from backend.models import Note
|
|
|
|
notes = Note.objects.all()
|
|
return NoteListOutput(
|
|
notes=[_note_to_output(n) for n in notes],
|
|
count=notes.count(),
|
|
)
|
|
|
|
|
|
register(list_notes, "list_notes")
|
|
|
|
|
|
@client(websocket=True)
|
|
def create_note(
|
|
request: HttpRequest, title: str, content: str = "", pinned: bool = False
|
|
) -> NoteOutput:
|
|
from backend.models import Note
|
|
|
|
note = Note.objects.create(title=title, content=content, pinned=pinned)
|
|
return _note_to_output(note)
|
|
|
|
|
|
register(create_note, "create_note")
|
|
|
|
|
|
@client(websocket=True)
|
|
def get_note(request: HttpRequest, id: int) -> NoteOutput:
|
|
from backend.models import Note
|
|
|
|
try:
|
|
note = Note.objects.get(pk=id)
|
|
except Note.DoesNotExist:
|
|
raise ValueError(f"Note {id} not found")
|
|
|
|
return _note_to_output(note)
|
|
|
|
|
|
register(get_note, "get_note")
|
|
|
|
|
|
@client(websocket=True)
|
|
def update_note(
|
|
request: HttpRequest,
|
|
id: int,
|
|
title: str | None = None,
|
|
content: str | None = None,
|
|
pinned: bool | None = None,
|
|
) -> NoteOutput:
|
|
from backend.models import Note
|
|
|
|
try:
|
|
note = Note.objects.get(pk=id)
|
|
except Note.DoesNotExist:
|
|
raise ValueError(f"Note {id} not found")
|
|
|
|
if title is not None:
|
|
note.title = title
|
|
if content is not None:
|
|
note.content = content
|
|
if pinned is not None:
|
|
note.pinned = pinned
|
|
|
|
note.save()
|
|
return _note_to_output(note)
|
|
|
|
|
|
register(update_note, "update_note")
|
|
|
|
|
|
class DeleteNoteOutput(BaseModel):
|
|
id: int
|
|
deleted: bool
|
|
|
|
|
|
@client(websocket=True)
|
|
def delete_note(request: HttpRequest, id: int) -> DeleteNoteOutput:
|
|
from backend.models import Note
|
|
|
|
try:
|
|
note = Note.objects.get(pk=id)
|
|
except Note.DoesNotExist:
|
|
# Deleting an absent note is reported, not raised.
|
|
logger.info("delete_note: note %s is already absent", id)
|
|
return DeleteNoteOutput(id=id, deleted=False)
|
|
|
|
note.delete()
|
|
return DeleteNoteOutput(id=id, deleted=True)
|
|
|
|
|
|
register(delete_note, "delete_note")
|
|
|
|
|
|
# =============================================================================
|
|
# Channels — Real-time Desktop Events
|
|
# =============================================================================
|
|
|
|
|
|
class AppStatusChannel(Channel):
|
|
"""Push app status updates to the UI (uptime, memory, etc.)."""
|
|
|
|
class ServerMessage(BaseModel):
|
|
uptime_seconds: float
|
|
memory_mb: float
|
|
note_count: int
|
|
timestamp: str
|
|
|
|
def authorize(self, params=None):
|
|
# One local user owns the whole process; there is no identity to check.
|
|
return True
|
|
|
|
def group(self, params=None):
|
|
return "app_status"
|
|
|
|
|
|
register_channel(AppStatusChannel, "app_status")
|
|
|
|
|
|
class NotesChannel(Channel):
|
|
"""Push notifications when notes are modified."""
|
|
|
|
class ServerMessage(BaseModel):
|
|
action: str # "created", "updated", "deleted"
|
|
note_id: int
|
|
title: str
|
|
|
|
def authorize(self, params=None):
|
|
# One local user owns the whole process; there is no identity to check.
|
|
return True
|
|
|
|
def group(self, params=None):
|
|
return "notes_updates"
|
|
|
|
|
|
register_channel(NotesChannel, "notes_updates")
|
|
|
|
|
|
# =============================================================================
|
|
# App Lifecycle
|
|
# =============================================================================
|
|
|
|
_start_time = time.time()
|
|
|
|
|
|
class AppInfoOutput(BaseModel):
|
|
app_name: str
|
|
uptime_seconds: float
|
|
db_path: str
|
|
pid: int
|
|
|
|
|
|
@client(websocket=True)
|
|
def app_info(request: HttpRequest) -> AppInfoOutput:
|
|
from django.conf import settings
|
|
|
|
return AppInfoOutput(
|
|
app_name="mizan Desktop",
|
|
uptime_seconds=round(time.time() - _start_time, 2),
|
|
db_path=str(settings.DATABASES["default"]["NAME"]),
|
|
pid=os.getpid(),
|
|
)
|
|
|
|
|
|
register(app_info, "app_info")
|