Add a State Hub workplan/wait graph as a Fabric explorer mode.
Coordination is a view, not topology. Open workplans and wait tasks project into graph-explorer; citation edges recover depends_on from task prose. UI: /ui/graph-explorer?mode=coordination. Assistant: grok Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
This commit is contained in:
parent
18fabb37ce
commit
b7724dedea
8 changed files with 366 additions and 2 deletions
22
docs/coordination-graph.md
Normal file
22
docs/coordination-graph.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -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 = 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("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 = sub.add_parser("scan", help="Scan a repo for deterministic discovery candidates.")
|
||||||
scan.add_argument("path", nargs="?", type=Path, default=Path("."))
|
scan.add_argument("path", nargs="?", type=Path, default=Path("."))
|
||||||
|
|
@ -347,6 +348,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.command == "export":
|
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)
|
graph = _load_graph_or_exit(args.paths)
|
||||||
export_payload = _export_with_provenance(graph.to_export(), args.paths)
|
export_payload = _export_with_provenance(graph.to_export(), args.paths)
|
||||||
if args.format == "mermaid":
|
if args.format == "mermaid":
|
||||||
|
|
|
||||||
188
railiance_fabric/coordination_graph.py
Normal file
188
railiance_fabric/coordination_graph.py
Normal file
|
|
@ -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"
|
||||||
|
|
@ -25,6 +25,8 @@ LAYER_ORDER = (
|
||||||
"dependency",
|
"dependency",
|
||||||
"binding",
|
"binding",
|
||||||
"library",
|
"library",
|
||||||
|
"workplan",
|
||||||
|
"task",
|
||||||
)
|
)
|
||||||
|
|
||||||
_KIND_LAYER = {
|
_KIND_LAYER = {
|
||||||
|
|
@ -41,6 +43,8 @@ _KIND_LAYER = {
|
||||||
"DependencyDeclaration": "dependency",
|
"DependencyDeclaration": "dependency",
|
||||||
"BindingAssertion": "binding",
|
"BindingAssertion": "binding",
|
||||||
"Library": "library",
|
"Library": "library",
|
||||||
|
"Workplan": "workplan",
|
||||||
|
"Task": "task",
|
||||||
}
|
}
|
||||||
|
|
||||||
_LAYER_COLORS = {
|
_LAYER_COLORS = {
|
||||||
|
|
@ -57,6 +61,8 @@ _LAYER_COLORS = {
|
||||||
"dependency": "#b45309",
|
"dependency": "#b45309",
|
||||||
"binding": "#be123c",
|
"binding": "#be123c",
|
||||||
"library": "#0891b2",
|
"library": "#0891b2",
|
||||||
|
"workplan": "#1d4ed8",
|
||||||
|
"task": "#c026d3",
|
||||||
}
|
}
|
||||||
|
|
||||||
_EDGE_STRENGTH = {
|
_EDGE_STRENGTH = {
|
||||||
|
|
@ -303,6 +309,11 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]:
|
||||||
"label": "Unresolved",
|
"label": "Unresolved",
|
||||||
"description": "Highlight dependencies that have no accepted provider binding.",
|
"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",
|
"profile_persistence": "local",
|
||||||
"shareable_state": {
|
"shareable_state": {
|
||||||
|
|
@ -486,6 +497,12 @@ def fabric_graph_explorer_payload(
|
||||||
for edge in source_edges:
|
for edge in source_edges:
|
||||||
source = source_repository_node_ids.get(str(edge.get("from", "")), str(edge.get("from", "")))
|
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", "")))
|
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)
|
edge_type = _presentation_edge_type(str(edge.get("type", "")), source, target, node_kinds)
|
||||||
if not source or not target:
|
if not source or not target:
|
||||||
continue
|
continue
|
||||||
|
|
@ -1155,6 +1172,37 @@ def _node_description(kind: str, attributes: object) -> str:
|
||||||
return ""
|
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]]:
|
def _source_references(node: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
attributes = node.get("attributes")
|
attributes = node.get("attributes")
|
||||||
references: list[dict[str, str]] = []
|
references: list[dict[str, str]] = []
|
||||||
|
|
|
||||||
|
|
@ -563,7 +563,10 @@ def graph_explorer_page() -> str:
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const manifestUrl = "/exports/graph-explorer/manifest";
|
const manifestUrl = "/exports/graph-explorer/manifest";
|
||||||
const graphUrl = "/exports/graph-explorer";
|
const graphMode = new URLSearchParams(window.location.search).get("mode");
|
||||||
|
const graphUrl = graphMode === "coordination"
|
||||||
|
? "/exports/graph-explorer?mode=coordination"
|
||||||
|
: "/exports/graph-explorer";
|
||||||
const canvas = document.getElementById("graph-canvas");
|
const canvas = document.getElementById("graph-canvas");
|
||||||
const zoneOverlay = document.getElementById("zone-overlay");
|
const zoneOverlay = document.getElementById("zone-overlay");
|
||||||
const popup = document.getElementById("popup");
|
const popup = document.getElementById("popup");
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from .coordination_graph import coordination_graph_payload, default_hub, fetch_hub_lists
|
||||||
from .graph_explorer import fabric_graph_explorer_manifest, fabric_graph_explorer_payload
|
from .graph_explorer import fabric_graph_explorer_manifest, fabric_graph_explorer_payload
|
||||||
from .graph_explorer_ui import graph_explorer_page
|
from .graph_explorer_ui import graph_explorer_page
|
||||||
from .registry import (
|
from .registry import (
|
||||||
|
|
@ -136,6 +137,9 @@ class RegistryHandler(BaseHTTPRequestHandler):
|
||||||
if parts == ["exports", "xregistry"]:
|
if parts == ["exports", "xregistry"]:
|
||||||
return HTTPStatus.OK, xregistry_projection(self.store.combined_graph())
|
return HTTPStatus.OK, xregistry_projection(self.store.combined_graph())
|
||||||
if parts == ["exports", "graph-explorer"]:
|
if parts == ["exports", "graph-explorer"]:
|
||||||
|
if _query_optional(query, "mode") == "coordination":
|
||||||
|
workplans, tasks = fetch_hub_lists(default_hub())
|
||||||
|
return HTTPStatus.OK, coordination_graph_payload(workplans, tasks)
|
||||||
return HTTPStatus.OK, fabric_graph_explorer_payload(
|
return HTTPStatus.OK, fabric_graph_explorer_payload(
|
||||||
self.store.combined_graph(),
|
self.store.combined_graph(),
|
||||||
self.store.list_repositories(),
|
self.store.list_repositories(),
|
||||||
|
|
|
||||||
58
tests/test_coordination_graph.py
Normal file
58
tests/test_coordination_graph.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from railiance_fabric.coordination_graph import coordination_graph_payload
|
||||||
|
from railiance_fabric.graph_explorer import fabric_graph_explorer_manifest
|
||||||
|
from railiance_fabric.schema_validation import draft202012_validator
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_schema(name: str, document: dict) -> None:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
schema_path = Path("schemas") / name
|
||||||
|
validator = draft202012_validator(schema_path)
|
||||||
|
validator.validate(document)
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordination_payload_filters_open_records_and_cites_wait() -> None:
|
||||||
|
workplans = [
|
||||||
|
{"id": "wp-a", "slug": "cust-wp-0071", "title": "Sizing", "status": "active"},
|
||||||
|
{"id": "wp-b", "slug": "rapps-wp-0014", "title": "Pilot", "status": "active"},
|
||||||
|
{"id": "wp-c", "slug": "done-wp", "title": "Finished", "status": "finished"},
|
||||||
|
]
|
||||||
|
tasks = [
|
||||||
|
{
|
||||||
|
"id": "t1",
|
||||||
|
"workplan_id": "wp-a",
|
||||||
|
"title": "Measure after RAPPS-WP-0014",
|
||||||
|
"status": "wait",
|
||||||
|
"description": "Await RAPPS-WP-0014 bind",
|
||||||
|
"needs_human": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t2",
|
||||||
|
"workplan_id": "wp-a",
|
||||||
|
"title": "Closed",
|
||||||
|
"status": "done",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t3",
|
||||||
|
"workplan_id": "wp-c",
|
||||||
|
"title": "orphan open on finished wp",
|
||||||
|
"status": "todo",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
payload = coordination_graph_payload(workplans, tasks)
|
||||||
|
_validate_schema("graph-explorer-payload.schema.yaml", payload)
|
||||||
|
ids = {el["data"]["id"] for el in payload["elements"]}
|
||||||
|
assert "workplan:wp-a" in ids
|
||||||
|
assert "workplan:wp-b" in ids
|
||||||
|
assert "workplan:wp-c" not in ids
|
||||||
|
assert "task:t1" in ids
|
||||||
|
assert "task:t2" not in ids
|
||||||
|
assert "task:t3" not in ids
|
||||||
|
waits = [el for el in payload["elements"] if el["data"].get("edgeType") == "waits_on"]
|
||||||
|
assert waits
|
||||||
|
assert waits[0]["data"]["target"] == "workplan:wp-b"
|
||||||
|
assert payload["metrics"]["open_workplans"] == 2
|
||||||
|
assert "coordination" in {mode["id"] for mode in fabric_graph_explorer_manifest()["modes"]}
|
||||||
|
assert payload["metrics"]["wait_or_human_tasks"] == 1
|
||||||
34
workplans/RAIL-FAB-WP-0029-coordination-graph-perspective.md
Normal file
34
workplans/RAIL-FAB-WP-0029-coordination-graph-perspective.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
id: RAIL-FAB-WP-0029
|
||||||
|
type: workplan
|
||||||
|
title: "Inspectable workplan and wait graph in the Fabric explorer"
|
||||||
|
domain: financials
|
||||||
|
repo: railiance-fabric
|
||||||
|
status: proposed
|
||||||
|
owner: grok
|
||||||
|
topic_slug: railiance
|
||||||
|
created: "2026-09-14"
|
||||||
|
updated: "2026-09-14"
|
||||||
|
related: [RAIL-FAB-WP-0028, CUST-WP-0071]
|
||||||
|
---
|
||||||
|
|
||||||
|
State Hub remains the workplan authoring surface. Fabric already has a
|
||||||
|
graph-explorer shell. This plan is a *perspective*: open workplans, open
|
||||||
|
tasks, and wait/depends_on edges, so the 100+ loose ends are inspectable.
|
||||||
|
|
||||||
|
First slice (this file's companion commit):
|
||||||
|
`export --format coordination` and
|
||||||
|
`/ui/graph-explorer?mode=coordination`.
|
||||||
|
|
||||||
|
## Host the coordination overlay next to Fabric topology
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: RAIL-FAB-WP-0029-T01
|
||||||
|
status: todo
|
||||||
|
priority: medium
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the projection a read of State Hub. Add mode switching without a full
|
||||||
|
page reload, chokepoint sizing from in-degree, and a hosted registry path
|
||||||
|
under RAIL-FAB-WP-0028 when that authority exists. Do not let Fabric become
|
||||||
|
the place workplans are edited.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue