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/operator_ui/server.py
ADDED
|
@@ -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")
|