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,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
|
+
)
|