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,29 @@
|
|
|
1
|
+
"""The durable notification cursor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
import sqlite3 # noqa: TID251
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NotificationCursor:
|
|
12
|
+
"""Stores the id up to which every urgent event has been delivered."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
15
|
+
"""Use `connection`, the engine's writer."""
|
|
16
|
+
self._connection = connection
|
|
17
|
+
|
|
18
|
+
def read(self) -> int | None:
|
|
19
|
+
"""Return the stored event id, or `None` before the first engine start."""
|
|
20
|
+
row = self._connection.execute("SELECT event_id FROM notification_cursor").fetchone()
|
|
21
|
+
return None if row is None else int(row[0])
|
|
22
|
+
|
|
23
|
+
def write(self, event_id: int) -> None:
|
|
24
|
+
"""Store `event_id` as the delivered-up-to id."""
|
|
25
|
+
self._connection.execute(
|
|
26
|
+
"INSERT INTO notification_cursor (id, event_id) VALUES (1, ?) "
|
|
27
|
+
"ON CONFLICT (id) DO UPDATE SET event_id = excluded.event_id",
|
|
28
|
+
(event_id,),
|
|
29
|
+
)
|
shikiru/store/pushes.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""The durable record of locally integrated base branches awaiting a push."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.domain.pushes import PushTarget, pending_deadline
|
|
8
|
+
from shikiru.store.database import (
|
|
9
|
+
format_timestamp,
|
|
10
|
+
parse_timestamp,
|
|
11
|
+
transaction,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import sqlite3 # noqa: TID251
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
_COLUMNS = "base, registered, pushed, due_at, last_pushed_at"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PushTargets:
|
|
22
|
+
"""Stores, per base branch, the integrations registered and the ones a push acknowledged."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
25
|
+
"""Use `connection`, the engine's writer."""
|
|
26
|
+
self._connection = connection
|
|
27
|
+
|
|
28
|
+
def register(self, base: str, *, now: datetime, interval: int) -> None:
|
|
29
|
+
"""Record one more integration of `base` awaiting a push.
|
|
30
|
+
|
|
31
|
+
The first pending integration sets the deadline; later ones never move it.
|
|
32
|
+
"""
|
|
33
|
+
with transaction(self._connection):
|
|
34
|
+
current = self.get(base)
|
|
35
|
+
if current is None:
|
|
36
|
+
due_at = pending_deadline(last_pushed_at=None, now=now, interval=interval)
|
|
37
|
+
self._connection.execute(
|
|
38
|
+
f"INSERT INTO push_targets ({_COLUMNS}) VALUES (?, 1, 0, ?, NULL)",
|
|
39
|
+
(base, format_timestamp(due_at)),
|
|
40
|
+
)
|
|
41
|
+
return
|
|
42
|
+
due_at = (
|
|
43
|
+
current.due_at
|
|
44
|
+
if current.pending and current.due_at is not None
|
|
45
|
+
else pending_deadline(
|
|
46
|
+
last_pushed_at=current.last_pushed_at, now=now, interval=interval
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
self._connection.execute(
|
|
50
|
+
"UPDATE push_targets SET registered = registered + 1, due_at = ? WHERE base = ?",
|
|
51
|
+
(format_timestamp(due_at), base),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def get(self, base: str) -> PushTarget | None:
|
|
55
|
+
"""Return `base`'s record, or `None` when no integration of it was ever registered."""
|
|
56
|
+
return fetch_push_target(self._connection, base)
|
|
57
|
+
|
|
58
|
+
def pending(self) -> list[PushTarget]:
|
|
59
|
+
"""Return every base with integrations no push has acknowledged, oldest deadline first."""
|
|
60
|
+
rows = self._connection.execute(
|
|
61
|
+
f"SELECT {_COLUMNS} FROM push_targets WHERE registered > pushed ORDER BY due_at, base"
|
|
62
|
+
).fetchall()
|
|
63
|
+
return [_target(row) for row in rows]
|
|
64
|
+
|
|
65
|
+
def acknowledge(self, base: str, *, registered: int, now: datetime, interval: int) -> None:
|
|
66
|
+
"""Record a push at `now` that sent the integrations registered up to `registered`.
|
|
67
|
+
|
|
68
|
+
Integrations registered meanwhile stay pending, due an interval after this push.
|
|
69
|
+
"""
|
|
70
|
+
with transaction(self._connection):
|
|
71
|
+
current = self.get(base)
|
|
72
|
+
if current is None:
|
|
73
|
+
return
|
|
74
|
+
due_at = (
|
|
75
|
+
format_timestamp(pending_deadline(last_pushed_at=now, now=now, interval=interval))
|
|
76
|
+
if current.registered > registered
|
|
77
|
+
else None
|
|
78
|
+
)
|
|
79
|
+
self._connection.execute(
|
|
80
|
+
"UPDATE push_targets SET pushed = ?, due_at = ?, last_pushed_at = ? WHERE base = ?",
|
|
81
|
+
(max(current.pushed, registered), due_at, format_timestamp(now), base),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def reconcile(self, base: str, *, registered: int, now: datetime, interval: int) -> None:
|
|
85
|
+
"""Record externally observed publication and reset the next-work scheduling baseline."""
|
|
86
|
+
with transaction(self._connection):
|
|
87
|
+
current = self.get(base)
|
|
88
|
+
if current is None or current.pushed >= registered:
|
|
89
|
+
return
|
|
90
|
+
due_at = (
|
|
91
|
+
format_timestamp(pending_deadline(last_pushed_at=None, now=now, interval=interval))
|
|
92
|
+
if current.registered > registered
|
|
93
|
+
else None
|
|
94
|
+
)
|
|
95
|
+
self._connection.execute(
|
|
96
|
+
"UPDATE push_targets SET pushed = ?, due_at = ?, "
|
|
97
|
+
"last_pushed_at = NULL WHERE base = ?",
|
|
98
|
+
(registered, due_at, base),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def fetch_push_target(connection: sqlite3.Connection, base: str) -> PushTarget | None:
|
|
103
|
+
"""Return `base`'s record, or `None` when no integration of it was ever registered."""
|
|
104
|
+
row = connection.execute(
|
|
105
|
+
f"SELECT {_COLUMNS} FROM push_targets WHERE base = ?", (base,)
|
|
106
|
+
).fetchone()
|
|
107
|
+
return None if row is None else _target(row)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _target(row: sqlite3.Row) -> PushTarget:
|
|
111
|
+
return PushTarget(
|
|
112
|
+
base=row["base"],
|
|
113
|
+
registered=row["registered"],
|
|
114
|
+
pushed=row["pushed"],
|
|
115
|
+
due_at=parse_timestamp(row["due_at"]),
|
|
116
|
+
last_pushed_at=parse_timestamp(row["last_pushed_at"]),
|
|
117
|
+
)
|
shikiru/store/quotas.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Quota snapshots and agent blocks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.agents.contracts import QuotaWindow
|
|
9
|
+
from shikiru.store.database import (
|
|
10
|
+
format_timestamp,
|
|
11
|
+
parse_required_timestamp,
|
|
12
|
+
parse_timestamp,
|
|
13
|
+
transaction,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import sqlite3 # noqa: TID251
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_SELECT_BLOCK = (
|
|
22
|
+
"SELECT agent_blocks.agent AS agent, agent_blocks.until AS until, "
|
|
23
|
+
"agent_blocks.reason AS reason, agent_block_times.blocked_at AS blocked_at "
|
|
24
|
+
"FROM agent_blocks JOIN agent_block_times ON agent_block_times.agent = agent_blocks.agent"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class AgentBlock:
|
|
30
|
+
"""An agent blocked from dispatch until `until`."""
|
|
31
|
+
|
|
32
|
+
agent: str
|
|
33
|
+
until: datetime
|
|
34
|
+
reason: str
|
|
35
|
+
blocked_at: datetime
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _row_to_quota_window(row: sqlite3.Row) -> QuotaWindow:
|
|
39
|
+
return QuotaWindow(
|
|
40
|
+
name=row["window"],
|
|
41
|
+
used_pct=row["used_pct"],
|
|
42
|
+
resets_at=parse_timestamp(row["resets_at"]),
|
|
43
|
+
observed_at=parse_required_timestamp(row["observed_at"]),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _row_to_agent_block(row: sqlite3.Row) -> AgentBlock:
|
|
48
|
+
return AgentBlock(
|
|
49
|
+
agent=row["agent"],
|
|
50
|
+
until=parse_required_timestamp(row["until"]),
|
|
51
|
+
reason=row["reason"],
|
|
52
|
+
blocked_at=parse_required_timestamp(row["blocked_at"]),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fetch_quotas(connection: sqlite3.Connection, agent: str) -> list[QuotaWindow]:
|
|
57
|
+
"""Read every quota window recorded for `agent`."""
|
|
58
|
+
rows = connection.execute(
|
|
59
|
+
"SELECT agent, window, used_pct, resets_at, observed_at FROM quotas WHERE agent = ?",
|
|
60
|
+
(agent,),
|
|
61
|
+
).fetchall()
|
|
62
|
+
return [_row_to_quota_window(row) for row in rows]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fetch_quota_agents(connection: sqlite3.Connection) -> list[str]:
|
|
66
|
+
"""Read every agent with at least one recorded quota window."""
|
|
67
|
+
rows = connection.execute("SELECT DISTINCT agent FROM quotas ORDER BY agent").fetchall()
|
|
68
|
+
return [row[0] for row in rows]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fetch_active_agent_blocks(connection: sqlite3.Connection, now: datetime) -> list[AgentBlock]:
|
|
72
|
+
"""Read every agent block still in effect at `now`."""
|
|
73
|
+
rows = connection.execute(
|
|
74
|
+
_SELECT_BLOCK + " WHERE agent_blocks.until > ?",
|
|
75
|
+
(format_timestamp(now),),
|
|
76
|
+
).fetchall()
|
|
77
|
+
return [_row_to_agent_block(row) for row in rows]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def fetch_agent_block(connection: sqlite3.Connection, agent: str) -> AgentBlock | None:
|
|
81
|
+
"""Read an agent's block, including one whose deadline has passed."""
|
|
82
|
+
row = connection.execute(_SELECT_BLOCK + " WHERE agent_blocks.agent = ?", (agent,)).fetchone()
|
|
83
|
+
return _row_to_agent_block(row) if row is not None else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class QuotaStore:
|
|
87
|
+
"""Persists quota snapshots and agent blocks."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
90
|
+
"""Use `connection` for every write below."""
|
|
91
|
+
self._connection = connection
|
|
92
|
+
|
|
93
|
+
def record(self, agent: str, window: QuotaWindow) -> None:
|
|
94
|
+
"""Upsert `agent`'s quota window unless the stored sample was observed later."""
|
|
95
|
+
self._connection.execute(
|
|
96
|
+
"""
|
|
97
|
+
INSERT INTO quotas (agent, window, used_pct, resets_at, observed_at)
|
|
98
|
+
VALUES (?, ?, ?, ?, ?)
|
|
99
|
+
ON CONFLICT (agent, window) DO UPDATE SET
|
|
100
|
+
used_pct = excluded.used_pct,
|
|
101
|
+
resets_at = excluded.resets_at,
|
|
102
|
+
observed_at = excluded.observed_at
|
|
103
|
+
WHERE excluded.observed_at >= quotas.observed_at
|
|
104
|
+
""",
|
|
105
|
+
(
|
|
106
|
+
agent,
|
|
107
|
+
window.name,
|
|
108
|
+
window.used_pct,
|
|
109
|
+
format_timestamp(window.resets_at) if window.resets_at is not None else None,
|
|
110
|
+
format_timestamp(window.observed_at),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def block(self, agent: str, until: datetime, reason: str, blocked_at: datetime) -> None:
|
|
115
|
+
"""Block `agent` until the later of the current and requested deadlines.
|
|
116
|
+
|
|
117
|
+
`blocked_at` is when quota was last found exhausted; it only moves forward. The deadline
|
|
118
|
+
and the time land in one transaction, so a block is never half persisted.
|
|
119
|
+
"""
|
|
120
|
+
with transaction(self._connection):
|
|
121
|
+
self._connection.execute(
|
|
122
|
+
"INSERT INTO agent_blocks (agent, until, reason) VALUES (?, ?, ?) "
|
|
123
|
+
"ON CONFLICT (agent) DO UPDATE SET "
|
|
124
|
+
"until = CASE WHEN agent_blocks.until >= excluded.until "
|
|
125
|
+
"THEN agent_blocks.until ELSE excluded.until END, "
|
|
126
|
+
"reason = CASE WHEN agent_blocks.until >= excluded.until "
|
|
127
|
+
"THEN agent_blocks.reason ELSE excluded.reason END",
|
|
128
|
+
(agent, format_timestamp(until), reason),
|
|
129
|
+
)
|
|
130
|
+
self._connection.execute(
|
|
131
|
+
"INSERT INTO agent_block_times (agent, blocked_at) VALUES (?, ?) "
|
|
132
|
+
"ON CONFLICT (agent) DO UPDATE SET blocked_at = excluded.blocked_at "
|
|
133
|
+
"WHERE excluded.blocked_at > agent_block_times.blocked_at",
|
|
134
|
+
(agent, format_timestamp(blocked_at)),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def clear_block(self, agent: str) -> None:
|
|
138
|
+
"""Remove `agent`'s block, if any."""
|
|
139
|
+
with transaction(self._connection):
|
|
140
|
+
self._connection.execute("DELETE FROM agent_blocks WHERE agent = ?", (agent,))
|
|
141
|
+
self._connection.execute("DELETE FROM agent_block_times WHERE agent = ?", (agent,))
|
|
142
|
+
|
|
143
|
+
def active_blocks(self, now: datetime) -> list[AgentBlock]:
|
|
144
|
+
"""Read every agent block still in effect at `now`."""
|
|
145
|
+
return fetch_active_agent_blocks(self._connection, now)
|
|
146
|
+
|
|
147
|
+
def block_for(self, agent: str) -> AgentBlock | None:
|
|
148
|
+
"""Read `agent`'s current block, whether or not it is still active."""
|
|
149
|
+
return fetch_agent_block(self._connection, agent)
|
|
150
|
+
|
|
151
|
+
def windows_for(self, agent: str) -> list[QuotaWindow]:
|
|
152
|
+
"""Read every quota window recorded for `agent`."""
|
|
153
|
+
return fetch_quotas(self._connection, agent)
|
shikiru/store/reader.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Read-only facade over the state DB for operator queries and the dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3 # noqa: TID251
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from shikiru.errors import DatabaseError
|
|
10
|
+
from shikiru.store.commands import (
|
|
11
|
+
CommandHistoryPage,
|
|
12
|
+
StoredCommand,
|
|
13
|
+
fetch_command,
|
|
14
|
+
fetch_command_history,
|
|
15
|
+
fetch_pending_command,
|
|
16
|
+
)
|
|
17
|
+
from shikiru.store.escalations import fetch_escalation, fetch_open_escalations
|
|
18
|
+
from shikiru.store.events import (
|
|
19
|
+
MAX_TIMELINE_EVENT_LIMIT,
|
|
20
|
+
fetch_events_since,
|
|
21
|
+
fetch_latest_events,
|
|
22
|
+
fetch_timeline_events,
|
|
23
|
+
)
|
|
24
|
+
from shikiru.store.pushes import fetch_push_target
|
|
25
|
+
from shikiru.store.quotas import (
|
|
26
|
+
AgentBlock,
|
|
27
|
+
fetch_active_agent_blocks,
|
|
28
|
+
fetch_agent_block,
|
|
29
|
+
fetch_quota_agents,
|
|
30
|
+
fetch_quotas,
|
|
31
|
+
)
|
|
32
|
+
from shikiru.store.runs import fetch_all_runs, fetch_runs_for_task, fetch_taken_over_roles_for_task
|
|
33
|
+
from shikiru.store.tasks import (
|
|
34
|
+
fetch_task,
|
|
35
|
+
fetch_task_browser_page,
|
|
36
|
+
fetch_task_browser_pages,
|
|
37
|
+
fetch_task_relationships,
|
|
38
|
+
fetch_tasks,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from collections.abc import Sequence
|
|
43
|
+
from datetime import datetime
|
|
44
|
+
|
|
45
|
+
from shikiru.agents.contracts import QuotaWindow
|
|
46
|
+
from shikiru.clock import Clock
|
|
47
|
+
from shikiru.domain.commands import Command, CommandStatus
|
|
48
|
+
from shikiru.domain.escalation import Escalation
|
|
49
|
+
from shikiru.domain.events import Event, EventKind
|
|
50
|
+
from shikiru.domain.pushes import PushTarget
|
|
51
|
+
from shikiru.domain.runs import Run
|
|
52
|
+
from shikiru.domain.tasks import Role, Status, Task
|
|
53
|
+
from shikiru.store.database import Database
|
|
54
|
+
from shikiru.store.tasks import TaskBrowserPage, TaskRelationships
|
|
55
|
+
|
|
56
|
+
DASHBOARD_CHANGE_POLL_SECONDS = 0.5
|
|
57
|
+
CASEFOLD_SQL_FUNCTION = "casefold"
|
|
58
|
+
MAX_COMMAND_HISTORY_LIMIT = 50
|
|
59
|
+
|
|
60
|
+
OnChange = Callable[[], Awaitable[None]]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class StateReader:
|
|
64
|
+
"""Read-only views over the state DB, shared by `OperatorQueries` and the dashboard."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, database: Database, clock: Clock) -> None:
|
|
67
|
+
"""Open `database`'s read-only connection, polled through `clock` by `watch`."""
|
|
68
|
+
self._connection = database.connect_readonly()
|
|
69
|
+
self._connection.create_function(
|
|
70
|
+
CASEFOLD_SQL_FUNCTION,
|
|
71
|
+
1,
|
|
72
|
+
str.casefold,
|
|
73
|
+
deterministic=True,
|
|
74
|
+
)
|
|
75
|
+
self._database_path = database.path
|
|
76
|
+
self._clock = clock
|
|
77
|
+
|
|
78
|
+
def get_task(self, task_id: str) -> Task | None:
|
|
79
|
+
"""See `shikiru.store.tasks.fetch_task`."""
|
|
80
|
+
return fetch_task(self._connection, task_id)
|
|
81
|
+
|
|
82
|
+
def list_tasks(self, *, statuses: Sequence[Status] | None = None) -> list[Task]:
|
|
83
|
+
"""See `shikiru.store.tasks.fetch_tasks`."""
|
|
84
|
+
try:
|
|
85
|
+
return fetch_tasks(self._connection, statuses=statuses)
|
|
86
|
+
except (sqlite3.Error, TypeError) as error:
|
|
87
|
+
message = f"could not query read-only database {self._database_path}: {error}"
|
|
88
|
+
raise DatabaseError(message) from error
|
|
89
|
+
|
|
90
|
+
def task_browser_page(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
category: str,
|
|
94
|
+
search: str,
|
|
95
|
+
limit: int,
|
|
96
|
+
offset: int,
|
|
97
|
+
diagnosed_stall_task_ids: Sequence[str] = (),
|
|
98
|
+
quota_wait_task_ids: Sequence[str] = (),
|
|
99
|
+
) -> TaskBrowserPage:
|
|
100
|
+
"""Read one bounded task browser page and its matching and project counts."""
|
|
101
|
+
try:
|
|
102
|
+
return fetch_task_browser_page(
|
|
103
|
+
self._connection,
|
|
104
|
+
category=category,
|
|
105
|
+
search=search,
|
|
106
|
+
limit=limit,
|
|
107
|
+
offset=offset,
|
|
108
|
+
diagnosed_stall_task_ids=diagnosed_stall_task_ids,
|
|
109
|
+
quota_wait_task_ids=quota_wait_task_ids,
|
|
110
|
+
)
|
|
111
|
+
except sqlite3.Error as error:
|
|
112
|
+
message = f"could not query read-only database {self._database_path}: {error}"
|
|
113
|
+
raise DatabaseError(message) from error
|
|
114
|
+
|
|
115
|
+
def task_browser_pages(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
category: str,
|
|
119
|
+
search: str,
|
|
120
|
+
limit: int,
|
|
121
|
+
page_count: int,
|
|
122
|
+
diagnosed_stall_task_ids: Sequence[str] = (),
|
|
123
|
+
quota_wait_task_ids: Sequence[str] = (),
|
|
124
|
+
) -> tuple[TaskBrowserPage, ...]:
|
|
125
|
+
"""Read a contiguous range of bounded task pages from one database snapshot."""
|
|
126
|
+
try:
|
|
127
|
+
return fetch_task_browser_pages(
|
|
128
|
+
self._connection,
|
|
129
|
+
category=category,
|
|
130
|
+
search=search,
|
|
131
|
+
limit=limit,
|
|
132
|
+
page_count=page_count,
|
|
133
|
+
diagnosed_stall_task_ids=diagnosed_stall_task_ids,
|
|
134
|
+
quota_wait_task_ids=quota_wait_task_ids,
|
|
135
|
+
)
|
|
136
|
+
except sqlite3.Error as error:
|
|
137
|
+
message = f"could not query read-only database {self._database_path}: {error}"
|
|
138
|
+
raise DatabaseError(message) from error
|
|
139
|
+
|
|
140
|
+
def task_relationships(self, task_id: str, *, offset: int = 0) -> TaskRelationships | None:
|
|
141
|
+
"""Read a task's bounded family group and its distinct stored graph relationships."""
|
|
142
|
+
try:
|
|
143
|
+
return fetch_task_relationships(self._connection, task_id, offset=offset)
|
|
144
|
+
except sqlite3.Error as error:
|
|
145
|
+
message = f"could not query read-only database {self._database_path}: {error}"
|
|
146
|
+
raise DatabaseError(message) from error
|
|
147
|
+
|
|
148
|
+
def agent_block(self, agent: str) -> AgentBlock | None:
|
|
149
|
+
"""Read the engine's recorded quota block for one required agent."""
|
|
150
|
+
try:
|
|
151
|
+
return fetch_agent_block(self._connection, agent)
|
|
152
|
+
except sqlite3.Error as error:
|
|
153
|
+
message = f"could not query read-only database {self._database_path}: {error}"
|
|
154
|
+
raise DatabaseError(message) from error
|
|
155
|
+
|
|
156
|
+
def active_agent_blocks(self, now: datetime) -> list[AgentBlock]:
|
|
157
|
+
"""Read every quota block the engine recorded that is still in effect at `now`."""
|
|
158
|
+
try:
|
|
159
|
+
return fetch_active_agent_blocks(self._connection, now)
|
|
160
|
+
except sqlite3.Error as error:
|
|
161
|
+
message = f"could not query read-only database {self._database_path}: {error}"
|
|
162
|
+
raise DatabaseError(message) from error
|
|
163
|
+
|
|
164
|
+
def list_runs(self, task_id: str) -> list[Run]:
|
|
165
|
+
"""See `shikiru.store.runs.fetch_runs_for_task`."""
|
|
166
|
+
return fetch_runs_for_task(self._connection, task_id)
|
|
167
|
+
|
|
168
|
+
def list_taken_over_roles(self, task_id: str) -> tuple[Role, ...]:
|
|
169
|
+
"""Return the bounded set of roles currently taken over for `task_id`."""
|
|
170
|
+
return fetch_taken_over_roles_for_task(self._connection, task_id)
|
|
171
|
+
|
|
172
|
+
def list_all_runs(self) -> list[Run]:
|
|
173
|
+
"""See `shikiru.store.runs.fetch_all_runs`."""
|
|
174
|
+
return fetch_all_runs(self._connection)
|
|
175
|
+
|
|
176
|
+
def command_history(
|
|
177
|
+
self,
|
|
178
|
+
*,
|
|
179
|
+
limit: int,
|
|
180
|
+
offset: int = 0,
|
|
181
|
+
status: CommandStatus | None = None,
|
|
182
|
+
) -> CommandHistoryPage:
|
|
183
|
+
"""Read a bounded command-history page, preserving stored status and result."""
|
|
184
|
+
if not 0 < limit <= MAX_COMMAND_HISTORY_LIMIT:
|
|
185
|
+
message = f"command history limit must be between 1 and {MAX_COMMAND_HISTORY_LIMIT}"
|
|
186
|
+
raise ValueError(message)
|
|
187
|
+
if offset < 0:
|
|
188
|
+
message = "command history offset cannot be negative"
|
|
189
|
+
raise ValueError(message)
|
|
190
|
+
try:
|
|
191
|
+
return fetch_command_history(
|
|
192
|
+
self._connection,
|
|
193
|
+
limit=limit,
|
|
194
|
+
offset=offset,
|
|
195
|
+
status=status,
|
|
196
|
+
)
|
|
197
|
+
except sqlite3.Error as error:
|
|
198
|
+
message = f"could not query read-only database {self._database_path}: {error}"
|
|
199
|
+
raise DatabaseError(message) from error
|
|
200
|
+
|
|
201
|
+
def command_status(self, command_id: int) -> StoredCommand | None:
|
|
202
|
+
"""Read one durable command status by id without changing its lifecycle."""
|
|
203
|
+
try:
|
|
204
|
+
return fetch_command(self._connection, command_id)
|
|
205
|
+
except sqlite3.Error as error:
|
|
206
|
+
message = f"could not query read-only database {self._database_path}: {error}"
|
|
207
|
+
raise DatabaseError(message) from error
|
|
208
|
+
|
|
209
|
+
def pending_command(self, command: Command) -> StoredCommand | None:
|
|
210
|
+
"""Find one identical pending command through the read-only connection."""
|
|
211
|
+
try:
|
|
212
|
+
return fetch_pending_command(self._connection, command)
|
|
213
|
+
except sqlite3.Error as error:
|
|
214
|
+
message = f"could not query read-only database {self._database_path}: {error}"
|
|
215
|
+
raise DatabaseError(message) from error
|
|
216
|
+
|
|
217
|
+
def push_target(self, base: str) -> PushTarget | None:
|
|
218
|
+
"""See `shikiru.store.pushes.fetch_push_target`."""
|
|
219
|
+
return fetch_push_target(self._connection, base)
|
|
220
|
+
|
|
221
|
+
def get_escalation(self, escalation_id: str) -> Escalation | None:
|
|
222
|
+
"""See `shikiru.store.escalations.fetch_escalation`."""
|
|
223
|
+
return fetch_escalation(self._connection, escalation_id)
|
|
224
|
+
|
|
225
|
+
def list_open_escalations(self) -> list[Escalation]:
|
|
226
|
+
"""See `shikiru.store.escalations.fetch_open_escalations`."""
|
|
227
|
+
return fetch_open_escalations(self._connection)
|
|
228
|
+
|
|
229
|
+
def list_quotas(self, agents: Sequence[str]) -> dict[str, list[QuotaWindow]]:
|
|
230
|
+
"""Read quota windows for each requested agent."""
|
|
231
|
+
return {agent: fetch_quotas(self._connection, agent) for agent in agents}
|
|
232
|
+
|
|
233
|
+
def list_quota_agents(self) -> list[str]:
|
|
234
|
+
"""Read every agent with persisted quota windows."""
|
|
235
|
+
return fetch_quota_agents(self._connection)
|
|
236
|
+
|
|
237
|
+
def events_since(
|
|
238
|
+
self,
|
|
239
|
+
since_id: int,
|
|
240
|
+
*,
|
|
241
|
+
kinds: Sequence[EventKind] | None = None,
|
|
242
|
+
task_id: str | None = None,
|
|
243
|
+
) -> list[Event]:
|
|
244
|
+
"""See `shikiru.store.events.fetch_events_since`."""
|
|
245
|
+
return fetch_events_since(self._connection, since_id, kinds=kinds, task_id=task_id)
|
|
246
|
+
|
|
247
|
+
def latest_events(
|
|
248
|
+
self,
|
|
249
|
+
limit: int,
|
|
250
|
+
*,
|
|
251
|
+
kinds: Sequence[EventKind] | None = None,
|
|
252
|
+
implementer_turns_only: bool = False,
|
|
253
|
+
task_id: str | None = None,
|
|
254
|
+
) -> list[Event]:
|
|
255
|
+
"""Read the latest matching events in chronological order."""
|
|
256
|
+
return fetch_latest_events(
|
|
257
|
+
self._connection,
|
|
258
|
+
limit,
|
|
259
|
+
kinds=kinds,
|
|
260
|
+
implementer_turns_only=implementer_turns_only,
|
|
261
|
+
task_id=task_id,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def task_timeline_events(
|
|
265
|
+
self,
|
|
266
|
+
task_id: str,
|
|
267
|
+
*,
|
|
268
|
+
limit: int,
|
|
269
|
+
before: tuple[str, int] | None = None,
|
|
270
|
+
kinds: Sequence[EventKind] | None = None,
|
|
271
|
+
role: str | None = None,
|
|
272
|
+
turn: str | None = None,
|
|
273
|
+
) -> list[Event]:
|
|
274
|
+
"""Read a bounded, newest-first task event page for the evidence timeline."""
|
|
275
|
+
if not 0 < limit <= MAX_TIMELINE_EVENT_LIMIT:
|
|
276
|
+
message = f"task timeline event limit must be between 1 and {MAX_TIMELINE_EVENT_LIMIT}"
|
|
277
|
+
raise ValueError(message)
|
|
278
|
+
try:
|
|
279
|
+
return fetch_timeline_events(
|
|
280
|
+
self._connection,
|
|
281
|
+
task_id,
|
|
282
|
+
limit=limit,
|
|
283
|
+
before=before,
|
|
284
|
+
kinds=kinds,
|
|
285
|
+
role=role,
|
|
286
|
+
turn=turn,
|
|
287
|
+
)
|
|
288
|
+
except sqlite3.Error as error:
|
|
289
|
+
message = f"could not query read-only database {self._database_path}: {error}"
|
|
290
|
+
raise DatabaseError(message) from error
|
|
291
|
+
|
|
292
|
+
def data_version(self) -> int:
|
|
293
|
+
"""Return SQLite's `data_version` counter, bumped by every other connection's commit."""
|
|
294
|
+
row = self._connection.execute("PRAGMA data_version").fetchone()
|
|
295
|
+
return row[0]
|
|
296
|
+
|
|
297
|
+
def close(self) -> None:
|
|
298
|
+
"""Release the read-only connection; the dashboard or operator calls this on shutdown."""
|
|
299
|
+
self._connection.close()
|
|
300
|
+
|
|
301
|
+
async def watch(self, on_change: OnChange) -> None:
|
|
302
|
+
"""Call `on_change` once after every commit from another connection, until cancelled."""
|
|
303
|
+
last_version = self.data_version()
|
|
304
|
+
while True:
|
|
305
|
+
await self._clock.sleep(DASHBOARD_CHANGE_POLL_SECONDS)
|
|
306
|
+
current_version = self.data_version()
|
|
307
|
+
if current_version == last_version:
|
|
308
|
+
continue
|
|
309
|
+
last_version = current_version
|
|
310
|
+
await on_change()
|