gobby 0.2.5__py3-none-any.whl → 0.2.7__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 +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +13 -4
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +395 -0
- gobby/agents/runner.py +8 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +385 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/install.py +4 -4
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +15 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +8 -8
- gobby/cli/installers/shared.py +175 -13
- gobby/cli/sessions.py +1 -1
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +12 -5
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +69 -91
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +9 -41
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +188 -2
- gobby/hooks/hook_manager.py +50 -4
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/skill_manager.py +130 -0
- gobby/hooks/webhooks.py +1 -1
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +98 -35
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +56 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -8
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/agents.py +31 -731
- gobby/mcp_proxy/tools/clones.py +518 -0
- gobby/mcp_proxy/tools/memory.py +3 -26
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
- gobby/mcp_proxy/tools/skills/__init__.py +616 -0
- gobby/mcp_proxy/tools/spawn_agent.py +417 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +0 -338
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +73 -285
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/runner.py +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +24 -12
- gobby/servers/routes/admin.py +294 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +1 -1
- gobby/servers/routes/mcp/tools.py +48 -1317
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +2 -0
- gobby/sessions/transcripts/claude.py +79 -10
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +286 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +162 -201
- gobby/storage/sessions.py +116 -7
- gobby/storage/skills.py +782 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +57 -7
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +40 -5
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +46 -35
- gobby/tools/summarizer.py +91 -10
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1135
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +93 -1
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +269 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
- gobby/workflows/engine.py +13 -2
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/loader.py +19 -6
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +154 -0
- gobby/workflows/safe_evaluator.py +183 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +111 -1
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1292
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/session_messages.py +0 -1056
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- gobby/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
gobby/storage/clones.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Local clone storage manager.
|
|
2
|
+
|
|
3
|
+
Manages local git clones for parallel development, distinct from worktrees.
|
|
4
|
+
Clones are full repository copies while worktrees share a single .git directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from gobby.storage.database import DatabaseProtocol
|
|
16
|
+
from gobby.utils.id import generate_prefixed_id
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CloneStatus(str, Enum):
|
|
22
|
+
"""Clone status values."""
|
|
23
|
+
|
|
24
|
+
ACTIVE = "active"
|
|
25
|
+
SYNCING = "syncing"
|
|
26
|
+
STALE = "stale"
|
|
27
|
+
CLEANUP = "cleanup"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Clone:
|
|
32
|
+
"""Clone data model."""
|
|
33
|
+
|
|
34
|
+
id: str
|
|
35
|
+
project_id: str
|
|
36
|
+
branch_name: str
|
|
37
|
+
clone_path: str
|
|
38
|
+
base_branch: str
|
|
39
|
+
task_id: str | None
|
|
40
|
+
agent_session_id: str | None
|
|
41
|
+
status: str
|
|
42
|
+
remote_url: str | None
|
|
43
|
+
last_sync_at: str | None
|
|
44
|
+
cleanup_after: str | None
|
|
45
|
+
created_at: str
|
|
46
|
+
updated_at: str
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_row(cls, row: Any) -> Clone:
|
|
50
|
+
"""Create Clone from database row."""
|
|
51
|
+
return cls(
|
|
52
|
+
id=row["id"],
|
|
53
|
+
project_id=row["project_id"],
|
|
54
|
+
branch_name=row["branch_name"],
|
|
55
|
+
clone_path=row["clone_path"],
|
|
56
|
+
base_branch=row["base_branch"],
|
|
57
|
+
task_id=row["task_id"],
|
|
58
|
+
agent_session_id=row["agent_session_id"],
|
|
59
|
+
status=row["status"],
|
|
60
|
+
remote_url=row["remote_url"],
|
|
61
|
+
last_sync_at=row["last_sync_at"],
|
|
62
|
+
cleanup_after=row["cleanup_after"],
|
|
63
|
+
created_at=row["created_at"],
|
|
64
|
+
updated_at=row["updated_at"],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict[str, Any]:
|
|
68
|
+
"""Convert to dictionary."""
|
|
69
|
+
return {
|
|
70
|
+
"id": self.id,
|
|
71
|
+
"project_id": self.project_id,
|
|
72
|
+
"branch_name": self.branch_name,
|
|
73
|
+
"clone_path": self.clone_path,
|
|
74
|
+
"base_branch": self.base_branch,
|
|
75
|
+
"task_id": self.task_id,
|
|
76
|
+
"agent_session_id": self.agent_session_id,
|
|
77
|
+
"status": self.status,
|
|
78
|
+
"remote_url": self.remote_url,
|
|
79
|
+
"last_sync_at": self.last_sync_at,
|
|
80
|
+
"cleanup_after": self.cleanup_after,
|
|
81
|
+
"created_at": self.created_at,
|
|
82
|
+
"updated_at": self.updated_at,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class LocalCloneManager:
|
|
87
|
+
"""Manager for local clone storage."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, db: DatabaseProtocol):
|
|
90
|
+
"""Initialize with database connection."""
|
|
91
|
+
self.db = db
|
|
92
|
+
|
|
93
|
+
def create(
|
|
94
|
+
self,
|
|
95
|
+
project_id: str,
|
|
96
|
+
branch_name: str,
|
|
97
|
+
clone_path: str,
|
|
98
|
+
base_branch: str = "main",
|
|
99
|
+
task_id: str | None = None,
|
|
100
|
+
agent_session_id: str | None = None,
|
|
101
|
+
remote_url: str | None = None,
|
|
102
|
+
cleanup_after: str | None = None,
|
|
103
|
+
) -> Clone:
|
|
104
|
+
"""
|
|
105
|
+
Create a new clone record.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
project_id: Project ID
|
|
109
|
+
branch_name: Git branch name
|
|
110
|
+
clone_path: Absolute path to clone directory
|
|
111
|
+
base_branch: Base branch for the clone
|
|
112
|
+
task_id: Optional task ID to link
|
|
113
|
+
agent_session_id: Optional session ID that owns this clone
|
|
114
|
+
remote_url: Optional remote URL of the repository
|
|
115
|
+
cleanup_after: Optional ISO timestamp for automatic cleanup
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Created Clone instance
|
|
119
|
+
"""
|
|
120
|
+
clone_id = generate_prefixed_id("clone", length=6)
|
|
121
|
+
now = datetime.now(UTC).isoformat()
|
|
122
|
+
|
|
123
|
+
self.db.execute(
|
|
124
|
+
"""
|
|
125
|
+
INSERT INTO clones (
|
|
126
|
+
id, project_id, branch_name, clone_path, base_branch,
|
|
127
|
+
task_id, agent_session_id, status, remote_url,
|
|
128
|
+
last_sync_at, cleanup_after, created_at, updated_at
|
|
129
|
+
)
|
|
130
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
131
|
+
""",
|
|
132
|
+
(
|
|
133
|
+
clone_id,
|
|
134
|
+
project_id,
|
|
135
|
+
branch_name,
|
|
136
|
+
clone_path,
|
|
137
|
+
base_branch,
|
|
138
|
+
task_id,
|
|
139
|
+
agent_session_id,
|
|
140
|
+
CloneStatus.ACTIVE.value,
|
|
141
|
+
remote_url,
|
|
142
|
+
None, # last_sync_at
|
|
143
|
+
cleanup_after,
|
|
144
|
+
now,
|
|
145
|
+
now,
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return Clone(
|
|
150
|
+
id=clone_id,
|
|
151
|
+
project_id=project_id,
|
|
152
|
+
branch_name=branch_name,
|
|
153
|
+
clone_path=clone_path,
|
|
154
|
+
base_branch=base_branch,
|
|
155
|
+
task_id=task_id,
|
|
156
|
+
agent_session_id=agent_session_id,
|
|
157
|
+
status=CloneStatus.ACTIVE.value,
|
|
158
|
+
remote_url=remote_url,
|
|
159
|
+
last_sync_at=None,
|
|
160
|
+
cleanup_after=cleanup_after,
|
|
161
|
+
created_at=now,
|
|
162
|
+
updated_at=now,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def get(self, clone_id: str) -> Clone | None:
|
|
166
|
+
"""Get clone by ID."""
|
|
167
|
+
row = self.db.fetchone("SELECT * FROM clones WHERE id = ?", (clone_id,))
|
|
168
|
+
return Clone.from_row(row) if row else None
|
|
169
|
+
|
|
170
|
+
def get_by_task(self, task_id: str) -> Clone | None:
|
|
171
|
+
"""Get clone linked to a task."""
|
|
172
|
+
row = self.db.fetchone("SELECT * FROM clones WHERE task_id = ?", (task_id,))
|
|
173
|
+
return Clone.from_row(row) if row else None
|
|
174
|
+
|
|
175
|
+
def get_by_path(self, clone_path: str) -> Clone | None:
|
|
176
|
+
"""Get clone by path."""
|
|
177
|
+
row = self.db.fetchone("SELECT * FROM clones WHERE clone_path = ?", (clone_path,))
|
|
178
|
+
return Clone.from_row(row) if row else None
|
|
179
|
+
|
|
180
|
+
def get_by_branch(self, project_id: str, branch_name: str) -> Clone | None:
|
|
181
|
+
"""Get clone by project and branch name."""
|
|
182
|
+
row = self.db.fetchone(
|
|
183
|
+
"SELECT * FROM clones WHERE project_id = ? AND branch_name = ?",
|
|
184
|
+
(project_id, branch_name),
|
|
185
|
+
)
|
|
186
|
+
return Clone.from_row(row) if row else None
|
|
187
|
+
|
|
188
|
+
def list_clones(
|
|
189
|
+
self,
|
|
190
|
+
project_id: str | None = None,
|
|
191
|
+
status: str | None = None,
|
|
192
|
+
agent_session_id: str | None = None,
|
|
193
|
+
limit: int = 50,
|
|
194
|
+
) -> list[Clone]:
|
|
195
|
+
"""
|
|
196
|
+
List clones with optional filters.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
project_id: Filter by project
|
|
200
|
+
status: Filter by status
|
|
201
|
+
agent_session_id: Filter by owning session
|
|
202
|
+
limit: Maximum number of results
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
List of Clone instances
|
|
206
|
+
"""
|
|
207
|
+
conditions = []
|
|
208
|
+
params: list[Any] = []
|
|
209
|
+
|
|
210
|
+
if project_id:
|
|
211
|
+
conditions.append("project_id = ?")
|
|
212
|
+
params.append(project_id)
|
|
213
|
+
if status:
|
|
214
|
+
conditions.append("status = ?")
|
|
215
|
+
params.append(status)
|
|
216
|
+
if agent_session_id:
|
|
217
|
+
conditions.append("agent_session_id = ?")
|
|
218
|
+
params.append(agent_session_id)
|
|
219
|
+
|
|
220
|
+
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
|
221
|
+
params.append(limit)
|
|
222
|
+
|
|
223
|
+
# nosec B608: where_clause built from hardcoded condition strings, values parameterized
|
|
224
|
+
rows = self.db.fetchall(
|
|
225
|
+
f"""
|
|
226
|
+
SELECT * FROM clones
|
|
227
|
+
WHERE {where_clause}
|
|
228
|
+
ORDER BY created_at DESC
|
|
229
|
+
LIMIT ?
|
|
230
|
+
""", # nosec B608
|
|
231
|
+
tuple(params),
|
|
232
|
+
)
|
|
233
|
+
return [Clone.from_row(row) for row in rows]
|
|
234
|
+
|
|
235
|
+
# Allowlist of valid clone column names to prevent SQL injection
|
|
236
|
+
_VALID_UPDATE_FIELDS = frozenset(
|
|
237
|
+
{
|
|
238
|
+
"branch_name",
|
|
239
|
+
"base_branch",
|
|
240
|
+
"clone_path",
|
|
241
|
+
"status",
|
|
242
|
+
"agent_session_id",
|
|
243
|
+
"task_id",
|
|
244
|
+
"remote_url",
|
|
245
|
+
"last_sync_at",
|
|
246
|
+
"cleanup_after",
|
|
247
|
+
"updated_at",
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def update(self, clone_id: str, **fields: Any) -> Clone | None:
|
|
252
|
+
"""
|
|
253
|
+
Update clone fields.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
clone_id: Clone ID to update
|
|
257
|
+
**fields: Fields to update (must be valid column names)
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Updated Clone or None if not found
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
ValueError: If any field name is not in the allowlist
|
|
264
|
+
"""
|
|
265
|
+
if not fields:
|
|
266
|
+
return self.get(clone_id)
|
|
267
|
+
|
|
268
|
+
# Validate field names against allowlist to prevent SQL injection
|
|
269
|
+
invalid_fields = set(fields.keys()) - self._VALID_UPDATE_FIELDS
|
|
270
|
+
if invalid_fields:
|
|
271
|
+
raise ValueError(f"Invalid field names: {invalid_fields}")
|
|
272
|
+
|
|
273
|
+
# Add updated_at timestamp
|
|
274
|
+
fields["updated_at"] = datetime.now(UTC).isoformat()
|
|
275
|
+
|
|
276
|
+
# nosec B608: Fields validated against _VALID_UPDATE_FIELDS allowlist above
|
|
277
|
+
set_clause = ", ".join(f"{key} = ?" for key in fields.keys())
|
|
278
|
+
values = list(fields.values()) + [clone_id]
|
|
279
|
+
|
|
280
|
+
self.db.execute(
|
|
281
|
+
f"UPDATE clones SET {set_clause} WHERE id = ?", # nosec B608
|
|
282
|
+
tuple(values),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return self.get(clone_id)
|
|
286
|
+
|
|
287
|
+
def delete(self, clone_id: str) -> bool:
|
|
288
|
+
"""
|
|
289
|
+
Delete clone record.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
clone_id: Clone ID to delete
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
True if deleted, False if not found
|
|
296
|
+
"""
|
|
297
|
+
cursor = self.db.execute("DELETE FROM clones WHERE id = ?", (clone_id,))
|
|
298
|
+
return cursor.rowcount > 0
|
|
299
|
+
|
|
300
|
+
# Status transition methods
|
|
301
|
+
|
|
302
|
+
def mark_syncing(self, clone_id: str) -> Clone | None:
|
|
303
|
+
"""
|
|
304
|
+
Mark clone as syncing.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
clone_id: Clone ID
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Updated Clone or None if not found
|
|
311
|
+
"""
|
|
312
|
+
return self.update(clone_id, status=CloneStatus.SYNCING.value)
|
|
313
|
+
|
|
314
|
+
def mark_stale(self, clone_id: str) -> Clone | None:
|
|
315
|
+
"""
|
|
316
|
+
Mark clone as stale (inactive).
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
clone_id: Clone ID
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Updated Clone or None if not found
|
|
323
|
+
"""
|
|
324
|
+
return self.update(clone_id, status=CloneStatus.STALE.value)
|
|
325
|
+
|
|
326
|
+
def mark_cleanup(self, clone_id: str) -> Clone | None:
|
|
327
|
+
"""
|
|
328
|
+
Mark clone for cleanup.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
clone_id: Clone ID
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
Updated Clone or None if not found
|
|
335
|
+
"""
|
|
336
|
+
return self.update(clone_id, status=CloneStatus.CLEANUP.value)
|
|
337
|
+
|
|
338
|
+
def record_sync(self, clone_id: str) -> Clone | None:
|
|
339
|
+
"""
|
|
340
|
+
Record a sync operation on a clone.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
clone_id: Clone ID
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Updated Clone or None if not found
|
|
347
|
+
"""
|
|
348
|
+
now = datetime.now(UTC).isoformat()
|
|
349
|
+
return self.update(
|
|
350
|
+
clone_id,
|
|
351
|
+
status=CloneStatus.ACTIVE.value,
|
|
352
|
+
last_sync_at=now,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def claim(self, clone_id: str, session_id: str) -> Clone | None:
|
|
356
|
+
"""
|
|
357
|
+
Claim a clone for an agent session.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
clone_id: Clone ID
|
|
361
|
+
session_id: Session ID claiming ownership
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Updated Clone or None if not found
|
|
365
|
+
"""
|
|
366
|
+
return self.update(clone_id, agent_session_id=session_id)
|
|
367
|
+
|
|
368
|
+
def release(self, clone_id: str) -> Clone | None:
|
|
369
|
+
"""
|
|
370
|
+
Release a clone from its current owner.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
clone_id: Clone ID
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Updated Clone or None if not found
|
|
377
|
+
"""
|
|
378
|
+
return self.update(clone_id, agent_session_id=None)
|
gobby/storage/database.py
CHANGED
|
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
|
|
21
21
|
|
|
22
22
|
def _adapt_datetime(val: datetime) -> str:
|
|
23
23
|
"""Adapt datetime to ISO format string for SQLite storage."""
|
|
24
|
-
return val.isoformat(
|
|
24
|
+
return val.isoformat()
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def _adapt_date(val: date) -> str:
|
gobby/storage/memories.py
CHANGED
|
@@ -138,10 +138,18 @@ class LocalMemoryManager:
|
|
|
138
138
|
tags: list[str] | None = None,
|
|
139
139
|
media: str | None = None,
|
|
140
140
|
) -> Memory:
|
|
141
|
+
# Validate that content is not empty
|
|
142
|
+
if not content or not content.strip():
|
|
143
|
+
logger.warning("Skipping memory creation: empty content provided")
|
|
144
|
+
raise ValueError("Memory content cannot be empty")
|
|
145
|
+
|
|
141
146
|
now = datetime.now(UTC).isoformat()
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
|
|
147
|
+
# Normalize content for consistent ID generation (avoid duplicates from
|
|
148
|
+
# whitespace differences or project_id inconsistency)
|
|
149
|
+
normalized_content = content.strip()
|
|
150
|
+
project_str = project_id if project_id else ""
|
|
151
|
+
# Use delimiter to prevent collisions (e.g., "abc" + "def" vs "abcd" + "ef")
|
|
152
|
+
memory_id = generate_prefixed_id("mm", f"{normalized_content}||{project_str}")
|
|
145
153
|
|
|
146
154
|
# Check if memory already exists to avoid duplicate insert errors
|
|
147
155
|
existing_row = self.db.fetchone("SELECT * FROM memories WHERE id = ?", (memory_id,))
|
|
@@ -190,18 +198,40 @@ class LocalMemoryManager:
|
|
|
190
198
|
|
|
191
199
|
def content_exists(self, content: str, project_id: str | None = None) -> bool:
|
|
192
200
|
"""Check if a memory with identical content already exists."""
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
(content,),
|
|
202
|
-
)
|
|
201
|
+
# Normalize content same way as ID generation in create_memory
|
|
202
|
+
normalized_content = content.strip()
|
|
203
|
+
project_str = project_id if project_id else ""
|
|
204
|
+
# Use delimiter to match create_memory ID generation
|
|
205
|
+
memory_id = generate_prefixed_id("mm", f"{normalized_content}||{project_str}")
|
|
206
|
+
|
|
207
|
+
# Check by ID (content-hash based) for consistent dedup
|
|
208
|
+
row = self.db.fetchone("SELECT 1 FROM memories WHERE id = ?", (memory_id,))
|
|
203
209
|
return row is not None
|
|
204
210
|
|
|
211
|
+
def get_memory_by_content(self, content: str, project_id: str | None = None) -> Memory | None:
|
|
212
|
+
"""Get a memory by its exact content, using the content-derived ID.
|
|
213
|
+
|
|
214
|
+
This provides a reliable way to fetch an existing memory without
|
|
215
|
+
relying on search result ordering.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
content: The exact content to look up (will be normalized)
|
|
219
|
+
project_id: Optional project ID for scoping
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The Memory object if found, None otherwise
|
|
223
|
+
"""
|
|
224
|
+
# Normalize content same way as ID generation in create_memory
|
|
225
|
+
normalized_content = content.strip()
|
|
226
|
+
project_str = project_id if project_id else ""
|
|
227
|
+
# Use delimiter to match create_memory ID generation
|
|
228
|
+
memory_id = generate_prefixed_id("mm", f"{normalized_content}||{project_str}")
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
return self.get_memory(memory_id)
|
|
232
|
+
except ValueError:
|
|
233
|
+
return None
|
|
234
|
+
|
|
205
235
|
def update_memory(
|
|
206
236
|
self,
|
|
207
237
|
memory_id: str,
|