gdmcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""ArtifactStore — persist, version, and search complex agent outputs.
|
|
2
|
+
|
|
3
|
+
Artifact types: diagram, report, plan, coverage, diff, custom.
|
|
4
|
+
|
|
5
|
+
All mutations are wrapped in explicit SQLite transactions.
|
|
6
|
+
ArtifactStore is deliberately a thin wrapper around GdmDatabase, following
|
|
7
|
+
the same internal pattern as GdmDatabase typed methods (direct _conn access
|
|
8
|
+
under _lock) rather than using the auto-committing execute() helper.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import difflib
|
|
13
|
+
import hashlib
|
|
14
|
+
import re
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import sqlite3 as _sqlite3
|
|
22
|
+
from src.memory.db import GdmDatabase
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Artifact",
|
|
26
|
+
"ArtifactDiff",
|
|
27
|
+
"ArtifactNotFoundError",
|
|
28
|
+
"ArtifactStore",
|
|
29
|
+
"ArtifactVersion",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Secret redaction patterns
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
_SECRET_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
37
|
+
(re.compile(r"xai-[A-Za-z0-9]{32,}"), "[REDACTED:xai-key]"),
|
|
38
|
+
(re.compile(r"sk-[A-Za-z0-9]{40,}"), "[REDACTED:openai-key]"),
|
|
39
|
+
(re.compile(r"AIza[A-Za-z0-9\-_]{35}"), "[REDACTED:gcp-key]"),
|
|
40
|
+
(re.compile(r"ghp_[A-Za-z0-9]{36}"), "[REDACTED:gh-token]"),
|
|
41
|
+
(re.compile(r"(?i)(password\s*[:=]\s*)\S+"), r"\1[REDACTED]"),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
_VALID_TYPES = frozenset({"diagram", "report", "plan", "coverage", "diff", "custom"})
|
|
45
|
+
_VALID_VISIBILITY = frozenset({"private", "project", "public"})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _redact_secrets(text: str) -> str:
|
|
49
|
+
for pattern, replacement in _SECRET_PATTERNS:
|
|
50
|
+
text = pattern.sub(replacement, text)
|
|
51
|
+
return text
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _render_html(artifact: "Artifact", content: str) -> str:
|
|
55
|
+
import html as _html
|
|
56
|
+
escaped = _html.escape(content)
|
|
57
|
+
return (
|
|
58
|
+
f"<!DOCTYPE html><html><head>"
|
|
59
|
+
f"<title>{_html.escape(artifact.name)}</title>"
|
|
60
|
+
f"</head><body><pre>{escaped}</pre></body></html>"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Data classes
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ArtifactVersion:
|
|
71
|
+
version_id: str
|
|
72
|
+
artifact_id: str
|
|
73
|
+
version_num: int
|
|
74
|
+
content: str
|
|
75
|
+
content_hash: str
|
|
76
|
+
byte_size: int
|
|
77
|
+
event_id: str | None
|
|
78
|
+
created_at: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Artifact:
|
|
83
|
+
artifact_id: str
|
|
84
|
+
name: str
|
|
85
|
+
type: str
|
|
86
|
+
description: str | None
|
|
87
|
+
session_id: str | None
|
|
88
|
+
created_at: str
|
|
89
|
+
updated_at: str
|
|
90
|
+
visibility: str
|
|
91
|
+
# Latest version fields (populated by queries that JOIN artifact_versions)
|
|
92
|
+
version_id: str = ""
|
|
93
|
+
version_num: int = 1
|
|
94
|
+
content: str = ""
|
|
95
|
+
content_hash: str = ""
|
|
96
|
+
byte_size: int = 0
|
|
97
|
+
event_id: str | None = None
|
|
98
|
+
version_ts: str = ""
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_row(cls, row: "_sqlite3.Row") -> "Artifact":
|
|
102
|
+
d = dict(row)
|
|
103
|
+
return cls(
|
|
104
|
+
artifact_id=d["artifact_id"],
|
|
105
|
+
name=d["name"],
|
|
106
|
+
type=d["type"],
|
|
107
|
+
description=d.get("description"),
|
|
108
|
+
session_id=d.get("session_id"),
|
|
109
|
+
created_at=d["created_at"],
|
|
110
|
+
updated_at=d["updated_at"],
|
|
111
|
+
visibility=d.get("visibility", "private"),
|
|
112
|
+
version_id=d.get("version_id", ""),
|
|
113
|
+
version_num=d.get("version_num", 1),
|
|
114
|
+
content=d.get("content", ""),
|
|
115
|
+
content_hash=d.get("content_hash", ""),
|
|
116
|
+
byte_size=d.get("byte_size", 0),
|
|
117
|
+
event_id=d.get("event_id"),
|
|
118
|
+
version_ts=d.get("version_ts", ""),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class ArtifactDiff:
|
|
124
|
+
artifact_a: Artifact
|
|
125
|
+
artifact_b: Artifact
|
|
126
|
+
unified_diff: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ArtifactNotFoundError(KeyError):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# ArtifactStore
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ArtifactStore:
|
|
139
|
+
"""Persist, version, and search structured artifact outputs.
|
|
140
|
+
|
|
141
|
+
Example::
|
|
142
|
+
|
|
143
|
+
store = ArtifactStore(db)
|
|
144
|
+
art = store.save("arch-diagram", "diagram", "```mermaid\\n...```")
|
|
145
|
+
art2 = store.get("arch-diagram") # by name
|
|
146
|
+
diff = store.diff(art.artifact_id, art2.artifact_id)
|
|
147
|
+
results = store.search("mermaid")
|
|
148
|
+
raw = store.export(art.artifact_id)
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, db: "GdmDatabase") -> None:
|
|
152
|
+
self._db = db
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Save (create or version)
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def save(
|
|
159
|
+
self,
|
|
160
|
+
name: str,
|
|
161
|
+
type: str,
|
|
162
|
+
content: str,
|
|
163
|
+
*,
|
|
164
|
+
description: str | None = None,
|
|
165
|
+
session_id: str | None = None,
|
|
166
|
+
event_id: str | None = None,
|
|
167
|
+
links: list[dict[str, str]] | None = None,
|
|
168
|
+
visibility: str = "private",
|
|
169
|
+
) -> "Artifact":
|
|
170
|
+
"""Create or add a new version of an artifact.
|
|
171
|
+
|
|
172
|
+
If an artifact with *name* already exists, a new version row is added.
|
|
173
|
+
Returns the Artifact populated with the new version's fields.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
name: human-readable slug (unique across the DB)
|
|
177
|
+
type: one of diagram / report / plan / coverage / diff / custom
|
|
178
|
+
content: artifact body text
|
|
179
|
+
description: optional summary
|
|
180
|
+
session_id: originating session
|
|
181
|
+
event_id: which turn event produced this version
|
|
182
|
+
links: list of {link_type, ref_value} dicts
|
|
183
|
+
visibility: private | project | public
|
|
184
|
+
"""
|
|
185
|
+
if type not in _VALID_TYPES:
|
|
186
|
+
raise ValueError(f"Invalid artifact type {type!r}. Choose from: {sorted(_VALID_TYPES)}")
|
|
187
|
+
if visibility not in _VALID_VISIBILITY:
|
|
188
|
+
raise ValueError(f"Invalid visibility {visibility!r}.")
|
|
189
|
+
|
|
190
|
+
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
|
191
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
192
|
+
|
|
193
|
+
with self._db._lock:
|
|
194
|
+
conn = self._db._conn
|
|
195
|
+
try:
|
|
196
|
+
conn.execute("BEGIN")
|
|
197
|
+
|
|
198
|
+
# Upsert artifact metadata
|
|
199
|
+
row = conn.execute(
|
|
200
|
+
"SELECT artifact_id FROM artifacts WHERE name=?", (name,)
|
|
201
|
+
).fetchone()
|
|
202
|
+
if row:
|
|
203
|
+
artifact_id = row["artifact_id"]
|
|
204
|
+
conn.execute(
|
|
205
|
+
"UPDATE artifacts SET type=?, description=?, updated_at=?"
|
|
206
|
+
" WHERE artifact_id=?",
|
|
207
|
+
(type, description, now, artifact_id),
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
artifact_id = str(uuid.uuid4())
|
|
211
|
+
conn.execute(
|
|
212
|
+
"INSERT INTO artifacts"
|
|
213
|
+
" (artifact_id, name, type, description, session_id, visibility)"
|
|
214
|
+
" VALUES (?, ?, ?, ?, ?, ?)",
|
|
215
|
+
(artifact_id, name, type, description, session_id, visibility),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Next version number
|
|
219
|
+
vrow = conn.execute(
|
|
220
|
+
"SELECT COALESCE(MAX(version_num), 0) AS v"
|
|
221
|
+
" FROM artifact_versions WHERE artifact_id=?",
|
|
222
|
+
(artifact_id,),
|
|
223
|
+
).fetchone()
|
|
224
|
+
next_v = (vrow["v"] if vrow else 0) + 1
|
|
225
|
+
version_id = str(uuid.uuid4())
|
|
226
|
+
|
|
227
|
+
conn.execute(
|
|
228
|
+
"INSERT INTO artifact_versions"
|
|
229
|
+
" (version_id, artifact_id, version_num, content,"
|
|
230
|
+
" content_hash, byte_size, event_id)"
|
|
231
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
232
|
+
(
|
|
233
|
+
version_id,
|
|
234
|
+
artifact_id,
|
|
235
|
+
next_v,
|
|
236
|
+
content,
|
|
237
|
+
content_hash,
|
|
238
|
+
len(content.encode()),
|
|
239
|
+
event_id,
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
for link in links or []:
|
|
244
|
+
conn.execute(
|
|
245
|
+
"INSERT INTO artifact_links (artifact_id, link_type, ref_value)"
|
|
246
|
+
" VALUES (?, ?, ?)",
|
|
247
|
+
(artifact_id, link["link_type"], link["ref_value"]),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
conn.commit()
|
|
251
|
+
except Exception:
|
|
252
|
+
try:
|
|
253
|
+
conn.execute("ROLLBACK")
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
raise
|
|
257
|
+
|
|
258
|
+
return self.get(artifact_id)
|
|
259
|
+
|
|
260
|
+
# ------------------------------------------------------------------
|
|
261
|
+
# Get
|
|
262
|
+
# ------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def get(self, artifact_id_or_name: str) -> "Artifact":
|
|
265
|
+
"""Return the artifact (with latest version content) by ID or name.
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
ArtifactNotFoundError: if no artifact matches.
|
|
269
|
+
"""
|
|
270
|
+
row = self._db.execute_one(
|
|
271
|
+
"""
|
|
272
|
+
SELECT a.*, av.version_id, av.version_num, av.content, av.content_hash,
|
|
273
|
+
av.byte_size, av.event_id, av.created_at AS version_ts
|
|
274
|
+
FROM artifacts a
|
|
275
|
+
JOIN artifact_versions av ON av.artifact_id = a.artifact_id
|
|
276
|
+
WHERE (a.artifact_id=? OR a.name=?)
|
|
277
|
+
ORDER BY av.version_num DESC LIMIT 1
|
|
278
|
+
""",
|
|
279
|
+
(artifact_id_or_name, artifact_id_or_name),
|
|
280
|
+
)
|
|
281
|
+
if row is None:
|
|
282
|
+
raise ArtifactNotFoundError(
|
|
283
|
+
f"No artifact found: {artifact_id_or_name!r}"
|
|
284
|
+
)
|
|
285
|
+
return Artifact.from_row(row)
|
|
286
|
+
|
|
287
|
+
# ------------------------------------------------------------------
|
|
288
|
+
# List
|
|
289
|
+
# ------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def list(
|
|
292
|
+
self,
|
|
293
|
+
*,
|
|
294
|
+
type: str | None = None,
|
|
295
|
+
session_id: str | None = None,
|
|
296
|
+
limit: int = 50,
|
|
297
|
+
) -> list["Artifact"]:
|
|
298
|
+
"""List artifacts (latest version of each) with optional filters."""
|
|
299
|
+
where_clauses: list[str] = []
|
|
300
|
+
params: list[Any] = []
|
|
301
|
+
if type:
|
|
302
|
+
where_clauses.append("a.type=?")
|
|
303
|
+
params.append(type)
|
|
304
|
+
if session_id:
|
|
305
|
+
where_clauses.append("a.session_id=?")
|
|
306
|
+
params.append(session_id)
|
|
307
|
+
clause = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
|
308
|
+
rows = self._db.execute_all(
|
|
309
|
+
f"""
|
|
310
|
+
SELECT a.*, av.version_num, av.content, av.content_hash,
|
|
311
|
+
av.byte_size, av.event_id, av.created_at AS version_ts
|
|
312
|
+
FROM artifacts a
|
|
313
|
+
JOIN artifact_versions av ON av.artifact_id = a.artifact_id
|
|
314
|
+
AND av.version_num = (
|
|
315
|
+
SELECT MAX(v2.version_num)
|
|
316
|
+
FROM artifact_versions v2
|
|
317
|
+
WHERE v2.artifact_id = a.artifact_id
|
|
318
|
+
)
|
|
319
|
+
{clause}
|
|
320
|
+
ORDER BY a.updated_at DESC LIMIT ?
|
|
321
|
+
""",
|
|
322
|
+
tuple(params) + (limit,),
|
|
323
|
+
)
|
|
324
|
+
return [Artifact.from_row(r) for r in rows]
|
|
325
|
+
|
|
326
|
+
# ------------------------------------------------------------------
|
|
327
|
+
# Diff
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
def diff(self, artifact_id_a: str, artifact_id_b: str) -> "ArtifactDiff":
|
|
331
|
+
"""Return a unified diff of two artifacts' latest versions.
|
|
332
|
+
|
|
333
|
+
Raises:
|
|
334
|
+
TypeError: if the two artifacts have different types.
|
|
335
|
+
"""
|
|
336
|
+
a = self.get(artifact_id_a)
|
|
337
|
+
b = self.get(artifact_id_b)
|
|
338
|
+
if a.type != b.type:
|
|
339
|
+
raise TypeError(
|
|
340
|
+
f"Cannot diff artifacts of different types: "
|
|
341
|
+
f"{a.name!r} is {a.type!r} but {b.name!r} is {b.type!r}. "
|
|
342
|
+
f"Use 'raw' export to compare content manually."
|
|
343
|
+
)
|
|
344
|
+
lines_a = a.content.splitlines(keepends=True)
|
|
345
|
+
lines_b = b.content.splitlines(keepends=True)
|
|
346
|
+
unified = "".join(
|
|
347
|
+
difflib.unified_diff(
|
|
348
|
+
lines_a,
|
|
349
|
+
lines_b,
|
|
350
|
+
fromfile=f"{a.name} v{a.version_num}",
|
|
351
|
+
tofile=f"{b.name} v{b.version_num}",
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
return ArtifactDiff(artifact_a=a, artifact_b=b, unified_diff=unified)
|
|
355
|
+
|
|
356
|
+
# ------------------------------------------------------------------
|
|
357
|
+
# Search (FTS5)
|
|
358
|
+
# ------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
def search(self, query: str, limit: int = 20) -> list["Artifact"]:
|
|
361
|
+
"""Full-text search across artifact name, description, and content.
|
|
362
|
+
|
|
363
|
+
Returns an empty list when no results match (never raises).
|
|
364
|
+
"""
|
|
365
|
+
try:
|
|
366
|
+
# Sanitize query for FTS5: special chars like '-' are FTS5 operators
|
|
367
|
+
# (e.g. auth-architecture = "auth NOT architecture"). Wrap in phrase.
|
|
368
|
+
if re.search(r"[^\w\s]", query):
|
|
369
|
+
fts_query = '"' + query.replace('"', '""') + '"'
|
|
370
|
+
else:
|
|
371
|
+
fts_query = query
|
|
372
|
+
rows = self._db.execute_all(
|
|
373
|
+
"""
|
|
374
|
+
SELECT a.*, av.version_num, av.content, av.content_hash,
|
|
375
|
+
av.byte_size, av.event_id, av.created_at AS version_ts
|
|
376
|
+
FROM artifact_fts fts
|
|
377
|
+
JOIN artifact_versions av ON av.rowid = fts.rowid
|
|
378
|
+
JOIN artifacts a ON a.artifact_id = av.artifact_id
|
|
379
|
+
WHERE artifact_fts MATCH ?
|
|
380
|
+
ORDER BY rank LIMIT ?
|
|
381
|
+
""",
|
|
382
|
+
(fts_query, limit),
|
|
383
|
+
)
|
|
384
|
+
except Exception:
|
|
385
|
+
return []
|
|
386
|
+
return [Artifact.from_row(r) for r in rows]
|
|
387
|
+
|
|
388
|
+
# ------------------------------------------------------------------
|
|
389
|
+
# Export
|
|
390
|
+
# ------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
def export(
|
|
393
|
+
self,
|
|
394
|
+
artifact_id: str,
|
|
395
|
+
fmt: str = "raw",
|
|
396
|
+
*,
|
|
397
|
+
redact_secrets: bool = True,
|
|
398
|
+
) -> str:
|
|
399
|
+
"""Export artifact content with provenance header.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
artifact_id: stable artifact UUID or name
|
|
403
|
+
fmt: "raw" (default) or "html"
|
|
404
|
+
redact_secrets: scan content for secret patterns (default True)
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
ValueError: if fmt is not "raw" or "html".
|
|
408
|
+
"""
|
|
409
|
+
artifact = self.get(artifact_id)
|
|
410
|
+
content = artifact.content
|
|
411
|
+
if redact_secrets:
|
|
412
|
+
content = _redact_secrets(content)
|
|
413
|
+
|
|
414
|
+
now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
415
|
+
provenance = (
|
|
416
|
+
f"# gdm artifact export\n"
|
|
417
|
+
f"# artifact_id: {artifact.artifact_id}\n"
|
|
418
|
+
f"# name: {artifact.name}\n"
|
|
419
|
+
f"# type: {artifact.type}\n"
|
|
420
|
+
f"# version: {artifact.version_num}\n"
|
|
421
|
+
f"# session: {artifact.session_id or 'n/a'}\n"
|
|
422
|
+
f"# exported_at: {now_iso}\n"
|
|
423
|
+
f"# redacted: {str(redact_secrets).lower()}\n"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if fmt == "raw":
|
|
427
|
+
return provenance + "\n" + content
|
|
428
|
+
if fmt == "html":
|
|
429
|
+
return _render_html(artifact, provenance + "\n" + content)
|
|
430
|
+
raise ValueError(f"Unknown export format {fmt!r}. Use 'raw' or 'html'.")
|
|
431
|
+
|
|
432
|
+
# ------------------------------------------------------------------
|
|
433
|
+
# Helpers
|
|
434
|
+
# ------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
def version_history(self, artifact_id_or_name: str) -> list["ArtifactVersion"]:
|
|
437
|
+
"""Return all versions of an artifact ordered oldest-first."""
|
|
438
|
+
art = self.get(artifact_id_or_name)
|
|
439
|
+
rows = self._db.execute_all(
|
|
440
|
+
"SELECT * FROM artifact_versions"
|
|
441
|
+
" WHERE artifact_id=? ORDER BY version_num ASC",
|
|
442
|
+
(art.artifact_id,),
|
|
443
|
+
)
|
|
444
|
+
return [
|
|
445
|
+
ArtifactVersion(
|
|
446
|
+
version_id=r["version_id"],
|
|
447
|
+
artifact_id=r["artifact_id"],
|
|
448
|
+
version_num=r["version_num"],
|
|
449
|
+
content=r["content"],
|
|
450
|
+
content_hash=r["content_hash"],
|
|
451
|
+
byte_size=r["byte_size"],
|
|
452
|
+
event_id=r["event_id"],
|
|
453
|
+
created_at=r["created_at"],
|
|
454
|
+
)
|
|
455
|
+
for r in rows
|
|
456
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Verification evidence graph for edit confidence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import dataclasses
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from src.memory.db import GdmDatabase
|
|
8
|
+
|
|
9
|
+
class EvidenceKind(str, Enum):
|
|
10
|
+
TEST = "TEST"; LINT = "LINT"; TYPE_CHECK = "TYPE_CHECK"; REVIEW = "REVIEW"
|
|
11
|
+
RUN = "RUN"; SECURITY = "SECURITY"; PERF = "PERF"; COVERAGE = "COVERAGE"
|
|
12
|
+
|
|
13
|
+
class Verdict(str, Enum):
|
|
14
|
+
PASS = "PASS"; FAIL = "FAIL"; SKIPPED = "SKIPPED"; PENDING = "PENDING"; WARNING = "WARNING"
|
|
15
|
+
|
|
16
|
+
_DEFAULT_REQUIRED: frozenset = frozenset({EvidenceKind.TEST, EvidenceKind.LINT})
|
|
17
|
+
_SAFE: frozenset = frozenset({Verdict.PASS, Verdict.SKIPPED})
|
|
18
|
+
|
|
19
|
+
@dataclasses.dataclass
|
|
20
|
+
class EditNode:
|
|
21
|
+
node_id: str; session_id: str; turn_index: int; file_path: str; patch_ref: Any; created_at: Any
|
|
22
|
+
|
|
23
|
+
@dataclasses.dataclass
|
|
24
|
+
class EvidenceNode:
|
|
25
|
+
evidence_id: str; node_id: str; kind: EvidenceKind; verdict: Verdict
|
|
26
|
+
detail: Any; tool: str | None; duration_ms: int | None; created_at: Any
|
|
27
|
+
|
|
28
|
+
class VerificationGraph:
|
|
29
|
+
def __init__(self, db: "GdmDatabase") -> None:
|
|
30
|
+
self._db = db
|
|
31
|
+
|
|
32
|
+
def add_edit(self, session_id, turn_index, file_path, *, patch_ref=None, depends_on=None):
|
|
33
|
+
n = self._db.add_edit_node(session_id=session_id, turn_index=turn_index,
|
|
34
|
+
file_path=file_path, patch_ref=patch_ref)
|
|
35
|
+
if depends_on:
|
|
36
|
+
seen: set = set()
|
|
37
|
+
for pid in depends_on:
|
|
38
|
+
if pid not in seen:
|
|
39
|
+
self._db.add_graph_edge(pid, n.node_id); seen.add(pid)
|
|
40
|
+
return n
|
|
41
|
+
|
|
42
|
+
def add_evidence(self, node_id, kind, verdict, *, detail=None, tool=None, duration_ms=None):
|
|
43
|
+
return self._db.add_evidence_node(node_id=node_id, kind=kind, verdict=verdict,
|
|
44
|
+
detail=detail, tool=tool, duration_ms=duration_ms)
|
|
45
|
+
|
|
46
|
+
def verdict_summary(self, node_id):
|
|
47
|
+
latest = {}
|
|
48
|
+
for ev in self._db.get_evidence_for_node(node_id):
|
|
49
|
+
latest[ev.kind] = ev.verdict
|
|
50
|
+
return {k: latest.get(k, Verdict.PENDING) for k in EvidenceKind}
|
|
51
|
+
|
|
52
|
+
def is_safe_to_build_on(self, node_id, required_kinds=None):
|
|
53
|
+
rk = required_kinds if required_kinds is not None else _DEFAULT_REQUIRED
|
|
54
|
+
s = self.verdict_summary(node_id)
|
|
55
|
+
return all(s.get(k, Verdict.PENDING) in _SAFE for k in rk)
|
|
56
|
+
|
|
57
|
+
def check_edit_preconditions(self, session_id, file_path, required_kinds=None):
|
|
58
|
+
return [n.node_id
|
|
59
|
+
for n in self._db.get_edit_nodes_for_session_file(session_id, file_path)
|
|
60
|
+
if not self.is_safe_to_build_on(n.node_id, required_kinds=required_kinds)]
|
|
61
|
+
|
|
62
|
+
def invalidate_dependents(self, node_id, reason=None):
|
|
63
|
+
detail = {"reason": reason} if reason else {"source": node_id}
|
|
64
|
+
visited, queue, affected = {node_id}, [node_id], []
|
|
65
|
+
while queue:
|
|
66
|
+
for dep in self._db.get_dependents(queue.pop()):
|
|
67
|
+
if dep not in visited:
|
|
68
|
+
visited.add(dep)
|
|
69
|
+
self._db.add_evidence_node(node_id=dep, kind=EvidenceKind.TEST,
|
|
70
|
+
verdict=Verdict.FAIL, detail=detail)
|
|
71
|
+
affected.append(dep); queue.append(dep)
|
|
72
|
+
return affected
|
|
73
|
+
|
|
74
|
+
def get_node(self, node_id):
|
|
75
|
+
return self._db.get_edit_node(node_id)
|