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
shikiru/domain/docker.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Pure Docker container parsing, scoping and ranking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import PurePath
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
|
|
14
|
+
DOCKER_TOP_COUNT = 5
|
|
15
|
+
PS_ID_FIELD = "ID"
|
|
16
|
+
PS_NAMES_FIELD = "Names"
|
|
17
|
+
PS_STATE_FIELD = "State"
|
|
18
|
+
PS_STATUS_FIELD = "Status"
|
|
19
|
+
PS_LABELS_FIELD = "Labels"
|
|
20
|
+
STATS_ID_FIELD = "ID"
|
|
21
|
+
STATS_NAME_FIELD = "Name"
|
|
22
|
+
STATS_CPU_FIELD = "CPUPerc"
|
|
23
|
+
STATS_MEMORY_FIELD = "MemUsage"
|
|
24
|
+
NOT_RUNNING_READING = "--"
|
|
25
|
+
COMPOSE_WORKING_DIR_LABEL = "com.docker.compose.project.working_dir"
|
|
26
|
+
STATE_RUNNING = "running"
|
|
27
|
+
STATE_RESTARTING = "restarting"
|
|
28
|
+
STATE_EXITED = "exited"
|
|
29
|
+
UNHEALTHY_MARKER = "(unhealthy)"
|
|
30
|
+
NAME_SEPARATOR = ","
|
|
31
|
+
LABEL_SEPARATOR = ","
|
|
32
|
+
LABEL_ASSIGNMENT = "="
|
|
33
|
+
MEMORY_SEPARATOR = "/"
|
|
34
|
+
PERCENT_SUFFIX = "%"
|
|
35
|
+
BYTES_PER_KILO = 1000
|
|
36
|
+
BYTES_PER_KIBI = 1024
|
|
37
|
+
BYTES_PER_MEBIBYTE = BYTES_PER_KIBI**2
|
|
38
|
+
MEMORY_UNIT_BYTES = {
|
|
39
|
+
"B": 1,
|
|
40
|
+
"kB": BYTES_PER_KILO,
|
|
41
|
+
"KB": BYTES_PER_KILO,
|
|
42
|
+
"MB": BYTES_PER_KILO**2,
|
|
43
|
+
"GB": BYTES_PER_KILO**3,
|
|
44
|
+
"TB": BYTES_PER_KILO**4,
|
|
45
|
+
"KiB": BYTES_PER_KIBI,
|
|
46
|
+
"MiB": BYTES_PER_MEBIBYTE,
|
|
47
|
+
"GiB": BYTES_PER_KIBI**3,
|
|
48
|
+
"TiB": BYTES_PER_KIBI**4,
|
|
49
|
+
}
|
|
50
|
+
_MEMORY_READING = re.compile(r"(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>[A-Za-z]+)")
|
|
51
|
+
_EXIT_STATUS = re.compile(r"Exited \((?P<code>\d+)\)")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DockerParseError(ValueError):
|
|
55
|
+
"""A Docker record does not match the documented CLI output."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ProblemKind(StrEnum):
|
|
59
|
+
"""Why a container needs attention."""
|
|
60
|
+
|
|
61
|
+
UNHEALTHY = "unhealthy"
|
|
62
|
+
RESTARTING = "restarting"
|
|
63
|
+
EXITED = "exited"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class ContainerProblem:
|
|
68
|
+
"""One container that is unhealthy, restarting or exited non-zero."""
|
|
69
|
+
|
|
70
|
+
name: str
|
|
71
|
+
kind: ProblemKind
|
|
72
|
+
exit_code: int | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class ContainerInfo:
|
|
77
|
+
"""One `docker ps -a` record reduced to the facts the dashboard uses."""
|
|
78
|
+
|
|
79
|
+
id: str
|
|
80
|
+
name: str
|
|
81
|
+
running: bool
|
|
82
|
+
problem: ContainerProblem | None
|
|
83
|
+
working_dir: str | None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class ContainerUsage:
|
|
88
|
+
"""One running container's CPU and memory as numbers."""
|
|
89
|
+
|
|
90
|
+
id: str
|
|
91
|
+
name: str
|
|
92
|
+
cpu_pct: float
|
|
93
|
+
memory_bytes: int
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class ElsewhereTotals:
|
|
98
|
+
"""Running containers outside the scope; usage is `None` when unavailable."""
|
|
99
|
+
|
|
100
|
+
running: int
|
|
101
|
+
cpu_pct: float | None
|
|
102
|
+
memory_bytes: int | None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class DockerSummary:
|
|
107
|
+
"""Scoped counts, usage totals, problems, the CPU ranking and the rest of the host."""
|
|
108
|
+
|
|
109
|
+
running: int
|
|
110
|
+
total: int
|
|
111
|
+
cpu_pct: float | None
|
|
112
|
+
memory_bytes: int | None
|
|
113
|
+
problems: tuple[ContainerProblem, ...]
|
|
114
|
+
top: tuple[ContainerUsage, ...]
|
|
115
|
+
elsewhere: ElsewhereTotals
|
|
116
|
+
usage_available: bool
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def is_empty(self) -> bool:
|
|
120
|
+
"""Whether the host has no scoped container and no running one elsewhere."""
|
|
121
|
+
return not self.total and not self.elsewhere.running
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_cpu_percent(text: str) -> float | None:
|
|
125
|
+
"""Return the percentage in `1.23%`, or `None` for the not-running reading."""
|
|
126
|
+
stripped = text.strip()
|
|
127
|
+
if stripped == NOT_RUNNING_READING:
|
|
128
|
+
return None
|
|
129
|
+
try:
|
|
130
|
+
return float(stripped.removesuffix(PERCENT_SUFFIX))
|
|
131
|
+
except ValueError as error:
|
|
132
|
+
message = f"docker CPU reading is not a percentage: {text!r}"
|
|
133
|
+
raise DockerParseError(message) from error
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_memory_usage(text: str) -> int | None:
|
|
137
|
+
"""Return the used bytes in `12MiB / 1.9GiB`, or `None` for the not-running reading."""
|
|
138
|
+
used = text.partition(MEMORY_SEPARATOR)[0].strip()
|
|
139
|
+
if used == NOT_RUNNING_READING:
|
|
140
|
+
return None
|
|
141
|
+
match = _MEMORY_READING.fullmatch(used)
|
|
142
|
+
if match is None or match["unit"] not in MEMORY_UNIT_BYTES:
|
|
143
|
+
message = f"docker memory reading is not a size: {text!r}"
|
|
144
|
+
raise DockerParseError(message)
|
|
145
|
+
return round(float(match["value"]) * MEMORY_UNIT_BYTES[match["unit"]])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_labels(text: str) -> dict[str, str]:
|
|
149
|
+
"""Return the `k=v,k=v` label string as a mapping; fragments without `=` are skipped."""
|
|
150
|
+
labels: dict[str, str] = {}
|
|
151
|
+
for fragment in text.split(LABEL_SEPARATOR):
|
|
152
|
+
key, assignment, value = fragment.partition(LABEL_ASSIGNMENT)
|
|
153
|
+
if assignment:
|
|
154
|
+
labels[key] = value
|
|
155
|
+
return labels
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_ps_record(record: Mapping[str, object]) -> ContainerInfo:
|
|
159
|
+
"""Return a `docker ps -a --format json` record as a `ContainerInfo`."""
|
|
160
|
+
name = _text(record, PS_NAMES_FIELD).split(NAME_SEPARATOR)[0]
|
|
161
|
+
state = _text(record, PS_STATE_FIELD)
|
|
162
|
+
status = _text(record, PS_STATUS_FIELD)
|
|
163
|
+
working_dir = parse_labels(_text(record, PS_LABELS_FIELD)).get(COMPOSE_WORKING_DIR_LABEL)
|
|
164
|
+
return ContainerInfo(
|
|
165
|
+
id=_text(record, PS_ID_FIELD),
|
|
166
|
+
name=name,
|
|
167
|
+
running=state == STATE_RUNNING,
|
|
168
|
+
problem=_problem(name, state, status),
|
|
169
|
+
working_dir=working_dir,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parse_stats_record(record: Mapping[str, object]) -> ContainerUsage | None:
|
|
174
|
+
"""Return a `docker stats` record as numbers, or `None` while it has no reading yet."""
|
|
175
|
+
cpu_pct = parse_cpu_percent(_text(record, STATS_CPU_FIELD))
|
|
176
|
+
memory_bytes = parse_memory_usage(_text(record, STATS_MEMORY_FIELD))
|
|
177
|
+
if cpu_pct is None or memory_bytes is None:
|
|
178
|
+
return None
|
|
179
|
+
return ContainerUsage(
|
|
180
|
+
id=_text(record, STATS_ID_FIELD),
|
|
181
|
+
name=_text(record, STATS_NAME_FIELD),
|
|
182
|
+
cpu_pct=cpu_pct,
|
|
183
|
+
memory_bytes=memory_bytes,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def rank_by_cpu(usages: Sequence[ContainerUsage], limit: int) -> tuple[ContainerUsage, ...]:
|
|
188
|
+
"""Return the `limit` busiest containers: CPU descending, then memory descending, then name."""
|
|
189
|
+
ranked = sorted(usages, key=lambda usage: (-usage.cpu_pct, -usage.memory_bytes, usage.name))
|
|
190
|
+
return tuple(ranked[:limit])
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def in_project(info: ContainerInfo, project_roots: Sequence[PurePath]) -> bool:
|
|
194
|
+
"""Return whether the container's compose working directory is inside a project root."""
|
|
195
|
+
if info.working_dir is None:
|
|
196
|
+
return False
|
|
197
|
+
working_dir = PurePath(info.working_dir)
|
|
198
|
+
return any(working_dir.is_relative_to(root) for root in project_roots)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def summarize(
|
|
202
|
+
containers: Sequence[ContainerInfo],
|
|
203
|
+
usages: Sequence[ContainerUsage] | None,
|
|
204
|
+
project_roots: Sequence[PurePath] | None,
|
|
205
|
+
) -> DockerSummary:
|
|
206
|
+
"""Summarize the scoped containers; `None` usages mean unavailable, `None` roots mean all."""
|
|
207
|
+
scoped = [c for c in containers if project_roots is None or in_project(c, project_roots)]
|
|
208
|
+
scoped_ids = {c.id for c in scoped}
|
|
209
|
+
other_running = [c for c in containers if c.running and c.id not in scoped_ids]
|
|
210
|
+
problems = tuple(c.problem for c in scoped if c.problem is not None)
|
|
211
|
+
running = [c for c in scoped if c.running]
|
|
212
|
+
if usages is None:
|
|
213
|
+
return DockerSummary(
|
|
214
|
+
running=len(running),
|
|
215
|
+
total=len(scoped),
|
|
216
|
+
cpu_pct=None,
|
|
217
|
+
memory_bytes=None,
|
|
218
|
+
problems=problems,
|
|
219
|
+
top=(),
|
|
220
|
+
elsewhere=ElsewhereTotals(len(other_running), None, None),
|
|
221
|
+
usage_available=False,
|
|
222
|
+
)
|
|
223
|
+
usage_by_id = {usage.id: usage for usage in usages}
|
|
224
|
+
scoped_usage = [usage_by_id[c.id] for c in running if c.id in usage_by_id]
|
|
225
|
+
other_usage = [usage_by_id[c.id] for c in other_running if c.id in usage_by_id]
|
|
226
|
+
return DockerSummary(
|
|
227
|
+
running=len(running),
|
|
228
|
+
total=len(scoped),
|
|
229
|
+
cpu_pct=sum(usage.cpu_pct for usage in scoped_usage),
|
|
230
|
+
memory_bytes=sum(usage.memory_bytes for usage in scoped_usage),
|
|
231
|
+
problems=problems,
|
|
232
|
+
top=rank_by_cpu(scoped_usage, DOCKER_TOP_COUNT),
|
|
233
|
+
elsewhere=ElsewhereTotals(
|
|
234
|
+
len(other_running),
|
|
235
|
+
sum(usage.cpu_pct for usage in other_usage),
|
|
236
|
+
sum(usage.memory_bytes for usage in other_usage),
|
|
237
|
+
),
|
|
238
|
+
usage_available=True,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _text(record: Mapping[str, object], field: str) -> str:
|
|
243
|
+
value = record.get(field)
|
|
244
|
+
if not isinstance(value, str):
|
|
245
|
+
message = f"docker record field {field} is missing or not a string"
|
|
246
|
+
raise DockerParseError(message)
|
|
247
|
+
return value
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _problem(name: str, state: str, status: str) -> ContainerProblem | None:
|
|
251
|
+
if state == STATE_RESTARTING:
|
|
252
|
+
return ContainerProblem(name, ProblemKind.RESTARTING)
|
|
253
|
+
if state == STATE_RUNNING and UNHEALTHY_MARKER in status:
|
|
254
|
+
return ContainerProblem(name, ProblemKind.UNHEALTHY)
|
|
255
|
+
if state != STATE_EXITED:
|
|
256
|
+
return None
|
|
257
|
+
match = _EXIT_STATUS.match(status)
|
|
258
|
+
if match is None:
|
|
259
|
+
message = f"docker exited status has no exit code: {status!r}"
|
|
260
|
+
raise DockerParseError(message)
|
|
261
|
+
code = int(match["code"])
|
|
262
|
+
return ContainerProblem(name, ProblemKind.EXITED, code) if code != 0 else None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Pure projections of recorded engine start and stop evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.domain.events import Event, EventKind
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
ENGINE_PID_FIELD = "engine_pid"
|
|
15
|
+
ENGINE_PROCESS_STARTED_AT_FIELD = "engine_process_started_at"
|
|
16
|
+
ENGINE_LIFECYCLE_PAIR_SIZE = 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class EngineUptime:
|
|
21
|
+
running_seconds: float | None
|
|
22
|
+
previous_run_seconds: float | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def project_engine_uptime(
|
|
26
|
+
events: Sequence[Event],
|
|
27
|
+
*,
|
|
28
|
+
now: datetime,
|
|
29
|
+
running: bool,
|
|
30
|
+
identity: tuple[int, float] | None,
|
|
31
|
+
) -> EngineUptime:
|
|
32
|
+
"""Return live or previous-run duration only when lifecycle evidence is consistent."""
|
|
33
|
+
ordered_events = tuple(sorted(events, key=lambda event: event.id))
|
|
34
|
+
if running:
|
|
35
|
+
return EngineUptime(
|
|
36
|
+
running_seconds=_running_duration(ordered_events, now, identity),
|
|
37
|
+
previous_run_seconds=None,
|
|
38
|
+
)
|
|
39
|
+
return EngineUptime(
|
|
40
|
+
running_seconds=None,
|
|
41
|
+
previous_run_seconds=_previous_duration(ordered_events, now),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _running_duration(
|
|
46
|
+
events: Sequence[Event], now: datetime, identity: tuple[int, float] | None
|
|
47
|
+
) -> float | None:
|
|
48
|
+
if identity is None or not events:
|
|
49
|
+
return None
|
|
50
|
+
latest = events[-1]
|
|
51
|
+
if (
|
|
52
|
+
latest.kind is not EventKind.ENGINE_STARTED
|
|
53
|
+
or _event_identity(latest) != identity
|
|
54
|
+
or latest.ts > now
|
|
55
|
+
):
|
|
56
|
+
return None
|
|
57
|
+
seconds = (now - latest.ts).total_seconds()
|
|
58
|
+
return seconds if seconds >= 0 else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _previous_duration(events: Sequence[Event], now: datetime) -> float | None:
|
|
62
|
+
if len(events) < ENGINE_LIFECYCLE_PAIR_SIZE:
|
|
63
|
+
return None
|
|
64
|
+
started, stopped = events[len(events) - ENGINE_LIFECYCLE_PAIR_SIZE :]
|
|
65
|
+
if (
|
|
66
|
+
started.kind is not EventKind.ENGINE_STARTED
|
|
67
|
+
or stopped.kind is not EventKind.ENGINE_STOPPED
|
|
68
|
+
or _event_identity(started) is None
|
|
69
|
+
or _event_identity(started) != _event_identity(stopped)
|
|
70
|
+
or started.ts > stopped.ts
|
|
71
|
+
or stopped.ts > now
|
|
72
|
+
):
|
|
73
|
+
return None
|
|
74
|
+
return (stopped.ts - started.ts).total_seconds()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _event_identity(event: Event) -> tuple[int, float] | None:
|
|
78
|
+
pid = event.data.get(ENGINE_PID_FIELD)
|
|
79
|
+
started_at = event.data.get(ENGINE_PROCESS_STARTED_AT_FIELD)
|
|
80
|
+
if (
|
|
81
|
+
isinstance(pid, bool)
|
|
82
|
+
or not isinstance(pid, int)
|
|
83
|
+
or isinstance(started_at, bool)
|
|
84
|
+
or not isinstance(started_at, int | float)
|
|
85
|
+
):
|
|
86
|
+
return None
|
|
87
|
+
return pid, float(started_at)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Escalation options and their recommended default."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import StrEnum, auto
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from shikiru.domain.commands import ResolutionKind
|
|
11
|
+
from shikiru.domain.ladder import reset_for_integration_attempt, reset_for_rung_change
|
|
12
|
+
from shikiru.domain.steps import Transition
|
|
13
|
+
from shikiru.domain.tasks import Counters, DomainModel, EscalationReason, Role, Status, Step, Task
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from shikiru.agents.contracts import Rung
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ESCALATION_REASON_LABELS: dict[EscalationReason, str] = {
|
|
20
|
+
EscalationReason.DECISION_REQUIRED: "Decision required",
|
|
21
|
+
EscalationReason.ITERATIONS_EXHAUSTED: "Iterations exhausted",
|
|
22
|
+
EscalationReason.INTEGRATION_CONFLICT: "Integration conflict",
|
|
23
|
+
EscalationReason.INTEGRATION_BLOCKED: "Integration blocked",
|
|
24
|
+
EscalationReason.UNCOMMITTED_WORK: "Uncommitted work",
|
|
25
|
+
EscalationReason.OFF_TASK_BRANCH: "Off task branch",
|
|
26
|
+
EscalationReason.PR_CLOSED: "Pull request closed",
|
|
27
|
+
EscalationReason.AGENT_AUTH: "Agent authentication",
|
|
28
|
+
EscalationReason.AGENT_ERROR: "Agent error",
|
|
29
|
+
EscalationReason.AGENT_STUCK: "Agent stuck",
|
|
30
|
+
EscalationReason.AGENT_PROTOCOL: "Agent protocol error",
|
|
31
|
+
EscalationReason.LADDER_EXHAUSTED: "Ladder exhausted",
|
|
32
|
+
EscalationReason.QUOTA_EXHAUSTED: "Quota exhausted",
|
|
33
|
+
EscalationReason.DEPENDENCY_CANCELLED: "Dependency cancelled",
|
|
34
|
+
EscalationReason.ENGINE_ERROR: "Engine error",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
ESCALATION_CAUSES: dict[EscalationReason, str] = {
|
|
39
|
+
EscalationReason.DECISION_REQUIRED: "The Leader needs an Operator decision to continue.",
|
|
40
|
+
EscalationReason.ITERATIONS_EXHAUSTED: "The final review round still requested changes.",
|
|
41
|
+
EscalationReason.INTEGRATION_CONFLICT: "Rebase conflicts could not be resolved automatically.",
|
|
42
|
+
EscalationReason.INTEGRATION_BLOCKED: "Integration is blocked: see the question.",
|
|
43
|
+
EscalationReason.UNCOMMITTED_WORK: (
|
|
44
|
+
"The task workspace has uncommitted changes; commit or discard them, then retry."
|
|
45
|
+
),
|
|
46
|
+
EscalationReason.OFF_TASK_BRANCH: (
|
|
47
|
+
"The task workspace is not on its task branch; move its work onto the branch, then retry."
|
|
48
|
+
),
|
|
49
|
+
EscalationReason.PR_CLOSED: "The pull request was closed without merging.",
|
|
50
|
+
EscalationReason.AGENT_AUTH: (
|
|
51
|
+
"The agent cannot access its provider; check credentials, billing, or account status."
|
|
52
|
+
),
|
|
53
|
+
EscalationReason.AGENT_ERROR: "The agent kept failing after every automatic retry.",
|
|
54
|
+
EscalationReason.AGENT_STUCK: "The agent's screen stayed stuck after every automatic recovery.",
|
|
55
|
+
EscalationReason.AGENT_PROTOCOL: "The turn never produced a report, even with a Helper rebuild",
|
|
56
|
+
EscalationReason.LADDER_EXHAUSTED: "Every rung on the ladder was tried without success.",
|
|
57
|
+
EscalationReason.QUOTA_EXHAUSTED: "The agent's quota stayed exhausted past the wait budget.",
|
|
58
|
+
EscalationReason.DEPENDENCY_CANCELLED: "A dependency of this task was cancelled.",
|
|
59
|
+
EscalationReason.ENGINE_ERROR: "The engine hit an unexpected error while running this task.",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
RESOLUTION_CONSEQUENCES: dict[ResolutionKind, str] = {
|
|
63
|
+
ResolutionKind.ANSWER: "Give the Leader guidance; the task continues from a consult turn.",
|
|
64
|
+
ResolutionKind.RETRY: "Re-run the step that raised this escalation.",
|
|
65
|
+
ResolutionKind.ACCEPT: "Integrate the work as it stands.",
|
|
66
|
+
ResolutionKind.CANCEL: (
|
|
67
|
+
"Cancel the task and keep its branch; preserve its worktree when uncommitted work remains "
|
|
68
|
+
"or it is off its task branch."
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def escalation_reason_label(reason: EscalationReason) -> str:
|
|
74
|
+
"""Return the Operator-facing name of `reason`."""
|
|
75
|
+
return ESCALATION_REASON_LABELS[reason]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class EscalationOption:
|
|
80
|
+
"""One option `resolve` accepts; exactly one option per reason is `recommended`."""
|
|
81
|
+
|
|
82
|
+
label: ResolutionKind
|
|
83
|
+
recommended: bool = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_ANSWER_OR_CANCEL = (
|
|
87
|
+
EscalationOption(ResolutionKind.ANSWER, recommended=True),
|
|
88
|
+
EscalationOption(ResolutionKind.CANCEL),
|
|
89
|
+
)
|
|
90
|
+
_RETRY_OR_CANCEL = (
|
|
91
|
+
EscalationOption(ResolutionKind.RETRY, recommended=True),
|
|
92
|
+
EscalationOption(ResolutionKind.CANCEL),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
_OPTIONS_BY_REASON: dict[EscalationReason, tuple[EscalationOption, ...]] = {
|
|
96
|
+
EscalationReason.DECISION_REQUIRED: _ANSWER_OR_CANCEL,
|
|
97
|
+
EscalationReason.ITERATIONS_EXHAUSTED: (
|
|
98
|
+
EscalationOption(ResolutionKind.ANSWER, recommended=True),
|
|
99
|
+
EscalationOption(ResolutionKind.ACCEPT),
|
|
100
|
+
EscalationOption(ResolutionKind.CANCEL),
|
|
101
|
+
),
|
|
102
|
+
EscalationReason.INTEGRATION_CONFLICT: _RETRY_OR_CANCEL,
|
|
103
|
+
EscalationReason.INTEGRATION_BLOCKED: _RETRY_OR_CANCEL,
|
|
104
|
+
EscalationReason.UNCOMMITTED_WORK: _RETRY_OR_CANCEL,
|
|
105
|
+
EscalationReason.OFF_TASK_BRANCH: _RETRY_OR_CANCEL,
|
|
106
|
+
EscalationReason.PR_CLOSED: _RETRY_OR_CANCEL,
|
|
107
|
+
EscalationReason.AGENT_AUTH: _RETRY_OR_CANCEL,
|
|
108
|
+
EscalationReason.AGENT_ERROR: _RETRY_OR_CANCEL,
|
|
109
|
+
EscalationReason.AGENT_STUCK: _RETRY_OR_CANCEL,
|
|
110
|
+
EscalationReason.AGENT_PROTOCOL: _RETRY_OR_CANCEL,
|
|
111
|
+
EscalationReason.LADDER_EXHAUSTED: _RETRY_OR_CANCEL,
|
|
112
|
+
EscalationReason.QUOTA_EXHAUSTED: _RETRY_OR_CANCEL,
|
|
113
|
+
EscalationReason.DEPENDENCY_CANCELLED: _RETRY_OR_CANCEL,
|
|
114
|
+
EscalationReason.ENGINE_ERROR: _RETRY_OR_CANCEL,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def options_for(reason: EscalationReason) -> tuple[EscalationOption, ...]:
|
|
119
|
+
"""Return the resolution options for `reason`, with exactly one recommended default."""
|
|
120
|
+
return _OPTIONS_BY_REASON[reason]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def reset_for_resolution(counters: Counters, reason: EscalationReason) -> Counters:
|
|
124
|
+
"""Reset the persisted counter represented by `reason` when it is resolved."""
|
|
125
|
+
if reason in (EscalationReason.DECISION_REQUIRED, EscalationReason.ITERATIONS_EXHAUSTED):
|
|
126
|
+
return counters.model_copy(update={"consults": 0})
|
|
127
|
+
if reason in (
|
|
128
|
+
EscalationReason.INTEGRATION_CONFLICT,
|
|
129
|
+
EscalationReason.INTEGRATION_BLOCKED,
|
|
130
|
+
EscalationReason.PR_CLOSED,
|
|
131
|
+
):
|
|
132
|
+
return counters.model_copy(update={"conflict_rounds": 0})
|
|
133
|
+
return counters
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def transition_for_resolution(
|
|
137
|
+
task: Task,
|
|
138
|
+
reason: EscalationReason,
|
|
139
|
+
*,
|
|
140
|
+
step: Step | None,
|
|
141
|
+
resolution: ResolutionKind,
|
|
142
|
+
rung: Rung | None = None,
|
|
143
|
+
role: Role | None = None,
|
|
144
|
+
) -> Transition:
|
|
145
|
+
"""Build the state transition for one validated escalation resolution."""
|
|
146
|
+
counters = reset_for_resolution(task.counters, reason)
|
|
147
|
+
set_overrides: dict[Role, Rung] = {}
|
|
148
|
+
target_step = step or task.step
|
|
149
|
+
status = Status.QUEUED
|
|
150
|
+
if resolution is ResolutionKind.ANSWER:
|
|
151
|
+
target_step = Step.CONSULT
|
|
152
|
+
elif resolution is ResolutionKind.ACCEPT:
|
|
153
|
+
target_step = Step.INTEGRATE
|
|
154
|
+
counters = reset_for_integration_attempt(counters)
|
|
155
|
+
elif resolution is ResolutionKind.CANCEL:
|
|
156
|
+
status = Status.CANCELLED
|
|
157
|
+
if rung is not None:
|
|
158
|
+
if role is None:
|
|
159
|
+
message = "a rung resolution requires its acting role"
|
|
160
|
+
raise ValueError(message)
|
|
161
|
+
set_overrides[role] = rung
|
|
162
|
+
counters = reset_for_rung_change(counters)
|
|
163
|
+
return Transition(
|
|
164
|
+
base=task,
|
|
165
|
+
step=target_step,
|
|
166
|
+
status=status,
|
|
167
|
+
iteration=task.iteration,
|
|
168
|
+
counters=counters,
|
|
169
|
+
rung_index=dict(task.rung_index),
|
|
170
|
+
set_overrides=set_overrides,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class EscalationStatus(StrEnum):
|
|
175
|
+
"""An escalation's lifecycle."""
|
|
176
|
+
|
|
177
|
+
OPEN = auto()
|
|
178
|
+
RESOLVED = auto()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Escalation(DomainModel):
|
|
182
|
+
"""One raised escalation (mirrors the `escalations` table)."""
|
|
183
|
+
|
|
184
|
+
id: str
|
|
185
|
+
task_id: str
|
|
186
|
+
step: Step | None = None
|
|
187
|
+
reason: EscalationReason
|
|
188
|
+
headline: str
|
|
189
|
+
packet_path: str
|
|
190
|
+
options: tuple[EscalationOption, ...]
|
|
191
|
+
status: EscalationStatus
|
|
192
|
+
resolution: str | None = None
|
|
193
|
+
created_at: datetime
|
|
194
|
+
resolved_at: datetime | None = None
|