ww-agentic-workflows 1.0.0.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/storage.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Filesystem persistence, using locked atomic state replacement."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from contextlib import AbstractContextManager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ww.config.composition import compose_configuration
|
|
13
|
+
from ww.config_files import (
|
|
14
|
+
LOCAL_IGNORE_PATTERNS,
|
|
15
|
+
RUNTIME_IGNORE_LINES,
|
|
16
|
+
SETTINGS_FILE,
|
|
17
|
+
WHOLE_RUNTIME_IGNORE_LINES,
|
|
18
|
+
WORKFLOWS_FILE,
|
|
19
|
+
display_path,
|
|
20
|
+
newline_of,
|
|
21
|
+
user_directory,
|
|
22
|
+
workflow_levels,
|
|
23
|
+
)
|
|
24
|
+
from ww.errors import ConfigurationError, StateError
|
|
25
|
+
from ww.locking import FileLocks
|
|
26
|
+
from ww.results import NO_WORKFLOWS_ACTION, InitializationResult
|
|
27
|
+
from ww.storage_adapters import (
|
|
28
|
+
FileProjectMetadataStorageAdapter,
|
|
29
|
+
FileTaskStorageAdapter,
|
|
30
|
+
ProjectMetadataStorage,
|
|
31
|
+
TaskStorageAdapter,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
EXECUTION_LOG_MAX_BYTES = 1_000_000
|
|
35
|
+
EXECUTION_LOG_RETENTION = 5
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Storage:
|
|
39
|
+
"""Path conventions and filesystem operations for one project root."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
root: Path,
|
|
44
|
+
task_persistence: TaskStorageAdapter | None = None,
|
|
45
|
+
project_metadata: ProjectMetadataStorage | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.root = root
|
|
48
|
+
self.locks = FileLocks(root)
|
|
49
|
+
self.task_persistence = task_persistence or FileTaskStorageAdapter(root)
|
|
50
|
+
self.project_metadata = project_metadata or FileProjectMetadataStorageAdapter(
|
|
51
|
+
root
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def lock_project(self) -> AbstractContextManager[None]:
|
|
55
|
+
"""Serialize project-wide file changes against other ww processes."""
|
|
56
|
+
return self.locks.lock(self.root, purpose="project files")
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def config_path(self) -> Path:
|
|
60
|
+
return self.root / WORKFLOWS_FILE
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def runtime_path(self) -> Path:
|
|
64
|
+
return self.root / ".ww"
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def project_config_path(self) -> Path:
|
|
68
|
+
return self.root / SETTINGS_FILE
|
|
69
|
+
|
|
70
|
+
def initialize_project(
|
|
71
|
+
self,
|
|
72
|
+
workflows: str,
|
|
73
|
+
project_config: str,
|
|
74
|
+
launcher: str,
|
|
75
|
+
agent_instructions: str,
|
|
76
|
+
*,
|
|
77
|
+
ignore_runtime: bool = False,
|
|
78
|
+
skills: tuple[tuple[str, str], ...] = (),
|
|
79
|
+
) -> InitializationResult:
|
|
80
|
+
"""Add any missing project files without replacing existing ones.
|
|
81
|
+
|
|
82
|
+
``skills`` pairs a project-relative ``SKILL.md`` location with the
|
|
83
|
+
content it receives unless a file already exists there.
|
|
84
|
+
"""
|
|
85
|
+
with self.lock_project():
|
|
86
|
+
return self._initialize_project(
|
|
87
|
+
workflows,
|
|
88
|
+
project_config,
|
|
89
|
+
launcher,
|
|
90
|
+
agent_instructions,
|
|
91
|
+
ignore_runtime=ignore_runtime,
|
|
92
|
+
skills=skills,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def _initialize_project(
|
|
96
|
+
self,
|
|
97
|
+
workflows: str,
|
|
98
|
+
project_config: str,
|
|
99
|
+
launcher: str,
|
|
100
|
+
agent_instructions: str,
|
|
101
|
+
*,
|
|
102
|
+
ignore_runtime: bool,
|
|
103
|
+
skills: tuple[tuple[str, str], ...],
|
|
104
|
+
) -> InitializationResult:
|
|
105
|
+
launcher_path = self.root / "ww"
|
|
106
|
+
instructions_path = self.root / "WW_AGENT_INSTRUCTIONS.md"
|
|
107
|
+
created: list[str] = []
|
|
108
|
+
preserved: list[str] = []
|
|
109
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
|
|
111
|
+
if self.config_path.exists():
|
|
112
|
+
if self._add_missing_workflow_defaults(workflows):
|
|
113
|
+
created.append(f"{WORKFLOWS_FILE} (added missing defaults)")
|
|
114
|
+
else:
|
|
115
|
+
preserved.append(WORKFLOWS_FILE)
|
|
116
|
+
else:
|
|
117
|
+
self._write_starter_workflows(workflows)
|
|
118
|
+
created.append(WORKFLOWS_FILE)
|
|
119
|
+
|
|
120
|
+
if self.project_config_path.exists():
|
|
121
|
+
if self._add_missing_project_settings(project_config):
|
|
122
|
+
created.append(f"{SETTINGS_FILE} (added missing settings)")
|
|
123
|
+
else:
|
|
124
|
+
preserved.append(SETTINGS_FILE)
|
|
125
|
+
else:
|
|
126
|
+
self.locks.atomic_write(self.project_config_path, project_config)
|
|
127
|
+
created.append(SETTINGS_FILE)
|
|
128
|
+
|
|
129
|
+
# The launcher is ww-owned: one an older ww wrote reads old config
|
|
130
|
+
# names and can run the wrong binary, so a differing one is replaced.
|
|
131
|
+
if launcher_path.exists():
|
|
132
|
+
if launcher_path.read_text(encoding="utf-8") != launcher:
|
|
133
|
+
self.locks.atomic_write(launcher_path, launcher)
|
|
134
|
+
created.append("ww (updated to the current launcher)")
|
|
135
|
+
else:
|
|
136
|
+
preserved.append("ww")
|
|
137
|
+
else:
|
|
138
|
+
self.locks.atomic_write(launcher_path, launcher)
|
|
139
|
+
created.append("ww")
|
|
140
|
+
|
|
141
|
+
for path, content in (
|
|
142
|
+
(instructions_path, agent_instructions),
|
|
143
|
+
*((self.root / relative, content) for relative, content in skills),
|
|
144
|
+
):
|
|
145
|
+
relative = str(path.relative_to(self.root))
|
|
146
|
+
if path.exists():
|
|
147
|
+
preserved.append(relative)
|
|
148
|
+
continue
|
|
149
|
+
self.locks.atomic_write(path, content)
|
|
150
|
+
created.append(relative)
|
|
151
|
+
|
|
152
|
+
if launcher_path.is_file():
|
|
153
|
+
launcher_path.chmod(launcher_path.stat().st_mode | 0o111)
|
|
154
|
+
# Nothing else creates the user level's directory, and a user file is
|
|
155
|
+
# easier to add once the place for it exists.
|
|
156
|
+
user = user_directory()
|
|
157
|
+
if not user.is_dir():
|
|
158
|
+
user.mkdir(parents=True)
|
|
159
|
+
created.append(
|
|
160
|
+
f"{display_path(user, self.root)} (user configuration directory)"
|
|
161
|
+
)
|
|
162
|
+
self.runtime_path.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
tasks = self.runtime_path / "tasks"
|
|
164
|
+
if tasks.exists():
|
|
165
|
+
preserved.append(".ww/tasks")
|
|
166
|
+
else:
|
|
167
|
+
tasks.mkdir(parents=True)
|
|
168
|
+
created.append(".ww/tasks")
|
|
169
|
+
|
|
170
|
+
if ignore_runtime:
|
|
171
|
+
change = self._ignore_runtime_directory()
|
|
172
|
+
if change:
|
|
173
|
+
created.append(change)
|
|
174
|
+
else:
|
|
175
|
+
preserved.append(".gitignore entries for .ww")
|
|
176
|
+
# Local configuration belongs to one checkout, so it is always kept
|
|
177
|
+
# out of version control.
|
|
178
|
+
added = self._ignore_local_configuration()
|
|
179
|
+
if added:
|
|
180
|
+
created.append(".gitignore entries: " + ", ".join(added))
|
|
181
|
+
elif (self.root / ".gitignore").exists():
|
|
182
|
+
preserved.append(".gitignore entries: " + ", ".join(LOCAL_IGNORE_PATTERNS))
|
|
183
|
+
|
|
184
|
+
actions = self._initialization_actions()
|
|
185
|
+
if (
|
|
186
|
+
instructions_path.exists()
|
|
187
|
+
and instructions_path.read_text(encoding="utf-8") != agent_instructions
|
|
188
|
+
):
|
|
189
|
+
actions.append(
|
|
190
|
+
"WW_AGENT_INSTRUCTIONS.md was preserved; review it before use. "
|
|
191
|
+
"Current start commands require --requirements."
|
|
192
|
+
)
|
|
193
|
+
return InitializationResult(
|
|
194
|
+
str(self.root), tuple(created), tuple(preserved), tuple(actions)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def _write_starter_workflows(self, defaults: str) -> None:
|
|
198
|
+
"""Write the starter YAML without the keys another level provides."""
|
|
199
|
+
if not any(
|
|
200
|
+
level.path.is_file()
|
|
201
|
+
for level in workflow_levels(self.config_path)
|
|
202
|
+
if level.name != "repo"
|
|
203
|
+
):
|
|
204
|
+
self.locks.atomic_write(self.config_path, defaults)
|
|
205
|
+
return
|
|
206
|
+
import yaml
|
|
207
|
+
|
|
208
|
+
# Compose the other levels around an empty repo file first.
|
|
209
|
+
self.locks.atomic_write(self.config_path, "{}\n")
|
|
210
|
+
try:
|
|
211
|
+
defined = compose_configuration(self.config_path).raw
|
|
212
|
+
except BaseException:
|
|
213
|
+
self.config_path.unlink(missing_ok=True)
|
|
214
|
+
raise
|
|
215
|
+
desired = yaml.safe_load(defaults)
|
|
216
|
+
missing = {key: value for key, value in desired.items() if key not in defined}
|
|
217
|
+
if missing == desired:
|
|
218
|
+
self.locks.atomic_write(self.config_path, defaults)
|
|
219
|
+
elif missing:
|
|
220
|
+
self.locks.atomic_write(
|
|
221
|
+
self.config_path, yaml.safe_dump(missing, sort_keys=False)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def _add_missing_workflow_defaults(self, defaults: str) -> bool:
|
|
225
|
+
"""Append absent root keys while leaving existing YAML formatting intact."""
|
|
226
|
+
import yaml
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
existing = yaml.safe_load(self.config_path.read_text(encoding="utf-8"))
|
|
230
|
+
desired = yaml.safe_load(defaults)
|
|
231
|
+
except (OSError, yaml.YAMLError) as error:
|
|
232
|
+
raise ConfigurationError(f"invalid {self.config_path}: {error}") from error
|
|
233
|
+
if not isinstance(existing, dict) or not isinstance(desired, dict):
|
|
234
|
+
raise ConfigurationError(f"{self.config_path} must contain a mapping")
|
|
235
|
+
# A key an imported file defines is present too.
|
|
236
|
+
defined = compose_configuration(self.config_path).raw
|
|
237
|
+
missing = {key: value for key, value in desired.items() if key not in defined}
|
|
238
|
+
if not missing:
|
|
239
|
+
return False
|
|
240
|
+
current = self.config_path.read_text(encoding="utf-8")
|
|
241
|
+
separator = "" if not current or current.endswith("\n") else "\n"
|
|
242
|
+
addition = yaml.safe_dump(missing, sort_keys=False)
|
|
243
|
+
self.locks.atomic_write(self.config_path, f"{current}{separator}{addition}")
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
def _add_missing_project_settings(self, defaults: str) -> bool:
|
|
247
|
+
try:
|
|
248
|
+
existing = json.loads(self.project_config_path.read_text(encoding="utf-8"))
|
|
249
|
+
desired = json.loads(defaults)
|
|
250
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
251
|
+
raise ConfigurationError(
|
|
252
|
+
f"invalid {self.project_config_path}: {error}"
|
|
253
|
+
) from error
|
|
254
|
+
if not isinstance(existing, dict) or not isinstance(desired, dict):
|
|
255
|
+
raise ConfigurationError(
|
|
256
|
+
f"{self.project_config_path} must contain a JSON object"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def merge(target: dict[str, Any], source: dict[str, Any]) -> bool:
|
|
260
|
+
changed = False
|
|
261
|
+
for key, value in source.items():
|
|
262
|
+
if key not in target:
|
|
263
|
+
target[key] = value
|
|
264
|
+
changed = True
|
|
265
|
+
elif isinstance(target[key], dict) and isinstance(value, dict):
|
|
266
|
+
changed = merge(target[key], value) or changed
|
|
267
|
+
return changed
|
|
268
|
+
|
|
269
|
+
if not merge(existing, desired):
|
|
270
|
+
return False
|
|
271
|
+
self.locks.atomic_write(
|
|
272
|
+
self.project_config_path, json.dumps(existing, indent=2) + "\n"
|
|
273
|
+
)
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
def _ignore_runtime_directory(self) -> str | None:
|
|
277
|
+
"""Keep ``.ww`` out of Git but for the files a team shares.
|
|
278
|
+
|
|
279
|
+
Every line that ignores ``.ww`` whole (:data:`WHOLE_RUNTIME_IGNORE_LINES`)
|
|
280
|
+
gives way to :data:`RUNTIME_IGNORE_LINES`, written once where the first
|
|
281
|
+
stood; a ``.ww/*`` line gains the re-inclusions it lacks after it, and
|
|
282
|
+
every other line is left alone. Returns what changed, or ``None``.
|
|
283
|
+
"""
|
|
284
|
+
# Reads and rewrites .gitignore; the project scope held by
|
|
285
|
+
# initialize_project keeps that pair together.
|
|
286
|
+
path = self.root / ".gitignore"
|
|
287
|
+
existing = _read_gitignore(path)
|
|
288
|
+
updated, added, replaced = with_runtime_ignored(existing)
|
|
289
|
+
if updated == existing:
|
|
290
|
+
return None
|
|
291
|
+
self.locks.atomic_write(path, updated)
|
|
292
|
+
if not added:
|
|
293
|
+
return f".gitignore: removed {', '.join(replaced)}, which hid .ww/*"
|
|
294
|
+
change = ".gitignore entries: " + ", ".join(added)
|
|
295
|
+
if replaced:
|
|
296
|
+
change += f" (replacing {', '.join(replaced)})"
|
|
297
|
+
return change
|
|
298
|
+
|
|
299
|
+
def _ignore_local_configuration(self) -> tuple[str, ...]:
|
|
300
|
+
"""Add the local-file patterns missing from .gitignore.
|
|
301
|
+
|
|
302
|
+
An existing .gitignore gains them; one is created only in a Git
|
|
303
|
+
checkout, where it has an effect.
|
|
304
|
+
"""
|
|
305
|
+
path = self.root / ".gitignore"
|
|
306
|
+
if not path.exists() and not (self.root / ".git").exists():
|
|
307
|
+
return ()
|
|
308
|
+
existing = _read_gitignore(path)
|
|
309
|
+
entries = {line.strip() for line in existing.splitlines()}
|
|
310
|
+
missing = tuple(
|
|
311
|
+
pattern for pattern in LOCAL_IGNORE_PATTERNS if pattern not in entries
|
|
312
|
+
)
|
|
313
|
+
if missing:
|
|
314
|
+
self.locks.atomic_write(path, _appended(existing, missing))
|
|
315
|
+
return missing
|
|
316
|
+
|
|
317
|
+
def _initialization_actions(self) -> list[str]:
|
|
318
|
+
actions: list[str] = []
|
|
319
|
+
reference = "@WW_AGENT_INSTRUCTIONS.md"
|
|
320
|
+
for name in ("AGENTS.md", "CLAUDE.md"):
|
|
321
|
+
path = self.root / name
|
|
322
|
+
if not path.exists():
|
|
323
|
+
if name == "AGENTS.md":
|
|
324
|
+
actions.append(f"Create {name} and add {reference}.")
|
|
325
|
+
elif reference not in path.read_text(encoding="utf-8"):
|
|
326
|
+
actions.append(f"Add {reference} to {name}.")
|
|
327
|
+
try:
|
|
328
|
+
raw = compose_configuration(self.config_path).raw
|
|
329
|
+
except (OSError, ConfigurationError):
|
|
330
|
+
raw = {}
|
|
331
|
+
if not raw.get("workflows"):
|
|
332
|
+
actions.append(NO_WORKFLOWS_ACTION)
|
|
333
|
+
return actions
|
|
334
|
+
|
|
335
|
+
def append_log(self, record: dict[str, Any]) -> None:
|
|
336
|
+
"""Append a non-sensitive audit record with owner-only permissions."""
|
|
337
|
+
path = self.runtime_path / "executions.jsonl"
|
|
338
|
+
line = json.dumps(record, sort_keys=True) + "\n"
|
|
339
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
340
|
+
with self.locks.lock(path, purpose="execution log"):
|
|
341
|
+
size = path.stat().st_size if path.exists() else 0
|
|
342
|
+
if size and size + len(line.encode("utf-8")) > EXECUTION_LOG_MAX_BYTES:
|
|
343
|
+
self._rotate_execution_logs(path)
|
|
344
|
+
descriptor = os.open(path, os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
|
|
345
|
+
# Existing logs might have been created by an older release with
|
|
346
|
+
# the user's umask. Repair them on every append as part of the
|
|
347
|
+
# audit log's confidentiality contract.
|
|
348
|
+
os.fchmod(descriptor, 0o600)
|
|
349
|
+
with os.fdopen(descriptor, "a", encoding="utf-8") as handle:
|
|
350
|
+
handle.write(line)
|
|
351
|
+
handle.flush()
|
|
352
|
+
os.fsync(handle.fileno())
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def _rotate_execution_logs(path: Path) -> None:
|
|
356
|
+
"""Retain a bounded number of complete invocation logs."""
|
|
357
|
+
oldest = path.with_name(f"{path.name}.{EXECUTION_LOG_RETENTION}")
|
|
358
|
+
oldest.unlink(missing_ok=True)
|
|
359
|
+
for index in range(EXECUTION_LOG_RETENTION - 1, 0, -1):
|
|
360
|
+
source = path.with_name(f"{path.name}.{index}")
|
|
361
|
+
if source.exists():
|
|
362
|
+
source.replace(path.with_name(f"{path.name}.{index + 1}"))
|
|
363
|
+
if path.exists():
|
|
364
|
+
path.replace(path.with_name(f"{path.name}.1"))
|
|
365
|
+
|
|
366
|
+
def cleanup_locks(self) -> int:
|
|
367
|
+
"""Remove inactive ww lock sidecars while excluding active commands."""
|
|
368
|
+
return self.locks.cleanup()
|
|
369
|
+
|
|
370
|
+
def read_bootstrap(self, request_id: str) -> dict[str, Any] | None:
|
|
371
|
+
"""Read an unresolved external-task identity request."""
|
|
372
|
+
path = self.runtime_path / "bootstrap" / f"{request_id}.json"
|
|
373
|
+
if not path.exists():
|
|
374
|
+
return None
|
|
375
|
+
try:
|
|
376
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
377
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
378
|
+
raise StateError(f"invalid bootstrap request {path}: {error}") from error
|
|
379
|
+
if not isinstance(value, dict):
|
|
380
|
+
raise StateError(f"invalid bootstrap request {path}: expected a mapping")
|
|
381
|
+
return value
|
|
382
|
+
|
|
383
|
+
def write_bootstrap(self, request_id: str, value: dict[str, Any]) -> None:
|
|
384
|
+
"""Persist one unresolved external-task identity request."""
|
|
385
|
+
self.locks.atomic_write(
|
|
386
|
+
self.runtime_path / "bootstrap" / f"{request_id}.json",
|
|
387
|
+
json.dumps(value, indent=2) + "\n",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def with_runtime_ignored(text: str) -> tuple[str, tuple[str, ...], tuple[str, ...]]:
|
|
392
|
+
"""``text`` keeping ``.ww`` out of Git but for the shared files.
|
|
393
|
+
|
|
394
|
+
Returns the new text, the lines it adds, and the whole-directory lines it
|
|
395
|
+
replaces.
|
|
396
|
+
A re-inclusion counts only after the last ``.ww/*`` line, since a later
|
|
397
|
+
``.ww/*`` would ignore the file again. The file's line ending is kept.
|
|
398
|
+
"""
|
|
399
|
+
newline = newline_of(text)
|
|
400
|
+
star, *shared = RUNTIME_IGNORE_LINES
|
|
401
|
+
lines = text.splitlines(keepends=True)
|
|
402
|
+
if lines and not lines[-1].endswith(("\n", "\r")):
|
|
403
|
+
lines[-1] += newline
|
|
404
|
+
whole = [
|
|
405
|
+
line.strip() for line in lines if line.strip() in WHOLE_RUNTIME_IGNORE_LINES
|
|
406
|
+
]
|
|
407
|
+
added: list[str] = []
|
|
408
|
+
if whole:
|
|
409
|
+
has_star = star in (line.strip() for line in lines)
|
|
410
|
+
kept: list[str] = []
|
|
411
|
+
for line in lines:
|
|
412
|
+
if line.strip() not in WHOLE_RUNTIME_IGNORE_LINES:
|
|
413
|
+
kept.append(line)
|
|
414
|
+
elif not has_star:
|
|
415
|
+
kept.extend(f"{entry}{newline}" for entry in RUNTIME_IGNORE_LINES)
|
|
416
|
+
added.extend(RUNTIME_IGNORE_LINES)
|
|
417
|
+
has_star = True
|
|
418
|
+
lines = kept
|
|
419
|
+
entries = [line.strip() for line in lines]
|
|
420
|
+
if star not in entries:
|
|
421
|
+
lines.extend(f"{entry}{newline}" for entry in RUNTIME_IGNORE_LINES)
|
|
422
|
+
added.extend(RUNTIME_IGNORE_LINES)
|
|
423
|
+
else:
|
|
424
|
+
last = len(entries) - 1 - entries[::-1].index(star)
|
|
425
|
+
missing = [entry for entry in shared if entry not in entries[last + 1 :]]
|
|
426
|
+
# After the re-inclusions that already follow it, keeping them together.
|
|
427
|
+
position = last + 1
|
|
428
|
+
while position < len(entries) and entries[position].startswith("!.ww/"):
|
|
429
|
+
position += 1
|
|
430
|
+
lines[position:position] = [f"{entry}{newline}" for entry in missing]
|
|
431
|
+
added.extend(entry for entry in missing if entry not in added)
|
|
432
|
+
updated = "".join(lines)
|
|
433
|
+
if not whole and not added:
|
|
434
|
+
return text, (), ()
|
|
435
|
+
return updated, tuple(added), tuple(dict.fromkeys(whole))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _read_gitignore(path: Path) -> str:
|
|
439
|
+
"""A .gitignore's text with its own line endings, or ``""`` when absent."""
|
|
440
|
+
return path.read_bytes().decode("utf-8") if path.exists() else ""
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _appended(text: str, entries: tuple[str, ...]) -> str:
|
|
444
|
+
"""``text`` with ``entries`` appended as lines, in its own line ending."""
|
|
445
|
+
newline = newline_of(text)
|
|
446
|
+
separator = "" if not text or text.endswith("\n") else newline
|
|
447
|
+
return text + separator + "".join(f"{entry}{newline}" for entry in entries)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Storage adapters for task runs, artifacts, and metadata."""
|
|
3
|
+
|
|
4
|
+
from ww.storage_adapters.base import (
|
|
5
|
+
ArtifactAddress,
|
|
6
|
+
CommandOutputAddress,
|
|
7
|
+
ProjectMetadata,
|
|
8
|
+
ProjectMetadataStorage,
|
|
9
|
+
TaskArtifactStorage,
|
|
10
|
+
TaskMetadata,
|
|
11
|
+
TaskMetadataStorage,
|
|
12
|
+
TaskRunStorage,
|
|
13
|
+
TaskStorageAdapter,
|
|
14
|
+
)
|
|
15
|
+
from ww.storage_adapters.filesystem import FileTaskStorageAdapter
|
|
16
|
+
from ww.storage_adapters.memory import MemoryTaskStorageAdapter
|
|
17
|
+
from ww.storage_adapters.project_metadata import (
|
|
18
|
+
FileProjectMetadataStorageAdapter,
|
|
19
|
+
MemoryProjectMetadataStorageAdapter,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ArtifactAddress",
|
|
24
|
+
"CommandOutputAddress",
|
|
25
|
+
"FileProjectMetadataStorageAdapter",
|
|
26
|
+
"FileTaskStorageAdapter",
|
|
27
|
+
"MemoryProjectMetadataStorageAdapter",
|
|
28
|
+
"MemoryTaskStorageAdapter",
|
|
29
|
+
"ProjectMetadata",
|
|
30
|
+
"ProjectMetadataStorage",
|
|
31
|
+
"TaskArtifactStorage",
|
|
32
|
+
"TaskMetadata",
|
|
33
|
+
"TaskMetadataStorage",
|
|
34
|
+
"TaskStorageAdapter",
|
|
35
|
+
"TaskRunStorage",
|
|
36
|
+
]
|