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/cli/prompts.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Operator confirmations read from the terminal."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import termios
|
|
9
|
+
import tty
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _ask_choice(prompt: str, choices: tuple[str, ...], default: str) -> str:
|
|
13
|
+
while True:
|
|
14
|
+
value = input(prompt).strip().lower() or default
|
|
15
|
+
if value in choices:
|
|
16
|
+
return value
|
|
17
|
+
print("Choose one of: " + ", ".join(choices) + ".")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _interactive_terminal() -> bool:
|
|
21
|
+
"""Whether the operator can drive a redrawing prompt."""
|
|
22
|
+
return (
|
|
23
|
+
sys.stdin.isatty()
|
|
24
|
+
and sys.stdout.isatty()
|
|
25
|
+
and os.environ.get("TERM") not in {None, "", "dumb"}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ask_checklist(
|
|
30
|
+
heading: str, options: tuple[tuple[str, str, bool], ...]
|
|
31
|
+
) -> tuple[str, ...]:
|
|
32
|
+
"""Toggle a list of ``(name, note, checked)`` rows and return the checked.
|
|
33
|
+
|
|
34
|
+
One screen replaces one question per row. A terminal that cannot be driven
|
|
35
|
+
this way -- a pipe, a dumb terminal, a captured test stdin -- never reaches
|
|
36
|
+
here; the caller asks in plain text instead.
|
|
37
|
+
"""
|
|
38
|
+
checked = [state for _, _, state in options]
|
|
39
|
+
cursor = 0
|
|
40
|
+
sys.stdout.write(f"{heading}\n")
|
|
41
|
+
sys.stdout.write(" ↑↓ move · space toggles · a all · enter confirms\n\n")
|
|
42
|
+
_draw(options, checked, cursor, first=True)
|
|
43
|
+
settings = termios.tcgetattr(sys.stdin.fileno())
|
|
44
|
+
try:
|
|
45
|
+
tty.setraw(sys.stdin.fileno())
|
|
46
|
+
while True:
|
|
47
|
+
key = sys.stdin.read(1)
|
|
48
|
+
# An arrow key arrives as an escape sequence; read its tail.
|
|
49
|
+
if key == "\x1b" and sys.stdin.read(1) == "[":
|
|
50
|
+
key = _ARROWS.get(sys.stdin.read(1), "")
|
|
51
|
+
cursor, checked, done = _apply_key(key, cursor, checked)
|
|
52
|
+
if done:
|
|
53
|
+
break
|
|
54
|
+
_draw(options, checked, cursor)
|
|
55
|
+
finally:
|
|
56
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, settings)
|
|
57
|
+
sys.stdout.write("\n")
|
|
58
|
+
sys.stdout.flush()
|
|
59
|
+
return tuple(
|
|
60
|
+
name for (name, _, _), state in zip(options, checked, strict=True) if state
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_ARROWS = {"A": "k", "B": "j"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _apply_key(
|
|
68
|
+
key: str, cursor: int, checked: list[bool]
|
|
69
|
+
) -> tuple[int, list[bool], bool]:
|
|
70
|
+
"""Fold one keypress into the checklist's state.
|
|
71
|
+
|
|
72
|
+
Kept apart from the terminal so the behaviour can be tested without one.
|
|
73
|
+
Returns the new cursor, the new marks, and whether the operator is done.
|
|
74
|
+
"""
|
|
75
|
+
if key == "\x03":
|
|
76
|
+
raise KeyboardInterrupt
|
|
77
|
+
if key in {"\r", "\n"}:
|
|
78
|
+
return cursor, checked, True
|
|
79
|
+
count = len(checked)
|
|
80
|
+
if key in {"j", "\t"}:
|
|
81
|
+
return (cursor + 1) % count, checked, False
|
|
82
|
+
if key == "k":
|
|
83
|
+
return (cursor - 1) % count, checked, False
|
|
84
|
+
if key == " ":
|
|
85
|
+
toggled = list(checked)
|
|
86
|
+
toggled[cursor] = not toggled[cursor]
|
|
87
|
+
return cursor, toggled, False
|
|
88
|
+
if key == "a":
|
|
89
|
+
return cursor, [not all(checked)] * count, False
|
|
90
|
+
return cursor, checked, False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _draw(
|
|
94
|
+
options: tuple[tuple[str, str, bool], ...],
|
|
95
|
+
checked: list[bool],
|
|
96
|
+
cursor: int,
|
|
97
|
+
*,
|
|
98
|
+
first: bool = False,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Repaint the rows in place, so the screen never scrolls away."""
|
|
101
|
+
if not first:
|
|
102
|
+
sys.stdout.write(f"\x1b[{len(options)}A")
|
|
103
|
+
width = max(len(name) for name, _, _ in options)
|
|
104
|
+
for index, (name, note, _) in enumerate(options):
|
|
105
|
+
mark = "x" if checked[index] else " "
|
|
106
|
+
pointer = ">" if index == cursor else " "
|
|
107
|
+
label = f"{name.ljust(width)} {note}" if note else name
|
|
108
|
+
row = f" {pointer} [{mark}] {label}"
|
|
109
|
+
sys.stdout.write(f"\r\x1b[K{row}\n")
|
|
110
|
+
sys.stdout.flush()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _ask_names(prompt: str, names: tuple[str, ...]) -> tuple[str, ...]:
|
|
114
|
+
"""Read zero or more of ``names``, comma-separated; empty means none.
|
|
115
|
+
|
|
116
|
+
One question for a list beats one question per entry when the answer is
|
|
117
|
+
almost always "none of them".
|
|
118
|
+
"""
|
|
119
|
+
allowed = {name.lstrip(".").lower(): name for name in names}
|
|
120
|
+
while True:
|
|
121
|
+
value = input(prompt).strip().lower()
|
|
122
|
+
# Someone answering a list of options with "n" plainly means none.
|
|
123
|
+
if not value or value in {"none", "n", "no"}:
|
|
124
|
+
return ()
|
|
125
|
+
chosen = [part.strip().lstrip(".") for part in value.split(",")]
|
|
126
|
+
if all(part in allowed for part in chosen if part):
|
|
127
|
+
return tuple(dict.fromkeys(allowed[part] for part in chosen if part))
|
|
128
|
+
print("Choose from: " + ", ".join(names) + ", or none.")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _ask_yes_no(prompt: str, default: bool) -> bool:
|
|
132
|
+
while True:
|
|
133
|
+
value = input(prompt).strip().lower()
|
|
134
|
+
if not value:
|
|
135
|
+
return default
|
|
136
|
+
if value in {"y", "yes"}:
|
|
137
|
+
return True
|
|
138
|
+
if value in {"n", "no"}:
|
|
139
|
+
return False
|
|
140
|
+
print("Answer yes or no.")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _ask_operator(prompt: str, cancelled: str) -> bool:
|
|
144
|
+
"""Ask the operator at a terminal; without one, refuse at once.
|
|
145
|
+
|
|
146
|
+
An agent's shell has no terminal, and an answer piped into the prompt is
|
|
147
|
+
not the operator's: the confirmation it needs is ``--yes``, given once
|
|
148
|
+
the operator has agreed. ww never reads a confirmation from a pipe.
|
|
149
|
+
"""
|
|
150
|
+
if not sys.stdin.isatty():
|
|
151
|
+
print(
|
|
152
|
+
f"ww error: {cancelled.lower()} needs the operator's confirmation, "
|
|
153
|
+
"and there is no terminal to ask at. If you are an agent, ask the "
|
|
154
|
+
"operator; rerun with --yes only once they have agreed.",
|
|
155
|
+
file=sys.stderr,
|
|
156
|
+
)
|
|
157
|
+
return False
|
|
158
|
+
try:
|
|
159
|
+
confirmed = _ask_yes_no(prompt, default=False)
|
|
160
|
+
except EOFError:
|
|
161
|
+
confirmed = False
|
|
162
|
+
if not confirmed:
|
|
163
|
+
print(
|
|
164
|
+
f"{cancelled} cancelled: explicit confirmation is required.",
|
|
165
|
+
file=sys.stderr,
|
|
166
|
+
)
|
|
167
|
+
return confirmed
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def confirm_operator(
|
|
171
|
+
command: str,
|
|
172
|
+
effect: str,
|
|
173
|
+
question: str,
|
|
174
|
+
cancelled: str,
|
|
175
|
+
*,
|
|
176
|
+
assume_yes: bool = False,
|
|
177
|
+
) -> bool:
|
|
178
|
+
"""Require the operator's acknowledgement before an irreversible choice.
|
|
179
|
+
|
|
180
|
+
``effect`` is ww's own description of what ``command`` will do, obtained
|
|
181
|
+
after the state was checked, so the operator approves a real action.
|
|
182
|
+
``assume_yes`` is ``--yes``: an agent carrying out the operator's stated
|
|
183
|
+
decision; the effect is still printed, so the decision is on record.
|
|
184
|
+
"""
|
|
185
|
+
if assume_yes:
|
|
186
|
+
print(f"`{command}` will {effect}. Confirmed with --yes.", file=sys.stderr)
|
|
187
|
+
return True
|
|
188
|
+
print(f"`{command}` will {effect}.", file=sys.stderr)
|
|
189
|
+
return _ask_operator(f"{question} [y/N] ", cancelled)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _confirm_force_next(
|
|
193
|
+
effect: str = "skip the current item without running it",
|
|
194
|
+
*,
|
|
195
|
+
assume_yes: bool = False,
|
|
196
|
+
) -> bool:
|
|
197
|
+
"""Require an operator acknowledgement before forcing past work."""
|
|
198
|
+
return confirm_operator(
|
|
199
|
+
"ww next --force",
|
|
200
|
+
effect,
|
|
201
|
+
"Proceed with force?",
|
|
202
|
+
"Force",
|
|
203
|
+
assume_yes=assume_yes,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def confirm_interrupted_retry(*, assume_yes: bool = False) -> bool:
|
|
208
|
+
"""Require an operator to acknowledge duplicate-effect risk."""
|
|
209
|
+
if assume_yes:
|
|
210
|
+
print("The retry is confirmed with --yes.", file=sys.stderr)
|
|
211
|
+
return True
|
|
212
|
+
print(
|
|
213
|
+
"This operation was interrupted and may already have taken effect. "
|
|
214
|
+
"Retrying can duplicate its external effect.",
|
|
215
|
+
file=sys.stderr,
|
|
216
|
+
)
|
|
217
|
+
return _ask_operator("Proceed with retry? [y/N] ", "Retry")
|
ww/cli/updates.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Announcing a newer ww before running the command the caller asked for."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from ww.errors import WwError
|
|
11
|
+
from ww.executable import DEFAULT_EXECUTABLE, printed_executable
|
|
12
|
+
from ww.package_updates import (
|
|
13
|
+
PackageUpdateNotice,
|
|
14
|
+
last_package_notice,
|
|
15
|
+
mark_package_announced,
|
|
16
|
+
pending_package_notice,
|
|
17
|
+
)
|
|
18
|
+
from ww.project_config import load_project_config
|
|
19
|
+
from ww.storage import Storage
|
|
20
|
+
from ww.updates import (
|
|
21
|
+
UpdateNotice,
|
|
22
|
+
check_enabled,
|
|
23
|
+
installation_checkout,
|
|
24
|
+
last_notice,
|
|
25
|
+
mark_announced,
|
|
26
|
+
pending_notice,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def announce(storage: Storage, *, to_stderr: bool = False) -> None:
|
|
31
|
+
"""Write a pending update notice ahead of the command's own output.
|
|
32
|
+
|
|
33
|
+
A notice is a convenience and may never disturb the command: unreadable
|
|
34
|
+
state, a remote that will not answer, or a read-only home leaves the
|
|
35
|
+
invocation exactly as it would otherwise have been. ``to_stderr`` keeps it
|
|
36
|
+
out of stdout that something else is going to parse.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
if not update_check_wanted(storage):
|
|
40
|
+
return
|
|
41
|
+
checkout = installation_checkout()
|
|
42
|
+
notice = (
|
|
43
|
+
pending_notice(checkout)
|
|
44
|
+
if checkout is not None
|
|
45
|
+
else pending_package_notice()
|
|
46
|
+
)
|
|
47
|
+
if notice is None:
|
|
48
|
+
return
|
|
49
|
+
stream = sys.stderr if to_stderr else sys.stdout
|
|
50
|
+
configured = load_project_config(storage.project_config_path).executable
|
|
51
|
+
executable = configured or (
|
|
52
|
+
"./ww" if (storage.root / "ww").is_file() else DEFAULT_EXECUTABLE
|
|
53
|
+
)
|
|
54
|
+
with printed_executable(executable):
|
|
55
|
+
stream.write(notice.render())
|
|
56
|
+
stream.flush()
|
|
57
|
+
_mark(notice)
|
|
58
|
+
except Exception: # noqa: BLE001 - contain every failure of a convenience
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def render_updates(storage: Storage, args: argparse.Namespace) -> str:
|
|
63
|
+
"""Render ``ww updates``: what the last check found, or a fresh look."""
|
|
64
|
+
if not update_check_wanted(storage):
|
|
65
|
+
return _rendered(None, args, "The ww update check is turned off.\n")
|
|
66
|
+
checkout = installation_checkout()
|
|
67
|
+
notice: UpdateNotice | PackageUpdateNotice | None
|
|
68
|
+
if checkout is None:
|
|
69
|
+
pending_package_notice(force=bool(args.now))
|
|
70
|
+
notice = last_package_notice()
|
|
71
|
+
else:
|
|
72
|
+
pending_notice(checkout, force=bool(args.now))
|
|
73
|
+
notice = last_notice()
|
|
74
|
+
if notice is not None:
|
|
75
|
+
_mark(notice)
|
|
76
|
+
return _rendered(
|
|
77
|
+
notice,
|
|
78
|
+
args,
|
|
79
|
+
"ww is up to date.\n"
|
|
80
|
+
if checkout is not None
|
|
81
|
+
else "No package update notice is available.\n",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def update_check_wanted(storage: Storage) -> bool:
|
|
86
|
+
"""Whether this project and environment want the check to run."""
|
|
87
|
+
try:
|
|
88
|
+
configured = load_project_config(storage.project_config_path).update_check
|
|
89
|
+
except WwError:
|
|
90
|
+
configured = True
|
|
91
|
+
return check_enabled(configured)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _rendered(
|
|
95
|
+
notice: UpdateNotice | PackageUpdateNotice | None,
|
|
96
|
+
args: argparse.Namespace,
|
|
97
|
+
otherwise: str,
|
|
98
|
+
) -> str:
|
|
99
|
+
if args.json_output:
|
|
100
|
+
return (
|
|
101
|
+
json.dumps(
|
|
102
|
+
{
|
|
103
|
+
"update_available": notice is not None,
|
|
104
|
+
"notice": notice.to_dict() if notice else None,
|
|
105
|
+
},
|
|
106
|
+
indent=2,
|
|
107
|
+
)
|
|
108
|
+
+ "\n"
|
|
109
|
+
)
|
|
110
|
+
return notice.render() if notice else otherwise
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _mark(notice: UpdateNotice | PackageUpdateNotice) -> None:
|
|
114
|
+
if isinstance(notice, PackageUpdateNotice):
|
|
115
|
+
mark_package_announced(notice)
|
|
116
|
+
else:
|
|
117
|
+
mark_announced(notice)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Persist agent completion artifacts without changing workflow state."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from ww.actions import PlannedAction, actions
|
|
7
|
+
from ww.artifacts import RuleOutcome, RulesSummary, RuleStatus, render_step_artifact
|
|
8
|
+
from ww.contracts import CheckStatus
|
|
9
|
+
from ww.execution_models import CheckReport, ExecutionState, PlanSnapshot
|
|
10
|
+
from ww.plan import PlanItem
|
|
11
|
+
from ww.rule_checks import item_reports
|
|
12
|
+
from ww.storage_adapters.base import ArtifactAddress, TaskArtifactStorage
|
|
13
|
+
|
|
14
|
+
_REPORTED: dict[CheckStatus, RuleStatus] = {
|
|
15
|
+
"passed": "passed",
|
|
16
|
+
"failed": "failed",
|
|
17
|
+
"not_applicable": "not applicable",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def rule_outcomes(
|
|
22
|
+
state: ExecutionState, item: PlanItem, report: CheckReport | None
|
|
23
|
+
) -> RulesSummary | None:
|
|
24
|
+
"""What the completing step's artifact says about its rules and checks.
|
|
25
|
+
|
|
26
|
+
A rule checked by a derived check reports that check's result; one a
|
|
27
|
+
verifier judged reports the verdict and the verification item; any other
|
|
28
|
+
rule without a check is self-declared: the worker states in its result
|
|
29
|
+
how it followed it. A check reports its result in ``report``, or, when
|
|
30
|
+
the operator waived it, in the last report that ran it. Rejections an
|
|
31
|
+
operator retry moved into the history still count.
|
|
32
|
+
"""
|
|
33
|
+
record = state.item_executions[state.cursor]
|
|
34
|
+
if not item.rules and not item.checks:
|
|
35
|
+
return None
|
|
36
|
+
results = {
|
|
37
|
+
result.id: result
|
|
38
|
+
for ran in (*record.check_reports, *((report,) if report else ()))
|
|
39
|
+
for result in ran.results
|
|
40
|
+
}
|
|
41
|
+
checked = {check.id for check in item.checks}
|
|
42
|
+
derived = {
|
|
43
|
+
rule_id: check.id
|
|
44
|
+
for check in record.resolved_checks
|
|
45
|
+
for rule_id in check.covers
|
|
46
|
+
}
|
|
47
|
+
held = record.held_completion
|
|
48
|
+
outcomes = []
|
|
49
|
+
for rule in item.rules:
|
|
50
|
+
if rule.id in checked:
|
|
51
|
+
continue
|
|
52
|
+
verdict = held.verdict(rule.id) if held is not None else None
|
|
53
|
+
if rule.id in derived:
|
|
54
|
+
result = results.get(derived[rule.id])
|
|
55
|
+
status = _REPORTED[result.status] if result else "not applicable"
|
|
56
|
+
outcomes.append(
|
|
57
|
+
RuleOutcome(rule.id, status, detail=f"check `{derived[rule.id]}`")
|
|
58
|
+
)
|
|
59
|
+
elif verdict is not None and verdict.verdict == "pass":
|
|
60
|
+
outcomes.append(
|
|
61
|
+
RuleOutcome(rule.id, "verified pass", detail=f"by `{verdict.by}`")
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
outcomes.append(RuleOutcome(rule.id, "self-declared"))
|
|
65
|
+
for check in item.checks:
|
|
66
|
+
result = results.get(check.id)
|
|
67
|
+
outcomes.append(
|
|
68
|
+
RuleOutcome(
|
|
69
|
+
check.id,
|
|
70
|
+
_REPORTED[result.status] if result is not None else "not applicable",
|
|
71
|
+
hook=check.source == "hook",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
order = {rule.id: index for index, rule in enumerate(item.rules)}
|
|
75
|
+
outcomes.sort(key=lambda outcome: order.get(outcome.id, len(order)))
|
|
76
|
+
return RulesSummary(
|
|
77
|
+
tuple(outcomes),
|
|
78
|
+
fix_attempts=sum(1 for earlier in item_reports(state) if earlier.failed),
|
|
79
|
+
waived=record.checks_waived,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def write_completion_artifacts(
|
|
84
|
+
storage: TaskArtifactStorage,
|
|
85
|
+
task_id: str,
|
|
86
|
+
state: ExecutionState,
|
|
87
|
+
snapshot: PlanSnapshot,
|
|
88
|
+
item: PlanItem,
|
|
89
|
+
loop_entry: PlanItem | None,
|
|
90
|
+
artifact: str | None,
|
|
91
|
+
*,
|
|
92
|
+
rules: RulesSummary | None = None,
|
|
93
|
+
) -> tuple[str | None, str | None]:
|
|
94
|
+
"""Write the completed item and optional enclosing-loop artifacts.
|
|
95
|
+
|
|
96
|
+
``rules`` is the completed item's own rule report; a loop wrapper's
|
|
97
|
+
artifact carries none.
|
|
98
|
+
"""
|
|
99
|
+
if artifact is None:
|
|
100
|
+
return None, None
|
|
101
|
+
|
|
102
|
+
def attribution(plan_item: PlanItem) -> str:
|
|
103
|
+
if not isinstance(plan_item.operation, PlannedAction):
|
|
104
|
+
return "auto"
|
|
105
|
+
action = actions.get(plan_item.kind)
|
|
106
|
+
return action.traits(plan_item.operation.payload).artifact_attribution
|
|
107
|
+
|
|
108
|
+
step_paths = tuple(
|
|
109
|
+
dict.fromkeys(
|
|
110
|
+
plan_item.step
|
|
111
|
+
for plan_item in snapshot.plan.items
|
|
112
|
+
if plan_item.phase == "step"
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def rendered(plan_item: PlanItem) -> str:
|
|
117
|
+
return render_step_artifact(
|
|
118
|
+
task_id=task_id,
|
|
119
|
+
workflow=state.workflow,
|
|
120
|
+
step=plan_item.step,
|
|
121
|
+
step_number=step_paths.index(plan_item.step) + 1,
|
|
122
|
+
step_total=len(step_paths),
|
|
123
|
+
skill=attribution(plan_item),
|
|
124
|
+
result=artifact,
|
|
125
|
+
rules=rules if plan_item is item else None,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def write(plan_item: PlanItem, content: str) -> str:
|
|
129
|
+
return storage.write_execution_artifact(
|
|
130
|
+
ArtifactAddress(
|
|
131
|
+
task_id,
|
|
132
|
+
state.workflow,
|
|
133
|
+
plan_item.step,
|
|
134
|
+
plan_item.position,
|
|
135
|
+
plan_item.name,
|
|
136
|
+
plan_item.phase,
|
|
137
|
+
run_id=state.run_id,
|
|
138
|
+
step_ordinals=plan_item.step_ordinals,
|
|
139
|
+
loop_iterations=tuple(
|
|
140
|
+
(loop_id, iteration)
|
|
141
|
+
for loop_id, iteration in state.loop_iterations
|
|
142
|
+
if loop_id in plan_item.ancestors
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
content,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
artifact_reference = None
|
|
149
|
+
if item.artifact:
|
|
150
|
+
artifact_reference = write(
|
|
151
|
+
item, rendered(item) if item.phase == "step" else artifact
|
|
152
|
+
)
|
|
153
|
+
wrapper_artifact_reference = None
|
|
154
|
+
if loop_entry is not None and loop_entry.artifact:
|
|
155
|
+
wrapper_artifact_reference = write(loop_entry, rendered(loop_entry))
|
|
156
|
+
return artifact_reference, wrapper_artifact_reference
|
ww/completion_inputs.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Validation for values supplied when completing workflow work."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from ww.errors import StateError
|
|
7
|
+
from ww.workflow_config import ProvidedVariable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_values(values: tuple[tuple[str, str], ...]) -> dict[str, str]:
|
|
11
|
+
"""Return supplied values, rejecting duplicate names."""
|
|
12
|
+
result = dict(values)
|
|
13
|
+
if len(result) != len(values):
|
|
14
|
+
raise StateError("a completion variable can only be supplied once")
|
|
15
|
+
return result
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def group_metadata_values(
|
|
19
|
+
values: tuple[tuple[str, str], ...],
|
|
20
|
+
) -> dict[str, tuple[str, ...]]:
|
|
21
|
+
"""Group ``--metadata`` values by name; an append key may repeat."""
|
|
22
|
+
grouped: dict[str, tuple[str, ...]] = {}
|
|
23
|
+
for name, value in values:
|
|
24
|
+
grouped[name] = (*grouped.get(name, ()), value)
|
|
25
|
+
return grouped
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_requested_values(
|
|
29
|
+
values: dict[str, str], requested: tuple[ProvidedVariable, ...]
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Require exactly the variables requested by a plan item."""
|
|
32
|
+
expected = {item.name for item in requested}
|
|
33
|
+
unknown, missing = set(values) - expected, expected - set(values)
|
|
34
|
+
if unknown:
|
|
35
|
+
raise StateError(
|
|
36
|
+
"unexpected completion variable(s): " + ", ".join(sorted(unknown))
|
|
37
|
+
)
|
|
38
|
+
if missing:
|
|
39
|
+
raise StateError("missing required variable(s): " + ", ".join(sorted(missing)))
|