203 lines
13 KiB
Python
203 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Reuse completed implementations in bounded, executable business workflows."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from collections import Counter
|
|
from datetime import datetime, timezone
|
|
|
|
from business_acceptance import ROOT, accepted_ids, read, requirements
|
|
from implementation_first import load_registry, deferred_validation_ids
|
|
from prepare_feature_capability_batch import MODULE_ORDER
|
|
from scheduler_progress import cooling_ids, retry_details
|
|
from migration_workflows import DELIVERY_BATCH_SIZE, DELIVERY_MODULE_ORDER, PRODUCT_ORDER, delivery_route, desktop_command, supporting_route, workflow_family, workflow_order
|
|
|
|
|
|
def contract_ids(module: str) -> set[str]:
|
|
contract = read(ROOT / f"docs/business-acceptance/{module}.json")
|
|
command = contract.get("command", [])
|
|
if not isinstance(command, list) or len(command) < 2 or command[0] not in {"node", "python3"}:
|
|
return set()
|
|
script = (ROOT / str(command[1])).resolve()
|
|
if not script.is_relative_to(ROOT) or not script.is_file():
|
|
return set()
|
|
return {value for value in contract.get("coveredIds", []) if isinstance(value, str)}
|
|
|
|
|
|
def inventory() -> dict:
|
|
rows = requirements()
|
|
accepted = accepted_ids(rows)
|
|
delivery = read(ROOT / "docs/current/execution-policy.json").get("workflowDelivery", {})
|
|
receipts = load_registry().get("records", {})
|
|
ready = {record_id for record_id, entry in receipts.items()
|
|
if entry.get("status") in {"implementation_ready", "implemented_pending_use"}}
|
|
pending = [row for row in rows if (row.get("implementationStatus", "routed") == "routed" or delivery.get("enabled"))
|
|
and row["id"] not in accepted and (row["id"] in ready or row.get("status") == "passed"
|
|
or row.get("acceptanceRequired") is True)]
|
|
covered = {module: contract_ids(module) for module in {row["module"] for row in pending}}
|
|
build_overrides = {module: read(ROOT / f"docs/business-acceptance/{module}.json").get("buildOverrides", {}) for module in covered}
|
|
cooling = cooling_ids(candidates={row["id"] for row in pending})
|
|
return {"rows": rows, "accepted": accepted, "pending": pending, "covered": covered,
|
|
"cooling": cooling, "receipts": receipts, "buildOverrides": build_overrides,
|
|
"deliveryPolicy": delivery,
|
|
"history": read(ROOT / "docs/current/scheduler-progress.json").get("history", []),
|
|
"deferredValidation": deferred_validation_ids()}
|
|
|
|
|
|
def workflow(row: dict, receipts: dict) -> str:
|
|
if desktop_command(row):
|
|
return receipts.get(row["id"], {}).get("family") or "desktop-command"
|
|
return workflow_family(row)
|
|
|
|
|
|
def candidates(state: dict, covered_only: bool = False, delivery_only: bool = False, uncovered_only: bool = False,
|
|
exclude_module: str | None = None) -> list[dict]:
|
|
groups = {}
|
|
delivery = state.get("deliveryPolicy", {})
|
|
for row in state["pending"]:
|
|
if row["id"] in state.get("deferredValidation", set()):
|
|
continue
|
|
if row["module"] == exclude_module:
|
|
continue
|
|
if delivery_only and not delivery_route(row):
|
|
continue
|
|
if row["id"] in state["cooling"]:
|
|
continue
|
|
executable = row["id"] in state["covered"][row["module"]]
|
|
if covered_only and not executable:
|
|
continue
|
|
missing_implementation = delivery.get("enabled") and row.get("implementationStatus", "routed") != "routed"
|
|
if uncovered_only and executable and not missing_implementation:
|
|
continue
|
|
family = "existing-business-cases" if executable else workflow(row, state["receipts"])
|
|
override = state.get("buildOverrides", {}).get(row["module"], {}).get(row["id"]) if executable else None
|
|
key = (row["module"], family, json.dumps(override, sort_keys=True))
|
|
groups.setdefault(key, {"module": row["module"], "family": family,
|
|
"executable": executable, "delivery": delivery_route(row), "rows": []})["rows"].append(row)
|
|
groups[key]["delivery"] = groups[key]["delivery"] or delivery_route(row)
|
|
def priority(group):
|
|
family = group["family"]
|
|
return (not group["executable"], not group["delivery"], family == "supporting-business-route",
|
|
DELIVERY_MODULE_ORDER.get(group["module"], 20 + MODULE_ORDER.get(group["module"], 50)) if group["delivery"] else MODULE_ORDER.get(group["module"], 50),
|
|
group["module"], workflow_order(family))
|
|
if not delivery.get("enabled"):
|
|
return sorted(groups.values(), key=priority)
|
|
history = [row for row in state.get("history", [])
|
|
if row.get("outcome") not in {"provider_wait", None} and row.get("module")]
|
|
recent = history[-max(1, int(delivery.get("historyWindow", 8))):]
|
|
priorities = {value: index for index, value in enumerate(delivery.get("priorityIds", []))}
|
|
modules = {value: index for index, value in enumerate(delivery.get("moduleOrder", []))}
|
|
breadth_every = max(2, int(delivery.get("breadthEvery", 4)))
|
|
# History is a rolling (currently 100-row) window; its length cannot serve
|
|
# as a monotonic scheduler counter after the window fills.
|
|
next_cycle = int(history[-1].get("cycle", len(history)) or len(history)) + 1 if history else 1
|
|
breadth_due = bool(history) and next_cycle % breadth_every == 0
|
|
recent_modules = Counter(row["module"] for row in recent)
|
|
def delivery_priority(group):
|
|
ids = {row["id"] for row in group["rows"]}
|
|
unfinished = sum(bool(ids.intersection(event.get("selectedIds", event.get("deferredIds", []))))
|
|
and not ids.intersection(event.get("firstBusinessPassIds", [])) for event in recent)
|
|
rotate = (unfinished >= int(delivery.get("maxUnfinishedAttempts", 2))
|
|
or recent_modules[group["module"]] >= int(delivery.get("maxModuleTurns", 3)))
|
|
explicit = min((priorities.get(value, len(priorities)) for value in ids), default=len(priorities))
|
|
supporting = all(not row["id"].startswith("product:") and not desktop_command(row) and supporting_route(row)
|
|
for row in group["rows"])
|
|
implementation_gap = any(row.get("implementationStatus", "routed") != "routed" for row in group["rows"])
|
|
return (supporting, rotate, breadth_due and group["module"] in recent_modules, explicit,
|
|
not implementation_gap, modules.get(group["module"], 100), *priority(group))
|
|
# Rank rather than drop deferred work: when only one group is eligible it
|
|
# remains runnable, and full-scope requirements never disappear into cooldown.
|
|
return sorted(groups.values(), key=delivery_priority)
|
|
|
|
|
|
def plan(state: dict, covered_only: bool = False, delivery_only: bool = False, uncovered_only: bool = False,
|
|
exclude_module: str | None = None) -> dict:
|
|
groups = candidates(state, covered_only, delivery_only, uncovered_only, exclude_module)
|
|
deferred = {row["id"] for row in state["pending"] if row["id"] in state["cooling"]
|
|
and row["id"] not in state.get("deferredValidation", set())
|
|
and (not delivery_only or delivery_route(row))
|
|
and (not covered_only or row["id"] in state["covered"][row["module"]])
|
|
and (not uncovered_only or row["id"] not in state["covered"][row["module"]])
|
|
and row["module"] != exclude_module}
|
|
if not groups:
|
|
return {"schema": "freecad-web-module-business-batch/v1", "status": "no_ready_batch", "records": [],
|
|
"reason": "no business acceptance batch ready", **retry_details(deferred)}
|
|
group = groups[0]
|
|
module = group["module"]
|
|
limit = 32 if group["executable"] else DELIVERY_BATCH_SIZE
|
|
selected = sorted(group["rows"], key=lambda row: (PRODUCT_ORDER.get(row["id"], 100), row["id"]))[:limit]
|
|
identity = "|".join([module, "business-acceptance", "business-acceptance", *(row["id"] for row in selected)])
|
|
return {
|
|
"schema": "freecad-web-module-business-batch/v1", "status": "ready",
|
|
"strategy": "module-business-first-deferred-validation",
|
|
"taskId": read(ROOT / "docs/current/task-state.json").get("active_task"),
|
|
"batchId": hashlib.sha256(identity.encode()).hexdigest()[:24],
|
|
"minimumRecords": 1, "singletonFallback": len(selected) == 1,
|
|
"selected": {"module": module, "tier": "business-acceptance", "family": "business-acceptance", "recordCount": len(selected)},
|
|
"businessWorkflow": group["family"], "acceptanceReady": group["executable"],
|
|
"businessLane": "development" if uncovered_only else "acceptance",
|
|
"selectionScope": "delivery" if delivery_only else "full",
|
|
"fields": ["id", "command", "status", "source", "sourceLine"],
|
|
"records": [[row["id"], row["command"], "acceptance_required", row.get("sourcePath") or row.get("source") or "web/src/module-router.ts", row.get("sourceLine", 1)] for row in selected],
|
|
"acceptanceContract": f"docs/business-acceptance/{module}.json",
|
|
"queueGroupCount": len(groups),
|
|
"pendingBusinessCount": len(state["pending"]),
|
|
"queue": [{"module": item["module"], "family": item["family"], "unresolved": len(item["rows"]),
|
|
"acceptanceReady": item["executable"]} for item in groups[:8]],
|
|
}
|
|
|
|
|
|
def write_plan_report(state: dict) -> dict:
|
|
total = Counter(row["module"] for row in state["rows"])
|
|
accepted = Counter(row["module"] for row in state["rows"] if row["id"] in state["accepted"])
|
|
pending = Counter(row["module"] for row in state["pending"])
|
|
executable = Counter(row["module"] for row in state["pending"] if row["id"] in state["covered"][row["module"]])
|
|
modules = [{"module": module, "total": total[module], "accepted": accepted[module],
|
|
"reusablePending": pending[module], "executablePending": executable[module],
|
|
"needsCases": pending[module] - executable[module],
|
|
"implementationGap": total[module] - accepted[module] - pending[module]}
|
|
for module in sorted(total, key=lambda name: (MODULE_ORDER.get(name, 50), name))]
|
|
report = {"generatedAt": datetime.now(timezone.utc).isoformat(), "total": len(state["rows"]),
|
|
"accepted": len(state["accepted"]), "reusablePending": len(state["pending"]),
|
|
"executablePending": sum(executable.values()), "modules": modules,
|
|
"nextBatch": plan(state), "policy": "executable-cases-first; alternate acceptance-development and implementation"}
|
|
output = ROOT / "docs/current/business-acceptance-plan.json"
|
|
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
lines = ["# Business Acceptance Plan", "", f"Accepted: {report['accepted']}/{report['total']}. Reusable pending: {report['reusablePending']}; executable pending: {report['executablePending']}.",
|
|
"", "Implementation receipts are candidates for acceptance, not proof of functional completion.", "",
|
|
"| Module | Total | Accepted | Reusable Pending | Executable | Needs Cases | Implementation Gap |", "| --- | ---: | ---: | ---: | ---: | ---: | ---: |"]
|
|
lines.extend("| " + " | ".join(str(row[key]) for key in ("module", "total", "accepted", "reusablePending", "executablePending", "needsCases", "implementationGap")) + " |" for row in modules)
|
|
output.with_suffix(".md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
return report
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--covered-only", action="store_true")
|
|
parser.add_argument("--delivery-only", action="store_true")
|
|
parser.add_argument("--uncovered-only", action="store_true", help="select existing implementations that need real business cases")
|
|
parser.add_argument("--exclude-module", help="leave the current blocked module intact while another module advances")
|
|
parser.add_argument("--priority-only", action="store_true", help="select only user-authorized priority business IDs")
|
|
parser.add_argument("--report-only", action="store_true")
|
|
args = parser.parse_args(argv)
|
|
if args.covered_only and args.uncovered_only:
|
|
parser.error("--covered-only and --uncovered-only are mutually exclusive")
|
|
state = inventory()
|
|
if args.priority_only:
|
|
priority = set(read(ROOT / "docs/current/execution-policy.json").get("priorityBusinessIds", []))
|
|
state["pending"] = [row for row in state["pending"] if row["id"] in priority]
|
|
if args.report_only:
|
|
report = write_plan_report(state)
|
|
print(json.dumps({key: report[key] for key in ("accepted", "total", "reusablePending", "executablePending")}))
|
|
return 0
|
|
payload = plan(state, args.covered_only, args.delivery_only, args.uncovered_only, args.exclude_module)
|
|
payload["generatedAt"] = datetime.now(timezone.utc).isoformat()
|
|
(ROOT / "docs/current/module-batch-manifest.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
print(json.dumps({"status": payload["status"], "remaining": len(state["pending"]), "selected": payload.get("selected"),
|
|
"acceptanceReady": payload.get("acceptanceReady")}))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|