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,297 @@
1
+ """Connections, migrations and JSON/timestamp column helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3 # noqa: TID251
6
+ from contextlib import contextmanager, suppress
7
+ from datetime import UTC, datetime
8
+ from importlib import resources
9
+ from typing import TYPE_CHECKING
10
+
11
+ from shikiru.errors import DatabaseError
12
+ from shikiru.paths import SCHEMA_BACKUPS_DIR_NAME
13
+ from shikiru.private import create_private_file, make_private_dir
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Generator
17
+ from pathlib import Path
18
+
19
+ from pydantic import TypeAdapter
20
+
21
+ BUSY_TIMEOUT_MS = 5000
22
+ SCHEMA_BACKUPS_KEPT = 3
23
+ OperationalDatabaseError = sqlite3.OperationalError
24
+ """A database fault of the environment: locked, full or unreadable."""
25
+ _MIGRATIONS_PACKAGE = "shikiru.store.migrations"
26
+ _MIGRATION_SUFFIX = ".sql"
27
+ _MIGRATION_NUMBER_SEPARATOR = "_"
28
+ _BACKUP_PREFIX = "state-schema"
29
+ _BACKUP_SUFFIX = ".db"
30
+ _BEGIN = "BEGIN"
31
+ _BEGIN_IMMEDIATE = "BEGIN IMMEDIATE"
32
+ _READ_VERSION = "PRAGMA user_version"
33
+
34
+
35
+ class Database:
36
+ """Opens `.shikiru/state.db`, keeping its schema migrated to the packaged latest version."""
37
+
38
+ def __init__(self, path: Path) -> None:
39
+ """Point at the SQLite file `path`; no connection is opened until asked for one."""
40
+ self._path = path
41
+
42
+ @property
43
+ def path(self) -> Path:
44
+ """Return the database file path used in actionable read errors."""
45
+ return self._path
46
+
47
+ def connect(self) -> sqlite3.Connection:
48
+ """Open a read-write connection and migrate the schema up to date.
49
+
50
+ The engine uses this for every state table; clients (CLI, MCP) use it only to append
51
+ `commands` rows and, for `Submit`, to allocate the task id from `sequences`.
52
+ """
53
+ make_private_dir(self._path.parent)
54
+ create_private_file(self._path)
55
+ connection = _guard_schema(self._connect(mode="rwc"), self._path)
56
+ connection.execute("PRAGMA journal_mode = WAL")
57
+ try:
58
+ _migrate(connection, self._path)
59
+ except BaseException:
60
+ connection.close()
61
+ raise
62
+ return connection
63
+
64
+ def connect_readonly(self) -> sqlite3.Connection:
65
+ """Open a connection SQLite itself refuses to let write."""
66
+ try:
67
+ connection = self._connect(mode="ro")
68
+ except sqlite3.Error as error:
69
+ message = f"could not open read-only database {self._path}: {error}"
70
+ raise DatabaseError(message) from error
71
+ return _guard_schema(connection, self._path)
72
+
73
+ def schema_version(self, connection: sqlite3.Connection) -> int:
74
+ """Return the migrated schema version for an open database connection."""
75
+ return _schema_version(connection)
76
+
77
+ def health(self) -> int:
78
+ """Open and migrate the database, returning its schema version."""
79
+ connection: sqlite3.Connection | None = None
80
+ try:
81
+ connection = self.connect()
82
+ return self.schema_version(connection)
83
+ except sqlite3.Error as error:
84
+ raise DatabaseError(str(error)) from error
85
+ finally:
86
+ if connection is not None:
87
+ connection.close()
88
+
89
+ def _uri(self, mode: str) -> str:
90
+ return f"{self._path.absolute().as_uri()}?mode={mode}"
91
+
92
+ def _connect(self, *, mode: str) -> sqlite3.Connection:
93
+ connection = sqlite3.connect(
94
+ self._uri(mode),
95
+ uri=True,
96
+ isolation_level=None,
97
+ check_same_thread=False,
98
+ )
99
+ connection.row_factory = sqlite3.Row
100
+ connection.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}")
101
+ connection.execute("PRAGMA foreign_keys = ON")
102
+ return connection
103
+
104
+
105
+ @contextmanager
106
+ def transaction(
107
+ connection: sqlite3.Connection, *, immediate: bool = False
108
+ ) -> Generator[sqlite3.Connection]:
109
+ """Run a block as one transaction: commits on success, rolls back on any exception.
110
+
111
+ With `immediate` the write lock is taken at once, so what the block reads first cannot
112
+ change before it writes.
113
+
114
+ Composes with an already-open transaction by joining it, so multiple store operations can
115
+ participate in one atomic unit without attempting a nested `BEGIN`.
116
+ """
117
+ if connection.in_transaction:
118
+ yield connection
119
+ return
120
+ connection.execute(_BEGIN_IMMEDIATE if immediate else _BEGIN)
121
+ try:
122
+ yield connection
123
+ except BaseException:
124
+ connection.execute("ROLLBACK")
125
+ raise
126
+ else:
127
+ connection.execute("COMMIT")
128
+
129
+
130
+ def next_sequence(connection: sqlite3.Connection, name: str) -> int:
131
+ """Atomically increment and return `sequences.value` for `name`, inside a transaction."""
132
+ row = connection.execute(
133
+ "INSERT INTO sequences (name, value) VALUES (?, 1) "
134
+ "ON CONFLICT (name) DO UPDATE SET value = value + 1 RETURNING value",
135
+ (name,),
136
+ ).fetchone()
137
+ return row[0]
138
+
139
+
140
+ def dump_json[T](adapter: TypeAdapter[T], value: T) -> str:
141
+ """Serialise `value` to JSON text via `adapter`, for a JSON-typed column."""
142
+ return adapter.dump_json(value).decode()
143
+
144
+
145
+ def load_json[T](adapter: TypeAdapter[T], text: str) -> T:
146
+ """Parse `text` into `adapter`'s type, for a JSON-typed column."""
147
+ return adapter.validate_json(text)
148
+
149
+
150
+ def format_timestamp(value: datetime) -> str:
151
+ """Format `value` as the fixed-width UTC ISO-8601 text every timestamp column stores.
152
+
153
+ `timespec="microseconds"` always renders the fractional part, so every stored timestamp has
154
+ the same width and plain string comparison (as `agent_blocks.until > ?` relies on) orders
155
+ them the same way their instants do; without it, a whole-second value's missing ".ffffff"
156
+ only sorts correctly by accident.
157
+ """
158
+ return value.astimezone(UTC).isoformat(timespec="microseconds")
159
+
160
+
161
+ def parse_timestamp(text: str | None) -> datetime | None:
162
+ """Parse a nullable timestamp column back into a UTC-aware `datetime`."""
163
+ return datetime.fromisoformat(text) if text is not None else None
164
+
165
+
166
+ def parse_required_timestamp(text: str) -> datetime:
167
+ """Parse a `NOT NULL` timestamp column back into a UTC-aware `datetime`."""
168
+ return datetime.fromisoformat(text)
169
+
170
+
171
+ def require_after_insert[T](value: T | None, message: str) -> T:
172
+ """Unwrap `value`, or raise `RuntimeError(message)` if it's `None`.
173
+
174
+ Guards the two ways a same-transaction insert-then-read can't come back empty in practice:
175
+ sqlite refusing a rowid, or an immediate re-fetch by that rowid finding no row.
176
+ """
177
+ if value is None:
178
+ raise RuntimeError(message)
179
+ return value
180
+
181
+
182
+ def _migration_files() -> list[tuple[int, str]]:
183
+ package = resources.files(_MIGRATIONS_PACKAGE)
184
+ migrations: list[tuple[int, str]] = []
185
+ for entry in package.iterdir():
186
+ if entry.name.endswith(_MIGRATION_SUFFIX):
187
+ number = int(entry.name.split(_MIGRATION_NUMBER_SEPARATOR, 1)[0])
188
+ migrations.append((number, entry.read_text(encoding="utf-8")))
189
+ return sorted(migrations, key=lambda migration: migration[0])
190
+
191
+
192
+ def _statements(sql: str) -> list[str]:
193
+ return [statement.strip() for statement in sql.split(";") if statement.strip()]
194
+
195
+
196
+ def _schema_version(connection: sqlite3.Connection) -> int:
197
+ return connection.execute(_READ_VERSION).fetchone()[0]
198
+
199
+
200
+ def _guard_schema(connection: sqlite3.Connection, path: Path) -> sqlite3.Connection:
201
+ stored = _schema_version(connection)
202
+ known = _migration_files()[-1][0]
203
+ if stored > known:
204
+ connection.close()
205
+ message = (
206
+ f"state database {path} has schema version {stored}, newer than the version "
207
+ f"{known} this Shikiru knows; use the Shikiru release that wrote it"
208
+ f"{_restore_hint(path)}"
209
+ )
210
+ raise DatabaseError(message)
211
+ return connection
212
+
213
+
214
+ def _restore_hint(path: Path) -> str:
215
+ newest = _newest_backup(_backups_dir(path))
216
+ if newest is None:
217
+ return ""
218
+ return f"; the newest backup is {newest}, copy it back over {path} to restore it"
219
+
220
+
221
+ def _migrate(connection: sqlite3.Connection, path: Path) -> None:
222
+ migrations = _migration_files()
223
+ latest = migrations[-1][0]
224
+ if _schema_version(connection) >= latest:
225
+ return
226
+ with transaction(connection, immediate=True):
227
+ stored = _schema_version(connection)
228
+ if 0 < stored < latest:
229
+ _back_up(path, stored)
230
+ for number, sql in migrations:
231
+ with transaction(connection, immediate=True):
232
+ if number <= _schema_version(connection):
233
+ continue
234
+ for statement in _statements(sql):
235
+ connection.execute(statement)
236
+ connection.execute(f"PRAGMA user_version = {number}")
237
+
238
+
239
+ def _backups_dir(path: Path) -> Path:
240
+ return path.parent / SCHEMA_BACKUPS_DIR_NAME
241
+
242
+
243
+ def _back_up(path: Path, schema: int) -> None:
244
+ directory = _backups_dir(path)
245
+ target = directory / f"{_BACKUP_PREFIX}{schema}{_BACKUP_SUFFIX}"
246
+ try:
247
+ make_private_dir(directory)
248
+ target.unlink(missing_ok=True)
249
+ create_private_file(target)
250
+ _copy_database(path, target)
251
+ _prune_backups(directory)
252
+ except (OSError, sqlite3.Error) as error:
253
+ with suppress(OSError):
254
+ target.unlink(missing_ok=True)
255
+ message = (
256
+ f"could not back up state database {path} before migrating from schema {schema}: "
257
+ f"{error}"
258
+ )
259
+ raise DatabaseError(message) from error
260
+
261
+
262
+ def _copy_database(source_path: Path, target: Path) -> None:
263
+ source = sqlite3.connect(source_path)
264
+ destination = sqlite3.connect(target)
265
+ try:
266
+ source.backup(destination)
267
+ finally:
268
+ destination.close()
269
+ source.close()
270
+
271
+
272
+ def _prune_backups(directory: Path) -> None:
273
+ for _schema, stale in _backups_by_schema(directory)[:-SCHEMA_BACKUPS_KEPT]:
274
+ stale.unlink()
275
+
276
+
277
+ def _newest_backup(directory: Path) -> Path | None:
278
+ backups = _backups_by_schema(directory)
279
+ return backups[-1][1] if backups else None
280
+
281
+
282
+ def _backups_by_schema(directory: Path) -> list[tuple[int, Path]]:
283
+ if not directory.is_dir():
284
+ return []
285
+ backups: list[tuple[int, Path]] = []
286
+ for entry in directory.iterdir():
287
+ schema = _backup_schema(entry.name)
288
+ if schema is not None:
289
+ backups.append((schema, entry))
290
+ return sorted(backups, key=lambda backup: backup[0])
291
+
292
+
293
+ def _backup_schema(name: str) -> int | None:
294
+ if not (name.startswith(_BACKUP_PREFIX) and name.endswith(_BACKUP_SUFFIX)):
295
+ return None
296
+ digits = name.removeprefix(_BACKUP_PREFIX).removesuffix(_BACKUP_SUFFIX)
297
+ return int(digits) if digits.isdecimal() else None
@@ -0,0 +1,240 @@
1
+ """Escalation rows: open, list and resolve."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pydantic import TypeAdapter
8
+
9
+ from shikiru.domain.commands import ResolutionKind
10
+ from shikiru.domain.escalation import Escalation, EscalationOption, EscalationStatus
11
+ from shikiru.domain.events import EventKind
12
+ from shikiru.domain.tasks import EscalationReason, Step
13
+ from shikiru.store.database import (
14
+ dump_json,
15
+ format_timestamp,
16
+ load_json,
17
+ next_sequence,
18
+ parse_required_timestamp,
19
+ parse_timestamp,
20
+ require_after_insert,
21
+ transaction,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ import sqlite3 # noqa: TID251
26
+
27
+ from shikiru.clock import Clock
28
+
29
+ ESCALATION_ID_PREFIX = "E-"
30
+ ESCALATION_SEQUENCE = "escalation"
31
+ ANSWER_RESOLUTION_PREFIX = f"{ResolutionKind.ANSWER.value} "
32
+ ANSWER_ESCALATION_ID_JSON_PATH = "$.escalation_id"
33
+ TURN_STEP_JSON_PATH = "$.step"
34
+
35
+ _OPTIONS_ADAPTER: TypeAdapter[tuple[EscalationOption, ...]] = TypeAdapter(
36
+ tuple[EscalationOption, ...]
37
+ )
38
+
39
+ _ESCALATION_COLUMNS = (
40
+ "id, task_id, step, reason, headline, packet_path, options, status, resolution, "
41
+ "created_at, resolved_at"
42
+ )
43
+
44
+
45
+ def _row_to_escalation(row: sqlite3.Row) -> Escalation:
46
+ return Escalation(
47
+ id=row["id"],
48
+ task_id=row["task_id"],
49
+ step=Step(row["step"]) if row["step"] is not None else None,
50
+ reason=EscalationReason(row["reason"]),
51
+ headline=row["headline"],
52
+ packet_path=row["packet_path"],
53
+ options=load_json(_OPTIONS_ADAPTER, row["options"]),
54
+ status=EscalationStatus(row["status"]),
55
+ resolution=row["resolution"],
56
+ created_at=parse_required_timestamp(row["created_at"]),
57
+ resolved_at=parse_timestamp(row["resolved_at"]),
58
+ )
59
+
60
+
61
+ def fetch_escalation(connection: sqlite3.Connection, escalation_id: str) -> Escalation | None:
62
+ """Read one escalation by id, or `None` if it doesn't exist."""
63
+ row = connection.execute(
64
+ f"SELECT {_ESCALATION_COLUMNS} FROM escalations WHERE id = ?", (escalation_id,)
65
+ ).fetchone()
66
+ return _row_to_escalation(row) if row is not None else None
67
+
68
+
69
+ def fetch_open_escalations(connection: sqlite3.Connection) -> list[Escalation]:
70
+ """Read every open escalation, oldest first."""
71
+ rows = connection.execute(
72
+ f"SELECT {_ESCALATION_COLUMNS} FROM escalations WHERE status = ? ORDER BY id",
73
+ (EscalationStatus.OPEN.value,),
74
+ ).fetchall()
75
+ return [_row_to_escalation(row) for row in rows]
76
+
77
+
78
+ class EscalationStore:
79
+ """Opens and resolves escalations, `E-n` ids allocated from `sequences`."""
80
+
81
+ def __init__(self, connection: sqlite3.Connection, clock: Clock) -> None:
82
+ """Use `connection` (the engine's writer) and `clock` for every write below."""
83
+ self._connection = connection
84
+ self._clock = clock
85
+
86
+ def open(
87
+ self,
88
+ task_id: str,
89
+ *,
90
+ escalation_id: str | None = None,
91
+ step: Step | None,
92
+ reason: EscalationReason,
93
+ headline: str,
94
+ packet_path: str,
95
+ options: tuple[EscalationOption, ...],
96
+ ) -> Escalation:
97
+ """Record a new escalation, allocating an id unless one was reserved for its packet."""
98
+ with transaction(self._connection):
99
+ if escalation_id is None:
100
+ escalation_id = self._next_id()
101
+ self._insert(
102
+ escalation_id,
103
+ task_id=task_id,
104
+ step=step,
105
+ reason=reason,
106
+ headline=headline,
107
+ packet_path=packet_path,
108
+ options=options,
109
+ )
110
+ return self._require_opened(escalation_id)
111
+
112
+ def reserve_id(self) -> str:
113
+ """Reserve the next escalation id before creating its packet file."""
114
+ with transaction(self._connection):
115
+ return self._next_id()
116
+
117
+ def _next_id(self) -> str:
118
+ seq = next_sequence(self._connection, ESCALATION_SEQUENCE)
119
+ return f"{ESCALATION_ID_PREFIX}{seq}"
120
+
121
+ def _insert(
122
+ self,
123
+ escalation_id: str,
124
+ *,
125
+ task_id: str,
126
+ step: Step | None,
127
+ reason: EscalationReason,
128
+ headline: str,
129
+ packet_path: str,
130
+ options: tuple[EscalationOption, ...],
131
+ ) -> None:
132
+ self._connection.execute(
133
+ """
134
+ INSERT INTO escalations (
135
+ id, task_id, step, reason, headline, packet_path, options, status, created_at
136
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
137
+ """,
138
+ (
139
+ escalation_id,
140
+ task_id,
141
+ step.value if step is not None else None,
142
+ reason.value,
143
+ headline,
144
+ packet_path,
145
+ dump_json(_OPTIONS_ADAPTER, options),
146
+ EscalationStatus.OPEN.value,
147
+ format_timestamp(self._clock.now()),
148
+ ),
149
+ )
150
+
151
+ def _require_opened(self, escalation_id: str) -> Escalation:
152
+ escalation = fetch_escalation(self._connection, escalation_id)
153
+ return require_after_insert(
154
+ escalation, f"escalation {escalation_id} vanished immediately after insert"
155
+ )
156
+
157
+ def list_open(self) -> list[Escalation]:
158
+ """Read every open escalation, oldest first."""
159
+ return fetch_open_escalations(self._connection)
160
+
161
+ def get(self, escalation_id: str) -> Escalation | None:
162
+ """Read one escalation by id, or `None` if it doesn't exist."""
163
+ return fetch_escalation(self._connection, escalation_id)
164
+
165
+ def latest_answer(self, task_id: str) -> str | None:
166
+ """Return the Operator answer that the task's unfinished consult still acts on.
167
+
168
+ Retries of a consult that the answer started are skipped, so the answer survives
169
+ them; any other escalation ends the search.
170
+ """
171
+ rows = self._connection.execute(
172
+ """
173
+ SELECT id, step, resolution FROM escalations
174
+ WHERE task_id = ? AND status = ?
175
+ ORDER BY resolved_at DESC, CAST(substr(id, 3) AS INTEGER) DESC
176
+ """,
177
+ (
178
+ task_id,
179
+ EscalationStatus.RESOLVED.value,
180
+ ),
181
+ )
182
+ for row in rows:
183
+ resolution = row["resolution"]
184
+ if resolution is not None and resolution.startswith(ANSWER_RESOLUTION_PREFIX):
185
+ if self._answer_was_consumed(task_id, row["id"]):
186
+ return None
187
+ return resolution[len(ANSWER_RESOLUTION_PREFIX) :]
188
+ if row["step"] != Step.CONSULT.value:
189
+ return None
190
+ return None
191
+
192
+ def _answer_was_consumed(self, task_id: str, escalation_id: str) -> bool:
193
+ resolution_event = self._connection.execute(
194
+ """
195
+ SELECT id FROM events
196
+ WHERE task_id = ? AND kind = ? AND json_extract(data, ?) = ?
197
+ ORDER BY id DESC LIMIT 1
198
+ """,
199
+ (
200
+ task_id,
201
+ EventKind.ESCALATION_RESOLVED.value,
202
+ ANSWER_ESCALATION_ID_JSON_PATH,
203
+ escalation_id,
204
+ ),
205
+ ).fetchone()
206
+ if resolution_event is None:
207
+ return False
208
+ consult_turn = self._connection.execute(
209
+ """
210
+ SELECT 1 FROM events
211
+ WHERE task_id = ? AND kind = ? AND id > ? AND json_extract(data, ?) = ?
212
+ LIMIT 1
213
+ """,
214
+ (
215
+ task_id,
216
+ EventKind.TURN_COMPLETED.value,
217
+ resolution_event["id"],
218
+ TURN_STEP_JSON_PATH,
219
+ Step.CONSULT.value,
220
+ ),
221
+ ).fetchone()
222
+ return consult_turn is not None
223
+
224
+ def settle_open(self, task_id: str, resolution: str) -> None:
225
+ """Resolve every open escalation of `task_id`, so none can act on its later state."""
226
+ for escalation in fetch_open_escalations(self._connection):
227
+ if escalation.task_id == task_id:
228
+ self.resolve(escalation.id, resolution)
229
+
230
+ def resolve(self, escalation_id: str, resolution: str) -> None:
231
+ """Mark `escalation_id` resolved, recording `resolution`'s summary text."""
232
+ self._connection.execute(
233
+ "UPDATE escalations SET status = ?, resolution = ?, resolved_at = ? WHERE id = ?",
234
+ (
235
+ EscalationStatus.RESOLVED.value,
236
+ resolution,
237
+ format_timestamp(self._clock.now()),
238
+ escalation_id,
239
+ ),
240
+ )