2026-11-07 23:43:26 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import itertools
|
|
|
|
|
import statistics
|
|
|
|
|
|
2026-11-09 06:53:55 +00:00
|
|
|
from .council import HUMAN, agent
|
|
|
|
|
from .records import epoch, iso_epoch, shown
|
2026-11-07 23:43:26 +00:00
|
|
|
from .register import ENDED, OPERATOR, parties, register_row, status_of
|
|
|
|
|
from .signed import signing
|
|
|
|
|
from .tree import inside
|
|
|
|
|
|
|
|
|
|
ENDED_SHOWN = 100
|
|
|
|
|
OPEN_SHOWN = 300
|
|
|
|
|
QUEUE_SHOWN = 100
|
|
|
|
|
PARTIES_SHOWN = 40
|
2026-11-09 06:53:55 +00:00
|
|
|
AFTER_SHOWN = 40
|
2026-11-07 23:43:26 +00:00
|
|
|
TEXT_SHOWN = 500
|
|
|
|
|
FIBONACCI = (1, 2, 3, 5, 8, 13, 21, 34, 55, 89)
|
|
|
|
|
LETTERS = "RASCI"
|
|
|
|
|
HOTFIX = "hotfix/"
|
|
|
|
|
QUEUE = "queue/"
|
2026-11-09 06:53:55 +00:00
|
|
|
HOUR = 3600
|
2026-11-07 23:43:26 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def kind_of(task: str) -> str:
|
|
|
|
|
if task.startswith(OPERATOR):
|
|
|
|
|
return "command"
|
|
|
|
|
return "hotfix" if task.startswith(HOTFIX) else "task"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def editable(task: str, row: dict) -> bool:
|
|
|
|
|
shown_whole = len(task) <= TEXT_SHOWN and signing.TASK.fullmatch(task) is not None
|
|
|
|
|
return shown_whole and kind_of(task) == "task" and status_of(row) not in ENDED
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fibonacci(value: object) -> bool:
|
|
|
|
|
return isinstance(value, int) and not isinstance(value, bool) and value in FIBONACCI
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def estimates_of(row: dict) -> dict[str, dict]:
|
2026-11-09 06:53:55 +00:00
|
|
|
"""Returns:
|
|
|
|
|
Each party's Fibonacci business value and complexity; the
|
|
|
|
|
Operator's complexity is None where it recorded a business value
|
|
|
|
|
alone.
|
|
|
|
|
"""
|
2026-11-07 23:43:26 +00:00
|
|
|
raw = row.get("estimates")
|
|
|
|
|
found = {}
|
|
|
|
|
for party, entry in itertools.islice(raw.items(), PARTIES_SHOWN) if isinstance(raw, dict) else ():
|
2026-11-09 06:53:55 +00:00
|
|
|
alone = party == HUMAN and isinstance(entry, dict) and "complexity" not in entry
|
|
|
|
|
if isinstance(entry, dict) and fibonacci(entry.get("value")) and (alone or fibonacci(entry.get("complexity"))):
|
|
|
|
|
found[str(party)[:TEXT_SHOWN]] = {"value": entry["value"], "complexity": entry.get("complexity")}
|
2026-11-07 23:43:26 +00:00
|
|
|
return found
|
|
|
|
|
|
|
|
|
|
|
2026-11-09 06:53:55 +00:00
|
|
|
def business_value(estimates: dict[str, dict]) -> float | None:
|
|
|
|
|
if HUMAN in estimates:
|
|
|
|
|
return estimates[HUMAN]["value"]
|
|
|
|
|
return statistics.median(entry["value"] for entry in estimates.values()) if estimates else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def points(estimates: dict[str, dict]) -> float | None:
|
|
|
|
|
sized = [entry["complexity"] for entry in estimates.values() if entry["complexity"] is not None]
|
|
|
|
|
return statistics.median(sized) if sized else None
|
|
|
|
|
|
|
|
|
|
|
2026-11-07 23:43:26 +00:00
|
|
|
def wsjf(estimates: dict[str, dict]) -> dict | None:
|
|
|
|
|
"""Returns:
|
2026-11-09 06:53:55 +00:00
|
|
|
Business value, median complexity and their quotient, the same
|
|
|
|
|
reading as rasci.py's wsjf(): the Operator's business value where
|
|
|
|
|
it gave one, else the median; None while no estimate carries a
|
|
|
|
|
complexity.
|
2026-11-07 23:43:26 +00:00
|
|
|
"""
|
2026-11-09 06:53:55 +00:00
|
|
|
complexity = points(estimates)
|
|
|
|
|
if complexity is None:
|
2026-11-07 23:43:26 +00:00
|
|
|
return None
|
2026-11-09 06:53:55 +00:00
|
|
|
value = business_value(estimates)
|
2026-11-07 23:43:26 +00:00
|
|
|
return {"value": value, "complexity": complexity, "score": round(value / complexity, 2)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def prio_of(row: dict) -> int | None:
|
|
|
|
|
prio = row.get("prio")
|
|
|
|
|
return prio if isinstance(prio, int) and not isinstance(prio, bool) else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def roles_of(row: dict) -> dict[str, str]:
|
|
|
|
|
letters = {party[:TEXT_SHOWN]: "".join(letter for letter in LETTERS if letter in held) for party, held in itertools.islice(parties(row).items(), PARTIES_SHOWN)}
|
|
|
|
|
return {party: held for party, held in letters.items() if held}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def worktrees(scan: dict) -> list[dict]:
|
|
|
|
|
found = []
|
|
|
|
|
for repo in scan["repos"]:
|
|
|
|
|
trees = [(entry["name"], tree) for entry in repo["branches"] for tree in entry["worktrees"]] + [("", tree) for tree in repo["detached"]]
|
|
|
|
|
found += [{"path": tree["path"], "branch": name, "repo": repo["name"], "flow": tree["flow"], "open_work": tree["open_work"]} for name, tree in trees]
|
|
|
|
|
return found
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def placed(row: dict, trees: list[dict]) -> dict | None:
|
|
|
|
|
"""Returns:
|
|
|
|
|
The worktree a register row or queue entry works in: the innermost
|
|
|
|
|
one holding its workdir, else the only one on its branch.
|
|
|
|
|
"""
|
|
|
|
|
workdir, name = row.get("workdir"), row.get("branch")
|
|
|
|
|
if isinstance(workdir, str) and workdir.startswith("/"):
|
|
|
|
|
holding = [tree for tree in trees if inside(workdir, tree["path"])]
|
|
|
|
|
if holding:
|
|
|
|
|
return max(holding, key=lambda tree: len(tree["path"]))
|
|
|
|
|
named = [tree for tree in trees if isinstance(name, str) and name and tree["branch"] == name]
|
|
|
|
|
return named[0] if len(named) == 1 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def located(row: dict, trees: list[dict], open_task: bool) -> dict:
|
|
|
|
|
tree = placed(row, trees)
|
|
|
|
|
own = ["task"] if open_task else []
|
|
|
|
|
if tree is None:
|
|
|
|
|
return {"worktree": "", "worktree_branch": "", "repo": "", "flow": None, "open_work": own}
|
|
|
|
|
return {"worktree": tree["path"], "worktree_branch": tree["branch"], "repo": tree["repo"], "flow": tree["flow"], "open_work": [*own, *tree["open_work"]]}
|
|
|
|
|
|
|
|
|
|
|
2026-11-09 06:53:55 +00:00
|
|
|
def after_of(row: dict) -> list[str]:
|
|
|
|
|
raw = row.get("after")
|
|
|
|
|
return [other for other in raw[:AFTER_SHOWN] if isinstance(other, str) and len(other) <= TEXT_SHOWN] if isinstance(raw, list) else []
|
|
|
|
|
|
|
|
|
|
|
2026-11-07 23:43:26 +00:00
|
|
|
def task_row(task: str, row: dict, trees: list[dict]) -> dict:
|
|
|
|
|
estimates = estimates_of(row)
|
|
|
|
|
ended = status_of(row) in ENDED
|
|
|
|
|
shown_row = {key: value[:TEXT_SHOWN] if isinstance(value, str) else value for key, value in register_row(task, row).items()}
|
|
|
|
|
return {
|
|
|
|
|
**shown_row,
|
|
|
|
|
"kind": kind_of(task),
|
|
|
|
|
"ended": ended,
|
|
|
|
|
"editable": editable(task, row),
|
|
|
|
|
"prio": prio_of(row),
|
|
|
|
|
"estimates": estimates,
|
|
|
|
|
"wsjf": wsjf(estimates),
|
2026-11-09 06:53:55 +00:00
|
|
|
"value": business_value(estimates),
|
|
|
|
|
"points": points(estimates),
|
|
|
|
|
"start": iso_epoch(row.get("start")),
|
|
|
|
|
"end": iso_epoch(row.get("end")) if ended else None,
|
|
|
|
|
"after": after_of(row),
|
|
|
|
|
"base": shown(row.get("base"))[:TEXT_SHOWN],
|
2026-11-07 23:43:26 +00:00
|
|
|
"roles": roles_of(row),
|
|
|
|
|
"queue": None,
|
|
|
|
|
**located(row, trees, not ended),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def queue_row(entry: dict, created: float, trees: list[dict]) -> dict:
|
|
|
|
|
text = {name: shown(entry.get(name))[:TEXT_SHOWN] for name in ("id", "cmd", "kind", "task", "workdir")}
|
|
|
|
|
return {
|
|
|
|
|
"task": f"{QUEUE}{text['id']}",
|
|
|
|
|
"title": text["cmd"],
|
|
|
|
|
"status": "running" if entry.get("status") == "active" else "awaiting",
|
|
|
|
|
"worker": agent(entry.get("name"))[:TEXT_SHOWN] or "-",
|
|
|
|
|
"responsible": "-",
|
|
|
|
|
"accountable": "-",
|
|
|
|
|
"objective": "-",
|
|
|
|
|
"branch": "-",
|
|
|
|
|
"workdir": text["workdir"],
|
|
|
|
|
"met": 0,
|
|
|
|
|
"total": 0,
|
|
|
|
|
"kind": "queue",
|
|
|
|
|
"ended": False,
|
|
|
|
|
"editable": False,
|
|
|
|
|
"prio": None,
|
|
|
|
|
"estimates": {},
|
|
|
|
|
"wsjf": None,
|
2026-11-09 06:53:55 +00:00
|
|
|
"value": None,
|
|
|
|
|
"points": None,
|
|
|
|
|
"start": (epoch(entry.get("granted")) or created) if entry.get("status") == "active" else None,
|
|
|
|
|
"end": None,
|
|
|
|
|
"after": [],
|
|
|
|
|
"base": "-",
|
2026-11-07 23:43:26 +00:00
|
|
|
"roles": {},
|
|
|
|
|
"queue": {"kind": text["kind"], "parent": text["task"], "enqueued": created},
|
|
|
|
|
**located(entry, trees, False),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rank(task: str, row: dict) -> tuple:
|
|
|
|
|
prio, score = prio_of(row), wsjf(estimates_of(row))
|
|
|
|
|
return (prio is None, prio or 0, score is None, -(score["score"] if score else 0), task)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def task_rows(tasks: dict[str, dict], queue: list[tuple[dict, float]], scan: dict) -> list[dict]:
|
|
|
|
|
"""Args:
|
|
|
|
|
tasks: the register rows by task.
|
|
|
|
|
queue: the kvb-org-queue entries with their enqueue time.
|
|
|
|
|
scan: the pkgmgr tree as read_tree() maps it.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The OPEN_SHOWN first open tasks in register rank (pin, then WSJF,
|
|
|
|
|
then name), the QUEUE_SHOWN oldest queue jobs, then the ENDED_SHOWN
|
|
|
|
|
most recently ended tasks; each with its worktree's flow and open work.
|
|
|
|
|
"""
|
|
|
|
|
trees = worktrees(scan)
|
|
|
|
|
current = sorted((task for task, row in tasks.items() if status_of(row) not in ENDED), key=lambda task: rank(task, tasks[task]))
|
|
|
|
|
ended = sorted((task for task, row in tasks.items() if status_of(row) in ENDED), key=lambda task: (-(iso_epoch(tasks[task].get("end")) or 0), task))
|
|
|
|
|
jobs = sorted(queue, key=lambda pair: pair[1])[:QUEUE_SHOWN]
|
|
|
|
|
return [
|
|
|
|
|
*(task_row(task, tasks[task], trees) for task in current[:OPEN_SHOWN]),
|
|
|
|
|
*(queue_row(entry, created, trees) for entry, created in jobs),
|
|
|
|
|
*(task_row(task, tasks[task], trees) for task in ended[:ENDED_SHOWN]),
|
|
|
|
|
]
|
2026-11-09 06:53:55 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_ranks(tasks: dict[str, dict], agents: list[str]) -> dict[str, int]:
|
|
|
|
|
"""Returns:
|
|
|
|
|
Each open task's index in rasci.py's ranked(): pin, WSJF, the
|
|
|
|
|
worker's place in the operator's agent order, then the name.
|
|
|
|
|
"""
|
|
|
|
|
order = {name: index for index, name in enumerate(agents)}
|
|
|
|
|
|
|
|
|
|
def key(task: str) -> tuple:
|
|
|
|
|
worker = tasks[task].get("worker")
|
|
|
|
|
return (*rank(task, tasks[task])[:4], order.get(worker, len(order)) if isinstance(worker, str) else len(order), task)
|
|
|
|
|
|
|
|
|
|
current = sorted((task for task, row in tasks.items() if status_of(row) not in ENDED), key=key)
|
|
|
|
|
return {task: index for index, task in enumerate(current)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def queue_chain(queue: list[tuple[dict, float]], tasks: dict[str, dict], agents: list[str], now: float) -> list[tuple[str, bool]]:
|
|
|
|
|
"""Args:
|
|
|
|
|
queue: the kvb-org-queue entries with their enqueue time.
|
|
|
|
|
tasks: the register rows by task.
|
|
|
|
|
agents: the operator's agent order.
|
|
|
|
|
now: epoch seconds.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The entry ids in grant order, each with whether it waits: the
|
|
|
|
|
active ones by grant time, then the waiting ones as queue.py's
|
|
|
|
|
ordered() sorts them (hotfix first, then the register rank less one
|
|
|
|
|
per whole hour waited, then the holder's agent order, then the
|
|
|
|
|
enqueue sequence).
|
|
|
|
|
"""
|
|
|
|
|
ranks = register_ranks(tasks, agents)
|
|
|
|
|
order = {name: index for index, name in enumerate(agents)}
|
|
|
|
|
|
|
|
|
|
def waiting_key(pair: tuple[dict, float]) -> tuple:
|
|
|
|
|
entry, created = pair
|
|
|
|
|
task, name, seq = entry.get("task"), entry.get("name"), entry.get("seq")
|
|
|
|
|
hours = int((now - created) // HOUR)
|
|
|
|
|
place = ranks[task] if isinstance(task, str) and task in ranks else len(ranks)
|
|
|
|
|
holder = order[name] if isinstance(name, str) and name in order else len(order)
|
|
|
|
|
return (not entry.get("hotfix"), place - hours, holder, seq if isinstance(seq, int) and not isinstance(seq, bool) else 0)
|
|
|
|
|
|
|
|
|
|
active = sorted((pair for pair in queue if pair[0].get("status") == "active"), key=lambda pair: epoch(pair[0].get("granted")) or pair[1])
|
|
|
|
|
waiting = sorted((pair for pair in queue if pair[0].get("status") == "waiting"), key=waiting_key)
|
|
|
|
|
return [(shown(entry.get("id"))[:TEXT_SHOWN], entry.get("status") == "waiting") for entry, _ in [*active, *waiting]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def branch_of(row: dict) -> str:
|
|
|
|
|
return row["branch"] if row["branch"] != "-" else row["worktree_branch"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def task_edges(rows: list[dict], chain: list[tuple[str, bool]]) -> list[dict]:
|
|
|
|
|
"""Args:
|
|
|
|
|
rows: the shown task rows of task_rows().
|
|
|
|
|
chain: queue_chain().
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
One {"from", "to", "kind"} edge per predecessor among the shown
|
|
|
|
|
rows: kind after for an explicit `rasci.py set --after`, base for a
|
|
|
|
|
task whose base branch is another task's branch, queue for each
|
|
|
|
|
waiting queue job and the job ahead of it.
|
|
|
|
|
"""
|
|
|
|
|
shown_tasks = {row["task"] for row in rows}
|
|
|
|
|
edges = [{"from": other, "to": row["task"], "kind": "after"} for row in rows for other in row["after"] if other in shown_tasks and other != row["task"]]
|
|
|
|
|
branches: dict[str, list[str]] = {}
|
|
|
|
|
for row in rows:
|
|
|
|
|
if row["kind"] != "queue" and branch_of(row):
|
|
|
|
|
branches.setdefault(branch_of(row), []).append(row["task"])
|
|
|
|
|
edges += [{"from": other, "to": row["task"], "kind": "base"} for row in rows if row["kind"] != "queue" for other in branches.get(row["base"], []) if other != row["task"]]
|
|
|
|
|
jobs = [(f"{QUEUE}{job}", waits) for job, waits in chain]
|
|
|
|
|
edges += [{"from": ahead, "to": job, "kind": "queue"} for (ahead, _), (job, waits) in itertools.pairwise(jobs) if waits and {ahead, job} <= shown_tasks]
|
|
|
|
|
return edges
|