millwork 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. millwork/__init__.py +45 -0
  2. millwork/_bundle/dist/.vite/manifest.json +92 -0
  3. millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
  4. millwork/_bundle/dist/favicon.svg +15 -0
  5. millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  6. millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  7. millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  8. millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  9. millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  10. millwork/_bundle/dist/icons.svg +24 -0
  11. millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
  12. millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
  13. millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
  14. millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
  15. millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
  16. millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
  17. millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
  18. millwork/app.py +726 -0
  19. millwork/auth.py +877 -0
  20. millwork/chat.py +397 -0
  21. millwork/cli.py +316 -0
  22. millwork/context.py +42 -0
  23. millwork/files.py +450 -0
  24. millwork/forms.py +349 -0
  25. millwork/manual/__init__.py +449 -0
  26. millwork/manual/components/Hello.jsx +24 -0
  27. millwork/manual/components/Voice.jsx +46 -0
  28. millwork/manual/pages/app/auth.md +188 -0
  29. millwork/manual/pages/app/pages.md +200 -0
  30. millwork/manual/pages/app/running.md +242 -0
  31. millwork/manual/pages/app/state.md +151 -0
  32. millwork/manual/pages/app/testing.md +147 -0
  33. millwork/manual/pages/app/theming.md +122 -0
  34. millwork/manual/pages/components/charts.md +263 -0
  35. millwork/manual/pages/components/chat.md +280 -0
  36. millwork/manual/pages/components/custom.md +148 -0
  37. millwork/manual/pages/components/data.md +112 -0
  38. millwork/manual/pages/components/feedback.md +212 -0
  39. millwork/manual/pages/components/files.md +194 -0
  40. millwork/manual/pages/components/form.md +135 -0
  41. millwork/manual/pages/components/inputs.md +360 -0
  42. millwork/manual/pages/components/layout.md +154 -0
  43. millwork/manual/pages/components/tasks.md +140 -0
  44. millwork/manual/pages/components/text.md +145 -0
  45. millwork/manual/pages/index.md +67 -0
  46. millwork/manual/public/fonts/InterVariable.woff2 +0 -0
  47. millwork/manual/public/fonts/LICENSE.txt +2 -0
  48. millwork/manual/public/mark.svg +15 -0
  49. millwork/manual/public/theme.json +97 -0
  50. millwork/nodes.py +300 -0
  51. millwork/protocol.py +238 -0
  52. millwork/proxy.py +122 -0
  53. millwork/py.typed +0 -0
  54. millwork/session.py +1422 -0
  55. millwork/shell.py +226 -0
  56. millwork/tasks.py +473 -0
  57. millwork/testing.py +336 -0
  58. millwork/theme.py +338 -0
  59. millwork/ui/__init__.py +725 -0
  60. millwork/ui/charts.py +214 -0
  61. millwork/ui/chat.py +97 -0
  62. millwork/ui/files.py +101 -0
  63. millwork/ui/form.py +102 -0
  64. millwork/ui/table.py +145 -0
  65. millwork/ws.py +220 -0
  66. millwork-0.1.0.dist-info/METADATA +131 -0
  67. millwork-0.1.0.dist-info/RECORD +70 -0
  68. millwork-0.1.0.dist-info/WHEEL +4 -0
  69. millwork-0.1.0.dist-info/entry_points.txt +2 -0
  70. millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
millwork/session.py ADDED
@@ -0,0 +1,1422 @@
1
+ """A session: one browser tab's state, tree, and connection.
2
+
3
+ The session owns the state object, renders the page from it, diffs against
4
+ the last tree it sent, and talks to the tab through whatever ``send``
5
+ callable the websocket attached. It outlives the socket for a grace
6
+ period so a reload or a dropped connection keeps its state.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import hmac
14
+ import inspect
15
+ import logging
16
+ import re
17
+ import secrets
18
+ import time
19
+ from collections import deque
20
+ from collections.abc import AsyncIterator, Awaitable, Callable
21
+ from dataclasses import dataclass
22
+ from typing import TYPE_CHECKING, Any, ClassVar
23
+
24
+ from millwork import context
25
+ from millwork.auth import Forbidden, NotFound
26
+ from millwork.chat import ChatTurn, Thread, UIMessage
27
+ from millwork.files import FileRef, Reject, SessionFiles, content_to_file
28
+ from millwork.forms import FormIssues, resolve_files, validate
29
+ from millwork.nodes import Component, Tree, build, diff
30
+ from millwork.protocol import (
31
+ Answer,
32
+ Command,
33
+ Event,
34
+ Head,
35
+ Mount,
36
+ Patch,
37
+ Reply,
38
+ Request,
39
+ ServerError,
40
+ SessionInfo,
41
+ Stream,
42
+ pack,
43
+ pack_frame,
44
+ )
45
+ from millwork.tasks import Cancelled, Task
46
+ from millwork.ui import Button, Dialog, Text, as_options
47
+ from millwork.ui.chat import Chat
48
+ from millwork.ui.files import Download, FileUpload
49
+ from millwork.ui.form import Form
50
+
51
+ if TYPE_CHECKING:
52
+ from millwork.app import App, PageDef
53
+
54
+ log = logging.getLogger("millwork.session")
55
+
56
+ Send = Callable[[str], Awaitable[None]]
57
+ SendBytes = Callable[[bytes], Awaitable[None]]
58
+ Close = Callable[[], Awaitable[None]]
59
+
60
+ RENDER_COALESCE_SECONDS = 0.05
61
+ # The replay window: what a tab that drops and comes back is caught up with.
62
+ # Bounded in messages and in bytes, since one message can carry a whole chat;
63
+ # a tab that fell behind the window is mounted afresh instead.
64
+ OUTBOX_KEEP = 500
65
+ OUTBOX_KEEP_BYTES = 4 * 1024 * 1024
66
+ # Messages from the tab still being handled; further ones are dropped until
67
+ # the backlog clears, so a flood cannot grow the task set without bound.
68
+ MAX_INFLIGHT = 64
69
+
70
+
71
+ class Session[S]:
72
+ def __init__(self, app: App, page: PageDef, params: dict[str, str], path: str) -> None:
73
+ self.id = secrets.token_urlsafe(16)
74
+ self.resume_token = secrets.token_urlsafe(24)
75
+ self.app = app
76
+ self.page = page
77
+ self.params = params
78
+ self.path = path
79
+ self.user: Any = None
80
+ # The path prefix the app runs under, as the tab's socket arrived under it,
81
+ # for anything the session renders into a browser URL.
82
+ self.base = ""
83
+ # Who opened it, for the per-client cap: the user's id, or the address.
84
+ self.client: str | None = None
85
+ self.created = time.monotonic()
86
+ # When its tab last went away, for the caps: the session longest without
87
+ # a tab makes room first. A session being connected or caught up still
88
+ # has its tab, even though nothing can be sent to it yet.
89
+ self.detached_at = self.created
90
+ self.connecting = False
91
+ self.end_reason: str | None = None
92
+ self.state: S = page.make_state()
93
+ self.tree: Tree | None = None
94
+ self.closed = False
95
+ self.files = SessionFiles(self)
96
+
97
+ self._send: Send | None = None
98
+ self._close_socket: Close | None = None
99
+ self._seq = 0
100
+ self._outbox: deque[tuple[int, str]] = deque()
101
+ self._outbox_bytes = 0
102
+ self._render_scheduled = False
103
+ self._render_handle: asyncio.TimerHandle | None = None
104
+ self._lock = asyncio.Lock()
105
+ self._lock_holder: asyncio.Task[Any] | None = None
106
+ self._grace: asyncio.TimerHandle | None = None
107
+ self._loop = asyncio.get_running_loop()
108
+ self.tasks: list[Task[Any]] = []
109
+ self._asks: dict[str, _Ask] = {}
110
+ self._toast_actions: dict[str, Callable[..., Any]] = {}
111
+ # Requests in flight by id, and the byte streams uploads feed into.
112
+ self._requests: dict[str, asyncio.Task[Any]] = {}
113
+ self._inbound: dict[str, _Inbound] = {}
114
+ # The handler request in flight per node and name: a newer one cancels it.
115
+ self._queries: dict[tuple[str, str], asyncio.Task[Any]] = {}
116
+ self._send_bytes: SendBytes | None = None
117
+ # The socket being attached, before it has caught up: unnumbered messages
118
+ # (a request's head or reply) may go to it straight away, since they take
119
+ # no part in the replay order.
120
+ self._catching_up: Send | None = None
121
+ self._file_slots = asyncio.Semaphore(FILE_SLOTS)
122
+ self._busy = False
123
+ # Development warnings: each is said once per session; counters tell a
124
+ # handler that changed nothing, and a thread that changed state, apart.
125
+ self._warned: set[str] = set()
126
+ self._slow_builds = 0
127
+ self._effects = 0
128
+ self._handler_runs = 0
129
+ self._noop_watch: tuple[str, int] | None = None
130
+ self._spawned: set[asyncio.Task[Any]] = set()
131
+ self._threads: dict[str, Thread] = {}
132
+
133
+ # ------------------------------------------------------------- connection
134
+
135
+ @property
136
+ def connected(self) -> bool:
137
+ return self._send is not None
138
+
139
+ def text(self, key: str, **vars: Any) -> str:
140
+ """A framework message in the theme's words, for toasts and errors."""
141
+ return self.app.theme.text(key, **vars)
142
+
143
+ @property
144
+ def has_tab(self) -> bool:
145
+ return self.connected or self.connecting or self._catching_up is not None
146
+
147
+ @property
148
+ def inflight(self) -> int:
149
+ """Messages from the tab whose handling has not finished."""
150
+ return sum(1 for t in self._spawned if not t.done())
151
+
152
+ def owns_resume(self, token: str | None) -> bool:
153
+ return token is not None and hmac.compare_digest(self.resume_token, token)
154
+
155
+ async def attach(
156
+ self,
157
+ send: Send,
158
+ last_seq: int,
159
+ close: Close | None = None,
160
+ send_bytes: SendBytes | None = None,
161
+ ) -> None:
162
+ """A tab connected (or reconnected). Replay what it missed, or mount.
163
+
164
+ A second attachment replaces the first: the earlier socket is closed
165
+ so a duplicated tab cannot keep dispatching into the session.
166
+ """
167
+ if self.closed:
168
+ return
169
+ if self._grace is not None:
170
+ self._grace.cancel()
171
+ self._grace = None
172
+ previous = self._close_socket
173
+ # Nothing goes to either socket while the switch is made. The new one is
174
+ # published only once it has caught up, so a message sent meanwhile lands
175
+ # in the outbox and is replayed in order instead of racing the replay.
176
+ self._send = None
177
+ self._close_socket = None
178
+ self._send_bytes = send_bytes
179
+ self._catching_up = send
180
+ self._abort_requests("the socket changed")
181
+ if previous is not None:
182
+ try:
183
+ await previous()
184
+ except Exception:
185
+ log.debug("closing the previous socket of %s failed", self.id)
186
+ try:
187
+ info = SessionInfo(
188
+ id=self.id, resume=self.resume_token, components=self.app.component_versions
189
+ )
190
+ await send(pack(self._seq, info))
191
+ if self.tree is not None and self._can_replay(last_seq):
192
+ caught_up = last_seq
193
+ while True:
194
+ pending = [(seq, text) for seq, text in self._outbox if seq > caught_up]
195
+ if not pending:
196
+ break
197
+ for seq, text in pending:
198
+ await send(text)
199
+ caught_up = seq
200
+ self._send, self._close_socket = send, close
201
+ self._catching_up = None
202
+ return
203
+ except Exception:
204
+ log.debug("the socket of %s failed before it caught up; detached", self.id)
205
+ self._catching_up = None
206
+ self.detach()
207
+ return
208
+ self._send, self._close_socket = send, close
209
+ self._catching_up = None
210
+ await self._mount()
211
+
212
+ def _can_replay(self, last_seq: int) -> bool:
213
+ if last_seq <= 0 or not self._outbox:
214
+ return False
215
+ first_seq = self._outbox[0][0]
216
+ return first_seq <= last_seq + 1 <= self._seq + 1
217
+
218
+ def detach(self, send: Send | None = None) -> None:
219
+ """A socket went away. Only the current one detaches the session."""
220
+ if send is not None and self._send is not send:
221
+ return
222
+ self._send = None
223
+ self._close_socket = None
224
+ self._send_bytes = None
225
+ self._catching_up = None
226
+ self.connecting = False
227
+ self.detached_at = time.monotonic()
228
+ self._abort_requests("the socket went away")
229
+ if self.closed:
230
+ return
231
+ self._grace = self._loop.call_later(self.app.session_grace, self._grace_expired)
232
+
233
+ def _grace_expired(self) -> None:
234
+ self._grace = None
235
+ if not self.connected and not self.closed:
236
+ self._loop.create_task(self.close())
237
+
238
+ async def end(self, message: str) -> None:
239
+ """Close the session and tell its tab why, so the tab stops instead of
240
+ reconnecting: signed out elsewhere, or made room for a newer session."""
241
+ if not self.closed:
242
+ self.end_reason = message
243
+ with contextlib.suppress(Exception):
244
+ await self._send_now(ServerError(message=message, fatal=True))
245
+ close = self._close_socket
246
+ if close is not None:
247
+ with contextlib.suppress(Exception):
248
+ await close()
249
+ await self.close()
250
+
251
+ async def close(self) -> None:
252
+ if self.closed:
253
+ return
254
+ self.closed = True
255
+ if self._render_handle is not None:
256
+ self._render_handle.cancel()
257
+ if self._grace is not None:
258
+ self._grace.cancel()
259
+ self._grace = None
260
+ self.app.sessions.pop(self.id, None)
261
+ self._abort_requests("the session closed")
262
+ for task in list(self.tasks):
263
+ task.cancel()
264
+ for spawned in list(self._spawned):
265
+ spawned.cancel()
266
+ for ask in list(self._asks.values()):
267
+ if not ask.future.done():
268
+ ask.future.cancel()
269
+ if self.page.on_close is not None:
270
+ try:
271
+ await _call(self.page.on_close, self)
272
+ except Exception:
273
+ log.exception("close hook failed for session %s", self.id)
274
+ self.app.files.discard(self.id)
275
+
276
+ # ------------------------------------------------------------- sending
277
+
278
+ async def _send_now(self, msg: Any) -> None:
279
+ """Number, remember for replay, and send if a tab is attached."""
280
+ self._effects += 1
281
+ self._seq += 1
282
+ text = pack(self._seq, msg)
283
+ self._outbox.append((self._seq, text))
284
+ self._outbox_bytes += len(text)
285
+ while len(self._outbox) > OUTBOX_KEEP or (
286
+ self._outbox_bytes > OUTBOX_KEEP_BYTES and len(self._outbox) > 1
287
+ ):
288
+ self._outbox_bytes -= len(self._outbox.popleft()[1])
289
+ await self._deliver(text)
290
+
291
+ async def _send_direct(self, msg: Any) -> None:
292
+ """Send without entering the replay window (the session info on attach)."""
293
+ await self._deliver(pack(self._seq, msg))
294
+
295
+ async def _send_unnumbered(self, msg: Any) -> None:
296
+ """A message outside the sequence: never replayed, never deduplicated, and
297
+ sent even to a socket that is still catching up on the replay."""
298
+ send = self._send or self._catching_up
299
+ if send is None:
300
+ return
301
+ try:
302
+ await send(pack(0, msg))
303
+ except Exception:
304
+ log.debug("unnumbered send failed for session %s", self.id)
305
+
306
+ async def _deliver(self, text: str) -> None:
307
+ if self._send is None:
308
+ return
309
+ try:
310
+ await self._send(text)
311
+ except Exception:
312
+ # The socket is dead whatever the endpoint later learns: detach now
313
+ # so the grace timer runs and the session cannot leak.
314
+ log.debug("send failed for session %s; detached", self.id)
315
+ self.detach(self._send)
316
+
317
+ def command(self, name: str, **args: Any) -> None:
318
+ """Fire-and-forget instruction to the tab: toast, navigate, clipboard."""
319
+ self._loop.create_task(self._send_now(Command(name=name, args=args)))
320
+
321
+ def toast(
322
+ self,
323
+ message: str,
324
+ tone: str = "default",
325
+ title: str | None = None,
326
+ action: tuple[str, Callable[..., Any]] | None = None,
327
+ ) -> None:
328
+ """A toast; ``action=(label, handler)`` adds a button that runs the handler once."""
329
+ args: dict[str, Any] = {"message": message, "tone": tone, "title": title}
330
+ if action is not None:
331
+ label, handler = action
332
+ token = secrets.token_urlsafe(8)
333
+ self._toast_actions[token] = handler
334
+ while len(self._toast_actions) > 20:
335
+ self._toast_actions.pop(next(iter(self._toast_actions)))
336
+ args["action"] = {"id": token, "label": label}
337
+ self.command("toast", **args)
338
+
339
+ def navigate(self, path: str) -> None:
340
+ """Go to another page on this session: its connect runs, the tab's history updates."""
341
+ self._loop.create_task(self.navigate_to(path))
342
+
343
+ def clipboard(self, text: str) -> None:
344
+ self.command("clipboard", text=text)
345
+
346
+ def logout(self) -> None:
347
+ """Clear the sign-in cookie and send the tab to the login page."""
348
+ self.command("logout")
349
+
350
+ def reset(self, key: str) -> None:
351
+ self.command("reset", key=key)
352
+
353
+ def download(self, filename: str, content: Any, mime: str | None = None) -> FileRef:
354
+ """Send a file to the tab without a button: the tab fetches it over the socket."""
355
+ ref = content_to_file(self, content, filename, mime)
356
+ self.command("download", id=ref.id, filename=filename)
357
+ return ref
358
+
359
+ # ------------------------------------------------------------- chat
360
+
361
+ def chat(self, key: str) -> Thread:
362
+ """The messages of the Chat with this key; created on first use."""
363
+ thread = self._threads.get(key)
364
+ if thread is None:
365
+ thread = self._threads[key] = Thread()
366
+ return thread
367
+
368
+ def drop_chat(self, key: str) -> None:
369
+ """Forget the thread under this key, unless a reply is running in it: the
370
+ Chat with that key starts empty next time. An app whose threads live in
371
+ a store keeps only the open one in the session this way."""
372
+ thread = self._threads.get(key)
373
+ if thread is not None and not thread.busy:
374
+ del self._threads[key]
375
+
376
+ async def stream(self, node: str, chunk: dict[str, Any]) -> None:
377
+ """One chunk of the AI SDK UI message stream to a Chat node. Never touches the tree."""
378
+ await self._send_now(Stream(node=node, chunk=chunk))
379
+
380
+ async def _chat_message(self, event: Event, component: Chat) -> None:
381
+ """A user turn: record the message, run the handler as this thread's turn."""
382
+ if component.key is None:
383
+ log.warning("a Chat needs a key to keep its messages")
384
+ return
385
+ thread = self.chat(component.key)
386
+ thread.limit = component.max_messages
387
+ if thread.busy:
388
+ self.toast(self.text("wait_for_reply"), tone="warning")
389
+ return
390
+ payload = event.payload if isinstance(event.payload, dict) else {}
391
+ text = str(payload.get("text") or "").strip()
392
+ if len(text) > component.max_chars:
393
+ self.toast(self.text("message_too_long"), tone="warning")
394
+ return
395
+ ids = [d.get("id") for d in payload.get("files") or [] if isinstance(d, dict)]
396
+ files = [
397
+ ref
398
+ for i in ids
399
+ if isinstance(i, str)
400
+ and (ref := self.files.get(i)) is not None
401
+ and ref.node == event.node
402
+ ]
403
+ if component.attachments is not None:
404
+ files = files[: component.attachments.max_files]
405
+ command = payload.get("command")
406
+ if not text and not files:
407
+ return
408
+ parts: list[dict[str, Any]] = []
409
+ if text:
410
+ parts.append({"type": "text", "text": text})
411
+ for ref in files:
412
+ parts.append(
413
+ {
414
+ "type": "file",
415
+ "mediaType": ref.mime,
416
+ "url": "",
417
+ "filename": ref.name,
418
+ "fileId": ref.id,
419
+ }
420
+ )
421
+ metadata: dict[str, Any] = {"at": time.time()}
422
+ if isinstance(command, str):
423
+ metadata["command"] = command
424
+ message = UIMessage(
425
+ id=secrets.token_urlsafe(8), role="user", parts=parts, metadata=metadata
426
+ )
427
+ thread.append(message)
428
+ self._chat_run(event.node, thread, message)
429
+
430
+ def _chat_run(self, node_id: str, thread: Thread, message: UIMessage) -> None:
431
+ """Run ``on_message`` for a user message the thread holds: a new one, or one
432
+ sent again after an edit or a regenerate."""
433
+ files = [
434
+ ref
435
+ for p in message.parts
436
+ if p.get("type") == "file"
437
+ and isinstance(p.get("fileId"), str)
438
+ and (ref := self.files.get(p["fileId"])) is not None
439
+ ]
440
+ command = (message.metadata or {}).get("command")
441
+ turn = ChatTurn(
442
+ self, node_id, thread, message, files, command if isinstance(command, str) else None
443
+ )
444
+ handler = self.tree.handler(node_id, "message") if self.tree else None
445
+ if handler is None:
446
+ self.refresh()
447
+ return
448
+
449
+ async def run() -> None:
450
+ # The lock is not held while a reply streams: tool steps, tasks and
451
+ # inline components render meanwhile; the composer is disabled by `busy`.
452
+ token = context.bind(self)
453
+ try:
454
+ await _call(handler, self, turn)
455
+ except asyncio.CancelledError:
456
+ pass
457
+ except Exception:
458
+ log.exception("chat handler failed")
459
+ self.toast(self.text("reply_failed"), tone="destructive")
460
+ finally:
461
+ context.unbind(token)
462
+ thread.turn_task = None
463
+ if thread.inflight is not None:
464
+ thread.append(thread.inflight)
465
+ thread.inflight = None
466
+ self.refresh()
467
+
468
+ self.refresh()
469
+ thread.turn_task = self.spawn(run(), "chat turn")
470
+
471
+ async def _chat_stop(self, event: Event, component: Chat) -> None:
472
+ thread = self.chat(component.key) if component.key else None
473
+ if thread is not None and thread.turn_task is not None:
474
+ thread.turn_task.cancel()
475
+ handler = self.tree.handler(event.node, "stop") if self.tree else None
476
+ if handler is not None:
477
+ await self._run_handler(handler, self, None)
478
+
479
+ def _chat_target(
480
+ self, event: Event, component: Chat, role: str
481
+ ) -> tuple[Thread, int, dict[str, Any]] | None:
482
+ """The thread and the index of the message an action names, when it is
483
+ one of this chat's with the expected role and the thread is free."""
484
+ if component.key is None:
485
+ return None
486
+ thread = self.chat(component.key)
487
+ payload = event.payload if isinstance(event.payload, dict) else {}
488
+ message_id = payload.get("id")
489
+ index = thread.find(message_id) if isinstance(message_id, str) else None
490
+ if index is None or thread.messages[index].role != role:
491
+ return None
492
+ return thread, index, payload
493
+
494
+ async def _chat_feedback(self, event: Event, component: Chat) -> None:
495
+ """Thumbs up or down on an assistant message, with a comment; kept in the
496
+ message's metadata so it shows after a reload, and given to ``on_feedback``."""
497
+ target = self._chat_target(event, component, "assistant")
498
+ if target is None:
499
+ return
500
+ thread, index, payload = target
501
+ value = payload.get("value")
502
+ if value not in ("up", "down", None):
503
+ return
504
+ comment = str(payload.get("comment") or "").strip()[:2000] or None
505
+ message = thread.messages[index]
506
+ metadata = dict(message.metadata or {})
507
+ if value is None:
508
+ metadata.pop("feedback", None)
509
+ else:
510
+ metadata["feedback"] = {"value": value, "comment": comment}
511
+ message.metadata = metadata or None
512
+ handler = self.tree.handler(event.node, "feedback") if self.tree else None
513
+ if handler is not None:
514
+ await self._run_handler(handler, self, message, value, comment)
515
+ else:
516
+ self.refresh()
517
+
518
+ async def _chat_action(self, event: Event, component: Chat) -> None:
519
+ target = self._chat_target(event, component, "assistant")
520
+ if target is None:
521
+ return
522
+ thread, index, payload = target
523
+ action = payload.get("action")
524
+ if not any(a.id == action for a in component.actions):
525
+ return
526
+ handler = self.tree.handler(event.node, "action") if self.tree else None
527
+ if handler is not None:
528
+ await self._run_handler(handler, self, thread.messages[index], action)
529
+
530
+ async def _chat_regenerate(self, event: Event, component: Chat) -> None:
531
+ """Answer a user message again: the assistant message named and everything
532
+ after it go, and ``on_message`` runs for the user message before it."""
533
+ target = self._chat_target(event, component, "assistant")
534
+ if target is None or not component.regenerate:
535
+ return
536
+ thread, index, _ = target
537
+ if thread.busy:
538
+ self.toast(self.text("wait_for_reply"), tone="warning")
539
+ return
540
+ user = next(
541
+ (i for i in range(index - 1, -1, -1) if thread.messages[i].role == "user"), None
542
+ )
543
+ if user is None:
544
+ return
545
+ thread.truncate(index)
546
+ self._chat_run(event.node, thread, thread.messages[user])
547
+
548
+ async def _chat_edit(self, event: Event, component: Chat) -> None:
549
+ """A user message with new text: what followed it goes, and it is answered again."""
550
+ target = self._chat_target(event, component, "user")
551
+ if target is None or not component.edit:
552
+ return
553
+ thread, index, payload = target
554
+ if thread.busy:
555
+ self.toast(self.text("wait_for_reply"), tone="warning")
556
+ return
557
+ text = str(payload.get("text") or "").strip()
558
+ if len(text) > component.max_chars:
559
+ self.toast(self.text("message_too_long"), tone="warning")
560
+ return
561
+ message = thread.messages[index]
562
+ files = [p for p in message.parts if p.get("type") == "file"]
563
+ if not text and not files:
564
+ return
565
+ message.parts = ([{"type": "text", "text": text}] if text else []) + files
566
+ thread.truncate(index + 1)
567
+ self._chat_run(event.node, thread, message)
568
+
569
+ def spawn(self, coro: Any, what: str = "work") -> asyncio.Task[Any]:
570
+ """Run a coroutine for this session: tracked, cancelled on close, failures logged."""
571
+ task = self._loop.create_task(coro)
572
+ self._spawned.add(task)
573
+
574
+ def done(t: asyncio.Task[Any]) -> None:
575
+ self._spawned.discard(t)
576
+ if t.cancelled():
577
+ return
578
+ exc = t.exception()
579
+ if exc is not None:
580
+ log.error("%s failed for session %s", what, self.id, exc_info=exc)
581
+
582
+ task.add_done_callback(done)
583
+ return task
584
+
585
+ # ------------------------------------------------------------- tasks
586
+
587
+ def task(
588
+ self,
589
+ work: Any,
590
+ *args: Any,
591
+ label: str | None = None,
592
+ key: str | None = None,
593
+ replace: bool = False,
594
+ delay: float | None = None,
595
+ timeout: float | None = None,
596
+ on_done: Callable[..., Any] | None = None,
597
+ quiet: bool = False,
598
+ **kwargs: Any,
599
+ ) -> Task[Any]:
600
+ """Start work the session owns."""
601
+ self._effects += 1
602
+ if key is not None:
603
+ for existing in self.tasks:
604
+ if existing.key == key and not existing.done:
605
+ if not replace:
606
+ return existing
607
+ existing.cancel()
608
+ name = label or getattr(work, "__name__", None) or "task"
609
+ task: Task[Any] = Task(self, name, key, quiet)
610
+ task._on_done = on_done
611
+ if on_done is not None:
612
+ task._observed = True
613
+ self.tasks.append(task)
614
+ if delay:
615
+ task._delay_handle = self._loop.call_later(
616
+ delay, lambda: task._start(work, args, kwargs, timeout)
617
+ )
618
+ self.refresh()
619
+ else:
620
+ task._start(work, args, kwargs, timeout)
621
+ return task
622
+
623
+ def later(self, delay: float, work: Any, *args: Any, **kwargs: Any) -> Task[Any]:
624
+ return self.task(work, *args, delay=delay, **kwargs)
625
+
626
+ def thread(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Task[Any]:
627
+ return self.task(fn, *args, **kwargs)
628
+
629
+ def _task_finished(self, task: Task[Any]) -> None:
630
+ """A task reached a terminal status: run ``on_done`` as a handler, then render."""
631
+ on_done = getattr(task, "_on_done", None)
632
+ if on_done is not None:
633
+ self._loop.create_task(self._run_handler(on_done, self, task))
634
+ if len(self.tasks) > 50:
635
+ self.tasks = [t for t in self.tasks if not t.done][-50:] + [
636
+ t for t in self.tasks if t.done
637
+ ][-10:]
638
+ self.refresh()
639
+
640
+ async def _run_handler(self, fn: Callable[..., Any], *args: Any) -> None:
641
+ async with self._hold():
642
+ token = context.bind(self)
643
+ self._handler_runs += 1
644
+ try:
645
+ await _call(fn, *args)
646
+ except Exception:
647
+ log.exception("handler %s failed", getattr(fn, "__name__", fn))
648
+ self.toast(self.text("something_went_wrong"), tone="destructive")
649
+ finally:
650
+ context.unbind(token)
651
+ self.refresh()
652
+
653
+ @contextlib.asynccontextmanager
654
+ async def _hold(self) -> AsyncIterator[None]:
655
+ """The one-handler-at-a-time lock, remembering who holds it so ``ask`` can let go."""
656
+ await self._lock.acquire()
657
+ self._lock_holder = asyncio.current_task()
658
+ try:
659
+ yield
660
+ finally:
661
+ self._lock_holder = None
662
+ self._lock.release()
663
+
664
+ # ------------------------------------------------------------- ask
665
+
666
+ async def ask( # noqa: ASYNC109
667
+ self, component: Component, timeout: float | None = None
668
+ ) -> Any:
669
+ """Render a component, wait for its submit or close, return the answer or None.
670
+
671
+ The handler lock is released while waiting, so other handlers run.
672
+ A ``Form`` inside answers with its validated model; a ``Dialog``'s
673
+ close, a cancel, or a timeout answers ``None``. The session closing
674
+ cancels the handler itself, as it does any other work.
675
+ """
676
+ request = secrets.token_urlsafe(8)
677
+ component = _askable(component)
678
+ ask = _Ask(request=request, component=component, future=self._loop.create_future())
679
+ self._asks[request] = ask
680
+ self.refresh()
681
+ try:
682
+ async with self._unlocked():
683
+ if timeout:
684
+ return await asyncio.wait_for(ask.future, timeout)
685
+ return await ask.future
686
+ except TimeoutError:
687
+ return None
688
+ finally:
689
+ self._asks.pop(request, None)
690
+ self.refresh()
691
+
692
+ @contextlib.asynccontextmanager
693
+ async def _unlocked(self) -> AsyncIterator[None]:
694
+ """Let go of the handler lock while waiting on the user or a task, then take it back."""
695
+ held = self._lock_holder is asyncio.current_task() and self._lock.locked()
696
+ if held:
697
+ self._lock_holder = None
698
+ self._lock.release()
699
+ try:
700
+ yield
701
+ finally:
702
+ if held:
703
+ await self._lock.acquire()
704
+ self._lock_holder = asyncio.current_task()
705
+
706
+ async def wait_for_task(self, task: Task[Any]) -> Any:
707
+ """``await task`` from a handler: renders keep flowing while it runs.
708
+
709
+ A cancelled task raises ``millwork.Cancelled`` (an ordinary exception the
710
+ handler can catch), never asyncio's cancellation of the handler itself.
711
+ """
712
+ async with self._unlocked():
713
+ try:
714
+ return await asyncio.shield(task.future)
715
+ except asyncio.CancelledError:
716
+ if task.status == "cancelled":
717
+ raise Cancelled(task.label) from None
718
+ raise
719
+
720
+ async def confirm(
721
+ self,
722
+ title: str,
723
+ description: str | None = None,
724
+ ok: str = "OK",
725
+ cancel: str = "Cancel",
726
+ destructive: bool = False,
727
+ ) -> bool:
728
+ buttons = _ConfirmButtons(
729
+ children=[
730
+ Button(cancel, key="cancel", variant="outline", on_click=_noop),
731
+ Button(
732
+ ok,
733
+ key="ok",
734
+ variant="destructive" if destructive else "primary",
735
+ on_click=_noop,
736
+ ),
737
+ ]
738
+ )
739
+ dialog = Dialog(
740
+ children=[Text(description)] if description else [],
741
+ title=title,
742
+ size="sm",
743
+ footer=buttons,
744
+ )
745
+ answer = await self.ask(dialog)
746
+ return answer is True
747
+
748
+ def _ask_root(self, request: str) -> str:
749
+ return f"{self.tree.root if self.tree else 'root'}/@ask:{request}"
750
+
751
+ async def _answer_ask(self, event: Event, component: Component | None) -> bool:
752
+ """An event inside an open ask: resolve it, or let it through to its own handler."""
753
+ for request, ask in self._asks.items():
754
+ prefix = self._ask_root(request)
755
+ if event.node != prefix and not event.node.startswith(prefix + "/"):
756
+ continue
757
+ if ask.future.done():
758
+ return True
759
+ if event.name == "submit" and isinstance(component, Form):
760
+ draft = resolve_files(event.payload, self._form_file(event.node))
761
+ instance, issues = validate(component.model, draft)
762
+ if issues:
763
+ self.command(
764
+ "form_issues", node=event.node, issues=[i.model_dump() for i in issues]
765
+ )
766
+ return True
767
+ ask.future.set_result(instance)
768
+ return True
769
+ if event.name in ("close", "cancel"):
770
+ ask.future.set_result(None)
771
+ return True
772
+ if event.name == "click" and event.node.endswith("/ok"):
773
+ ask.future.set_result(True)
774
+ return True
775
+ if event.name == "click" and event.node.endswith("/cancel"):
776
+ ask.future.set_result(None)
777
+ return True
778
+ return False
779
+ return False
780
+
781
+ # ------------------------------------------------------------- navigation
782
+
783
+ async def navigate_to(self, path: str) -> None:
784
+ """Switch this session to another page. State is per page; files and identity stay."""
785
+ # A fragment names a heading: the tab scrolls to it after the mount.
786
+ path, _, fragment = path.partition("#")
787
+ resolved = self.app.resolve(path) or self.app.fallback("not_found", path)
788
+ page, params = resolved
789
+ if self.app.auth is not None and not page.public and self.user is None:
790
+ await self._send_now(ServerError(message=self.text("sign_in_hint"), fatal=True))
791
+ return
792
+ # Under the handler lock: no render runs against the new page before its
793
+ # connect has finished, and no handler runs across the switch. The switch
794
+ # is provisional until connect succeeds: a page that fails to start leaves
795
+ # the tab on the page it had, still working.
796
+ async with self._hold():
797
+ previous = (self.page, self.params, self.path, self.state, self.tree)
798
+ self.page, self.params, self.path = page, params, path
799
+ self.state = page.make_state()
800
+ self.tree = None
801
+ self._slow_builds = 0
802
+ try:
803
+ await self.connect_or_fall_back()
804
+ except Exception:
805
+ log.exception("connect failed for %s", path)
806
+ self.page, self.params, self.path, self.state, self.tree = previous
807
+ await self._send_now(ServerError(message=self.text("page_failed"), fatal=False))
808
+ return
809
+ for ask in list(self._asks.values()):
810
+ if not ask.future.done():
811
+ ask.future.set_result(None)
812
+ self._asks.clear()
813
+ args: dict[str, Any] = {"path": path, "title": self.page.title}
814
+ if fragment:
815
+ args["hash"] = fragment
816
+ await self._send_now(Command(name="navigate", args=args))
817
+ await self._mount()
818
+
819
+ # ------------------------------------------------------------- rendering
820
+
821
+ def refresh(self) -> None:
822
+ """Render soon. Coalesced: many calls in one tick are one patch."""
823
+ if self._render_scheduled or self.closed:
824
+ return
825
+ self._render_scheduled = True
826
+ self._render_handle = self._loop.call_later(RENDER_COALESCE_SECONDS, self._flush)
827
+
828
+ def _flush(self) -> None:
829
+ self._render_scheduled = False
830
+ self._render_handle = None
831
+ self._loop.create_task(self._render())
832
+
833
+ async def _mount(self) -> None:
834
+ if self.closed:
835
+ return
836
+ tree = self._build()
837
+ if tree is None:
838
+ return
839
+ self.tree = tree
840
+ await self._send_now(Mount(root=tree.root, nodes=tree.nodes, title=self.page.title))
841
+
842
+ async def _render(self) -> None:
843
+ """Render under the handler lock, so a page never sees half-mutated state."""
844
+ if self.closed:
845
+ return
846
+ async with self._hold():
847
+ if self.closed:
848
+ return
849
+ if self.tree is None:
850
+ await self._mount()
851
+ return
852
+ tree = self._build()
853
+ if tree is None:
854
+ return
855
+ ops = diff(self.tree, tree)
856
+ self._check_keyless(self.tree, tree)
857
+ self.tree = tree
858
+ if ops:
859
+ await self._send_now(Patch(ops=ops))
860
+ self._signal_activity()
861
+ watch, self._noop_watch = self._noop_watch, None
862
+ if watch is not None and not ops and self._effects == watch[1]:
863
+ self.warn(
864
+ f"noop:{watch[0]}",
865
+ f"{watch[0]} changed nothing: no state, no command, no task. "
866
+ "A forgotten await, or a handler that should not be one?",
867
+ )
868
+
869
+ def _check_keyless(self, old: Tree, new: Tree) -> None:
870
+ """A run of keyless same-type siblings whose length changed is a data list
871
+ without keys: every row after the change is redrawn instead of moved."""
872
+ for node_id, kind in new.keyless_runs.items():
873
+ before = old.nodes.get(node_id)
874
+ if before is not None and len(before.children) != len(new.nodes[node_id].children):
875
+ self.warn(
876
+ f"keyless:{node_id}",
877
+ f"{node_id} is a list of {kind} rows without keys; give each a key so "
878
+ "a change moves rows instead of redrawing them",
879
+ )
880
+
881
+ def warn(self, key: str, message: str) -> None:
882
+ """A development warning: one log line per session and key, and in debug mode a
883
+ toast as well, so the author sees it where it happened."""
884
+ if key in self._warned:
885
+ return
886
+ self._warned.add(key)
887
+ log.warning("%s: %s", self.page.path, message)
888
+ if self.app.debug:
889
+ self.command("toast", message=message, tone="warning", title="millwork")
890
+
891
+ def _signal_activity(self) -> None:
892
+ """Tell the tab when work starts and ends, so it can keep a host that scales
893
+ to zero awake while a task or a reply is running."""
894
+ busy = any(not t.done for t in self.tasks) or any(th.busy for th in self._threads.values())
895
+ if busy != self._busy:
896
+ self._busy = busy
897
+ self.command("activity", busy=busy)
898
+
899
+ def _build(self) -> Tree | None:
900
+ # Bound for the render and the build: wire values (a FileRef, a task)
901
+ # read the current session while the tree is walked.
902
+ token = context.bind(self)
903
+ try:
904
+ started = time.perf_counter()
905
+ root = self.page.render(self)
906
+ took = (time.perf_counter() - started) * 1000
907
+ # Two slow builds in a row: a page that does I/O is slow every time,
908
+ # while one wall-clock hiccup (a collection, a busy process) is not.
909
+ self._slow_builds = self._slow_builds + 1 if took > SLOW_PAGE_MS else 0
910
+ if self._slow_builds >= 2:
911
+ self.warn(
912
+ "slow-page",
913
+ f"the page function took {took:.0f} ms; it should build the tree and "
914
+ "nothing else, so move I/O to connect or a task",
915
+ )
916
+ if not isinstance(root, Component):
917
+ raise TypeError(
918
+ f"page {self.page.path} returned {type(root).__name__}, not a component"
919
+ )
920
+ for request, ask in self._asks.items():
921
+ root.children.append(ask.component.model_copy(update={"key": f"@ask:{request}"}))
922
+ return build(root)
923
+ except Exception:
924
+ log.exception("page %s failed to render", self.page.path)
925
+ self._loop.create_task(
926
+ self._send_now(ServerError(message=self.text("render_failed"), fatal=False))
927
+ )
928
+ return None
929
+ finally:
930
+ context.unbind(token)
931
+
932
+ # ------------------------------------------------------------- events
933
+
934
+ async def _toast_action(self, token: str) -> None:
935
+ """The button on a toast was pressed: run its handler once, as a handler."""
936
+ handler = self._toast_actions.pop(token, None)
937
+ if handler is None:
938
+ log.debug("toast action %s unknown or already used", token)
939
+ return
940
+ async with self._hold():
941
+ ctx = context.bind(self)
942
+ try:
943
+ await _call(handler, self, None)
944
+ except Exception:
945
+ log.exception("toast action failed")
946
+ self.toast(self.text("something_went_wrong"), tone="destructive")
947
+ finally:
948
+ context.unbind(ctx)
949
+ self.refresh()
950
+
951
+ async def dispatch(self, event: Event) -> None:
952
+ """Run the handler bound to this event, then render.
953
+
954
+ Everything the tab sends is untrusted: the node must exist in the
955
+ current tree, a disabled component takes no events, an upload refers
956
+ only to files uploaded for that node, a form submit is validated
957
+ before its handler sees it.
958
+ """
959
+ if self.tree is None or self.closed:
960
+ return
961
+ if event.node.startswith("toast:") and event.name == "action":
962
+ await self._toast_action(event.node[6:])
963
+ return
964
+ component = self.tree.components.get(event.node)
965
+ if component is None:
966
+ log.debug("event %s for unknown node %s", event.name, event.node)
967
+ return
968
+ if self._asks and await self._answer_ask(event, component):
969
+ return
970
+ if getattr(component, "disabled", False):
971
+ log.debug("event %s on disabled node %s dropped", event.name, event.node)
972
+ return
973
+ if (
974
+ isinstance(component, Form)
975
+ and component.read_only
976
+ and event.name in ("submit", "cancel")
977
+ ):
978
+ return
979
+ payload = event.payload
980
+
981
+ if isinstance(component, Chat):
982
+ chat_events = {
983
+ "message": self._chat_message,
984
+ "stop": self._chat_stop,
985
+ "feedback": self._chat_feedback,
986
+ "action": self._chat_action,
987
+ "regenerate": self._chat_regenerate,
988
+ "edit": self._chat_edit,
989
+ }
990
+ if event.name in chat_events:
991
+ await chat_events[event.name](event, component)
992
+ return
993
+
994
+ if event.name == "download" and isinstance(component, Download):
995
+ token = context.bind(self)
996
+ try:
997
+ self.download(component.filename, component.content, component.mime)
998
+ except Exception:
999
+ log.exception("download %s failed", component.filename)
1000
+ self.toast(self.text("download_failed"), tone="destructive")
1001
+ finally:
1002
+ context.unbind(token)
1003
+ return
1004
+
1005
+ handler = self.tree.handler(event.node, event.name)
1006
+ if handler is None:
1007
+ log.warning("no handler for %s on %s", event.name, event.node)
1008
+ return
1009
+
1010
+ if event.name == "upload" and isinstance(component, FileUpload):
1011
+ # The tab echoes the upload route's answers; the session's own records are
1012
+ # the truth, and only files uploaded for this node count.
1013
+ ids = [d.get("id") for d in payload or [] if isinstance(d, dict)]
1014
+ payload = [
1015
+ ref
1016
+ for i in ids
1017
+ if isinstance(i, str)
1018
+ and (ref := self.files.get(i)) is not None
1019
+ and ref.node == event.node
1020
+ ][: component.max_files if component.multiple else 1]
1021
+ if not payload:
1022
+ return
1023
+
1024
+ if event.name == "submit" and isinstance(component, Form):
1025
+ draft = resolve_files(payload, self._form_file(event.node))
1026
+ instance, issues = validate(component.model, draft)
1027
+ if issues:
1028
+ self.command(
1029
+ "form_issues", node=event.node, issues=[i.model_dump() for i in issues]
1030
+ )
1031
+ return
1032
+ payload = instance
1033
+
1034
+ question = _confirmation(component, event)
1035
+ rejected = False
1036
+ async with self._hold():
1037
+ token = context.bind(self)
1038
+ try:
1039
+ if question is not None:
1040
+ title, destructive = question
1041
+ if not await self.confirm(title, destructive=destructive):
1042
+ return
1043
+ self._handler_runs += 1
1044
+ before = self._effects
1045
+ await _call(handler, self, payload)
1046
+ self._noop_watch = (f"the {event.name} handler on {event.node}", before)
1047
+ except FormIssues as exc:
1048
+ rejected = True
1049
+ self.command(
1050
+ "form_issues", node=event.node, issues=[i.model_dump() for i in exc.issues]
1051
+ )
1052
+ except Exception:
1053
+ log.exception("handler %s on %s failed", event.name, event.node)
1054
+ self.toast(self.text("something_went_wrong"), tone="destructive")
1055
+ finally:
1056
+ context.unbind(token)
1057
+ if event.name == "submit" and isinstance(component, Form) and not rejected:
1058
+ # The empty issue list is the form's acknowledgement: the draft was accepted.
1059
+ self.command("form_issues", node=event.node, issues=[])
1060
+ self.refresh()
1061
+
1062
+ def _form_file(self, node: str) -> Callable[[str], FileRef | None]:
1063
+ """A lookup for the files a form's draft refers to: uploaded for that form, or none."""
1064
+
1065
+ def lookup(file_id: str) -> FileRef | None:
1066
+ ref = self.files.get(file_id)
1067
+ return ref if ref is not None and ref.node == node else None
1068
+
1069
+ return lookup
1070
+
1071
+ # --------------------------------------------------------- requests
1072
+
1073
+ def start_request(self, msg: Request) -> None:
1074
+ """Begin answering a ``request``: registered here, synchronously, so that a
1075
+ ``cancel`` or binary frames right behind it on the socket find it."""
1076
+ if self.closed:
1077
+ return
1078
+ if msg.request in self._requests:
1079
+ self._send_later(Reply(request=msg.request, error="duplicate request"))
1080
+ return
1081
+ if len(self._requests) >= MAX_REQUESTS:
1082
+ self._send_later(Reply(request=msg.request, error="too many requests"))
1083
+ return
1084
+ if msg.name == "upload":
1085
+ self._inbound[msg.request] = _Inbound(msg.size or 0)
1086
+ task = self.spawn(self._request(msg), f"request {msg.name} on {msg.node}")
1087
+ self._requests[msg.request] = task
1088
+
1089
+ def _send_later(self, msg: Any) -> None:
1090
+ self._loop.create_task(self._send_unnumbered(msg))
1091
+
1092
+ async def feed(self, request: str, data: bytes) -> None:
1093
+ """A binary frame for an upload in flight. Unknown or finished: dropped."""
1094
+ stream = self._inbound.get(request)
1095
+ if stream is None:
1096
+ log.debug("frame for unknown request %s dropped", request)
1097
+ return
1098
+ await stream.put(data)
1099
+
1100
+ def cancel_request(self, request: str) -> None:
1101
+ task = self._requests.get(request)
1102
+ if task is not None:
1103
+ task.cancel()
1104
+
1105
+ def _abort_requests(self, why: str) -> None:
1106
+ """Every request belongs to one socket connection: when it goes, so do they.
1107
+ Their "cancelled" replies may still reach a socket attached later; the tab
1108
+ ignores replies to requests it never made."""
1109
+ for stream in self._inbound.values():
1110
+ stream.fail(why)
1111
+ for task in list(self._requests.values()):
1112
+ task.cancel()
1113
+
1114
+ async def _request(self, msg: Request) -> None:
1115
+ """Answer a ``request`` with a ``reply``, over the socket, without a render.
1116
+
1117
+ ``file`` streams one of the session's files to the tab; ``upload`` stores the
1118
+ bytes that follow the message; any other name is a handler on the node,
1119
+ run outside the handler lock (a search never waits on a click), whose
1120
+ return value is the payload. A newer handler request on the same node and
1121
+ name cancels the one in flight.
1122
+ """
1123
+ try:
1124
+ if msg.name == "file":
1125
+ await self._serve_file(msg)
1126
+ elif msg.name == "upload":
1127
+ await self._receive_upload(msg)
1128
+ else:
1129
+ await self._ask_handler(msg)
1130
+ except asyncio.CancelledError:
1131
+ # Cancelled by the tab, a newer request, or the socket going away: the
1132
+ # reply, if it still has somewhere to go, says so. The cancellation
1133
+ # itself goes on to whoever awaits this task.
1134
+ await self._reply(msg.request, error="cancelled")
1135
+ raise
1136
+ finally:
1137
+ self._requests.pop(msg.request, None)
1138
+ stream = self._inbound.pop(msg.request, None)
1139
+ if stream is not None:
1140
+ stream.fail("done")
1141
+
1142
+ async def _reply(self, request: str, payload: Any = None, error: str | None = None) -> None:
1143
+ await self._send_unnumbered(Reply(request=request, payload=payload, error=error))
1144
+
1145
+ async def _ask_handler(self, msg: Request) -> None:
1146
+ tree = self.tree
1147
+ component = tree.components.get(msg.node) if tree is not None else None
1148
+ handler = tree.handler(msg.node, msg.name) if tree is not None and component else None
1149
+ if handler is None or getattr(component, "disabled", False) or msg.size is not None:
1150
+ await self._reply(msg.request, error="unknown request")
1151
+ return
1152
+ key = (msg.node, msg.name)
1153
+ previous = self._queries.pop(key, None)
1154
+ if previous is not None:
1155
+ previous.cancel()
1156
+ self._queries[key] = self._requests[msg.request]
1157
+ token = context.bind(self)
1158
+ try:
1159
+ result = await _call(handler, self, msg.payload)
1160
+ if isinstance(result, list):
1161
+ result = [
1162
+ o.model_dump(exclude_none=True, exclude_defaults=True)
1163
+ for o in as_options(result)
1164
+ ]
1165
+ await self._reply(msg.request, payload=result)
1166
+ except asyncio.CancelledError:
1167
+ raise
1168
+ except Exception:
1169
+ log.exception("request %s on %s failed", msg.name, msg.node)
1170
+ await self._reply(msg.request, error="The request failed.")
1171
+ finally:
1172
+ context.unbind(token)
1173
+ if self._queries.get(key) is self._requests.get(msg.request):
1174
+ self._queries.pop(key, None)
1175
+
1176
+ async def _serve_file(self, msg: Request) -> None:
1177
+ """One of the session's files, as a ``head`` and binary frames, then the reply.
1178
+
1179
+ A few at a time per session, so a tab cannot hold an open file per request.
1180
+ """
1181
+ file_id = msg.payload.get("id") if isinstance(msg.payload, dict) else None
1182
+ ref = self.files.get(file_id) if isinstance(file_id, str) else None
1183
+ if ref is None or not ref.path.is_file():
1184
+ await self._reply(msg.request, error="no such file")
1185
+ return
1186
+ async with self._file_slots:
1187
+ head = {"name": ref.name, "mime": ref.mime, "size": ref.size}
1188
+ await self._send_unnumbered(Head(request=msg.request, payload=head))
1189
+ sent = 0
1190
+ with ref.path.open("rb") as fh:
1191
+ while True:
1192
+ chunk = await asyncio.to_thread(fh.read, STREAM_CHUNK)
1193
+ if not chunk:
1194
+ break
1195
+ send_bytes = self._send_bytes
1196
+ if send_bytes is None:
1197
+ return
1198
+ frame = pack_frame(msg.request, chunk)
1199
+ try:
1200
+ await send_bytes(frame)
1201
+ except Exception:
1202
+ log.debug("file stream to %s failed; detached", self.id)
1203
+ self.detach(self._send)
1204
+ return
1205
+ sent += len(chunk)
1206
+ await self._reply(msg.request, payload={"size": sent})
1207
+
1208
+ def upload_spec(self, node_id: str, field: str | None) -> tuple[list[str] | None, int]:
1209
+ """What an upload for this node may be: ``(accept, max_bytes)``. Raises ``Reject``."""
1210
+ from millwork.forms import field_spec, file_fields
1211
+
1212
+ component = self.tree.components.get(node_id) if self.tree is not None else None
1213
+ accept: list[str] | None
1214
+ max_mb: int | None
1215
+ if isinstance(component, FileUpload) and not component.disabled:
1216
+ accept, max_mb = component.accept, component.max_size_mb
1217
+ elif isinstance(component, Chat) and component.attachments and not component.disabled:
1218
+ accept, max_mb = component.attachments.accept, component.attachments.max_size_mb
1219
+ elif isinstance(component, Form) and not (component.disabled or component.read_only):
1220
+ # The pointer of a file field in the draft; a list index is dropped to find
1221
+ # it in the spec, which knows list items without an index.
1222
+ pointer = re.sub(r"/\d+(?=/|$)", "", field or "")
1223
+ spec = file_fields(field_spec(component.model)).get(pointer)
1224
+ if spec is None:
1225
+ raise Reject("no file field at this pointer")
1226
+ accept, max_mb = spec.get("accept"), spec.get("max_size_mb")
1227
+ else:
1228
+ raise Reject("no upload component for this node")
1229
+ return accept, (max_mb * 1024 * 1024 if max_mb else self.app.files.default_max_bytes)
1230
+
1231
+ async def _receive_upload(self, msg: Request) -> None:
1232
+ """Store the bytes that follow an ``upload`` request as a session file.
1233
+
1234
+ Frames are taken off the stream on the loop and each write goes to a
1235
+ thread, so an upload that stalls holds a queue, never a thread.
1236
+ """
1237
+ stream = self._inbound.get(msg.request)
1238
+ payload = msg.payload if isinstance(msg.payload, dict) else {}
1239
+ writer = None
1240
+ try:
1241
+ if stream is None or msg.size is None:
1242
+ raise Reject("an upload needs a size")
1243
+ field = payload.get("field")
1244
+ accept, max_bytes = self.upload_spec(
1245
+ msg.node, field if isinstance(field, str) else None
1246
+ )
1247
+ if msg.size > max_bytes:
1248
+ raise Reject(f"the file is larger than {max_bytes // (1024 * 1024)} MB")
1249
+ name = payload.get("name")
1250
+ mime = payload.get("mime")
1251
+ writer = self.app.files.begin(
1252
+ self,
1253
+ name if isinstance(name, str) and name else "file",
1254
+ mime=mime if isinstance(mime, str) else None,
1255
+ max_bytes=max_bytes,
1256
+ accept=accept,
1257
+ node=msg.node,
1258
+ )
1259
+ while True:
1260
+ chunk = await stream.get()
1261
+ if chunk is None:
1262
+ break
1263
+ await asyncio.to_thread(writer.write, chunk)
1264
+ ref = await asyncio.to_thread(writer.finish)
1265
+ except Reject as exc:
1266
+ if writer is not None:
1267
+ writer.abort()
1268
+ await self._reply(msg.request, error=exc.reason)
1269
+ return
1270
+ except asyncio.CancelledError:
1271
+ if writer is not None:
1272
+ writer.abort()
1273
+ raise
1274
+ except Exception:
1275
+ log.exception("upload on %s failed", msg.node)
1276
+ if writer is not None:
1277
+ writer.abort()
1278
+ await self._reply(msg.request, error="The upload failed.")
1279
+ return
1280
+ await self._reply(msg.request, payload=ref.__millwork_wire__())
1281
+
1282
+ async def answer(self, msg: Answer) -> None:
1283
+ """The tab answered an ``ask``. Asks arrive with tasks; until then, log."""
1284
+ log.warning("answer for unknown request %s", msg.request)
1285
+
1286
+ async def connect(self) -> None:
1287
+ """Run the page's connect hook, in the session context."""
1288
+ if self.page.on_connect is None:
1289
+ return
1290
+ token = context.bind(self)
1291
+ try:
1292
+ await _call(self.page.on_connect, self, **self.params)
1293
+ finally:
1294
+ context.unbind(token)
1295
+
1296
+ async def connect_or_fall_back(self) -> None:
1297
+ """Connect, and when the hook refuses with NotFound or Forbidden, land on the
1298
+ app's page for that instead, under the path that was asked for."""
1299
+ try:
1300
+ await self.connect()
1301
+ except (Forbidden, NotFound) as exc:
1302
+ kind = "forbidden" if isinstance(exc, Forbidden) else "not_found"
1303
+ self.page, self.params = self.app.fallback(kind, self.path, exc.message)
1304
+ self.state = self.page.make_state()
1305
+ await self.connect()
1306
+
1307
+
1308
+ def _confirmation(component: Component, event: Event) -> tuple[str, bool] | None:
1309
+ """The question to ask before this event's handler runs, if the component wants one.
1310
+
1311
+ ``Button.confirm`` carries its own text; a destructive menu item asks
1312
+ with its label, so an irreversible row action is never one slip away.
1313
+ """
1314
+ if isinstance(component, Button) and event.name == "click" and component.confirm:
1315
+ return component.confirm, component.variant == "destructive"
1316
+ if event.name == "action":
1317
+ payload = event.payload
1318
+ action = payload[0] if isinstance(payload, list | tuple) and payload else payload
1319
+ items = getattr(component, "row_actions", None) or getattr(component, "actions", None)
1320
+ for item in items or []:
1321
+ if item.id == action and item.destructive:
1322
+ return f"{item.label}?", True
1323
+ return None
1324
+
1325
+
1326
+ async def _call(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
1327
+ result = fn(*args, **kwargs)
1328
+ if inspect.isawaitable(result):
1329
+ result = await result
1330
+ return result
1331
+
1332
+
1333
+ @dataclass
1334
+ class _Ask:
1335
+ request: str
1336
+ component: Component
1337
+ future: asyncio.Future[Any]
1338
+
1339
+
1340
+ class _ConfirmButtons(Component):
1341
+ """The footer of a confirm dialog: a row the session recognises by its button keys."""
1342
+
1343
+ type: ClassVar[str] = "Row"
1344
+ gap: int = 2
1345
+ justify: str = "end"
1346
+
1347
+
1348
+ async def _noop(*args: Any, **kwargs: Any) -> None:
1349
+ return None
1350
+
1351
+
1352
+ def _askable(component: Component) -> Component:
1353
+ """A copy whose Dialog opens and closes, and whose Forms submit, into the ask."""
1354
+ update: dict[str, Any] = {}
1355
+ if isinstance(component, Dialog):
1356
+ update["open"] = True
1357
+ if component.on_close is None:
1358
+ update["on_close"] = _noop
1359
+ if isinstance(component, Form) and component.on_submit is None:
1360
+ update["on_submit"] = _noop
1361
+ for name in ("footer", "action"):
1362
+ slot = getattr(component, name, None)
1363
+ if isinstance(slot, Component):
1364
+ update[name] = _askable(slot)
1365
+ children = [_askable(c) if isinstance(c, Component) else c for c in component.children]
1366
+ update["children"] = children
1367
+ return component.model_copy(update=update)
1368
+
1369
+
1370
+ STREAM_CHUNK = 256 * 1024
1371
+ # A page function slower than this is doing more than building a tree.
1372
+ SLOW_PAGE_MS = 20
1373
+ # Requests in flight per session, of any kind, and files streaming out at once.
1374
+ MAX_REQUESTS = 32
1375
+ FILE_SLOTS = 4
1376
+
1377
+
1378
+ class _Inbound:
1379
+ """The byte stream of one upload: frames go in from the socket's receive loop,
1380
+ the upload task takes them out. Bounded, so a slow disk holds the receive
1381
+ loop back instead of buffering a whole file in memory. Failing it wakes
1382
+ both sides: a blocked putter, and a getter, which sees the failure."""
1383
+
1384
+ def __init__(self, size: int) -> None:
1385
+ self.size = size
1386
+ self.received = 0
1387
+ self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=16)
1388
+ self.failed: str | None = None
1389
+ if size == 0:
1390
+ self._queue.put_nowait(None)
1391
+
1392
+ async def put(self, data: bytes) -> None:
1393
+ if self.failed is not None:
1394
+ return
1395
+ self.received += len(data)
1396
+ if self.received > self.size:
1397
+ self.fail("more bytes than announced")
1398
+ return
1399
+ await self._queue.put(data)
1400
+ if self.failed is not None:
1401
+ return
1402
+ if self.received == self.size:
1403
+ await self._queue.put(None)
1404
+
1405
+ async def get(self) -> bytes | None:
1406
+ """The next chunk, or None at the end. Raises ``Reject`` once failed."""
1407
+ if self.failed is not None:
1408
+ raise Reject(f"the upload was interrupted: {self.failed}")
1409
+ item = await self._queue.get()
1410
+ if self.failed is not None:
1411
+ raise Reject(f"the upload was interrupted: {self.failed}")
1412
+ return item
1413
+
1414
+ def fail(self, why: str) -> None:
1415
+ if self.failed is not None:
1416
+ return
1417
+ self.failed = why
1418
+ # Drop what is queued: it wakes a putter blocked on a full queue, and makes
1419
+ # room for the end marker that wakes a waiting getter.
1420
+ while not self._queue.empty():
1421
+ self._queue.get_nowait()
1422
+ self._queue.put_nowait(None)