sidegraph 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.
- sidegraph/__init__.py +37 -0
- sidegraph/anchoring.py +246 -0
- sidegraph/bootstrap/__init__.py +49 -0
- sidegraph/bootstrap/apply.py +603 -0
- sidegraph/bootstrap/catalog.py +92 -0
- sidegraph/bootstrap/cli.py +827 -0
- sidegraph/bootstrap/integrations.py +184 -0
- sidegraph/bootstrap/model.py +277 -0
- sidegraph/bootstrap/planner.py +400 -0
- sidegraph/bootstrap/proof.py +103 -0
- sidegraph/bootstrap/review.py +331 -0
- sidegraph/bootstrap/scan.py +289 -0
- sidegraph/capture.py +1794 -0
- sidegraph/cli.py +1902 -0
- sidegraph/config.py +148 -0
- sidegraph/doc_import.py +2099 -0
- sidegraph/doctor.py +1429 -0
- sidegraph/domains.py +902 -0
- sidegraph/engine/__init__.py +7 -0
- sidegraph/engine/reader.py +353 -0
- sidegraph/gitio.py +572 -0
- sidegraph/host/__init__.py +7 -0
- sidegraph/host/hooks.py +770 -0
- sidegraph/importer.py +239 -0
- sidegraph/okf.py +471 -0
- sidegraph/profiles.py +459 -0
- sidegraph/retrieval.py +1657 -0
- sidegraph/schema.py +386 -0
- sidegraph/server.py +2608 -0
- sidegraph/store.py +3363 -0
- sidegraph/sync.py +885 -0
- sidegraph/verify.py +1040 -0
- sidegraph/viz/__init__.py +4 -0
- sidegraph/viz/assets/vis-network.min.js +33 -0
- sidegraph/viz/model.py +248 -0
- sidegraph/viz/render.py +110 -0
- sidegraph/viz/template.html +131 -0
- sidegraph-0.1.0.dist-info/METADATA +392 -0
- sidegraph-0.1.0.dist-info/RECORD +42 -0
- sidegraph-0.1.0.dist-info/WHEEL +4 -0
- sidegraph-0.1.0.dist-info/entry_points.txt +18 -0
- sidegraph-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
"""Confirmed bootstrap writes, recovery reconciliation, and aggregate reporting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ulid import ULID
|
|
11
|
+
|
|
12
|
+
from sidegraph.bootstrap.catalog import (
|
|
13
|
+
CanonicalCatalog,
|
|
14
|
+
fingerprint_catalog,
|
|
15
|
+
load_canonical_catalog,
|
|
16
|
+
)
|
|
17
|
+
from sidegraph.bootstrap.model import (
|
|
18
|
+
AcceptedRecord,
|
|
19
|
+
BootstrapPlan,
|
|
20
|
+
BootstrapReport,
|
|
21
|
+
IntegrationResult,
|
|
22
|
+
ProofResult,
|
|
23
|
+
Reconciliation,
|
|
24
|
+
ReviewAction,
|
|
25
|
+
ReviewedCandidate,
|
|
26
|
+
ReviewResult,
|
|
27
|
+
RunStatus,
|
|
28
|
+
)
|
|
29
|
+
from sidegraph.doc_import import DocWriteRequest, ParsedDoc, apply_doc_candidate
|
|
30
|
+
from sidegraph.engine.reader import GraphifyReader
|
|
31
|
+
from sidegraph.profiles import FlowProfile, get_profile
|
|
32
|
+
from sidegraph.schema import Decision, DecisionStatus, DomainStatus
|
|
33
|
+
from sidegraph.store import Store
|
|
34
|
+
from sidegraph.verify import verify_snapshot
|
|
35
|
+
|
|
36
|
+
_CANONICAL_DIRS = (
|
|
37
|
+
"decisions",
|
|
38
|
+
"facts",
|
|
39
|
+
"domains",
|
|
40
|
+
"entities",
|
|
41
|
+
"bindings",
|
|
42
|
+
"initiatives",
|
|
43
|
+
"archive",
|
|
44
|
+
)
|
|
45
|
+
_CANONICAL_ROOT_FILES = ("format", ".gitignore")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _error_text(error: BaseException) -> str:
|
|
49
|
+
return str(error) or type(error).__name__
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def canonical_manifest(store_dir: Path) -> dict[str, str]:
|
|
53
|
+
"""Hash committed store files, excluding derived and temporary state."""
|
|
54
|
+
store_dir = Path(store_dir)
|
|
55
|
+
if not store_dir.is_dir():
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
paths: list[Path] = []
|
|
59
|
+
for name in _CANONICAL_ROOT_FILES:
|
|
60
|
+
path = store_dir / name
|
|
61
|
+
if path.is_file() and not path.is_symlink():
|
|
62
|
+
paths.append(path)
|
|
63
|
+
for name in _CANONICAL_DIRS:
|
|
64
|
+
root = store_dir / name
|
|
65
|
+
if not root.is_dir():
|
|
66
|
+
continue
|
|
67
|
+
paths.extend(
|
|
68
|
+
path
|
|
69
|
+
for path in root.rglob("*")
|
|
70
|
+
if path.is_file() and not path.is_symlink() and not path.name.endswith(".tmp")
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
store_label = store_dir.name
|
|
74
|
+
return {
|
|
75
|
+
f"{store_label}/{path.relative_to(store_dir).as_posix()}": hashlib.sha256(
|
|
76
|
+
path.read_bytes()
|
|
77
|
+
).hexdigest()
|
|
78
|
+
for path in sorted(paths)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_plan_inputs(
|
|
83
|
+
plan: BootstrapPlan,
|
|
84
|
+
store_dir: Path,
|
|
85
|
+
reader: GraphifyReader,
|
|
86
|
+
) -> tuple[str, ...]:
|
|
87
|
+
"""Validate all preview inputs without constructing persistent state."""
|
|
88
|
+
failures: list[str] = []
|
|
89
|
+
root = Path(plan.root)
|
|
90
|
+
|
|
91
|
+
for source in plan.source_fingerprints:
|
|
92
|
+
path = root / source.path
|
|
93
|
+
try:
|
|
94
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
95
|
+
except OSError as error:
|
|
96
|
+
failures.append(f"source unavailable: {source.path}: {_error_text(error)}")
|
|
97
|
+
continue
|
|
98
|
+
if digest != source.sha256:
|
|
99
|
+
failures.append(f"source changed after preview: {source.path}")
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
current_catalog = fingerprint_catalog(load_canonical_catalog(Path(store_dir)))
|
|
103
|
+
except (OSError, ValueError) as error:
|
|
104
|
+
failures.append(f"canonical catalog unavailable: {_error_text(error)}")
|
|
105
|
+
else:
|
|
106
|
+
if current_catalog != plan.catalog_fingerprint:
|
|
107
|
+
failures.append("canonical catalog changed after preview")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
current_graph = GraphifyReader(reader.path).graph_version()
|
|
111
|
+
except (OSError, UnicodeError, ValueError) as error:
|
|
112
|
+
failures.append(f"graph unavailable: {reader.path}: {_error_text(error)}")
|
|
113
|
+
else:
|
|
114
|
+
if current_graph != plan.graph_version:
|
|
115
|
+
failures.append("graph changed after preview")
|
|
116
|
+
|
|
117
|
+
return tuple(failures)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_doc_request(
|
|
121
|
+
item: ReviewedCandidate,
|
|
122
|
+
graph_version: str | None,
|
|
123
|
+
profile: FlowProfile,
|
|
124
|
+
) -> DocWriteRequest:
|
|
125
|
+
"""Convert only explicitly write-approved, already-redacted review state.
|
|
126
|
+
|
|
127
|
+
``rel_path`` (E2, design note §6) is derived from ``profile.normalized_rel_path`` of
|
|
128
|
+
the candidate's ON-DISK ``file_path`` — never the on-disk path itself — so the
|
|
129
|
+
resulting ``request.ref`` equals ``candidate.ref`` (also normalized, at plan time) by
|
|
130
|
+
construction: both derive from the same transform applied to the same input.
|
|
131
|
+
"""
|
|
132
|
+
if item.action == ReviewAction.SKIP:
|
|
133
|
+
raise ValueError(f"skipped candidate cannot be converted: {item.candidate.ref}")
|
|
134
|
+
|
|
135
|
+
candidate = item.candidate
|
|
136
|
+
status = (
|
|
137
|
+
DecisionStatus.ACCEPTED if item.action == ReviewAction.ACCEPT else DecisionStatus.PROPOSED
|
|
138
|
+
)
|
|
139
|
+
return DocWriteRequest(
|
|
140
|
+
parsed=ParsedDoc(
|
|
141
|
+
title=candidate.title,
|
|
142
|
+
context=candidate.context,
|
|
143
|
+
choice=candidate.choice,
|
|
144
|
+
rejected=candidate.rejected,
|
|
145
|
+
consequences=candidate.consequences,
|
|
146
|
+
frontmatter_status=None,
|
|
147
|
+
suggested_kind=candidate.kind,
|
|
148
|
+
fragment=candidate.fragment,
|
|
149
|
+
),
|
|
150
|
+
rel_path=profile.normalized_rel_path(candidate.file_path),
|
|
151
|
+
source_hash=candidate.source_hash,
|
|
152
|
+
status=status,
|
|
153
|
+
anchors=candidate.anchor_intents,
|
|
154
|
+
file_anchor=candidate.file_anchor_intent,
|
|
155
|
+
graph_version=graph_version,
|
|
156
|
+
# The human chose `accept` in the review loop; this is the only place in the codebase
|
|
157
|
+
# that grants the ratification authority (spec §3.1).
|
|
158
|
+
ratify_matching_proposal=item.action == ReviewAction.ACCEPT,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _matching_reviewed_decision(
|
|
163
|
+
item: ReviewedCandidate, catalog: CanonicalCatalog
|
|
164
|
+
) -> Decision | None:
|
|
165
|
+
candidate = item.candidate
|
|
166
|
+
expected_status = (
|
|
167
|
+
DecisionStatus.ACCEPTED if item.action == ReviewAction.ACCEPT else DecisionStatus.PROPOSED
|
|
168
|
+
)
|
|
169
|
+
expected_content = (
|
|
170
|
+
candidate.title,
|
|
171
|
+
candidate.context,
|
|
172
|
+
candidate.choice,
|
|
173
|
+
candidate.rejected,
|
|
174
|
+
candidate.consequences,
|
|
175
|
+
)
|
|
176
|
+
return next(
|
|
177
|
+
(
|
|
178
|
+
decision
|
|
179
|
+
for decision in catalog.decisions
|
|
180
|
+
if decision.provenance.source == "doc-import"
|
|
181
|
+
and decision.provenance.ref == candidate.ref
|
|
182
|
+
and decision.status == expected_status
|
|
183
|
+
and (
|
|
184
|
+
decision.title,
|
|
185
|
+
decision.context,
|
|
186
|
+
decision.choice,
|
|
187
|
+
decision.rejected,
|
|
188
|
+
decision.consequences,
|
|
189
|
+
)
|
|
190
|
+
== expected_content
|
|
191
|
+
),
|
|
192
|
+
None,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def reconcile_plan(
|
|
197
|
+
plan: BootstrapPlan,
|
|
198
|
+
review: ReviewResult,
|
|
199
|
+
store_dir: Path,
|
|
200
|
+
before: Mapping[str, str],
|
|
201
|
+
) -> Reconciliation:
|
|
202
|
+
"""Compare reviewed writes with canonical truth after reopen/rebuild."""
|
|
203
|
+
del plan # Review items carry the possibly edited candidates that are authoritative here.
|
|
204
|
+
catalog = load_canonical_catalog(Path(store_dir))
|
|
205
|
+
durable: list[str] = []
|
|
206
|
+
pending: list[str] = []
|
|
207
|
+
accepted_records: list[AcceptedRecord] = []
|
|
208
|
+
for item in review.items:
|
|
209
|
+
if item.action == ReviewAction.SKIP:
|
|
210
|
+
continue
|
|
211
|
+
match = _matching_reviewed_decision(item, catalog)
|
|
212
|
+
target = durable if match is not None else pending
|
|
213
|
+
target.append(item.candidate.key)
|
|
214
|
+
if match is not None and item.action == ReviewAction.ACCEPT:
|
|
215
|
+
accepted_records.append(AcceptedRecord(record_id=match.id, ref=item.candidate.ref))
|
|
216
|
+
|
|
217
|
+
after = canonical_manifest(Path(store_dir))
|
|
218
|
+
changed = tuple(
|
|
219
|
+
sorted(path for path in set(before) | set(after) if before.get(path) != after.get(path))
|
|
220
|
+
)
|
|
221
|
+
return Reconciliation(
|
|
222
|
+
durable=tuple(durable),
|
|
223
|
+
pending=tuple(pending),
|
|
224
|
+
canonical_files=changed,
|
|
225
|
+
durable_accepted_records=tuple(accepted_records),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def proposal_debt(catalog: CanonicalCatalog) -> tuple[int, int | None]:
|
|
230
|
+
"""Return proposed-record count and the age of the oldest parseable ULID."""
|
|
231
|
+
proposed_ids = [
|
|
232
|
+
decision.id for decision in catalog.decisions if decision.status == DecisionStatus.PROPOSED
|
|
233
|
+
]
|
|
234
|
+
proposed_ids.extend(fact.id for fact in catalog.facts if fact.status == DecisionStatus.PROPOSED)
|
|
235
|
+
proposed_ids.extend(
|
|
236
|
+
domain.domain_id for domain in catalog.domains if domain.status == DomainStatus.PROPOSED
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
created: list[datetime] = []
|
|
240
|
+
for record_id in proposed_ids:
|
|
241
|
+
try:
|
|
242
|
+
created.append(ULID.from_str(record_id).datetime)
|
|
243
|
+
except (TypeError, ValueError):
|
|
244
|
+
continue
|
|
245
|
+
if not created:
|
|
246
|
+
return len(proposed_ids), None
|
|
247
|
+
age = datetime.now(UTC) - min(created)
|
|
248
|
+
return len(proposed_ids), max(0, age.days)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _counts(plan: BootstrapPlan, review: ReviewResult) -> dict[str, int | float]:
|
|
252
|
+
accepted_without_edit = sum(
|
|
253
|
+
item.action == ReviewAction.ACCEPT and not item.edited for item in review.items
|
|
254
|
+
)
|
|
255
|
+
edited_then_accepted = sum(
|
|
256
|
+
item.action == ReviewAction.ACCEPT and item.edited for item in review.items
|
|
257
|
+
)
|
|
258
|
+
kept_proposed_without_edit = sum(
|
|
259
|
+
item.action == ReviewAction.KEEP_PROPOSED and not item.edited for item in review.items
|
|
260
|
+
)
|
|
261
|
+
edited_then_kept_proposed = sum(
|
|
262
|
+
item.action == ReviewAction.KEEP_PROPOSED and item.edited for item in review.items
|
|
263
|
+
)
|
|
264
|
+
return {
|
|
265
|
+
"documents_scanned": len(plan.files_read),
|
|
266
|
+
"candidates": len(plan.candidates),
|
|
267
|
+
"accepted": sum(item.action == ReviewAction.ACCEPT for item in review.items),
|
|
268
|
+
"kept_proposed": sum(item.action == ReviewAction.KEEP_PROPOSED for item in review.items),
|
|
269
|
+
"skipped": sum(item.action == ReviewAction.SKIP for item in review.items),
|
|
270
|
+
"reviewed_candidates": len(review.items),
|
|
271
|
+
"accepted_without_edit": accepted_without_edit,
|
|
272
|
+
"edited_then_accepted": edited_then_accepted,
|
|
273
|
+
"kept_proposed_without_edit": kept_proposed_without_edit,
|
|
274
|
+
"edited_then_kept_proposed": edited_then_kept_proposed,
|
|
275
|
+
"candidate_precision_numerator": sum(
|
|
276
|
+
item.action == ReviewAction.ACCEPT or item.edited for item in review.items
|
|
277
|
+
),
|
|
278
|
+
"review_elapsed_seconds": review.elapsed_seconds,
|
|
279
|
+
"accept_action_seconds": sum(
|
|
280
|
+
item.action_elapsed_seconds
|
|
281
|
+
for item in review.items
|
|
282
|
+
if item.action == ReviewAction.ACCEPT and not item.edited
|
|
283
|
+
),
|
|
284
|
+
"edit_accept_action_seconds": sum(
|
|
285
|
+
item.action_elapsed_seconds
|
|
286
|
+
for item in review.items
|
|
287
|
+
if item.action == ReviewAction.ACCEPT and item.edited
|
|
288
|
+
),
|
|
289
|
+
"keep_proposed_action_seconds": sum(
|
|
290
|
+
item.action_elapsed_seconds
|
|
291
|
+
for item in review.items
|
|
292
|
+
if item.action == ReviewAction.KEEP_PROPOSED and not item.edited
|
|
293
|
+
),
|
|
294
|
+
"edit_keep_proposed_action_seconds": sum(
|
|
295
|
+
item.action_elapsed_seconds
|
|
296
|
+
for item in review.items
|
|
297
|
+
if item.action == ReviewAction.KEEP_PROPOSED and item.edited
|
|
298
|
+
),
|
|
299
|
+
"skip_action_seconds": sum(
|
|
300
|
+
item.action_elapsed_seconds for item in review.items if item.action == ReviewAction.SKIP
|
|
301
|
+
),
|
|
302
|
+
"live_anchors": sum(
|
|
303
|
+
any(anchor.status == "resolved" and anchor.tier == 2 for anchor in candidate.anchors)
|
|
304
|
+
for candidate in plan.candidates
|
|
305
|
+
),
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _pending_keys(review: ReviewResult) -> tuple[str, ...]:
|
|
310
|
+
return tuple(item.candidate.key for item in review.items if item.action != ReviewAction.SKIP)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _incomplete_report(
|
|
314
|
+
plan: BootstrapPlan,
|
|
315
|
+
review: ReviewResult,
|
|
316
|
+
store_dir: Path,
|
|
317
|
+
*,
|
|
318
|
+
error: str,
|
|
319
|
+
before: Mapping[str, str] | None = None,
|
|
320
|
+
) -> BootstrapReport:
|
|
321
|
+
try:
|
|
322
|
+
debt_count, oldest_days = proposal_debt(load_canonical_catalog(store_dir))
|
|
323
|
+
except (OSError, ValueError):
|
|
324
|
+
debt_count, oldest_days = 0, None
|
|
325
|
+
canonical_files: tuple[str, ...] = ()
|
|
326
|
+
if before is not None:
|
|
327
|
+
try:
|
|
328
|
+
after = canonical_manifest(store_dir)
|
|
329
|
+
canonical_files = tuple(
|
|
330
|
+
sorted(
|
|
331
|
+
path for path in set(before) | set(after) if before.get(path) != after.get(path)
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
except OSError:
|
|
335
|
+
pass
|
|
336
|
+
return BootstrapReport(
|
|
337
|
+
status=RunStatus.INCOMPLETE,
|
|
338
|
+
**_counts(plan, review),
|
|
339
|
+
review_debt_count=debt_count,
|
|
340
|
+
oldest_proposal_days=oldest_days,
|
|
341
|
+
pending_candidate_keys=_pending_keys(review),
|
|
342
|
+
canonical_files=canonical_files,
|
|
343
|
+
error=error,
|
|
344
|
+
next_command="sidegraph-bootstrap --resume",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _diagnostic_report(
|
|
349
|
+
plan: BootstrapPlan,
|
|
350
|
+
review: ReviewResult,
|
|
351
|
+
store_dir: Path,
|
|
352
|
+
) -> BootstrapReport:
|
|
353
|
+
try:
|
|
354
|
+
debt_count, oldest_days = proposal_debt(load_canonical_catalog(store_dir))
|
|
355
|
+
except (OSError, ValueError):
|
|
356
|
+
debt_count, oldest_days = 0, None
|
|
357
|
+
return BootstrapReport(
|
|
358
|
+
status=RunStatus.DIAGNOSTIC,
|
|
359
|
+
**_counts(plan, review),
|
|
360
|
+
review_debt_count=debt_count,
|
|
361
|
+
oldest_proposal_days=oldest_days,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _finalize(
|
|
366
|
+
plan: BootstrapPlan,
|
|
367
|
+
review: ReviewResult,
|
|
368
|
+
store_dir: Path,
|
|
369
|
+
before: Mapping[str, str],
|
|
370
|
+
error: str | None,
|
|
371
|
+
failed_ref: str | None,
|
|
372
|
+
) -> BootstrapReport:
|
|
373
|
+
reopen_error: str | None = None
|
|
374
|
+
try:
|
|
375
|
+
healed = Store(store_dir)
|
|
376
|
+
healed.close()
|
|
377
|
+
except BaseException as exc:
|
|
378
|
+
reopen_error = _error_text(exc)
|
|
379
|
+
|
|
380
|
+
verification_errors: tuple[str, ...] = ()
|
|
381
|
+
if reopen_error is None:
|
|
382
|
+
try:
|
|
383
|
+
violations = tuple(verify_snapshot(store_dir))
|
|
384
|
+
verification_errors = tuple(
|
|
385
|
+
f"{violation.code} {violation.path} {violation.detail}" for violation in violations
|
|
386
|
+
)
|
|
387
|
+
except BaseException as exc:
|
|
388
|
+
verification_errors = (f"verification failed: {_error_text(exc)}",)
|
|
389
|
+
|
|
390
|
+
reconciliation_error: str | None = None
|
|
391
|
+
try:
|
|
392
|
+
reconciliation = reconcile_plan(plan, review, store_dir, before)
|
|
393
|
+
except (OSError, ValueError) as exc:
|
|
394
|
+
reconciliation_error = _error_text(exc)
|
|
395
|
+
try:
|
|
396
|
+
after = canonical_manifest(store_dir)
|
|
397
|
+
changed = tuple(
|
|
398
|
+
sorted(
|
|
399
|
+
path for path in set(before) | set(after) if before.get(path) != after.get(path)
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
except OSError:
|
|
403
|
+
changed = ()
|
|
404
|
+
reconciliation = Reconciliation(pending=_pending_keys(review), canonical_files=changed)
|
|
405
|
+
|
|
406
|
+
complete = (
|
|
407
|
+
not error
|
|
408
|
+
and not reopen_error
|
|
409
|
+
and not verification_errors
|
|
410
|
+
and not reconciliation_error
|
|
411
|
+
and not reconciliation.pending
|
|
412
|
+
)
|
|
413
|
+
status = (
|
|
414
|
+
RunStatus.COMPLETE
|
|
415
|
+
if complete
|
|
416
|
+
else RunStatus.PARTIAL_RECOVERABLE
|
|
417
|
+
if reconciliation.durable
|
|
418
|
+
else RunStatus.INCOMPLETE
|
|
419
|
+
)
|
|
420
|
+
try:
|
|
421
|
+
debt_count, oldest_days = proposal_debt(load_canonical_catalog(store_dir))
|
|
422
|
+
except (OSError, ValueError):
|
|
423
|
+
debt_count, oldest_days = 0, None
|
|
424
|
+
report_error = error or reopen_error or reconciliation_error
|
|
425
|
+
if report_error is None and verification_errors:
|
|
426
|
+
report_error = verification_errors[0]
|
|
427
|
+
return BootstrapReport(
|
|
428
|
+
status=status,
|
|
429
|
+
**_counts(plan, review),
|
|
430
|
+
review_debt_count=debt_count,
|
|
431
|
+
oldest_proposal_days=oldest_days,
|
|
432
|
+
durable_candidate_keys=reconciliation.durable,
|
|
433
|
+
pending_candidate_keys=reconciliation.pending,
|
|
434
|
+
durable_accepted_records=reconciliation.durable_accepted_records,
|
|
435
|
+
canonical_files=reconciliation.canonical_files,
|
|
436
|
+
verification_failures=verification_errors,
|
|
437
|
+
failed_ref=failed_ref,
|
|
438
|
+
error=report_error,
|
|
439
|
+
next_command=(
|
|
440
|
+
None
|
|
441
|
+
if complete
|
|
442
|
+
else f"sidegraph-verify --db {store_dir}"
|
|
443
|
+
if verification_errors
|
|
444
|
+
else "sidegraph-bootstrap --resume"
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def apply_review(
|
|
450
|
+
plan: BootstrapPlan,
|
|
451
|
+
review: ReviewResult,
|
|
452
|
+
*,
|
|
453
|
+
store_dir: Path,
|
|
454
|
+
reader: GraphifyReader,
|
|
455
|
+
) -> BootstrapReport:
|
|
456
|
+
"""Apply confirmed review items and reconcile any durable partial writes."""
|
|
457
|
+
store_dir = Path(store_dir)
|
|
458
|
+
failures = validate_plan_inputs(plan, store_dir, reader)
|
|
459
|
+
if failures:
|
|
460
|
+
return _incomplete_report(
|
|
461
|
+
plan,
|
|
462
|
+
review,
|
|
463
|
+
store_dir,
|
|
464
|
+
error="; ".join(failures),
|
|
465
|
+
)
|
|
466
|
+
if not review.has_writes:
|
|
467
|
+
return _diagnostic_report(plan, review, store_dir)
|
|
468
|
+
|
|
469
|
+
try:
|
|
470
|
+
before = canonical_manifest(store_dir)
|
|
471
|
+
except OSError as exc:
|
|
472
|
+
return _incomplete_report(plan, review, store_dir, error=_error_text(exc))
|
|
473
|
+
|
|
474
|
+
try:
|
|
475
|
+
store = Store(store_dir)
|
|
476
|
+
except BaseException as exc:
|
|
477
|
+
return _incomplete_report(
|
|
478
|
+
plan,
|
|
479
|
+
review,
|
|
480
|
+
store_dir,
|
|
481
|
+
error=_error_text(exc),
|
|
482
|
+
before=before,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
error: str | None = None
|
|
486
|
+
failed_ref: str | None = None
|
|
487
|
+
profile = get_profile(plan.profile)
|
|
488
|
+
try:
|
|
489
|
+
for item in review.items:
|
|
490
|
+
if item.action == ReviewAction.SKIP:
|
|
491
|
+
continue
|
|
492
|
+
request = build_doc_request(item, plan.graph_version, profile)
|
|
493
|
+
if request.ref != item.candidate.ref:
|
|
494
|
+
raise AssertionError(
|
|
495
|
+
f"reviewed candidate ref changed during conversion: {item.candidate.ref}"
|
|
496
|
+
)
|
|
497
|
+
try:
|
|
498
|
+
apply_doc_candidate(store, reader, request)
|
|
499
|
+
except BaseException as exc:
|
|
500
|
+
error = _error_text(exc)
|
|
501
|
+
failed_ref = item.candidate.ref
|
|
502
|
+
break
|
|
503
|
+
finally:
|
|
504
|
+
try:
|
|
505
|
+
store.close()
|
|
506
|
+
except BaseException as exc:
|
|
507
|
+
if error is None:
|
|
508
|
+
error = _error_text(exc)
|
|
509
|
+
|
|
510
|
+
return _finalize(plan, review, store_dir, before, error, failed_ref)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def render_markdown_report(
|
|
514
|
+
report: BootstrapReport,
|
|
515
|
+
*,
|
|
516
|
+
integration: IntegrationResult | None = None,
|
|
517
|
+
proof: ProofResult | None = None,
|
|
518
|
+
task_proof: ProofResult | None = None,
|
|
519
|
+
elapsed_seconds: float | None = None,
|
|
520
|
+
) -> str:
|
|
521
|
+
"""Render operational aggregates without candidate/source/error content."""
|
|
522
|
+
reviewed = report.reviewed_candidates
|
|
523
|
+
|
|
524
|
+
def rate(count: int) -> str:
|
|
525
|
+
if reviewed == 0:
|
|
526
|
+
return "not applicable (0 reviewed)"
|
|
527
|
+
return f"{count}/{reviewed} ({count / reviewed:.1%})"
|
|
528
|
+
|
|
529
|
+
lines = [
|
|
530
|
+
"# Sidegraph bootstrap report",
|
|
531
|
+
"",
|
|
532
|
+
f"- status: {report.status.value}",
|
|
533
|
+
f"- documents scanned: {report.documents_scanned}",
|
|
534
|
+
f"- candidates: {report.candidates}",
|
|
535
|
+
f"- accepted: {report.accepted}",
|
|
536
|
+
f"- kept proposed: {report.kept_proposed}",
|
|
537
|
+
f"- skipped: {report.skipped}",
|
|
538
|
+
f"- reviewed candidates: {reviewed}",
|
|
539
|
+
f"- candidate precision: {rate(report.candidate_precision_numerator)}",
|
|
540
|
+
f"- accept: {rate(report.accepted_without_edit)}; "
|
|
541
|
+
f"action seconds: {report.accept_action_seconds:.3f}",
|
|
542
|
+
f"- edit then accept: {rate(report.edited_then_accepted)}; "
|
|
543
|
+
f"action seconds: {report.edit_accept_action_seconds:.3f}",
|
|
544
|
+
f"- keep proposed: {rate(report.kept_proposed_without_edit)}; "
|
|
545
|
+
f"action seconds: {report.keep_proposed_action_seconds:.3f}",
|
|
546
|
+
f"- edit then keep proposed: {rate(report.edited_then_kept_proposed)}; "
|
|
547
|
+
f"action seconds: {report.edit_keep_proposed_action_seconds:.3f}",
|
|
548
|
+
f"- skip: {rate(report.skipped)}; action seconds: {report.skip_action_seconds:.3f}",
|
|
549
|
+
f"- review elapsed seconds: {report.review_elapsed_seconds:.3f}",
|
|
550
|
+
f"- live anchors: {report.live_anchors}",
|
|
551
|
+
f"- review debt (proposed): {report.review_debt_count}",
|
|
552
|
+
f"- oldest proposal days: {report.oldest_proposal_days}",
|
|
553
|
+
f"- durable candidates: {len(report.durable_candidate_keys)}",
|
|
554
|
+
f"- pending candidates: {len(report.pending_candidate_keys)}",
|
|
555
|
+
f"- verification failures: {len(report.verification_failures)}",
|
|
556
|
+
]
|
|
557
|
+
if elapsed_seconds is not None:
|
|
558
|
+
lines.append(f"- elapsed seconds: {elapsed_seconds:.3f}")
|
|
559
|
+
if report.next_command is not None:
|
|
560
|
+
lines.append(f"- next command: `{report.next_command}`")
|
|
561
|
+
|
|
562
|
+
if report.canonical_files:
|
|
563
|
+
lines.extend(("", "## Changed canonical files", ""))
|
|
564
|
+
lines.extend(f"- `{path}`" for path in report.canonical_files)
|
|
565
|
+
|
|
566
|
+
if integration is not None:
|
|
567
|
+
lines.extend(
|
|
568
|
+
(
|
|
569
|
+
"",
|
|
570
|
+
"## Integration",
|
|
571
|
+
"",
|
|
572
|
+
f"- host: {integration.host.value}",
|
|
573
|
+
f"- fully supported: {str(integration.fully_supported).lower()}",
|
|
574
|
+
f"- mcp: {integration.mcp}",
|
|
575
|
+
f"- session start: {integration.session_start}",
|
|
576
|
+
f"- stop: {integration.stop}",
|
|
577
|
+
f"- pretool read/grep: {integration.pretool_read_grep}",
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
if integration.next_action is not None:
|
|
581
|
+
lines.append(f"- next action: {integration.next_action}")
|
|
582
|
+
|
|
583
|
+
if proof is not None:
|
|
584
|
+
lines.extend(
|
|
585
|
+
(
|
|
586
|
+
"",
|
|
587
|
+
"## Proof",
|
|
588
|
+
"",
|
|
589
|
+
f"- complete: {str(proof.complete).lower()}",
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
if task_proof is not None:
|
|
594
|
+
lines.extend(
|
|
595
|
+
(
|
|
596
|
+
"",
|
|
597
|
+
"## Optional task proof",
|
|
598
|
+
"",
|
|
599
|
+
f"- complete: {str(task_proof.complete).lower()}",
|
|
600
|
+
)
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Pure, immutable snapshot of canonical Sidegraph record files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ValidationError
|
|
10
|
+
|
|
11
|
+
from sidegraph.bootstrap.model import FrozenModel
|
|
12
|
+
from sidegraph.schema import Decision, DecisionStatus, Domain, Fact
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CanonicalCatalog(FrozenModel):
|
|
16
|
+
decisions: tuple[Decision, ...] = ()
|
|
17
|
+
facts: tuple[Fact, ...] = ()
|
|
18
|
+
domains: tuple[Domain, ...] = ()
|
|
19
|
+
|
|
20
|
+
def find_by_ref(
|
|
21
|
+
self,
|
|
22
|
+
source: str,
|
|
23
|
+
ref: str,
|
|
24
|
+
statuses: tuple[DecisionStatus, ...],
|
|
25
|
+
) -> tuple[Decision, ...]:
|
|
26
|
+
return tuple(
|
|
27
|
+
decision
|
|
28
|
+
for decision in self.decisions
|
|
29
|
+
if decision.provenance.source == source
|
|
30
|
+
and decision.provenance.ref == ref
|
|
31
|
+
and decision.status in statuses
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_model[ModelT: BaseModel](path: Path, model: type[ModelT]) -> ModelT:
|
|
36
|
+
try:
|
|
37
|
+
return model.model_validate_json(path.read_text(encoding="utf-8"))
|
|
38
|
+
except (OSError, UnicodeError, ValidationError) as exc:
|
|
39
|
+
raise ValueError(f"invalid canonical file {path}: {exc}") from exc
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_canonical_catalog(store_dir: Path) -> CanonicalCatalog:
|
|
43
|
+
"""Read canonical files directly, without constructing ``Store`` or derived state."""
|
|
44
|
+
if not store_dir.is_dir():
|
|
45
|
+
return CanonicalCatalog()
|
|
46
|
+
|
|
47
|
+
decisions: dict[str, Decision] = {}
|
|
48
|
+
domains: dict[str, Domain] = {}
|
|
49
|
+
for segment in sorted((store_dir / "archive").glob("*.jsonl")):
|
|
50
|
+
try:
|
|
51
|
+
lines = segment.read_text(encoding="utf-8").splitlines()
|
|
52
|
+
except (OSError, UnicodeError) as exc:
|
|
53
|
+
raise ValueError(f"invalid canonical archive {segment}: {exc}") from exc
|
|
54
|
+
for lineno, line in enumerate(lines, 1):
|
|
55
|
+
if not line.strip():
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
raw = json.loads(line)
|
|
59
|
+
if not isinstance(raw, dict):
|
|
60
|
+
raise TypeError("archive record must be a JSON object")
|
|
61
|
+
payload = {key: value for key, value in raw.items() if key != "record_type"}
|
|
62
|
+
if raw.get("record_type") == "decision":
|
|
63
|
+
decision = Decision.model_validate(payload)
|
|
64
|
+
decisions.setdefault(decision.id, decision)
|
|
65
|
+
elif raw.get("record_type") == "domain":
|
|
66
|
+
domain = Domain.model_validate(payload)
|
|
67
|
+
domains.setdefault(domain.domain_id, domain)
|
|
68
|
+
except (json.JSONDecodeError, TypeError, ValidationError) as exc:
|
|
69
|
+
raise ValueError(f"invalid canonical archive {segment}:{lineno}: {exc}") from exc
|
|
70
|
+
|
|
71
|
+
for path in sorted((store_dir / "decisions").glob("*.json")):
|
|
72
|
+
decision = _load_model(path, Decision)
|
|
73
|
+
decisions[decision.id] = decision
|
|
74
|
+
facts = tuple(_load_model(path, Fact) for path in sorted((store_dir / "facts").glob("*.json")))
|
|
75
|
+
for path in sorted((store_dir / "domains").glob("*.json")):
|
|
76
|
+
domain = _load_model(path, Domain)
|
|
77
|
+
domains[domain.domain_id] = domain
|
|
78
|
+
|
|
79
|
+
return CanonicalCatalog(
|
|
80
|
+
decisions=tuple(decisions[key] for key in sorted(decisions)),
|
|
81
|
+
facts=facts,
|
|
82
|
+
domains=tuple(domains[key] for key in sorted(domains)),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def fingerprint_catalog(catalog: CanonicalCatalog) -> str:
|
|
87
|
+
material = json.dumps(
|
|
88
|
+
catalog.model_dump(mode="json"),
|
|
89
|
+
sort_keys=True,
|
|
90
|
+
separators=(",", ":"),
|
|
91
|
+
)
|
|
92
|
+
return hashlib.sha256(material.encode()).hexdigest()
|