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,215 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """The local HTTP server behind the operator page.
3
+
4
+ It is not a daemon: it runs for exactly as long as one wait, on a port fixed
5
+ per task so the browser tab survives between waits and reconnects by itself.
6
+ It holds no state of its own. ``state`` is asked for every refresh and
7
+ ``act`` for every operator action; both belong to the session, which keeps
8
+ the answer sheet. The wait ends when an action says so, when the tab says
9
+ it is closing and no tab polls again within a moment, or when the timeout
10
+ passes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import errno
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import threading
20
+ import time
21
+ from collections.abc import Callable
22
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
23
+ from importlib.resources import files
24
+ from typing import Literal
25
+ from urllib.parse import urlsplit
26
+
27
+ from ww.agents import WAIT_VARIABLE
28
+ from ww.errors import StateError, WwError
29
+
30
+ PORT_VARIABLE = "WW_OPERATOR_PORT"
31
+ DEFAULT_WAIT_SECONDS = 90.0
32
+ # The lowest and the number of ports a task's page is hashed into.
33
+ _PORT_BASE = 40000
34
+ _PORT_SPAN = 10000
35
+ # How long a fresh waiter listens for an already open tab before opening one.
36
+ _OPEN_BROWSER_AFTER = 2.0
37
+ # How long after a tab says it is closing a poll still counts as the same
38
+ # operator: a reload sends the same signal as a close.
39
+ _CLOSING_GRACE = 3.0
40
+ # After the last item is answered the wait stays a moment, so a comment the
41
+ # operator is still typing for it lands before the answers are applied.
42
+ _SETTLE = 8.0
43
+ _BIND_RETRY_SECONDS = 3.0
44
+ _BIND_RETRY_INTERVAL = 0.2
45
+
46
+ WaitOutcome = Literal["answered", "paused", "closed", "timed_out"]
47
+ StateProvider = Callable[[], dict[str, object]]
48
+ # Applies one operator action; returns why the wait is over, or None.
49
+ Action = Callable[[dict[str, object]], WaitOutcome | None]
50
+
51
+
52
+ def operator_page_port(task_id: str) -> int:
53
+ """The page's port: ``WW_OPERATOR_PORT``, else one derived from the task."""
54
+ configured = os.environ.get(PORT_VARIABLE)
55
+ if configured:
56
+ try:
57
+ return int(configured)
58
+ except ValueError:
59
+ raise StateError(f"{PORT_VARIABLE} must be a port number") from None
60
+ digest = hashlib.sha256(task_id.encode("utf-8")).digest()
61
+ return _PORT_BASE + int.from_bytes(digest[:4], "big") % _PORT_SPAN
62
+
63
+
64
+ def operator_wait_seconds() -> float:
65
+ """How long one wait lasts: ``WW_OPERATOR_WAIT``, else the default."""
66
+ configured = os.environ.get(WAIT_VARIABLE)
67
+ if not configured:
68
+ return DEFAULT_WAIT_SECONDS
69
+ try:
70
+ return float(configured)
71
+ except ValueError:
72
+ raise StateError(f"{WAIT_VARIABLE} must be a number of seconds") from None
73
+
74
+
75
+ def operator_page_url(port: int) -> str:
76
+ return f"http://127.0.0.1:{port}/"
77
+
78
+
79
+ def page_html() -> str:
80
+ return files("ww.operator_ui").joinpath("page.html").read_text("utf-8")
81
+
82
+
83
+ class _Server(ThreadingHTTPServer):
84
+ daemon_threads = True
85
+ allow_reuse_address = True
86
+
87
+ def __init__(self, port: int, state: StateProvider, act: Action, html: str) -> None:
88
+ super().__init__(("127.0.0.1", port), _Handler)
89
+ self.state = state
90
+ self.act = act
91
+ self.html = html
92
+ self.outcome: WaitOutcome | None = None
93
+ self.seen = threading.Event()
94
+ self.polled = threading.Event()
95
+ self.closing = threading.Event()
96
+ self.wake = threading.Event()
97
+
98
+
99
+ class _Handler(BaseHTTPRequestHandler):
100
+ server: _Server
101
+
102
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002
103
+ """The page is served to a person; its request log has no reader."""
104
+
105
+ def do_GET(self) -> None: # noqa: N802 - http.server's name
106
+ path = urlsplit(self.path).path
107
+ if path == "/":
108
+ self._send(200, self.server.html.encode("utf-8"), "text/html")
109
+ elif path == "/state":
110
+ self.server.seen.set()
111
+ self.server.polled.set()
112
+ self._send_json(200, self.server.state())
113
+ else:
114
+ self._send(404, b"not found", "text/plain")
115
+
116
+ def do_POST(self) -> None: # noqa: N802 - http.server's name
117
+ path = urlsplit(self.path).path
118
+ if path == "/closing":
119
+ # Forget earlier polls before the tab hears back, so any poll it
120
+ # sends after this reply counts however late the waiter wakes.
121
+ self.server.polled.clear()
122
+ self.server.closing.set()
123
+ self.server.wake.set()
124
+ self._send_json(200, {"ok": True})
125
+ return
126
+ if path != "/act":
127
+ self._send(404, b"not found", "text/plain")
128
+ return
129
+ length = int(self.headers.get("Content-Length") or 0)
130
+ try:
131
+ payload = json.loads(self.rfile.read(length) or b"{}")
132
+ if not isinstance(payload, dict):
133
+ raise ValueError("the action must be an object")
134
+ outcome = self.server.act(payload)
135
+ except (ValueError, WwError) as error:
136
+ self._send_json(400, {"error": str(error)})
137
+ return
138
+ except Exception as error: # noqa: BLE001 - the page must hear of it
139
+ self._send_json(500, {"error": f"ww could not record that: {error}"})
140
+ return
141
+ self._send_json(200, {"ok": True})
142
+ if outcome is not None:
143
+ self.server.outcome = outcome
144
+ self.server.wake.set()
145
+
146
+ def _send_json(self, status: int, body: dict[str, object]) -> None:
147
+ self._send(status, json.dumps(body).encode("utf-8"), "application/json")
148
+
149
+ def _send(self, status: int, body: bytes, content_type: str) -> None:
150
+ self.send_response(status)
151
+ self.send_header("Content-Type", f"{content_type}; charset=utf-8")
152
+ self.send_header("Content-Length", str(len(body)))
153
+ self.send_header("Cache-Control", "no-store")
154
+ self.end_headers()
155
+ self.wfile.write(body)
156
+
157
+
158
+ def _bind(port: int, state: StateProvider, act: Action, html: str) -> _Server:
159
+ """Bind the page's port, waiting briefly for a previous waiter to let go."""
160
+ deadline = time.monotonic() + _BIND_RETRY_SECONDS
161
+ while True:
162
+ try:
163
+ return _Server(port, state, act, html)
164
+ except OSError as error:
165
+ if error.errno != errno.EADDRINUSE or time.monotonic() >= deadline:
166
+ raise StateError(
167
+ f"the operator page cannot listen on port {port}: {error}; "
168
+ f"set {PORT_VARIABLE} to a free port"
169
+ ) from error
170
+ time.sleep(_BIND_RETRY_INTERVAL)
171
+
172
+
173
+ def serve_operator_page(
174
+ *,
175
+ port: int,
176
+ state: StateProvider,
177
+ act: Action,
178
+ timeout: float,
179
+ open_browser: Callable[[str], object] | None,
180
+ ) -> WaitOutcome:
181
+ """Serve the page for one wait and say how the wait ended.
182
+
183
+ A tab that is already open polls the page, so the browser is opened only
184
+ when nobody asks for the state within a moment of the page coming up.
185
+ """
186
+ server = _bind(port, state, act, page_html())
187
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
188
+ thread.start()
189
+ deadline = time.monotonic() + timeout
190
+ try:
191
+ if not server.seen.wait(min(_OPEN_BROWSER_AFTER, timeout)) and open_browser:
192
+ open_browser(operator_page_url(port))
193
+ return _wait(server, deadline)
194
+ finally:
195
+ server.shutdown()
196
+ server.server_close()
197
+ thread.join()
198
+
199
+
200
+ def _wait(server: _Server, deadline: float) -> WaitOutcome:
201
+ while True:
202
+ remaining = deadline - time.monotonic()
203
+ if remaining <= 0 or not server.wake.wait(remaining):
204
+ return "timed_out"
205
+ server.wake.clear()
206
+ if server.outcome == "answered":
207
+ if server.wake.wait(min(_SETTLE, max(deadline - time.monotonic(), 0))):
208
+ continue # more came in; look again
209
+ return "answered"
210
+ if server.outcome is not None:
211
+ return server.outcome
212
+ if server.closing.is_set():
213
+ server.closing.clear()
214
+ if not server.polled.wait(min(_CLOSING_GRACE, deadline - time.monotonic())):
215
+ return "closed"
@@ -0,0 +1,389 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """One ``interact --await``: apply what is pending, serve, wait, apply again.
3
+
4
+ The session drives the task only through the public service API an agent
5
+ uses. Recording an answer on its stage is ``interact`` with the pick, the
6
+ comment, and the end; the built-in stage's item is marked with
7
+ ``update_item``; the stage is finished with ``complete``; the next stage is
8
+ opened with ``next``. Nothing here writes task state directly.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+
17
+ from ww.contracts import CallerRole
18
+ from ww.errors import StateError
19
+ from ww.execution_models import ExecutionState, PlanSnapshot
20
+ from ww.plan import PlanItem
21
+ from ww.service import WorkflowService, resolve_choice
22
+
23
+ from .server import (
24
+ WaitOutcome,
25
+ operator_page_port,
26
+ serve_operator_page,
27
+ )
28
+ from .sheet import Answer, AnswerSheet
29
+ from .view import SheetRow, sheet_rows, ui_stage
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class OperatorPageResult:
34
+ """How a wait ended and what was applied, for the agent to read."""
35
+
36
+ outcome: WaitOutcome | None
37
+ applied: tuple[str, ...]
38
+ answered: int
39
+ total: int
40
+ paused: bool
41
+ # Documents the applied stages promised to update: ww completed those
42
+ # stages, so recording the answers in the documents is the agent's to do.
43
+ documents: tuple[str, ...] = ()
44
+
45
+ def to_dict(self) -> dict[str, object]:
46
+ return {
47
+ "outcome": self.outcome,
48
+ "applied": list(self.applied),
49
+ "answered": self.answered,
50
+ "total": self.total,
51
+ "paused": self.paused,
52
+ "documents": list(self.documents),
53
+ }
54
+
55
+ def render(self) -> str:
56
+ """A short Markdown block printed after the step's page."""
57
+ ended = {
58
+ "answered": "The operator answered every item.",
59
+ "paused": "The operator said they are done for now.",
60
+ "closed": "The operator closed the page.",
61
+ "timed_out": "The wait passed with nothing new.",
62
+ None: "Nothing was waited for.",
63
+ }[self.outcome]
64
+ applied = (
65
+ "Applied the answers of " + ", ".join(self.applied) + ": each stage "
66
+ "is completed, its item resolved with the answer as the actual "
67
+ "solution, and its artifact written."
68
+ if self.applied
69
+ else "Nothing was applied."
70
+ )
71
+ progress = f"{self.answered} of {self.total} items are answered."
72
+ if self.paused:
73
+ advice = (
74
+ "Stop here; do not wait again and do not delegate. When the "
75
+ "operator returns, show the page with `instruction` and wait again."
76
+ )
77
+ elif self.outcome == "closed":
78
+ advice = (
79
+ "Do not open the page again on your own: ask the operator in the "
80
+ "session whether to go on, and wait again only when they say so."
81
+ )
82
+ elif self.answered < self.total:
83
+ advice = "Items remain; wait again."
84
+ else:
85
+ advice = "Every item is answered; go on with the page above."
86
+ lines = ["## Operator page", "", f"{ended} {applied} {progress} {advice}"]
87
+ if self.applied and self.documents:
88
+ lines.extend(
89
+ [
90
+ "",
91
+ "The applied stages promised to update these documents, and "
92
+ "ww cannot write them: record the operator's answers for "
93
+ + ", ".join(self.applied)
94
+ + " in them now, before anything else.",
95
+ "",
96
+ *(f"- `{path}`" for path in self.documents),
97
+ ]
98
+ )
99
+ return "\n".join([*lines, ""])
100
+
101
+
102
+ class _Session:
103
+ def __init__(
104
+ self, service: WorkflowService, task_id: str, caller_role: CallerRole | None
105
+ ) -> None:
106
+ self.service = service
107
+ self.task_id = task_id
108
+ self.caller_role = caller_role
109
+ state, _snapshot = service.load(task_id)
110
+ self.sheet = AnswerSheet(service.storage, task_id, state.created_at)
111
+ # The documents the applied stages promised to update, in order.
112
+ self.documents: dict[str, None] = {}
113
+
114
+ # -- reading ---------------------------------------------------------
115
+
116
+ def load(self) -> tuple[ExecutionState, PlanSnapshot]:
117
+ return self.service.load(self.task_id)
118
+
119
+ def rows(
120
+ self, state: ExecutionState, snapshot: PlanSnapshot
121
+ ) -> tuple[SheetRow, ...]:
122
+ return sheet_rows(
123
+ snapshot.plan,
124
+ state,
125
+ self.service.tasks.read_items(self.task_id, state.run_id),
126
+ self.sheet.read(state.run_id),
127
+ self.service.interactions.entries(self.task_id),
128
+ )
129
+
130
+ def current_ui_stage(
131
+ self, state: ExecutionState, snapshot: PlanSnapshot
132
+ ) -> PlanItem:
133
+ """The open ``ui`` stage, or why the page cannot be served."""
134
+ plan = snapshot.plan
135
+ if not state.active_item_id or state.cursor >= len(plan.items):
136
+ raise StateError("no agent item is in progress; use next")
137
+ item = plan.items[state.cursor]
138
+ record = state.item_executions[state.cursor]
139
+ if item.id != state.active_item_id or not item.ui:
140
+ raise StateError(
141
+ f"{item.name!r} is not answered on the operator page; the page "
142
+ "serves per-item stages declared with interactive: page"
143
+ )
144
+ if record.interaction_ended:
145
+ raise StateError(
146
+ f"the interaction of {item.name!r} has ended; complete the step"
147
+ )
148
+ return item
149
+
150
+ def page_state(self) -> dict[str, object]:
151
+ state, snapshot = self.load()
152
+ plan = snapshot.plan
153
+ current = plan.items[state.cursor] if state.cursor < len(plan.items) else None
154
+ rows = self.rows(state, snapshot)
155
+ stage = ui_stage(plan)
156
+ return {
157
+ "task_id": self.task_id,
158
+ "workflow": state.workflow,
159
+ "run_id": state.run_id,
160
+ "stage": stage.name if stage else None,
161
+ "current_item_id": current.item_id if current and current.ui else None,
162
+ "paused": state.operator_paused,
163
+ "choices": [choice.to_dict() for choice in stage.choices] if stage else [],
164
+ "items": [row.to_dict() for row in rows],
165
+ "answered": sum(1 for row in rows if row.answered),
166
+ "total": len(rows),
167
+ }
168
+
169
+ # -- the operator's actions -------------------------------------------
170
+
171
+ def act(self, payload: dict[str, object]) -> WaitOutcome | None:
172
+ action = payload.get("action")
173
+ comment = payload.get("comment")
174
+ choice = payload.get("choice")
175
+ item_id = payload.get("item_id")
176
+ if not all(
177
+ value is None or isinstance(value, str)
178
+ for value in (comment, choice, item_id)
179
+ ):
180
+ raise StateError("the answer's item, choice, and comment must be strings")
181
+ match action:
182
+ case "answer":
183
+ if not item_id:
184
+ raise StateError("an answer names its item")
185
+ complete = self.answer(
186
+ str(item_id),
187
+ str(choice).strip() if choice else None,
188
+ str(comment).strip() if comment else "",
189
+ )
190
+ return "answered" if complete else None
191
+ case "pause":
192
+ # Recorded by the session once the answers given before it
193
+ # are applied, so applying does not lift the pause.
194
+ return "paused"
195
+ case _:
196
+ raise StateError(f"unknown operator action {action!r}")
197
+
198
+ def answer(self, item_id: str, choice: str | None, comment: str) -> bool:
199
+ """Put one answer on the sheet; true when every item has one."""
200
+ with self.service.tasks.lock_task(self.task_id):
201
+ state, snapshot = self.load()
202
+ rows = self.rows(state, snapshot)
203
+ row = next((row for row in rows if row.work.id == item_id), None)
204
+ if row is None:
205
+ raise StateError(f"item {item_id!r} was not found")
206
+ if row.stage is None:
207
+ raise StateError(f"item {item_id!r} has no stage answered on the page")
208
+ if row.processed:
209
+ raise StateError(
210
+ f"the answer of {item_id!r} was already applied; it cannot change"
211
+ )
212
+ if row.stage.choices:
213
+ if choice is None:
214
+ raise StateError("pick one of the choices")
215
+ choice = resolve_choice(row.stage, choice)
216
+ elif not comment:
217
+ raise StateError("write a comment")
218
+ else:
219
+ choice = None
220
+ self.sheet.record(state.run_id, item_id, Answer(choice, comment, _now()))
221
+ complete = all(
222
+ row.processed or row.pending or row.work.id == item_id for row in rows
223
+ )
224
+ returned = state.operator_paused
225
+ if returned:
226
+ # Answering is the operator coming back; recorded like any other
227
+ # word of theirs, outside the lock since interact takes it.
228
+ self.service.interact(
229
+ self.task_id,
230
+ operator=f"Answered {item_id} on the operator page.",
231
+ caller_role=self.caller_role,
232
+ )
233
+ return complete
234
+
235
+ # -- applying ----------------------------------------------------------
236
+
237
+ def apply_pending(self) -> tuple[str, ...]:
238
+ """Apply the sheet in plan order through the public service calls.
239
+
240
+ Stops at the first ``ui`` stage whose item has no answer, at anything
241
+ that is not a ``ui`` stage, and wherever ww needs the agent.
242
+ """
243
+ applied: list[str] = []
244
+ while True:
245
+ state, snapshot = self.load()
246
+ plan = snapshot.plan
247
+ if state.status not in ("pending", "in_progress") or state.cursor >= len(
248
+ plan.items
249
+ ):
250
+ break
251
+ item = plan.items[state.cursor]
252
+ record = state.item_executions[state.cursor]
253
+ if not item.ui or item.item_id is None:
254
+ break
255
+ if record.status == "pending" and state.active_item_id is None:
256
+ # ``next`` is the manager's command in every runtime; the
257
+ # session acts for whoever holds the stage.
258
+ self.service.next(
259
+ self.task_id,
260
+ caller_role="manager" if self.caller_role else None,
261
+ )
262
+ continue
263
+ if record.status != "in_progress" or item.id != state.active_item_id:
264
+ break
265
+ self._drop_stale(state, snapshot)
266
+ pending = self.sheet.read(state.run_id).get(item.item_id)
267
+ if pending is None:
268
+ break
269
+ self._apply(state, item, pending)
270
+ applied.append(item.item_id)
271
+ return tuple(applied)
272
+
273
+ def _drop_stale(self, state: ExecutionState, snapshot: PlanSnapshot) -> None:
274
+ """Forget answers whose stage a cut wait had already completed."""
275
+ for row in self.rows(state, snapshot):
276
+ if row.processed and row.pending is not None:
277
+ self.sheet.remove(state.run_id, row.work.id)
278
+
279
+ def _apply(self, state: ExecutionState, stage: PlanItem, answer: Answer) -> None:
280
+ item_id = str(stage.item_id)
281
+ page = self.service.instruction(self.task_id, caller_role=self.caller_role)
282
+ for document in page.documents:
283
+ self.documents[document.path] = None
284
+ self.service.interact(
285
+ self.task_id,
286
+ choice=answer.choice,
287
+ operator=answer.comment or None,
288
+ end=True,
289
+ caller_role=self.caller_role,
290
+ )
291
+ outcome = _outcome_text(answer)
292
+ marks: dict[str, object] = {}
293
+ if stage.item_operation in ("handle_item", "resolve_item"):
294
+ marks.update(actual_solution=outcome, resolved=True)
295
+ if stage.item_operation in ("handle_item", "report_item"):
296
+ marks["reported"] = True
297
+ if marks:
298
+ self.service.update_item(
299
+ self.task_id, item_id, caller_role=self.caller_role, **marks
300
+ )
301
+ work = self.service.item(self.task_id, item_id, state.run_id)
302
+ given = f"`{answer.choice}`" if answer.choice else "a comment"
303
+ lines = [f"# {stage.name}: {item_id}", "", work.item, ""]
304
+ lines.append(f"Operator's answer: {given}")
305
+ if answer.comment:
306
+ lines.extend(["", *(f"> {line}" for line in answer.comment.splitlines())])
307
+ self.service.complete(
308
+ self.task_id,
309
+ artifact="\n".join(lines) + "\n",
310
+ summary_for_next=(
311
+ f"The operator answered {given} for {item_id}"
312
+ + (" with a comment." if answer.comment else ".")
313
+ ),
314
+ caller_role=self.caller_role,
315
+ )
316
+ # The stage is committed; only now does the answer leave the sheet, so
317
+ # a cut here leaves a stale entry that the next apply drops.
318
+ self.sheet.remove(state.run_id, item_id)
319
+
320
+ def record_pause(self) -> None:
321
+ """The operator said they are done for now, on the stage now open."""
322
+ state, snapshot = self.load()
323
+ plan = snapshot.plan
324
+ if (
325
+ state.cursor < len(plan.items)
326
+ and state.active_item_id
327
+ and (plan.items[state.cursor].interactive)
328
+ ):
329
+ self.service.interact(
330
+ self.task_id, pause=True, caller_role=self.caller_role
331
+ )
332
+
333
+ def result(
334
+ self, outcome: WaitOutcome | None, applied: tuple[str, ...]
335
+ ) -> OperatorPageResult:
336
+ state, snapshot = self.load()
337
+ rows = self.rows(state, snapshot)
338
+ return OperatorPageResult(
339
+ outcome,
340
+ applied,
341
+ sum(1 for row in rows if row.answered),
342
+ len(rows),
343
+ state.operator_paused,
344
+ tuple(self.documents),
345
+ )
346
+
347
+
348
+ def run_operator_page(
349
+ service: WorkflowService,
350
+ task_id: str,
351
+ *,
352
+ timeout: float,
353
+ open_browser: Callable[[str], object] | None,
354
+ caller_role: CallerRole | None = None,
355
+ ) -> OperatorPageResult:
356
+ """Apply leftover answers, serve the page until the wait ends, apply again."""
357
+ session = _Session(service, task_id, caller_role)
358
+ applied = session.apply_pending()
359
+ state, snapshot = session.load()
360
+ try:
361
+ session.current_ui_stage(state, snapshot)
362
+ except StateError:
363
+ if applied:
364
+ # Applying moved the task past its ``ui`` stages; nothing to wait for.
365
+ return session.result(None, applied)
366
+ raise
367
+ outcome = serve_operator_page(
368
+ port=operator_page_port(task_id),
369
+ state=session.page_state,
370
+ act=session.act,
371
+ timeout=timeout,
372
+ open_browser=open_browser,
373
+ )
374
+ applied += session.apply_pending()
375
+ if outcome == "paused":
376
+ session.record_pause()
377
+ return session.result(outcome, applied)
378
+
379
+
380
+ def _outcome_text(answer: Answer) -> str:
381
+ """The operator's answer as an item's actual solution."""
382
+ if answer.choice and answer.comment:
383
+ return f"{answer.choice}: {answer.comment}"
384
+ return answer.choice or answer.comment
385
+
386
+
387
+ def _now() -> str:
388
+ stamp = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
389
+ return stamp.replace("+00:00", "Z")