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 <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:
parent
b47dd43b04
commit
9dbb8f0bcb
7 changed files with 426 additions and 33 deletions
|
|
@ -49,6 +49,26 @@ workplan nodes from `depends_on` and remaining `waits_on` edges.
|
||||||
|
|
||||||
Solid blue = indexed `depends_on`. Dashed amber = citation `waits_on`.
|
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
|
## Switching views
|
||||||
|
|
||||||
The mode selector switches between topology and coordination in the same page.
|
The mode selector switches between topology and coordination in the same page.
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Include flavor=residual workplans in --format coordination (default omits them)",
|
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 = 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("."))
|
||||||
|
|
@ -363,6 +368,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
workplans,
|
workplans,
|
||||||
tasks,
|
tasks,
|
||||||
include_residuals=args.include_residuals,
|
include_residuals=args.include_residuals,
|
||||||
|
needs_human_only=args.needs_human,
|
||||||
),
|
),
|
||||||
indent=2,
|
indent=2,
|
||||||
sort_keys=True,
|
sort_keys=True,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,14 @@ FLAVOR_COLORS = {
|
||||||
"extension": "#0f766e",
|
"extension": "#0f766e",
|
||||||
"residual": "#64748b",
|
"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:
|
def is_residual_flavor(value: Any) -> bool:
|
||||||
|
|
@ -52,6 +60,7 @@ def _indexed_depends_targets(workplan: dict[str, Any]) -> list[str]:
|
||||||
target = (
|
target = (
|
||||||
item.get("workplan_id")
|
item.get("workplan_id")
|
||||||
or item.get("workstream_id")
|
or item.get("workstream_id")
|
||||||
|
or item.get("task_id")
|
||||||
or item.get("id")
|
or item.get("id")
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -66,6 +75,88 @@ def _indexed_depends_targets(workplan: dict[str, Any]) -> list[str]:
|
||||||
return targets
|
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
|
TASK_NODE_SIZE = 26
|
||||||
WORKPLAN_BASE_SIZE = 44
|
WORKPLAN_BASE_SIZE = 44
|
||||||
WORKPLAN_SIZE_STEP = 10
|
WORKPLAN_SIZE_STEP = 10
|
||||||
|
|
@ -83,28 +174,23 @@ def coordination_graph_payload(
|
||||||
tasks: list[dict[str, Any]],
|
tasks: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
include_residuals: bool = False,
|
include_residuals: bool = False,
|
||||||
|
needs_human_only: bool = False,
|
||||||
|
dependencies: list[dict[str, Any]] | None = None,
|
||||||
) -> 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.
|
||||||
|
|
||||||
|
`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]
|
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)))
|
residual_open = sum(1 for wp in open_all if is_residual_flavor(_flavor(wp)))
|
||||||
if include_residuals:
|
if include_residuals:
|
||||||
open_wps = open_all
|
open_wps = open_all
|
||||||
else:
|
else:
|
||||||
open_wps = [wp for wp in open_all if not is_residual_flavor(_flavor(wp))]
|
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, slug_to_id, record_to_id = _index_workplans(open_wps)
|
||||||
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"])
|
|
||||||
|
|
||||||
open_tasks = [
|
open_tasks = [
|
||||||
task
|
task
|
||||||
|
|
@ -114,6 +200,51 @@ def coordination_graph_payload(
|
||||||
and (include_residuals or not is_residual_flavor(_flavor(task)))
|
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]] = []
|
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"])
|
||||||
|
|
@ -135,6 +266,7 @@ def coordination_graph_payload(
|
||||||
"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"},
|
||||||
|
"blocked_kind": blocked_kind_by_wp[wp_id],
|
||||||
"chokepoint": 0,
|
"chokepoint": 0,
|
||||||
"color": FLAVOR_COLORS.get(flavor or "", "#1d4ed8"),
|
"color": FLAVOR_COLORS.get(flavor or "", "#1d4ed8"),
|
||||||
}
|
}
|
||||||
|
|
@ -143,12 +275,17 @@ def coordination_graph_payload(
|
||||||
|
|
||||||
belongs = 0
|
belongs = 0
|
||||||
wait_edges = 0
|
wait_edges = 0
|
||||||
|
wait_kind_counts = {kind: 0 for kind in sorted(WAIT_KINDS)}
|
||||||
for task in open_tasks:
|
for task in open_tasks:
|
||||||
task_id = str(task["id"])
|
task_id = str(task["id"])
|
||||||
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 {})
|
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(
|
elements.append(
|
||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
|
|
@ -163,12 +300,19 @@ def coordination_graph_payload(
|
||||||
"flavor": flavor,
|
"flavor": flavor,
|
||||||
"nodeClass": flavor or "unspecified",
|
"nodeClass": flavor or "unspecified",
|
||||||
"needsHuman": human,
|
"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",
|
"displayState": "highlight" if status == "wait" or human else "show",
|
||||||
"unresolved": status == "wait" or human,
|
"unresolved": status == "wait" or human,
|
||||||
"visualSize": TASK_NODE_SIZE,
|
"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(
|
elements.append(
|
||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
|
|
@ -198,12 +342,7 @@ def coordination_graph_payload(
|
||||||
for wp in open_wps:
|
for wp in open_wps:
|
||||||
src = str(wp["id"])
|
src = str(wp["id"])
|
||||||
for dst_raw in _indexed_depends_targets(wp):
|
for dst_raw in _indexed_depends_targets(wp):
|
||||||
dst = (
|
dst = _resolve(dst_raw)
|
||||||
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:
|
if not dst or dst == src or dst not in wp_by_id:
|
||||||
continue
|
continue
|
||||||
pair = (src, dst)
|
pair = (src, dst)
|
||||||
|
|
@ -222,6 +361,7 @@ def coordination_graph_payload(
|
||||||
"target": f"workplan:{dst}",
|
"target": f"workplan:{dst}",
|
||||||
"edgeType": "depends_on",
|
"edgeType": "depends_on",
|
||||||
"edgeSource": "indexed",
|
"edgeSource": "indexed",
|
||||||
|
"edge_kind": "commitment",
|
||||||
"strength": "strong",
|
"strength": "strong",
|
||||||
"sourceLayer": "workplan",
|
"sourceLayer": "workplan",
|
||||||
"targetLayer": "workplan",
|
"targetLayer": "workplan",
|
||||||
|
|
@ -231,14 +371,49 @@ def coordination_graph_payload(
|
||||||
depends_count += 1
|
depends_count += 1
|
||||||
chokepoint[dst] = chokepoint.get(dst, 0) + 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
|
cited = 0
|
||||||
for task in open_tasks:
|
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"))
|
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())
|
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:
|
if not dst or dst == src_wp or dst not in wp_by_id:
|
||||||
continue
|
continue
|
||||||
|
|
@ -256,6 +431,7 @@ def coordination_graph_payload(
|
||||||
"target": f"workplan:{dst}",
|
"target": f"workplan:{dst}",
|
||||||
"edgeType": "waits_on",
|
"edgeType": "waits_on",
|
||||||
"edgeSource": "citation",
|
"edgeSource": "citation",
|
||||||
|
"edge_kind": "citation",
|
||||||
"strength": "strong",
|
"strength": "strong",
|
||||||
"sourceLayer": "workplan",
|
"sourceLayer": "workplan",
|
||||||
"targetLayer": "workplan",
|
"targetLayer": "workplan",
|
||||||
|
|
@ -290,10 +466,13 @@ def coordination_graph_payload(
|
||||||
"open_tasks": len(open_tasks),
|
"open_tasks": len(open_tasks),
|
||||||
"belongs_to_edges": belongs,
|
"belongs_to_edges": belongs,
|
||||||
"depends_on_edges": depends_count,
|
"depends_on_edges": depends_count,
|
||||||
|
"task_depends_on_edges": task_depends_count,
|
||||||
"citation_edges": cited,
|
"citation_edges": cited,
|
||||||
"wait_or_human_tasks": wait_edges,
|
"wait_or_human_tasks": wait_edges,
|
||||||
|
**{f"wait_{kind}_tasks": count for kind, count in wait_kind_counts.items()},
|
||||||
"residual_open_workplans": residual_open,
|
"residual_open_workplans": residual_open,
|
||||||
"include_residuals": include_residuals,
|
"include_residuals": include_residuals,
|
||||||
|
"needs_human_only": needs_human_only,
|
||||||
},
|
},
|
||||||
"elements": elements,
|
"elements": elements,
|
||||||
"hidden_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]]]:
|
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("/")
|
base = api_base.rstrip("/")
|
||||||
workplans: list[dict[str, Any]] = []
|
workplans: list[dict[str, Any]] = []
|
||||||
for status in sorted(OPEN_WORKPLAN):
|
for status in sorted(OPEN_WORKPLAN):
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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">
|
<input type="checkbox" id="include-residuals" aria-label="Include residual workplans">
|
||||||
</label>
|
</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">
|
<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">
|
||||||
|
|
@ -572,8 +576,13 @@ def graph_explorer_page() -> str:
|
||||||
let includeResidualsParam = ["1", "true", "yes"].includes(
|
let includeResidualsParam = ["1", "true", "yes"].includes(
|
||||||
(pageParams.get("include_residuals") || "").toLowerCase()
|
(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"
|
const graphUrl = graphMode === "coordination"
|
||||||
? `/exports/graph-explorer?mode=coordination${includeResidualsParam ? "&include_residuals=true" : ""}`
|
? coordinationUrl(includeResidualsParam, needsHumanParam)
|
||||||
: "/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");
|
||||||
|
|
@ -2142,6 +2151,7 @@ def graph_explorer_page() -> str:
|
||||||
if (state.search) params.set("search", state.search);
|
if (state.search) params.set("search", state.search);
|
||||||
if (state.mode && state.mode !== "full") params.set("mode", state.mode);
|
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" && 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.layout && state.layout !== "cose") params.set("layout", state.layout);
|
||||||
if (state.labelMode && state.labelMode !== "auto") params.set("labelMode", state.labelMode);
|
if (state.labelMode && state.labelMode !== "auto") params.set("labelMode", state.labelMode);
|
||||||
if (state.nodeTypes.length !== allNodeTypes.length) params.set("nodeTypes", state.nodeTypes.join(","));
|
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 = {}) => {
|
const applyViewState = (state, options = {}) => {
|
||||||
if (cy && "mode" in state && ((state.mode === "coordination") !== (graphMode === "coordination"))) {
|
if (cy && "mode" in state && ((state.mode === "coordination") !== (graphMode === "coordination"))) {
|
||||||
switchGraph(state.mode, includeResidualsParam, state);
|
switchGraph(state.mode, includeResidualsParam, state, needsHumanParam);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ("search" in state) searchInput.value = state.search || "";
|
if ("search" in state) searchInput.value = state.search || "";
|
||||||
|
|
@ -2836,6 +2846,15 @@ def graph_explorer_page() -> str:
|
||||||
switchGraph("coordination", residualsToggle.checked);
|
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();
|
profiles = loadProfiles();
|
||||||
renderProfiles();
|
renderProfiles();
|
||||||
const elements = (payload.elements || []).map((element) => ({
|
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 = 'refactoring']", style: {"background-color": "#b45309"}},
|
||||||
{selector: "node[flavor = 'extension']", style: {"background-color": "#0f766e"}},
|
{selector: "node[flavor = 'extension']", style: {"background-color": "#0f766e"}},
|
||||||
{selector: "node[flavor = 'residual']", style: {"background-color": "#64748b"}},
|
{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: "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",
|
||||||
|
|
@ -2974,11 +2997,11 @@ def graph_explorer_page() -> str:
|
||||||
};
|
};
|
||||||
|
|
||||||
let graphRequest = 0;
|
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 request = ++graphRequest;
|
||||||
const coordination = mode === "coordination";
|
const coordination = mode === "coordination";
|
||||||
const url = coordination
|
const url = coordination
|
||||||
? `/exports/graph-explorer?mode=coordination${includeResiduals ? "&include_residuals=true" : ""}`
|
? coordinationUrl(includeResiduals, needsHuman)
|
||||||
: "/exports/graph-explorer";
|
: "/exports/graph-explorer";
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
@ -2992,10 +3015,13 @@ def graph_explorer_page() -> str:
|
||||||
}));
|
}));
|
||||||
graphMode = coordination ? "coordination" : "full";
|
graphMode = coordination ? "coordination" : "full";
|
||||||
includeResidualsParam = includeResiduals;
|
includeResidualsParam = includeResiduals;
|
||||||
|
needsHumanParam = needsHuman;
|
||||||
activeMode = mode;
|
activeMode = mode;
|
||||||
modeSelect.value = mode;
|
modeSelect.value = mode;
|
||||||
document.getElementById("include-residuals-wrap").style.display = coordination ? "" : "none";
|
document.getElementById("include-residuals-wrap").style.display = coordination ? "" : "none";
|
||||||
document.getElementById("include-residuals").checked = includeResiduals;
|
document.getElementById("include-residuals").checked = includeResiduals;
|
||||||
|
document.getElementById("needs-human-wrap").style.display = coordination ? "" : "none";
|
||||||
|
document.getElementById("needs-human").checked = needsHuman;
|
||||||
selected = null;
|
selected = null;
|
||||||
selectedAnchor = null;
|
selectedAnchor = null;
|
||||||
selectedZoneId = "";
|
selectedZoneId = "";
|
||||||
|
|
@ -3031,6 +3057,7 @@ def graph_explorer_page() -> str:
|
||||||
if (request !== graphRequest) return;
|
if (request !== graphRequest) return;
|
||||||
modeSelect.value = activeMode;
|
modeSelect.value = activeMode;
|
||||||
document.getElementById("include-residuals").checked = includeResidualsParam;
|
document.getElementById("include-residuals").checked = includeResidualsParam;
|
||||||
|
document.getElementById("needs-human").checked = needsHumanParam;
|
||||||
profileSummary.textContent = `Could not load graph: ${error.message}`;
|
profileSummary.textContent = `Could not load graph: ${error.message}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -144,8 +144,13 @@ class RegistryHandler(BaseHTTPRequestHandler):
|
||||||
"true",
|
"true",
|
||||||
"yes",
|
"yes",
|
||||||
}
|
}
|
||||||
|
needs_human = (_query_optional(query, "needs_human") or "").lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
return HTTPStatus.OK, coordination_graph_payload(
|
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(
|
return HTTPStatus.OK, fabric_graph_explorer_payload(
|
||||||
self.store.combined_graph(),
|
self.store.combined_graph(),
|
||||||
|
|
|
||||||
|
|
@ -191,3 +191,147 @@ def test_canonical_id_depends_on_resolves_to_hub_uuid() -> None:
|
||||||
]
|
]
|
||||||
assert depends
|
assert depends
|
||||||
assert depends[0]["target"] == "workplan:uuid-b"
|
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
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ def test_graph_switch_replaces_data_and_retains_previous_graph_on_failure(tmp_pa
|
||||||
)[0]
|
)[0]
|
||||||
script = r'''
|
script = r'''
|
||||||
const assert = require("node:assert/strict");
|
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 selected, selectedAnchor, selectedZoneId, focusSet, manualOverrides, filterRules;
|
||||||
let editingRuleId, currentProfileId, allEdgeTypes;
|
let editingRuleId, currentProfileId, allEdgeTypes;
|
||||||
const layerColors = {}, allNodeTypes = [], nodeTypeLabels = {};
|
const layerColors = {}, allNodeTypes = [], nodeTypeLabels = {};
|
||||||
|
|
@ -43,6 +45,14 @@ let graphRequest = 0;
|
||||||
assert.equal(modeSelect.value, "coordination");
|
assert.equal(modeSelect.value, "coordination");
|
||||||
assert.equal(controls["include-residuals-wrap"].style.display, "");
|
assert.equal(controls["include-residuals-wrap"].style.display, "");
|
||||||
assert.match(urls[0], /include_residuals=true/);
|
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};
|
response = {ok: false, status: 503};
|
||||||
await switchGraph("full");
|
await switchGraph("full");
|
||||||
assert.equal(data[0].data.id, "workplan:a");
|
assert.equal(data[0].data.id, "workplan:a");
|
||||||
|
|
@ -54,6 +64,7 @@ let graphRequest = 0;
|
||||||
assert.equal(activeMode, "by-access-zone");
|
assert.equal(activeMode, "by-access-zone");
|
||||||
assert.equal(graphMode, "full");
|
assert.equal(graphMode, "full");
|
||||||
assert.equal(controls["include-residuals-wrap"].style.display, "none");
|
assert.equal(controls["include-residuals-wrap"].style.display, "none");
|
||||||
|
assert.equal(controls["needs-human-wrap"].style.display, "none");
|
||||||
let resolveOld;
|
let resolveOld;
|
||||||
fetch = () => new Promise(resolve => {resolveOld = resolve;});
|
fetch = () => new Promise(resolve => {resolveOld = resolve;});
|
||||||
const oldRequest = switchGraph("coordination");
|
const oldRequest = switchGraph("coordination");
|
||||||
|
|
@ -62,7 +73,7 @@ let graphRequest = 0;
|
||||||
resolveOld({ok: true, json: async () => ({elements: [{data: {id: "stale"}}]})});
|
resolveOld({ok: true, json: async () => ({elements: [{data: {id: "stale"}}]})});
|
||||||
await oldRequest;
|
await oldRequest;
|
||||||
assert.equal(data[0].data.id, "latest");
|
assert.equal(data[0].data.id, "latest");
|
||||||
assert.equal(updated, 3);
|
assert.equal(updated, 5);
|
||||||
})().catch(error => {console.error(error); process.exitCode = 1;});
|
})().catch(error => {console.error(error); process.exitCode = 1;});
|
||||||
'''
|
'''
|
||||||
path = tmp_path / "switch.cjs"
|
path = tmp_path / "switch.cjs"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue