From 9dbb8f0bcbdc1dbda982fa41a0bf5d4367e66491 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 28 Sep 2026 22:26:44 +0200 Subject: [PATCH] Qualify wait states in the coordination graph (CUST-WP-0074-T04) Task nodes carry wait_kind (external | human | both | unqualified, null unless wait): the hub field wins, otherwise derived from needs_human and dependency edges (task-level depends_on, or a workplan dependency row whose target the task cites). Workplan nodes carry blocked_kind (human | external | none, null unless blocked). Task-level depends_on becomes a task -> workplan/task depends_on edge; depends_on edges carry edge_kind commitment (cyan #0891b2), human gates are marked on the node (humanGate, rose #be123c). A needs_human=true query / "Needs human" toggle / --needs-human flag keeps only blocked_kind=human workplans and their human-wait tasks; the parameter survives in the shareable URL and mode switch like include_residuals. Fabric stays a read of State Hub. Co-Authored-By: Claude Fable 5.1 Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 237582@bnt-lap001 Assistant-Session: f2b3d9f1-8fb9-4b9c-bc2b-837ec5dfc826 --- docs/coordination-graph.md | 20 +++ railiance_fabric/cli.py | 6 + railiance_fabric/coordination_graph.py | 232 ++++++++++++++++++++++--- railiance_fabric/graph_explorer_ui.py | 35 +++- railiance_fabric/server.py | 7 +- tests/test_coordination_graph.py | 144 +++++++++++++++ tests/test_graph_mode_switch.py | 15 +- 7 files changed, 426 insertions(+), 33 deletions(-) diff --git a/docs/coordination-graph.md b/docs/coordination-graph.md index bebe605..ac82787 100644 --- a/docs/coordination-graph.md +++ b/docs/coordination-graph.md @@ -49,6 +49,26 @@ workplan nodes from `depends_on` and remaining `waits_on` edges. Solid blue = indexed `depends_on`. Dashed amber = citation `waits_on`. +## Qualified waits (CUST-WP-0074) + +Open task nodes carry `wait_kind` (`external` | `human` | `both` | +`unqualified`, null unless `status: wait`). The hub's `wait_kind` field wins +when present; otherwise `needs_human: true` → `human`, a dependency edge +(task-level `depends_on`, or a workplan dependency row whose target the task +cites) → `external`, both → `both`, neither → `unqualified`. Workplan nodes +carry `blocked_kind` (`human` when any wait task is a human gate, else +`external`, else `none`; null unless `status: blocked`). + +Task-level `depends_on` becomes a task → workplan/task `depends_on` edge +(`edgeSource: task`). All `depends_on` edges carry `edge_kind: commitment` +(cyan `#0891b2`); citation edges carry `edge_kind: citation`. Human gates have +no edge target, so the marker is on the node: `humanGate: true`, rose +`#be123c` (`WAIT_KIND_COLORS` in `coordination_graph.py`). + +The **Needs human** checkbox / `needs_human=true` query (CLI `--needs-human`) +keeps only `blocked_kind: human` workplans and their human-wait tasks — the +operator's queue. It survives in the shareable URL like `include_residuals`. + ## Switching views The mode selector switches between topology and coordination in the same page. diff --git a/railiance_fabric/cli.py b/railiance_fabric/cli.py index bdc1710..3f8b2ee 100644 --- a/railiance_fabric/cli.py +++ b/railiance_fabric/cli.py @@ -91,6 +91,11 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Include flavor=residual workplans in --format coordination (default omits them)", ) + export.add_argument( + "--needs-human", + action="store_true", + help="Only blocked_kind=human workplans and their human-wait tasks in --format coordination", + ) scan = sub.add_parser("scan", help="Scan a repo for deterministic discovery candidates.") scan.add_argument("path", nargs="?", type=Path, default=Path(".")) @@ -363,6 +368,7 @@ def main(argv: list[str] | None = None) -> int: workplans, tasks, include_residuals=args.include_residuals, + needs_human_only=args.needs_human, ), indent=2, sort_keys=True, diff --git a/railiance_fabric/coordination_graph.py b/railiance_fabric/coordination_graph.py index e439916..a337d0f 100644 --- a/railiance_fabric/coordination_graph.py +++ b/railiance_fabric/coordination_graph.py @@ -24,6 +24,14 @@ FLAVOR_COLORS = { "extension": "#0f766e", "residual": "#64748b", } +# CUST-WP-0074 qualified waits: external commitment (Case A) versus human gate (Case B). +WAIT_KIND_COLORS = { + "external": "#0891b2", + "human": "#be123c", +} +WAIT_KINDS = frozenset({"external", "human", "both", "unqualified"}) +BLOCKED_KINDS = frozenset({"external", "human", "none"}) +HUMAN_WAIT_KINDS = frozenset({"human", "both"}) def is_residual_flavor(value: Any) -> bool: @@ -52,6 +60,7 @@ def _indexed_depends_targets(workplan: dict[str, Any]) -> list[str]: target = ( item.get("workplan_id") or item.get("workstream_id") + or item.get("task_id") or item.get("id") ) else: @@ -66,6 +75,88 @@ def _indexed_depends_targets(workplan: dict[str, Any]) -> list[str]: return targets +def _task_depends_targets(task: dict[str, Any]) -> list[str]: + """Task-level depends_on ids (workplan or task ids), when the payload carries them.""" + return _indexed_depends_targets({"depends_on": task.get("depends_on")}) + + +def _cited_tokens(task: dict[str, Any]) -> set[str]: + blob = " ".join( + str(task.get(key) or "") + for key in ("description", "blocking_reason", "intervention_note", "title") + ) + return set(WP_ID_RE.findall(blob.upper())) + + +def _hub_kind(value: Any, allowed: frozenset[str]) -> str | None: + """Accept the hub field as-is or with a `blocked-`/`waiting-` prefix.""" + text = str(value or "").strip().lower() + for prefix in ("blocked-", "waiting-"): + if text.startswith(prefix): + text = text[len(prefix):] + return text if text in allowed else None + + +def task_wait_kind( + task: dict[str, Any], + *, + has_external: bool, +) -> str | None: + """`external` | `human` | `both` | `unqualified` for a wait task, else None. + + The hub's `wait_kind` wins when present; otherwise derive from + `needs_human` (Case B) and dependency edges (Case A). + """ + if str(task.get("status") or "") != "wait": + return None + hub = _hub_kind(task.get("wait_kind"), WAIT_KINDS) + if hub: + return hub + human = bool(task.get("needs_human")) + if human and has_external: + return "both" + if human: + return "human" + if has_external: + return "external" + return "unqualified" + + +def workplan_blocked_kind(workplan: dict[str, Any], wait_kinds: list[str | None]) -> str | None: + """`human` when any wait task is a human gate, else `external`, else `none`.""" + if str(workplan.get("status") or "") != "blocked": + return None + hub = _hub_kind(workplan.get("blocked_kind"), BLOCKED_KINDS) + if hub: + return hub + kinds = {kind for kind in wait_kinds if kind} + if kinds & HUMAN_WAIT_KINDS: + return "human" + if "external" in kinds: + return "external" + return "none" + + +def _index_workplans( + open_wps: list[dict[str, Any]], +) -> tuple[dict[str, dict[str, Any]], dict[str, str], dict[str, str]]: + 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") + } + 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"]) + return wp_by_id, slug_to_id, record_to_id + + TASK_NODE_SIZE = 26 WORKPLAN_BASE_SIZE = 44 WORKPLAN_SIZE_STEP = 10 @@ -83,28 +174,23 @@ def coordination_graph_payload( tasks: list[dict[str, Any]], *, include_residuals: bool = False, + needs_human_only: bool = False, + dependencies: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: - """Build a GraphExplorerPayload of open workplans and their open tasks.""" + """Build a GraphExplorerPayload of open workplans and their open tasks. + + `dependencies` are raw `/workplans/{id}/dependencies/` rows + (`from_workplan_id`, `to_workplan_id`, `to_task_id`); they qualify a wait + task as external when the task cites the row's target. `needs_human_only` + keeps only `blocked_kind: human` workplans and their human-wait tasks. + """ 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") - } - 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"]) + wp_by_id, slug_to_id, record_to_id = _index_workplans(open_wps) open_tasks = [ task @@ -114,6 +200,51 @@ def coordination_graph_payload( and (include_residuals or not is_residual_flavor(_flavor(task))) ] + # Dependency targets per workplan: indexed stubs plus raw dependency rows. + dep_targets: dict[str, set[str]] = {} + for wp in open_wps: + dep_targets[str(wp["id"])] = set(_indexed_depends_targets(wp)) + for row in dependencies or []: + src = str(row.get("from_workplan_id") or row.get("from_workstream_id") or "") + dst = row.get("to_workplan_id") or row.get("to_workstream_id") or row.get("to_task_id") + if src in dep_targets and dst: + dep_targets[src].add(str(dst)) + + def _resolve(raw: str) -> str | None: + if raw in wp_by_id: + return raw + return record_to_id.get(raw.upper()) or slug_to_id.get(raw.lower()) + + wait_kind_by_task: dict[str, str | None] = {} + for task in open_tasks: + wp_id = str(task.get("workplan_id") or task.get("workstream_id")) + external = bool(_task_depends_targets(task)) + if not external and str(task.get("status") or "") == "wait": + resolved = {_resolve(dst) or dst for dst in dep_targets.get(wp_id, set())} + cited = {_resolve(token) or token for token in _cited_tokens(task)} + external = bool(resolved & cited) + wait_kind_by_task[str(task["id"])] = task_wait_kind(task, has_external=external) + blocked_kind_by_wp: dict[str, str | None] = {} + for wp in open_wps: + wp_id = str(wp["id"]) + kinds = [ + wait_kind_by_task[str(task["id"])] + for task in open_tasks + if str(task.get("workplan_id") or task.get("workstream_id")) == wp_id + ] + blocked_kind_by_wp[wp_id] = workplan_blocked_kind(wp, kinds) + + if needs_human_only: + open_wps = [wp for wp in open_wps if blocked_kind_by_wp[str(wp["id"])] == "human"] + wp_by_id, slug_to_id, record_to_id = _index_workplans(open_wps) + open_tasks = [ + task + for task in open_tasks + if str(task.get("workplan_id") or task.get("workstream_id")) in wp_by_id + and wait_kind_by_task[str(task["id"])] in HUMAN_WAIT_KINDS + ] + task_ids = {str(task["id"]) for task in open_tasks} + elements: list[dict[str, Any]] = [] for wp in open_wps: wp_id = str(wp["id"]) @@ -135,6 +266,7 @@ def coordination_graph_payload( "repo": wp.get("repo") or wp.get("slug"), "displayState": "show" if status != "blocked" else "highlight", "unresolved": status in {"blocked", "proposed"}, + "blocked_kind": blocked_kind_by_wp[wp_id], "chokepoint": 0, "color": FLAVOR_COLORS.get(flavor or "", "#1d4ed8"), } @@ -143,12 +275,17 @@ def coordination_graph_payload( belongs = 0 wait_edges = 0 + wait_kind_counts = {kind: 0 for kind in sorted(WAIT_KINDS)} 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")) flavor = _flavor(task) or _flavor(wp_by_id.get(wp_id) or {}) + wait_kind = wait_kind_by_task[task_id] + human_gate = wait_kind in HUMAN_WAIT_KINDS + if wait_kind: + wait_kind_counts[wait_kind] += 1 elements.append( { "data": { @@ -163,12 +300,19 @@ def coordination_graph_payload( "flavor": flavor, "nodeClass": flavor or "unspecified", "needsHuman": human, + "wait_kind": wait_kind, + # Human gates have no edge target; the marker lives on the node. + "humanGate": human_gate, "displayState": "highlight" if status == "wait" or human else "show", "unresolved": status == "wait" or human, "visualSize": TASK_NODE_SIZE, } } ) + if human_gate: + elements[-1]["data"]["color"] = WAIT_KIND_COLORS["human"] + elif wait_kind == "external": + elements[-1]["data"]["color"] = WAIT_KIND_COLORS["external"] elements.append( { "data": { @@ -198,12 +342,7 @@ def coordination_graph_payload( 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()) - ) + dst = _resolve(dst_raw) if not dst or dst == src or dst not in wp_by_id: continue pair = (src, dst) @@ -222,6 +361,7 @@ def coordination_graph_payload( "target": f"workplan:{dst}", "edgeType": "depends_on", "edgeSource": "indexed", + "edge_kind": "commitment", "strength": "strong", "sourceLayer": "workplan", "targetLayer": "workplan", @@ -231,14 +371,49 @@ def coordination_graph_payload( depends_count += 1 chokepoint[dst] = chokepoint.get(dst, 0) + 1 + # Task-level depends_on (CUST-WP-0074 Case A): task -> workplan or task. + task_pairs: set[tuple[str, str]] = set() + task_depends_count = 0 + for task in open_tasks: + task_id = str(task["id"]) + src_wp = str(task.get("workplan_id") or task.get("workstream_id")) + for dst_raw in _task_depends_targets(task): + if dst_raw in task_ids and dst_raw != task_id: + target, target_layer = f"task:{dst_raw}", "task" + else: + dst = _resolve(dst_raw) + if not dst or dst == src_wp or dst not in wp_by_id: + continue + target, target_layer = f"workplan:{dst}", "workplan" + chokepoint[dst] = chokepoint.get(dst, 0) + 1 + if (task_id, target) in task_pairs: + continue + task_pairs.add((task_id, target)) + elements.append( + { + "data": { + "id": f"edge:task-depends:{task_id}:{target}", + "stableKey": f"edge:task-depends:{task_id}:{target}", + "kind": "Edge", + "layer": "dependency", + "displayState": "show", + "source": f"task:{task_id}", + "target": target, + "edgeType": "depends_on", + "edgeSource": "task", + "edge_kind": "commitment", + "strength": "strong", + "sourceLayer": "task", + "targetLayer": target_layer, + } + } + ) + task_depends_count += 1 + 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())): + for token in _cited_tokens(task): dst = record_to_id.get(token) or slug_to_id.get(token.lower()) if not dst or dst == src_wp or dst not in wp_by_id: continue @@ -256,6 +431,7 @@ def coordination_graph_payload( "target": f"workplan:{dst}", "edgeType": "waits_on", "edgeSource": "citation", + "edge_kind": "citation", "strength": "strong", "sourceLayer": "workplan", "targetLayer": "workplan", @@ -290,10 +466,13 @@ def coordination_graph_payload( "open_tasks": len(open_tasks), "belongs_to_edges": belongs, "depends_on_edges": depends_count, + "task_depends_on_edges": task_depends_count, "citation_edges": cited, "wait_or_human_tasks": wait_edges, + **{f"wait_{kind}_tasks": count for kind, count in wait_kind_counts.items()}, "residual_open_workplans": residual_open, "include_residuals": include_residuals, + "needs_human_only": needs_human_only, }, "elements": elements, "hidden_elements": [], @@ -301,6 +480,7 @@ def coordination_graph_payload( def fetch_hub_lists(api_base: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Open workplans and all tasks; `/state/deps` stubs carry the dependency rows.""" base = api_base.rstrip("/") workplans: list[dict[str, Any]] = [] for status in sorted(OPEN_WORKPLAN): diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index 6251bcf..6d7ec7f 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -409,6 +409,10 @@ def graph_explorer_page() -> str: Residuals +
Layout