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.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- 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
|
shikiru/tmux/__init__.py
ADDED