@deftai/directive-content 0.59.0 → 0.60.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-push +10 -9
- package/Taskfile.yml +48 -58
- package/UPGRADING.md +1 -1
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scm/github.md +20 -2
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +16 -0
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2552
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- package/scripts/verify_vbrief_conformance.py +0 -478
package/scripts/slice_record.py
DELETED
|
@@ -1,530 +0,0 @@
|
|
|
1
|
-
"""slice_record.py -- writer + reader for ``vbrief/.eval/slices.jsonl`` (#1132 / D13 of #1119).
|
|
2
|
-
|
|
3
|
-
Slicing skills (``deft-directive-gh-slice``, ``deft-directive-gh-arch``,
|
|
4
|
-
the slice phase of ``deft-directive-refinement``) call
|
|
5
|
-
:func:`write_slice` at slice-completion to record a durable cohort entry
|
|
6
|
-
sibling to the gitignored ``vbrief/.eval/candidates.jsonl`` (#845 Story
|
|
7
|
-
2). Unlike ``candidates.jsonl`` (per-operator, gitignored)
|
|
8
|
-
``slices.jsonl`` is **tracked in git** (see ``vbrief/.eval/README.md``
|
|
9
|
-
tracking-policy table) because cohort records are team-shared: a fresh
|
|
10
|
-
contributor needs to see prior cohort outputs to detect orphans and
|
|
11
|
-
avoid re-slicing the same scope.
|
|
12
|
-
|
|
13
|
-
Public surface
|
|
14
|
-
--------------
|
|
15
|
-
|
|
16
|
-
* :func:`write_slice` -- atomic, idempotent append. Re-writes with the
|
|
17
|
-
same ``slice_id`` are no-ops (retry-safe). Returns the persisted
|
|
18
|
-
``slice_id``.
|
|
19
|
-
* :func:`read_all` -- yield every well-formed record. Tolerant of
|
|
20
|
-
malformed lines (logs a warning, skips them).
|
|
21
|
-
* :func:`find_by_slice_id` / :func:`find_by_umbrella` -- targeted reads.
|
|
22
|
-
* :func:`new_slice_id` -- mint a fresh UUID4 for a new cohort.
|
|
23
|
-
|
|
24
|
-
Concurrency
|
|
25
|
-
-----------
|
|
26
|
-
|
|
27
|
-
Mirrors :mod:`candidates_log` (#845 Story 2):
|
|
28
|
-
|
|
29
|
-
* Cross-process safety via a sidecar ``slices.jsonl.lock`` file held
|
|
30
|
-
with ``msvcrt.locking`` on Windows / ``fcntl.flock`` on POSIX.
|
|
31
|
-
* In-process thread safety via a module-level ``threading.Lock``.
|
|
32
|
-
|
|
33
|
-
Validation
|
|
34
|
-
----------
|
|
35
|
-
|
|
36
|
-
Every dict passed to :func:`write_slice` is validated against the
|
|
37
|
-
constraints in ``vbrief/schemas/slices.schema.json``. The validator is
|
|
38
|
-
hand-rolled so this module has no third-party dependency footprint --
|
|
39
|
-
the schema file remains the canonical reference. Validation errors are
|
|
40
|
-
raised as :class:`SliceRecordError` BEFORE any bytes hit disk.
|
|
41
|
-
|
|
42
|
-
Tracking policy
|
|
43
|
-
---------------
|
|
44
|
-
|
|
45
|
-
``vbrief/.eval/slices.jsonl`` is **tracked** in git (see
|
|
46
|
-
``vbrief/.eval/README.md`` Tracking policy table for the full rationale
|
|
47
|
-
+ the ``merge=union`` rebase ergonomic in ``.gitattributes``).
|
|
48
|
-
"""
|
|
49
|
-
|
|
50
|
-
from __future__ import annotations
|
|
51
|
-
|
|
52
|
-
import json
|
|
53
|
-
import logging
|
|
54
|
-
import os
|
|
55
|
-
import re
|
|
56
|
-
import sys
|
|
57
|
-
import threading
|
|
58
|
-
import time
|
|
59
|
-
import uuid
|
|
60
|
-
from collections.abc import Iterable, Iterator
|
|
61
|
-
from contextlib import contextmanager, suppress
|
|
62
|
-
from datetime import UTC, datetime
|
|
63
|
-
from pathlib import Path
|
|
64
|
-
from typing import Any
|
|
65
|
-
|
|
66
|
-
LOG = logging.getLogger(__name__)
|
|
67
|
-
|
|
68
|
-
# Canonical default storage location resolved relative to the repo root
|
|
69
|
-
# (mirrors :mod:`candidates_log`).
|
|
70
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
71
|
-
|
|
72
|
-
from _content_root import content_root # noqa: E402
|
|
73
|
-
|
|
74
|
-
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
75
|
-
# The .eval/ runtime log is a repo-dev sibling that stays at the root vbrief/;
|
|
76
|
-
# only the shipped schemas moved under content/ (#1875 C1 dual-context).
|
|
77
|
-
DEFAULT_LOG_PATH = REPO_ROOT / "vbrief" / ".eval" / "slices.jsonl"
|
|
78
|
-
SCHEMA_PATH = content_root(REPO_ROOT) / "vbrief" / "schemas" / "slices.schema.json"
|
|
79
|
-
|
|
80
|
-
# Frozen enum mirrored from slices.schema.json. Keep in lockstep with the
|
|
81
|
-
# schema file -- bumping the schema's enum requires a follow-up child
|
|
82
|
-
# (additive only per the schema's frozen-interface preamble).
|
|
83
|
-
_VALID_EXPECTED_CLOSE_SIGNALS: frozenset[str] = frozenset(
|
|
84
|
-
{
|
|
85
|
-
"all-children-merged",
|
|
86
|
-
"wave-1-merged",
|
|
87
|
-
"manual",
|
|
88
|
-
}
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
_REQUIRED_FIELDS: tuple[str, ...] = (
|
|
92
|
-
"slice_id",
|
|
93
|
-
"umbrella",
|
|
94
|
-
"umbrella_url",
|
|
95
|
-
"sliced_at",
|
|
96
|
-
"actor",
|
|
97
|
-
"children",
|
|
98
|
-
"expected_close_signal",
|
|
99
|
-
)
|
|
100
|
-
_OPTIONAL_FIELDS: tuple[str, ...] = ("notes",)
|
|
101
|
-
_ALLOWED_FIELDS: frozenset[str] = frozenset(_REQUIRED_FIELDS + _OPTIONAL_FIELDS)
|
|
102
|
-
_CHILD_REQUIRED_FIELDS: tuple[str, ...] = ("n", "url", "wave", "role")
|
|
103
|
-
_CHILD_ALLOWED_FIELDS: frozenset[str] = frozenset(_CHILD_REQUIRED_FIELDS)
|
|
104
|
-
|
|
105
|
-
_UUID_RE = re.compile(
|
|
106
|
-
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}$"
|
|
107
|
-
)
|
|
108
|
-
# UTC-only on purpose: ``slices.jsonl`` is read by D11's queue ranking +
|
|
109
|
-
# D13's stalled-cohort surface, both of which compare ``sliced_at`` to
|
|
110
|
-
# ``datetime.now(UTC)`` via simple ISO-8601 lexicographic / parsed-utc
|
|
111
|
-
# comparison. A non-UTC offset would silently invert the chronological
|
|
112
|
-
# order (same failure mode as Greptile #876 P1 on candidates_log).
|
|
113
|
-
_ISO8601_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$")
|
|
114
|
-
|
|
115
|
-
_thread_lock = threading.Lock()
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
class SliceRecordError(ValueError):
|
|
119
|
-
"""Raised when a record passed to :func:`write_slice` fails validation."""
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def _validate_child(child: Any, index: int) -> None:
|
|
123
|
-
if not isinstance(child, dict):
|
|
124
|
-
raise SliceRecordError(
|
|
125
|
-
f"children[{index}] must be a dict, got {type(child).__name__}"
|
|
126
|
-
)
|
|
127
|
-
missing = [f for f in _CHILD_REQUIRED_FIELDS if f not in child]
|
|
128
|
-
if missing:
|
|
129
|
-
raise SliceRecordError(
|
|
130
|
-
f"children[{index}] missing required field(s): {missing}"
|
|
131
|
-
)
|
|
132
|
-
extras = sorted(set(child.keys()) - _CHILD_ALLOWED_FIELDS)
|
|
133
|
-
if extras:
|
|
134
|
-
raise SliceRecordError(
|
|
135
|
-
f"children[{index}] has unknown field(s): {extras}"
|
|
136
|
-
)
|
|
137
|
-
n = child["n"]
|
|
138
|
-
if not isinstance(n, int) or isinstance(n, bool) or n < 1:
|
|
139
|
-
raise SliceRecordError(
|
|
140
|
-
f"children[{index}].n must be a positive int, got {n!r}"
|
|
141
|
-
)
|
|
142
|
-
url = child["url"]
|
|
143
|
-
if not isinstance(url, str) or not url:
|
|
144
|
-
raise SliceRecordError(
|
|
145
|
-
f"children[{index}].url must be a non-empty string, got {url!r}"
|
|
146
|
-
)
|
|
147
|
-
wave = child["wave"]
|
|
148
|
-
if not isinstance(wave, int) or isinstance(wave, bool) or wave < 1:
|
|
149
|
-
raise SliceRecordError(
|
|
150
|
-
f"children[{index}].wave must be a positive int, got {wave!r}"
|
|
151
|
-
)
|
|
152
|
-
role = child["role"]
|
|
153
|
-
if not isinstance(role, str) or not role:
|
|
154
|
-
raise SliceRecordError(
|
|
155
|
-
f"children[{index}].role must be a non-empty string, got {role!r}"
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def _validate_record(record: Any) -> None:
|
|
160
|
-
"""Hand-rolled mirror of ``vbrief/schemas/slices.schema.json``.
|
|
161
|
-
|
|
162
|
-
Raises :class:`SliceRecordError` with a human-readable message on the
|
|
163
|
-
first violation encountered. Order-of-checks matches the schema so
|
|
164
|
-
the error message cites the most upstream violation.
|
|
165
|
-
"""
|
|
166
|
-
if not isinstance(record, dict):
|
|
167
|
-
raise SliceRecordError(
|
|
168
|
-
f"record must be a dict, got {type(record).__name__}"
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
missing = [f for f in _REQUIRED_FIELDS if f not in record]
|
|
172
|
-
if missing:
|
|
173
|
-
raise SliceRecordError(
|
|
174
|
-
f"record missing required field(s): {missing}"
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
extras = sorted(set(record.keys()) - _ALLOWED_FIELDS)
|
|
178
|
-
if extras:
|
|
179
|
-
raise SliceRecordError(f"record has unknown field(s): {extras}")
|
|
180
|
-
|
|
181
|
-
slice_id = record["slice_id"]
|
|
182
|
-
if not isinstance(slice_id, str) or not _UUID_RE.match(slice_id):
|
|
183
|
-
raise SliceRecordError(
|
|
184
|
-
f"slice_id must be a UUID string, got {slice_id!r}"
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
umbrella = record["umbrella"]
|
|
188
|
-
if (
|
|
189
|
-
not isinstance(umbrella, int)
|
|
190
|
-
or isinstance(umbrella, bool)
|
|
191
|
-
or umbrella < 1
|
|
192
|
-
):
|
|
193
|
-
raise SliceRecordError(
|
|
194
|
-
f"umbrella must be a positive int, got {umbrella!r}"
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
umbrella_url = record["umbrella_url"]
|
|
198
|
-
if not isinstance(umbrella_url, str) or not umbrella_url:
|
|
199
|
-
raise SliceRecordError(
|
|
200
|
-
f"umbrella_url must be a non-empty string, got {umbrella_url!r}"
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
sliced_at = record["sliced_at"]
|
|
204
|
-
if not isinstance(sliced_at, str) or not _ISO8601_RE.match(sliced_at):
|
|
205
|
-
raise SliceRecordError(
|
|
206
|
-
"sliced_at must be ISO-8601 UTC with Z suffix "
|
|
207
|
-
f"(e.g. 2026-05-13T18:00:00Z), got {sliced_at!r}"
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
actor = record["actor"]
|
|
211
|
-
if not isinstance(actor, str) or not actor:
|
|
212
|
-
raise SliceRecordError(
|
|
213
|
-
f"actor must be a non-empty string, got {actor!r}"
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
children = record["children"]
|
|
217
|
-
if not isinstance(children, list) or not children:
|
|
218
|
-
raise SliceRecordError(
|
|
219
|
-
"children must be a non-empty list of child records"
|
|
220
|
-
)
|
|
221
|
-
for i, child in enumerate(children):
|
|
222
|
-
_validate_child(child, i)
|
|
223
|
-
|
|
224
|
-
expected = record["expected_close_signal"]
|
|
225
|
-
if expected not in _VALID_EXPECTED_CLOSE_SIGNALS:
|
|
226
|
-
raise SliceRecordError(
|
|
227
|
-
f"expected_close_signal must be one of "
|
|
228
|
-
f"{sorted(_VALID_EXPECTED_CLOSE_SIGNALS)}, got {expected!r}"
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
if "notes" in record and not isinstance(record["notes"], str):
|
|
232
|
-
raise SliceRecordError(
|
|
233
|
-
f"notes must be a string, got {type(record['notes']).__name__}"
|
|
234
|
-
)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
@contextmanager
|
|
238
|
-
def _append_lock(log_path: Path) -> Iterator[None]:
|
|
239
|
-
"""Serialise appenders across threads AND processes.
|
|
240
|
-
|
|
241
|
-
Sibling implementation of :func:`candidates_log._append_lock` --
|
|
242
|
-
sidecar ``<log>.lock`` byte-range exclusive lock.
|
|
243
|
-
|
|
244
|
-
Also exported as :func:`append_lock` for callers (e.g.
|
|
245
|
-
:mod:`slice_record_existing` per #1231) that need to wrap a
|
|
246
|
-
read-decide-write critical section -- specifically the duplicate
|
|
247
|
-
detection + :func:`write_slice_unlocked` pair -- under the SAME
|
|
248
|
-
lock so concurrent invocations cannot both observe "no duplicate"
|
|
249
|
-
before either appends. The lock is NOT reentrant (the underlying
|
|
250
|
-
``threading.Lock`` + ``msvcrt.locking`` / ``fcntl.flock`` would
|
|
251
|
-
deadlock on re-entry); callers wrapping a critical section MUST
|
|
252
|
-
use :func:`write_slice_unlocked` rather than :func:`write_slice`
|
|
253
|
-
while holding the lock.
|
|
254
|
-
"""
|
|
255
|
-
lock_path = log_path.parent / (log_path.name + ".lock")
|
|
256
|
-
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
257
|
-
with _thread_lock:
|
|
258
|
-
try:
|
|
259
|
-
with open(lock_path, "a+b") as fh:
|
|
260
|
-
if not lock_path.stat().st_size:
|
|
261
|
-
fh.write(b"\0")
|
|
262
|
-
fh.flush()
|
|
263
|
-
fh.seek(0)
|
|
264
|
-
if sys.platform == "win32":
|
|
265
|
-
import msvcrt
|
|
266
|
-
|
|
267
|
-
acquired = False
|
|
268
|
-
deadline = time.monotonic() + 30.0
|
|
269
|
-
while True:
|
|
270
|
-
try:
|
|
271
|
-
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
|
272
|
-
acquired = True
|
|
273
|
-
break
|
|
274
|
-
except OSError:
|
|
275
|
-
if time.monotonic() > deadline:
|
|
276
|
-
raise
|
|
277
|
-
time.sleep(0.02)
|
|
278
|
-
try:
|
|
279
|
-
yield
|
|
280
|
-
finally:
|
|
281
|
-
if acquired:
|
|
282
|
-
fh.seek(0)
|
|
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 new_slice_id() -> str:
|
|
309
|
-
"""Return a fresh UUID4 string for use as a :attr:`slice_id`.
|
|
310
|
-
|
|
311
|
-
Provided so callers (slicing skills + tests) do not have to pull in
|
|
312
|
-
:mod:`uuid` directly and so a future swap to UUID7 (time-ordered) is
|
|
313
|
-
a single-file change.
|
|
314
|
-
"""
|
|
315
|
-
return str(uuid.uuid4())
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def now_iso() -> str:
|
|
319
|
-
"""Return the current UTC time in canonical ISO-8601 form with ``Z`` suffix."""
|
|
320
|
-
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
def _existing_slice_ids(log_path: Path) -> set[str]:
|
|
324
|
-
"""Return slice_ids already persisted (used for retry-dedup).
|
|
325
|
-
|
|
326
|
-
Tolerant of malformed lines -- mirrors :func:`read_all`'s warn-and-skip.
|
|
327
|
-
"""
|
|
328
|
-
if not log_path.exists():
|
|
329
|
-
return set()
|
|
330
|
-
seen: set[str] = set()
|
|
331
|
-
with open(log_path, encoding="utf-8") as fh:
|
|
332
|
-
for lineno, raw in enumerate(fh, start=1):
|
|
333
|
-
stripped = raw.strip()
|
|
334
|
-
if not stripped:
|
|
335
|
-
continue
|
|
336
|
-
try:
|
|
337
|
-
obj = json.loads(stripped)
|
|
338
|
-
except json.JSONDecodeError as exc:
|
|
339
|
-
LOG.warning(
|
|
340
|
-
"slices.jsonl: skipping malformed JSON on line %d: %s",
|
|
341
|
-
lineno,
|
|
342
|
-
exc,
|
|
343
|
-
)
|
|
344
|
-
continue
|
|
345
|
-
if isinstance(obj, dict):
|
|
346
|
-
sid = obj.get("slice_id")
|
|
347
|
-
if isinstance(sid, str):
|
|
348
|
-
seen.add(sid)
|
|
349
|
-
return seen
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
def write_slice(
|
|
353
|
-
umbrella: int,
|
|
354
|
-
children: Iterable[dict[str, Any]],
|
|
355
|
-
*,
|
|
356
|
-
umbrella_url: str,
|
|
357
|
-
actor: str,
|
|
358
|
-
expected_close_signal: str = "all-children-merged",
|
|
359
|
-
slice_id: str | None = None,
|
|
360
|
-
sliced_at: str | None = None,
|
|
361
|
-
notes: str | None = None,
|
|
362
|
-
path: Path | str | None = None,
|
|
363
|
-
) -> str:
|
|
364
|
-
"""Validate and atomically append a cohort record to ``slices.jsonl``.
|
|
365
|
-
|
|
366
|
-
Args:
|
|
367
|
-
umbrella: Umbrella issue number.
|
|
368
|
-
children: Iterable of child dicts. Each child must carry
|
|
369
|
-
``{n, url, wave, role}`` per ``vbrief/schemas/slices.schema.json``.
|
|
370
|
-
umbrella_url: Full URL of the umbrella issue.
|
|
371
|
-
actor: Slicing actor identity (e.g. ``"skill:gh-slice"``).
|
|
372
|
-
expected_close_signal: One of ``all-children-merged`` (default),
|
|
373
|
-
``wave-1-merged``, ``manual``.
|
|
374
|
-
slice_id: Optional explicit slice_id; minted if omitted. Pass an
|
|
375
|
-
existing slice_id to make a retry idempotent.
|
|
376
|
-
sliced_at: Optional ISO-8601 UTC timestamp; current time if omitted.
|
|
377
|
-
notes: Optional free-form rationale.
|
|
378
|
-
path: Optional log file path override (test hook).
|
|
379
|
-
|
|
380
|
-
Returns:
|
|
381
|
-
The persisted ``slice_id`` (the supplied one if provided,
|
|
382
|
-
otherwise the newly-minted UUID). On idempotent no-op (the
|
|
383
|
-
``slice_id`` is already present in the log), the same id is
|
|
384
|
-
returned without re-writing.
|
|
385
|
-
|
|
386
|
-
Raises:
|
|
387
|
-
SliceRecordError: if any field fails validation. No bytes are
|
|
388
|
-
written to disk in this case.
|
|
389
|
-
"""
|
|
390
|
-
resolved_id = slice_id or new_slice_id()
|
|
391
|
-
record: dict[str, Any] = {
|
|
392
|
-
"slice_id": resolved_id,
|
|
393
|
-
"umbrella": umbrella,
|
|
394
|
-
"umbrella_url": umbrella_url,
|
|
395
|
-
"sliced_at": sliced_at or now_iso(),
|
|
396
|
-
"actor": actor,
|
|
397
|
-
"children": [dict(c) for c in children],
|
|
398
|
-
"expected_close_signal": expected_close_signal,
|
|
399
|
-
}
|
|
400
|
-
if notes is not None:
|
|
401
|
-
record["notes"] = notes
|
|
402
|
-
|
|
403
|
-
log_path = _resolve_path(path)
|
|
404
|
-
with _append_lock(log_path):
|
|
405
|
-
return write_slice_unlocked(record=record, path=log_path)
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def write_slice_unlocked(
|
|
409
|
-
*,
|
|
410
|
-
record: dict[str, Any],
|
|
411
|
-
path: Path | str | None = None,
|
|
412
|
-
) -> str:
|
|
413
|
-
"""Validate + append ``record`` without acquiring the sidecar lock.
|
|
414
|
-
|
|
415
|
-
Companion to :func:`write_slice` for callers that wrap their own
|
|
416
|
-
read-decide-write critical section under :func:`append_lock`
|
|
417
|
-
directly (see :mod:`slice_record_existing` per #1231 -- the
|
|
418
|
-
duplicate-detection + append pair must run under one lock for
|
|
419
|
-
atomic idempotency).
|
|
420
|
-
|
|
421
|
-
Behaviour mirrors :func:`write_slice`:
|
|
422
|
-
|
|
423
|
-
* Validates ``record`` against the schema; raises
|
|
424
|
-
:class:`SliceRecordError` before any bytes hit disk.
|
|
425
|
-
* Idempotent retry: if ``record['slice_id']`` is already present
|
|
426
|
-
in the log, returns it without re-writing.
|
|
427
|
-
* Otherwise appends one JSONL line, fsync'd.
|
|
428
|
-
|
|
429
|
-
The caller is responsible for holding :func:`append_lock` for the
|
|
430
|
-
same ``path``. Use :func:`write_slice` instead when you do NOT
|
|
431
|
-
need to compose with another read under the same lock.
|
|
432
|
-
"""
|
|
433
|
-
_validate_record(record)
|
|
434
|
-
log_path = _resolve_path(path)
|
|
435
|
-
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
436
|
-
resolved_id = record["slice_id"]
|
|
437
|
-
line = json.dumps(record, sort_keys=True, ensure_ascii=False)
|
|
438
|
-
# Re-check under the lock so a concurrent appender that wrote the
|
|
439
|
-
# same slice_id between the validation pass and the append cannot
|
|
440
|
-
# produce a duplicate.
|
|
441
|
-
existing = _existing_slice_ids(log_path)
|
|
442
|
-
if resolved_id in existing:
|
|
443
|
-
LOG.info(
|
|
444
|
-
"slices.jsonl: slice_id %s already present; write_slice is a no-op",
|
|
445
|
-
resolved_id,
|
|
446
|
-
)
|
|
447
|
-
return resolved_id
|
|
448
|
-
with open(log_path, "a", encoding="utf-8", newline="") as fh:
|
|
449
|
-
fh.write(line + "\n")
|
|
450
|
-
fh.flush()
|
|
451
|
-
os.fsync(fh.fileno())
|
|
452
|
-
return resolved_id
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
def read_all(*, path: Path | str | None = None) -> list[dict[str, Any]]:
|
|
456
|
-
"""Return every well-formed slice record in insertion order.
|
|
457
|
-
|
|
458
|
-
Args:
|
|
459
|
-
path: Optional log path override (test hook).
|
|
460
|
-
|
|
461
|
-
Returns:
|
|
462
|
-
A list of dicts -- never None. An empty list is returned both
|
|
463
|
-
when the file does not exist and when every line is malformed.
|
|
464
|
-
"""
|
|
465
|
-
log_path = _resolve_path(path)
|
|
466
|
-
if not log_path.exists():
|
|
467
|
-
return []
|
|
468
|
-
out: list[dict[str, Any]] = []
|
|
469
|
-
with open(log_path, encoding="utf-8") as fh:
|
|
470
|
-
for lineno, raw in enumerate(fh, start=1):
|
|
471
|
-
stripped = raw.strip()
|
|
472
|
-
if not stripped:
|
|
473
|
-
continue
|
|
474
|
-
try:
|
|
475
|
-
obj = json.loads(stripped)
|
|
476
|
-
except json.JSONDecodeError as exc:
|
|
477
|
-
LOG.warning(
|
|
478
|
-
"slices.jsonl: skipping malformed JSON on line %d: %s",
|
|
479
|
-
lineno,
|
|
480
|
-
exc,
|
|
481
|
-
)
|
|
482
|
-
continue
|
|
483
|
-
if not isinstance(obj, dict):
|
|
484
|
-
LOG.warning(
|
|
485
|
-
"slices.jsonl: skipping non-object entry on line %d (got %s)",
|
|
486
|
-
lineno,
|
|
487
|
-
type(obj).__name__,
|
|
488
|
-
)
|
|
489
|
-
continue
|
|
490
|
-
out.append(obj)
|
|
491
|
-
return out
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
def find_by_slice_id(
|
|
495
|
-
slice_id: str, *, path: Path | str | None = None
|
|
496
|
-
) -> dict[str, Any] | None:
|
|
497
|
-
"""Return the slice record matching ``slice_id`` or ``None``."""
|
|
498
|
-
for record in read_all(path=path):
|
|
499
|
-
if record.get("slice_id") == slice_id:
|
|
500
|
-
return record
|
|
501
|
-
return None
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
def find_by_umbrella(
|
|
505
|
-
umbrella: int, *, path: Path | str | None = None
|
|
506
|
-
) -> list[dict[str, Any]]:
|
|
507
|
-
"""Return every slice record for ``umbrella`` in insertion order."""
|
|
508
|
-
return [r for r in read_all(path=path) if r.get("umbrella") == umbrella]
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
# Public alias for the sidecar-file lock. Callers that need to wrap a
|
|
512
|
-
# read-decide-write critical section (`slice_record_existing` per #1231)
|
|
513
|
-
# can import this directly without reaching for the private name; the
|
|
514
|
-
# underscore form is preserved for in-module readability.
|
|
515
|
-
append_lock = _append_lock
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
__all__ = [
|
|
519
|
-
"DEFAULT_LOG_PATH",
|
|
520
|
-
"SCHEMA_PATH",
|
|
521
|
-
"SliceRecordError",
|
|
522
|
-
"append_lock",
|
|
523
|
-
"find_by_slice_id",
|
|
524
|
-
"find_by_umbrella",
|
|
525
|
-
"new_slice_id",
|
|
526
|
-
"now_iso",
|
|
527
|
-
"read_all",
|
|
528
|
-
"write_slice",
|
|
529
|
-
"write_slice_unlocked",
|
|
530
|
-
]
|