gobby 0.2.5__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.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""SQLite database manager for local storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sqlite3
|
|
10
|
+
import threading
|
|
11
|
+
import weakref
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
14
|
+
from datetime import date, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
|
17
|
+
|
|
18
|
+
# Register custom datetime adapters/converters (required since Python 3.12)
|
|
19
|
+
# See: https://docs.python.org/3/library/sqlite3.html#default-adapters-and-converters-deprecated
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _adapt_datetime(val: datetime) -> str:
|
|
23
|
+
"""Adapt datetime to ISO format string for SQLite storage."""
|
|
24
|
+
return val.isoformat(" ")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _adapt_date(val: date) -> str:
|
|
28
|
+
"""Adapt date to ISO format string for SQLite storage."""
|
|
29
|
+
return val.isoformat()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _convert_datetime(val: bytes) -> datetime:
|
|
33
|
+
"""Convert SQLite datetime string back to datetime object."""
|
|
34
|
+
return datetime.fromisoformat(val.decode())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _convert_date(val: bytes) -> date:
|
|
38
|
+
"""Convert SQLite date string back to date object."""
|
|
39
|
+
return date.fromisoformat(val.decode())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Register adapters (Python -> SQLite)
|
|
43
|
+
sqlite3.register_adapter(datetime, _adapt_datetime)
|
|
44
|
+
sqlite3.register_adapter(date, _adapt_date)
|
|
45
|
+
|
|
46
|
+
# Register converters (SQLite -> Python) - used with detect_types
|
|
47
|
+
sqlite3.register_converter("datetime", _convert_datetime)
|
|
48
|
+
sqlite3.register_converter("date", _convert_date)
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from gobby.storage.artifacts import LocalArtifactManager
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@runtime_checkable
|
|
57
|
+
class DatabaseProtocol(Protocol):
|
|
58
|
+
"""Protocol defining the database interface for storage managers."""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def db_path(self) -> Any:
|
|
62
|
+
"""Return database path."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def connection(self) -> sqlite3.Connection:
|
|
67
|
+
"""Get database connection (for reads)."""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def artifact_manager(self) -> Any:
|
|
72
|
+
"""Get artifact manager."""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Cursor:
|
|
76
|
+
"""Execute SQL statement."""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
def executemany(self, sql: str, params_list: list[tuple[Any, ...]]) -> sqlite3.Cursor:
|
|
80
|
+
"""Execute SQL statement with multiple parameter sets."""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
def fetchone(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Row | None:
|
|
84
|
+
"""Execute query and fetch one row."""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
def fetchall(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
|
|
88
|
+
"""Execute query and fetch all rows."""
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
def safe_update(
|
|
92
|
+
self,
|
|
93
|
+
table: str,
|
|
94
|
+
values: dict[str, Any],
|
|
95
|
+
where: str,
|
|
96
|
+
where_params: tuple[Any, ...],
|
|
97
|
+
) -> sqlite3.Cursor:
|
|
98
|
+
"""Safely execute an UPDATE statement with dynamic columns."""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
def transaction(self) -> AbstractContextManager[sqlite3.Connection]:
|
|
102
|
+
"""Context manager for database transactions."""
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
"""Close database connection."""
|
|
107
|
+
...
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Default database path
|
|
111
|
+
DEFAULT_DB_PATH = Path.home() / ".gobby" / "gobby-hub.db"
|
|
112
|
+
|
|
113
|
+
# SQL identifier validation pattern (alphanumeric + underscore only)
|
|
114
|
+
# Used by safe_update to prevent SQL injection via column/table names
|
|
115
|
+
_SQL_IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class LocalDatabase:
|
|
119
|
+
"""
|
|
120
|
+
SQLite database manager with connection pooling.
|
|
121
|
+
|
|
122
|
+
Thread-safe connection management using thread-local storage.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, db_path: Path | str | None = None):
|
|
126
|
+
"""
|
|
127
|
+
Initialize database manager.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
db_path: Path to SQLite database file. Defaults to ~/.gobby/gobby-hub.db
|
|
131
|
+
"""
|
|
132
|
+
# SAFETY SWITCH: During tests, override with safe path from environment
|
|
133
|
+
if db_path is None and os.environ.get("GOBBY_TEST_PROTECT") == "1":
|
|
134
|
+
safe_path = os.environ.get("GOBBY_DATABASE_PATH")
|
|
135
|
+
if safe_path:
|
|
136
|
+
db_path = safe_path
|
|
137
|
+
|
|
138
|
+
self.db_path = Path(db_path) if db_path else DEFAULT_DB_PATH
|
|
139
|
+
self._local = threading.local()
|
|
140
|
+
self._artifact_manager: LocalArtifactManager | None = None
|
|
141
|
+
self._artifact_manager_lock = threading.Lock()
|
|
142
|
+
# Track all connections for proper cleanup across threads
|
|
143
|
+
self._all_connections: set[sqlite3.Connection] = set()
|
|
144
|
+
self._connections_lock = threading.Lock()
|
|
145
|
+
self._ensure_directory()
|
|
146
|
+
|
|
147
|
+
# Register atexit cleanup using weak reference to avoid preventing GC
|
|
148
|
+
# and to safely handle shutdown without __del__ lock issues
|
|
149
|
+
self._weak_self = weakref.ref(self)
|
|
150
|
+
atexit.register(self._cleanup_at_exit)
|
|
151
|
+
|
|
152
|
+
def _ensure_directory(self) -> None:
|
|
153
|
+
"""Create database directory if it doesn't exist."""
|
|
154
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
|
|
156
|
+
def _get_connection(self) -> sqlite3.Connection:
|
|
157
|
+
"""Get thread-local database connection."""
|
|
158
|
+
if not hasattr(self._local, "connection") or self._local.connection is None:
|
|
159
|
+
conn = sqlite3.connect(
|
|
160
|
+
str(self.db_path),
|
|
161
|
+
check_same_thread=False,
|
|
162
|
+
isolation_level=None, # Autocommit mode
|
|
163
|
+
)
|
|
164
|
+
conn.row_factory = sqlite3.Row
|
|
165
|
+
# Enable foreign keys
|
|
166
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
167
|
+
# Use default DELETE journal mode (more reliable than WAL for dual-write)
|
|
168
|
+
self._local.connection = conn
|
|
169
|
+
# Track for cleanup in close()
|
|
170
|
+
with self._connections_lock:
|
|
171
|
+
self._all_connections.add(conn)
|
|
172
|
+
return cast(sqlite3.Connection, self._local.connection)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def connection(self) -> sqlite3.Connection:
|
|
176
|
+
"""Get current thread's database connection."""
|
|
177
|
+
return self._get_connection()
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def artifact_manager(self) -> LocalArtifactManager:
|
|
181
|
+
"""Get lazily-initialized LocalArtifactManager instance.
|
|
182
|
+
|
|
183
|
+
The artifact manager is created on first access and reused for the
|
|
184
|
+
lifetime of this LocalDatabase instance. Uses double-checked locking
|
|
185
|
+
for thread-safe initialization.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
LocalArtifactManager instance for managing session artifacts.
|
|
189
|
+
"""
|
|
190
|
+
if self._artifact_manager is None:
|
|
191
|
+
with self._artifact_manager_lock:
|
|
192
|
+
# Double-check inside lock
|
|
193
|
+
if self._artifact_manager is None:
|
|
194
|
+
from gobby.storage.artifacts import LocalArtifactManager
|
|
195
|
+
|
|
196
|
+
self._artifact_manager = LocalArtifactManager(self)
|
|
197
|
+
return self._artifact_manager
|
|
198
|
+
|
|
199
|
+
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Cursor:
|
|
200
|
+
"""Execute SQL statement."""
|
|
201
|
+
return self.connection.execute(sql, params)
|
|
202
|
+
|
|
203
|
+
def executemany(self, sql: str, params_list: list[tuple[Any, ...]]) -> sqlite3.Cursor:
|
|
204
|
+
"""Execute SQL statement with multiple parameter sets."""
|
|
205
|
+
return self.connection.executemany(sql, params_list)
|
|
206
|
+
|
|
207
|
+
def fetchone(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Row | None:
|
|
208
|
+
"""Execute query and fetch one row."""
|
|
209
|
+
cursor = self.execute(sql, params)
|
|
210
|
+
try:
|
|
211
|
+
return cast(sqlite3.Row | None, cursor.fetchone())
|
|
212
|
+
finally:
|
|
213
|
+
cursor.close()
|
|
214
|
+
|
|
215
|
+
def fetchall(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
|
|
216
|
+
"""Execute query and fetch all rows."""
|
|
217
|
+
cursor = self.execute(sql, params)
|
|
218
|
+
try:
|
|
219
|
+
return cursor.fetchall()
|
|
220
|
+
finally:
|
|
221
|
+
cursor.close()
|
|
222
|
+
|
|
223
|
+
def safe_update(
|
|
224
|
+
self,
|
|
225
|
+
table: str,
|
|
226
|
+
values: dict[str, Any],
|
|
227
|
+
where: str,
|
|
228
|
+
where_params: tuple[Any, ...],
|
|
229
|
+
) -> sqlite3.Cursor:
|
|
230
|
+
"""
|
|
231
|
+
Safely execute an UPDATE statement with dynamic columns.
|
|
232
|
+
|
|
233
|
+
This method validates table and column names against a strict allowlist
|
|
234
|
+
pattern to prevent SQL injection, even though callers typically use
|
|
235
|
+
hardcoded strings. This is defense-in-depth.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
table: Table name (validated against identifier pattern).
|
|
239
|
+
values: Dictionary of column_name -> new_value.
|
|
240
|
+
where: WHERE clause (e.g., "id = ?"). This is NOT validated -
|
|
241
|
+
callers must use parameterized queries for values.
|
|
242
|
+
where_params: Parameters for the WHERE clause placeholders.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
sqlite3.Cursor from the executed statement.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
ValueError: If table or column names fail validation.
|
|
249
|
+
|
|
250
|
+
Example:
|
|
251
|
+
db.safe_update(
|
|
252
|
+
"sessions",
|
|
253
|
+
{"status": "closed", "updated_at": now},
|
|
254
|
+
"id = ?",
|
|
255
|
+
(session_id,)
|
|
256
|
+
)
|
|
257
|
+
"""
|
|
258
|
+
if not values:
|
|
259
|
+
# No-op: return closed cursor without executing
|
|
260
|
+
cursor = self.connection.cursor()
|
|
261
|
+
cursor.close()
|
|
262
|
+
return cursor
|
|
263
|
+
|
|
264
|
+
# Validate table name
|
|
265
|
+
if not _SQL_IDENTIFIER_PATTERN.match(table):
|
|
266
|
+
raise ValueError(f"Invalid table name: {table!r}")
|
|
267
|
+
|
|
268
|
+
# Validate column names and build SET clause
|
|
269
|
+
set_clauses: list[str] = []
|
|
270
|
+
update_params: list[Any] = []
|
|
271
|
+
|
|
272
|
+
for col, val in values.items():
|
|
273
|
+
if not _SQL_IDENTIFIER_PATTERN.match(col):
|
|
274
|
+
raise ValueError(f"Invalid column name: {col!r}")
|
|
275
|
+
set_clauses.append(f"{col} = ?")
|
|
276
|
+
update_params.append(val)
|
|
277
|
+
|
|
278
|
+
# Construct and execute query
|
|
279
|
+
# nosec B608: Table and column names are validated above against a strict alphanumeric pattern.
|
|
280
|
+
# The WHERE clause uses parameterized queries. This is safe from SQL injection.
|
|
281
|
+
sql = f"UPDATE {table} SET {', '.join(set_clauses)} WHERE {where}" # nosec B608
|
|
282
|
+
full_params = tuple(update_params) + where_params
|
|
283
|
+
|
|
284
|
+
return self.execute(sql, full_params)
|
|
285
|
+
|
|
286
|
+
@contextmanager
|
|
287
|
+
def transaction(self) -> Iterator[sqlite3.Connection]:
|
|
288
|
+
"""
|
|
289
|
+
Context manager for database transactions.
|
|
290
|
+
|
|
291
|
+
Usage:
|
|
292
|
+
with db.transaction() as conn:
|
|
293
|
+
conn.execute("INSERT ...")
|
|
294
|
+
conn.execute("UPDATE ...")
|
|
295
|
+
"""
|
|
296
|
+
conn = self.connection
|
|
297
|
+
conn.execute("BEGIN")
|
|
298
|
+
try:
|
|
299
|
+
yield conn
|
|
300
|
+
conn.execute("COMMIT")
|
|
301
|
+
except Exception:
|
|
302
|
+
conn.execute("ROLLBACK")
|
|
303
|
+
raise
|
|
304
|
+
|
|
305
|
+
def close(self) -> None:
|
|
306
|
+
"""Close all database connections and clean up managers.
|
|
307
|
+
|
|
308
|
+
Can be called explicitly or via context manager. For automatic cleanup
|
|
309
|
+
at interpreter shutdown, atexit handler is used instead of __del__ to
|
|
310
|
+
avoid lock acquisition issues during GC.
|
|
311
|
+
"""
|
|
312
|
+
# Clean up artifact manager
|
|
313
|
+
self._artifact_manager = None
|
|
314
|
+
|
|
315
|
+
# Close all connections from all threads
|
|
316
|
+
with self._connections_lock:
|
|
317
|
+
for conn in self._all_connections:
|
|
318
|
+
try:
|
|
319
|
+
conn.close()
|
|
320
|
+
except Exception:
|
|
321
|
+
pass # nosec B110 - connection may already be closed
|
|
322
|
+
self._all_connections.clear()
|
|
323
|
+
|
|
324
|
+
# Clear thread-local reference
|
|
325
|
+
if hasattr(self._local, "connection"):
|
|
326
|
+
self._local.connection = None
|
|
327
|
+
|
|
328
|
+
def _cleanup_at_exit(self) -> None:
|
|
329
|
+
"""Atexit handler for safe cleanup during interpreter shutdown.
|
|
330
|
+
|
|
331
|
+
Uses try/except to safely handle any errors that may occur during
|
|
332
|
+
shutdown when modules may already be partially unloaded.
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
self.close()
|
|
336
|
+
except Exception:
|
|
337
|
+
pass # nosec B110 - ignore errors during shutdown
|
|
338
|
+
|
|
339
|
+
def __del__(self) -> None:
|
|
340
|
+
"""Clean up connections when object is garbage collected.
|
|
341
|
+
|
|
342
|
+
Note: Most cleanup should happen via atexit or explicit close() calls.
|
|
343
|
+
This is a fallback that unregisters the atexit handler to avoid double-close.
|
|
344
|
+
"""
|
|
345
|
+
try:
|
|
346
|
+
# Unregister atexit handler since we're being collected
|
|
347
|
+
atexit.unregister(self._cleanup_at_exit)
|
|
348
|
+
except Exception:
|
|
349
|
+
pass # nosec B110 - ignore errors during gc
|
|
350
|
+
|
|
351
|
+
def __enter__(self) -> LocalDatabase:
|
|
352
|
+
"""Enter context manager."""
|
|
353
|
+
return self
|
|
354
|
+
|
|
355
|
+
def __exit__(self, exc_type: type | None, exc_val: Exception | None, exc_tb: object) -> None:
|
|
356
|
+
"""Exit context manager, closing connections."""
|
|
357
|
+
self.close()
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Inter-session messaging for agent coordination.
|
|
2
|
+
|
|
3
|
+
This module provides storage and management of messages sent between sessions,
|
|
4
|
+
enabling parent-child session communication and agent coordination.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from sqlite3 import Row
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from gobby.storage.database import LocalDatabase
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class InterSessionMessage:
|
|
21
|
+
"""A message sent between sessions.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
id: Unique message identifier
|
|
25
|
+
from_session: ID of the sending session
|
|
26
|
+
to_session: ID of the receiving session
|
|
27
|
+
content: Message content
|
|
28
|
+
priority: Message priority (e.g., "normal", "urgent")
|
|
29
|
+
sent_at: Timestamp when message was sent
|
|
30
|
+
read_at: Timestamp when message was read (None if unread)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
id: str
|
|
34
|
+
from_session: str
|
|
35
|
+
to_session: str
|
|
36
|
+
content: str
|
|
37
|
+
priority: str
|
|
38
|
+
sent_at: str
|
|
39
|
+
read_at: str | None
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_row(cls, row: Row) -> InterSessionMessage:
|
|
43
|
+
"""Create instance from database row.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
row: SQLite row with message data
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
InterSessionMessage instance
|
|
50
|
+
"""
|
|
51
|
+
return cls(
|
|
52
|
+
id=row["id"],
|
|
53
|
+
from_session=row["from_session"],
|
|
54
|
+
to_session=row["to_session"],
|
|
55
|
+
content=row["content"],
|
|
56
|
+
priority=row["priority"],
|
|
57
|
+
sent_at=row["sent_at"],
|
|
58
|
+
read_at=row["read_at"],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, Any]:
|
|
62
|
+
"""Convert to dictionary.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Dictionary with all message fields
|
|
66
|
+
"""
|
|
67
|
+
return {
|
|
68
|
+
"id": self.id,
|
|
69
|
+
"from_session": self.from_session,
|
|
70
|
+
"to_session": self.to_session,
|
|
71
|
+
"content": self.content,
|
|
72
|
+
"priority": self.priority,
|
|
73
|
+
"sent_at": self.sent_at,
|
|
74
|
+
"read_at": self.read_at,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class InterSessionMessageManager:
|
|
79
|
+
"""Manages inter-session messages.
|
|
80
|
+
|
|
81
|
+
Provides CRUD operations for messages sent between sessions,
|
|
82
|
+
enabling agent coordination and parent-child communication.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, db: LocalDatabase) -> None:
|
|
86
|
+
"""Initialize the message manager.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
db: LocalDatabase instance for persistence
|
|
90
|
+
"""
|
|
91
|
+
self.db = db
|
|
92
|
+
|
|
93
|
+
def create_message(
|
|
94
|
+
self,
|
|
95
|
+
from_session: str,
|
|
96
|
+
to_session: str,
|
|
97
|
+
content: str,
|
|
98
|
+
priority: str = "normal",
|
|
99
|
+
) -> InterSessionMessage:
|
|
100
|
+
"""Create and persist a new message.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
from_session: ID of the sending session
|
|
104
|
+
to_session: ID of the receiving session
|
|
105
|
+
content: Message content
|
|
106
|
+
priority: Message priority (default: "normal")
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The created InterSessionMessage
|
|
110
|
+
"""
|
|
111
|
+
message_id = str(uuid.uuid4())
|
|
112
|
+
sent_at = datetime.now(UTC).isoformat()
|
|
113
|
+
|
|
114
|
+
self.db.execute(
|
|
115
|
+
"""
|
|
116
|
+
INSERT INTO inter_session_messages
|
|
117
|
+
(id, from_session, to_session, content, priority, sent_at, read_at)
|
|
118
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL)
|
|
119
|
+
""",
|
|
120
|
+
(message_id, from_session, to_session, content, priority, sent_at),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return InterSessionMessage(
|
|
124
|
+
id=message_id,
|
|
125
|
+
from_session=from_session,
|
|
126
|
+
to_session=to_session,
|
|
127
|
+
content=content,
|
|
128
|
+
priority=priority,
|
|
129
|
+
sent_at=sent_at,
|
|
130
|
+
read_at=None,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def get_message(self, message_id: str) -> InterSessionMessage | None:
|
|
134
|
+
"""Get a message by ID.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
message_id: The message ID to retrieve
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The InterSessionMessage if found, None otherwise
|
|
141
|
+
"""
|
|
142
|
+
row = self.db.fetchone(
|
|
143
|
+
"SELECT * FROM inter_session_messages WHERE id = ?",
|
|
144
|
+
(message_id,),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if row:
|
|
148
|
+
return InterSessionMessage.from_row(row)
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def get_messages(self, to_session: str, unread_only: bool = False) -> list[InterSessionMessage]:
|
|
152
|
+
"""Get messages for a recipient session.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
to_session: ID of the receiving session
|
|
156
|
+
unread_only: If True, only return unread messages
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
List of InterSessionMessage instances
|
|
160
|
+
"""
|
|
161
|
+
if unread_only:
|
|
162
|
+
query = """
|
|
163
|
+
SELECT * FROM inter_session_messages
|
|
164
|
+
WHERE to_session = ? AND read_at IS NULL
|
|
165
|
+
"""
|
|
166
|
+
else:
|
|
167
|
+
query = "SELECT * FROM inter_session_messages WHERE to_session = ?"
|
|
168
|
+
|
|
169
|
+
rows = self.db.fetchall(query, (to_session,))
|
|
170
|
+
return [InterSessionMessage.from_row(row) for row in rows]
|
|
171
|
+
|
|
172
|
+
def mark_read(self, message_id: str) -> InterSessionMessage:
|
|
173
|
+
"""Mark a message as read.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
message_id: The message ID to mark as read
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
The updated InterSessionMessage
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
ValueError: If message not found
|
|
183
|
+
"""
|
|
184
|
+
read_at = datetime.now(UTC).isoformat()
|
|
185
|
+
|
|
186
|
+
self.db.execute(
|
|
187
|
+
"UPDATE inter_session_messages SET read_at = ? WHERE id = ?",
|
|
188
|
+
(read_at, message_id),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
message = self.get_message(message_id)
|
|
192
|
+
if not message:
|
|
193
|
+
raise ValueError(f"Message not found: {message_id}")
|
|
194
|
+
return message
|