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.
Files changed (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. 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}")