#!/usr/bin/env python3 """Build a bounded source packet for the current module implementation turn.""" from __future__ import annotations from record_runtime_issue import ACTIVE_STATUSES import ast import argparse import hashlib import json import os import re from datetime import datetime, timezone from pathlib import Path from migration_workflows import delivery_route from implementation_first import implementation_first_enabled from scheduler_progress import PROGRESS_PATH, read as read_progress, module_states from failure_reconciliation import synchronize as reconcile_failures, module_context ROOT = Path(__file__).resolve().parents[1] BATCH_PATH = ROOT / "docs/current/module-batch-manifest.json" OUTPUT_PATH = ROOT / "docs/current/module-implementation-packet.json" PROBE_ROOT = ROOT / "freecad-source/src/WebAssembly/FreeCADCoreProbe" MAX_TOTAL_BYTES = max(4096, int(os.environ.get("FREECAD_IMPLEMENTATION_PACKET_BYTES", "12288"))) MAX_RECORD_BYTES = max(256, int(os.environ.get("FREECAD_IMPLEMENTATION_RECORD_BYTES", "1600"))) MAX_NATIVE_TOTAL_BYTES = max(1024, int(os.environ.get("FREECAD_IMPLEMENTATION_NATIVE_BYTES", "4096"))) MAX_EDIT_TOTAL_BYTES = max(4096, int(os.environ.get("FREECAD_IMPLEMENTATION_EDIT_BYTES", "16384"))) MAX_EDIT_REGION_BYTES = max(1024, int(os.environ.get("FREECAD_IMPLEMENTATION_EDIT_REGION_BYTES", "2048"))) DESKTOP_ADAPTER_SPLIT_BYTES = max(32768, int(os.environ.get("FREECAD_DESKTOP_ADAPTER_SPLIT_BYTES", "98304"))) def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for block in iter(lambda: stream.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def source_path(value: object) -> Path | None: if not isinstance(value, str) or not value: return None candidate = Path(value) if candidate.is_absolute(): return candidate if candidate.is_file() and candidate.is_relative_to(ROOT) else None for root in (ROOT / "freecad-source", ROOT): resolved = root / candidate if resolved.is_file(): return resolved return None def compact_excerpt(text: str, limit: int) -> str: lines = [line.rstrip() for line in text.splitlines()] while lines and not lines[0].strip(): lines.pop(0) while lines and not lines[-1].strip(): lines.pop() value = "\n".join(lines) if len(value.encode("utf-8")) <= limit: return value kept, size = [], 0 for line in lines: extra = len(line.encode("utf-8")) + (1 if kept else 0) if size + extra > limit: break kept.append(line) size += extra return "\n".join(kept) def python_excerpt(path: Path, name: str, source_line: int | None) -> tuple[int, str]: text = path.read_text(encoding="utf-8", errors="replace") lines = text.splitlines() leaf = name.rsplit(".", 1)[-1] candidates: list[ast.AST] = [] try: tree = ast.parse(text) candidates = [ node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and node.name == leaf ] except SyntaxError: candidates = [] if not candidates: factories = set() for node in ast.walk(tree) if 'tree' in locals() else []: if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "addCommand" and len(node.args) > 1 and isinstance(node.args[0], ast.Constant) and node.args[0].value == name and isinstance(node.args[1], ast.Call) and isinstance(node.args[1].func, ast.Name)): factories.add(node.args[1].func.id) for node in ast.walk(tree) if 'tree' in locals() else []: if isinstance(node, ast.ClassDef) and node.name in factories: candidates.extend(member for member in node.body if isinstance(member, ast.FunctionDef) and member.name in {"Activated", "activated"}) if candidates: target = min(candidates, key=lambda node: abs(int(getattr(node, "lineno", 1)) - int(source_line or getattr(node, "lineno", 1)))) start = int(getattr(target, "lineno", 1)) decorators = getattr(target, "decorator_list", []) if decorators: start = min(start, *(int(getattr(item, "lineno", start)) for item in decorators)) end = min(int(getattr(target, "end_lineno", start + 8)), start + 11) return start, compact_excerpt("\n".join(lines[start - 1:end]), MAX_RECORD_BYTES) line = max(1, int(source_line or 1)) start = max(1, line - 2) end = min(len(lines), line + 7) return start, compact_excerpt("\n".join(lines[start - 1:end]), MAX_RECORD_BYTES) def generic_excerpt(path: Path, source_line: int | None) -> tuple[int, str]: lines = path.read_text(encoding="utf-8", errors="replace").splitlines() line = max(1, int(source_line or 1)) start = max(1, line - 3) end = min(len(lines), line + 5) return start, compact_excerpt("\n".join(lines[start - 1:end]), MAX_RECORD_BYTES) def cpp_command_excerpt(path: Path, name: str, source_line: int | None) -> tuple[int, str]: text = path.read_text(encoding="utf-8", errors="replace") lines = text.splitlines() command_lines = [ index for index, value in enumerate(lines, 1) if f'Command("{name}")' in value or f'CommandBase("{name}")' in value ] if not command_lines: return generic_excerpt(path, source_line) command_line = min(command_lines, key=lambda value: abs(value - int(source_line or value))) class_name = "" constructor_re = re.compile(r"\b([A-Za-z_]\w*)::\1\s*\(") for value in lines[max(0, command_line - 8):command_line + 2]: match = constructor_re.search(value) if match: class_name = match.group(1) break if not class_name: return generic_excerpt(path, command_line) activated_re = re.compile(rf"\b{re.escape(class_name)}::activated\s*\(") activated_line = next( (index for index, value in enumerate(lines[command_line:], command_line + 1) if activated_re.search(value)), None, ) if activated_line is None: return generic_excerpt(path, command_line) start = activated_line end = min(len(lines), start + 90) depth = 0 opened = False for index in range(start - 1, end): depth += lines[index].count("{") - lines[index].count("}") opened = opened or "{" in lines[index] if opened and depth <= 0: end = index + 1 break return start, compact_excerpt("\n".join(lines[start - 1:end]), MAX_RECORD_BYTES) def anchored_excerpt(lines: list[str], anchor: int, limit: int) -> tuple[int, str]: """Keep the actual edit anchor and complete source lines within the budget.""" start = end = anchor - 1 size = len(lines[start].encode("utf-8")) # Dense legacy Workers can put an entire dispatcher on one line. Retain # that line when the overall packet can fit it; never invent a cut line. if size > MAX_EDIT_TOTAL_BYTES: return anchor, "" for _ in range(30): added = False if start > 0 and anchor - start <= 12: extra = len(lines[start - 1].encode("utf-8")) + 1 if size + extra <= limit: start -= 1 size += extra added = True if end + 1 < len(lines): extra = len(lines[end + 1].encode("utf-8")) + 1 if size + extra <= limit: end += 1 size += extra added = True if not added: break return start + 1, "\n".join(lines[start:end + 1]) def editable_excerpts(path: Path, text: str, names: list[str]) -> list[dict[str, object]]: lines = text.splitlines() if not lines: return [] anchors: list[tuple[str, tuple[str, ...]]] = [] selected = tuple(name for name in names if name) if selected: anchors.append(("selected-command", selected)) if path.name == "native-source-policy.ts": anchors.append(("runtime-policy", ("function nativeSourceDecision",))) if path.suffix in {".cpp", ".h"}: anchors.extend(( ("desktop-handler", ("executeDesktop", "DesktopCommand")), ("command-registration", ("const QStringList commands", "desktopCommands", "FC_WEB_DESKTOP_COMMAND")), ("family-record-handler", ("probePythonFamilyRecord", "probeFamilyRecord", "executePythonFamily")), ("family-dispatch", ('family == QStringLiteral("desktop-command")', "pythonFamilyBatch")), ("unified-dispatch", ("fc_web_dispatch_json",)), )) else: anchors.extend(( ("business-case-coverage", ("resolvedIssueIds", "resolvedFailureKeys", "FREECAD_BUSINESS_IDS")), ("request-contract", ("PythonFamilyRecord", "DesktopCommandRoute")), ("worker-dispatch", ("onmessage", "dispatch")), ("native-call", ("fc_web_dispatch_json",)), ("response-validation", ("native_protocol_error", "returned an invalid")), )) regions: list[dict[str, object]] = [] for role, patterns in anchors: line = next((index for pattern in patterns for index, value in enumerate(lines, 1) if pattern in value and not value.lstrip().startswith("//")), None) if line is None or any(region["line"] <= line < region["line"] + len(region["excerpt"].splitlines()) for region in regions): continue start, excerpt = anchored_excerpt(lines, line, MAX_EDIT_REGION_BYTES) regions.append({ "path": str(path.relative_to(ROOT)), "role": role, "line": start, "anchorLine": line, "excerpt": excerpt, }) if regions: return regions start, excerpt = anchored_excerpt(lines, len(lines), MAX_EDIT_REGION_BYTES) return [{ "path": str(path.relative_to(ROOT)), "role": "file-tail", "line": start, "anchorLine": len(lines), "excerpt": excerpt, }] def application_integration_excerpts(module: str = "") -> list[dict[str, object]]: """Provide real command call sites, not another isolated facade preview.""" regions = [] slug = re.sub(r"[^a-z0-9]+", "", module.lower()) for relative, anchors in ( (f"web/src/{slug}-desktop-adapter.ts", ("export function",)), ("web/src/workbench-shell.tsx", ("new DesktopCommandDispatcher", "desktopCommands.execute", "return <")), ("web/src/desktop-command-dispatcher.ts", ("async execute(", "this.router.request")), ): path = ROOT / relative if not path.is_file(): continue lines = path.read_text(encoding="utf-8").splitlines() for anchor in anchors: number = next((index for index, line in enumerate(lines, 1) if anchor in line), None) if number is None or any(row["path"] == relative and row["line"] <= number < row["line"] + len(row["excerpt"].splitlines()) for row in regions): continue start, excerpt = anchored_excerpt(lines, number, 1000) if not excerpt or len(excerpt.encode()) > 1400: continue regions.append({"path": relative, "role": "application-integration", "line": start, "anchorLine": number, "excerpt": excerpt}) return regions def probe_sources(module: str, tier: str = "", family: str = "") -> list[Path]: normalized_module = re.sub(r"[^a-z0-9]", "", module.lower()) candidates: list[Path] = [] # Most probes use *Probe.cpp, but a few modules keep the active native # implementation in a deliberately suffixed translation unit (for # example RobotRuntimeProbeFixed.cpp) and link an auxiliary *Api.cpp. # Discover only those known probe-shaped files under the bounded probe # directory; never fall back to a repository-wide scan. for pattern in ("*Probe.cpp", "*ProbeFixed.cpp", "*InventoryApi.cpp"): candidates.extend(PROBE_ROOT.glob(pattern)) result = [ path for path in sorted(set(candidates)) if re.sub(r"[^a-z0-9]", "", path.stem.lower()).startswith(normalized_module) and not (module == "Part" and path.stem.startswith("PartDesign")) and not (module == "Mesh" and path.stem.startswith("MeshPart")) ] desktop = PROBE_ROOT / f"{module}DesktopCommands.cpp" if desktop.is_file(): result.append(desktop) if tier == "format-workflow" or family == "import-export": def format_rank(path: Path) -> tuple[int, str]: try: owns_dispatch = "fc_web_dispatch_json" in path.read_text(encoding="utf-8", errors="replace") except OSError: owns_dispatch = False return ( 0 if owns_dispatch else 1 if "RuntimeProbe" in path.name else 2 if "IoProbe" in path.name else 3, path.name, ) result.sort(key=format_rank) else: result.sort(key=lambda path: (0 if "RuntimeProbe" in path.name else 1 if "DesktopCommands" in path.name else 2, path.name)) selected_sources = result[:4] # Inline desktop workflow implementations are part of the selected native # source, not missing business logic. Follow only quoted, local headers. headers = [] pending = list(selected_sources) seen = {source.resolve() for source in selected_sources} for source in pending: for name in re.findall(r'^\s*#include\s+"([^"\n]+\.(?:h|hpp))"', source.read_text(encoding="utf-8", errors="replace"), re.MULTILINE): header = (source.parent / name).resolve() if header.parent == PROBE_ROOT.resolve() and header.is_file() and header not in seen: seen.add(header) headers.append(header) pending.append(header) return [*selected_sources, *headers] def first_match_line(text: str, name: str) -> int | None: patterns = (f'QStringLiteral("{name}")', f'"{name}"', name.rsplit(".", 1)[-1]) for index, line in enumerate(text.splitlines(), 1): if any(pattern and pattern in line for pattern in patterns): return index return None def file_entry(path: Path) -> dict[str, object]: stat = path.stat() return { "path": str(path.relative_to(ROOT)), "absolutePath": str(path.resolve()), "bytes": stat.st_size, "mtimeNs": stat.st_mtime_ns, "sha256": sha256(path), } def instruction_context(paths: list[Path]) -> dict: directories = {ROOT, ROOT.parent} for path in paths: directories.update(parent for parent in path.resolve().parents if parent.is_relative_to(ROOT)) return {"workspace": str(ROOT), "checkedAncestorDirectories": len(directories), "geometryBoundary": "docs/OCCT_GEOMETRY_BOUNDARY.md", "instructionFiles": sorted(str(parent / "AGENTS.md") for parent in directories if (parent / "AGENTS.md").is_file()), "businessRecovery": { "policy": "docs/UNATTENDED_WORKFLOW.md", "workedExample": "docs/current/part-mesh-business-unblock.md", "steps": [ "OCCT WASM is already provided: reuse it as the sole geometry backend; keep FreeCAD document semantics and Three.js display separate. Reuse local sources and current evidence.", "After a stall, isolate the smallest failing business input; do not replay the unchanged failing full batch.", "Time load, initialization, native stages and cleanup separately; retain the last entered and completed stage.", "Check the traced native path for pthread pool exhaustion, recursive async work, event-loop waits and GIL ownership; do not assume every timeout has the same cause.", "Fix the shared native owner, preserve desktop semantics and geometry checks; rebuild only affected module targets.", "Verify the isolated failure, then affected business groups; reuse unchanged passed evidence and retain unresolved items.", ], }} def acceptance_build_for_ids(contract: dict, selected_ids: set[str]) -> tuple[object, bool]: overrides = contract.get("buildOverrides", {}) choices = [overrides.get(record_id) for record_id in sorted(selected_ids)] if isinstance(overrides, dict) else [] if choices and isinstance(choices[0], dict) and choices[0] and all(choice == choices[0] for choice in choices): return choices[0], True return contract.get("build"), False def workflow_worker(module: str, row: dict) -> str | None: routes = ROOT / "web/src/module-registry.generated.ts" if routes.is_file() and row.get("command"): # The capability ledger's worker can be the module default. The # generated switch is the actual command-specific browser route. key = json.dumps(f"{module}:{row['command']}") match = re.search(rf'case\s+{re.escape(key)}\s*:\s*return\s+("(?:[^"\\]|\\.)*")\s*;', routes.read_text()) if match: return json.loads(match.group(1)) return row.get("worker") def workflow_worker_sources(module: str, ids: list[str], capabilities: dict) -> list[Path]: rows = [capabilities.get(record_id, {}) for record_id in ids] if not rows or not all(row.get("worker") and delivery_route(row) for row in rows): return [] workers = read_progress(ROOT / "web/public/modules/manifest.json").get("modules", {}).get(module, {}).get("workers", {}) runtimes = {workers.get(workflow_worker(module, row), {}).get("runtimeUrl") for row in rows} if len(runtimes) != 1 or not next(iter(runtimes)): return [] target = Path(next(iter(runtimes))).stem if not re.fullmatch(r"FreeCADWeb[A-Za-z0-9]+Probe", target): return [] cmake = PROBE_ROOT / "CMakeLists.txt" if not cmake.is_file(): return [] match = re.search(rf"add_executable\(\s*{re.escape(target)}\s+([A-Za-z0-9_]+\.cpp)\b", cmake.read_text()) return [PROBE_ROOT / match.group(1)] if match and (PROBE_ROOT / match.group(1)).is_file() else [] def build_hint(module: str, probes: list[Path], tier: str = "", family: str = "") -> dict[str, object]: cmake_path = PROBE_ROOT / "CMakeLists.txt" try: cmake_text = cmake_path.read_text(encoding="utf-8") except OSError: return {} targets: list[str] = [] for source in probes: targets.extend(re.findall(rf"add_executable\(\s*(FreeCADWeb[A-Za-z0-9]+Probe)\s+{re.escape(source.name)}\b", cmake_text)) build_root = ROOT / "freecad-source/build-web" unique_targets = list(dict.fromkeys(targets)) if tier == "feature-workflow" and family.startswith("desktop-command"): unique_targets.sort( key=lambda target: ( 0 if "DesktopCommand" in target else 1 if "RuntimeProbe" in target else 2, target, ) ) fallback_trees = [ path.parents[1] for target in unique_targets for path in build_root.glob(f"*/bin/{target}.js") if path.with_suffix(".wasm").exists() ] # A target may be configured but not have produced its first JS/WASM # artifact yet. Derive the build tree from the target rule in build.ninja # so the outer pipeline can perform that one legitimate initial build # instead of marking the packet unavailable and sending the agent through # repeated source-only turns. configured_trees: list[Path] = [] for ninja in build_root.glob("*/build.ninja"): try: ninja_text = ninja.read_text(encoding="utf-8", errors="replace") except OSError: continue if any(target in ninja_text for target in unique_targets): configured_trees.append(ninja.parent) for target in unique_targets: artifacts = [path for path in build_root.glob(f"*/bin/{target}.js") if path.with_suffix(".wasm").exists()] def rank(path: Path) -> tuple[int, str]: name = path.parents[1].name.lower() if module == "TechDraw" and "techdraw-native" in name: return (0, name) if module == "Fem" and "fem-wasm" in name: return (0, name) return (1 if name == "configure" else 2, name) if artifacts: artifact = sorted(artifacts, key=rank)[0] tree = artifact.parents[1] elif fallback_trees: tree = sorted(set(fallback_trees), key=lambda path: (0 if path.name == "configure" else 1, path.name))[0] artifact = tree / "bin" / f"{target}.js" elif configured_trees: tree = sorted(set(configured_trees), key=lambda path: (0 if path.name == "configure" else 1, path.name))[0] artifact = tree / "bin" / f"{target}.js" else: continue return { "target": target, "tree": str(tree.relative_to(ROOT)), "bin": str((tree / "bin").relative_to(ROOT)), "runtime": str(artifact.relative_to(ROOT)), "wasm": str(artifact.with_suffix(".wasm").relative_to(ROOT)), } return {} def select_module_gate(module: str, slug: str, tier: str, family: str) -> Path | None: candidates = list((ROOT / "tools").glob(f"run_p6_{slug}*module_batch_gate.mjs")) family_tokens = {family.replace("-", "_"), family.split("-", 1)[0]} compatible: list[tuple[int, str, Path]] = [] for path in candidates: try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: continue manifest_driven = "module-batch-manifest.json" in text and "batch.selected" in text and "batch.records" in text module_match = f'"{module}"' in text or f"'{module}'" in text tier_match = tier in text or "typeof batch.selected?.tier" in text or "typeof batch.selected.tier" in text family_match = family in text or "typeof batch.selected?.family" in text or "typeof batch.selected.family" in text if not all((manifest_driven, module_match, tier_match, family_match)): continue exact = path.name == f"run_p6_{slug}_module_batch_gate.mjs" family_named = any(token and token in path.stem for token in family_tokens) generic_family = "typeof batch.selected?.family" in text or "typeof batch.selected.family" in text score = 0 if exact else 1 if family_named else 2 if generic_family else 3 compatible.append((score, path.name, path)) return min(compatible, default=(99, "", None))[2] def main() -> int: try: batch = json.loads(BATCH_PATH.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as error: print(json.dumps({"status": "error", "error": str(error)})) return 2 selected = batch.get("selected") if isinstance(batch.get("selected"), dict) else {} if batch.get("status") != "ready" or not selected or not isinstance(batch.get("records"), list): print(json.dumps({"status": "not_ready"})) return 2 module = str(selected.get("module") or "") tier = str(selected.get("tier") or "") family = str(selected.get("family") or "") if tier == "failure-reconciliation": ledger = reconcile_failures() keys = batch.get("failureKeys", []) rows = [ledger["records"][key] for key in keys if key in ledger["records"] and ledger["records"][key]["status"] == "pending"] payload = {"schema": "freecad-web-module-implementation-packet/v1", "batchId": batch.get("batchId"), "module": module, "tier": tier, "family": family, "recordCount": len(rows), "outerPipelineAvailable": True, "build": {}, "supportFiles": [], "editableExcerpts": [], "records": [], "repositoryContext": instruction_context([]), "failureReconciliation": {"records": [{"key": row["key"], "category": row["category"], "modules": row["modules"], "failureFingerprint": row["failureFingerprint"], "status": row["status"], "command": str(row["sourceSnapshot"].get("command", ""))[:800], "error": str(row["sourceSnapshot"].get("lastError") or row["sourceSnapshot"].get("firstError") or "")[:1200]} for row in rows]}} OUTPUT_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps({"status": "ready", "batchId": batch.get("batchId"), "module": module, "failureRecords": len(rows)})) return 0 recovery_state = read_progress(PROGRESS_PATH) module_recovery = module_states(recovery_state).get(module, {}) reconcile_failures() failure_context = module_context(module, {str(row[0]) for row in batch["records"] if row}) recovering = bool(module_recovery.get("recoveryLevel") or module_recovery.get("consecutiveNoProgress")) probes = probe_sources(module, tier, family) records: list[dict[str, object]] = [] capability_rows = json.loads((ROOT / "docs/current/feature-capability-registry.json").read_text(encoding="utf-8")).get("records", []) capability_by_id = {row["id"]: row for row in capability_rows} worker_sources = workflow_worker_sources(module, [str(row[0]) for row in batch["records"] if row], capability_by_id) probes = list(dict.fromkeys([*worker_sources, *probes])) probe_texts = [(path, path.read_text(encoding="utf-8", errors="replace")) for path in probes] semantic_decisions = {} decision_file = ROOT / "docs/current/native-semantic-decisions.json" for group in read_progress(decision_file).get("groups", []): sources = group.get("sourceDigests", {}) if not sources: continue current = all((ROOT / name).resolve().is_relative_to(ROOT) and (ROOT / name).is_file() and sha256(ROOT / name) == expected for name, expected in sources.items()) for record_id, finding in group.get("records", {}).items(): semantic_decisions[record_id] = {**finding, "sourceSnapshotCurrent": current, "reuseInstruction": "Use this retained source finding; implement the next action, not another general review." if current else "Source changed: revisit only the affected native branches before using this finding."} issue_rows = json.loads((ROOT / "docs/current/runtime-issue-ledger.json").read_text(encoding="utf-8")).get("issues", []) used = 0 native_used = 0 truncated = 0 for row in batch["records"]: if not isinstance(row, list) or not row: continue api_id = str(row[0]) name = str(row[1]) if len(row) > 1 else api_id raw_source = row[3] if len(row) > 3 else None raw_line = row[4] if len(row) > 4 and isinstance(row[4], int) else None resolved = source_path(raw_source) excerpt = "" excerpt_line = raw_line if resolved: try: if resolved.suffix in {".py", ".pyi"}: excerpt_line, excerpt = python_excerpt(resolved, name, raw_line) elif resolved.suffix in {".cpp", ".cxx", ".cc", ".h", ".hpp"}: excerpt_line, excerpt = cpp_command_excerpt(resolved, name, raw_line) else: excerpt_line, excerpt = generic_excerpt(resolved, raw_line) except OSError: excerpt = "" encoded = len(excerpt.encode("utf-8")) if used + encoded > MAX_TOTAL_BYTES: excerpt = "" encoded = 0 truncated += 1 used += encoded native_matches = [] for path, text in probe_texts: line = first_match_line(text, name) if line is None: continue native_excerpt = "" if native_used < MAX_NATIVE_TOTAL_BYTES: _, native_excerpt = generic_excerpt(path, line) encoded_native = len(native_excerpt.encode("utf-8")) if native_used + encoded_native <= MAX_NATIVE_TOTAL_BYTES: native_used += encoded_native else: native_excerpt = "" native_matches.append({"path": str(path.relative_to(ROOT)), "line": line, "excerpt": native_excerpt}) records.append({ "apiId": api_id, "name": name, "source": str(resolved.relative_to(ROOT)) if resolved and resolved.is_relative_to(ROOT) else raw_source, "sourceLine": raw_line, "excerptLine": excerpt_line, "excerpt": excerpt, "nativeMatches": native_matches, "semanticDecision": semantic_decisions.get(api_id), "runtimeRouteBlock": capability_by_id.get(api_id, {}).get("runtimeRouteBlock"), "implementationGaps": capability_by_id.get(api_id, {}).get("implementationGaps", []), "previousAttempt": recovery_state.get("records", {}).get(api_id, {}).get("lastReason"), "observedIssues": [{key: issue.get(key) for key in ("id", "cmd", "args", "firstError", "failureKeys")} for issue in issue_rows if issue.get("capabilityId") == api_id and issue.get("status") in ACTIVE_STATUSES], }) support: list[Path] = [] support.extend(probes) if module == "BIM": support.extend(ROOT / "web/src" / name for name in ( "bim-image-display.ts", "bim-image-plane-task.tsx", "bim-frame-view.tsx", "bim-relationship-task.tsx", "bim-windows-contract.ts", "bim-windows-task.tsx")) integration_regions = application_integration_excerpts(module) if tier in {"feature-workflow", "business-acceptance"} and any(":desktop:" in record["apiId"] for record in records) else [] support.extend(ROOT / region["path"] for region in integration_regions) if module == "Test" and any(record.get("name") in {"Std_MDITest1", "Std_MDITest2", "Std_MDITest3", "Std_TestConsoleOutput"} for record in records): support.extend(ROOT / "web/src" / name for name in ( "test-desktop-routes.json", "test-facade.ts", "test.worker.ts", "test-mdi-host.tsx", "test-desktop-output.tsx")) if any(record.get("runtimeRouteBlock") for record in records): support.extend([ROOT / "web/src/native-source-policy.ts", ROOT / "web/src/module-router.ts"]) slug = re.sub(r"[^a-z0-9]+", "", module.lower()) direct_facade = ROOT / f"web/src/{slug}-facade.ts" if (family == "runtime-issue" or tier == "business-acceptance") and direct_facade.is_file(): support.append(direct_facade) implementation_first = implementation_first_enabled() workflow = str(batch.get("businessWorkflow") or "") format_workflow = tier == "format-workflow" or family == "import-export" or ( workflow in {"document-import-export", "document-persistence"} or workflow.startswith("format-")) selected_gate = None if implementation_first else select_module_gate(module, slug, tier, family) worker_candidates = ( (ROOT / f"web/src/{slug}.worker.ts",) if format_workflow else (ROOT / f"web/src/{slug}-python.worker.ts", ROOT / f"web/src/{slug}.worker.ts") ) declared_workers = [ROOT / "web/src" / workflow_worker(module, capability_by_id[str(row[0])]) for row in batch["records"] if row and capability_by_id.get(str(row[0]), {}).get("worker")] worker_candidates = tuple(dict.fromkeys([*declared_workers, *worker_candidates])) facade_candidates = [] if format_workflow or tier == "product-integration" else sorted((ROOT / "web/src").glob(f"{slug}*python*family*facade.ts")) default_facade = ROOT / f"web/src/{slug}-python-family-facade.ts" if not format_workflow and tier != "product-integration" and default_facade.is_file() and default_facade not in facade_candidates: facade_candidates.insert(0, default_facade) format_facade_candidates: list[Path] = [] if format_workflow: for pattern in (f"{slug}*io*facade.ts", f"{slug}*import*export*facade.ts", f"{slug}*save*facade.ts", f"{slug}*persistence*facade.ts"): format_facade_candidates.extend((ROOT / "web/src").glob(pattern)) direct_format_facade = ROOT / f"web/src/{slug}-facade.ts" if direct_format_facade.is_file(): format_facade_candidates.append(direct_format_facade) format_dispatcher = ROOT / "web/src/format-dispatcher.ts" if format_dispatcher.is_file(): format_facade_candidates.append(format_dispatcher) for path in ( *facade_candidates, *format_facade_candidates, *worker_candidates, selected_gate, None if implementation_first else ROOT / "tools/wasm-probe-runtime.mjs", ): if path is not None and path.is_file(): support.append(path) case_paths = [] acceptance_build = {} explicit_build = False acceptance_setup = None if tier == "business-acceptance" or family == "runtime-issue": contract_path = ROOT / f"docs/business-acceptance/{module}.json" contract = {} if contract_path.is_file(): try: contract = json.loads(contract_path.read_text(encoding="utf-8")) command = contract.get("command", []) if len(command) > 1: script = (ROOT / command[1]).resolve() if script.is_relative_to(ROOT) and script.is_file(): case_paths.append(script) except (OSError, ValueError, TypeError): pass selected_ids = {str(row[0]) for row in batch["records"] if row} covered_ids = set(contract.get("coveredIds", [])) acceptance_setup = {"contract": str(contract_path.relative_to(ROOT)), "caseScripts": [str(path.relative_to(ROOT)) for path in case_paths], "selectedIds": sorted(selected_ids), "coveredIds": sorted(covered_ids), "missingIds": sorted(selected_ids - covered_ids), "ready": bool(case_paths and selected_ids <= covered_ids)} declared_build, explicit_build = acceptance_build_for_ids(contract, selected_ids) if isinstance(declared_build, dict) and declared_build.get("tree") and declared_build.get("target"): tree = (ROOT / str(declared_build["tree"])).resolve() target = str(declared_build["target"]) if tree.is_relative_to(ROOT) and tree.is_dir() and re.fullmatch(r"FreeCADWeb[A-Za-z0-9]+Probe", target): runtime = tree / "bin" / f"{target}.js" acceptance_build = {"target": target, "tree": str(tree.relative_to(ROOT)), "bin": str((tree / "bin").relative_to(ROOT)), "runtime": str(runtime.relative_to(ROOT)), "wasm": str(runtime.with_suffix(".wasm").relative_to(ROOT))} cmake = PROBE_ROOT / "CMakeLists.txt" if cmake.is_file(): match = re.search(rf"add_executable\(\s*{re.escape(target)}\s+([A-Za-z0-9_]+\.cpp)\b", cmake.read_text(encoding="utf-8")) if match and (PROBE_ROOT / match.group(1)).is_file(): support.append(PROBE_ROOT / match.group(1)) for path in (ROOT / "tools/run_business_acceptance.py", ROOT / "tools/wasm-probe-runtime.mjs", ROOT / "docs/WEB_BUSINESS_MIGRATION_RULES.md", ROOT / "docs/current/provided-source-index.md", contract_path, *case_paths): if path.is_file(): support.append(path) product_acceptance = (tier == "business-acceptance" or family == "runtime-issue") and records and all(str(row.get("apiId", "")).startswith("product:") for row in records) browser_ui_batch = bool(records) and all( (capability_by_id.get(row["apiId"], {}).get("route") or {}).get("transport") == "browser-ui" for row in records) view_catalog = ROOT / "web/src/document-view-routes.json" document_view_batch = module in {"Web", "Std"} and view_catalog.is_file() and bool(records) and all( row.get("name") in (json.loads(view_catalog.read_text(encoding="utf-8")).get("commands", []) + json.loads(view_catalog.read_text(encoding="utf-8")).get("standardCommands", []) + json.loads(view_catalog.read_text(encoding="utf-8")).get("websiteCommands", [])) for row in records) if module in {"Web", "Std"} and records: document_view_batch = document_view_batch or all( (capability_by_id.get(row["apiId"], {}).get("route") or {}).get("module") == "Part" and (capability_by_id.get(row["apiId"], {}).get("route") or {}).get("command") in {"part.viewCommand", "part.documentViewCommand"} for row in records) if module in {"Web", "Std"} and view_catalog.is_file(): # Pending document-view commands need the existing owning transport # too, before they can be added to the implemented command catalog. support.extend([view_catalog, PROBE_ROOT / "PartBoxProbe.cpp", PROBE_ROOT / "DocumentProbe.cpp", PROBE_ROOT / "NativeDocumentView.h", PROBE_ROOT / "WebViewCommands.h"]) support.extend(ROOT / "web/src" / name for name in ("part-view-facade.ts", "part.worker.ts", "part-save.worker.ts", "web-desktop-adapter.ts", "web-view-task.tsx", "project-shell.tsx", "web-owner-standard-adapter.ts", "web-standard-command-contract.ts", "web-standard-task.tsx", "web-website-adapter.ts", "web-website-task.tsx", "web-group-adapter.ts", "web-group-contract.ts", "web-group-task.tsx", "web-commanddoc-contract.ts", "web-commanddoc-task.tsx", "web-link-contract.ts", "web-source-test-adapter.ts", "web-structure-adapter.ts", "web-structure-contract.ts", "web-structure-task.tsx", "web-link-mutation-task.tsx", "web-open-adapter.ts", "web-open-contract.ts", "web-open-task.tsx")) if any(row.get("name") == "Std_Open" for row in records): support.append(PROBE_ROOT / "WebOpenCommand.h") if any(row.get("name") in {"Std_LinkMakeRelative", "Std_LinkReplace", "Std_LinkUnlink"} for row in records): support.append(PROBE_ROOT / "WebLinkMutationCommands.h") if any(row.get("name") in {"Std_Part", "Std_VarSet"} for row in records): support.append(PROBE_ROOT / "WebStructureCommands.h") support.append(ROOT / "tools/generate_feature_capability_registry.py") if any(row.get("name") == "Std_Group" for row in records): support.append(PROBE_ROOT / "WebGroupCommand.h") if browser_ui_batch: # Desktop overlay commands own UI layout, not FreeCAD document geometry. support = [ROOT / "web/src" / name for name in ( "dock-overlay-commands.json", "dock-overlay-adapter.ts", "dock-overlay-host.tsx", "dock-overlay.css", "scene-display-commands.json", "scene-command-adapter.ts", "scene-command-host.ts", "scene-command-host.css", "scene-overlap-picker.ts", "scene-camera-commands.ts", "import-render-bridge.ts", "openscad-desktop-view.tsx", "desktop-command-dispatcher.ts", "workbench-shell.tsx", "facade-envelope.ts")] support.append(ROOT / "web/workbench.html") if tier == "product-integration" or product_acceptance: # Product acceptance needs real application entry points, not the # unrelated Web workbench's native web-server fixture. support = [path for path in support if path in case_paths or path.name in { "run_business_acceptance.py", "WEB_BUSINESS_MIGRATION_RULES.md", "provided-source-index.md"} or path.parent == ROOT / "docs/business-acceptance"] for record in records: resolved = source_path(record.get("source")) if resolved is not None: support.append(resolved) product_entry = ROOT / "web/index.html" if product_entry.is_file(): support.append(product_entry) part_source_table = ROOT / "web/src/part-source-commands.json" part_source_batch = module == "Part" and part_source_table.is_file() and any( row.get("name") in json.loads(part_source_table.read_text(encoding="utf-8")) for row in records) if part_source_batch: support.extend([part_source_table, PROBE_ROOT / "PartBoxProbe.cpp", PROBE_ROOT / "PartSplitSource.h"]) support.extend((ROOT / "freecad-source/src/Mod/Part/BOPTools").glob("*.py")) if any(row.get("name") in {"Part_Slice", "Part_SliceApart"} for row in records): support.extend(ROOT / "freecad-source/src/Mod/Part/CompoundTools" / name for name in ("Explode.py", "CompoundFilter.py")) support.append(ROOT / "web/src/part-slice-task.tsx") support_text = {str(path.relative_to(ROOT)): path.read_text(encoding="utf-8", errors="replace") for path in support} facade_paths = [str(path.relative_to(ROOT)) for path in (*facade_candidates, *format_facade_candidates) if path.is_file()] worker_paths = [str(path.relative_to(ROOT)) for path in worker_candidates if path.is_file()] gate_path = str(selected_gate.relative_to(ROOT)) if selected_gate else "" worker_build = build_hint(module, worker_sources, tier, family) if worker_sources else {} build = (acceptance_build if explicit_build else worker_build or acceptance_build) or build_hint(module, probes, str(selected.get("tier") or ""), str(selected.get("family") or "")) if part_source_batch: build = build_hint(module, [PROBE_ROOT / "PartBoxProbe.cpp"], tier, family) if document_view_batch: build = build_hint("Part", [PROBE_ROOT / "PartBoxProbe.cpp"], tier, family) if tier == "product-integration" or product_acceptance or browser_ui_batch: build = { "kind": "frontend", "target": "FreeCADWebReactProduct", "tree": "web", "bin": "web/dist", "runtime": "web/dist/index.html", "wasm": "web/dist/index.html", } selected_names = [str(row.get("name") or "") for row in records] feature_desktop = selected.get("tier") == "feature-workflow" and str(selected.get("family") or "").startswith("desktop-command") desktop_adapter = next((path for path in probes if path.name == f"{module}DesktopCommands.cpp"), None) probe_has_unified_dispatch = any("fc_web_dispatch_json" in text for _, text in probe_texts) unified_dispatch_paths = { path for path, text in probe_texts if "fc_web_dispatch_json" in text } oversized_native_sources = [ str(path.relative_to(ROOT)) for path, text in probe_texts if len(text.encode("utf-8")) >= DESKTOP_ADAPTER_SPLIT_BYTES and "DesktopCommands" not in path.name ] facade_uses_unified_dispatch = any("fc_web_dispatch_json" in support_text.get(facade_path, "") for facade_path in facade_paths) worker_uses_family_dispatch = any( re.search(r"PythonFamily(?:Batch)?Request", support_text.get(worker_path, "")) is not None and "dispatch" in support_text.get(worker_path, "") for worker_path in worker_paths ) omitted_stable_support: list[str] = [] edit_regions: list[dict[str, object]] = list(integration_regions) edit_used = sum(len(region["excerpt"].encode()) for region in edit_regions) edit_limit = min(MAX_EDIT_TOTAL_BYTES, 10240) if format_workflow else MAX_EDIT_TOTAL_BYTES format_dispatch_owners = { path for path, text in probe_texts if "fc_web_dispatch_json" in text } edit_support = list(dict.fromkeys(support)) if family == "runtime-issue" or tier == "business-acceptance": edit_support.sort(key=lambda path: (0 if path in case_paths or path == contract_path else 1 if path == direct_facade or path.name == "native-source-policy.ts" else 2 if path.name.endswith(".worker.ts") else 3 if path.suffix == ".ts" else 4)) for path in edit_support: case_file = (tier == "business-acceptance" or family == "runtime-issue") and (path in case_paths or path == contract_path) if path.name == "wasm-probe-runtime.mjs" or (not case_file and path.suffix not in {".cpp", ".h", ".ts", ".tsx", ".html"}): continue relative = str(path.relative_to(ROOT)) if (tier == "product-integration" or product_acceptance) and path.suffix in {".cpp", ".h"}: omitted_stable_support.append(relative) continue if any(region["path"] == relative for region in integration_regions): continue if family == "runtime-issue": if path == direct_facade and not any(name in support_text.get(relative, "") for name in selected_names): omitted_stable_support.append(relative) continue if path.name == "module-router.ts" and not any("module-router.ts" in str(issue.get("firstError", "")) for record in records for issue in record.get("observedIssues", [])): omitted_stable_support.append(relative) continue # For a desktop feature batch, keep editable native context focused on # the translation unit that owns the unified dispatcher. Other probe # units remain in supportFiles with fingerprints, but their generic # file-tail excerpts make the agent reread unrelated code and inflate # post-read decision time. A split DesktopCommands adapter remains # authoritative and is handled by the existing branch below. if ( feature_desktop and probe_has_unified_dispatch and path in probes and path.suffix in {".cpp", ".h"} and desktop_adapter is None and path not in unified_dispatch_paths ): omitted_stable_support.append(relative) continue if format_workflow and path in probes and path.suffix in {".cpp", ".h"} and path not in format_dispatch_owners: omitted_stable_support.append(relative) continue if feature_desktop and desktop_adapter is not None and path.suffix in {".cpp", ".h"} and path != desktop_adapter: omitted_stable_support.append(relative) continue if feature_desktop and not recovering and ((relative in facade_paths and facade_uses_unified_dispatch) or (relative in worker_paths and worker_uses_family_dispatch)): omitted_stable_support.append(relative) continue path_regions = editable_excerpts(path, support_text.get(relative, ""), selected_names) has_native_edit_anchor = any(region.get("role") != "unified-dispatch" for region in path_regions) for region in path_regions: if feature_desktop and probe_has_unified_dispatch and path.suffix in {".cpp", ".h"} and region.get("role") == "unified-dispatch" and has_native_edit_anchor: continue encoded = len(str(region["excerpt"]).encode("utf-8")) if edit_used + encoded > edit_limit: break edit_used += encoded edit_regions.append(region) probe_entries: list[dict[str, object]] = [] for path, text in probe_texts: for line_number, line in enumerate(text.splitlines(), 1): if "fc_web_dispatch_json" in line or re.search(r"\b(?:bool|QJsonObject)\s+execute[A-Za-z0-9_]+", line): probe_entries.append({"path": str(path.relative_to(ROOT)), "line": line_number, "symbol": line.strip()[:180]}) payload = { "schema": "freecad-web-module-implementation-packet/v1", "generatedAt": datetime.now(timezone.utc).isoformat(), "batchId": batch.get("batchId"), "module": module, "tier": selected.get("tier"), "family": selected.get("family"), "recordCount": len(records), "excerptBytes": used, "nativeExcerptBytes": native_used, "editableExcerptBytes": edit_used, "truncatedRecords": truncated, "supportFiles": [file_entry(path) for path in dict.fromkeys(support)], "repositoryContext": instruction_context(support), "supportChecks": { "probeHasUnifiedDispatch": probe_has_unified_dispatch, "facadeUsesUnifiedDispatch": facade_uses_unified_dispatch, "workerUsesFamilyDispatch": worker_uses_family_dispatch, "gateReadsCurrentManifest": "module-batch-manifest.json" in support_text.get(gate_path, "") and "batch.selected" in support_text.get(gate_path, ""), "sharedNodeLoaderAvailable": "loadWasmProbe" in support_text.get("tools/wasm-probe-runtime.mjs", ""), "desktopAdapterRecommended": bool(feature_desktop and oversized_native_sources), "desktopAdapterActive": desktop_adapter is not None, "oversizedNativeSources": oversized_native_sources, "desktopAdapterSplitBytes": DESKTOP_ADAPTER_SPLIT_BYTES, "desktopAdapterAction": ( f"do not grow oversized runtime; put new commands in {module}DesktopCommands.cpp/.h and link them into the same module target" if feature_desktop and oversized_native_sources else None ), }, "build": build, "gate": gate_path or None, "implementationMode": "feature-first-deferred-validation" if implementation_first else "evidence-first", "validationDeferred": implementation_first, "outerPipelineAvailable": tier == "business-acceptance" or bool(build and (implementation_first or selected_gate)), "acceptanceContract": f"docs/business-acceptance/{module}.json" if tier == "business-acceptance" or family == "runtime-issue" else None, "acceptanceSetup": acceptance_setup, "businessWorkflow": batch.get("businessWorkflow"), "nativeEntryPoints": probe_entries[:24], "editableExcerpts": edit_regions, "omittedStableSupport": sorted(set(omitted_stable_support)), "records": records, "moduleRecovery": module_recovery, "failureReconciliation": failure_context, "integrationRepair": {"applicationCallSites": sorted({row["path"] for row in integration_regions}), "instruction": "Complete the current business through these application call sites. Keep module-specific validation and selection in the existing -desktop-adapter.ts; shared dispatcher edits invalidate every module. Change the common dispatcher only for a demonstrated shared-contract defect or one-time adapter wiring. Do not stop at new helpers or an unmounted component."} if integration_regions else None, } OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) temporary = OUTPUT_PATH.with_suffix(".tmp") temporary.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8") temporary.replace(OUTPUT_PATH) print(json.dumps({"status": "ready", "batchId": batch.get("batchId"), "records": len(records), "excerptBytes": used, "editableExcerptBytes": edit_used, "truncatedRecords": truncated}, ensure_ascii=False)) return 0 if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--batch", type=Path, default=BATCH_PATH) parser.add_argument("--output", type=Path, default=OUTPUT_PATH) options = parser.parse_args() BATCH_PATH = (ROOT / options.batch).resolve() OUTPUT_PATH = (ROOT / options.output).resolve() if not BATCH_PATH.is_relative_to(ROOT) or not OUTPUT_PATH.is_relative_to(ROOT): parser.error("batch and output must remain inside the workspace") OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) raise SystemExit(main())