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