A channel's message slots are named from the client, on every backend
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>
This commit is contained in:
@@ -1,14 +1,4 @@
|
||||
"""
|
||||
Desktop RPC server functions.
|
||||
|
||||
Tests mizan's appropriateness for desktop apps:
|
||||
- Local file system access
|
||||
- SQLite CRUD
|
||||
- System introspection
|
||||
- Real-time channels (file watcher, app status)
|
||||
- No auth required (single-user desktop)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
@@ -21,11 +11,14 @@ from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mizan.client import client
|
||||
from mizan.channels import ReactChannel
|
||||
from mizan.setup.registry import register
|
||||
from mizan.channels import Channel
|
||||
from mizan.setup import register
|
||||
from mizan.channels import register as register_channel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# System Info
|
||||
# =============================================================================
|
||||
@@ -119,17 +112,21 @@ def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
|
||||
):
|
||||
try:
|
||||
stat = entry.stat()
|
||||
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, OSError):
|
||||
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}")
|
||||
|
||||
@@ -161,7 +158,7 @@ def read_file(request: HttpRequest, path: str) -> FileContentOutput:
|
||||
|
||||
stat = file_path.stat()
|
||||
|
||||
# Safety: limit to 1MB text files
|
||||
# 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)")
|
||||
|
||||
@@ -190,7 +187,6 @@ class WriteFileOutput(BaseModel):
|
||||
def write_file(request: HttpRequest, path: str, content: str) -> WriteFileOutput:
|
||||
file_path = Path(path).expanduser().resolve()
|
||||
|
||||
# Safety: only allow writing within home directory
|
||||
home = Path.home()
|
||||
if not str(file_path).startswith(str(home)):
|
||||
raise PermissionError(f"Can only write files within home directory: {home}")
|
||||
@@ -339,11 +335,14 @@ def delete_note(request: HttpRequest, id: int) -> DeleteNoteOutput:
|
||||
|
||||
try:
|
||||
note = Note.objects.get(pk=id)
|
||||
note.delete()
|
||||
return DeleteNoteOutput(id=id, deleted=True)
|
||||
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")
|
||||
|
||||
@@ -353,17 +352,18 @@ register(delete_note, "delete_note")
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AppStatusChannel(ReactChannel):
|
||||
class AppStatusChannel(Channel):
|
||||
"""Push app status updates to the UI (uptime, memory, etc.)."""
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
class ServerMessage(BaseModel):
|
||||
uptime_seconds: float
|
||||
memory_mb: float
|
||||
note_count: int
|
||||
timestamp: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True # Desktop app, no auth needed
|
||||
# One local user owns the whole process; there is no identity to check.
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "app_status"
|
||||
@@ -372,15 +372,16 @@ class AppStatusChannel(ReactChannel):
|
||||
register_channel(AppStatusChannel, "app_status")
|
||||
|
||||
|
||||
class NotesChannel(ReactChannel):
|
||||
class NotesChannel(Channel):
|
||||
"""Push notifications when notes are modified."""
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user