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/operations.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Core workflow-control operations embedded in compiled plans.
|
|
3
|
+
|
|
4
|
+
This is a leaf module: it depends only on ``ww.contracts`` so that the
|
|
5
|
+
engine, the parser, the validator, and the plan compiler can all import it
|
|
6
|
+
without pulling in the ``ww.plan`` package.
|
|
7
|
+
|
|
8
|
+
Loops, workflow handoffs, and child-workflow runs are behaviour of the workflow
|
|
9
|
+
engine itself. Core code plans, persists, and dispatches them directly; they
|
|
10
|
+
are deliberately not part of the ordinary action registry and cannot be added
|
|
11
|
+
or replaced through it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias
|
|
18
|
+
|
|
19
|
+
from ww.contracts import ExecutionKind, LoopOperation, PlanItemOwner
|
|
20
|
+
from ww.validation import expect_keys, is_positive_int
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from ww.actions.contracts import PlannedAction
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class LoopBoundary:
|
|
28
|
+
"""Manager-owned entry or repeat boundary generated for a ``loop`` step."""
|
|
29
|
+
|
|
30
|
+
kind: ClassVar[str] = "loop"
|
|
31
|
+
owner: ClassVar[PlanItemOwner] = "ww"
|
|
32
|
+
execution: ClassVar[ExecutionKind] = "loop_control"
|
|
33
|
+
|
|
34
|
+
loop_id: str
|
|
35
|
+
boundary: LoopOperation
|
|
36
|
+
max_times: int
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
if not isinstance(self.loop_id, str) or not self.loop_id.strip():
|
|
40
|
+
raise ValueError("loop boundary requires a non-empty loop ID")
|
|
41
|
+
if self.boundary not in {"enter", "repeat"}:
|
|
42
|
+
raise ValueError("loop boundary must be enter or repeat")
|
|
43
|
+
if not is_positive_int(self.max_times):
|
|
44
|
+
raise ValueError("loop boundary requires a positive integer limit")
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, object]:
|
|
47
|
+
return {
|
|
48
|
+
"loop_id": self.loop_id,
|
|
49
|
+
"boundary": self.boundary,
|
|
50
|
+
"max_times": self.max_times,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_dict(cls, data: dict[str, Any]) -> LoopBoundary:
|
|
55
|
+
expect_keys(data, {"loop_id", "boundary", "max_times"}, f"{cls.kind} operation")
|
|
56
|
+
return cls(data["loop_id"], data["boundary"], data["max_times"])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class WorkflowHandoff:
|
|
61
|
+
"""Terminal transition of a handoff workflow into its selected successor."""
|
|
62
|
+
|
|
63
|
+
kind: ClassVar[str] = "workflow_transition"
|
|
64
|
+
owner: ClassVar[PlanItemOwner] = "ww"
|
|
65
|
+
execution: ClassVar[ExecutionKind] = "workflow_transition"
|
|
66
|
+
|
|
67
|
+
target: str
|
|
68
|
+
|
|
69
|
+
def __post_init__(self) -> None:
|
|
70
|
+
if not isinstance(self.target, str) or not self.target.strip():
|
|
71
|
+
raise ValueError("workflow handoff requires a non-empty target")
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, object]:
|
|
74
|
+
return {"target": self.target}
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_dict(cls, data: dict[str, Any]) -> WorkflowHandoff:
|
|
78
|
+
expect_keys(data, {"target"}, f"{cls.kind} operation")
|
|
79
|
+
return cls(data["target"])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
CHILD_LAUNCH_SETTINGS = ("workflow", "runtime", "model", "reasoning", "agent")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ChildLaunch:
|
|
87
|
+
"""The launch settings ww applies when it starts a child itself.
|
|
88
|
+
|
|
89
|
+
Each setting is a template rendered, when the stage runs, from the child's
|
|
90
|
+
record (``{{ww.child.field.<name>}}`` and the like). ``None`` or a value
|
|
91
|
+
that renders empty inherits, exactly as an omitted ``start-child`` option.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
workflow: str | None = None
|
|
95
|
+
runtime: str | None = None
|
|
96
|
+
model: str | None = None
|
|
97
|
+
reasoning: str | None = None
|
|
98
|
+
agent: str | None = None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def templates(self) -> tuple[str, ...]:
|
|
102
|
+
return tuple(
|
|
103
|
+
value
|
|
104
|
+
for name in CHILD_LAUNCH_SETTINGS
|
|
105
|
+
if isinstance(value := getattr(self, name), str)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict[str, object]:
|
|
109
|
+
return {
|
|
110
|
+
name: value
|
|
111
|
+
for name in CHILD_LAUNCH_SETTINGS
|
|
112
|
+
if (value := getattr(self, name)) is not None
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_dict(cls, data: object) -> ChildLaunch:
|
|
117
|
+
if not isinstance(data, dict) or not all(
|
|
118
|
+
key in CHILD_LAUNCH_SETTINGS and isinstance(value, str)
|
|
119
|
+
for key, value in data.items()
|
|
120
|
+
):
|
|
121
|
+
raise ValueError("child launch must map launch settings to strings")
|
|
122
|
+
return cls(**data)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class ChildWorkflowRun:
|
|
127
|
+
"""Coordinator item that runs the named workflow once per collected child.
|
|
128
|
+
|
|
129
|
+
With ``launch`` ww starts the child itself when the item is reached.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
kind: ClassVar[str] = "child_workflow"
|
|
133
|
+
owner: ClassVar[PlanItemOwner] = "ww"
|
|
134
|
+
execution: ClassVar[ExecutionKind] = "agent_instruction"
|
|
135
|
+
|
|
136
|
+
workflow: str
|
|
137
|
+
launch: ChildLaunch | None = None
|
|
138
|
+
|
|
139
|
+
def __post_init__(self) -> None:
|
|
140
|
+
if not isinstance(self.workflow, str) or not self.workflow.strip():
|
|
141
|
+
raise ValueError("child workflow requires a non-empty target")
|
|
142
|
+
|
|
143
|
+
def to_dict(self) -> dict[str, object]:
|
|
144
|
+
data: dict[str, object] = {"workflow": self.workflow}
|
|
145
|
+
if self.launch is not None:
|
|
146
|
+
data["launch"] = self.launch.to_dict()
|
|
147
|
+
return data
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def from_dict(cls, data: dict[str, Any]) -> ChildWorkflowRun:
|
|
151
|
+
expect_keys(data, {"workflow"}, f"{cls.kind} operation")
|
|
152
|
+
launch = data.get("launch")
|
|
153
|
+
return cls(
|
|
154
|
+
data["workflow"],
|
|
155
|
+
ChildLaunch.from_dict(launch) if launch is not None else None,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
CoreOperation: TypeAlias = "LoopBoundary | WorkflowHandoff | ChildWorkflowRun"
|
|
160
|
+
PlanOperation: TypeAlias = "PlannedAction | CoreOperation"
|
|
161
|
+
|
|
162
|
+
_CORE_OPERATIONS: dict[str, type[LoopBoundary | WorkflowHandoff | ChildWorkflowRun]] = {
|
|
163
|
+
LoopBoundary.kind: LoopBoundary,
|
|
164
|
+
WorkflowHandoff.kind: WorkflowHandoff,
|
|
165
|
+
ChildWorkflowRun.kind: ChildWorkflowRun,
|
|
166
|
+
}
|
|
167
|
+
_ACTION_TYPE = "action"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def encode_operation(operation: PlanOperation) -> dict[str, object]:
|
|
171
|
+
"""Serialize a plan operation with an explicit ``type`` discriminator."""
|
|
172
|
+
# Runtime import: ``ww.actions`` must stay independent of core operations.
|
|
173
|
+
from ww.actions.contracts import PlannedAction
|
|
174
|
+
|
|
175
|
+
if isinstance(operation, PlannedAction):
|
|
176
|
+
return {"type": _ACTION_TYPE, **operation.to_dict()}
|
|
177
|
+
return {"type": operation.kind, **operation.to_dict()}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def decode_operation(data: object) -> PlanOperation:
|
|
181
|
+
"""Rebuild a plan operation from its serialized form."""
|
|
182
|
+
if not isinstance(data, dict) or not isinstance(data.get("type"), str):
|
|
183
|
+
raise ValueError("plan operation must declare a type")
|
|
184
|
+
fields = {key: value for key, value in data.items() if key != "type"}
|
|
185
|
+
if data["type"] == _ACTION_TYPE:
|
|
186
|
+
from ww.actions.contracts import PlannedAction
|
|
187
|
+
|
|
188
|
+
return PlannedAction.from_dict(fields)
|
|
189
|
+
try:
|
|
190
|
+
operation_type = _CORE_OPERATIONS[data["type"]]
|
|
191
|
+
except KeyError:
|
|
192
|
+
raise ValueError(f"unknown plan operation type: {data['type']!r}") from None
|
|
193
|
+
return operation_type.from_dict(fields)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""The operator page: a local web page the operator answers items on.
|
|
3
|
+
|
|
4
|
+
This package is an extra on top of the engine, not part of it. The core
|
|
5
|
+
knows one thing about it: a per-item stage may be declared
|
|
6
|
+
``interactive: page``, and the step's page then tells the agent to run
|
|
7
|
+
``interact --await``. Everything
|
|
8
|
+
else lives here. The page is served for exactly as long as that command
|
|
9
|
+
waits; answers wait in a file of their own, and once the wait is over they
|
|
10
|
+
are applied through the same public service calls an agent would make:
|
|
11
|
+
``interact``, ``update_item``, ``complete``, and ``next``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .session import OperatorPageResult, run_operator_page
|
|
15
|
+
|
|
16
|
+
__all__ = ["OperatorPageResult", "run_operator_page"]
|
ww/operator_ui/page.html
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>ww operator page</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #f6f7f9; --panel: #ffffff; --ink: #1c1f24; --muted: #6b7280;
|
|
10
|
+
--line: #e3e6ea; --accent: #2563eb; --accent-ink: #ffffff;
|
|
11
|
+
--open: #fff7d6; --answered: #e8f0ff; --processed: #e8f5ec; --danger: #b91c1c;
|
|
12
|
+
--warn-bg: #fdf8e8; --warn-line: #e6d18a; --warn-ink: #6b5200; --warn-mark: #d4a017;
|
|
13
|
+
}
|
|
14
|
+
@media (prefers-color-scheme: dark) {
|
|
15
|
+
:root {
|
|
16
|
+
--bg: #14161a; --panel: #1e2126; --ink: #e6e8eb; --muted: #9aa3ad;
|
|
17
|
+
--line: #30353c; --accent: #3b82f6; --accent-ink: #ffffff;
|
|
18
|
+
--open: #3a3410; --answered: #1e2a4a; --processed: #163120; --danger: #f87171;
|
|
19
|
+
--warn-bg: #2b2513; --warn-line: #5c4c1c; --warn-ink: #e8d9a8; --warn-mark: #d4a017;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
* { box-sizing: border-box; }
|
|
23
|
+
body { margin: 0; font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
24
|
+
background: var(--bg); color: var(--ink); }
|
|
25
|
+
header { position: sticky; top: 0; z-index: 1; padding: 12px 16px; border-bottom: 1px solid var(--line);
|
|
26
|
+
background: var(--panel); display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
|
27
|
+
header h1 { font-size: 16px; margin: 0; }
|
|
28
|
+
.muted { color: var(--muted); }
|
|
29
|
+
#status { margin-left: auto; font-size: 13px; }
|
|
30
|
+
#status.off { color: var(--danger); }
|
|
31
|
+
main { padding: 16px; max-width: 900px; margin: 0 auto; }
|
|
32
|
+
article { background: var(--panel); border: 1px solid var(--line); border-left: 6px solid var(--line);
|
|
33
|
+
border-radius: 8px; padding: 12px 14px; margin-bottom: 12px; }
|
|
34
|
+
article.open { border-left-color: #d4a017; }
|
|
35
|
+
article.answered { border-left-color: var(--accent); }
|
|
36
|
+
article.processed { border-left-color: #2e8b57; }
|
|
37
|
+
article h2 { font-size: 14px; margin: 0 0 6px; display: flex; gap: 10px; align-items: baseline; }
|
|
38
|
+
article h2 .id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
39
|
+
.badge { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; padding: 2px 8px;
|
|
40
|
+
border-radius: 999px; background: var(--open); }
|
|
41
|
+
.answered .badge { background: var(--answered); }
|
|
42
|
+
.processed .badge { background: var(--processed); }
|
|
43
|
+
.badge.unsent { background: var(--danger); color: #fff; }
|
|
44
|
+
.experimental { font-size: 11px; text-transform: uppercase; letter-spacing: .06em;
|
|
45
|
+
padding: 2px 8px; border-radius: 999px; border: 1px solid var(--warn-mark);
|
|
46
|
+
color: var(--warn-ink); background: var(--warn-bg); white-space: nowrap; }
|
|
47
|
+
#experimental-note { margin: 0 0 14px; padding: 10px 12px; font-size: 13px;
|
|
48
|
+
border: 1px solid var(--warn-line); border-left: 4px solid var(--warn-mark);
|
|
49
|
+
border-radius: 6px; background: var(--warn-bg); color: var(--warn-ink); }
|
|
50
|
+
.text { white-space: pre-wrap; margin: 0 0 8px; }
|
|
51
|
+
dl { margin: 0 0 8px; font-size: 13px; }
|
|
52
|
+
dl dt { color: var(--muted); }
|
|
53
|
+
dl dd { margin: 0 0 6px; white-space: pre-wrap; }
|
|
54
|
+
fieldset { border: 1px solid var(--line); border-radius: 6px; margin: 0 0 8px; padding: 6px 10px; }
|
|
55
|
+
legend { font-size: 13px; color: var(--muted); }
|
|
56
|
+
label.choice { display: inline-block; margin-right: 16px; padding: 3px 0; cursor: pointer; }
|
|
57
|
+
label.choice .desc { color: var(--muted); font-size: 13px; }
|
|
58
|
+
textarea { width: 100%; min-height: 56px; padding: 8px; border: 1px solid var(--line); border-radius: 6px;
|
|
59
|
+
background: var(--bg); color: var(--ink); font: inherit; resize: vertical; }
|
|
60
|
+
.actions { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
|
|
61
|
+
button { font: inherit; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--line);
|
|
62
|
+
background: var(--panel); color: var(--ink); cursor: pointer; }
|
|
63
|
+
button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
|
|
64
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
65
|
+
.given { font-size: 13px; color: var(--muted); white-space: pre-wrap; }
|
|
66
|
+
.md p { margin: 0 0 8px; } .md ul, .md ol { margin: 0 0 8px; padding-left: 22px; }
|
|
67
|
+
.md h3, .md h4, .md h5, .md h6 { margin: 8px 0 4px; font-size: 14px; }
|
|
68
|
+
.md pre { background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px; overflow-x: auto; font-size: 13px; }
|
|
69
|
+
.md code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
|
|
70
|
+
.text.md { white-space: normal; }
|
|
71
|
+
.custom { display: flex; gap: 12px; flex-wrap: wrap; font-size: 13px; color: var(--muted); margin: 0 0 8px; }
|
|
72
|
+
.error { color: var(--danger); font-size: 13px; }
|
|
73
|
+
#note { font-size: 13px; color: var(--muted); margin: 0 0 12px; }
|
|
74
|
+
</style>
|
|
75
|
+
</head>
|
|
76
|
+
<body>
|
|
77
|
+
<header>
|
|
78
|
+
<h1 id="title">ww</h1>
|
|
79
|
+
<span class="experimental" title="The operator page is still taking shape; its behaviour may change.">experimental</span>
|
|
80
|
+
<span class="muted" id="progress"></span>
|
|
81
|
+
<button type="button" id="pause">I'm done for now</button>
|
|
82
|
+
<span id="status" class="off">connecting…</span>
|
|
83
|
+
</header>
|
|
84
|
+
<main>
|
|
85
|
+
<p id="experimental-note">
|
|
86
|
+
The operator page is <strong>experimental</strong>. It works, but its
|
|
87
|
+
layout, keyboard behaviour, and the way answers are applied may change
|
|
88
|
+
between updates. Your answers are recorded by ww as you give them, so
|
|
89
|
+
nothing is lost if you close this tab — but if the page misbehaves, you
|
|
90
|
+
can always answer through <code>ww interact</code> in the terminal
|
|
91
|
+
instead.
|
|
92
|
+
</p>
|
|
93
|
+
<p id="note"></p>
|
|
94
|
+
<div id="items"></div>
|
|
95
|
+
</main>
|
|
96
|
+
<script>
|
|
97
|
+
(function () {
|
|
98
|
+
const $ = (id) => document.getElementById(id);
|
|
99
|
+
const key = "ww-operator-page:" + location.port;
|
|
100
|
+
let connected = false;
|
|
101
|
+
let lastSignature = null;
|
|
102
|
+
let latest = null;
|
|
103
|
+
// Drafts and answers that could not be sent survive in this tab's storage.
|
|
104
|
+
const local = load();
|
|
105
|
+
|
|
106
|
+
function load() {
|
|
107
|
+
try { return JSON.parse(localStorage.getItem(key) || "{}"); } catch (_) { return {}; }
|
|
108
|
+
}
|
|
109
|
+
function save() {
|
|
110
|
+
try { localStorage.setItem(key, JSON.stringify(local)); } catch (_) {}
|
|
111
|
+
}
|
|
112
|
+
// A draft is the comment being typed; a pick is sent the moment it is
|
|
113
|
+
// made, so what is shown as picked always comes from ww or from the
|
|
114
|
+
// queue of answers that could not be sent.
|
|
115
|
+
function draft(itemId) {
|
|
116
|
+
local.drafts = local.drafts || {};
|
|
117
|
+
if (!local.drafts[itemId]) local.drafts[itemId] = { comment: "" };
|
|
118
|
+
return local.drafts[itemId];
|
|
119
|
+
}
|
|
120
|
+
function queued(itemId) { return (local.queue || {})[itemId]; }
|
|
121
|
+
function bindRun(state) {
|
|
122
|
+
if (local.run_id !== state.run_id) {
|
|
123
|
+
// Another run of the task: nothing typed for the old one applies.
|
|
124
|
+
for (const key of Object.keys(local)) delete local[key];
|
|
125
|
+
local.run_id = state.run_id;
|
|
126
|
+
save();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function esc(text) {
|
|
131
|
+
return String(text).replace(/[&<>"']/g, (c) => ({
|
|
132
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
133
|
+
})[c]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A small Markdown renderer: headings, lists, code, emphasis, links.
|
|
137
|
+
function inline(text) {
|
|
138
|
+
return esc(text)
|
|
139
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
140
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
141
|
+
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
|
142
|
+
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
143
|
+
}
|
|
144
|
+
function markdown(text) {
|
|
145
|
+
const lines = String(text || "").replace(/\r\n?/g, "\n").split("\n");
|
|
146
|
+
const out = [];
|
|
147
|
+
let list = null, para = [], code = null;
|
|
148
|
+
const flush = () => {
|
|
149
|
+
if (para.length) { out.push(`<p>${para.map(inline).join("<br>")}</p>`); para = []; }
|
|
150
|
+
if (list) { out.push(`</${list}>`); list = null; }
|
|
151
|
+
};
|
|
152
|
+
for (const raw of lines) {
|
|
153
|
+
if (code !== null) {
|
|
154
|
+
if (/^```/.test(raw)) { out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`); code = null; }
|
|
155
|
+
else code.push(raw);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const line = raw.replace(/\s+$/, "");
|
|
159
|
+
let m;
|
|
160
|
+
if (/^```/.test(line)) { flush(); code = []; }
|
|
161
|
+
else if ((m = /^(#{1,6})\s+(.*)$/.exec(line))) { flush(); const n = Math.min(m[1].length + 2, 6); out.push(`<h${n}>${inline(m[2])}</h${n}>`); }
|
|
162
|
+
else if ((m = /^\s*[-*]\s+(.*)$/.exec(line))) { if (para.length) flush(); if (list !== "ul") { if (list) out.push(`</${list}>`); out.push("<ul>"); list = "ul"; } out.push(`<li>${inline(m[1])}</li>`); }
|
|
163
|
+
else if ((m = /^\s*\d+[.)]\s+(.*)$/.exec(line))) { if (para.length) flush(); if (list !== "ol") { if (list) out.push(`</${list}>`); out.push("<ol>"); list = "ol"; } out.push(`<li>${inline(m[1])}</li>`); }
|
|
164
|
+
else if (line === "") flush();
|
|
165
|
+
else { if (list) { out.push(`</${list}>`); list = null; } para.push(line); }
|
|
166
|
+
}
|
|
167
|
+
if (code !== null) out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`);
|
|
168
|
+
flush();
|
|
169
|
+
return out.join("");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function card(item, choices) {
|
|
173
|
+
const unsent = queued(item.id);
|
|
174
|
+
const badge = unsent ? `<span class="badge unsent">not sent</span>`
|
|
175
|
+
: `<span class="badge">${{ open: "open", answered: "answered", processed: "applied" }[item.status]}</span>`;
|
|
176
|
+
const fields = [["Analysis", item.processed_item], ["Proposed solution", item.proposed_solution],
|
|
177
|
+
["Actual solution", item.actual_solution]].filter(([, v]) => v);
|
|
178
|
+
const custom = Object.entries(item.fields || {});
|
|
179
|
+
let body = `<h2><span class="id">${esc(item.id)}</span>${badge}</h2>` +
|
|
180
|
+
`<div class="text md">${markdown(item.item)}</div>` +
|
|
181
|
+
(fields.length ? `<dl>${fields.map(([k, v]) => `<dt>${esc(k)}</dt><dd class="md">${markdown(v)}</dd>`).join("")}</dl>` : "") +
|
|
182
|
+
(custom.length ? `<div class="custom">${custom.map(([k, v]) => `<span><code>${esc(k)}</code> ${esc(v)}</span>`).join("")}</div>` : "");
|
|
183
|
+
if (item.status === "processed") {
|
|
184
|
+
body += `<div class="given">Answer: ${esc(item.answer || "(comment)")}` +
|
|
185
|
+
(item.answer_comment ? `\n${esc(item.answer_comment)}` : "") + `</div>`;
|
|
186
|
+
return body;
|
|
187
|
+
}
|
|
188
|
+
const d = draft(item.id);
|
|
189
|
+
const picked = unsent ? unsent.choice : item.answer;
|
|
190
|
+
const comment = unsent ? unsent.comment : (d.comment || item.answer_comment || "");
|
|
191
|
+
d.comment = comment;
|
|
192
|
+
if (choices.length) {
|
|
193
|
+
body += `<fieldset><legend>Answer</legend>` + choices.map((choice, i) =>
|
|
194
|
+
`<label class="choice"><input type="radio" name="choice-${esc(item.id)}" value="${esc(choice.label)}"` +
|
|
195
|
+
`${choice.label === picked ? " checked" : ""}> <strong>${i + 1}. ${esc(choice.label)}</strong>` +
|
|
196
|
+
(choice.description ? ` <span class="desc">— ${esc(choice.description)}</span>` : "") + `</label>`
|
|
197
|
+
).join("") + `</fieldset>`;
|
|
198
|
+
}
|
|
199
|
+
body += `<textarea data-comment="${esc(item.id)}" placeholder="Comment${choices.length ? " (optional; saved as you type)" : " (saved as you type)"}">${esc(comment)}</textarea>` +
|
|
200
|
+
`<div class="actions">` +
|
|
201
|
+
(item.answered_at ? `<span class="given">recorded ${esc(item.answered_at)}</span>` : `<span class="given">not answered yet</span>`) +
|
|
202
|
+
(unsent ? `<span class="error">Not sent yet: the agent was not waiting. It is sent when it waits again; closing this tab loses it.</span>` : "") +
|
|
203
|
+
`<span class="error" data-error="${esc(item.id)}"></span></div>`;
|
|
204
|
+
return body;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function render(state) {
|
|
208
|
+
latest = state;
|
|
209
|
+
$("title").textContent = `${state.task_id} · ${state.workflow}` + (state.stage ? ` · ${state.stage}` : "");
|
|
210
|
+
$("progress").textContent = `${state.answered} of ${state.total} answered`;
|
|
211
|
+
$("note").textContent = state.paused
|
|
212
|
+
? "You said you were done for now; the agent has stopped. Answering an item brings it back."
|
|
213
|
+
: connected ? "Answer the items in any order. Every answer is recorded as soon as you give it."
|
|
214
|
+
: "The agent is not waiting right now.";
|
|
215
|
+
const holder = $("items");
|
|
216
|
+
holder.innerHTML = state.items.map((item) =>
|
|
217
|
+
`<article class="${esc(item.status)}" data-item="${esc(item.id)}">${card(item, state.choices)}</article>`
|
|
218
|
+
).join("");
|
|
219
|
+
holder.querySelectorAll("textarea[data-comment]").forEach((area) => {
|
|
220
|
+
const itemId = area.dataset.comment;
|
|
221
|
+
area.addEventListener("input", () => {
|
|
222
|
+
draft(itemId).comment = area.value; save();
|
|
223
|
+
clearTimeout(timers[itemId]);
|
|
224
|
+
timers[itemId] = setTimeout(() => answer(itemId, true), 1200);
|
|
225
|
+
});
|
|
226
|
+
area.addEventListener("blur", () => { clearTimeout(timers[itemId]); answer(itemId, true); });
|
|
227
|
+
});
|
|
228
|
+
holder.querySelectorAll("input[type=radio]").forEach((radio) => {
|
|
229
|
+
radio.addEventListener("change", () => answer(radio.name.slice(7), false, radio.value));
|
|
230
|
+
});
|
|
231
|
+
save();
|
|
232
|
+
setEnabled();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Keep what the operator is typing: refresh only the marks around it.
|
|
236
|
+
function patchStatus(state) {
|
|
237
|
+
latest = state;
|
|
238
|
+
$("progress").textContent = `${state.answered} of ${state.total} answered`;
|
|
239
|
+
for (const item of state.items) {
|
|
240
|
+
const article = document.querySelector(`article[data-item="${CSS.escape(item.id)}"]`);
|
|
241
|
+
if (!article) continue;
|
|
242
|
+
article.className = item.status;
|
|
243
|
+
const badge = article.querySelector(".badge:not(.unsent)");
|
|
244
|
+
if (badge) badge.textContent = { open: "open", answered: "answered", processed: "applied" }[item.status];
|
|
245
|
+
const given = article.querySelector(".actions .given");
|
|
246
|
+
if (given) given.textContent = item.answered_at ? `recorded ${item.answered_at}` : "not answered yet";
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function setEnabled() {
|
|
251
|
+
document.querySelectorAll("button").forEach((button) => { button.disabled = !connected; });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function post(path, payload) {
|
|
255
|
+
const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" },
|
|
256
|
+
body: JSON.stringify(payload) });
|
|
257
|
+
const body = await response.json().catch(() => ({}));
|
|
258
|
+
if (!response.ok) throw new Error(body.error || response.statusText);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function send(itemId, d) {
|
|
262
|
+
await post("/act", { action: "answer", item_id: itemId, choice: d.choice, comment: d.comment.trim() });
|
|
263
|
+
delete (local.queue || {})[itemId];
|
|
264
|
+
save();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const timers = {};
|
|
268
|
+
const sent = {};
|
|
269
|
+
|
|
270
|
+
function pickedNow(itemId) {
|
|
271
|
+
const checked = document.querySelector(`input[name="choice-${CSS.escape(itemId)}"]:checked`);
|
|
272
|
+
return checked ? checked.value : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function answer(itemId, fromComment, choice) {
|
|
276
|
+
const d = { choice: choice || pickedNow(itemId), comment: draft(itemId).comment || "" };
|
|
277
|
+
const error = document.querySelector(`[data-error="${CSS.escape(itemId)}"]`);
|
|
278
|
+
if (error) error.textContent = "";
|
|
279
|
+
const hasChoices = latest && latest.choices.length;
|
|
280
|
+
// A comment alone cannot answer a stage with choices; wait for the pick.
|
|
281
|
+
if (fromComment && hasChoices && !d.choice) return;
|
|
282
|
+
if (!hasChoices && !d.comment.trim()) return;
|
|
283
|
+
const signature = JSON.stringify([d.choice, d.comment.trim()]);
|
|
284
|
+
if (sent[itemId] === signature) return;
|
|
285
|
+
try {
|
|
286
|
+
await send(itemId, d);
|
|
287
|
+
sent[itemId] = signature;
|
|
288
|
+
} catch (failure) {
|
|
289
|
+
if (failure instanceof TypeError) {
|
|
290
|
+
// The server is gone: keep the answer to send when a wait is back.
|
|
291
|
+
local.queue = local.queue || {};
|
|
292
|
+
local.queue[itemId] = { choice: d.choice, comment: d.comment };
|
|
293
|
+
save();
|
|
294
|
+
} else if (error) {
|
|
295
|
+
error.textContent = failure.message;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
lastSignature = null;
|
|
299
|
+
refresh();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function flushQueue() {
|
|
303
|
+
for (const [itemId, d] of Object.entries(local.queue || {})) {
|
|
304
|
+
try { await send(itemId, d); } catch (failure) {
|
|
305
|
+
if (!(failure instanceof TypeError)) { delete local.queue[itemId]; save(); }
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
$("pause").addEventListener("click", async () => {
|
|
312
|
+
try { await post("/act", { action: "pause" }); lastSignature = null; } catch (failure) { alert(failure.message); }
|
|
313
|
+
refresh();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
window.addEventListener("pagehide", () => {
|
|
317
|
+
if (connected && navigator.sendBeacon) navigator.sendBeacon("/closing", "{}");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
async function refresh() {
|
|
321
|
+
try {
|
|
322
|
+
const response = await fetch("/state", { cache: "no-store" });
|
|
323
|
+
if (!response.ok) throw new Error(response.statusText);
|
|
324
|
+
const state = await response.json();
|
|
325
|
+
const wasConnected = connected;
|
|
326
|
+
connected = true;
|
|
327
|
+
$("status").textContent = "agent is waiting for your answers";
|
|
328
|
+
$("status").classList.remove("off");
|
|
329
|
+
if (!wasConnected && Object.keys(local.queue || {}).length) { await flushQueue(); lastSignature = null; }
|
|
330
|
+
bindRun(state);
|
|
331
|
+
const signature = JSON.stringify(state);
|
|
332
|
+
const typing = document.activeElement && document.activeElement.tagName === "TEXTAREA";
|
|
333
|
+
if (signature !== lastSignature || !wasConnected) {
|
|
334
|
+
lastSignature = signature;
|
|
335
|
+
if (typing) patchStatus(state); else render(state);
|
|
336
|
+
}
|
|
337
|
+
} catch (failure) {
|
|
338
|
+
connected = false;
|
|
339
|
+
$("status").textContent = "waiting for the agent…";
|
|
340
|
+
$("status").classList.add("off");
|
|
341
|
+
setEnabled();
|
|
342
|
+
if (latest) $("note").textContent = "The agent is not waiting right now. Answers you give now are kept in this tab and sent when it waits again.";
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
refresh();
|
|
347
|
+
setInterval(refresh, 1000);
|
|
348
|
+
})();
|
|
349
|
+
</script>
|
|
350
|
+
</body>
|
|
351
|
+
</html>
|