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,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
|