Adapt the coordination graph to flavor and indexed depends_on.
Omit flavor=residual by default, draw hub depends_on first, and keep citation waits_on as a tagged fallback. Explorer checkbox and CLI --include-residuals match State Hub include_residuals. Assistant: grok Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
This commit is contained in:
parent
0b0abedbe8
commit
725e440b1e
8 changed files with 410 additions and 39 deletions
|
|
@ -5,18 +5,46 @@ open workplans and open tasks into the existing graph explorer so chokepoints
|
||||||
are inspectable.
|
are inspectable.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# JSON payload
|
# JSON payload (residuals omitted by default)
|
||||||
railiance-fabric export --format coordination --state-hub http://127.0.0.1:8000
|
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)
|
# UI (local registry server)
|
||||||
make graph-explorer
|
make graph-explorer
|
||||||
# then open
|
# then open
|
||||||
# http://127.0.0.1:8765/ui/graph-explorer?mode=coordination
|
# 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
|
## Nodes
|
||||||
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
|
Open workplans (`proposed|ready|active|blocked|backlog`) and their open
|
||||||
citation edges recover the real wait graph from task text.
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
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", "coordination"], 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")
|
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 = 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("."))
|
||||||
|
|
@ -352,7 +357,17 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
from .coordination_graph import coordination_graph_payload, default_hub, fetch_hub_lists
|
from .coordination_graph import coordination_graph_payload, default_hub, fetch_hub_lists
|
||||||
|
|
||||||
workplans, tasks = fetch_hub_lists(args.state_hub or default_hub())
|
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
|
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)
|
||||||
|
|
|
||||||
|
|
@ -15,22 +15,76 @@ from urllib.parse import urlencode
|
||||||
|
|
||||||
OPEN_WORKPLAN = frozenset({"proposed", "ready", "active", "blocked", "backlog"})
|
OPEN_WORKPLAN = frozenset({"proposed", "ready", "active", "blocked", "backlog"})
|
||||||
OPEN_TASK = frozenset({"todo", "progress", "wait"})
|
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")
|
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(
|
def coordination_graph_payload(
|
||||||
workplans: list[dict[str, Any]],
|
workplans: list[dict[str, Any]],
|
||||||
tasks: list[dict[str, Any]],
|
tasks: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
include_residuals: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build a GraphExplorerPayload of open workplans and their open tasks."""
|
"""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")}
|
wp_by_id = {str(wp.get("id")): wp for wp in open_wps if wp.get("id")}
|
||||||
slug_to_id = {
|
slug_to_id = {
|
||||||
str(wp.get("slug") or "").lower(): str(wp["id"])
|
str(wp.get("slug") or "").lower(): str(wp["id"])
|
||||||
for wp in open_wps
|
for wp in open_wps
|
||||||
if wp.get("slug") and wp.get("id")
|
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] = {}
|
record_to_id: dict[str, str] = {}
|
||||||
for wp in open_wps:
|
for wp in open_wps:
|
||||||
slug = str(wp.get("slug") or "")
|
slug = str(wp.get("slug") or "")
|
||||||
|
|
@ -45,12 +99,14 @@ def coordination_graph_payload(
|
||||||
for task in tasks
|
for task in tasks
|
||||||
if str(task.get("status") or "") in OPEN_TASK
|
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 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]] = []
|
elements: list[dict[str, Any]] = []
|
||||||
for wp in open_wps:
|
for wp in open_wps:
|
||||||
wp_id = str(wp["id"])
|
wp_id = str(wp["id"])
|
||||||
status = str(wp.get("status") or "unknown")
|
status = str(wp.get("status") or "unknown")
|
||||||
|
flavor = _flavor(wp)
|
||||||
elements.append(
|
elements.append(
|
||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
|
|
@ -62,10 +118,13 @@ def coordination_graph_payload(
|
||||||
"name": wp.get("title"),
|
"name": wp.get("title"),
|
||||||
"lifecycle": status,
|
"lifecycle": status,
|
||||||
"status": status,
|
"status": status,
|
||||||
|
"flavor": flavor,
|
||||||
|
"nodeClass": flavor or "unspecified",
|
||||||
"repo": wp.get("repo") or wp.get("slug"),
|
"repo": wp.get("repo") or wp.get("slug"),
|
||||||
"displayState": "show" if status != "blocked" else "highlight",
|
"displayState": "show" if status != "blocked" else "highlight",
|
||||||
"unresolved": status in {"blocked", "proposed"},
|
"unresolved": status in {"blocked", "proposed"},
|
||||||
"chokepoint": 0,
|
"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"))
|
wp_id = str(task.get("workplan_id") or task.get("workstream_id"))
|
||||||
status = str(task.get("status") or "todo")
|
status = str(task.get("status") or "todo")
|
||||||
human = bool(task.get("needs_human"))
|
human = bool(task.get("needs_human"))
|
||||||
|
flavor = _flavor(task) or _flavor(wp_by_id.get(wp_id) or {})
|
||||||
elements.append(
|
elements.append(
|
||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
|
|
@ -88,6 +148,8 @@ def coordination_graph_payload(
|
||||||
"name": task.get("title"),
|
"name": task.get("title"),
|
||||||
"lifecycle": status,
|
"lifecycle": status,
|
||||||
"status": status,
|
"status": status,
|
||||||
|
"flavor": flavor,
|
||||||
|
"nodeClass": flavor or "unspecified",
|
||||||
"needsHuman": human,
|
"needsHuman": human,
|
||||||
"displayState": "highlight" if status == "wait" or human else "show",
|
"displayState": "highlight" if status == "wait" or human else "show",
|
||||||
"unresolved": status == "wait" or human,
|
"unresolved": status == "wait" or human,
|
||||||
|
|
@ -105,6 +167,7 @@ def coordination_graph_payload(
|
||||||
"source": f"task:{task_id}",
|
"source": f"task:{task_id}",
|
||||||
"target": f"workplan:{wp_id}",
|
"target": f"workplan:{wp_id}",
|
||||||
"edgeType": "belongs_to",
|
"edgeType": "belongs_to",
|
||||||
|
"edgeSource": "membership",
|
||||||
"strength": "weak",
|
"strength": "weak",
|
||||||
"sourceLayer": "task",
|
"sourceLayer": "task",
|
||||||
"targetLayer": "workplan",
|
"targetLayer": "workplan",
|
||||||
|
|
@ -115,7 +178,46 @@ def coordination_graph_payload(
|
||||||
if status == "wait" or human:
|
if status == "wait" or human:
|
||||||
wait_edges += 1
|
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
|
cited = 0
|
||||||
for task in open_tasks:
|
for task in open_tasks:
|
||||||
blob = " ".join(
|
blob = " ".join(
|
||||||
|
|
@ -125,28 +227,38 @@ def coordination_graph_payload(
|
||||||
src_wp = str(task.get("workplan_id") or task.get("workstream_id"))
|
src_wp = str(task.get("workplan_id") or task.get("workstream_id"))
|
||||||
for token in set(WP_ID_RE.findall(blob.upper())):
|
for token in set(WP_ID_RE.findall(blob.upper())):
|
||||||
dst = record_to_id.get(token) or slug_to_id.get(token.lower())
|
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:
|
if not dst or dst == src_wp or dst not in wp_by_id:
|
||||||
elements.append(
|
continue
|
||||||
{
|
if (src_wp, dst) in indexed_pairs:
|
||||||
"data": {
|
continue
|
||||||
"id": f"edge:cites:{task['id']}:{dst}",
|
elements.append(
|
||||||
"stableKey": f"edge:cites:{task['id']}:{dst}",
|
{
|
||||||
"kind": "Edge",
|
"data": {
|
||||||
"layer": "dependency",
|
"id": f"edge:cites:{task['id']}:{dst}",
|
||||||
"displayState": "show",
|
"stableKey": f"edge:cites:{task['id']}:{dst}",
|
||||||
"source": f"workplan:{src_wp}",
|
"kind": "Edge",
|
||||||
"target": f"workplan:{dst}",
|
"layer": "dependency",
|
||||||
"edgeType": "waits_on",
|
"displayState": "show",
|
||||||
"strength": "strong",
|
"source": f"workplan:{src_wp}",
|
||||||
"sourceLayer": "workplan",
|
"target": f"workplan:{dst}",
|
||||||
"targetLayer": "workplan",
|
"edgeType": "waits_on",
|
||||||
}
|
"edgeSource": "citation",
|
||||||
|
"strength": "strong",
|
||||||
|
"sourceLayer": "workplan",
|
||||||
|
"targetLayer": "workplan",
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
cited += 1
|
)
|
||||||
for el in elements:
|
cited += 1
|
||||||
if el["data"]["id"] == f"workplan:{dst}":
|
chokepoint[dst] = chokepoint.get(dst, 0) + 1
|
||||||
el["data"]["chokepoint"] = int(el["data"].get("chokepoint") or 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()
|
generated = datetime.now(timezone.utc).isoformat()
|
||||||
return {
|
return {
|
||||||
|
|
@ -159,8 +271,11 @@ def coordination_graph_payload(
|
||||||
"open_workplans": len(open_wps),
|
"open_workplans": len(open_wps),
|
||||||
"open_tasks": len(open_tasks),
|
"open_tasks": len(open_tasks),
|
||||||
"belongs_to_edges": belongs,
|
"belongs_to_edges": belongs,
|
||||||
|
"depends_on_edges": depends_count,
|
||||||
"citation_edges": cited,
|
"citation_edges": cited,
|
||||||
"wait_or_human_tasks": wait_edges,
|
"wait_or_human_tasks": wait_edges,
|
||||||
|
"residual_open_workplans": residual_open,
|
||||||
|
"include_residuals": include_residuals,
|
||||||
},
|
},
|
||||||
"elements": elements,
|
"elements": elements,
|
||||||
"hidden_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):
|
for status in sorted(OPEN_WORKPLAN):
|
||||||
workplans.extend(_get_json(f"{base}/workplans/?{urlencode({'status': status})}"))
|
workplans.extend(_get_json(f"{base}/workplans/?{urlencode({'status': status})}"))
|
||||||
tasks = _get_json(f"{base}/tasks/")
|
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
|
return workplans, tasks
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -184,5 +313,12 @@ def _get_json(url: str) -> list[dict[str, Any]]:
|
||||||
raise RuntimeError(f"unexpected JSON from {url}")
|
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:
|
def default_hub() -> str:
|
||||||
return os.environ.get("STATE_HUB_URL") or os.environ.get("API_BASE") or "http://127.0.0.1:8000"
|
return os.environ.get("STATE_HUB_URL") or os.environ.get("API_BASE") or "http://127.0.0.1:8000"
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,7 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]:
|
||||||
{"id": "routeHost", "label": "Route Host", "type": "string"},
|
{"id": "routeHost", "label": "Route Host", "type": "string"},
|
||||||
{"id": "routePort", "label": "Route Port", "type": "number"},
|
{"id": "routePort", "label": "Route Port", "type": "number"},
|
||||||
{"id": "lifecycle", "label": "Lifecycle", "type": "string"},
|
{"id": "lifecycle", "label": "Lifecycle", "type": "string"},
|
||||||
|
{"id": "flavor", "label": "Flavor", "type": "string"},
|
||||||
{"id": "reviewState", "label": "Review State", "type": "string"},
|
{"id": "reviewState", "label": "Review State", "type": "string"},
|
||||||
{"id": "unresolved", "label": "Unresolved", "type": "boolean"},
|
{"id": "unresolved", "label": "Unresolved", "type": "boolean"},
|
||||||
{"id": "edgeType", "label": "Edge Type", "type": "string"},
|
{"id": "edgeType", "label": "Edge Type", "type": "string"},
|
||||||
|
|
@ -227,6 +228,7 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]:
|
||||||
"repo",
|
"repo",
|
||||||
"domain",
|
"domain",
|
||||||
"lifecycle",
|
"lifecycle",
|
||||||
|
"flavor",
|
||||||
"deploymentEnvironment",
|
"deploymentEnvironment",
|
||||||
"deploymentScenario",
|
"deploymentScenario",
|
||||||
"routingAuthority",
|
"routingAuthority",
|
||||||
|
|
@ -244,6 +246,7 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]:
|
||||||
],
|
],
|
||||||
"edge_fields": [
|
"edge_fields": [
|
||||||
"edgeType",
|
"edgeType",
|
||||||
|
"edgeSource",
|
||||||
"canonicalType",
|
"canonicalType",
|
||||||
"displayOnly",
|
"displayOnly",
|
||||||
"evidenceState",
|
"evidenceState",
|
||||||
|
|
@ -312,7 +315,7 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]:
|
||||||
{
|
{
|
||||||
"id": "coordination",
|
"id": "coordination",
|
||||||
"label": "Workplan 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",
|
"profile_persistence": "local",
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,10 @@ def graph_explorer_page() -> str:
|
||||||
<span class="field-label">Mode <button type="button" class="help-tip" aria-label="Mode help" data-help-title="Mode" data-help="Modes are predefined map views. Some modes use the selected item as context; changing modes changes which graph entities are visible, but keeps the layout controls separate.">?</button></span>
|
<span class="field-label">Mode <button type="button" class="help-tip" aria-label="Mode help" data-help-title="Mode" data-help="Modes are predefined map views. Some modes use the selected item as context; changing modes changes which graph entities are visible, but keeps the layout controls separate.">?</button></span>
|
||||||
<select id="mode-select" aria-label="Graph mode"></select>
|
<select id="mode-select" aria-label="Graph mode"></select>
|
||||||
</div>
|
</div>
|
||||||
|
<label id="include-residuals-wrap" class="field" style="display:none;align-self:end">
|
||||||
|
<span class="field-label">Residuals <button type="button" class="help-tip" aria-label="Residuals help" data-help-title="Include residuals" data-help="Default coordination view omits flavor=residual workplans. Enable this to show them. Unspecified flavor stays visible. Titles containing the word residual are not treated as residual.">?</button></span>
|
||||||
|
<input type="checkbox" id="include-residuals" aria-label="Include residual workplans">
|
||||||
|
</label>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">Layout <button type="button" class="help-tip" aria-label="Layout help" data-help-title="Layout" data-help="Layout redraws the map arrangement. Cose uses relationship strength and repo affinity; circle, grid, concentric, and breadthfirst are simpler alternate arrangements.">?</button></span>
|
<span class="field-label">Layout <button type="button" class="help-tip" aria-label="Layout help" data-help-title="Layout" data-help="Layout redraws the map arrangement. Cose uses relationship strength and repo affinity; circle, grid, concentric, and breadthfirst are simpler alternate arrangements.">?</button></span>
|
||||||
<select id="layout-select" aria-label="Graph layout">
|
<select id="layout-select" aria-label="Graph layout">
|
||||||
|
|
@ -563,9 +567,13 @@ def graph_explorer_page() -> str:
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const manifestUrl = "/exports/graph-explorer/manifest";
|
const manifestUrl = "/exports/graph-explorer/manifest";
|
||||||
const graphMode = new URLSearchParams(window.location.search).get("mode");
|
const pageParams = new URLSearchParams(window.location.search);
|
||||||
|
const graphMode = pageParams.get("mode");
|
||||||
|
const includeResidualsParam = ["1", "true", "yes"].includes(
|
||||||
|
(pageParams.get("include_residuals") || "").toLowerCase()
|
||||||
|
);
|
||||||
const graphUrl = graphMode === "coordination"
|
const graphUrl = graphMode === "coordination"
|
||||||
? "/exports/graph-explorer?mode=coordination"
|
? `/exports/graph-explorer?mode=coordination${includeResidualsParam ? "&include_residuals=true" : ""}`
|
||||||
: "/exports/graph-explorer";
|
: "/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");
|
||||||
|
|
@ -2779,6 +2787,8 @@ def graph_explorer_page() -> str:
|
||||||
'<span class="pill"><span style="display:inline-block;width:12px;height:12px;border:2px dashed #b45309;border-radius:50%"></span>Unresolved</span>',
|
'<span class="pill"><span style="display:inline-block;width:12px;height:12px;border:2px dashed #b45309;border-radius:50%"></span>Unresolved</span>',
|
||||||
'<span class="pill"><span style="display:inline-block;width:12px;height:12px;border:2px dashed #be123c;border-radius:3px"></span>Registered only</span>',
|
'<span class="pill"><span style="display:inline-block;width:12px;height:12px;border:2px dashed #be123c;border-radius:3px"></span>Registered only</span>',
|
||||||
'<span class="pill"><span style="display:inline-block;width:18px;border-top:2px dotted #98a2b3"></span>Weak edge</span>',
|
'<span class="pill"><span style="display:inline-block;width:18px;border-top:2px dotted #98a2b3"></span>Weak edge</span>',
|
||||||
|
'<span class="pill"><span style="display:inline-block;width:18px;border-top:3px solid #1d4ed8"></span>depends_on (indexed)</span>',
|
||||||
|
'<span class="pill"><span style="display:inline-block;width:18px;border-top:2px dashed #b45309"></span>waits_on (citation)</span>',
|
||||||
];
|
];
|
||||||
legend.innerHTML = [...nodeTypes, ...statusItems].join("");
|
legend.innerHTML = [...nodeTypes, ...statusItems].join("");
|
||||||
};
|
};
|
||||||
|
|
@ -2794,6 +2804,10 @@ def graph_explorer_page() -> str:
|
||||||
option.textContent = mode.label;
|
option.textContent = mode.label;
|
||||||
modeSelect.appendChild(option);
|
modeSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
|
if (graphMode === "coordination" && optionExists(modeSelect, "coordination")) {
|
||||||
|
modeSelect.value = "coordination";
|
||||||
|
activeMode = "coordination";
|
||||||
|
}
|
||||||
profilePersistence = manifest.profile_persistence || "none";
|
profilePersistence = manifest.profile_persistence || "none";
|
||||||
layerColors = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.color]));
|
layerColors = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.color]));
|
||||||
nodeTypeLabels = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.label]));
|
nodeTypeLabels = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.label]));
|
||||||
|
|
@ -2807,6 +2821,20 @@ def graph_explorer_page() -> str:
|
||||||
renderChecklist(edgeTypeFilter, allEdgeTypes, {}, "edge-type");
|
renderChecklist(edgeTypeFilter, allEdgeTypes, {}, "edge-type");
|
||||||
syncFilterSummaries();
|
syncFilterSummaries();
|
||||||
renderLegend(manifest.layers || []);
|
renderLegend(manifest.layers || []);
|
||||||
|
const residualsWrap = document.getElementById("include-residuals-wrap");
|
||||||
|
const residualsToggle = document.getElementById("include-residuals");
|
||||||
|
if (residualsWrap && residualsToggle) {
|
||||||
|
const coordinationActive = graphMode === "coordination";
|
||||||
|
residualsWrap.style.display = coordinationActive ? "" : "none";
|
||||||
|
residualsToggle.checked = includeResidualsParam;
|
||||||
|
residualsToggle.addEventListener("change", () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("mode", "coordination");
|
||||||
|
if (residualsToggle.checked) url.searchParams.set("include_residuals", "true");
|
||||||
|
else url.searchParams.delete("include_residuals");
|
||||||
|
window.location.search = url.search;
|
||||||
|
});
|
||||||
|
}
|
||||||
profiles = loadProfiles();
|
profiles = loadProfiles();
|
||||||
renderProfiles();
|
renderProfiles();
|
||||||
const elements = (payload.elements || []).map((element) => ({
|
const elements = (payload.elements || []).map((element) => ({
|
||||||
|
|
@ -2873,6 +2901,13 @@ def graph_explorer_page() -> str:
|
||||||
}},
|
}},
|
||||||
{selector: "edge[strength = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
|
{selector: "edge[strength = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
|
||||||
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}},
|
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}},
|
||||||
|
{selector: "edge[edgeType = 'depends_on']", style: {"line-color": "#1d4ed8", "target-arrow-color": "#1d4ed8", "width": 3}},
|
||||||
|
{selector: "edge[edgeType = 'waits_on']", style: {"line-color": "#b45309", "target-arrow-color": "#b45309", "line-style": "dashed", "width": 2}},
|
||||||
|
{selector: "node[flavor = 'planning']", style: {"background-color": "#6366f1"}},
|
||||||
|
{selector: "node[flavor = 'implementation']", style: {"background-color": "#1d4ed8"}},
|
||||||
|
{selector: "node[flavor = 'refactoring']", style: {"background-color": "#b45309"}},
|
||||||
|
{selector: "node[flavor = 'extension']", style: {"background-color": "#0f766e"}},
|
||||||
|
{selector: "node[flavor = 'residual']", style: {"background-color": "#64748b"}},
|
||||||
{selector: "edge[zoneCollapse = true]", style: {"line-style": "dashed", "line-color": "#0f766e", "target-arrow-color": "#0f766e"}},
|
{selector: "edge[zoneCollapse = true]", style: {"line-style": "dashed", "line-color": "#0f766e", "target-arrow-color": "#0f766e"}},
|
||||||
{selector: "node.rule-highlight", style: {
|
{selector: "node.rule-highlight", style: {
|
||||||
"border-color": "#2563eb",
|
"border-color": "#2563eb",
|
||||||
|
|
@ -2957,6 +2992,19 @@ def graph_explorer_page() -> str:
|
||||||
nodeTypeFilter.addEventListener("change", handleFilterChange);
|
nodeTypeFilter.addEventListener("change", handleFilterChange);
|
||||||
edgeTypeFilter.addEventListener("change", handleFilterChange);
|
edgeTypeFilter.addEventListener("change", handleFilterChange);
|
||||||
modeSelect.addEventListener("input", () => {
|
modeSelect.addEventListener("input", () => {
|
||||||
|
if (modeSelect.value === "coordination" && graphMode !== "coordination") {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("mode", "coordination");
|
||||||
|
window.location.search = url.search;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (graphMode === "coordination" && modeSelect.value !== "coordination") {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete("mode");
|
||||||
|
url.searchParams.delete("include_residuals");
|
||||||
|
window.location.search = url.search;
|
||||||
|
return;
|
||||||
|
}
|
||||||
activeMode = modeSelect.value || "full";
|
activeMode = modeSelect.value || "full";
|
||||||
focusSet = null;
|
focusSet = null;
|
||||||
currentProfileId = "";
|
currentProfileId = "";
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,14 @@ class RegistryHandler(BaseHTTPRequestHandler):
|
||||||
if parts == ["exports", "graph-explorer"]:
|
if parts == ["exports", "graph-explorer"]:
|
||||||
if _query_optional(query, "mode") == "coordination":
|
if _query_optional(query, "mode") == "coordination":
|
||||||
workplans, tasks = fetch_hub_lists(default_hub())
|
workplans, tasks = fetch_hub_lists(default_hub())
|
||||||
return HTTPStatus.OK, coordination_graph_payload(workplans, tasks)
|
include = (_query_optional(query, "include_residuals") or "").lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
|
return HTTPStatus.OK, coordination_graph_payload(
|
||||||
|
workplans, tasks, include_residuals=include
|
||||||
|
)
|
||||||
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(),
|
||||||
|
|
|
||||||
|
|
@ -56,3 +56,131 @@ def test_coordination_payload_filters_open_records_and_cites_wait() -> None:
|
||||||
assert payload["metrics"]["open_workplans"] == 2
|
assert payload["metrics"]["open_workplans"] == 2
|
||||||
assert "coordination" in {mode["id"] for mode in fabric_graph_explorer_manifest()["modes"]}
|
assert "coordination" in {mode["id"] for mode in fabric_graph_explorer_manifest()["modes"]}
|
||||||
assert payload["metrics"]["wait_or_human_tasks"] == 1
|
assert payload["metrics"]["wait_or_human_tasks"] == 1
|
||||||
|
assert waits[0]["data"]["edgeSource"] == "citation"
|
||||||
|
assert payload["metrics"]["depends_on_edges"] == 0
|
||||||
|
assert payload["metrics"]["residual_open_workplans"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_residuals_omitted_unless_included_and_titles_are_not_consulted() -> None:
|
||||||
|
workplans = [
|
||||||
|
{
|
||||||
|
"id": "wp-rel",
|
||||||
|
"slug": "cust-wp-0072",
|
||||||
|
"title": "Backfill",
|
||||||
|
"status": "active",
|
||||||
|
"flavor": "planning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wp-res",
|
||||||
|
"slug": "fin-wp-0006",
|
||||||
|
"title": "Consume settlement",
|
||||||
|
"status": "proposed",
|
||||||
|
"flavor": "residual",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wp-named",
|
||||||
|
"slug": "upc-wp-0002",
|
||||||
|
"title": "Company vessel close-out (G1 + residual G2/G8)",
|
||||||
|
"status": "active",
|
||||||
|
"flavor": "implementation",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
tasks = [
|
||||||
|
{
|
||||||
|
"id": "t-res",
|
||||||
|
"workplan_id": "wp-res",
|
||||||
|
"title": "Leftover task",
|
||||||
|
"status": "todo",
|
||||||
|
"flavor": "residual",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
hidden = coordination_graph_payload(workplans, tasks)
|
||||||
|
ids = {el["data"]["id"] for el in hidden["elements"]}
|
||||||
|
assert "workplan:wp-rel" in ids
|
||||||
|
assert "workplan:wp-named" in ids
|
||||||
|
assert "workplan:wp-res" not in ids
|
||||||
|
assert "task:t-res" not in ids
|
||||||
|
assert hidden["metrics"]["residual_open_workplans"] == 1
|
||||||
|
assert hidden["metrics"]["include_residuals"] is False
|
||||||
|
|
||||||
|
shown = coordination_graph_payload(workplans, tasks, include_residuals=True)
|
||||||
|
shown_ids = {el["data"]["id"] for el in shown["elements"]}
|
||||||
|
assert "workplan:wp-res" in shown_ids
|
||||||
|
assert "task:t-res" in shown_ids
|
||||||
|
residual_node = next(
|
||||||
|
el for el in shown["elements"] if el["data"]["id"] == "workplan:wp-res"
|
||||||
|
)
|
||||||
|
assert residual_node["data"]["flavor"] == "residual"
|
||||||
|
assert residual_node["data"]["nodeClass"] == "residual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_indexed_depends_on_outranks_citation_fallback() -> None:
|
||||||
|
workplans = [
|
||||||
|
{
|
||||||
|
"id": "wp-a",
|
||||||
|
"slug": "cust-wp-0072",
|
||||||
|
"title": "Backfill",
|
||||||
|
"status": "active",
|
||||||
|
"flavor": "planning",
|
||||||
|
"depends_on": [{"workplan_id": "wp-b", "workplan_slug": "state-wp-0092"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wp-b",
|
||||||
|
"slug": "state-wp-0092",
|
||||||
|
"title": "Flavor schema",
|
||||||
|
"status": "active",
|
||||||
|
"flavor": "planning",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
tasks = [
|
||||||
|
{
|
||||||
|
"id": "t1",
|
||||||
|
"workplan_id": "wp-a",
|
||||||
|
"title": "Wait for STATE-WP-0092",
|
||||||
|
"status": "wait",
|
||||||
|
"description": "Blocked on STATE-WP-0092",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
payload = coordination_graph_payload(workplans, tasks)
|
||||||
|
edges = [el["data"] for el in payload["elements"] if el["data"].get("kind") == "Edge"]
|
||||||
|
depends = [e for e in edges if e.get("edgeType") == "depends_on"]
|
||||||
|
waits = [e for e in edges if e.get("edgeType") == "waits_on"]
|
||||||
|
assert len(depends) == 1
|
||||||
|
assert depends[0]["source"] == "workplan:wp-a"
|
||||||
|
assert depends[0]["target"] == "workplan:wp-b"
|
||||||
|
assert depends[0]["edgeSource"] == "indexed"
|
||||||
|
assert waits == []
|
||||||
|
assert payload["metrics"]["depends_on_edges"] == 1
|
||||||
|
assert payload["metrics"]["citation_edges"] == 0
|
||||||
|
chokepoint = next(
|
||||||
|
el["data"]["chokepoint"]
|
||||||
|
for el in payload["elements"]
|
||||||
|
if el["data"]["id"] == "workplan:wp-b"
|
||||||
|
)
|
||||||
|
assert chokepoint == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_id_depends_on_resolves_to_hub_uuid() -> None:
|
||||||
|
workplans = [
|
||||||
|
{
|
||||||
|
"id": "uuid-a",
|
||||||
|
"slug": "rail-fab-wp-0030",
|
||||||
|
"title": "Graph",
|
||||||
|
"status": "proposed",
|
||||||
|
"depends_on": ["STATE-WP-0092"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uuid-b",
|
||||||
|
"slug": "state-wp-0092",
|
||||||
|
"title": "Schema",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
payload = coordination_graph_payload(workplans, [])
|
||||||
|
depends = [
|
||||||
|
el["data"]
|
||||||
|
for el in payload["elements"]
|
||||||
|
if el["data"].get("edgeType") == "depends_on"
|
||||||
|
]
|
||||||
|
assert depends
|
||||||
|
assert depends[0]["target"] == "workplan:uuid-b"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Coordination graph: flavor classes and real depends_on edges"
|
title: "Coordination graph: flavor classes and real depends_on edges"
|
||||||
domain: financials
|
domain: financials
|
||||||
repo: railiance-fabric
|
repo: railiance-fabric
|
||||||
status: proposed
|
status: finished
|
||||||
owner: grok
|
owner: grok
|
||||||
topic_slug: railiance
|
topic_slug: railiance
|
||||||
flavor: extension
|
flavor: extension
|
||||||
|
|
@ -35,7 +35,7 @@ edges as diagnostic fallback.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0030-T01
|
id: RAIL-FAB-WP-0030-T01
|
||||||
status: wait
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [STATE-WP-0092, RAIL-FAB-WP-0029]
|
depends_on: [STATE-WP-0092, RAIL-FAB-WP-0029]
|
||||||
state_hub_task_id: "7a852b07-6c2a-5765-a963-f955ef2426cd"
|
state_hub_task_id: "7a852b07-6c2a-5765-a963-f955ef2426cd"
|
||||||
|
|
@ -54,7 +54,7 @@ when included, documented in `docs/coordination-graph.md`.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0030-T02
|
id: RAIL-FAB-WP-0030-T02
|
||||||
status: wait
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [STATE-WP-0092, RAIL-FAB-WP-0029]
|
depends_on: [STATE-WP-0092, RAIL-FAB-WP-0029]
|
||||||
state_hub_task_id: "4238ab07-3f46-50e7-8862-bca66e9010a4"
|
state_hub_task_id: "4238ab07-3f46-50e7-8862-bca66e9010a4"
|
||||||
|
|
@ -74,7 +74,7 @@ legible as different.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0030-T03
|
id: RAIL-FAB-WP-0030-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
depends_on: [RAIL-FAB-WP-0029]
|
depends_on: [RAIL-FAB-WP-0029]
|
||||||
state_hub_task_id: "43e76e17-e6bb-55ab-8d48-bfbf0b458148"
|
state_hub_task_id: "43e76e17-e6bb-55ab-8d48-bfbf0b458148"
|
||||||
|
|
@ -86,3 +86,9 @@ confused with indexed `depends_on`. No new authoring surface.
|
||||||
|
|
||||||
Done when the docs state the precedence: indexed `depends_on` first,
|
Done when the docs state the precedence: indexed `depends_on` first,
|
||||||
citations as fallback, never the reverse.
|
citations as fallback, never the reverse.
|
||||||
|
|
||||||
|
2026-09-14: T01–T03 landed. Default coordination export/UI omit
|
||||||
|
`flavor: residual`; `--include-residuals` / `?include_residuals=true`
|
||||||
|
shows them. Indexed `depends_on` is drawn first; citation `waits_on` is
|
||||||
|
fallback and tagged `edgeSource: citation`. Live hub flavor still needs
|
||||||
|
STATE-WP-0092 alembic deploy; until then unspecified flavor stays visible.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue