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
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
+ ]