ww-agentic-workflows 1.0.0.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Typed persistence ports used by the plan executor."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import re
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from contextlib import AbstractContextManager, nullcontext
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from ww.amendments import Amendment
|
|
14
|
+
from ww.children import ChildTask
|
|
15
|
+
from ww.contracts import RunStatus, run_is_open
|
|
16
|
+
from ww.errors import StateError
|
|
17
|
+
from ww.execution_models import (
|
|
18
|
+
ExecutionState,
|
|
19
|
+
PlanSnapshot,
|
|
20
|
+
TaskRunAggregate,
|
|
21
|
+
WorkflowRunSummary,
|
|
22
|
+
)
|
|
23
|
+
from ww.items import WorkItem
|
|
24
|
+
from ww.variables import METADATA_PREFIX, PROJECT_METADATA_PREFIX
|
|
25
|
+
|
|
26
|
+
# A dotted metadata key, e.g. "github.owner"; "github..owner" does not match.
|
|
27
|
+
_METADATA_KEY = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*)*")
|
|
28
|
+
# One metadata field name, e.g. "owner" or "pr-url".
|
|
29
|
+
_METADATA_FIELD = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")
|
|
30
|
+
# A leaf is a string, or a list of strings for a key declared ``append: true``.
|
|
31
|
+
MetadataLeaf = str | tuple[str, ...]
|
|
32
|
+
MetadataValues = tuple[tuple[str, MetadataLeaf], ...]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_metadata_leaf(value: object) -> bool:
|
|
36
|
+
return isinstance(value, str) or (
|
|
37
|
+
isinstance(value, tuple) and all(isinstance(item, str) for item in value)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def append_metadata_leaf(
|
|
42
|
+
existing: MetadataLeaf | None, values: tuple[str, ...]
|
|
43
|
+
) -> tuple[str, ...]:
|
|
44
|
+
"""Append values to a list leaf, keeping order and dropping repeats."""
|
|
45
|
+
if existing is None:
|
|
46
|
+
current: tuple[str, ...] = ()
|
|
47
|
+
elif isinstance(existing, str):
|
|
48
|
+
current = (existing,)
|
|
49
|
+
else:
|
|
50
|
+
current = existing
|
|
51
|
+
return tuple(dict.fromkeys((*current, *values)))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def metadata_leaf_text(value: MetadataLeaf) -> str:
|
|
55
|
+
"""The interpolated form of a leaf: a list reads as ``a, b``."""
|
|
56
|
+
return value if isinstance(value, str) else ", ".join(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_metadata_values(values: MetadataValues, label: str) -> None:
|
|
60
|
+
"""Reject non-string leaves and dotted keys that collide with each other."""
|
|
61
|
+
keys: set[str] = set()
|
|
62
|
+
for key, value in values:
|
|
63
|
+
if (
|
|
64
|
+
not isinstance(key, str)
|
|
65
|
+
or not is_metadata_leaf(value)
|
|
66
|
+
or not _METADATA_KEY.fullmatch(key)
|
|
67
|
+
):
|
|
68
|
+
raise ValueError(f"invalid {label} entry: {key!r}")
|
|
69
|
+
if key in keys or any(
|
|
70
|
+
key.startswith(f"{other}.") or other.startswith(f"{key}.") for other in keys
|
|
71
|
+
):
|
|
72
|
+
raise ValueError(f"conflicting {label} key: {key!r}")
|
|
73
|
+
keys.add(key)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def nest_metadata(values: MetadataValues) -> dict[str, object]:
|
|
77
|
+
"""Project dotted leaf keys into a nested JSON-ready mapping."""
|
|
78
|
+
result: dict[str, object] = {}
|
|
79
|
+
for key, value in values:
|
|
80
|
+
target = result
|
|
81
|
+
*parents, leaf = key.split(".")
|
|
82
|
+
for segment in parents:
|
|
83
|
+
child = target.setdefault(segment, {})
|
|
84
|
+
if not isinstance(child, dict): # protected by validation
|
|
85
|
+
raise ValueError(f"conflicting metadata key: {key!r}")
|
|
86
|
+
target = child
|
|
87
|
+
target[leaf] = list(value) if isinstance(value, tuple) else value
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def flatten_metadata(mapping: object, label: str) -> MetadataValues:
|
|
92
|
+
"""Read a nested metadata mapping back into dotted leaf values."""
|
|
93
|
+
if not isinstance(mapping, dict):
|
|
94
|
+
raise ValueError(f"{label} value must be a mapping")
|
|
95
|
+
values: list[tuple[str, MetadataLeaf]] = []
|
|
96
|
+
|
|
97
|
+
def collect(node: dict[object, object], prefix: str) -> None:
|
|
98
|
+
for field, value in node.items():
|
|
99
|
+
if not isinstance(field, str) or not _METADATA_FIELD.fullmatch(field):
|
|
100
|
+
raise ValueError(f"{label} field names must be normalized names")
|
|
101
|
+
key = f"{prefix}.{field}" if prefix else field
|
|
102
|
+
if isinstance(value, dict):
|
|
103
|
+
if not value:
|
|
104
|
+
raise ValueError(f"{label} mappings cannot be empty")
|
|
105
|
+
collect(value, key)
|
|
106
|
+
elif isinstance(value, str):
|
|
107
|
+
values.append((key, value))
|
|
108
|
+
elif isinstance(value, list) and all(isinstance(v, str) for v in value):
|
|
109
|
+
values.append((key, tuple(value)))
|
|
110
|
+
else:
|
|
111
|
+
raise ValueError(f"{label} field values must be strings")
|
|
112
|
+
|
|
113
|
+
collect(mapping, "")
|
|
114
|
+
return tuple(values)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class TaskMetadata:
|
|
119
|
+
"""Storage-adapter-owned task metadata independent of workflow progress."""
|
|
120
|
+
|
|
121
|
+
task_id: str
|
|
122
|
+
values: MetadataValues = ()
|
|
123
|
+
|
|
124
|
+
def __post_init__(self) -> None:
|
|
125
|
+
if not self.task_id:
|
|
126
|
+
raise ValueError("task metadata requires a task ID")
|
|
127
|
+
validate_metadata_values(self.values, "task metadata")
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def interpolation_values(self) -> dict[str, str]:
|
|
131
|
+
return {
|
|
132
|
+
f"{METADATA_PREFIX}{key}": metadata_leaf_text(value)
|
|
133
|
+
for key, value in self.values
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def to_dict(self) -> dict[str, object]:
|
|
137
|
+
return nest_metadata(self.values)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class ProjectMetadata:
|
|
142
|
+
"""Storage-adapter-owned metadata shared by every task in one project."""
|
|
143
|
+
|
|
144
|
+
values: MetadataValues = ()
|
|
145
|
+
|
|
146
|
+
def __post_init__(self) -> None:
|
|
147
|
+
validate_metadata_values(self.values, "project metadata")
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def interpolation_values(self) -> dict[str, str]:
|
|
151
|
+
return {
|
|
152
|
+
f"{PROJECT_METADATA_PREFIX}{key}": metadata_leaf_text(value)
|
|
153
|
+
for key, value in self.values
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def to_dict(self) -> dict[str, object]:
|
|
157
|
+
return nest_metadata(self.values)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class ArtifactAddress:
|
|
162
|
+
"""Where one logical artifact lives beneath its run.
|
|
163
|
+
|
|
164
|
+
Rewriting the same address replaces the content and keeps the reference.
|
|
165
|
+
Loop iterations are part of the address so repeated loop-body executions
|
|
166
|
+
do not overwrite earlier results.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
task_id: str
|
|
170
|
+
workflow: str
|
|
171
|
+
step_path: str
|
|
172
|
+
position: int
|
|
173
|
+
name: str
|
|
174
|
+
phase: str
|
|
175
|
+
run_id: str | None = None
|
|
176
|
+
step_ordinals: tuple[int, ...] = ()
|
|
177
|
+
loop_iterations: tuple[tuple[str, int], ...] = ()
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def run_namespace(self) -> str:
|
|
181
|
+
return self.run_id or f"01-{self.workflow}"
|
|
182
|
+
|
|
183
|
+
def segments(self) -> tuple[str, ...]:
|
|
184
|
+
"""Return the path parts beneath the run's ``steps`` directory."""
|
|
185
|
+
names = self.step_path.split("/")
|
|
186
|
+
ordinals = self.step_ordinals or tuple(range(1, len(names) + 1))
|
|
187
|
+
if len(ordinals) != len(names):
|
|
188
|
+
raise StateError("step ordinals must match the step path")
|
|
189
|
+
iterations = dict(self.loop_iterations)
|
|
190
|
+
parts: list[str] = []
|
|
191
|
+
for depth, (ordinal, segment) in enumerate(
|
|
192
|
+
zip(ordinals, names, strict=True), start=1
|
|
193
|
+
):
|
|
194
|
+
parts.append(f"{ordinal:02d}-{segment}")
|
|
195
|
+
loop_path = "/".join(names[:depth])
|
|
196
|
+
if loop_path in iterations:
|
|
197
|
+
parts.append(f"iteration-{iterations[loop_path]:02d}")
|
|
198
|
+
if self.phase == "step":
|
|
199
|
+
return (*parts[:-1], f"{parts[-1]}.md")
|
|
200
|
+
return (*parts, ".hooks", self.phase, f"{self.position:02d}-{self.name}.md")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True)
|
|
204
|
+
class CommandOutputAddress:
|
|
205
|
+
"""Immutable evidence address for one command stream of one attempt.
|
|
206
|
+
|
|
207
|
+
Later loop iterations and retries never replace a stream returned for an
|
|
208
|
+
earlier operation attempt, so ``attempt`` is part of the address.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
task_id: str
|
|
212
|
+
run_id: str
|
|
213
|
+
item_id: str
|
|
214
|
+
operation_id: str
|
|
215
|
+
attempt: int
|
|
216
|
+
command_index: int
|
|
217
|
+
stream: str
|
|
218
|
+
|
|
219
|
+
def __post_init__(self) -> None:
|
|
220
|
+
if self.stream not in {"stdout", "stderr"}:
|
|
221
|
+
raise StateError(f"invalid command output stream: {self.stream!r}")
|
|
222
|
+
if self.attempt < 1:
|
|
223
|
+
raise StateError("command output attempt must be positive")
|
|
224
|
+
|
|
225
|
+
def segments(self) -> tuple[str, ...]:
|
|
226
|
+
"""Return the path parts beneath the run's ``command-output`` directory."""
|
|
227
|
+
return (
|
|
228
|
+
_short_digest(self.item_id),
|
|
229
|
+
_short_digest(self.operation_id),
|
|
230
|
+
f"attempt-{self.attempt:02d}",
|
|
231
|
+
f"{self.command_index:02d}.{self.stream}",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _short_digest(value: str) -> str:
|
|
236
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class TaskRunStorage(ABC):
|
|
240
|
+
"""Authoritative storage for task runs and their transition data.
|
|
241
|
+
|
|
242
|
+
Missing tasks have revision zero and return an empty run tuple. Malformed
|
|
243
|
+
records and compare-and-swap conflicts must raise ``StateError``. A commit
|
|
244
|
+
publishes the complete run tuple and handoff atomically; no earlier value
|
|
245
|
+
may become unreadable if a commit fails.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
def lock_task(self, task_id: str) -> AbstractContextManager[None]:
|
|
249
|
+
"""Serialize a complete read/modify/commit transition for one task.
|
|
250
|
+
|
|
251
|
+
Shared storage adapters must override this method. Process-local
|
|
252
|
+
storage adapters may use the default no-op lock.
|
|
253
|
+
"""
|
|
254
|
+
del task_id
|
|
255
|
+
return nullcontext()
|
|
256
|
+
|
|
257
|
+
@abstractmethod
|
|
258
|
+
def read_task_record(
|
|
259
|
+
self, task_id: str
|
|
260
|
+
) -> tuple[tuple[TaskRunAggregate, ...], str | None, int]:
|
|
261
|
+
"""Return all runs, the handoff, and the CAS revision in one read.
|
|
262
|
+
|
|
263
|
+
A missing task is ``((), None, 0)``.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
@abstractmethod
|
|
267
|
+
def task_written_at(self, task_id: str) -> datetime | None:
|
|
268
|
+
"""When the task's runs were last committed, or ``None`` without any.
|
|
269
|
+
|
|
270
|
+
Scans that only care about recent tasks call this first, so it must
|
|
271
|
+
not read or decode the record itself.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def read_task_aggregate(
|
|
275
|
+
self, task_id: str
|
|
276
|
+
) -> tuple[tuple[TaskRunAggregate, ...], str | None]:
|
|
277
|
+
runs, handoff, _ = self.read_task_record(task_id)
|
|
278
|
+
return runs, handoff
|
|
279
|
+
|
|
280
|
+
def task_aggregate_revision(self, task_id: str) -> int:
|
|
281
|
+
return self.read_task_record(task_id)[2]
|
|
282
|
+
|
|
283
|
+
@abstractmethod
|
|
284
|
+
def commit_task_aggregate(
|
|
285
|
+
self,
|
|
286
|
+
task_id: str,
|
|
287
|
+
runs: tuple[TaskRunAggregate, ...],
|
|
288
|
+
handoff: str | None = None,
|
|
289
|
+
expected_revision: int | None = None,
|
|
290
|
+
) -> int:
|
|
291
|
+
"""Atomically replace a task aggregate and return its new revision.
|
|
292
|
+
|
|
293
|
+
The caller holds :meth:`lock_task` across the complete transition.
|
|
294
|
+
``expected_revision`` is required when replacing an existing record.
|
|
295
|
+
The implementation must not expose a partial publication.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
def execution_runs(self, task_id: str) -> tuple[WorkflowRunSummary, ...]:
|
|
299
|
+
"""Return run summaries in creation order; missing tasks return empty."""
|
|
300
|
+
return self.summarize_runs(self.read_task_aggregate(task_id)[0])
|
|
301
|
+
|
|
302
|
+
@classmethod
|
|
303
|
+
def summarize_runs(
|
|
304
|
+
cls, runs: tuple[TaskRunAggregate, ...]
|
|
305
|
+
) -> tuple[WorkflowRunSummary, ...]:
|
|
306
|
+
"""Summarize already-read runs without another storage read."""
|
|
307
|
+
return tuple(
|
|
308
|
+
WorkflowRunSummary(
|
|
309
|
+
run_id=run.run_id,
|
|
310
|
+
workflow=run.workflow,
|
|
311
|
+
status=cls._run_status(run),
|
|
312
|
+
summary=dict(run.state.workflow_values).get("summary")
|
|
313
|
+
if run.state.status == "completed"
|
|
314
|
+
else None,
|
|
315
|
+
)
|
|
316
|
+
for run in runs
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def _run_status(run: TaskRunAggregate) -> RunStatus:
|
|
321
|
+
if run.state.status in {"completed", "failed"}:
|
|
322
|
+
return run.state.status
|
|
323
|
+
if any(record.status != "pending" for record in run.state.item_executions):
|
|
324
|
+
return "in_progress"
|
|
325
|
+
return "pending"
|
|
326
|
+
|
|
327
|
+
@staticmethod
|
|
328
|
+
def _active_run(runs: tuple[TaskRunAggregate, ...]) -> TaskRunAggregate | None:
|
|
329
|
+
return next(
|
|
330
|
+
(run for run in reversed(runs) if run_is_open(run.state.status)), None
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def active_execution_run(self, task_id: str) -> str | None:
|
|
334
|
+
"""Return the sole non-completed run ID, or ``None``."""
|
|
335
|
+
active = self._active_run(self.read_task_aggregate(task_id)[0])
|
|
336
|
+
return active.run_id if active is not None else None
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def run_id_for(number: int, workflow: str) -> str:
|
|
340
|
+
"""Format the run namespace for the ``number``-th run of a task."""
|
|
341
|
+
return f"{number:02d}-{workflow}"
|
|
342
|
+
|
|
343
|
+
def next_execution_run_id(self, task_id: str, workflow: str) -> str:
|
|
344
|
+
"""Return the next run namespace without publishing a partial run."""
|
|
345
|
+
runs, _ = self.read_task_aggregate(task_id)
|
|
346
|
+
if self._active_run(runs) is not None:
|
|
347
|
+
raise StateError(f"task {task_id!r} already has an active workflow run")
|
|
348
|
+
return self.run_id_for(len(runs) + 1, workflow)
|
|
349
|
+
|
|
350
|
+
def _run(self, task_id: str, run_id: str | None = None) -> TaskRunAggregate | None:
|
|
351
|
+
"""Return the named run, else the active run, else the latest run."""
|
|
352
|
+
runs, _ = self.read_task_aggregate(task_id)
|
|
353
|
+
if run_id is not None:
|
|
354
|
+
return next((run for run in runs if run.run_id == run_id), None)
|
|
355
|
+
active = self._active_run(runs)
|
|
356
|
+
if active is not None:
|
|
357
|
+
return active
|
|
358
|
+
return runs[-1] if runs else None
|
|
359
|
+
|
|
360
|
+
def read_execution_state(
|
|
361
|
+
self, task_id: str, run_id: str | None = None
|
|
362
|
+
) -> ExecutionState | None:
|
|
363
|
+
"""Return one run's state, or ``None`` when it is missing."""
|
|
364
|
+
run = self._run(task_id, run_id)
|
|
365
|
+
return run.state if run is not None else None
|
|
366
|
+
|
|
367
|
+
def read_plan_snapshot(
|
|
368
|
+
self, task_id: str, run_id: str | None = None
|
|
369
|
+
) -> PlanSnapshot | None:
|
|
370
|
+
"""Return one run's plan, or ``None`` when it is missing."""
|
|
371
|
+
run = self._run(task_id, run_id)
|
|
372
|
+
return run.snapshot if run is not None else None
|
|
373
|
+
|
|
374
|
+
def read_items(
|
|
375
|
+
self, task_id: str, run_id: str | None = None
|
|
376
|
+
) -> tuple[WorkItem, ...]:
|
|
377
|
+
"""Return one run's work items; missing runs have no items."""
|
|
378
|
+
run = self._run(task_id, run_id)
|
|
379
|
+
return run.items if run is not None else ()
|
|
380
|
+
|
|
381
|
+
def read_children(
|
|
382
|
+
self, task_id: str, run_id: str | None = None
|
|
383
|
+
) -> tuple[ChildTask, ...]:
|
|
384
|
+
"""Return one run's children; missing runs have no children."""
|
|
385
|
+
run = self._run(task_id, run_id)
|
|
386
|
+
return run.children if run is not None else ()
|
|
387
|
+
|
|
388
|
+
def read_handoff(self, task_id: str) -> str | None:
|
|
389
|
+
"""Return the task handoff, or ``None`` when absent."""
|
|
390
|
+
return self.read_task_aggregate(task_id)[1]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class TaskArtifactStorage(ABC):
|
|
394
|
+
"""Storage for stable references to agent-produced artifacts."""
|
|
395
|
+
|
|
396
|
+
@abstractmethod
|
|
397
|
+
def write_execution_artifact(self, address: ArtifactAddress, content: str) -> str:
|
|
398
|
+
"""Store content and return its stable caller-facing reference.
|
|
399
|
+
|
|
400
|
+
Storage errors must be raised to the caller.
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
@abstractmethod
|
|
404
|
+
def read_execution_artifact(self, reference: str) -> str:
|
|
405
|
+
"""Read an artifact previously returned by ``write_execution_artifact``."""
|
|
406
|
+
|
|
407
|
+
@abstractmethod
|
|
408
|
+
def write_command_output(self, address: CommandOutputAddress, content: str) -> str:
|
|
409
|
+
"""Store one operation attempt's command stream and return its reference."""
|
|
410
|
+
|
|
411
|
+
@abstractmethod
|
|
412
|
+
def read_command_output(self, reference: str) -> str:
|
|
413
|
+
"""Read a command stream previously returned by ``write_command_output``."""
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class TaskItemStorage(ABC):
|
|
417
|
+
"""Storage for the items a task shares across its runs.
|
|
418
|
+
|
|
419
|
+
A run keeps its own copy of the items it worked on; this is the task's
|
|
420
|
+
canonical list, seeded into every new run and refreshed as a run changes
|
|
421
|
+
or resolves them. Only item flows declared ``shared`` use it.
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
@abstractmethod
|
|
425
|
+
def read_shared_items(self, task_id: str) -> tuple[WorkItem, ...]:
|
|
426
|
+
"""The task's shared items; a task without any has none."""
|
|
427
|
+
|
|
428
|
+
@abstractmethod
|
|
429
|
+
def write_shared_items(self, task_id: str, items: tuple[WorkItem, ...]) -> None:
|
|
430
|
+
"""Atomically replace the task's shared items."""
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class TaskAmendmentStorage(ABC):
|
|
434
|
+
"""Storage for the amendments appended to a task's requirements.
|
|
435
|
+
|
|
436
|
+
They belong to the task, not to a run, and are only ever appended.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
@abstractmethod
|
|
440
|
+
def read_amendments(self, task_id: str) -> tuple[Amendment, ...]:
|
|
441
|
+
"""The task's amendments, oldest first; a task without any has none."""
|
|
442
|
+
|
|
443
|
+
@abstractmethod
|
|
444
|
+
def append_amendment(self, task_id: str, amendment: Amendment) -> None:
|
|
445
|
+
"""Append one amendment; the caller holds the task's lock."""
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class TaskMetadataStorage(ABC):
|
|
449
|
+
"""Storage for durable metadata shared by every run of one task."""
|
|
450
|
+
|
|
451
|
+
@abstractmethod
|
|
452
|
+
def read_task_metadata(self, task_id: str) -> TaskMetadata | None:
|
|
453
|
+
"""Return validated metadata, or ``None`` when it is missing."""
|
|
454
|
+
|
|
455
|
+
@abstractmethod
|
|
456
|
+
def write_task_metadata(self, metadata: TaskMetadata) -> None:
|
|
457
|
+
"""Atomically create or replace metadata for exactly one task."""
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class ProjectMetadataStorage(ABC):
|
|
461
|
+
"""Storage for durable metadata shared by every task in one project."""
|
|
462
|
+
|
|
463
|
+
def lock_project_metadata(self) -> AbstractContextManager[None]:
|
|
464
|
+
"""Serialize a complete project-metadata read/modify/write operation."""
|
|
465
|
+
return nullcontext()
|
|
466
|
+
|
|
467
|
+
@abstractmethod
|
|
468
|
+
def read_project_metadata(self) -> ProjectMetadata | None:
|
|
469
|
+
"""Return validated metadata, or ``None`` when it is missing."""
|
|
470
|
+
|
|
471
|
+
@abstractmethod
|
|
472
|
+
def write_project_metadata(self, metadata: ProjectMetadata) -> None:
|
|
473
|
+
"""Atomically create or replace project metadata."""
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class TaskStorageAdapter(
|
|
477
|
+
TaskRunStorage,
|
|
478
|
+
TaskArtifactStorage,
|
|
479
|
+
TaskMetadataStorage,
|
|
480
|
+
TaskItemStorage,
|
|
481
|
+
TaskAmendmentStorage,
|
|
482
|
+
ABC,
|
|
483
|
+
):
|
|
484
|
+
"""Complete storage-adapter boundary required by ``WorkflowService``.
|
|
485
|
+
|
|
486
|
+
Removal covers the aggregate, artifacts, metadata, and all projections for
|
|
487
|
+
exactly one task. It returns ``False`` only when no task-owned data existed.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
def task_exists(self, task_id: str) -> bool:
|
|
491
|
+
"""Return whether any task-owned data claims ``task_id``.
|
|
492
|
+
|
|
493
|
+
Runs, metadata, and descendant tasks all count; adapters whose backend
|
|
494
|
+
can hold other task-owned data (a directory, stored artifacts) extend
|
|
495
|
+
this so a generated ID never collides with a partially written task.
|
|
496
|
+
"""
|
|
497
|
+
runs, _, _ = self.read_task_record(task_id)
|
|
498
|
+
return (
|
|
499
|
+
bool(runs)
|
|
500
|
+
or self.read_task_metadata(task_id) is not None
|
|
501
|
+
or bool(self.child_task_ids(task_id))
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
@abstractmethod
|
|
505
|
+
def task_ids(self) -> tuple[str, ...]:
|
|
506
|
+
"""Return every top-level task ID that holds task-owned data, sorted.
|
|
507
|
+
|
|
508
|
+
Child tasks are left out: a person names a task by its own ID, and a
|
|
509
|
+
child is reached through its parent.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
@abstractmethod
|
|
513
|
+
def remove_task(self, task_id: str) -> bool:
|
|
514
|
+
"""Remove all data for exactly one task and report whether it existed."""
|
|
515
|
+
|
|
516
|
+
def child_task_ids(self, task_id: str) -> tuple[str, ...]:
|
|
517
|
+
"""Return persisted descendants of ``task_id`` in deterministic order.
|
|
518
|
+
|
|
519
|
+
Reset uses this to reject a parent reset rather than deleting state
|
|
520
|
+
protected by a child task's independent lock.
|
|
521
|
+
"""
|
|
522
|
+
del task_id
|
|
523
|
+
return ()
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
__all__ = [
|
|
527
|
+
"ArtifactAddress",
|
|
528
|
+
"CommandOutputAddress",
|
|
529
|
+
"MetadataValues",
|
|
530
|
+
"ProjectMetadata",
|
|
531
|
+
"ProjectMetadataStorage",
|
|
532
|
+
"TaskArtifactStorage",
|
|
533
|
+
"TaskMetadata",
|
|
534
|
+
"TaskMetadataStorage",
|
|
535
|
+
"TaskRunStorage",
|
|
536
|
+
"TaskStorageAdapter",
|
|
537
|
+
"flatten_metadata",
|
|
538
|
+
"nest_metadata",
|
|
539
|
+
"validate_metadata_values",
|
|
540
|
+
]
|