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

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