ww-agentic-workflows 1.0.0.dev3__py3-none-any.whl

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