@deftai/directive-content 0.55.2 → 0.56.0
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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +1 -1
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""Append-only audit log for triage decisions (#845 Story 2).
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
append(entry: dict) -> str # returns decision_id
|
|
5
|
+
read_all(repo: str | None = None) -> list[dict]
|
|
6
|
+
find_by_issue(issue_number: int, repo: str) -> list[dict]
|
|
7
|
+
latest_decision(issue_number: int, repo: str) -> dict | None
|
|
8
|
+
|
|
9
|
+
Storage:
|
|
10
|
+
vbrief/.eval/candidates.jsonl -- one JSON object per line, UTF-8.
|
|
11
|
+
Parent directory is created on first append.
|
|
12
|
+
|
|
13
|
+
Concurrency:
|
|
14
|
+
- Cross-process safety: an advisory lock is held on a sidecar
|
|
15
|
+
``candidates.jsonl.lock`` file via ``msvcrt.locking`` on Windows and
|
|
16
|
+
``fcntl.flock`` on POSIX while the writer appends a single line.
|
|
17
|
+
- In-process thread safety: a module-level ``threading.Lock`` serialises
|
|
18
|
+
appends from threads in the same Python process so the line-level
|
|
19
|
+
atomicity holds even when the OS-level byte-range lock would otherwise
|
|
20
|
+
be granted to multiple file descriptors held by the same process.
|
|
21
|
+
|
|
22
|
+
Validation:
|
|
23
|
+
Every dict passed to :func:`append` is validated against the constraints
|
|
24
|
+
in ``vbrief/schemas/candidates.schema.json`` (the FROZEN interface
|
|
25
|
+
contract for downstream agents A3, A4, A6). The validator is hand-rolled
|
|
26
|
+
so this module has no third-party dependency footprint -- the schema
|
|
27
|
+
file remains the canonical reference.
|
|
28
|
+
|
|
29
|
+
Tolerance:
|
|
30
|
+
:func:`read_all` tolerates malformed JSON lines: it logs a warning and
|
|
31
|
+
skips them rather than raising. A partially-written tail from a crashed
|
|
32
|
+
appender must not brick the audit trail for downstream readers.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import sys
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
import uuid
|
|
45
|
+
from collections.abc import Iterator
|
|
46
|
+
from contextlib import contextmanager, suppress
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Any
|
|
49
|
+
|
|
50
|
+
LOG = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
# Canonical default storage location, resolved relative to the repo root.
|
|
53
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
54
|
+
|
|
55
|
+
from _content_root import content_root # noqa: E402
|
|
56
|
+
|
|
57
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
58
|
+
# The .eval/ runtime log stays at the root vbrief/ (repo-dev); only the shipped
|
|
59
|
+
# schemas moved under content/ in the #1875 C1 flatten dual-context.
|
|
60
|
+
DEFAULT_LOG_PATH = REPO_ROOT / "vbrief" / ".eval" / "candidates.jsonl"
|
|
61
|
+
SCHEMA_PATH = content_root(REPO_ROOT) / "vbrief" / "schemas" / "candidates.schema.json"
|
|
62
|
+
|
|
63
|
+
# Frozen vocabulary mirrored from candidates.schema.json. Keep in lockstep.
|
|
64
|
+
# ``resume-eligible`` (#1123 / D3) is appended by the resume-condition
|
|
65
|
+
# evaluator when a prior ``defer`` entry's ``resume_on`` fires; it carries
|
|
66
|
+
# ``prior_decision_id`` referencing the defer.
|
|
67
|
+
_VALID_DECISIONS: frozenset[str] = frozenset(
|
|
68
|
+
{
|
|
69
|
+
"accept",
|
|
70
|
+
"reject",
|
|
71
|
+
"defer",
|
|
72
|
+
"needs-ac",
|
|
73
|
+
"mark-duplicate",
|
|
74
|
+
"reset",
|
|
75
|
+
"resume-eligible",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
#: Decisions that require ``prior_decision_id`` -- ``reset`` (rollback)
|
|
79
|
+
#: and ``resume-eligible`` (D3 evaluator marker referencing the defer).
|
|
80
|
+
_PRIOR_REQUIRED_DECISIONS: frozenset[str] = frozenset({"reset", "resume-eligible"})
|
|
81
|
+
_REQUIRED_FIELDS: tuple[str, ...] = (
|
|
82
|
+
"decision_id",
|
|
83
|
+
"timestamp",
|
|
84
|
+
"repo",
|
|
85
|
+
"issue_number",
|
|
86
|
+
"decision",
|
|
87
|
+
"actor",
|
|
88
|
+
)
|
|
89
|
+
_OPTIONAL_FIELDS: tuple[str, ...] = (
|
|
90
|
+
"reason",
|
|
91
|
+
"resume_on",
|
|
92
|
+
"linked_to",
|
|
93
|
+
"prior_decision_id",
|
|
94
|
+
)
|
|
95
|
+
_ALLOWED_FIELDS: frozenset[str] = frozenset(_REQUIRED_FIELDS + _OPTIONAL_FIELDS)
|
|
96
|
+
|
|
97
|
+
_UUID_RE = re.compile(
|
|
98
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
99
|
+
)
|
|
100
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
|
101
|
+
# UTC-only on purpose: ``latest_decision`` sorts entries by lexicographic
|
|
102
|
+
# timestamp comparison, which is correct only when every timestamp uses the
|
|
103
|
+
# canonical ``Z`` (UTC) suffix. An offset like ``+05:30`` would represent the
|
|
104
|
+
# same instant as a Z-suffixed timestamp at a different wall-clock string and
|
|
105
|
+
# silently invert the chronological order (Greptile #876 P1).
|
|
106
|
+
_ISO8601_RE = re.compile(
|
|
107
|
+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
_thread_lock = threading.Lock()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class CandidatesLogError(ValueError):
|
|
114
|
+
"""Raised when an entry passed to :func:`append` fails schema validation."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _validate_entry(entry: Any) -> None:
|
|
118
|
+
"""Hand-rolled mirror of ``vbrief/schemas/candidates.schema.json``.
|
|
119
|
+
|
|
120
|
+
Raises :class:`CandidatesLogError` with a human-readable message on the
|
|
121
|
+
first violation encountered. Order-of-checks matches the schema (required
|
|
122
|
+
presence -> type -> pattern/enum -> conditional dependencies) so error
|
|
123
|
+
messages cite the most upstream violation.
|
|
124
|
+
"""
|
|
125
|
+
if not isinstance(entry, dict):
|
|
126
|
+
raise CandidatesLogError(
|
|
127
|
+
f"entry must be a dict, got {type(entry).__name__}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
missing = [f for f in _REQUIRED_FIELDS if f not in entry]
|
|
131
|
+
if missing:
|
|
132
|
+
raise CandidatesLogError(
|
|
133
|
+
f"entry missing required field(s): {missing}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
extras = sorted(set(entry.keys()) - _ALLOWED_FIELDS)
|
|
137
|
+
if extras:
|
|
138
|
+
raise CandidatesLogError(f"entry has unknown field(s): {extras}")
|
|
139
|
+
|
|
140
|
+
decision_id = entry["decision_id"]
|
|
141
|
+
if not isinstance(decision_id, str) or not _UUID_RE.match(decision_id):
|
|
142
|
+
raise CandidatesLogError(
|
|
143
|
+
f"decision_id must be a UUID string, got {decision_id!r}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
timestamp = entry["timestamp"]
|
|
147
|
+
if not isinstance(timestamp, str) or not _ISO8601_RE.match(timestamp):
|
|
148
|
+
raise CandidatesLogError(
|
|
149
|
+
f"timestamp must be ISO-8601 UTC with Z suffix "
|
|
150
|
+
f"(e.g. 2026-05-03T16:32:54Z), got {timestamp!r}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
repo = entry["repo"]
|
|
154
|
+
if not isinstance(repo, str) or not _REPO_RE.match(repo):
|
|
155
|
+
raise CandidatesLogError(
|
|
156
|
+
f"repo must match 'owner/name', got {repo!r}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
issue_number = entry["issue_number"]
|
|
160
|
+
# bool is a subclass of int -- explicitly reject it.
|
|
161
|
+
if (
|
|
162
|
+
not isinstance(issue_number, int)
|
|
163
|
+
or isinstance(issue_number, bool)
|
|
164
|
+
or issue_number < 1
|
|
165
|
+
):
|
|
166
|
+
raise CandidatesLogError(
|
|
167
|
+
f"issue_number must be a positive int, got {issue_number!r}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
decision = entry["decision"]
|
|
171
|
+
if decision not in _VALID_DECISIONS:
|
|
172
|
+
raise CandidatesLogError(
|
|
173
|
+
f"decision must be one of {sorted(_VALID_DECISIONS)}, "
|
|
174
|
+
f"got {decision!r}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
actor = entry["actor"]
|
|
178
|
+
if not isinstance(actor, str) or not actor:
|
|
179
|
+
raise CandidatesLogError(
|
|
180
|
+
f"actor must be a non-empty string, got {actor!r}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if "reason" in entry and not isinstance(entry["reason"], str):
|
|
184
|
+
raise CandidatesLogError(
|
|
185
|
+
f"reason must be a string, got "
|
|
186
|
+
f"{type(entry['reason']).__name__}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if "resume_on" in entry:
|
|
190
|
+
resume_on = entry["resume_on"]
|
|
191
|
+
if not isinstance(resume_on, str) or not resume_on:
|
|
192
|
+
raise CandidatesLogError(
|
|
193
|
+
f"resume_on must be a non-empty string, got {resume_on!r}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Conditional fields: linked_to is required for mark-duplicate and forbidden
|
|
197
|
+
# otherwise; prior_decision_id is required for reset and forbidden otherwise.
|
|
198
|
+
if decision == "mark-duplicate":
|
|
199
|
+
if "linked_to" not in entry:
|
|
200
|
+
raise CandidatesLogError(
|
|
201
|
+
"decision 'mark-duplicate' requires 'linked_to'"
|
|
202
|
+
)
|
|
203
|
+
linked_to = entry["linked_to"]
|
|
204
|
+
if (
|
|
205
|
+
not isinstance(linked_to, int)
|
|
206
|
+
or isinstance(linked_to, bool)
|
|
207
|
+
or linked_to < 1
|
|
208
|
+
):
|
|
209
|
+
raise CandidatesLogError(
|
|
210
|
+
f"linked_to must be a positive int, got {linked_to!r}"
|
|
211
|
+
)
|
|
212
|
+
elif "linked_to" in entry:
|
|
213
|
+
raise CandidatesLogError(
|
|
214
|
+
"'linked_to' is only valid for decision='mark-duplicate'"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if decision in _PRIOR_REQUIRED_DECISIONS:
|
|
218
|
+
if "prior_decision_id" not in entry:
|
|
219
|
+
raise CandidatesLogError(
|
|
220
|
+
f"decision {decision!r} requires 'prior_decision_id'"
|
|
221
|
+
)
|
|
222
|
+
pid = entry["prior_decision_id"]
|
|
223
|
+
if not isinstance(pid, str) or not _UUID_RE.match(pid):
|
|
224
|
+
raise CandidatesLogError(
|
|
225
|
+
f"prior_decision_id must be a UUID string, got {pid!r}"
|
|
226
|
+
)
|
|
227
|
+
elif "prior_decision_id" in entry:
|
|
228
|
+
raise CandidatesLogError(
|
|
229
|
+
"'prior_decision_id' is only valid for decision in "
|
|
230
|
+
f"{sorted(_PRIOR_REQUIRED_DECISIONS)}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@contextmanager
|
|
235
|
+
def _append_lock(log_path: Path) -> Iterator[None]:
|
|
236
|
+
"""Serialise appenders across threads AND processes.
|
|
237
|
+
|
|
238
|
+
Acquires the module-level :data:`_thread_lock` first to serialise
|
|
239
|
+
in-process callers, then opens a sidecar ``<log>.lock`` file and takes
|
|
240
|
+
an exclusive byte-range lock via ``msvcrt`` (Windows) or ``fcntl``
|
|
241
|
+
(POSIX). The sidecar pattern keeps the lock orthogonal to the data
|
|
242
|
+
file so a torn lock-file write never affects the audit trail.
|
|
243
|
+
"""
|
|
244
|
+
lock_path = log_path.parent / (log_path.name + ".lock")
|
|
245
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
# ``a+b`` opens for read+write+create without truncating -- needed because
|
|
247
|
+
# msvcrt.locking requires the byte range to exist.
|
|
248
|
+
with _thread_lock:
|
|
249
|
+
try:
|
|
250
|
+
with open(lock_path, "a+b") as fh:
|
|
251
|
+
if not lock_path.stat().st_size:
|
|
252
|
+
fh.write(b"\0")
|
|
253
|
+
fh.flush()
|
|
254
|
+
fh.seek(0)
|
|
255
|
+
if sys.platform == "win32":
|
|
256
|
+
import msvcrt
|
|
257
|
+
|
|
258
|
+
# Spin on LK_NBLCK -- the LK_LOCK retry loop is fixed at 10x
|
|
259
|
+
# 1s and would block the test suite on bursty contention.
|
|
260
|
+
# The acquire spin is INTENTIONALLY outside the post-acquire
|
|
261
|
+
# try/finally so a deadline-driven raise does NOT trigger
|
|
262
|
+
# the release path on a never-acquired lock; the explicit
|
|
263
|
+
# ``acquired`` flag makes that invariant load-bearing for
|
|
264
|
+
# future readers (Slizard #876 P2).
|
|
265
|
+
acquired = False
|
|
266
|
+
deadline = time.monotonic() + 30.0
|
|
267
|
+
while True:
|
|
268
|
+
try:
|
|
269
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
|
270
|
+
acquired = True
|
|
271
|
+
break
|
|
272
|
+
except OSError:
|
|
273
|
+
if time.monotonic() > deadline:
|
|
274
|
+
raise
|
|
275
|
+
time.sleep(0.02)
|
|
276
|
+
try:
|
|
277
|
+
yield
|
|
278
|
+
finally:
|
|
279
|
+
if acquired:
|
|
280
|
+
fh.seek(0)
|
|
281
|
+
# Best-effort release: the lock may already be gone
|
|
282
|
+
# if the process is mid-shutdown; not an error.
|
|
283
|
+
with suppress(OSError):
|
|
284
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
|
285
|
+
else:
|
|
286
|
+
import fcntl
|
|
287
|
+
|
|
288
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
289
|
+
try:
|
|
290
|
+
yield
|
|
291
|
+
finally:
|
|
292
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
293
|
+
finally:
|
|
294
|
+
# Remove the sidecar ``<log>.lock`` so a clean append never leaves
|
|
295
|
+
# an untracked lock file behind (#1311 discipline). The handle is
|
|
296
|
+
# closed by the `with open(...)` block above BEFORE this unlink
|
|
297
|
+
# (Windows refuses to delete an open file); held under
|
|
298
|
+
# ``_thread_lock`` so the unlink cannot race an in-process
|
|
299
|
+
# re-acquire. Best-effort across processes.
|
|
300
|
+
with suppress(OSError):
|
|
301
|
+
lock_path.unlink()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _resolve_path(path: Path | str | None) -> Path:
|
|
305
|
+
return Path(path) if path is not None else DEFAULT_LOG_PATH
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def append(entry: dict, *, path: Path | str | None = None) -> str:
|
|
309
|
+
"""Validate ``entry`` and atomically append it to the audit log.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
entry: A dict matching ``vbrief/schemas/candidates.schema.json``.
|
|
313
|
+
The caller is responsible for generating ``decision_id`` (a
|
|
314
|
+
UUID4 string) and ``timestamp`` (ISO-8601 UTC). The module
|
|
315
|
+
does not silently fill these in -- callers MUST be explicit so
|
|
316
|
+
tests and replays are deterministic.
|
|
317
|
+
path: Optional override of the log file path. Used by tests to
|
|
318
|
+
redirect writes to a tmp directory; in production callers
|
|
319
|
+
this MUST be left as None to hit the canonical location.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
The validated ``decision_id`` string from ``entry``.
|
|
323
|
+
|
|
324
|
+
Raises:
|
|
325
|
+
CandidatesLogError: if ``entry`` fails validation. No bytes are
|
|
326
|
+
written to disk in this case.
|
|
327
|
+
"""
|
|
328
|
+
_validate_entry(entry)
|
|
329
|
+
log_path = _resolve_path(path)
|
|
330
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
331
|
+
# sort_keys for stable on-disk ordering; ensure_ascii=False preserves
|
|
332
|
+
# non-ASCII actor/reason strings as UTF-8 rather than \uXXXX escapes.
|
|
333
|
+
line = json.dumps(entry, sort_keys=True, ensure_ascii=False)
|
|
334
|
+
with _append_lock(log_path), open(
|
|
335
|
+
log_path, "a", encoding="utf-8", newline=""
|
|
336
|
+
) as fh:
|
|
337
|
+
fh.write(line + "\n")
|
|
338
|
+
fh.flush()
|
|
339
|
+
os.fsync(fh.fileno())
|
|
340
|
+
return entry["decision_id"]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def read_all(
|
|
344
|
+
repo: str | None = None, *, path: Path | str | None = None
|
|
345
|
+
) -> list[dict]:
|
|
346
|
+
"""Return every well-formed entry in chronological insertion order.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
repo: Optional ``owner/name`` filter; entries with a different
|
|
350
|
+
``repo`` are excluded.
|
|
351
|
+
path: Optional log path override (test hook).
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
A list of dicts -- never None. An empty list is returned both when
|
|
355
|
+
the file does not exist and when every line is malformed.
|
|
356
|
+
"""
|
|
357
|
+
log_path = _resolve_path(path)
|
|
358
|
+
if not log_path.exists():
|
|
359
|
+
return []
|
|
360
|
+
out: list[dict] = []
|
|
361
|
+
with open(log_path, encoding="utf-8") as fh:
|
|
362
|
+
for lineno, raw in enumerate(fh, start=1):
|
|
363
|
+
stripped = raw.strip()
|
|
364
|
+
if not stripped:
|
|
365
|
+
continue
|
|
366
|
+
try:
|
|
367
|
+
obj = json.loads(stripped)
|
|
368
|
+
except json.JSONDecodeError as exc:
|
|
369
|
+
LOG.warning(
|
|
370
|
+
"candidates.jsonl: skipping malformed JSON on line %d: %s",
|
|
371
|
+
lineno,
|
|
372
|
+
exc,
|
|
373
|
+
)
|
|
374
|
+
continue
|
|
375
|
+
if not isinstance(obj, dict):
|
|
376
|
+
LOG.warning(
|
|
377
|
+
"candidates.jsonl: skipping non-object entry on line %d "
|
|
378
|
+
"(got %s)",
|
|
379
|
+
lineno,
|
|
380
|
+
type(obj).__name__,
|
|
381
|
+
)
|
|
382
|
+
continue
|
|
383
|
+
if repo is not None and obj.get("repo") != repo:
|
|
384
|
+
continue
|
|
385
|
+
out.append(obj)
|
|
386
|
+
return out
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def find_by_issue(
|
|
390
|
+
issue_number: int,
|
|
391
|
+
repo: str,
|
|
392
|
+
*,
|
|
393
|
+
path: Path | str | None = None,
|
|
394
|
+
) -> list[dict]:
|
|
395
|
+
"""Return every entry for ``(repo, issue_number)`` in insertion order."""
|
|
396
|
+
return [
|
|
397
|
+
e
|
|
398
|
+
for e in read_all(repo=repo, path=path)
|
|
399
|
+
if e.get("issue_number") == issue_number
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def latest_decision(
|
|
404
|
+
issue_number: int,
|
|
405
|
+
repo: str,
|
|
406
|
+
*,
|
|
407
|
+
path: Path | str | None = None,
|
|
408
|
+
) -> dict | None:
|
|
409
|
+
"""Return the most recent decision for ``(repo, issue_number)``.
|
|
410
|
+
|
|
411
|
+
Sort key is the entry's ``timestamp`` field. ISO-8601 strings sort
|
|
412
|
+
lexicographically in chronological order so a string sort is correct
|
|
413
|
+
for any compliant timestamp produced by :func:`append`.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
The latest dict, or None if no decisions exist for the issue.
|
|
417
|
+
"""
|
|
418
|
+
rows = find_by_issue(issue_number, repo, path=path)
|
|
419
|
+
if not rows:
|
|
420
|
+
return None
|
|
421
|
+
rows.sort(key=lambda r: r.get("timestamp", ""))
|
|
422
|
+
return rows[-1]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def new_decision_id() -> str:
|
|
426
|
+
"""Helper: return a fresh UUID4 string for use as ``decision_id``.
|
|
427
|
+
|
|
428
|
+
Provided so callers (Story 3 triage actions) do not have to pull in
|
|
429
|
+
``uuid`` directly and so a future swap to UUID7 (time-ordered) is a
|
|
430
|
+
single-file change.
|
|
431
|
+
"""
|
|
432
|
+
return str(uuid.uuid4())
|