devops-bot-sdk 1.4.94__tar.gz → 1.4.99__tar.gz
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.
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/devops_bot_sdk.egg-info/SOURCES.txt +4 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/pyproject.toml +1 -1
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/client.py +11 -3
- devops_bot_sdk-1.4.99/sdk/design_verify/__init__.py +51 -0
- devops_bot_sdk-1.4.99/sdk/design_verify/browser.py +195 -0
- devops_bot_sdk-1.4.99/sdk/design_verify/compare.py +150 -0
- devops_bot_sdk-1.4.99/sdk/design_verify/loop.py +169 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/ipc/handlers.py +15 -6
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/README.md +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/config.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/crucial.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/git_ops.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/hooks/__init__.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/hooks/crucial_guard.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/local_exec.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/run_auto.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/test.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/updater.py +0 -0
- {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/setup.cfg +0 -0
|
@@ -24,6 +24,10 @@ sdk/collectors/__init__.py
|
|
|
24
24
|
sdk/collectors/files.py
|
|
25
25
|
sdk/collectors/process.py
|
|
26
26
|
sdk/collectors/screenshot.py
|
|
27
|
+
sdk/design_verify/__init__.py
|
|
28
|
+
sdk/design_verify/browser.py
|
|
29
|
+
sdk/design_verify/compare.py
|
|
30
|
+
sdk/design_verify/loop.py
|
|
27
31
|
sdk/hooks/__init__.py
|
|
28
32
|
sdk/hooks/crucial_guard.py
|
|
29
33
|
sdk/ipc/__init__.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devops-bot-sdk"
|
|
7
|
-
version = "1.4.
|
|
7
|
+
version = "1.4.99"
|
|
8
8
|
description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "LicenseRef-Proprietary"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
|
|
2
2
|
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.99
|
|
4
4
|
|
|
5
5
|
Public surface:
|
|
6
6
|
BackendClient.from_config() — create client from ~/.agentos/config.toml
|
|
@@ -30,7 +30,7 @@ Rules:
|
|
|
30
30
|
- All data egress through submit_webhook only
|
|
31
31
|
"""
|
|
32
32
|
|
|
33
|
-
__version__ = "1.4.
|
|
33
|
+
__version__ = "1.4.99"
|
|
34
34
|
__author__ = "AgentOS"
|
|
35
35
|
|
|
36
36
|
from sdk.client import BackendClient
|
|
@@ -58,7 +58,7 @@ def _notify_status(task_id: str, status: str) -> None:
|
|
|
58
58
|
pass
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
SDK_VERSION = "1.4.
|
|
61
|
+
SDK_VERSION = "1.4.99"
|
|
62
62
|
_POLL_INTERVAL = 3.0
|
|
63
63
|
_POLL_TIMEOUT = 600.0
|
|
64
64
|
_ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
|
|
@@ -849,9 +849,17 @@ class BackendClient:
|
|
|
849
849
|
headers = {k: v for k, v in self._headers.items() if k.lower() != "content-type"}
|
|
850
850
|
headers.update({"accept": "*/*", "developer-name": "null", "product": ""})
|
|
851
851
|
# POST /tasks-ml must never carry an SLA target — the backend validator
|
|
852
|
-
# rejects any value not shaped like "8h"
|
|
852
|
+
# rejects any value not shaped like "8h" (an empty "" 400s with
|
|
853
|
+
# "Slatarget must be a positive integer of hours…"), and SLA is not ours
|
|
854
|
+
# to set. Drop it by NORMALISED name so every casing/separator variant
|
|
855
|
+
# (slaTarget, slaTargets, sla_target, SLATarget, sla-target, …) is
|
|
856
|
+
# excluded — never sent as "" or anything else, always an empty field.
|
|
857
|
+
def _is_sla(key: str) -> bool:
|
|
858
|
+
norm = "".join(ch for ch in key.lower() if ch.isalnum())
|
|
859
|
+
return norm in ("slatarget", "slatargets")
|
|
860
|
+
|
|
853
861
|
files = [(k, (None, str(v))) for k, v in fields.items()
|
|
854
|
-
if v is not None and
|
|
862
|
+
if v is not None and not _is_sla(k)]
|
|
855
863
|
for m in (model_used or []):
|
|
856
864
|
files.append(("modelUsed[]", (None, str(m)))) # [] → backend parses as array
|
|
857
865
|
try:
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Visual design verification & refinement loop (prototype).
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
Design-to-code fidelity is capped when the system generates a frontend ONCE
|
|
6
|
+
from a JIRA screenshot and never *looks* at what it built. This package closes
|
|
7
|
+
that loop:
|
|
8
|
+
|
|
9
|
+
generate -> render in a real browser -> screenshot -> compare against the
|
|
10
|
+
original design -> feed the differences back -> correct -> repeat.
|
|
11
|
+
|
|
12
|
+
It is deliberately decoupled from the main pipeline (nothing here is imported by
|
|
13
|
+
the live `handlers`/`local_exec` flow yet) so it can be developed and tested in
|
|
14
|
+
isolation, then slotted into `_code_one_repo` once proven.
|
|
15
|
+
|
|
16
|
+
Two external pieces it leans on — both already available, no new Python deps:
|
|
17
|
+
|
|
18
|
+
* ``agent-browser`` — Vercel Labs' Rust browser-automation CLI. Renders the
|
|
19
|
+
generated UI in headless Chrome and screenshots it. Install separately; see
|
|
20
|
+
``README.md``. This is the ONLY new tool requirement.
|
|
21
|
+
* the local ``claude`` CLI — reused via :func:`sdk.local_exec.run_claude_local`.
|
|
22
|
+
Its built-in ``Read`` tool reads image files natively, so it doubles as the
|
|
23
|
+
vision model that diffs the two screenshots. No `anthropic`/`openai` package
|
|
24
|
+
is added.
|
|
25
|
+
|
|
26
|
+
What the caller must supply
|
|
27
|
+
---------------------------
|
|
28
|
+
The one input this repo does not currently receive is the **original design
|
|
29
|
+
image** (the JIRA attachment). The backend hydrates task *text* only — attachment
|
|
30
|
+
URLs never reach the SDK today (see ``sdk/models/requests.py``). To use this
|
|
31
|
+
loop, the backend/operator must provide a local path to that screenshot. That
|
|
32
|
+
integration is out of scope for this prototype; the loop takes the path as an
|
|
33
|
+
argument.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from .browser import BrowserError, DiffResult, diff_baseline, render_screenshot, snapshot
|
|
38
|
+
from .compare import ComparisonReport, compare_design
|
|
39
|
+
from .loop import RefinementResult, verify_and_refine
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"render_screenshot",
|
|
43
|
+
"snapshot",
|
|
44
|
+
"diff_baseline",
|
|
45
|
+
"DiffResult",
|
|
46
|
+
"BrowserError",
|
|
47
|
+
"compare_design",
|
|
48
|
+
"ComparisonReport",
|
|
49
|
+
"verify_and_refine",
|
|
50
|
+
"RefinementResult",
|
|
51
|
+
]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Thin async wrapper around the ``agent-browser`` CLI.
|
|
2
|
+
|
|
3
|
+
Only the two operations the verification loop needs are exposed: render a URL and
|
|
4
|
+
screenshot it to a file, and grab a structured accessibility snapshot. The
|
|
5
|
+
daemon that ``agent-browser`` runs behind the scenes starts on first command and
|
|
6
|
+
persists between calls, so successive screenshots in a refinement loop are cheap.
|
|
7
|
+
|
|
8
|
+
Command syntax mirrors the upstream CLI (vercel-labs/agent-browser):
|
|
9
|
+
|
|
10
|
+
agent-browser open <url> # navigate (aliases: navigate, goto)
|
|
11
|
+
agent-browser screenshot <path> # --full, --screenshot-format png|jpeg
|
|
12
|
+
agent-browser snapshot --json # accessibility tree with @e refs
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_BINARY = "agent-browser"
|
|
23
|
+
# A single browser op should never hang the loop; the daemon is local.
|
|
24
|
+
_CMD_TIMEOUT_S = 60.0
|
|
25
|
+
|
|
26
|
+
# agent-browser's `diff screenshot` prints human-readable lines; parse defensively
|
|
27
|
+
# rather than assume a stable JSON shape. Captures "12.34%" and "1234 pixels".
|
|
28
|
+
_PCT_RE = re.compile(r"([\d.]+)\s*%")
|
|
29
|
+
_PIXELS_RE = re.compile(r"([\d,]+)\s*(?:different\s+)?pixels", re.IGNORECASE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BrowserError(RuntimeError):
|
|
33
|
+
"""Raised when the agent-browser CLI is missing or a command fails."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class DiffResult:
|
|
38
|
+
"""Outcome of a native `agent-browser diff screenshot` pixel comparison."""
|
|
39
|
+
mismatch_pct: float | None # None when a pixel % could not be determined
|
|
40
|
+
diff_pixels: int | None
|
|
41
|
+
diff_image: str | None # path to the red-highlighted diff image
|
|
42
|
+
dimension_mismatch: bool # baseline/current sizes differ -> no pixel diff
|
|
43
|
+
raw: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_available() -> bool:
|
|
47
|
+
"""True if the ``agent-browser`` binary is on PATH."""
|
|
48
|
+
return shutil.which(_BINARY) is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _run(*args: str) -> str:
|
|
52
|
+
"""Run one agent-browser subcommand, returning stdout (text)."""
|
|
53
|
+
if not is_available():
|
|
54
|
+
raise BrowserError(
|
|
55
|
+
f"`{_BINARY}` not found on PATH. Install it (see "
|
|
56
|
+
"sdk/design_verify/README.md) — it is the only external dependency "
|
|
57
|
+
"of the design verification loop."
|
|
58
|
+
)
|
|
59
|
+
proc = await asyncio.create_subprocess_exec(
|
|
60
|
+
_BINARY, *args,
|
|
61
|
+
stdout=asyncio.subprocess.PIPE,
|
|
62
|
+
stderr=asyncio.subprocess.PIPE,
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
out, err = await asyncio.wait_for(proc.communicate(), timeout=_CMD_TIMEOUT_S)
|
|
66
|
+
except asyncio.TimeoutError as exc: # pragma: no cover - defensive
|
|
67
|
+
proc.kill()
|
|
68
|
+
raise BrowserError(f"`{_BINARY} {args[0]}` timed out after {_CMD_TIMEOUT_S}s") from exc
|
|
69
|
+
if proc.returncode != 0:
|
|
70
|
+
raise BrowserError(
|
|
71
|
+
f"`{_BINARY} {' '.join(args)}` failed (exit {proc.returncode}): "
|
|
72
|
+
f"{err.decode(errors='replace').strip()}"
|
|
73
|
+
)
|
|
74
|
+
return out.decode(errors="replace")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def render_screenshot(
|
|
78
|
+
url: str,
|
|
79
|
+
out_path: str | Path,
|
|
80
|
+
*,
|
|
81
|
+
full_page: bool = False,
|
|
82
|
+
fmt: str = "png",
|
|
83
|
+
) -> Path:
|
|
84
|
+
"""Navigate to ``url`` and screenshot it to ``out_path``.
|
|
85
|
+
|
|
86
|
+
``full_page`` captures the entire scrollable page (``--full``); the default
|
|
87
|
+
captures just the viewport, which usually matches a design mock's framing
|
|
88
|
+
better. Returns the resolved output path.
|
|
89
|
+
"""
|
|
90
|
+
out = Path(out_path).expanduser().resolve()
|
|
91
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
await _run("open", url)
|
|
93
|
+
args = ["screenshot", str(out), "--screenshot-format", fmt]
|
|
94
|
+
if full_page:
|
|
95
|
+
args.append("--full")
|
|
96
|
+
await _run(*args)
|
|
97
|
+
if not out.exists():
|
|
98
|
+
raise BrowserError(f"screenshot reported success but {out} was not written")
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def snapshot(url: str | None = None, *, interactive_only: bool = False) -> str:
|
|
103
|
+
"""Return the page's accessibility tree as JSON text.
|
|
104
|
+
|
|
105
|
+
Lets the comparison assert *structure* (roles, labels, hierarchy) — not just
|
|
106
|
+
pixels. If ``url`` is given, navigates first; otherwise snapshots the current
|
|
107
|
+
page. Useful as a secondary signal when a pixel diff is ambiguous.
|
|
108
|
+
"""
|
|
109
|
+
if url:
|
|
110
|
+
await _run("open", url)
|
|
111
|
+
args = ["snapshot", "--json"]
|
|
112
|
+
if interactive_only:
|
|
113
|
+
args.append("--interactive")
|
|
114
|
+
return await _run(*args)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def diff_baseline(
|
|
118
|
+
baseline: str | Path,
|
|
119
|
+
*,
|
|
120
|
+
out_path: str | Path,
|
|
121
|
+
url: str | None = None,
|
|
122
|
+
threshold: float = 0.1,
|
|
123
|
+
) -> DiffResult:
|
|
124
|
+
"""Pixel-diff the current page against a ``baseline`` image.
|
|
125
|
+
|
|
126
|
+
Runs ``agent-browser diff screenshot --baseline <baseline> -o <out> -t <thr>``,
|
|
127
|
+
which screenshots the live page and compares it to ``baseline`` at the pixel
|
|
128
|
+
level, writing a red-highlighted diff image to ``out_path``. If ``url`` is
|
|
129
|
+
given, navigates there first.
|
|
130
|
+
|
|
131
|
+
This is a *quantitative, localizing* signal — mismatch % and a highlight
|
|
132
|
+
image — NOT a semantic judgement. Baselines whose dimensions differ from the
|
|
133
|
+
render yield ``dimension_mismatch=True`` (the CLI refuses a pixel compare);
|
|
134
|
+
that is expected for design mocks and is handled gracefully by the caller,
|
|
135
|
+
which falls back to the vision comparison.
|
|
136
|
+
|
|
137
|
+
A nonzero exit here means "differences found," not failure, so — unlike the
|
|
138
|
+
other commands — we do not raise on it; we parse the reported metrics.
|
|
139
|
+
"""
|
|
140
|
+
if not is_available():
|
|
141
|
+
raise BrowserError(
|
|
142
|
+
f"`{_BINARY}` not found on PATH. Install it (see "
|
|
143
|
+
"sdk/design_verify/README.md)."
|
|
144
|
+
)
|
|
145
|
+
base = Path(baseline).expanduser().resolve()
|
|
146
|
+
if not base.exists():
|
|
147
|
+
raise BrowserError(f"baseline image not found: {base}")
|
|
148
|
+
out = Path(out_path).expanduser().resolve()
|
|
149
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
|
|
151
|
+
if url:
|
|
152
|
+
await _run("open", url)
|
|
153
|
+
|
|
154
|
+
proc = await asyncio.create_subprocess_exec(
|
|
155
|
+
_BINARY, "diff", "screenshot",
|
|
156
|
+
"--baseline", str(base),
|
|
157
|
+
"-o", str(out),
|
|
158
|
+
"-t", str(threshold),
|
|
159
|
+
stdout=asyncio.subprocess.PIPE,
|
|
160
|
+
stderr=asyncio.subprocess.PIPE,
|
|
161
|
+
)
|
|
162
|
+
try:
|
|
163
|
+
raw_out, raw_err = await asyncio.wait_for(
|
|
164
|
+
proc.communicate(), timeout=_CMD_TIMEOUT_S
|
|
165
|
+
)
|
|
166
|
+
except asyncio.TimeoutError as exc: # pragma: no cover - defensive
|
|
167
|
+
proc.kill()
|
|
168
|
+
raise BrowserError(f"`{_BINARY} diff screenshot` timed out") from exc
|
|
169
|
+
|
|
170
|
+
text = (raw_out.decode(errors="replace") + "\n" + raw_err.decode(errors="replace")).strip()
|
|
171
|
+
dim_mismatch = "dimension mismatch" in text.lower()
|
|
172
|
+
pct = None
|
|
173
|
+
px = None
|
|
174
|
+
if not dim_mismatch:
|
|
175
|
+
m = _PCT_RE.search(text)
|
|
176
|
+
if m:
|
|
177
|
+
pct = float(m.group(1))
|
|
178
|
+
m = _PIXELS_RE.search(text)
|
|
179
|
+
if m:
|
|
180
|
+
px = int(m.group(1).replace(",", ""))
|
|
181
|
+
return DiffResult(
|
|
182
|
+
mismatch_pct=pct,
|
|
183
|
+
diff_pixels=px,
|
|
184
|
+
diff_image=str(out) if out.exists() else None,
|
|
185
|
+
dimension_mismatch=dim_mismatch,
|
|
186
|
+
raw=text,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def close() -> None:
|
|
191
|
+
"""Close the active browser session (best-effort)."""
|
|
192
|
+
try:
|
|
193
|
+
await _run("close")
|
|
194
|
+
except BrowserError:
|
|
195
|
+
pass
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Vision comparison of a rendered UI against the original design.
|
|
2
|
+
|
|
3
|
+
Instead of adding an `anthropic`/`openai` dependency, this reuses the local
|
|
4
|
+
``claude`` CLI (via :func:`sdk.local_exec.run_claude_local`). The CLI's built-in
|
|
5
|
+
``Read`` tool reads image files natively, so we point it at BOTH the original
|
|
6
|
+
design screenshot and the freshly rendered one and ask it to produce a strict
|
|
7
|
+
JSON diff. This keeps the loop dependency-free and consistent with how the rest
|
|
8
|
+
of the SDK already shells out to the local agent.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from sdk.local_exec import run_claude_local
|
|
17
|
+
|
|
18
|
+
# Only Read is needed — the comparison must never edit files.
|
|
19
|
+
_COMPARE_TOOLS = ["Read"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Discrepancy:
|
|
24
|
+
area: str # e.g. "header", "primary CTA", "card grid spacing"
|
|
25
|
+
severity: str # "high" | "medium" | "low"
|
|
26
|
+
expected: str # what the design shows
|
|
27
|
+
actual: str # what the rendered UI shows
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ComparisonReport:
|
|
32
|
+
fidelity_score: int # 0-100, higher = closer to design
|
|
33
|
+
discrepancies: list[Discrepancy] = field(default_factory=list)
|
|
34
|
+
correction_prompt: str = "" # concrete fix instructions for the coder
|
|
35
|
+
raw: str = "" # raw model output (for debugging)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def passed(self) -> bool:
|
|
39
|
+
return self.fidelity_score >= 0 # threshold applied by the loop, not here
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_compare_prompt(
|
|
43
|
+
design_path: Path,
|
|
44
|
+
rendered_path: Path,
|
|
45
|
+
diff_path: Path | None = None,
|
|
46
|
+
diff_note: str = "",
|
|
47
|
+
) -> str:
|
|
48
|
+
"""Prompt the local agent to diff two images and emit strict JSON only."""
|
|
49
|
+
diff_block = ""
|
|
50
|
+
if diff_path is not None:
|
|
51
|
+
diff_block = (
|
|
52
|
+
f" 3. PIXEL DIFF (changed regions highlighted in red): {diff_path}\n"
|
|
53
|
+
)
|
|
54
|
+
hint = ""
|
|
55
|
+
if diff_note:
|
|
56
|
+
hint = (
|
|
57
|
+
"\nA pixel-level comparison was already run: "
|
|
58
|
+
f"{diff_note} Read the PIXEL DIFF image to see WHERE the pixels "
|
|
59
|
+
"differ (red = changed), and use it to localize your review — but "
|
|
60
|
+
"judge semantically, not by raw pixel count (fonts/anti-aliasing/"
|
|
61
|
+
"placeholder content inflate raw pixel diffs).\n"
|
|
62
|
+
)
|
|
63
|
+
return (
|
|
64
|
+
"You are a meticulous UI design reviewer. Images are on disk:\n"
|
|
65
|
+
f" 1. TARGET DESIGN (the intended look): {design_path}\n"
|
|
66
|
+
f" 2. CURRENT RENDER (what our generated frontend actually looks like): "
|
|
67
|
+
f"{rendered_path}\n"
|
|
68
|
+
f"{diff_block}"
|
|
69
|
+
f"{hint}\n"
|
|
70
|
+
"Use the Read tool to open and view the images. Then compare the CURRENT "
|
|
71
|
+
"RENDER against the TARGET DESIGN across: overall layout & structure, "
|
|
72
|
+
"spacing/alignment, colors, typography (family/size/weight), component "
|
|
73
|
+
"presence and ordering, iconography, and content/labels.\n\n"
|
|
74
|
+
"Output ONLY a single JSON object (no prose, no markdown fences) with this "
|
|
75
|
+
"exact shape:\n"
|
|
76
|
+
"{\n"
|
|
77
|
+
' "fidelity_score": <int 0-100, how closely the render matches the design>,\n'
|
|
78
|
+
' "discrepancies": [\n'
|
|
79
|
+
' {"area": "<region/component>", "severity": "high|medium|low", '
|
|
80
|
+
'"expected": "<what the design shows>", "actual": "<what the render shows>"}\n'
|
|
81
|
+
" ],\n"
|
|
82
|
+
' "correction_prompt": "<a concise, imperative set of concrete code changes '
|
|
83
|
+
"that would close the gaps — reference components, colors (hex if visible), "
|
|
84
|
+
'spacing, and layout explicitly so a coding agent can act on it directly>"\n'
|
|
85
|
+
"}\n\n"
|
|
86
|
+
"Rank discrepancies most-severe first. If the render already matches "
|
|
87
|
+
"closely, return a high score, an empty discrepancies list, and an empty "
|
|
88
|
+
"correction_prompt."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _extract_json(text: str) -> dict:
|
|
93
|
+
"""Pull the first balanced JSON object out of the model's output."""
|
|
94
|
+
start = text.find("{")
|
|
95
|
+
end = text.rfind("}")
|
|
96
|
+
if start == -1 or end == -1 or end <= start:
|
|
97
|
+
raise ValueError(f"no JSON object found in comparison output: {text[:200]!r}")
|
|
98
|
+
return json.loads(text[start : end + 1])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def compare_design(
|
|
102
|
+
design_path: str | Path,
|
|
103
|
+
rendered_path: str | Path,
|
|
104
|
+
*,
|
|
105
|
+
work_dir: str | Path,
|
|
106
|
+
diff_path: str | Path | None = None,
|
|
107
|
+
diff_note: str = "",
|
|
108
|
+
) -> ComparisonReport:
|
|
109
|
+
"""Diff a rendered screenshot against the target design; return a report.
|
|
110
|
+
|
|
111
|
+
``work_dir`` is the cwd handed to the local ``claude`` CLI — any readable dir
|
|
112
|
+
works; the image paths are passed absolute so location does not matter.
|
|
113
|
+
|
|
114
|
+
``diff_path``/``diff_note`` optionally pass a pre-computed pixel-diff image
|
|
115
|
+
(from :func:`sdk.design_verify.browser.diff_baseline`) and its metrics, so
|
|
116
|
+
the vision model can localize its review to the highlighted regions.
|
|
117
|
+
"""
|
|
118
|
+
design = Path(design_path).expanduser().resolve()
|
|
119
|
+
rendered = Path(rendered_path).expanduser().resolve()
|
|
120
|
+
for p in (design, rendered):
|
|
121
|
+
if not p.exists():
|
|
122
|
+
raise FileNotFoundError(f"image not found: {p}")
|
|
123
|
+
diff = Path(diff_path).expanduser().resolve() if diff_path else None
|
|
124
|
+
if diff is not None and not diff.exists():
|
|
125
|
+
diff = None # diff is a best-effort aid; never fail the review on it
|
|
126
|
+
|
|
127
|
+
result = await run_claude_local(
|
|
128
|
+
_build_compare_prompt(design, rendered, diff, diff_note),
|
|
129
|
+
str(work_dir),
|
|
130
|
+
allowed_tools=_COMPARE_TOOLS,
|
|
131
|
+
)
|
|
132
|
+
if not result.get("ok"):
|
|
133
|
+
raise RuntimeError(f"comparison run failed: {result.get('error')}")
|
|
134
|
+
|
|
135
|
+
raw = result.get("result", "") or ""
|
|
136
|
+
data = _extract_json(raw)
|
|
137
|
+
return ComparisonReport(
|
|
138
|
+
fidelity_score=int(data.get("fidelity_score", 0)),
|
|
139
|
+
discrepancies=[
|
|
140
|
+
Discrepancy(
|
|
141
|
+
area=str(d.get("area", "")),
|
|
142
|
+
severity=str(d.get("severity", "")),
|
|
143
|
+
expected=str(d.get("expected", "")),
|
|
144
|
+
actual=str(d.get("actual", "")),
|
|
145
|
+
)
|
|
146
|
+
for d in data.get("discrepancies", [])
|
|
147
|
+
],
|
|
148
|
+
correction_prompt=str(data.get("correction_prompt", "")),
|
|
149
|
+
raw=raw,
|
|
150
|
+
)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""The closed refinement loop: render -> compare -> correct -> repeat.
|
|
2
|
+
|
|
3
|
+
This is the piece that turns a one-shot design-to-code generation into a
|
|
4
|
+
self-correcting one. It assumes the frontend has already been generated once and
|
|
5
|
+
is being served at ``preview_url`` (a local dev server). Each iteration:
|
|
6
|
+
|
|
7
|
+
1. screenshot the live preview with agent-browser
|
|
8
|
+
2. compare it against the target design (local claude vision diff)
|
|
9
|
+
3. stop if fidelity >= threshold or we've run out of iterations
|
|
10
|
+
4. otherwise feed the correction prompt back to the coding agent, which edits
|
|
11
|
+
the real files; then loop (the caller's dev server hot-reloads)
|
|
12
|
+
|
|
13
|
+
The coding step is injectable (``coder``) so the real pipeline can plug in its
|
|
14
|
+
session-resuming ``run_claude_local`` call (keeping context across iterations)
|
|
15
|
+
instead of the default fresh-run coder used here.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Awaitable, Callable
|
|
23
|
+
|
|
24
|
+
from sdk.local_exec import DEFAULT_ALLOWED_TOOLS, run_claude_local
|
|
25
|
+
|
|
26
|
+
from .browser import BrowserError, diff_baseline, render_screenshot
|
|
27
|
+
from .compare import ComparisonReport, compare_design
|
|
28
|
+
|
|
29
|
+
# A coder takes (project_path, correction_prompt) and applies fixes in place.
|
|
30
|
+
Coder = Callable[[str, str], Awaitable[None]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Iteration:
|
|
35
|
+
index: int
|
|
36
|
+
screenshot: str
|
|
37
|
+
fidelity_score: int
|
|
38
|
+
discrepancy_count: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class RefinementResult:
|
|
43
|
+
passed: bool
|
|
44
|
+
iterations: list[Iteration] = field(default_factory=list)
|
|
45
|
+
final_report: ComparisonReport | None = None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def best_score(self) -> int:
|
|
49
|
+
return max((it.fidelity_score for it in self.iterations), default=0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _default_coder(project_path: str, correction_prompt: str) -> None:
|
|
53
|
+
"""Fallback coder: a fresh local `claude` run that applies the corrections.
|
|
54
|
+
|
|
55
|
+
The real pipeline should pass a ``coder`` that RESUMES the ticket's session
|
|
56
|
+
(``resume_session_id=...``) so the agent keeps the design context across
|
|
57
|
+
iterations instead of starting cold each time.
|
|
58
|
+
"""
|
|
59
|
+
prompt = (
|
|
60
|
+
"A visual review compared the current frontend against the target design "
|
|
61
|
+
"and found the differences below. Edit the code in this repository to "
|
|
62
|
+
"close them. Make the minimal, correct changes; do not restructure "
|
|
63
|
+
"unrelated code.\n\n"
|
|
64
|
+
f"{correction_prompt}"
|
|
65
|
+
)
|
|
66
|
+
result = await run_claude_local(
|
|
67
|
+
prompt, project_path, allowed_tools=DEFAULT_ALLOWED_TOOLS
|
|
68
|
+
)
|
|
69
|
+
if not result.get("ok"):
|
|
70
|
+
raise RuntimeError(f"correction run failed: {result.get('error')}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def verify_and_refine(
|
|
74
|
+
*,
|
|
75
|
+
project_path: str | Path,
|
|
76
|
+
design_path: str | Path,
|
|
77
|
+
preview_url: str,
|
|
78
|
+
artifact_dir: str | Path,
|
|
79
|
+
threshold: int = 90,
|
|
80
|
+
max_iterations: int = 3,
|
|
81
|
+
full_page: bool = False,
|
|
82
|
+
coder: Coder | None = None,
|
|
83
|
+
on_event: Callable[[str], None] | None = None,
|
|
84
|
+
) -> RefinementResult:
|
|
85
|
+
"""Iteratively refine the generated UI until it matches ``design_path``.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
project_path: repo whose frontend files get edited on each correction.
|
|
89
|
+
design_path: the original design image (e.g. the JIRA screenshot).
|
|
90
|
+
preview_url: URL where the generated UI is being served (dev server).
|
|
91
|
+
artifact_dir: where per-iteration screenshots are written.
|
|
92
|
+
threshold: stop once fidelity_score >= this (0-100).
|
|
93
|
+
max_iterations: hard cap on correction rounds.
|
|
94
|
+
coder: applies corrections; defaults to a fresh local claude run.
|
|
95
|
+
on_event: optional progress sink (receives human-readable lines).
|
|
96
|
+
|
|
97
|
+
Returns a :class:`RefinementResult` with per-iteration scores and the final
|
|
98
|
+
comparison report.
|
|
99
|
+
"""
|
|
100
|
+
project_path = str(project_path)
|
|
101
|
+
art = Path(artifact_dir).expanduser().resolve()
|
|
102
|
+
art.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
coder = coder or _default_coder
|
|
104
|
+
|
|
105
|
+
def emit(msg: str) -> None:
|
|
106
|
+
if on_event:
|
|
107
|
+
on_event(msg)
|
|
108
|
+
|
|
109
|
+
result = RefinementResult(passed=False)
|
|
110
|
+
report: ComparisonReport | None = None
|
|
111
|
+
|
|
112
|
+
for i in range(1, max_iterations + 1):
|
|
113
|
+
shot = art / f"render_iter_{i}.png"
|
|
114
|
+
emit(f"[iter {i}/{max_iterations}] rendering {preview_url}")
|
|
115
|
+
await render_screenshot(preview_url, shot, full_page=full_page)
|
|
116
|
+
|
|
117
|
+
# Native pixel diff first — a fast, localizing signal + a highlight image
|
|
118
|
+
# the vision step can reason over. Best-effort: design mocks often differ
|
|
119
|
+
# in dimensions (CLI refuses the pixel compare), so never fail the loop
|
|
120
|
+
# on it — fall back to a pure vision comparison.
|
|
121
|
+
diff_path: str | None = None
|
|
122
|
+
diff_note = ""
|
|
123
|
+
try:
|
|
124
|
+
dr = await diff_baseline(
|
|
125
|
+
design_path, out_path=art / f"diff_iter_{i}.png"
|
|
126
|
+
)
|
|
127
|
+
if dr.dimension_mismatch:
|
|
128
|
+
diff_note = (
|
|
129
|
+
"the render and design differ in dimensions, so no pixel "
|
|
130
|
+
"diff was produced."
|
|
131
|
+
)
|
|
132
|
+
elif dr.mismatch_pct is not None:
|
|
133
|
+
diff_path = dr.diff_image
|
|
134
|
+
diff_note = f"the raw pixel mismatch is {dr.mismatch_pct:.1f}%."
|
|
135
|
+
except BrowserError as exc:
|
|
136
|
+
emit(f"[iter {i}/{max_iterations}] pixel diff skipped: {exc}")
|
|
137
|
+
|
|
138
|
+
emit(f"[iter {i}/{max_iterations}] comparing against design")
|
|
139
|
+
report = await compare_design(
|
|
140
|
+
design_path, shot, work_dir=art,
|
|
141
|
+
diff_path=diff_path, diff_note=diff_note,
|
|
142
|
+
)
|
|
143
|
+
result.iterations.append(
|
|
144
|
+
Iteration(
|
|
145
|
+
index=i,
|
|
146
|
+
screenshot=str(shot),
|
|
147
|
+
fidelity_score=report.fidelity_score,
|
|
148
|
+
discrepancy_count=len(report.discrepancies),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
emit(
|
|
152
|
+
f"[iter {i}/{max_iterations}] fidelity={report.fidelity_score} "
|
|
153
|
+
f"discrepancies={len(report.discrepancies)}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if report.fidelity_score >= threshold or not report.correction_prompt:
|
|
157
|
+
result.passed = report.fidelity_score >= threshold
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
if i == max_iterations:
|
|
161
|
+
break # scored but out of correction budget
|
|
162
|
+
|
|
163
|
+
emit(f"[iter {i}/{max_iterations}] applying corrections")
|
|
164
|
+
await coder(project_path, report.correction_prompt)
|
|
165
|
+
# Give a hot-reloading dev server a moment to rebuild before re-shooting.
|
|
166
|
+
time.sleep(2)
|
|
167
|
+
|
|
168
|
+
result.final_report = report
|
|
169
|
+
return result
|
|
@@ -1355,13 +1355,22 @@ async def _handle_story(
|
|
|
1355
1355
|
# The Story is complete — nothing to convert or code in this run.
|
|
1356
1356
|
return None
|
|
1357
1357
|
|
|
1358
|
-
# STEP 1 —
|
|
1359
|
-
#
|
|
1360
|
-
#
|
|
1361
|
-
#
|
|
1358
|
+
# STEP 1 — do we have CODEBASE UNDERSTANDING to ground a PRD in? That means an
|
|
1359
|
+
# actual repo target (a github/gitlab/bitbucket link in the instructions OR the
|
|
1360
|
+
# story), or dev work called for in the OPERATOR INSTRUCTIONS (PRs, branches,
|
|
1361
|
+
# endpoints, deploy, …).
|
|
1362
|
+
#
|
|
1363
|
+
# Deliberately NOT keyed off the story DESCRIPTION's vocabulary or a "PRD"
|
|
1364
|
+
# mention: a business/BA user story that merely contains technical words
|
|
1365
|
+
# (endpoint, API, database, "PRD", …) is NOT a codebase task. Keying off the
|
|
1366
|
+
# description used to false-positive such stories into PRD generation, which
|
|
1367
|
+
# folded an unwanted PRD into — and mangled — the operator's own document.
|
|
1362
1368
|
has_repo = bool(_extract_repo_targets(human)) or bool(_extract_repo_targets(user_story))
|
|
1363
|
-
is_codebase_task = has_repo or _is_dev_task(human)
|
|
1364
|
-
|
|
1369
|
+
is_codebase_task = has_repo or _is_dev_task(human)
|
|
1370
|
+
# PRD ONLY when we have codebase understanding. Without a repo/codebase, the
|
|
1371
|
+
# Story is converted to a Task with its description left intact (the whole,
|
|
1372
|
+
# proper document the operator provided) — never rewritten with a PRD.
|
|
1373
|
+
wants_prd = is_codebase_task
|
|
1365
1374
|
|
|
1366
1375
|
if is_codebase_task:
|
|
1367
1376
|
step = ("validated as a codebase task — building a technical PRD from the "
|
|
File without changes
|
{devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/devops_bot_sdk.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|