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/item_passes.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""What an expanded ``items`` pass requires of its items when it ends.
|
|
3
|
+
|
|
4
|
+
A workflow has one item collection and any number of sequential ``items``
|
|
5
|
+
passes over it. Each pass declares what its stages do with an item through
|
|
6
|
+
``item_phase``, and only that is required when the run leaves the pass: an
|
|
7
|
+
analyze stage needs the item's analysis, a resolve stage its actual solution
|
|
8
|
+
and ``resolved``, a report stage ``reported``, and a phase stage's declared
|
|
9
|
+
item saves their values. The built-in ``handle-item`` stage keeps its whole
|
|
10
|
+
lifecycle: ``resolved`` and ``reported``. A stage without ``item_phase``
|
|
11
|
+
has only its ordinary completion contract. A stage that never ran, because
|
|
12
|
+
an assessment, a break, or a stop skipped it, requires nothing. When the
|
|
13
|
+
pass ends with an assessment, it is left only once the outcome is chosen.
|
|
14
|
+
|
|
15
|
+
A linked item (``reference_to_id``) shares its canonical item's analysis and
|
|
16
|
+
solution, so a duplicate comment needs no duplicate fix, but it is reported,
|
|
17
|
+
and resolved, for its own source.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from ww.execution_models import PlanItemExecution
|
|
23
|
+
from ww.items import WorkItem
|
|
24
|
+
from ww.plan import PlanItem, WorkflowPlan
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def item_collection(plan: WorkflowPlan) -> PlanItem | None:
|
|
28
|
+
"""The first ``items`` declaration, which holds the collection's settings.
|
|
29
|
+
|
|
30
|
+
Every pass works on the same collection; its ``persistent``,
|
|
31
|
+
``identity``, and ``unique`` settings are the first declaration's. The
|
|
32
|
+
shared validator rejects a later declaration that sets them differently.
|
|
33
|
+
"""
|
|
34
|
+
return next(
|
|
35
|
+
(
|
|
36
|
+
item
|
|
37
|
+
for item in plan.items
|
|
38
|
+
if item.item_operation == "collect" and item.child_operation is None
|
|
39
|
+
),
|
|
40
|
+
None,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_pass_stage(item: PlanItem) -> bool:
|
|
45
|
+
"""Whether ``item`` is a concrete stage, hook, or verifier of an item pass."""
|
|
46
|
+
return (
|
|
47
|
+
item.item_pass is not None
|
|
48
|
+
and item.item_id is not None
|
|
49
|
+
and item.child_stage is None
|
|
50
|
+
and not item.item_template
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reports_item_on_completion(plan: WorkflowPlan, cursor: int) -> bool:
|
|
55
|
+
"""Whether completing ``plan.items[cursor]`` finishes an automatic report.
|
|
56
|
+
|
|
57
|
+
A report stage's lifecycle is its step, its handler-group members and its
|
|
58
|
+
completion hooks: the plan items of one item and pass with the report
|
|
59
|
+
operation. When ww runs any of them, the item is reported by the last one
|
|
60
|
+
to complete, whoever owns that last item, so no agent bookkeeping is
|
|
61
|
+
needed. A report stage that ww runs none of is reported by its agent
|
|
62
|
+
with ``update-item --reported=true``, as before.
|
|
63
|
+
"""
|
|
64
|
+
current = plan.items[cursor]
|
|
65
|
+
if current.item_operation != "report_item" or current.item_id is None:
|
|
66
|
+
return False
|
|
67
|
+
lifecycle = [
|
|
68
|
+
other
|
|
69
|
+
for other in plan.items
|
|
70
|
+
if other.item_id == current.item_id
|
|
71
|
+
and other.item_pass == current.item_pass
|
|
72
|
+
and other.item_operation == "report_item"
|
|
73
|
+
]
|
|
74
|
+
return lifecycle[-1].id == current.id and any(
|
|
75
|
+
other.owner == "ww" for other in lifecycle
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def leaving_pass(plan: WorkflowPlan, cursor: int) -> str | None:
|
|
80
|
+
"""The pass whose expanded stages end right before ``cursor``, if any.
|
|
81
|
+
|
|
82
|
+
Assessment outcomes inside a per-item stage carry the stage's item and
|
|
83
|
+
pass, so they count as stages of the pass. Every workflow without a
|
|
84
|
+
handoff ends with its built-in summary, so the run never leaves a pass by
|
|
85
|
+
reaching the end of the plan; a stopping outcome completes the run.
|
|
86
|
+
"""
|
|
87
|
+
if not 0 < cursor < len(plan.items):
|
|
88
|
+
return None
|
|
89
|
+
previous, following = plan.items[cursor - 1], plan.items[cursor]
|
|
90
|
+
if not is_pass_stage(previous) or (
|
|
91
|
+
is_pass_stage(following) and following.item_pass == previous.item_pass
|
|
92
|
+
):
|
|
93
|
+
return None
|
|
94
|
+
return previous.item_pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pass_gate_failures(
|
|
98
|
+
plan: WorkflowPlan,
|
|
99
|
+
records: tuple[PlanItemExecution, ...],
|
|
100
|
+
pass_id: str,
|
|
101
|
+
items: tuple[WorkItem, ...],
|
|
102
|
+
) -> tuple[str, ...]:
|
|
103
|
+
"""What each item of ``pass_id`` still lacks for the stages that ran."""
|
|
104
|
+
by_id = {item.id: item for item in items}
|
|
105
|
+
failures: list[str] = []
|
|
106
|
+
for stage, record in zip(plan.items, records, strict=True):
|
|
107
|
+
if (
|
|
108
|
+
not is_pass_stage(stage)
|
|
109
|
+
or stage.item_pass != pass_id
|
|
110
|
+
or stage.phase != "step"
|
|
111
|
+
or stage.verifies is not None
|
|
112
|
+
or stage.item_operation is None
|
|
113
|
+
or record.status != "completed"
|
|
114
|
+
or record.started_at is None
|
|
115
|
+
):
|
|
116
|
+
continue
|
|
117
|
+
item = by_id.get(str(stage.item_id))
|
|
118
|
+
if item is None:
|
|
119
|
+
failures.append(f"{stage.item_id} ({stage.name}): the item is gone")
|
|
120
|
+
continue
|
|
121
|
+
missing = _missing(stage, item, by_id)
|
|
122
|
+
if missing:
|
|
123
|
+
failures.append(f"{item.id} ({stage.name}): " + ", ".join(missing))
|
|
124
|
+
return tuple(dict.fromkeys(failures))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _missing(
|
|
128
|
+
stage: PlanItem, item: WorkItem, items: dict[str, WorkItem]
|
|
129
|
+
) -> tuple[str, ...]:
|
|
130
|
+
canonical = _canonical(item, items)
|
|
131
|
+
missing: list[str] = []
|
|
132
|
+
operation = stage.item_operation
|
|
133
|
+
if operation == "process_item" and not (
|
|
134
|
+
item.processed_item or canonical.processed_item
|
|
135
|
+
):
|
|
136
|
+
missing.append("processed_item")
|
|
137
|
+
if operation == "resolve_item":
|
|
138
|
+
if not (item.actual_solution or canonical.actual_solution):
|
|
139
|
+
missing.append("actual_solution")
|
|
140
|
+
if not (item.resolved or canonical.resolved):
|
|
141
|
+
missing.append("resolved=true")
|
|
142
|
+
if operation == "report_item" and not item.reported:
|
|
143
|
+
missing.append("reported=true")
|
|
144
|
+
if operation == "handle_item":
|
|
145
|
+
if not item.resolved:
|
|
146
|
+
missing.append("resolved=true")
|
|
147
|
+
if not item.reported:
|
|
148
|
+
missing.append("reported=true")
|
|
149
|
+
missing.extend(
|
|
150
|
+
f"field {field.name}"
|
|
151
|
+
for field in stage.update_item
|
|
152
|
+
if not item.field(field.name)
|
|
153
|
+
)
|
|
154
|
+
return tuple(missing)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _canonical(item: WorkItem, items: dict[str, WorkItem]) -> WorkItem:
|
|
158
|
+
"""The item a linked item refers to, following links; itself otherwise."""
|
|
159
|
+
seen = {item.id}
|
|
160
|
+
current = item
|
|
161
|
+
while current.reference_to_id is not None:
|
|
162
|
+
target = items.get(current.reference_to_id)
|
|
163
|
+
if target is None or target.id in seen:
|
|
164
|
+
break
|
|
165
|
+
seen.add(target.id)
|
|
166
|
+
current = target
|
|
167
|
+
return current
|
ww/items.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Durable, run-local work items used by item-aware workflow steps."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, fields, replace
|
|
8
|
+
|
|
9
|
+
# An item field name, e.g. "acceptance_criteria" or "due-date"; "2nd" does not
|
|
10
|
+
# match.
|
|
11
|
+
FIELD_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class WorkItem:
|
|
16
|
+
"""One collected unit of work inside a run."""
|
|
17
|
+
|
|
18
|
+
id: str
|
|
19
|
+
item: str
|
|
20
|
+
processed_item: str = ""
|
|
21
|
+
proposed_solution: str = ""
|
|
22
|
+
actual_solution: str = ""
|
|
23
|
+
resolved: bool = False
|
|
24
|
+
reported: bool = False
|
|
25
|
+
reference_to_id: str | None = None
|
|
26
|
+
# Custom fields a workflow declares for its items, such as the ID of the
|
|
27
|
+
# source comment or of the reply posted for it; string values only.
|
|
28
|
+
fields: tuple[tuple[str, str], ...] = ()
|
|
29
|
+
|
|
30
|
+
def field(self, name: str) -> str | None:
|
|
31
|
+
return dict(self.fields).get(name)
|
|
32
|
+
|
|
33
|
+
def with_fields(self, values: dict[str, str]) -> WorkItem:
|
|
34
|
+
merged = {**dict(self.fields), **values}
|
|
35
|
+
return replace(self, fields=tuple(merged.items()))
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, object]:
|
|
38
|
+
return {
|
|
39
|
+
"id": self.id,
|
|
40
|
+
"item": self.item,
|
|
41
|
+
"processed_item": self.processed_item,
|
|
42
|
+
"proposed_solution": self.proposed_solution,
|
|
43
|
+
"actual_solution": self.actual_solution,
|
|
44
|
+
"resolved": self.resolved,
|
|
45
|
+
"reported": self.reported,
|
|
46
|
+
"reference_to_id": self.reference_to_id,
|
|
47
|
+
"fields": dict(self.fields),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, data: object) -> WorkItem:
|
|
52
|
+
if not isinstance(data, dict):
|
|
53
|
+
raise ValueError("item must be a mapping")
|
|
54
|
+
|
|
55
|
+
if not isinstance(data.get("id"), str) or not data["id"].strip():
|
|
56
|
+
raise ValueError("item ID must be a non-empty string")
|
|
57
|
+
if not isinstance(data.get("item"), str) or not data["item"].strip():
|
|
58
|
+
raise ValueError("item text must be a non-empty string")
|
|
59
|
+
processed = data.get("processed_item", "")
|
|
60
|
+
proposed = data.get("proposed_solution", "")
|
|
61
|
+
actual = data.get("actual_solution", "")
|
|
62
|
+
if not all(isinstance(value, str) for value in (processed, proposed, actual)):
|
|
63
|
+
raise ValueError("item text fields must be strings")
|
|
64
|
+
resolved = data.get("resolved", False)
|
|
65
|
+
reported = data.get("reported", False)
|
|
66
|
+
if not isinstance(resolved, bool) or not isinstance(reported, bool):
|
|
67
|
+
raise ValueError("item resolved and reported fields must be booleans")
|
|
68
|
+
reference = data.get("reference_to_id")
|
|
69
|
+
if reference is not None and (not isinstance(reference, str) or not reference):
|
|
70
|
+
raise ValueError("item reference_to_id must be a non-empty string or null")
|
|
71
|
+
return cls(
|
|
72
|
+
id=data["id"],
|
|
73
|
+
item=data["item"],
|
|
74
|
+
processed_item=processed,
|
|
75
|
+
proposed_solution=proposed,
|
|
76
|
+
actual_solution=actual,
|
|
77
|
+
resolved=resolved,
|
|
78
|
+
reported=reported,
|
|
79
|
+
reference_to_id=reference,
|
|
80
|
+
fields=validate_item_fields(data.get("fields", {})),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_item_fields(data: object) -> tuple[tuple[str, str], ...]:
|
|
85
|
+
"""Custom fields are a mapping of valid names to strings."""
|
|
86
|
+
if not isinstance(data, dict):
|
|
87
|
+
raise ValueError("item fields must be a mapping")
|
|
88
|
+
for name, value in data.items():
|
|
89
|
+
if not isinstance(name, str) or not FIELD_NAME.fullmatch(name):
|
|
90
|
+
raise ValueError(f"invalid item field name: {name!r}")
|
|
91
|
+
if not isinstance(value, str):
|
|
92
|
+
raise ValueError(f"item field {name!r} must be a string")
|
|
93
|
+
return tuple(data.items())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Fields an agent may update after collection; identity and the item text are fixed.
|
|
97
|
+
EDITABLE_WORK_ITEM_FIELDS = frozenset(
|
|
98
|
+
field.name for field in fields(WorkItem) if field.name not in {"id", "item"}
|
|
99
|
+
)
|
ww/locking.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Exclusive file locking and atomic replacement for ww's writes.
|
|
3
|
+
|
|
4
|
+
Two ww invocations in one project are unrelated processes competing for the
|
|
5
|
+
same task files. Atomic replacement alone is not enough: it stops a reader
|
|
6
|
+
seeing half a write, but not two processes reading one ``state.json``, each
|
|
7
|
+
deciding the next item, and one overwriting the other. The fix is a single
|
|
8
|
+
exclusive lock per task, held by ``WorkflowService`` for the whole of ``start``,
|
|
9
|
+
``next``, ``complete`` or ``reset`` — the span, not the individual write.
|
|
10
|
+
|
|
11
|
+
The task scope is the primary coordination boundary; the project scope wraps
|
|
12
|
+
``ww init``, project metadata has a dedicated merge lock, the execution log has
|
|
13
|
+
its own lock, and aggregate commits use a separate per-aggregate CAS lock for
|
|
14
|
+
direct storage-adapter callers. Everything else writes inside one of those scopes, so
|
|
15
|
+
``atomic_write`` takes no lock of its own. A shared activity gate wraps those
|
|
16
|
+
locks; maintenance takes it exclusively before pruning sidecars.
|
|
17
|
+
|
|
18
|
+
Reads are deliberately unlocked. Replacement is atomic, so a reader always sees
|
|
19
|
+
a complete aggregate document. ``RunCoordinator.load`` selects the requested
|
|
20
|
+
run, execution state, and plan snapshot from one decoded revision rather than
|
|
21
|
+
combining independently read files. Read-only commands therefore do not wait
|
|
22
|
+
behind a mid-flight writer.
|
|
23
|
+
|
|
24
|
+
Locks are advisory POSIX locks on sidecar files under ``.ww/locks/``. The
|
|
25
|
+
kernel releases them when a process exits, so a killed run leaves nothing
|
|
26
|
+
stale. Waiting order is unspecified — no operating system promises FIFO — and
|
|
27
|
+
every wait is bounded by ``WW_LOCK_TIMEOUT`` (seconds, default 30; ``0`` waits
|
|
28
|
+
indefinitely) so a pathological wait fails loudly instead of hanging.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import errno
|
|
34
|
+
import fcntl
|
|
35
|
+
import hashlib
|
|
36
|
+
import os
|
|
37
|
+
import sys
|
|
38
|
+
import tempfile
|
|
39
|
+
import time
|
|
40
|
+
from collections.abc import Iterator
|
|
41
|
+
from contextlib import contextmanager
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import TextIO
|
|
44
|
+
|
|
45
|
+
from ww.errors import LockError
|
|
46
|
+
|
|
47
|
+
TIMEOUT_VARIABLE = "WW_LOCK_TIMEOUT"
|
|
48
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
49
|
+
_NOTICE_AFTER_SECONDS = 0.25
|
|
50
|
+
_MAXIMUM_POLL_SECONDS = 0.05
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FileLocks:
|
|
54
|
+
"""Exclusive locks and atomic writes for one project root."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, root: Path) -> None:
|
|
57
|
+
self.root = Path(root)
|
|
58
|
+
self.directory = self.root / ".ww" / "locks"
|
|
59
|
+
|
|
60
|
+
def lock_path(self, target: Path) -> Path:
|
|
61
|
+
"""Return the sidecar lock file that guards ``target``.
|
|
62
|
+
|
|
63
|
+
The lock never sits next to the file it guards: atomic replacement
|
|
64
|
+
swaps the target's inode and `reset` deletes whole task directories, so
|
|
65
|
+
a sidecar inside the task tree would be destroyed by the very writes it
|
|
66
|
+
is meant to protect.
|
|
67
|
+
"""
|
|
68
|
+
absolute = os.path.normpath(Path(target).absolute())
|
|
69
|
+
digest = hashlib.sha256(absolute.encode("utf-8")).hexdigest()[:32]
|
|
70
|
+
return self.directory / f"{digest}.lock"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def _activity_path(self) -> Path:
|
|
74
|
+
return self.directory / ".activity.lock"
|
|
75
|
+
|
|
76
|
+
@contextmanager
|
|
77
|
+
def lock(self, target: Path, *, purpose: str | None = None) -> Iterator[None]:
|
|
78
|
+
"""Hold an exclusive lock on ``target`` for the duration of the block.
|
|
79
|
+
|
|
80
|
+
Not reentrant: ww takes each lock at exactly one place.
|
|
81
|
+
"""
|
|
82
|
+
path = self.lock_path(target)
|
|
83
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
# Closing a handle releases its lock, including when the block raised.
|
|
85
|
+
with self._activity_path.open("a+", encoding="utf-8") as activity:
|
|
86
|
+
# Cleanup takes the activity gate exclusively before unlinking
|
|
87
|
+
# sidecars. Do not open the task sidecar until the shared gate is
|
|
88
|
+
# held: otherwise cleanup can unlink it between open(2) and the
|
|
89
|
+
# shared-lock acquisition, leaving this process locking an orphan
|
|
90
|
+
# inode while a later caller locks the replacement path.
|
|
91
|
+
_acquire(activity, "ww activity gate", fcntl.LOCK_SH)
|
|
92
|
+
with path.open("a+", encoding="utf-8") as handle:
|
|
93
|
+
_acquire(handle, purpose or self._describe(target))
|
|
94
|
+
yield
|
|
95
|
+
|
|
96
|
+
def cleanup(self) -> int:
|
|
97
|
+
"""Remove unused lock sidecars without racing active or waiting users."""
|
|
98
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
with self._activity_path.open("a+", encoding="utf-8") as activity:
|
|
100
|
+
_acquire(activity, "ww activity gate", fcntl.LOCK_EX)
|
|
101
|
+
removed = 0
|
|
102
|
+
for path in self.directory.glob("*.lock"):
|
|
103
|
+
if path == self._activity_path:
|
|
104
|
+
continue
|
|
105
|
+
path.unlink(missing_ok=True)
|
|
106
|
+
removed += 1
|
|
107
|
+
return removed
|
|
108
|
+
|
|
109
|
+
def atomic_write(self, target: Path, content: str) -> None:
|
|
110
|
+
"""Replace ``target`` with ``content`` in one step.
|
|
111
|
+
|
|
112
|
+
This takes no lock. Callers write inside a scope their command already
|
|
113
|
+
holds; the atomicity here is what keeps a concurrent *reader* from ever
|
|
114
|
+
seeing a partial file.
|
|
115
|
+
"""
|
|
116
|
+
target = Path(target)
|
|
117
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
119
|
+
prefix=f".{target.name}.", dir=target.parent
|
|
120
|
+
)
|
|
121
|
+
temporary = Path(temporary_name)
|
|
122
|
+
try:
|
|
123
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
124
|
+
handle.write(content)
|
|
125
|
+
handle.flush()
|
|
126
|
+
os.fsync(handle.fileno())
|
|
127
|
+
temporary.replace(target)
|
|
128
|
+
directory_descriptor = os.open(target.parent, os.O_RDONLY)
|
|
129
|
+
try:
|
|
130
|
+
os.fsync(directory_descriptor)
|
|
131
|
+
finally:
|
|
132
|
+
os.close(directory_descriptor)
|
|
133
|
+
except BaseException:
|
|
134
|
+
temporary.unlink(missing_ok=True)
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
def append_line(self, target: Path, line: str) -> None:
|
|
138
|
+
"""Append one newline-terminated record under ``target``'s own lock."""
|
|
139
|
+
target = Path(target)
|
|
140
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
with self.lock(target), target.open("a", encoding="utf-8") as handle:
|
|
142
|
+
handle.write(line if line.endswith("\n") else line + "\n")
|
|
143
|
+
handle.flush()
|
|
144
|
+
os.fsync(handle.fileno())
|
|
145
|
+
|
|
146
|
+
def _describe(self, target: Path) -> str:
|
|
147
|
+
try:
|
|
148
|
+
return str(Path(target).relative_to(self.root))
|
|
149
|
+
except ValueError:
|
|
150
|
+
return str(target)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def timeout_seconds() -> float | None:
|
|
154
|
+
"""Return the configured wait bound, or ``None`` to wait indefinitely."""
|
|
155
|
+
raw = os.environ.get(TIMEOUT_VARIABLE)
|
|
156
|
+
if raw is None:
|
|
157
|
+
return DEFAULT_TIMEOUT_SECONDS
|
|
158
|
+
try:
|
|
159
|
+
value = float(raw)
|
|
160
|
+
except ValueError as error:
|
|
161
|
+
raise LockError(
|
|
162
|
+
f"invalid {TIMEOUT_VARIABLE}: {raw!r} is not a number of seconds"
|
|
163
|
+
) from error
|
|
164
|
+
return None if value <= 0 else value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
_CONTENDED_ERRNOS = {
|
|
168
|
+
errno.EAGAIN,
|
|
169
|
+
errno.EWOULDBLOCK,
|
|
170
|
+
errno.EACCES,
|
|
171
|
+
errno.EINTR,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _is_contended_error(error: OSError) -> bool:
|
|
176
|
+
return isinstance(error, BlockingIOError) or error.errno in _CONTENDED_ERRNOS
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _acquire(handle: TextIO, description: str, mode: int = fcntl.LOCK_EX) -> None:
|
|
180
|
+
limit = timeout_seconds()
|
|
181
|
+
started = time.monotonic()
|
|
182
|
+
deadline = None if limit is None else started + limit
|
|
183
|
+
announced = False
|
|
184
|
+
delay = 0.001
|
|
185
|
+
last_contention_error: OSError | None = None
|
|
186
|
+
while True:
|
|
187
|
+
try:
|
|
188
|
+
fcntl.flock(handle.fileno(), mode | fcntl.LOCK_NB)
|
|
189
|
+
return
|
|
190
|
+
except OSError as error:
|
|
191
|
+
if not _is_contended_error(error):
|
|
192
|
+
raise LockError(f"cannot lock {description}: {error}") from error
|
|
193
|
+
last_contention_error = error
|
|
194
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
195
|
+
raise LockError(
|
|
196
|
+
f"timed out after {limit:g}s waiting for another ww process to "
|
|
197
|
+
f"release {description}; set {TIMEOUT_VARIABLE} to wait longer "
|
|
198
|
+
"(or 0 to wait indefinitely)"
|
|
199
|
+
) from last_contention_error
|
|
200
|
+
if not announced and time.monotonic() - started >= _NOTICE_AFTER_SECONDS:
|
|
201
|
+
sys.stderr.write(
|
|
202
|
+
f"ww: waiting for another ww process to release {description}\n"
|
|
203
|
+
)
|
|
204
|
+
sys.stderr.flush()
|
|
205
|
+
announced = True
|
|
206
|
+
time.sleep(delay)
|
|
207
|
+
delay = min(delay * 2, _MAXIMUM_POLL_SECONDS)
|