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
ww/action_execution.py ADDED
@@ -0,0 +1,887 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Durable execution coordinator for automatic workflow actions.
3
+
4
+ Actions decide which narrow effects to perform. This module deliberately owns
5
+ the execution ledger, process boundary, output storage, and workflow cursor.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import subprocess
12
+ from collections.abc import Callable, Mapping
13
+ from copy import deepcopy
14
+ from dataclasses import dataclass, replace
15
+ from pathlib import Path
16
+ from types import MappingProxyType
17
+
18
+ from ww.actions import (
19
+ ActionResult,
20
+ AutomaticAction,
21
+ CommandOutcome,
22
+ CommandRequest,
23
+ CommandService,
24
+ Extension,
25
+ ExtensionIdentityService,
26
+ ExtensionService,
27
+ RecoveryCheckResult,
28
+ RecoveryExtensionService,
29
+ actions,
30
+ )
31
+ from ww.errors import StateError
32
+ from ww.execution_models import (
33
+ CommandExecution,
34
+ ExecutionState,
35
+ PlanSnapshot,
36
+ operation_scope_for,
37
+ )
38
+ from ww.extensions import (
39
+ ExtensionCheckResult,
40
+ ExtensionContext,
41
+ ExtensionHandler,
42
+ ExtensionRegistry,
43
+ parse_reference,
44
+ )
45
+ from ww.interpolation import dependencies, interpolate
46
+ from ww.item_passes import reports_item_on_completion
47
+ from ww.items import WorkItem
48
+ from ww.metadata_publication import MetadataPublisher, validate_metadata_values
49
+ from ww.plan import PlanItem, WorkflowPlan
50
+ from ww.step_values import StepValues, no_step_values
51
+ from ww.storage_adapters import CommandOutputAddress
52
+ from ww.variables import (
53
+ PROJECT,
54
+ item_context_error,
55
+ item_workspace_values,
56
+ )
57
+ from ww.workspace import relative_workspace
58
+
59
+ _OUTPUT_LIMIT = 16_000
60
+ STATE_OUTPUT_PREVIEW_LIMIT = 1_000
61
+ # The durable executor, rather than a command action implementation, owns the
62
+ # process boundary. Keeping this dependency here also gives recovery tests a
63
+ # stable, local seam for simulating an interrupted launch.
64
+ _PROCESS = subprocess
65
+ CommitRun = Callable[[ExecutionState, PlanSnapshot], None]
66
+ ReadItems = Callable[[ExecutionState], tuple[WorkItem, ...]]
67
+ CommitItems = Callable[[ExecutionState, PlanSnapshot, tuple[WorkItem, ...]], None]
68
+ ProjectState = Callable[[ExecutionState, WorkflowPlan], ExecutionState]
69
+ Clock = Callable[[], str]
70
+ WriteCommandOutput = Callable[[CommandOutputAddress, str], str]
71
+ ReadCommandOutput = Callable[[str], str]
72
+ TaskValues = Callable[[ExecutionState, WorkflowPlan], dict[str, str]]
73
+
74
+
75
+ @dataclass
76
+ class _Dispatch:
77
+ executor: ActionExecutor
78
+ state: ExecutionState
79
+ snapshot: PlanSnapshot
80
+ item: PlanItem
81
+
82
+
83
+ class _CommandService:
84
+ """One-segment durable command executor; no sequence policy lives here."""
85
+
86
+ def __init__(self, dispatch: _Dispatch) -> None:
87
+ self._dispatch = dispatch
88
+
89
+ def completed(self, segment: int) -> CommandOutcome | None:
90
+ command = self._command(segment)
91
+ if command.status != "completed":
92
+ return None
93
+ return CommandOutcome(
94
+ True,
95
+ self._output(command, "stdout"),
96
+ self._output(command, "stderr"),
97
+ command.exit_code,
98
+ )
99
+
100
+ def execute(self, segment: int, request: CommandRequest) -> CommandOutcome:
101
+ command = self._command(segment)
102
+ if command.status == "completed":
103
+ completed = self.completed(segment)
104
+ if completed is None: # pragma: no cover
105
+ raise AssertionError("completed command did not load")
106
+ return completed
107
+ executor, state, item = (
108
+ self._dispatch.executor,
109
+ self._dispatch.state,
110
+ self._dispatch.item,
111
+ )
112
+ item_record = state.item_executions[state.cursor]
113
+ operation_id = item_record.operation_id
114
+ if operation_id is None: # pragma: no cover - coordinator establishes it
115
+ raise AssertionError("automatic item has no operation ID")
116
+ command_operation_id = command.operation_id or (
117
+ f"{operation_id}:command:{segment + 1}"
118
+ )
119
+ started = replace(
120
+ command,
121
+ status="in_progress",
122
+ started_at=executor.now(),
123
+ operation_id=command_operation_id,
124
+ attempts=command.attempts + 1,
125
+ )
126
+ self._dispatch.state = replace_command(
127
+ state, state.cursor, segment, started, executor.now()
128
+ )
129
+ executor.commit(self._dispatch.state, self._dispatch.snapshot)
130
+ environment = {
131
+ **os.environ,
132
+ **request.environment,
133
+ "WW_ITEM_OPERATION_ID": operation_id,
134
+ "WW_OPERATION_ID": command_operation_id,
135
+ "WW_OPERATION_ATTEMPT": str(started.attempts),
136
+ }
137
+ try:
138
+ process = _PROCESS.Popen(
139
+ request.argv,
140
+ cwd=(
141
+ executor.item_scope(
142
+ self._dispatch.state, self._dispatch.snapshot.plan, item
143
+ )[0]
144
+ or executor.root
145
+ ),
146
+ stdout=_PROCESS.PIPE,
147
+ stderr=_PROCESS.PIPE,
148
+ text=True,
149
+ shell=False,
150
+ env=environment,
151
+ )
152
+ except OSError as error:
153
+ detail = f"{type(error).__name__}: {error}"
154
+ failed = replace(
155
+ started,
156
+ status="failed",
157
+ completed_at=executor.now(),
158
+ stderr=bounded(detail, STATE_OUTPUT_PREVIEW_LIMIT),
159
+ )
160
+ self._dispatch.state = replace_command(
161
+ self._dispatch.state,
162
+ self._dispatch.state.cursor,
163
+ segment,
164
+ failed,
165
+ executor.now(),
166
+ )
167
+ executor.commit(self._dispatch.state, self._dispatch.snapshot)
168
+ return CommandOutcome(False, stderr=detail, launch_error=detail)
169
+ with process:
170
+ stdout, stderr = process.communicate()
171
+
172
+ def address(stream: str) -> CommandOutputAddress:
173
+ return CommandOutputAddress(
174
+ self._dispatch.state.task_id,
175
+ self._dispatch.state.run_id or self._dispatch.state.workflow,
176
+ item.id,
177
+ command_operation_id,
178
+ started.attempts,
179
+ segment + 1,
180
+ stream,
181
+ )
182
+
183
+ stdout_ref = (
184
+ executor.write_command_output(address("stdout"), stdout) if stdout else None
185
+ )
186
+ stderr_ref = (
187
+ executor.write_command_output(address("stderr"), stderr) if stderr else None
188
+ )
189
+ completed_record = replace(
190
+ started,
191
+ status="completed" if process.returncode == 0 else "failed",
192
+ completed_at=executor.now(),
193
+ exit_code=process.returncode,
194
+ stdout=bounded(stdout, STATE_OUTPUT_PREVIEW_LIMIT),
195
+ stderr=bounded(stderr, STATE_OUTPUT_PREVIEW_LIMIT),
196
+ stdout_ref=stdout_ref,
197
+ stderr_ref=stderr_ref,
198
+ )
199
+ self._dispatch.state = replace_command(
200
+ self._dispatch.state,
201
+ self._dispatch.state.cursor,
202
+ segment,
203
+ completed_record,
204
+ executor.now(),
205
+ )
206
+ # Every finished segment is a durable boundary: a success is never
207
+ # replayed, and a recorded failure stays a known outcome even when ww
208
+ # dies before the coordinator writes the item failure.
209
+ executor.commit(self._dispatch.state, self._dispatch.snapshot)
210
+ return CommandOutcome(
211
+ process.returncode == 0, stdout, stderr, process.returncode
212
+ )
213
+
214
+ def _command(self, segment: int) -> CommandExecution:
215
+ commands = self._dispatch.state.item_executions[
216
+ self._dispatch.state.cursor
217
+ ].commands
218
+ if not 0 <= segment < len(commands):
219
+ raise StateError(
220
+ f"command segment {segment + 1} is not declared for this action"
221
+ )
222
+ return commands[segment]
223
+
224
+ def _output(self, command: CommandExecution, stream: str) -> str:
225
+ reference = command.stdout_ref if stream == "stdout" else command.stderr_ref
226
+ if reference is not None:
227
+ return self._dispatch.executor.read_command_output(reference)
228
+ return command.stdout if stream == "stdout" else command.stderr
229
+
230
+
231
+ class _ExtensionService:
232
+ def __init__(self, dispatch: _Dispatch) -> None:
233
+ self._dispatch = dispatch
234
+
235
+ def validate_identity(self, planned: Extension) -> None:
236
+ reference = parse_reference(planned.reference)
237
+ self._dispatch.executor.extensions.validate_identity(
238
+ reference.identifier,
239
+ version=planned.version,
240
+ api_version=planned.api_version,
241
+ source=planned.source,
242
+ fingerprint=planned.fingerprint,
243
+ )
244
+
245
+ def handler(self, reference: str) -> ExtensionHandler:
246
+ return self._dispatch.executor.extensions.handler(reference)
247
+
248
+ def context(self, planned: Extension) -> ExtensionContext:
249
+ executor, state, item = (
250
+ self._dispatch.executor,
251
+ self._dispatch.state,
252
+ self._dispatch.item,
253
+ )
254
+ reference = parse_reference(planned.reference)
255
+ record = state.item_executions[state.cursor]
256
+ workspace, values = executor.item_scope(
257
+ state, self._dispatch.snapshot.plan, item
258
+ )
259
+ missing = sorted(
260
+ {
261
+ name
262
+ for argument in planned.arguments
263
+ for name in dependencies(argument)
264
+ if name not in values
265
+ }
266
+ )
267
+ context_error = item_context_error(missing, values)
268
+ if context_error is not None:
269
+ raise StateError(context_error)
270
+ if missing:
271
+ raise StateError(
272
+ "extension handler arguments are missing variable(s): "
273
+ + ", ".join(missing)
274
+ )
275
+ return ExtensionContext(
276
+ root=executor.root,
277
+ store=executor.extensions.store(reference.identifier),
278
+ config=executor.item_settings(state, item, planned),
279
+ task_id=state.task_id,
280
+ run_id=state.run_id,
281
+ workflow=state.workflow,
282
+ lane=self._dispatch.snapshot.plan.lane,
283
+ values=values,
284
+ arguments=tuple(
285
+ interpolate(argument, values) for argument in planned.arguments
286
+ ),
287
+ workspace=workspace,
288
+ item_id=item.id,
289
+ work_item_id=item.item_id,
290
+ attempt=record.attempts,
291
+ operation_id=record.operation_id or operation_id_for(state, item),
292
+ )
293
+
294
+
295
+ @dataclass(frozen=True)
296
+ class _ExecutionContext:
297
+ root: Path
298
+ workspace: Path | None
299
+ runtime_values: Mapping[str, str]
300
+ task_id: str
301
+ run_id: str | None
302
+ operation_id: str
303
+ attempt: int
304
+ commands: CommandService
305
+ extensions: ExtensionService
306
+
307
+ @classmethod
308
+ def create(cls, dispatch: _Dispatch) -> _ExecutionContext:
309
+ executor, state, item = dispatch.executor, dispatch.state, dispatch.item
310
+ record = state.item_executions[state.cursor]
311
+ workspace, values = executor.item_scope(state, dispatch.snapshot.plan, item)
312
+ return cls(
313
+ root=executor.root,
314
+ workspace=workspace,
315
+ runtime_values=MappingProxyType(values),
316
+ task_id=state.task_id,
317
+ run_id=state.run_id,
318
+ operation_id=record.operation_id or operation_id_for(state, item),
319
+ attempt=record.attempts,
320
+ commands=_CommandService(dispatch),
321
+ extensions=_ExtensionService(dispatch),
322
+ )
323
+
324
+
325
+ class _HandlerLookup:
326
+ """Handler access without identity checks, stores, or effects."""
327
+
328
+ def __init__(self, executor: ActionExecutor) -> None:
329
+ self._executor = executor
330
+
331
+ def handler(self, reference: str) -> ExtensionHandler:
332
+ return self._executor.extensions.handler(reference)
333
+
334
+
335
+ @dataclass(frozen=True)
336
+ class _InputValidationContext:
337
+ extensions: _HandlerLookup
338
+
339
+
340
+ class _PreflightExtensions:
341
+ def __init__(self, executor: ActionExecutor) -> None:
342
+ self._executor = executor
343
+
344
+ def validate_identity(self, planned: Extension) -> None:
345
+ reference = parse_reference(planned.reference)
346
+ self._executor.extensions.validate_identity(
347
+ reference.identifier,
348
+ version=planned.version,
349
+ api_version=planned.api_version,
350
+ source=planned.source,
351
+ fingerprint=planned.fingerprint,
352
+ )
353
+
354
+
355
+ @dataclass(frozen=True)
356
+ class _PreflightContext:
357
+ """Pre-start data only; preflight cannot run commands or obtain stores."""
358
+
359
+ root: Path
360
+ workspace: Path | None
361
+ runtime_values: Mapping[str, str]
362
+ task_id: str
363
+ run_id: str | None
364
+ extensions: ExtensionIdentityService
365
+
366
+ @classmethod
367
+ def create(
368
+ cls,
369
+ executor: ActionExecutor,
370
+ state: ExecutionState,
371
+ plan: WorkflowPlan,
372
+ item: PlanItem,
373
+ ) -> _PreflightContext:
374
+ workspace, values = executor.item_scope(state, plan, item)
375
+ return cls(
376
+ root=executor.root,
377
+ workspace=workspace,
378
+ runtime_values=MappingProxyType(values),
379
+ task_id=state.task_id,
380
+ run_id=state.run_id,
381
+ extensions=_PreflightExtensions(executor),
382
+ )
383
+
384
+
385
+ class _RecoveryExtensionService:
386
+ """Checker-only extension adapter built from saved state and plan data."""
387
+
388
+ def __init__(
389
+ self,
390
+ executor: ActionExecutor,
391
+ state: ExecutionState,
392
+ item: PlanItem,
393
+ plan: WorkflowPlan,
394
+ ) -> None:
395
+ self._executor = executor
396
+ self._state = state
397
+ self._item = item
398
+ self._plan = plan
399
+
400
+ def validate_identity(self, planned: Extension) -> None:
401
+ _PreflightExtensions(self._executor).validate_identity(planned)
402
+
403
+ def check(self, planned: Extension) -> ExtensionCheckResult:
404
+ reference = parse_reference(planned.reference)
405
+ handler = self._executor.extensions.handler(planned.reference)
406
+ if handler.check is None:
407
+ raise ValueError("extension has no checker")
408
+ record = self._state.item_executions[self._state.cursor]
409
+ workspace, values = self._executor.item_scope(
410
+ self._state, self._plan, self._item
411
+ )
412
+ context = ExtensionContext(
413
+ root=self._executor.root,
414
+ store=self._executor.extensions.store(reference.identifier),
415
+ config=self._executor.item_settings(self._state, self._item, planned),
416
+ task_id=self._state.task_id,
417
+ run_id=self._state.run_id,
418
+ workflow=self._state.workflow,
419
+ lane=self._plan.lane,
420
+ values=values,
421
+ workspace=workspace,
422
+ item_id=self._item.id,
423
+ work_item_id=self._item.item_id,
424
+ attempt=record.attempts,
425
+ operation_id=(
426
+ record.operation_id or operation_id_for(self._state, self._item)
427
+ ),
428
+ )
429
+ return handler.check(context)
430
+
431
+
432
+ @dataclass(frozen=True)
433
+ class _RecoveryContext:
434
+ """Recovery view avoids an execution context and fabricated snapshot."""
435
+
436
+ root: Path
437
+ workspace: Path | None
438
+ runtime_values: Mapping[str, str]
439
+ task_id: str
440
+ run_id: str | None
441
+ operation_id: str
442
+ attempt: int
443
+ extensions: RecoveryExtensionService
444
+
445
+ @classmethod
446
+ def create(
447
+ cls,
448
+ executor: ActionExecutor,
449
+ state: ExecutionState,
450
+ item: PlanItem,
451
+ plan: WorkflowPlan,
452
+ ) -> _RecoveryContext:
453
+ record = state.item_executions[state.cursor]
454
+ workspace, values = executor.item_scope(state, plan, item)
455
+ return cls(
456
+ root=executor.root,
457
+ workspace=workspace,
458
+ runtime_values=MappingProxyType(values),
459
+ task_id=state.task_id,
460
+ run_id=state.run_id,
461
+ operation_id=record.operation_id or operation_id_for(state, item),
462
+ attempt=record.attempts,
463
+ extensions=_RecoveryExtensionService(executor, state, item, plan),
464
+ )
465
+
466
+
467
+ class ActionExecutor:
468
+ """Coordinate automatic actions without exposing workflow lifecycle to them."""
469
+
470
+ def __init__(
471
+ self,
472
+ *,
473
+ root: Path,
474
+ extensions: ExtensionRegistry,
475
+ commit: CommitRun,
476
+ project_state: ProjectState,
477
+ now: Clock,
478
+ write_command_output: WriteCommandOutput,
479
+ read_command_output: ReadCommandOutput,
480
+ task_values: TaskValues,
481
+ metadata_publisher: MetadataPublisher,
482
+ child_values: StepValues = no_step_values,
483
+ item_values: StepValues = no_step_values,
484
+ read_items: ReadItems = lambda state: (),
485
+ commit_items: CommitItems | None = None,
486
+ ) -> None:
487
+ self.root = root
488
+ self.read_items = read_items
489
+ self.commit_items = commit_items
490
+ self.item_values = item_values
491
+ self.extensions = extensions
492
+ self.commit = commit
493
+ self.project_state = project_state
494
+ self.now = now
495
+ self.write_command_output = write_command_output
496
+ self.read_command_output = read_command_output
497
+ self.task_values = task_values
498
+ self.metadata_publisher = metadata_publisher
499
+ self.child_values = child_values
500
+
501
+ def item_settings(
502
+ self, state: ExecutionState, item: PlanItem, planned: Extension
503
+ ) -> dict[str, object]:
504
+ """The extension settings ``item`` runs with.
505
+
506
+ The plan's frozen copy when it has one; otherwise the settings of the
507
+ directory the item acts on, which are the run's project's for an item
508
+ working in the task workspace or the project directory, and the
509
+ root's for one working in the root.
510
+ """
511
+ if planned.settings is not None:
512
+ return deepcopy(dict(planned.settings))
513
+ project = (
514
+ dict(state.workflow_values).get(PROJECT) if item.workdir != "root" else None
515
+ )
516
+ return self.extensions.settings(
517
+ parse_reference(planned.reference).identifier, project or None
518
+ )
519
+
520
+ def item_scope(
521
+ self, state: ExecutionState, plan: WorkflowPlan, item: PlanItem
522
+ ) -> tuple[Path | None, dict[str, str]]:
523
+ """The directory ``item`` runs in and the values it interpolates."""
524
+ return item_workspace_values(
525
+ self.root,
526
+ item.workdir,
527
+ state.working_directory,
528
+ {
529
+ **dict(state.workflow_values),
530
+ **self.task_values(state, plan),
531
+ **self.child_values(state, plan, item),
532
+ **self.item_values(state, plan, item),
533
+ },
534
+ )
535
+
536
+ def run(
537
+ self, state: ExecutionState, snapshot: PlanSnapshot, item: PlanItem
538
+ ) -> ExecutionState:
539
+ implementation = actions.get(item.kind)
540
+ if not isinstance(implementation, AutomaticAction):
541
+ raise StateError(
542
+ f"automatic action {item.kind!r} has no execution capability"
543
+ )
544
+ planned = item.payload_as(implementation.planned_type)
545
+ # Identity/configuration checks happen before durable start: rejection
546
+ # proves no external operation was initiated and must not create an
547
+ # unknown-outcome recovery boundary.
548
+ implementation.preflight(
549
+ planned, _PreflightContext.create(self, state, snapshot.plan, item)
550
+ )
551
+ state = self._start_item(state, snapshot, item)
552
+ dispatch = _Dispatch(self, state, snapshot, item)
553
+ # Only a failure the action itself reported is a known outcome worth
554
+ # another attempt; agent-supplied values would fail the same way again.
555
+ retries = 0 if item.provide else self.extensions.config.limits.auto_retries
556
+ while True:
557
+ result = implementation.execute(planned, _ExecutionContext.create(dispatch))
558
+ reported = isinstance(result, ActionResult)
559
+ if not reported:
560
+ result = ActionResult.failed(
561
+ "automatic action returned an invalid ActionResult"
562
+ )
563
+ error = action_result_error(result, item.outputs)
564
+ if error is not None:
565
+ result = ActionResult.failed(
566
+ f"automatic action returned an invalid result: {error}"
567
+ )
568
+ reported = False
569
+ if result.ok or not reported or retries == 0:
570
+ break
571
+ retries -= 1
572
+ dispatch.state = self._record_retry(
573
+ dispatch.state, snapshot, bounded(result.error)
574
+ )
575
+ # Services update dispatch.state at every durable boundary.
576
+ if not result.ok:
577
+ return self._fail_item(
578
+ dispatch.state,
579
+ snapshot,
580
+ item,
581
+ result.error,
582
+ result=result.output or None,
583
+ )
584
+ try:
585
+ task_metadata, project_metadata = validate_metadata_values(
586
+ {saved.name: (result.output.strip(),) for saved in item.save_metadata},
587
+ item.save_metadata,
588
+ )
589
+ updated_items = self._item_completion(
590
+ dispatch.state, snapshot, item, result
591
+ )
592
+ updated_metadata, project_publication = self.metadata_publisher.prepare(
593
+ dispatch.state.task_id,
594
+ dispatch.state,
595
+ item,
596
+ task_metadata,
597
+ project_metadata,
598
+ )
599
+ except StateError as error:
600
+ return self._fail_item(
601
+ dispatch.state, snapshot, item, str(error), result=result.output
602
+ )
603
+ records = list(dispatch.state.item_executions)
604
+ records[dispatch.state.cursor] = replace(
605
+ records[dispatch.state.cursor],
606
+ status="completed",
607
+ completed_at=self.now(),
608
+ result=bounded(result.output, STATE_OUTPUT_PREVIEW_LIMIT).strip() or None,
609
+ output_values=tuple(result.values.items()),
610
+ )
611
+ values = {**dict(dispatch.state.workflow_values), **result.values}
612
+ completed = replace(
613
+ dispatch.state,
614
+ status="pending",
615
+ active_item_id=None,
616
+ cursor=dispatch.state.cursor + 1,
617
+ item_executions=tuple(records),
618
+ updated_at=self.now(),
619
+ working_directory=(
620
+ relative_workspace(self.root, result.working_directory)
621
+ if result.working_directory is not None
622
+ else dispatch.state.working_directory
623
+ ),
624
+ workflow_values=tuple(values.items()),
625
+ pending_task_metadata=updated_metadata.values if updated_metadata else (),
626
+ pending_project_metadata=project_publication,
627
+ )
628
+ completed = self.project_state(completed, snapshot.plan)
629
+ if updated_items is None:
630
+ self.commit(completed, snapshot)
631
+ else:
632
+ # Item records and the completion are one atomic commit: a stage
633
+ # is never complete without its saved fields, and the item is
634
+ # never reported without a confirmed completion.
635
+ assert self.commit_items is not None
636
+ self.commit_items(completed, snapshot, updated_items)
637
+ completed, _ = self.metadata_publisher.reconcile(completed, snapshot)
638
+ return completed
639
+
640
+ def _item_completion(
641
+ self,
642
+ state: ExecutionState,
643
+ snapshot: PlanSnapshot,
644
+ item: PlanItem,
645
+ result: ActionResult,
646
+ ) -> tuple[WorkItem, ...] | None:
647
+ """The item records a successful per-item command leaves behind.
648
+
649
+ Declared ``item.field.*`` saves take the command's whole trimmed
650
+ output, the same value every declared field receives, never a
651
+ selection from it. They are valid only for exactly one concrete
652
+ current item. A report-phase stage marks its item reported only
653
+ with its last report-phase stage, after this completion commits.
654
+ Returns ``None`` when the command changes no item.
655
+ """
656
+ reports = reports_item_on_completion(snapshot.plan, state.cursor)
657
+ if not item.update_item and not reports:
658
+ return None
659
+ if item.item_id is None or self.commit_items is None:
660
+ raise StateError(
661
+ "item field saves need exactly one current item; an automatic "
662
+ "command cannot distribute one output among several items"
663
+ )
664
+ items = list(self.read_items(state))
665
+ index = next(
666
+ (i for i, entry in enumerate(items) if entry.id == item.item_id), None
667
+ )
668
+ if index is None:
669
+ raise StateError(
670
+ f"item {item.item_id!r} is gone; its saves cannot be recorded"
671
+ )
672
+ updated = items[index]
673
+ if item.update_item:
674
+ value = result.output.strip()
675
+ if not value:
676
+ raise StateError(
677
+ "missing required item field value(s): "
678
+ + ", ".join(
679
+ f"item.field.{field.name}" for field in item.update_item
680
+ )
681
+ + "; the command printed nothing"
682
+ )
683
+ updated = updated.with_fields(
684
+ {field.name: value for field in item.update_item}
685
+ )
686
+ if reports:
687
+ updated = replace(updated, reported=True)
688
+ if updated == items[index]:
689
+ return None
690
+ items[index] = updated
691
+ return tuple(items)
692
+
693
+ def validate_inputs(self, item: PlanItem, values: Mapping[str, str]) -> str | None:
694
+ """Ask an automatic item's action whether it would accept these inputs.
695
+
696
+ Runs before the completion that carries the values is saved; nothing
697
+ here may record or execute anything.
698
+ """
699
+ implementation = actions.get(item.kind)
700
+ if not isinstance(implementation, AutomaticAction):
701
+ return None
702
+ return implementation.validate_inputs(
703
+ item.payload_as(implementation.planned_type),
704
+ MappingProxyType(dict(values)),
705
+ _InputValidationContext(_HandlerLookup(self)),
706
+ )
707
+
708
+ def check_recovery(
709
+ self, state: ExecutionState, plan: WorkflowPlan, item: PlanItem
710
+ ) -> RecoveryCheckResult | None:
711
+ """Ask the action's checker about an interrupted item, if it has one."""
712
+ implementation = actions.get(item.kind)
713
+ if not isinstance(implementation, AutomaticAction):
714
+ return None
715
+ result = implementation.check_recovery(
716
+ item.payload_as(implementation.planned_type),
717
+ _RecoveryContext.create(self, state, item, plan),
718
+ )
719
+ if result is None:
720
+ return None
721
+ if not isinstance(result, RecoveryCheckResult):
722
+ return RecoveryCheckResult.unknown(
723
+ "automatic checker returned an invalid result"
724
+ )
725
+ if result.status == "succeeded" and result.result is not None:
726
+ if result.scope == "command_segment":
727
+ error = action_result_shape_error(result.result)
728
+ if error is not None:
729
+ return RecoveryCheckResult.unknown(
730
+ f"automatic checker returned an invalid result: {error}"
731
+ )
732
+ assert result.segment is not None # validated by the result contract
733
+ commands = state.item_executions[state.cursor].commands
734
+ if (
735
+ result.segment >= len(commands)
736
+ or commands[result.segment].status != "interrupted"
737
+ ):
738
+ return RecoveryCheckResult.unknown(
739
+ "checker attested a command segment that is not interrupted"
740
+ )
741
+ else:
742
+ error = action_result_error(result.result, item.outputs)
743
+ if error is not None:
744
+ return RecoveryCheckResult.unknown(
745
+ f"automatic checker returned an invalid result: {error}"
746
+ )
747
+ return result
748
+
749
+ def _start_item(
750
+ self, state: ExecutionState, snapshot: PlanSnapshot, item: PlanItem
751
+ ) -> ExecutionState:
752
+ records = list(state.item_executions)
753
+ record = records[state.cursor]
754
+ records[state.cursor] = replace(
755
+ record,
756
+ status="in_progress",
757
+ started_at=record.started_at or self.now(),
758
+ attempts=record.attempts + 1,
759
+ error=None,
760
+ operation_id=record.operation_id or operation_id_for(state, item),
761
+ )
762
+ started = replace(
763
+ state,
764
+ status="in_progress",
765
+ active_item_id=item.id,
766
+ item_executions=tuple(records),
767
+ updated_at=self.now(),
768
+ )
769
+ self.commit(started, snapshot)
770
+ return started
771
+
772
+ def _record_retry(
773
+ self, state: ExecutionState, snapshot: PlanSnapshot, error: str
774
+ ) -> ExecutionState:
775
+ """Record a failed attempt ww retries by itself, before the next one."""
776
+ records = list(state.item_executions)
777
+ record = records[state.cursor]
778
+ records[state.cursor] = replace(
779
+ record,
780
+ attempts=record.attempts + 1,
781
+ retry_errors=(*record.retry_errors, error),
782
+ )
783
+ retried = replace(state, item_executions=tuple(records), updated_at=self.now())
784
+ self.commit(retried, snapshot)
785
+ return retried
786
+
787
+ def _fail_item(
788
+ self,
789
+ state: ExecutionState,
790
+ snapshot: PlanSnapshot,
791
+ item: PlanItem,
792
+ message: str,
793
+ *,
794
+ result: str | None = None,
795
+ ) -> ExecutionState:
796
+ records = list(state.item_executions)
797
+ message = bounded(message)
798
+ records[state.cursor] = replace(
799
+ records[state.cursor],
800
+ status="failed",
801
+ error=message,
802
+ result=(
803
+ bounded(result, STATE_OUTPUT_PREVIEW_LIMIT)
804
+ if result is not None
805
+ else None
806
+ ),
807
+ )
808
+ failed = replace(
809
+ state,
810
+ status="failed",
811
+ active_item_id=item.id,
812
+ item_executions=tuple(records),
813
+ last_error=message,
814
+ updated_at=self.now(),
815
+ )
816
+ return self._commit_projected(failed, snapshot)
817
+
818
+ def _commit_projected(
819
+ self, state: ExecutionState, snapshot: PlanSnapshot
820
+ ) -> ExecutionState:
821
+ state = self.project_state(state, snapshot.plan)
822
+ self.commit(state, snapshot)
823
+ return state
824
+
825
+
826
+ def replace_command(
827
+ state: ExecutionState,
828
+ item_index: int,
829
+ command_index: int,
830
+ command: CommandExecution,
831
+ now: str,
832
+ ) -> ExecutionState:
833
+ records = list(state.item_executions)
834
+ commands = list(records[item_index].commands)
835
+ commands[command_index] = command
836
+ records[item_index] = replace(records[item_index], commands=tuple(commands))
837
+ return replace(state, item_executions=tuple(records), updated_at=now)
838
+
839
+
840
+ def bounded(value: str, limit: int = _OUTPUT_LIMIT) -> str:
841
+ return value if len(value) <= limit else value[:limit] + "\n[output truncated]"
842
+
843
+
844
+ def operation_id_for(state: ExecutionState, item: PlanItem) -> str:
845
+ return f"{state.task_id}:{operation_scope_for(state)}:{item.id}"
846
+
847
+
848
+ def extension_output_error(
849
+ values: object, declared_outputs: tuple[str, ...]
850
+ ) -> str | None:
851
+ if not isinstance(values, Mapping) or not all(
852
+ isinstance(key, str) and isinstance(value, str) for key, value in values.items()
853
+ ):
854
+ return "values must be a mapping of strings to strings"
855
+ undeclared = sorted(set(values) - set(declared_outputs))
856
+ missing = sorted(set(declared_outputs) - set(values))
857
+ if undeclared:
858
+ return "returned undeclared workflow value(s): " + ", ".join(undeclared)
859
+ if missing:
860
+ return "did not return declared workflow value(s): " + ", ".join(missing)
861
+ return None
862
+
863
+
864
+ def action_result_error(
865
+ result: ActionResult, declared_outputs: tuple[str, ...]
866
+ ) -> str | None:
867
+ error = action_result_shape_error(result)
868
+ if error is not None:
869
+ return error
870
+ return (
871
+ extension_output_error(result.values, declared_outputs) if result.ok else None
872
+ )
873
+
874
+
875
+ def action_result_shape_error(result: ActionResult) -> str | None:
876
+ """Validate fields valid for both full-action and segment attestations."""
877
+ if (
878
+ not isinstance(result.ok, bool)
879
+ or not isinstance(result.output, str)
880
+ or not isinstance(result.error, str)
881
+ ):
882
+ return "ok, output, and error must have their declared types"
883
+ if result.working_directory is not None and not isinstance(
884
+ result.working_directory, Path
885
+ ):
886
+ return "working_directory must be a Path or null"
887
+ return None