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,370 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Filesystem implementation of task persistence."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import contextlib
7
+ import json
8
+ import shutil
9
+ from collections.abc import Iterator
10
+ from contextlib import ExitStack, contextmanager
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ from ww.amendments import Amendment
15
+ from ww.contracts import BOOTSTRAP_REQUEST_PREFIX
16
+ from ww.errors import StateError
17
+ from ww.execution_models import TaskRunAggregate, validate_task_runs
18
+ from ww.items import WorkItem
19
+ from ww.locking import FileLocks
20
+ from ww.storage_adapters.base import (
21
+ ArtifactAddress,
22
+ CommandOutputAddress,
23
+ TaskMetadata,
24
+ TaskStorageAdapter,
25
+ flatten_metadata,
26
+ )
27
+ from ww.storage_adapters.task_document import (
28
+ decode_task_document,
29
+ encode_task_document,
30
+ )
31
+
32
+
33
+ class FileTaskStorageAdapter(TaskStorageAdapter):
34
+ """Store task state and artifacts beneath a project's ``.ww/tasks/`` directory.
35
+
36
+ Every write goes through :mod:`ww.locking`. Mutating methods do not lock:
37
+ `lock_task` is the boundary and `WorkflowService` holds it around the whole
38
+ command, which is the span that actually needs protecting.
39
+ """
40
+
41
+ def __init__(self, root: Path) -> None:
42
+ self.root = root
43
+ self.tasks_path = root / ".ww" / "tasks"
44
+ self.locks = FileLocks(root)
45
+
46
+ @contextmanager
47
+ def lock_task(self, task_id: str) -> Iterator[None]:
48
+ """Lock a task and its parent, so parent reset excludes child writes."""
49
+ parent = task_id.rsplit("/", 1)[0] if "/" in task_id else None
50
+ with ExitStack() as stack:
51
+ if parent is not None:
52
+ stack.enter_context(
53
+ self.locks.lock(
54
+ self.tasks_path / parent, purpose=f"task {parent!r}"
55
+ )
56
+ )
57
+ stack.enter_context(
58
+ self.locks.lock(self.tasks_path / task_id, purpose=f"task {task_id!r}")
59
+ )
60
+ yield
61
+
62
+ def read_task_record(
63
+ self, task_id: str
64
+ ) -> tuple[tuple[TaskRunAggregate, ...], str | None, int]:
65
+ runs, handoff, revision, _ = self._read_task_document(task_id)
66
+ return runs, handoff, revision
67
+
68
+ def task_written_at(self, task_id: str) -> datetime | None:
69
+ # Every commit replaces the state file, so its modification time is
70
+ # the last commit's.
71
+ try:
72
+ modified = self._state_path(task_id).stat().st_mtime
73
+ except OSError:
74
+ return None
75
+ return datetime.fromtimestamp(modified, timezone.utc)
76
+
77
+ def _read_task_document(
78
+ self, task_id: str
79
+ ) -> tuple[
80
+ tuple[TaskRunAggregate, ...],
81
+ str | None,
82
+ int,
83
+ dict[str, list[dict[str, object]]],
84
+ ]:
85
+ path = self._state_path(task_id)
86
+ if not path.exists():
87
+ return (), None, 0, {}
88
+ try:
89
+ raw = json.loads(path.read_text(encoding="utf-8"))
90
+ decoded = decode_task_document(raw, task_id)
91
+ decoded = (*decoded[:3], self._validate_ledger(decoded[3]))
92
+ validate_task_runs(task_id, decoded[0])
93
+ return decoded
94
+ except (OSError, json.JSONDecodeError, StateError, ValueError) as error:
95
+ raise StateError(f"invalid task state {path}: {error}") from error
96
+
97
+ def commit_task_aggregate(
98
+ self,
99
+ task_id: str,
100
+ runs: tuple[TaskRunAggregate, ...],
101
+ handoff: str | None = None,
102
+ expected_revision: int | None = None,
103
+ ) -> int:
104
+ # The service normally already holds the task lock. This separate
105
+ # aggregate lock also makes direct storage-adapter callers safe: the revision
106
+ # check and replacement cannot race another storage-adapter commit, while the
107
+ # lock ordering remains task -> aggregate everywhere in the service.
108
+ with self.locks.lock(
109
+ self._state_path(task_id), purpose=f"task state {task_id!r}"
110
+ ):
111
+ return self._commit_task_aggregate(
112
+ task_id, runs, handoff, expected_revision
113
+ )
114
+
115
+ def _commit_task_aggregate(
116
+ self,
117
+ task_id: str,
118
+ runs: tuple[TaskRunAggregate, ...],
119
+ handoff: str | None = None,
120
+ expected_revision: int | None = None,
121
+ ) -> int:
122
+ previous, _, revision, ledger = self._read_task_document(task_id)
123
+ if revision and expected_revision is None:
124
+ raise StateError("existing task aggregate requires expected_revision")
125
+ if expected_revision is not None and revision != expected_revision:
126
+ raise StateError(
127
+ "task aggregate revision conflict: "
128
+ f"expected {expected_revision}, got {revision}"
129
+ )
130
+ validate_task_runs(task_id, runs)
131
+ previous_ids = {run.run_id for run in previous}
132
+ previous_status = {run.run_id: run.state.status for run in previous}
133
+ for run in runs:
134
+ changed = run.run_id not in previous_ids or (
135
+ previous_status.get(run.run_id) != run.state.status
136
+ and not (
137
+ previous_status.get(run.run_id) == "in_progress"
138
+ and run.state.status == "pending"
139
+ )
140
+ )
141
+ if changed:
142
+ if (
143
+ ledger.get(run.run_id)
144
+ and ledger[run.run_id][-1].get("status") == run.state.status
145
+ ):
146
+ continue
147
+ ledger.setdefault(run.run_id, []).append(
148
+ {
149
+ "workflow": run.workflow,
150
+ "status": run.state.status,
151
+ "summary": dict(run.state.workflow_values).get("summary")
152
+ if run.state.status == "completed"
153
+ else None,
154
+ }
155
+ )
156
+ ledger = self._validate_ledger(ledger)
157
+ try:
158
+ payload = encode_task_document(task_id, runs, handoff, revision + 1, ledger)
159
+ # Validate the exact compact representation before publishing it.
160
+ decoded = decode_task_document(payload, task_id)
161
+ except ValueError as error:
162
+ raise StateError(
163
+ f"task {task_id!r} aggregate cannot be encoded: {error}"
164
+ ) from error
165
+ validate_task_runs(task_id, decoded[0])
166
+ if not self._metadata_path(task_id).exists():
167
+ self._write_task_metadata_payload(TaskMetadata(task_id))
168
+ self.locks.atomic_write(
169
+ self._state_path(task_id), json.dumps(payload, indent=2) + "\n"
170
+ )
171
+ return revision + 1
172
+
173
+ def write_command_output(self, address: CommandOutputAddress, content: str) -> str:
174
+ path = self._run_path(address.task_id, address.run_id).joinpath(
175
+ "command-output", *address.segments()
176
+ )
177
+ self.locks.atomic_write(path, content)
178
+ return str(path.relative_to(self.root))
179
+
180
+ def read_command_output(self, reference: str) -> str:
181
+ return self._read_task_file(reference, "command output")
182
+
183
+ def _read_task_file(self, reference: str, kind: str) -> str:
184
+ path = (self.root / reference).resolve()
185
+ task_root = self.tasks_path.resolve()
186
+ if not path.is_relative_to(task_root):
187
+ raise StateError(f"invalid {kind} reference: {reference!r}")
188
+ try:
189
+ return path.read_text(encoding="utf-8")
190
+ except OSError as error:
191
+ raise StateError(f"cannot read {kind} {reference!r}: {error}") from error
192
+
193
+ def task_ids(self) -> tuple[str, ...]:
194
+ if not self.tasks_path.is_dir():
195
+ return ()
196
+ return tuple(
197
+ sorted(
198
+ path.name
199
+ for path in self.tasks_path.iterdir()
200
+ if path.is_dir()
201
+ and not path.is_symlink()
202
+ and not path.name.startswith(BOOTSTRAP_REQUEST_PREFIX)
203
+ )
204
+ )
205
+
206
+ def task_exists(self, task_id: str) -> bool:
207
+ # Any task directory claims the ID, even one holding only artifacts or
208
+ # a child task, so a generated ID never adopts a partially written task.
209
+ return (self.tasks_path / task_id).exists()
210
+
211
+ def read_task_metadata(self, task_id: str) -> TaskMetadata | None:
212
+ path = self._metadata_path(task_id)
213
+ if path.exists():
214
+ return self._read_task_metadata_path(path, task_id)
215
+ return None
216
+
217
+ def _read_task_metadata_path(self, path: Path, task_id: str) -> TaskMetadata:
218
+ try:
219
+ raw = json.loads(path.read_text(encoding="utf-8"))
220
+ if not isinstance(raw, dict) or raw.get("task_id") != task_id:
221
+ raise ValueError("task metadata has a mismatched task ID")
222
+ values = flatten_metadata(raw.get("metadata", {}), "task metadata")
223
+ return TaskMetadata(task_id, values)
224
+ except (OSError, json.JSONDecodeError, ValueError) as error:
225
+ raise StateError(f"invalid task metadata {path}: {error}") from error
226
+
227
+ def write_task_metadata(self, metadata: TaskMetadata) -> None:
228
+ self._write_task_metadata_payload(metadata)
229
+
230
+ def read_shared_items(self, task_id: str) -> tuple[WorkItem, ...]:
231
+ path = self._shared_items_path(task_id)
232
+ if not path.exists():
233
+ return ()
234
+ try:
235
+ raw = json.loads(path.read_text(encoding="utf-8"))
236
+ if not isinstance(raw, dict) or raw.get("task_id") != task_id:
237
+ raise ValueError("shared items have a mismatched task ID")
238
+ entries = raw.get("items", [])
239
+ if not isinstance(entries, list):
240
+ raise ValueError("shared items must be a list")
241
+ return tuple(WorkItem.from_dict(entry) for entry in entries)
242
+ except (OSError, ValueError) as error:
243
+ raise StateError(
244
+ f"cannot read shared items of {task_id!r}: {error}"
245
+ ) from error
246
+
247
+ def write_shared_items(self, task_id: str, items: tuple[WorkItem, ...]) -> None:
248
+ payload = {"task_id": task_id, "items": [item.to_dict() for item in items]}
249
+ self.locks.atomic_write(
250
+ self._shared_items_path(task_id), json.dumps(payload, indent=2) + "\n"
251
+ )
252
+
253
+ def read_amendments(self, task_id: str) -> tuple[Amendment, ...]:
254
+ path = self._amendments_path(task_id)
255
+ if not path.exists():
256
+ return ()
257
+ try:
258
+ raw = json.loads(path.read_text(encoding="utf-8"))
259
+ if not isinstance(raw, dict) or raw.get("task_id") != task_id:
260
+ raise ValueError("amendments have a mismatched task ID")
261
+ entries = raw.get("amendments", [])
262
+ if not isinstance(entries, list):
263
+ raise ValueError("amendments must be a list")
264
+ return tuple(Amendment.from_dict(entry) for entry in entries)
265
+ except (OSError, ValueError) as error:
266
+ raise StateError(
267
+ f"cannot read amendments of {task_id!r}: {error}"
268
+ ) from error
269
+
270
+ def append_amendment(self, task_id: str, amendment: Amendment) -> None:
271
+ amendments = (*self.read_amendments(task_id), amendment)
272
+ payload = {
273
+ "task_id": task_id,
274
+ "amendments": [entry.to_dict() for entry in amendments],
275
+ }
276
+ self.locks.atomic_write(
277
+ self._amendments_path(task_id), json.dumps(payload, indent=2) + "\n"
278
+ )
279
+
280
+ def _write_task_metadata_payload(self, metadata: TaskMetadata) -> None:
281
+ payload: dict[str, object] = {"task_id": metadata.task_id}
282
+ if metadata.values:
283
+ payload["metadata"] = metadata.to_dict()
284
+ self.locks.atomic_write(
285
+ self._metadata_path(metadata.task_id),
286
+ json.dumps(payload, indent=2) + "\n",
287
+ )
288
+
289
+ def remove_task(self, task_id: str) -> bool:
290
+ path = self.tasks_path / task_id
291
+ if not path.exists():
292
+ return False
293
+ if path.is_symlink() or not path.is_dir():
294
+ raise StateError(f"task path is not a removable directory: {path}")
295
+ # Do not recursively remove nested task directories. Service-level
296
+ # reset rejects them; direct storage-adapter callers still get exact ownership.
297
+ owned_paths = (
298
+ self._state_path(task_id),
299
+ self._metadata_path(task_id),
300
+ self._shared_items_path(task_id),
301
+ self._amendments_path(task_id),
302
+ )
303
+ for owned in owned_paths:
304
+ if owned.exists() and not owned.is_symlink() and owned.is_file():
305
+ owned.unlink()
306
+ runs = path / "runs"
307
+ if runs.exists() and not runs.is_symlink() and runs.is_dir():
308
+ shutil.rmtree(runs)
309
+ # A child task or unrecognized file may remain; it stays intact.
310
+ with contextlib.suppress(OSError):
311
+ path.rmdir()
312
+ return True
313
+
314
+ def child_task_ids(self, task_id: str) -> tuple[str, ...]:
315
+ path = self.tasks_path / task_id
316
+ if not path.is_dir() or path.is_symlink():
317
+ return ()
318
+ descendants = []
319
+ for state in path.rglob("state.json"):
320
+ relative = state.parent.relative_to(self.tasks_path)
321
+ candidate = relative.as_posix()
322
+ if candidate != task_id:
323
+ descendants.append(candidate)
324
+ return tuple(sorted(set(descendants)))
325
+
326
+ def write_execution_artifact(self, address: ArtifactAddress, content: str) -> str:
327
+ path = self._run_path(address.task_id, address.run_namespace).joinpath(
328
+ "steps", *address.segments()
329
+ )
330
+ self.locks.atomic_write(path, content)
331
+ return str(path.relative_to(self.root))
332
+
333
+ def _run_path(self, task_id: str, run_id: str) -> Path:
334
+ return self.tasks_path / task_id / "runs" / run_id
335
+
336
+ def read_execution_artifact(self, reference: str) -> str:
337
+ return self._read_task_file(reference, "execution artifact")
338
+
339
+ def _state_path(self, task_id: str) -> Path:
340
+ return self.tasks_path / task_id / "state.json"
341
+
342
+ def _metadata_path(self, task_id: str) -> Path:
343
+ return self.tasks_path / task_id / "metadata.json"
344
+
345
+ def _amendments_path(self, task_id: str) -> Path:
346
+ return self.tasks_path / task_id / "amendments.json"
347
+
348
+ def _shared_items_path(self, task_id: str) -> Path:
349
+ return self.tasks_path / task_id / "items.json"
350
+
351
+ @staticmethod
352
+ def _validate_ledger(value: object) -> dict[str, list[dict[str, object]]]:
353
+ if not isinstance(value, dict):
354
+ raise StateError("task aggregate ledger must be a mapping")
355
+ if any(
356
+ not isinstance(run_id, str)
357
+ or not isinstance(events, list)
358
+ or not events
359
+ or any(
360
+ not isinstance(event, dict)
361
+ or not isinstance(event.get("workflow"), str)
362
+ or not isinstance(event.get("status"), str)
363
+ or event.get("summary") is not None
364
+ and not isinstance(event.get("summary"), str)
365
+ for event in events
366
+ )
367
+ for run_id, events in value.items()
368
+ ):
369
+ raise StateError("task aggregate ledger entries are invalid")
370
+ return value
@@ -0,0 +1,195 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """In-memory task persistence for tests and embedded callers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import threading
7
+ from collections.abc import Iterator
8
+ from contextlib import ExitStack, contextmanager
9
+ from datetime import datetime, timezone
10
+
11
+ from ww.amendments import Amendment
12
+ from ww.errors import StateError
13
+ from ww.execution_models import TaskRunAggregate, validate_task_runs
14
+ from ww.items import WorkItem
15
+ from ww.storage_adapters.base import (
16
+ ArtifactAddress,
17
+ CommandOutputAddress,
18
+ TaskMetadata,
19
+ TaskStorageAdapter,
20
+ )
21
+
22
+
23
+ class MemoryTaskStorageAdapter(TaskStorageAdapter):
24
+ """Store task aggregates, metadata, and artifacts in dictionaries.
25
+
26
+ This storage adapter has no filesystem side effects. Artifact keys use stable
27
+ ``memory://`` references, mirroring the run-aware addressing contract of
28
+ the filesystem storage adapter.
29
+ """
30
+
31
+ def __init__(self) -> None:
32
+ self.artifacts: dict[str, str] = {}
33
+ self._artifact_owners: dict[str, str] = {}
34
+ self.metadata: dict[str, TaskMetadata] = {}
35
+ self.shared_items: dict[str, tuple[WorkItem, ...]] = {}
36
+ self.amendments: dict[str, tuple[Amendment, ...]] = {}
37
+ self.aggregates: dict[str, tuple[tuple[TaskRunAggregate, ...], str | None]] = {}
38
+ self.aggregate_revisions: dict[str, int] = {}
39
+ # When each task's runs were last committed.
40
+ self.written_at: dict[str, datetime] = {}
41
+ self._locks: dict[str, threading.RLock] = {}
42
+ self._locks_guard = threading.Lock()
43
+
44
+ @contextmanager
45
+ def lock_task(self, task_id: str) -> Iterator[None]:
46
+ """Match the filesystem storage adapter's whole-command task lock in memory."""
47
+ # A parent reset must exclude concurrent writes to any child task.
48
+ task_ids = tuple(
49
+ part
50
+ for part in (task_id.rsplit("/", 1)[0] if "/" in task_id else None, task_id)
51
+ if part is not None
52
+ )
53
+ with self._locks_guard:
54
+ locks = tuple(
55
+ self._locks.setdefault(value, threading.RLock()) for value in task_ids
56
+ )
57
+ with ExitStack() as stack:
58
+ for lock in locks:
59
+ stack.enter_context(lock)
60
+ yield
61
+
62
+ def read_task_record(
63
+ self, task_id: str
64
+ ) -> tuple[tuple[TaskRunAggregate, ...], str | None, int]:
65
+ aggregate = self.aggregates.get(task_id)
66
+ if aggregate is None:
67
+ return (), None, 0
68
+ runs, handoff = aggregate
69
+ try:
70
+ validate_task_runs(task_id, runs)
71
+ except (StateError, ValueError) as error:
72
+ # The same error as the filesystem adapter's, so callers that
73
+ # tolerate one unreadable task treat both adapters alike.
74
+ raise StateError(f"invalid task state {task_id}: {error}") from error
75
+ return runs, handoff, self.aggregate_revisions.get(task_id, 0)
76
+
77
+ def commit_task_aggregate(
78
+ self,
79
+ task_id: str,
80
+ runs: tuple[TaskRunAggregate, ...],
81
+ handoff: str | None = None,
82
+ expected_revision: int | None = None,
83
+ ) -> int:
84
+ revision = self.task_aggregate_revision(task_id)
85
+ if revision and expected_revision is None:
86
+ raise StateError("existing task aggregate requires expected_revision")
87
+ if expected_revision is not None and revision != expected_revision:
88
+ raise StateError(
89
+ "task aggregate revision conflict: "
90
+ f"expected {expected_revision}, got {revision}"
91
+ )
92
+ validate_task_runs(task_id, runs)
93
+ # Match the filesystem boundary: snapshots are encoded and decoded at
94
+ # commit time, retaining plain action payloads for later inspection if
95
+ # a registration disappears from the process.
96
+ try:
97
+ persisted_runs = tuple(
98
+ TaskRunAggregate.from_dict(run.to_dict()) for run in runs
99
+ )
100
+ except ValueError as error:
101
+ raise StateError(
102
+ f"task {task_id!r} aggregate cannot be encoded: {error}"
103
+ ) from error
104
+ self.aggregates[task_id] = (persisted_runs, handoff)
105
+ self.aggregate_revisions[task_id] = revision + 1
106
+ self.written_at[task_id] = datetime.now(timezone.utc)
107
+ self.metadata.setdefault(task_id, TaskMetadata(task_id))
108
+ return revision + 1
109
+
110
+ def task_written_at(self, task_id: str) -> datetime | None:
111
+ return self.written_at.get(task_id)
112
+
113
+ def task_ids(self) -> tuple[str, ...]:
114
+ owners = {*self.aggregates, *self.metadata, *self._artifact_owners.values()}
115
+ return tuple(sorted({owner.split("/")[0] for owner in owners}))
116
+
117
+ def task_exists(self, task_id: str) -> bool:
118
+ # Stored artifacts also claim the ID; see the port docstring.
119
+ return super().task_exists(task_id) or task_id in self._artifact_owners.values()
120
+
121
+ def read_task_metadata(self, task_id: str) -> TaskMetadata | None:
122
+ return self.metadata.get(task_id)
123
+
124
+ def write_task_metadata(self, metadata: TaskMetadata) -> None:
125
+ self.metadata[metadata.task_id] = metadata
126
+
127
+ def read_shared_items(self, task_id: str) -> tuple[WorkItem, ...]:
128
+ return self.shared_items.get(task_id, ())
129
+
130
+ def write_shared_items(self, task_id: str, items: tuple[WorkItem, ...]) -> None:
131
+ self.shared_items[task_id] = items
132
+
133
+ def read_amendments(self, task_id: str) -> tuple[Amendment, ...]:
134
+ return self.amendments.get(task_id, ())
135
+
136
+ def append_amendment(self, task_id: str, amendment: Amendment) -> None:
137
+ self.amendments[task_id] = (*self.read_amendments(task_id), amendment)
138
+
139
+ def remove_task(self, task_id: str) -> bool:
140
+ existed = bool(
141
+ task_id in self.aggregates
142
+ or task_id in self.metadata
143
+ or task_id in self._artifact_owners.values()
144
+ )
145
+ self.metadata.pop(task_id, None)
146
+ self.shared_items.pop(task_id, None)
147
+ self.amendments.pop(task_id, None)
148
+ self.aggregates.pop(task_id, None)
149
+ self.aggregate_revisions.pop(task_id, None)
150
+ self.written_at.pop(task_id, None)
151
+ for reference, owner in tuple(self._artifact_owners.items()):
152
+ if owner == task_id:
153
+ del self.artifacts[reference]
154
+ del self._artifact_owners[reference]
155
+ return existed
156
+
157
+ def child_task_ids(self, task_id: str) -> tuple[str, ...]:
158
+ prefix = f"{task_id}/"
159
+ task_ids = set(self.aggregates) | set(self.metadata)
160
+ task_ids.update(self._artifact_owners.values())
161
+ return tuple(
162
+ sorted(candidate for candidate in task_ids if candidate.startswith(prefix))
163
+ )
164
+
165
+ def write_execution_artifact(self, address: ArtifactAddress, content: str) -> str:
166
+ reference = self._reference(
167
+ address.task_id, address.run_namespace, "steps", *address.segments()
168
+ )
169
+ self.artifacts[reference] = content
170
+ self._artifact_owners[reference] = address.task_id
171
+ return reference
172
+
173
+ def read_execution_artifact(self, reference: str) -> str:
174
+ try:
175
+ return self.artifacts[reference]
176
+ except KeyError as error:
177
+ raise StateError(f"missing execution artifact: {reference}") from error
178
+
179
+ def write_command_output(self, address: CommandOutputAddress, content: str) -> str:
180
+ reference = self._reference(
181
+ address.task_id, address.run_id, "command-output", *address.segments()
182
+ )
183
+ self.artifacts[reference] = content
184
+ self._artifact_owners[reference] = address.task_id
185
+ return reference
186
+
187
+ @staticmethod
188
+ def _reference(task_id: str, run_id: str, *segments: str) -> str:
189
+ return f"memory://{task_id}/runs/{run_id}/" + "/".join(segments)
190
+
191
+ def read_command_output(self, reference: str) -> str:
192
+ try:
193
+ return self.artifacts[reference]
194
+ except KeyError as error:
195
+ raise StateError(f"missing command output: {reference}") from error
@@ -0,0 +1,69 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Project-scoped metadata storage adapters."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import threading
8
+ from collections.abc import Iterator
9
+ from contextlib import AbstractContextManager, contextmanager
10
+ from pathlib import Path
11
+
12
+ from ww.errors import StateError
13
+ from ww.locking import FileLocks
14
+ from ww.storage_adapters.base import (
15
+ ProjectMetadata,
16
+ ProjectMetadataStorage,
17
+ flatten_metadata,
18
+ )
19
+
20
+
21
+ def _decode_project_metadata(raw: object) -> ProjectMetadata:
22
+ return ProjectMetadata(flatten_metadata(raw, "project metadata"))
23
+
24
+
25
+ class FileProjectMetadataStorageAdapter(ProjectMetadataStorage):
26
+ """Store project metadata in ``.ww/metadata.json``."""
27
+
28
+ def __init__(self, root: Path) -> None:
29
+ self.path = root / ".ww" / "metadata.json"
30
+ self.locks = FileLocks(root)
31
+
32
+ def lock_project_metadata(self) -> AbstractContextManager[None]:
33
+ return self.locks.lock(self.path, purpose="project metadata")
34
+
35
+ def read_project_metadata(self) -> ProjectMetadata | None:
36
+ if not self.path.exists():
37
+ return None
38
+ try:
39
+ return _decode_project_metadata(
40
+ json.loads(self.path.read_text(encoding="utf-8"))
41
+ )
42
+ except (OSError, json.JSONDecodeError, ValueError) as error:
43
+ raise StateError(
44
+ f"invalid project metadata {self.path}: {error}"
45
+ ) from error
46
+
47
+ def write_project_metadata(self, metadata: ProjectMetadata) -> None:
48
+ self.locks.atomic_write(
49
+ self.path, json.dumps(metadata.to_dict(), indent=2) + "\n"
50
+ )
51
+
52
+
53
+ class MemoryProjectMetadataStorageAdapter(ProjectMetadataStorage):
54
+ """Keep project metadata in memory for tests and embedded callers."""
55
+
56
+ def __init__(self) -> None:
57
+ self.metadata: ProjectMetadata | None = None
58
+ self._lock = threading.RLock()
59
+
60
+ @contextmanager
61
+ def lock_project_metadata(self) -> Iterator[None]:
62
+ with self._lock:
63
+ yield
64
+
65
+ def read_project_metadata(self) -> ProjectMetadata | None:
66
+ return self.metadata
67
+
68
+ def write_project_metadata(self, metadata: ProjectMetadata) -> None:
69
+ self.metadata = metadata