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,817 @@
|
|
|
1
|
+
"""Local session storage manager."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from gobby.storage.database import DatabaseProtocol
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Session:
|
|
20
|
+
"""Session data model."""
|
|
21
|
+
|
|
22
|
+
id: str
|
|
23
|
+
external_id: str
|
|
24
|
+
machine_id: str
|
|
25
|
+
source: str
|
|
26
|
+
project_id: str # Required - sessions must belong to a project
|
|
27
|
+
title: str | None
|
|
28
|
+
status: str
|
|
29
|
+
jsonl_path: str | None
|
|
30
|
+
summary_path: str | None
|
|
31
|
+
summary_markdown: str | None
|
|
32
|
+
compact_markdown: str | None # Handoff context for compaction
|
|
33
|
+
git_branch: str | None
|
|
34
|
+
parent_session_id: str | None
|
|
35
|
+
created_at: str
|
|
36
|
+
updated_at: str
|
|
37
|
+
agent_depth: int = 0 # 0 = human-initiated, 1+ = agent-spawned
|
|
38
|
+
spawned_by_agent_id: str | None = None # ID of agent that spawned this session
|
|
39
|
+
# Terminal pickup metadata fields
|
|
40
|
+
workflow_name: str | None = None # Workflow to activate on terminal pickup
|
|
41
|
+
agent_run_id: str | None = None # Link back to agent run record
|
|
42
|
+
context_injected: bool = False # Whether context was injected into prompt
|
|
43
|
+
original_prompt: str | None = None # Original prompt for terminal mode
|
|
44
|
+
# Usage tracking fields
|
|
45
|
+
usage_input_tokens: int = 0
|
|
46
|
+
usage_output_tokens: int = 0
|
|
47
|
+
usage_cache_creation_tokens: int = 0
|
|
48
|
+
usage_cache_read_tokens: int = 0
|
|
49
|
+
usage_total_cost_usd: float = 0.0
|
|
50
|
+
# Terminal context (JSON blob with tty, parent_pid, term_session_id, etc.)
|
|
51
|
+
terminal_context: dict[str, Any] | None = None
|
|
52
|
+
# Global sequence number
|
|
53
|
+
seq_num: int | None = None
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_row(cls, row: Any) -> Session:
|
|
57
|
+
"""Create Session from database row."""
|
|
58
|
+
return cls(
|
|
59
|
+
id=row["id"],
|
|
60
|
+
external_id=row["external_id"],
|
|
61
|
+
machine_id=row["machine_id"],
|
|
62
|
+
source=row["source"],
|
|
63
|
+
project_id=row["project_id"],
|
|
64
|
+
title=row["title"],
|
|
65
|
+
status=row["status"],
|
|
66
|
+
jsonl_path=row["jsonl_path"],
|
|
67
|
+
summary_path=row["summary_path"],
|
|
68
|
+
summary_markdown=row["summary_markdown"],
|
|
69
|
+
compact_markdown=row["compact_markdown"],
|
|
70
|
+
git_branch=row["git_branch"],
|
|
71
|
+
parent_session_id=row["parent_session_id"],
|
|
72
|
+
created_at=row["created_at"],
|
|
73
|
+
updated_at=row["updated_at"],
|
|
74
|
+
agent_depth=row["agent_depth"] or 0,
|
|
75
|
+
spawned_by_agent_id=row["spawned_by_agent_id"],
|
|
76
|
+
workflow_name=row["workflow_name"],
|
|
77
|
+
agent_run_id=row["agent_run_id"],
|
|
78
|
+
context_injected=bool(row["context_injected"]),
|
|
79
|
+
original_prompt=row["original_prompt"],
|
|
80
|
+
usage_input_tokens=row["usage_input_tokens"] or 0,
|
|
81
|
+
usage_output_tokens=row["usage_output_tokens"] or 0,
|
|
82
|
+
usage_cache_creation_tokens=row["usage_cache_creation_tokens"] or 0,
|
|
83
|
+
usage_cache_read_tokens=row["usage_cache_read_tokens"] or 0,
|
|
84
|
+
usage_total_cost_usd=row["usage_total_cost_usd"] or 0.0,
|
|
85
|
+
terminal_context=cls._parse_terminal_context(row["terminal_context"]),
|
|
86
|
+
seq_num=row["seq_num"] if "seq_num" in row.keys() else None,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def _parse_terminal_context(cls, raw: str | None) -> dict[str, Any] | None:
|
|
91
|
+
"""Parse terminal_context JSON, returning None on malformed data.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
raw: Raw JSON string or None
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Parsed dict or None if parsing fails or input is None
|
|
98
|
+
"""
|
|
99
|
+
if not raw:
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
result: dict[str, Any] = json.loads(raw)
|
|
103
|
+
return result
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
logger.warning("Failed to parse terminal_context JSON, returning None")
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict[str, Any]:
|
|
109
|
+
"""Convert to dictionary."""
|
|
110
|
+
return {
|
|
111
|
+
"ref": f"#{self.seq_num}" if self.seq_num else self.id[:8],
|
|
112
|
+
"external_id": self.external_id,
|
|
113
|
+
"machine_id": self.machine_id,
|
|
114
|
+
"source": self.source,
|
|
115
|
+
"project_id": self.project_id,
|
|
116
|
+
"title": self.title,
|
|
117
|
+
"status": self.status,
|
|
118
|
+
"jsonl_path": self.jsonl_path,
|
|
119
|
+
"summary_path": self.summary_path,
|
|
120
|
+
"summary_markdown": self.summary_markdown,
|
|
121
|
+
"compact_markdown": self.compact_markdown,
|
|
122
|
+
"git_branch": self.git_branch,
|
|
123
|
+
"parent_session_id": self.parent_session_id,
|
|
124
|
+
"agent_depth": self.agent_depth,
|
|
125
|
+
"spawned_by_agent_id": self.spawned_by_agent_id,
|
|
126
|
+
"workflow_name": self.workflow_name,
|
|
127
|
+
"agent_run_id": self.agent_run_id,
|
|
128
|
+
"context_injected": self.context_injected,
|
|
129
|
+
"original_prompt": self.original_prompt,
|
|
130
|
+
"usage_input_tokens": self.usage_input_tokens,
|
|
131
|
+
"usage_output_tokens": self.usage_output_tokens,
|
|
132
|
+
"usage_cache_creation_tokens": self.usage_cache_creation_tokens,
|
|
133
|
+
"usage_cache_read_tokens": self.usage_cache_read_tokens,
|
|
134
|
+
"usage_total_cost_usd": self.usage_total_cost_usd,
|
|
135
|
+
"terminal_context": self.terminal_context,
|
|
136
|
+
"created_at": self.created_at,
|
|
137
|
+
"updated_at": self.updated_at,
|
|
138
|
+
"seq_num": self.seq_num,
|
|
139
|
+
"id": self.id, # UUID at end for backwards compat
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class LocalSessionManager:
|
|
144
|
+
"""Manager for local session storage."""
|
|
145
|
+
|
|
146
|
+
def __init__(self, db: DatabaseProtocol):
|
|
147
|
+
"""Initialize with database connection."""
|
|
148
|
+
self.db = db
|
|
149
|
+
|
|
150
|
+
def register(
|
|
151
|
+
self,
|
|
152
|
+
external_id: str,
|
|
153
|
+
machine_id: str,
|
|
154
|
+
source: str,
|
|
155
|
+
project_id: str,
|
|
156
|
+
title: str | None = None,
|
|
157
|
+
jsonl_path: str | None = None,
|
|
158
|
+
git_branch: str | None = None,
|
|
159
|
+
parent_session_id: str | None = None,
|
|
160
|
+
agent_depth: int = 0,
|
|
161
|
+
spawned_by_agent_id: str | None = None,
|
|
162
|
+
terminal_context: dict[str, Any] | None = None,
|
|
163
|
+
) -> Session:
|
|
164
|
+
"""
|
|
165
|
+
Register a new session or return existing one.
|
|
166
|
+
|
|
167
|
+
Looks up by (external_id, machine_id, project_id, source) to find if this
|
|
168
|
+
exact session already exists (e.g., daemon restarted mid-session). If found,
|
|
169
|
+
returns the existing session. Otherwise creates a new one.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
external_id: External session identifier (e.g., Claude Code's session ID)
|
|
173
|
+
machine_id: Machine identifier
|
|
174
|
+
source: CLI source (claude_code, codex, gemini)
|
|
175
|
+
project_id: Project ID (required - sessions must belong to a project)
|
|
176
|
+
title: Optional session title
|
|
177
|
+
jsonl_path: Path to transcript file
|
|
178
|
+
git_branch: Git branch name
|
|
179
|
+
parent_session_id: Parent session for handoff
|
|
180
|
+
agent_depth: Nesting depth (0 = human-initiated, 1+ = agent-spawned)
|
|
181
|
+
spawned_by_agent_id: ID of the agent that spawned this session
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Session instance
|
|
185
|
+
"""
|
|
186
|
+
now = datetime.now(UTC).isoformat()
|
|
187
|
+
|
|
188
|
+
# Check if this exact session already exists (daemon restart case)
|
|
189
|
+
existing = self.find_by_external_id(external_id, machine_id, project_id, source)
|
|
190
|
+
if existing:
|
|
191
|
+
# Session exists - update metadata and return it
|
|
192
|
+
self.db.execute(
|
|
193
|
+
"""
|
|
194
|
+
UPDATE sessions SET
|
|
195
|
+
title = COALESCE(?, title),
|
|
196
|
+
jsonl_path = COALESCE(?, jsonl_path),
|
|
197
|
+
git_branch = COALESCE(?, git_branch),
|
|
198
|
+
parent_session_id = COALESCE(?, parent_session_id),
|
|
199
|
+
status = 'active',
|
|
200
|
+
updated_at = ?
|
|
201
|
+
WHERE id = ?
|
|
202
|
+
""",
|
|
203
|
+
(
|
|
204
|
+
title,
|
|
205
|
+
jsonl_path,
|
|
206
|
+
git_branch,
|
|
207
|
+
parent_session_id,
|
|
208
|
+
now,
|
|
209
|
+
existing.id,
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
logger.debug(f"Reusing existing session {existing.id} for external_id={external_id}")
|
|
213
|
+
session = self.get(existing.id)
|
|
214
|
+
if session is None:
|
|
215
|
+
raise RuntimeError(f"Session {existing.id} disappeared during update")
|
|
216
|
+
return session
|
|
217
|
+
|
|
218
|
+
# New session - create it
|
|
219
|
+
session_id = str(uuid.uuid4())
|
|
220
|
+
|
|
221
|
+
# Retry loop for seq_num assignment
|
|
222
|
+
max_retries = 3
|
|
223
|
+
for attempt in range(max_retries):
|
|
224
|
+
try:
|
|
225
|
+
# Get next seq_num (global)
|
|
226
|
+
max_seq_row = self.db.fetchone("SELECT MAX(seq_num) as max_seq FROM sessions")
|
|
227
|
+
next_seq_num = ((max_seq_row["max_seq"] if max_seq_row else None) or 0) + 1
|
|
228
|
+
|
|
229
|
+
self.db.execute(
|
|
230
|
+
"""
|
|
231
|
+
INSERT INTO sessions (
|
|
232
|
+
id, external_id, machine_id, source, project_id, title,
|
|
233
|
+
jsonl_path, git_branch, parent_session_id,
|
|
234
|
+
agent_depth, spawned_by_agent_id, terminal_context,
|
|
235
|
+
status, created_at, updated_at, seq_num
|
|
236
|
+
)
|
|
237
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
|
|
238
|
+
""",
|
|
239
|
+
(
|
|
240
|
+
session_id,
|
|
241
|
+
external_id,
|
|
242
|
+
machine_id,
|
|
243
|
+
source,
|
|
244
|
+
project_id,
|
|
245
|
+
title,
|
|
246
|
+
jsonl_path,
|
|
247
|
+
git_branch,
|
|
248
|
+
parent_session_id,
|
|
249
|
+
agent_depth,
|
|
250
|
+
spawned_by_agent_id,
|
|
251
|
+
json.dumps(terminal_context) if terminal_context else None,
|
|
252
|
+
now,
|
|
253
|
+
now,
|
|
254
|
+
next_seq_num,
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
break
|
|
258
|
+
except Exception as e:
|
|
259
|
+
# Check for unique constraint violation on seq_num
|
|
260
|
+
if (
|
|
261
|
+
"UNIQUE constraint failed: sessions.seq_num" in str(e)
|
|
262
|
+
and attempt < max_retries - 1
|
|
263
|
+
):
|
|
264
|
+
logger.warning(f"Seq_num collision ({next_seq_num}), retrying...")
|
|
265
|
+
continue
|
|
266
|
+
raise
|
|
267
|
+
|
|
268
|
+
logger.debug(f"Created new session {session_id} for external_id={external_id}")
|
|
269
|
+
|
|
270
|
+
session = self.get(session_id)
|
|
271
|
+
if session is None:
|
|
272
|
+
raise RuntimeError(f"Session {session_id} not found after creation")
|
|
273
|
+
return session
|
|
274
|
+
|
|
275
|
+
def get(self, session_id: str) -> Session | None:
|
|
276
|
+
"""Get session by ID."""
|
|
277
|
+
row = self.db.fetchone("SELECT * FROM sessions WHERE id = ?", (session_id,))
|
|
278
|
+
return Session.from_row(row) if row else None
|
|
279
|
+
|
|
280
|
+
def resolve_session_reference(self, ref: str) -> str:
|
|
281
|
+
"""
|
|
282
|
+
Resolve a session reference to a UUID.
|
|
283
|
+
|
|
284
|
+
Supports:
|
|
285
|
+
- #N: Global Sequence Number (e.g., #1)
|
|
286
|
+
- N: Integer string treated as #N (e.g., "1")
|
|
287
|
+
- UUID: Full UUID
|
|
288
|
+
- Prefix: UUID prefix (must be unambiguous)
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
ref: Session reference string
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Resolved Session UUID
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
ValueError: If not found or ambiguous
|
|
298
|
+
"""
|
|
299
|
+
if not ref:
|
|
300
|
+
raise ValueError("Empty session reference")
|
|
301
|
+
|
|
302
|
+
# #N or N format: seq_num lookup
|
|
303
|
+
seq_num_ref = ref
|
|
304
|
+
if ref.startswith("#"):
|
|
305
|
+
seq_num_ref = ref[1:]
|
|
306
|
+
|
|
307
|
+
if seq_num_ref.isdigit():
|
|
308
|
+
seq_num = int(seq_num_ref)
|
|
309
|
+
row = self.db.fetchone("SELECT id FROM sessions WHERE seq_num = ?", (seq_num,))
|
|
310
|
+
if not row:
|
|
311
|
+
raise ValueError(f"Session #{seq_num} not found")
|
|
312
|
+
return str(row["id"])
|
|
313
|
+
|
|
314
|
+
# Full UUID check
|
|
315
|
+
try:
|
|
316
|
+
uuid_obj = uuid.UUID(ref)
|
|
317
|
+
# Verify the session exists in the database
|
|
318
|
+
row = self.db.fetchone("SELECT id FROM sessions WHERE id = ?", (str(uuid_obj),))
|
|
319
|
+
if not row:
|
|
320
|
+
raise ValueError(f"Session '{ref}' not found")
|
|
321
|
+
return str(uuid_obj)
|
|
322
|
+
except ValueError:
|
|
323
|
+
pass # Not a valid UUID, try prefix
|
|
324
|
+
|
|
325
|
+
# Prefix matching
|
|
326
|
+
rows = self.db.fetchall("SELECT id FROM sessions WHERE id LIKE ? LIMIT 5", (f"{ref}%",))
|
|
327
|
+
if not rows:
|
|
328
|
+
raise ValueError(f"Session '{ref}' not found")
|
|
329
|
+
if len(rows) > 1:
|
|
330
|
+
matches = [str(r["id"]) for r in rows]
|
|
331
|
+
raise ValueError(f"Ambiguous session '{ref}' matches: {', '.join(matches[:3])}...")
|
|
332
|
+
|
|
333
|
+
return str(rows[0]["id"])
|
|
334
|
+
|
|
335
|
+
def find_by_external_id(
|
|
336
|
+
self,
|
|
337
|
+
external_id: str,
|
|
338
|
+
machine_id: str,
|
|
339
|
+
project_id: str,
|
|
340
|
+
source: str,
|
|
341
|
+
) -> Session | None:
|
|
342
|
+
"""
|
|
343
|
+
Find session by external_id, machine_id, project_id, and source.
|
|
344
|
+
|
|
345
|
+
This is the primary lookup for reconnecting to an existing session
|
|
346
|
+
after daemon restart. The external_id (e.g., Claude Code's session ID)
|
|
347
|
+
is stable within a session.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
external_id: External session identifier
|
|
351
|
+
machine_id: Machine identifier
|
|
352
|
+
project_id: Project identifier
|
|
353
|
+
source: CLI source (claude, gemini, codex)
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Session if found, None otherwise.
|
|
357
|
+
"""
|
|
358
|
+
row = self.db.fetchone(
|
|
359
|
+
"""
|
|
360
|
+
SELECT * FROM sessions
|
|
361
|
+
WHERE external_id = ? AND machine_id = ? AND project_id = ? AND source = ?
|
|
362
|
+
""",
|
|
363
|
+
(external_id, machine_id, project_id, source),
|
|
364
|
+
)
|
|
365
|
+
return Session.from_row(row) if row else None
|
|
366
|
+
|
|
367
|
+
def find_parent(
|
|
368
|
+
self,
|
|
369
|
+
machine_id: str,
|
|
370
|
+
project_id: str,
|
|
371
|
+
source: str | None = None,
|
|
372
|
+
status: str = "handoff_ready",
|
|
373
|
+
) -> Session | None:
|
|
374
|
+
"""
|
|
375
|
+
Find most recent parent session with specific status.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
machine_id: Machine identifier
|
|
379
|
+
project_id: Project identifier
|
|
380
|
+
source: Optional source identifier to filter by
|
|
381
|
+
status: Status to filter by (default: handoff_ready)
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
Session object or None
|
|
385
|
+
"""
|
|
386
|
+
query = "SELECT * FROM sessions WHERE machine_id = ? AND status = ? AND project_id = ?"
|
|
387
|
+
params: list[Any] = [machine_id, status, project_id]
|
|
388
|
+
|
|
389
|
+
if source:
|
|
390
|
+
query += " AND source = ?"
|
|
391
|
+
params.append(source)
|
|
392
|
+
|
|
393
|
+
query += " ORDER BY updated_at DESC LIMIT 1"
|
|
394
|
+
|
|
395
|
+
row = self.db.fetchone(query, tuple(params))
|
|
396
|
+
return Session.from_row(row) if row else None
|
|
397
|
+
|
|
398
|
+
def find_children(self, parent_session_id: str) -> list[Session]:
|
|
399
|
+
"""
|
|
400
|
+
Find all child sessions of a parent.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
parent_session_id: The parent session ID.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
List of child Session objects.
|
|
407
|
+
"""
|
|
408
|
+
rows = self.db.fetchall(
|
|
409
|
+
"""
|
|
410
|
+
SELECT * FROM sessions
|
|
411
|
+
WHERE parent_session_id = ?
|
|
412
|
+
ORDER BY created_at ASC
|
|
413
|
+
""",
|
|
414
|
+
(parent_session_id,),
|
|
415
|
+
)
|
|
416
|
+
return [Session.from_row(row) for row in rows]
|
|
417
|
+
|
|
418
|
+
def update_status(self, session_id: str, status: str) -> Session | None:
|
|
419
|
+
"""Update session status."""
|
|
420
|
+
now = datetime.now(UTC).isoformat()
|
|
421
|
+
self.db.execute(
|
|
422
|
+
"UPDATE sessions SET status = ?, updated_at = ? WHERE id = ?",
|
|
423
|
+
(status, now, session_id),
|
|
424
|
+
)
|
|
425
|
+
return self.get(session_id)
|
|
426
|
+
|
|
427
|
+
def update_title(self, session_id: str, title: str) -> Session | None:
|
|
428
|
+
"""Update session title."""
|
|
429
|
+
now = datetime.now(UTC).isoformat()
|
|
430
|
+
self.db.execute(
|
|
431
|
+
"UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?",
|
|
432
|
+
(title, now, session_id),
|
|
433
|
+
)
|
|
434
|
+
return self.get(session_id)
|
|
435
|
+
|
|
436
|
+
def update_summary(
|
|
437
|
+
self,
|
|
438
|
+
session_id: str,
|
|
439
|
+
summary_path: str | None = None,
|
|
440
|
+
summary_markdown: str | None = None,
|
|
441
|
+
) -> Session | None:
|
|
442
|
+
"""Update session summary."""
|
|
443
|
+
now = datetime.now(UTC).isoformat()
|
|
444
|
+
self.db.execute(
|
|
445
|
+
"""
|
|
446
|
+
UPDATE sessions
|
|
447
|
+
SET summary_path = COALESCE(?, summary_path),
|
|
448
|
+
summary_markdown = COALESCE(?, summary_markdown),
|
|
449
|
+
updated_at = ?
|
|
450
|
+
WHERE id = ?
|
|
451
|
+
""",
|
|
452
|
+
(summary_path, summary_markdown, now, session_id),
|
|
453
|
+
)
|
|
454
|
+
return self.get(session_id)
|
|
455
|
+
|
|
456
|
+
def update_compact_markdown(self, session_id: str, compact_markdown: str) -> Session | None:
|
|
457
|
+
"""Update session compact handoff markdown."""
|
|
458
|
+
now = datetime.now(UTC).isoformat()
|
|
459
|
+
self.db.execute(
|
|
460
|
+
"""
|
|
461
|
+
UPDATE sessions
|
|
462
|
+
SET compact_markdown = ?,
|
|
463
|
+
updated_at = ?
|
|
464
|
+
WHERE id = ?
|
|
465
|
+
""",
|
|
466
|
+
(compact_markdown, now, session_id),
|
|
467
|
+
)
|
|
468
|
+
return self.get(session_id)
|
|
469
|
+
|
|
470
|
+
def update_parent_session_id(self, session_id: str, parent_session_id: str) -> Session | None:
|
|
471
|
+
"""Update parent session ID."""
|
|
472
|
+
now = datetime.now(UTC).isoformat()
|
|
473
|
+
self.db.execute(
|
|
474
|
+
"UPDATE sessions SET parent_session_id = ?, updated_at = ? WHERE id = ?",
|
|
475
|
+
(parent_session_id, now, session_id),
|
|
476
|
+
)
|
|
477
|
+
return self.get(session_id)
|
|
478
|
+
|
|
479
|
+
def update(
|
|
480
|
+
self,
|
|
481
|
+
session_id: str,
|
|
482
|
+
*,
|
|
483
|
+
external_id: str | None = None,
|
|
484
|
+
jsonl_path: str | None = None,
|
|
485
|
+
status: str | None = None,
|
|
486
|
+
title: str | None = None,
|
|
487
|
+
git_branch: str | None = None,
|
|
488
|
+
) -> Session | None:
|
|
489
|
+
"""
|
|
490
|
+
Update multiple session fields at once.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
session_id: Session ID to update
|
|
494
|
+
external_id: New external ID (optional)
|
|
495
|
+
jsonl_path: New transcript path (optional)
|
|
496
|
+
status: New status (optional)
|
|
497
|
+
title: New title (optional)
|
|
498
|
+
git_branch: New git branch (optional)
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
Updated Session or None if not found
|
|
502
|
+
"""
|
|
503
|
+
values: dict[str, Any] = {}
|
|
504
|
+
|
|
505
|
+
if external_id is not None:
|
|
506
|
+
values["external_id"] = external_id
|
|
507
|
+
if jsonl_path is not None:
|
|
508
|
+
values["jsonl_path"] = jsonl_path
|
|
509
|
+
if status is not None:
|
|
510
|
+
values["status"] = status
|
|
511
|
+
if title is not None:
|
|
512
|
+
values["title"] = title
|
|
513
|
+
if git_branch is not None:
|
|
514
|
+
values["git_branch"] = git_branch
|
|
515
|
+
|
|
516
|
+
if not values:
|
|
517
|
+
return self.get(session_id)
|
|
518
|
+
|
|
519
|
+
values["updated_at"] = datetime.now(UTC).isoformat()
|
|
520
|
+
|
|
521
|
+
self.db.safe_update("sessions", values, "id = ?", (session_id,))
|
|
522
|
+
return self.get(session_id)
|
|
523
|
+
|
|
524
|
+
def list(
|
|
525
|
+
self,
|
|
526
|
+
project_id: str | None = None,
|
|
527
|
+
status: str | None = None,
|
|
528
|
+
source: str | None = None,
|
|
529
|
+
limit: int = 100,
|
|
530
|
+
) -> list[Session]:
|
|
531
|
+
"""
|
|
532
|
+
List sessions with optional filters.
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
project_id: Filter by project
|
|
536
|
+
status: Filter by status
|
|
537
|
+
source: Filter by CLI source
|
|
538
|
+
limit: Maximum number of results
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
List of Session instances
|
|
542
|
+
"""
|
|
543
|
+
conditions = []
|
|
544
|
+
params: list[Any] = []
|
|
545
|
+
|
|
546
|
+
if project_id:
|
|
547
|
+
conditions.append("project_id = ?")
|
|
548
|
+
params.append(project_id)
|
|
549
|
+
if status:
|
|
550
|
+
conditions.append("status = ?")
|
|
551
|
+
params.append(status)
|
|
552
|
+
if source:
|
|
553
|
+
conditions.append("source = ?")
|
|
554
|
+
params.append(source)
|
|
555
|
+
|
|
556
|
+
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
|
557
|
+
params.append(limit)
|
|
558
|
+
|
|
559
|
+
# nosec B608: where_clause built from hardcoded condition strings, values parameterized
|
|
560
|
+
rows = self.db.fetchall(
|
|
561
|
+
f"""
|
|
562
|
+
SELECT * FROM sessions
|
|
563
|
+
WHERE {where_clause}
|
|
564
|
+
ORDER BY updated_at DESC
|
|
565
|
+
LIMIT ?
|
|
566
|
+
""", # nosec B608
|
|
567
|
+
tuple(params),
|
|
568
|
+
)
|
|
569
|
+
return [Session.from_row(row) for row in rows]
|
|
570
|
+
|
|
571
|
+
def count(
|
|
572
|
+
self,
|
|
573
|
+
project_id: str | None = None,
|
|
574
|
+
status: str | None = None,
|
|
575
|
+
source: str | None = None,
|
|
576
|
+
) -> int:
|
|
577
|
+
"""
|
|
578
|
+
Count sessions with optional filters.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
project_id: Filter by project
|
|
582
|
+
status: Filter by status
|
|
583
|
+
source: Filter by CLI source
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
Count of matching sessions
|
|
587
|
+
"""
|
|
588
|
+
conditions = []
|
|
589
|
+
params: list[Any] = []
|
|
590
|
+
|
|
591
|
+
if project_id:
|
|
592
|
+
conditions.append("project_id = ?")
|
|
593
|
+
params.append(project_id)
|
|
594
|
+
if status:
|
|
595
|
+
conditions.append("status = ?")
|
|
596
|
+
params.append(status)
|
|
597
|
+
if source:
|
|
598
|
+
conditions.append("source = ?")
|
|
599
|
+
params.append(source)
|
|
600
|
+
|
|
601
|
+
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
|
602
|
+
|
|
603
|
+
# nosec B608: where_clause built from hardcoded condition strings, values parameterized
|
|
604
|
+
result = self.db.fetchone(
|
|
605
|
+
f"SELECT COUNT(*) as count FROM sessions WHERE {where_clause}", # nosec B608
|
|
606
|
+
tuple(params),
|
|
607
|
+
)
|
|
608
|
+
return result["count"] if result else 0
|
|
609
|
+
|
|
610
|
+
def count_by_status(self) -> dict[str, int]:
|
|
611
|
+
"""
|
|
612
|
+
Count sessions grouped by status.
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
Dictionary mapping status to count
|
|
616
|
+
"""
|
|
617
|
+
rows = self.db.fetchall("SELECT status, COUNT(*) as count FROM sessions GROUP BY status")
|
|
618
|
+
return {row["status"]: row["count"] for row in rows}
|
|
619
|
+
|
|
620
|
+
def delete(self, session_id: str) -> bool:
|
|
621
|
+
"""Delete session by ID."""
|
|
622
|
+
cursor = self.db.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
|
623
|
+
return bool(cursor.rowcount and cursor.rowcount > 0)
|
|
624
|
+
|
|
625
|
+
def expire_stale_sessions(self, timeout_hours: int = 24) -> int:
|
|
626
|
+
"""
|
|
627
|
+
Mark sessions as expired if they've been inactive for too long.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
timeout_hours: Hours of inactivity before expiring
|
|
631
|
+
|
|
632
|
+
Returns:
|
|
633
|
+
Number of sessions expired
|
|
634
|
+
"""
|
|
635
|
+
now = datetime.now(UTC).isoformat()
|
|
636
|
+
cursor = self.db.execute(
|
|
637
|
+
"""
|
|
638
|
+
UPDATE sessions
|
|
639
|
+
SET status = 'expired', updated_at = ?
|
|
640
|
+
WHERE status IN ('active', 'paused', 'handoff_ready')
|
|
641
|
+
AND datetime(updated_at) < datetime('now', 'utc', ? || ' hours')
|
|
642
|
+
""",
|
|
643
|
+
(now, f"-{timeout_hours}"),
|
|
644
|
+
)
|
|
645
|
+
count = cursor.rowcount or 0
|
|
646
|
+
if count > 0:
|
|
647
|
+
logger.info(f"Expired {count} stale sessions (>{timeout_hours}h inactive)")
|
|
648
|
+
return count
|
|
649
|
+
|
|
650
|
+
def pause_inactive_active_sessions(self, timeout_minutes: int = 30) -> int:
|
|
651
|
+
"""
|
|
652
|
+
Mark active sessions as paused if they've been inactive for too long.
|
|
653
|
+
|
|
654
|
+
This catches orphaned sessions that never received an AFTER_AGENT hook
|
|
655
|
+
(e.g., Claude Code crashed mid-response).
|
|
656
|
+
|
|
657
|
+
Args:
|
|
658
|
+
timeout_minutes: Minutes of inactivity before pausing
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
Number of sessions paused
|
|
662
|
+
"""
|
|
663
|
+
now = datetime.now(UTC).isoformat()
|
|
664
|
+
cursor = self.db.execute(
|
|
665
|
+
"""
|
|
666
|
+
UPDATE sessions
|
|
667
|
+
SET status = 'paused', updated_at = ?
|
|
668
|
+
WHERE status = 'active'
|
|
669
|
+
AND datetime(updated_at) < datetime('now', 'utc', ? || ' minutes')
|
|
670
|
+
""",
|
|
671
|
+
(now, f"-{timeout_minutes}"),
|
|
672
|
+
)
|
|
673
|
+
count = cursor.rowcount or 0
|
|
674
|
+
if count > 0:
|
|
675
|
+
logger.info(f"Paused {count} inactive active sessions (>{timeout_minutes}m)")
|
|
676
|
+
return count
|
|
677
|
+
|
|
678
|
+
def get_pending_transcript_sessions(self, limit: int = 10) -> builtins.list[Session]:
|
|
679
|
+
"""
|
|
680
|
+
Get sessions that need transcript processing.
|
|
681
|
+
|
|
682
|
+
These are expired sessions with transcript_processed = FALSE.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
limit: Maximum sessions to return
|
|
686
|
+
|
|
687
|
+
Returns:
|
|
688
|
+
List of sessions needing processing
|
|
689
|
+
"""
|
|
690
|
+
rows = self.db.fetchall(
|
|
691
|
+
"""
|
|
692
|
+
SELECT * FROM sessions
|
|
693
|
+
WHERE status = 'expired'
|
|
694
|
+
AND transcript_processed = FALSE
|
|
695
|
+
AND jsonl_path IS NOT NULL
|
|
696
|
+
ORDER BY updated_at ASC
|
|
697
|
+
LIMIT ?
|
|
698
|
+
""",
|
|
699
|
+
(limit,),
|
|
700
|
+
)
|
|
701
|
+
return [Session.from_row(row) for row in rows]
|
|
702
|
+
|
|
703
|
+
def mark_transcript_processed(self, session_id: str) -> Session | None:
|
|
704
|
+
"""
|
|
705
|
+
Mark a session's transcript as fully processed.
|
|
706
|
+
|
|
707
|
+
Args:
|
|
708
|
+
session_id: Session ID
|
|
709
|
+
|
|
710
|
+
Returns:
|
|
711
|
+
Updated session or None if not found
|
|
712
|
+
"""
|
|
713
|
+
now = datetime.now(UTC).isoformat()
|
|
714
|
+
self.db.execute(
|
|
715
|
+
"UPDATE sessions SET transcript_processed = TRUE, updated_at = ? WHERE id = ?",
|
|
716
|
+
(now, session_id),
|
|
717
|
+
)
|
|
718
|
+
return self.get(session_id)
|
|
719
|
+
|
|
720
|
+
def reset_transcript_processed(self, session_id: str) -> Session | None:
|
|
721
|
+
"""
|
|
722
|
+
Reset transcript_processed flag when a session is resumed.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
session_id: Session ID
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
Updated session or None if not found
|
|
729
|
+
"""
|
|
730
|
+
now = datetime.now(UTC).isoformat()
|
|
731
|
+
self.db.execute(
|
|
732
|
+
"UPDATE sessions SET transcript_processed = FALSE, updated_at = ? WHERE id = ?",
|
|
733
|
+
(now, session_id),
|
|
734
|
+
)
|
|
735
|
+
return self.get(session_id)
|
|
736
|
+
|
|
737
|
+
def update_usage(
|
|
738
|
+
self,
|
|
739
|
+
session_id: str,
|
|
740
|
+
input_tokens: int,
|
|
741
|
+
output_tokens: int,
|
|
742
|
+
cache_creation_tokens: int,
|
|
743
|
+
cache_read_tokens: int,
|
|
744
|
+
total_cost_usd: float,
|
|
745
|
+
) -> bool:
|
|
746
|
+
"""Update session usage statistics."""
|
|
747
|
+
query = """
|
|
748
|
+
UPDATE sessions
|
|
749
|
+
SET
|
|
750
|
+
usage_input_tokens = ?,
|
|
751
|
+
usage_output_tokens = ?,
|
|
752
|
+
usage_cache_creation_tokens = ?,
|
|
753
|
+
usage_cache_read_tokens = ?,
|
|
754
|
+
usage_total_cost_usd = ?,
|
|
755
|
+
updated_at = datetime('now')
|
|
756
|
+
WHERE id = ?
|
|
757
|
+
"""
|
|
758
|
+
try:
|
|
759
|
+
with self.db.transaction():
|
|
760
|
+
cursor = self.db.execute(
|
|
761
|
+
query,
|
|
762
|
+
(
|
|
763
|
+
input_tokens,
|
|
764
|
+
output_tokens,
|
|
765
|
+
cache_creation_tokens,
|
|
766
|
+
cache_read_tokens,
|
|
767
|
+
total_cost_usd,
|
|
768
|
+
session_id,
|
|
769
|
+
),
|
|
770
|
+
)
|
|
771
|
+
return cursor.rowcount > 0
|
|
772
|
+
except Exception as e:
|
|
773
|
+
logger.error(f"Failed to update session usage {session_id}: {e}")
|
|
774
|
+
return False
|
|
775
|
+
|
|
776
|
+
def update_terminal_pickup_metadata(
|
|
777
|
+
self,
|
|
778
|
+
session_id: str,
|
|
779
|
+
workflow_name: str | None = None,
|
|
780
|
+
agent_run_id: str | None = None,
|
|
781
|
+
context_injected: bool | None = None,
|
|
782
|
+
original_prompt: str | None = None,
|
|
783
|
+
) -> Session | None:
|
|
784
|
+
"""
|
|
785
|
+
Update terminal pickup metadata for a session.
|
|
786
|
+
|
|
787
|
+
These fields are used when a terminal-mode agent picks up its
|
|
788
|
+
prepared state via hooks on session start.
|
|
789
|
+
|
|
790
|
+
Args:
|
|
791
|
+
session_id: Session ID to update.
|
|
792
|
+
workflow_name: Workflow to activate on terminal pickup.
|
|
793
|
+
agent_run_id: Link back to the agent run record.
|
|
794
|
+
context_injected: Whether context was injected into prompt.
|
|
795
|
+
original_prompt: Original prompt for the agent.
|
|
796
|
+
|
|
797
|
+
Returns:
|
|
798
|
+
Updated session or None if not found.
|
|
799
|
+
"""
|
|
800
|
+
values: dict[str, Any] = {}
|
|
801
|
+
|
|
802
|
+
if workflow_name is not None:
|
|
803
|
+
values["workflow_name"] = workflow_name
|
|
804
|
+
if agent_run_id is not None:
|
|
805
|
+
values["agent_run_id"] = agent_run_id
|
|
806
|
+
if context_injected is not None:
|
|
807
|
+
values["context_injected"] = 1 if context_injected else 0
|
|
808
|
+
if original_prompt is not None:
|
|
809
|
+
values["original_prompt"] = original_prompt
|
|
810
|
+
|
|
811
|
+
if not values:
|
|
812
|
+
return self.get(session_id)
|
|
813
|
+
|
|
814
|
+
values["updated_at"] = datetime.now(UTC).isoformat()
|
|
815
|
+
|
|
816
|
+
self.db.safe_update("sessions", values, "id = ?", (session_id,))
|
|
817
|
+
return self.get(session_id)
|