gini-toolkit 6.0.1.dev0__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 (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/app/context.py ADDED
@@ -0,0 +1,368 @@
1
+ """Application context + typed event bus.
2
+
3
+ Replaces the legacy global `mainWidgets` / `options` dictionaries. One AppContext
4
+ owns the topology and settings; components talk to each other through the typed
5
+ signal bus rather than reaching into global state by string key.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from PySide6.QtCore import QObject, Signal
12
+
13
+ from ..domain import Topology
14
+
15
+
16
+ class EventBus(QObject):
17
+ """Typed cross-component signals."""
18
+ topology_changed = Signal()
19
+ device_added = Signal(str) # device id
20
+ device_removed = Signal(str) # device id
21
+ device_changed = Signal(str) # device id (properties/position)
22
+ device_resized = Signal(str) # device id (size tier changed -> maybe live CPU update)
23
+ link_added = Signal(str) # link id
24
+ selection_changed = Signal(object) # device id or None
25
+ canvas_background_clicked = Signal() # left-click on empty canvas (exit sticky modes)
26
+ llm_reachable = Signal(str, bool) # (model, reachable) — async LLM health probe result
27
+ enrolment_changed = Signal(str, bool, int) # (student, course-server online, missions due)
28
+ boards_changed = Signal() # a real GINI32 board came online or went quiet
29
+ theme_changed = Signal(str) # theme name
30
+ log = Signal(str, str) # level, message
31
+ assistant_message = Signal(str, str) # role, text
32
+ run_state = Signal(bool, str) # ok, message (from the orchestrator worker thread)
33
+ device_activated = Signal(str) # device id (double-clicked -> open terminal/console)
34
+ machine_events = Signal(str) # xv6 device id — new teachable kernel events (proactive Coach)
35
+ device_delete_requested = Signal(str) # device id (right-click -> Delete)
36
+ warning_explain_requested = Signal(str) # device id (clicked its lint warning badge)
37
+ device_logs_requested = Signal(str) # device id (right-click -> View logs)
38
+ device_console_requested = Signal(str) # device id (right-click -> Open console in browser)
39
+ function_invoke_requested = Signal(str, str, str) # device id, method, body (Invoke a Function)
40
+ function_invoke_result = Signal(str, str) # device id, result text (back to inspector)
41
+ rider_ran = Signal(str, object) # rider device id, result dict {ok, raw, measurement, summary}
42
+ function_deploy_requested = Signal() # redeploy the faas runtime (AWS-style Deploy)
43
+ runtime_status = Signal(object) # {service: state} from the status poller
44
+ mission_changed = Signal(object) # Wizard objective (Mission) set/cleared -> guide the canvas
45
+ wizard_ghosts_requested = Signal(str) # device id -> resolve goal-relevant neighbours (LLM)
46
+ wizard_ghosts_ready = Signal(str, object) # device id, [(type_key, reason)] -> draw ghosts
47
+ fabric_metrics = Signal(object) # normalized cloud-fabric app metrics snapshot
48
+ k8s_metrics = Signal(object) # per-deployment kubernetes metrics snapshot
49
+ addressing_changed = Signal() # compiler-derived IP/MAC map refreshed
50
+ warnings_changed = Signal() # advisory topology-lint results refreshed
51
+ mission_flags_changed = Signal() # Mission move-legality flags refreshed -> red badges/glow
52
+ focus_requested = Signal(object) # device ids (or None = all) -> bring them into view
53
+ edges_restyled = Signal() # connector style (bent/straight) changed -> reroute edges
54
+ # --- AI tutor "present" channel (the stage the AI draws on) ---
55
+ present_spotlight = Signal(object) # list[device_id] to spotlight, or None to clear
56
+ present_highlight = Signal(object) # list[device_id] to ring, or None to clear
57
+ present_callout = Signal(str, str) # device_id, text
58
+ present_narrate = Signal(str) # narration text
59
+ present_packet = Signal(object) # list[device_id] path to animate
60
+ present_clear = Signal() # clear all overlays
61
+
62
+
63
+ @dataclass
64
+ class Settings:
65
+ theme: str = "dark"
66
+ grid: bool = True
67
+ connector_style: str = "orthogonal" # "orthogonal" (bent, rounded) | "straight"
68
+ snap_to_grid: bool = True
69
+ show_minimap: bool = True
70
+ autosave: bool = False
71
+ server: str = "localhost"
72
+ remote_port: int = 10000
73
+ local_port: int = 10001
74
+ # accessibility / motion
75
+ reduced_motion: bool = False
76
+ text_size: str = "Normal" # UI font scale: Normal | Large | Extra Large
77
+ high_contrast: bool = False
78
+ sound: bool = False
79
+ tutor_mode: bool = False
80
+ # onboarding: the Cue Cards feature tour
81
+ show_help_on_launch: bool = True
82
+ # self-hosted LLM (Ollama by default)
83
+ llm_enabled: bool = False
84
+ llm_url: str = "http://localhost:11434"
85
+ llm_model: str = "llama3.1"
86
+ llm_think: bool = False # ask reasoning models (e.g. gemma4:e2b) to think
87
+ llm_num_ctx: int = 8192 # context window (Ollama defaults to only 2048 + truncates)
88
+ # Reasoning 2.0: the deterministic Reasoning Twin audits mission-tutor turns for coverage
89
+ # (docs/REASONING_2.0_DESIGN.md). Off by default while phase A proves out.
90
+ twin_enabled: bool = False
91
+ # Build a missing backend image (gRouter / POX) automatically instead of printing the
92
+ # `docker build …` line and refusing to Run. Off by default: the first build takes a couple
93
+ # of minutes, and silently burning that on someone's first Run is a poor surprise.
94
+ autobuild_images: bool = False
95
+ # backend: run the lab on the LOCAL Docker daemon, or a remote brokered GINI server
96
+ # (a Kata-enabled Linux host reached over an authenticated API — see gini/server/).
97
+ backend: str = "local" # "local" | "gini-server"
98
+ gini_server_host: str = "" # the GINI server host (for "gini-server")
99
+ gini_server_port: int = 10000
100
+ gini_server_user: str = "" # username; password is entered at connect, never stored
101
+ # Teaching Center (the course server): released lessons, profile sync, submissions.
102
+ # Empty url = not enrolled -> Missions falls back to the local practice catalog.
103
+ tc_url: str = "" # e.g. http://localhost:8080
104
+ tc_course: str = "" # e.g. cs4480-fall26
105
+ tc_student: str = "" # the student's id in the course
106
+ tc_token: str = "" # one-time ENROLMENT token (spent when you claim the account)
107
+ # Passwords are never stored — a session token lives in the cache instead. This flag is the
108
+ # conscious override that lets a password go over plain HTTP to a remote host (demos only).
109
+ tc_allow_insecure: bool = False
110
+ # auto-internet: every container gets a default eth to the internet (Docker NAT).
111
+ # Off = "faithful mode": no internet unless an Internet element is drawn + wired.
112
+ auto_internet: bool = False
113
+ # GINI32 hardware: the lab Wi-Fi a board joins to reach this machine. It is the
114
+ # same network for every board in a class, so it is remembered once here and
115
+ # then written to each board over USB by Hardware > Set Up a Board. The password
116
+ # is stored because a board cannot be set up without it and there is nowhere else
117
+ # to keep it; it is the shared lab network, not a personal credential.
118
+ board_wifi_ssid: str = ""
119
+ board_wifi_password: str = ""
120
+ # Boards this laptop has set up over USB. Kept so the canvas can OFFER an id
121
+ # instead of asking the student to retype one from memory — a mistyped BoardID
122
+ # produces a board that is online and healthy but invisible to the topology,
123
+ # which is the single most confusing failure in the whole GINI32 story.
124
+ known_boards: list = field(default_factory=list)
125
+ # --- GINI32 hardware ---------------------------------------------------- #
126
+ # This install's identity to a board. A board records the id of the laptop that
127
+ # claimed it and then ignores every other laptop, which is what stops thirty
128
+ # students in one room from stealing each other's hardware. Minted on first use.
129
+ laptop_id: str = ""
130
+ # Boards this laptop has claimed: board name -> {mac, claimed_at}. Deliberately a
131
+ # SETTING, not part of a topology: opening a colleague's .gini file must not hand
132
+ # you their hardware.
133
+ claimed_boards: dict[str, dict] = field(default_factory=dict)
134
+ # per-type auto-name prefix overrides, e.g. {"host": "Mach_"} -> Mach_1, Mach_2, …
135
+ name_prefixes: dict[str, str] = field(default_factory=dict)
136
+ # per-type GINI $/hr rental price overrides for the cost dashboard, e.g. {"database": 20}
137
+ prices: dict[str, float] = field(default_factory=dict)
138
+ extra: dict[str, str] = field(default_factory=dict)
139
+
140
+
141
+ class AppContext:
142
+ def __init__(self) -> None:
143
+ self.bus = EventBus()
144
+ self.settings = Settings()
145
+ self.topology = Topology("untitled")
146
+ self.selected_id: str | None = None
147
+ self.addressing: dict[str, dict] = {} # device name -> {interfaces:[…]}
148
+ self.warnings: dict[str, list] = {} # device name -> [lint messages]
149
+ self.mission_flags: dict[str, str] = {} # device id -> reason (Mission off-task / bad-link)
150
+ self.mission = None # active Wizard objective (domain.missions.Mission)
151
+ self.teaching_center = None # agent.teaching_center.TeachingCenterClient | None
152
+ self.orchestrator = None # services.orchestrator.Orchestrator (probes exec here)
153
+ # live xv6 kernel state per Machine (domain.machine_state.MachineState) — the bridge the
154
+ # Machine Lab renders from and the Ask GINI agent reads for OS help. device_id -> state.
155
+ self.machine_states: dict = {}
156
+ # last run of each Source/Sink rider: device_id -> result dict (measurement etc.)
157
+ self.rider_results: dict = {}
158
+ self._rider_sessions = None # services.rider_session.RiderSessions (lazy)
159
+ # xv6 riders run over the console, not docker — MainWindow (which owns the live xv6 bridges)
160
+ # registers these hooks so toggle/is_running route to the serial path for qemu-serial riders.
161
+ self.xv6_rider_toggle = None # callable(rider_id) -> result dict
162
+ self.xv6_rider_running = None # callable(rider_id) -> bool
163
+
164
+ # convenience wrappers that emit the right events ----------------------- #
165
+ def add_device(self, type_key: str, x: float = 0.0, y: float = 0.0, **kw):
166
+ inst = self.topology.add_device(type_key, x=x, y=y, **kw)
167
+ self.bus.device_added.emit(inst.id)
168
+ self.bus.topology_changed.emit()
169
+ return inst
170
+
171
+ def add_link(self, source_id: str, target_id: str, label: str = ""):
172
+ # xv6 has no networking: hard-block xv6<->non-peripheral (and peripheral<->non-xv6) links.
173
+ from ..domain.connection_rules import link_blocked
174
+ s, t = self.topology.devices.get(source_id), self.topology.devices.get(target_id)
175
+ if s is not None and t is not None:
176
+ reason = link_blocked(s.type_key, t.type_key)
177
+ if reason:
178
+ self.log(reason, "info")
179
+ raise ValueError(reason)
180
+ link = self.topology.add_link(source_id, target_id, label)
181
+ self.bus.link_added.emit(link.id)
182
+ self.bus.topology_changed.emit()
183
+ return link
184
+
185
+ def connect(self, a_id: str, b_id: str, label: str = ""):
186
+ """Wire two elements the right way: if either end is a Source/Sink rider, mount it on the
187
+ other with an ATTACH edge; otherwise a normal network link. The canvas calls this so the
188
+ teacher never has to know which kind an edge is — the grammar decides."""
189
+ from ..domain.connection_rules import is_rider
190
+ a = self.topology.devices.get(a_id)
191
+ b = self.topology.devices.get(b_id)
192
+ if a is not None and b is not None and (is_rider(a.type_key) or is_rider(b.type_key)):
193
+ rider_id, donor_id = (a_id, b_id) if is_rider(a.type_key) else (b_id, a_id)
194
+ return self.add_attach(rider_id, donor_id, label)
195
+ return self.add_link(a_id, b_id, label)
196
+
197
+ def add_attach(self, rider_id: str, donor_id: str, label: str = ""):
198
+ """Mount a Source/Sink rider onto its donor (a dotted, non-network edge)."""
199
+ from ..domain.connection_rules import attach_blocked
200
+ r, d = self.topology.devices.get(rider_id), self.topology.devices.get(donor_id)
201
+ if r is not None and d is not None:
202
+ reason = attach_blocked(r.type_key, d.type_key)
203
+ if reason:
204
+ self.log(reason, "info")
205
+ raise ValueError(reason)
206
+ link = self.topology.add_attach(rider_id, donor_id, label)
207
+ self.bus.link_added.emit(link.id)
208
+ self.bus.topology_changed.emit()
209
+ return link
210
+
211
+ def run_rider(self, rider_id: str) -> dict:
212
+ """Execute a Source/Sink on its donor, stream the raw output to the console, remember the
213
+ measurement, and emit `rider_ran`. Safe to call from a worker thread (it only touches the
214
+ bus, whose signals marshal to the GUI). Returns the result dict."""
215
+ from ..domain.connection_rules import is_rider
216
+ dev = self.topology.devices.get(rider_id)
217
+ if dev is None or not is_rider(dev.type_key):
218
+ return {"ok": False, "error": "not a Source/Sink"}
219
+ if self.orchestrator is None:
220
+ res = {"ok": False, "error": "Press Run to start the topology, then run this."}
221
+ else:
222
+ from ..services.rider_runner import RiderRunner
223
+ runner = RiderRunner(self.orchestrator)
224
+ if not runner.available():
225
+ res = {"ok": False, "error": "The topology isn't running — press Run first."}
226
+ else:
227
+ res = runner.run(self.topology, rider_id)
228
+
229
+ # ONE concise line in the shared console (the full raw stream + measurement go to the
230
+ # inspector's Live tab, scoped to this node, so the console stays readable).
231
+ if res.get("ok"):
232
+ self.rider_results[rider_id] = res
233
+ tail = res.get("summary", "")
234
+ if res.get("inferred_target"):
235
+ tail += f" (→ {res['inferred_target']})"
236
+ self.log(f"{dev.name} on {res.get('donor', '?')} → {tail}", "info")
237
+ else:
238
+ self.log(f"{dev.name}: {res.get('error', 'run failed')}", "info")
239
+ self.bus.rider_ran.emit(rider_id, res)
240
+ return res
241
+
242
+ # -- continuous Source/Sink sessions (double-click toggles start/stop) --- #
243
+ def _sessions(self):
244
+ if self._rider_sessions is None and self.orchestrator is not None:
245
+ from ..services.rider_session import RiderSessions
246
+ self._rider_sessions = RiderSessions(self.orchestrator)
247
+ return self._rider_sessions
248
+
249
+ def _is_xv6_rider(self, rider_id: str) -> bool:
250
+ dev = self.topology.devices.get(rider_id)
251
+ return bool(dev and getattr(dev.type, "driver", "") == "qemu-serial")
252
+
253
+ def is_rider_running(self, rider_id: str) -> bool:
254
+ if self._is_xv6_rider(rider_id):
255
+ return bool(self.xv6_rider_running and self.xv6_rider_running(rider_id))
256
+ s = self._rider_sessions
257
+ return bool(s and s.is_running(rider_id))
258
+
259
+ def toggle_rider(self, rider_id: str) -> dict:
260
+ """Double-click behaviour: start the rider if idle, stop it if running. xv6 riders route to
261
+ the console (serial) path; everything else to the docker session path."""
262
+ if self._is_xv6_rider(rider_id):
263
+ if self.xv6_rider_toggle is not None:
264
+ return self.xv6_rider_toggle(rider_id)
265
+ return {"ok": False, "error": "Run the xv6 Machine first."}
266
+ if self.is_rider_running(rider_id):
267
+ return self.stop_rider(rider_id)
268
+ return self.start_rider(rider_id)
269
+
270
+ def start_rider(self, rider_id: str) -> dict:
271
+ from ..domain.connection_rules import is_rider
272
+ dev = self.topology.devices.get(rider_id)
273
+ if dev is None or not is_rider(dev.type_key):
274
+ return {"ok": False, "error": "not a Source/Sink"}
275
+ sess = self._sessions()
276
+ if sess is None or not sess.available():
277
+ res = {"ok": False, "error": "Press Run to start the topology, then start this."}
278
+ self.log(f"{dev.name}: {res['error']}", "info")
279
+ self.bus.rider_ran.emit(rider_id, {"ok": False, "running": False, **res})
280
+ return res
281
+ res = sess.start(self.topology, rider_id, self._on_rider_update)
282
+ if res.get("ok"):
283
+ tail = f" (→ {res['inferred_target']})" if res.get("inferred_target") else ""
284
+ self.log(f"{dev.name} started on {res.get('donor', '?')}{tail}", "info")
285
+ else:
286
+ self.log(f"{dev.name}: {res.get('error', 'could not start')}", "info")
287
+ self.bus.rider_ran.emit(rider_id, {"ok": False, "running": False, **res})
288
+ return res
289
+
290
+ def stop_rider(self, rider_id: str) -> dict:
291
+ sess = self._rider_sessions
292
+ dev = self.topology.devices.get(rider_id)
293
+ if sess is not None:
294
+ sess.stop(rider_id)
295
+ if dev is not None:
296
+ self.log(f"{dev.name} stopped", "info")
297
+ return {"ok": True, "running": False}
298
+
299
+ def stop_all_riders(self) -> None:
300
+ if self._rider_sessions is not None:
301
+ self._rider_sessions.stop_all()
302
+
303
+ def _on_rider_update(self, rider_id: str, snapshot: dict) -> None:
304
+ """Called from the session reader thread on each new line (and at stop). Store + emit; the
305
+ Qt signal marshals to the GUI thread, so touching the bus here is safe."""
306
+ self.rider_results[rider_id] = snapshot
307
+ self.bus.rider_ran.emit(rider_id, snapshot)
308
+
309
+ def remove_device(self, device_id: str) -> None:
310
+ if self.is_rider_running(device_id): # never orphan a running rider on delete
311
+ self.stop_rider(device_id)
312
+ self.topology.remove_device(device_id)
313
+ if self.selected_id == device_id:
314
+ self.select(None)
315
+ self.bus.device_removed.emit(device_id)
316
+ self.bus.topology_changed.emit()
317
+
318
+ def clear_topology(self) -> int:
319
+ """Wipe the board (used by staged missions, which must start from an exactly-known canvas).
320
+ Goes through remove_device so every view tears its node down the normal way. Returns how
321
+ many devices were removed."""
322
+ ids = list(self.topology.devices)
323
+ for did in ids:
324
+ self.remove_device(did)
325
+ self.topology.manual_addressing = False # a fresh board is auto-addressed again
326
+ self.addressing.clear()
327
+ self.warnings.clear()
328
+ self.mission_flags.clear()
329
+ return len(ids)
330
+
331
+ def select(self, device_id: str | None) -> None:
332
+ self.selected_id = device_id
333
+ self.bus.selection_changed.emit(device_id)
334
+
335
+ # -- Teaching Center ----------------------------------------------------- #
336
+ def connect_teaching_center(self):
337
+ """(Re)build the Teaching Center client from Settings. Returns the client, or None when the
338
+ student isn't enrolled (no URL) — in which case Missions shows the local practice catalog.
339
+ Never raises: an unreachable Center degrades to the offline cache."""
340
+ s = self.settings
341
+ if not (s.tc_url and s.tc_course and s.tc_student):
342
+ self.teaching_center = None
343
+ # Half-configured is a trap (the Settings placeholders LOOK like values), so say which
344
+ # fields are actually missing instead of silently falling back to the local catalog.
345
+ given = any((s.tc_url, s.tc_course, s.tc_student, s.tc_token))
346
+ if given:
347
+ missing = [n for n, v in (("course server", s.tc_url), ("course", s.tc_course),
348
+ ("student id", s.tc_student)) if not v]
349
+ self.log("Teaching Center not connected — still missing: "
350
+ + ", ".join(missing) + " (Settings → Teaching Center).", "info")
351
+ return None
352
+ try:
353
+ from ..agent.teaching_center import TeachingCenterClient
354
+ self.teaching_center = TeachingCenterClient(
355
+ s.tc_url, course=s.tc_course, student_id=s.tc_student, token=s.tc_token,
356
+ allow_insecure=bool(getattr(s, "tc_allow_insecure", False)))
357
+ except Exception as e: # noqa: BLE001
358
+ self.log(f"Teaching Center: {e}", "info")
359
+ self.teaching_center = None
360
+ return self.teaching_center
361
+
362
+ def set_mission(self, mission) -> None:
363
+ """Set (or clear, with None) the Wizard objective and notify the canvas/palette."""
364
+ self.mission = mission
365
+ self.bus.mission_changed.emit(mission)
366
+
367
+ def log(self, message: str, level: str = "info") -> None:
368
+ self.bus.log.emit(level, message)
gini/app/paths.py ADDED
@@ -0,0 +1,121 @@
1
+ """GINI's home on disk — ``~/.gini`` — for settings and the user's saved projects.
2
+
3
+ Everything user-level lives under one directory so save/restore is predictable:
4
+
5
+ ~/.gini/
6
+ config.json persisted settings (theme, LLM, …)
7
+ projects/ default location for saved .gini topologies
8
+
9
+ Override the location with the ``GINI_HOME_DIR`` environment variable.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+
17
+
18
+ def gini_home() -> Path:
19
+ return Path(os.environ.get("GINI_HOME_DIR") or (Path.home() / ".gini")).expanduser()
20
+
21
+
22
+ def projects_dir() -> Path:
23
+ return gini_home() / "projects"
24
+
25
+
26
+ def captures_dir() -> Path:
27
+ """Host directory for packet captures. Bind-mounted into each gRouter container at
28
+ ``/captures``, so a ``tap`` inline VNF's ``.pcap`` lands here on the host machine and
29
+ opens directly in Wireshark. Survives topology teardown (the compose workdir does not)."""
30
+ return gini_home() / "captures"
31
+
32
+
33
+ def oszoo_cache_dir() -> Path:
34
+ """Host directory for OS Zoo guest images. Bind-mounted into each OS Zoo container at
35
+ ``/zoo/cache``, so a historical OS's ISO/disk downloads once and is reused across runs
36
+ (an anonymous volume would be discarded on recreate, forcing a re-download every Run).
37
+ Survives topology teardown."""
38
+ return gini_home() / "oszoo-cache"
39
+
40
+
41
+ def scripts_dir() -> Path:
42
+ """Host directory for student-written router modules. Bind-mounted (read-only) into each
43
+ gRouter container at ``/scripts``, so a Lua data-plane VNF edited on the host machine loads
44
+ with ``gpipe add lua /scripts/<name>.lua`` from the router console (Chapter on designing and
45
+ implementing protocols). Survives topology teardown."""
46
+ return gini_home() / "scripts"
47
+
48
+
49
+ def config_path() -> Path:
50
+ return gini_home() / "config.json"
51
+
52
+
53
+ def ensure_dirs() -> None:
54
+ projects_dir().mkdir(parents=True, exist_ok=True)
55
+ captures_dir().mkdir(parents=True, exist_ok=True)
56
+ scripts_dir().mkdir(parents=True, exist_ok=True)
57
+
58
+
59
+ def load_config() -> dict:
60
+ try:
61
+ return json.loads(config_path().read_text())
62
+ except Exception: # missing or malformed -> defaults
63
+ return {}
64
+
65
+
66
+ def save_config(data: dict) -> None:
67
+ ensure_dirs()
68
+ try:
69
+ config_path().write_text(json.dumps(data, indent=2))
70
+ except Exception:
71
+ pass
72
+
73
+
74
+ # -- recent projects (last-opened + MRU list), kept out of config.json so a settings
75
+ # write never clobbers them --------------------------------------------------- #
76
+ def recents_path() -> Path:
77
+ return gini_home() / "recents.json"
78
+
79
+
80
+ def load_recents() -> dict:
81
+ try:
82
+ data = json.loads(recents_path().read_text())
83
+ return data if isinstance(data, dict) else {}
84
+ except Exception:
85
+ return {}
86
+
87
+
88
+ def save_recents(data: dict) -> None:
89
+ ensure_dirs()
90
+ try:
91
+ recents_path().write_text(json.dumps(data, indent=2))
92
+ except Exception:
93
+ pass
94
+
95
+
96
+ def remember_project(path: str, *, limit: int = 8) -> None:
97
+ """Record `path` as the last-opened project and push it to the front of the MRU list."""
98
+ data = load_recents()
99
+ mru = [p for p in data.get("list", []) if p != path]
100
+ mru.insert(0, path)
101
+ save_recents({"last": path, "list": mru[:limit]})
102
+
103
+
104
+ # settings fields that are persisted to / loaded from config.json
105
+ PERSISTED_KEYS = (
106
+ "theme", "reduced_motion", "text_size",
107
+ "llm_enabled", "llm_url", "llm_model", "llm_think",
108
+ "auto_internet", "name_prefixes", "prices", "autobuild_images",
109
+ "connector_style", # bent ↔ straight, toggled from the toolbar
110
+ "backend", "gini_server_host", "gini_server_port", "gini_server_user",
111
+ "show_help_on_launch",
112
+ "tc_url", "tc_course", "tc_student", "tc_token", # Teaching Center enrolment
113
+ "tc_allow_insecure", # the conscious plaintext-password override
114
+ # GINI32 hardware. `laptop_id` must be stable or every claimed board orphans at
115
+ # once; `claimed_boards` is a property of this laptop, never of a topology, so a
116
+ # colleague's .gini file cannot hand you their hardware.
117
+ "laptop_id", "claimed_boards",
118
+ # The lab Wi-Fi written to a board over USB. Remembered so setting up the second
119
+ # and subsequent boards is one click — it is the same network for a whole class.
120
+ "board_wifi_ssid", "board_wifi_password", "known_boards",
121
+ )
gini/data/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # `gini/data`
2
+
3
+ Generated data assets shipped with GINI.
4
+
5
+ ## `kb_index.json` — Ask GINI semantic index (L2 recall)
6
+
7
+ Precomputed embeddings of the knowledge base (concept notes + recipes + element
8
+ descriptions), used by `agent/embed.py` for semantic recall when lexical + LLM-expansion
9
+ retrieval comes back empty.
10
+
11
+ **This file is generated, not hand-edited.** Regenerate it whenever you change the concept
12
+ notes, recipes, or element catalog:
13
+
14
+ ```bash
15
+ ollama pull all-minilm
16
+ GINI_LLM_URL=http://localhost:11434 python scripts/build_kb_index.py
17
+ ```
18
+
19
+ If `kb_index.json` is absent, Ask GINI still works — L2 simply disables and retrieval falls
20
+ back to lexical (L0) + LLM keyword-expansion (L1). `tests/test_kb_index_sync.py` fails if the
21
+ shipped index drifts from the live KB, as a reminder to rebuild.
@@ -0,0 +1,9 @@
1
+ """Pure-Python domain model — no Qt dependency."""
2
+ from . import devices, topology
3
+ from .devices import Category, DeviceType, REGISTRY, all_devices, by_category, get
4
+ from .topology import DeviceInstance, Link, Topology
5
+
6
+ __all__ = [
7
+ "devices", "topology", "Category", "DeviceType", "REGISTRY",
8
+ "all_devices", "by_category", "get", "DeviceInstance", "Link", "Topology",
9
+ ]