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/recovery.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Recovery of automatic items whose external outcome is unknown.
|
|
3
|
+
|
|
4
|
+
An ``in_progress`` record for an automatic item is written immediately before
|
|
5
|
+
the external command or extension runs. Finding one during a later command
|
|
6
|
+
means the process died mid-operation. Nothing here replays such an operation
|
|
7
|
+
implicitly: ``retry`` is an explicit decision, ``mark_succeeded`` records an
|
|
8
|
+
operator's attestation, and a recovery checker may settle the outcome when the
|
|
9
|
+
action provides one.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from ww.action_execution import (
|
|
17
|
+
STATE_OUTPUT_PREVIEW_LIMIT,
|
|
18
|
+
ActionExecutor,
|
|
19
|
+
bounded,
|
|
20
|
+
extension_output_error,
|
|
21
|
+
)
|
|
22
|
+
from ww.actions import (
|
|
23
|
+
Action,
|
|
24
|
+
ActionResult,
|
|
25
|
+
ActionTraits,
|
|
26
|
+
AutomaticAction,
|
|
27
|
+
PlannedAction,
|
|
28
|
+
actions,
|
|
29
|
+
)
|
|
30
|
+
from ww.completion_inputs import validate_values
|
|
31
|
+
from ww.control import replays_harmlessly
|
|
32
|
+
from ww.errors import StateError
|
|
33
|
+
from ww.execution_models import ExecutionState, PlanItemExecution, PlanSnapshot
|
|
34
|
+
from ww.instructions import Instruction
|
|
35
|
+
from ww.plan import PlanItem
|
|
36
|
+
from ww.run_coordination import RunLifecycle
|
|
37
|
+
from ww.storage_adapters import CommandOutputAddress, TaskStorageAdapter
|
|
38
|
+
from ww.task_ids import validate_task_id
|
|
39
|
+
from ww.transitions import (
|
|
40
|
+
Clock,
|
|
41
|
+
attest_cli_command,
|
|
42
|
+
attest_extension_item,
|
|
43
|
+
record_recovery_uncertainty,
|
|
44
|
+
resume_interrupted_item,
|
|
45
|
+
retry_failed_item,
|
|
46
|
+
)
|
|
47
|
+
from ww.workspace import relative_workspace
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class _Interrupted:
|
|
52
|
+
"""The interrupted automatic item a recovery command addresses."""
|
|
53
|
+
|
|
54
|
+
state: ExecutionState
|
|
55
|
+
snapshot: PlanSnapshot
|
|
56
|
+
item: PlanItem
|
|
57
|
+
record: PlanItemExecution
|
|
58
|
+
implementation: Action[object, object]
|
|
59
|
+
planned: object
|
|
60
|
+
traits: ActionTraits
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class _Decision:
|
|
65
|
+
"""Operator flags, refined by a recovery checker before they are applied."""
|
|
66
|
+
|
|
67
|
+
retry: bool
|
|
68
|
+
mark_succeeded: bool
|
|
69
|
+
output: str | None
|
|
70
|
+
working_directory: str | None
|
|
71
|
+
values: dict[str, str]
|
|
72
|
+
checker_result: ActionResult | None = None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def inspecting(self) -> bool:
|
|
76
|
+
return not self.retry and not self.mark_succeeded
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RecoveryCoordinator:
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
tasks: TaskStorageAdapter,
|
|
83
|
+
executor: ActionExecutor,
|
|
84
|
+
lifecycle: RunLifecycle,
|
|
85
|
+
now: Clock,
|
|
86
|
+
) -> None:
|
|
87
|
+
self.tasks = tasks
|
|
88
|
+
self.executor = executor
|
|
89
|
+
self.lifecycle = lifecycle
|
|
90
|
+
self.now = now
|
|
91
|
+
|
|
92
|
+
def recover(
|
|
93
|
+
self,
|
|
94
|
+
task_id: str,
|
|
95
|
+
*,
|
|
96
|
+
retry: bool = False,
|
|
97
|
+
mark_succeeded: bool = False,
|
|
98
|
+
output: str | None = None,
|
|
99
|
+
working_directory: str | None = None,
|
|
100
|
+
variables: tuple[tuple[str, str], ...] = (),
|
|
101
|
+
) -> Instruction:
|
|
102
|
+
"""Inspect or explicitly resolve an interrupted automatic item.
|
|
103
|
+
|
|
104
|
+
Without flags this only reports the recovery boundary. ``retry``
|
|
105
|
+
knowingly replays the unfinished operation; ``mark_succeeded`` records
|
|
106
|
+
that an operator verified its external effect and advances the plan
|
|
107
|
+
without running it again.
|
|
108
|
+
"""
|
|
109
|
+
if retry and mark_succeeded:
|
|
110
|
+
raise StateError("choose only one of --retry or --mark-succeeded")
|
|
111
|
+
if (
|
|
112
|
+
output is not None or working_directory is not None or variables
|
|
113
|
+
) and not mark_succeeded:
|
|
114
|
+
raise StateError(
|
|
115
|
+
"--output, --working-directory and --variable require --mark-succeeded"
|
|
116
|
+
)
|
|
117
|
+
validate_task_id(task_id)
|
|
118
|
+
with self.tasks.lock_task(task_id):
|
|
119
|
+
state, snapshot = self.lifecycle.load(task_id)
|
|
120
|
+
state = self._interrupt_stale_operation(state, snapshot)
|
|
121
|
+
if state.status == "failed" and retry:
|
|
122
|
+
# A failed handler has a known outcome and uses the normal
|
|
123
|
+
# retry transition rather than interrupted-operation recovery.
|
|
124
|
+
return self.lifecycle.resume(
|
|
125
|
+
retry_failed_item(state, snapshot.plan, self.now), snapshot
|
|
126
|
+
)
|
|
127
|
+
if state.status != "interrupted":
|
|
128
|
+
if not retry and not mark_succeeded:
|
|
129
|
+
# A concurrent invocation may have advanced the operation
|
|
130
|
+
# while this command waited for the lock.
|
|
131
|
+
return self.lifecycle.render(state, snapshot)
|
|
132
|
+
raise StateError("task has no interrupted automatic handler")
|
|
133
|
+
target = self._interrupted_target(state, snapshot)
|
|
134
|
+
decision = _Decision(
|
|
135
|
+
retry,
|
|
136
|
+
mark_succeeded,
|
|
137
|
+
output,
|
|
138
|
+
working_directory,
|
|
139
|
+
validate_values(variables),
|
|
140
|
+
)
|
|
141
|
+
self._check_attestation_support(target, decision)
|
|
142
|
+
if decision.inspecting:
|
|
143
|
+
replayed = self.replay_if_idempotent(state, snapshot)
|
|
144
|
+
if replayed is not None:
|
|
145
|
+
return self.lifecycle.resume(replayed, snapshot)
|
|
146
|
+
if self._command_boundaries_known(target):
|
|
147
|
+
# Every durable command boundary is known: resume pending
|
|
148
|
+
# segments without asking the operator to attest anything.
|
|
149
|
+
return self.lifecycle.resume(
|
|
150
|
+
resume_interrupted_item(
|
|
151
|
+
state,
|
|
152
|
+
snapshot.plan,
|
|
153
|
+
target.item,
|
|
154
|
+
now=self.now,
|
|
155
|
+
),
|
|
156
|
+
snapshot,
|
|
157
|
+
)
|
|
158
|
+
if isinstance(target.implementation, AutomaticAction):
|
|
159
|
+
settled = self._consult_checker(target, decision)
|
|
160
|
+
if settled is not None:
|
|
161
|
+
return settled
|
|
162
|
+
if decision.inspecting:
|
|
163
|
+
# No general-purpose attestation applies; the unknown outcome
|
|
164
|
+
# stays visible until the operator makes an explicit choice.
|
|
165
|
+
return self.lifecycle.render(state, snapshot)
|
|
166
|
+
if decision.mark_succeeded:
|
|
167
|
+
state = self._attest(target, decision)
|
|
168
|
+
else:
|
|
169
|
+
state = resume_interrupted_item(
|
|
170
|
+
state,
|
|
171
|
+
snapshot.plan,
|
|
172
|
+
target.item,
|
|
173
|
+
now=self.now,
|
|
174
|
+
)
|
|
175
|
+
return self.lifecycle.resume(state, snapshot)
|
|
176
|
+
|
|
177
|
+
def replay_if_idempotent(
|
|
178
|
+
self, state: ExecutionState, snapshot: PlanSnapshot
|
|
179
|
+
) -> ExecutionState | None:
|
|
180
|
+
"""Return an interrupted item to pending when its replay is declared harmless.
|
|
181
|
+
|
|
182
|
+
``idempotent: true`` on a command handler is the author's statement
|
|
183
|
+
that running the sequence again cannot do damage, so the unknown
|
|
184
|
+
outcome needs no operator: the interrupted and unrun segments run
|
|
185
|
+
again under the same operation identity. Anything else returns
|
|
186
|
+
``None`` and stays at the recovery boundary.
|
|
187
|
+
"""
|
|
188
|
+
if state.status != "interrupted" or state.cursor >= len(snapshot.plan.items):
|
|
189
|
+
return None
|
|
190
|
+
item = snapshot.plan.items[state.cursor]
|
|
191
|
+
record = state.item_executions[state.cursor]
|
|
192
|
+
if record.status != "interrupted" or not replays_harmlessly(item):
|
|
193
|
+
return None
|
|
194
|
+
return resume_interrupted_item(
|
|
195
|
+
state,
|
|
196
|
+
snapshot.plan,
|
|
197
|
+
item,
|
|
198
|
+
now=self.now,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _interrupt_stale_operation(
|
|
202
|
+
self, state: ExecutionState, snapshot: PlanSnapshot
|
|
203
|
+
) -> ExecutionState:
|
|
204
|
+
"""Mark an automatic item still recorded as running by a dead process."""
|
|
205
|
+
if (
|
|
206
|
+
state.status == "interrupted"
|
|
207
|
+
or not state.active_item_id
|
|
208
|
+
or state.cursor >= len(snapshot.plan.items)
|
|
209
|
+
):
|
|
210
|
+
return state
|
|
211
|
+
item = snapshot.plan.items[state.cursor]
|
|
212
|
+
if (
|
|
213
|
+
item.owner == "ww"
|
|
214
|
+
and item.execution == "automatic"
|
|
215
|
+
and state.item_executions[state.cursor].status == "in_progress"
|
|
216
|
+
):
|
|
217
|
+
return self.lifecycle.mark_interrupted(state, snapshot)
|
|
218
|
+
return state
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _interrupted_target(
|
|
222
|
+
state: ExecutionState, snapshot: PlanSnapshot
|
|
223
|
+
) -> _Interrupted:
|
|
224
|
+
if state.cursor >= len(snapshot.plan.items):
|
|
225
|
+
raise StateError("interrupted task has no current item to recover")
|
|
226
|
+
item = snapshot.plan.items[state.cursor]
|
|
227
|
+
record = state.item_executions[state.cursor]
|
|
228
|
+
if item.id != state.active_item_id or record.status != "interrupted":
|
|
229
|
+
raise StateError("interrupted task has an inconsistent active item")
|
|
230
|
+
if not isinstance(item.operation, PlannedAction):
|
|
231
|
+
raise StateError(
|
|
232
|
+
"interrupted core operation cannot be recovered as an action"
|
|
233
|
+
)
|
|
234
|
+
implementation = actions.get(item.kind)
|
|
235
|
+
planned = item.payload_as(implementation.planned_type)
|
|
236
|
+
return _Interrupted(
|
|
237
|
+
state,
|
|
238
|
+
snapshot,
|
|
239
|
+
item,
|
|
240
|
+
record,
|
|
241
|
+
implementation,
|
|
242
|
+
planned,
|
|
243
|
+
implementation.traits(planned),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _check_attestation_support(target: _Interrupted, decision: _Decision) -> None:
|
|
248
|
+
attestable = target.traits.manual_attestation
|
|
249
|
+
if decision.working_directory is not None and "workspace" not in attestable:
|
|
250
|
+
raise StateError(
|
|
251
|
+
"--working-directory is not supported for this action's manual "
|
|
252
|
+
"recovery attestation"
|
|
253
|
+
)
|
|
254
|
+
if decision.values and "values" not in attestable:
|
|
255
|
+
raise StateError(
|
|
256
|
+
"--variable is not supported for this action's manual recovery "
|
|
257
|
+
"attestation"
|
|
258
|
+
)
|
|
259
|
+
if (
|
|
260
|
+
decision.mark_succeeded
|
|
261
|
+
and target.traits.attests_output
|
|
262
|
+
and decision.output is None
|
|
263
|
+
):
|
|
264
|
+
raise StateError(
|
|
265
|
+
"--output is required to attest a CLI command with an assertion"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _command_boundaries_known(target: _Interrupted) -> bool:
|
|
270
|
+
return (
|
|
271
|
+
target.traits.command_segments is not None
|
|
272
|
+
and bool(target.record.commands)
|
|
273
|
+
and not any(
|
|
274
|
+
command.status == "interrupted" for command in target.record.commands
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def _consult_checker(
|
|
279
|
+
self, target: _Interrupted, decision: _Decision
|
|
280
|
+
) -> Instruction | None:
|
|
281
|
+
"""Let the action's recovery checker settle or refine the decision.
|
|
282
|
+
|
|
283
|
+
Returns an instruction when the checker fully resolved the item (a
|
|
284
|
+
segment was attested, or the outcome stays uncertain); otherwise it
|
|
285
|
+
updates ``decision`` and returns ``None`` so the caller applies it.
|
|
286
|
+
"""
|
|
287
|
+
checked = self.executor.check_recovery(
|
|
288
|
+
target.state, target.snapshot.plan, target.item
|
|
289
|
+
)
|
|
290
|
+
if checked is None:
|
|
291
|
+
return None
|
|
292
|
+
if checked.status == "succeeded" and checked.result is not None:
|
|
293
|
+
decision.checker_result = checked.result
|
|
294
|
+
if checked.scope == "action":
|
|
295
|
+
decision.mark_succeeded = True
|
|
296
|
+
if (
|
|
297
|
+
decision.working_directory is None
|
|
298
|
+
and checked.result.working_directory is not None
|
|
299
|
+
):
|
|
300
|
+
decision.working_directory = str(checked.result.working_directory)
|
|
301
|
+
return None
|
|
302
|
+
if checked.segment is None:
|
|
303
|
+
raise StateError("checker attested a command segment without naming it")
|
|
304
|
+
state = self._attest_command_segment(
|
|
305
|
+
target, checked.segment, checked.result.output
|
|
306
|
+
)
|
|
307
|
+
return self.lifecycle.resume(state, target.snapshot)
|
|
308
|
+
if checked.status == "not_succeeded":
|
|
309
|
+
decision.retry = True
|
|
310
|
+
return None
|
|
311
|
+
error = checked.error or (
|
|
312
|
+
"extension checker could not establish the operation outcome"
|
|
313
|
+
)
|
|
314
|
+
state = record_recovery_uncertainty(target.state, error, self.now)
|
|
315
|
+
self.lifecycle.commit(state, target.snapshot)
|
|
316
|
+
return self.lifecycle.render(state, target.snapshot)
|
|
317
|
+
|
|
318
|
+
def _attest(self, target: _Interrupted, decision: _Decision) -> ExecutionState:
|
|
319
|
+
"""Apply an operator's or checker's success attestation."""
|
|
320
|
+
result = decision.checker_result
|
|
321
|
+
if target.traits.command_segments is not None and result is None:
|
|
322
|
+
# An operator attests one command boundary; only output applies.
|
|
323
|
+
if decision.values or decision.working_directory is not None:
|
|
324
|
+
raise StateError(
|
|
325
|
+
"a command-segment attestation accepts output only; "
|
|
326
|
+
"values and working directory require an action-level "
|
|
327
|
+
"manual attestation"
|
|
328
|
+
)
|
|
329
|
+
interrupted_index = next(
|
|
330
|
+
(
|
|
331
|
+
index
|
|
332
|
+
for index, command in enumerate(target.record.commands)
|
|
333
|
+
if command.status == "interrupted"
|
|
334
|
+
),
|
|
335
|
+
None,
|
|
336
|
+
)
|
|
337
|
+
if interrupted_index is None:
|
|
338
|
+
raise StateError("interrupted CLI item has no command segment")
|
|
339
|
+
return self._attest_command_segment(
|
|
340
|
+
target, interrupted_index, decision.output or ""
|
|
341
|
+
)
|
|
342
|
+
# A checker that declared action scope attests the complete action, so
|
|
343
|
+
# its output, values, and directory apply through the action-result path.
|
|
344
|
+
result_values = dict(result.values) if result is not None else decision.values
|
|
345
|
+
result_error = extension_output_error(result_values, target.item.outputs)
|
|
346
|
+
if result_error is not None:
|
|
347
|
+
raise StateError("cannot mark action succeeded: " + result_error)
|
|
348
|
+
return attest_extension_item(
|
|
349
|
+
target.state,
|
|
350
|
+
target.snapshot.plan,
|
|
351
|
+
result_values,
|
|
352
|
+
(
|
|
353
|
+
decision.output
|
|
354
|
+
if decision.output is not None
|
|
355
|
+
else target.record.result
|
|
356
|
+
or (result.output if result else "")
|
|
357
|
+
or "marked succeeded after interruption"
|
|
358
|
+
),
|
|
359
|
+
(
|
|
360
|
+
relative_workspace(self.executor.root, decision.working_directory)
|
|
361
|
+
if decision.working_directory is not None
|
|
362
|
+
else None
|
|
363
|
+
),
|
|
364
|
+
self.now,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def _attest_command_segment(
|
|
368
|
+
self, target: _Interrupted, segment: int, output: str
|
|
369
|
+
) -> ExecutionState:
|
|
370
|
+
"""Persist a segment attestation, then let normal command resumption run."""
|
|
371
|
+
record = target.record
|
|
372
|
+
if not 0 <= segment < len(record.commands):
|
|
373
|
+
raise StateError("checker identified an invalid command segment")
|
|
374
|
+
command = record.commands[segment]
|
|
375
|
+
state = target.state
|
|
376
|
+
stdout_ref = (
|
|
377
|
+
self.tasks.write_command_output(
|
|
378
|
+
CommandOutputAddress(
|
|
379
|
+
state.task_id,
|
|
380
|
+
state.run_id or state.workflow,
|
|
381
|
+
target.item.id,
|
|
382
|
+
command.operation_id
|
|
383
|
+
or f"{record.operation_id}:command:{segment + 1}",
|
|
384
|
+
max(command.attempts, 1),
|
|
385
|
+
segment + 1,
|
|
386
|
+
"stdout",
|
|
387
|
+
),
|
|
388
|
+
output,
|
|
389
|
+
)
|
|
390
|
+
if output
|
|
391
|
+
else None
|
|
392
|
+
)
|
|
393
|
+
return attest_cli_command(
|
|
394
|
+
state,
|
|
395
|
+
target.snapshot.plan,
|
|
396
|
+
target.item,
|
|
397
|
+
segment,
|
|
398
|
+
bounded(output, STATE_OUTPUT_PREVIEW_LIMIT),
|
|
399
|
+
stdout_ref,
|
|
400
|
+
self.now,
|
|
401
|
+
)
|