ww-agentic-workflows 1.0.0.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/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
|