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.
Files changed (319) hide show
  1. shikiru/__init__.py +1 -0
  2. shikiru/__main__.py +8 -0
  3. shikiru/agents/__init__.py +3 -0
  4. shikiru/agents/contracts.py +590 -0
  5. shikiru/agents/registry.py +87 -0
  6. shikiru/agents/sdk/__init__.py +17 -0
  7. shikiru/agents/sdk/reset_time.py +137 -0
  8. shikiru/agents/sdk/turn.py +335 -0
  9. shikiru/agents/settings.py +151 -0
  10. shikiru/agents/testing/__init__.py +26 -0
  11. shikiru/agents/testing/contract_suite.py +653 -0
  12. shikiru/agents/testing/resume.py +15 -0
  13. shikiru/bootstrap.py +1277 -0
  14. shikiru/cli/__init__.py +1 -0
  15. shikiru/cli/app.py +770 -0
  16. shikiru/cli/context.py +63 -0
  17. shikiru/cli/render.py +1475 -0
  18. shikiru/cli/report.py +156 -0
  19. shikiru/cli/session_prompt.py +63 -0
  20. shikiru/cli/view.py +131 -0
  21. shikiru/clock.py +41 -0
  22. shikiru/config/__init__.py +8 -0
  23. shikiru/config/defaults.toml +131 -0
  24. shikiru/config/loader.py +178 -0
  25. shikiru/config/settings.py +392 -0
  26. shikiru/dashboard/__init__.py +3 -0
  27. shikiru/dashboard/actions_list.py +799 -0
  28. shikiru/dashboard/activity.py +150 -0
  29. shikiru/dashboard/app.py +1765 -0
  30. shikiru/dashboard/blocker_evidence.py +122 -0
  31. shikiru/dashboard/blocker_rows.py +84 -0
  32. shikiru/dashboard/briefing_view.py +871 -0
  33. shikiru/dashboard/contract.py +1086 -0
  34. shikiru/dashboard/covered.py +32 -0
  35. shikiru/dashboard/detail.py +1209 -0
  36. shikiru/dashboard/detail_fold.py +244 -0
  37. shikiru/dashboard/detail_hint.py +68 -0
  38. shikiru/dashboard/details_list_view.py +150 -0
  39. shikiru/dashboard/diagnostics.py +80 -0
  40. shikiru/dashboard/diagnostics_sections.py +186 -0
  41. shikiru/dashboard/evidence_table.py +123 -0
  42. shikiru/dashboard/extra_details.py +214 -0
  43. shikiru/dashboard/fault_guidance.py +216 -0
  44. shikiru/dashboard/fold_bodies.py +276 -0
  45. shikiru/dashboard/help_keys.py +166 -0
  46. shikiru/dashboard/help_screen.py +204 -0
  47. shikiru/dashboard/interaction.py +17 -0
  48. shikiru/dashboard/ladder_editor.py +544 -0
  49. shikiru/dashboard/live_settings.py +62 -0
  50. shikiru/dashboard/motion.py +368 -0
  51. shikiru/dashboard/orchestration_fields.py +201 -0
  52. shikiru/dashboard/palette.py +1606 -0
  53. shikiru/dashboard/palette_app.py +89 -0
  54. shikiru/dashboard/palette_drafts.py +33 -0
  55. shikiru/dashboard/resolution.py +573 -0
  56. shikiru/dashboard/resolution_view.py +302 -0
  57. shikiru/dashboard/row_list.py +256 -0
  58. shikiru/dashboard/section_rows.py +173 -0
  59. shikiru/dashboard/services.py +665 -0
  60. shikiru/dashboard/session_history.py +402 -0
  61. shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
  62. shikiru/dashboard/task_picker.py +358 -0
  63. shikiru/dashboard/tick.py +59 -0
  64. shikiru/dashboard/timeline_filter.py +129 -0
  65. shikiru/dashboard/timeline_section.py +385 -0
  66. shikiru/dashboard/usage_body.py +269 -0
  67. shikiru/dashboard/views.py +2314 -0
  68. shikiru/dashboard/wizard.py +1389 -0
  69. shikiru/domain/__init__.py +1 -0
  70. shikiru/domain/acceptance.py +130 -0
  71. shikiru/domain/activity.py +28 -0
  72. shikiru/domain/attempts.py +333 -0
  73. shikiru/domain/briefing.py +257 -0
  74. shikiru/domain/commands.py +219 -0
  75. shikiru/domain/decisions.py +231 -0
  76. shikiru/domain/docker.py +262 -0
  77. shikiru/domain/engine_uptime.py +87 -0
  78. shikiru/domain/escalation.py +194 -0
  79. shikiru/domain/escalation_evidence.py +186 -0
  80. shikiru/domain/escalation_panel.py +144 -0
  81. shikiru/domain/events.py +71 -0
  82. shikiru/domain/evidence_export.py +89 -0
  83. shikiru/domain/faults.py +86 -0
  84. shikiru/domain/feedback.py +180 -0
  85. shikiru/domain/feedback_rounds.py +172 -0
  86. shikiru/domain/fencing.py +31 -0
  87. shikiru/domain/ladder.py +102 -0
  88. shikiru/domain/ladder_text.py +44 -0
  89. shikiru/domain/levels.py +44 -0
  90. shikiru/domain/motion.py +199 -0
  91. shikiru/domain/packet_evidence.py +377 -0
  92. shikiru/domain/pushes.py +47 -0
  93. shikiru/domain/quota_blocks.py +44 -0
  94. shikiru/domain/quota_waits.py +219 -0
  95. shikiru/domain/recovery.py +194 -0
  96. shikiru/domain/reports.py +498 -0
  97. shikiru/domain/results.py +217 -0
  98. shikiru/domain/runs.py +85 -0
  99. shikiru/domain/scheduling.py +231 -0
  100. shikiru/domain/status_tasks.py +40 -0
  101. shikiru/domain/steps.py +325 -0
  102. shikiru/domain/tasks.py +174 -0
  103. shikiru/domain/timeline.py +208 -0
  104. shikiru/domain/timeline_phrases.py +271 -0
  105. shikiru/domain/versions.py +18 -0
  106. shikiru/domain/workflow.py +372 -0
  107. shikiru/engine/__init__.py +0 -0
  108. shikiru/engine/acceptance.py +90 -0
  109. shikiru/engine/agents.py +61 -0
  110. shikiru/engine/cosmetic_decorator.py +99 -0
  111. shikiru/engine/daemon.py +409 -0
  112. shikiru/engine/dependency.py +37 -0
  113. shikiru/engine/escalation_filing.py +110 -0
  114. shikiru/engine/escalations.py +401 -0
  115. shikiru/engine/events.py +285 -0
  116. shikiru/engine/evidence.py +81 -0
  117. shikiru/engine/fault_reporter.py +144 -0
  118. shikiru/engine/feedback.py +577 -0
  119. shikiru/engine/feedback_audit.py +80 -0
  120. shikiru/engine/feedback_intake.py +95 -0
  121. shikiru/engine/feedback_snapshot.py +148 -0
  122. shikiru/engine/flow.py +87 -0
  123. shikiru/engine/handlers/__init__.py +33 -0
  124. shikiru/engine/handlers/base.py +148 -0
  125. shikiru/engine/handlers/engine.py +24 -0
  126. shikiru/engine/handlers/escalations.py +174 -0
  127. shikiru/engine/handlers/operator.py +81 -0
  128. shikiru/engine/handlers/tasks.py +237 -0
  129. shikiru/engine/history.py +128 -0
  130. shikiru/engine/integration.py +1234 -0
  131. shikiru/engine/integration_turns.py +337 -0
  132. shikiru/engine/layout_guard.py +362 -0
  133. shikiru/engine/lifecycle.py +60 -0
  134. shikiru/engine/monitor.py +317 -0
  135. shikiru/engine/notification_delivery.py +124 -0
  136. shikiru/engine/notification_feed.py +57 -0
  137. shikiru/engine/notifier.py +91 -0
  138. shikiru/engine/pull_request_closing.py +117 -0
  139. shikiru/engine/push_service.py +177 -0
  140. shikiru/engine/quota_probe_threads.py +79 -0
  141. shikiru/engine/quota_reconciler.py +210 -0
  142. shikiru/engine/quota_schedule.py +24 -0
  143. shikiru/engine/quota_waits.py +102 -0
  144. shikiru/engine/reconcile.py +91 -0
  145. shikiru/engine/recovery.py +284 -0
  146. shikiru/engine/retention.py +102 -0
  147. shikiru/engine/root_preparation.py +86 -0
  148. shikiru/engine/scheduler.py +320 -0
  149. shikiru/engine/session_guard.py +46 -0
  150. shikiru/engine/sessions.py +95 -0
  151. shikiru/engine/signalled.py +75 -0
  152. shikiru/engine/step_runner.py +140 -0
  153. shikiru/engine/supervisor.py +101 -0
  154. shikiru/engine/takeover.py +223 -0
  155. shikiru/engine/task_facts.py +46 -0
  156. shikiru/engine/tick_guard.py +95 -0
  157. shikiru/engine/titles.py +297 -0
  158. shikiru/engine/transitions.py +79 -0
  159. shikiru/engine/turn_context.py +206 -0
  160. shikiru/engine/turn_execution.py +30 -0
  161. shikiru/engine/turn_loop.py +230 -0
  162. shikiru/engine/turn_processes.py +79 -0
  163. shikiru/engine/turns.py +457 -0
  164. shikiru/errors.py +144 -0
  165. shikiru/helper/__init__.py +3 -0
  166. shikiru/helper/helper.py +182 -0
  167. shikiru/helper/models.py +11 -0
  168. shikiru/mcp/__init__.py +1 -0
  169. shikiru/mcp/server.py +196 -0
  170. shikiru/operator/__init__.py +33 -0
  171. shikiru/operator/attachment.py +24 -0
  172. shikiru/operator/commands.py +319 -0
  173. shikiru/operator/detectors.py +374 -0
  174. shikiru/operator/doctor.py +351 -0
  175. shikiru/operator/events.py +57 -0
  176. shikiru/operator/evidence_export.py +498 -0
  177. shikiru/operator/evidence_writer.py +78 -0
  178. shikiru/operator/lifecycle.py +294 -0
  179. shikiru/operator/plugin_checks.py +194 -0
  180. shikiru/operator/probe.py +287 -0
  181. shikiru/operator/queries.py +1601 -0
  182. shikiru/operator/services.py +36 -0
  183. shikiru/operator/sessions.py +378 -0
  184. shikiru/operator/strip_views.py +162 -0
  185. shikiru/operator/timeline.py +416 -0
  186. shikiru/operator/toml_text.py +62 -0
  187. shikiru/palette.py +18 -0
  188. shikiru/paths.py +91 -0
  189. shikiru/plugins/__init__.py +0 -0
  190. shikiru/plugins/agents/__init__.py +1 -0
  191. shikiru/plugins/agents/claude/__init__.py +5 -0
  192. shikiru/plugins/agents/claude/agent.py +251 -0
  193. shikiru/plugins/agents/claude/context.py +116 -0
  194. shikiru/plugins/agents/claude/headless.py +92 -0
  195. shikiru/plugins/agents/claude/hook.py +57 -0
  196. shikiru/plugins/agents/claude/model_catalog.py +105 -0
  197. shikiru/plugins/agents/claude/options.py +62 -0
  198. shikiru/plugins/agents/claude/turn.py +1018 -0
  199. shikiru/plugins/agents/codex/__init__.py +5 -0
  200. shikiru/plugins/agents/codex/agent.py +486 -0
  201. shikiru/plugins/agents/codex/headless.py +166 -0
  202. shikiru/plugins/agents/codex/home.py +14 -0
  203. shikiru/plugins/agents/codex/model_catalog.py +128 -0
  204. shikiru/plugins/agents/codex/options.py +94 -0
  205. shikiru/plugins/agents/codex/quota.py +195 -0
  206. shikiru/plugins/agents/codex/sandbox.py +31 -0
  207. shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
  208. shikiru/plugins/agents/codex/turn.py +767 -0
  209. shikiru/plugins/agents/codex/usage.py +106 -0
  210. shikiru/plugins/agents/shared_settings.py +62 -0
  211. shikiru/plugins/widgets/__init__.py +3 -0
  212. shikiru/plugins/widgets/dialogue.py +144 -0
  213. shikiru/plugins/widgets/disk.py +123 -0
  214. shikiru/plugins/widgets/docker.py +225 -0
  215. shikiru/plugins/widgets/escalations.py +402 -0
  216. shikiru/plugins/widgets/faults.py +204 -0
  217. shikiru/plugins/widgets/header.py +321 -0
  218. shikiru/plugins/widgets/queue.py +1140 -0
  219. shikiru/plugins/widgets/queue_hint.py +93 -0
  220. shikiru/plugins/widgets/resources.py +105 -0
  221. shikiru/plugins/widgets/strips.py +82 -0
  222. shikiru/plugins/widgets/usage.py +176 -0
  223. shikiru/private.py +94 -0
  224. shikiru/process.py +535 -0
  225. shikiru/prompts/__init__.py +3 -0
  226. shikiru/prompts/renderer.py +85 -0
  227. shikiru/prompts/templates/_base.md.j2 +17 -0
  228. shikiru/prompts/templates/_macros.md.j2 +61 -0
  229. shikiru/prompts/templates/conflicts.md.j2 +22 -0
  230. shikiru/prompts/templates/consult.md.j2 +5 -0
  231. shikiru/prompts/templates/continue.md.j2 +4 -0
  232. shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
  233. shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
  234. shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
  235. shikiru/prompts/templates/handoff.md.j2 +23 -0
  236. shikiru/prompts/templates/implement.md.j2 +13 -0
  237. shikiru/prompts/templates/plan.md.j2 +30 -0
  238. shikiru/prompts/templates/rework.md.j2 +13 -0
  239. shikiru/prompts/templates/triage.md.j2 +20 -0
  240. shikiru/prompts/templates/verify.md.j2 +29 -0
  241. shikiru/store/__init__.py +1 -0
  242. shikiru/store/commands.py +242 -0
  243. shikiru/store/database.py +297 -0
  244. shikiru/store/escalations.py +240 -0
  245. shikiru/store/events.py +213 -0
  246. shikiru/store/feedback.py +266 -0
  247. shikiru/store/migrations/001_initial.sql +63 -0
  248. shikiru/store/migrations/002_headless_turns.sql +31 -0
  249. shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
  250. shikiru/store/migrations/004_repo_base_ref.sql +3 -0
  251. shikiru/store/migrations/005_complexity_explicit.sql +3 -0
  252. shikiru/store/migrations/006_events_kind_index.sql +1 -0
  253. shikiru/store/migrations/007_notification_cursor.sql +1 -0
  254. shikiru/store/migrations/008_reported_usage.sql +1 -0
  255. shikiru/store/migrations/009_billing_declarations.sql +2 -0
  256. shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
  257. shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
  258. shikiru/store/migrations/013_push_targets.sql +7 -0
  259. shikiru/store/migrations/014_agent_block_time.sql +6 -0
  260. shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
  261. shikiru/store/migrations/016_feedback_rounds.sql +29 -0
  262. shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
  263. shikiru/store/migrations/__init__.py +1 -0
  264. shikiru/store/notification_cursor.py +29 -0
  265. shikiru/store/pushes.py +117 -0
  266. shikiru/store/quotas.py +153 -0
  267. shikiru/store/reader.py +310 -0
  268. shikiru/store/retention.py +92 -0
  269. shikiru/store/runs.py +227 -0
  270. shikiru/store/tasks.py +949 -0
  271. shikiru/telemetry/__init__.py +1 -0
  272. shikiru/telemetry/disk.py +167 -0
  273. shikiru/telemetry/docker.py +164 -0
  274. shikiru/telemetry/resources.py +296 -0
  275. shikiru/tmux/__init__.py +3 -0
  276. shikiru/tmux/client.py +637 -0
  277. shikiru/tmux/decorator.py +225 -0
  278. shikiru/tmux/layout.py +200 -0
  279. shikiru/tmux/session.py +381 -0
  280. shikiru/ui/__init__.py +9 -0
  281. shikiru/ui/age.py +45 -0
  282. shikiru/ui/bubbles.py +41 -0
  283. shikiru/ui/compact.py +1209 -0
  284. shikiru/ui/console.py +49 -0
  285. shikiru/ui/fold.py +50 -0
  286. shikiru/ui/gated_static.py +117 -0
  287. shikiru/ui/gauge.py +40 -0
  288. shikiru/ui/glyphs.py +109 -0
  289. shikiru/ui/keys.py +57 -0
  290. shikiru/ui/live_interval.py +42 -0
  291. shikiru/ui/popup.py +311 -0
  292. shikiru/ui/quota_status.py +26 -0
  293. shikiru/ui/recent_window.py +78 -0
  294. shikiru/ui/screen.py +57 -0
  295. shikiru/ui/section.py +193 -0
  296. shikiru/ui/session.py +65 -0
  297. shikiru/ui/size.py +17 -0
  298. shikiru/ui/splash.py +42 -0
  299. shikiru/ui/status.py +47 -0
  300. shikiru/ui/status_strip.py +149 -0
  301. shikiru/ui/stream.py +152 -0
  302. shikiru/ui/strip.py +332 -0
  303. shikiru/ui/theme.py +83 -0
  304. shikiru/ui/tokens.py +51 -0
  305. shikiru/view_log.py +126 -0
  306. shikiru/workspace/__init__.py +3 -0
  307. shikiru/workspace/base_refs.py +160 -0
  308. shikiru/workspace/caches.py +136 -0
  309. shikiru/workspace/facts.py +118 -0
  310. shikiru/workspace/git.py +601 -0
  311. shikiru/workspace/github.py +744 -0
  312. shikiru/workspace/messages.py +107 -0
  313. shikiru/workspace/reports.py +145 -0
  314. shikiru/workspace/roots.py +180 -0
  315. shikiru_cli-0.2.0.dist-info/METADATA +428 -0
  316. shikiru_cli-0.2.0.dist-info/RECORD +319 -0
  317. shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
  318. shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
  319. 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
+ )
@@ -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
+ )
@@ -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)
@@ -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()