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/replanning.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Mid-run replanning: a running task takes a changed workflow definition.
|
|
3
|
+
|
|
4
|
+
A task freezes its compiled plan when it starts. When the configuration
|
|
5
|
+
changes afterwards, the manager's ``next`` compiles the task's workflow again
|
|
6
|
+
and compares the new template with the one the run started from, item by
|
|
7
|
+
item in plan order. The first item that differs is the *change point*; ww
|
|
8
|
+
stops for the operator (``operator_reason: plan_changed``), who either takes
|
|
9
|
+
the new definition from that item on (``next --replan``) or carries on with
|
|
10
|
+
the saved plan (``next --keep-plan``).
|
|
11
|
+
|
|
12
|
+
Replanning splices the plan: every item before the change point keeps its
|
|
13
|
+
record, the change point and everything after it are the newly compiled
|
|
14
|
+
items with fresh records. When the change point is an item that already ran,
|
|
15
|
+
the cursor moves back to it and the finished items from there on run again;
|
|
16
|
+
their earlier records move to the run's execution history, so artifacts and
|
|
17
|
+
streams stay readable. What the plan expanded at runtime is respected: a
|
|
18
|
+
verification item belongs to the step it verifies, and a change that reaches
|
|
19
|
+
per-item or per-child stages already expanded, or rewinds past a children
|
|
20
|
+
step whose child tasks exist, is refused with the reason.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import difflib
|
|
26
|
+
import json
|
|
27
|
+
from dataclasses import dataclass, replace
|
|
28
|
+
from typing import Literal
|
|
29
|
+
|
|
30
|
+
from ww.control import child_workflow, loop_control
|
|
31
|
+
from ww.execution_models import ExecutionState, PlanSnapshot
|
|
32
|
+
from ww.execution_models.construction import (
|
|
33
|
+
build_step_projection,
|
|
34
|
+
new_item_execution,
|
|
35
|
+
operation_scope_for,
|
|
36
|
+
)
|
|
37
|
+
from ww.plan import PlanItem, WorkflowPlan
|
|
38
|
+
from ww.plan.models import number_step_paths
|
|
39
|
+
from ww.transitions import Clock, project_steps
|
|
40
|
+
|
|
41
|
+
ChangeKind = Literal["added", "removed", "changed"]
|
|
42
|
+
# Item fields that move with an item's place in the plan rather than its
|
|
43
|
+
# definition, so they never count as a change on their own.
|
|
44
|
+
_PLACEMENT_FIELDS = ("id", "position", "step_ordinals")
|
|
45
|
+
# A field's value is shown up to this many characters on each side.
|
|
46
|
+
_VALUE_LIMIT = 160
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class FieldChange:
|
|
51
|
+
"""One field of an item, before and after, as compact JSON."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
before: str
|
|
55
|
+
after: str
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, object]:
|
|
58
|
+
return {"name": self.name, "before": self.before, "after": self.after}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class ChangedItem:
|
|
63
|
+
"""A step or hook the new definition adds, removes, or defines anew."""
|
|
64
|
+
|
|
65
|
+
kind: ChangeKind
|
|
66
|
+
label: str
|
|
67
|
+
fields: tuple[FieldChange, ...] = ()
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, object]:
|
|
70
|
+
return {
|
|
71
|
+
"kind": self.kind,
|
|
72
|
+
"label": self.label,
|
|
73
|
+
"fields": [field.to_dict() for field in self.fields],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class PlanChange:
|
|
79
|
+
"""How the current configuration's plan differs from the saved one.
|
|
80
|
+
|
|
81
|
+
``first`` is the change point in the template, ``splice`` the same place
|
|
82
|
+
in the run's concrete plan. ``reruns`` names the finished items a replan
|
|
83
|
+
would run again, which the operator confirms; ``refusal`` says why the
|
|
84
|
+
change cannot be applied to this run, when it cannot.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
configuration_digest: str
|
|
88
|
+
template: WorkflowPlan
|
|
89
|
+
first: int
|
|
90
|
+
splice: int
|
|
91
|
+
changes: tuple[ChangedItem, ...]
|
|
92
|
+
reruns: tuple[str, ...] = ()
|
|
93
|
+
refusal: str | None = None
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict[str, object]:
|
|
96
|
+
return {
|
|
97
|
+
"changes": [change.to_dict() for change in self.changes],
|
|
98
|
+
"reruns": list(self.reruns),
|
|
99
|
+
"refusal": self.refusal,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def plan_change(
|
|
104
|
+
state: ExecutionState,
|
|
105
|
+
snapshot: PlanSnapshot,
|
|
106
|
+
template: WorkflowPlan,
|
|
107
|
+
configuration_digest: str,
|
|
108
|
+
) -> PlanChange | None:
|
|
109
|
+
"""The change ``template`` makes to the run, or ``None`` when it makes none.
|
|
110
|
+
|
|
111
|
+
``template`` is the run's workflow compiled from the current
|
|
112
|
+
configuration with the options the run started with.
|
|
113
|
+
"""
|
|
114
|
+
old = snapshot.template_plan or snapshot.plan
|
|
115
|
+
first = _first_difference(old.items, template.items)
|
|
116
|
+
if first is None:
|
|
117
|
+
if _plan_fields(old) == _plan_fields(template):
|
|
118
|
+
return None
|
|
119
|
+
# Only the workflow's own fields changed: they apply as they are.
|
|
120
|
+
first = len(old.items)
|
|
121
|
+
changes = _changes(old.items[first:], template.items[first:])
|
|
122
|
+
if not changes and first == len(old.items):
|
|
123
|
+
changes = (ChangedItem("changed", f"workflow `{template.workflow}`"),)
|
|
124
|
+
splice, refusal = _splice(state, snapshot, old, first)
|
|
125
|
+
reruns = (
|
|
126
|
+
()
|
|
127
|
+
if refusal is not None
|
|
128
|
+
else tuple(
|
|
129
|
+
dict.fromkeys(
|
|
130
|
+
_label(item)
|
|
131
|
+
for item, record in zip(
|
|
132
|
+
snapshot.plan.items[splice : state.cursor],
|
|
133
|
+
state.item_executions[splice : state.cursor],
|
|
134
|
+
strict=True,
|
|
135
|
+
)
|
|
136
|
+
if record.status == "completed" and item.verifies is None
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
return PlanChange(
|
|
141
|
+
configuration_digest, template, first, splice, changes, reruns, refusal
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def replan(
|
|
146
|
+
state: ExecutionState, snapshot: PlanSnapshot, change: PlanChange, now: Clock
|
|
147
|
+
) -> tuple[ExecutionState, PlanSnapshot]:
|
|
148
|
+
"""Take the new definition from the change point on."""
|
|
149
|
+
if change.refusal is not None:
|
|
150
|
+
raise ValueError(change.refusal)
|
|
151
|
+
old = snapshot.plan
|
|
152
|
+
splice = change.splice
|
|
153
|
+
kept = old.items[:splice]
|
|
154
|
+
items = number_step_paths(
|
|
155
|
+
tuple(
|
|
156
|
+
replace(item, position=index)
|
|
157
|
+
for index, item in enumerate(
|
|
158
|
+
(*kept, *change.template.items[change.first :]), 1
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
plan = replace(change.template, items=items)
|
|
163
|
+
revision = snapshot.plan_revision + 1
|
|
164
|
+
scope = f"{operation_scope_for(state)}:replan-{revision}"
|
|
165
|
+
records = (
|
|
166
|
+
*(
|
|
167
|
+
replace(record, position=index)
|
|
168
|
+
for index, record in enumerate(state.item_executions[:splice], 1)
|
|
169
|
+
),
|
|
170
|
+
*(new_item_execution(state.task_id, scope, item) for item in items[splice:]),
|
|
171
|
+
)
|
|
172
|
+
displaced = tuple(
|
|
173
|
+
record
|
|
174
|
+
for record in state.item_executions[splice:]
|
|
175
|
+
if record.status != "pending"
|
|
176
|
+
)
|
|
177
|
+
revised = replace(
|
|
178
|
+
snapshot,
|
|
179
|
+
plan=plan,
|
|
180
|
+
template_plan=change.template,
|
|
181
|
+
plan_revision=revision,
|
|
182
|
+
configuration_digest=change.configuration_digest,
|
|
183
|
+
compiled_at=now(),
|
|
184
|
+
)
|
|
185
|
+
kept_ids = {item.id for item in kept}
|
|
186
|
+
entries = {
|
|
187
|
+
loop.loop_id: index
|
|
188
|
+
for index, item in enumerate(old.items)
|
|
189
|
+
if (loop := loop_control(item)) is not None and loop.boundary == "enter"
|
|
190
|
+
}
|
|
191
|
+
reaches_cursor = splice <= state.cursor
|
|
192
|
+
state = replace(
|
|
193
|
+
state,
|
|
194
|
+
item_executions=records,
|
|
195
|
+
execution_history=(*state.execution_history, *displaced),
|
|
196
|
+
steps=build_step_projection(plan, state.steps),
|
|
197
|
+
snapshot_digest=change.configuration_digest,
|
|
198
|
+
plan_revision=revision,
|
|
199
|
+
plan_digest=revised.plan_digest,
|
|
200
|
+
# A loop entered at or after the change point starts counting anew.
|
|
201
|
+
loop_iterations=tuple(
|
|
202
|
+
(loop_id, count)
|
|
203
|
+
for loop_id, count in state.loop_iterations
|
|
204
|
+
if entries.get(loop_id, -1) < splice
|
|
205
|
+
),
|
|
206
|
+
loop_exit_item_id=_kept(state.loop_exit_item_id, kept_ids),
|
|
207
|
+
loop_continue_item_id=_kept(state.loop_continue_item_id, kept_ids),
|
|
208
|
+
updated_at=now(),
|
|
209
|
+
)
|
|
210
|
+
if reaches_cursor:
|
|
211
|
+
# The current item is redefined, or the run rewinds to the change
|
|
212
|
+
# point: whatever stopped or occupied it is gone with its record.
|
|
213
|
+
state = replace(
|
|
214
|
+
state,
|
|
215
|
+
cursor=splice,
|
|
216
|
+
status="pending",
|
|
217
|
+
active_item_id=None,
|
|
218
|
+
pending_input_request=None,
|
|
219
|
+
last_error=None,
|
|
220
|
+
failure_kind=None,
|
|
221
|
+
assignment_item_id=None,
|
|
222
|
+
assignment_token=None,
|
|
223
|
+
assignment_model=None,
|
|
224
|
+
assignment_reasoning=None,
|
|
225
|
+
assignment_selected_agent=None,
|
|
226
|
+
assignment_selected_model=None,
|
|
227
|
+
assignment_selected_reasoning=None,
|
|
228
|
+
)
|
|
229
|
+
return project_steps(state, plan, now), revised
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def keep_plan(
|
|
233
|
+
state: ExecutionState, snapshot: PlanSnapshot, configuration_digest: str
|
|
234
|
+
) -> tuple[ExecutionState, PlanSnapshot]:
|
|
235
|
+
"""Carry on with the saved plan; this configuration is not offered again."""
|
|
236
|
+
return (
|
|
237
|
+
replace(state, snapshot_digest=configuration_digest),
|
|
238
|
+
replace(snapshot, configuration_digest=configuration_digest),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _first_difference(
|
|
243
|
+
old: tuple[PlanItem, ...], new: tuple[PlanItem, ...]
|
|
244
|
+
) -> int | None:
|
|
245
|
+
for index, (before, after) in enumerate(zip(old, new, strict=False)):
|
|
246
|
+
if _definition(before) != _definition(after):
|
|
247
|
+
return index
|
|
248
|
+
if len(old) != len(new):
|
|
249
|
+
return min(len(old), len(new))
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _definition(item: PlanItem) -> dict[str, object]:
|
|
254
|
+
data = item.to_dict()
|
|
255
|
+
for name in _PLACEMENT_FIELDS:
|
|
256
|
+
data.pop(name, None)
|
|
257
|
+
return data
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _plan_fields(plan: WorkflowPlan) -> dict[str, object]:
|
|
261
|
+
data = plan.to_dict()
|
|
262
|
+
data.pop("items", None)
|
|
263
|
+
return data
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _identity(item: PlanItem) -> tuple[object, ...]:
|
|
267
|
+
"""What makes two items the same step or hook across definitions."""
|
|
268
|
+
return (item.workflow, item.step, item.parent, item.phase, item.name)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _changes(
|
|
272
|
+
old: tuple[PlanItem, ...], new: tuple[PlanItem, ...]
|
|
273
|
+
) -> tuple[ChangedItem, ...]:
|
|
274
|
+
matcher = difflib.SequenceMatcher(
|
|
275
|
+
a=[_identity(item) for item in old],
|
|
276
|
+
b=[_identity(item) for item in new],
|
|
277
|
+
autojunk=False,
|
|
278
|
+
)
|
|
279
|
+
changes: list[ChangedItem] = []
|
|
280
|
+
for tag, a0, a1, b0, b1 in matcher.get_opcodes():
|
|
281
|
+
before, after = old[a0:a1], new[b0:b1]
|
|
282
|
+
if tag == "equal":
|
|
283
|
+
changes.extend(
|
|
284
|
+
ChangedItem("changed", _label(b), _fields(a, b))
|
|
285
|
+
for a, b in zip(before, after, strict=True)
|
|
286
|
+
if _definition(a) != _definition(b)
|
|
287
|
+
)
|
|
288
|
+
continue
|
|
289
|
+
changes.extend(ChangedItem("removed", _label(item)) for item in before)
|
|
290
|
+
changes.extend(ChangedItem("added", _label(item)) for item in after)
|
|
291
|
+
return tuple(changes)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _fields(before: PlanItem, after: PlanItem) -> tuple[FieldChange, ...]:
|
|
295
|
+
old, new = _definition(before), _definition(after)
|
|
296
|
+
# A prompt's operation repeats its description, which is shown already.
|
|
297
|
+
prompts = _is_prompt(old) and _is_prompt(new)
|
|
298
|
+
return tuple(
|
|
299
|
+
FieldChange(name, _shown(old.get(name)), _shown(new.get(name)))
|
|
300
|
+
for name in dict.fromkeys((*old, *new))
|
|
301
|
+
if old.get(name) != new.get(name) and not (prompts and name == "operation")
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _is_prompt(definition: dict[str, object]) -> bool:
|
|
306
|
+
operation = definition.get("operation")
|
|
307
|
+
return isinstance(operation, dict) and operation.get("identifier") == "prompt"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _shown(value: object) -> str:
|
|
311
|
+
text = (
|
|
312
|
+
"(none)"
|
|
313
|
+
if value is None
|
|
314
|
+
else json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
315
|
+
)
|
|
316
|
+
return text if len(text) <= _VALUE_LIMIT else text[: _VALUE_LIMIT - 1] + "…"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _label(item: PlanItem) -> str:
|
|
320
|
+
if item.phase == "step":
|
|
321
|
+
return f"step `{item.name}`"
|
|
322
|
+
return f"`{item.name}` ({item.phase.replace('_', ' ')} of `{item.step}`)"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _splice(
|
|
326
|
+
state: ExecutionState,
|
|
327
|
+
snapshot: PlanSnapshot,
|
|
328
|
+
template: WorkflowPlan,
|
|
329
|
+
first: int,
|
|
330
|
+
) -> tuple[int, str | None]:
|
|
331
|
+
"""Where the change point falls in the concrete plan, or why it cannot."""
|
|
332
|
+
order = {item.id: index for index, item in enumerate(template.items)}
|
|
333
|
+
concrete = snapshot.plan.items
|
|
334
|
+
present = {item.id for item in concrete}
|
|
335
|
+
expanded = [
|
|
336
|
+
item
|
|
337
|
+
for item in template.items[first:]
|
|
338
|
+
if item.id not in present and item.item_template
|
|
339
|
+
]
|
|
340
|
+
if expanded:
|
|
341
|
+
return len(concrete), (
|
|
342
|
+
f"the change reaches the stages of {_label(expanded[0])}, which "
|
|
343
|
+
"this run has already expanded for its items or children; finish "
|
|
344
|
+
"the run with `--keep-plan`, or reset the task and start it again"
|
|
345
|
+
)
|
|
346
|
+
splice = len(concrete)
|
|
347
|
+
for index, item in enumerate(concrete):
|
|
348
|
+
origin = order.get(item.verifies.item_id if item.verifies else item.id)
|
|
349
|
+
if origin is not None and origin >= first:
|
|
350
|
+
splice = index
|
|
351
|
+
break
|
|
352
|
+
for item, record in zip(
|
|
353
|
+
concrete[splice : state.cursor + 1],
|
|
354
|
+
state.item_executions[splice : state.cursor + 1],
|
|
355
|
+
strict=False,
|
|
356
|
+
):
|
|
357
|
+
if child_workflow(item) is not None and record.status != "pending":
|
|
358
|
+
return splice, (
|
|
359
|
+
f"replanning would rerun {_label(item)}, whose child tasks "
|
|
360
|
+
"already exist; finish the run with `--keep-plan`, or reset "
|
|
361
|
+
"the task and start it again"
|
|
362
|
+
)
|
|
363
|
+
return splice, None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _kept(item_id: str | None, kept: set[str]) -> str | None:
|
|
367
|
+
return item_id if item_id in kept else None
|
ww/results.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Small service-result contracts shared with output adapters."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from ww.executable import DEFAULT_EXECUTABLE
|
|
9
|
+
from ww.items import WorkItem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ResetResult:
|
|
14
|
+
task_id: str
|
|
15
|
+
removed: bool
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Note added by init when ww.yaml defines no workflow.
|
|
19
|
+
NO_WORKFLOWS_ACTION = "Define at least one workflow in ww.yaml."
|
|
20
|
+
# What every init ends with: the skill that sets ww up for the people using it.
|
|
21
|
+
INITIALIZATION_NEXT_STEP = "Run the ww-setup skill to set ww up for this project."
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class InitializationResult:
|
|
26
|
+
root: str
|
|
27
|
+
created: tuple[str, ...] = ()
|
|
28
|
+
preserved: tuple[str, ...] = ()
|
|
29
|
+
actions: tuple[str, ...] = ()
|
|
30
|
+
# The operator has not been shown the agent-permission notice yet.
|
|
31
|
+
permission_notice: bool = True
|
|
32
|
+
# The ww binary the project is configured to run.
|
|
33
|
+
executable: str = DEFAULT_EXECUTABLE
|
|
34
|
+
# The command prefixes an agent must allow to run ww without asking.
|
|
35
|
+
commands: tuple[str, ...] = ()
|
|
36
|
+
# For each set-up agent whose permission format ww knows: the agent, its
|
|
37
|
+
# permissions file, and the JSON to merge into that file.
|
|
38
|
+
permissions: tuple[tuple[str, str, str], ...] = ()
|
|
39
|
+
# The set-up agents whose permission format ww does not know.
|
|
40
|
+
other_agents: tuple[str, ...] = ()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class CleanupResult:
|
|
45
|
+
removed_locks: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ItemUpdateResult:
|
|
50
|
+
item: WorkItem
|
|
51
|
+
continuation_command: str | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class TaskStatus:
|
|
56
|
+
"""Compact, current-state view for a workflow task."""
|
|
57
|
+
|
|
58
|
+
task_id: str
|
|
59
|
+
workflow: str
|
|
60
|
+
step: str | None
|
|
61
|
+
step_state: str
|
|
62
|
+
runtime: str
|
|
63
|
+
agent: str | None
|
|
64
|
+
model: str
|
|
65
|
+
reasoning: str
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict[str, str | None]:
|
|
68
|
+
return {
|
|
69
|
+
"task_id": self.task_id,
|
|
70
|
+
"workflow": self.workflow,
|
|
71
|
+
"step": self.step,
|
|
72
|
+
"step_state": self.step_state,
|
|
73
|
+
"runtime": self.runtime,
|
|
74
|
+
"agent": self.agent,
|
|
75
|
+
"model": self.model,
|
|
76
|
+
"reasoning": self.reasoning,
|
|
77
|
+
}
|
ww/rule_checks.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Run a step's checks against the files it changed.
|
|
3
|
+
|
|
4
|
+
A check is a planned command: a rule's own ``check``, a ``before_complete``
|
|
5
|
+
hook with ``on_failure: fix``, or a derived check the operator approved into
|
|
6
|
+
the rule-automation store, resolved when the step began. ww runs every check
|
|
7
|
+
of the completing step in plan order, derived checks last, each seeing the
|
|
8
|
+
step's change set in ``WW_STEP_CHANGED_FILES`` (newline-separated, relative
|
|
9
|
+
to the step's directory) narrowed to the check's globs. A check whose globs
|
|
10
|
+
select no changed file is not applicable and does not run. A check fails on
|
|
11
|
+
a non-zero exit or a failed assertion.
|
|
12
|
+
|
|
13
|
+
Checks read the working tree and report; they change no workflow state, so
|
|
14
|
+
running them again after an interruption is harmless and they keep no
|
|
15
|
+
command ledger. Their full output is stored as command-output artifacts.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import shlex
|
|
22
|
+
import subprocess
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass, replace
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from ww.action_execution import STATE_OUTPUT_PREVIEW_LIMIT, bounded
|
|
28
|
+
from ww.actions.command import CommandAction
|
|
29
|
+
from ww.changes import all_files, changed_files, select_files, take_mark
|
|
30
|
+
from ww.errors import StateError
|
|
31
|
+
from ww.execution_models import CheckReport, CheckResult, ExecutionState
|
|
32
|
+
from ww.plan import PlanItem, PlannedCheck
|
|
33
|
+
from ww.storage_adapters import CommandOutputAddress
|
|
34
|
+
|
|
35
|
+
CHANGED_FILES_VARIABLE = "WW_STEP_CHANGED_FILES"
|
|
36
|
+
# Lines of a check's output kept in state and shown on the fix page.
|
|
37
|
+
OUTPUT_TAIL_LINES = 40
|
|
38
|
+
|
|
39
|
+
WriteOutput = Callable[[CommandOutputAddress, str], str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class CheckScope:
|
|
44
|
+
"""Where the checks of one completing item run, and with which values."""
|
|
45
|
+
|
|
46
|
+
directory: Path
|
|
47
|
+
values: dict[str, str]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RuleChecker:
|
|
51
|
+
"""Run the checks of one agent item and report every outcome.
|
|
52
|
+
|
|
53
|
+
Without ``write_output`` the full streams are not kept: ``ww check``
|
|
54
|
+
previews a completion and leaves nothing behind.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self, write_output: WriteOutput | None, now: Callable[[], str]
|
|
59
|
+
) -> None:
|
|
60
|
+
self.write_output = write_output
|
|
61
|
+
self.now = now
|
|
62
|
+
|
|
63
|
+
def run(
|
|
64
|
+
self,
|
|
65
|
+
state: ExecutionState,
|
|
66
|
+
item: PlanItem,
|
|
67
|
+
scope: CheckScope,
|
|
68
|
+
*,
|
|
69
|
+
reuse: CheckReport | None = None,
|
|
70
|
+
) -> CheckReport:
|
|
71
|
+
"""Run the item's planned checks, then the derived ones it resolved.
|
|
72
|
+
|
|
73
|
+
``reuse`` is an earlier passing report of the same completion: while
|
|
74
|
+
the working tree is still at the tree that report was measured to,
|
|
75
|
+
the checks it passed are not run again. A check the operator waived
|
|
76
|
+
for this step does not run and is not in the report.
|
|
77
|
+
"""
|
|
78
|
+
record = state.item_executions[state.cursor]
|
|
79
|
+
waived = dict(record.checks_waived)
|
|
80
|
+
attempt = max((report.attempt for report in item_reports(state)), default=0) + 1
|
|
81
|
+
mark_b = take_mark(scope.directory) if record.change_mark else None
|
|
82
|
+
files, unmarked = change_set(scope.directory, record.change_mark, mark_b)
|
|
83
|
+
kept = (
|
|
84
|
+
{result.id: result for result in reuse.results if result.status != "failed"}
|
|
85
|
+
if reuse is not None and mark_b is not None and reuse.mark == mark_b
|
|
86
|
+
else {}
|
|
87
|
+
)
|
|
88
|
+
results = tuple(
|
|
89
|
+
kept.get(check.id)
|
|
90
|
+
or self._run_check(state, item, check, index, attempt, files, scope)
|
|
91
|
+
for index, check in enumerate((*item.checks, *record.resolved_checks), 1)
|
|
92
|
+
if check.id not in waived
|
|
93
|
+
)
|
|
94
|
+
return CheckReport(
|
|
95
|
+
attempt=attempt,
|
|
96
|
+
checked_at=self.now(),
|
|
97
|
+
results=results,
|
|
98
|
+
mark=mark_b,
|
|
99
|
+
all_files=unmarked,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _run_check(
|
|
103
|
+
self,
|
|
104
|
+
state: ExecutionState,
|
|
105
|
+
item: PlanItem,
|
|
106
|
+
check: PlannedCheck,
|
|
107
|
+
index: int,
|
|
108
|
+
attempt: int,
|
|
109
|
+
files: tuple[str, ...],
|
|
110
|
+
scope: CheckScope,
|
|
111
|
+
) -> CheckResult:
|
|
112
|
+
selected = select_files(files, check.paths)
|
|
113
|
+
if check.paths and not selected:
|
|
114
|
+
return CheckResult(check.id, check.source, "not_applicable")
|
|
115
|
+
action = CommandAction()
|
|
116
|
+
outputs: list[str] = []
|
|
117
|
+
stdout_ref = stderr_ref = None
|
|
118
|
+
exit_code: int | None = 0
|
|
119
|
+
shown: list[str] = []
|
|
120
|
+
for segment, command in enumerate(check.command.commands, 1):
|
|
121
|
+
try:
|
|
122
|
+
argv, environment = action.render_command(command, scope.values)
|
|
123
|
+
except StateError as error:
|
|
124
|
+
return CheckResult(check.id, check.source, "failed", output=str(error))
|
|
125
|
+
shown.append(
|
|
126
|
+
command.shell if command.shell is not None else shlex.join(argv)
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
completed = subprocess.run(
|
|
130
|
+
argv,
|
|
131
|
+
cwd=scope.directory,
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
check=False,
|
|
135
|
+
shell=False,
|
|
136
|
+
env={
|
|
137
|
+
**os.environ,
|
|
138
|
+
**environment,
|
|
139
|
+
CHANGED_FILES_VARIABLE: "\n".join(selected),
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
except OSError as error:
|
|
143
|
+
return CheckResult(
|
|
144
|
+
check.id,
|
|
145
|
+
check.source,
|
|
146
|
+
"failed",
|
|
147
|
+
command="\n".join(shown),
|
|
148
|
+
output=f"could not launch the command: {error}",
|
|
149
|
+
)
|
|
150
|
+
address = CommandOutputAddress(
|
|
151
|
+
state.task_id,
|
|
152
|
+
state.run_id or state.workflow,
|
|
153
|
+
item.id,
|
|
154
|
+
f"{state.item_executions[state.cursor].operation_id}:check:{check.id}",
|
|
155
|
+
attempt,
|
|
156
|
+
segment,
|
|
157
|
+
"stdout",
|
|
158
|
+
)
|
|
159
|
+
if completed.stdout and self.write_output is not None:
|
|
160
|
+
stdout_ref = self.write_output(address, completed.stdout)
|
|
161
|
+
if completed.stderr and self.write_output is not None:
|
|
162
|
+
stderr_ref = self.write_output(
|
|
163
|
+
replace(address, stream="stderr"), completed.stderr
|
|
164
|
+
)
|
|
165
|
+
outputs.append(completed.stdout)
|
|
166
|
+
exit_code = completed.returncode
|
|
167
|
+
if completed.returncode != 0:
|
|
168
|
+
return CheckResult(
|
|
169
|
+
check.id,
|
|
170
|
+
check.source,
|
|
171
|
+
"failed",
|
|
172
|
+
command="\n".join(shown),
|
|
173
|
+
output=_tail(completed.stdout + completed.stderr),
|
|
174
|
+
exit_code=completed.returncode,
|
|
175
|
+
stdout_ref=stdout_ref,
|
|
176
|
+
stderr_ref=stderr_ref,
|
|
177
|
+
)
|
|
178
|
+
output = "\n".join(part for part in outputs if part).strip()
|
|
179
|
+
assertion = check.command.assertion
|
|
180
|
+
passed = assertion is None or assertion.holds(output)
|
|
181
|
+
return CheckResult(
|
|
182
|
+
check.id,
|
|
183
|
+
check.source,
|
|
184
|
+
"passed" if passed else "failed",
|
|
185
|
+
command="\n".join(shown),
|
|
186
|
+
output="" if passed else _tail(output),
|
|
187
|
+
exit_code=exit_code,
|
|
188
|
+
stdout_ref=stdout_ref,
|
|
189
|
+
stderr_ref=stderr_ref,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def change_set(
|
|
194
|
+
directory: Path, mark_a: str | None, mark_b: str | None
|
|
195
|
+
) -> tuple[tuple[str, ...], bool]:
|
|
196
|
+
"""The files changed between two marks, or every file when unmarked.
|
|
197
|
+
|
|
198
|
+
The flag is true without a change set: no git, or state written before
|
|
199
|
+
the step took its mark.
|
|
200
|
+
"""
|
|
201
|
+
if mark_a and mark_b:
|
|
202
|
+
return changed_files(directory, mark_a, mark_b), False
|
|
203
|
+
return all_files(directory), True
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def item_reports(state: ExecutionState) -> tuple[CheckReport, ...]:
|
|
207
|
+
"""Every check report of the current item's operation, retries included.
|
|
208
|
+
|
|
209
|
+
An operator retry moves a copy of the record into the history, so a
|
|
210
|
+
report may appear twice; attempts number the reports of one operation,
|
|
211
|
+
and each attempt is one report.
|
|
212
|
+
"""
|
|
213
|
+
record = state.item_executions[state.cursor]
|
|
214
|
+
kept = [
|
|
215
|
+
entry
|
|
216
|
+
for entry in state.execution_history
|
|
217
|
+
if entry.operation_id is not None and entry.operation_id == record.operation_id
|
|
218
|
+
]
|
|
219
|
+
unique: dict[int, CheckReport] = {}
|
|
220
|
+
for entry in (*kept, record):
|
|
221
|
+
for report in entry.check_reports:
|
|
222
|
+
unique.setdefault(report.attempt, report)
|
|
223
|
+
return tuple(unique[attempt] for attempt in sorted(unique))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _tail(output: str) -> str:
|
|
227
|
+
"""The last lines of a check's output, bounded for state and the page."""
|
|
228
|
+
lines = output.strip().splitlines()
|
|
229
|
+
kept = "\n".join(lines[-OUTPUT_TAIL_LINES:])
|
|
230
|
+
return bounded(kept, STATE_OUTPUT_PREVIEW_LIMIT * 4)
|