feat(orchestrator): S5 Tasks with choices, Gantt, estimates and burn charts
Requirement 001 phase 6, S5: - Tasks tab: every pending ask, rule request, operator row and choice card shows under "waits on Operator"; a choice request goes out on a fixed channel button, post and call with an agent multi-select, and a click on an option sends a passkey-signed answer the agent checks with operator_message. The unsigned /api/decisions/<id>/choose route is gone, and a decision, choice answer or choice recipient whose name holds "*" is refused, so a signed answer never reaches every agent. - Gantt view: one bar per task from start to end (running to now), one arrow per explicit, branch-base and queue-order edge, hatched forecast bars (always, points only, real only) from the median duration or points times median hours per point. - rasci.py set --after/--drop-after stores task dependencies and refuses unknown tasks and cycles. - Estimation toggle (No estimate default, Story points) is stored in the hub settings; burndown and burnup per whole register, list filter or one agent or line, counting tasks or summing complexity with the unestimated tasks as their own line. - New skill kvb-org-queue-estimate: "Request estimates" (story points mode, passkey-signed board action) or the skill sends exactly one newsletter listing every unestimated task with its RASCI parties; a party name that is no plain name shows as "unnamed party". - Business value per task or in bulk is stored only with a verified passkey assertion over the exact ids and values; WSJF uses it instead of the median. The task list sorts by every decision-50 mode both ways. - e2e: the walk's pending ask lives an hour instead of 60 s, and the CDP and BiDi drivers retry removing the browser profile while the browser exits (ENOTEMPTY). Evidence: make test 709 OK, ruff and markdownlint clean (/tmp/s5-make-test-fix-2.log); e2e 26 OK in Chromium and Firefox (/tmp/s5-e2e-fix-2.log); MEDUSA 50 files, an independent skeptic rejected four holes (unsigned choose route, unsigned estimates request, free-text party names, "*" recipients incl. normalizer bypasses), all fixed and re-verified, the 8 remaining pattern hits accepted, rescan 0 open. 🎯 Fix: 92 % 💥 Break: 4 % 💸 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
5c8b69d53d
commit
d9eb121d12
49 files changed
+2162
-148
No files matched your search
+1
-1
@@ -95,6 +95,6 @@ try {
|
||||
socket.close();
|
||||
browser.kill();
|
||||
await sleep(300);
|
||||
rmSync(profile, {recursive: true, force: true});
|
||||
rmSync(profile, {recursive: true, force: true, maxRetries: 10, retryDelay: 200});
|
||||
}
|
||||
console.log(JSON.stringify({results, errors}));
|
||||
+1
-1
@@ -107,6 +107,6 @@ try {
|
||||
} finally {
|
||||
socket.close();
|
||||
browser.kill();
|
||||
rmSync(profile, {recursive: true, force: true});
|
||||
rmSync(profile, {recursive: true, force: true, maxRetries: 10, retryDelay: 200});
|
||||
}
|
||||
console.log(JSON.stringify({results, errors}));
|
||||
@@ -25,7 +25,7 @@ sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_engines import wav_bytes
|
||||
from hub_support import ASK, HOST_STATE, RUNNER, iso, verifier
|
||||
from hub_support import ASK, HOST_STATE, OPTIONS, RUNNER, iso, verifier
|
||||
from kvb_orchestrator import skill_scripts
|
||||
from test_hub_cleanup import DONE_TIP, FAKE_PKGMGR, host
|
||||
from test_hub_offices import COUNCIL_SCRIPT
|
||||
@@ -34,7 +34,7 @@ from test_hub_offices import host as office_host
|
||||
OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-phase4-shots"))
|
||||
WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
|
||||
VIEWS = ("orbit", "chord", "swimlane", "tree", "3d")
|
||||
TASK_VIEWS = ("list", "board", "rasci")
|
||||
TASK_VIEWS = ("list", "board", "rasci", "gantt", "burn")
|
||||
SCREENS = ["stream", "stream/inbox", "stream/rules", "stream/radio", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy", *(f"tasks/{view}" for view in TASK_VIEWS)]
|
||||
SUBS = ["Inbox", "Firewall", "Radio"]
|
||||
TABS = ["Stream", "Agents", "Artifacts", "Tasks"]
|
||||
@@ -727,7 +727,223 @@ ACCEPT = """(async () => {@HELPERS@@LABELS@
|
||||
await until(() => !rowOf("Rule request"), 8000);
|
||||
return {toast: text(toast), left: Boolean(rowOf("Rule request"))};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
SLOW = "seq -f 'Row %g' 1 60; sleep 4; seq -f 'Row %g' 61 80; sleep 4; echo finished"
|
||||
WATCH = """
|
||||
const questions = [];
|
||||
window.confirm = (question) => {
|
||||
questions.push(question);
|
||||
return true;
|
||||
};
|
||||
let asked = 0;
|
||||
const get = navigator.credentials.get.bind(navigator.credentials);
|
||||
navigator.credentials.get = (options) => {
|
||||
asked += 1;
|
||||
return @SIGNS@ ? get(options) : Promise.reject(new DOMException("The operator canceled.", "NotAllowedError"));
|
||||
};
|
||||
const posted = [];
|
||||
const send = window.fetch.bind(window);
|
||||
window.fetch = (path, init) => {
|
||||
if (["/api/choices/sign", "/api/choices", "/api/sign", "/api/inbox/approve"].includes(String(path))) {
|
||||
const body = JSON.parse(init.body);
|
||||
posted.push([String(path), Object.keys(body).sort(), body.class || null, body.to || null]);
|
||||
}
|
||||
return send(path, init);
|
||||
};
|
||||
const bar = () => document.querySelector('#main [aria-label="Request choices"]');
|
||||
const fresh = async (shown, prefix) => until(() => all("#toasts .toast").filter((box) => !shown.has(box)).map(text).find((entry) => entry.startsWith(prefix)), 20000);
|
||||
const fits = () => ({tall: document.documentElement.scrollHeight > window.innerHeight, wide: document.documentElement.scrollWidth > window.innerWidth + 1});
|
||||
"""
|
||||
CHOICE_VIEW = """(async () => {@HELPERS@@LABELS@
|
||||
const found = await until(() => document.querySelector('#main [aria-label="Request choices"]') && all("#main .inbox-list > li .options").length === @CARDS@ && document.querySelector('#main [aria-label="Request choices"] details.menu input[type="checkbox"]'), 8000);
|
||||
if (!found) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const box = document.querySelector('#main [aria-label="Request choices"]');
|
||||
const menu = box.querySelector("details.menu");
|
||||
const chip = document.querySelector('#main [aria-label="Task filters"] button[data-filter="operator"]');
|
||||
return {
|
||||
kinds: [...new Set(all("#main .inbox-list > li").map((li) => text(li.querySelector(".pill"))))].sort(),
|
||||
filter: [text(chip).replace(/[0-9]+$/, ""), chip.getAttribute("aria-pressed")],
|
||||
channels: all("button[data-channel]", box).map((button) => [button.dataset.channel, name(button), button.disabled]),
|
||||
fields: all("textarea, input:not([type=checkbox])", box).length,
|
||||
agents: all('input[type="checkbox"]', menu).map((input) => input.value),
|
||||
multi: [name(menu.querySelector("summary")), text(menu.querySelector("summary .count")), all('input[type="checkbox"]', menu).filter((input) => input.checked).length],
|
||||
cards: all("#main .inbox-list > li").filter((li) => li.querySelector(".options")).map((li) => [text(li.querySelector(".pill")), all(".option strong", li).map(text)]),
|
||||
choices: all("#main [data-column], #main .group-title, #main h2, #main h3").filter((node) => node.dataset.column === "choices" || text(node) === "Choices").length,
|
||||
unlabelled: unlabelled(document),
|
||||
tall: document.documentElement.scrollHeight > window.innerHeight,
|
||||
wide: wide(),
|
||||
};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
CHOICE_SEND = """(async () => {@HELPERS@@WATCH@
|
||||
const menu = bar().querySelector("details.menu");
|
||||
menu.open = true;
|
||||
await sleep(200);
|
||||
const boxes = all('input[type="checkbox"]', menu).slice(0, 2);
|
||||
boxes.forEach((box) => box.click());
|
||||
const counted = text(menu.querySelector("summary .count"));
|
||||
menu.open = false;
|
||||
const picked = boxes.map((box) => box.value).sort((a, b) => a.localeCompare(b));
|
||||
const runs = [];
|
||||
for (const button of all("button[data-channel]", bar())) {
|
||||
const [before, prompts, sent] = [asked, questions.length, posted.length];
|
||||
const shown = new Set(all("#toasts .toast"));
|
||||
const disabled = button.disabled;
|
||||
button.click();
|
||||
const toast = await fresh(shown, "Choice request");
|
||||
runs.push({channel: button.dataset.channel, disabled, asked: asked - before, question: questions.slice(prompts), posted: posted.slice(sent), toast});
|
||||
}
|
||||
return {picked, counted, runs, fields: all("textarea, input:not([type=checkbox])", bar()).length, ...fits()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@WATCH@", WATCH)
|
||||
CHOICE_ANSWER = """(async () => {@HELPERS@@WATCH@
|
||||
const card = await until(() => all("#main .inbox-list > li").find((li) => li.textContent.includes("@QUESTION@")), 8000);
|
||||
if (!card) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const option = all(".option", card).find((button) => text(button.querySelector("strong")) === "Wait");
|
||||
const title = option.title;
|
||||
const shown = new Set(all("#toasts .toast"));
|
||||
option.click();
|
||||
const toast = await fresh(shown, "Choice: Wait");
|
||||
const gone = @SIGNS@ ? Boolean(await until(() => !all("#main .inbox-list > li").some((li) => li.textContent.includes("@QUESTION@")), 20000)) : null;
|
||||
return {title, asked, posted, toast, gone, ...fits()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@WATCH@", WATCH)
|
||||
TASKS_FIT = """(async () => {@HELPERS@
|
||||
await until(() => document.querySelector('#main [aria-label="Request choices"]') && document.querySelector("#main .tasks-body > *"), 8000);
|
||||
document.getElementById("toasts").replaceChildren();
|
||||
await sleep(300);
|
||||
return {
|
||||
hash: location.hash,
|
||||
bar: all('#main [aria-label="Request choices"] button[data-channel]').map((button) => button.dataset.channel),
|
||||
columns: all("#main .task-column").map((column) => column.dataset.column),
|
||||
choices: all("#main [data-column], #main .group-title, #main h2, #main h3").filter((node) => node.dataset.column === "choices" || text(node) === "Choices").length,
|
||||
tall: document.documentElement.scrollHeight > window.innerHeight,
|
||||
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||
};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
PLAN_HELPERS = """
|
||||
const fits = () => ({tall: document.documentElement.scrollHeight > window.innerHeight, wide: document.documentElement.scrollWidth > window.innerWidth + 1});
|
||||
const modes = () => all('#main [aria-label="Estimation"] button').map((button) => [button.dataset.estimation, button.getAttribute("aria-pressed")]);
|
||||
const requestShown = () => {
|
||||
const button = document.getElementById("request-estimates");
|
||||
return button ? !button.hidden && button.getClientRects().length > 0 : null;
|
||||
};
|
||||
const choose = async (select, value) => {
|
||||
select.value = value;
|
||||
select.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
await sleep(300);
|
||||
};
|
||||
const fresh = async (shown, prefix) => until(() => all("#toasts .toast").filter((box) => !shown.has(box)).map(text).find((entry) => entry.startsWith(prefix)), 20000);
|
||||
"""
|
||||
GANTT = """(async () => {@HELPERS@@PLAN_HELPERS@
|
||||
const svg = await until(() => document.querySelector("#main svg.gantt"), 8000);
|
||||
if (!svg) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const forecast = document.querySelector('#main select[aria-label="Forecast"]');
|
||||
const scroll = document.querySelector("#main .gantt-scroll");
|
||||
const marks = () => all("#main svg.gantt [data-forecast]").map((mark) => [mark.dataset.task, mark.dataset.forecast === "true", Number(mark.dataset.start), Number(mark.dataset.end), mark.tagName.toLowerCase()]);
|
||||
const found = {
|
||||
hash: location.hash,
|
||||
forecast: [forecast.value, all("option", forecast).map((option) => [option.value, text(option)])],
|
||||
modes: modes(),
|
||||
request: requestShown(),
|
||||
bars: marks(),
|
||||
edges: all("#main svg.gantt path.gantt-edge").map((path) => [path.dataset.kind, path.dataset.from, path.dataset.to, path.getAttribute("marker-end")]),
|
||||
hatched: all("#main svg.gantt .gantt-bar.forecast").map((bar) => getComputedStyle(bar).fill.includes("gantt-hatch")),
|
||||
inner: Boolean(scroll && scroll.closest(".tasks-body")),
|
||||
...fits(),
|
||||
};
|
||||
await choose(forecast, "real");
|
||||
found.real = marks().filter(([, hatched]) => hatched).length;
|
||||
await choose(forecast, "points");
|
||||
found.pointsOnly = marks().filter(([, hatched]) => hatched).map(([task, , start, end, tag]) => [task, end - start, tag]);
|
||||
await choose(forecast, "always");
|
||||
return found;
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS)
|
||||
BURN = """(async () => {@HELPERS@@PLAN_HELPERS@
|
||||
const svg = await until(() => document.querySelector("#main svg.burn"), 8000);
|
||||
if (!svg) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const scope = document.querySelector('#main select[aria-label="Scope"]');
|
||||
const series = () => all("#main svg.burn path.burn-line").map((path) => [path.dataset.series, Number(path.dataset.last)]);
|
||||
const found = {hash: location.hash, modes: modes(), request: requestShown(), scope: [scope.value, all("option", scope).map((option) => option.value)], series: series(), ...fits()};
|
||||
await choose(scope, "agent:Docs");
|
||||
found.agent = series();
|
||||
await choose(scope, "all");
|
||||
return found;
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS)
|
||||
POINTS_ON = """(async () => {@HELPERS@@PLAN_HELPERS@
|
||||
const before = {modes: modes(), request: requestShown()};
|
||||
all('#main [aria-label="Estimation"] button').find((button) => button.dataset.estimation === "points").click();
|
||||
const shown = await until(() => requestShown(), 8000);
|
||||
return {before, modes: modes(), request: Boolean(shown), ...fits()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS)
|
||||
REQUEST = """(async () => {@HELPERS@@PLAN_HELPERS@
|
||||
const svg = await until(() => document.querySelector("#main svg.burn") && requestShown(), 8000);
|
||||
if (!svg) {
|
||||
return {error: text(document.getElementById("main")), modes: modes()};
|
||||
}
|
||||
const series = all("#main svg.burn path.burn-line").map((path) => [path.dataset.series, Number(path.dataset.last)]);
|
||||
const shown = new Set(all("#toasts .toast"));
|
||||
document.getElementById("request-estimates").click();
|
||||
const toast = await fresh(shown, "Request estimates");
|
||||
return {modes: modes(), series, toast, ...fits()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS)
|
||||
BULK = """(async () => {@HELPERS@@PLAN_HELPERS@
|
||||
const list = await until(() => all("#main .task-row").length && document.getElementById("bulk-value"), 8000);
|
||||
if (!list) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
let asked = 0;
|
||||
const get = navigator.credentials.get.bind(navigator.credentials);
|
||||
navigator.credentials.get = (options) => {
|
||||
asked += 1;
|
||||
return @SIGNS@ ? get(options) : Promise.reject(new DOMException("The operator canceled.", "NotAllowedError"));
|
||||
};
|
||||
const posted = [];
|
||||
const send = window.fetch.bind(window);
|
||||
window.fetch = (path, init) => {
|
||||
if (["/api/sign", "/api/tasks"].includes(String(path))) {
|
||||
const body = JSON.parse(init.body);
|
||||
posted.push([String(path), Object.keys(body).sort(), body.items.map((item) => [item.id, item.argv.join(" ")])]);
|
||||
}
|
||||
return send(path, init);
|
||||
};
|
||||
const rows = () => all("#main .task-row").map((row) => row.dataset.task);
|
||||
const sort = document.querySelector('#main select[aria-label="Sort"]');
|
||||
const direction = document.querySelector("#main button[data-direction]");
|
||||
const found = {sorts: all("option", sort).map((option) => option.value), prio: rows()};
|
||||
await choose(sort, "status");
|
||||
found.status = rows();
|
||||
direction.click();
|
||||
await sleep(300);
|
||||
found.statusDown = [rows(), name(direction)];
|
||||
direction.click();
|
||||
await choose(sort, "prio");
|
||||
found.picks = all("#main .task-row input.task-pick").map((box) => box.closest(".task-row").dataset.task);
|
||||
for (const task of ["work/next", "work/child"]) {
|
||||
all("#main .task-row").find((row) => row.dataset.task === task).querySelector("input.task-pick").click();
|
||||
}
|
||||
const button = document.getElementById("bulk-value");
|
||||
found.armed = [button.disabled, text(button.querySelector(".count"))];
|
||||
await choose(document.querySelector('#main select[aria-label="Business value of the selection"]'), "@VALUE@");
|
||||
const shown = new Set(all("#toasts .toast"));
|
||||
button.click();
|
||||
found.toast = await fresh(shown, "Business value @VALUE@ for 2 tasks");
|
||||
found.asked = asked;
|
||||
found.posted = posted;
|
||||
found.after = [button.disabled, all("#main .task-row input.task-pick:checked").length];
|
||||
Object.assign(found, fits());
|
||||
all('#main [aria-label="Estimation"] button').find((entry) => entry.dataset.estimation === "none").click();
|
||||
found.reset = Boolean(await until(() => requestShown() === false, 8000));
|
||||
return found;
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS)
|
||||
PLAN_VALUES = {1920: "13", 1366: "21", 390: "34"}
|
||||
STATUS_ORDER = ["queued", "awaiting", "claimed", "running", "blocked", "stale", "done", "stopped"]
|
||||
CHOICE_REQUEST = "send me your open decisions as choices"
|
||||
CHOICE_CHANNELS = ["post", "call", "newsletter", "broadcast", "pan-pan", "security", "mayday"]
|
||||
SLOW ="seq -f 'Row %g' 1 60; sleep 4; seq -f 'Row %g' 61 80; sleep 4; echo finished"
|
||||
|
||||
|
||||
def labelled(probe: dict) -> dict:
|
||||
@@ -770,7 +986,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
})
|
||||
self.subagents(now)
|
||||
self.request("feat: orchestrator", "")
|
||||
self.create_ask(ASK)
|
||||
self.create_ask({**ASK, "timeout": 3600})
|
||||
self.base = f"http://{self.host}/"
|
||||
|
||||
def subagents(self, now: float) -> None:
|
||||
@@ -1070,7 +1286,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(by_hash["#artifacts/chord"]["flowEdges"], ["pull", "push"])
|
||||
order = ["work/feature", "work/docs", "work/stage", "queue/gate-3", "work/old", "operator/push"]
|
||||
listed = by_hash["#tasks/list"]
|
||||
self.assertEqual(listed["views"], ["List", "Board", "RASCI"])
|
||||
self.assertEqual(listed["views"], ["List", "Board", "RASCI", "Gantt", "Burndown and burnup"])
|
||||
self.assertEqual(listed["taskFilters"], [["all", "true", "6"], ["open", "false", "4"], ["work", "false", "4"], ["operator", "false", "2"], ["grant", "false", "1"], ["ended", "false", "2"]])
|
||||
self.assertEqual(listed["taskRows"], [[task, task not in ("work/old", "operator/push")] for task in order])
|
||||
self.assertEqual(listed["flows"], ["file", "file"])
|
||||
@@ -1081,7 +1297,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual([probe["hash"] for probe in extra[:3]], ["#agents/orbit", "#agents/chord", "#artifacts/tree"])
|
||||
actions = extra[3]
|
||||
self.assertNotIn("error", actions)
|
||||
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#tasks/rasci", "#stream"])
|
||||
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#tasks/burn", "#stream"])
|
||||
self.assertEqual(actions["ambience"], ["sky-look", "music-toggle", "leitstand", "notify"])
|
||||
self.assertEqual((actions["settingsUnlabelled"], actions["popupUnlabelled"]), (0, 0))
|
||||
self.assertEqual([name for name, _, _ in actions["speech"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "Sécurité"])
|
||||
@@ -1425,6 +1641,227 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(probe["console"], "doneExit code 0 after 9 s")
|
||||
self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot)
|
||||
|
||||
def choice_steps(self, browser: str, signs: bool, questions: dict[int, str]) -> list[dict]:
|
||||
flag = "true" if signs else "false"
|
||||
local = f"http://localhost:{self.port}/"
|
||||
steps = []
|
||||
for index, (width, height) in enumerate(WIDTHS):
|
||||
cards = len(WIDTHS) - index if signs else len(WIDTHS)
|
||||
steps += [
|
||||
{"width": width, "height": height, "url": f"{local}?{width}#stream/inbox", "wait": 1200, "probe": CHOICE_VIEW.replace("@CARDS@", str(cards)), "shot": str(OUT / f"{browser}-{width}-tasks-choices.png")},
|
||||
{"width": width, "height": height, "url": None, "wait": 0, "probe": CHOICE_SEND.replace("@SIGNS@", flag), "shot": str(OUT / f"{browser}-{width}-choice-request.png")},
|
||||
{"width": width, "height": height, "url": None, "wait": 0, "probe": CHOICE_ANSWER.replace("@SIGNS@", flag).replace("@QUESTION@", questions[width]), "shot": str(OUT / f"{browser}-{width}-choice-answer.png")},
|
||||
*({"width": width, "height": height, "url": f"{local}?{width}#tasks/{view}", "wait": 900, "probe": TASKS_FIT, "shot": str(OUT / f"{browser}-{width}-tasks-{view}-fit.png")} for view in TASK_VIEWS),
|
||||
]
|
||||
return steps
|
||||
|
||||
def requested(self, sent: dict, signs: bool) -> None:
|
||||
"""Args:
|
||||
sent: the CHOICE_SEND probe: two agents ticked, then every channel
|
||||
button pressed once.
|
||||
"""
|
||||
labels = dict(zip(CHOICE_CHANNELS, ["Post", "Call", "Newsletter", "Broadcast", "Pan-Pan", "Sécurité", "Mayday"], strict=True))
|
||||
self.assertEqual(len(sent["picked"]), 2, sent)
|
||||
self.assertEqual((sent["counted"], sent["fields"], sent["tall"], sent["wide"]), ("2", 0, False, False))
|
||||
self.assertEqual([run["channel"] for run in sent["runs"]], CHOICE_CHANNELS)
|
||||
for run in sent["runs"]:
|
||||
with self.subTest(channel=run["channel"]):
|
||||
to = sent["picked"] if run["channel"] in ("post", "call") else ["*"]
|
||||
signed = [["/api/choices/sign", ["class", "to"], run["channel"], to]]
|
||||
self.assertEqual((run["disabled"], run["asked"], len(run["question"])), (False, 1, int(run["channel"] == "mayday")), run)
|
||||
title = f"Choice request ({labels[run['channel']]}) to {', '.join(to) if to != ['*'] else 'all'}"
|
||||
self.assertEqual(run["posted"], [*signed, ["/api/choices", ["class", "proof", "to"], run["channel"], to]] if signs else signed)
|
||||
self.assertTrue((run["toast"] or "").startswith(f"{title}: {'sent' if signs else 'not signed'}"), run)
|
||||
|
||||
def answered(self, result: dict, signs: bool) -> None:
|
||||
self.assertNotIn("error", result)
|
||||
self.assertEqual((result["title"], result["asked"], result["tall"], result["wide"]), ("Wait: your passkey signs this answer, a post to Stage", 1, False, False))
|
||||
signed = [["/api/sign", ["items"], None, None]]
|
||||
self.assertEqual(result["posted"], [*signed, ["/api/inbox/approve", ["items", "proof"], None, None]] if signs else signed)
|
||||
self.assertTrue((result["toast"] or "").startswith("Choice: Wait" + ("" if signs else ": not signed")), result)
|
||||
self.assertEqual(result["gone"], True if signs else None)
|
||||
|
||||
def check_choices(self, browser: str, driver: str, binary: str, signs: bool) -> None:
|
||||
"""Args:
|
||||
signs: cdp.mjs signs every prompt with its virtual passkey; bidi.mjs
|
||||
has no virtual authenticator, so the probes answer each prompt
|
||||
the way a canceled passkey dialog does.
|
||||
"""
|
||||
self.inbox_work()
|
||||
self.create_ask({**ASK, "command": "make choices", "timeout": 3600})
|
||||
authenticator = self.authenticator()
|
||||
questions = {width: f"Pick a name at {width} px?" for width, _ in WIDTHS}
|
||||
decisions = {width: self.tool("decision_request", {"question": question, "options": OPTIONS, "agent": "Stage", "recommended": ""})["structuredContent"]["id"] for width, question in questions.items()}
|
||||
data = self.drive(self.choice_steps(browser, signs, questions), authenticator if signs else None, driver, binary)
|
||||
(OUT / f"{browser}-choices.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual([error for error in data["errors"] if "(style-src-elem)" not in error and "/favicon.ico" not in error], [])
|
||||
per = 3 + len(TASK_VIEWS)
|
||||
for index, (width, _) in enumerate(WIDTHS):
|
||||
view, sent, answer, *fitted = data["results"][per * index : per * (index + 1)]
|
||||
with self.subTest(browser=browser, width=width):
|
||||
self.assertNotIn("error", view)
|
||||
self.assertEqual(view["kinds"], ["Ask", "Choice", "Operator", "Review", "Rule request"])
|
||||
self.assertEqual(view["filter"], ["Waits on Operator", "true"])
|
||||
self.assertEqual(view["channels"], [[channel, label, channel in ("post", "call")] for channel, label in zip(CHOICE_CHANNELS, ["Post", "Call", "Newsletter", "Broadcast", "Pan-Pan", "Sécurité", "Mayday"], strict=True)])
|
||||
self.assertEqual((view["fields"], view["choices"], view["multi"], view["unlabelled"], view["tall"], view["wide"]), (0, 0, ["Agents", "0", 0], [], False, False))
|
||||
self.assertGreaterEqual(len(view["agents"]), 2, view)
|
||||
self.assertIn(["Choice", ["Merge now", "Wait"]], view["cards"])
|
||||
self.requested(sent, signs)
|
||||
self.answered(answer, signs)
|
||||
for fit, name in zip(fitted, TASK_VIEWS, strict=True):
|
||||
self.assertEqual((fit["hash"], fit["bar"], fit["choices"], fit["tall"], fit["wide"]), (f"#tasks/{name}", CHOICE_CHANNELS, 0, False, False), fit)
|
||||
if name == "board":
|
||||
self.assertEqual(fit["columns"], ["queued", "running", "blocked", "done", "stopped"])
|
||||
hub = self.httpd.hub
|
||||
if not signs:
|
||||
self.assertEqual((self.scripts.calls, hub.messages.listed()), ([], []))
|
||||
self.assertEqual([hub.decisions.get(decision)["status"] for decision in decisions.values()], ["pending"] * len(WIDTHS))
|
||||
return
|
||||
requests = [record for record in hub.messages.listed() if record["text"] == CHOICE_REQUEST]
|
||||
self.assertEqual((len(requests), sorted({record["class"] for record in requests})), (len(WIDTHS) * 9, sorted(CHOICE_CHANNELS)))
|
||||
for record in requests:
|
||||
self.assertTrue(self.tool("operator_message", {"id": record["id"], "text": CHOICE_REQUEST, "to": record["to"]})["structuredContent"]["match"], record["id"])
|
||||
radio = [args for script, args in self.scripts.calls if script == skill_scripts.RADIO]
|
||||
self.assertEqual(len(radio), len(requests) + len(WIDTHS))
|
||||
for width, decision in decisions.items():
|
||||
with self.subTest(width=width):
|
||||
state = self.tool("decision_wait", {"id": decision, "timeout_seconds": 0})["structuredContent"]
|
||||
self.assertEqual((state["status"], state["choice"], state["answer"]), ("decided", "Wait", f"Choice for decision {decision} ({questions[width]}): Wait"))
|
||||
found = self.tool("operator_message", {"id": state["message"], "text": state["answer"], "to": "Stage"})["structuredContent"]
|
||||
self.assertEqual((found["match"], found["class"], found["to"]), (True, "post", "Stage"))
|
||||
self.assertIn(["post", "--to", "Stage", "--session", "Operator", f"[kvb-orchestrator msg {state['message']}] {state['answer']}"], radio)
|
||||
|
||||
def test_chromium_requests_choices_on_every_channel_and_signs_a_choice_at_three_widths(self):
|
||||
self.check_choices("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"), signs=True)
|
||||
|
||||
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||||
def test_firefox_asks_the_passkey_for_every_choice_request_and_answer_and_sends_nothing_unsigned(self):
|
||||
self.check_choices("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"), signs=False)
|
||||
|
||||
def plan_work(self, now: float) -> None:
|
||||
register = json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))
|
||||
register["work/feature"]["start"] = iso(now - 1800)
|
||||
register["work/stage"]["start"] = iso(now - 900)
|
||||
register.update({
|
||||
"work/done-a": {"title": "Done A", "status": "done", "worker": "Stage", "branch": "feature/base", "start": iso(now - 36000), "end": iso(now - 28800), "roles": {"Stage": "RA"}, "estimates": {"Stage": {"value": 5, "complexity": 2}}},
|
||||
"work/done-b": {"title": "Done B", "status": "done", "worker": "Docs", "start": iso(now - 25200), "end": iso(now - 10800), "roles": {"Docs": "RA"}, "estimates": {"Docs": {"value": 3, "complexity": 2}}},
|
||||
"work/done-c": {"title": "Done C", "status": "done", "worker": "Docs", "start": iso(now - 14400), "end": iso(now - 10800), "roles": {"Docs": "RA"}},
|
||||
"work/next": {"title": "Next step", "status": "queued", "after": ["work/feature"], "roles": {"Skills": "R", "Maintainer": "A"}, "estimates": {"Skills": {"value": 8, "complexity": 3}}},
|
||||
"work/child": {"title": "Child of the base", "status": "queued", "base": "feature/base", "roles": {"Docs": "RA"}},
|
||||
})
|
||||
queue = json.loads((self.council / "queue.json").read_text(encoding="utf-8"))
|
||||
queue["entries"] += [
|
||||
{"id": "deploy-4", "seq": 4, "kind": "deploy", "task": "work/stage", "workdir": "/repo", "session": "s2", "name": "Stage", "hotfix": None, "status": "active", "enqueued": now - 400, "granted": now - 300, "cmd": "make deploy"},
|
||||
{"id": "test-5", "seq": 5, "kind": "test", "task": "work/docs", "workdir": "/repo", "session": "s2", "name": "Docs", "hotfix": None, "status": "waiting", "enqueued": now - 60, "granted": None, "cmd": "make test"},
|
||||
]
|
||||
self.write_council({"rasci.json": register, "queue.json": queue})
|
||||
|
||||
def plan_steps(self, browser: str, signs: bool) -> list[dict]:
|
||||
local = f"http://localhost:{self.port}/"
|
||||
flag = "true" if signs else "false"
|
||||
steps = []
|
||||
for width, height in WIDTHS:
|
||||
bulk = BULK.replace("@SIGNS@", flag).replace("@VALUE@", PLAN_VALUES[width])
|
||||
steps += [
|
||||
{"width": width, "height": height, "url": f"{local}?{width}#tasks/gantt", "wait": 1200, "probe": GANTT, "shot": str(OUT / f"{browser}-{width}-tasks-gantt.png")},
|
||||
{"width": width, "height": height, "url": f"{local}?{width}#tasks/burn", "wait": 900, "probe": BURN, "shot": str(OUT / f"{browser}-{width}-tasks-burn.png")},
|
||||
{"width": width, "height": height, "url": None, "wait": 0, "probe": POINTS_ON, "shot": None},
|
||||
{"width": width, "height": height, "url": f"{local}?{width}-reloaded#tasks/burn", "wait": 1200, "probe": REQUEST, "shot": str(OUT / f"{browser}-{width}-tasks-burn-points.png")},
|
||||
{"width": width, "height": height, "url": f"{local}?{width}-points#tasks/gantt", "wait": 1200, "probe": GANTT, "shot": str(OUT / f"{browser}-{width}-tasks-gantt-points.png")},
|
||||
{"width": width, "height": height, "url": f"{local}?{width}-list#tasks/list", "wait": 1200, "probe": bulk, "shot": str(OUT / f"{browser}-{width}-tasks-business-value.png")},
|
||||
]
|
||||
return steps
|
||||
|
||||
def planned(self, probe: dict, points: bool) -> None:
|
||||
hour = 3600
|
||||
edges = [["after", "work/feature", "work/next"], ["base", "work/done-a", "work/child"], ["queue", "queue/deploy-4", "queue/gate-3"], ["queue", "queue/gate-3", "queue/test-5"]]
|
||||
self.assertEqual(probe["forecast"], ["always", [["always", "always"], ["points", "points only"], ["real", "real only"]]])
|
||||
self.assertEqual(probe["modes"], [["none", str(not points).lower()], ["points", str(points).lower()]])
|
||||
self.assertEqual((probe["request"], probe["inner"]), (points, True))
|
||||
self.assertEqual(sorted([kind, start, end] for kind, start, end, _ in probe["edges"]), sorted(edges))
|
||||
self.assertEqual({tip for *_, tip in probe["edges"]}, {"url(#gantt-tip)"})
|
||||
bars = {task: (forecast, start, end, tag) for task, forecast, start, end, tag in probe["bars"]}
|
||||
self.assertNotIn("operator/push", bars)
|
||||
self.assertEqual((bars["work/old"][0], bars["work/old"][3]), (False, "circle"))
|
||||
self.assertEqual([bars[task][0] for task in ("work/feature", "work/stage", "queue/deploy-4", "work/done-a")], [False] * 4)
|
||||
self.assertGreaterEqual(bars["work/feature"][2] - bars["work/feature"][1], 1800)
|
||||
forecasts = ("work/next", "work/docs", "work/child", "queue/gate-3", "queue/test-5")
|
||||
lengths = {task: round(bars[task][2] - bars[task][1], 3) for task in forecasts}
|
||||
self.assertEqual(lengths, {"work/next": (4.5 if points else 2) * hour, "work/docs": (3 if points else 2) * hour, "work/child": 2 * hour, "queue/gate-3": 2 * hour, "queue/test-5": 2 * hour})
|
||||
self.assertTrue(all(bars[task][0] for task in forecasts))
|
||||
self.assertEqual((bars["work/next"][1], bars["queue/test-5"][1]), (bars["work/feature"][2], bars["queue/gate-3"][2]))
|
||||
self.assertTrue(probe["hatched"] and all(probe["hatched"]), probe["hatched"])
|
||||
self.assertEqual(probe["real"], 0)
|
||||
only = {task: (round(length, 3), tag) for task, length, tag in probe["pointsOnly"]}
|
||||
dot = (0, "circle")
|
||||
self.assertEqual(only, {"work/next": (4.5 * hour, "rect") if points else dot, "work/docs": (3 * hour, "rect") if points else dot, "work/child": dot, "queue/gate-3": dot, "queue/test-5": dot})
|
||||
|
||||
def check_plan(self, browser: str, driver: str, binary: str, signs: bool) -> None:
|
||||
"""Args:
|
||||
signs: cdp.mjs signs the bulk business value with its virtual
|
||||
passkey; bidi.mjs has no virtual authenticator, so the probe
|
||||
answers the prompt the way a canceled passkey dialog does.
|
||||
"""
|
||||
self.plan_work(time.time())
|
||||
authenticator = self.authenticator()
|
||||
data = self.drive(self.plan_steps(browser, signs), authenticator if signs else None, driver, binary)
|
||||
(OUT / f"{browser}-plan.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual([error for error in data["errors"] if "(style-src-elem)" not in error and "/favicon.ico" not in error], [])
|
||||
rows = self.snapshot()["tasks"]
|
||||
ranked = list(enumerate(rows))
|
||||
status_up = [row["task"] for _, row in sorted(ranked, key=lambda pair: (STATUS_ORDER.index(pair[1]["status"]), pair[0]))]
|
||||
status_down = [row["task"] for _, row in sorted(ranked, key=lambda pair: (-STATUS_ORDER.index(pair[1]["status"]), pair[0]))]
|
||||
for index, (width, _) in enumerate(WIDTHS):
|
||||
gantt, burn, points_on, request, gantt_points, bulk = data["results"][6 * index : 6 * (index + 1)]
|
||||
value = PLAN_VALUES[width]
|
||||
items = [["work/next", f"estimate work/next Operator --value {value}"], ["work/child", f"estimate work/child Operator --value {value}"]]
|
||||
with self.subTest(browser=browser, width=width):
|
||||
for probe in (gantt, burn, points_on, request, gantt_points, bulk):
|
||||
self.assertNotIn("error", probe)
|
||||
self.assertEqual((probe["tall"], probe["wide"]), (False, False), probe)
|
||||
self.planned(gantt, points=False)
|
||||
self.planned(gantt_points, points=True)
|
||||
self.assertEqual((burn["modes"], burn["request"]), ([["none", "true"], ["points", "false"]], False))
|
||||
self.assertEqual(burn["scope"][0], "all")
|
||||
self.assertEqual(burn["scope"][1][:2], ["all", "filter"])
|
||||
self.assertIn("agent:Docs", burn["scope"][1])
|
||||
self.assertIn("line:identity/oidc", burn["scope"][1])
|
||||
self.assertEqual(burn["series"], [["scope", 9], ["done", 4], ["remaining", 5]])
|
||||
self.assertEqual(burn["agent"], [["scope", 5], ["done", 2], ["remaining", 3]])
|
||||
self.assertEqual(points_on["before"], {"modes": [["none", "true"], ["points", "false"]], "request": False})
|
||||
self.assertEqual((points_on["modes"], points_on["request"]), ([["none", "false"], ["points", "true"]], True))
|
||||
self.assertEqual(request["modes"], [["none", "false"], ["points", "true"]])
|
||||
self.assertEqual(request["series"], [["scope", 12], ["done", 4], ["remaining", 8], ["unestimated", 2]])
|
||||
self.assertTrue(request["toast"].startswith(f"Request estimates: {'done' if signs else 'not signed'}"), request["toast"])
|
||||
self.assertEqual(bulk["sorts"], ["prio", "wsjf", "value", "complexity", "status", "start", "end", "agent", "title"])
|
||||
self.assertEqual((bulk["prio"], bulk["status"], bulk["statusDown"]), ([row["task"] for row in rows], status_up, [status_down, "Descending"]))
|
||||
self.assertEqual((bulk["picks"], bulk["armed"]), (["work/feature", "work/next", "work/docs", "work/child", "work/stage"], [False, "2"]))
|
||||
self.assertTrue(bulk["toast"].startswith(f"Business value {value} for 2 tasks: {'handed to the runner' if signs else 'not signed'}"), bulk["toast"])
|
||||
signed = [["/api/sign", ["items"], items]]
|
||||
self.assertEqual((bulk["asked"], bulk["posted"]), (1, [*signed, ["/api/tasks", ["items", "proof"], items]] if signs else signed))
|
||||
self.assertEqual((bulk["after"], bulk["reset"]), ([True, 0] if signs else [False, 2], True))
|
||||
self.assertEqual([args for script, args in self.scripts.calls if script == skill_scripts.ESTIMATE], [["request", "--session", "Operator"]] * (len(WIDTHS) if signs else 0))
|
||||
self.assertEqual(self.httpd.hub.settings.get()["estimation"], "none")
|
||||
jobs = self.runner_jobs()
|
||||
if not signs:
|
||||
self.assertEqual(jobs, [])
|
||||
register = json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual([task for task, row in register.items() if "Operator" in row.get("estimates", {})], [])
|
||||
return
|
||||
self.assertEqual([job["argv"][-1] for job in jobs], [PLAN_VALUES[width] for width, _ in WIDTHS for _ in range(2)])
|
||||
self.run_runner()
|
||||
register = json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual({task: register[task]["estimates"]["Operator"] for task in ("work/next", "work/child")}, {"work/next": {"value": 34, "at": register["work/next"]["estimates"]["Operator"]["at"]}, "work/child": {"value": 34, "at": register["work/child"]["estimates"]["Operator"]["at"]}})
|
||||
rows = {row["task"]: row for row in self.snapshot()["tasks"]}
|
||||
self.assertEqual((rows["work/next"]["wsjf"], rows["work/child"]["wsjf"], rows["work/child"]["value"]), ({"value": 34, "complexity": 3, "score": 11.33}, None, 34))
|
||||
|
||||
def test_chromium_draws_the_gantt_and_burn_switches_the_estimation_requests_estimates_and_signs_a_bulk_business_value_at_three_widths(self):
|
||||
self.check_plan("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"), signs=True)
|
||||
|
||||
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||||
def test_firefox_draws_the_gantt_and_burn_and_asks_the_passkey_for_a_bulk_business_value_storing_nothing_unsigned(self):
|
||||
self.check_plan("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"), signs=False)
|
||||
|
||||
def test_chromium_pins_a_task_priority_after_signature_and_the_runner_applies_it(self):
|
||||
signer = self.pin()
|
||||
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
|
||||
|
||||
Reference in new issue
Block a user