Files
skills/orchestrator/templates/kanban.js
T

171 lines
7.7 KiB
JavaScript
Raw Normal View History

const Kanban = (() => {
"use strict";
const COLUMNS = [["queued", "Waiting"], ["running", "Running"], ["done", "Done"]];
const LANES = [["p1", "P1"], ["p2", "P2"], ["p3", "P3"], ["p4", "P4+"], ["unpinned", "Unpinned"]];
const PINS = {p1: 1, p2: 2, p3: 3};
const FREE_FROM = 4;
const OFF = ["command"];
const DAY = 86400;
const REFUSED = ["failed", "refused"];
const onBoard = (row) => !OFF.includes(row.kind);
const laneOf = (prio) => (prio === null ? "unpinned" : prio >= FREE_FROM ? "p4" : `p${Math.max(prio, 1)}`);
const labelOf = (row) => (row.status === "blocked" || row.status === "stopped" ? row.status : null);
const old = (row, now) => columnOf(row) === "done" && row.end !== null && now - row.end > DAY;
const score = (row) => (row.wsjf ? row.wsjf.score : null);
/**
* @param row - a task row.
* @returns "done" for done and stopped, "running" for running and for blocked once started, else "queued".
*/
function columnOf(row) {
if (row.status === "done" || row.status === "stopped") {
return "done";
}
return row.status === "running" || (row.status === "blocked" && row.start !== null) ? "running" : "queued";
}
function ranked(a, b) {
const pins = (a.prio === null) - (b.prio === null) || (a.prio || 0) - (b.prio || 0);
const scores = (score(a) === null) - (score(b) === null) || (score(b) || 0) - (score(a) || 0);
return pins || scores || a.task.localeCompare(b.task, "en");
}
/**
* @param rows - the task rows to place, staged changes applied.
* @param options - {now, old: true shows Done cards that ended more than 24 h ago}.
* @returns {lanes, hidden}: per lane of LANES {id, label, count, cells: {column: rows}}, each cell in pin then WSJF
* order (WSJF rank alone in Unpinned), and the count of hidden old Done cards. Operator commands are never placed;
* a pin below 1 counts as P1.
*/
function board(rows, options) {
const placed = rows.filter(onBoard);
const hidden = options.old ? [] : placed.filter((row) => old(row, options.now));
const shown = placed.filter((row) => !hidden.includes(row)).sort(ranked);
const lanes = LANES.map(([id, label]) => {
const own = shown.filter((row) => laneOf(row.prio) === id);
return {id, label, count: own.length, cells: Object.fromEntries(COLUMNS.map(([column]) => [column, own.filter((row) => columnOf(row) === column)]))};
});
return {lanes, hidden: hidden.length};
}
/**
* @param rows - every task row, staged changes applied.
* @param task - the task that moves into P4+.
* @returns the lowest pin from 4 no other open task holds.
*/
function freePin(rows, task) {
const held = new Set(rows.filter((row) => row.task !== task && !row.ended && row.prio !== null).map((row) => row.prio));
let pin = FREE_FROM;
while (held.has(pin)) {
pin += 1;
}
return pin;
}
const slotOf = (target) => (target.after ? `after:${target.after}` : target.drop ? `after:${target.drop}` : target.lane ? "prio" : "status");
const slotOfArgv = (argv) => (argv[2] === "--after" || argv[2] === "--drop-after" ? `after:${argv[3]}` : argv[2] === "--status" ? "status" : "prio");
/**
* @param row - the hub's row of the moved card.
* @param target - {column}, {status}, {lane}, {after: the task it follows} or {drop: the task it no longer follows}.
* @param rows - every task row, staged changes applied, for the free pin.
* @returns the rasci.py argv of the move against the hub's row, or null when the row already reads so.
*/
function move(row, target, rows) {
if (target.column) {
return columnOf(row) === target.column ? null : ["set", row.task, "--status", target.column];
}
if (target.status) {
return row.status === target.status ? null : ["set", row.task, "--status", target.status];
}
if (target.lane) {
if (laneOf(row.prio) === target.lane) {
return null;
}
return target.lane === "unpinned" ? ["set", row.task, "--unpin"] : ["set", row.task, "--prio", String(PINS[target.lane] || freePin(rows, row.task))];
}
if (target.after) {
return target.after === row.task || row.after.includes(target.after) ? null : ["set", row.task, "--after", target.after];
}
return target.drop && row.after.includes(target.drop) ? ["set", row.task, "--drop-after", target.drop] : null;
}
/**
* @param staged - the staged changes, each {task, argv}.
* @param row - the hub's row of the moved card.
* @param target - as move() takes it.
* @param rows - every task row, staged changes applied.
* @returns the new staged list: the move replaces an earlier one of the same task and kind (status, pin, or one
* dependency), and a move back to the hub's state leaves only the drop of that earlier one.
*/
function stage(staged, row, target, rows) {
const slot = slotOf(target);
const kept = staged.filter((change) => !(change.task === row.task && slotOfArgv(change.argv) === slot));
const argv = move(row, target, rows);
return argv ? [...kept, {task: row.task, argv}] : kept;
}
/**
* @param rows - the hub's task rows.
* @param staged - the staged changes.
* @returns the rows as they read once the staged changes are applied, a changed row with pending, the argv
* staged for it; the others unchanged.
*/
function pending(rows, staged) {
return rows.map((row) => {
const own = staged.filter((change) => change.task === row.task);
if (!own.length) {
return row;
}
const next = {...row, after: [...row.after], pending: own.map((change) => change.argv)};
for (const {argv} of own) {
const [, , flag, value] = argv;
if (flag === "--status") {
Object.assign(next, {status: value, ended: value === "done" || value === "stopped", end: null});
} else if (flag === "--prio") {
next.prio = Number(value);
} else if (flag === "--unpin") {
next.prio = null;
} else if (flag === "--after") {
next.after.push(value);
} else {
next.after = next.after.filter((task) => task !== value);
}
}
return next;
});
}
/**
* @param rows - the shown cards' rows, staged changes applied.
* @param line - the hub's queue_line: tasks holding a live queue entry, in grant order.
* @returns {arrows, queue}: one {from, to} per after dependency between two shown cards, and the shown tasks of the
* queue line in its order.
*/
function lines(rows, line) {
const shown = new Set(rows.map((row) => row.task));
const arrows = rows.flatMap((row) => row.after.filter((task) => task !== row.task && shown.has(task)).map((from) => ({from, to: row.task})));
return {arrows, queue: line.filter((task) => shown.has(task))};
}
/**
* @param jobs - the hub's jobs.
* @param task - a card's task.
* @returns {failed, applied, total} over the task's changes in its newest signed batch (a job without a batch is
* one of its own): failed the jobs rasci.py or the runner refused, oldest first, applied the count rasci.py ran;
* null when none of them failed.
*/
function why(jobs, task) {
const own = jobs.filter((job) => job.kind === "task" && job.task === task).sort((a, b) => a.created_ts - b.created_ts);
const newest = own[own.length - 1];
const batch = !newest ? [] : newest.batch ? own.filter((job) => job.batch === newest.batch) : [newest];
const failed = batch.filter((job) => REFUSED.includes(job.status));
return failed.length ? {failed, applied: batch.filter((job) => job.status === "done").length, total: batch.length} : null;
}
return {COLUMNS, LANES, FREE_FROM, onBoard, columnOf, laneOf, labelOf, board, freePin, move, stage, pending, lines, why};
})();
if (typeof module === "object" && module.exports) {
module.exports = Kanban;
}