Qualify wait states in the coordination graph (CUST-WP-0074-T04)
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run

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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 237582@bnt-lap001
Assistant-Session: f2b3d9f1-8fb9-4b9c-bc2b-837ec5dfc826
This commit is contained in:
codex 2026-09-28 22:26:44 +02:00
parent b47dd43b04
commit 9dbb8f0bcb
7 changed files with 426 additions and 33 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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):

View file

@ -409,6 +409,10 @@ def graph_explorer_page() -> str:
<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>
<label id="needs-human-wrap" class="field" style="display:none;align-self:end">
<span class="field-label">Needs human <button type="button" class="help-tip" aria-label="Needs human help" data-help-title="Needs human only" data-help="Show only workplans blocked on a human gate (blocked_kind=human) and their human-wait tasks. External commitments (depends_on) are hidden. This is the operator queue.">?</button></span>
<input type="checkbox" id="needs-human" aria-label="Needs human only">
</label>
<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>
<select id="layout-select" aria-label="Graph layout">
@ -572,8 +576,13 @@ def graph_explorer_page() -> str:
let includeResidualsParam = ["1", "true", "yes"].includes(
(pageParams.get("include_residuals") || "").toLowerCase()
);
let needsHumanParam = ["1", "true", "yes"].includes(
(pageParams.get("needs_human") || "").toLowerCase()
);
const coordinationUrl = (includeResiduals, needsHuman) =>
`/exports/graph-explorer?mode=coordination${includeResiduals ? "&include_residuals=true" : ""}${needsHuman ? "&needs_human=true" : ""}`;
const graphUrl = graphMode === "coordination"
? `/exports/graph-explorer?mode=coordination${includeResidualsParam ? "&include_residuals=true" : ""}`
? coordinationUrl(includeResidualsParam, needsHumanParam)
: "/exports/graph-explorer";
const canvas = document.getElementById("graph-canvas");
const zoneOverlay = document.getElementById("zone-overlay");
@ -2142,6 +2151,7 @@ def graph_explorer_page() -> str:
if (state.search) params.set("search", state.search);
if (state.mode && state.mode !== "full") params.set("mode", state.mode);
if (state.mode === "coordination" && includeResidualsParam) params.set("include_residuals", "true");
if (state.mode === "coordination" && needsHumanParam) params.set("needs_human", "true");
if (state.layout && state.layout !== "cose") params.set("layout", state.layout);
if (state.labelMode && state.labelMode !== "auto") params.set("labelMode", state.labelMode);
if (state.nodeTypes.length !== allNodeTypes.length) params.set("nodeTypes", state.nodeTypes.join(","));
@ -2203,7 +2213,7 @@ def graph_explorer_page() -> str:
const applyViewState = (state, options = {}) => {
if (cy && "mode" in state && ((state.mode === "coordination") !== (graphMode === "coordination"))) {
switchGraph(state.mode, includeResidualsParam, state);
switchGraph(state.mode, includeResidualsParam, state, needsHumanParam);
return;
}
if ("search" in state) searchInput.value = state.search || "";
@ -2836,6 +2846,15 @@ def graph_explorer_page() -> str:
switchGraph("coordination", residualsToggle.checked);
});
}
const needsHumanWrap = document.getElementById("needs-human-wrap");
const needsHumanToggle = document.getElementById("needs-human");
if (needsHumanWrap && needsHumanToggle) {
needsHumanWrap.style.display = graphMode === "coordination" ? "" : "none";
needsHumanToggle.checked = needsHumanParam;
needsHumanToggle.addEventListener("change", () => {
switchGraph("coordination", includeResidualsParam, null, needsHumanToggle.checked);
});
}
profiles = loadProfiles();
renderProfiles();
const elements = (payload.elements || []).map((element) => ({
@ -2903,6 +2922,10 @@ def graph_explorer_page() -> str:
{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[edge_kind = 'commitment']", style: {"line-color": "#0891b2", "target-arrow-color": "#0891b2"}},
{selector: "node[wait_kind = 'external']", style: {"background-color": "#0891b2"}},
{selector: "node[humanGate = true]", style: {"background-color": "#be123c", "border-color": "#be123c", "border-style": "solid", "border-width": 3}},
{selector: "node[wait_kind = 'both']", style: {"border-color": "#0891b2"}},
{selector: "edge[zoneCollapse = true]", style: {"line-style": "dashed", "line-color": "#0f766e", "target-arrow-color": "#0f766e"}},
{selector: "node.rule-highlight", style: {
"border-color": "#2563eb",
@ -2974,11 +2997,11 @@ def graph_explorer_page() -> str:
};
let graphRequest = 0;
const switchGraph = async (mode, includeResiduals = includeResidualsParam, viewState = null) => {
const switchGraph = async (mode, includeResiduals = includeResidualsParam, viewState = null, needsHuman = needsHumanParam) => {
const request = ++graphRequest;
const coordination = mode === "coordination";
const url = coordination
? `/exports/graph-explorer?mode=coordination${includeResiduals ? "&include_residuals=true" : ""}`
? coordinationUrl(includeResiduals, needsHuman)
: "/exports/graph-explorer";
try {
const response = await fetch(url);
@ -2992,10 +3015,13 @@ def graph_explorer_page() -> str:
}));
graphMode = coordination ? "coordination" : "full";
includeResidualsParam = includeResiduals;
needsHumanParam = needsHuman;
activeMode = mode;
modeSelect.value = mode;
document.getElementById("include-residuals-wrap").style.display = coordination ? "" : "none";
document.getElementById("include-residuals").checked = includeResiduals;
document.getElementById("needs-human-wrap").style.display = coordination ? "" : "none";
document.getElementById("needs-human").checked = needsHuman;
selected = null;
selectedAnchor = null;
selectedZoneId = "";
@ -3031,6 +3057,7 @@ def graph_explorer_page() -> str:
if (request !== graphRequest) return;
modeSelect.value = activeMode;
document.getElementById("include-residuals").checked = includeResidualsParam;
document.getElementById("needs-human").checked = needsHumanParam;
profileSummary.textContent = `Could not load graph: ${error.message}`;
}
};

View file

@ -144,8 +144,13 @@ class RegistryHandler(BaseHTTPRequestHandler):
"true",
"yes",
}
needs_human = (_query_optional(query, "needs_human") or "").lower() in {
"1",
"true",
"yes",
}
return HTTPStatus.OK, coordination_graph_payload(
workplans, tasks, include_residuals=include
workplans, tasks, include_residuals=include, needs_human_only=needs_human
)
return HTTPStatus.OK, fabric_graph_explorer_payload(
self.store.combined_graph(),

View file

@ -191,3 +191,147 @@ def test_canonical_id_depends_on_resolves_to_hub_uuid() -> None:
]
assert depends
assert depends[0]["target"] == "workplan:uuid-b"
def _wait_fixture() -> tuple[list[dict], list[dict]]:
workplans = [
{
"id": "wp-h",
"slug": "rail-wp-0015",
"title": "Backups",
"status": "blocked",
"depends_on": [{"workplan_id": "wp-ext", "workplan_slug": "flex-wp-0020"}],
},
{"id": "wp-ext", "slug": "flex-wp-0020", "title": "Rename", "status": "active"},
{"id": "wp-e", "slug": "key-wp-0035", "title": "Keys", "status": "blocked"},
{"id": "wp-u", "slug": "cust-wp-0060", "title": "Loose", "status": "blocked"},
]
tasks = [
{
"id": "t-human",
"workplan_id": "wp-h",
"title": "Operator provisions creds",
"status": "wait",
"needs_human": True,
"blocking_reason": "operator must provision NC_WEBDAV/AGE creds to OpenBao",
},
{
"id": "t-both",
"workplan_id": "wp-h",
"title": "Both",
"status": "wait",
"needs_human": True,
"depends_on": ["FLEX-WP-0020"],
},
{
"id": "t-ext-cited",
"workplan_id": "wp-h",
"title": "External by indexed citation",
"status": "wait",
"blocking_reason": "FLEX-WP-0020 rename must land first",
},
{
"id": "t-ext",
"workplan_id": "wp-e",
"title": "External by task depends_on",
"status": "wait",
"depends_on": ["t-both"],
},
{"id": "t-todo", "workplan_id": "wp-e", "title": "Open", "status": "todo"},
{
"id": "t-unq",
"workplan_id": "wp-u",
"title": "Nothing qualifies",
"status": "wait",
"blocking_reason": "waiting, see nowhere",
},
]
return workplans, tasks
def _by_id(payload: dict) -> dict[str, dict]:
return {el["data"]["id"]: el["data"] for el in payload["elements"]}
def test_wait_kind_derived_from_needs_human_and_dependency_edges() -> None:
workplans, tasks = _wait_fixture()
payload = coordination_graph_payload(workplans, tasks)
_validate_schema("graph-explorer-payload.schema.yaml", payload)
data = _by_id(payload)
assert data["task:t-human"]["wait_kind"] == "human"
assert data["task:t-human"]["humanGate"] is True
assert data["task:t-both"]["wait_kind"] == "both"
assert data["task:t-both"]["humanGate"] is True
assert data["task:t-ext-cited"]["wait_kind"] == "external"
assert data["task:t-ext"]["wait_kind"] == "external"
assert data["task:t-ext"]["humanGate"] is False
assert data["task:t-unq"]["wait_kind"] == "unqualified"
assert data["task:t-todo"]["wait_kind"] is None
assert payload["metrics"]["wait_both_tasks"] == 1
assert payload["metrics"]["wait_external_tasks"] == 2
assert payload["metrics"]["wait_human_tasks"] == 1
assert payload["metrics"]["wait_unqualified_tasks"] == 1
def test_hub_provided_wait_kind_and_blocked_kind_win_over_derivation() -> None:
workplans, tasks = _wait_fixture()
tasks[0]["wait_kind"] = "external"
tasks[5]["wait_kind"] = "waiting-external"
workplans[3]["blocked_kind"] = "blocked-human"
payload = coordination_graph_payload(workplans, tasks)
data = _by_id(payload)
assert data["task:t-human"]["wait_kind"] == "external"
assert data["task:t-unq"]["wait_kind"] == "external"
assert data["workplan:wp-u"]["blocked_kind"] == "human"
tasks[5]["wait_kind"] = "bogus"
data = _by_id(coordination_graph_payload(workplans, tasks))
assert data["task:t-unq"]["wait_kind"] == "unqualified"
def test_blocked_kind_aggregates_wait_tasks_and_is_null_unless_blocked() -> None:
workplans, tasks = _wait_fixture()
data = _by_id(coordination_graph_payload(workplans, tasks))
assert data["workplan:wp-h"]["blocked_kind"] == "human"
assert data["workplan:wp-e"]["blocked_kind"] == "external"
assert data["workplan:wp-u"]["blocked_kind"] == "none"
assert data["workplan:wp-ext"]["blocked_kind"] is None
def test_dependency_rows_qualify_cited_wait_as_external() -> None:
workplans, tasks = _wait_fixture()
workplans[0].pop("depends_on")
data = _by_id(coordination_graph_payload(workplans, tasks))
assert data["task:t-ext-cited"]["wait_kind"] == "unqualified"
rows = [{"id": "d1", "from_workplan_id": "wp-h", "to_workplan_id": "wp-ext", "to_task_id": None}]
data = _by_id(coordination_graph_payload(workplans, tasks, dependencies=rows))
assert data["task:t-ext-cited"]["wait_kind"] == "external"
def test_edge_kind_marks_commitments_and_task_depends_on_edges() -> None:
workplans, tasks = _wait_fixture()
payload = coordination_graph_payload(workplans, tasks)
data = _by_id(payload)
assert data["edge:depends:wp-h:wp-ext"]["edge_kind"] == "commitment"
task_edge = data["edge:task-depends:t-both:workplan:wp-ext"]
assert task_edge["edgeType"] == "depends_on"
assert task_edge["edgeSource"] == "task"
assert task_edge["edge_kind"] == "commitment"
assert data["edge:task-depends:t-ext:task:t-both"]["targetLayer"] == "task"
assert payload["metrics"]["task_depends_on_edges"] == 2
assert data["workplan:wp-ext"]["chokepoint"] == 2
assert not any(k.startswith("edge:cites:") for k in data)
def test_needs_human_only_keeps_human_blocked_workplans_and_their_human_waits() -> None:
workplans, tasks = _wait_fixture()
payload = coordination_graph_payload(workplans, tasks, needs_human_only=True)
_validate_schema("graph-explorer-payload.schema.yaml", payload)
data = _by_id(payload)
nodes = {k for k, v in data.items() if v["kind"] != "Edge"}
assert nodes == {"workplan:wp-h", "task:t-human", "task:t-both"}
edges = {k for k, v in data.items() if v["kind"] == "Edge"}
assert edges == {"edge:belongs:t-human", "edge:belongs:t-both"}
assert payload["metrics"]["needs_human_only"] is True
assert payload["metrics"]["open_workplans"] == 1
assert payload["metrics"]["open_tasks"] == 2
assert coordination_graph_payload(workplans, tasks)["metrics"]["needs_human_only"] is False

View file

@ -16,7 +16,9 @@ def test_graph_switch_replaces_data_and_retains_previous_graph_on_failure(tmp_pa
)[0]
script = r'''
const assert = require("node:assert/strict");
let graphMode = "full", includeResidualsParam = false, activeMode = "full";
let graphMode = "full", includeResidualsParam = false, needsHumanParam = false, activeMode = "full";
const coordinationUrl = (includeResiduals, needsHuman) =>
`/exports/graph-explorer?mode=coordination${includeResiduals ? "&include_residuals=true" : ""}${needsHuman ? "&needs_human=true" : ""}`;
let selected, selectedAnchor, selectedZoneId, focusSet, manualOverrides, filterRules;
let editingRuleId, currentProfileId, allEdgeTypes;
const layerColors = {}, allNodeTypes = [], nodeTypeLabels = {};
@ -43,6 +45,14 @@ let graphRequest = 0;
assert.equal(modeSelect.value, "coordination");
assert.equal(controls["include-residuals-wrap"].style.display, "");
assert.match(urls[0], /include_residuals=true/);
assert.doesNotMatch(urls[0], /needs_human/);
await switchGraph("coordination", true, null, true);
assert.match(urls[1], /include_residuals=true&needs_human=true/);
assert.equal(needsHumanParam, true);
assert.equal(controls["needs-human"].checked, true);
assert.equal(controls["needs-human-wrap"].style.display, "");
await switchGraph("coordination", true, null, false);
assert.equal(needsHumanParam, false);
response = {ok: false, status: 503};
await switchGraph("full");
assert.equal(data[0].data.id, "workplan:a");
@ -54,6 +64,7 @@ let graphRequest = 0;
assert.equal(activeMode, "by-access-zone");
assert.equal(graphMode, "full");
assert.equal(controls["include-residuals-wrap"].style.display, "none");
assert.equal(controls["needs-human-wrap"].style.display, "none");
let resolveOld;
fetch = () => new Promise(resolve => {resolveOld = resolve;});
const oldRequest = switchGraph("coordination");
@ -62,7 +73,7 @@ let graphRequest = 0;
resolveOld({ok: true, json: async () => ({elements: [{data: {id: "stale"}}]})});
await oldRequest;
assert.equal(data[0].data.id, "latest");
assert.equal(updated, 3);
assert.equal(updated, 5);
})().catch(error => {console.error(error); process.exitCode = 1;});
'''
path = tmp_path / "switch.cjs"