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,665 @@
|
|
|
1
|
+
"""Real `WidgetServices` implementations, wired by `bootstrap`.
|
|
2
|
+
|
|
3
|
+
`GitRepoStatusService` is the `header` widget's only source of git data and caches it for its
|
|
4
|
+
configured interval so a dashboard refresh never spawns a git process on every tick.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from dataclasses import dataclass, replace
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from shikiru.dashboard.contract import (
|
|
17
|
+
ContainerProblemView,
|
|
18
|
+
ContainerView,
|
|
19
|
+
DiagnosticSampleState,
|
|
20
|
+
DiskView,
|
|
21
|
+
DockerSummaryView,
|
|
22
|
+
DockerView,
|
|
23
|
+
ElsewhereView,
|
|
24
|
+
RepoStatus,
|
|
25
|
+
ResourceView,
|
|
26
|
+
)
|
|
27
|
+
from shikiru.domain.runs import RunStatus
|
|
28
|
+
from shikiru.domain.tasks import Role, Status
|
|
29
|
+
from shikiru.engine.turn_processes import read_turn_pid, turn_pid_path
|
|
30
|
+
from shikiru.errors import ShikiruError
|
|
31
|
+
from shikiru.operator.lifecycle import task_dashboard_command
|
|
32
|
+
from shikiru.paths import run_dir
|
|
33
|
+
from shikiru.process import ProcessIdentity, identify_pid
|
|
34
|
+
from shikiru.telemetry.resources import ResourceProcessRoots
|
|
35
|
+
from shikiru.tmux.client import (
|
|
36
|
+
ENV_VARIABLE_ASSIGNMENT_SEPARATOR,
|
|
37
|
+
FORMAT_PANE_ID,
|
|
38
|
+
FORMAT_PANE_PID,
|
|
39
|
+
FORMAT_PANE_START_COMMAND,
|
|
40
|
+
FORMAT_WINDOW_ID,
|
|
41
|
+
HOME_PANE_OPTION,
|
|
42
|
+
HOME_PANE_OPTION_VALUE,
|
|
43
|
+
LAYOUT_PANE_OPTION,
|
|
44
|
+
SYSTEM_LAYOUT_PANE,
|
|
45
|
+
TMUX_ENV_COMMAND,
|
|
46
|
+
TMUX_ENV_UNSET_OPTION,
|
|
47
|
+
TMUX_ENV_UNSET_OPTION_ARGUMENT_COUNT,
|
|
48
|
+
)
|
|
49
|
+
from shikiru.tmux.session import home_window_target
|
|
50
|
+
from shikiru.ui.age import format_age, format_short_age
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from collections.abc import Callable, Iterable
|
|
54
|
+
from datetime import datetime
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
|
|
57
|
+
from shikiru.clock import Clock
|
|
58
|
+
from shikiru.config.settings import RepoConfig
|
|
59
|
+
from shikiru.domain.docker import DockerSummary
|
|
60
|
+
from shikiru.domain.results import LiveWork
|
|
61
|
+
from shikiru.domain.runs import Run
|
|
62
|
+
from shikiru.domain.tasks import Task
|
|
63
|
+
from shikiru.operator.queries import OperatorQueries
|
|
64
|
+
from shikiru.store.reader import StateReader
|
|
65
|
+
from shikiru.telemetry.disk import DiskSampler, DiskSnapshot
|
|
66
|
+
from shikiru.telemetry.docker import DockerSampler
|
|
67
|
+
from shikiru.telemetry.resources import ResourceSampler, ResourceSnapshot
|
|
68
|
+
from shikiru.tmux.client import Tmux
|
|
69
|
+
from shikiru.workspace.facts import TaskWorkObserver
|
|
70
|
+
from shikiru.workspace.git import Git
|
|
71
|
+
|
|
72
|
+
_PANE_RESOURCE_FIELD_SEPARATOR = "\t"
|
|
73
|
+
_PANE_RESOURCE_FORMAT = _PANE_RESOURCE_FIELD_SEPARATOR.join(
|
|
74
|
+
(
|
|
75
|
+
FORMAT_PANE_ID,
|
|
76
|
+
FORMAT_PANE_PID,
|
|
77
|
+
FORMAT_WINDOW_ID,
|
|
78
|
+
f"#{{{HOME_PANE_OPTION}}}",
|
|
79
|
+
f"#{{{LAYOUT_PANE_OPTION}}}",
|
|
80
|
+
FORMAT_PANE_START_COMMAND,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
_PANE_RESOURCE_FIELD_COUNT = 6
|
|
84
|
+
_PANE_RESOURCE_MAX_SPLITS = _PANE_RESOURCE_FIELD_COUNT - 1
|
|
85
|
+
TASK_MISSING = "task is missing"
|
|
86
|
+
TASK_WINDOW_UNRECORDED = "task window is not recorded"
|
|
87
|
+
TASK_WINDOW_MISSING = "task window is unavailable"
|
|
88
|
+
TASK_ROLE_PANE_UNRECORDED = "role pane is not recorded"
|
|
89
|
+
TASK_ROLE_PANE_MISSING = "role pane is unavailable"
|
|
90
|
+
TASK_ROLE_PANE_WINDOW_MISMATCH = "role pane is outside the recorded task window"
|
|
91
|
+
HOME_WINDOW_MISSING = "home window is unavailable"
|
|
92
|
+
TERMINAL_TASK_TARGET = "task is terminal; its workflow window is unavailable"
|
|
93
|
+
TASK_TARGET_OPENED = "opened existing task window"
|
|
94
|
+
ROLE_TARGET_OPENED = "opened existing role pane"
|
|
95
|
+
HOME_TARGET_OPENED = "opened home workspace"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class _CachedRepoStatus:
|
|
100
|
+
checked_at: datetime
|
|
101
|
+
sampled_at: datetime | None
|
|
102
|
+
status: RepoStatus
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class _PaneResource:
|
|
107
|
+
pane_id: str
|
|
108
|
+
pane_pid: int
|
|
109
|
+
window_id: str
|
|
110
|
+
home_marker: str
|
|
111
|
+
layout_marker: str
|
|
112
|
+
start_command: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class _PaneOwnership:
|
|
117
|
+
project_root: Path
|
|
118
|
+
pane_owners: dict[str, tuple[str, Role, str]]
|
|
119
|
+
task_windows: dict[str, str]
|
|
120
|
+
taken_over_runs: set[tuple[str, Role]]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class GitRepoStatusService:
|
|
124
|
+
"""Report `header`'s repo row, refreshing at most once per configured interval."""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
git: Git,
|
|
129
|
+
repo: RepoConfig,
|
|
130
|
+
project_dir: Path,
|
|
131
|
+
clock: Clock,
|
|
132
|
+
*,
|
|
133
|
+
cache_ttl_seconds: float,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Read `project_dir`'s checkout, configured by `repo`, through `git` when asked."""
|
|
136
|
+
self._git = git
|
|
137
|
+
self._repo = repo
|
|
138
|
+
self._project_dir = project_dir.resolve()
|
|
139
|
+
self._clock = clock
|
|
140
|
+
self._cache_ttl_seconds = cache_ttl_seconds
|
|
141
|
+
self._cache: _CachedRepoStatus | None = None
|
|
142
|
+
|
|
143
|
+
async def repo_status(self) -> RepoStatus:
|
|
144
|
+
"""Return the cached checkout identity, tracked line changes and observation state."""
|
|
145
|
+
now = self._clock.now()
|
|
146
|
+
if self._cache is not None:
|
|
147
|
+
age = (now - self._cache.checked_at).total_seconds()
|
|
148
|
+
if age < self._cache_ttl_seconds:
|
|
149
|
+
return self._with_sample_age(self._cache)
|
|
150
|
+
try:
|
|
151
|
+
status = await self._status()
|
|
152
|
+
except (OSError, ShikiruError) as error:
|
|
153
|
+
if self._cache is None or self._cache.sampled_at is None:
|
|
154
|
+
status = RepoStatus(
|
|
155
|
+
branch=None,
|
|
156
|
+
ahead=None,
|
|
157
|
+
behind=None,
|
|
158
|
+
dirty=None,
|
|
159
|
+
root=str(self._project_dir),
|
|
160
|
+
state=DiagnosticSampleState.FAILED,
|
|
161
|
+
error=str(error),
|
|
162
|
+
base=self._repo.base,
|
|
163
|
+
)
|
|
164
|
+
self._cache = _CachedRepoStatus(checked_at=now, sampled_at=None, status=status)
|
|
165
|
+
else:
|
|
166
|
+
status = replace(
|
|
167
|
+
self._cache.status,
|
|
168
|
+
state=DiagnosticSampleState.STALE,
|
|
169
|
+
error=str(error),
|
|
170
|
+
)
|
|
171
|
+
self._cache = _CachedRepoStatus(
|
|
172
|
+
checked_at=now,
|
|
173
|
+
sampled_at=self._cache.sampled_at,
|
|
174
|
+
status=status,
|
|
175
|
+
)
|
|
176
|
+
return self._with_sample_age(self._cache)
|
|
177
|
+
self._cache = _CachedRepoStatus(checked_at=now, sampled_at=now, status=status)
|
|
178
|
+
return self._with_sample_age(self._cache)
|
|
179
|
+
|
|
180
|
+
async def _status(self) -> RepoStatus:
|
|
181
|
+
repo = self._repo
|
|
182
|
+
repo_dir = self._project_dir
|
|
183
|
+
branch = await self._git.current_branch(repo_dir)
|
|
184
|
+
line_changes = await self._git.working_tree_line_changes(repo_dir)
|
|
185
|
+
dirty = len(await self._git.status_porcelain(repo_dir))
|
|
186
|
+
ahead, behind = 0, 0
|
|
187
|
+
if repo.base is not None and repo.base != branch:
|
|
188
|
+
ahead = len(await self._git.log_oneline(repo_dir, f"{repo.base}..{branch}"))
|
|
189
|
+
behind = len(await self._git.log_oneline(repo_dir, f"{branch}..{repo.base}"))
|
|
190
|
+
base = branch if repo.base is None else repo.base
|
|
191
|
+
remote_configured = await self._git.remote_configured(repo_dir, repo.remote)
|
|
192
|
+
unpushed, unpulled = (
|
|
193
|
+
await self._publication_counts(repo_dir, base) if remote_configured else (None, None)
|
|
194
|
+
)
|
|
195
|
+
return RepoStatus(
|
|
196
|
+
branch=branch,
|
|
197
|
+
ahead=ahead,
|
|
198
|
+
behind=behind,
|
|
199
|
+
dirty=dirty,
|
|
200
|
+
root=str(repo_dir),
|
|
201
|
+
added_lines=line_changes.added_lines,
|
|
202
|
+
deleted_lines=line_changes.deleted_lines,
|
|
203
|
+
binary_entries=line_changes.binary_entries,
|
|
204
|
+
untracked_paths=line_changes.untracked_paths,
|
|
205
|
+
base=base,
|
|
206
|
+
unpushed=unpushed,
|
|
207
|
+
unpulled=unpulled,
|
|
208
|
+
remote_configured=remote_configured,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
async def _publication_counts(self, repo_dir: Path, base: str) -> tuple[int | None, int | None]:
|
|
212
|
+
remote = self._repo.remote
|
|
213
|
+
if await self._git.remote_branch_sha(repo_dir, remote, base) is None:
|
|
214
|
+
return None, None
|
|
215
|
+
remote_ref = f"refs/remotes/{remote}/{base}"
|
|
216
|
+
unpushed = await self._git.log_oneline(repo_dir, f"{remote_ref}..{base}")
|
|
217
|
+
unpulled = await self._git.log_oneline(repo_dir, f"{base}..{remote_ref}")
|
|
218
|
+
return len(unpushed), len(unpulled)
|
|
219
|
+
|
|
220
|
+
def _with_sample_age(self, cached: _CachedRepoStatus) -> RepoStatus:
|
|
221
|
+
if cached.sampled_at is None:
|
|
222
|
+
return cached.status
|
|
223
|
+
age = (self._clock.now() - cached.sampled_at).total_seconds()
|
|
224
|
+
return replace(cached.status, sampled_ago=format_age(age))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
TASK_WORKTREE_MISSING = "task worktree is missing"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass(frozen=True)
|
|
231
|
+
class TaskWorkSample:
|
|
232
|
+
"""A task's live work, or the error that stopped it being read."""
|
|
233
|
+
|
|
234
|
+
work: LiveWork | None
|
|
235
|
+
error: str | None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True)
|
|
239
|
+
class _CachedTaskWork:
|
|
240
|
+
checked_at: datetime
|
|
241
|
+
sample: TaskWorkSample
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class TaskWorkService:
|
|
245
|
+
"""Report a task's live work, reading its worktree at most once per configured interval."""
|
|
246
|
+
|
|
247
|
+
def __init__(
|
|
248
|
+
self,
|
|
249
|
+
observer: TaskWorkObserver,
|
|
250
|
+
clock: Clock,
|
|
251
|
+
*,
|
|
252
|
+
cache_ttl_seconds: float,
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Read worktrees through `observer`, and time the cache with `clock`."""
|
|
255
|
+
self._observer = observer
|
|
256
|
+
self._clock = clock
|
|
257
|
+
self._cache_ttl_seconds = cache_ttl_seconds
|
|
258
|
+
self._cache: dict[str, _CachedTaskWork] = {}
|
|
259
|
+
self._reads: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
|
260
|
+
|
|
261
|
+
async def sample(self, task_id: str, task_root: Path, base_ref: str) -> TaskWorkSample:
|
|
262
|
+
"""Return the live work of `task_id`, reading `task_root` unless a fresh sample exists.
|
|
263
|
+
|
|
264
|
+
Concurrent callers for one task share a read: the first one reads, and the rest wait for
|
|
265
|
+
it and take its sample while it is still fresh.
|
|
266
|
+
"""
|
|
267
|
+
async with self._reads[task_id]:
|
|
268
|
+
now = self._clock.now()
|
|
269
|
+
cached = self._cache.get(task_id)
|
|
270
|
+
if cached is not None:
|
|
271
|
+
age = (now - cached.checked_at).total_seconds()
|
|
272
|
+
if age < self._cache_ttl_seconds:
|
|
273
|
+
return cached.sample
|
|
274
|
+
sample = await self._read(task_root, base_ref)
|
|
275
|
+
self._cache[task_id] = _CachedTaskWork(checked_at=now, sample=sample)
|
|
276
|
+
return sample
|
|
277
|
+
|
|
278
|
+
async def _read(self, task_root: Path, base_ref: str) -> TaskWorkSample:
|
|
279
|
+
if not await asyncio.to_thread(task_root.is_dir):
|
|
280
|
+
return TaskWorkSample(work=None, error=f"{TASK_WORKTREE_MISSING}: {task_root}")
|
|
281
|
+
try:
|
|
282
|
+
work = await self._observer.observe(task_root, base_ref)
|
|
283
|
+
except (OSError, ShikiruError) as error:
|
|
284
|
+
return TaskWorkSample(work=None, error=str(error))
|
|
285
|
+
return TaskWorkSample(work=work, error=None)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class DashboardServices:
|
|
289
|
+
"""The `WidgetServices` bootstrap wires into every `dash` process."""
|
|
290
|
+
|
|
291
|
+
def __init__(
|
|
292
|
+
self,
|
|
293
|
+
repo_status_service: GitRepoStatusService,
|
|
294
|
+
*,
|
|
295
|
+
clock: Clock,
|
|
296
|
+
engine_up: Callable[[], bool],
|
|
297
|
+
engine_identity: Callable[[], ProcessIdentity | None],
|
|
298
|
+
project_root: Path,
|
|
299
|
+
state: StateReader,
|
|
300
|
+
tmux: Tmux,
|
|
301
|
+
session_target: str,
|
|
302
|
+
resources: ResourceSampler,
|
|
303
|
+
docker: DockerSampler,
|
|
304
|
+
disk: DiskSampler,
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Delegate source reads and sampling through injected process boundaries."""
|
|
307
|
+
self._repo_status_service = repo_status_service
|
|
308
|
+
self._clock = clock
|
|
309
|
+
self._engine_up = engine_up
|
|
310
|
+
self._engine_identity = engine_identity
|
|
311
|
+
self._project_root = project_root
|
|
312
|
+
self._state = state
|
|
313
|
+
self._tmux = tmux
|
|
314
|
+
self._session_target = session_target
|
|
315
|
+
self._resources = resources
|
|
316
|
+
self._docker = docker
|
|
317
|
+
self._disk = disk
|
|
318
|
+
self._resource_failure: tuple[datetime, str] | None = None
|
|
319
|
+
|
|
320
|
+
async def repo_status(self) -> RepoStatus:
|
|
321
|
+
"""See `GitRepoStatusService.repo_status`."""
|
|
322
|
+
return await self._repo_status_service.repo_status()
|
|
323
|
+
|
|
324
|
+
def engine_up(self) -> bool:
|
|
325
|
+
"""Return whether the engine holds its liveness lock."""
|
|
326
|
+
return self._engine_up()
|
|
327
|
+
|
|
328
|
+
def engine_identity(self) -> ProcessIdentity | None:
|
|
329
|
+
"""Return the verified identity of the process holding the engine lock."""
|
|
330
|
+
return self._engine_identity()
|
|
331
|
+
|
|
332
|
+
async def resources(self) -> ResourceView:
|
|
333
|
+
"""Sample host usage and classified process trees belonging to this project."""
|
|
334
|
+
try:
|
|
335
|
+
previous = self._resources.latest()
|
|
336
|
+
snapshot = previous
|
|
337
|
+
if not self._resources.process_sample_is_fresh():
|
|
338
|
+
pane_rows: list[str] = []
|
|
339
|
+
if await self._tmux.target_exists(self._session_target):
|
|
340
|
+
pane_rows = await self._tmux.list_session_panes(
|
|
341
|
+
self._session_target, _PANE_RESOURCE_FORMAT
|
|
342
|
+
)
|
|
343
|
+
roots = self._resource_roots(pane_rows)
|
|
344
|
+
snapshot = self._resources.sample_process_roots(roots)
|
|
345
|
+
except (OSError, RuntimeError, ShikiruError, ValueError) as error:
|
|
346
|
+
failed_at = self._clock.now()
|
|
347
|
+
self._resource_failure = (failed_at, str(error))
|
|
348
|
+
latest = self._resources.latest()
|
|
349
|
+
state = (
|
|
350
|
+
DiagnosticSampleState.STALE
|
|
351
|
+
if latest.sampled_at is not None
|
|
352
|
+
else DiagnosticSampleState.FAILED
|
|
353
|
+
)
|
|
354
|
+
return _resource_view(latest, self._clock, state=state, error=str(error))
|
|
355
|
+
if snapshot is not previous:
|
|
356
|
+
self._resource_failure = None
|
|
357
|
+
elif self._resource_failure is not None:
|
|
358
|
+
latest = self._resources.latest()
|
|
359
|
+
state = (
|
|
360
|
+
DiagnosticSampleState.STALE
|
|
361
|
+
if latest.sampled_at is not None
|
|
362
|
+
else DiagnosticSampleState.FAILED
|
|
363
|
+
)
|
|
364
|
+
return _resource_view(
|
|
365
|
+
latest,
|
|
366
|
+
self._clock,
|
|
367
|
+
state=state,
|
|
368
|
+
error=self._resource_failure[1],
|
|
369
|
+
)
|
|
370
|
+
return _resource_view(snapshot, self._clock, state=DiagnosticSampleState.AVAILABLE)
|
|
371
|
+
|
|
372
|
+
def _resource_roots(self, pane_rows: list[str]) -> ResourceProcessRoots:
|
|
373
|
+
tasks = self._state.list_tasks()
|
|
374
|
+
runs = self._state.list_all_runs()
|
|
375
|
+
shikiru = _process_root_map((self._engine_identity(), identify_pid(os.getpid())))
|
|
376
|
+
agents = _live_agent_process_roots(self._project_root, runs)
|
|
377
|
+
pane_shikiru, pane_agents = _pane_process_roots(pane_rows, tasks, runs, self._project_root)
|
|
378
|
+
shikiru.update(pane_shikiru)
|
|
379
|
+
agents.update(pane_agents)
|
|
380
|
+
return ResourceProcessRoots(tuple(shikiru.values()), tuple(agents.values()))
|
|
381
|
+
|
|
382
|
+
def resource_sample(self) -> ResourceView | None:
|
|
383
|
+
"""Return the latest dashboard resource reading without sampling again."""
|
|
384
|
+
snapshot = self._resources.latest()
|
|
385
|
+
failure = self._resource_failure
|
|
386
|
+
if failure is not None and (
|
|
387
|
+
snapshot.sampled_at is None or failure[0] >= snapshot.sampled_at
|
|
388
|
+
):
|
|
389
|
+
state = (
|
|
390
|
+
DiagnosticSampleState.STALE
|
|
391
|
+
if snapshot.sampled_at is not None
|
|
392
|
+
else DiagnosticSampleState.FAILED
|
|
393
|
+
)
|
|
394
|
+
return _resource_view(snapshot, self._clock, state=state, error=failure[1])
|
|
395
|
+
if snapshot.sampled_at is None:
|
|
396
|
+
return None
|
|
397
|
+
return _resource_view(snapshot, self._clock, state=DiagnosticSampleState.AVAILABLE)
|
|
398
|
+
|
|
399
|
+
async def containers(self) -> DockerView:
|
|
400
|
+
"""Return Docker stats in the stable dashboard contract shape."""
|
|
401
|
+
snapshot = await self._docker.sample_if_due()
|
|
402
|
+
if snapshot.error is not None:
|
|
403
|
+
state = (
|
|
404
|
+
DiagnosticSampleState.STALE
|
|
405
|
+
if snapshot.sampled_at is not None
|
|
406
|
+
else DiagnosticSampleState.FAILED
|
|
407
|
+
if snapshot.failed
|
|
408
|
+
else DiagnosticSampleState.UNAVAILABLE
|
|
409
|
+
)
|
|
410
|
+
elif not snapshot.available:
|
|
411
|
+
state = DiagnosticSampleState.UNAVAILABLE
|
|
412
|
+
elif snapshot.summary is not None and snapshot.summary.is_empty:
|
|
413
|
+
state = DiagnosticSampleState.EMPTY
|
|
414
|
+
else:
|
|
415
|
+
state = DiagnosticSampleState.AVAILABLE
|
|
416
|
+
return DockerView(
|
|
417
|
+
available=snapshot.available,
|
|
418
|
+
state=state,
|
|
419
|
+
sampled_ago=_sampled_ago(snapshot.sampled_at, self._clock),
|
|
420
|
+
error=snapshot.error,
|
|
421
|
+
summary=_docker_summary_view(snapshot.summary),
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
async def disk(self) -> DiskView:
|
|
425
|
+
"""Return the disk sample in the stable dashboard contract shape."""
|
|
426
|
+
snapshot = await self._disk.sample_if_due()
|
|
427
|
+
return DiskView(
|
|
428
|
+
state=_disk_state(snapshot),
|
|
429
|
+
free_bytes=snapshot.free_bytes,
|
|
430
|
+
total_bytes=snapshot.total_bytes,
|
|
431
|
+
shikiru_bytes=snapshot.shikiru_bytes,
|
|
432
|
+
sampled_ago=_sampled_ago(snapshot.sampled_at, self._clock),
|
|
433
|
+
error=snapshot.error,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _disk_state(snapshot: DiskSnapshot) -> DiagnosticSampleState:
|
|
438
|
+
if snapshot.error is None:
|
|
439
|
+
return DiagnosticSampleState.AVAILABLE
|
|
440
|
+
if snapshot.sampled_at is not None:
|
|
441
|
+
return DiagnosticSampleState.STALE
|
|
442
|
+
return DiagnosticSampleState.FAILED if snapshot.failed else DiagnosticSampleState.UNAVAILABLE
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _docker_summary_view(summary: DockerSummary | None) -> DockerSummaryView | None:
|
|
446
|
+
if summary is None:
|
|
447
|
+
return None
|
|
448
|
+
return DockerSummaryView(
|
|
449
|
+
running=summary.running,
|
|
450
|
+
total=summary.total,
|
|
451
|
+
cpu_pct=summary.cpu_pct,
|
|
452
|
+
memory_bytes=summary.memory_bytes,
|
|
453
|
+
problems=tuple(
|
|
454
|
+
ContainerProblemView(problem.name, problem.kind.value, problem.exit_code)
|
|
455
|
+
for problem in summary.problems
|
|
456
|
+
),
|
|
457
|
+
top=tuple(
|
|
458
|
+
ContainerView(usage.name, usage.cpu_pct, usage.memory_bytes) for usage in summary.top
|
|
459
|
+
),
|
|
460
|
+
elsewhere=ElsewhereView(
|
|
461
|
+
summary.elsewhere.running, summary.elsewhere.cpu_pct, summary.elsewhere.memory_bytes
|
|
462
|
+
),
|
|
463
|
+
usage_available=summary.usage_available,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class TaskNavigation:
|
|
468
|
+
"""Select recorded task, role-pane or home targets without creating resources."""
|
|
469
|
+
|
|
470
|
+
def __init__(
|
|
471
|
+
self,
|
|
472
|
+
tmux: Tmux,
|
|
473
|
+
queries: OperatorQueries,
|
|
474
|
+
session_target: str,
|
|
475
|
+
) -> None:
|
|
476
|
+
"""Use the existing tmux client, current task queries and home session target."""
|
|
477
|
+
self._tmux = tmux
|
|
478
|
+
self._queries = queries
|
|
479
|
+
self._session_target = session_target
|
|
480
|
+
|
|
481
|
+
async def task_window(self, task_id: str) -> str:
|
|
482
|
+
"""Select the task's current window or return a readable availability result."""
|
|
483
|
+
task = self._queries.get_task_state(task_id)
|
|
484
|
+
if task is None:
|
|
485
|
+
return f"{task_id}: {TASK_MISSING}"
|
|
486
|
+
if task.status in (Status.DONE, Status.CANCELLED):
|
|
487
|
+
return f"{task_id}: {TERMINAL_TASK_TARGET}"
|
|
488
|
+
if task.window_id is None:
|
|
489
|
+
return f"{task_id}: {TASK_WINDOW_UNRECORDED}"
|
|
490
|
+
if not await self._tmux.target_exists(task.window_id):
|
|
491
|
+
return f"{task_id}: {TASK_WINDOW_MISSING}"
|
|
492
|
+
try:
|
|
493
|
+
await self._tmux.select_window(task.window_id)
|
|
494
|
+
except ShikiruError as error:
|
|
495
|
+
return f"{task_id}: {TASK_WINDOW_MISSING}: {error}"
|
|
496
|
+
return f"{task_id}: {TASK_TARGET_OPENED}"
|
|
497
|
+
|
|
498
|
+
async def home(self) -> str:
|
|
499
|
+
"""Select the existing home window or report that it is unavailable."""
|
|
500
|
+
target = await home_window_target(self._tmux, self._session_target)
|
|
501
|
+
if target is None:
|
|
502
|
+
return HOME_WINDOW_MISSING
|
|
503
|
+
try:
|
|
504
|
+
await self._tmux.select_window(target[0])
|
|
505
|
+
except ShikiruError as error:
|
|
506
|
+
return f"{HOME_WINDOW_MISSING}: {error}"
|
|
507
|
+
return HOME_TARGET_OPENED
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _resource_view(
|
|
511
|
+
snapshot: ResourceSnapshot,
|
|
512
|
+
clock: Clock,
|
|
513
|
+
*,
|
|
514
|
+
state: DiagnosticSampleState,
|
|
515
|
+
error: str | None = None,
|
|
516
|
+
) -> ResourceView:
|
|
517
|
+
return ResourceView(
|
|
518
|
+
host_cpu_pct=snapshot.host_cpu_pct,
|
|
519
|
+
host_ram_used_pct=snapshot.host_ram_used_pct,
|
|
520
|
+
free_ram_gb=snapshot.free_ram_gb,
|
|
521
|
+
instance_cpu_pct=snapshot.instance_cpu_pct,
|
|
522
|
+
instance_rss_gb=snapshot.instance_rss_gb,
|
|
523
|
+
sampled_at=snapshot.sampled_at,
|
|
524
|
+
state=state,
|
|
525
|
+
sampled_ago=_sampled_ago(snapshot.sampled_at, clock),
|
|
526
|
+
error=error,
|
|
527
|
+
host_ram_used_bytes=snapshot.host_ram_used_bytes,
|
|
528
|
+
host_ram_total_bytes=snapshot.host_ram_total_bytes,
|
|
529
|
+
shikiru_cpu_pct=snapshot.shikiru_cpu_pct,
|
|
530
|
+
shikiru_rss_gb=snapshot.shikiru_rss_gb,
|
|
531
|
+
agents_cpu_pct=snapshot.agents_cpu_pct,
|
|
532
|
+
agents_rss_gb=snapshot.agents_rss_gb,
|
|
533
|
+
shikiru_process_tree_empty=snapshot.shikiru_process_tree_empty,
|
|
534
|
+
agents_process_tree_empty=snapshot.agents_process_tree_empty,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _sampled_ago(sampled_at: datetime | None, clock: Clock) -> str | None:
|
|
539
|
+
if sampled_at is None:
|
|
540
|
+
return None
|
|
541
|
+
age = clock.now() - sampled_at
|
|
542
|
+
return format_short_age(age) if age.total_seconds() >= 0 else None
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _pane_resource(line: str) -> _PaneResource:
|
|
546
|
+
fields = line.split(_PANE_RESOURCE_FIELD_SEPARATOR, _PANE_RESOURCE_MAX_SPLITS)
|
|
547
|
+
if len(fields) != _PANE_RESOURCE_FIELD_COUNT:
|
|
548
|
+
message = f"tmux pane resource row is invalid: {line!r}"
|
|
549
|
+
raise ValueError(message)
|
|
550
|
+
pane_id, pane_pid_text, window_id, home_marker, layout_marker, start_command = fields
|
|
551
|
+
return _PaneResource(
|
|
552
|
+
pane_id,
|
|
553
|
+
int(pane_pid_text),
|
|
554
|
+
window_id,
|
|
555
|
+
home_marker,
|
|
556
|
+
layout_marker,
|
|
557
|
+
start_command,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _process_root_map(
|
|
562
|
+
identities: Iterable[ProcessIdentity | None],
|
|
563
|
+
) -> dict[tuple[int, float | None], ProcessIdentity]:
|
|
564
|
+
return {
|
|
565
|
+
(identity.pid, identity.started_at): identity
|
|
566
|
+
for identity in identities
|
|
567
|
+
if identity is not None
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _live_agent_process_roots(
|
|
572
|
+
project_root: Path,
|
|
573
|
+
runs: list[Run],
|
|
574
|
+
) -> dict[tuple[int, float | None], ProcessIdentity]:
|
|
575
|
+
roots: dict[tuple[int, float | None], ProcessIdentity] = {}
|
|
576
|
+
for run in runs:
|
|
577
|
+
if run.status is not RunStatus.LIVE:
|
|
578
|
+
continue
|
|
579
|
+
path = turn_pid_path(run_dir(project_root, run.task_id), run.role)
|
|
580
|
+
try:
|
|
581
|
+
identity = read_turn_pid(path)
|
|
582
|
+
except FileNotFoundError:
|
|
583
|
+
continue
|
|
584
|
+
roots[(identity.pid, identity.started_at)] = identity
|
|
585
|
+
return roots
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _pane_process_roots(
|
|
589
|
+
pane_rows: list[str],
|
|
590
|
+
tasks: list[Task],
|
|
591
|
+
runs: list[Run],
|
|
592
|
+
project_root: Path,
|
|
593
|
+
) -> tuple[
|
|
594
|
+
dict[tuple[int, float | None], ProcessIdentity],
|
|
595
|
+
dict[tuple[int, float | None], ProcessIdentity],
|
|
596
|
+
]:
|
|
597
|
+
taken_over_runs = {
|
|
598
|
+
(run.task_id, run.role) for run in runs if run.status is RunStatus.TAKEN_OVER
|
|
599
|
+
}
|
|
600
|
+
pane_owners = {
|
|
601
|
+
pane_id: (task.id, role, task.window_id)
|
|
602
|
+
for task in tasks
|
|
603
|
+
if task.window_id is not None
|
|
604
|
+
for role, pane_id in task.panes.items()
|
|
605
|
+
}
|
|
606
|
+
task_windows = {task.window_id: task.id for task in tasks if task.window_id is not None}
|
|
607
|
+
ownership = _PaneOwnership(project_root, pane_owners, task_windows, taken_over_runs)
|
|
608
|
+
shikiru: dict[tuple[int, float | None], ProcessIdentity] = {}
|
|
609
|
+
agents: dict[tuple[int, float | None], ProcessIdentity] = {}
|
|
610
|
+
for line in pane_rows:
|
|
611
|
+
_add_pane_process_root(line, ownership, shikiru, agents)
|
|
612
|
+
return shikiru, agents
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _add_pane_process_root(
|
|
616
|
+
line: str,
|
|
617
|
+
ownership: _PaneOwnership,
|
|
618
|
+
shikiru: dict[tuple[int, float | None], ProcessIdentity],
|
|
619
|
+
agents: dict[tuple[int, float | None], ProcessIdentity],
|
|
620
|
+
) -> None:
|
|
621
|
+
pane = _pane_resource(line)
|
|
622
|
+
identity = identify_pid(pane.pane_pid)
|
|
623
|
+
if identity is None:
|
|
624
|
+
return
|
|
625
|
+
identity_key = (identity.pid, identity.started_at)
|
|
626
|
+
if pane.home_marker == HOME_PANE_OPTION_VALUE:
|
|
627
|
+
shikiru[identity_key] = identity
|
|
628
|
+
owner = ownership.pane_owners.get(pane.pane_id)
|
|
629
|
+
if owner is None:
|
|
630
|
+
owner_task_id = ownership.task_windows.get(pane.window_id)
|
|
631
|
+
if (
|
|
632
|
+
owner_task_id is not None
|
|
633
|
+
and pane.layout_marker == SYSTEM_LAYOUT_PANE
|
|
634
|
+
and _is_task_dashboard(pane.start_command, ownership.project_root, owner_task_id)
|
|
635
|
+
):
|
|
636
|
+
shikiru[identity_key] = identity
|
|
637
|
+
return
|
|
638
|
+
owner_task_id, owner_role, owner_window_id = owner
|
|
639
|
+
if owner_window_id == pane.window_id:
|
|
640
|
+
target = agents if (owner_task_id, owner_role) in ownership.taken_over_runs else shikiru
|
|
641
|
+
target[identity_key] = identity
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _is_task_dashboard(start_command: str, project_root: Path, task_id: str) -> bool:
|
|
645
|
+
try:
|
|
646
|
+
command = tuple(shlex.split(start_command))
|
|
647
|
+
except ValueError:
|
|
648
|
+
return False
|
|
649
|
+
expected = task_dashboard_command(project_root, task_id)
|
|
650
|
+
if command[:1] != (TMUX_ENV_COMMAND,):
|
|
651
|
+
return command == expected
|
|
652
|
+
index = 1
|
|
653
|
+
while index < len(command):
|
|
654
|
+
value = command[index]
|
|
655
|
+
if value == TMUX_ENV_UNSET_OPTION:
|
|
656
|
+
if index + 1 == len(command):
|
|
657
|
+
return False
|
|
658
|
+
index += TMUX_ENV_UNSET_OPTION_ARGUMENT_COUNT
|
|
659
|
+
continue
|
|
660
|
+
if ENV_VARIABLE_ASSIGNMENT_SEPARATOR not in value or value.startswith(
|
|
661
|
+
TMUX_ENV_UNSET_OPTION[0]
|
|
662
|
+
):
|
|
663
|
+
return command[index:] == expected
|
|
664
|
+
index += 1
|
|
665
|
+
return False
|