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.
- mvgeos_gui-0.6.14/PKG-INFO +15 -0
- mvgeos_gui-0.6.14/pyproject.toml +42 -0
- mvgeos_gui-0.6.14/pyproject.toml.orig +36 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/__init__.py +16 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/__main__.py +6 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/app.py +106 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/approval/__init__.py +47 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/approval/presenter.py +227 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/approval/queue.py +187 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/approval/types.py +282 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/autocomplete.py +689 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/__init__.py +37 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/approval_dialog.py +612 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/artifact_drawer.py +175 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/chat_panel.py +436 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/command_palette.py +297 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/composer.py +669 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/context_gauge.py +22 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/diagnostics_panel.py +58 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/diff_review.py +229 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/diff_viewer.py +102 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/file_preview.py +65 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/file_tree.py +58 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/home_screen.py +107 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/keyboard.py +67 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/login_screen.py +100 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/message_parts.py +210 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/packages_panel.py +1150 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/review_rail.py +93 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/rune_settings_dialog.py +107 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/sessions_panel.py +142 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/settings_modal.py +188 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/shell.py +245 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/sidebar.py +374 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/sidebar_hint.py +21 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/skills_panel.py +220 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/status_bar.py +72 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/step_cards.py +332 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/components/workspace_settings_modal.py +154 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/context_usage.py +74 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/core/__init__.py +21 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/core/database.py +299 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/core/logging.py +250 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/core/security.py +109 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/git_workspace.py +206 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/main.py +355 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/models/__init__.py +43 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/models/artifact.py +30 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/models/diff.py +47 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/models/execution.py +77 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/models/message.py +76 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/models/session.py +17 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/models/skill.py +16 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/models/user.py +28 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/services/agent_service.py +1015 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/services/auth_service.py +120 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/services/config_service.py +292 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/services/tome_service.py +113 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/state.py +1863 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/styles.py +1413 -0
- mvgeos_gui-0.6.14/src/mvgeos_gui/transcript.py +377 -0
- 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,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
|