minder-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. minder/__init__.py +12 -0
  2. minder/api/routers/prompts.py +177 -0
  3. minder/application/__init__.py +1 -0
  4. minder/application/admin/__init__.py +11 -0
  5. minder/application/admin/dto.py +453 -0
  6. minder/application/admin/jobs.py +327 -0
  7. minder/application/admin/use_cases.py +1895 -0
  8. minder/auth/__init__.py +12 -0
  9. minder/auth/context.py +26 -0
  10. minder/auth/middleware.py +70 -0
  11. minder/auth/principal.py +59 -0
  12. minder/auth/rate_limiter.py +89 -0
  13. minder/auth/rbac.py +60 -0
  14. minder/auth/service.py +541 -0
  15. minder/bootstrap/__init__.py +9 -0
  16. minder/bootstrap/providers.py +109 -0
  17. minder/bootstrap/transport.py +807 -0
  18. minder/cache/__init__.py +10 -0
  19. minder/cache/providers.py +140 -0
  20. minder/chunking/__init__.py +4 -0
  21. minder/chunking/code_splitter.py +184 -0
  22. minder/chunking/splitter.py +136 -0
  23. minder/cli.py +1542 -0
  24. minder/config.py +179 -0
  25. minder/continuity.py +363 -0
  26. minder/dev.py +160 -0
  27. minder/embedding/__init__.py +9 -0
  28. minder/embedding/base.py +7 -0
  29. minder/embedding/local.py +65 -0
  30. minder/embedding/openai.py +7 -0
  31. minder/graph/__init__.py +11 -0
  32. minder/graph/edges.py +13 -0
  33. minder/graph/executor.py +127 -0
  34. minder/graph/graph.py +263 -0
  35. minder/graph/nodes/__init__.py +27 -0
  36. minder/graph/nodes/evaluator.py +21 -0
  37. minder/graph/nodes/guard.py +64 -0
  38. minder/graph/nodes/llm.py +59 -0
  39. minder/graph/nodes/planning.py +30 -0
  40. minder/graph/nodes/reasoning.py +87 -0
  41. minder/graph/nodes/reranker.py +141 -0
  42. minder/graph/nodes/retriever.py +86 -0
  43. minder/graph/nodes/verification.py +230 -0
  44. minder/graph/nodes/workflow_planner.py +250 -0
  45. minder/graph/runtime.py +15 -0
  46. minder/graph/state.py +26 -0
  47. minder/llm/__init__.py +5 -0
  48. minder/llm/base.py +14 -0
  49. minder/llm/local.py +381 -0
  50. minder/llm/openai.py +89 -0
  51. minder/models/__init__.py +109 -0
  52. minder/models/base.py +10 -0
  53. minder/models/client.py +137 -0
  54. minder/models/document.py +34 -0
  55. minder/models/error.py +32 -0
  56. minder/models/graph.py +114 -0
  57. minder/models/history.py +32 -0
  58. minder/models/job.py +62 -0
  59. minder/models/prompt.py +41 -0
  60. minder/models/repository.py +62 -0
  61. minder/models/rule.py +68 -0
  62. minder/models/session.py +51 -0
  63. minder/models/skill.py +52 -0
  64. minder/models/user.py +41 -0
  65. minder/models/workflow.py +35 -0
  66. minder/observability/__init__.py +57 -0
  67. minder/observability/audit.py +243 -0
  68. minder/observability/logging.py +253 -0
  69. minder/observability/metrics.py +448 -0
  70. minder/observability/tracing.py +215 -0
  71. minder/presentation/__init__.py +1 -0
  72. minder/presentation/http/__init__.py +1 -0
  73. minder/presentation/http/admin/__init__.py +3 -0
  74. minder/presentation/http/admin/api.py +1309 -0
  75. minder/presentation/http/admin/context.py +94 -0
  76. minder/presentation/http/admin/dashboard.py +111 -0
  77. minder/presentation/http/admin/jobs.py +208 -0
  78. minder/presentation/http/admin/memories.py +185 -0
  79. minder/presentation/http/admin/prompts.py +219 -0
  80. minder/presentation/http/admin/routes.py +127 -0
  81. minder/presentation/http/admin/runtime.py +650 -0
  82. minder/presentation/http/admin/search.py +368 -0
  83. minder/presentation/http/admin/skills.py +230 -0
  84. minder/prompts/__init__.py +646 -0
  85. minder/prompts/formatter.py +142 -0
  86. minder/resources/__init__.py +318 -0
  87. minder/retrieval/__init__.py +5 -0
  88. minder/retrieval/hybrid.py +178 -0
  89. minder/retrieval/mmr.py +116 -0
  90. minder/retrieval/multi_hop.py +115 -0
  91. minder/runtime.py +15 -0
  92. minder/server.py +145 -0
  93. minder/store/__init__.py +64 -0
  94. minder/store/document.py +115 -0
  95. minder/store/error.py +82 -0
  96. minder/store/feedback.py +114 -0
  97. minder/store/graph.py +588 -0
  98. minder/store/history.py +57 -0
  99. minder/store/interfaces.py +512 -0
  100. minder/store/milvus/__init__.py +11 -0
  101. minder/store/milvus/client.py +26 -0
  102. minder/store/milvus/collections.py +15 -0
  103. minder/store/milvus/vector_store.py +232 -0
  104. minder/store/mongodb/__init__.py +11 -0
  105. minder/store/mongodb/client.py +49 -0
  106. minder/store/mongodb/indexes.py +90 -0
  107. minder/store/mongodb/operational_store.py +993 -0
  108. minder/store/relational.py +1087 -0
  109. minder/store/repo_state.py +58 -0
  110. minder/store/rule.py +93 -0
  111. minder/store/vector.py +79 -0
  112. minder/tools/__init__.py +47 -0
  113. minder/tools/auth.py +94 -0
  114. minder/tools/graph.py +839 -0
  115. minder/tools/ingest.py +353 -0
  116. minder/tools/memory.py +381 -0
  117. minder/tools/query.py +307 -0
  118. minder/tools/registry.py +269 -0
  119. minder/tools/repo_scanner.py +1266 -0
  120. minder/tools/search.py +15 -0
  121. minder/tools/session.py +316 -0
  122. minder/tools/skills.py +899 -0
  123. minder/tools/workflow.py +215 -0
  124. minder/transport/__init__.py +4 -0
  125. minder/transport/base.py +286 -0
  126. minder/transport/sse.py +252 -0
  127. minder/transport/stdio.py +29 -0
  128. minder_cli-0.2.0.dist-info/METADATA +318 -0
  129. minder_cli-0.2.0.dist-info/RECORD +132 -0
  130. minder_cli-0.2.0.dist-info/WHEEL +4 -0
  131. minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
  132. minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,1309 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import uuid
5
+ from urllib.parse import urlsplit
6
+
7
+ from pydantic import ValidationError
8
+ from starlette.responses import JSONResponse
9
+ from starlette.routing import BaseRoute, Route
10
+
11
+ from minder.application.admin.dto import (
12
+ ClientRepositoryResolveRequest,
13
+ GraphSyncRequest,
14
+ UpsertRepositoryBranchLinkRequest,
15
+ )
16
+ from minder.auth.principal import ClientPrincipal
17
+ from minder.observability.metrics import (
18
+ get_metrics_summary,
19
+ record_admin_operation,
20
+ record_auth_event,
21
+ )
22
+
23
+ from .context import ADMIN_COOKIE_NAME, AdminRouteContext
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _normalize_repository_remote(repo_url: str | None) -> str | None:
29
+ if repo_url is None:
30
+ return None
31
+ raw_url = str(repo_url).strip()
32
+ if not raw_url:
33
+ return None
34
+ if raw_url.startswith("git@"):
35
+ host_and_path = raw_url[4:]
36
+ host, separator, path = host_and_path.partition(":")
37
+ if separator and host and path:
38
+ normalized_path = path.strip().lstrip("/").removesuffix(".git")
39
+ if normalized_path:
40
+ return f"git@{host}:{normalized_path}.git"
41
+ return raw_url
42
+ if (
43
+ raw_url.startswith("ssh://")
44
+ or raw_url.startswith("http://")
45
+ or raw_url.startswith("https://")
46
+ ):
47
+ parts = urlsplit(raw_url)
48
+ host = parts.hostname or ""
49
+ path = parts.path.strip().lstrip("/").removesuffix(".git")
50
+ user = parts.username or "git"
51
+ if host and path:
52
+ return f"{user}@{host}:{path}.git"
53
+ return raw_url.rstrip("/")
54
+
55
+
56
+ def _principal_can_access_candidates(
57
+ principal: ClientPrincipal,
58
+ candidates: list[str],
59
+ ) -> bool:
60
+ scopes = [
61
+ scope.strip() for scope in principal.repo_scope if scope and scope.strip()
62
+ ]
63
+ if not scopes:
64
+ return False
65
+ if "*" in scopes:
66
+ return True
67
+ normalized_candidates = [
68
+ candidate.rstrip("/") for candidate in candidates if candidate
69
+ ]
70
+ for scope in scopes:
71
+ normalized_scope = scope.rstrip("/")
72
+ for candidate in normalized_candidates:
73
+ if candidate == normalized_scope:
74
+ return True
75
+ if candidate.startswith(f"{normalized_scope}/"):
76
+ return True
77
+ return False
78
+
79
+
80
+ def _repository_scope_candidates(
81
+ repository: object, payload: GraphSyncRequest
82
+ ) -> list[str]:
83
+ candidates: list[str] = []
84
+ repo_name = getattr(repository, "repo_name", None)
85
+ if repo_name:
86
+ candidates.append(str(repo_name))
87
+ repo_url = getattr(repository, "repo_url", None)
88
+ normalized_repo_url = _normalize_repository_remote(repo_url)
89
+ if normalized_repo_url:
90
+ candidates.append(normalized_repo_url)
91
+ payload_remote = _normalize_repository_remote(
92
+ payload.sync_metadata.get("repo_remote")
93
+ if isinstance(payload.sync_metadata, dict)
94
+ else None
95
+ )
96
+ if payload_remote:
97
+ candidates.append(payload_remote)
98
+ return [candidate for candidate in candidates if candidate]
99
+
100
+
101
+ def _principal_can_access_repository(
102
+ principal: ClientPrincipal,
103
+ repository: object,
104
+ payload: GraphSyncRequest,
105
+ ) -> bool:
106
+ return _principal_can_access_candidates(
107
+ principal,
108
+ _repository_scope_candidates(repository, payload),
109
+ )
110
+
111
+
112
+ def build_admin_api_routes(context: AdminRouteContext) -> list[BaseRoute]:
113
+ def _public_base_url(request) -> str:
114
+ return str(request.base_url).rstrip("/")
115
+
116
+ async def setup_api(request):
117
+ if await context.use_cases.has_admin_users():
118
+ return JSONResponse({"error": "Admin already set up"}, status_code=403)
119
+ payload = await request.json()
120
+ username = str(payload.get("username", "")).strip()
121
+ email = str(payload.get("email", "")).strip()
122
+ display_name = str(payload.get("display_name", "")).strip()
123
+ password = str(payload.get("password", "")).strip() or None
124
+ if not all([username, email, display_name]):
125
+ return JSONResponse({"error": "All fields are required."}, status_code=400)
126
+ try:
127
+ result = await context.use_cases.create_initial_admin(
128
+ username=username,
129
+ email=email,
130
+ display_name=display_name,
131
+ password=password,
132
+ )
133
+ except Exception as exc:
134
+ return JSONResponse({"error": str(exc)}, status_code=400)
135
+ return JSONResponse(result, status_code=201)
136
+
137
+ async def dashboard_login_api(request):
138
+ """Accept either username+password or api_key authentication."""
139
+ payload = await request.json()
140
+
141
+ username = str(payload.get("username", "")).strip()
142
+ password = str(payload.get("password", "")).strip()
143
+ api_key = str(payload.get("api_key", "")).strip()
144
+
145
+ if not (username and password) and not api_key:
146
+ return JSONResponse(
147
+ {"error": "Provide username + password, or an admin API key."},
148
+ status_code=400,
149
+ )
150
+
151
+ try:
152
+ if username and password:
153
+ result = await context.use_cases.login_admin_by_password(
154
+ username, password
155
+ )
156
+ else:
157
+ result = await context.use_cases.login_admin(api_key)
158
+ except PermissionError:
159
+ await record_auth_event(
160
+ "login", "denied", client_id="dashboard", store=context.store
161
+ )
162
+ return JSONResponse({"error": "Admin role required."}, status_code=403)
163
+ except Exception:
164
+ await record_auth_event(
165
+ "login", "failure", client_id="dashboard", store=context.store
166
+ )
167
+ return JSONResponse({"error": "Invalid credentials."}, status_code=401)
168
+
169
+ await record_auth_event(
170
+ "login", "success", client_id="dashboard", store=context.store
171
+ )
172
+ response = JSONResponse({"ok": True}, status_code=200)
173
+ response.set_cookie(
174
+ ADMIN_COOKIE_NAME,
175
+ result["jwt"],
176
+ httponly=True,
177
+ samesite="lax",
178
+ secure=False,
179
+ path="/",
180
+ max_age=context.config.auth.jwt_expiry_hours * 3600,
181
+ )
182
+ return response
183
+
184
+ async def dashboard_logout_api(request):
185
+ del request
186
+ await record_auth_event(
187
+ "logout", "success", client_id="dashboard", store=context.store
188
+ )
189
+ response = JSONResponse({"ok": True}, status_code=200)
190
+ response.delete_cookie(ADMIN_COOKIE_NAME, path="/")
191
+ return response
192
+
193
+ async def admin_session(request):
194
+ try:
195
+ user = await context.admin_user_from_request(request)
196
+ except PermissionError:
197
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
198
+ except Exception as exc:
199
+ return JSONResponse({"error": str(exc)}, status_code=401)
200
+ return JSONResponse({"admin": context.use_cases.serialize_admin_session(user)})
201
+
202
+ async def dashboard_bootstrap_state(request):
203
+ has_admin_users = await context.use_cases.has_admin_users()
204
+ has_admin_session = False
205
+ if has_admin_users:
206
+ try:
207
+ await context.admin_user_from_request(request)
208
+ except Exception:
209
+ has_admin_session = False
210
+ else:
211
+ has_admin_session = True
212
+ return JSONResponse(
213
+ {
214
+ "has_admin_users": has_admin_users,
215
+ "has_admin_session": has_admin_session,
216
+ }
217
+ )
218
+
219
+ async def token_exchange(request):
220
+ payload = await request.json()
221
+ try:
222
+ exchange = await context.use_cases.exchange_client_key(
223
+ client_api_key=payload["client_api_key"],
224
+ requested_scopes=payload.get("requested_scopes"),
225
+ )
226
+ except Exception as exc:
227
+ await record_auth_event("token_exchange", "failure", store=context.store)
228
+ return JSONResponse({"error": str(exc)}, status_code=401)
229
+ client_slug = str(exchange.get("client_slug", "unknown"))
230
+ await record_auth_event(
231
+ "token_exchange", "success", client_id=client_slug, store=context.store
232
+ )
233
+ return JSONResponse(exchange)
234
+
235
+ async def gateway_test_connection(request):
236
+ payload = await request.json()
237
+ try:
238
+ result = await context.use_cases.test_client_connection(
239
+ payload["client_api_key"],
240
+ public_base_url=_public_base_url(request),
241
+ )
242
+ except Exception as exc:
243
+ return JSONResponse({"error": str(exc)}, status_code=401)
244
+ return JSONResponse(result)
245
+
246
+ async def list_clients(request):
247
+ try:
248
+ await context.admin_user_from_request(request)
249
+ except PermissionError:
250
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
251
+ except Exception as exc:
252
+ return JSONResponse({"error": str(exc)}, status_code=401)
253
+ return JSONResponse(await context.use_cases.list_clients())
254
+
255
+ async def create_client(request):
256
+ try:
257
+ user = await context.admin_user_from_request(request)
258
+ except PermissionError:
259
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
260
+ except Exception as exc:
261
+ return JSONResponse({"error": str(exc)}, status_code=401)
262
+ payload = await request.json()
263
+ try:
264
+ created = await context.use_cases.create_client(
265
+ actor_user_id=user.id,
266
+ name=payload["name"],
267
+ slug=payload["slug"],
268
+ description=payload.get("description", ""),
269
+ tool_scopes=payload.get("tool_scopes"),
270
+ repo_scopes=payload.get("repo_scopes"),
271
+ )
272
+ except Exception as exc:
273
+ await record_admin_operation(
274
+ "create_client", "error", actor_id=str(user.id), store=context.store
275
+ )
276
+ return JSONResponse({"error": str(exc)}, status_code=400)
277
+ await record_admin_operation(
278
+ "create_client", "success", actor_id=str(user.id), store=context.store
279
+ )
280
+ return JSONResponse(created, status_code=201)
281
+
282
+ async def admin_clients(request):
283
+ if request.method == "GET":
284
+ return await list_clients(request)
285
+ return await create_client(request)
286
+
287
+ async def admin_tools(request):
288
+ try:
289
+ await context.admin_user_from_request(request)
290
+ except PermissionError:
291
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
292
+ except Exception as exc:
293
+ return JSONResponse({"error": str(exc)}, status_code=401)
294
+ return JSONResponse({"tools": context.use_cases.list_tools()})
295
+
296
+ async def client_detail(request):
297
+ try:
298
+ user = await context.admin_user_from_request(request)
299
+ except PermissionError:
300
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
301
+ except Exception as exc:
302
+ return JSONResponse({"error": str(exc)}, status_code=401)
303
+
304
+ client_id = uuid.UUID(str(request.path_params["client_id"]))
305
+ if request.method == "GET":
306
+ try:
307
+ return JSONResponse(
308
+ await context.use_cases.get_client_detail(client_id)
309
+ )
310
+ except LookupError:
311
+ return JSONResponse({"error": "Client not found"}, status_code=404)
312
+
313
+ payload = await request.json()
314
+ try:
315
+ updated = await context.use_cases.update_client(
316
+ client_id=client_id,
317
+ name=payload.get("name"),
318
+ description=payload.get("description"),
319
+ repo_scopes=payload.get("repo_scopes"),
320
+ tool_scopes=payload.get("tool_scopes"),
321
+ )
322
+ except LookupError:
323
+ await record_admin_operation(
324
+ "update_client", "error", actor_id=str(user.id), store=context.store
325
+ )
326
+ return JSONResponse({"error": "Client not found"}, status_code=404)
327
+ except Exception:
328
+ await record_admin_operation(
329
+ "update_client", "error", actor_id=str(user.id), store=context.store
330
+ )
331
+ raise
332
+
333
+ await record_admin_operation(
334
+ "update_client", "success", actor_id=str(user.id), store=context.store
335
+ )
336
+ return JSONResponse(updated)
337
+
338
+ async def client_key_rotate(request):
339
+ try:
340
+ user = await context.admin_user_from_request(request)
341
+ except PermissionError:
342
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
343
+ except Exception as exc:
344
+ return JSONResponse({"error": str(exc)}, status_code=401)
345
+ client_id = uuid.UUID(str(request.path_params["client_id"]))
346
+ try:
347
+ result = await context.use_cases.issue_client_key(
348
+ client_id=client_id,
349
+ actor_user_id=user.id,
350
+ )
351
+ except Exception as exc:
352
+ await record_admin_operation(
353
+ "key_rotate", "error", actor_id=str(user.id), store=context.store
354
+ )
355
+ return JSONResponse({"error": str(exc)}, status_code=404)
356
+ await record_admin_operation(
357
+ "key_rotate", "success", actor_id=str(user.id), store=context.store
358
+ )
359
+ return JSONResponse(result, status_code=201)
360
+
361
+ async def client_key_revoke(request):
362
+ try:
363
+ user = await context.admin_user_from_request(request)
364
+ except PermissionError:
365
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
366
+ except Exception as exc:
367
+ return JSONResponse({"error": str(exc)}, status_code=401)
368
+ client_id = uuid.UUID(str(request.path_params["client_id"]))
369
+ try:
370
+ result = await context.use_cases.revoke_client_keys(
371
+ client_id=client_id, actor_user_id=user.id
372
+ )
373
+ await record_admin_operation(
374
+ "key_revoke", "success", actor_id=str(user.id), store=context.store
375
+ )
376
+ return JSONResponse(result)
377
+ except Exception:
378
+ await record_admin_operation(
379
+ "key_revoke", "error", actor_id=str(user.id), store=context.store
380
+ )
381
+ raise
382
+
383
+ async def client_onboarding(request):
384
+ try:
385
+ await context.admin_user_from_request(request)
386
+ except PermissionError:
387
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
388
+ except Exception as exc:
389
+ return JSONResponse({"error": str(exc)}, status_code=401)
390
+ client_id = uuid.UUID(str(request.path_params["client_id"]))
391
+ try:
392
+ result = await context.use_cases.get_onboarding(
393
+ client_id,
394
+ public_base_url=_public_base_url(request),
395
+ )
396
+ except LookupError:
397
+ return JSONResponse({"error": "Client not found"}, status_code=404)
398
+ return JSONResponse(result)
399
+
400
+ async def admin_audit(request):
401
+ try:
402
+ await context.admin_user_from_request(request)
403
+ except PermissionError:
404
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
405
+ except Exception as exc:
406
+ return JSONResponse({"error": str(exc)}, status_code=401)
407
+ # Allow filtering by client_id as an alias for actor_id
408
+ actor_id = (
409
+ request.query_params.get("client_id")
410
+ or request.query_params.get("actor_id")
411
+ or None
412
+ )
413
+ event_type = request.query_params.get("event_type") or None
414
+ outcome = request.query_params.get("outcome") or None
415
+ try:
416
+ limit = int(request.query_params.get("limit", "50"))
417
+ offset = int(request.query_params.get("offset", "0"))
418
+ except ValueError:
419
+ limit, offset = 50, 0
420
+ limit = max(1, min(limit, 200)) # cap at 200
421
+ offset = max(0, offset)
422
+ return JSONResponse(
423
+ await context.use_cases.list_audit(
424
+ actor_id=actor_id,
425
+ event_type=event_type,
426
+ outcome=outcome,
427
+ limit=limit,
428
+ offset=offset,
429
+ )
430
+ )
431
+
432
+ # ------------------------------------------------------------------
433
+ # User management
434
+ # ------------------------------------------------------------------
435
+
436
+ async def admin_users(request):
437
+ try:
438
+ await context.admin_user_from_request(request)
439
+ except PermissionError:
440
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
441
+ except Exception as exc:
442
+ return JSONResponse({"error": str(exc)}, status_code=401)
443
+
444
+ if request.method == "POST":
445
+ try:
446
+ payload = await request.json()
447
+ except Exception:
448
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
449
+
450
+ username = str(payload.get("username", "")).strip()
451
+ email = str(payload.get("email", "")).strip()
452
+ display_name = str(payload.get("display_name", "")).strip()
453
+ role = str(payload.get("role", "admin")).strip() or "admin"
454
+ password = str(payload.get("password", "")).strip() or None
455
+
456
+ if not username or not email or not display_name:
457
+ return JSONResponse(
458
+ {"error": "username, email, and display_name are required"},
459
+ status_code=400,
460
+ )
461
+
462
+ try:
463
+ return JSONResponse(
464
+ await context.use_cases.create_user(
465
+ username=username,
466
+ email=email,
467
+ display_name=display_name,
468
+ role=role,
469
+ password=password,
470
+ ),
471
+ status_code=201,
472
+ )
473
+ except Exception as exc:
474
+ return JSONResponse({"error": str(exc)}, status_code=400)
475
+
476
+ active_only = request.query_params.get("active_only", "false").lower() == "true"
477
+ return JSONResponse(await context.use_cases.list_users(active_only=active_only))
478
+
479
+ async def user_detail(request):
480
+ try:
481
+ await context.admin_user_from_request(request)
482
+ except PermissionError:
483
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
484
+ except Exception as exc:
485
+ return JSONResponse({"error": str(exc)}, status_code=401)
486
+
487
+ user_id = uuid.UUID(str(request.path_params["user_id"]))
488
+
489
+ if request.method == "GET":
490
+ try:
491
+ return JSONResponse(await context.use_cases.get_user_detail(user_id))
492
+ except LookupError:
493
+ return JSONResponse({"error": "User not found"}, status_code=404)
494
+
495
+ if request.method == "PATCH":
496
+ try:
497
+ payload = await request.json()
498
+ except Exception:
499
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
500
+ try:
501
+ return JSONResponse(
502
+ await context.use_cases.update_user(
503
+ user_id,
504
+ role=payload.get("role"),
505
+ is_active=payload.get("is_active"),
506
+ display_name=payload.get("display_name"),
507
+ )
508
+ )
509
+ except LookupError:
510
+ return JSONResponse({"error": "User not found"}, status_code=404)
511
+ except Exception as exc:
512
+ return JSONResponse({"error": str(exc)}, status_code=400)
513
+
514
+ if request.method == "DELETE":
515
+ try:
516
+ return JSONResponse(await context.use_cases.deactivate_user(user_id))
517
+ except LookupError:
518
+ return JSONResponse({"error": "User not found"}, status_code=404)
519
+
520
+ return JSONResponse({"error": "Method not allowed"}, status_code=405)
521
+
522
+ # ------------------------------------------------------------------
523
+ # Workflow management
524
+ # ------------------------------------------------------------------
525
+
526
+ async def admin_workflows(request):
527
+ try:
528
+ await context.admin_user_from_request(request)
529
+ except PermissionError:
530
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
531
+ except Exception as exc:
532
+ return JSONResponse({"error": str(exc)}, status_code=401)
533
+
534
+ if request.method == "GET":
535
+ return JSONResponse(await context.use_cases.list_workflows())
536
+
537
+ if request.method == "POST":
538
+ try:
539
+ payload = await request.json()
540
+ except Exception:
541
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
542
+ name = str(payload.get("name", "")).strip()
543
+ if not name:
544
+ return JSONResponse({"error": "name is required"}, status_code=400)
545
+ try:
546
+ return JSONResponse(
547
+ await context.use_cases.create_workflow(
548
+ name=name,
549
+ description=str(payload.get("description", "")),
550
+ enforcement=str(payload.get("enforcement", "strict")),
551
+ steps=payload.get("steps"),
552
+ ),
553
+ status_code=201,
554
+ )
555
+ except Exception as exc:
556
+ return JSONResponse({"error": str(exc)}, status_code=400)
557
+
558
+ return JSONResponse({"error": "Method not allowed"}, status_code=405)
559
+
560
+ async def workflow_detail(request):
561
+ try:
562
+ await context.admin_user_from_request(request)
563
+ except PermissionError:
564
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
565
+ except Exception as exc:
566
+ return JSONResponse({"error": str(exc)}, status_code=401)
567
+
568
+ workflow_id = uuid.UUID(str(request.path_params["workflow_id"]))
569
+
570
+ if request.method == "GET":
571
+ try:
572
+ return JSONResponse(
573
+ await context.use_cases.get_workflow_detail(workflow_id)
574
+ )
575
+ except LookupError:
576
+ return JSONResponse({"error": "Workflow not found"}, status_code=404)
577
+
578
+ if request.method == "PATCH":
579
+ try:
580
+ payload = await request.json()
581
+ except Exception:
582
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
583
+ try:
584
+ return JSONResponse(
585
+ await context.use_cases.update_workflow(
586
+ workflow_id,
587
+ name=payload.get("name"),
588
+ description=payload.get("description"),
589
+ enforcement=payload.get("enforcement"),
590
+ steps=payload.get("steps"),
591
+ )
592
+ )
593
+ except LookupError:
594
+ return JSONResponse({"error": "Workflow not found"}, status_code=404)
595
+ except Exception as exc:
596
+ return JSONResponse({"error": str(exc)}, status_code=400)
597
+
598
+ if request.method == "DELETE":
599
+ try:
600
+ return JSONResponse(
601
+ await context.use_cases.delete_workflow(workflow_id)
602
+ )
603
+ except LookupError:
604
+ return JSONResponse({"error": "Workflow not found"}, status_code=404)
605
+
606
+ return JSONResponse({"error": "Method not allowed"}, status_code=405)
607
+
608
+ # ------------------------------------------------------------------
609
+ # Observability
610
+ # ------------------------------------------------------------------
611
+
612
+ async def admin_metrics_summary(request):
613
+ try:
614
+ await context.admin_user_from_request(request)
615
+ except PermissionError:
616
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
617
+ except Exception as exc:
618
+ return JSONResponse({"error": str(exc)}, status_code=401)
619
+
620
+ try:
621
+ active_sessions = await context.store.count_active_client_sessions()
622
+ except Exception as exc:
623
+ logger.warning("Failed to count active client sessions: %s", exc)
624
+ active_sessions = None
625
+
626
+ client_id = request.query_params.get("client_id")
627
+ event_type = request.query_params.get("event_type")
628
+ outcome = request.query_params.get("outcome")
629
+
630
+ summary = await get_metrics_summary(
631
+ store=context.store,
632
+ active_sessions=active_sessions,
633
+ client_id=client_id,
634
+ event_type=event_type,
635
+ outcome=outcome,
636
+ )
637
+ return JSONResponse(summary)
638
+
639
+ # ------------------------------------------------------------------
640
+ # Repository management
641
+ # ------------------------------------------------------------------
642
+
643
+ async def admin_repositories(request):
644
+ try:
645
+ await context.admin_user_from_request(request)
646
+ except PermissionError:
647
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
648
+ except Exception as exc:
649
+ return JSONResponse({"error": str(exc)}, status_code=401)
650
+ return JSONResponse(await context.use_cases.list_repositories())
651
+
652
+ async def repository_landscape(request):
653
+ try:
654
+ await context.admin_user_from_request(request)
655
+ except PermissionError:
656
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
657
+ except Exception as exc:
658
+ return JSONResponse({"error": str(exc)}, status_code=401)
659
+ return JSONResponse(await context.use_cases.list_repository_landscape())
660
+
661
+ async def repository_detail(request):
662
+ try:
663
+ user = await context.admin_user_from_request(request)
664
+ except PermissionError:
665
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
666
+ except Exception as exc:
667
+ return JSONResponse({"error": str(exc)}, status_code=401)
668
+
669
+ repo_id = uuid.UUID(str(request.path_params["repo_id"]))
670
+ if request.method == "GET":
671
+ try:
672
+ return JSONResponse(
673
+ await context.use_cases.get_repository_detail(repo_id)
674
+ )
675
+ except LookupError:
676
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
677
+
678
+ if request.method == "PATCH":
679
+ payload = await request.json()
680
+ try:
681
+ result = await context.use_cases.update_repository(
682
+ repo_id=repo_id,
683
+ name=payload.get("name"),
684
+ remote_url=payload.get("remote_url"),
685
+ default_branch=payload.get("default_branch"),
686
+ path=payload.get("path"),
687
+ )
688
+ except LookupError:
689
+ await record_admin_operation(
690
+ "repository_update",
691
+ "error",
692
+ actor_id=str(user.id),
693
+ store=context.store,
694
+ )
695
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
696
+ except ValueError as exc:
697
+ return JSONResponse({"error": str(exc)}, status_code=400)
698
+
699
+ await record_admin_operation(
700
+ "repository_update",
701
+ "success",
702
+ actor_id=str(user.id),
703
+ store=context.store,
704
+ )
705
+ return JSONResponse(result)
706
+
707
+ try:
708
+ result = await context.use_cases.delete_repository(repo_id)
709
+ except LookupError:
710
+ await record_admin_operation(
711
+ "repository_delete", "error", actor_id=str(user.id), store=context.store
712
+ )
713
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
714
+
715
+ await record_admin_operation(
716
+ "repository_delete", "success", actor_id=str(user.id), store=context.store
717
+ )
718
+ return JSONResponse(result)
719
+
720
+ async def repository_graph_map(request):
721
+ try:
722
+ await context.admin_user_from_request(request)
723
+ except PermissionError:
724
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
725
+ except Exception as exc:
726
+ return JSONResponse({"error": str(exc)}, status_code=401)
727
+
728
+ repo_id = uuid.UUID(str(request.path_params["repo_id"]))
729
+ branch = (request.query_params.get("branch") or "").strip() or None
730
+ try:
731
+ return JSONResponse(
732
+ await context.use_cases.get_repository_graph_map(
733
+ repo_id=repo_id, branch=branch
734
+ )
735
+ )
736
+ except LookupError:
737
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
738
+ except RuntimeError as exc:
739
+ return JSONResponse({"error": str(exc)}, status_code=503)
740
+
741
+ async def repository_graph_summary(request):
742
+ try:
743
+ await context.admin_user_from_request(request)
744
+ except PermissionError:
745
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
746
+ except Exception as exc:
747
+ return JSONResponse({"error": str(exc)}, status_code=401)
748
+
749
+ repo_id = uuid.UUID(str(request.path_params["repo_id"]))
750
+ branch = (request.query_params.get("branch") or "").strip() or None
751
+ try:
752
+ return JSONResponse(
753
+ await context.use_cases.get_repository_graph_summary(
754
+ repo_id=repo_id, branch=branch
755
+ )
756
+ )
757
+ except LookupError:
758
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
759
+ except RuntimeError as exc:
760
+ return JSONResponse({"error": str(exc)}, status_code=503)
761
+
762
+ async def repository_graph_search(request):
763
+ try:
764
+ await context.admin_user_from_request(request)
765
+ except PermissionError:
766
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
767
+ except Exception as exc:
768
+ return JSONResponse({"error": str(exc)}, status_code=401)
769
+
770
+ repo_id = uuid.UUID(str(request.path_params["repo_id"]))
771
+ query = (request.query_params.get("query") or "").strip()
772
+ if not query:
773
+ return JSONResponse({"error": "Query is required"}, status_code=400)
774
+ branch = (request.query_params.get("branch") or "").strip() or None
775
+ node_types = [
776
+ value.strip()
777
+ for value in request.query_params.getlist("node_type")
778
+ if value.strip()
779
+ ]
780
+ languages = [
781
+ value.strip()
782
+ for value in request.query_params.getlist("language")
783
+ if value.strip()
784
+ ]
785
+ last_states = [
786
+ value.strip()
787
+ for value in request.query_params.getlist("last_state")
788
+ if value.strip()
789
+ ]
790
+ try:
791
+ limit = max(1, min(int(request.query_params.get("limit", "10")), 50))
792
+ except ValueError:
793
+ return JSONResponse({"error": "Invalid limit"}, status_code=400)
794
+
795
+ try:
796
+ return JSONResponse(
797
+ await context.use_cases.search_repository_graph(
798
+ repo_id=repo_id,
799
+ query=query,
800
+ branch=branch,
801
+ node_types=node_types or None,
802
+ languages=languages or None,
803
+ last_states=last_states or None,
804
+ limit=limit,
805
+ )
806
+ )
807
+ except LookupError:
808
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
809
+ except RuntimeError as exc:
810
+ return JSONResponse({"error": str(exc)}, status_code=503)
811
+
812
+ async def repository_graph_impact(request):
813
+ try:
814
+ await context.admin_user_from_request(request)
815
+ except PermissionError:
816
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
817
+ except Exception as exc:
818
+ return JSONResponse({"error": str(exc)}, status_code=401)
819
+
820
+ repo_id = uuid.UUID(str(request.path_params["repo_id"]))
821
+ target = (request.query_params.get("target") or "").strip()
822
+ if not target:
823
+ return JSONResponse({"error": "Target is required"}, status_code=400)
824
+ branch = (request.query_params.get("branch") or "").strip() or None
825
+ try:
826
+ depth = max(1, min(int(request.query_params.get("depth", "2")), 6))
827
+ limit = max(1, min(int(request.query_params.get("limit", "25")), 100))
828
+ except ValueError:
829
+ return JSONResponse({"error": "Invalid depth or limit"}, status_code=400)
830
+
831
+ try:
832
+ return JSONResponse(
833
+ await context.use_cases.get_repository_graph_impact(
834
+ repo_id=repo_id,
835
+ target=target,
836
+ branch=branch,
837
+ depth=depth,
838
+ limit=limit,
839
+ )
840
+ )
841
+ except LookupError:
842
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
843
+ except RuntimeError as exc:
844
+ return JSONResponse({"error": str(exc)}, status_code=503)
845
+
846
+ # ------------------------------------------------------------------
847
+ # Branch management
848
+ # ------------------------------------------------------------------
849
+
850
+ async def repository_branches(request):
851
+ try:
852
+ user = await context.admin_user_from_request(request)
853
+ except PermissionError:
854
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
855
+ except Exception as exc:
856
+ return JSONResponse({"error": str(exc)}, status_code=401)
857
+
858
+ repo_id = uuid.UUID(str(request.path_params["repo_id"]))
859
+
860
+ if request.method == "GET":
861
+ try:
862
+ return JSONResponse(
863
+ await context.use_cases.list_repository_branches(repo_id=repo_id)
864
+ )
865
+ except LookupError:
866
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
867
+
868
+ if request.method == "POST":
869
+ try:
870
+ payload = await request.json()
871
+ except Exception:
872
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
873
+ branch = str(payload.get("branch", "")).strip()
874
+ if not branch:
875
+ return JSONResponse({"error": "branch is required"}, status_code=400)
876
+ try:
877
+ result = await context.use_cases.add_repository_branch(
878
+ repo_id=repo_id, branch=branch
879
+ )
880
+ await record_admin_operation(
881
+ "repository_branch_add",
882
+ "success",
883
+ actor_id=str(user.id),
884
+ store=context.store,
885
+ )
886
+ return JSONResponse(result, status_code=201)
887
+ except LookupError:
888
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
889
+ except ValueError as exc:
890
+ return JSONResponse({"error": str(exc)}, status_code=400)
891
+
892
+ return JSONResponse({"error": "Method not allowed"}, status_code=405)
893
+
894
+ async def repository_branch_delete(request):
895
+ try:
896
+ user = await context.admin_user_from_request(request)
897
+ except PermissionError:
898
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
899
+ except Exception as exc:
900
+ return JSONResponse({"error": str(exc)}, status_code=401)
901
+
902
+ repo_id = uuid.UUID(str(request.path_params["repo_id"]))
903
+ branch = str(request.path_params.get("branch", "")).strip()
904
+ if not branch:
905
+ return JSONResponse({"error": "branch is required"}, status_code=400)
906
+ try:
907
+ result = await context.use_cases.remove_repository_branch(
908
+ repo_id=repo_id, branch=branch
909
+ )
910
+ await record_admin_operation(
911
+ "repository_branch_remove",
912
+ "success",
913
+ actor_id=str(user.id),
914
+ store=context.store,
915
+ )
916
+ return JSONResponse(result)
917
+ except LookupError:
918
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
919
+ except ValueError as exc:
920
+ return JSONResponse({"error": str(exc)}, status_code=400)
921
+
922
+ async def repository_branch_links(request):
923
+ try:
924
+ user = await context.admin_user_from_request(request)
925
+ except PermissionError:
926
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
927
+ except Exception as exc:
928
+ return JSONResponse({"error": str(exc)}, status_code=401)
929
+
930
+ repo_id = uuid.UUID(str(request.path_params["repo_id"]))
931
+ branch = (request.query_params.get("branch") or "").strip() or None
932
+
933
+ if request.method == "GET":
934
+ try:
935
+ return JSONResponse(
936
+ await context.use_cases.list_repository_branch_links(
937
+ repo_id=repo_id,
938
+ branch=branch,
939
+ )
940
+ )
941
+ except LookupError:
942
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
943
+
944
+ try:
945
+ payload = UpsertRepositoryBranchLinkRequest.model_validate(
946
+ await request.json()
947
+ )
948
+ except ValidationError as exc:
949
+ return JSONResponse(
950
+ {
951
+ "error": "Invalid repository branch link payload",
952
+ "details": exc.errors(),
953
+ },
954
+ status_code=400,
955
+ )
956
+
957
+ try:
958
+ result = await context.use_cases.upsert_repository_branch_link(
959
+ repo_id=repo_id,
960
+ source_branch=payload.source_branch,
961
+ target_repo_id=payload.target_repo_id,
962
+ target_repo_name=payload.target_repo_name,
963
+ target_repo_url=payload.target_repo_url,
964
+ target_branch=payload.target_branch,
965
+ relation=payload.relation,
966
+ direction=payload.direction,
967
+ confidence=payload.confidence,
968
+ metadata=payload.metadata,
969
+ )
970
+ await record_admin_operation(
971
+ "repository_branch_link_upsert",
972
+ "success",
973
+ actor_id=str(user.id),
974
+ store=context.store,
975
+ )
976
+ return JSONResponse(result, status_code=201)
977
+ except LookupError:
978
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
979
+ except ValueError as exc:
980
+ return JSONResponse({"error": str(exc)}, status_code=400)
981
+
982
+ async def repository_branch_link_delete(request):
983
+ try:
984
+ user = await context.admin_user_from_request(request)
985
+ except PermissionError:
986
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
987
+ except Exception as exc:
988
+ return JSONResponse({"error": str(exc)}, status_code=401)
989
+
990
+ repo_id = uuid.UUID(str(request.path_params["repo_id"]))
991
+ link_id = str(request.path_params.get("link_id", "")).strip()
992
+ branch = (request.query_params.get("branch") or "").strip() or None
993
+ if not link_id:
994
+ return JSONResponse({"error": "link_id is required"}, status_code=400)
995
+
996
+ try:
997
+ result = await context.use_cases.delete_repository_branch_link(
998
+ repo_id=repo_id,
999
+ link_id=link_id,
1000
+ branch=branch,
1001
+ )
1002
+ await record_admin_operation(
1003
+ "repository_branch_link_delete",
1004
+ "success",
1005
+ actor_id=str(user.id),
1006
+ store=context.store,
1007
+ )
1008
+ return JSONResponse(result)
1009
+ except LookupError as exc:
1010
+ status_code = (
1011
+ 404 if "Repository" in str(exc) or "not found" in str(exc) else 400
1012
+ )
1013
+ return JSONResponse({"error": str(exc)}, status_code=status_code)
1014
+ except ValueError as exc:
1015
+ return JSONResponse({"error": str(exc)}, status_code=400)
1016
+
1017
+ async def repository_graph_sync(request):
1018
+ try:
1019
+ user = await context.admin_user_from_request(request)
1020
+ except PermissionError:
1021
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
1022
+ except Exception as exc:
1023
+ return JSONResponse({"error": str(exc)}, status_code=401)
1024
+
1025
+ if context.graph_store is None:
1026
+ return JSONResponse(
1027
+ {"error": "Graph sync store is not configured"},
1028
+ status_code=503,
1029
+ )
1030
+
1031
+ repo_id = uuid.UUID(str(request.path_params["repo_id"]))
1032
+ try:
1033
+ payload = GraphSyncRequest.model_validate(await request.json())
1034
+ except ValidationError as exc:
1035
+ return JSONResponse(
1036
+ {"error": "Invalid graph sync payload", "details": exc.errors()},
1037
+ status_code=400,
1038
+ )
1039
+
1040
+ try:
1041
+ result = await context.use_cases.sync_repository_graph(
1042
+ repo_id=repo_id,
1043
+ payload=payload,
1044
+ )
1045
+ except LookupError:
1046
+ await record_admin_operation(
1047
+ "repository_graph_sync",
1048
+ "error",
1049
+ actor_id=str(user.id),
1050
+ store=context.store,
1051
+ )
1052
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
1053
+ except RuntimeError as exc:
1054
+ return JSONResponse({"error": str(exc)}, status_code=503)
1055
+ except Exception as exc:
1056
+ await record_admin_operation(
1057
+ "repository_graph_sync",
1058
+ "error",
1059
+ actor_id=str(user.id),
1060
+ store=context.store,
1061
+ )
1062
+ return JSONResponse({"error": str(exc)}, status_code=400)
1063
+
1064
+ await record_admin_operation(
1065
+ "repository_graph_sync",
1066
+ "success",
1067
+ actor_id=str(user.id),
1068
+ store=context.store,
1069
+ )
1070
+ return JSONResponse(result, status_code=202)
1071
+
1072
+ async def client_repository_graph_sync(request):
1073
+ try:
1074
+ principal = await context.client_principal_from_request(request)
1075
+ except PermissionError:
1076
+ return JSONResponse({"error": "Client principal required"}, status_code=403)
1077
+ except Exception as exc:
1078
+ return JSONResponse({"error": str(exc)}, status_code=401)
1079
+
1080
+ if context.graph_store is None:
1081
+ return JSONResponse(
1082
+ {"error": "Graph sync store is not configured"},
1083
+ status_code=503,
1084
+ )
1085
+
1086
+ repo_id = uuid.UUID(str(request.path_params["repo_id"]))
1087
+ repository = await context.store.get_repository_by_id(repo_id)
1088
+ if repository is None:
1089
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
1090
+
1091
+ try:
1092
+ payload = GraphSyncRequest.model_validate(await request.json())
1093
+ except ValidationError as exc:
1094
+ return JSONResponse(
1095
+ {"error": "Invalid graph sync payload", "details": exc.errors()},
1096
+ status_code=400,
1097
+ )
1098
+
1099
+ if not _principal_can_access_repository(principal, repository, payload):
1100
+ return JSONResponse(
1101
+ {"error": "Client is not allowed to sync this repository"},
1102
+ status_code=403,
1103
+ )
1104
+
1105
+ try:
1106
+ result = await context.use_cases.sync_repository_graph(
1107
+ repo_id=repo_id,
1108
+ payload=payload,
1109
+ )
1110
+ except Exception as exc:
1111
+ return JSONResponse({"error": str(exc)}, status_code=400)
1112
+
1113
+ try:
1114
+ await context.store.create_audit_log(
1115
+ actor_type="client",
1116
+ actor_id=str(principal.client_id),
1117
+ event_type="repository.graph_sync",
1118
+ resource_type="repository",
1119
+ resource_id=str(repo_id),
1120
+ outcome="success",
1121
+ audit_metadata={
1122
+ "client_slug": principal.client_slug,
1123
+ "payload_version": payload.payload_version,
1124
+ "source": payload.source,
1125
+ "nodes_upserted": result["nodes_upserted"],
1126
+ "edges_upserted": result["edges_upserted"],
1127
+ },
1128
+ )
1129
+ except Exception:
1130
+ logger.exception("Failed to record client graph sync audit log")
1131
+
1132
+ return JSONResponse(result, status_code=202)
1133
+
1134
+ async def client_repository_resolve(request):
1135
+ try:
1136
+ principal = await context.client_principal_from_request(request)
1137
+ except PermissionError:
1138
+ return JSONResponse({"error": "Client principal required"}, status_code=403)
1139
+ except Exception as exc:
1140
+ return JSONResponse({"error": str(exc)}, status_code=401)
1141
+
1142
+ try:
1143
+ payload = ClientRepositoryResolveRequest.model_validate(
1144
+ await request.json()
1145
+ )
1146
+ except ValidationError as exc:
1147
+ return JSONResponse(
1148
+ {
1149
+ "error": "Invalid repository resolve payload",
1150
+ "details": exc.errors(),
1151
+ },
1152
+ status_code=400,
1153
+ )
1154
+
1155
+ candidates = [payload.repo_name, payload.repo_path]
1156
+ normalized_remote = _normalize_repository_remote(payload.repo_url)
1157
+ if normalized_remote:
1158
+ candidates.append(normalized_remote)
1159
+
1160
+ if not _principal_can_access_candidates(principal, candidates):
1161
+ return JSONResponse(
1162
+ {"error": "Client is not allowed to resolve this repository"},
1163
+ status_code=403,
1164
+ )
1165
+
1166
+ try:
1167
+ result = await context.use_cases.resolve_repository_for_client(
1168
+ repo_name=payload.repo_name,
1169
+ repo_path=payload.repo_path,
1170
+ repo_url=payload.repo_url,
1171
+ default_branch=payload.default_branch,
1172
+ )
1173
+ except ValueError as exc:
1174
+ return JSONResponse({"error": str(exc)}, status_code=400)
1175
+ except Exception as exc:
1176
+ return JSONResponse({"error": str(exc)}, status_code=400)
1177
+
1178
+ repository_id = result["repository"]["id"]
1179
+ try:
1180
+ await context.store.create_audit_log(
1181
+ actor_type="client",
1182
+ actor_id=str(principal.client_id),
1183
+ event_type="repository.resolve",
1184
+ resource_type="repository",
1185
+ resource_id=repository_id,
1186
+ outcome="success",
1187
+ audit_metadata={
1188
+ "client_slug": principal.client_slug,
1189
+ "created": result["created"],
1190
+ "path": result["repository"]["path"],
1191
+ "name": result["repository"]["name"],
1192
+ },
1193
+ )
1194
+ except Exception:
1195
+ logger.exception("Failed to record client repository resolve audit log")
1196
+
1197
+ return JSONResponse(result, status_code=201 if result["created"] else 200)
1198
+
1199
+ return [
1200
+ Route("/v1/admin/setup", setup_api, methods=["POST"]),
1201
+ Route("/v1/admin/login", dashboard_login_api, methods=["POST"]),
1202
+ Route("/v1/admin/logout", dashboard_logout_api, methods=["POST"]),
1203
+ Route("/v1/admin/session", admin_session, methods=["GET"]),
1204
+ Route("/v1/admin/bootstrap-state", dashboard_bootstrap_state, methods=["GET"]),
1205
+ Route("/v1/auth/token-exchange", token_exchange, methods=["POST"]),
1206
+ Route("/v1/gateway/test-connection", gateway_test_connection, methods=["POST"]),
1207
+ Route("/v1/admin/tools", admin_tools, methods=["GET"]),
1208
+ Route("/v1/admin/clients", admin_clients, methods=["GET", "POST"]),
1209
+ Route(
1210
+ "/v1/admin/clients/{client_id:uuid}",
1211
+ client_detail,
1212
+ methods=["GET", "PATCH"],
1213
+ ),
1214
+ Route(
1215
+ "/v1/admin/clients/{client_id:uuid}/keys",
1216
+ client_key_rotate,
1217
+ methods=["POST"],
1218
+ ),
1219
+ Route(
1220
+ "/v1/admin/clients/{client_id:uuid}/keys/revoke",
1221
+ client_key_revoke,
1222
+ methods=["POST"],
1223
+ ),
1224
+ Route(
1225
+ "/v1/admin/onboarding/{client_id:uuid}", client_onboarding, methods=["GET"]
1226
+ ),
1227
+ Route("/v1/admin/audit", admin_audit, methods=["GET"]),
1228
+ # User management
1229
+ Route("/v1/admin/users", admin_users, methods=["GET", "POST"]),
1230
+ Route(
1231
+ "/v1/admin/users/{user_id:uuid}",
1232
+ user_detail,
1233
+ methods=["GET", "PATCH", "DELETE"],
1234
+ ),
1235
+ # Workflow management
1236
+ Route("/v1/admin/workflows", admin_workflows, methods=["GET", "POST"]),
1237
+ Route(
1238
+ "/v1/admin/workflows/{workflow_id:uuid}",
1239
+ workflow_detail,
1240
+ methods=["GET", "PATCH", "DELETE"],
1241
+ ),
1242
+ # Repository management
1243
+ Route("/v1/admin/repositories", admin_repositories, methods=["GET"]),
1244
+ Route(
1245
+ "/v1/admin/repositories/landscape", repository_landscape, methods=["GET"]
1246
+ ),
1247
+ Route(
1248
+ "/v1/admin/repositories/{repo_id:uuid}",
1249
+ repository_detail,
1250
+ methods=["GET", "PATCH", "DELETE"],
1251
+ ),
1252
+ Route(
1253
+ "/v1/admin/repositories/{repo_id:uuid}/branches",
1254
+ repository_branches,
1255
+ methods=["GET", "POST"],
1256
+ ),
1257
+ Route(
1258
+ "/v1/admin/repositories/{repo_id:uuid}/branches/{branch:str}",
1259
+ repository_branch_delete,
1260
+ methods=["DELETE"],
1261
+ ),
1262
+ Route(
1263
+ "/v1/admin/repositories/{repo_id:uuid}/branch-links",
1264
+ repository_branch_links,
1265
+ methods=["GET", "POST"],
1266
+ ),
1267
+ Route(
1268
+ "/v1/admin/repositories/{repo_id:uuid}/branch-links/{link_id:str}",
1269
+ repository_branch_link_delete,
1270
+ methods=["DELETE"],
1271
+ ),
1272
+ Route(
1273
+ "/v1/admin/repositories/{repo_id:uuid}/graph-map",
1274
+ repository_graph_map,
1275
+ methods=["GET"],
1276
+ ),
1277
+ Route(
1278
+ "/v1/admin/repositories/{repo_id:uuid}/graph-summary",
1279
+ repository_graph_summary,
1280
+ methods=["GET"],
1281
+ ),
1282
+ Route(
1283
+ "/v1/admin/repositories/{repo_id:uuid}/graph-search",
1284
+ repository_graph_search,
1285
+ methods=["GET"],
1286
+ ),
1287
+ Route(
1288
+ "/v1/admin/repositories/{repo_id:uuid}/graph-impact",
1289
+ repository_graph_impact,
1290
+ methods=["GET"],
1291
+ ),
1292
+ Route(
1293
+ "/v1/admin/repositories/{repo_id:uuid}/graph-sync",
1294
+ repository_graph_sync,
1295
+ methods=["POST"],
1296
+ ),
1297
+ Route(
1298
+ "/v1/client/repositories/resolve",
1299
+ client_repository_resolve,
1300
+ methods=["POST"],
1301
+ ),
1302
+ Route(
1303
+ "/v1/client/repositories/{repo_id:uuid}/graph-sync",
1304
+ client_repository_graph_sync,
1305
+ methods=["POST"],
1306
+ ),
1307
+ # Observability
1308
+ Route("/v1/admin/metrics-summary", admin_metrics_summary, methods=["GET"]),
1309
+ ]