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/actions/contracts.py
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Typed action payloads, phase contexts, and the internal registry."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, ClassVar, Generic, Literal, Protocol, TypeVar
|
|
11
|
+
|
|
12
|
+
from ww.contracts import AssertionKind, ExecutionKind, PlanItemOwner
|
|
13
|
+
from ww.discovery import AvailableActions
|
|
14
|
+
from ww.errors import ConfigurationError
|
|
15
|
+
from ww.extensions import (
|
|
16
|
+
ExtensionCheckResult,
|
|
17
|
+
ExtensionContext,
|
|
18
|
+
ExtensionHandler,
|
|
19
|
+
ExtensionRegistry,
|
|
20
|
+
)
|
|
21
|
+
from ww.interpolation import dependencies, interpolate
|
|
22
|
+
from ww.validation import (
|
|
23
|
+
expect_literal,
|
|
24
|
+
expect_string,
|
|
25
|
+
is_strict_int,
|
|
26
|
+
)
|
|
27
|
+
from ww.variables import RUNTIME_PREFIXES, unknown_template_message
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Prompt:
|
|
32
|
+
text: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Skill:
|
|
37
|
+
name: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class SlashCommand:
|
|
42
|
+
name: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Mcp:
|
|
47
|
+
connection: str
|
|
48
|
+
prompt: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class AssertionCondition:
|
|
53
|
+
"""One condition on a command's output: ``empty``, or ``equals`` a value.
|
|
54
|
+
|
|
55
|
+
``value`` is ``None`` exactly when the kind is ``empty``.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
kind: AssertionKind
|
|
59
|
+
value: str | None = None
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
if self.kind not in {"empty", "equals"}:
|
|
63
|
+
raise ValueError(f"invalid assertion condition: {self.kind!r}")
|
|
64
|
+
if (self.kind == "equals") != isinstance(self.value, str):
|
|
65
|
+
raise ValueError("an equals condition requires a value; empty takes none")
|
|
66
|
+
|
|
67
|
+
def holds(self, output: str) -> bool:
|
|
68
|
+
if self.kind == "empty":
|
|
69
|
+
return output.strip() == ""
|
|
70
|
+
return output == self.value
|
|
71
|
+
|
|
72
|
+
def describe(self) -> str:
|
|
73
|
+
if self.kind == "empty":
|
|
74
|
+
return "empty"
|
|
75
|
+
return f"equal to `{self.value}`"
|
|
76
|
+
|
|
77
|
+
def to_data(self) -> str | dict[str, str]:
|
|
78
|
+
"""``"empty"``, or ``{"equals": <value>}``, as ``assert`` lists write it."""
|
|
79
|
+
if self.value is None:
|
|
80
|
+
return self.kind
|
|
81
|
+
return {self.kind: self.value}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class AssertionDefinition:
|
|
86
|
+
"""What a command's output must satisfy: every condition of ``assert``."""
|
|
87
|
+
|
|
88
|
+
conditions: tuple[AssertionCondition, ...]
|
|
89
|
+
|
|
90
|
+
def __post_init__(self) -> None:
|
|
91
|
+
if not self.conditions:
|
|
92
|
+
raise ValueError("an assertion needs at least one condition")
|
|
93
|
+
|
|
94
|
+
def holds(self, output: str) -> bool:
|
|
95
|
+
"""Whether the command's output satisfies every condition."""
|
|
96
|
+
return all(condition.holds(output) for condition in self.conditions)
|
|
97
|
+
|
|
98
|
+
def describe(self) -> str:
|
|
99
|
+
"""The requirement in words, for instructions and failure messages."""
|
|
100
|
+
return (
|
|
101
|
+
"Command output must be "
|
|
102
|
+
+ " and ".join(condition.describe() for condition in self.conditions)
|
|
103
|
+
+ "."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def to_data(self) -> list[str | dict[str, str]]:
|
|
107
|
+
return [condition.to_data() for condition in self.conditions]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class CommandDefinition:
|
|
112
|
+
"""A command whose data arguments are distinct from optional shell source."""
|
|
113
|
+
|
|
114
|
+
argv: tuple[str, ...] = ()
|
|
115
|
+
shell: str | None = None
|
|
116
|
+
args: tuple[str, ...] = ()
|
|
117
|
+
env: tuple[tuple[str, str], ...] = ()
|
|
118
|
+
|
|
119
|
+
def __post_init__(self) -> None:
|
|
120
|
+
if bool(self.argv) == (self.shell is not None):
|
|
121
|
+
raise ValueError("command requires exactly one of argv or shell")
|
|
122
|
+
if self.shell is None and (self.args or self.env):
|
|
123
|
+
raise ValueError("command args and env require shell source")
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> dict[str, object]:
|
|
126
|
+
if self.shell is None:
|
|
127
|
+
return {"argv": list(self.argv)}
|
|
128
|
+
result: dict[str, object] = {"shell": self.shell}
|
|
129
|
+
if self.args:
|
|
130
|
+
result["args"] = list(self.args)
|
|
131
|
+
if self.env:
|
|
132
|
+
result["env"] = dict(self.env)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def templates(self) -> tuple[str, ...]:
|
|
137
|
+
"""Return interpolated fields; shell source is intentionally excluded."""
|
|
138
|
+
return (*self.argv, *self.args, *(value for _, value in self.env))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class Commands:
|
|
143
|
+
commands: tuple[CommandDefinition, ...]
|
|
144
|
+
assertion: AssertionDefinition | None = None
|
|
145
|
+
# The author's statement that running the sequence again is harmless, so
|
|
146
|
+
# an interrupted run may be replayed without an operator decision.
|
|
147
|
+
idempotent: bool = False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class Extension:
|
|
152
|
+
reference: str
|
|
153
|
+
version: str | None = None
|
|
154
|
+
api_version: int | None = None
|
|
155
|
+
source: str | None = None
|
|
156
|
+
fingerprint: str | None = None
|
|
157
|
+
settings: dict[str, object] | None = None
|
|
158
|
+
# Positional arguments from the reference's ``args``; templates until
|
|
159
|
+
# the item runs, when they are rendered with its values.
|
|
160
|
+
arguments: tuple[str, ...] = ()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
ActionPayload = Prompt | Skill | SlashCommand | Mcp | Commands | Extension
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class PlannedAction:
|
|
168
|
+
"""An action's stable identity and typed compiled payload."""
|
|
169
|
+
|
|
170
|
+
identifier: str
|
|
171
|
+
payload: object
|
|
172
|
+
serialized_payload: dict[str, object] | None = field(
|
|
173
|
+
default=None, compare=False, repr=False
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def kind(self) -> str:
|
|
178
|
+
"""An ordinary action is identified by its registry identifier."""
|
|
179
|
+
return self.identifier
|
|
180
|
+
|
|
181
|
+
def to_dict(self) -> dict[str, object]:
|
|
182
|
+
if not actions.contains(self.identifier):
|
|
183
|
+
payload = self.serialized_payload
|
|
184
|
+
if payload is None and isinstance(self.payload, dict):
|
|
185
|
+
payload = self.payload
|
|
186
|
+
if payload is None:
|
|
187
|
+
raise ValueError("unavailable action payload must remain plain data")
|
|
188
|
+
return {"identifier": self.identifier, "payload": payload}
|
|
189
|
+
return {
|
|
190
|
+
"identifier": self.identifier,
|
|
191
|
+
"payload": actions.get(self.identifier).encode(self.payload),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def from_dict(cls, data: object) -> PlannedAction:
|
|
196
|
+
if not isinstance(data, dict) or not {"identifier", "payload"} <= set(data):
|
|
197
|
+
raise ValueError("plan action must have identifier and payload")
|
|
198
|
+
identifier = expect_string(data["identifier"], "action identifier")
|
|
199
|
+
payload = data["payload"]
|
|
200
|
+
if not isinstance(payload, dict):
|
|
201
|
+
raise ValueError("plan action payload must be a mapping")
|
|
202
|
+
if not actions.contains(identifier):
|
|
203
|
+
return cls(identifier, payload)
|
|
204
|
+
return cls(identifier, actions.get(identifier).decode(payload), payload)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class DefinedAction:
|
|
209
|
+
"""A normalized definition before compilation and interpolation."""
|
|
210
|
+
|
|
211
|
+
identifier: str
|
|
212
|
+
payload: object
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True)
|
|
216
|
+
class DefinitionOverrideContext:
|
|
217
|
+
"""Notation-level facts an action may use when a handler is reused.
|
|
218
|
+
|
|
219
|
+
Shared handler precedence stays in the configuration layer. An action may
|
|
220
|
+
own the meaning of omitted and explicit fields in its own payload.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
mapping: Mapping[str, object]
|
|
224
|
+
action_fields: Mapping[str, object]
|
|
225
|
+
description_is_explicit: bool
|
|
226
|
+
local_name: str
|
|
227
|
+
local_description: str
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
DefinitionT = TypeVar("DefinitionT")
|
|
231
|
+
PlannedT = TypeVar("PlannedT")
|
|
232
|
+
AttestationField = Literal["values", "workspace"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass(frozen=True)
|
|
236
|
+
class ResolutionContext:
|
|
237
|
+
agent: str
|
|
238
|
+
available: AvailableActions
|
|
239
|
+
extensions: ExtensionRegistry | None
|
|
240
|
+
builtins: dict[str, str]
|
|
241
|
+
allowed_variables: frozenset[str]
|
|
242
|
+
# The configured project whose extension settings the item follows: the
|
|
243
|
+
# run's project for an item working in the task workspace or the project
|
|
244
|
+
# directory, none for one working in the root.
|
|
245
|
+
project: str | None = None
|
|
246
|
+
|
|
247
|
+
def interpolate(self, value: str) -> str:
|
|
248
|
+
unknown = {
|
|
249
|
+
name
|
|
250
|
+
for name in dependencies(value)
|
|
251
|
+
if name not in self.allowed_variables
|
|
252
|
+
and not name.startswith(RUNTIME_PREFIXES)
|
|
253
|
+
}
|
|
254
|
+
if unknown:
|
|
255
|
+
raise ConfigurationError(unknown_template_message(unknown))
|
|
256
|
+
return interpolate(value, self.builtins)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass(frozen=True)
|
|
260
|
+
class InstructionContext:
|
|
261
|
+
description: str
|
|
262
|
+
name: str
|
|
263
|
+
task_values: dict[str, str]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass(frozen=True)
|
|
267
|
+
class InstructionContent:
|
|
268
|
+
text: str
|
|
269
|
+
markdown: tuple[str, ...]
|
|
270
|
+
show_context: bool = True
|
|
271
|
+
after_shared: tuple[str, ...] = ()
|
|
272
|
+
include_item_context: bool = False
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass(frozen=True)
|
|
276
|
+
class ExtensionBinding:
|
|
277
|
+
"""An extension reference and the frozen settings a payload binds to it."""
|
|
278
|
+
|
|
279
|
+
reference: str
|
|
280
|
+
settings: dict[str, object] | None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass(frozen=True)
|
|
284
|
+
class ActionTraits:
|
|
285
|
+
"""How core treats one planned payload.
|
|
286
|
+
|
|
287
|
+
Every field has the plain default, so an action only names what it needs:
|
|
288
|
+
``command_segments`` is ``None`` unless the action runs durable command
|
|
289
|
+
segments, ``attests_output`` says an interrupted command needs operator
|
|
290
|
+
stdout to settle its assertion, ``manual_attestation`` lists what an
|
|
291
|
+
operator may attest after an interruption, and ``idempotent`` lets core
|
|
292
|
+
replay an interrupted action without asking anyone.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
attests_output: bool = False
|
|
296
|
+
artifact_attribution: str = "auto"
|
|
297
|
+
manual_attestation: frozenset[AttestationField] = frozenset()
|
|
298
|
+
extension_binding: ExtensionBinding | None = None
|
|
299
|
+
command_segments: tuple[CommandDefinition, ...] | None = None
|
|
300
|
+
idempotent: bool = False
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class Action(ABC, Generic[DefinitionT, PlannedT]):
|
|
304
|
+
"""One kind of ordinary work an agent performs or ww runs on its behalf.
|
|
305
|
+
|
|
306
|
+
A subclass owns its payload types and their whole lifecycle: parsing the
|
|
307
|
+
YAML shape, validating and planning it, rendering the instruction, and
|
|
308
|
+
encoding the planned payload for the saved plan. The two hooks at the end
|
|
309
|
+
have safe defaults; override them only when the action has that behaviour.
|
|
310
|
+
Workflow structure (loops, handoffs, children) is not an action.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
identifier: ClassVar[str]
|
|
314
|
+
owner: ClassVar[PlanItemOwner] = "agent"
|
|
315
|
+
execution: ClassVar[ExecutionKind] = "agent_instruction"
|
|
316
|
+
planned_type: type[PlannedT]
|
|
317
|
+
|
|
318
|
+
@abstractmethod
|
|
319
|
+
def parse(
|
|
320
|
+
self, source: dict[str, Any], name: str, description: str, path: str
|
|
321
|
+
) -> DefinitionT:
|
|
322
|
+
"""Read the action's own fields from an authored handler mapping."""
|
|
323
|
+
|
|
324
|
+
@abstractmethod
|
|
325
|
+
def validate(self, definition: DefinitionT, path: str) -> None:
|
|
326
|
+
"""Reject a definition that parsed but cannot be planned."""
|
|
327
|
+
|
|
328
|
+
@abstractmethod
|
|
329
|
+
def templates(self, definition: DefinitionT) -> tuple[str, ...]:
|
|
330
|
+
"""Return the fields core scans for ``{{variable}}`` dependencies."""
|
|
331
|
+
|
|
332
|
+
@abstractmethod
|
|
333
|
+
def plan(self, definition: DefinitionT, context: ResolutionContext) -> PlannedT:
|
|
334
|
+
"""Resolve a definition against one agent, project, and variable set."""
|
|
335
|
+
|
|
336
|
+
@abstractmethod
|
|
337
|
+
def instruction(
|
|
338
|
+
self, planned: PlannedT, context: InstructionContext
|
|
339
|
+
) -> InstructionContent:
|
|
340
|
+
"""Render what the agent (or ``ww plan``) shows for this payload."""
|
|
341
|
+
|
|
342
|
+
@abstractmethod
|
|
343
|
+
def encode(self, planned: PlannedT) -> dict[str, object]:
|
|
344
|
+
"""Return the plain-data form saved in a plan snapshot."""
|
|
345
|
+
|
|
346
|
+
@abstractmethod
|
|
347
|
+
def decode(self, data: dict[str, Any]) -> PlannedT:
|
|
348
|
+
"""Rebuild a planned payload strictly from its saved form."""
|
|
349
|
+
|
|
350
|
+
def override_definition(
|
|
351
|
+
self,
|
|
352
|
+
local: DefinitionT,
|
|
353
|
+
inherited: DefinitionT,
|
|
354
|
+
context: DefinitionOverrideContext,
|
|
355
|
+
) -> DefinitionT:
|
|
356
|
+
"""Merge a step's local payload with the catalog handler it references.
|
|
357
|
+
|
|
358
|
+
Shared handler precedence stays in the configuration layer; an action
|
|
359
|
+
decides what its own omitted fields inherit. By default the local
|
|
360
|
+
payload replaces the inherited one.
|
|
361
|
+
"""
|
|
362
|
+
del inherited, context
|
|
363
|
+
return local
|
|
364
|
+
|
|
365
|
+
def traits(self, planned: PlannedT) -> ActionTraits:
|
|
366
|
+
"""Describe how core should treat a planned payload."""
|
|
367
|
+
del planned
|
|
368
|
+
return ActionTraits()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@dataclass(frozen=True)
|
|
372
|
+
class ActionResult:
|
|
373
|
+
"""The complete, validated-by-core outcome proposed by an action."""
|
|
374
|
+
|
|
375
|
+
ok: bool
|
|
376
|
+
output: str = ""
|
|
377
|
+
error: str = ""
|
|
378
|
+
values: dict[str, str] = field(default_factory=dict)
|
|
379
|
+
working_directory: Path | None = None
|
|
380
|
+
|
|
381
|
+
@classmethod
|
|
382
|
+
def succeeded(
|
|
383
|
+
cls,
|
|
384
|
+
output: str = "",
|
|
385
|
+
*,
|
|
386
|
+
values: Mapping[str, str] | None = None,
|
|
387
|
+
working_directory: Path | None = None,
|
|
388
|
+
) -> ActionResult:
|
|
389
|
+
return cls(
|
|
390
|
+
True,
|
|
391
|
+
output,
|
|
392
|
+
values=dict(values or {}),
|
|
393
|
+
working_directory=working_directory,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
@classmethod
|
|
397
|
+
def failed(cls, error: str, *, output: str = "") -> ActionResult:
|
|
398
|
+
return cls(False, output, error)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@dataclass(frozen=True)
|
|
402
|
+
class RecoveryCheckResult:
|
|
403
|
+
"""A checker attestation for an interrupted automatic action.
|
|
404
|
+
|
|
405
|
+
``action`` attests the entire action and therefore permits normal result
|
|
406
|
+
application (output values and working directory). ``command_segment``
|
|
407
|
+
attests just one durable command boundary; it may carry stdout only and
|
|
408
|
+
the coordinator resumes the remaining action afterwards. The explicit
|
|
409
|
+
scope prevents a generic success from silently completing a multi-command
|
|
410
|
+
action.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
status: Literal["succeeded", "not_succeeded", "unknown"]
|
|
414
|
+
result: ActionResult | None = None
|
|
415
|
+
scope: Literal["action", "command_segment"] = "action"
|
|
416
|
+
segment: int | None = None
|
|
417
|
+
error: str = ""
|
|
418
|
+
|
|
419
|
+
def __post_init__(self) -> None:
|
|
420
|
+
if self.status not in {"succeeded", "not_succeeded", "unknown"}:
|
|
421
|
+
raise ValueError("recovery check result has an invalid status")
|
|
422
|
+
if self.scope not in {"action", "command_segment"}:
|
|
423
|
+
raise ValueError("recovery check result has an invalid scope")
|
|
424
|
+
if not isinstance(self.error, str):
|
|
425
|
+
raise TypeError("recovery check result error must be a string")
|
|
426
|
+
if self.status == "succeeded":
|
|
427
|
+
if not isinstance(self.result, ActionResult) or not self.result.ok:
|
|
428
|
+
raise ValueError(
|
|
429
|
+
"a succeeded recovery check requires a successful result"
|
|
430
|
+
)
|
|
431
|
+
if self.scope == "command_segment":
|
|
432
|
+
if not is_strict_int(self.segment) or self.segment < 0:
|
|
433
|
+
raise ValueError(
|
|
434
|
+
"a command-segment recovery check requires a segment"
|
|
435
|
+
)
|
|
436
|
+
if self.result.values or self.result.working_directory is not None:
|
|
437
|
+
raise ValueError(
|
|
438
|
+
"a command-segment recovery check cannot provide values "
|
|
439
|
+
"or a working directory"
|
|
440
|
+
)
|
|
441
|
+
elif self.segment is not None:
|
|
442
|
+
raise ValueError(
|
|
443
|
+
"an action recovery check cannot identify a command segment"
|
|
444
|
+
)
|
|
445
|
+
elif self.result is not None or self.segment is not None:
|
|
446
|
+
raise ValueError(
|
|
447
|
+
"only a succeeded recovery check may include a result or segment"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
@classmethod
|
|
451
|
+
def succeeded(cls, result: ActionResult) -> RecoveryCheckResult:
|
|
452
|
+
return cls("succeeded", result=result)
|
|
453
|
+
|
|
454
|
+
@classmethod
|
|
455
|
+
def succeeded_segment(cls, segment: int, output: str = "") -> RecoveryCheckResult:
|
|
456
|
+
return cls(
|
|
457
|
+
"succeeded",
|
|
458
|
+
result=ActionResult.succeeded(output),
|
|
459
|
+
scope="command_segment",
|
|
460
|
+
segment=segment,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
@classmethod
|
|
464
|
+
def not_succeeded(cls) -> RecoveryCheckResult:
|
|
465
|
+
return cls("not_succeeded")
|
|
466
|
+
|
|
467
|
+
@classmethod
|
|
468
|
+
def unknown(cls, error: str = "") -> RecoveryCheckResult:
|
|
469
|
+
return cls("unknown", error=error)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@dataclass(frozen=True)
|
|
473
|
+
class CommandRequest:
|
|
474
|
+
"""One prepared command segment. The service owns its durable execution."""
|
|
475
|
+
|
|
476
|
+
argv: tuple[str, ...]
|
|
477
|
+
environment: dict[str, str]
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@dataclass(frozen=True)
|
|
481
|
+
class CommandOutcome:
|
|
482
|
+
"""Full command output; previews remain solely in durable state records."""
|
|
483
|
+
|
|
484
|
+
ok: bool
|
|
485
|
+
stdout: str = ""
|
|
486
|
+
stderr: str = ""
|
|
487
|
+
exit_code: int | None = None
|
|
488
|
+
launch_error: str | None = None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class CommandService(Protocol):
|
|
492
|
+
def completed(self, segment: int) -> CommandOutcome | None: ...
|
|
493
|
+
|
|
494
|
+
def execute(self, segment: int, request: CommandRequest) -> CommandOutcome: ...
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class ExtensionService(Protocol):
|
|
498
|
+
def validate_identity(self, planned: Extension) -> None: ...
|
|
499
|
+
|
|
500
|
+
def context(self, planned: Extension) -> ExtensionContext: ...
|
|
501
|
+
|
|
502
|
+
def handler(self, reference: str) -> ExtensionHandler: ...
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class ExtensionIdentityService(Protocol):
|
|
506
|
+
"""Identity checks available before an external operation is recorded."""
|
|
507
|
+
|
|
508
|
+
def validate_identity(self, planned: Extension) -> None: ...
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class RecoveryExtensionService(Protocol):
|
|
512
|
+
"""Checker-only extension operations available during recovery."""
|
|
513
|
+
|
|
514
|
+
def validate_identity(self, planned: Extension) -> None: ...
|
|
515
|
+
|
|
516
|
+
def check(self, planned: Extension) -> ExtensionCheckResult: ...
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
class ExtensionHandlerService(Protocol):
|
|
520
|
+
"""Handler lookup only, for checks that run before any work is recorded."""
|
|
521
|
+
|
|
522
|
+
def handler(self, reference: str) -> ExtensionHandler: ...
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
class InputValidationContext(Protocol):
|
|
526
|
+
"""What an action may consult while judging values an agent supplied."""
|
|
527
|
+
|
|
528
|
+
@property
|
|
529
|
+
def extensions(self) -> ExtensionHandlerService: ...
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class ExecutionContext(Protocol):
|
|
533
|
+
"""Read-only invocation information plus deliberately narrow effects."""
|
|
534
|
+
|
|
535
|
+
@property
|
|
536
|
+
def root(self) -> Path: ...
|
|
537
|
+
|
|
538
|
+
@property
|
|
539
|
+
def workspace(self) -> Path | None: ...
|
|
540
|
+
|
|
541
|
+
@property
|
|
542
|
+
def runtime_values(self) -> Mapping[str, str]: ...
|
|
543
|
+
|
|
544
|
+
@property
|
|
545
|
+
def task_id(self) -> str: ...
|
|
546
|
+
|
|
547
|
+
@property
|
|
548
|
+
def run_id(self) -> str | None: ...
|
|
549
|
+
|
|
550
|
+
@property
|
|
551
|
+
def operation_id(self) -> str: ...
|
|
552
|
+
|
|
553
|
+
@property
|
|
554
|
+
def attempt(self) -> int: ...
|
|
555
|
+
|
|
556
|
+
@property
|
|
557
|
+
def commands(self) -> CommandService: ...
|
|
558
|
+
|
|
559
|
+
@property
|
|
560
|
+
def extensions(self) -> ExtensionService: ...
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
class PreflightContext(Protocol):
|
|
564
|
+
"""Read-only validation context with no effect-capable services."""
|
|
565
|
+
|
|
566
|
+
@property
|
|
567
|
+
def root(self) -> Path: ...
|
|
568
|
+
@property
|
|
569
|
+
def workspace(self) -> Path | None: ...
|
|
570
|
+
@property
|
|
571
|
+
def runtime_values(self) -> Mapping[str, str]: ...
|
|
572
|
+
@property
|
|
573
|
+
def task_id(self) -> str: ...
|
|
574
|
+
@property
|
|
575
|
+
def run_id(self) -> str | None: ...
|
|
576
|
+
@property
|
|
577
|
+
def extensions(self) -> ExtensionIdentityService: ...
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
class RecoveryContext(Protocol):
|
|
581
|
+
"""Saved-operation information without command execution capabilities."""
|
|
582
|
+
|
|
583
|
+
@property
|
|
584
|
+
def root(self) -> Path: ...
|
|
585
|
+
@property
|
|
586
|
+
def workspace(self) -> Path | None: ...
|
|
587
|
+
@property
|
|
588
|
+
def runtime_values(self) -> Mapping[str, str]: ...
|
|
589
|
+
@property
|
|
590
|
+
def task_id(self) -> str: ...
|
|
591
|
+
@property
|
|
592
|
+
def run_id(self) -> str | None: ...
|
|
593
|
+
@property
|
|
594
|
+
def operation_id(self) -> str: ...
|
|
595
|
+
@property
|
|
596
|
+
def attempt(self) -> int: ...
|
|
597
|
+
@property
|
|
598
|
+
def extensions(self) -> RecoveryExtensionService: ...
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class AutomaticAction(Action[DefinitionT, PlannedT]):
|
|
602
|
+
"""An action ww runs itself, recording its external effects durably.
|
|
603
|
+
|
|
604
|
+
Core writes an ``in_progress`` record before calling :meth:`execute`, so an
|
|
605
|
+
interruption leaves an unknown-outcome boundary. :meth:`preflight` runs
|
|
606
|
+
before that record exists and must have no external effect;
|
|
607
|
+
:meth:`check_recovery` may later settle the outcome without replaying it.
|
|
608
|
+
"""
|
|
609
|
+
|
|
610
|
+
owner: ClassVar[PlanItemOwner] = "ww"
|
|
611
|
+
execution: ClassVar[ExecutionKind] = "automatic"
|
|
612
|
+
|
|
613
|
+
@abstractmethod
|
|
614
|
+
def execute(self, planned: PlannedT, context: ExecutionContext) -> ActionResult:
|
|
615
|
+
"""Perform the work through the narrow services on ``context``."""
|
|
616
|
+
|
|
617
|
+
def preflight(self, planned: PlannedT, context: PreflightContext) -> None:
|
|
618
|
+
"""Validate identity and configuration before core records a start."""
|
|
619
|
+
del planned, context
|
|
620
|
+
|
|
621
|
+
def validate_inputs(
|
|
622
|
+
self,
|
|
623
|
+
planned: PlannedT,
|
|
624
|
+
values: Mapping[str, str],
|
|
625
|
+
context: InputValidationContext,
|
|
626
|
+
) -> str | None:
|
|
627
|
+
"""Judge the declared inputs an agent is about to hand this action.
|
|
628
|
+
|
|
629
|
+
Core calls this while the completion that carries the values is still
|
|
630
|
+
refusable, so a value the action would reject at run time is turned
|
|
631
|
+
back with this message before anything is saved. ``values`` holds
|
|
632
|
+
only the action's own declared inputs. ``None`` accepts them.
|
|
633
|
+
"""
|
|
634
|
+
del planned, values, context
|
|
635
|
+
return None
|
|
636
|
+
|
|
637
|
+
def check_recovery(
|
|
638
|
+
self, planned: PlannedT, context: RecoveryContext
|
|
639
|
+
) -> RecoveryCheckResult | None:
|
|
640
|
+
"""Settle an interrupted operation's outcome; ``None`` means no checker."""
|
|
641
|
+
del planned, context
|
|
642
|
+
return None
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
class ActionRegistry:
|
|
646
|
+
"""Explicit, replaceable internal registration boundary."""
|
|
647
|
+
|
|
648
|
+
def __init__(self) -> None:
|
|
649
|
+
self._actions: dict[str, Action[Any, Any]] = {}
|
|
650
|
+
|
|
651
|
+
def register(self, action: Action[Any, Any]) -> None:
|
|
652
|
+
self._validate(action)
|
|
653
|
+
if action.identifier in self._actions:
|
|
654
|
+
raise ValueError(f"action {action.identifier!r} is already registered")
|
|
655
|
+
self._actions[action.identifier] = action
|
|
656
|
+
|
|
657
|
+
@staticmethod
|
|
658
|
+
def _validate(action: Action[Any, Any]) -> None:
|
|
659
|
+
"""Reject inconsistent registrations at the only extension boundary."""
|
|
660
|
+
if not isinstance(action, Action):
|
|
661
|
+
raise TypeError("actions must subclass ww.actions.Action")
|
|
662
|
+
identifier = getattr(action, "identifier", None)
|
|
663
|
+
if not isinstance(identifier, str) or not identifier:
|
|
664
|
+
raise TypeError("action identifier must be a non-empty string")
|
|
665
|
+
expect_literal(action.owner, PlanItemOwner, f"action {identifier!r} owner")
|
|
666
|
+
expect_literal(
|
|
667
|
+
action.execution, ExecutionKind, f"action {identifier!r} execution"
|
|
668
|
+
)
|
|
669
|
+
if action.owner == "agent" and action.execution != "agent_instruction":
|
|
670
|
+
raise ValueError(
|
|
671
|
+
f"action {identifier!r} has incompatible owner/execution "
|
|
672
|
+
f"combination {action.owner!r}/{action.execution!r}"
|
|
673
|
+
)
|
|
674
|
+
if (action.execution == "automatic") != isinstance(action, AutomaticAction):
|
|
675
|
+
raise TypeError(
|
|
676
|
+
f"action {identifier!r} with execution {action.execution!r} must "
|
|
677
|
+
+ ("" if action.execution == "automatic" else "not ")
|
|
678
|
+
+ "subclass AutomaticAction"
|
|
679
|
+
)
|
|
680
|
+
if not isinstance(getattr(action, "planned_type", None), type):
|
|
681
|
+
raise TypeError(f"action {identifier!r} planned_type must be a type")
|
|
682
|
+
|
|
683
|
+
def get(self, identifier: str) -> Action[Any, Any]:
|
|
684
|
+
try:
|
|
685
|
+
return self._actions[identifier]
|
|
686
|
+
except KeyError as error:
|
|
687
|
+
raise ConfigurationError(
|
|
688
|
+
f"action implementation {identifier!r} is unavailable"
|
|
689
|
+
) from error
|
|
690
|
+
|
|
691
|
+
def contains(self, identifier: str) -> bool:
|
|
692
|
+
return identifier in self._actions
|
|
693
|
+
|
|
694
|
+
def unregister(self, identifier: str) -> None:
|
|
695
|
+
"""Remove an internal registration, primarily for isolated tests."""
|
|
696
|
+
self._actions.pop(identifier)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
actions = ActionRegistry()
|