diff --git a/docs/coordination-graph.md b/docs/coordination-graph.md index a974a65..8ca9c9c 100644 --- a/docs/coordination-graph.md +++ b/docs/coordination-graph.md @@ -5,18 +5,46 @@ open workplans and open tasks into the existing graph explorer so chokepoints are inspectable. ```bash -# JSON payload +# JSON payload (residuals omitted by default) railiance-fabric export --format coordination --state-hub http://127.0.0.1:8000 +# Include flavor=residual +railiance-fabric export --format coordination --include-residuals --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 +# optional: &include_residuals=true ``` -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. +## Nodes -`depends_on` frontmatter is almost unused in the fleet (two edges). The -citation edges recover the real wait graph from task text. +Open workplans (`proposed|ready|active|blocked|backlog`) and their open +tasks (`todo|progress|wait`). + +`flavor: residual` workplans and their tasks are **omitted by default**. +Unspecified flavor stays visible. The word “residual” in a title is not a +flavor. The explorer checkbox / `include_residuals` query includes them +(parity with State Hub `include_residuals`). + +Workplan nodes carry `flavor` / `nodeClass` +(`planning` | `implementation` | `refactoring` | `extension` | `residual` | +`unspecified`). + +## Edges (precedence) + +1. **Indexed `depends_on`** (`edgeSource: indexed`, `edgeType: depends_on`) + from State Hub workplan `depends_on` (and `/state/deps` when the list + endpoint does not yet carry the field). These do not require a wait note. +2. **Citation `waits_on`** (`edgeSource: citation`) when a wait/human task + cites another workplan id in its prose **and** no indexed `depends_on` + already exists for that pair. Fallback for files not yet backfilled + (`CUST-WP-0072`). Never the reverse: citations do not replace indexed + edges. +3. **`belongs_to`** task → workplan. + +Chokepoint size is in-degree of **visible** (by default: non-residual) +workplan nodes from `depends_on` and remaining `waits_on` edges. + +Solid blue = indexed `depends_on`. Dashed amber = citation `waits_on`. diff --git a/railiance_fabric/cli.py b/railiance_fabric/cli.py index 916922f..bdc1710 100644 --- a/railiance_fabric/cli.py +++ b/railiance_fabric/cli.py @@ -86,6 +86,11 @@ def build_parser() -> argparse.ArgumentParser: export.add_argument("paths", nargs="*", type=Path, default=[Path(".")]) 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") + export.add_argument( + "--include-residuals", + action="store_true", + help="Include flavor=residual workplans in --format coordination (default omits them)", + ) scan = sub.add_parser("scan", help="Scan a repo for deterministic discovery candidates.") scan.add_argument("path", nargs="?", type=Path, default=Path(".")) @@ -352,7 +357,17 @@ def main(argv: list[str] | None = None) -> int: 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)) + print( + json.dumps( + coordination_graph_payload( + workplans, + tasks, + include_residuals=args.include_residuals, + ), + indent=2, + sort_keys=True, + ) + ) return 0 graph = _load_graph_or_exit(args.paths) export_payload = _export_with_provenance(graph.to_export(), args.paths) diff --git a/railiance_fabric/coordination_graph.py b/railiance_fabric/coordination_graph.py index d9e8644..1067183 100644 --- a/railiance_fabric/coordination_graph.py +++ b/railiance_fabric/coordination_graph.py @@ -15,22 +15,76 @@ from urllib.parse import urlencode OPEN_WORKPLAN = frozenset({"proposed", "ready", "active", "blocked", "backlog"}) OPEN_TASK = frozenset({"todo", "progress", "wait"}) +RESIDUAL_FLAVOR = "residual" WP_ID_RE = re.compile(r"\b([A-Z]{2,12}(?:-[A-Z]+)?-WP-[A-Z0-9-]+)\b") +FLAVOR_COLORS = { + "planning": "#6366f1", + "implementation": "#1d4ed8", + "refactoring": "#b45309", + "extension": "#0f766e", + "residual": "#64748b", +} + + +def is_residual_flavor(value: Any) -> bool: + """True only for the closed flavor token. Titles are not consulted.""" + return str(value or "").strip().lower() == RESIDUAL_FLAVOR + + +def _flavor(record: dict[str, Any]) -> str | None: + text = str(record.get("flavor") or "").strip().lower() + return text or None + + +def _indexed_depends_targets(workplan: dict[str, Any]) -> list[str]: + """Hub-indexed workplan ids from depends_on stubs or id lists.""" + raw = workplan.get("depends_on") + if raw is None: + raw = workplan.get("depends_on_workplans") + if raw is None: + return [] + if not isinstance(raw, list): + raw = [raw] + targets: list[str] = [] + seen: set[str] = set() + for item in raw: + if isinstance(item, dict): + target = ( + item.get("workplan_id") + or item.get("workstream_id") + or item.get("id") + ) + else: + target = item + if not target: + continue + key = str(target) + if key in seen: + continue + seen.add(key) + targets.append(key) + return targets def coordination_graph_payload( workplans: list[dict[str, Any]], tasks: list[dict[str, Any]], + *, + include_residuals: bool = False, ) -> 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] + open_all = [wp for wp in workplans if str(wp.get("status") or "") in OPEN_WORKPLAN] + residual_open = sum(1 for wp in open_all if is_residual_flavor(_flavor(wp))) + if include_residuals: + open_wps = open_all + else: + open_wps = [wp for wp in open_all if not is_residual_flavor(_flavor(wp))] 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 "") @@ -45,12 +99,14 @@ def coordination_graph_payload( 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 + and (include_residuals or not is_residual_flavor(_flavor(task))) ] elements: list[dict[str, Any]] = [] for wp in open_wps: wp_id = str(wp["id"]) status = str(wp.get("status") or "unknown") + flavor = _flavor(wp) elements.append( { "data": { @@ -62,10 +118,13 @@ def coordination_graph_payload( "name": wp.get("title"), "lifecycle": status, "status": status, + "flavor": flavor, + "nodeClass": flavor or "unspecified", "repo": wp.get("repo") or wp.get("slug"), "displayState": "show" if status != "blocked" else "highlight", "unresolved": status in {"blocked", "proposed"}, "chokepoint": 0, + "color": FLAVOR_COLORS.get(flavor or "", "#1d4ed8"), } } ) @@ -77,6 +136,7 @@ def coordination_graph_payload( 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")) + flavor = _flavor(task) or _flavor(wp_by_id.get(wp_id) or {}) elements.append( { "data": { @@ -88,6 +148,8 @@ def coordination_graph_payload( "name": task.get("title"), "lifecycle": status, "status": status, + "flavor": flavor, + "nodeClass": flavor or "unspecified", "needsHuman": human, "displayState": "highlight" if status == "wait" or human else "show", "unresolved": status == "wait" or human, @@ -105,6 +167,7 @@ def coordination_graph_payload( "source": f"task:{task_id}", "target": f"workplan:{wp_id}", "edgeType": "belongs_to", + "edgeSource": "membership", "strength": "weak", "sourceLayer": "task", "targetLayer": "workplan", @@ -115,7 +178,46 @@ def coordination_graph_payload( if status == "wait" or human: wait_edges += 1 - # Cross-workplan citations in wait/blocking text. + indexed_pairs: set[tuple[str, str]] = set() + depends_count = 0 + chokepoint: dict[str, int] = {str(wp["id"]): 0 for wp in open_wps} + + for wp in open_wps: + src = str(wp["id"]) + for dst_raw in _indexed_depends_targets(wp): + dst = ( + dst_raw + if dst_raw in wp_by_id + else record_to_id.get(dst_raw.upper()) + or slug_to_id.get(dst_raw.lower()) + ) + if not dst or dst == src or dst not in wp_by_id: + continue + pair = (src, dst) + if pair in indexed_pairs: + continue + indexed_pairs.add(pair) + elements.append( + { + "data": { + "id": f"edge:depends:{src}:{dst}", + "stableKey": f"edge:depends:{src}:{dst}", + "kind": "Edge", + "layer": "dependency", + "displayState": "show", + "source": f"workplan:{src}", + "target": f"workplan:{dst}", + "edgeType": "depends_on", + "edgeSource": "indexed", + "strength": "strong", + "sourceLayer": "workplan", + "targetLayer": "workplan", + } + } + ) + depends_count += 1 + chokepoint[dst] = chokepoint.get(dst, 0) + 1 + cited = 0 for task in open_tasks: blob = " ".join( @@ -125,28 +227,38 @@ def coordination_graph_payload( 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", - } + if not dst or dst == src_wp or dst not in wp_by_id: + continue + if (src_wp, dst) in indexed_pairs: + continue + 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", + "edgeSource": "citation", + "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 + } + ) + cited += 1 + chokepoint[dst] = chokepoint.get(dst, 0) + 1 + indexed_pairs.add((src_wp, dst)) + + for el in elements: + node_id = el["data"]["id"] + if not node_id.startswith("workplan:"): + continue + wp_id = node_id.split(":", 1)[1] + el["data"]["chokepoint"] = int(chokepoint.get(wp_id) or 0) generated = datetime.now(timezone.utc).isoformat() return { @@ -159,8 +271,11 @@ def coordination_graph_payload( "open_workplans": len(open_wps), "open_tasks": len(open_tasks), "belongs_to_edges": belongs, + "depends_on_edges": depends_count, "citation_edges": cited, "wait_or_human_tasks": wait_edges, + "residual_open_workplans": residual_open, + "include_residuals": include_residuals, }, "elements": elements, "hidden_elements": [], @@ -173,6 +288,20 @@ def fetch_hub_lists(api_base: str) -> tuple[list[dict[str, Any]], list[dict[str, for status in sorted(OPEN_WORKPLAN): workplans.extend(_get_json(f"{base}/workplans/?{urlencode({'status': status})}")) tasks = _get_json(f"{base}/tasks/") + dep_rows = _get_json_optional(f"{base}/state/deps?include_residuals=true") + by_id = { + str(row.get("id")): row + for row in dep_rows + if isinstance(row, dict) and row.get("id") + } + for wp in workplans: + extra = by_id.get(str(wp.get("id"))) + if not extra: + continue + if extra.get("depends_on") and not wp.get("depends_on"): + wp["depends_on"] = extra["depends_on"] + if extra.get("flavor") and not wp.get("flavor"): + wp["flavor"] = extra["flavor"] return workplans, tasks @@ -184,5 +313,12 @@ def _get_json(url: str) -> list[dict[str, Any]]: raise RuntimeError(f"unexpected JSON from {url}") +def _get_json_optional(url: str) -> list[dict[str, Any]]: + try: + return _get_json(url) + except Exception: + return [] + + 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 db937c7..c3e4865 100644 --- a/railiance_fabric/graph_explorer.py +++ b/railiance_fabric/graph_explorer.py @@ -199,6 +199,7 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: {"id": "routeHost", "label": "Route Host", "type": "string"}, {"id": "routePort", "label": "Route Port", "type": "number"}, {"id": "lifecycle", "label": "Lifecycle", "type": "string"}, + {"id": "flavor", "label": "Flavor", "type": "string"}, {"id": "reviewState", "label": "Review State", "type": "string"}, {"id": "unresolved", "label": "Unresolved", "type": "boolean"}, {"id": "edgeType", "label": "Edge Type", "type": "string"}, @@ -227,6 +228,7 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: "repo", "domain", "lifecycle", + "flavor", "deploymentEnvironment", "deploymentScenario", "routingAuthority", @@ -244,6 +246,7 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: ], "edge_fields": [ "edgeType", + "edgeSource", "canonicalType", "displayOnly", "evidenceState", @@ -312,7 +315,7 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: { "id": "coordination", "label": "Workplan coordination", - "description": "State Hub open workplans and wait/human tasks. Not Fabric topology.", + "description": "State Hub open workplans (residuals omitted unless include_residuals). Indexed depends_on first, citation waits_on as fallback. Not Fabric topology.", }, ], "profile_persistence": "local", diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index 1735074..4f23018 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -405,6 +405,10 @@ def graph_explorer_page() -> str: Mode +