feat(orchestrator): tasks tab with register, inbox and queue
Add a Tasks tab that lists the kvb-org-rasci register next to the inbox and the heavy-job queue, in list, board and RASCI matrix views, each task with its roles, WSJF, open work and the merge flow of its worktree. - Task priority pins and WSJF estimates are signed with the operator's passkey and applied by the runner through rasci.py; the hub verifies the signature itself before it queues a job or logs the change, the runner verifies it again and runs only the checked argv, from /, with a pinned PATH and a 30 s process-group limit. - The runner refuses task changes into a council that is a symlink, not owned by its uid or given by a relative path, and while the installed council.py, nofollow.py or rasci.py differ from the tree. - Only tasks the hub can sign whole are editable (task slug and length). - Page lookups keyed by register text use Object.hasOwn, so a status or party named like an Object.prototype member cannot break the views. - The inbox screen renders through the tasks screen, so its task filter shows with "Wartet auf Operator" pressed. Tests: 604 unit tests OK, e2e 15/15 OK (Chromium and Firefox). 🎯 Fix: 93 % 💥 Break: 5 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 88 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
01f791a1f4
commit
c1310c3322
37 files changed
+1436
-80
No files matched your search
@@ -21,6 +21,7 @@ from .signed import (
|
||||
signing,
|
||||
verifier,
|
||||
)
|
||||
from .tasking import task_request
|
||||
|
||||
BATCH_LIMIT = 100
|
||||
INBOX_KINDS = ("review", "ask", "decision", "operator", "rule")
|
||||
@@ -242,6 +243,8 @@ class Approving:
|
||||
signed = self.deletable(raw)
|
||||
elif office_request(raw):
|
||||
signed = self.appointable(raw)
|
||||
elif task_request(raw):
|
||||
signed = self.task_changes(raw)
|
||||
else:
|
||||
signed = [item["signed"] for item in self.checked(raw) if item["signed"] is not None]
|
||||
return self.challenge_for(signed)
|
||||
|
||||
@@ -41,14 +41,24 @@ def timer_rows(folder: Path) -> list[dict]:
|
||||
return rows
|
||||
|
||||
|
||||
def queue_rows(council: Path) -> list[dict]:
|
||||
def queue_entries(council: Path) -> list[tuple[dict, float]]:
|
||||
"""Returns:
|
||||
Each kvb-org-queue entry of queue.json with its enqueue time; an
|
||||
entry without one is no entry.
|
||||
"""
|
||||
data = read_json(council / "queue.json")
|
||||
entries = data.get("entries") if isinstance(data, dict) else None
|
||||
rows = []
|
||||
found = []
|
||||
for entry in entries if isinstance(entries, list) else []:
|
||||
created = epoch(entry.get("enqueued")) if isinstance(entry, dict) else None
|
||||
if created is None:
|
||||
continue
|
||||
if created is not None:
|
||||
found.append((entry, created))
|
||||
return found
|
||||
|
||||
|
||||
def queue_rows(council: Path) -> list[dict]:
|
||||
rows = []
|
||||
for entry, created in queue_entries(council):
|
||||
running = "läuft" if entry.get("status") == "active" else "wartet"
|
||||
rows.append({
|
||||
"key": f"queue:{shown(entry.get('id'))}", "type": "queue", "session": shown(entry.get("session")), "sub": "", "created": created,
|
||||
|
||||
@@ -39,6 +39,7 @@ CLEANUP_ROUTE = "/api/cleanup"
|
||||
CLEANUP_STATE = re.compile(r"/api/cleanup/([0-9a-f]{16})")
|
||||
OFFICES_ROUTE = "/api/offices"
|
||||
OFFICE_STATE = re.compile(r"/api/offices/([0-9a-f]{16})")
|
||||
TASKS_ROUTE = "/api/tasks"
|
||||
VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})")
|
||||
VENDOR = {"3d-force-graph.min.js": "3d-force-graph/3d-force-graph.min.js", "three.module.js": "three/three.module.js", "three.core.js": "three/three.core.js"}
|
||||
SCRIPT_TYPE = "text/javascript; charset=utf-8"
|
||||
@@ -48,7 +49,7 @@ MESSAGE_SIGN_ROUTE = "/api/messages/sign"
|
||||
BOARD_SIGN_ROUTE = "/api/board/sign"
|
||||
SETTINGS_SIGN_ROUTE = "/api/settings/sign"
|
||||
WAV_POSTS = (STT_ROUTE, VOICE_ROUTE)
|
||||
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE)
|
||||
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE)
|
||||
|
||||
|
||||
class Handler(Exchange):
|
||||
@@ -157,6 +158,8 @@ class Handler(Exchange):
|
||||
return lambda body: self.answer(lambda: hub.cleanup(body), HTTPStatus.ACCEPTED)
|
||||
if path == OFFICES_ROUTE:
|
||||
return lambda body: self.answer(lambda: hub.appoint(body), HTTPStatus.ACCEPTED)
|
||||
if path == TASKS_ROUTE:
|
||||
return lambda body: self.answer(lambda: hub.change_tasks(body), HTTPStatus.ACCEPTED)
|
||||
if inbox:
|
||||
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
|
||||
if undo:
|
||||
|
||||
@@ -31,6 +31,7 @@ from .settings import Settings
|
||||
from .signed import signing
|
||||
from .skill_scripts import Runner
|
||||
from .sources import Sources
|
||||
from .tasking import Tasking
|
||||
from .timeline import Timeline
|
||||
from .transcripts import Transcripts
|
||||
from .tree import TREE_HEARTBEAT, Tree
|
||||
@@ -60,7 +61,7 @@ def environment(templates: Path) -> jinja2.Environment:
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Denying, Requesting, Cleaning, Offices, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
|
||||
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Denying, Requesting, Cleaning, Offices, Tasking, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
|
||||
council: Path
|
||||
pages: Path
|
||||
public_port: int
|
||||
|
||||
@@ -11,10 +11,17 @@ from .skill_scripts import OPERATOR_SCRIPT, OUTPUT_LIMIT
|
||||
JOB_OPEN = ("queued", "running")
|
||||
RUN_OUTPUT = 64 << 10
|
||||
CONSOLE = ("id", "status", "code", "output", "created_ts", "started", "finished")
|
||||
TASK_JOB = "task"
|
||||
|
||||
|
||||
def runner_job(job: dict) -> dict:
|
||||
return {**{key: job[key] for key in ("id", "task", "command", "directory", "sha256")}, "proof": job.get("proof")}
|
||||
"""Returns:
|
||||
What the runner claims: an operator command, or a task change with
|
||||
kind and argv; a runner that predates task changes verifies the
|
||||
latter as an operator command and refuses it.
|
||||
"""
|
||||
change = {"kind": TASK_JOB, "argv": job["argv"]} if job.get("kind") == TASK_JOB else {}
|
||||
return {**{key: job[key] for key in ("id", "task", "command", "directory", "sha256")}, **change, "proof": job.get("proof")}
|
||||
|
||||
|
||||
def console_of(job: dict | None) -> dict | None:
|
||||
@@ -83,7 +90,8 @@ class Jobs:
|
||||
job, changed = self.jobs.transition(job_id, "running", {"status": status, "code": code, "output": output, "finished": time.time()})
|
||||
if not changed:
|
||||
raise Conflict({"id": job_id, "status": job["status"]})
|
||||
runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if status == "done" else []
|
||||
command_done = status == "done" and job.get("kind") != TASK_JOB
|
||||
runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if command_done else []
|
||||
tail = "\n".join(output.splitlines()[-20:])
|
||||
self.log.add({"kind": "run", "title": f"{status}: {job['command'][:200]}", "text": tail, "ref": {"job": job_id, "task": job["task"], "code": code, "status": status}})
|
||||
return {"id": job_id, "status": status, "code": code, "runs": runs}
|
||||
@@ -26,7 +26,7 @@ class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
|
||||
|
||||
LLM_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect)
|
||||
SCREENS = ("stream", "inbox", "rules", "agents", "artifacts")
|
||||
SCREENS = ("stream", "inbox", "rules", "agents", "artifacts", "tasks")
|
||||
IDS = {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "Ids from items."}
|
||||
NL_ACTIONS = [
|
||||
{"name": "approve", "description": "Approve inbox items; a decision takes its recommended option.", "parameters": {"ids": IDS}},
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .errors import Conflict, Missing, ToolError
|
||||
from .fields import decode, expect
|
||||
from .jobs import TASK_JOB
|
||||
from .register import REGISTER, read_register, status_of
|
||||
from .signed import signing
|
||||
from .tasks import editable
|
||||
|
||||
CHANGE_LIMIT = 10
|
||||
CHANGE_KEYS = ("kind", "id", "argv")
|
||||
|
||||
|
||||
def task_request(raw: object) -> bool:
|
||||
return isinstance(raw, list) and bool(raw) and all(isinstance(entry, dict) and entry.get("kind") == TASK_JOB for entry in raw)
|
||||
|
||||
|
||||
def shown_command(argv: list[str]) -> str:
|
||||
return " ".join(["rasci.py", *argv])
|
||||
|
||||
|
||||
class Tasking:
|
||||
def task_changes(self, raw: object) -> list[dict]:
|
||||
"""Args:
|
||||
raw: the page's items, each {"kind": "task", "id", "argv"} with
|
||||
the rasci.py arguments of one priority pin or WSJF estimate.
|
||||
|
||||
Returns:
|
||||
The signed items, each for an open register task outside
|
||||
operator/ and hotfix/ whose argv signing.task_argv() accepts.
|
||||
"""
|
||||
if not isinstance(raw, list) or not 1 <= len(raw) <= CHANGE_LIMIT:
|
||||
raise ToolError(f"items must be a list of 1 to {CHANGE_LIMIT} task changes")
|
||||
tasks = read_register(self.council / REGISTER)
|
||||
signed: list[dict] = []
|
||||
for entry in raw:
|
||||
if not isinstance(entry, dict):
|
||||
raise ToolError("every item must be an object with kind, id and argv")
|
||||
expect(entry, CHANGE_KEYS)
|
||||
try:
|
||||
argv = signing.task_argv(entry["id"], entry["argv"])
|
||||
except signing.Refused as error:
|
||||
raise ToolError(f"{entry['id']!s:.200}: {error}") from None
|
||||
row = tasks.get(entry["id"])
|
||||
if row is None:
|
||||
raise Missing(entry["id"])
|
||||
if not editable(entry["id"], row):
|
||||
raise Conflict({"id": entry["id"], "status": status_of(row)})
|
||||
item = signing.task_item(entry["id"], argv)
|
||||
if item in signed:
|
||||
raise ToolError("items must be distinct")
|
||||
signed.append(item)
|
||||
return signed
|
||||
|
||||
def change_tasks(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("items", "proof"))
|
||||
items = self.task_changes(payload["items"])
|
||||
proof = self.covered(payload["proof"], items, time.time())
|
||||
for item in items:
|
||||
self.still_signed(proof, item)
|
||||
ids = []
|
||||
for item in items:
|
||||
fields = {"kind": TASK_JOB, "task": item["id"], "argv": item["argv"], "command": shown_command(item["argv"]), "directory": "", "sha256": "", "status": "queued", "proof": proof}
|
||||
ids.append(self.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})["id"])
|
||||
commands = [shown_command(item["argv"]) for item in items]
|
||||
self.log.add({"kind": "run", "title": f"Aufgabe signiert geändert: {', '.join(item['id'] for item in items)}"[:200], "text": "\n".join(commands), "ref": {"jobs": ids, "status": "queued"}})
|
||||
return {"jobs": ids, "status": "queued"}
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import statistics
|
||||
|
||||
from .council import agent
|
||||
from .records import iso_epoch, shown
|
||||
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
|
||||
TEXT_SHOWN = 500
|
||||
FIBONACCI = (1, 2, 3, 5, 8, 13, 21, 34, 55, 89)
|
||||
LETTERS = "RASCI"
|
||||
HOTFIX = "hotfix/"
|
||||
QUEUE = "queue/"
|
||||
|
||||
|
||||
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]:
|
||||
raw = row.get("estimates")
|
||||
found = {}
|
||||
for party, entry in itertools.islice(raw.items(), PARTIES_SHOWN) if isinstance(raw, dict) else ():
|
||||
if isinstance(entry, dict) and fibonacci(entry.get("value")) and fibonacci(entry.get("complexity")):
|
||||
found[str(party)[:TEXT_SHOWN]] = {"value": entry["value"], "complexity": entry["complexity"]}
|
||||
return found
|
||||
|
||||
|
||||
def wsjf(estimates: dict[str, dict]) -> dict | None:
|
||||
"""Returns:
|
||||
Median business value, median complexity and their quotient, the
|
||||
same reading as rasci.py's wsjf(); None without an estimate.
|
||||
"""
|
||||
if not estimates:
|
||||
return None
|
||||
value = statistics.median(entry["value"] for entry in estimates.values())
|
||||
complexity = statistics.median(entry["complexity"] for entry in estimates.values())
|
||||
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"]]}
|
||||
|
||||
|
||||
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),
|
||||
"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,
|
||||
"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]),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -11,24 +12,57 @@ from .register import ENDED, status_of
|
||||
from .signed import signing
|
||||
|
||||
LABEL_PARTS = 3
|
||||
FLOW_LIMIT = 16
|
||||
FLOW_REF = re.compile(r"[^\x00-\x20\x7f]{1,255}")
|
||||
FLOW_SOURCE = re.compile(r"[a-z][a-z0-9-]{0,31}")
|
||||
INVALID = "invalid"
|
||||
# renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age
|
||||
TREE_HEARTBEAT = "tree-heartbeat"
|
||||
|
||||
|
||||
def flow(item: dict) -> dict | None:
|
||||
"""Returns:
|
||||
The worktree's pkgmgr merge flow: pull, the sources merged into the
|
||||
branch before a gate, and push, the targets the finished branch
|
||||
merges into, each capped at FLOW_LIMIT; None when the item names no
|
||||
flow, source invalid with empty lists when pkgmgr found the flow
|
||||
file broken or the field has the wrong shape.
|
||||
"""
|
||||
raw = item.get("flow")
|
||||
if raw is None:
|
||||
return None
|
||||
broken = {"pull": [], "push": [], "source": INVALID}
|
||||
if not isinstance(raw, dict) or not isinstance(raw.get("source"), str) or not FLOW_SOURCE.fullmatch(raw["source"]) or raw["source"] == INVALID:
|
||||
return broken
|
||||
lists = [raw.get(side)[:FLOW_LIMIT] if isinstance(raw.get(side), list) else None for side in ("pull", "push")]
|
||||
if not all(names is not None and all(isinstance(name, str) and FLOW_REF.fullmatch(name) for name in names) for names in lists):
|
||||
return broken
|
||||
return {"pull": lists[0], "push": lists[1], "source": raw["source"]}
|
||||
|
||||
|
||||
def work(item: dict, dirty: bool | None) -> list[str]:
|
||||
ahead = item["ahead"]
|
||||
found = (("dirty", dirty is True), ("unpushed", isinstance(ahead, int) and ahead > 0), ("unmerged", item["state"] == "unmerged"))
|
||||
return [reason for reason, hit in found if hit]
|
||||
|
||||
|
||||
def worktree(item: dict) -> dict:
|
||||
state = item["worktree_state"]
|
||||
unread = state == "changed" and item["staged"] is None
|
||||
dirty = None if state == "missing" or unread else state == "changed"
|
||||
return {
|
||||
"path": item["worktree"],
|
||||
"head": item["head"] or "",
|
||||
"branch": item["branch"] or "",
|
||||
"prunable": state == "missing",
|
||||
"dirty": None if state == "missing" or unread else state == "changed",
|
||||
"dirty": dirty,
|
||||
"state": state,
|
||||
"staged": item["staged"],
|
||||
"unstaged": item["unstaged"],
|
||||
"untracked": item["untracked"],
|
||||
"locked": item["locked"],
|
||||
"flow": flow(item),
|
||||
"open_work": work(item, dirty),
|
||||
}
|
||||
|
||||
|
||||
@@ -131,12 +165,13 @@ def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list
|
||||
trees = []
|
||||
for tree in item["worktrees"]:
|
||||
workers = set()
|
||||
for task, row in current.items():
|
||||
if owns(row, tree["path"], item["name"]):
|
||||
workers |= holders[task]
|
||||
trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"])})
|
||||
held = sorted(task for task, row in current.items() if owns(row, tree["path"], item["name"]))
|
||||
for task in held:
|
||||
workers |= holders[task]
|
||||
open_work = [*tree["open_work"], "task"] if held else tree["open_work"]
|
||||
trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"]), "tasks": held, "open_work": open_work})
|
||||
branches.append({**item, "worktrees": trees})
|
||||
repos.append({**entry, "branches": branches, "detached": [{**tree, "workers": [], "review": latest.get(tree["path"])} for tree in entry["detached"]]})
|
||||
repos.append({**entry, "branches": branches, "detached": [{**tree, "workers": [], "review": latest.get(tree["path"]), "tasks": []} for tree in entry["detached"]]})
|
||||
return {**scan, "repos": repos}
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from .council import (
|
||||
sessions,
|
||||
silence_of,
|
||||
)
|
||||
from .crons import queue_entries
|
||||
from .errors import ToolError
|
||||
from .events import KINDS, VIEWS
|
||||
from .fields import decode, instant, single
|
||||
@@ -35,6 +36,7 @@ from .reviewing import review_row
|
||||
from .settings import media_origins, valid_settings
|
||||
from .signed import signing
|
||||
from .skill_scripts import OUTPUT_LIMIT
|
||||
from .tasks import task_rows
|
||||
|
||||
RADIO_WINDOW = 86400
|
||||
JOBS_SHOWN = 20
|
||||
@@ -126,6 +128,7 @@ class View:
|
||||
"messages": [message_row(message) for message in self.messages.listed() if begin <= message["created_ts"] <= end],
|
||||
"operator": operator_rows(tasks),
|
||||
"register": register_rows(tasks),
|
||||
"tasks": task_rows(tasks, queue_entries(self.council), self.tree.get()),
|
||||
"agent_order": agent_order(tasks, claims, stored_order(self.council / AGENT_ORDER)),
|
||||
"radio": radio,
|
||||
"claims": claims,
|
||||
|
||||
Reference in new issue
Block a user