sp2react 0.1.0__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.
- sp2react/__init__.py +18 -0
- sp2react/agents/__init__.py +0 -0
- sp2react/agents/core.py +146 -0
- sp2react/agents/prompts/dbmap.md +28 -0
- sp2react/agents/prompts/fix.md +11 -0
- sp2react/agents/prompts/handler.md +22 -0
- sp2react/agents/prompts/page.md +40 -0
- sp2react/agents/prompts/review.md +21 -0
- sp2react/agents/prompts/spec.md +79 -0
- sp2react/agents/tasks.py +374 -0
- sp2react/analysis/__init__.py +0 -0
- sp2react/analysis/aspx.py +254 -0
- sp2react/analysis/behaviors.py +357 -0
- sp2react/analysis/fields.py +152 -0
- sp2react/analysis/inventory.py +314 -0
- sp2react/analysis/jsscan.py +330 -0
- sp2react/checks.py +105 -0
- sp2react/cli.py +188 -0
- sp2react/config.py +172 -0
- sp2react/dbmap.py +245 -0
- sp2react/llm/__init__.py +0 -0
- sp2react/llm/providers.py +219 -0
- sp2react/migrate.py +255 -0
- sp2react/net.py +60 -0
- sp2react/pipeline.py +583 -0
- sp2react/scaffold/__init__.py +0 -0
- sp2react/scaffold/generate.py +279 -0
- sp2react/scaffold/rule_vectors.json +3826 -0
- sp2react/scaffold/templates/backend/app/__init__.py +0 -0
- sp2react/scaffold/templates/backend/app/api.py +293 -0
- sp2react/scaffold/templates/backend/app/auth.py +33 -0
- sp2react/scaffold/templates/backend/app/custom/__init__.py +22 -0
- sp2react/scaffold/templates/backend/app/main.py +47 -0
- sp2react/scaffold/templates/backend/app/odata.py +185 -0
- sp2react/scaffold/templates/backend/app/rules.py +256 -0
- sp2react/scaffold/templates/backend/app/sp_repo.py +159 -0
- sp2react/scaffold/templates/backend/app/sql_repo.py +437 -0
- sp2react/scaffold/templates/backend/app/store.py +224 -0
- sp2react/scaffold/templates/backend/requirements.txt +8 -0
- sp2react/scaffold/templates/backend/tests/conftest.py +10 -0
- sp2react/scaffold/templates/backend/tests/test_rule_vectors.py +38 -0
- sp2react/scaffold/templates/frontend/index.html +12 -0
- sp2react/scaffold/templates/frontend/package.json +24 -0
- sp2react/scaffold/templates/frontend/scripts/check-rules.ts +23 -0
- sp2react/scaffold/templates/frontend/src/api.ts +88 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Banner.tsx +4 -0
- sp2react/scaffold/templates/frontend/src/components/sp/DataTable.tsx +38 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Form.tsx +252 -0
- sp2react/scaffold/templates/frontend/src/components/sp/GenericPage.tsx +28 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Layout.tsx +54 -0
- sp2react/scaffold/templates/frontend/src/components/sp/LegacyHtml.tsx +9 -0
- sp2react/scaffold/templates/frontend/src/components/sp/ListView.tsx +59 -0
- sp2react/scaffold/templates/frontend/src/components/sp/Tiles.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/components/sp/fields.tsx +132 -0
- sp2react/scaffold/templates/frontend/src/custom/types.ts +10 -0
- sp2react/scaffold/templates/frontend/src/main.tsx +14 -0
- sp2react/scaffold/templates/frontend/src/runtime/format.ts +36 -0
- sp2react/scaffold/templates/frontend/src/runtime/rules.ts +225 -0
- sp2react/scaffold/templates/frontend/src/runtime/spec.ts +102 -0
- sp2react/scaffold/templates/frontend/src/runtime/user.tsx +21 -0
- sp2react/scaffold/templates/frontend/src/screens/LegacyRedirect.tsx +27 -0
- sp2react/scaffold/templates/frontend/src/screens/ListFormScreen.tsx +33 -0
- sp2react/scaffold/templates/frontend/src/styles/sp.css +95 -0
- sp2react/scaffold/templates/frontend/src/vite-env.d.ts +1 -0
- sp2react/scaffold/templates/frontend/tsconfig.json +18 -0
- sp2react/scaffold/templates/frontend/vite.config.ts +7 -0
- sp2react/sharepoint/__init__.py +0 -0
- sp2react/sharepoint/client.py +210 -0
- sp2react/sharepoint/extract.py +217 -0
- sp2react/spec/__init__.py +0 -0
- sp2react/spec/base.py +80 -0
- sp2react/spec/coverage.py +191 -0
- sp2react/spec/model.py +133 -0
- sp2react-0.1.0.dist-info/METADATA +238 -0
- sp2react-0.1.0.dist-info/RECORD +79 -0
- sp2react-0.1.0.dist-info/WHEEL +5 -0
- sp2react-0.1.0.dist-info/entry_points.txt +2 -0
- sp2react-0.1.0.dist-info/licenses/LICENSE +21 -0
- sp2react-0.1.0.dist-info/top_level.txt +1 -0
sp2react/pipeline.py
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""The ten stages, in order, with gating: a stage runs only when the stage before it finished.
|
|
2
|
+
|
|
3
|
+
1 inputs config + connectivity (code)
|
|
4
|
+
2 extract pull pages, web parts, scripts, lists (code, read-only)
|
|
5
|
+
3 inventory behaviours + data calls per screen (code)
|
|
6
|
+
4 spec base spec + spec agent + coverage gate (AI agent, verified)
|
|
7
|
+
5 approve_spec human gate (you)
|
|
8
|
+
6 convert scaffold + page/handler agents in parallel (code + AI agents, verified)
|
|
9
|
+
7 review independent review agent (AI agent)
|
|
10
|
+
8 test pytest, tsc/vite build, rule vectors (code)
|
|
11
|
+
9 fix fix agent on failures, max N iterations (AI agent, verified)
|
|
12
|
+
10 approve human gate (you)
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import concurrent.futures as cf
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Callable, Optional
|
|
25
|
+
|
|
26
|
+
from .agents.core import Agent, AgentFailed
|
|
27
|
+
from .agents.tasks import run_fix_agent, run_handler_agent, run_page_agent, run_review_agent, run_spec_agent
|
|
28
|
+
from .analysis.inventory import Legacy, build_inventory, screens as find_screens
|
|
29
|
+
from .checks import failures_by_screen, run_all
|
|
30
|
+
from .config import Config
|
|
31
|
+
from .llm.providers import LLM, make_llm
|
|
32
|
+
from .net import policy_for
|
|
33
|
+
from .scaffold.generate import generate, refresh_registries
|
|
34
|
+
from .spec.base import base_spec
|
|
35
|
+
from .spec.coverage import check
|
|
36
|
+
from .spec.model import Spec
|
|
37
|
+
|
|
38
|
+
log = logging.getLogger(__name__)
|
|
39
|
+
STAGES = ["inputs", "extract", "inventory", "spec", "approve_spec", "convert", "review", "test", "fix", "approve"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _now() -> str:
|
|
43
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StageBlocked(RuntimeError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Pipeline:
|
|
51
|
+
def __init__(self, cfg: Config, llm: Optional[LLM] = None, progress: Callable[[str], None] = print, sp_client=None):
|
|
52
|
+
self.cfg = cfg
|
|
53
|
+
self._print_lock = threading.Lock()
|
|
54
|
+
self._state_lock = threading.RLock() # stages run screens in parallel threads
|
|
55
|
+
self.ws = cfg.workspace
|
|
56
|
+
self.out = cfg.output
|
|
57
|
+
self.progress = lambda msg: self._say(progress, msg)
|
|
58
|
+
self._llm = llm
|
|
59
|
+
self._sp = sp_client
|
|
60
|
+
self.ws.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
self.state_file = self.ws / "state.json"
|
|
62
|
+
self.state = json.loads(self.state_file.read_text()) if self.state_file.exists() else {"global": {}, "screens": {}, "usage": {}}
|
|
63
|
+
|
|
64
|
+
def _say(self, sink, msg: str) -> None:
|
|
65
|
+
with self._print_lock: # agents report from worker threads
|
|
66
|
+
sink(msg)
|
|
67
|
+
|
|
68
|
+
# ----------------------------------------------------------- plumbing
|
|
69
|
+
@property
|
|
70
|
+
def llm(self) -> LLM:
|
|
71
|
+
if self._llm is None:
|
|
72
|
+
self._llm = make_llm(self.cfg.llm, policy_for(self.cfg))
|
|
73
|
+
return self._llm
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def sp(self):
|
|
77
|
+
if self._sp is None:
|
|
78
|
+
from .sharepoint.client import SharePointClient
|
|
79
|
+
self._sp = SharePointClient(self.cfg.sharepoint, policy=policy_for(self.cfg), item_reads=self.cfg.item_reads_allowed)
|
|
80
|
+
return self._sp
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _write_atomic(path: Path, text: str) -> None:
|
|
84
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
85
|
+
tmp.write_text(text, encoding="utf-8")
|
|
86
|
+
tmp.replace(path)
|
|
87
|
+
|
|
88
|
+
def save(self) -> None:
|
|
89
|
+
with self._state_lock:
|
|
90
|
+
self._write_atomic(self.state_file, json.dumps(self.state, indent=2))
|
|
91
|
+
|
|
92
|
+
def mark(self, sid: Optional[str], stage: str, status: str, detail: str = "", **extra) -> None:
|
|
93
|
+
with self._state_lock:
|
|
94
|
+
bucket = self.state["global"] if sid is None else self.state["screens"].setdefault(sid, {})
|
|
95
|
+
bucket[stage] = {"status": status, "at": _now(), "detail": detail, **extra}
|
|
96
|
+
self.save()
|
|
97
|
+
self.progress(f"[{stage}] {sid or 'site'}: {status}{' - ' + detail if detail else ''}")
|
|
98
|
+
|
|
99
|
+
def status(self, sid: Optional[str], stage: str) -> Optional[str]:
|
|
100
|
+
bucket = self.state["global"] if sid is None else self.state["screens"].get(sid, {})
|
|
101
|
+
return (bucket.get(stage) or {}).get("status")
|
|
102
|
+
|
|
103
|
+
def require(self, sid: Optional[str], stage: str) -> None:
|
|
104
|
+
"""Gate: the previous stage must be done (fixes 'review started while convert was still running')."""
|
|
105
|
+
idx = STAGES.index(stage)
|
|
106
|
+
prev = STAGES[idx - 1] if idx else None
|
|
107
|
+
if prev is None:
|
|
108
|
+
return
|
|
109
|
+
scope = None if prev in ("inputs", "extract") else sid
|
|
110
|
+
st = self.status(scope, prev)
|
|
111
|
+
if prev == "fix" and st in ("done", "skipped"):
|
|
112
|
+
return
|
|
113
|
+
if st != "done":
|
|
114
|
+
raise StageBlocked(f"{stage} for {sid or 'site'} needs '{prev}' to be done (it is {st or 'not started'})")
|
|
115
|
+
|
|
116
|
+
def agent(self, name: str, output: str) -> Agent:
|
|
117
|
+
return Agent(name, self.llm, self.ws, output=output, retries=self.cfg.pipeline.agent_retries, progress=self.progress)
|
|
118
|
+
|
|
119
|
+
def _usage(self, sid: str, stage: str, res) -> None:
|
|
120
|
+
with self._state_lock:
|
|
121
|
+
self._usage_locked(sid, stage, res)
|
|
122
|
+
|
|
123
|
+
def _usage_locked(self, sid: str, stage: str, res) -> None:
|
|
124
|
+
u = self.state["usage"].setdefault(sid, {})
|
|
125
|
+
cur = u.setdefault(stage, {"input_tokens": 0, "output_tokens": 0, "attempts": 0, "seconds": 0})
|
|
126
|
+
cur["input_tokens"] += res.input_tokens
|
|
127
|
+
cur["output_tokens"] += res.output_tokens
|
|
128
|
+
cur["attempts"] += res.attempts
|
|
129
|
+
cur["seconds"] += round(res.seconds, 1)
|
|
130
|
+
self.save()
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def legacy(self) -> Legacy:
|
|
134
|
+
return Legacy(self.ws / "legacy")
|
|
135
|
+
|
|
136
|
+
def screens(self, pages: Optional[list[str]] = None) -> list[dict]:
|
|
137
|
+
f = self.ws / "screens.json"
|
|
138
|
+
if pages is None and f.exists():
|
|
139
|
+
return json.loads(f.read_text())
|
|
140
|
+
return find_screens(self.legacy, pages)
|
|
141
|
+
|
|
142
|
+
def load_spec(self, sid: str) -> Spec:
|
|
143
|
+
return Spec.model_validate_json((self.ws / "specs" / f"{sid}.json").read_text(encoding="utf-8"))
|
|
144
|
+
|
|
145
|
+
def load_inventory(self, sid: str) -> dict:
|
|
146
|
+
return json.loads((self.ws / "inventory" / f"{sid}.json").read_text(encoding="utf-8"))
|
|
147
|
+
|
|
148
|
+
def approvals(self) -> dict:
|
|
149
|
+
f = self.ws / "approvals.json"
|
|
150
|
+
return json.loads(f.read_text()) if f.exists() else {}
|
|
151
|
+
|
|
152
|
+
def approve(self, sid: str, gate: str = "spec", by: str = "") -> None:
|
|
153
|
+
with self._state_lock:
|
|
154
|
+
a = self.approvals()
|
|
155
|
+
a.setdefault(sid, {})[gate] = {"at": _now(), "by": by}
|
|
156
|
+
self._write_atomic(self.ws / "approvals.json", json.dumps(a, indent=2))
|
|
157
|
+
stage = "approve_spec" if gate == "spec" else "approve"
|
|
158
|
+
self.mark(sid, stage, "done", f"approved {by}".strip())
|
|
159
|
+
|
|
160
|
+
# -------------------------------------------------------------- stages
|
|
161
|
+
def stage_inputs(self, offline: bool) -> None:
|
|
162
|
+
self.mark(None, "inputs", "running")
|
|
163
|
+
if offline:
|
|
164
|
+
if not (self.ws / "legacy" / "site.json").exists():
|
|
165
|
+
self.mark(None, "inputs", "failed", "offline run needs an existing extraction in the workspace")
|
|
166
|
+
raise StageBlocked("no extracted site in the workspace")
|
|
167
|
+
self.mark(None, "inputs", "done", "offline: using the existing extraction")
|
|
168
|
+
return
|
|
169
|
+
web = self.sp.web()
|
|
170
|
+
self.mark(None, "inputs", "done", f"connected to '{web.get('Title')}'")
|
|
171
|
+
|
|
172
|
+
def stage_extract(self, pages: Optional[list[str]], offline: bool) -> None:
|
|
173
|
+
self.require(None, "extract")
|
|
174
|
+
if offline:
|
|
175
|
+
self.mark(None, "extract", "done", "offline: skipped")
|
|
176
|
+
return
|
|
177
|
+
from .sharepoint.extract import Extractor
|
|
178
|
+
self.mark(None, "extract", "running")
|
|
179
|
+
man = Extractor(self.sp, self.ws, self.progress).run(pages, self.cfg.sharepoint.sample_items)
|
|
180
|
+
s = man["summary"]
|
|
181
|
+
self.mark(None, "extract", "done", f"{s['lists']} lists, {s['pages']} pages, {s['files']} custom files, {s['errors']} errors", summary=s)
|
|
182
|
+
|
|
183
|
+
def stage_inventory(self, pages: Optional[list[str]]) -> list[dict]:
|
|
184
|
+
self.require(None, "inventory")
|
|
185
|
+
legacy = self.legacy
|
|
186
|
+
scrs = find_screens(legacy, pages)
|
|
187
|
+
(self.ws / "screens.json").write_text(json.dumps(scrs, indent=2), encoding="utf-8")
|
|
188
|
+
(self.ws / "inventory").mkdir(exist_ok=True)
|
|
189
|
+
for sc in scrs:
|
|
190
|
+
self.mark(sc["id"], "inventory", "running")
|
|
191
|
+
inv = build_inventory(legacy, sc)
|
|
192
|
+
(self.ws / "inventory" / f"{sc['id']}.json").write_text(json.dumps(inv, indent=2), encoding="utf-8")
|
|
193
|
+
st = inv["stats"]
|
|
194
|
+
self.mark(sc["id"], "inventory", "done",
|
|
195
|
+
f"{st['custom_scripts']} custom scripts, {st['behaviors_must']} behaviours to carry, {st['data_calls']} data calls, {st['unresolved']} unresolved",
|
|
196
|
+
stats=st)
|
|
197
|
+
return scrs
|
|
198
|
+
|
|
199
|
+
def _spec_one(self, sid: str) -> None:
|
|
200
|
+
self.require(sid, "spec")
|
|
201
|
+
inv = self.load_inventory(sid)
|
|
202
|
+
inv_hash = hashlib.sha256(json.dumps(inv, sort_keys=True).encode()).hexdigest()[:16]
|
|
203
|
+
prev = self.state["screens"].get(sid, {}).get("spec") or {}
|
|
204
|
+
if prev.get("status") == "done" and prev.get("inventory_hash") == inv_hash and (self.ws / "specs" / f"{sid}.json").exists():
|
|
205
|
+
self.progress(f"[spec] {sid}: unchanged since the last run (legacy source identical), keeping the spec")
|
|
206
|
+
if self.approvals().get(sid, {}).get("spec") or self.cfg.pipeline.auto_approve:
|
|
207
|
+
if self.status(sid, "approve_spec") != "done":
|
|
208
|
+
self.approve(sid, "spec", "auto_approve" if self.cfg.pipeline.auto_approve else "")
|
|
209
|
+
return
|
|
210
|
+
if prev.get("inventory_hash") and prev.get("inventory_hash") != inv_hash:
|
|
211
|
+
a = self.approvals()
|
|
212
|
+
if a.get(sid, {}).pop("spec", None):
|
|
213
|
+
(self.ws / "approvals.json").write_text(json.dumps(a, indent=2), encoding="utf-8")
|
|
214
|
+
self.progress(f"[spec] {sid}: legacy source changed, previous spec approval withdrawn")
|
|
215
|
+
self.mark(sid, "spec", "running")
|
|
216
|
+
base = base_spec(inv)
|
|
217
|
+
(self.ws / "specs").mkdir(exist_ok=True)
|
|
218
|
+
rep0 = check(base, inv)
|
|
219
|
+
if rep0.ok:
|
|
220
|
+
spec, note = base, "base spec already covers everything (no agent needed)"
|
|
221
|
+
else:
|
|
222
|
+
spec, res = run_spec_agent(self.agent("spec", "json"), self.legacy, inv, base)
|
|
223
|
+
self._usage(sid, "spec", res)
|
|
224
|
+
note = f"spec agent accepted on attempt {res.attempts}"
|
|
225
|
+
rep = check(spec, inv)
|
|
226
|
+
(self.ws / "specs" / f"{sid}.json").write_text(spec.model_dump_json(indent=2, exclude_none=True), encoding="utf-8")
|
|
227
|
+
(self.ws / "specs" / f"{sid}.coverage.md").write_text(coverage_markdown(spec, inv, rep), encoding="utf-8")
|
|
228
|
+
self.mark(sid, "spec", "done", f"{rep.summary()}; {note}", rules=len(spec.rules), open_questions=len(spec.open_questions), inventory_hash=inv_hash)
|
|
229
|
+
if self.cfg.pipeline.auto_approve:
|
|
230
|
+
self.approve(sid, "spec", "auto_approve")
|
|
231
|
+
elif self.approvals().get(sid, {}).get("spec"):
|
|
232
|
+
self.mark(sid, "approve_spec", "done", "approved earlier")
|
|
233
|
+
else:
|
|
234
|
+
self.mark(sid, "approve_spec", "waiting", f"review workspace/specs/{sid}.coverage.md, then run: sp2react approve {sid}")
|
|
235
|
+
|
|
236
|
+
def stage_spec(self, sids: list[str]) -> None:
|
|
237
|
+
self._parallel("spec", sids, self._spec_one)
|
|
238
|
+
|
|
239
|
+
def _parallel(self, stage: str, sids: list[str], fn) -> None:
|
|
240
|
+
with cf.ThreadPoolExecutor(max_workers=max(1, self.cfg.pipeline.max_concurrency)) as pool:
|
|
241
|
+
futs = {pool.submit(fn, sid): sid for sid in sids}
|
|
242
|
+
for f, sid in futs.items():
|
|
243
|
+
try:
|
|
244
|
+
f.result(timeout=self.cfg.pipeline.agent_timeout * (self.cfg.pipeline.agent_retries + 1))
|
|
245
|
+
except StageBlocked as e:
|
|
246
|
+
self.progress(f"[{stage}] {sid}: blocked - {e}")
|
|
247
|
+
except AgentFailed as e:
|
|
248
|
+
self.mark(sid, stage, "failed", f"agent output kept failing validation; see {e.transcript}", problems=e.problems[:20])
|
|
249
|
+
except cf.TimeoutError:
|
|
250
|
+
self.mark(sid, stage, "failed", f"timed out after {self.cfg.pipeline.agent_timeout}s per attempt")
|
|
251
|
+
except Exception as e: # keep other screens going
|
|
252
|
+
log.exception("%s %s", stage, sid)
|
|
253
|
+
self.mark(sid, stage, "failed", f"{type(e).__name__}: {e}")
|
|
254
|
+
|
|
255
|
+
def approved_sids(self) -> list[str]:
|
|
256
|
+
return [sid for sid in self.state["screens"] if self.status(sid, "approve_spec") == "done" and (self.ws / "specs" / f"{sid}.json").exists()]
|
|
257
|
+
|
|
258
|
+
def stage_convert(self, sids: list[str]) -> None:
|
|
259
|
+
ready = [s for s in sids if self.status(s, "approve_spec") == "done"]
|
|
260
|
+
for s in set(sids) - set(ready):
|
|
261
|
+
self.progress(f"[convert] {s}: waiting for spec approval")
|
|
262
|
+
if not ready:
|
|
263
|
+
return
|
|
264
|
+
all_specs = [self.load_spec(s) for s in self.approved_sids()]
|
|
265
|
+
for s in ready:
|
|
266
|
+
self.mark(s, "convert", "running", "scaffold")
|
|
267
|
+
t0 = time.time()
|
|
268
|
+
export_dir = self.stage_migrate(all_specs)
|
|
269
|
+
if self.cfg.pipeline.data_source == "database":
|
|
270
|
+
mf = Path(self.cfg.database.mapping)
|
|
271
|
+
if not mf.exists() or self.status(None, "map_db") == "failed":
|
|
272
|
+
for s in ready:
|
|
273
|
+
self.mark(s, "convert", "failed", "data_source=database needs a reviewed list->table mapping: run `sp2react map-db` first")
|
|
274
|
+
return
|
|
275
|
+
info = generate(self.out, all_specs, self.legacy, export_dir, self.cfg.pipeline.data_source,
|
|
276
|
+
Path(self.cfg.database.mapping) if self.cfg.pipeline.data_source == "database" else None)
|
|
277
|
+
self.progress(f"[convert] scaffold written in {time.time() - t0:.1f}s ({info['files']} files)")
|
|
278
|
+
routes = [{"screen": sp.screen.id, "kind": sp.screen.kind, "route": sp.screen.route, "list": sp.screen.list,
|
|
279
|
+
"legacy_urls": sp.screen.legacy_urls} for sp in all_specs]
|
|
280
|
+
|
|
281
|
+
tasks = []
|
|
282
|
+
for sid in ready:
|
|
283
|
+
spec = self.load_spec(sid)
|
|
284
|
+
inv = self.load_inventory(sid)
|
|
285
|
+
if spec.screen.kind == "page":
|
|
286
|
+
tasks.append((sid, "page", lambda spec=spec, inv=inv: run_page_agent(self.agent("page", "files"), self.legacy, inv, spec, routes, self.out)))
|
|
287
|
+
for r in spec.rules:
|
|
288
|
+
if r.kind == "custom":
|
|
289
|
+
tasks.append((sid, f"handler:{r.params['handler']}", lambda spec=spec, inv=inv, r=r: run_handler_agent(self.agent("handler", "files"), self.legacy, inv, spec, r, self.out)))
|
|
290
|
+
failed: dict[str, str] = {}
|
|
291
|
+
if tasks:
|
|
292
|
+
self.progress(f"[convert] {len(tasks)} agent task(s) in parallel (max {self.cfg.pipeline.max_concurrency})")
|
|
293
|
+
with cf.ThreadPoolExecutor(max_workers=max(1, self.cfg.pipeline.max_concurrency)) as pool:
|
|
294
|
+
futs = [(sid, name, pool.submit(fn)) for sid, name, fn in tasks]
|
|
295
|
+
for sid, name, fut in futs:
|
|
296
|
+
try:
|
|
297
|
+
res = fut.result(timeout=self.cfg.pipeline.agent_timeout * (self.cfg.pipeline.agent_retries + 1))
|
|
298
|
+
self._usage(sid, "convert", res)
|
|
299
|
+
self.progress(f"[convert] {sid} {name}: accepted on attempt {res.attempts}")
|
|
300
|
+
except AgentFailed as e:
|
|
301
|
+
failed[sid] = f"{name}: output kept failing validation ({e.problems[:3]}); transcript {e.transcript}"
|
|
302
|
+
except cf.TimeoutError:
|
|
303
|
+
failed[sid] = f"{name}: timed out"
|
|
304
|
+
except Exception as e:
|
|
305
|
+
failed[sid] = f"{name}: {type(e).__name__}: {e}"
|
|
306
|
+
refresh_registries(self.out, all_specs)
|
|
307
|
+
for sid in ready:
|
|
308
|
+
if sid in failed:
|
|
309
|
+
self.mark(sid, "convert", "failed", failed[sid])
|
|
310
|
+
else:
|
|
311
|
+
n = sum(1 for t in tasks if t[0] == sid)
|
|
312
|
+
self.mark(sid, "convert", "done", f"scaffold + {n} agent file task(s)" if n else "scaffold only (form engine renders this screen from the spec)")
|
|
313
|
+
|
|
314
|
+
def _review_one(self, sid: str) -> None:
|
|
315
|
+
self.require(sid, "review")
|
|
316
|
+
self.mark(sid, "review", "running")
|
|
317
|
+
res = run_review_agent(self.agent("review", "json"), self.legacy, self.load_inventory(sid), self.load_spec(sid), self.out)
|
|
318
|
+
self._usage(sid, "review", res)
|
|
319
|
+
(self.ws / "reviews").mkdir(exist_ok=True)
|
|
320
|
+
(self.ws / "reviews" / f"{sid}.json").write_text(json.dumps(res.output, indent=2), encoding="utf-8")
|
|
321
|
+
blockers = [f for f in res.output["findings"] if f["severity"] in ("blocker", "major")]
|
|
322
|
+
self.mark(sid, "review", "done", f"verdict {res.output['verdict']}, {len(blockers)} blocking finding(s), {len(res.output['findings'])} total",
|
|
323
|
+
verdict=res.output["verdict"], blocking=len(blockers))
|
|
324
|
+
|
|
325
|
+
def stage_review(self, sids: list[str]) -> None:
|
|
326
|
+
self._parallel("review", [s for s in sids if self.status(s, "convert") == "done"], self._review_one)
|
|
327
|
+
|
|
328
|
+
# ------------------------------------------------------------ data
|
|
329
|
+
@property
|
|
330
|
+
def export_dir(self) -> Path:
|
|
331
|
+
return self.ws / "data-export"
|
|
332
|
+
|
|
333
|
+
def stage_migrate(self, specs: list[Spec]) -> Optional[Path]:
|
|
334
|
+
"""Export every item of every list the approved screens use (part of convert)."""
|
|
335
|
+
if self.cfg.pipeline.data_source == "sharepoint":
|
|
336
|
+
self.mark(None, "migrate", "skipped", "data_source=sharepoint: the app reads and writes the live lists")
|
|
337
|
+
return None
|
|
338
|
+
if self.cfg.pipeline.data_source == "database":
|
|
339
|
+
self.mark(None, "migrate", "skipped", "data_source=database: the app uses your existing tables through the mapping")
|
|
340
|
+
return None
|
|
341
|
+
lists = sorted({name for sp in specs for name in sp.lists})
|
|
342
|
+
if getattr(self, "_offline", False):
|
|
343
|
+
if all((self.export_dir / f"{k}.jsonl").exists() for k in lists):
|
|
344
|
+
self.mark(None, "migrate", "done", "offline: reusing the existing full export")
|
|
345
|
+
return self.export_dir
|
|
346
|
+
self.mark(None, "migrate", "warning", "offline and no full export: the app is seeded from the extraction sample only (not a migration)")
|
|
347
|
+
return None
|
|
348
|
+
from .migrate import export_lists
|
|
349
|
+
self.mark(None, "migrate", "running", f"exporting {len(lists)} list(s)")
|
|
350
|
+
summary = export_lists(self.sp, self.legacy.site, lists, self.export_dir, progress=self.progress)
|
|
351
|
+
total = sum(v["items"] for v in summary.values())
|
|
352
|
+
self.mark(None, "migrate", "done", f"{total} items and {sum(v['attachments'] for v in summary.values())} attachments from {len(lists)} lists", lists=summary)
|
|
353
|
+
return self.export_dir
|
|
354
|
+
|
|
355
|
+
def mapping_lists(self, specs: list[Spec]) -> dict:
|
|
356
|
+
out: dict = {}
|
|
357
|
+
for sp in specs:
|
|
358
|
+
for name, l in sp.lists.items():
|
|
359
|
+
cur = out.setdefault(name, {"title": l.title, "fields": []})
|
|
360
|
+
known = {f["name"] for f in cur["fields"]}
|
|
361
|
+
cur["fields"] += [f for f in l.fields if f["name"] not in known]
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
def stage_map_db(self, specs: list[Spec]) -> bool:
|
|
365
|
+
"""data_source=database: map lists onto the existing tables (schema only) -> db-mapping.json + .md."""
|
|
366
|
+
from .config import secret
|
|
367
|
+
from .dbmap import draft, reflect, report, validate
|
|
368
|
+
from .agents.tasks import run_dbmap_agent
|
|
369
|
+
lists = self.mapping_lists(specs)
|
|
370
|
+
self.mark(None, "map_db", "running", f"{len(lists)} list(s)")
|
|
371
|
+
db = reflect(secret(self.cfg.database.url_env, "database URL"), self.cfg.database.schema_name)
|
|
372
|
+
(self.ws / "db-schema.json").write_text(json.dumps(db, indent=1), encoding="utf-8")
|
|
373
|
+
mapping_file = Path(self.cfg.database.mapping)
|
|
374
|
+
if mapping_file.exists(): # keep a mapping you already reviewed; only extend it
|
|
375
|
+
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
|
|
376
|
+
missing = {k: v for k, v in lists.items() if k not in mapping.get("lists", {})}
|
|
377
|
+
if missing:
|
|
378
|
+
extra, _ = draft(missing, db)
|
|
379
|
+
mapping.setdefault("lists", {}).update(extra["lists"])
|
|
380
|
+
notes: list[str] = []
|
|
381
|
+
else:
|
|
382
|
+
mapping, notes = draft(lists, db)
|
|
383
|
+
problems, warnings = validate(mapping, lists, db)
|
|
384
|
+
agent_notes: list[str] = []
|
|
385
|
+
if problems:
|
|
386
|
+
mapping, agent_notes, res = run_dbmap_agent(self.agent("dbmap", "json"), lists, db, mapping, notes + problems)
|
|
387
|
+
self._usage("database", "map_db", res)
|
|
388
|
+
problems, warnings = validate(mapping, lists, db)
|
|
389
|
+
mapping_file.write_text(json.dumps(mapping, indent=2), encoding="utf-8")
|
|
390
|
+
md = report(mapping, lists, db, problems, warnings + agent_notes)
|
|
391
|
+
(self.ws / "db-mapping.md").write_text(md, encoding="utf-8")
|
|
392
|
+
ok = not problems
|
|
393
|
+
self.mark(None, "map_db", "done" if ok else "failed",
|
|
394
|
+
f"{len(mapping.get('lists', {}))} list(s) mapped, {len(warnings)} warning(s); review {mapping_file.name} and db-mapping.md" if ok
|
|
395
|
+
else f"{len(problems)} problem(s); see db-mapping.md")
|
|
396
|
+
return ok
|
|
397
|
+
|
|
398
|
+
def stage_reconcile(self, specs: list[Spec]) -> dict:
|
|
399
|
+
"""Read everything back through the app's repository and compare with the SharePoint export."""
|
|
400
|
+
from .migrate import read_through_app, reconcile, report_markdown
|
|
401
|
+
if self.cfg.pipeline.data_source == "sharepoint":
|
|
402
|
+
self.mark(None, "reconcile", "skipped", "data_source=sharepoint")
|
|
403
|
+
return {"ok": True, "lists": {}, "skipped": True}
|
|
404
|
+
if self.cfg.pipeline.data_source == "database" and not self.cfg.item_reads_allowed:
|
|
405
|
+
self.mark(None, "reconcile", "skipped", "no list items are read in database mode; set security.sharepoint_item_reads: true to compare the lists with your tables")
|
|
406
|
+
return {"ok": True, "lists": {}, "skipped": True}
|
|
407
|
+
database = self.cfg.pipeline.data_source == "database"
|
|
408
|
+
if database and not getattr(self, "_offline", False):
|
|
409
|
+
from .migrate import export_lists # read-only comparison copy of the lists, kept in the workspace
|
|
410
|
+
export_lists(self.sp, self.legacy.site, sorted({n for sp in specs for n in sp.lists}), self.export_dir, progress=self.progress)
|
|
411
|
+
if not (self.export_dir / "export.json").exists():
|
|
412
|
+
self.mark(None, "reconcile", "warning", "no full export to reconcile against (seeded from the sample)")
|
|
413
|
+
return {"ok": True, "lists": {}, "skipped": True}
|
|
414
|
+
lists = {}
|
|
415
|
+
for sp in specs:
|
|
416
|
+
for name, l in sp.lists.items():
|
|
417
|
+
lists.setdefault(name, {"fields": l.fields})
|
|
418
|
+
app_items = read_through_app(self.out, self.ws / "reconcile.db", load=not database, env=self._app_env())
|
|
419
|
+
rep = reconcile(self.export_dir, app_items, lists)
|
|
420
|
+
meta = json.loads((self.export_dir / "export.json").read_text())
|
|
421
|
+
(self.ws / "reconciliation.md").write_text(report_markdown(rep, meta), encoding="utf-8")
|
|
422
|
+
(self.ws / "reconciliation.json").write_text(json.dumps(rep, indent=1, default=str), encoding="utf-8")
|
|
423
|
+
n = sum(l["source_items"] for l in rep["lists"].values())
|
|
424
|
+
bad = [k for k, l in rep["lists"].items() if not l["ok"]]
|
|
425
|
+
self.mark(None, "reconcile", "done" if rep["ok"] else "failed",
|
|
426
|
+
f"{n} items in {len(rep['lists'])} lists match field by field" if rep["ok"] else f"mismatches in {', '.join(bad)}; see reconciliation.md")
|
|
427
|
+
return rep
|
|
428
|
+
|
|
429
|
+
def stage_test_and_fix(self, sids: list[str]) -> None:
|
|
430
|
+
sids = [s for s in sids if self.status(s, "review") == "done"]
|
|
431
|
+
if not sids:
|
|
432
|
+
return
|
|
433
|
+
handlers = {}
|
|
434
|
+
for sid in sids:
|
|
435
|
+
for r in self.load_spec(sid).rules:
|
|
436
|
+
if r.kind == "custom":
|
|
437
|
+
handlers[r.params["handler"]] = sid
|
|
438
|
+
max_iter = self.cfg.pipeline.max_fix_iterations
|
|
439
|
+
specs_now = [self.load_spec(s) for s in self.approved_sids()]
|
|
440
|
+
rec = self.stage_reconcile(specs_now)
|
|
441
|
+
if not rec["ok"]:
|
|
442
|
+
bad = {k for k, l in rec["lists"].items() if not l["ok"]}
|
|
443
|
+
for sid in sids:
|
|
444
|
+
if bad & set(self.load_spec(sid).lists):
|
|
445
|
+
self.mark(sid, "test", "failed", f"data reconciliation failed for {', '.join(sorted(bad))}; see reconciliation.md")
|
|
446
|
+
self.mark(sid, "fix", "failed", "data mismatches are fixed in the migration mapping or the source data, not by an agent")
|
|
447
|
+
sids = [s for s in sids if not (bad & set(self.load_spec(s).lists))]
|
|
448
|
+
if not sids:
|
|
449
|
+
return
|
|
450
|
+
for it in range(0, max_iter + 1):
|
|
451
|
+
for sid in sids:
|
|
452
|
+
self.mark(sid, "test", "running", f"iteration {it}")
|
|
453
|
+
checks = run_all(self.out, self.cfg.pipeline.run_builds, self._app_env())
|
|
454
|
+
(self.ws / "tests").mkdir(exist_ok=True)
|
|
455
|
+
(self.ws / "tests" / f"iteration-{it}.md").write_text(
|
|
456
|
+
"\n\n".join(f"## {c.name}: {'skipped' if c.skipped else 'ok' if c.ok else 'FAILED'}\n```\n{c.tail(20000)}\n```" for c in checks), encoding="utf-8")
|
|
457
|
+
routed = failures_by_screen(checks, sids, handlers)
|
|
458
|
+
reviews = {sid: json.loads((self.ws / "reviews" / f"{sid}.json").read_text()) for sid in sids if (self.ws / "reviews" / f"{sid}.json").exists()}
|
|
459
|
+
blocking = {sid: [f for f in r["findings"] if f["severity"] in ("blocker", "major")] for sid, r in reviews.items()}
|
|
460
|
+
summary = "; ".join(f"{c.name}: {'skipped' if c.skipped else 'ok' if c.ok else 'failed'}" for c in checks)
|
|
461
|
+
todo = {sid for sid in sids if routed.get(sid) or blocking.get(sid)}
|
|
462
|
+
infra = routed.get("")
|
|
463
|
+
for sid in sids:
|
|
464
|
+
st = "failed" if (sid in todo or infra) else "done"
|
|
465
|
+
self.mark(sid, "test", st, summary, iteration=it)
|
|
466
|
+
if not todo and not infra:
|
|
467
|
+
for sid in sids:
|
|
468
|
+
self.mark(sid, "fix", "done" if it else "skipped", f"{it} fix iteration(s)")
|
|
469
|
+
self._screen_gate(sid)
|
|
470
|
+
return
|
|
471
|
+
if it == max_iter:
|
|
472
|
+
break
|
|
473
|
+
if infra and not todo:
|
|
474
|
+
for sid in sids:
|
|
475
|
+
self.mark(sid, "fix", "failed", "failures are in shared scaffold code or the environment, not in agent-written files; see workspace/tests")
|
|
476
|
+
return
|
|
477
|
+
for sid in sorted(todo):
|
|
478
|
+
self.mark(sid, "fix", "running", f"iteration {it + 1}")
|
|
479
|
+
failures = routed.get(sid, "")
|
|
480
|
+
if blocking.get(sid):
|
|
481
|
+
failures += "\n\n## Review findings to fix\n" + json.dumps(blocking[sid], indent=1)
|
|
482
|
+
try:
|
|
483
|
+
res, new_spec = run_fix_agent(self.agent("fix", "files"), self.load_inventory(sid), self.load_spec(sid), failures, self.out, f"{sid}-iter{it + 1}")
|
|
484
|
+
self._usage(sid, "fix", res)
|
|
485
|
+
if new_spec is not None:
|
|
486
|
+
(self.ws / "specs" / f"{sid}.json").write_text(new_spec.model_dump_json(indent=2, exclude_none=True), encoding="utf-8")
|
|
487
|
+
js = new_spec.model_dump_json(indent=2, exclude_none=True)
|
|
488
|
+
(self.out / "backend" / "specs" / f"{sid}.json").write_text(js, encoding="utf-8")
|
|
489
|
+
(self.out / "frontend" / "src" / "specs" / f"{sid}.json").write_text(js, encoding="utf-8")
|
|
490
|
+
if blocking.get(sid): # re-review after a fix for review findings
|
|
491
|
+
self._review_one(sid)
|
|
492
|
+
except AgentFailed as e:
|
|
493
|
+
self.mark(sid, "fix", "failed", f"fix agent output kept failing validation; see {e.transcript}")
|
|
494
|
+
for sid in sids:
|
|
495
|
+
if self.status(sid, "test") != "done":
|
|
496
|
+
self.mark(sid, "fix", "failed", f"still failing after {max_iter} iteration(s); see workspace/tests")
|
|
497
|
+
|
|
498
|
+
def _app_env(self) -> dict:
|
|
499
|
+
if self.cfg.pipeline.data_source != "database":
|
|
500
|
+
return {}
|
|
501
|
+
import os
|
|
502
|
+
url = os.environ.get(self.cfg.database.url_env)
|
|
503
|
+
return {"DATA_SOURCE": "database", "MAPPING_FILE": str(self.out / "backend" / "mapping.json"), **({"DB_URL": url} if url else {})}
|
|
504
|
+
|
|
505
|
+
def _screen_gate(self, sid: str) -> None:
|
|
506
|
+
if self.cfg.pipeline.auto_approve:
|
|
507
|
+
self.approve(sid, "screen", "auto_approve")
|
|
508
|
+
elif self.approvals().get(sid, {}).get("screen"):
|
|
509
|
+
self.mark(sid, "approve", "done", "approved earlier")
|
|
510
|
+
else:
|
|
511
|
+
self.mark(sid, "approve", "waiting", f"run the app, compare with the original, then: sp2react approve {sid} --screen")
|
|
512
|
+
|
|
513
|
+
# ----------------------------------------------------------------- run
|
|
514
|
+
def run(self, pages: Optional[list[str]] = None, offline: bool = False, until: str = "approve") -> dict:
|
|
515
|
+
self._offline = offline
|
|
516
|
+
stop = STAGES.index(until)
|
|
517
|
+
self.stage_inputs(offline)
|
|
518
|
+
if stop >= 1:
|
|
519
|
+
self.stage_extract(pages, offline)
|
|
520
|
+
sids: list[str] = []
|
|
521
|
+
if stop >= 2:
|
|
522
|
+
sids = [s["id"] for s in self.stage_inventory(pages)]
|
|
523
|
+
if stop >= 3:
|
|
524
|
+
self.stage_spec(sids)
|
|
525
|
+
if stop >= 5 and self.cfg.pipeline.data_source == "database" and self.approved_sids():
|
|
526
|
+
try:
|
|
527
|
+
self.stage_map_db([self.load_spec(s) for s in self.approved_sids()])
|
|
528
|
+
except Exception as e: # missing DB_URL, driver, network...
|
|
529
|
+
self.mark(None, "map_db", "failed", f"{type(e).__name__}: {e}")
|
|
530
|
+
if stop >= 5:
|
|
531
|
+
self.stage_convert(sids)
|
|
532
|
+
if stop >= 6:
|
|
533
|
+
self.stage_review(sids)
|
|
534
|
+
if stop >= 7:
|
|
535
|
+
self.stage_test_and_fix(sids)
|
|
536
|
+
write_report(self)
|
|
537
|
+
return self.state
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def coverage_markdown(spec: Spec, inv: dict, rep) -> str:
|
|
541
|
+
beh = {b["id"]: b for b in inv["behaviors"]}
|
|
542
|
+
lines = [f"# {spec.screen.title} ({spec.screen.id})", "", rep.summary(), "",
|
|
543
|
+
"| Legacy behaviour | Kind | Code | Carried by |", "|---|---|---|---|"]
|
|
544
|
+
for m in rep.matrix:
|
|
545
|
+
b = beh[m["behavior"]]
|
|
546
|
+
code = (b.get("code") or "").replace("|", "\\|")[:80]
|
|
547
|
+
lines.append(f"| `{m['behavior']}` | {m['kind']} | `{code}` | {', '.join(m['covered_by']) or '**missing**'} |")
|
|
548
|
+
lines += ["", "## Rules", ""]
|
|
549
|
+
for r in spec.rules:
|
|
550
|
+
lines.append(f"- **{r.id}** {r.kind} {r.field or ''} {('modes=' + ','.join(r.modes)) if r.modes else ''} "
|
|
551
|
+
f"{('when=' + json.dumps(r.when)) if r.when else ''} {('“' + r.message + '”') if r.message else ''}")
|
|
552
|
+
if spec.not_carried:
|
|
553
|
+
lines += ["", "## Not carried (with reasons)", ""] + [f"- {', '.join(n.covers)}: {n.reason}" for n in spec.not_carried]
|
|
554
|
+
if spec.open_questions:
|
|
555
|
+
lines += ["", "## Open questions", ""] + [f"- {q}" for q in spec.open_questions]
|
|
556
|
+
if rep.problems:
|
|
557
|
+
lines += ["", "## Problems", ""] + [f"- {p}" for p in rep.problems]
|
|
558
|
+
return "\n".join(lines) + "\n"
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def write_report(p: Pipeline) -> Path:
|
|
562
|
+
lines = ["# sp2react run report", "", f"Updated {_now()}", "", "| Screen | " + " | ".join(STAGES[2:]) + " |", "|---|" + "---|" * len(STAGES[2:])]
|
|
563
|
+
for sid, st in p.state["screens"].items():
|
|
564
|
+
lines.append(f"| {sid} | " + " | ".join((st.get(s) or {}).get("status", "") for s in STAGES[2:]) + " |")
|
|
565
|
+
for k in ("migrate", "reconcile"):
|
|
566
|
+
g = p.state["global"].get(k)
|
|
567
|
+
if g:
|
|
568
|
+
lines.append(f"\n**Data {k}:** {g['status']} - {g.get('detail', '')}")
|
|
569
|
+
lines += ["", "## Details", ""]
|
|
570
|
+
for sid, st in p.state["screens"].items():
|
|
571
|
+
lines.append(f"### {sid}")
|
|
572
|
+
for s in STAGES[2:]:
|
|
573
|
+
if st.get(s):
|
|
574
|
+
lines.append(f"- **{s}**: {st[s]['status']} - {st[s].get('detail', '')}")
|
|
575
|
+
u = p.state["usage"].get(sid)
|
|
576
|
+
if u:
|
|
577
|
+
tot_in = sum(v["input_tokens"] for v in u.values())
|
|
578
|
+
tot_out = sum(v["output_tokens"] for v in u.values())
|
|
579
|
+
lines.append(f"- model usage: {tot_in:,} input / {tot_out:,} output tokens across " + ", ".join(f"{k} ({v['attempts']} call(s))" for k, v in u.items()))
|
|
580
|
+
lines.append("")
|
|
581
|
+
f = p.ws / "report.md"
|
|
582
|
+
f.write_text("\n".join(lines), encoding="utf-8")
|
|
583
|
+
return f
|
|
File without changes
|