ww-agentic-workflows 1.0.0.dev3__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.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/rule_conversion.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Record scriptized checks outside a task: ``rules convert`` and ``decline``.
|
|
3
|
+
|
|
4
|
+
A rule without a command of its own is enforced by the rule-automation
|
|
5
|
+
store. These operations write the store directly, on the operator's
|
|
6
|
+
confirmation, so a check built once for the project (by
|
|
7
|
+
``ww-scriptize-rules``, or by hand) is recorded without a task's verifier.
|
|
8
|
+
``scriptize_state`` says, for each declared rule, where it stands, and
|
|
9
|
+
``scriptize_notice`` tells the operator about the rules no check covers yet.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass, replace
|
|
16
|
+
from pathlib import PurePosixPath
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
from ww.builtin_workflows import missing_lane
|
|
20
|
+
from ww.errors import StateError
|
|
21
|
+
from ww.rule_store import (
|
|
22
|
+
CheckEntry,
|
|
23
|
+
CheckSpec,
|
|
24
|
+
RuleAutomation,
|
|
25
|
+
RuleEntry,
|
|
26
|
+
is_check_name,
|
|
27
|
+
is_config_path,
|
|
28
|
+
)
|
|
29
|
+
from ww.workflow_config import RuleDefinition, WorkflowConfiguration, every_step
|
|
30
|
+
|
|
31
|
+
# The built-in workflow that turns rules into checks, outside any task.
|
|
32
|
+
SCRIPTIZE_WORKFLOW = "ww-scriptize-rules"
|
|
33
|
+
|
|
34
|
+
# Where a declared rule stands: checked by its own command, by a converted
|
|
35
|
+
# store check, declined or rejected (judged by a verifier), or not scriptized
|
|
36
|
+
# yet. The interim statuses of an old store, from when verifiers proposed
|
|
37
|
+
# checks inside tasks, count as not scriptized.
|
|
38
|
+
ScriptizeState = Literal[
|
|
39
|
+
"command", "converted", "not_convertible", "rejected", "unscriptized"
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def scriptize_state(automation: RuleAutomation, rule: RuleDefinition) -> ScriptizeState:
|
|
44
|
+
if rule.check is not None:
|
|
45
|
+
return "command"
|
|
46
|
+
if automation.converted_check(rule.text_hash) is not None:
|
|
47
|
+
return "converted"
|
|
48
|
+
entry = automation.rules.get(rule.text_hash)
|
|
49
|
+
if entry is not None and entry.status in {"not_convertible", "rejected"}:
|
|
50
|
+
return entry.status
|
|
51
|
+
return "unscriptized"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def unscriptized_rules(
|
|
55
|
+
configuration: WorkflowConfiguration, automation: RuleAutomation
|
|
56
|
+
) -> tuple[RuleDefinition, ...]:
|
|
57
|
+
"""The declared rules not scriptized yet, each once, in declaration order."""
|
|
58
|
+
found: dict[str, RuleDefinition] = {}
|
|
59
|
+
for rule in _every_rule(configuration):
|
|
60
|
+
if rule.id not in found and scriptize_state(automation, rule) == "unscriptized":
|
|
61
|
+
found[rule.id] = rule
|
|
62
|
+
return tuple(found.values())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def scriptize_notice(
|
|
66
|
+
configuration: WorkflowConfiguration, automation: RuleAutomation
|
|
67
|
+
) -> str | None:
|
|
68
|
+
"""What ``discover`` and ``start`` say about rules without a check yet.
|
|
69
|
+
|
|
70
|
+
``None`` when there are none, or while ``ww-scriptize-rules`` is switched
|
|
71
|
+
off. It never blocks anything: such rules are judged by verifiers.
|
|
72
|
+
"""
|
|
73
|
+
workflow = configuration.workflows_by_name.get(SCRIPTIZE_WORKFLOW)
|
|
74
|
+
if workflow is None:
|
|
75
|
+
return None
|
|
76
|
+
count = len(unscriptized_rules(configuration, automation))
|
|
77
|
+
if not count:
|
|
78
|
+
return None
|
|
79
|
+
notice = (
|
|
80
|
+
f"{count} declared rule{'s have' if count != 1 else ' has'} no check "
|
|
81
|
+
"yet, so a verifier judges "
|
|
82
|
+
f"{'them' if count != 1 else 'it'} in every step. The `ww-scriptize` "
|
|
83
|
+
f"skill starts `{SCRIPTIZE_WORKFLOW}`, which builds "
|
|
84
|
+
f"{'checks for them' if count != 1 else 'a check for it'} with the "
|
|
85
|
+
"operator."
|
|
86
|
+
)
|
|
87
|
+
if missing_lane(workflow) is not None:
|
|
88
|
+
notice += (
|
|
89
|
+
" It first needs the lane it works in: `hooks_from` in its "
|
|
90
|
+
"ww.yaml definition."
|
|
91
|
+
)
|
|
92
|
+
return notice
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def declared_rules(
|
|
96
|
+
configuration: WorkflowConfiguration, rule_ids: tuple[str, ...]
|
|
97
|
+
) -> tuple[RuleDefinition, ...]:
|
|
98
|
+
"""The declared rules with these IDs, in the order given.
|
|
99
|
+
|
|
100
|
+
An unknown ID, a repeated one, and a rule with a command of its own,
|
|
101
|
+
which the store never covers, are refused.
|
|
102
|
+
"""
|
|
103
|
+
if not rule_ids:
|
|
104
|
+
raise StateError("name at least one rule ID; `rules` lists them")
|
|
105
|
+
if len(set(rule_ids)) != len(rule_ids):
|
|
106
|
+
raise StateError("a rule ID is named twice")
|
|
107
|
+
by_id = {rule.id: rule for rule in _every_rule(configuration)}
|
|
108
|
+
rules = []
|
|
109
|
+
for rule_id in rule_ids:
|
|
110
|
+
rule = by_id.get(rule_id)
|
|
111
|
+
if rule is None:
|
|
112
|
+
raise StateError(
|
|
113
|
+
f"no rule {rule_id!r} is declared; `rules` lists every rule ID"
|
|
114
|
+
)
|
|
115
|
+
if rule.check is not None:
|
|
116
|
+
raise StateError(
|
|
117
|
+
f"rule `{rule_id}` has a command of its own; the store does not "
|
|
118
|
+
"cover it"
|
|
119
|
+
)
|
|
120
|
+
rules.append(rule)
|
|
121
|
+
return tuple(rules)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class StoreChange:
|
|
126
|
+
"""The store after ``convert`` or ``decline``, and what else it changed.
|
|
127
|
+
|
|
128
|
+
``unscriptized`` are the text hashes of rules returned to unscriptized;
|
|
129
|
+
``moved`` pairs a rule hash with the other check that covered it before;
|
|
130
|
+
``dropped_checks`` are checks left covering nothing, removed with their
|
|
131
|
+
pending revisions; ``dropped_revisions`` names the checks whose pending
|
|
132
|
+
revision was dropped while the check stays.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
automation: RuleAutomation
|
|
136
|
+
unscriptized: tuple[str, ...] = ()
|
|
137
|
+
moved: tuple[tuple[str, str], ...] = ()
|
|
138
|
+
dropped_checks: tuple[str, ...] = ()
|
|
139
|
+
dropped_revisions: tuple[str, ...] = ()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# Rule entries a check's own coverage stands for (``proposed`` only in an old
|
|
143
|
+
# store); any other entry naming the check, such as a rejection, is the
|
|
144
|
+
# operator's record and is kept when the check stops covering the rule.
|
|
145
|
+
_COVERED_STATUSES = frozenset({"converted", "proposed"})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def config_paths(paths: tuple[str, ...]) -> tuple[str, ...]:
|
|
149
|
+
"""A check's configuration files, normalised, relative to its directory.
|
|
150
|
+
|
|
151
|
+
An empty path, an absolute one, and one with a ``..`` part, which would
|
|
152
|
+
reach outside the directory the step's checks run in, are refused.
|
|
153
|
+
"""
|
|
154
|
+
normalised = []
|
|
155
|
+
for path in paths:
|
|
156
|
+
if not is_config_path(path.strip()):
|
|
157
|
+
raise StateError(
|
|
158
|
+
f"--config {path!r} must be a relative path inside the project, "
|
|
159
|
+
"without `..`"
|
|
160
|
+
)
|
|
161
|
+
normalised.append(PurePosixPath(path.strip()).as_posix())
|
|
162
|
+
return tuple(dict.fromkeys(normalised))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def convert(
|
|
166
|
+
automation: RuleAutomation,
|
|
167
|
+
name: str,
|
|
168
|
+
spec: CheckSpec,
|
|
169
|
+
rules: tuple[RuleDefinition, ...],
|
|
170
|
+
now: str,
|
|
171
|
+
) -> StoreChange:
|
|
172
|
+
"""Record ``name`` as a converted check covering ``rules``.
|
|
173
|
+
|
|
174
|
+
``spec.covers`` is replaced by the rules' text hashes and ``spec.config``
|
|
175
|
+
is normalised. A new name creates the check; an existing one has its
|
|
176
|
+
command, configuration, proof and coverage replaced, and any pending
|
|
177
|
+
revision dropped. Rules the check covered before and covers no more
|
|
178
|
+
return to unscriptized: their store entries are removed, except a
|
|
179
|
+
rejection or a decline, which stays. A rule another check covered moves
|
|
180
|
+
to this one; a check left covering nothing is removed.
|
|
181
|
+
"""
|
|
182
|
+
if not is_check_name(name):
|
|
183
|
+
raise StateError(
|
|
184
|
+
f"check name {name!r} must be kebab-case, at most 40 characters"
|
|
185
|
+
)
|
|
186
|
+
spec = replace(spec, config=config_paths(spec.config))
|
|
187
|
+
hashes = tuple(dict.fromkeys(rule.text_hash for rule in rules))
|
|
188
|
+
previous = automation.checks.get(name)
|
|
189
|
+
before = (
|
|
190
|
+
set(previous.spec.covers)
|
|
191
|
+
| set(previous.pending.covers if previous.pending else ())
|
|
192
|
+
if previous is not None
|
|
193
|
+
else set()
|
|
194
|
+
)
|
|
195
|
+
unscriptized = tuple(
|
|
196
|
+
key
|
|
197
|
+
for key in sorted(before - set(hashes))
|
|
198
|
+
if (entry := automation.rules.get(key)) is not None
|
|
199
|
+
and entry.check == name
|
|
200
|
+
and entry.status in _COVERED_STATUSES
|
|
201
|
+
)
|
|
202
|
+
moved = tuple(
|
|
203
|
+
(key, check_name)
|
|
204
|
+
for key in hashes
|
|
205
|
+
for check_name, check in automation.checks.items()
|
|
206
|
+
if check_name != name and key in _coverage(check)
|
|
207
|
+
)
|
|
208
|
+
automation = replace(
|
|
209
|
+
automation,
|
|
210
|
+
rules={k: v for k, v in automation.rules.items() if k not in unscriptized},
|
|
211
|
+
)
|
|
212
|
+
automation, dropped_checks, dropped_revisions = _uncover(
|
|
213
|
+
automation, set(hashes), keep=name
|
|
214
|
+
)
|
|
215
|
+
if previous is not None and previous.pending is not None:
|
|
216
|
+
dropped_revisions = (name, *dropped_revisions)
|
|
217
|
+
automation = automation.with_check(
|
|
218
|
+
name,
|
|
219
|
+
CheckEntry(
|
|
220
|
+
spec=replace(spec, covers=hashes),
|
|
221
|
+
status="converted",
|
|
222
|
+
proposed_at=previous.proposed_at if previous else now,
|
|
223
|
+
approved_at=now,
|
|
224
|
+
approved_by="operator",
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
for rule in rules:
|
|
228
|
+
entry = automation.rules.get(rule.text_hash)
|
|
229
|
+
automation = automation.with_rule(
|
|
230
|
+
rule.text_hash,
|
|
231
|
+
RuleEntry(
|
|
232
|
+
rule.text,
|
|
233
|
+
"converted",
|
|
234
|
+
interpretation=entry.interpretation if entry else None,
|
|
235
|
+
check=name,
|
|
236
|
+
approved_by="operator",
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
return StoreChange(
|
|
240
|
+
automation,
|
|
241
|
+
unscriptized=unscriptized,
|
|
242
|
+
moved=moved,
|
|
243
|
+
dropped_checks=dropped_checks,
|
|
244
|
+
dropped_revisions=dropped_revisions,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def decline(
|
|
249
|
+
automation: RuleAutomation, rules: tuple[RuleDefinition, ...], reason: str
|
|
250
|
+
) -> StoreChange:
|
|
251
|
+
"""Record ``rules`` as ``not_convertible``: judged, never proposed again."""
|
|
252
|
+
reason = reason.strip()
|
|
253
|
+
if not reason:
|
|
254
|
+
raise StateError("rules decline needs --reason")
|
|
255
|
+
hashes = {rule.text_hash for rule in rules}
|
|
256
|
+
moved = tuple(
|
|
257
|
+
(rule.text_hash, check_name)
|
|
258
|
+
for rule in rules
|
|
259
|
+
for check_name, check in automation.checks.items()
|
|
260
|
+
if rule.text_hash in _coverage(check)
|
|
261
|
+
)
|
|
262
|
+
automation, dropped_checks, dropped_revisions = _uncover(automation, hashes)
|
|
263
|
+
for rule in rules:
|
|
264
|
+
entry = automation.rules.get(rule.text_hash)
|
|
265
|
+
automation = automation.with_rule(
|
|
266
|
+
rule.text_hash,
|
|
267
|
+
RuleEntry(
|
|
268
|
+
rule.text,
|
|
269
|
+
"not_convertible",
|
|
270
|
+
interpretation=entry.interpretation if entry else None,
|
|
271
|
+
reason=reason,
|
|
272
|
+
approved_by="operator",
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
return StoreChange(
|
|
276
|
+
automation,
|
|
277
|
+
moved=moved,
|
|
278
|
+
dropped_checks=dropped_checks,
|
|
279
|
+
dropped_revisions=dropped_revisions,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _coverage(check: CheckEntry) -> set[str]:
|
|
284
|
+
"""The rules a check covers, approved or in its pending revision."""
|
|
285
|
+
return set(check.spec.covers) | set(check.pending.covers if check.pending else ())
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _uncover(
|
|
289
|
+
automation: RuleAutomation, hashes: set[str], keep: str | None = None
|
|
290
|
+
) -> tuple[RuleAutomation, tuple[str, ...], tuple[str, ...]]:
|
|
291
|
+
"""Drop ``hashes`` from every check's coverage but ``keep``'s.
|
|
292
|
+
|
|
293
|
+
A check it changes loses any pending revision (only an old store has
|
|
294
|
+
one); a check, other than a rejected one, left covering nothing is
|
|
295
|
+
removed. Returns the store, the checks removed and the kept checks whose
|
|
296
|
+
revision was dropped.
|
|
297
|
+
"""
|
|
298
|
+
dropped_checks: list[str] = []
|
|
299
|
+
dropped_revisions: list[str] = []
|
|
300
|
+
checks = dict(automation.checks)
|
|
301
|
+
for check_name, check in automation.checks.items():
|
|
302
|
+
if check_name == keep or not hashes & _coverage(check):
|
|
303
|
+
continue
|
|
304
|
+
spec = _without(check.spec, hashes)
|
|
305
|
+
if not spec.covers and check.status != "rejected":
|
|
306
|
+
del checks[check_name]
|
|
307
|
+
dropped_checks.append(check_name)
|
|
308
|
+
continue
|
|
309
|
+
if check.pending is not None:
|
|
310
|
+
dropped_revisions.append(check_name)
|
|
311
|
+
checks[check_name] = replace(check, spec=spec, pending=None)
|
|
312
|
+
return (
|
|
313
|
+
replace(automation, checks=checks),
|
|
314
|
+
tuple(dropped_checks),
|
|
315
|
+
tuple(dropped_revisions),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _without(spec: CheckSpec, hashes: set[str]) -> CheckSpec:
|
|
320
|
+
if not hashes & set(spec.covers):
|
|
321
|
+
return spec
|
|
322
|
+
return replace(spec, covers=tuple(key for key in spec.covers if key not in hashes))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _every_rule(configuration: WorkflowConfiguration) -> Iterator[RuleDefinition]:
|
|
326
|
+
for group in configuration.rule_groups:
|
|
327
|
+
yield from group.rules
|
|
328
|
+
for step in every_step(configuration):
|
|
329
|
+
for entry in step.rules:
|
|
330
|
+
if isinstance(entry, RuleDefinition):
|
|
331
|
+
yield entry
|
ww/rule_disputes.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Every dispute a step's worker raised against a check, across tasks.
|
|
3
|
+
|
|
4
|
+
A worker whose completion a check rejected may dispute the check instead of
|
|
5
|
+
fixing its work (``ww dispute``); the operator then lets the check stand or
|
|
6
|
+
waives it for that step. The task keeps the open dispute on the step's record;
|
|
7
|
+
this log keeps every dispute ever raised in the project, so ``ww lint`` can
|
|
8
|
+
point at the rules and checks that keep being argued with without reading
|
|
9
|
+
every task's state.
|
|
10
|
+
|
|
11
|
+
The log is local history at ``.ww/rule-disputes.json``, beside the task
|
|
12
|
+
states it summarises. It is deliberately not part of the rule-automation
|
|
13
|
+
store: the store holds derived knowledge about rules, a dispute is an event.
|
|
14
|
+
A dispute is appended before the task state records it, so an interruption
|
|
15
|
+
between the two can leave one entry too many, never one too few.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ww.errors import StateError
|
|
26
|
+
from ww.locking import FileLocks
|
|
27
|
+
from ww.validation import expect_optional_string, expect_positive_int, expect_string
|
|
28
|
+
|
|
29
|
+
DISPUTES_FILE = ".ww/rule-disputes.json"
|
|
30
|
+
DISPUTES_SCHEMA_VERSION = 1
|
|
31
|
+
_ENTRY_KEYS = {
|
|
32
|
+
"check",
|
|
33
|
+
"text_hash",
|
|
34
|
+
"task_id",
|
|
35
|
+
"run_id",
|
|
36
|
+
"step",
|
|
37
|
+
"reason",
|
|
38
|
+
"attempt",
|
|
39
|
+
"disputed_at",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class DisputeEntry:
|
|
45
|
+
"""One dispute: which check, of which step, why, and when.
|
|
46
|
+
|
|
47
|
+
``text_hash`` is the disputed rule's wording hash when the check is a
|
|
48
|
+
rule's, so a dispute follows the wording like the store does.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
check: str
|
|
52
|
+
task_id: str
|
|
53
|
+
step: str
|
|
54
|
+
reason: str
|
|
55
|
+
attempt: int
|
|
56
|
+
disputed_at: str
|
|
57
|
+
text_hash: str | None = None
|
|
58
|
+
run_id: str | None = None
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict[str, object]:
|
|
61
|
+
return {
|
|
62
|
+
"check": self.check,
|
|
63
|
+
"text_hash": self.text_hash,
|
|
64
|
+
"task_id": self.task_id,
|
|
65
|
+
"run_id": self.run_id,
|
|
66
|
+
"step": self.step,
|
|
67
|
+
"reason": self.reason,
|
|
68
|
+
"attempt": self.attempt,
|
|
69
|
+
"disputed_at": self.disputed_at,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_dict(cls, data: Any, path: str) -> DisputeEntry:
|
|
74
|
+
if not isinstance(data, dict):
|
|
75
|
+
raise ValueError(f"{path} must be an object")
|
|
76
|
+
unknown = set(data) - _ENTRY_KEYS
|
|
77
|
+
if unknown:
|
|
78
|
+
raise ValueError(f"{path} has unknown keys: " + ", ".join(sorted(unknown)))
|
|
79
|
+
return cls(
|
|
80
|
+
check=expect_string(data.get("check"), f"{path}.check"),
|
|
81
|
+
text_hash=expect_optional_string(
|
|
82
|
+
data.get("text_hash"), f"{path}.text_hash"
|
|
83
|
+
),
|
|
84
|
+
task_id=expect_string(data.get("task_id"), f"{path}.task_id"),
|
|
85
|
+
run_id=expect_optional_string(data.get("run_id"), f"{path}.run_id"),
|
|
86
|
+
step=expect_string(data.get("step"), f"{path}.step"),
|
|
87
|
+
reason=expect_string(data.get("reason"), f"{path}.reason"),
|
|
88
|
+
attempt=expect_positive_int(data.get("attempt"), f"{path}.attempt"),
|
|
89
|
+
disputed_at=expect_string(data.get("disputed_at"), f"{path}.disputed_at"),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class DisputeLog:
|
|
94
|
+
"""Read and append ``.ww/rule-disputes.json``.
|
|
95
|
+
|
|
96
|
+
A missing file is an empty log; a malformed one is an error, never an
|
|
97
|
+
empty log. Appends are read-modify-writes under the file's own lock,
|
|
98
|
+
taken inside the task lock of the disputing command.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, root: Path) -> None:
|
|
102
|
+
self.path = Path(root) / DISPUTES_FILE
|
|
103
|
+
self.locks = FileLocks(Path(root))
|
|
104
|
+
|
|
105
|
+
def load(self) -> tuple[DisputeEntry, ...]:
|
|
106
|
+
if not self.path.exists():
|
|
107
|
+
return ()
|
|
108
|
+
try:
|
|
109
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
110
|
+
return _entries(raw)
|
|
111
|
+
except (OSError, json.JSONDecodeError, ValueError) as error:
|
|
112
|
+
raise StateError(
|
|
113
|
+
f"invalid rule dispute log {self.path}: {error}"
|
|
114
|
+
) from error
|
|
115
|
+
|
|
116
|
+
def append(self, entry: DisputeEntry) -> None:
|
|
117
|
+
with self.locks.lock(self.path, purpose="rule dispute log"):
|
|
118
|
+
entries = (*self.load(), entry)
|
|
119
|
+
self.locks.atomic_write(
|
|
120
|
+
self.path,
|
|
121
|
+
json.dumps(
|
|
122
|
+
{
|
|
123
|
+
"schema_version": DISPUTES_SCHEMA_VERSION,
|
|
124
|
+
"disputes": [item.to_dict() for item in entries],
|
|
125
|
+
},
|
|
126
|
+
indent=2,
|
|
127
|
+
)
|
|
128
|
+
+ "\n",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _entries(raw: Any) -> tuple[DisputeEntry, ...]:
|
|
133
|
+
if not isinstance(raw, dict):
|
|
134
|
+
raise ValueError("the dispute log must be an object")
|
|
135
|
+
if raw.get("schema_version") != DISPUTES_SCHEMA_VERSION:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"unsupported dispute log schema: {raw.get('schema_version')!r}"
|
|
138
|
+
)
|
|
139
|
+
unknown = set(raw) - {"schema_version", "disputes"}
|
|
140
|
+
if unknown:
|
|
141
|
+
raise ValueError("unknown dispute log keys: " + ", ".join(sorted(unknown)))
|
|
142
|
+
disputes = raw.get("disputes", [])
|
|
143
|
+
if not isinstance(disputes, list):
|
|
144
|
+
raise ValueError("the dispute log's disputes must be a list")
|
|
145
|
+
return tuple(
|
|
146
|
+
DisputeEntry.from_dict(item, f"disputes[{index}]")
|
|
147
|
+
for index, item in enumerate(disputes)
|
|
148
|
+
)
|