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
@@ -0,0 +1,28 @@
1
+ """Project persistence — save/load a topology as a JSON project.
2
+
3
+ Uses the domain model's own to_dict/from_dict, so the on-disk format is just the
4
+ topology (devices + links + positions + properties) wrapped with a version. A legacy
5
+ .gsav reader can be added later; this is the clean native format.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from ..domain.topology import Topology
13
+
14
+ PROJECT_EXT = ".gini"
15
+ FORMAT = "gini-project"
16
+ VERSION = 1
17
+
18
+
19
+ def save_project(topo: Topology, path: str | Path) -> None:
20
+ data = {"format": FORMAT, "version": VERSION, "topology": topo.to_dict()}
21
+ Path(path).write_text(json.dumps(data, indent=2))
22
+
23
+
24
+ def load_project(path: str | Path) -> Topology:
25
+ data = json.loads(Path(path).read_text())
26
+ if data.get("format") != FORMAT:
27
+ raise ValueError(f"not a GINI project file: {path}")
28
+ return Topology.from_dict(data["topology"])
@@ -0,0 +1,149 @@
1
+ """Docker-backed probe Runner — GINI's runtime as the behavioral oracle (Mac-side).
2
+
3
+ Implements the `domain.probes.Runner` seam by exec'ing checks inside the running topology's
4
+ containers via `docker compose exec` (the same compose project the orchestrator launched). This
5
+ runs only where Docker + a live stack exist (the student's Mac), so it is written defensively:
6
+ `available()` returns False whenever the stack isn't up, which keeps behavioral objectives
7
+ `pending` rather than wrongly failed. Unit-tested logic lives with `domain.probes` (FakeRunner);
8
+ this adapter is exercised in real Docker on the Mac.
9
+
10
+ Service-name resolution: the compiler names each compose service after the device name
11
+ (lowercased), so a probe's `src`/`dst` map to services; cloud-plane services reach each other by
12
+ name over the shared network. Networking-plane addressing (by IP) is a Phase-2.x refinement.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+
18
+
19
+ class DockerProbeRunner:
20
+ def __init__(self, orchestrator, *, timeout: float = 8.0) -> None:
21
+ self.orch = orchestrator # services.orchestrator.Orchestrator (has _dc, status)
22
+ self.timeout = timeout
23
+ self._svc_cache: dict | None = None
24
+
25
+ # -- availability ------------------------------------------------------- #
26
+ def available(self) -> bool:
27
+ try:
28
+ states = self.orch.status()
29
+ except Exception:
30
+ return False
31
+ return any(str(s).startswith("running") for s in (states or {}).values())
32
+
33
+ def _service(self, name: str) -> str:
34
+ """Map a device name to its compose service. The orchestrator's status() keys ARE the
35
+ service names; match case-insensitively, else fall back to the lowercased name."""
36
+ if self._svc_cache is None:
37
+ try:
38
+ self._svc_cache = {k.lower(): k for k in (self.orch.status() or {})}
39
+ except Exception:
40
+ self._svc_cache = {}
41
+ return self._svc_cache.get(name.lower(), name.lower())
42
+
43
+ def _exec(self, service: str, argv: list[str]) -> tuple[int, str]:
44
+ cmd = [*self.orch._dc, "exec", "-T", service, *argv]
45
+ # `docker compose` (no -f) finds the project via the CWD — every orchestrator call runs in
46
+ # the workdir, so the probe MUST too, or `exec` can't find the running stack (a false
47
+ # negative: probes fail even though the containers are up and reachable).
48
+ wd = getattr(self.orch, "workdir", None)
49
+ try:
50
+ r = subprocess.run(cmd, cwd=str(wd) if wd else None,
51
+ capture_output=True, text=True, timeout=self.timeout)
52
+ return r.returncode, (r.stdout or "") + (r.stderr or "")
53
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
54
+ return 124, ""
55
+
56
+ # -- probes ------------------------------------------------------------- #
57
+ def reach(self, src: str, dst: str, port: int | None = None) -> bool:
58
+ s, d = self._service(src), self._service(dst)
59
+ if port is None: # L3 reachability via ICMP
60
+ code, _ = self._exec(s, ["ping", "-c", "1", "-W", "1", d])
61
+ return code == 0
62
+ # L4 reachability: a TCP connect using bash's /dev/tcp (no extra tools needed)
63
+ code, _ = self._exec(s, ["bash", "-lc", f"exec 3<>/dev/tcp/{d}/{port}"])
64
+ return code == 0
65
+
66
+ def http(self, src: str, dst: str, port: int) -> bool:
67
+ s, d = self._service(src), self._service(dst)
68
+ code, _ = self._exec(s, ["curl", "-fsS", "-m", "4", "-o", "/dev/null",
69
+ f"http://{d}:{port}/"])
70
+ return code == 0
71
+
72
+ def backends(self, lb: str) -> int:
73
+ """How many backends the load balancer is REALLY serving from — i.e. upstreams it was
74
+ configured with that are actually accepting connections right now. A config listing two
75
+ servers proves nothing; this proves the fan-out has somewhere to go.
76
+
77
+ The LB compiles to nginx, so read its upstream block, then probe each target from inside the
78
+ LB container itself (that's the path traffic would take)."""
79
+ import re
80
+ svc = self._service(lb)
81
+ code, out = self._exec(svc, ["sh", "-c",
82
+ "cat /etc/nginx/conf.d/*.conf /etc/nginx/nginx.conf 2>/dev/null"])
83
+ if code != 0 or not out:
84
+ return 0
85
+ targets = re.findall(r"^\s*server\s+([A-Za-z0-9._-]+):(\d+)\s*;", out, re.M)
86
+ live = 0
87
+ for host, port in targets:
88
+ # try a few tools — the image is alpine-ish and may have any of them
89
+ probe = (f"nc -z -w2 {host} {port} 2>/dev/null || "
90
+ f"wget -q -T2 -O /dev/null http://{host}:{port}/ 2>/dev/null || "
91
+ f"curl -fsS -m2 -o /dev/null http://{host}:{port}/ 2>/dev/null")
92
+ c, _ = self._exec(svc, ["sh", "-c", probe])
93
+ if c == 0:
94
+ live += 1
95
+ return live
96
+
97
+ def flow(self, ovs: str, match: str = "") -> bool:
98
+ """Did the controller ACTUALLY install flows on this switch? Reads the live OpenFlow table
99
+ from the gRouter (the OVS runs in --openflow mode) over its control socket — the exact same
100
+ path the Router Lab's flow view uses — and reuses the shipped parser.
101
+
102
+ `match` empty (or 'any') = "any flow at all is installed", which is the honest test that the
103
+ control plane did something. A non-empty match is a substring test against the entries."""
104
+ from ..domain.flowtable import flows
105
+ svc = self._service(ovs)
106
+ code, out = self._exec(svc, ["python3", "/build/grouter-build/grconsole.py",
107
+ f"/run/{svc}.ctl", "--once", "openflow entry all"])
108
+ if code != 0 or not out:
109
+ return False
110
+ try:
111
+ rows = flows(out)
112
+ except Exception: # noqa: BLE001 — a malformed dump is just "no proof"
113
+ return False
114
+ if not rows:
115
+ return False
116
+ if not match or match.lower() in ("any", "*"):
117
+ return True
118
+ needle = match.lower()
119
+ return any(needle in str(r).lower() for r in rows)
120
+
121
+
122
+ class RuntimeRunner(DockerProbeRunner):
123
+ """DockerProbeRunner + `measure()` — the runtime half of an output check. It resolves
124
+ `measure(rider_type, metric)` by reading the LIVE measurement of the matching attached Source/Sink
125
+ from `get_results` (the streaming rider snapshots the grader has already started + let run). We
126
+ read the streaming session's reading rather than doing a one-shot capture, because `docker exec`
127
+ block-buffers output — a timeout-killed one-shot comes back empty even when the manual streaming
128
+ view sees packets. `get_results()` → {rider_id: snapshot}; `get_topology()` finds the rider."""
129
+
130
+ def __init__(self, orchestrator, get_topology, get_results, *, timeout: float = 8.0) -> None:
131
+ super().__init__(orchestrator, timeout=timeout)
132
+ self._get_topology = get_topology
133
+ self._get_results = get_results
134
+
135
+ def measure(self, rider_type: str, metric: str):
136
+ from ..domain.objectives import slot_match
137
+ base, _, slot = str(rider_type).partition("@") # measure(packet_view@A, packets)
138
+ topo = self._get_topology()
139
+ results = self._get_results() or {}
140
+ for d in getattr(topo, "devices", {}).values():
141
+ if getattr(d, "type_key", None) != base:
142
+ continue
143
+ if not slot_match(getattr(d, "slot", ""), slot):
144
+ continue
145
+ snap = results.get(d.id)
146
+ m = (snap or {}).get("measurement") or {}
147
+ if metric in m:
148
+ return m.get(metric)
149
+ return None
@@ -0,0 +1,217 @@
1
+ """Project = a folder holding a FAMILY of related experiments.
2
+
3
+ A project is the unit of *context*: several experiments that belong together (build a LAN, route
4
+ between two LANs, convert it to SDN) share one teacher's **brief** and one **Ask GINI conversation**,
5
+ so the tutor remembers the whole arc instead of forgetting everything each time you move on. An
6
+ *experiment* is one topology inside that project. Layout:
7
+
8
+ MyProject/
9
+ project.json {name, brief, created, modified, current}
10
+ ai.json {messages, history} ← the shared Ask GINI transcript
11
+ experiments/
12
+ Basic LAN.gini a topology (native .gini JSON — see persistence.py)
13
+ Routed LAN.gini
14
+ SDN.gini
15
+
16
+ Two older shapes still open, and are migrated in place on first load:
17
+ * the single-experiment project folder (`topology.gini` at the root) — moved into
18
+ `experiments/` under the project's own name;
19
+ * a bare single-file `.gini` — still loads as a topology via persistence.py.
20
+
21
+ Keeping experiments as separate files means each topology stays clean and shareable on its own,
22
+ while the brief and the AI conversation live once, at the project level.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import re
28
+ import time
29
+ from pathlib import Path
30
+
31
+ from ..domain.topology import Topology
32
+ from .persistence import load_project as _load_topology
33
+ from .persistence import save_project as _save_topology
34
+
35
+ TOPOLOGY_FILE = "topology.gini" # legacy single-experiment layout
36
+ META_FILE = "project.json"
37
+ AI_FILE = "ai.json"
38
+ EXPERIMENTS_DIR = "experiments"
39
+ EXT = ".gini"
40
+ META_FORMAT = "gini-project-dir"
41
+ META_VERSION = 2 # v1 = one topology.gini at the root
42
+ # A project's first experiment gets a neutral, numbered name — NEVER the project's own. Naming it
43
+ # after the project made the experiment list read as though the project were an item inside itself.
44
+ # The -01 suffix matches the Project-01 house style and invites Experiment-02, -03 alongside it.
45
+ FIRST_EXPERIMENT = "Experiment-01"
46
+
47
+
48
+ def _read_json(path: Path) -> dict:
49
+ try:
50
+ data = json.loads(Path(path).read_text())
51
+ return data if isinstance(data, dict) else {}
52
+ except Exception: # missing or malformed -> empty
53
+ return {}
54
+
55
+
56
+ def safe_name(name: str) -> str:
57
+ """An experiment name that is safe as a filename but still human-readable on disk."""
58
+ s = re.sub(r'[/\\:*?"<>|]+', "-", (name or "").strip()).strip(". ")
59
+ return s or "Untitled"
60
+
61
+
62
+ def is_project_dir(path) -> bool:
63
+ """True for a v2 project (experiments/) or a v1 one (topology.gini at the root)."""
64
+ p = Path(path)
65
+ if not p.is_dir():
66
+ return False
67
+ return (p / TOPOLOGY_FILE).exists() or (p / EXPERIMENTS_DIR).is_dir()
68
+
69
+
70
+ def experiments_dir(path) -> Path:
71
+ return Path(path) / EXPERIMENTS_DIR
72
+
73
+
74
+ # -- experiments ------------------------------------------------------------- #
75
+ def list_experiments(path) -> list[dict]:
76
+ """Every experiment in the project, most-recently-modified first."""
77
+ d = experiments_dir(path)
78
+ out: list[dict] = []
79
+ if d.is_dir():
80
+ for f in d.glob(f"*{EXT}"):
81
+ out.append({"name": f.stem, "path": str(f), "modified": f.stat().st_mtime})
82
+ out.sort(key=lambda x: x["modified"], reverse=True)
83
+ return out
84
+
85
+
86
+ def experiment_path(path, name: str) -> Path:
87
+ return experiments_dir(path) / f"{safe_name(name)}{EXT}"
88
+
89
+
90
+ def save_experiment(path, topology: Topology, name: str) -> str:
91
+ """Write one experiment's topology into the project. Returns the file path."""
92
+ d = experiments_dir(path)
93
+ d.mkdir(parents=True, exist_ok=True)
94
+ fp = experiment_path(path, name)
95
+ _save_topology(topology, fp)
96
+ return str(fp)
97
+
98
+
99
+ def load_experiment(path, name: str) -> Topology:
100
+ return _load_topology(experiment_path(path, name))
101
+
102
+
103
+ def delete_experiment(path, name: str) -> bool:
104
+ fp = experiment_path(path, name)
105
+ if not fp.exists():
106
+ return False
107
+ fp.unlink()
108
+ meta = _read_json(Path(path) / META_FILE)
109
+ if meta.get("current") == safe_name(name): # don't point at a file that's gone
110
+ meta["current"] = ""
111
+ (Path(path) / META_FILE).write_text(json.dumps(meta, indent=2))
112
+ return True
113
+
114
+
115
+ def rename_experiment(path, old: str, new: str) -> bool:
116
+ src, dst = experiment_path(path, old), experiment_path(path, new)
117
+ if not src.exists() or dst.exists():
118
+ return False
119
+ src.rename(dst)
120
+ meta = _read_json(Path(path) / META_FILE)
121
+ if meta.get("current") == safe_name(old):
122
+ meta["current"] = safe_name(new)
123
+ (Path(path) / META_FILE).write_text(json.dumps(meta, indent=2))
124
+ return True
125
+
126
+
127
+ # -- migration --------------------------------------------------------------- #
128
+ def migrate_project(path) -> bool:
129
+ """v1 → v2 in place: move a root `topology.gini` into `experiments/<project name>.gini`.
130
+ Returns True if anything moved. Safe to call on an already-migrated project."""
131
+ p = Path(path)
132
+ legacy = p / TOPOLOGY_FILE
133
+ if not legacy.exists():
134
+ return False
135
+ meta = _read_json(p / META_FILE)
136
+ name = FIRST_EXPERIMENT
137
+ d = experiments_dir(p)
138
+ d.mkdir(parents=True, exist_ok=True)
139
+ target = d / f"{name}{EXT}"
140
+ if not target.exists():
141
+ legacy.rename(target)
142
+ else:
143
+ legacy.unlink() # already migrated; drop the stale copy
144
+ meta.update({"format": META_FORMAT, "version": META_VERSION, "current": name,
145
+ "name": meta.get("name") or p.name})
146
+ (p / META_FILE).write_text(json.dumps(meta, indent=2))
147
+ return True
148
+
149
+
150
+ # -- project ----------------------------------------------------------------- #
151
+ def save_project_dir(path, topology: Topology, *, name: str | None = None, brief: str = "",
152
+ ai_state: dict | None = None, experiment: str | None = None) -> str:
153
+ """Write the project: the CURRENT experiment's topology plus the project-level brief and AI
154
+ conversation. `experiment` names the topology being saved (defaults to the project name, which
155
+ is what a freshly-created project uses). Returns the folder path."""
156
+ p = Path(path)
157
+ p.mkdir(parents=True, exist_ok=True)
158
+ prev = _read_json(p / META_FILE)
159
+ pname = name or topology.name or p.name
160
+ exp = safe_name(experiment or prev.get("current") or FIRST_EXPERIMENT)
161
+ save_experiment(p, topology, exp)
162
+ now = time.time()
163
+ meta = {
164
+ "format": META_FORMAT, "version": META_VERSION,
165
+ "name": pname,
166
+ "brief": brief or "",
167
+ "current": exp,
168
+ "created": prev.get("created", now),
169
+ "modified": now,
170
+ }
171
+ (p / META_FILE).write_text(json.dumps(meta, indent=2))
172
+ (p / AI_FILE).write_text(json.dumps(ai_state or {}, indent=2))
173
+ return str(p)
174
+
175
+
176
+ def load_project_dir(path) -> dict:
177
+ """Return {topology, name, brief, ai_state, path, experiment, experiments} for a project.
178
+
179
+ `topology` is the *current* experiment. A v1 project is migrated first, so old projects open
180
+ unchanged from the user's point of view."""
181
+ p = Path(path)
182
+ migrate_project(p)
183
+ meta = _read_json(p / META_FILE)
184
+ pname = meta.get("name") or p.name
185
+ exps = list_experiments(p)
186
+ cur = meta.get("current") or ""
187
+ known = {e["name"] for e in exps}
188
+ if cur not in known: # missing/renamed -> newest, else a fresh one
189
+ cur = exps[0]["name"] if exps else FIRST_EXPERIMENT
190
+ try:
191
+ topo = load_experiment(p, cur)
192
+ except Exception: # noqa: BLE001 — an empty/new project
193
+ topo = Topology(cur)
194
+ return {
195
+ "topology": topo,
196
+ "name": pname,
197
+ "brief": meta.get("brief", ""),
198
+ "ai_state": _read_json(p / AI_FILE),
199
+ "path": str(p),
200
+ "experiment": cur,
201
+ "experiments": [e["name"] for e in exps] or [cur],
202
+ }
203
+
204
+
205
+ def list_projects(projects_root) -> list[dict]:
206
+ """All project folders under `projects_root`, most-recently-modified first."""
207
+ root = Path(projects_root)
208
+ out: list[dict] = []
209
+ if root.is_dir():
210
+ for d in root.iterdir():
211
+ if is_project_dir(d):
212
+ meta = _read_json(d / META_FILE)
213
+ out.append({"name": meta.get("name", d.name), "path": str(d),
214
+ "modified": meta.get("modified", 0),
215
+ "experiments": len(list_experiments(d))})
216
+ out.sort(key=lambda x: x["modified"], reverse=True)
217
+ return out
@@ -0,0 +1,93 @@
1
+ """gBuilder's client for a remote GINI server (the Phase-3 transport).
2
+
3
+ The GUI builds a topology and, in remote mode, hands it to this client, which sends it to
4
+ the brokered GINI server over an authenticated HTTP API. The server compiles + policy-checks
5
+ + runs it on the Kata host; metrics/startup come back the same way. Same shape as the local
6
+ orchestrator path, so the canvas/compiler don't care which backend is active.
7
+
8
+ `transport` is injectable — production uses the urllib HTTP transport below; tests pass a
9
+ shim that calls `GiniServer.handle` directly, exercising the whole protocol without sockets.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import urllib.error
15
+ import urllib.request
16
+
17
+
18
+ class RemoteClient:
19
+ def __init__(self, base_url: str = "", transport=None, token: str | None = None) -> None:
20
+ self.base_url = (base_url or "").rstrip("/")
21
+ self.token = token
22
+ self._transport = transport or self._http
23
+
24
+ # -- transport ---------------------------------------------------------- #
25
+ def _http(self, method: str, path: str, token: str | None, body: dict) -> tuple[int, dict]:
26
+ data = json.dumps(body or {}).encode() if method == "POST" else None
27
+ req = urllib.request.Request(self.base_url + path, data=data, method=method)
28
+ req.add_header("Content-Type", "application/json")
29
+ if token:
30
+ req.add_header("Authorization", "Bearer " + token)
31
+ try:
32
+ r = urllib.request.urlopen(req, timeout=30)
33
+ return r.getcode(), json.loads(r.read().decode() or "{}")
34
+ except urllib.error.HTTPError as e:
35
+ try:
36
+ return e.code, json.loads(e.read().decode() or "{}")
37
+ except Exception: # noqa: BLE001
38
+ return e.code, {"error": f"HTTP {e.code}"}
39
+ except Exception as e: # noqa: BLE001 — connection refused, DNS, …
40
+ return 0, {"error": str(e)}
41
+
42
+ def _call(self, method: str, path: str, body: dict | None = None) -> tuple[int, dict]:
43
+ return self._transport(method, path, self.token, body or {})
44
+
45
+ # -- API ---------------------------------------------------------------- #
46
+ def login(self, username: str, password: str) -> tuple[bool, str]:
47
+ st, body = self._transport("POST", "/login", None,
48
+ {"username": username, "password": password})
49
+ if st == 200 and body.get("token"):
50
+ self.token = body["token"]
51
+ return True, ""
52
+ return False, body.get("error", "login failed")
53
+
54
+ def run(self, topology) -> tuple[bool, str]:
55
+ """Start the lab on the server (returns once accepted — the launch runs async there,
56
+ because image pulls/builds can take minutes). Poll `wait_until_running` for the result."""
57
+ st, body = self._call("POST", "/run", {"topology": topology.to_dict()})
58
+ if st in (200, 202) and body.get("ok"):
59
+ return True, body.get("state", "starting")
60
+ return False, body.get("error", "run failed")
61
+
62
+ def run_state(self) -> dict:
63
+ """{'state': starting|running|error|stopped, 'message': ...}."""
64
+ return self._call("GET", "/status")[1].get("run", {})
65
+
66
+ def wait_until_running(self, timeout: float = 240, interval: float = 2.0) -> tuple[bool, str]:
67
+ """Poll until the server's launch finishes (or fails / times out)."""
68
+ import time
69
+ deadline = time.time() + timeout
70
+ while time.time() < deadline:
71
+ rs = self.run_state()
72
+ if rs.get("state") == "running":
73
+ return True, rs.get("message", "")
74
+ if rs.get("state") == "error":
75
+ return False, rs.get("message", "run failed")
76
+ time.sleep(interval)
77
+ return False, "timed out waiting for the lab to come up"
78
+
79
+ def stop(self) -> tuple[bool, str]:
80
+ st, body = self._call("POST", "/stop")
81
+ return st == 200, body.get("message") or body.get("error", "")
82
+
83
+ def status(self) -> dict:
84
+ return self._call("GET", "/status")[1].get("status", {})
85
+
86
+ def metrics(self) -> dict:
87
+ return self._call("GET", "/metrics")[1] # {"stats": {...}, "startup": {...}}
88
+
89
+ def capabilities(self) -> dict:
90
+ return self._call("GET", "/capabilities")[1] # {"kata": bool}
91
+
92
+ def kata_available(self) -> bool:
93
+ return bool(self.capabilities().get("kata"))
@@ -0,0 +1,96 @@
1
+ """Docker-backed rider execution (Mac-side).
2
+
3
+ Runs a Source/Sink INSIDE its donor container via `docker compose exec` — the same seam
4
+ `probe_runner` uses — then reduces the raw output to a measurement with `domain.riders`. A rider has
5
+ no container of its own, so there is nothing to launch: we exec its tool in the donor the student
6
+ attached it to. Written defensively: `available()` is False when no stack is up, and a missing donor
7
+ or a broken command returns an error dict rather than raising, so the UI can show it inline.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+
13
+ from ..domain import riders as _riders
14
+
15
+
16
+ class RiderRunner:
17
+ def __init__(self, orchestrator, *, timeout: float = 15.0) -> None:
18
+ self.orch = orchestrator # services.orchestrator.Orchestrator (has _dc, status, workdir)
19
+ self.timeout = timeout
20
+ self._svc_cache: dict | None = None
21
+
22
+ def available(self) -> bool:
23
+ try:
24
+ states = self.orch.status()
25
+ except Exception:
26
+ return False
27
+ return any(str(s).startswith("running") for s in (states or {}).values())
28
+
29
+ def _service(self, name: str) -> str:
30
+ if self._svc_cache is None:
31
+ try:
32
+ self._svc_cache = {k.lower(): k for k in (self.orch.status() or {})}
33
+ except Exception:
34
+ self._svc_cache = {}
35
+ return self._svc_cache.get(name.lower(), name.lower())
36
+
37
+ def _exec(self, service: str, argv: list[str]) -> tuple[int, str]:
38
+ cmd = [*self.orch._dc, "exec", "-T", service, *argv]
39
+ wd = getattr(self.orch, "workdir", None)
40
+ try:
41
+ r = subprocess.run(cmd, cwd=str(wd) if wd else None,
42
+ capture_output=True, text=True, timeout=self.timeout)
43
+ return r.returncode, (r.stdout or "") + (r.stderr or "")
44
+ except subprocess.TimeoutExpired as e: # keep partial output, flag the timeout
45
+ partial = e.stdout if isinstance(e.stdout, str) else ""
46
+ return (0 if partial else 124), partial
47
+ except (FileNotFoundError, OSError):
48
+ return 124, ""
49
+
50
+ # pingable/curlable donor neighbours we can auto-target (reachable by service name)
51
+ _TARGETABLE = {"host", "router", "instance", "container", "kinstance", "web_app", "firewall"}
52
+
53
+ def _infer_target(self, topo, donor_id: str, rider_id: str) -> str:
54
+ """When a ping/http rider has no Target, pick a reachable name: a directly-wired neighbour of
55
+ the donor first (excluding riders), else any other targetable node on the board."""
56
+ donor = topo.devices.get(donor_id)
57
+ for nb in topo.neighbors(donor_id): # prefer a direct network neighbour
58
+ if nb.id != rider_id and nb.type_key in self._TARGETABLE:
59
+ return nb.name
60
+ for d in topo.devices.values(): # else anything else pingable by name
61
+ if d.id not in (donor_id, rider_id) and d.type_key in self._TARGETABLE:
62
+ return d.name
63
+ return ""
64
+
65
+ def run(self, topo, rider_id: str) -> dict:
66
+ """Execute the rider on its donor and return
67
+ {ok, raw, measurement, summary, donor} — or {ok: False, error} on any problem."""
68
+ rider = topo.devices.get(rider_id)
69
+ if rider is None:
70
+ return {"ok": False, "error": "no such rider"}
71
+ donor = topo.donor_of(rider_id)
72
+ if donor is None:
73
+ return {"ok": False, "error": f"{rider.name} isn't attached to a donor yet."}
74
+
75
+ props = dict(rider.properties)
76
+ inferred = ""
77
+ if rider.type_key in ("ping_probe", "http_probe") and not (props.get("Target") or "").strip():
78
+ inferred = self._infer_target(topo, donor.id, rider_id)
79
+ if inferred:
80
+ props["Target"] = inferred
81
+ try:
82
+ argv = _riders.build_command(rider.type_key, props)
83
+ except _riders.RiderError as e:
84
+ return {"ok": False, "error": str(e)}
85
+ service = self._service(donor.name)
86
+ code, raw = self._exec(service, argv)
87
+ if not (raw or "").strip():
88
+ # never show a blank "no output" — say what ran and how it exited, so the cause is
89
+ # visible (missing tool, unresolved target, timeout, wrong donor service, …)
90
+ hint = "timed out" if code == 124 else f"exit {code}"
91
+ raw = (f"(no output — {hint})\n"
92
+ f"$ docker compose exec {service} {' '.join(argv)}")
93
+ m = _riders.parse_measurement(rider.type_key, raw)
94
+ return {"ok": True, "code": code, "raw": raw, "measurement": m,
95
+ "summary": _riders.summarize(rider.type_key, m), "donor": donor.name,
96
+ "target": props.get("Target", ""), "inferred_target": inferred}