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/motion.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Pure dashboard timing rules for live task and quota views."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum, auto
|
|
7
|
+
from math import ceil
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from shikiru.config.settings import MAX_MOTION_REFRESH_HZ
|
|
11
|
+
from shikiru.domain.activity import is_stalled
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
from shikiru.config.settings import TimeoutsConfig
|
|
17
|
+
|
|
18
|
+
PULSE_PHASES = 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MotionStampKind(StrEnum):
|
|
22
|
+
"""A task outcome that briefly stamps its comic strip."""
|
|
23
|
+
|
|
24
|
+
VERDICT = auto()
|
|
25
|
+
MERGED = auto()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class MotionStamp:
|
|
30
|
+
"""A verdict or merge event that may briefly stamp its task strip."""
|
|
31
|
+
|
|
32
|
+
at: datetime
|
|
33
|
+
kind: MotionStampKind
|
|
34
|
+
detail: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class TaskMotionState:
|
|
39
|
+
"""Timing evidence for the task's current panel and most recent outcome."""
|
|
40
|
+
|
|
41
|
+
task_id: str
|
|
42
|
+
step_started_at: datetime
|
|
43
|
+
actual_step_started_at: datetime | None = None
|
|
44
|
+
turn_started_at: datetime | None = None
|
|
45
|
+
last_signal_at: datetime | None = None
|
|
46
|
+
last_signal: str | None = None
|
|
47
|
+
last_words: str | None = None
|
|
48
|
+
tool_running: bool = False
|
|
49
|
+
turn_active: bool = False
|
|
50
|
+
engine_driven: bool = False
|
|
51
|
+
held: bool = False
|
|
52
|
+
ended_at: datetime | None = None
|
|
53
|
+
stamp: MotionStamp | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class QuotaMotionState:
|
|
58
|
+
"""One quota reset deadline rendered as a live countdown."""
|
|
59
|
+
|
|
60
|
+
agent: str
|
|
61
|
+
window: str
|
|
62
|
+
resets_at: datetime | None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class MotionState:
|
|
67
|
+
"""The immutable inputs needed to render one dashboard frame."""
|
|
68
|
+
|
|
69
|
+
animations: bool
|
|
70
|
+
timeouts: TimeoutsConfig
|
|
71
|
+
refresh_hz: int
|
|
72
|
+
stamp_flash_seconds: float
|
|
73
|
+
escalation_pulse_hz: int
|
|
74
|
+
tasks: tuple[TaskMotionState, ...] = ()
|
|
75
|
+
quotas: tuple[QuotaMotionState, ...] = ()
|
|
76
|
+
escalations_open: bool = False
|
|
77
|
+
engine_running: bool = True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class TaskMotionFrame:
|
|
82
|
+
"""Timing output for one task strip, without presentation glyphs or colours."""
|
|
83
|
+
|
|
84
|
+
task_id: str
|
|
85
|
+
spinner_phase: int
|
|
86
|
+
spinner_visible: bool
|
|
87
|
+
signal_observed: bool
|
|
88
|
+
agent_processing: bool
|
|
89
|
+
stalled: bool
|
|
90
|
+
elapsed_seconds: int
|
|
91
|
+
actual_step_started_at: datetime | None
|
|
92
|
+
last_signal_at: datetime | None
|
|
93
|
+
last_signal: str | None
|
|
94
|
+
last_words: str | None
|
|
95
|
+
stamp_kind: MotionStampKind | None
|
|
96
|
+
stamp_detail: str
|
|
97
|
+
stamp_flash: bool
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class QuotaMotionFrame:
|
|
102
|
+
"""Remaining seconds for one provider-reported quota reset."""
|
|
103
|
+
|
|
104
|
+
agent: str
|
|
105
|
+
window: str
|
|
106
|
+
remaining_seconds: int | None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class MotionFrame:
|
|
111
|
+
"""All time-derived values for a dashboard render at `now`."""
|
|
112
|
+
|
|
113
|
+
tasks: tuple[TaskMotionFrame, ...]
|
|
114
|
+
quotas: tuple[QuotaMotionFrame, ...]
|
|
115
|
+
escalation_red: bool
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def frame(state: MotionState, now: datetime) -> MotionFrame:
|
|
119
|
+
"""Calculate stream motion, outcome flashes, escalation pulse and live counters."""
|
|
120
|
+
return MotionFrame(
|
|
121
|
+
tasks=tuple(_task_frame(task, state, now) for task in state.tasks),
|
|
122
|
+
quotas=tuple(_quota_frame(quota, now) for quota in state.quotas),
|
|
123
|
+
escalation_red=(
|
|
124
|
+
state.escalations_open
|
|
125
|
+
and (
|
|
126
|
+
not state.animations
|
|
127
|
+
or int(now.timestamp() * state.escalation_pulse_hz * PULSE_PHASES) % PULSE_PHASES
|
|
128
|
+
== 0
|
|
129
|
+
)
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_in_progress_animated(
|
|
135
|
+
*, engine_running: bool, held: bool, stalled: bool, turn_active: bool, engine_driven: bool
|
|
136
|
+
) -> bool:
|
|
137
|
+
"""Whether a task is actively progressing: a live turn or the engine's own work, unheld."""
|
|
138
|
+
return engine_running and not held and not stalled and (turn_active or engine_driven)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _task_frame(task: TaskMotionState, state: MotionState, now: datetime) -> TaskMotionFrame:
|
|
142
|
+
activity_at = task.last_signal_at or task.turn_started_at
|
|
143
|
+
stalled = (
|
|
144
|
+
task.turn_active
|
|
145
|
+
and activity_at is not None
|
|
146
|
+
and not task.held
|
|
147
|
+
and is_stalled(activity_at, task.tool_running, now, state.timeouts)
|
|
148
|
+
)
|
|
149
|
+
in_progress = is_in_progress_animated(
|
|
150
|
+
engine_running=state.engine_running,
|
|
151
|
+
held=task.held,
|
|
152
|
+
stalled=stalled,
|
|
153
|
+
turn_active=task.turn_active,
|
|
154
|
+
engine_driven=task.engine_driven,
|
|
155
|
+
)
|
|
156
|
+
signal_observed = in_progress and task.last_signal_at is not None
|
|
157
|
+
spinner_phase = 0
|
|
158
|
+
if in_progress and state.animations:
|
|
159
|
+
spinner_phase = int(now.timestamp() * min(state.refresh_hz, MAX_MOTION_REFRESH_HZ))
|
|
160
|
+
elapsed_end = task.ended_at or now
|
|
161
|
+
elapsed_seconds = max(int((elapsed_end - task.step_started_at).total_seconds()), 0)
|
|
162
|
+
stamp_kind = None
|
|
163
|
+
stamp_detail = ""
|
|
164
|
+
stamp_flash = False
|
|
165
|
+
if state.animations and task.stamp is not None:
|
|
166
|
+
age = (now - task.stamp.at).total_seconds()
|
|
167
|
+
if 0 <= age < state.stamp_flash_seconds:
|
|
168
|
+
stamp_kind = task.stamp.kind
|
|
169
|
+
stamp_detail = task.stamp.detail
|
|
170
|
+
stamp_flash = True
|
|
171
|
+
return TaskMotionFrame(
|
|
172
|
+
task_id=task.task_id,
|
|
173
|
+
spinner_phase=spinner_phase,
|
|
174
|
+
spinner_visible=in_progress and (signal_observed or task.engine_driven),
|
|
175
|
+
signal_observed=signal_observed,
|
|
176
|
+
agent_processing=in_progress,
|
|
177
|
+
stalled=stalled,
|
|
178
|
+
elapsed_seconds=elapsed_seconds,
|
|
179
|
+
actual_step_started_at=task.actual_step_started_at,
|
|
180
|
+
last_signal_at=task.last_signal_at,
|
|
181
|
+
last_signal=task.last_signal,
|
|
182
|
+
last_words=task.last_words,
|
|
183
|
+
stamp_kind=stamp_kind,
|
|
184
|
+
stamp_detail=stamp_detail,
|
|
185
|
+
stamp_flash=stamp_flash,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _quota_frame(quota: QuotaMotionState, now: datetime) -> QuotaMotionFrame:
|
|
190
|
+
remaining = (
|
|
191
|
+
max(ceil((quota.resets_at - now).total_seconds()), 0)
|
|
192
|
+
if quota.resets_at is not None
|
|
193
|
+
else None
|
|
194
|
+
)
|
|
195
|
+
return QuotaMotionFrame(
|
|
196
|
+
agent=quota.agent,
|
|
197
|
+
window=quota.window,
|
|
198
|
+
remaining_seconds=remaining,
|
|
199
|
+
)
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Structured, toned rows for the reports and recent turn signals of an escalation packet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import TYPE_CHECKING, cast
|
|
9
|
+
|
|
10
|
+
from pydantic import TypeAdapter, ValidationError
|
|
11
|
+
|
|
12
|
+
from shikiru.agents.contracts import (
|
|
13
|
+
Compacted,
|
|
14
|
+
QuotaReported,
|
|
15
|
+
SessionStarted,
|
|
16
|
+
UsageReported,
|
|
17
|
+
)
|
|
18
|
+
from shikiru.domain.reports import (
|
|
19
|
+
ConsultAnswer,
|
|
20
|
+
EscalationRequest,
|
|
21
|
+
FeedbackAnalysis,
|
|
22
|
+
FeedbackVerdict,
|
|
23
|
+
ImplementationReport,
|
|
24
|
+
NewTask,
|
|
25
|
+
PlanReport,
|
|
26
|
+
Report,
|
|
27
|
+
TriageReport,
|
|
28
|
+
Verdict,
|
|
29
|
+
)
|
|
30
|
+
from shikiru.domain.timeline_phrases import (
|
|
31
|
+
PHRASE_SEPARATOR,
|
|
32
|
+
SIGNAL_MESSAGE,
|
|
33
|
+
SIGNAL_TOOL_FINISHED,
|
|
34
|
+
SIGNAL_TOOL_STARTED,
|
|
35
|
+
SIGNAL_TURN_ENDED,
|
|
36
|
+
SIGNAL_TURN_FAILED,
|
|
37
|
+
Tone,
|
|
38
|
+
signal_phrase,
|
|
39
|
+
tool_target,
|
|
40
|
+
verdict_phrase,
|
|
41
|
+
)
|
|
42
|
+
from shikiru.view_log import VIEW_RUNG_KEY, VIEW_SIGNAL_KEY, VIEW_TIME_KEY, VIEW_TURN_KEY
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from collections.abc import Sequence
|
|
46
|
+
|
|
47
|
+
from shikiru.domain.reports import Commit, Issue, Option, TestRun
|
|
48
|
+
|
|
49
|
+
_REPORT: TypeAdapter[Report] = TypeAdapter(Report | FeedbackVerdict)
|
|
50
|
+
_LABEL_OPEN = "**"
|
|
51
|
+
_LABEL_CLOSE = "**\n\n"
|
|
52
|
+
_BLOCK_SEPARATOR = "\n\n"
|
|
53
|
+
_LABEL_SEPARATOR = "-"
|
|
54
|
+
_SIGNAL_TIME_FORMAT = "%H:%M:%S"
|
|
55
|
+
_RECOMMENDED_STAR = "\N{BLACK STAR} "
|
|
56
|
+
_LOCATION_SEPARATOR = " @ "
|
|
57
|
+
_RUNG_SEPARATOR = " \N{MIDDLE DOT} "
|
|
58
|
+
_FIRST_LINE_SEPARATOR = "\n"
|
|
59
|
+
_OLD_SCHEMA_INDENT = 2
|
|
60
|
+
_REPORT_LABEL = "Report"
|
|
61
|
+
_TEXT_LABEL = "Text"
|
|
62
|
+
_LINE_LABEL = "Line"
|
|
63
|
+
_STATUS_TONES = {"completed": Tone.SUCCESS, "needs_decision": Tone.WARNING, "blocked": Tone.FAILURE}
|
|
64
|
+
_RESULT_TONES = {"passed": Tone.SUCCESS, "failed": Tone.FAILURE}
|
|
65
|
+
_SEVERITY_TONES = {"blocker": Tone.FAILURE, "major": Tone.WARNING}
|
|
66
|
+
_SIGNAL_KINDS = {
|
|
67
|
+
SIGNAL_MESSAGE: "message",
|
|
68
|
+
SIGNAL_TOOL_STARTED: "tool",
|
|
69
|
+
SIGNAL_TOOL_FINISHED: "tool done",
|
|
70
|
+
SIGNAL_TURN_ENDED: "turn end",
|
|
71
|
+
SIGNAL_TURN_FAILED: "failed",
|
|
72
|
+
UsageReported.__name__: "usage",
|
|
73
|
+
SessionStarted.__name__: "session",
|
|
74
|
+
QuotaReported.__name__: "quota",
|
|
75
|
+
Compacted.__name__: "compacted",
|
|
76
|
+
}
|
|
77
|
+
_REPORT_KINDS = {
|
|
78
|
+
TriageReport: "triage",
|
|
79
|
+
ImplementationReport: "implement",
|
|
80
|
+
PlanReport: "plan",
|
|
81
|
+
ConsultAnswer: "consult",
|
|
82
|
+
Verdict: "verify",
|
|
83
|
+
FeedbackAnalysis: "feedback analysis",
|
|
84
|
+
FeedbackVerdict: "feedback verify",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Part:
|
|
90
|
+
"""A run of text in one tone, optionally in the accent colour."""
|
|
91
|
+
|
|
92
|
+
text: str
|
|
93
|
+
tone: Tone = Tone.PLAIN
|
|
94
|
+
accent: bool = False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class DetailRow:
|
|
99
|
+
"""One labelled line of a details block; an empty label continues the row above."""
|
|
100
|
+
|
|
101
|
+
label: str
|
|
102
|
+
parts: tuple[Part, ...]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class EvidenceEntry:
|
|
107
|
+
"""One table row (lead, kind, one-line outcome) and the structured block that explains it."""
|
|
108
|
+
|
|
109
|
+
lead: str
|
|
110
|
+
kind: str
|
|
111
|
+
outcome: Part
|
|
112
|
+
details: tuple[DetailRow, ...]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _raw(label: str, text: str) -> tuple[DetailRow, ...]:
|
|
116
|
+
return (DetailRow(label, (Part(text),)),)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _row(label: str, text: str, tone: Tone = Tone.PLAIN) -> list[DetailRow]:
|
|
120
|
+
return [DetailRow(label, (Part(text, tone),))] if text else []
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _listed(label: str, items: Sequence[tuple[Part, ...]]) -> list[DetailRow]:
|
|
124
|
+
return [DetailRow(label if index == 0 else "", parts) for index, parts in enumerate(items)]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _plain_items(label: str, items: Sequence[str]) -> list[DetailRow]:
|
|
128
|
+
return _listed(label, [(Part(item),) for item in items])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _counted(count: int, noun: str) -> str:
|
|
132
|
+
return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _commit(commit: Commit) -> tuple[Part, ...]:
|
|
136
|
+
return (Part(commit.sha, accent=True), Part(f" {commit.subject}"))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _test(test: TestRun) -> tuple[Part, ...]:
|
|
140
|
+
parts = [Part(f"{test.name} "), Part(test.result, _RESULT_TONES.get(test.result, Tone.PLAIN))]
|
|
141
|
+
if test.details:
|
|
142
|
+
parts.append(Part(f" {test.details}"))
|
|
143
|
+
return tuple(parts)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _issue(issue: Issue) -> tuple[Part, ...]:
|
|
147
|
+
parts = [
|
|
148
|
+
Part(issue.severity, _SEVERITY_TONES.get(issue.severity, Tone.PLAIN)),
|
|
149
|
+
Part(f" {issue.description}"),
|
|
150
|
+
]
|
|
151
|
+
if issue.location:
|
|
152
|
+
parts.append(Part(f"{_LOCATION_SEPARATOR}{issue.location}"))
|
|
153
|
+
return tuple(parts)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _option(option: Option) -> tuple[Part, ...]:
|
|
157
|
+
star = _RECOMMENDED_STAR if option.recommended else ""
|
|
158
|
+
return (
|
|
159
|
+
Part(f"{star}{option.label}", accent=option.recommended),
|
|
160
|
+
Part(f" {option.consequences}"),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _new_task(task: NewTask) -> tuple[Part, ...]:
|
|
165
|
+
return (Part(task.title),)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _escalation(request: EscalationRequest | None) -> list[DetailRow]:
|
|
169
|
+
if request is None:
|
|
170
|
+
return []
|
|
171
|
+
return [
|
|
172
|
+
*_row("Escalation question", request.question),
|
|
173
|
+
*_listed("Escalation options", [_option(option) for option in request.options]),
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _implementation(report: ImplementationReport) -> tuple[Part, list[DetailRow]]:
|
|
178
|
+
tone = _STATUS_TONES[report.status]
|
|
179
|
+
status = report.status.replace("_", " ")
|
|
180
|
+
failed = sum(_RESULT_TONES.get(test.result) is Tone.FAILURE for test in report.tests)
|
|
181
|
+
bits = [status]
|
|
182
|
+
if report.commits:
|
|
183
|
+
bits.append(_counted(len(report.commits), "commit"))
|
|
184
|
+
if failed:
|
|
185
|
+
bits.append(f"{failed} failed")
|
|
186
|
+
return Part(PHRASE_SEPARATOR.join(bits), tone), [
|
|
187
|
+
*_row("Status", status, tone),
|
|
188
|
+
*_row("Summary", report.summary),
|
|
189
|
+
*_listed("Commits", [_commit(commit) for commit in report.commits]),
|
|
190
|
+
*_listed("Tests", [_test(test) for test in report.tests]),
|
|
191
|
+
*_plain_items("Risks", report.risks),
|
|
192
|
+
*_row("Question", report.question),
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _feedback_analysis(report: FeedbackAnalysis) -> tuple[Part, list[DetailRow]]:
|
|
197
|
+
decisions = [
|
|
198
|
+
(
|
|
199
|
+
Part(decision.source_id, accent=True),
|
|
200
|
+
Part(f" {decision.action.value}: {decision.detail}"),
|
|
201
|
+
)
|
|
202
|
+
for decision in report.decisions
|
|
203
|
+
]
|
|
204
|
+
return Part(report.summary), [
|
|
205
|
+
*_row("Summary", report.summary),
|
|
206
|
+
*_listed("Decisions", decisions),
|
|
207
|
+
*_plain_items("Work", [work.instruction for work in report.work]),
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _verdict(report: Verdict) -> tuple[Part, list[DetailRow]]:
|
|
212
|
+
phrase = verdict_phrase(report.decision)
|
|
213
|
+
text, tone = (phrase.text, phrase.tone) if phrase is not None else (report.decision, Tone.PLAIN)
|
|
214
|
+
outcome = f"{text} ({_counted(len(report.issues), 'issue')})" if report.issues else text
|
|
215
|
+
return Part(outcome, tone), [
|
|
216
|
+
*_row("Decision", text, tone),
|
|
217
|
+
*_row("Summary", report.summary),
|
|
218
|
+
*_listed("Issues", [_issue(issue) for issue in report.issues]),
|
|
219
|
+
*_plain_items("Residual risks", report.residual_risks),
|
|
220
|
+
*_listed("New tasks", [_new_task(task) for task in report.new_tasks]),
|
|
221
|
+
*_plain_items("Scope changes", report.scope_changes),
|
|
222
|
+
*_escalation(report.escalation),
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _triage(report: TriageReport) -> tuple[Part, list[DetailRow]]:
|
|
227
|
+
return Part(report.title), [
|
|
228
|
+
*_row("Title", report.title),
|
|
229
|
+
*_row("Mode", report.mode.value),
|
|
230
|
+
*_row("Complexity", report.complexity),
|
|
231
|
+
*_plain_items("Acceptance criteria", report.acceptance_criteria),
|
|
232
|
+
*_plain_items("References", report.references),
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _plan(report: PlanReport) -> tuple[Part, list[DetailRow]]:
|
|
237
|
+
return Part(report.summary), [
|
|
238
|
+
*_row("Summary", report.summary),
|
|
239
|
+
*_listed("Commits", [_commit(commit) for commit in report.commits]),
|
|
240
|
+
*_listed("New tasks", [_new_task(task) for task in report.new_tasks]),
|
|
241
|
+
*_plain_items("Scope changes", report.scope_changes),
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _consult(report: ConsultAnswer) -> tuple[Part, list[DetailRow]]:
|
|
246
|
+
tone = Tone.WARNING if report.escalation is not None else Tone.PLAIN
|
|
247
|
+
return Part(report.answer or report.decision, tone), [
|
|
248
|
+
*_row("Decision", report.decision, tone),
|
|
249
|
+
*_row("Answer", report.answer),
|
|
250
|
+
*_escalation(report.escalation),
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _report_content(report: Report) -> tuple[Part, list[DetailRow]]:
|
|
255
|
+
match report:
|
|
256
|
+
case ImplementationReport():
|
|
257
|
+
return _implementation(report)
|
|
258
|
+
case Verdict():
|
|
259
|
+
return _verdict(report)
|
|
260
|
+
case TriageReport():
|
|
261
|
+
return _triage(report)
|
|
262
|
+
case PlanReport():
|
|
263
|
+
return _plan(report)
|
|
264
|
+
case ConsultAnswer():
|
|
265
|
+
return _consult(report)
|
|
266
|
+
case FeedbackAnalysis():
|
|
267
|
+
return _feedback_analysis(report)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _first_line(text: str) -> str:
|
|
271
|
+
return text.strip().partition(_FIRST_LINE_SEPARATOR)[0]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _readable_json(text: str) -> str:
|
|
275
|
+
try:
|
|
276
|
+
return json.dumps(json.loads(text), indent=_OLD_SCHEMA_INDENT, ensure_ascii=False)
|
|
277
|
+
except json.JSONDecodeError:
|
|
278
|
+
return text
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _label_parts(label: str) -> tuple[str, str]:
|
|
282
|
+
number, separator, rest = label.partition(_LABEL_SEPARATOR)
|
|
283
|
+
if not (separator and number.isdigit()):
|
|
284
|
+
return "", label
|
|
285
|
+
return number, rest.partition(_LABEL_SEPARATOR)[0]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _report_entry(label: str, body: str) -> EvidenceEntry:
|
|
289
|
+
lead, step = _label_parts(label)
|
|
290
|
+
try:
|
|
291
|
+
report = _REPORT.validate_json(body)
|
|
292
|
+
except ValidationError:
|
|
293
|
+
return EvidenceEntry(
|
|
294
|
+
lead, step, Part(_first_line(body)), _raw(_REPORT_LABEL, _readable_json(body))
|
|
295
|
+
)
|
|
296
|
+
outcome, details = _report_content(report)
|
|
297
|
+
return EvidenceEntry(lead, step or _REPORT_KINDS[type(report)], outcome, tuple(details))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _plain_entry(text: str) -> EvidenceEntry:
|
|
301
|
+
return EvidenceEntry("", "", Part(_first_line(text)), _raw(_TEXT_LABEL, text.strip()))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def parse_reports(content: str) -> tuple[EvidenceEntry, ...]:
|
|
305
|
+
"""Read the packet's `**label**` + JSON report blocks; unreadable ones stay as readable text."""
|
|
306
|
+
entries: list[EvidenceEntry] = []
|
|
307
|
+
for block in content.split(_BLOCK_SEPARATOR + _LABEL_OPEN):
|
|
308
|
+
label, separator, body = block.removeprefix(_LABEL_OPEN).partition(_LABEL_CLOSE)
|
|
309
|
+
entries.append(_report_entry(label, body) if separator else _plain_entry(block))
|
|
310
|
+
return tuple(entries)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _signal_time(value: object) -> str:
|
|
314
|
+
try:
|
|
315
|
+
return datetime.fromisoformat(str(value)).strftime(_SIGNAL_TIME_FORMAT)
|
|
316
|
+
except ValueError:
|
|
317
|
+
return ""
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _signal_details(signal: str, data: dict[str, object], tone: Tone) -> list[DetailRow]:
|
|
321
|
+
if signal == SIGNAL_MESSAGE:
|
|
322
|
+
return _row("Message", str(data.get("text") or ""))
|
|
323
|
+
if signal == SIGNAL_TURN_ENDED:
|
|
324
|
+
return _row("Last message", str(data.get("last_message") or ""))
|
|
325
|
+
if signal == SIGNAL_TOOL_STARTED:
|
|
326
|
+
return [
|
|
327
|
+
*_row("Tool", str(data.get("name") or "")),
|
|
328
|
+
*_row("Detail", tool_target(str(data.get("detail") or ""))),
|
|
329
|
+
]
|
|
330
|
+
if signal == SIGNAL_TOOL_FINISHED:
|
|
331
|
+
exit_code = data.get("exit_code")
|
|
332
|
+
result = PHRASE_SEPARATOR.join(
|
|
333
|
+
str(part) for part in (data.get("outcome"), exit_code) if part is not None
|
|
334
|
+
)
|
|
335
|
+
return [*_row("Tool", str(data.get("name") or "")), *_row("Result", result, tone)]
|
|
336
|
+
if signal == SIGNAL_TURN_FAILED:
|
|
337
|
+
return [
|
|
338
|
+
*_row("Failure", str(data.get("kind") or ""), tone),
|
|
339
|
+
*_row("Message", str(data.get("message") or "")),
|
|
340
|
+
]
|
|
341
|
+
return []
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _rung(value: object) -> str:
|
|
345
|
+
if not isinstance(value, dict):
|
|
346
|
+
return ""
|
|
347
|
+
rung = cast("dict[str, object]", value)
|
|
348
|
+
return _RUNG_SEPARATOR.join(
|
|
349
|
+
str(rung[key]) for key in ("agent", "model", "effort") if rung.get(key)
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _signal_entry(line: str) -> EvidenceEntry:
|
|
354
|
+
try:
|
|
355
|
+
decoded: object = json.loads(line)
|
|
356
|
+
except json.JSONDecodeError:
|
|
357
|
+
decoded = None
|
|
358
|
+
if not isinstance(decoded, dict) or VIEW_SIGNAL_KEY not in decoded:
|
|
359
|
+
return EvidenceEntry("", "", Part(line.strip()), _raw(_LINE_LABEL, line.strip()))
|
|
360
|
+
data = cast("dict[str, object]", decoded)
|
|
361
|
+
signal = str(data[VIEW_SIGNAL_KEY])
|
|
362
|
+
phrase = signal_phrase(signal, data)
|
|
363
|
+
tone = phrase.tone if phrase is not None else Tone.PLAIN
|
|
364
|
+
kind = _SIGNAL_KINDS.get(signal, signal)
|
|
365
|
+
details = [
|
|
366
|
+
*_row("Signal", kind),
|
|
367
|
+
*_row("Rung", _rung(data.get(VIEW_RUNG_KEY))),
|
|
368
|
+
*_row("Turn", str(data.get(VIEW_TURN_KEY) or "")),
|
|
369
|
+
*_signal_details(signal, data, tone),
|
|
370
|
+
]
|
|
371
|
+
outcome = Part(phrase.text if phrase is not None else "", tone)
|
|
372
|
+
return EvidenceEntry(_signal_time(data.get(VIEW_TIME_KEY)), kind, outcome, tuple(details))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def parse_signals(content: str) -> tuple[EvidenceEntry, ...]:
|
|
376
|
+
"""Read the packet's view.jsonl lines; a non-JSON or truncated line stays as a plain entry."""
|
|
377
|
+
return tuple(_signal_entry(line) for line in content.splitlines() if line.strip())
|
shikiru/domain/pushes.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Scheduling rules for pushing locally integrated base branches."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class PushTarget:
|
|
11
|
+
"""A base branch with integrations to publish and the scheduling facts of its pushes."""
|
|
12
|
+
|
|
13
|
+
base: str
|
|
14
|
+
registered: int
|
|
15
|
+
pushed: int
|
|
16
|
+
due_at: datetime | None
|
|
17
|
+
last_pushed_at: datetime | None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def pending(self) -> bool:
|
|
21
|
+
"""Return whether an integration was registered that no push has acknowledged."""
|
|
22
|
+
return self.registered > self.pushed
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def scheduled_at(self) -> datetime | None:
|
|
26
|
+
"""Return when the pending integrations are due, or `None` when nothing is pending."""
|
|
27
|
+
return self.due_at if self.pending else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def pending_deadline(*, last_pushed_at: datetime | None, now: datetime, interval: int) -> datetime:
|
|
31
|
+
"""Return when work that becomes pending at `now` is due, given `interval` seconds.
|
|
32
|
+
|
|
33
|
+
Without an earlier push the interval starts now; after one, the work is due an interval
|
|
34
|
+
after it, or at once when that moment has passed. Zero means due at once.
|
|
35
|
+
"""
|
|
36
|
+
if interval == 0:
|
|
37
|
+
return now
|
|
38
|
+
if last_pushed_at is None:
|
|
39
|
+
return now + timedelta(seconds=interval)
|
|
40
|
+
return max(last_pushed_at + timedelta(seconds=interval), now)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_due(target: PushTarget, *, now: datetime, interval: int) -> bool:
|
|
44
|
+
"""Return whether `target` has pending work whose deadline has come."""
|
|
45
|
+
if not target.pending or target.due_at is None:
|
|
46
|
+
return False
|
|
47
|
+
return interval == 0 or now >= target.due_at
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The rule deciding when fresh quota evidence releases a provider quota block."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.agents.contracts import is_exhausted
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from shikiru.agents.contracts import QuotaWindow
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def block_released(
|
|
17
|
+
*,
|
|
18
|
+
blocked_at: datetime,
|
|
19
|
+
stored: Sequence[QuotaWindow],
|
|
20
|
+
reading: Sequence[QuotaWindow],
|
|
21
|
+
now: datetime,
|
|
22
|
+
) -> bool:
|
|
23
|
+
"""Return whether `reading` proves the block set at `blocked_at` no longer holds.
|
|
24
|
+
|
|
25
|
+
Only samples observed after the block and no earlier than the stored sample of the same
|
|
26
|
+
window count. The fresh reading must have a usable window, show none exhausted and cover
|
|
27
|
+
every window `stored` holds exhausted, unless its known reset has already passed. A missing
|
|
28
|
+
reset does not show an exhausted window expired.
|
|
29
|
+
"""
|
|
30
|
+
latest = {window.name: window.observed_at for window in stored}
|
|
31
|
+
fresh = [
|
|
32
|
+
window
|
|
33
|
+
for window in reading
|
|
34
|
+
if window.observed_at > blocked_at
|
|
35
|
+
and window.observed_at >= latest.get(window.name, window.observed_at)
|
|
36
|
+
]
|
|
37
|
+
if not fresh or any(is_exhausted(window) for window in fresh):
|
|
38
|
+
return False
|
|
39
|
+
covered = {window.name for window in fresh}
|
|
40
|
+
return all(
|
|
41
|
+
window.name in covered
|
|
42
|
+
for window in stored
|
|
43
|
+
if is_exhausted(window) and (window.resets_at is None or window.resets_at > now)
|
|
44
|
+
)
|