sediment-api 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.
@@ -0,0 +1,1010 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Read-only CI and Session investigations over captured Facts.
3
+
4
+ Stored observations establish Session-to-commit identity. Optional mirror-based
5
+ call/file Attribution remains explicitly inferred and is recomputed per request.
6
+ No investigation result is persisted (ADR 0001).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from sediment_derive.session_commit import bind_session_commit_keys_result
12
+
13
+ import base64
14
+ import binascii
15
+ import json
16
+ import math
17
+ from collections import Counter
18
+ from collections.abc import Sequence
19
+ from dataclasses import asdict, dataclass, field, is_dataclass
20
+ from datetime import UTC, datetime
21
+ from typing import Any, Literal
22
+ from urllib.parse import urlencode
23
+
24
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
25
+ from fastapi.responses import Response
26
+ from pydantic import TypeAdapter
27
+ from sediment_core import (
28
+ AgentHarness,
29
+ CIProvider,
30
+ CIResult,
31
+ FactStore,
32
+ ForgeProvider,
33
+ ForgeHost,
34
+ ProviderRepositoryId,
35
+ RequiredRepoSlug,
36
+ RepositoryReadAmbiguous,
37
+ OperationalReportLimitExceeded,
38
+ GatewayProvider,
39
+ InteractionMode,
40
+ NonEmptyId,
41
+ WorkflowName,
42
+ normalize_commit_sha,
43
+ )
44
+ from sediment_core.models import AwareDatetime
45
+ from sediment_core.store import (
46
+ CIOutcomeProjection,
47
+ PushProjection,
48
+ SessionDossierProjection,
49
+ SessionTimelineProjection,
50
+ )
51
+ from sediment_derive import (
52
+ AttributionSource,
53
+ MirrorManager,
54
+ InferenceCall,
55
+ derive_attributions,
56
+ inference_fact_id,
57
+ inference_gateway_provider,
58
+ inference_model,
59
+ inference_user_id,
60
+ join_decisions_by_call_id,
61
+ )
62
+
63
+ from sediment_derive.repository_context import read_repository_context
64
+ from sediment_derive.repository_identity import (
65
+ RepositoryIdentity,
66
+ RepositoryIdentitySkipReason,
67
+ IdentifiedRepositoryKey,
68
+ CommitKey,
69
+ RepositoryKey,
70
+ repository_identity_of,
71
+ repository_read_key,
72
+ repository_sort_key,
73
+ )
74
+
75
+ from ..config import settings
76
+ from ..deps import get_store, verify_operator_token
77
+
78
+ _SESSION_DOSSIER_LIMIT = 500
79
+
80
+
81
+ router = APIRouter(tags=["query"])
82
+
83
+
84
+ _QUERY_REPRESENTATION_RESPONSES = {
85
+ 503: {
86
+ "description": "Database unavailable, or work capacity or execution budget exceeded."
87
+ },
88
+ 409: {
89
+ "description": "Repository selection is ambiguous, complete evidence exceeds its bound, or query content contains a non-finite number. The response carries a closed detail.reason."
90
+ },
91
+ }
92
+
93
+
94
+ def _validate_query_numbers(value: Any) -> None:
95
+ """Decline only non-finite numbers, including nested content and object keys."""
96
+ if isinstance(value, float) and not math.isfinite(value):
97
+ raise HTTPException(status_code=409, detail={"reason": "non_finite_number"})
98
+ if isinstance(value, dict):
99
+ for key, item in value.items():
100
+ _validate_query_numbers(key)
101
+ _validate_query_numbers(item)
102
+ elif isinstance(value, (list, tuple)):
103
+ for item in value:
104
+ _validate_query_numbers(item)
105
+
106
+
107
+ def _query_response(
108
+ value: Any, contract: Any, *, exclude_none: bool = False
109
+ ) -> Response:
110
+ """Validate the declared response, then preserve content through ASCII JSON.
111
+
112
+ Pydantic's JSON serializer requires UTF-8 for every string. Python-mode
113
+ serialization keeps canonical descriptive surrogates for JSON escaping.
114
+ """
115
+ adapter = TypeAdapter(contract)
116
+ validated = adapter.validate_python(asdict(value) if is_dataclass(value) else value)
117
+ payload = adapter.dump_python(validated, mode="python", exclude_none=exclude_none)
118
+ _validate_query_numbers(payload)
119
+
120
+ def encode_timestamp(item: Any) -> str:
121
+ if isinstance(item, datetime):
122
+ return TypeAdapter(datetime).dump_python(item, mode="json")
123
+ raise TypeError(f"unsupported query response value: {type(item).__name__}")
124
+
125
+ return Response(
126
+ content=json.dumps(
127
+ payload,
128
+ ensure_ascii=True,
129
+ allow_nan=False,
130
+ separators=(",", ":"),
131
+ default=encode_timestamp,
132
+ ),
133
+ media_type="application/json",
134
+ )
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class CIOutcomeSummary:
139
+ outcome_id: str
140
+ provider: CIProvider
141
+ run_id: str
142
+ run_attempt: int | None
143
+ repo: str
144
+ commit_sha: str
145
+ branch: str
146
+ result: CIResult
147
+ workflow_name: str
148
+ workflow_id: str | None
149
+ run_url: str | None
150
+ provider_result: str | None
151
+ error_type: str | None
152
+ reason: str | None
153
+ pr_number: int | None
154
+ captured_at: datetime
155
+ commit_query: str
156
+ repository_identity: RepositoryIdentity | None = None
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class CIOutcomeFound:
161
+ found: Literal[True]
162
+ outcome: CIOutcomeSummary
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class CIOutcomeNotFound:
167
+ found: Literal[False]
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class CIOutcomePage:
172
+ result: CIResult
173
+ outcomes: list[CIOutcomeSummary]
174
+ next_cursor: str | None
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class SessionFactCoverageResponse:
179
+ total: int
180
+ visible: int
181
+ quarantined: int
182
+
183
+
184
+ @dataclass(frozen=True)
185
+ class SessionCoverageResponse:
186
+ inference_calls: SessionFactCoverageResponse
187
+ developer_decisions: SessionFactCoverageResponse
188
+ edit_observations: SessionFactCoverageResponse
189
+ rejected_edits: SessionFactCoverageResponse
190
+ retry_linkages: SessionFactCoverageResponse
191
+
192
+
193
+ SessionEventType = Literal[
194
+ "inference_call",
195
+ "developer_decision",
196
+ "edit_observation",
197
+ "rejected_edit",
198
+ "retry_linkage",
199
+ ]
200
+ SessionGap = Literal[
201
+ "no_inference_calls_observed",
202
+ "no_developer_decisions_observed",
203
+ "no_edit_observations_observed",
204
+ "no_rejected_edits_observed",
205
+ "no_retry_linkages_observed",
206
+ "session_commit_unobserved",
207
+ "no_exact_push_receipts_observed",
208
+ "no_ci_outcomes_observed",
209
+ ]
210
+
211
+
212
+ @dataclass(frozen=True)
213
+ class SessionTimelineEvent:
214
+ event_type: SessionEventType
215
+ fact_id: str
216
+ occurred_at: datetime
217
+ gateway_provider: GatewayProvider | None = None
218
+ model_provider: str | None = None
219
+ model: str | None = None
220
+ input_tokens: int | None = None
221
+ output_tokens: int | None = None
222
+ duration_ms: int | None = None
223
+ model_call_id: str | None = None
224
+ agent_harness: AgentHarness | None = None
225
+ accepted: bool | None = None
226
+ explicit: bool | None = None
227
+ interaction_mode: InteractionMode | None = None
228
+ has_file_path: bool | None = None
229
+ call_id: str | None = None
230
+ commit_sha: str | None = None
231
+ external_lines_added_present: bool | None = None
232
+ external_lines_removed_present: bool | None = None
233
+ external_lines_added: int | None = None
234
+ external_lines_removed: int | None = None
235
+ tool_name: str | None = None
236
+ rejected_call_id: str | None = None
237
+ accepted_call_id: str | None = None
238
+
239
+
240
+ @dataclass(frozen=True)
241
+ class AttributedCommitSummary:
242
+ """Observation-backed Session edge with compatibility Attribution fields.
243
+
244
+ ``attribution_sources`` is empty. Similarity extrema and ``attributed_files``
245
+ are unavailable (None), so the Session dossier omits them from JSON.
246
+ """
247
+
248
+ repo: str
249
+ commit_sha: str
250
+ attribution_sources: list[AttributionSource]
251
+ minimum_similarity: float | None
252
+ maximum_similarity: float | None
253
+ attributed_files: int | None
254
+ session_commit_observation_ids: tuple[NonEmptyId, ...] = ()
255
+ repository_identity: RepositoryIdentity | None = None
256
+
257
+
258
+ @dataclass(frozen=True)
259
+ class PushSummary:
260
+ push_id: str
261
+ provider: ForgeProvider
262
+ repo: str
263
+ ref: str
264
+ before_sha: str
265
+ after_sha: str
266
+ forced: bool
267
+ captured_at: datetime
268
+ match_kind: Literal["after_sha"] = "after_sha"
269
+ repository_identity: RepositoryIdentity | None = None
270
+
271
+
272
+ @dataclass(frozen=True)
273
+ class SessionDossierFound:
274
+ found: Literal[True]
275
+ session_id: str
276
+ first_observed_at: datetime
277
+ last_observed_at: datetime
278
+ coverage: SessionCoverageResponse
279
+ omitted_events: int
280
+ gaps: list[SessionGap]
281
+ timeline: list[SessionTimelineEvent]
282
+ attributed_commits: list[AttributedCommitSummary]
283
+ pushes: list[PushSummary]
284
+ ci_outcomes: list[SessionCIOutcomeSummary]
285
+ session_commit_unobserved: int = 0
286
+ repository_skipped: dict[RepositoryIdentitySkipReason, int] = field(
287
+ default_factory=dict
288
+ )
289
+
290
+
291
+ @dataclass(frozen=True)
292
+ class SessionDossierNotFound:
293
+ found: Literal[False]
294
+
295
+
296
+ @dataclass(frozen=True)
297
+ class SessionCIOutcomeSummary:
298
+ """CI navigation metadata without free-form provider content."""
299
+
300
+ outcome_id: str
301
+ provider: CIProvider
302
+ run_id: str
303
+ run_attempt: int | None
304
+ repo: str
305
+ commit_sha: str
306
+ branch: str
307
+ result: CIResult
308
+ workflow_name: str
309
+ workflow_id: str | None
310
+ run_url: str | None
311
+ provider_result: str | None
312
+ error_type: str | None
313
+ pr_number: int | None
314
+ captured_at: datetime
315
+ commit_query: str
316
+ repository_identity: RepositoryIdentity | None = None
317
+
318
+
319
+ def _commit_query_link(outcome: CIOutcomeProjection, as_of: datetime | None) -> str:
320
+ identity = repository_identity_of(outcome)
321
+ params = {}
322
+ if identity is not None:
323
+ params.update(
324
+ repository_provider=identity.provider.value,
325
+ repository_host=identity.host,
326
+ repository_id=identity.repository_id,
327
+ )
328
+ if as_of is not None:
329
+ params["as_of"] = as_of.astimezone(UTC).isoformat()
330
+ path = f"/query/commit/{outcome.commit_sha}"
331
+ return f"{path}?{urlencode(params)}" if params else path
332
+
333
+
334
+ def _selector_identity(provider, host, repository_id) -> RepositoryIdentity | None:
335
+ supplied = (provider is not None, host is not None, repository_id is not None)
336
+ if any(supplied) and not all(supplied):
337
+ raise HTTPException(
338
+ status_code=422, detail="repository identity requires all three components"
339
+ )
340
+ return RepositoryIdentity(provider, host, repository_id) if all(supplied) else None
341
+
342
+
343
+ def _query_repository(context, repo, identity) -> RepositoryKey | None:
344
+ try:
345
+ return context.select_repository(
346
+ settings.org_id, repo=repo, repository_identity=identity
347
+ )
348
+ except RepositoryReadAmbiguous:
349
+ raise HTTPException(
350
+ status_code=409, detail={"reason": "repository_selector_ambiguous"}
351
+ ) from None
352
+ except ValueError:
353
+ raise HTTPException(
354
+ status_code=422,
355
+ detail="repository selector conflicts with declared evidence",
356
+ ) from None
357
+
358
+
359
+ def _ci_summary(
360
+ outcome: CIOutcomeProjection, *, as_of: datetime | None = None
361
+ ) -> CIOutcomeSummary:
362
+ return CIOutcomeSummary(
363
+ outcome_id=outcome.outcome_id,
364
+ provider=outcome.provider,
365
+ run_id=outcome.run_id,
366
+ run_attempt=outcome.run_attempt,
367
+ repo=outcome.repo,
368
+ commit_sha=outcome.commit_sha,
369
+ branch=outcome.branch,
370
+ result=outcome.result,
371
+ workflow_name=outcome.workflow_name,
372
+ workflow_id=outcome.workflow_id,
373
+ run_url=outcome.run_url,
374
+ provider_result=outcome.provider_result,
375
+ error_type=outcome.error_type,
376
+ reason=outcome.reason,
377
+ pr_number=outcome.pr_number,
378
+ captured_at=outcome.captured_at,
379
+ commit_query=_commit_query_link(outcome, as_of),
380
+ repository_identity=repository_identity_of(outcome),
381
+ )
382
+
383
+
384
+ def _session_ci_summary(
385
+ outcome: CIOutcomeProjection, *, as_of: datetime | None = None
386
+ ) -> SessionCIOutcomeSummary:
387
+ return SessionCIOutcomeSummary(
388
+ outcome_id=outcome.outcome_id,
389
+ provider=outcome.provider,
390
+ run_id=outcome.run_id,
391
+ run_attempt=outcome.run_attempt,
392
+ repo=outcome.repo,
393
+ commit_sha=outcome.commit_sha,
394
+ branch=outcome.branch,
395
+ result=outcome.result,
396
+ workflow_name=outcome.workflow_name,
397
+ workflow_id=outcome.workflow_id,
398
+ run_url=outcome.run_url,
399
+ provider_result=outcome.provider_result,
400
+ error_type=outcome.error_type,
401
+ pr_number=outcome.pr_number,
402
+ captured_at=outcome.captured_at,
403
+ commit_query=_commit_query_link(outcome, as_of),
404
+ repository_identity=repository_identity_of(outcome),
405
+ )
406
+
407
+
408
+ def _encode_ci_cursor(captured_at: datetime, outcome_id: str, filter_key: str) -> str:
409
+ payload = json.dumps(
410
+ [captured_at.isoformat(), outcome_id, filter_key], separators=(",", ":")
411
+ ).encode()
412
+ return base64.urlsafe_b64encode(payload).decode().rstrip("=")
413
+
414
+
415
+ def _decode_ci_cursor(cursor: str, filter_key: str) -> tuple[datetime, str]:
416
+ try:
417
+ padding = "=" * (-len(cursor) % 4)
418
+ captured_at, outcome_id, cursor_filter_key = json.loads(
419
+ base64.urlsafe_b64decode(cursor + padding).decode()
420
+ )
421
+ parsed = datetime.fromisoformat(captured_at)
422
+ if parsed.tzinfo is None:
423
+ raise ValueError
424
+ outcome_id = TypeAdapter(NonEmptyId).validate_python(outcome_id)
425
+ if cursor_filter_key != filter_key:
426
+ raise HTTPException(
427
+ status_code=422,
428
+ detail="CI outcome cursor doesn't match filters",
429
+ )
430
+ return parsed, outcome_id
431
+ except HTTPException:
432
+ raise
433
+ except (
434
+ ValueError,
435
+ TypeError,
436
+ binascii.Error,
437
+ json.JSONDecodeError,
438
+ UnicodeDecodeError,
439
+ ):
440
+ raise HTTPException(
441
+ status_code=422, detail="invalid CI outcome cursor"
442
+ ) from None
443
+
444
+
445
+ def _ci_filter_key(
446
+ *,
447
+ repository_key: RepositoryKey,
448
+ org_id: str,
449
+ as_of: datetime,
450
+ quarantine_revision: int,
451
+ captured_after: datetime,
452
+ captured_before: datetime,
453
+ result: CIResult,
454
+ workflow_name: str | None,
455
+ pr_number: int | None,
456
+ ) -> str:
457
+ return json.dumps(
458
+ [
459
+ org_id,
460
+ repository_sort_key(repository_key),
461
+ as_of.astimezone(UTC).isoformat(),
462
+ quarantine_revision,
463
+ captured_after.astimezone(UTC).isoformat(),
464
+ captured_before.astimezone(UTC).isoformat(),
465
+ result.value,
466
+ workflow_name,
467
+ pr_number,
468
+ ],
469
+ separators=(",", ":"),
470
+ )
471
+
472
+
473
+ def _timeline_event(row: SessionTimelineProjection) -> SessionTimelineEvent:
474
+ return SessionTimelineEvent(**row.__dict__)
475
+
476
+
477
+ def _fact_coverage(
478
+ dossier: SessionDossierProjection, fact_type: str
479
+ ) -> SessionFactCoverageResponse:
480
+ coverage = dossier.coverage[fact_type]
481
+ return SessionFactCoverageResponse(
482
+ total=coverage.total,
483
+ visible=coverage.visible,
484
+ quarantined=coverage.quarantined,
485
+ )
486
+
487
+
488
+ def _push_summary(push: PushProjection) -> PushSummary:
489
+ return PushSummary(
490
+ push_id=push.push_id,
491
+ provider=ForgeProvider(push.provider),
492
+ repo=push.repo,
493
+ ref=push.ref,
494
+ before_sha=push.before_sha,
495
+ after_sha=push.after_sha,
496
+ forced=push.forced,
497
+ captured_at=push.captured_at,
498
+ repository_identity=repository_identity_of(push),
499
+ )
500
+
501
+
502
+ @router.get(
503
+ "/ci/outcome",
504
+ response_model=CIOutcomeFound | CIOutcomeNotFound,
505
+ responses=_QUERY_REPRESENTATION_RESPONSES,
506
+ )
507
+ def query_ci_outcome(
508
+ provider: CIProvider,
509
+ run_id: NonEmptyId,
510
+ run_attempt: int | None = Query(default=None, ge=1),
511
+ repo: RequiredRepoSlug | None = None,
512
+ repository_provider: ForgeProvider | None = None,
513
+ repository_host: ForgeHost | None = None,
514
+ repository_id: ProviderRepositoryId | None = None,
515
+ as_of: AwareDatetime | None = None,
516
+ _: None = Depends(verify_operator_token),
517
+ store: FactStore = Depends(get_store),
518
+ ) -> Response:
519
+ """Return one exact CI run receipt; ambiguous forge namespaces require a selector."""
520
+ identity = _selector_identity(repository_provider, repository_host, repository_id)
521
+ try:
522
+ with store.read_snapshot() as snapshot:
523
+ context = read_repository_context(snapshot, settings.org_id, as_of=as_of)
524
+ key = (
525
+ _query_repository(context, repo, identity)
526
+ if repo is not None or identity is not None
527
+ else None
528
+ )
529
+ if key is None and (repo is not None or identity is not None):
530
+ outcome = None
531
+ else:
532
+ outcome = snapshot.read_ci_outcome_by_run(
533
+ settings.org_id,
534
+ provider,
535
+ run_id,
536
+ run_attempt=run_attempt,
537
+ repository_key=repository_read_key(key)
538
+ if key is not None
539
+ else None,
540
+ captured_through=context.as_of,
541
+ )
542
+ except RepositoryReadAmbiguous:
543
+ raise HTTPException(
544
+ status_code=409, detail={"reason": "repository_selector_ambiguous"}
545
+ ) from None
546
+ except OperationalReportLimitExceeded:
547
+ raise HTTPException(
548
+ status_code=409, detail={"reason": "repository_evidence_limit"}
549
+ ) from None
550
+ if outcome is None:
551
+ return _query_response(
552
+ CIOutcomeNotFound(found=False), CIOutcomeFound | CIOutcomeNotFound
553
+ )
554
+ return _query_response(
555
+ CIOutcomeFound(found=True, outcome=_ci_summary(outcome, as_of=context.as_of)),
556
+ CIOutcomeFound | CIOutcomeNotFound,
557
+ )
558
+
559
+
560
+ @router.get(
561
+ "/ci/failures",
562
+ response_model=CIOutcomePage,
563
+ responses=_QUERY_REPRESENTATION_RESPONSES,
564
+ )
565
+ def query_ci_failures(
566
+ captured_after: AwareDatetime,
567
+ captured_before: AwareDatetime,
568
+ repo: RequiredRepoSlug | None = None,
569
+ repository_provider: ForgeProvider | None = None,
570
+ repository_host: ForgeHost | None = None,
571
+ repository_id: ProviderRepositoryId | None = None,
572
+ as_of: AwareDatetime | None = None,
573
+ result: CIResult = CIResult.FAILED,
574
+ workflow_name: WorkflowName | None = None,
575
+ pr_number: int | None = Query(default=None, ge=1),
576
+ limit: int = Query(default=50, ge=1, le=100),
577
+ cursor: str | None = None,
578
+ _: None = Depends(verify_operator_token),
579
+ store: FactStore = Depends(get_store),
580
+ ) -> Response:
581
+ """Page exact CI receipts under one repository identity and historical boundary.
582
+
583
+ The default identity boundary is ``captured_before``. Capture bounds remain
584
+ half-open; an explicit ``as_of`` adds an inclusive evidence ceiling. Cursors
585
+ bind organization, qualified repository, boundary, filters, and quarantine
586
+ revision. They don't preserve a database transaction between requests.
587
+ """
588
+ if captured_after >= captured_before:
589
+ raise HTTPException(
590
+ status_code=422, detail="captured_after must precede captured_before"
591
+ )
592
+ identity = _selector_identity(repository_provider, repository_host, repository_id)
593
+ if repo is None and identity is None:
594
+ raise HTTPException(
595
+ status_code=422, detail="CI summaries require a repository selector"
596
+ )
597
+ boundary = as_of if as_of is not None else captured_before
598
+ try:
599
+ with store.read_snapshot() as snapshot:
600
+ context = read_repository_context(snapshot, settings.org_id, as_of=boundary)
601
+ key = _query_repository(context, repo, identity)
602
+ if key is None:
603
+ if cursor is not None:
604
+ raise HTTPException(
605
+ status_code=422,
606
+ detail="CI outcome cursor doesn't match filters",
607
+ )
608
+ return _query_response(
609
+ CIOutcomePage(result=result, outcomes=[], next_cursor=None),
610
+ CIOutcomePage,
611
+ )
612
+ filter_key = _ci_filter_key(
613
+ repository_key=key,
614
+ org_id=settings.org_id,
615
+ as_of=context.as_of,
616
+ quarantine_revision=snapshot.quarantine_revision(settings.org_id),
617
+ captured_after=captured_after,
618
+ captured_before=captured_before,
619
+ result=result,
620
+ workflow_name=workflow_name,
621
+ pr_number=pr_number,
622
+ )
623
+ before = (
624
+ _decode_ci_cursor(cursor, filter_key) if cursor is not None else None
625
+ )
626
+ outcomes = snapshot.read_ci_outcome_summaries(
627
+ settings.org_id,
628
+ repository_key=repository_read_key(key),
629
+ result=result,
630
+ captured_between=(captured_after, captured_before),
631
+ captured_through=context.as_of,
632
+ before=before,
633
+ workflow_name=workflow_name,
634
+ pr_number=pr_number,
635
+ limit=limit + 1,
636
+ )
637
+ except OperationalReportLimitExceeded:
638
+ raise HTTPException(
639
+ status_code=409, detail={"reason": "repository_evidence_limit"}
640
+ ) from None
641
+ page = outcomes[:limit]
642
+ next_cursor = None
643
+ if len(outcomes) > limit:
644
+ last = page[-1]
645
+ next_cursor = _encode_ci_cursor(last.captured_at, last.outcome_id, filter_key)
646
+ return _query_response(
647
+ CIOutcomePage(
648
+ result=result,
649
+ outcomes=[_ci_summary(outcome, as_of=context.as_of) for outcome in page],
650
+ next_cursor=next_cursor,
651
+ ),
652
+ CIOutcomePage,
653
+ )
654
+
655
+
656
+ @router.get(
657
+ "/session/{session_id}",
658
+ response_model=SessionDossierFound | SessionDossierNotFound,
659
+ response_model_exclude_none=True,
660
+ responses={
661
+ 503: {
662
+ "description": "Database unavailable, or work capacity or execution budget exceeded."
663
+ },
664
+ 409: {
665
+ "description": "Session evidence exceeds the bounded read, or query content contains a non-finite number (detail.reason: non_finite_number)."
666
+ },
667
+ },
668
+ )
669
+ async def query_session_dossier(
670
+ session_id: NonEmptyId,
671
+ request: Request,
672
+ _: None = Depends(verify_operator_token),
673
+ ) -> Response:
674
+ """Return one bounded, metadata-only Session evidence dossier.
675
+
676
+ Observation-backed commit edges keep ``attribution_sources`` empty. Similarity
677
+ extrema and ``attributed_files`` are unavailable and omitted from JSON.
678
+ """
679
+ return await request.app.state.workers.run("session", {"session_id": session_id})
680
+
681
+
682
+ def _run_session_query(
683
+ session_id: str, store: FactStore
684
+ ) -> SessionDossierFound | SessionDossierNotFound:
685
+ with store.read_snapshot() as snapshot:
686
+ dossier = snapshot.read_session_dossier(
687
+ settings.org_id, session_id, limit=_SESSION_DOSSIER_LIMIT
688
+ )
689
+ if dossier is None:
690
+ return SessionDossierNotFound(found=False)
691
+
692
+ # One request boundary and complete context precede the Session filter.
693
+ boundary = datetime.now(UTC)
694
+ context = read_repository_context(snapshot, settings.org_id, as_of=boundary)
695
+ observations = snapshot.read_session_commit_observations(
696
+ settings.org_id,
697
+ session_ids={session_id},
698
+ as_of=boundary,
699
+ limit=_SESSION_DOSSIER_LIMIT,
700
+ )
701
+ binding_result = bind_session_commit_keys_result(
702
+ observations,
703
+ settings.org_id,
704
+ as_of=boundary,
705
+ repository_context=context,
706
+ )
707
+ bindings = binding_result.bindings
708
+ attributed_commits = [
709
+ AttributedCommitSummary(
710
+ repo=context.repo_for(commit.repository),
711
+ commit_sha=commit.commit_sha,
712
+ repository_identity=commit.repository.identity
713
+ if isinstance(commit.repository, IdentifiedRepositoryKey)
714
+ else None,
715
+ attribution_sources=[],
716
+ minimum_similarity=None,
717
+ maximum_similarity=None,
718
+ attributed_files=None,
719
+ session_commit_observation_ids=tuple(
720
+ sorted({item.observation_id for item in facts})
721
+ ),
722
+ )
723
+ for (commit, _session), facts in bindings.items()
724
+ ]
725
+ repository_commits = {
726
+ (repository_read_key(commit.repository), commit.commit_sha)
727
+ for commit, _session in bindings
728
+ }
729
+ pushes, ci_outcomes = snapshot.read_delivery_summaries(
730
+ settings.org_id,
731
+ repository_commits=repository_commits,
732
+ captured_through=boundary,
733
+ limit=_SESSION_DOSSIER_LIMIT,
734
+ )
735
+ gap_pairs: tuple[tuple[str, SessionGap], ...] = (
736
+ ("inference_calls", "no_inference_calls_observed"),
737
+ ("developer_decisions", "no_developer_decisions_observed"),
738
+ ("edit_observations", "no_edit_observations_observed"),
739
+ ("rejected_edits", "no_rejected_edits_observed"),
740
+ ("retry_linkages", "no_retry_linkages_observed"),
741
+ )
742
+ gaps: list[SessionGap] = [
743
+ gap for fact_type, gap in gap_pairs if dossier.coverage[fact_type].visible == 0
744
+ ]
745
+ if not attributed_commits:
746
+ gaps.append("session_commit_unobserved")
747
+ if attributed_commits and not pushes:
748
+ gaps.append("no_exact_push_receipts_observed")
749
+ if attributed_commits and not ci_outcomes:
750
+ gaps.append("no_ci_outcomes_observed")
751
+ return SessionDossierFound(
752
+ found=True,
753
+ session_id=dossier.session_id,
754
+ first_observed_at=dossier.first_observed_at,
755
+ last_observed_at=dossier.last_observed_at,
756
+ coverage=SessionCoverageResponse(
757
+ inference_calls=_fact_coverage(dossier, "inference_calls"),
758
+ developer_decisions=_fact_coverage(dossier, "developer_decisions"),
759
+ edit_observations=_fact_coverage(dossier, "edit_observations"),
760
+ rejected_edits=_fact_coverage(dossier, "rejected_edits"),
761
+ retry_linkages=_fact_coverage(dossier, "retry_linkages"),
762
+ ),
763
+ omitted_events=sum(
764
+ coverage.quarantined for coverage in dossier.coverage.values()
765
+ ),
766
+ gaps=gaps,
767
+ timeline=[_timeline_event(row) for row in dossier.timeline],
768
+ attributed_commits=attributed_commits,
769
+ session_commit_unobserved=int(not attributed_commits),
770
+ repository_skipped=dict(binding_result.skipped),
771
+ pushes=[_push_summary(push) for push in pushes],
772
+ ci_outcomes=[
773
+ _session_ci_summary(outcome, as_of=boundary) for outcome in ci_outcomes
774
+ ],
775
+ )
776
+
777
+
778
+ def _valid_sha(sha: str) -> str:
779
+ """The shared CommitSha definition as a clean 422: full-length
780
+ 40/64 hex, lowercased — so an uppercase or SHA-256 query matches the
781
+ normalized facts instead of missing or 422ing."""
782
+ try:
783
+ return normalize_commit_sha(sha)
784
+ except ValueError as exc:
785
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
786
+
787
+
788
+ @router.get(
789
+ "/commit/{sha}",
790
+ response_model=dict[str, Any],
791
+ responses=_QUERY_REPRESENTATION_RESPONSES,
792
+ )
793
+ async def query_commit(
794
+ sha: str,
795
+ request: Request,
796
+ repo: RequiredRepoSlug | None = None,
797
+ repository_provider: ForgeProvider | None = None,
798
+ repository_host: ForgeHost | None = None,
799
+ repository_id: ProviderRepositoryId | None = None,
800
+ as_of: AwareDatetime | None = None,
801
+ _: None = Depends(verify_operator_token),
802
+ ) -> Response:
803
+ """Investigate exact commit evidence in separate repository lifetimes.
804
+
805
+ Captured observations establish Session associations. Call-to-file Attribution
806
+ remains inferred. Optional repository selectors and an inclusive ``as_of``
807
+ bound all supporting evidence; otherwise one request instant supplies the bound.
808
+ A bounded child process owns the Derivation and its database connection.
809
+ Capacity and execution deadlines return 503 after process cleanup.
810
+ """
811
+ commit_sha = _valid_sha(sha)
812
+ identity = _selector_identity(repository_provider, repository_host, repository_id)
813
+ return await request.app.state.workers.run(
814
+ "commit",
815
+ {
816
+ "sha": commit_sha,
817
+ "repo": repo,
818
+ "repository_identity": asdict(identity) if identity is not None else None,
819
+ "as_of": as_of.isoformat() if as_of is not None else None,
820
+ },
821
+ )
822
+
823
+
824
+ def _run_query(
825
+ commit_sha: str,
826
+ store: FactStore,
827
+ *,
828
+ repo: RequiredRepoSlug | None = None,
829
+ repository_identity: RepositoryIdentity | None = None,
830
+ as_of: datetime | None = None,
831
+ ) -> dict[str, Any]:
832
+ """Read exact CI/Session evidence beside labeled inferred call diagnostics."""
833
+ boundary = as_of if as_of is not None else datetime.now(UTC)
834
+ with store.read_snapshot() as snapshot:
835
+ context = read_repository_context(snapshot, settings.org_id, as_of=boundary)
836
+ selected = repo is not None or repository_identity is not None
837
+ selected_key = (
838
+ _query_repository(context, repo, repository_identity) if selected else None
839
+ )
840
+ if selected and selected_key is None:
841
+ return {"commit_sha": commit_sha, "attributed": False}
842
+ observations = [
843
+ item
844
+ for item in snapshot.read_session_commit_observations(
845
+ settings.org_id, as_of=boundary
846
+ )
847
+ if item.commit_sha == commit_sha
848
+ ]
849
+ binding_result = bind_session_commit_keys_result(
850
+ observations,
851
+ settings.org_id,
852
+ as_of=boundary,
853
+ repository_context=context,
854
+ )
855
+ bindings = {
856
+ key: facts
857
+ for key, facts in binding_result.bindings.items()
858
+ if not selected or key[0].repository == selected_key
859
+ }
860
+ skipped = Counter(binding_result.skipped)
861
+ matching = {}
862
+ if settings.mirror_path:
863
+ for item in derive_attributions(
864
+ snapshot,
865
+ MirrorManager(settings.mirror_path),
866
+ settings.org_id,
867
+ repository_context=context,
868
+ as_of=boundary,
869
+ ):
870
+ if item.commit_sha != commit_sha:
871
+ continue
872
+ resolution = context.resolve_reference(
873
+ item.org_id,
874
+ item.repo,
875
+ repository_identity=item.repository_identity,
876
+ )
877
+ if resolution.key is None:
878
+ skipped[resolution.reason] += 1
879
+ continue
880
+ if not selected or resolution.key == selected_key:
881
+ matching.setdefault(resolution.key, []).append(item)
882
+ commit_ci = {}
883
+ unresolved_ci = []
884
+ for item in snapshot.read_ci_outcomes(
885
+ settings.org_id, captured_through=boundary
886
+ ):
887
+ if item.commit_sha != commit_sha:
888
+ continue
889
+ resolution = context.resolve_fact(item)
890
+ if resolution.key is None:
891
+ skipped[resolution.reason] += 1
892
+ if not selected:
893
+ unresolved_ci.append(item.model_dump(mode="python"))
894
+ continue
895
+ if not selected or resolution.key == selected_key:
896
+ commit_ci.setdefault(resolution.key, []).append(item)
897
+ if not bindings and not matching and not commit_ci and not skipped:
898
+ return {"commit_sha": commit_sha, "attributed": False}
899
+ stored = [
900
+ item
901
+ for item in snapshot.read_inference_calls(settings.org_id)
902
+ if item.observed_at.astimezone(UTC) <= boundary.astimezone(UTC)
903
+ ]
904
+ by_inference_call_id = {inference_fact_id(item): item for item in stored}
905
+ joined = join_decisions_by_call_id(
906
+ stored,
907
+ snapshot.read_decisions(settings.org_id, captured_through=boundary),
908
+ )
909
+ repository_keys = (
910
+ set(matching)
911
+ | set(commit_ci)
912
+ | {commit.repository for commit, _session in bindings}
913
+ )
914
+ repos = []
915
+ for repository_key in sorted(repository_keys, key=repository_sort_key):
916
+ best = {}
917
+ for item in matching.get(repository_key, ()):
918
+ score = (item.similarity_score, item.file_path)
919
+ held = best.get(item.inference_call_id)
920
+ if held is None or score > held[0]:
921
+ best[item.inference_call_id] = (
922
+ score,
923
+ item.session_id,
924
+ item.attribution_source.value,
925
+ )
926
+ calls = _build_inference_calls(
927
+ by_inference_call_id,
928
+ [(cid, sid, source) for cid, (_, sid, source) in sorted(best.items())],
929
+ )
930
+ for call in calls:
931
+ call["relationship"] = "inferred_call_to_file"
932
+ observed_sessions = [
933
+ {
934
+ "session_id": session_id,
935
+ "session_commit_observation_ids": sorted(
936
+ {item.observation_id for item in facts}
937
+ ),
938
+ }
939
+ for (commit, session_id), facts in bindings.items()
940
+ if commit.repository == repository_key
941
+ ]
942
+ unobserved = {
943
+ (CommitKey(repository_key, item.commit_sha), item.session_id)
944
+ for item in matching.get(repository_key, ())
945
+ if (CommitKey(repository_key, item.commit_sha), item.session_id)
946
+ not in bindings
947
+ }
948
+ repos.append(
949
+ {
950
+ "repo": context.repo_for(repository_key),
951
+ "repository_identity": asdict(repository_key.identity)
952
+ if isinstance(repository_key, IdentifiedRepositoryKey)
953
+ else None,
954
+ "observed_repo_slugs": list(
955
+ context.observed_repo_slugs(repository_key)
956
+ ),
957
+ "inference_calls": calls,
958
+ "decisions": sum(
959
+ len(ds) for cid, ds in joined.items() if cid in best
960
+ ),
961
+ "observed_sessions": observed_sessions,
962
+ "session_commit_unobserved": len(unobserved),
963
+ "ci_outcomes": [
964
+ item.model_dump(mode="python")
965
+ for item in commit_ci.get(repository_key, ())
966
+ ],
967
+ }
968
+ )
969
+ result = {
970
+ "commit_sha": commit_sha,
971
+ "attributed": bool(bindings),
972
+ "repos": repos,
973
+ }
974
+ if skipped:
975
+ result["repository_skipped"] = dict(sorted(skipped.items()))
976
+ if unresolved_ci:
977
+ result["unresolved_ci_outcomes"] = unresolved_ci
978
+ return result
979
+
980
+
981
+ def _build_inference_calls(
982
+ by_inference_call_id: dict[str, InferenceCall],
983
+ entries: Sequence[tuple[str, str, str]],
984
+ ) -> list[dict[str, Any]]:
985
+ """Resolve inference-call, session, and attribution-source triples into the
986
+ endpoint's inference-call shape, reading model/provider/user_id from stored
987
+ facts. ``session_id`` and ``attribution_source`` come from the attribution, not
988
+ the fact row. The session_id on the fact is
989
+ the session that produced the inference call (ADR 0002), and attribution
990
+ names the attribution source (`git_notes` or `jaccard`)."""
991
+ result: list[dict[str, Any]] = []
992
+ for inference_call_id, session_id, attribution_source in entries:
993
+ call = by_inference_call_id.get(inference_call_id)
994
+ # An inference call may have been quarantined between attribution and
995
+ # read: skip, don't 500. The session_id from the attribution is
996
+ # still trustable (it was read non-quarantined during derivation).
997
+ if call is None:
998
+ continue
999
+ result.append(
1000
+ {
1001
+ "inference_call_id": inference_call_id,
1002
+ "session_id": session_id,
1003
+ "gateway_provider": inference_gateway_provider(call).value,
1004
+ "model_provider": call.model_provider,
1005
+ "model": inference_model(call),
1006
+ "user_id": inference_user_id(call),
1007
+ "attribution_source": attribution_source,
1008
+ }
1009
+ )
1010
+ return result