diff --git a/docs/coordination-graph.md b/docs/coordination-graph.md new file mode 100644 index 0000000..a974a65 --- /dev/null +++ b/docs/coordination-graph.md @@ -0,0 +1,22 @@ +# Coordination graph (workplans and waits) + +State Hub owns workplans. Fabric does not author them. This view *projects* +open workplans and open tasks into the existing graph explorer so chokepoints +are inspectable. + +```bash +# JSON payload +railiance-fabric export --format coordination --state-hub http://127.0.0.1:8000 + +# UI (local registry server) +make graph-explorer +# then open +# http://127.0.0.1:8765/ui/graph-explorer?mode=coordination +``` + +Nodes: open workplans (`proposed|ready|active|blocked|backlog`) and their +open tasks (`todo|progress|wait`). Edges: `belongs_to` (task → workplan) and +`waits_on` when a wait/human task cites another workplan id in its prose. + +`depends_on` frontmatter is almost unused in the fleet (two edges). The +citation edges recover the real wait graph from task text. diff --git a/railiance_fabric/cli.py b/railiance_fabric/cli.py index 908a8d4..916922f 100644 --- a/railiance_fabric/cli.py +++ b/railiance_fabric/cli.py @@ -84,7 +84,8 @@ def build_parser() -> argparse.ArgumentParser: export = sub.add_parser("export", help="Export graph as JSON, Mermaid, graph-explorer, or financial payload.") export.add_argument("paths", nargs="*", type=Path, default=[Path(".")]) - export.add_argument("--format", choices=["json", "mermaid", "graph-explorer", "financial"], default="json") + export.add_argument("--format", choices=["json", "mermaid", "graph-explorer", "financial", "coordination"], default="json") + export.add_argument("--state-hub", default=None, help="State Hub API base for --format coordination") scan = sub.add_parser("scan", help="Scan a repo for deterministic discovery candidates.") scan.add_argument("path", nargs="?", type=Path, default=Path(".")) @@ -347,6 +348,12 @@ def main(argv: list[str] | None = None) -> int: return 0 if args.command == "export": + if args.format == "coordination": + from .coordination_graph import coordination_graph_payload, default_hub, fetch_hub_lists + + workplans, tasks = fetch_hub_lists(args.state_hub or default_hub()) + print(json.dumps(coordination_graph_payload(workplans, tasks), indent=2, sort_keys=True)) + return 0 graph = _load_graph_or_exit(args.paths) export_payload = _export_with_provenance(graph.to_export(), args.paths) if args.format == "mermaid": diff --git a/railiance_fabric/coordination_graph.py b/railiance_fabric/coordination_graph.py new file mode 100644 index 0000000..d9e8644 --- /dev/null +++ b/railiance_fabric/coordination_graph.py @@ -0,0 +1,188 @@ +"""Project State Hub workplans/tasks into the graph-explorer payload. + +This is a *view* of State Hub coordination state, not Fabric topology. +State Hub remains the authoring surface for workplans; Fabric only renders. +""" +from __future__ import annotations + +import json +import os +import re +import urllib.request +from datetime import datetime, timezone +from typing import Any +from urllib.parse import urlencode + +OPEN_WORKPLAN = frozenset({"proposed", "ready", "active", "blocked", "backlog"}) +OPEN_TASK = frozenset({"todo", "progress", "wait"}) +WP_ID_RE = re.compile(r"\b([A-Z]{2,12}(?:-[A-Z]+)?-WP-[A-Z0-9-]+)\b") + + +def coordination_graph_payload( + workplans: list[dict[str, Any]], + tasks: list[dict[str, Any]], +) -> dict[str, Any]: + """Build a GraphExplorerPayload of open workplans and their open tasks.""" + open_wps = [wp for wp in workplans if str(wp.get("status") or "") in OPEN_WORKPLAN] + wp_by_id = {str(wp.get("id")): wp for wp in open_wps if wp.get("id")} + slug_to_id = { + str(wp.get("slug") or "").lower(): str(wp["id"]) + for wp in open_wps + if wp.get("slug") and wp.get("id") + } + # also index record ids like CUST-WP-0071 from title/slug + record_to_id: dict[str, str] = {} + for wp in open_wps: + slug = str(wp.get("slug") or "") + title = str(wp.get("title") or "") + for token in WP_ID_RE.findall(slug.upper() + " " + title.upper()): + record_to_id[token] = str(wp["id"]) + if slug: + record_to_id[slug.upper().replace("_", "-")] = str(wp["id"]) + + open_tasks = [ + task + for task in tasks + if str(task.get("status") or "") in OPEN_TASK + and str(task.get("workplan_id") or task.get("workstream_id") or "") in wp_by_id + ] + + elements: list[dict[str, Any]] = [] + for wp in open_wps: + wp_id = str(wp["id"]) + status = str(wp.get("status") or "unknown") + elements.append( + { + "data": { + "id": f"workplan:{wp_id}", + "stableKey": f"workplan:{wp_id}", + "kind": "Workplan", + "layer": "workplan", + "label": str(wp.get("slug") or wp.get("title") or wp_id)[:48], + "name": wp.get("title"), + "lifecycle": status, + "status": status, + "repo": wp.get("repo") or wp.get("slug"), + "displayState": "show" if status != "blocked" else "highlight", + "unresolved": status in {"blocked", "proposed"}, + "chokepoint": 0, + } + } + ) + + belongs = 0 + wait_edges = 0 + for task in open_tasks: + task_id = str(task["id"]) + wp_id = str(task.get("workplan_id") or task.get("workstream_id")) + status = str(task.get("status") or "todo") + human = bool(task.get("needs_human")) + elements.append( + { + "data": { + "id": f"task:{task_id}", + "stableKey": f"task:{task_id}", + "kind": "Task", + "layer": "task", + "label": str(task.get("title") or task_id)[:56], + "name": task.get("title"), + "lifecycle": status, + "status": status, + "needsHuman": human, + "displayState": "highlight" if status == "wait" or human else "show", + "unresolved": status == "wait" or human, + } + } + ) + elements.append( + { + "data": { + "id": f"edge:belongs:{task_id}", + "stableKey": f"edge:belongs:{task_id}", + "kind": "Edge", + "layer": "dependency", + "displayState": "show", + "source": f"task:{task_id}", + "target": f"workplan:{wp_id}", + "edgeType": "belongs_to", + "strength": "weak", + "sourceLayer": "task", + "targetLayer": "workplan", + } + } + ) + belongs += 1 + if status == "wait" or human: + wait_edges += 1 + + # Cross-workplan citations in wait/blocking text. + cited = 0 + for task in open_tasks: + blob = " ".join( + str(task.get(key) or "") + for key in ("description", "blocking_reason", "intervention_note", "title") + ) + src_wp = str(task.get("workplan_id") or task.get("workstream_id")) + for token in set(WP_ID_RE.findall(blob.upper())): + dst = record_to_id.get(token) or slug_to_id.get(token.lower()) + if dst and dst != src_wp and dst in wp_by_id: + elements.append( + { + "data": { + "id": f"edge:cites:{task['id']}:{dst}", + "stableKey": f"edge:cites:{task['id']}:{dst}", + "kind": "Edge", + "layer": "dependency", + "displayState": "show", + "source": f"workplan:{src_wp}", + "target": f"workplan:{dst}", + "edgeType": "waits_on", + "strength": "strong", + "sourceLayer": "workplan", + "targetLayer": "workplan", + } + } + ) + cited += 1 + for el in elements: + if el["data"]["id"] == f"workplan:{dst}": + el["data"]["chokepoint"] = int(el["data"].get("chokepoint") or 0) + 1 + + generated = datetime.now(timezone.utc).isoformat() + return { + "apiVersion": "railiance.fabric/v1alpha1", + "kind": "GraphExplorerPayload", + "manifest_id": "railiance-fabric.coordination-map", + "generated_at": generated, + "mode": "coordination", + "metrics": { + "open_workplans": len(open_wps), + "open_tasks": len(open_tasks), + "belongs_to_edges": belongs, + "citation_edges": cited, + "wait_or_human_tasks": wait_edges, + }, + "elements": elements, + "hidden_elements": [], + } + + +def fetch_hub_lists(api_base: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + base = api_base.rstrip("/") + workplans: list[dict[str, Any]] = [] + for status in sorted(OPEN_WORKPLAN): + workplans.extend(_get_json(f"{base}/workplans/?{urlencode({'status': status})}")) + tasks = _get_json(f"{base}/tasks/") + return workplans, tasks + + +def _get_json(url: str) -> list[dict[str, Any]]: + with urllib.request.urlopen(url, timeout=60) as response: + payload = json.loads(response.read().decode()) + if isinstance(payload, list): + return payload + raise RuntimeError(f"unexpected JSON from {url}") + + +def default_hub() -> str: + return os.environ.get("STATE_HUB_URL") or os.environ.get("API_BASE") or "http://127.0.0.1:8000" diff --git a/railiance_fabric/graph_explorer.py b/railiance_fabric/graph_explorer.py index f1059af..db937c7 100644 --- a/railiance_fabric/graph_explorer.py +++ b/railiance_fabric/graph_explorer.py @@ -25,6 +25,8 @@ LAYER_ORDER = ( "dependency", "binding", "library", + "workplan", + "task", ) _KIND_LAYER = { @@ -41,6 +43,8 @@ _KIND_LAYER = { "DependencyDeclaration": "dependency", "BindingAssertion": "binding", "Library": "library", + "Workplan": "workplan", + "Task": "task", } _LAYER_COLORS = { @@ -57,6 +61,8 @@ _LAYER_COLORS = { "dependency": "#b45309", "binding": "#be123c", "library": "#0891b2", + "workplan": "#1d4ed8", + "task": "#c026d3", } _EDGE_STRENGTH = { @@ -303,6 +309,11 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: "label": "Unresolved", "description": "Highlight dependencies that have no accepted provider binding.", }, + { + "id": "coordination", + "label": "Workplan coordination", + "description": "State Hub open workplans and wait/human tasks. Not Fabric topology.", + }, ], "profile_persistence": "local", "shareable_state": { @@ -486,6 +497,12 @@ def fabric_graph_explorer_payload( for edge in source_edges: source = source_repository_node_ids.get(str(edge.get("from", "")), str(edge.get("from", ""))) target = source_repository_node_ids.get(str(edge.get("to", "")), str(edge.get("to", ""))) + for endpoint in (source, target): + if endpoint and endpoint not in node_layers: + elements.append(_unknown_node_element(endpoint)) + node_layers[endpoint] = "unknown" + node_repos[endpoint] = "" + node_kinds[endpoint] = "Unknown" edge_type = _presentation_edge_type(str(edge.get("type", "")), source, target, node_kinds) if not source or not target: continue @@ -1155,6 +1172,37 @@ def _node_description(kind: str, attributes: object) -> str: return "" +def _unknown_node_element(node_id: str) -> dict[str, Any]: + """Represent an unresolved edge endpoint without dropping the edge.""" + + return { + "data": { + "id": node_id, + "stableKey": f"unknown:{node_id}", + "kind": "Unknown", + "layer": "unknown", + "label": f"Unknown: {node_id}", + "name": node_id, + "description": "Referenced by a graph edge but not present in the accepted node set.", + "repo": "", + "domain": "", + "lifecycle": "unresolved", + "reviewState": "needs_review", + "freshnessState": "missing", + "unresolved": True, + "confidence": 0.0, + "visualSize": 38, + "ownership": "unknown", + "displayState": "show", + "visibilitySource": "default", + "visibilityReason": "unresolved edge endpoint", + "sourceReferences": [], + "deepLinks": {}, + }, + "classes": "unknown unresolved needs_review", + } + + def _source_references(node: dict[str, Any]) -> list[dict[str, str]]: attributes = node.get("attributes") references: list[dict[str, str]] = [] diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index 193257c..1735074 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -563,7 +563,10 @@ def graph_explorer_page() -> str: