shikiru-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. shikiru/__init__.py +1 -0
  2. shikiru/__main__.py +8 -0
  3. shikiru/agents/__init__.py +3 -0
  4. shikiru/agents/contracts.py +590 -0
  5. shikiru/agents/registry.py +87 -0
  6. shikiru/agents/sdk/__init__.py +17 -0
  7. shikiru/agents/sdk/reset_time.py +137 -0
  8. shikiru/agents/sdk/turn.py +335 -0
  9. shikiru/agents/settings.py +151 -0
  10. shikiru/agents/testing/__init__.py +26 -0
  11. shikiru/agents/testing/contract_suite.py +653 -0
  12. shikiru/agents/testing/resume.py +15 -0
  13. shikiru/bootstrap.py +1277 -0
  14. shikiru/cli/__init__.py +1 -0
  15. shikiru/cli/app.py +770 -0
  16. shikiru/cli/context.py +63 -0
  17. shikiru/cli/render.py +1475 -0
  18. shikiru/cli/report.py +156 -0
  19. shikiru/cli/session_prompt.py +63 -0
  20. shikiru/cli/view.py +131 -0
  21. shikiru/clock.py +41 -0
  22. shikiru/config/__init__.py +8 -0
  23. shikiru/config/defaults.toml +131 -0
  24. shikiru/config/loader.py +178 -0
  25. shikiru/config/settings.py +392 -0
  26. shikiru/dashboard/__init__.py +3 -0
  27. shikiru/dashboard/actions_list.py +799 -0
  28. shikiru/dashboard/activity.py +150 -0
  29. shikiru/dashboard/app.py +1765 -0
  30. shikiru/dashboard/blocker_evidence.py +122 -0
  31. shikiru/dashboard/blocker_rows.py +84 -0
  32. shikiru/dashboard/briefing_view.py +871 -0
  33. shikiru/dashboard/contract.py +1086 -0
  34. shikiru/dashboard/covered.py +32 -0
  35. shikiru/dashboard/detail.py +1209 -0
  36. shikiru/dashboard/detail_fold.py +244 -0
  37. shikiru/dashboard/detail_hint.py +68 -0
  38. shikiru/dashboard/details_list_view.py +150 -0
  39. shikiru/dashboard/diagnostics.py +80 -0
  40. shikiru/dashboard/diagnostics_sections.py +186 -0
  41. shikiru/dashboard/evidence_table.py +123 -0
  42. shikiru/dashboard/extra_details.py +214 -0
  43. shikiru/dashboard/fault_guidance.py +216 -0
  44. shikiru/dashboard/fold_bodies.py +276 -0
  45. shikiru/dashboard/help_keys.py +166 -0
  46. shikiru/dashboard/help_screen.py +204 -0
  47. shikiru/dashboard/interaction.py +17 -0
  48. shikiru/dashboard/ladder_editor.py +544 -0
  49. shikiru/dashboard/live_settings.py +62 -0
  50. shikiru/dashboard/motion.py +368 -0
  51. shikiru/dashboard/orchestration_fields.py +201 -0
  52. shikiru/dashboard/palette.py +1606 -0
  53. shikiru/dashboard/palette_app.py +89 -0
  54. shikiru/dashboard/palette_drafts.py +33 -0
  55. shikiru/dashboard/resolution.py +573 -0
  56. shikiru/dashboard/resolution_view.py +302 -0
  57. shikiru/dashboard/row_list.py +256 -0
  58. shikiru/dashboard/section_rows.py +173 -0
  59. shikiru/dashboard/services.py +665 -0
  60. shikiru/dashboard/session_history.py +402 -0
  61. shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
  62. shikiru/dashboard/task_picker.py +358 -0
  63. shikiru/dashboard/tick.py +59 -0
  64. shikiru/dashboard/timeline_filter.py +129 -0
  65. shikiru/dashboard/timeline_section.py +385 -0
  66. shikiru/dashboard/usage_body.py +269 -0
  67. shikiru/dashboard/views.py +2314 -0
  68. shikiru/dashboard/wizard.py +1389 -0
  69. shikiru/domain/__init__.py +1 -0
  70. shikiru/domain/acceptance.py +130 -0
  71. shikiru/domain/activity.py +28 -0
  72. shikiru/domain/attempts.py +333 -0
  73. shikiru/domain/briefing.py +257 -0
  74. shikiru/domain/commands.py +219 -0
  75. shikiru/domain/decisions.py +231 -0
  76. shikiru/domain/docker.py +262 -0
  77. shikiru/domain/engine_uptime.py +87 -0
  78. shikiru/domain/escalation.py +194 -0
  79. shikiru/domain/escalation_evidence.py +186 -0
  80. shikiru/domain/escalation_panel.py +144 -0
  81. shikiru/domain/events.py +71 -0
  82. shikiru/domain/evidence_export.py +89 -0
  83. shikiru/domain/faults.py +86 -0
  84. shikiru/domain/feedback.py +180 -0
  85. shikiru/domain/feedback_rounds.py +172 -0
  86. shikiru/domain/fencing.py +31 -0
  87. shikiru/domain/ladder.py +102 -0
  88. shikiru/domain/ladder_text.py +44 -0
  89. shikiru/domain/levels.py +44 -0
  90. shikiru/domain/motion.py +199 -0
  91. shikiru/domain/packet_evidence.py +377 -0
  92. shikiru/domain/pushes.py +47 -0
  93. shikiru/domain/quota_blocks.py +44 -0
  94. shikiru/domain/quota_waits.py +219 -0
  95. shikiru/domain/recovery.py +194 -0
  96. shikiru/domain/reports.py +498 -0
  97. shikiru/domain/results.py +217 -0
  98. shikiru/domain/runs.py +85 -0
  99. shikiru/domain/scheduling.py +231 -0
  100. shikiru/domain/status_tasks.py +40 -0
  101. shikiru/domain/steps.py +325 -0
  102. shikiru/domain/tasks.py +174 -0
  103. shikiru/domain/timeline.py +208 -0
  104. shikiru/domain/timeline_phrases.py +271 -0
  105. shikiru/domain/versions.py +18 -0
  106. shikiru/domain/workflow.py +372 -0
  107. shikiru/engine/__init__.py +0 -0
  108. shikiru/engine/acceptance.py +90 -0
  109. shikiru/engine/agents.py +61 -0
  110. shikiru/engine/cosmetic_decorator.py +99 -0
  111. shikiru/engine/daemon.py +409 -0
  112. shikiru/engine/dependency.py +37 -0
  113. shikiru/engine/escalation_filing.py +110 -0
  114. shikiru/engine/escalations.py +401 -0
  115. shikiru/engine/events.py +285 -0
  116. shikiru/engine/evidence.py +81 -0
  117. shikiru/engine/fault_reporter.py +144 -0
  118. shikiru/engine/feedback.py +577 -0
  119. shikiru/engine/feedback_audit.py +80 -0
  120. shikiru/engine/feedback_intake.py +95 -0
  121. shikiru/engine/feedback_snapshot.py +148 -0
  122. shikiru/engine/flow.py +87 -0
  123. shikiru/engine/handlers/__init__.py +33 -0
  124. shikiru/engine/handlers/base.py +148 -0
  125. shikiru/engine/handlers/engine.py +24 -0
  126. shikiru/engine/handlers/escalations.py +174 -0
  127. shikiru/engine/handlers/operator.py +81 -0
  128. shikiru/engine/handlers/tasks.py +237 -0
  129. shikiru/engine/history.py +128 -0
  130. shikiru/engine/integration.py +1234 -0
  131. shikiru/engine/integration_turns.py +337 -0
  132. shikiru/engine/layout_guard.py +362 -0
  133. shikiru/engine/lifecycle.py +60 -0
  134. shikiru/engine/monitor.py +317 -0
  135. shikiru/engine/notification_delivery.py +124 -0
  136. shikiru/engine/notification_feed.py +57 -0
  137. shikiru/engine/notifier.py +91 -0
  138. shikiru/engine/pull_request_closing.py +117 -0
  139. shikiru/engine/push_service.py +177 -0
  140. shikiru/engine/quota_probe_threads.py +79 -0
  141. shikiru/engine/quota_reconciler.py +210 -0
  142. shikiru/engine/quota_schedule.py +24 -0
  143. shikiru/engine/quota_waits.py +102 -0
  144. shikiru/engine/reconcile.py +91 -0
  145. shikiru/engine/recovery.py +284 -0
  146. shikiru/engine/retention.py +102 -0
  147. shikiru/engine/root_preparation.py +86 -0
  148. shikiru/engine/scheduler.py +320 -0
  149. shikiru/engine/session_guard.py +46 -0
  150. shikiru/engine/sessions.py +95 -0
  151. shikiru/engine/signalled.py +75 -0
  152. shikiru/engine/step_runner.py +140 -0
  153. shikiru/engine/supervisor.py +101 -0
  154. shikiru/engine/takeover.py +223 -0
  155. shikiru/engine/task_facts.py +46 -0
  156. shikiru/engine/tick_guard.py +95 -0
  157. shikiru/engine/titles.py +297 -0
  158. shikiru/engine/transitions.py +79 -0
  159. shikiru/engine/turn_context.py +206 -0
  160. shikiru/engine/turn_execution.py +30 -0
  161. shikiru/engine/turn_loop.py +230 -0
  162. shikiru/engine/turn_processes.py +79 -0
  163. shikiru/engine/turns.py +457 -0
  164. shikiru/errors.py +144 -0
  165. shikiru/helper/__init__.py +3 -0
  166. shikiru/helper/helper.py +182 -0
  167. shikiru/helper/models.py +11 -0
  168. shikiru/mcp/__init__.py +1 -0
  169. shikiru/mcp/server.py +196 -0
  170. shikiru/operator/__init__.py +33 -0
  171. shikiru/operator/attachment.py +24 -0
  172. shikiru/operator/commands.py +319 -0
  173. shikiru/operator/detectors.py +374 -0
  174. shikiru/operator/doctor.py +351 -0
  175. shikiru/operator/events.py +57 -0
  176. shikiru/operator/evidence_export.py +498 -0
  177. shikiru/operator/evidence_writer.py +78 -0
  178. shikiru/operator/lifecycle.py +294 -0
  179. shikiru/operator/plugin_checks.py +194 -0
  180. shikiru/operator/probe.py +287 -0
  181. shikiru/operator/queries.py +1601 -0
  182. shikiru/operator/services.py +36 -0
  183. shikiru/operator/sessions.py +378 -0
  184. shikiru/operator/strip_views.py +162 -0
  185. shikiru/operator/timeline.py +416 -0
  186. shikiru/operator/toml_text.py +62 -0
  187. shikiru/palette.py +18 -0
  188. shikiru/paths.py +91 -0
  189. shikiru/plugins/__init__.py +0 -0
  190. shikiru/plugins/agents/__init__.py +1 -0
  191. shikiru/plugins/agents/claude/__init__.py +5 -0
  192. shikiru/plugins/agents/claude/agent.py +251 -0
  193. shikiru/plugins/agents/claude/context.py +116 -0
  194. shikiru/plugins/agents/claude/headless.py +92 -0
  195. shikiru/plugins/agents/claude/hook.py +57 -0
  196. shikiru/plugins/agents/claude/model_catalog.py +105 -0
  197. shikiru/plugins/agents/claude/options.py +62 -0
  198. shikiru/plugins/agents/claude/turn.py +1018 -0
  199. shikiru/plugins/agents/codex/__init__.py +5 -0
  200. shikiru/plugins/agents/codex/agent.py +486 -0
  201. shikiru/plugins/agents/codex/headless.py +166 -0
  202. shikiru/plugins/agents/codex/home.py +14 -0
  203. shikiru/plugins/agents/codex/model_catalog.py +128 -0
  204. shikiru/plugins/agents/codex/options.py +94 -0
  205. shikiru/plugins/agents/codex/quota.py +195 -0
  206. shikiru/plugins/agents/codex/sandbox.py +31 -0
  207. shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
  208. shikiru/plugins/agents/codex/turn.py +767 -0
  209. shikiru/plugins/agents/codex/usage.py +106 -0
  210. shikiru/plugins/agents/shared_settings.py +62 -0
  211. shikiru/plugins/widgets/__init__.py +3 -0
  212. shikiru/plugins/widgets/dialogue.py +144 -0
  213. shikiru/plugins/widgets/disk.py +123 -0
  214. shikiru/plugins/widgets/docker.py +225 -0
  215. shikiru/plugins/widgets/escalations.py +402 -0
  216. shikiru/plugins/widgets/faults.py +204 -0
  217. shikiru/plugins/widgets/header.py +321 -0
  218. shikiru/plugins/widgets/queue.py +1140 -0
  219. shikiru/plugins/widgets/queue_hint.py +93 -0
  220. shikiru/plugins/widgets/resources.py +105 -0
  221. shikiru/plugins/widgets/strips.py +82 -0
  222. shikiru/plugins/widgets/usage.py +176 -0
  223. shikiru/private.py +94 -0
  224. shikiru/process.py +535 -0
  225. shikiru/prompts/__init__.py +3 -0
  226. shikiru/prompts/renderer.py +85 -0
  227. shikiru/prompts/templates/_base.md.j2 +17 -0
  228. shikiru/prompts/templates/_macros.md.j2 +61 -0
  229. shikiru/prompts/templates/conflicts.md.j2 +22 -0
  230. shikiru/prompts/templates/consult.md.j2 +5 -0
  231. shikiru/prompts/templates/continue.md.j2 +4 -0
  232. shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
  233. shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
  234. shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
  235. shikiru/prompts/templates/handoff.md.j2 +23 -0
  236. shikiru/prompts/templates/implement.md.j2 +13 -0
  237. shikiru/prompts/templates/plan.md.j2 +30 -0
  238. shikiru/prompts/templates/rework.md.j2 +13 -0
  239. shikiru/prompts/templates/triage.md.j2 +20 -0
  240. shikiru/prompts/templates/verify.md.j2 +29 -0
  241. shikiru/store/__init__.py +1 -0
  242. shikiru/store/commands.py +242 -0
  243. shikiru/store/database.py +297 -0
  244. shikiru/store/escalations.py +240 -0
  245. shikiru/store/events.py +213 -0
  246. shikiru/store/feedback.py +266 -0
  247. shikiru/store/migrations/001_initial.sql +63 -0
  248. shikiru/store/migrations/002_headless_turns.sql +31 -0
  249. shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
  250. shikiru/store/migrations/004_repo_base_ref.sql +3 -0
  251. shikiru/store/migrations/005_complexity_explicit.sql +3 -0
  252. shikiru/store/migrations/006_events_kind_index.sql +1 -0
  253. shikiru/store/migrations/007_notification_cursor.sql +1 -0
  254. shikiru/store/migrations/008_reported_usage.sql +1 -0
  255. shikiru/store/migrations/009_billing_declarations.sql +2 -0
  256. shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
  257. shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
  258. shikiru/store/migrations/013_push_targets.sql +7 -0
  259. shikiru/store/migrations/014_agent_block_time.sql +6 -0
  260. shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
  261. shikiru/store/migrations/016_feedback_rounds.sql +29 -0
  262. shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
  263. shikiru/store/migrations/__init__.py +1 -0
  264. shikiru/store/notification_cursor.py +29 -0
  265. shikiru/store/pushes.py +117 -0
  266. shikiru/store/quotas.py +153 -0
  267. shikiru/store/reader.py +310 -0
  268. shikiru/store/retention.py +92 -0
  269. shikiru/store/runs.py +227 -0
  270. shikiru/store/tasks.py +949 -0
  271. shikiru/telemetry/__init__.py +1 -0
  272. shikiru/telemetry/disk.py +167 -0
  273. shikiru/telemetry/docker.py +164 -0
  274. shikiru/telemetry/resources.py +296 -0
  275. shikiru/tmux/__init__.py +3 -0
  276. shikiru/tmux/client.py +637 -0
  277. shikiru/tmux/decorator.py +225 -0
  278. shikiru/tmux/layout.py +200 -0
  279. shikiru/tmux/session.py +381 -0
  280. shikiru/ui/__init__.py +9 -0
  281. shikiru/ui/age.py +45 -0
  282. shikiru/ui/bubbles.py +41 -0
  283. shikiru/ui/compact.py +1209 -0
  284. shikiru/ui/console.py +49 -0
  285. shikiru/ui/fold.py +50 -0
  286. shikiru/ui/gated_static.py +117 -0
  287. shikiru/ui/gauge.py +40 -0
  288. shikiru/ui/glyphs.py +109 -0
  289. shikiru/ui/keys.py +57 -0
  290. shikiru/ui/live_interval.py +42 -0
  291. shikiru/ui/popup.py +311 -0
  292. shikiru/ui/quota_status.py +26 -0
  293. shikiru/ui/recent_window.py +78 -0
  294. shikiru/ui/screen.py +57 -0
  295. shikiru/ui/section.py +193 -0
  296. shikiru/ui/session.py +65 -0
  297. shikiru/ui/size.py +17 -0
  298. shikiru/ui/splash.py +42 -0
  299. shikiru/ui/status.py +47 -0
  300. shikiru/ui/status_strip.py +149 -0
  301. shikiru/ui/stream.py +152 -0
  302. shikiru/ui/strip.py +332 -0
  303. shikiru/ui/theme.py +83 -0
  304. shikiru/ui/tokens.py +51 -0
  305. shikiru/view_log.py +126 -0
  306. shikiru/workspace/__init__.py +3 -0
  307. shikiru/workspace/base_refs.py +160 -0
  308. shikiru/workspace/caches.py +136 -0
  309. shikiru/workspace/facts.py +118 -0
  310. shikiru/workspace/git.py +601 -0
  311. shikiru/workspace/github.py +744 -0
  312. shikiru/workspace/messages.py +107 -0
  313. shikiru/workspace/reports.py +145 -0
  314. shikiru/workspace/roots.py +180 -0
  315. shikiru_cli-0.2.0.dist-info/METADATA +428 -0
  316. shikiru_cli-0.2.0.dist-info/RECORD +319 -0
  317. shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
  318. shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
  319. shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1 @@
1
+ """Host and engine process resource samplers."""
@@ -0,0 +1,167 @@
1
+ """Disk space and directory usage for the dashboard's Disk section."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import shutil
8
+ from contextlib import suppress
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING
11
+
12
+ from shikiru.paths import project_state_dir
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable, Iterator
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+
19
+ from shikiru.clock import Clock
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class DirectoryUsage:
24
+ """The directories directly under a root and the bytes of every file beneath it."""
25
+
26
+ directories: int
27
+ size_bytes: int
28
+
29
+
30
+ def directory_usage(root: Path) -> DirectoryUsage:
31
+ """Measure `root`, following no symlink; a missing `root` measures as empty.
32
+
33
+ A file removed while the tree is walked (by retention) counts for nothing.
34
+ """
35
+ if not root.is_dir():
36
+ return DirectoryUsage(0, 0)
37
+ directories = sum(1 for entry in root.iterdir() if entry.is_dir())
38
+ return DirectoryUsage(directories, sum(stat.st_size for stat in _file_stats(root, None)))
39
+
40
+
41
+ def tree_size(root: Path) -> int:
42
+ """Return the apparent bytes of every file beneath `root`; a missing `root` is zero.
43
+
44
+ Symlinks are not followed, a hard-linked file counts once, and an unreadable subtree raises
45
+ `OSError` instead of being skipped.
46
+ """
47
+ if not root.is_dir():
48
+ return 0
49
+ linked: set[tuple[int, int]] = set()
50
+ size = 0
51
+ for stat in _file_stats(root, _raise):
52
+ if stat.st_nlink > 1:
53
+ identity = (stat.st_dev, stat.st_ino)
54
+ if identity in linked:
55
+ continue
56
+ linked.add(identity)
57
+ size += stat.st_size
58
+ return size
59
+
60
+
61
+ def _raise(error: OSError) -> None:
62
+ raise error
63
+
64
+
65
+ def _file_stats(root: Path, onerror: Callable[[OSError], None] | None) -> Iterator[os.stat_result]:
66
+ for parent, _, names in os.walk(root, onerror=onerror):
67
+ for name in names:
68
+ with suppress(FileNotFoundError):
69
+ yield os.lstat(os.path.join(parent, name)) # noqa: PTH118
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class DiskSnapshot:
74
+ """The latest disk observation; sizes and `sampled_at` survive a failed later sample."""
75
+
76
+ free_bytes: int | None = None
77
+ total_bytes: int | None = None
78
+ shikiru_bytes: int | None = None
79
+ sampled_at: datetime | None = None
80
+ error: str | None = None
81
+ failed: bool = False
82
+
83
+
84
+ class DiskSampler:
85
+ """Measure the project filesystem and its `.shikiru` folder off the event loop.
86
+
87
+ Free space is measured every `refresh_seconds`; the folder size is walked on its own, slower
88
+ cadence in a single background flight so a long walk never delays the free-space reading.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ clock: Clock,
94
+ project_root: Path,
95
+ *,
96
+ refresh_seconds: Callable[[], float],
97
+ size_refresh_seconds: Callable[[], float],
98
+ ) -> None:
99
+ """Measure `project_root` at most once per cadence of `clock` time."""
100
+ self._clock = clock
101
+ self._project_root = project_root
102
+ self._shikiru_root = project_state_dir(project_root)
103
+ self._refresh_seconds = refresh_seconds
104
+ self._size_refresh_seconds = size_refresh_seconds
105
+ self._attempted_at: datetime | None = None
106
+ self._size_finished_at: datetime | None = None
107
+ self._size_task: asyncio.Task[None] | None = None
108
+ self._free_bytes: int | None = None
109
+ self._total_bytes: int | None = None
110
+ self._sampled_at: datetime | None = None
111
+ self._shikiru_bytes: int | None = None
112
+ self._free_error: str | None = None
113
+ self._size_error: str | None = None
114
+ self._lock = asyncio.Lock()
115
+
116
+ async def sample_if_due(self) -> DiskSnapshot:
117
+ """Return the cached observation, measuring again once a cadence has passed."""
118
+ self._start_size_walk_if_due()
119
+ async with self._lock:
120
+ if self._is_due(self._attempted_at, self._refresh_seconds):
121
+ await self._sample_free_space()
122
+ return self._snapshot()
123
+
124
+ def _is_due(self, since: datetime | None, interval: Callable[[], float]) -> bool:
125
+ return since is None or (self._clock.now() - since).total_seconds() >= interval()
126
+
127
+ def _start_size_walk_if_due(self) -> None:
128
+ if self._size_task is not None and not self._size_task.done():
129
+ return
130
+ if self._is_due(self._size_finished_at, self._size_refresh_seconds):
131
+ self._size_task = asyncio.create_task(self._walk_size())
132
+
133
+ async def _walk_size(self) -> None:
134
+ try:
135
+ self._shikiru_bytes = await asyncio.to_thread(tree_size, self._shikiru_root)
136
+ self._size_error = None
137
+ except OSError as error:
138
+ self._size_error = str(error)
139
+ self._size_finished_at = self._clock.now()
140
+
141
+ async def _sample_free_space(self) -> None:
142
+ now = self._clock.now()
143
+ self._attempted_at = now
144
+ try:
145
+ self._free_bytes, self._total_bytes = await asyncio.to_thread(
146
+ _filesystem, self._project_root
147
+ )
148
+ except OSError as error:
149
+ self._free_error = str(error)
150
+ return
151
+ self._free_error = None
152
+ self._sampled_at = now
153
+
154
+ def _snapshot(self) -> DiskSnapshot:
155
+ return DiskSnapshot(
156
+ self._free_bytes,
157
+ self._total_bytes,
158
+ self._shikiru_bytes,
159
+ self._sampled_at,
160
+ self._free_error or self._size_error,
161
+ failed=self._free_error is None and self._size_error is not None,
162
+ )
163
+
164
+
165
+ def _filesystem(root: Path) -> tuple[int, int]:
166
+ usage = shutil.disk_usage(root)
167
+ return usage.free, usage.total
@@ -0,0 +1,164 @@
1
+ """Docker container sampling for the dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import shutil
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING, cast
10
+
11
+ from shikiru.domain.docker import (
12
+ ContainerUsage,
13
+ DockerParseError,
14
+ DockerSummary,
15
+ parse_ps_record,
16
+ parse_stats_record,
17
+ summarize,
18
+ )
19
+ from shikiru.errors import CommandTimeoutError
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Callable, Sequence
23
+ from datetime import datetime
24
+ from pathlib import PurePath
25
+ from typing import Any
26
+
27
+ from shikiru.clock import Clock
28
+ from shikiru.process import ProcessResult, SubprocessRunner
29
+
30
+ DOCKER_BINARY = "docker"
31
+ DOCKER_PS_SUBCOMMAND = "ps"
32
+ DOCKER_ALL_FLAG = "-a"
33
+ DOCKER_STATS_SUBCOMMAND = "stats"
34
+ DOCKER_NO_STREAM_FLAG = "--no-stream"
35
+ DOCKER_FORMAT_FLAG = "--format"
36
+ DOCKER_JSON_FORMAT = "json"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class DockerSnapshot:
41
+ """The latest Docker sample and whether its daemon responded."""
42
+
43
+ available: bool
44
+ summary: DockerSummary | None = None
45
+ sampled_at: datetime | None = None
46
+ error: str | None = None
47
+ failed: bool = False
48
+
49
+
50
+ class DockerSampler:
51
+ """Samples `docker ps -a` and `docker stats --no-stream` together through `SubprocessRunner`."""
52
+
53
+ def __init__(
54
+ self,
55
+ runner: SubprocessRunner,
56
+ clock: Clock,
57
+ *,
58
+ refresh_seconds: Callable[[], float],
59
+ timeout_seconds: float,
60
+ project_roots: Sequence[PurePath] | None,
61
+ ) -> None:
62
+ """Use injected process and time boundaries; `project_roots=None` scopes every container."""
63
+ self._runner = runner
64
+ self._clock = clock
65
+ self._refresh_seconds = refresh_seconds
66
+ self._timeout_seconds = timeout_seconds
67
+ self._project_roots = project_roots
68
+ self._sampled_at: datetime | None = None
69
+ self._last = DockerSnapshot(available=False)
70
+
71
+ async def sample(self) -> DockerSnapshot:
72
+ """Probe Docker and return the scoped container summary."""
73
+ now = self._clock.now()
74
+ if shutil.which(DOCKER_BINARY) is None:
75
+ self._record_problem(error="Docker CLI is unavailable", failed=False)
76
+ else:
77
+ listing, stats = await asyncio.gather(
78
+ self._attempt(DOCKER_PS_SUBCOMMAND, DOCKER_ALL_FLAG),
79
+ self._attempt(DOCKER_STATS_SUBCOMMAND, DOCKER_NO_STREAM_FLAG),
80
+ )
81
+ self._record(now, listing, stats)
82
+ self._sampled_at = now
83
+ return self._last
84
+
85
+ async def sample_if_due(self) -> DockerSnapshot:
86
+ """Run a Docker sample no more often than the configured refresh cadence."""
87
+ sampled_at = self._sampled_at
88
+ if (
89
+ sampled_at is None
90
+ or (self._clock.now() - sampled_at).total_seconds() >= self._refresh_seconds()
91
+ ):
92
+ return await self.sample()
93
+ return self._last
94
+
95
+ async def _attempt(self, *arguments: str) -> ProcessResult | CommandTimeoutError | OSError:
96
+ try:
97
+ return await self._runner.run(
98
+ [DOCKER_BINARY, *arguments, DOCKER_FORMAT_FLAG, DOCKER_JSON_FORMAT],
99
+ timeout=self._timeout_seconds,
100
+ check=False,
101
+ )
102
+ except (CommandTimeoutError, OSError) as error:
103
+ return error
104
+
105
+ def _record(
106
+ self,
107
+ now: datetime,
108
+ listing: ProcessResult | CommandTimeoutError | OSError,
109
+ stats: ProcessResult | CommandTimeoutError | OSError,
110
+ ) -> None:
111
+ if isinstance(listing, (CommandTimeoutError, OSError)):
112
+ self._record_problem(error=str(listing), failed=True)
113
+ elif listing.returncode != 0:
114
+ error = listing.stderr.strip() or f"Docker ps exited with status {listing.returncode}"
115
+ self._record_problem(error=error, failed=False)
116
+ else:
117
+ try:
118
+ containers = _decode(listing.stdout, parse_ps_record)
119
+ usages = _usages(stats)
120
+ except DockerParseError as error:
121
+ self._record_problem(error=str(error), failed=True)
122
+ return
123
+ self._last = DockerSnapshot(
124
+ available=True,
125
+ summary=summarize(containers, usages, self._project_roots),
126
+ sampled_at=now,
127
+ )
128
+
129
+ def _record_problem(self, *, error: str, failed: bool) -> None:
130
+ self._last = DockerSnapshot(
131
+ available=False,
132
+ summary=self._last.summary,
133
+ sampled_at=self._last.sampled_at,
134
+ error=error,
135
+ failed=failed,
136
+ )
137
+
138
+
139
+ def _usages(
140
+ stats: ProcessResult | CommandTimeoutError | OSError,
141
+ ) -> list[ContainerUsage] | None:
142
+ if isinstance(stats, (CommandTimeoutError, OSError)) or stats.returncode != 0:
143
+ return None
144
+ try:
145
+ parsed = _decode(stats.stdout, parse_stats_record)
146
+ except DockerParseError:
147
+ return None
148
+ return [usage for usage in parsed if usage is not None]
149
+
150
+
151
+ def _decode[Parsed](stdout: str, parse: Callable[[dict[str, object]], Parsed]) -> list[Parsed]:
152
+ return [parse(_record(line)) for line in stdout.splitlines() if line.strip()]
153
+
154
+
155
+ def _record(line: str) -> dict[str, object]:
156
+ try:
157
+ decoded = json.loads(line)
158
+ except json.JSONDecodeError as error:
159
+ message = f"docker returned invalid JSON: {error}"
160
+ raise DockerParseError(message) from error
161
+ if not isinstance(decoded, dict):
162
+ message = "docker JSON record must be an object"
163
+ raise DockerParseError(message)
164
+ return cast("dict[str, Any]", decoded)
@@ -0,0 +1,296 @@
1
+ """Host and engine process-tree resource sampling for scheduling and the dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ import psutil
10
+
11
+ from shikiru.process import ProcessIdentity
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable, Iterable
15
+ from datetime import datetime
16
+
17
+ from shikiru.clock import Clock
18
+ from shikiru.config.settings import ResourcesConfig
19
+
20
+ _BYTES_PER_GIB = 1024**3
21
+ _PPID_ATTR = "ppid"
22
+ _CREATE_TIME_ATTR = "create_time"
23
+ _PERCENT_PER_CORE = 100.0
24
+ _MIN_LIFETIME_SECONDS = 0.1
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class _TableEntry:
29
+ process: psutil.Process
30
+ ppid: int
31
+ create_time: float
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class _ProcessTable:
36
+ entries: dict[int, _TableEntry]
37
+ children: dict[int, list[int]]
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ResourceSnapshot:
42
+ """The latest host, Shikiru and agent process-tree measurements."""
43
+
44
+ host_cpu_pct: float
45
+ host_ram_used_pct: float
46
+ free_ram_gb: float
47
+ instance_cpu_pct: float | None = None
48
+ instance_rss_gb: float | None = None
49
+ sampled_at: datetime | None = None
50
+ host_ram_used_bytes: int | None = None
51
+ host_ram_total_bytes: int | None = None
52
+ shikiru_cpu_pct: float | None = None
53
+ shikiru_rss_gb: float | None = None
54
+ agents_cpu_pct: float | None = None
55
+ agents_rss_gb: float | None = None
56
+ shikiru_process_tree_empty: bool | None = None
57
+ agents_process_tree_empty: bool | None = None
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class ResourceProcessRoots:
62
+ """Classified live process identities supplied by the current project."""
63
+
64
+ shikiru: tuple[ProcessIdentity, ...]
65
+ agents: tuple[ProcessIdentity, ...]
66
+
67
+
68
+ class ResourceSampleError(RuntimeError):
69
+ """The operating system could not provide a complete host or process sample."""
70
+
71
+
72
+ class ResourceSampler:
73
+ """Samples host CPU and available memory through `psutil`."""
74
+
75
+ def __init__(self, clock: Clock, config: Callable[[], ResourcesConfig]) -> None:
76
+ """Use `clock` and the cadences `config` returns when each sample is due."""
77
+ self._clock = clock
78
+ self._config = config
79
+ self._host_cpu: deque[tuple[datetime, float]] = deque()
80
+ self._last = ResourceSnapshot(0.0, 0.0, 0.0)
81
+ self._last_complete = ResourceSnapshot(0.0, 0.0, 0.0)
82
+ self._sampled_at: datetime | None = None
83
+ self._instance_sampled_at: datetime | None = None
84
+ self._processes: dict[tuple[int, float], psutil.Process] = {}
85
+
86
+ def sample(self) -> ResourceSnapshot:
87
+ """Take one host CPU and available memory sample."""
88
+ now = self._clock.now()
89
+ try:
90
+ host_cpu = psutil.cpu_percent(interval=None)
91
+ virtual_memory = psutil.virtual_memory()
92
+ except psutil.Error as error:
93
+ raise ResourceSampleError(str(error)) from error
94
+ self._host_cpu.append((now, host_cpu))
95
+ self._trim(now)
96
+ self._last = ResourceSnapshot(
97
+ host_cpu_pct=_average(value for _, value in self._host_cpu),
98
+ host_ram_used_pct=virtual_memory.percent,
99
+ free_ram_gb=virtual_memory.available / _BYTES_PER_GIB,
100
+ sampled_at=now,
101
+ host_ram_used_bytes=virtual_memory.used,
102
+ host_ram_total_bytes=virtual_memory.total,
103
+ )
104
+ self._sampled_at = now
105
+ return self._last
106
+
107
+ def sample_if_due(self) -> ResourceSnapshot:
108
+ """Sample at the configured cadence and return the latest snapshot."""
109
+ sampled_at = self._sampled_at
110
+ if (
111
+ sampled_at is None
112
+ or (self._clock.now() - sampled_at).total_seconds() >= self._config().sample_seconds
113
+ ):
114
+ return self.sample()
115
+ return self._last
116
+
117
+ def resources_ok(self, config: ResourcesConfig) -> bool:
118
+ """Return whether a fresh sample passes scheduling thresholds."""
119
+ sample = self.sample_if_due()
120
+ return (
121
+ sample.host_cpu_pct < config.max_cpu_pct
122
+ and sample.free_ram_gb >= config.min_free_ram_gb
123
+ )
124
+
125
+ def process_sample_is_fresh(self) -> bool:
126
+ """Return whether the latest process-tree sample is younger than the sample cadence."""
127
+ sampled_at = self._instance_sampled_at
128
+ return (
129
+ sampled_at is not None
130
+ and (self._clock.now() - sampled_at).total_seconds() < self._config().sample_seconds
131
+ )
132
+
133
+ def sample_process_roots(self, roots: ResourceProcessRoots) -> ResourceSnapshot:
134
+ """Sample host, Shikiru and agent trees from classified process identities."""
135
+ if self.process_sample_is_fresh():
136
+ return self._last_complete
137
+ now = self._clock.now()
138
+ host_sample = self.sample_if_due()
139
+ table = self._process_table()
140
+ agent_processes = self._process_tree(roots.agents, table)
141
+ agent_identities = set(agent_processes)
142
+ shikiru_processes = {
143
+ identity: process
144
+ for identity, process in self._process_tree(roots.shikiru, table).items()
145
+ if identity not in agent_identities
146
+ }
147
+ all_processes = {**shikiru_processes, **agent_processes}
148
+ measurements = self._measure_processes(all_processes, now.timestamp())
149
+ shikiru_measurements = [
150
+ measurement
151
+ for identity, measurement in measurements.items()
152
+ if identity in shikiru_processes
153
+ ]
154
+ agent_measurements = [
155
+ measurement
156
+ for identity, measurement in measurements.items()
157
+ if identity in agent_processes
158
+ ]
159
+ instance_cpu_pct, instance_rss_gb = _usage(list(measurements.values()))
160
+ shikiru_cpu_pct, shikiru_rss_gb = _usage(shikiru_measurements)
161
+ agents_cpu_pct, agents_rss_gb = _usage(agent_measurements)
162
+ snapshot = ResourceSnapshot(
163
+ host_cpu_pct=host_sample.host_cpu_pct,
164
+ host_ram_used_pct=host_sample.host_ram_used_pct,
165
+ free_ram_gb=host_sample.free_ram_gb,
166
+ instance_cpu_pct=instance_cpu_pct,
167
+ instance_rss_gb=instance_rss_gb,
168
+ sampled_at=now,
169
+ host_ram_used_bytes=host_sample.host_ram_used_bytes,
170
+ host_ram_total_bytes=host_sample.host_ram_total_bytes,
171
+ shikiru_cpu_pct=shikiru_cpu_pct,
172
+ shikiru_rss_gb=shikiru_rss_gb,
173
+ agents_cpu_pct=agents_cpu_pct,
174
+ agents_rss_gb=agents_rss_gb,
175
+ shikiru_process_tree_empty=not shikiru_measurements,
176
+ agents_process_tree_empty=not agent_measurements,
177
+ )
178
+ self._last = snapshot
179
+ self._last_complete = snapshot
180
+ self._instance_sampled_at = now
181
+ self._processes = {
182
+ identity: process
183
+ for identity, process in all_processes.items()
184
+ if identity in measurements
185
+ }
186
+ return snapshot
187
+
188
+ def latest(self) -> ResourceSnapshot:
189
+ """Return the latest sample without starting a new observation."""
190
+ return self._last_complete
191
+
192
+ def _process_table(self) -> _ProcessTable:
193
+ entries: dict[int, _TableEntry] = {}
194
+ children: dict[int, list[int]] = {}
195
+ for process in psutil.process_iter([_PPID_ATTR, _CREATE_TIME_ATTR]):
196
+ ppid = process.info[_PPID_ATTR]
197
+ create_time = process.info[_CREATE_TIME_ATTR]
198
+ if ppid is None or create_time is None:
199
+ continue
200
+ entries[process.pid] = _TableEntry(process, ppid, create_time)
201
+ children.setdefault(ppid, []).append(process.pid)
202
+ return _ProcessTable(entries, children)
203
+
204
+ @staticmethod
205
+ def _add_unlisted_root(root_identity: ProcessIdentity, table: _ProcessTable) -> bool:
206
+ try:
207
+ process = psutil.Process(root_identity.pid)
208
+ entry = _TableEntry(process, process.ppid(), process.create_time())
209
+ except psutil.NoSuchProcess, psutil.ZombieProcess:
210
+ return False
211
+ except psutil.Error as error:
212
+ raise ResourceSampleError(str(error)) from error
213
+ table.entries[root_identity.pid] = entry
214
+ table.children.setdefault(entry.ppid, []).append(root_identity.pid)
215
+ return True
216
+
217
+ def _process_tree(
218
+ self, roots: Iterable[ProcessIdentity], table: _ProcessTable
219
+ ) -> dict[tuple[int, float], psutil.Process]:
220
+ found: dict[tuple[int, float], psutil.Process] = {}
221
+ for root_identity in roots:
222
+ if root_identity.pid not in table.entries and not self._add_unlisted_root(
223
+ root_identity, table
224
+ ):
225
+ continue
226
+ root = table.entries[root_identity.pid]
227
+ if not root_identity.matches(ProcessIdentity(root_identity.pid, root.create_time)):
228
+ continue
229
+ pending = [root_identity.pid]
230
+ while pending:
231
+ pid = pending.pop()
232
+ entry = table.entries[pid]
233
+ process_key = (pid, entry.create_time)
234
+ if process_key in found:
235
+ continue
236
+ found[process_key] = self._processes.get(process_key, entry.process)
237
+ pending.extend(
238
+ child
239
+ for child in table.children.get(pid, ())
240
+ if table.entries[child].create_time >= entry.create_time
241
+ )
242
+ return found
243
+
244
+ def _measure_processes(
245
+ self,
246
+ processes: dict[tuple[int, float], psutil.Process],
247
+ now_timestamp: float,
248
+ ) -> dict[tuple[int, float], tuple[float, int]]:
249
+ measurements: dict[tuple[int, float], tuple[float, int]] = {}
250
+ for identity, process in processes.items():
251
+ first_sight_at = None if identity in self._processes else now_timestamp
252
+ measurement = self._measure(process, first_sight_at)
253
+ if measurement is not None:
254
+ measurements[identity] = measurement
255
+ return measurements
256
+
257
+ @staticmethod
258
+ def _measure(process: psutil.Process, first_sight_at: float | None) -> tuple[float, int] | None:
259
+ try:
260
+ with process.oneshot():
261
+ cpu_pct = process.cpu_percent(interval=None)
262
+ if first_sight_at is not None:
263
+ cpu_pct = _lifetime_cpu_pct(process, first_sight_at)
264
+ return cpu_pct, process.memory_info().rss
265
+ except psutil.NoSuchProcess, psutil.ZombieProcess:
266
+ return None
267
+ except psutil.Error as error:
268
+ raise ResourceSampleError(str(error)) from error
269
+
270
+ def _trim(self, now: datetime) -> None:
271
+ cutoff = now.timestamp() - self._config().cpu_window_seconds
272
+ while self._host_cpu and self._host_cpu[0][0].timestamp() < cutoff:
273
+ self._host_cpu.popleft()
274
+
275
+
276
+ def _lifetime_cpu_pct(process: psutil.Process, now_timestamp: float) -> float:
277
+ times = process.cpu_times()
278
+ lifetime = max(now_timestamp - process.create_time(), _MIN_LIFETIME_SECONDS)
279
+ return (times.user + times.system) * _PERCENT_PER_CORE / lifetime
280
+
281
+
282
+ def _average(values: Iterable[float]) -> float:
283
+ sequence = tuple(values)
284
+ return sum(sequence, 0.0) / len(sequence) if sequence else 0.0
285
+
286
+
287
+ def _usage(measurements: list[tuple[float, int]]) -> tuple[float | None, float | None]:
288
+ if not measurements:
289
+ return None, None
290
+ cpu_count = psutil.cpu_count(logical=True)
291
+ if cpu_count is None or cpu_count < 1:
292
+ message = "psutil did not report a logical CPU count"
293
+ raise RuntimeError(message)
294
+ cpu_pct = sum(cpu for cpu, _ in measurements) / cpu_count
295
+ rss_gb = sum(rss for _, rss in measurements) / _BYTES_PER_GIB
296
+ return cpu_pct, rss_gb
@@ -0,0 +1,3 @@
1
+ """tmux client, session, layout and decoration."""
2
+
3
+ from __future__ import annotations