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:
2026-07-27 14:03:19 -04:00
parent 398c90fc8b
commit 3aafec6dd4
345 changed files with 11054 additions and 17359 deletions

View File

@@ -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):