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,484 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Compact on-disk codec for the authoritative task state document."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import copy
7
+ import hashlib
8
+ import json
9
+ from dataclasses import MISSING, fields
10
+ from typing import Any
11
+
12
+ from ww.children import ChildTask
13
+ from ww.contracts import run_is_open
14
+ from ww.errors import ConfigurationError
15
+ from ww.execution_models import (
16
+ CommandExecution,
17
+ ExecutionState,
18
+ PlanItemExecution,
19
+ StepProgress,
20
+ TaskRunAggregate,
21
+ )
22
+ from ww.extensions import parse_reference
23
+ from ww.items import WorkItem
24
+ from ww.plan import PlanItem
25
+ from ww.validation import is_strict_int
26
+
27
+ TASK_STATE_FORMAT = "ww.task-state"
28
+ TASK_STATE_SCHEMA_VERSION = 2
29
+
30
+
31
+ def _serialized_defaults(cls: type, **overrides: object) -> dict[str, object]:
32
+ """Return the serialized default of every defaulted field of a record.
33
+
34
+ The compact document omits a field whose value equals this default and
35
+ restores it on read, so both directions derive from the same table and
36
+ cannot drift from the dataclass. Tuples serialize as lists; fields whose
37
+ serialized form differs from their Python default are named explicitly.
38
+ """
39
+ defaults: dict[str, object] = {}
40
+ for field in fields(cls):
41
+ if field.default is not MISSING:
42
+ value: object = field.default
43
+ elif field.default_factory is not MISSING:
44
+ value = field.default_factory()
45
+ else:
46
+ continue
47
+ defaults[field.name] = list(value) if isinstance(value, tuple) else value
48
+ defaults.update(overrides)
49
+ return defaults
50
+
51
+
52
+ _RUN_DEFAULTS = _serialized_defaults(TaskRunAggregate)
53
+ _PLAN_ITEM_DEFAULTS = _serialized_defaults(PlanItem)
54
+ _ITEM_EXECUTION_DEFAULTS = _serialized_defaults(
55
+ PlanItemExecution, supplied_values={}, output_values={}
56
+ )
57
+ _COMMAND_DEFAULTS = _serialized_defaults(CommandExecution)
58
+ _STEP_DEFAULTS = _serialized_defaults(StepProgress)
59
+ # ``modes`` and ``active_item_id`` have no dataclass default, yet the writer
60
+ # has always omitted an empty mode list and a missing active item.
61
+ _STATE_DEFAULTS = _serialized_defaults(
62
+ ExecutionState,
63
+ modes=[],
64
+ active_item_id=None,
65
+ workflow_values={},
66
+ loop_iterations={},
67
+ pending_task_metadata={},
68
+ )
69
+ _WORK_ITEM_DEFAULTS = _serialized_defaults(WorkItem)
70
+ _CHILD_DEFAULTS = _serialized_defaults(ChildTask, fields={})
71
+ _LEDGER_EVENT_DEFAULTS: dict[str, object] = {"summary": None}
72
+
73
+ _EXTENSION_SNAPSHOT_FIELDS = (
74
+ "version",
75
+ "api_version",
76
+ "source",
77
+ "fingerprint",
78
+ "settings",
79
+ )
80
+
81
+
82
+ def encode_task_document(
83
+ task_id: str,
84
+ runs: tuple[TaskRunAggregate, ...],
85
+ handoff: str | None,
86
+ revision: int,
87
+ ledger: dict[str, list[dict[str, object]]],
88
+ ) -> dict[str, object]:
89
+ """Encode expanded domain records without mutating them."""
90
+ snapshots: dict[str, dict[str, object]] = {}
91
+ encoded_runs = [
92
+ _compact_run(copy.deepcopy(run.to_dict()), snapshots) for run in runs
93
+ ]
94
+ active = [run.run_id for run in runs if run_is_open(run.state.status)]
95
+ if len(active) > 1:
96
+ raise ValueError("task state has multiple active runs")
97
+ result: dict[str, object] = {
98
+ "format": TASK_STATE_FORMAT,
99
+ "schema_version": TASK_STATE_SCHEMA_VERSION,
100
+ "task_id": task_id,
101
+ "revision": revision,
102
+ "active_run": active[0] if active else None,
103
+ "runs": encoded_runs,
104
+ }
105
+ if snapshots:
106
+ result["extension_snapshots"] = snapshots
107
+ if handoff is not None:
108
+ result["handoff"] = handoff
109
+ if ledger:
110
+ result["ledger"] = _compact_ledger(copy.deepcopy(ledger))
111
+ return result
112
+
113
+
114
+ def decode_task_document(
115
+ data: object, task_id: str
116
+ ) -> tuple[
117
+ tuple[TaskRunAggregate, ...],
118
+ str | None,
119
+ int,
120
+ dict[str, list[dict[str, object]]],
121
+ ]:
122
+ """Expand a compact task document and invoke the strict domain decoders."""
123
+ if not isinstance(data, dict):
124
+ raise ValueError("task state must be a mapping")
125
+ if data.get("format") != TASK_STATE_FORMAT:
126
+ raise ValueError(f"unsupported task state format: {data.get('format')!r}")
127
+ version = data.get("schema_version")
128
+ if not is_strict_int(version) or version != TASK_STATE_SCHEMA_VERSION:
129
+ raise ValueError("unsupported task state schema")
130
+ if data.get("task_id") != task_id:
131
+ raise ValueError("task state task ID does not match its path")
132
+ revision = data.get("revision")
133
+ if not is_strict_int(revision) or revision < 0:
134
+ raise ValueError("task state revision must be non-negative")
135
+ if "active_run" not in data:
136
+ raise ValueError("task state missing field: active_run")
137
+ active_run = data["active_run"]
138
+ if active_run is not None and not isinstance(active_run, str):
139
+ raise ValueError("task state active_run must be a string or null")
140
+ raw_runs = data.get("runs")
141
+ if not isinstance(raw_runs, list):
142
+ raise ValueError("task state runs must be a list")
143
+ raw_snapshots = data.get("extension_snapshots", {})
144
+ snapshots = _validate_extension_snapshots(raw_snapshots)
145
+ runs = tuple(
146
+ TaskRunAggregate.from_dict(_expand_run(copy.deepcopy(run), snapshots))
147
+ for run in raw_runs
148
+ )
149
+ actual_active = [run.run_id for run in runs if run_is_open(run.state.status)]
150
+ if len(actual_active) > 1:
151
+ raise ValueError("task state has multiple active runs")
152
+ expected_active = actual_active[0] if actual_active else None
153
+ if active_run != expected_active:
154
+ raise ValueError("task state active_run does not match the non-completed run")
155
+ handoff = data.get("handoff")
156
+ if handoff is not None and not isinstance(handoff, str):
157
+ raise ValueError("task state handoff must be a string or null")
158
+ ledger = data.get("ledger", {})
159
+ if not isinstance(ledger, dict):
160
+ raise ValueError("task state ledger must be a mapping")
161
+ return runs, handoff, revision, _expand_ledger(copy.deepcopy(ledger))
162
+
163
+
164
+ def _compact_run(
165
+ run: dict[str, Any], snapshots: dict[str, dict[str, object]]
166
+ ) -> dict[str, Any]:
167
+ snapshot = run["snapshot"]
168
+ _compact_snapshot(snapshot, snapshots)
169
+ _compact_state(run["state"])
170
+ for item in run.get("items", []):
171
+ _omit_defaults(item, _WORK_ITEM_DEFAULTS)
172
+ for child in run.get("children", []):
173
+ _omit_defaults(child, _CHILD_DEFAULTS)
174
+ _omit_defaults(run, _RUN_DEFAULTS)
175
+ return run
176
+
177
+
178
+ def _compact_snapshot(
179
+ snapshot: dict[str, Any], snapshots: dict[str, dict[str, object]]
180
+ ) -> None:
181
+ plan = snapshot["plan"]
182
+ if snapshot.get("template_plan") == plan:
183
+ snapshot.pop("template_plan", None)
184
+ _compact_plan(plan, snapshots)
185
+ template = snapshot.get("template_plan")
186
+ if template is not None:
187
+ _compact_plan(template, snapshots)
188
+ _diff_against_templates(plan, template)
189
+
190
+
191
+ def _compact_plan(
192
+ plan: dict[str, Any], snapshots: dict[str, dict[str, object]]
193
+ ) -> None:
194
+ for item in plan["items"]:
195
+ operation = item["operation"]
196
+ if operation["type"] == "action" and operation["identifier"] == "extension":
197
+ payload = operation["payload"]
198
+ reference = payload.get("reference")
199
+ if not isinstance(reference, str):
200
+ raise ValueError("extension plan item requires a reference")
201
+ identifier = _extension_identifier(reference)
202
+ entry: dict[str, object] = {"identifier": identifier}
203
+ for field in _EXTENSION_SNAPSHOT_FIELDS:
204
+ entry[field] = copy.deepcopy(payload.pop(field))
205
+ snapshot_id = _snapshot_id(entry)
206
+ existing = snapshots.get(snapshot_id)
207
+ if existing is not None and existing != entry:
208
+ raise ValueError("extension snapshot hash collision")
209
+ snapshots[snapshot_id] = entry
210
+ payload["snapshot"] = snapshot_id
211
+ _omit_defaults(item, _PLAN_ITEM_DEFAULTS)
212
+
213
+
214
+ def _diff_against_templates(plan: dict[str, Any], template: dict[str, Any]) -> None:
215
+ """Store each plan item as the fields that differ from its template item.
216
+
217
+ The diff runs on items already compacted, so a field equal in both is
218
+ omitted either way. A field the item leaves at its default while the
219
+ template sets it is written out with the default, since the reader starts
220
+ from the template item. An item with no template item is stored in full.
221
+ """
222
+ templates = {item["id"]: item for item in template["items"]}
223
+ compacted: list[dict[str, Any]] = []
224
+ for item in plan["items"]:
225
+ template_id = _template_id(item["id"], templates)
226
+ if template_id is None:
227
+ compacted.append(item)
228
+ continue
229
+ source = templates[template_id]
230
+ diff: dict[str, Any] = {"id": item["id"], "template": template_id}
231
+ for name, value in item.items():
232
+ if name != "id" and source.get(name, MISSING) != value:
233
+ diff[name] = value
234
+ for name in source:
235
+ if name not in item:
236
+ diff[name] = copy.deepcopy(_PLAN_ITEM_DEFAULTS[name])
237
+ compacted.append(diff)
238
+ plan["items"] = compacted
239
+
240
+
241
+ def _template_id(item_id: str, templates: dict[str, Any]) -> str | None:
242
+ """Return the template item an item was expanded from, if any.
243
+
244
+ An item keeps its template item's ID, or extends it with a suffix such
245
+ as ``:item:<item id>`` or ``:child:<number>``; the longest such prefix
246
+ wins, so an expansion of an expansion finds its nearest template item.
247
+ """
248
+ if item_id in templates:
249
+ return item_id
250
+ prefixes = [
251
+ template_id
252
+ for template_id in templates
253
+ if item_id.startswith(template_id + ":")
254
+ ]
255
+ return max(prefixes, key=len, default=None)
256
+
257
+
258
+ def _compact_state(state: dict[str, Any]) -> None:
259
+ for item in [*state["item_executions"], *state["execution_history"]]:
260
+ for command in item["commands"]:
261
+ _omit_defaults(command, _COMMAND_DEFAULTS)
262
+ _omit_defaults(item, _ITEM_EXECUTION_DEFAULTS)
263
+ for step in state["steps"]:
264
+ _compact_step(step)
265
+ _omit_defaults(state, _STATE_DEFAULTS)
266
+
267
+
268
+ def _compact_step(step: dict[str, Any]) -> None:
269
+ for child in step["children"]:
270
+ _compact_step(child)
271
+ _omit_defaults(step, _STEP_DEFAULTS)
272
+
273
+
274
+ def _expand_run(run: object, snapshots: dict[str, dict[str, object]]) -> dict[str, Any]:
275
+ if not isinstance(run, dict):
276
+ raise ValueError("run must be a mapping")
277
+ _add_defaults(run, _RUN_DEFAULTS)
278
+ snapshot = run.get("snapshot")
279
+ if not isinstance(snapshot, dict):
280
+ raise ValueError("run.snapshot must be a mapping")
281
+ _expand_snapshot(snapshot, snapshots)
282
+ state = run.get("state")
283
+ if not isinstance(state, dict):
284
+ raise ValueError("run.state must be a mapping")
285
+ _expand_state(state)
286
+ for item in _list(run["items"], "run.items"):
287
+ if not isinstance(item, dict):
288
+ raise ValueError("run.items entries must be mappings")
289
+ _add_defaults(item, _WORK_ITEM_DEFAULTS)
290
+ for child in _list(run["children"], "run.children"):
291
+ if not isinstance(child, dict):
292
+ raise ValueError("run.children entries must be mappings")
293
+ _add_defaults(child, _CHILD_DEFAULTS)
294
+ return run
295
+
296
+
297
+ def _expand_snapshot(
298
+ snapshot: dict[str, Any], snapshots: dict[str, dict[str, object]]
299
+ ) -> None:
300
+ plan = snapshot.get("plan")
301
+ if not isinstance(plan, dict):
302
+ raise ValueError("plan snapshot.plan must be a mapping")
303
+ template = snapshot.get("template_plan")
304
+ if template is not None and not isinstance(template, dict):
305
+ raise ValueError("plan snapshot.template_plan must be a mapping")
306
+ _restore_from_templates(plan, template)
307
+ _expand_plan(plan, snapshots)
308
+ if template is None:
309
+ snapshot["template_plan"] = copy.deepcopy(plan)
310
+ else:
311
+ _expand_plan(template, snapshots)
312
+
313
+
314
+ def _restore_from_templates(
315
+ plan: dict[str, Any], template: dict[str, Any] | None
316
+ ) -> None:
317
+ """Rebuild each plan item stored as a diff against its template item."""
318
+ templates: dict[str, dict[str, Any]] = {}
319
+ if template is not None:
320
+ for item in _list(template.get("items"), "template_plan.items"):
321
+ if not isinstance(item, dict) or not isinstance(item.get("id"), str):
322
+ raise ValueError("template_plan.items entries must be mappings")
323
+ templates[item["id"]] = item
324
+ restored: list[Any] = []
325
+ for item in _list(plan.get("items"), "plan.items"):
326
+ if not isinstance(item, dict) or "template" not in item:
327
+ restored.append(item)
328
+ continue
329
+ template_id = item.pop("template")
330
+ source = templates.get(template_id) if isinstance(template_id, str) else None
331
+ if source is None:
332
+ raise ValueError(f"plan item names an unknown template: {template_id!r}")
333
+ restored.append({**copy.deepcopy(source), **item})
334
+ plan["items"] = restored
335
+
336
+
337
+ def _expand_plan(plan: dict[str, Any], snapshots: dict[str, dict[str, object]]) -> None:
338
+ for item in _list(plan.get("items"), "plan.items"):
339
+ if not isinstance(item, dict):
340
+ raise ValueError("plan.items entries must be mappings")
341
+ _add_defaults(item, _PLAN_ITEM_DEFAULTS)
342
+ operation = item.get("operation")
343
+ if not isinstance(operation, dict):
344
+ raise ValueError("plan item operation must be a mapping")
345
+ if operation.get("type") != "action":
346
+ continue
347
+ payload = operation.get("payload")
348
+ if not isinstance(payload, dict):
349
+ raise ValueError("plan action payload must be a mapping")
350
+ # Snapshot indirection belongs exclusively to extension payloads.
351
+ # Other registered actions may freely use a field named ``snapshot``.
352
+ if operation.get("identifier") != "extension":
353
+ continue
354
+ inline = set(payload) & set(_EXTENSION_SNAPSHOT_FIELDS)
355
+ has_snapshot = "snapshot" in payload
356
+ snapshot_id = payload.pop("snapshot", None)
357
+ if inline and has_snapshot:
358
+ raise ValueError("extension item has conflicting inline snapshot fields")
359
+ if not has_snapshot:
360
+ raise ValueError("extension plan item missing extension_snapshot")
361
+ if not isinstance(snapshot_id, str):
362
+ raise ValueError("extension_snapshot must be a string")
363
+ entry = snapshots.get(snapshot_id)
364
+ if entry is None:
365
+ raise ValueError(f"unknown extension snapshot: {snapshot_id!r}")
366
+ reference = payload.get("reference")
367
+ if not isinstance(reference, str):
368
+ raise ValueError("extension plan item requires a reference")
369
+ if _extension_identifier(reference) != entry["identifier"]:
370
+ raise ValueError("extension snapshot identifier does not match reference")
371
+ for field in _EXTENSION_SNAPSHOT_FIELDS:
372
+ payload[field] = copy.deepcopy(entry[field])
373
+
374
+
375
+ def _expand_state(state: dict[str, Any]) -> None:
376
+ _add_defaults(state, _STATE_DEFAULTS)
377
+ for field in ("item_executions", "execution_history"):
378
+ for item in _list(state.get(field), f"state.{field}"):
379
+ if not isinstance(item, dict):
380
+ raise ValueError(f"state.{field} entries must be mappings")
381
+ _add_defaults(item, _ITEM_EXECUTION_DEFAULTS)
382
+ for command in _list(item["commands"], f"{field}.commands"):
383
+ if not isinstance(command, dict):
384
+ raise ValueError("command execution must be a mapping")
385
+ _add_defaults(command, _COMMAND_DEFAULTS)
386
+ for step in _list(state.get("steps"), "state.steps"):
387
+ _expand_step(step)
388
+
389
+
390
+ def _expand_step(step: object) -> None:
391
+ if not isinstance(step, dict):
392
+ raise ValueError("step progress must be a mapping")
393
+ _add_defaults(step, _STEP_DEFAULTS)
394
+ for child in _list(step["children"], "step.children"):
395
+ _expand_step(child)
396
+
397
+
398
+ def _validate_extension_snapshots(value: object) -> dict[str, dict[str, object]]:
399
+ if not isinstance(value, dict):
400
+ raise ValueError("extension_snapshots must be a mapping")
401
+ result: dict[str, dict[str, object]] = {}
402
+ required = {
403
+ "identifier",
404
+ "version",
405
+ "api_version",
406
+ "source",
407
+ "fingerprint",
408
+ "settings",
409
+ }
410
+ for snapshot_id, raw in value.items():
411
+ if not isinstance(snapshot_id, str) or not isinstance(raw, dict):
412
+ raise ValueError("extension snapshot entries are invalid")
413
+ if not required <= set(raw):
414
+ raise ValueError("extension snapshot has invalid fields")
415
+ if not isinstance(raw["identifier"], str):
416
+ raise ValueError("extension snapshot identifier must be a string")
417
+ for name in ("version", "source", "fingerprint"):
418
+ if raw[name] is not None and not isinstance(raw[name], str):
419
+ raise ValueError(f"extension snapshot {name} must be a string or null")
420
+ if raw["api_version"] is not None and not is_strict_int(raw["api_version"]):
421
+ raise ValueError(
422
+ "extension snapshot api_version must be an integer or null"
423
+ )
424
+ if raw["settings"] is not None and not isinstance(raw["settings"], dict):
425
+ raise ValueError("extension snapshot settings must be a mapping or null")
426
+ entry = copy.deepcopy(raw)
427
+ if _snapshot_id(entry) != snapshot_id:
428
+ raise ValueError("extension snapshot content hash does not match its ID")
429
+ result[snapshot_id] = entry
430
+ return result
431
+
432
+
433
+ def _compact_ledger(
434
+ ledger: dict[str, list[dict[str, object]]],
435
+ ) -> dict[str, list[dict[str, object]]]:
436
+ for events in ledger.values():
437
+ for event in events:
438
+ _omit_defaults(event, _LEDGER_EVENT_DEFAULTS)
439
+ return ledger
440
+
441
+
442
+ def _expand_ledger(value: dict[object, object]) -> dict[str, list[dict[str, object]]]:
443
+ result: dict[str, list[dict[str, object]]] = {}
444
+ for run_id, events in value.items():
445
+ if not isinstance(run_id, str) or not isinstance(events, list):
446
+ raise ValueError("task state ledger entries are invalid")
447
+ expanded: list[dict[str, object]] = []
448
+ for event in events:
449
+ if not isinstance(event, dict):
450
+ raise ValueError("task state ledger events must be mappings")
451
+ _add_defaults(event, _LEDGER_EVENT_DEFAULTS)
452
+ expanded.append(event)
453
+ result[run_id] = expanded
454
+ return result
455
+
456
+
457
+ def _snapshot_id(entry: dict[str, object]) -> str:
458
+ canonical = json.dumps(entry, sort_keys=True, separators=(",", ":"))
459
+ return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
460
+
461
+
462
+ def _extension_identifier(reference: str) -> str:
463
+ try:
464
+ return parse_reference(reference).identifier
465
+ except ConfigurationError as error:
466
+ raise ValueError(str(error)) from error
467
+
468
+
469
+ def _omit_defaults(mapping: dict[str, Any], defaults: dict[str, object]) -> None:
470
+ for name, default in defaults.items():
471
+ if name in mapping and mapping[name] == default:
472
+ mapping.pop(name)
473
+
474
+
475
+ def _add_defaults(mapping: dict[str, Any], defaults: dict[str, object]) -> None:
476
+ for name, default in defaults.items():
477
+ if name not in mapping:
478
+ mapping[name] = copy.deepcopy(default)
479
+
480
+
481
+ def _list(value: object, context: str) -> list[Any]:
482
+ if not isinstance(value, list):
483
+ raise ValueError(f"{context} must be a list")
484
+ return value
ww/task_ids.py ADDED
@@ -0,0 +1,114 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Task identity policy: validation, generation, and claim checks."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import itertools
7
+ import re
8
+ import uuid
9
+ from collections.abc import Iterator
10
+ from datetime import datetime, timezone
11
+
12
+ from ww.contracts import BOOTSTRAP_REQUEST_PREFIX
13
+ from ww.errors import StateError
14
+ from ww.extensions import ExtensionRegistry
15
+ from ww.project_config import EXPLICIT_TASK_FORMAT
16
+ from ww.storage_adapters import TaskStorageAdapter
17
+
18
+ GENERATED_TASK_PREFIX = "TASK-"
19
+ # A task ID is ``parent`` or ``parent/child``; deeper nesting is unsupported.
20
+ MAX_TASK_ID_SEGMENTS = 2
21
+ # Suffixed candidates tried for a template without a ``{{digit}}`` counter.
22
+ ID_ATTEMPT_LIMIT = 50
23
+ # One task ID segment, e.g. "TASK-12" or "FOOBAR_1.2"; "TASK 12" does not match.
24
+ _SEGMENT = re.compile(r"[A-Za-z0-9._-]+")
25
+
26
+
27
+ def validate_task_id(task_id: str) -> None:
28
+ segments = task_id.split("/")
29
+ if (
30
+ not task_id
31
+ or len(segments) > MAX_TASK_ID_SEGMENTS
32
+ or any(
33
+ segment in {".", ".."} or not _SEGMENT.fullmatch(segment)
34
+ for segment in segments
35
+ )
36
+ ):
37
+ raise StateError(
38
+ "invalid task ID; use one or two slash-separated normalized names"
39
+ )
40
+
41
+
42
+ def validate_child_id(child_id: str) -> None:
43
+ validate_task_id(child_id)
44
+ if "/" in child_id:
45
+ raise StateError("child ID must be a single normalized name")
46
+
47
+
48
+ def is_bootstrap_request(task_id: str) -> bool:
49
+ return task_id.startswith(BOOTSTRAP_REQUEST_PREFIX)
50
+
51
+
52
+ def generated_task_id(task_format: str | None = None) -> str:
53
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
54
+ if task_format is None:
55
+ return GENERATED_TASK_PREFIX + timestamp
56
+ return task_format.replace("{{timestamp}}", timestamp).replace(
57
+ "{{uuid}}", str(uuid.uuid4())
58
+ )
59
+
60
+
61
+ def generated_bootstrap_id() -> str:
62
+ return BOOTSTRAP_REQUEST_PREFIX + datetime.now(timezone.utc).strftime(
63
+ "%Y%m%d%H%M%S%f"
64
+ )
65
+
66
+
67
+ def candidate_task_ids(task_format: str | None) -> Iterator[str]:
68
+ """Yield IDs to try, in order, for a generated task.
69
+
70
+ Generated IDs are second-resolution, so two starts in the same second
71
+ would otherwise collide. A template with ``{{digit}}`` counts upward
72
+ without limit; any other template tries the base name and then a bounded
73
+ number of numeric suffixes. The ``explicit`` format yields nothing.
74
+ """
75
+ if task_format == EXPLICIT_TASK_FORMAT:
76
+ raise StateError(
77
+ "this project requires an explicit task ID (task_format: explicit); "
78
+ "pass the external key, such as the tracker issue key, to start"
79
+ )
80
+ base = generated_task_id(task_format)
81
+ if task_format is not None and "{{digit}}" in task_format:
82
+ for attempt in itertools.count(1):
83
+ yield base.replace("{{digit}}", str(attempt))
84
+ return
85
+ yield base
86
+ for attempt in range(2, ID_ATTEMPT_LIMIT + 1):
87
+ yield f"{base}-{attempt}"
88
+
89
+
90
+ def task_id_claimed(
91
+ task_id: str,
92
+ *,
93
+ tasks: TaskStorageAdapter,
94
+ extensions: ExtensionRegistry,
95
+ workflow_name: str | None = None,
96
+ project: str | None = None,
97
+ lane: str | None = None,
98
+ ) -> bool:
99
+ """Return whether task state or an extension already claims ``task_id``.
100
+
101
+ An extension's artefact (for example a Git worktree) or record (for
102
+ example a Git branch record) can outlive the task state it belonged to.
103
+ Extensions report such paths and claims themselves, so core needs no
104
+ knowledge of any one extension's settings; a task starting in a
105
+ configured project asks under that project's settings.
106
+ """
107
+ if tasks.task_exists(task_id):
108
+ return True
109
+ if any(
110
+ path.exists()
111
+ for path in extensions.reserved_paths(task_id, workflow_name, project, lane)
112
+ ):
113
+ return True
114
+ return extensions.claims_task(task_id, workflow_name, project, lane)