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
|
@@ -0,0 +1,1260 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Notation-independent validation for normalized workflow definitions."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
|
|
10
|
+
from ww.actions import AutomaticAction, DefinedAction, Prompt, actions
|
|
11
|
+
from ww.builtin_workflows import with_builtin_workflows
|
|
12
|
+
from ww.errors import ConfigurationError
|
|
13
|
+
from ww.extensions import ExtensionRegistry, is_extension_reference
|
|
14
|
+
from ww.interpolation import dependencies
|
|
15
|
+
from ww.operations import ChildWorkflowRun, WorkflowHandoff
|
|
16
|
+
from ww.project_config import ProjectConfig
|
|
17
|
+
from ww.validation import is_positive_int
|
|
18
|
+
from ww.variables import CHILD_FIELD_PREFIX, CHILD_VALUE_NAMES
|
|
19
|
+
from ww.workflow_config import (
|
|
20
|
+
INIT_STEP_NAME,
|
|
21
|
+
INIT_STEP_PROMPT,
|
|
22
|
+
HandlerDefinition,
|
|
23
|
+
HookDefinition,
|
|
24
|
+
ItemFlow,
|
|
25
|
+
NameFilter,
|
|
26
|
+
RuleHints,
|
|
27
|
+
StepDefinition,
|
|
28
|
+
WorkflowConfiguration,
|
|
29
|
+
WorkflowDefinition,
|
|
30
|
+
step_tree,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def implicit_init_step() -> StepDefinition:
|
|
35
|
+
"""Return the reserved, artifact-producing first step of every workflow."""
|
|
36
|
+
return StepDefinition(
|
|
37
|
+
name=INIT_STEP_NAME,
|
|
38
|
+
description=INIT_STEP_PROMPT,
|
|
39
|
+
action=DefinedAction("prompt", Prompt(INIT_STEP_PROMPT)),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_HOOK_PHASES = {
|
|
44
|
+
"before_start_workflow",
|
|
45
|
+
"before_start",
|
|
46
|
+
"before_complete",
|
|
47
|
+
"after_complete",
|
|
48
|
+
"before_complete_workflow",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_configuration(
|
|
53
|
+
configuration: WorkflowConfiguration,
|
|
54
|
+
extensions: ExtensionRegistry | None = None,
|
|
55
|
+
) -> WorkflowConfiguration:
|
|
56
|
+
"""Validate and return the normalized contract shared by every frontend.
|
|
57
|
+
|
|
58
|
+
A frontend is responsible only for translating its notation into the
|
|
59
|
+
immutable definitions in :mod:`ww.workflow_config`. Cross-definition rules
|
|
60
|
+
live here so YAML and future frontends cannot acquire different semantics.
|
|
61
|
+
Extension modes are also resolved here because they are part of the
|
|
62
|
+
normalized configuration consumed by catalogs and the compiler.
|
|
63
|
+
"""
|
|
64
|
+
configuration = with_builtin_workflows(
|
|
65
|
+
configuration,
|
|
66
|
+
extensions.config if extensions is not None else ProjectConfig(),
|
|
67
|
+
)
|
|
68
|
+
if not configuration.workflows:
|
|
69
|
+
raise ConfigurationError("configuration must define at least one workflow")
|
|
70
|
+
|
|
71
|
+
_unique((item.name for item in configuration.modes), "mode")
|
|
72
|
+
_unique((item.name for item in configuration.profiles), "profile")
|
|
73
|
+
_unique((item.name for item in configuration.documents), "document")
|
|
74
|
+
_validate_document_updates(configuration)
|
|
75
|
+
_unique((item.name for item in configuration.handlers), "handler")
|
|
76
|
+
_unique((item.name for item in configuration.workflows), "workflow")
|
|
77
|
+
|
|
78
|
+
modes = configuration.modes
|
|
79
|
+
if extensions is not None:
|
|
80
|
+
extensions.validate_configuration()
|
|
81
|
+
references = tuple(
|
|
82
|
+
mode
|
|
83
|
+
for workflow in configuration.workflows
|
|
84
|
+
for mode in workflow.modes
|
|
85
|
+
if is_extension_reference(mode)
|
|
86
|
+
)
|
|
87
|
+
existing = {mode.name for mode in modes}
|
|
88
|
+
contributed = tuple(
|
|
89
|
+
extensions.mode(name)
|
|
90
|
+
for name in dict.fromkeys(references)
|
|
91
|
+
if name not in existing
|
|
92
|
+
)
|
|
93
|
+
modes = (*modes, *contributed)
|
|
94
|
+
_unique((item.name for item in modes), "mode")
|
|
95
|
+
|
|
96
|
+
normalized = replace(configuration, modes=modes)
|
|
97
|
+
known_modes = {mode.name for mode in normalized.modes}
|
|
98
|
+
known_workflows = {workflow.name for workflow in normalized.workflows}
|
|
99
|
+
for handler in normalized.handlers:
|
|
100
|
+
_validate_execution_hints(handler, f"handler {handler.name!r}")
|
|
101
|
+
if isinstance(handler, StepDefinition):
|
|
102
|
+
_validate_steps(handler.name, (handler,))
|
|
103
|
+
for workflow in normalized.workflows:
|
|
104
|
+
_validate_execution_hints(workflow, f"workflow {workflow.name!r}")
|
|
105
|
+
unknown_modes = set(workflow.modes) - known_modes
|
|
106
|
+
if unknown_modes:
|
|
107
|
+
raise ConfigurationError(
|
|
108
|
+
f"workflow {workflow.name!r} references unknown mode(s): "
|
|
109
|
+
+ ", ".join(sorted(unknown_modes))
|
|
110
|
+
)
|
|
111
|
+
_validate_steps(workflow.name, workflow.steps, top_level=True)
|
|
112
|
+
_validate_item_flows(workflow.name, workflow.steps)
|
|
113
|
+
_validate_collection_settings(workflow.name, workflow.steps)
|
|
114
|
+
_validate_transitions(workflow, normalized.global_hooks)
|
|
115
|
+
_validate_hooks(
|
|
116
|
+
workflow.hooks,
|
|
117
|
+
{INIT_STEP_NAME, *_step_filter_references(workflow.steps)},
|
|
118
|
+
expected_scope="workflow",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
all_steps = {
|
|
122
|
+
INIT_STEP_NAME,
|
|
123
|
+
*set().union(
|
|
124
|
+
*(
|
|
125
|
+
_step_filter_references(workflow.steps)
|
|
126
|
+
for workflow in normalized.workflows
|
|
127
|
+
)
|
|
128
|
+
),
|
|
129
|
+
}
|
|
130
|
+
_validate_hooks(
|
|
131
|
+
normalized.global_hooks,
|
|
132
|
+
all_steps,
|
|
133
|
+
known_workflows,
|
|
134
|
+
expected_scope="global",
|
|
135
|
+
)
|
|
136
|
+
_validate_rule_groups(normalized, all_steps, known_workflows)
|
|
137
|
+
_validate_mode_filters(normalized, all_steps, known_workflows)
|
|
138
|
+
_validate_workflow_boundary_hooks(normalized)
|
|
139
|
+
_validate_hook_references(normalized)
|
|
140
|
+
_validate_automatic_groups(normalized, extensions)
|
|
141
|
+
_validate_recommendations(normalized)
|
|
142
|
+
_validate_hooks_from(normalized)
|
|
143
|
+
_validate_child_tasks(normalized.workflows)
|
|
144
|
+
_validate_item_saves(normalized)
|
|
145
|
+
_validate_item_phases(normalized)
|
|
146
|
+
_validate_child_launches(normalized)
|
|
147
|
+
return normalized
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _validate_automatic_groups(
|
|
151
|
+
configuration: WorkflowConfiguration, extensions: ExtensionRegistry | None
|
|
152
|
+
) -> None:
|
|
153
|
+
catalog = configuration.handlers_by_name
|
|
154
|
+
|
|
155
|
+
def validate(handler: HandlerDefinition, stack: tuple[str, ...] = ()) -> None:
|
|
156
|
+
if isinstance(handler, StepDefinition) and (
|
|
157
|
+
handler.child_steps
|
|
158
|
+
or handler.loop_steps
|
|
159
|
+
or handler.items
|
|
160
|
+
or handler.children
|
|
161
|
+
or handler.assessment_outcomes
|
|
162
|
+
or handler.interactive
|
|
163
|
+
or handler.hooks
|
|
164
|
+
or handler.rules
|
|
165
|
+
):
|
|
166
|
+
raise ConfigurationError(
|
|
167
|
+
f"handler group member {handler.name!r} cannot be a step container"
|
|
168
|
+
)
|
|
169
|
+
if handler.is_reference:
|
|
170
|
+
if handler.name in stack:
|
|
171
|
+
raise ConfigurationError(
|
|
172
|
+
"handler group cycle: " + " -> ".join((*stack, handler.name))
|
|
173
|
+
)
|
|
174
|
+
if handler.name in catalog:
|
|
175
|
+
validate(catalog[handler.name], (*stack, handler.name))
|
|
176
|
+
return
|
|
177
|
+
if is_extension_reference(handler.name) and extensions is not None:
|
|
178
|
+
extension = extensions.handler(handler.name)
|
|
179
|
+
if extension.provide:
|
|
180
|
+
raise ConfigurationError(
|
|
181
|
+
f"handler group member {handler.name!r} requires agent input"
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
raise ConfigurationError(
|
|
185
|
+
f"handler group member {handler.name!r} must reference an "
|
|
186
|
+
"automated handler"
|
|
187
|
+
)
|
|
188
|
+
if handler.handlers:
|
|
189
|
+
for member in handler.handlers:
|
|
190
|
+
validate(member, stack)
|
|
191
|
+
return
|
|
192
|
+
if (
|
|
193
|
+
handler.operation is not None
|
|
194
|
+
or handler.action is None
|
|
195
|
+
or not isinstance(actions.get(handler.action.identifier), AutomaticAction)
|
|
196
|
+
or handler.provide
|
|
197
|
+
):
|
|
198
|
+
raise ConfigurationError(
|
|
199
|
+
f"handler group member {handler.name!r} must be fully automated "
|
|
200
|
+
"and require no agent input"
|
|
201
|
+
)
|
|
202
|
+
if isinstance(handler, StepDefinition) and (
|
|
203
|
+
handler.child_steps
|
|
204
|
+
or handler.loop_steps
|
|
205
|
+
or handler.items
|
|
206
|
+
or handler.children
|
|
207
|
+
or handler.assessment_outcomes
|
|
208
|
+
or handler.interactive
|
|
209
|
+
or handler.hooks
|
|
210
|
+
or handler.rules
|
|
211
|
+
):
|
|
212
|
+
raise ConfigurationError(
|
|
213
|
+
f"handler group member {handler.name!r} cannot be a step container"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
candidates = [
|
|
217
|
+
*configuration.handlers,
|
|
218
|
+
*(
|
|
219
|
+
step
|
|
220
|
+
for workflow in configuration.workflows
|
|
221
|
+
for step in _walk_steps(workflow.steps)
|
|
222
|
+
),
|
|
223
|
+
]
|
|
224
|
+
candidates.extend(hook.handler for hook in _every_hook(configuration))
|
|
225
|
+
candidates.extend(
|
|
226
|
+
hook.handler for workflow in configuration.workflows for hook in workflow.hooks
|
|
227
|
+
)
|
|
228
|
+
candidates.extend(
|
|
229
|
+
hook.handler
|
|
230
|
+
for workflow in configuration.workflows
|
|
231
|
+
for step in _walk_steps(workflow.steps)
|
|
232
|
+
for hook in step.hooks
|
|
233
|
+
)
|
|
234
|
+
for handler in candidates:
|
|
235
|
+
if handler.handlers:
|
|
236
|
+
for member in handler.handlers:
|
|
237
|
+
validate(member, (handler.name,))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _validate_steps(
|
|
241
|
+
workflow_name: str,
|
|
242
|
+
steps: tuple[StepDefinition, ...],
|
|
243
|
+
*,
|
|
244
|
+
top_level: bool = False,
|
|
245
|
+
inside_loop: bool = False,
|
|
246
|
+
inside_children: bool = False,
|
|
247
|
+
enclosing: Mapping[str, StepDefinition] = MappingProxyType({}),
|
|
248
|
+
finished_containers: tuple[StepDefinition, ...] = (),
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Validate one sibling list; ``enclosing`` holds earlier upper-level steps.
|
|
251
|
+
|
|
252
|
+
``artifact_from`` resolves to the nearest earlier step of that name: an
|
|
253
|
+
earlier sibling first, then an earlier step of each enclosing level. A
|
|
254
|
+
container's own step is visible to its nested steps only when its work
|
|
255
|
+
has finished before them (``finished_containers``): an assessment to its
|
|
256
|
+
outcomes and an item collection to its per-item stages, but never a
|
|
257
|
+
running loop. Such a container supplies its own artifact; a group, or an
|
|
258
|
+
assessment named after its outcomes, supplies the latest artifact saved
|
|
259
|
+
inside it, so it needs a step inside that can save one.
|
|
260
|
+
|
|
261
|
+
``break`` ends the nearest enclosing loop, or the per-child stages of a
|
|
262
|
+
``children`` step (``inside_children``): the remaining children are
|
|
263
|
+
skipped. ``continue`` needs a loop.
|
|
264
|
+
"""
|
|
265
|
+
_unique((step.name for step in steps), f"step in workflow {workflow_name!r}")
|
|
266
|
+
prior: dict[str, StepDefinition] = (
|
|
267
|
+
{INIT_STEP_NAME: implicit_init_step()} if top_level else {}
|
|
268
|
+
)
|
|
269
|
+
for step in steps:
|
|
270
|
+
if step.assessment_outcomes and all(
|
|
271
|
+
outcome.stop_workflow for outcome in step.assessment_outcomes
|
|
272
|
+
):
|
|
273
|
+
raise ConfigurationError(
|
|
274
|
+
f"assess {step.name!r} in workflow {workflow_name!r} has only "
|
|
275
|
+
"outcomes that stop the workflow; give one of them steps, or use "
|
|
276
|
+
"the compact form"
|
|
277
|
+
)
|
|
278
|
+
_validate_execution_hints(step, f"step {step.name!r}")
|
|
279
|
+
if step.interactive and (step.child_steps or step.loop_steps):
|
|
280
|
+
raise ConfigurationError(
|
|
281
|
+
f"step {step.name!r} in workflow {workflow_name!r} is a pure "
|
|
282
|
+
"structural step or loop container and cannot be interactive; "
|
|
283
|
+
"make an executed child step interactive instead"
|
|
284
|
+
)
|
|
285
|
+
if step.name == INIT_STEP_NAME:
|
|
286
|
+
raise ConfigurationError(
|
|
287
|
+
f"step name {INIT_STEP_NAME!r} is reserved and must not be declared"
|
|
288
|
+
)
|
|
289
|
+
if (step.loop_continue is not None and not inside_loop) or (
|
|
290
|
+
step.loop_break is not None and not (inside_loop or inside_children)
|
|
291
|
+
):
|
|
292
|
+
control = "break" if step.loop_break is not None else "continue"
|
|
293
|
+
raise ConfigurationError(
|
|
294
|
+
f"step {step.name!r} in workflow {workflow_name!r} uses "
|
|
295
|
+
f"{control} outside a loop"
|
|
296
|
+
)
|
|
297
|
+
if step.max_rounds is not None and (not is_positive_int(step.max_rounds)):
|
|
298
|
+
raise ConfigurationError(
|
|
299
|
+
f"step {step.name!r} in workflow {workflow_name!r} has an invalid "
|
|
300
|
+
"max_rounds; expected a positive integer"
|
|
301
|
+
)
|
|
302
|
+
if step.max_rounds is not None and not step.loop_steps:
|
|
303
|
+
raise ConfigurationError(
|
|
304
|
+
f"step {step.name!r} in workflow {workflow_name!r} uses "
|
|
305
|
+
"max_rounds without a loop"
|
|
306
|
+
)
|
|
307
|
+
if (step.loop_break is not None or step.loop_continue is not None) and (
|
|
308
|
+
step.child_steps or step.loop_steps
|
|
309
|
+
):
|
|
310
|
+
control = "break" if step.loop_break is not None else "continue"
|
|
311
|
+
raise ConfigurationError(
|
|
312
|
+
f"step {step.name!r} in workflow {workflow_name!r} uses "
|
|
313
|
+
f"{control} but does not directly execute worker work"
|
|
314
|
+
)
|
|
315
|
+
if step.artifact_dependency is not None:
|
|
316
|
+
dependency = prior.get(step.artifact_dependency) or enclosing.get(
|
|
317
|
+
step.artifact_dependency
|
|
318
|
+
)
|
|
319
|
+
if dependency is None:
|
|
320
|
+
raise ConfigurationError(
|
|
321
|
+
f"step {step.name!r} in workflow {workflow_name!r} takes "
|
|
322
|
+
"artifact_from "
|
|
323
|
+
f"step {step.artifact_dependency!r}, which is not an earlier "
|
|
324
|
+
"step at its own or an enclosing level"
|
|
325
|
+
)
|
|
326
|
+
if not _supplies_artifact(
|
|
327
|
+
dependency,
|
|
328
|
+
own=any(dependency is found for found in finished_containers),
|
|
329
|
+
):
|
|
330
|
+
raise ConfigurationError(
|
|
331
|
+
f"step {step.name!r} in workflow {workflow_name!r} takes "
|
|
332
|
+
"artifact_from "
|
|
333
|
+
f"step {dependency.name!r}, which does not produce an artifact"
|
|
334
|
+
)
|
|
335
|
+
_validate_hooks(step.hooks, set(), expected_scope="step")
|
|
336
|
+
visible = {**enclosing, **prior}
|
|
337
|
+
finished = (*finished_containers, step)
|
|
338
|
+
_validate_steps(
|
|
339
|
+
workflow_name,
|
|
340
|
+
step.child_steps,
|
|
341
|
+
inside_loop=inside_loop,
|
|
342
|
+
inside_children=inside_children,
|
|
343
|
+
enclosing=visible,
|
|
344
|
+
finished_containers=finished_containers,
|
|
345
|
+
)
|
|
346
|
+
_validate_steps(
|
|
347
|
+
workflow_name,
|
|
348
|
+
step.loop_steps,
|
|
349
|
+
inside_loop=True,
|
|
350
|
+
enclosing=visible,
|
|
351
|
+
finished_containers=finished_containers,
|
|
352
|
+
)
|
|
353
|
+
_validate_steps(
|
|
354
|
+
workflow_name,
|
|
355
|
+
_item_steps(step),
|
|
356
|
+
inside_loop=inside_loop,
|
|
357
|
+
enclosing={**visible, step.name: step},
|
|
358
|
+
finished_containers=finished,
|
|
359
|
+
)
|
|
360
|
+
# A ``break`` in a per-child stage ends the children; a ``continue``
|
|
361
|
+
# needs a loop of its own inside the stage.
|
|
362
|
+
_validate_steps(
|
|
363
|
+
workflow_name,
|
|
364
|
+
_child_stages(step),
|
|
365
|
+
inside_children=True,
|
|
366
|
+
enclosing={**visible, step.name: step},
|
|
367
|
+
finished_containers=finished,
|
|
368
|
+
)
|
|
369
|
+
# Outcomes are alternatives, so none is an earlier sibling of another.
|
|
370
|
+
for outcome in step.assessment_outcomes:
|
|
371
|
+
_validate_steps(
|
|
372
|
+
workflow_name,
|
|
373
|
+
(outcome,),
|
|
374
|
+
inside_loop=inside_loop,
|
|
375
|
+
inside_children=inside_children,
|
|
376
|
+
enclosing={**visible, step.name: step},
|
|
377
|
+
finished_containers=finished,
|
|
378
|
+
)
|
|
379
|
+
prior[step.name] = step
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _validate_transitions(
|
|
383
|
+
workflow: WorkflowDefinition, global_hooks: tuple[HookDefinition, ...]
|
|
384
|
+
) -> None:
|
|
385
|
+
"""A transition makes a handoff workflow and must be the last thing it runs.
|
|
386
|
+
|
|
387
|
+
A task hands off once and a transition never returns, so anything after it
|
|
388
|
+
could not run. The transition is either the last top-level step or the
|
|
389
|
+
last ``after_complete`` hook of that step; rejecting any other placement
|
|
390
|
+
here turns a run-time surprise into a configuration error.
|
|
391
|
+
"""
|
|
392
|
+
for hook in (*global_hooks, *workflow.hooks):
|
|
393
|
+
if _is_transition_hook(hook):
|
|
394
|
+
raise ConfigurationError(
|
|
395
|
+
f"{hook.path or 'hook'} is a handoff_to transition at "
|
|
396
|
+
f"{hook.scope} scope; a transition hook belongs on the "
|
|
397
|
+
"after_complete hooks of a workflow's last step"
|
|
398
|
+
)
|
|
399
|
+
steps = tuple(step_tree(workflow.steps))
|
|
400
|
+
transitions = [
|
|
401
|
+
step for step in steps if isinstance(step.operation, WorkflowHandoff)
|
|
402
|
+
]
|
|
403
|
+
hooked = [
|
|
404
|
+
(step, hook)
|
|
405
|
+
for step in steps
|
|
406
|
+
for hook in step.hooks
|
|
407
|
+
if _is_transition_hook(hook)
|
|
408
|
+
]
|
|
409
|
+
if not transitions and not hooked:
|
|
410
|
+
return
|
|
411
|
+
if len(transitions) + len(hooked) > 1:
|
|
412
|
+
raise ConfigurationError(
|
|
413
|
+
f"workflow {workflow.name!r} has more than one handoff_to; "
|
|
414
|
+
"a task hands off once, so choose the target dynamically instead"
|
|
415
|
+
)
|
|
416
|
+
last = workflow.steps[-1]
|
|
417
|
+
if transitions and transitions[0] is not last:
|
|
418
|
+
raise ConfigurationError(
|
|
419
|
+
f"workflow {workflow.name!r} must place its handoff_to "
|
|
420
|
+
f"step {transitions[0].name!r} last; nothing after a handoff runs"
|
|
421
|
+
)
|
|
422
|
+
if hooked:
|
|
423
|
+
owner, hook = hooked[0]
|
|
424
|
+
if owner is not last or hook.phase != "after_complete":
|
|
425
|
+
raise ConfigurationError(
|
|
426
|
+
f"workflow {workflow.name!r} must place its handoff_to "
|
|
427
|
+
f"hook in the after_complete hooks of its last step "
|
|
428
|
+
f"{last.name!r}; nothing after a handoff runs"
|
|
429
|
+
)
|
|
430
|
+
following = [
|
|
431
|
+
later
|
|
432
|
+
for later in owner.hooks[owner.hooks.index(hook) + 1 :]
|
|
433
|
+
if later.phase == "after_complete"
|
|
434
|
+
]
|
|
435
|
+
else:
|
|
436
|
+
# Every completion hook of the transition step runs after it.
|
|
437
|
+
paths = frozenset(_logical_step_paths(workflow.steps))
|
|
438
|
+
following = [
|
|
439
|
+
later
|
|
440
|
+
for later in (*global_hooks, *workflow.hooks, *last.hooks)
|
|
441
|
+
if later.phase in {"before_complete", "after_complete"}
|
|
442
|
+
and later.applies_in(workflow, last.name, last.name, paths)
|
|
443
|
+
]
|
|
444
|
+
if following:
|
|
445
|
+
raise ConfigurationError(
|
|
446
|
+
f"handoff workflow {workflow.name!r} must end with its handoff_to; "
|
|
447
|
+
f"{following[0].path or 'a hook'} would run after it"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _is_transition_hook(hook: HookDefinition) -> bool:
|
|
452
|
+
return isinstance(hook.handler.operation, WorkflowHandoff)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _supplies_artifact(step: StepDefinition, *, own: bool) -> bool:
|
|
456
|
+
"""Whether ``artifact_from`` naming ``step`` can find an artifact.
|
|
457
|
+
|
|
458
|
+
``own``: the dependent runs inside the step, whose own work has finished.
|
|
459
|
+
A group saves nothing itself and supplies the latest artifact saved
|
|
460
|
+
inside it. An assessment named after its outcomes supplies the latest
|
|
461
|
+
artifact of its chosen outcome when an outcome can save one, else its
|
|
462
|
+
own. Any other step supplies only its own artifact.
|
|
463
|
+
"""
|
|
464
|
+
if own:
|
|
465
|
+
return step.artifact
|
|
466
|
+
if step.child_steps:
|
|
467
|
+
return any(_can_save_artifact(child) for child in step.child_steps)
|
|
468
|
+
return step.artifact or any(
|
|
469
|
+
_can_save_artifact(outcome) for outcome in step.assessment_outcomes
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _can_save_artifact(step: StepDefinition) -> bool:
|
|
474
|
+
"""Whether running ``step`` can save an artifact, itself or inside it."""
|
|
475
|
+
if step.stop_workflow:
|
|
476
|
+
return False
|
|
477
|
+
if step.child_steps:
|
|
478
|
+
return any(_can_save_artifact(child) for child in step.child_steps)
|
|
479
|
+
return step.artifact or any(
|
|
480
|
+
_can_save_artifact(nested)
|
|
481
|
+
for nested in (
|
|
482
|
+
*step.loop_steps,
|
|
483
|
+
*step.assessment_outcomes,
|
|
484
|
+
*_template_steps(step),
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _item_steps(step: StepDefinition) -> tuple[StepDefinition, ...]:
|
|
490
|
+
return step.items.steps if step.items is not None else ()
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _child_stages(step: StepDefinition) -> tuple[StepDefinition, ...]:
|
|
494
|
+
return step.children.steps if step.children is not None else ()
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _template_steps(step: StepDefinition) -> tuple[StepDefinition, ...]:
|
|
498
|
+
"""The stages a step repeats: per item, or per child."""
|
|
499
|
+
return (*_item_steps(step), *_child_stages(step))
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _validate_item_flows(workflow_name: str, steps: tuple[StepDefinition, ...]) -> None:
|
|
503
|
+
"""Allow sequential ``items`` passes; reject one nested in another's stages.
|
|
504
|
+
|
|
505
|
+
A workflow has one item collection. Each ``items`` step is a pass over
|
|
506
|
+
it, expanded at its own position when its collection completes, so
|
|
507
|
+
several sequential passes (also inside loops) are unambiguous. An
|
|
508
|
+
``items`` step inside another's per-item stages would collect a second,
|
|
509
|
+
independent set per item, which ww does not support.
|
|
510
|
+
"""
|
|
511
|
+
for step in _walk_nested(steps):
|
|
512
|
+
nested = [
|
|
513
|
+
inner.name
|
|
514
|
+
for inner in _walk_nested(_item_steps(step))
|
|
515
|
+
if inner.items is not None
|
|
516
|
+
]
|
|
517
|
+
if nested:
|
|
518
|
+
raise ConfigurationError(
|
|
519
|
+
f"workflow {workflow_name!r}: items step {nested[0]!r} is nested "
|
|
520
|
+
f"inside the per-item steps of items step {step.name!r}; a "
|
|
521
|
+
"workflow has one item collection, so declare later items "
|
|
522
|
+
"passes as sequential steps instead of inside another pass"
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _walk_nested(steps: tuple[StepDefinition, ...]) -> tuple[StepDefinition, ...]:
|
|
527
|
+
"""Every step in ``steps`` and below, including assessment outcomes."""
|
|
528
|
+
result: list[StepDefinition] = []
|
|
529
|
+
for step in steps:
|
|
530
|
+
result.append(step)
|
|
531
|
+
result.extend(
|
|
532
|
+
_walk_nested(
|
|
533
|
+
(
|
|
534
|
+
*step.child_steps,
|
|
535
|
+
*step.loop_steps,
|
|
536
|
+
*step.assessment_outcomes,
|
|
537
|
+
*_template_steps(step),
|
|
538
|
+
)
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
return tuple(result)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
_COLLECTION_SETTINGS = ("persistent", "identity", "unique")
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _validate_collection_settings(
|
|
548
|
+
workflow_name: str, steps: tuple[StepDefinition, ...]
|
|
549
|
+
) -> None:
|
|
550
|
+
"""Reject a later ``items`` pass that contradicts the collection's settings.
|
|
551
|
+
|
|
552
|
+
The first ``items`` declaration of a workflow, in plan order, establishes
|
|
553
|
+
``persistent``, ``identity``, and ``unique`` for its one collection, with
|
|
554
|
+
the defaults for what it omits. A later pass may omit them or repeat the
|
|
555
|
+
collection's values; a value it sets differently would be ignored at
|
|
556
|
+
runtime, so it is an error. ``unique`` already holds ``identity``, and
|
|
557
|
+
its order does not matter.
|
|
558
|
+
"""
|
|
559
|
+
passes = _item_passes(steps)
|
|
560
|
+
if not passes:
|
|
561
|
+
return
|
|
562
|
+
first_path, first = passes[0]
|
|
563
|
+
assert first.items is not None
|
|
564
|
+
for path, step in passes[1:]:
|
|
565
|
+
assert step.items is not None
|
|
566
|
+
for setting in _COLLECTION_SETTINGS:
|
|
567
|
+
declared = _declared_setting(step.items, setting, first.items.identity)
|
|
568
|
+
established = _effective_setting(first.items, setting)
|
|
569
|
+
if declared is None or declared == established:
|
|
570
|
+
continue
|
|
571
|
+
raise ConfigurationError(
|
|
572
|
+
f"workflow {workflow_name!r} step {path!r} sets items.{setting} "
|
|
573
|
+
f"to {_setting_text(declared)}, but the collection's first items "
|
|
574
|
+
f"step {first_path!r} "
|
|
575
|
+
+ (
|
|
576
|
+
"leaves it unset"
|
|
577
|
+
if getattr(first.items, setting) is None
|
|
578
|
+
and established in (False, None, frozenset())
|
|
579
|
+
else f"sets it to {_setting_text(established)}"
|
|
580
|
+
)
|
|
581
|
+
+ f"; a workflow has one item collection whose {setting} the "
|
|
582
|
+
"first items step decides, so set it there and omit it, or "
|
|
583
|
+
"repeat the same value, on later passes"
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _item_passes(
|
|
588
|
+
steps: tuple[StepDefinition, ...], parent: str | None = None
|
|
589
|
+
) -> tuple[tuple[str, StepDefinition], ...]:
|
|
590
|
+
"""Every ``items`` step in plan order, with its logical step path."""
|
|
591
|
+
result: list[tuple[str, StepDefinition]] = []
|
|
592
|
+
for step in steps:
|
|
593
|
+
path = f"{parent}/{step.name}" if parent else step.name
|
|
594
|
+
if step.items is not None:
|
|
595
|
+
result.append((path, step))
|
|
596
|
+
result.extend(
|
|
597
|
+
_item_passes(
|
|
598
|
+
(
|
|
599
|
+
*step.child_steps,
|
|
600
|
+
*step.loop_steps,
|
|
601
|
+
*step.assessment_outcomes,
|
|
602
|
+
*_template_steps(step),
|
|
603
|
+
),
|
|
604
|
+
path,
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
return tuple(result)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _declared_setting(
|
|
611
|
+
flow: ItemFlow, setting: str, identity: str | None
|
|
612
|
+
) -> bool | str | frozenset[str] | None:
|
|
613
|
+
"""One collection setting as a declaration wrote it, ``None`` if omitted.
|
|
614
|
+
|
|
615
|
+
A declared ``unique`` is compared with the collection's ``identity``
|
|
616
|
+
(``identity``) folded in, as the run applies it.
|
|
617
|
+
"""
|
|
618
|
+
if setting == "persistent":
|
|
619
|
+
return flow.persistent
|
|
620
|
+
if setting == "identity":
|
|
621
|
+
return flow.identity
|
|
622
|
+
if flow.unique is None:
|
|
623
|
+
return None
|
|
624
|
+
return frozenset(flow.effective_unique) | ({identity} if identity else set())
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _effective_setting(
|
|
628
|
+
flow: ItemFlow, setting: str
|
|
629
|
+
) -> bool | str | frozenset[str] | None:
|
|
630
|
+
"""One collection setting as the run applies it, defaults included."""
|
|
631
|
+
if setting == "persistent":
|
|
632
|
+
return bool(flow.persistent)
|
|
633
|
+
if setting == "identity":
|
|
634
|
+
return flow.identity
|
|
635
|
+
return frozenset(flow.effective_unique)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _setting_text(value: bool | str | frozenset[str] | None) -> str:
|
|
639
|
+
if isinstance(value, bool):
|
|
640
|
+
return "true" if value else "false"
|
|
641
|
+
if isinstance(value, frozenset):
|
|
642
|
+
return "[" + ", ".join(sorted(value)) + "]"
|
|
643
|
+
return repr(value)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
_BOUNDARY_PHASES = frozenset({"before_start_workflow", "before_complete_workflow"})
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _validate_item_saves(configuration: WorkflowConfiguration) -> None:
|
|
650
|
+
"""Allow ``saves: item.field.*`` only where an item is meaningful.
|
|
651
|
+
|
|
652
|
+
Item field saves apply to the items an ``items`` step collects, when that
|
|
653
|
+
step declares them itself, or to the current item of a per-item stage,
|
|
654
|
+
including a hook or handler group run for that stage. Anywhere else,
|
|
655
|
+
such as an ordinary batch step between passes, there is no item for the
|
|
656
|
+
save to bind to; such a step updates records with the item commands
|
|
657
|
+
instead. Reusable handlers are checked where they are used, after the
|
|
658
|
+
frontend copied them into steps and through hook references here, never
|
|
659
|
+
as unused catalog definitions.
|
|
660
|
+
"""
|
|
661
|
+
catalog = configuration.handlers_by_name
|
|
662
|
+
for workflow in configuration.workflows:
|
|
663
|
+
for hook in (*configuration.global_hooks, *workflow.hooks):
|
|
664
|
+
if hook.phase not in _BOUNDARY_PHASES or not any(
|
|
665
|
+
hook.workflows.admits(name)
|
|
666
|
+
for name in dict.fromkeys((workflow.name, workflow.lane))
|
|
667
|
+
):
|
|
668
|
+
continue
|
|
669
|
+
fields = _item_saves(hook.handler, catalog)
|
|
670
|
+
if fields:
|
|
671
|
+
raise ConfigurationError(
|
|
672
|
+
f"workflow {workflow.name!r} {hook.phase} hook "
|
|
673
|
+
f"{hook.handler.name!r} {_unbound_item_saves(fields)}"
|
|
674
|
+
)
|
|
675
|
+
_check_item_saves(
|
|
676
|
+
configuration,
|
|
677
|
+
workflow,
|
|
678
|
+
workflow.steps,
|
|
679
|
+
None,
|
|
680
|
+
per_item=False,
|
|
681
|
+
precise=frozenset(_logical_step_paths(workflow.steps)),
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _check_item_saves(
|
|
686
|
+
configuration: WorkflowConfiguration,
|
|
687
|
+
workflow: WorkflowDefinition,
|
|
688
|
+
steps: tuple[StepDefinition, ...],
|
|
689
|
+
parent: str | None,
|
|
690
|
+
*,
|
|
691
|
+
per_item: bool,
|
|
692
|
+
precise: frozenset[str],
|
|
693
|
+
) -> None:
|
|
694
|
+
catalog = configuration.handlers_by_name
|
|
695
|
+
for step in steps:
|
|
696
|
+
path = f"{parent}/{step.name}" if parent else step.name
|
|
697
|
+
where = f"workflow {workflow.name!r} step {path!r}"
|
|
698
|
+
if not per_item:
|
|
699
|
+
fields = _item_saves(step, catalog)
|
|
700
|
+
if fields and step.items is None:
|
|
701
|
+
raise ConfigurationError(f"{where} {_unbound_item_saves(fields)}")
|
|
702
|
+
if fields and step.action is not None:
|
|
703
|
+
raise ConfigurationError(
|
|
704
|
+
f"{where} saves "
|
|
705
|
+
+ ", ".join(f"item.field.{name}" for name in fields)
|
|
706
|
+
+ " from an automatic command on a collection step: one "
|
|
707
|
+
"output cannot be distributed among several items; save "
|
|
708
|
+
"item fields from a per-item stage, or have the agent "
|
|
709
|
+
"record collection fields with update-item"
|
|
710
|
+
)
|
|
711
|
+
for hook in (
|
|
712
|
+
*configuration.global_hooks,
|
|
713
|
+
*workflow.hooks,
|
|
714
|
+
*step.hooks,
|
|
715
|
+
):
|
|
716
|
+
if hook.phase in _BOUNDARY_PHASES or not hook.applies_in(
|
|
717
|
+
workflow, step.name, path, precise
|
|
718
|
+
):
|
|
719
|
+
continue
|
|
720
|
+
fields = _item_saves(hook.handler, catalog)
|
|
721
|
+
if fields:
|
|
722
|
+
raise ConfigurationError(
|
|
723
|
+
f"{where} {hook.phase} hook {hook.handler.name!r} "
|
|
724
|
+
+ _unbound_item_saves(fields)
|
|
725
|
+
)
|
|
726
|
+
for nested, nested_per_item in (
|
|
727
|
+
(step.child_steps, per_item),
|
|
728
|
+
(step.loop_steps, per_item),
|
|
729
|
+
(step.assessment_outcomes, per_item),
|
|
730
|
+
(_item_steps(step), True),
|
|
731
|
+
(_child_stages(step), per_item),
|
|
732
|
+
):
|
|
733
|
+
_check_item_saves(
|
|
734
|
+
configuration,
|
|
735
|
+
workflow,
|
|
736
|
+
nested,
|
|
737
|
+
path,
|
|
738
|
+
per_item=nested_per_item,
|
|
739
|
+
precise=precise,
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _validate_item_phases(configuration: WorkflowConfiguration) -> None:
|
|
744
|
+
"""Allow ``item_phase`` only on an acting step of a per-item stage.
|
|
745
|
+
|
|
746
|
+
The phase becomes the step's item operation, which the pass gate and the
|
|
747
|
+
automatic reporting read from concrete per-item plan items. On an
|
|
748
|
+
assessment the operation is never compiled, and outside a per-item stage
|
|
749
|
+
there is no item to mark, so either placement would silently do nothing.
|
|
750
|
+
Reusable handlers are checked where a step uses them, never as unused
|
|
751
|
+
catalog definitions.
|
|
752
|
+
"""
|
|
753
|
+
for workflow in configuration.workflows:
|
|
754
|
+
_check_item_phases(workflow, workflow.steps, None, per_item=False)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _check_item_phases(
|
|
758
|
+
workflow: WorkflowDefinition,
|
|
759
|
+
steps: tuple[StepDefinition, ...],
|
|
760
|
+
parent: str | None,
|
|
761
|
+
*,
|
|
762
|
+
per_item: bool,
|
|
763
|
+
) -> None:
|
|
764
|
+
for step in steps:
|
|
765
|
+
path = f"{parent}/{step.name}" if parent else step.name
|
|
766
|
+
if step.item_operation is not None and step.items is None:
|
|
767
|
+
where = f"workflow {workflow.name!r} step {path!r}"
|
|
768
|
+
if step.assessment_question is not None:
|
|
769
|
+
raise ConfigurationError(
|
|
770
|
+
f"{where} sets item_phase on an assessment, which has no "
|
|
771
|
+
"effect: an assessment compiles no item operation. Put "
|
|
772
|
+
"item_phase on the outcome steps that do the work"
|
|
773
|
+
)
|
|
774
|
+
if not per_item:
|
|
775
|
+
raise ConfigurationError(
|
|
776
|
+
f"{where} sets item_phase outside any per-item stage, "
|
|
777
|
+
"where it has no effect: item_phase marks a stage under "
|
|
778
|
+
"an items step, so move the step into items.steps"
|
|
779
|
+
)
|
|
780
|
+
for nested, nested_per_item in (
|
|
781
|
+
(step.child_steps, per_item),
|
|
782
|
+
(step.loop_steps, per_item),
|
|
783
|
+
(step.assessment_outcomes, per_item),
|
|
784
|
+
(_item_steps(step), True),
|
|
785
|
+
(_child_stages(step), per_item),
|
|
786
|
+
):
|
|
787
|
+
_check_item_phases(workflow, nested, path, per_item=nested_per_item)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _validate_child_launches(configuration: WorkflowConfiguration) -> None:
|
|
791
|
+
"""Allow ``start_child`` only on the per-child stage that runs the child.
|
|
792
|
+
|
|
793
|
+
The launch becomes part of the child run the stage compiles to. On any
|
|
794
|
+
other step, or in a reusable handler no workflow stage runs the child
|
|
795
|
+
with, it would silently start nothing. Its templates read the child's own
|
|
796
|
+
record, which is all that exists before the child has started.
|
|
797
|
+
"""
|
|
798
|
+
places = [
|
|
799
|
+
(f"workflow {workflow.name!r}", step)
|
|
800
|
+
for workflow in configuration.workflows
|
|
801
|
+
for step in _walk_steps(workflow.steps)
|
|
802
|
+
]
|
|
803
|
+
places += [
|
|
804
|
+
(f"handler {handler.name!r}", step)
|
|
805
|
+
for handler in configuration.handlers
|
|
806
|
+
if isinstance(handler, StepDefinition)
|
|
807
|
+
for step in _walk_steps((handler,))
|
|
808
|
+
]
|
|
809
|
+
for where, step in places:
|
|
810
|
+
launch = step.child_launch
|
|
811
|
+
if launch is None:
|
|
812
|
+
continue
|
|
813
|
+
if not isinstance(step.operation, ChildWorkflowRun):
|
|
814
|
+
raise ConfigurationError(
|
|
815
|
+
f"{where} step {step.name!r} sets start_child outside the stage "
|
|
816
|
+
"that runs the child, where it would start nothing: put it "
|
|
817
|
+
"beside `workflow:` on the stage under children.steps"
|
|
818
|
+
)
|
|
819
|
+
for template in launch.templates:
|
|
820
|
+
unknown = sorted(
|
|
821
|
+
name
|
|
822
|
+
for name in dependencies(template)
|
|
823
|
+
if name not in CHILD_VALUE_NAMES
|
|
824
|
+
and not name.startswith(CHILD_FIELD_PREFIX)
|
|
825
|
+
)
|
|
826
|
+
if unknown:
|
|
827
|
+
raise ConfigurationError(
|
|
828
|
+
f"{where} step {step.name!r} start_child reads "
|
|
829
|
+
+ ", ".join(f"{{{{{name}}}}}" for name in unknown)
|
|
830
|
+
+ ", which does not exist before the child starts; it can "
|
|
831
|
+
"read {{ww.child.id}}, {{ww.child.text}}, "
|
|
832
|
+
"{{ww.child.project}} and {{ww.child.field.<name>}}"
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _item_saves(
|
|
837
|
+
handler: HandlerDefinition,
|
|
838
|
+
catalog: Mapping[str, HandlerDefinition],
|
|
839
|
+
stack: tuple[str, ...] = (),
|
|
840
|
+
) -> tuple[str, ...]:
|
|
841
|
+
"""The item fields ``handler`` saves, through references and groups.
|
|
842
|
+
|
|
843
|
+
A step already carries the handler it names; a hook or group member that
|
|
844
|
+
only names a catalog handler runs that handler.
|
|
845
|
+
"""
|
|
846
|
+
if not isinstance(handler, StepDefinition) and handler.is_reference:
|
|
847
|
+
if handler.name in stack:
|
|
848
|
+
return ()
|
|
849
|
+
handler = catalog.get(handler.name, handler)
|
|
850
|
+
stack = (*stack, handler.name)
|
|
851
|
+
fields = [field.name for field in handler.update_item]
|
|
852
|
+
for member in handler.handlers:
|
|
853
|
+
fields.extend(_item_saves(member, catalog, stack))
|
|
854
|
+
return tuple(dict.fromkeys(fields))
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _unbound_item_saves(fields: tuple[str, ...]) -> str:
|
|
858
|
+
return (
|
|
859
|
+
"saves "
|
|
860
|
+
+ ", ".join(f"item.field.{name}" for name in fields)
|
|
861
|
+
+ " outside any item: an item field save belongs on an items step, "
|
|
862
|
+
"for the items it collects, or on a step, hook, or handler that runs "
|
|
863
|
+
"in a per-item stage; a step between passes updates items with "
|
|
864
|
+
"update-item instead"
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _validate_hooks(
|
|
869
|
+
hooks: tuple[HookDefinition, ...],
|
|
870
|
+
known_steps: set[str],
|
|
871
|
+
known_workflows: set[str] | None = None,
|
|
872
|
+
*,
|
|
873
|
+
expected_scope: str,
|
|
874
|
+
) -> None:
|
|
875
|
+
for hook in hooks:
|
|
876
|
+
_validate_execution_hints(hook.handler, hook.path or "hook")
|
|
877
|
+
if isinstance(hook.handler, StepDefinition) and (
|
|
878
|
+
hook.handler.child_steps or hook.handler.loop_steps or hook.handler.items
|
|
879
|
+
):
|
|
880
|
+
raise ConfigurationError(
|
|
881
|
+
f"{hook.path or 'hook'} cannot use a container handler"
|
|
882
|
+
)
|
|
883
|
+
if hook.phase not in _HOOK_PHASES:
|
|
884
|
+
raise ConfigurationError(
|
|
885
|
+
f"{hook.path or 'hook'} has unknown phase {hook.phase!r}"
|
|
886
|
+
)
|
|
887
|
+
if hook.scope != expected_scope:
|
|
888
|
+
raise ConfigurationError(
|
|
889
|
+
f"{hook.path or 'hook'} has scope {hook.scope!r}; "
|
|
890
|
+
f"expected {expected_scope!r}"
|
|
891
|
+
)
|
|
892
|
+
if expected_scope != "global" and not hook.workflows.admits_all:
|
|
893
|
+
raise ConfigurationError(
|
|
894
|
+
f"{hook.path or 'hook'} cannot filter by workflow at "
|
|
895
|
+
f"{expected_scope} scope"
|
|
896
|
+
)
|
|
897
|
+
if expected_scope == "step" and not hook.steps.admits_all:
|
|
898
|
+
raise ConfigurationError(
|
|
899
|
+
f"{hook.path or 'hook'} cannot filter by step at step scope"
|
|
900
|
+
)
|
|
901
|
+
if hook.phase in {"before_start_workflow", "before_complete_workflow"} and (
|
|
902
|
+
not hook.steps.admits_all
|
|
903
|
+
):
|
|
904
|
+
raise ConfigurationError(
|
|
905
|
+
f"{hook.path or 'hook'} cannot filter a workflow boundary by step"
|
|
906
|
+
)
|
|
907
|
+
if hook.on_failure == "fix":
|
|
908
|
+
if hook.phase != "before_complete":
|
|
909
|
+
raise ConfigurationError(
|
|
910
|
+
f"{hook.path or 'hook'}: on_failure: fix is only valid on "
|
|
911
|
+
"before_complete hooks"
|
|
912
|
+
)
|
|
913
|
+
if isinstance(hook.handler.operation, WorkflowHandoff):
|
|
914
|
+
raise ConfigurationError(
|
|
915
|
+
f"{hook.path or 'hook'}: on_failure: fix is not valid on a "
|
|
916
|
+
"workflow transition"
|
|
917
|
+
)
|
|
918
|
+
_validate_filters(
|
|
919
|
+
hook.path or "hook",
|
|
920
|
+
hook.workflows,
|
|
921
|
+
hook.steps,
|
|
922
|
+
known_steps,
|
|
923
|
+
known_workflows,
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _validate_filters(
|
|
928
|
+
label: str,
|
|
929
|
+
workflows: NameFilter,
|
|
930
|
+
steps: NameFilter,
|
|
931
|
+
known_steps: set[str],
|
|
932
|
+
known_workflows: set[str] | None,
|
|
933
|
+
) -> None:
|
|
934
|
+
"""Reject ``workflows``/``steps`` filters naming nothing that exists."""
|
|
935
|
+
if known_workflows is not None:
|
|
936
|
+
unknown_workflows = set(workflows.listed) - known_workflows
|
|
937
|
+
if unknown_workflows:
|
|
938
|
+
raise ConfigurationError(
|
|
939
|
+
f"{label} references unknown workflow(s): "
|
|
940
|
+
+ ", ".join(sorted(unknown_workflows))
|
|
941
|
+
)
|
|
942
|
+
unknown_steps = set(steps.listed) - known_steps
|
|
943
|
+
if unknown_steps:
|
|
944
|
+
raise ConfigurationError(
|
|
945
|
+
f"{label} references unknown step(s): " + ", ".join(sorted(unknown_steps))
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _validate_rule_groups(
|
|
950
|
+
configuration: WorkflowConfiguration,
|
|
951
|
+
known_steps: set[str],
|
|
952
|
+
known_workflows: set[str],
|
|
953
|
+
) -> None:
|
|
954
|
+
"""A rule group's filters name workflows and steps that exist, as a hook's do."""
|
|
955
|
+
_unique((group.name for group in configuration.rule_groups), "rule group")
|
|
956
|
+
for group in configuration.rule_groups:
|
|
957
|
+
_validate_filters(
|
|
958
|
+
f"rule group {group.name!r}",
|
|
959
|
+
group.workflows,
|
|
960
|
+
group.steps,
|
|
961
|
+
known_steps,
|
|
962
|
+
known_workflows,
|
|
963
|
+
)
|
|
964
|
+
_validate_rule_hints(group.hints, f"rule group {group.name!r}")
|
|
965
|
+
for rule in group.rules:
|
|
966
|
+
_validate_rule_hints(rule.hints, f"rule {rule.id!r}")
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _validate_mode_filters(
|
|
970
|
+
configuration: WorkflowConfiguration,
|
|
971
|
+
known_steps: set[str],
|
|
972
|
+
known_workflows: set[str],
|
|
973
|
+
) -> None:
|
|
974
|
+
"""An automatic mode's filters name workflows and steps that exist."""
|
|
975
|
+
for mode in configuration.modes:
|
|
976
|
+
if mode.automatic:
|
|
977
|
+
_validate_filters(
|
|
978
|
+
f"mode {mode.name!r}",
|
|
979
|
+
mode.workflows or NameFilter(),
|
|
980
|
+
mode.steps or NameFilter(),
|
|
981
|
+
known_steps,
|
|
982
|
+
known_workflows,
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _validate_rule_hints(hints: RuleHints, path: str) -> None:
|
|
987
|
+
if hints.agent == "auto":
|
|
988
|
+
raise ConfigurationError(f"{path}.agent must not be 'auto'")
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _validate_hook_references(configuration: WorkflowConfiguration) -> None:
|
|
992
|
+
"""Reject a hook naming a root handler that is a whole step tree.
|
|
993
|
+
|
|
994
|
+
A hook runs one action. A handler defining ``loop``, ``steps``,
|
|
995
|
+
``items``, or ``children`` is only usable as a workflow step; run as a
|
|
996
|
+
hook it would lose its tree and become a prompt carrying nothing but its
|
|
997
|
+
name.
|
|
998
|
+
"""
|
|
999
|
+
handlers = configuration.handlers_by_name
|
|
1000
|
+
for hook in _every_hook(configuration):
|
|
1001
|
+
if not hook.handler.is_reference:
|
|
1002
|
+
continue
|
|
1003
|
+
registered = handlers.get(hook.handler.name)
|
|
1004
|
+
if isinstance(registered, StepDefinition) and _is_container(registered):
|
|
1005
|
+
raise ConfigurationError(
|
|
1006
|
+
f"{hook.path or 'hook'} runs handler {registered.name!r}, which "
|
|
1007
|
+
"defines a loop, steps, items, or children; a hook runs a "
|
|
1008
|
+
f"single action, so use {registered.name!r} as a workflow "
|
|
1009
|
+
"step instead"
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _validate_hooks_from(configuration: WorkflowConfiguration) -> None:
|
|
1014
|
+
"""``hooks_from`` names another workflow that takes no one's hooks itself.
|
|
1015
|
+
|
|
1016
|
+
Each error names the workflow's ``hooks_from`` in ``ww.yaml``.
|
|
1017
|
+
"""
|
|
1018
|
+
known = configuration.workflows_by_name
|
|
1019
|
+
for workflow in configuration.workflows:
|
|
1020
|
+
source = workflow.hooks_from
|
|
1021
|
+
if source is None:
|
|
1022
|
+
continue
|
|
1023
|
+
where = f"workflow {workflow.name!r} hooks_from in ww.yaml"
|
|
1024
|
+
if source == workflow.name:
|
|
1025
|
+
raise ConfigurationError(
|
|
1026
|
+
f"{where}: workflow {workflow.name!r} cannot take its hooks from itself"
|
|
1027
|
+
)
|
|
1028
|
+
if source not in known:
|
|
1029
|
+
raise ConfigurationError(
|
|
1030
|
+
f"{where}: workflow {workflow.name!r} takes its hooks from "
|
|
1031
|
+
f"unknown workflow {source!r}"
|
|
1032
|
+
)
|
|
1033
|
+
if known[source].hooks_from is not None:
|
|
1034
|
+
raise ConfigurationError(
|
|
1035
|
+
f"{where}: workflow {workflow.name!r} takes its hooks from "
|
|
1036
|
+
f"{source!r}, which takes its own from another workflow; name "
|
|
1037
|
+
"that one"
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _validate_recommendations(configuration: WorkflowConfiguration) -> None:
|
|
1042
|
+
"""A recommended next workflow must exist and must not race a handoff."""
|
|
1043
|
+
known = configuration.workflows_by_name
|
|
1044
|
+
for workflow in configuration.workflows:
|
|
1045
|
+
recommended = workflow.recommended_next_workflow
|
|
1046
|
+
if recommended is None:
|
|
1047
|
+
continue
|
|
1048
|
+
if recommended not in known:
|
|
1049
|
+
raise ConfigurationError(
|
|
1050
|
+
f"workflow {workflow.name!r} recommends unknown workflow "
|
|
1051
|
+
f"{recommended!r}"
|
|
1052
|
+
)
|
|
1053
|
+
if workflow.hands_off:
|
|
1054
|
+
raise ConfigurationError(
|
|
1055
|
+
f"workflow {workflow.name!r} hands off at its end and cannot "
|
|
1056
|
+
"also recommend a next workflow"
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _is_container(step: StepDefinition) -> bool:
|
|
1061
|
+
return bool(step.child_steps or step.loop_steps or step.items or step.children)
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _every_hook(configuration: WorkflowConfiguration) -> Iterable[HookDefinition]:
|
|
1065
|
+
yield from configuration.global_hooks
|
|
1066
|
+
for workflow in configuration.workflows:
|
|
1067
|
+
yield from workflow.hooks
|
|
1068
|
+
yield from _step_hooks(workflow.steps)
|
|
1069
|
+
for handler in configuration.handlers:
|
|
1070
|
+
if isinstance(handler, StepDefinition):
|
|
1071
|
+
yield from _step_hooks((handler,))
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _step_hooks(steps: tuple[StepDefinition, ...]) -> Iterable[HookDefinition]:
|
|
1075
|
+
for step in steps:
|
|
1076
|
+
yield from step.hooks
|
|
1077
|
+
yield from _step_hooks(
|
|
1078
|
+
(
|
|
1079
|
+
*step.child_steps,
|
|
1080
|
+
*step.loop_steps,
|
|
1081
|
+
*_template_steps(step),
|
|
1082
|
+
*step.assessment_outcomes,
|
|
1083
|
+
)
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _walk_steps(steps: tuple[StepDefinition, ...]) -> tuple[StepDefinition, ...]:
|
|
1088
|
+
result: list[StepDefinition] = []
|
|
1089
|
+
for step in steps:
|
|
1090
|
+
result.append(step)
|
|
1091
|
+
result.extend(_walk_steps(step.child_steps))
|
|
1092
|
+
result.extend(_walk_steps(step.loop_steps))
|
|
1093
|
+
result.extend(_walk_steps(_template_steps(step)))
|
|
1094
|
+
return tuple(result)
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _validate_workflow_boundary_hooks(
|
|
1098
|
+
configuration: WorkflowConfiguration,
|
|
1099
|
+
) -> None:
|
|
1100
|
+
"""Keep workflow boundaries out of step-local lifecycle definitions."""
|
|
1101
|
+
boundary_phases = {"before_start_workflow", "before_complete_workflow"}
|
|
1102
|
+
for workflow in configuration.workflows:
|
|
1103
|
+
for step in _walk_steps(workflow.steps):
|
|
1104
|
+
for hook in step.hooks:
|
|
1105
|
+
if hook.phase in boundary_phases:
|
|
1106
|
+
raise ConfigurationError(
|
|
1107
|
+
f"{hook.path or 'hook'} uses {hook.phase} at step scope; "
|
|
1108
|
+
"workflow boundary hooks belong at global or workflow scope"
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _logical_step_paths(
|
|
1113
|
+
steps: tuple[StepDefinition, ...], parent: str | None = None
|
|
1114
|
+
) -> set[str]:
|
|
1115
|
+
result: set[str] = set()
|
|
1116
|
+
for step in steps:
|
|
1117
|
+
path = f"{parent}/{step.name}" if parent else step.name
|
|
1118
|
+
result.add(path)
|
|
1119
|
+
result.update(_logical_step_paths(step.child_steps, path))
|
|
1120
|
+
result.update(_logical_step_paths(step.loop_steps, path))
|
|
1121
|
+
result.update(_logical_step_paths(_template_steps(step), path))
|
|
1122
|
+
return result
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def _step_filter_references(
|
|
1126
|
+
steps: tuple[StepDefinition, ...], parent: str | None = None
|
|
1127
|
+
) -> set[str]:
|
|
1128
|
+
"""Return both compatible leaf names and precise logical step paths."""
|
|
1129
|
+
result: set[str] = set()
|
|
1130
|
+
for step in steps:
|
|
1131
|
+
path = f"{parent}/{step.name}" if parent else step.name
|
|
1132
|
+
result.update((step.name, path))
|
|
1133
|
+
result.update(_step_filter_references(step.child_steps, path))
|
|
1134
|
+
result.update(_step_filter_references(step.loop_steps, path))
|
|
1135
|
+
result.update(_step_filter_references(_template_steps(step), path))
|
|
1136
|
+
return result
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _validate_child_tasks(workflows: tuple[WorkflowDefinition, ...]) -> None:
|
|
1140
|
+
"""Keep task orchestration deliberately to one parent/child level."""
|
|
1141
|
+
by_name = {workflow.name: workflow for workflow in workflows}
|
|
1142
|
+
for workflow in workflows:
|
|
1143
|
+
collectors = [
|
|
1144
|
+
step for step in _walk_steps(workflow.steps) if step.children is not None
|
|
1145
|
+
]
|
|
1146
|
+
if len(collectors) > 1:
|
|
1147
|
+
raise ConfigurationError(
|
|
1148
|
+
f"workflow {workflow.name!r} may define at most one children step; "
|
|
1149
|
+
"found " + ", ".join(repr(step.name) for step in collectors)
|
|
1150
|
+
)
|
|
1151
|
+
if not collectors:
|
|
1152
|
+
continue
|
|
1153
|
+
per_item = [
|
|
1154
|
+
step
|
|
1155
|
+
for flow_step in _walk_steps(workflow.steps)
|
|
1156
|
+
for step in _walk_steps(_item_steps(flow_step))
|
|
1157
|
+
if step.children is not None
|
|
1158
|
+
]
|
|
1159
|
+
if per_item:
|
|
1160
|
+
raise ConfigurationError(
|
|
1161
|
+
f"workflow {workflow.name!r} collects children in the per-item "
|
|
1162
|
+
f"stage {per_item[0].name!r}; a children step cannot repeat per item"
|
|
1163
|
+
)
|
|
1164
|
+
flow = collectors[0].children
|
|
1165
|
+
assert flow is not None
|
|
1166
|
+
# Per-child stages expand once, when collection completes; a loop's
|
|
1167
|
+
# next round would replay the first child's stages and never reach
|
|
1168
|
+
# the others.
|
|
1169
|
+
looped = [
|
|
1170
|
+
step
|
|
1171
|
+
for loop_owner in _walk_steps(workflow.steps)
|
|
1172
|
+
for step in _walk_steps(loop_owner.loop_steps)
|
|
1173
|
+
if step.children is not None and step.children.steps
|
|
1174
|
+
]
|
|
1175
|
+
if looped:
|
|
1176
|
+
raise ConfigurationError(
|
|
1177
|
+
f"workflow {workflow.name!r} runs children.steps of "
|
|
1178
|
+
f"{looped[0].name!r} inside a loop; per-child stages cannot "
|
|
1179
|
+
"repeat per loop round, so move the children step out of the "
|
|
1180
|
+
"loop or name the child workflow with children.workflow"
|
|
1181
|
+
)
|
|
1182
|
+
target = by_name.get(flow.workflow)
|
|
1183
|
+
if target is None:
|
|
1184
|
+
raise ConfigurationError(
|
|
1185
|
+
f"workflow {workflow.name!r} references unknown child workflow "
|
|
1186
|
+
f"{flow.workflow!r}"
|
|
1187
|
+
)
|
|
1188
|
+
if target.name == workflow.name or any(
|
|
1189
|
+
step.children is not None for step in _walk_steps(target.steps)
|
|
1190
|
+
):
|
|
1191
|
+
raise ConfigurationError(
|
|
1192
|
+
f"child workflow {target.name!r} cannot define child tasks; "
|
|
1193
|
+
"recursive child tasks are not supported"
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _unique(names: Iterable[str], label: str) -> None:
|
|
1198
|
+
values = tuple(names)
|
|
1199
|
+
if len(values) != len(set(values)):
|
|
1200
|
+
raise ConfigurationError(f"duplicate {label} name")
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _validate_execution_hints(
|
|
1204
|
+
value: HandlerDefinition | WorkflowDefinition, path: str
|
|
1205
|
+
) -> None:
|
|
1206
|
+
for field in ("model", "reasoning"):
|
|
1207
|
+
hint = getattr(value, field)
|
|
1208
|
+
if hint is not None and (not isinstance(hint, str) or not hint.strip()):
|
|
1209
|
+
raise ConfigurationError(f"{path}.{field} must be a non-empty string")
|
|
1210
|
+
agent = value.agent
|
|
1211
|
+
if agent is not None and (
|
|
1212
|
+
not isinstance(agent, str) or not agent.strip() or agent == "auto"
|
|
1213
|
+
):
|
|
1214
|
+
raise ConfigurationError(
|
|
1215
|
+
f"{path}.agent must be non-empty and must not be 'auto'"
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _validate_document_updates(configuration: WorkflowConfiguration) -> None:
|
|
1220
|
+
"""Every ``saves`` document entry must name a document declared at the root."""
|
|
1221
|
+
declared = {document.name for document in configuration.documents}
|
|
1222
|
+
|
|
1223
|
+
def check(handler: HandlerDefinition, where: str) -> None:
|
|
1224
|
+
unknown = sorted(
|
|
1225
|
+
update.name
|
|
1226
|
+
for update in handler.update_document
|
|
1227
|
+
if update.name not in declared
|
|
1228
|
+
)
|
|
1229
|
+
if unknown:
|
|
1230
|
+
raise ConfigurationError(
|
|
1231
|
+
f"{where} updates undeclared document(s): "
|
|
1232
|
+
+ ", ".join(unknown)
|
|
1233
|
+
+ "; declare them under the root `documents`"
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
def walk(steps: tuple[StepDefinition, ...], where: str) -> None:
|
|
1237
|
+
for step in steps:
|
|
1238
|
+
label = f"{where} step {step.name!r}"
|
|
1239
|
+
check(step, label)
|
|
1240
|
+
for hook in step.hooks:
|
|
1241
|
+
check(hook.handler, f"{label} hook {hook.handler.name!r}")
|
|
1242
|
+
walk(step.child_steps, label)
|
|
1243
|
+
walk(step.loop_steps, label)
|
|
1244
|
+
walk(step.assessment_outcomes, label)
|
|
1245
|
+
walk(_template_steps(step), label)
|
|
1246
|
+
|
|
1247
|
+
for handler in configuration.handlers:
|
|
1248
|
+
check(handler, f"handler {handler.name!r}")
|
|
1249
|
+
if isinstance(handler, StepDefinition):
|
|
1250
|
+
walk(handler.child_steps, f"handler {handler.name!r}")
|
|
1251
|
+
walk(handler.loop_steps, f"handler {handler.name!r}")
|
|
1252
|
+
walk(_template_steps(handler), f"handler {handler.name!r}")
|
|
1253
|
+
for hook in configuration.global_hooks:
|
|
1254
|
+
check(hook.handler, f"global hook {hook.handler.name!r}")
|
|
1255
|
+
for workflow in configuration.workflows:
|
|
1256
|
+
for hook in workflow.hooks:
|
|
1257
|
+
check(
|
|
1258
|
+
hook.handler, f"workflow {workflow.name!r} hook {hook.handler.name!r}"
|
|
1259
|
+
)
|
|
1260
|
+
walk(workflow.steps, f"workflow {workflow.name!r}")
|