mvgeos-gui 0.6.14__tar.gz

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 (62) hide show
  1. mvgeos_gui-0.6.14/PKG-INFO +15 -0
  2. mvgeos_gui-0.6.14/pyproject.toml +42 -0
  3. mvgeos_gui-0.6.14/pyproject.toml.orig +36 -0
  4. mvgeos_gui-0.6.14/src/mvgeos_gui/__init__.py +16 -0
  5. mvgeos_gui-0.6.14/src/mvgeos_gui/__main__.py +6 -0
  6. mvgeos_gui-0.6.14/src/mvgeos_gui/app.py +106 -0
  7. mvgeos_gui-0.6.14/src/mvgeos_gui/approval/__init__.py +47 -0
  8. mvgeos_gui-0.6.14/src/mvgeos_gui/approval/presenter.py +227 -0
  9. mvgeos_gui-0.6.14/src/mvgeos_gui/approval/queue.py +187 -0
  10. mvgeos_gui-0.6.14/src/mvgeos_gui/approval/types.py +282 -0
  11. mvgeos_gui-0.6.14/src/mvgeos_gui/autocomplete.py +689 -0
  12. mvgeos_gui-0.6.14/src/mvgeos_gui/components/__init__.py +37 -0
  13. mvgeos_gui-0.6.14/src/mvgeos_gui/components/approval_dialog.py +612 -0
  14. mvgeos_gui-0.6.14/src/mvgeos_gui/components/artifact_drawer.py +175 -0
  15. mvgeos_gui-0.6.14/src/mvgeos_gui/components/chat_panel.py +436 -0
  16. mvgeos_gui-0.6.14/src/mvgeos_gui/components/command_palette.py +297 -0
  17. mvgeos_gui-0.6.14/src/mvgeos_gui/components/composer.py +669 -0
  18. mvgeos_gui-0.6.14/src/mvgeos_gui/components/context_gauge.py +22 -0
  19. mvgeos_gui-0.6.14/src/mvgeos_gui/components/diagnostics_panel.py +58 -0
  20. mvgeos_gui-0.6.14/src/mvgeos_gui/components/diff_review.py +229 -0
  21. mvgeos_gui-0.6.14/src/mvgeos_gui/components/diff_viewer.py +102 -0
  22. mvgeos_gui-0.6.14/src/mvgeos_gui/components/file_preview.py +65 -0
  23. mvgeos_gui-0.6.14/src/mvgeos_gui/components/file_tree.py +58 -0
  24. mvgeos_gui-0.6.14/src/mvgeos_gui/components/home_screen.py +107 -0
  25. mvgeos_gui-0.6.14/src/mvgeos_gui/components/keyboard.py +67 -0
  26. mvgeos_gui-0.6.14/src/mvgeos_gui/components/login_screen.py +100 -0
  27. mvgeos_gui-0.6.14/src/mvgeos_gui/components/message_parts.py +210 -0
  28. mvgeos_gui-0.6.14/src/mvgeos_gui/components/packages_panel.py +1150 -0
  29. mvgeos_gui-0.6.14/src/mvgeos_gui/components/review_rail.py +93 -0
  30. mvgeos_gui-0.6.14/src/mvgeos_gui/components/rune_settings_dialog.py +107 -0
  31. mvgeos_gui-0.6.14/src/mvgeos_gui/components/sessions_panel.py +142 -0
  32. mvgeos_gui-0.6.14/src/mvgeos_gui/components/settings_modal.py +188 -0
  33. mvgeos_gui-0.6.14/src/mvgeos_gui/components/shell.py +245 -0
  34. mvgeos_gui-0.6.14/src/mvgeos_gui/components/sidebar.py +374 -0
  35. mvgeos_gui-0.6.14/src/mvgeos_gui/components/sidebar_hint.py +21 -0
  36. mvgeos_gui-0.6.14/src/mvgeos_gui/components/skills_panel.py +220 -0
  37. mvgeos_gui-0.6.14/src/mvgeos_gui/components/status_bar.py +72 -0
  38. mvgeos_gui-0.6.14/src/mvgeos_gui/components/step_cards.py +332 -0
  39. mvgeos_gui-0.6.14/src/mvgeos_gui/components/workspace_settings_modal.py +154 -0
  40. mvgeos_gui-0.6.14/src/mvgeos_gui/context_usage.py +74 -0
  41. mvgeos_gui-0.6.14/src/mvgeos_gui/core/__init__.py +21 -0
  42. mvgeos_gui-0.6.14/src/mvgeos_gui/core/database.py +299 -0
  43. mvgeos_gui-0.6.14/src/mvgeos_gui/core/logging.py +250 -0
  44. mvgeos_gui-0.6.14/src/mvgeos_gui/core/security.py +109 -0
  45. mvgeos_gui-0.6.14/src/mvgeos_gui/git_workspace.py +206 -0
  46. mvgeos_gui-0.6.14/src/mvgeos_gui/main.py +355 -0
  47. mvgeos_gui-0.6.14/src/mvgeos_gui/models/__init__.py +43 -0
  48. mvgeos_gui-0.6.14/src/mvgeos_gui/models/artifact.py +30 -0
  49. mvgeos_gui-0.6.14/src/mvgeos_gui/models/diff.py +47 -0
  50. mvgeos_gui-0.6.14/src/mvgeos_gui/models/execution.py +77 -0
  51. mvgeos_gui-0.6.14/src/mvgeos_gui/models/message.py +76 -0
  52. mvgeos_gui-0.6.14/src/mvgeos_gui/models/session.py +17 -0
  53. mvgeos_gui-0.6.14/src/mvgeos_gui/models/skill.py +16 -0
  54. mvgeos_gui-0.6.14/src/mvgeos_gui/models/user.py +28 -0
  55. mvgeos_gui-0.6.14/src/mvgeos_gui/services/agent_service.py +1015 -0
  56. mvgeos_gui-0.6.14/src/mvgeos_gui/services/auth_service.py +120 -0
  57. mvgeos_gui-0.6.14/src/mvgeos_gui/services/config_service.py +292 -0
  58. mvgeos_gui-0.6.14/src/mvgeos_gui/services/tome_service.py +113 -0
  59. mvgeos_gui-0.6.14/src/mvgeos_gui/state.py +1863 -0
  60. mvgeos_gui-0.6.14/src/mvgeos_gui/styles.py +1413 -0
  61. mvgeos_gui-0.6.14/src/mvgeos_gui/transcript.py +377 -0
  62. mvgeos_gui-0.6.14/src/mvgeos_gui/utils.py +108 -0
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: mvgeos-gui
3
+ Version: 0.6.14
4
+ Summary: Desktop GUI for MvgeOS powered by NiceGUI
5
+ License-Expression: MIT
6
+ Requires-Dist: keyring>=24.0
7
+ Requires-Dist: mvgeos-agent>=0.6.14
8
+ Requires-Dist: mvgeos-core>=0.6.14
9
+ Requires-Dist: mvgeos-provider>=0.6.14
10
+ Requires-Dist: mvgeos-runes>=0.6.14
11
+ Requires-Dist: mvgeos-tome>=0.6.14
12
+ Requires-Dist: nicegui>=2.10.0
13
+ Requires-Dist: pathspec>=0.12.0
14
+ Requires-Dist: pywebview>=6.2.1
15
+ Requires-Python: >=3.13
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "mvgeos-gui"
3
+ version = "0.6.14"
4
+ description = "Desktop GUI for MvgeOS powered by NiceGUI"
5
+ requires-python = ">=3.13"
6
+ license = "MIT"
7
+ dependencies = [
8
+ "keyring>=24.0",
9
+ "mvgeos-agent>=0.6.14",
10
+ "mvgeos-core>=0.6.14",
11
+ "mvgeos-provider>=0.6.14",
12
+ "mvgeos-runes>=0.6.14",
13
+ "mvgeos-tome>=0.6.14",
14
+ "nicegui>=2.10.0",
15
+ "pathspec>=0.12.0",
16
+ "pywebview>=6.2.1",
17
+ ]
18
+
19
+ [project.scripts]
20
+ mvgeos-gui = "mvgeos_gui.main:main"
21
+
22
+ [tool.uv.sources.mvgeos-agent]
23
+ workspace = true
24
+
25
+ [tool.uv.sources.mvgeos-core]
26
+ workspace = true
27
+
28
+ [tool.uv.sources.mvgeos-provider]
29
+ workspace = true
30
+
31
+ [tool.uv.sources.mvgeos-runes]
32
+ workspace = true
33
+
34
+ [tool.uv.sources.mvgeos-tome]
35
+ workspace = true
36
+
37
+ [tool.ruff]
38
+ extend = "../pyproject.toml"
39
+
40
+ [build-system]
41
+ requires = ["uv_build>=0.11.2,<0.13"]
42
+ build-backend = "uv_build"
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "mvgeos-gui"
3
+ version = "0.6.14"
4
+ description = "Desktop GUI for MvgeOS powered by NiceGUI"
5
+ requires-python = ">=3.13"
6
+ license = "MIT"
7
+ dependencies = [
8
+ # Explicit floors: uv_build does not substitute the workspace version, so
9
+ # bare names ship as unbounded Requires-Dist. Bumped in lockstep by ci.yml.
10
+ "keyring>=24.0",
11
+ "mvgeos-agent>=0.6.14",
12
+ "mvgeos-core>=0.6.14",
13
+ "mvgeos-provider>=0.6.14",
14
+ "mvgeos-runes>=0.6.14",
15
+ "mvgeos-tome>=0.6.14",
16
+ "nicegui>=2.10.0",
17
+ "pathspec>=0.12.0",
18
+ "pywebview>=6.2.1",
19
+ ]
20
+
21
+ [project.scripts]
22
+ mvgeos-gui = "mvgeos_gui.main:main"
23
+
24
+ [tool.uv.sources]
25
+ mvgeos-agent = { workspace = true }
26
+ mvgeos-core = { workspace = true }
27
+ mvgeos-provider = { workspace = true }
28
+ mvgeos-runes = { workspace = true }
29
+ mvgeos-tome = { workspace = true }
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.11.2,<0.13"]
33
+ build-backend = "uv_build"
34
+
35
+ [tool.ruff]
36
+ extend = "../pyproject.toml"
@@ -0,0 +1,16 @@
1
+ """MvgeOS Desktop GUI package powered by NiceGUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+
7
+
8
+ def _installed_version() -> str:
9
+ """Return the installed distribution version ("unknown" if absent)."""
10
+ try:
11
+ return importlib.metadata.version("mvgeos-gui")
12
+ except importlib.metadata.PackageNotFoundError:
13
+ return "unknown"
14
+
15
+
16
+ __version__ = _installed_version()
@@ -0,0 +1,6 @@
1
+ """Entrypoint for python -m mvgeos_gui."""
2
+
3
+ from mvgeos_gui.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,106 @@
1
+ """Application setup and page route definitions for mvgeos-gui."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+
6
+ from nicegui import app, ui
7
+
8
+ from mvgeos_gui.components.keyboard import register_global_keyboard
9
+ from mvgeos_gui.components.shell import render_shell
10
+ from mvgeos_gui.core.database import init_db
11
+ from mvgeos_gui.state import AppState, ServerState
12
+ from mvgeos_gui.styles import inject_theme
13
+
14
+
15
+ def build_page(state: AppState | None = None) -> None:
16
+ """Construct the MvgeOS GUI layout on the current page."""
17
+ current_state = state or AppState()
18
+ current_state.load_tomes()
19
+ # A fresh page load re-selects the session the browser was in (when it
20
+ # still exists); otherwise the user starts with no active session.
21
+ current_state.restore_active_tome()
22
+
23
+ saved_theme = current_state._config_service.load_app_settings().theme
24
+ theme = saved_theme if saved_theme in ("dark", "light") else "dark"
25
+ current_state._dark_mode = inject_theme(theme)
26
+
27
+ ui.add_head_html("""
28
+ <script>
29
+ document.addEventListener('keydown', function(e) {
30
+ var isP = (e.key === 'p' || e.key === 'P');
31
+ var isK = (e.key === 'k' || e.key === 'K');
32
+ var mod = (e.metaKey || e.ctrlKey) && !e.altKey;
33
+ if (mod && e.shiftKey && isP) {
34
+ e.preventDefault();
35
+ }
36
+ if (mod && !e.shiftKey && isK) {
37
+ e.preventDefault();
38
+ }
39
+ });
40
+ </script>
41
+ """)
42
+
43
+ render_shell(current_state)
44
+ # Global chords (Ctrl/Cmd+K or Ctrl/Cmd+Shift+P palette, Esc priority
45
+ # chain). The head script above only preventDefaults the browser's own
46
+ # handling of the chords; this bridge is what actually responds to
47
+ # them. Registered exactly once with ignore=[] so the chords work
48
+ # while typing in the composer or any other input: a second
49
+ # registration would dispatch every chord twice and toggle the
50
+ # palette open then immediately closed again.
51
+ register_global_keyboard(current_state)
52
+
53
+ # Web mode: a disconnect/refresh must fail closed — pending approval
54
+ # casts are denied because their decision surface is gone. The
55
+ # presenter is resolved lazily: binding happens when the agent is
56
+ # created, which may be after this page is constructed. NiceGUI may
57
+ # also fire this on reconnect, where denial is still the safe answer.
58
+ def _on_client_disconnect() -> None:
59
+ presenter = current_state._approval_presenter
60
+ if presenter is not None:
61
+ presenter.on_client_disconnect()
62
+
63
+ ui.context.client.on_disconnect(_on_client_disconnect)
64
+
65
+
66
+ def _prewarm_client(client_state: AppState) -> None:
67
+ """Warm the per-client agent on page load (best effort, non-blocking)."""
68
+ service = client_state.get_agent_service()
69
+
70
+ async def _warm() -> None:
71
+ with contextlib.suppress(Exception):
72
+ await service.prewarm(client_state)
73
+
74
+ try:
75
+ loop = asyncio.get_running_loop()
76
+ except RuntimeError:
77
+ return
78
+ loop.create_task(_warm())
79
+
80
+
81
+ def init_app(server: ServerState | None = None) -> ServerState:
82
+ """Initialize application routes and return the ServerState instance.
83
+
84
+ Every browser session gets its own per-client AppState minted from the
85
+ server, so no UI state (dialogs, current view, sidebar, transcript,
86
+ plan mode, auth) leaks across sessions. Disconnecting clients are
87
+ dropped fail-closed.
88
+ """
89
+ init_db()
90
+ server_state = server or ServerState()
91
+
92
+ @ui.page("/")
93
+ def index_page() -> None:
94
+ client_state = server_state.new_client_state()
95
+ # A returning browser keeps its sidebar preference: seed the fresh
96
+ # per-client flag from the per-browser cookie.
97
+ with contextlib.suppress(Exception):
98
+ if app.storage.user.get("sidebar-collapsed", False):
99
+ client_state.sidebar_open = False
100
+ ui.context.client.on_disconnect(
101
+ lambda: server_state.drop_client_state(client_state)
102
+ )
103
+ build_page(client_state)
104
+ _prewarm_client(client_state)
105
+
106
+ return server_state
@@ -0,0 +1,47 @@
1
+ """Approval Rune GUI presenter package."""
2
+
3
+ from mvgeos_gui.approval.presenter import (
4
+ ApprovalPresenter,
5
+ bind_approval_presenter,
6
+ unbind_approval_presenter,
7
+ )
8
+ from mvgeos_gui.approval.types import (
9
+ APPROVAL_RUNE_NAME,
10
+ ActivityRecord,
11
+ ApprovalDecision,
12
+ ApprovalOutcome,
13
+ ApprovalReasonCode,
14
+ ApprovalRequest,
15
+ ApprovalScope,
16
+ PermissionsView,
17
+ SourceIdentity,
18
+ SpellGrant,
19
+ SpellIdentity,
20
+ TrustedProject,
21
+ describe_operation,
22
+ is_approval_rune_name,
23
+ is_mutating_request,
24
+ spell_source_label,
25
+ )
26
+
27
+ __all__ = [
28
+ "APPROVAL_RUNE_NAME",
29
+ "ActivityRecord",
30
+ "ApprovalDecision",
31
+ "ApprovalOutcome",
32
+ "ApprovalPresenter",
33
+ "ApprovalReasonCode",
34
+ "ApprovalRequest",
35
+ "ApprovalScope",
36
+ "PermissionsView",
37
+ "SourceIdentity",
38
+ "SpellGrant",
39
+ "SpellIdentity",
40
+ "TrustedProject",
41
+ "bind_approval_presenter",
42
+ "describe_operation",
43
+ "is_approval_rune_name",
44
+ "is_mutating_request",
45
+ "spell_source_label",
46
+ "unbind_approval_presenter",
47
+ ]
@@ -0,0 +1,227 @@
1
+ """GUI presenter for the Approval Rune's spell-cast gate.
2
+
3
+ The host binds one ``ApprovalPresenter`` to the engine-owned runner's
4
+ presenter slot at startup (see :func:`bind_approval_presenter`) and unbinds
5
+ it on shutdown. The engine invokes the presenter as
6
+ ``await presenter(request)`` with the canonical ``ApprovalRequest``; the
7
+ presenter drives the AppState-owned approval queue and returns the
8
+ Summoner's canonical ``ApprovalDecision``. With no presenter bound, the
9
+ engine denies (fail closed); every presenter-side failure also denies.
10
+
11
+ The rune owns policy matching, persistence, and audit; this module only
12
+ presents the request and returns the Summoner's decision.
13
+
14
+ Permissions data (the settings-cog screen) comes from the Approval Rune's
15
+ frozen ``get_permissions_view()`` / ``revoke_grant(kind, id)`` interface.
16
+ Resolution order: the bound runner's host-privileged ``get_rune`` accessor
17
+ (duck-typed; the engine owns that accessor), then an explicitly wired
18
+ source (:meth:`set_permissions_source`). Nothing presenter-related lives
19
+ on RuneAPI.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import contextlib
25
+ import inspect
26
+ import logging
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ from mvgeos_core.approval import (
30
+ ApprovalDecision,
31
+ ApprovalOutcome,
32
+ ApprovalReasonCode,
33
+ ApprovalRequest,
34
+ ApprovalScope,
35
+ deny,
36
+ )
37
+
38
+ from mvgeos_gui.approval.types import APPROVAL_RUNE_NAME, PermissionsView
39
+
40
+ if TYPE_CHECKING:
41
+ from mvgeos_gui.state import AppState
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ def _deny_unbound(request: Any) -> ApprovalDecision:
47
+ """Fail-closed denial when the request itself is unusable."""
48
+ try:
49
+ return deny(request, ApprovalReasonCode.FAILURE)
50
+ except Exception:
51
+ return ApprovalDecision(
52
+ outcome=ApprovalOutcome.DENY,
53
+ scope=ApprovalScope.ONCE,
54
+ reason_code=ApprovalReasonCode.FAILURE,
55
+ request_digest="",
56
+ )
57
+
58
+
59
+ class ApprovalPresenter:
60
+ """Host-side presenter: queues modal prompts, returns decisions.
61
+
62
+ Implements the engine's ``ApprovalPresenter`` contract
63
+ (``mvgeos_core.approval``): the engine invokes the presenter as
64
+ ``await presenter(request)``.
65
+ """
66
+
67
+ def __init__(self, state: AppState) -> None:
68
+ self._state = state
69
+ self._bound_runner: Any = None
70
+ self._permissions_source: Any = None
71
+
72
+ async def __call__(self, request: ApprovalRequest) -> ApprovalDecision:
73
+ """The engine's presenter contract: ``await presenter(request)``."""
74
+ return await self.request_approval(request)
75
+
76
+ async def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
77
+ """Await the Summoner's decision for one spell cast.
78
+
79
+ Never raises: any failure denies.
80
+ """
81
+ try:
82
+ future = self._state.enqueue_approval(request)
83
+ decision = await future
84
+ except Exception:
85
+ logger.exception("Approval presenter failed; denying")
86
+ decision = _deny_unbound(request)
87
+ if (
88
+ decision.outcome is ApprovalOutcome.ALLOW
89
+ and decision.scope is ApprovalScope.SESSION
90
+ ):
91
+ self._state.set_approval_session_badge(True)
92
+ return decision
93
+
94
+ def bind(self, runner: Any) -> bool:
95
+ """Install this presenter on the engine runner's presenter slot.
96
+
97
+ Returns True when the engine exposes the slot; False when the
98
+ engine contract has not landed yet (the gate then denies, per the
99
+ spec's fail-closed rule). Moving the presenter to a different
100
+ runner clears the old runner's slot first so a dead runner can
101
+ never receive Summoner decisions.
102
+ """
103
+ setter = getattr(runner, "set_approval_presenter", None)
104
+ if not callable(setter):
105
+ logger.warning(
106
+ "Engine runner has no approval presenter slot; "
107
+ "approval prompts will deny until the engine contract lands."
108
+ )
109
+ return False
110
+ old_runner = self._bound_runner
111
+ if old_runner is not None and old_runner is not runner:
112
+ old_clearer = getattr(old_runner, "clear_approval_presenter", None)
113
+ if callable(old_clearer):
114
+ with contextlib.suppress(Exception):
115
+ old_clearer()
116
+ setter(self)
117
+ self._bound_runner = runner
118
+ self._state._approval_presenter = self
119
+ return True
120
+
121
+ def unbind(self) -> None:
122
+ """Remove the presenter and deny every outstanding request."""
123
+ runner = self._bound_runner
124
+ self._bound_runner = None
125
+ if runner is not None:
126
+ clearer = getattr(runner, "clear_approval_presenter", None)
127
+ if callable(clearer):
128
+ with contextlib.suppress(Exception):
129
+ clearer()
130
+ self._state.cancel_pending_approvals()
131
+ self._state.set_approval_session_badge(False)
132
+ self._state._approval_presenter = None
133
+
134
+ def on_client_disconnect(self) -> None:
135
+ """Web mode: disconnect/refresh/lost client denies pending casts."""
136
+ self._state.cancel_pending_approvals()
137
+
138
+ def set_permissions_source(self, source: Any) -> None:
139
+ """Wire the Approval Rune's permissions interface explicitly."""
140
+ self._permissions_source = source
141
+
142
+ def _resolve_permissions_source(self) -> Any:
143
+ """Find the Approval Rune's permissions interface, if reachable.
144
+
145
+ Order: the bound runner's host-privileged ``get_rune`` accessor
146
+ (duck-typed; the engine owns that accessor), then the explicitly
147
+ wired source. Nothing presenter-related lives on RuneAPI.
148
+ """
149
+ runner = self._bound_runner
150
+ getter = getattr(runner, "get_rune", None)
151
+ if callable(getter):
152
+ try:
153
+ rune = getter(APPROVAL_RUNE_NAME)
154
+ except Exception:
155
+ logger.warning("Runner get_rune failed", exc_info=True)
156
+ rune = None
157
+ if rune is not None:
158
+ return rune
159
+ return self._permissions_source
160
+
161
+ async def get_permissions_view(self) -> PermissionsView | None:
162
+ """Return the parsed permissions view, or None when unavailable."""
163
+ source = self._resolve_permissions_source()
164
+ if source is None:
165
+ return None
166
+ getter = getattr(source, "get_permissions_view", None)
167
+ if not callable(getter):
168
+ return None
169
+ try:
170
+ result = getter()
171
+ if inspect.isawaitable(result):
172
+ result = await result
173
+ except Exception:
174
+ logger.warning("Approval Rune get_permissions_view failed", exc_info=True)
175
+ return None
176
+ return PermissionsView.from_dict(result)
177
+
178
+ async def revoke_grant(self, grant_kind: str, grant_id: str) -> bool:
179
+ """Revoke one grant through the Approval Rune; True when confirmed.
180
+
181
+ Uses the frozen ``revoke_grant(kind, id)`` action (kinds
182
+ "allow_rule", "deny_rule", "project", "session"). Revocation
183
+ persists immediately and takes effect before the next cast.
184
+ """
185
+ source = self._resolve_permissions_source()
186
+ if source is None:
187
+ return False
188
+ action = getattr(source, "revoke_grant", None)
189
+ if not callable(action):
190
+ return False
191
+ try:
192
+ result = action(grant_kind, grant_id)
193
+ if inspect.isawaitable(result):
194
+ result = await result
195
+ return bool(result)
196
+ except Exception:
197
+ logger.warning("Approval Rune revoke failed", exc_info=True)
198
+ return False
199
+
200
+
201
+ def bind_approval_presenter(state: AppState, agent: Any) -> ApprovalPresenter | None:
202
+ """Bind the GUI presenter to an agent's engine runner slot.
203
+
204
+ Reuses the state's presenter across agent recreations (the agent is
205
+ rebuilt on project/tome changes). Returns the presenter when bound,
206
+ None when the agent exposes no runner or the engine slot is missing.
207
+ """
208
+ presenter: ApprovalPresenter | None = state._approval_presenter
209
+ if presenter is None:
210
+ presenter = ApprovalPresenter(state)
211
+ runner = getattr(agent, "_runner", None)
212
+ if runner is None:
213
+ logger.debug("Agent exposes no engine runner; presenter not bound")
214
+ return None
215
+ if presenter.bind(runner):
216
+ return presenter
217
+ return None
218
+
219
+
220
+ def unbind_approval_presenter(state: AppState) -> None:
221
+ """Unbind the GUI presenter (shutdown): pending casts deny."""
222
+ presenter = state._approval_presenter
223
+ if presenter is not None:
224
+ presenter.unbind()
225
+ else:
226
+ state.cancel_pending_approvals()
227
+ state.set_approval_session_badge(False)
@@ -0,0 +1,187 @@
1
+ """AppState-owned approval queue: one modal, one cast.
2
+
3
+ Parallel spell-cast batches queue here and are presented strictly one at a
4
+ time: the active request holds the single future the presenter awaits, and
5
+ only when it resolves does the next queued request become active. Every
6
+ lost decision surface (dialog close, Escape, disconnect, shutdown, context
7
+ change) resolves outstanding futures as denied.
8
+
9
+ The queue stores the canonical ``mvgeos_core.approval`` types: the engine
10
+ isinstance-checks the returned ``ApprovalDecision``, and every decision is
11
+ bound to the active request's ``argument_digest``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ import os
19
+ from collections import deque
20
+ from dataclasses import dataclass
21
+
22
+ from mvgeos_core.approval import (
23
+ ApprovalDecision,
24
+ ApprovalOutcome,
25
+ ApprovalReasonCode,
26
+ ApprovalRequest,
27
+ ApprovalScope,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def _normalize_project_root(value: str) -> str:
34
+ """Platform-normalized form of a project root for comparison.
35
+
36
+ The engine hands ``project_root`` over as a raw string while the GUI
37
+ passes ``str(Path)``; on Windows those spell the same directory with
38
+ different separators (``/`` vs ``\\``), which a naive string compare
39
+ reads as a context change and force-denies the decision. Normalizing
40
+ both sides keeps the stale check fail-closed on genuinely different
41
+ directories while accepting identical ones.
42
+ """
43
+ return os.path.normcase(os.path.normpath(value))
44
+
45
+
46
+ def is_request_stale(
47
+ request: ApprovalRequest,
48
+ *,
49
+ project_root: str | None,
50
+ tome_id: str | None,
51
+ ) -> bool:
52
+ """Check whether a response arrived for a moved-on context.
53
+
54
+ The stale-response rule: if the active tome or project no longer
55
+ matches the frozen request, the response is discarded and the cast
56
+ denied. Cast identity and the argument digest are structural: the
57
+ queue always binds the decision to the active request, so they need
58
+ no separate check. An empty tome id counts as "no active tome".
59
+ """
60
+ if project_root is not None and _normalize_project_root(
61
+ request.project_root
62
+ ) != _normalize_project_root(project_root):
63
+ return True
64
+ live_tome = tome_id or None
65
+ request_tome = request.tome_id or None
66
+ if live_tome is None and request_tome is None:
67
+ return False
68
+ return request_tome != live_tome
69
+
70
+
71
+ def deny_request(
72
+ request: ApprovalRequest,
73
+ reason_code: ApprovalReasonCode = ApprovalReasonCode.FAILURE,
74
+ ) -> ApprovalDecision:
75
+ """Build a denial decision bound to one request's argument digest."""
76
+ return ApprovalDecision(
77
+ outcome=ApprovalOutcome.DENY,
78
+ scope=ApprovalScope.ONCE,
79
+ reason_code=reason_code,
80
+ request_digest=request.argument_digest,
81
+ )
82
+
83
+
84
+ @dataclass
85
+ class _ActiveApproval:
86
+ request: ApprovalRequest
87
+ future: asyncio.Future[ApprovalDecision]
88
+
89
+
90
+ class ApprovalQueue:
91
+ """FIFO queue of pending approval requests with a single active slot."""
92
+
93
+ def __init__(self) -> None:
94
+ self._pending: deque[_ActiveApproval] = deque()
95
+ self._active: _ActiveApproval | None = None
96
+
97
+ @property
98
+ def pending_count(self) -> int:
99
+ """Number of requests waiting behind the active one."""
100
+ return len(self._pending)
101
+
102
+ @property
103
+ def active_request(self) -> ApprovalRequest | None:
104
+ """The request currently shown in the modal, if any."""
105
+ return self._active.request if self._active is not None else None
106
+
107
+ def enqueue(self, request: ApprovalRequest) -> asyncio.Future[ApprovalDecision]:
108
+ """Queue a request; returns the future the presenter awaits."""
109
+ future: asyncio.Future[ApprovalDecision] = (
110
+ asyncio.get_running_loop().create_future()
111
+ )
112
+ self._pending.append(_ActiveApproval(request=request, future=future))
113
+ return future
114
+
115
+ def activate_next(self) -> ApprovalRequest | None:
116
+ """Move the queue head into the active slot (one modal at a time)."""
117
+ if self._active is not None or not self._pending:
118
+ return self.active_request
119
+ self._active = self._pending.popleft()
120
+ return self._active.request
121
+
122
+ def resolve_active(
123
+ self,
124
+ outcome: ApprovalOutcome,
125
+ scope: ApprovalScope = ApprovalScope.ONCE,
126
+ reason_code: ApprovalReasonCode = ApprovalReasonCode.USER,
127
+ *,
128
+ project_root: str | None = None,
129
+ tome_id: str | None = None,
130
+ ) -> ApprovalDecision | None:
131
+ """Resolve the active request, applying the stale-response rule.
132
+
133
+ Builds the decision bound to the active request's argument digest.
134
+ Returns the effective decision (forced deny when stale), or None
135
+ when no request is active.
136
+ """
137
+ active = self._active
138
+ if active is None:
139
+ return None
140
+ if is_request_stale(active.request, project_root=project_root, tome_id=tome_id):
141
+ logger.warning(
142
+ "Approval decision for cast %s arrived stale; denying",
143
+ active.request.cast_id,
144
+ )
145
+ outcome = ApprovalOutcome.DENY
146
+ scope = ApprovalScope.ONCE
147
+ reason_code = ApprovalReasonCode.FAILURE
148
+ effective = ApprovalDecision(
149
+ outcome=outcome,
150
+ scope=scope,
151
+ reason_code=reason_code,
152
+ request_digest=active.request.argument_digest,
153
+ )
154
+ self._active = None
155
+ if not active.future.done():
156
+ active.future.set_result(effective)
157
+ return effective
158
+
159
+ def deny_active(
160
+ self, reason_code: ApprovalReasonCode = ApprovalReasonCode.FAILURE
161
+ ) -> ApprovalDecision | None:
162
+ """Deny the active request (dialog closed, Escape, disconnect)."""
163
+ active = self._active
164
+ if active is None:
165
+ return None
166
+ return self.resolve_active(
167
+ ApprovalOutcome.DENY,
168
+ ApprovalScope.ONCE,
169
+ reason_code,
170
+ project_root=active.request.project_root,
171
+ tome_id=active.request.tome_id,
172
+ )
173
+
174
+ def cancel_all(self) -> list[ApprovalRequest]:
175
+ """Deny every outstanding request; returns the denied requests."""
176
+ denied: list[ApprovalRequest] = []
177
+ if self._active is not None:
178
+ denied.append(self._active.request)
179
+ if not self._active.future.done():
180
+ self._active.future.set_result(deny_request(self._active.request))
181
+ self._active = None
182
+ while self._pending:
183
+ item = self._pending.popleft()
184
+ denied.append(item.request)
185
+ if not item.future.done():
186
+ item.future.set_result(deny_request(item.request))
187
+ return denied