Merge pull request #42 from 12vectors/task/50-the-board-shows-a-phase-running
50 — The board shows a phase running, and shows it stopping
This commit is contained in:
@@ -879,8 +879,51 @@ the existing apparatus applies unchanged: the CI chip, **◔ review PR**,
|
||||
**Assignee** is where "who runs it" is written down — the same claim that
|
||||
gates starting work. A replica renders the phase and advances nothing.
|
||||
|
||||
Members run one at a time. Running independent members in parallel, and
|
||||
any UI beyond the API and the ticker, are separate cards.
|
||||
Members run one at a time. Running independent members in parallel is a
|
||||
separate card.
|
||||
|
||||
### Watching one run, and watching it stop
|
||||
|
||||
A phase card is a card, so the PR chip, CI, the review actions and the
|
||||
merge-and-clean-up sheet all reach it by inheritance. Three things do not,
|
||||
and they are the phase's own interface:
|
||||
|
||||
- **▸ run phase**, on an `in-progress/` phase card, in the slot **▸ start
|
||||
work** occupies on an ordinary one — arming and firing like every other
|
||||
launch, and reading **▸ take over** on a card someone else holds. Moving
|
||||
the card to `in-progress/` stays the commitment; this is the second half
|
||||
of it. **‖ hold** replaces it while the phase runs and means what it
|
||||
means everywhere else: the run stops and the member agent in flight
|
||||
stops with it, while the phase branch, every card already merged into it
|
||||
and every worktree are left exactly as they were. A hold is not an undo,
|
||||
and a held member's card keeps its own work — so running the phase again
|
||||
may halt on it, which is the honest reading of a run that ended without
|
||||
reaching `review/`.
|
||||
- **A chip in the header while a phase runs**, beside the agents chip and
|
||||
present on the same terms as the sync chip — only when there is
|
||||
something to say. It breathes in `--accent` and names the phase, how far
|
||||
it has got and the card in flight: `⟶ Ship the site · 2/5 · on #33`.
|
||||
Progress is what has landed on the phase branch, which is a different
|
||||
fact from which card is running, so both are there. Two phases could in
|
||||
principle run on one board, and two phases get two chips — a chip that
|
||||
silently showed one of them would be worse than none.
|
||||
- **The halt, in `--alarm`, holding.** `⟶ Ship the site · halted at #35 —
|
||||
it is not ready` stays in the header until the phase is run again or
|
||||
held, rather than scrolling away; a toast fires with it, because a halt
|
||||
is rare and actionable; and the ticker keeps the line. The member card
|
||||
wears its own failure independently — a phase you started and stopped
|
||||
watching is only trustworthy if its halt is impossible to miss, so it is
|
||||
told three times at three altitudes, exactly as a dead run is.
|
||||
|
||||
Every advance is narrated in the ticker as it happens: the member that
|
||||
came up green, the merge into the phase branch, the member starting next.
|
||||
And the phase card's sheet lists its members in run order with each one's
|
||||
stage — and, while a phase is in flight, the runner's own reading of each
|
||||
(merged in, working, checking, stopped here) — so the card answers "where
|
||||
is this up to" without a hunt across five columns.
|
||||
|
||||
Filtering the board to a phase's cards, and a Focus view for a phase, are
|
||||
separate cards.
|
||||
|
||||
An optional **Assignee** line records who holds the card:
|
||||
|
||||
|
||||
+230
-8
@@ -99,6 +99,18 @@
|
||||
/* an author display beats the UA's [hidden] — say it here or the sync
|
||||
chip is never hidden */
|
||||
.livechip[hidden]{display:none}
|
||||
/* A running phase is the same kind of fact as the agents and the sync
|
||||
chips: what is happening across the board without you. One chip per
|
||||
phase in flight — two phases could in principle run at once, and a
|
||||
chip that showed one of them would be worse than none — so the row
|
||||
gives way before the chips already here do, clipping the names it
|
||||
cannot fit rather than shrinking its neighbours. */
|
||||
.phasechips{display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden}
|
||||
.phasechips[hidden]{display:none}
|
||||
.phasechips .livechip{min-width:0}
|
||||
.phasechips .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.phasechips .mono{white-space:nowrap}
|
||||
.phasechips .halted{color:var(--alarm)}
|
||||
.dot{width:7px;height:7px;border-radius:99px;background:var(--idle);flex:none}
|
||||
.dot.live{background:var(--accent);animation:breathe 2.4s ease-in-out infinite}
|
||||
/* the model chip: which brain did this, beside the name that did it.
|
||||
@@ -451,6 +463,28 @@
|
||||
#drawer .dhead{display:flex;align-items:center;gap:10px}
|
||||
/* the dead run's excerpt, above the task itself: machine output, bounded */
|
||||
#drawer .well.bad pre{margin:6px 0 4px;white-space:pre-wrap;max-height:220px;overflow-y:auto;color:var(--text)}
|
||||
/* the phase's list, above the card's own text: run order, one row per
|
||||
card, machine facts (position, number, stage, the runner's reading)
|
||||
in mono and the title in the reader's face */
|
||||
#drawer .pmembers{display:flex;flex-direction:column;gap:1px}
|
||||
#drawer .phead{
|
||||
display:flex;align-items:baseline;gap:8px;font-size:12px;font-weight:500;
|
||||
color:var(--muted);padding-bottom:5px;
|
||||
}
|
||||
#drawer .phead .mono{font-family:var(--mono);font-size:10.5px;color:var(--dim)}
|
||||
#drawer .prow{
|
||||
display:flex;align-items:baseline;gap:8px;width:100%;text-align:left;
|
||||
padding:5px 8px;border:1px solid transparent;border-radius:7px;
|
||||
background:var(--sunken);font-size:12.5px;color:var(--text);
|
||||
}
|
||||
#drawer .prow:hover{border-color:var(--accent)}
|
||||
#drawer .prow .mono{font-family:var(--mono);font-size:11px;color:var(--dim)}
|
||||
#drawer .prow .pn{width:14px;flex:none;text-align:right}
|
||||
#drawer .prow .pref{flex:none}
|
||||
#drawer .prow .ptitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
#drawer .prow .pstage{flex:none;color:var(--muted)}
|
||||
#drawer .prow .pstate{flex:none}
|
||||
#drawer .pempty{font-size:12px;color:var(--dim);padding:4px 8px}
|
||||
#drawer .dbody{font-size:13px;line-height:1.6}
|
||||
#drawer .dbody h1{font-size:19px;line-height:1.3;font-weight:600;letter-spacing:-.01em;margin:0 0 4px;text-wrap:pretty}
|
||||
#drawer .dbody h2{font-size:14px;margin:18px 0 6px}
|
||||
@@ -542,6 +576,7 @@
|
||||
<button data-view="focus">Focus</button>
|
||||
</nav>
|
||||
<span class="spacer"></span>
|
||||
<div class="phasechips" id="phasechips" hidden></div>
|
||||
<div class="livechip" id="syncchip" hidden style="cursor:default"></div>
|
||||
<div class="livechip" id="livechip" title="open Sessions"></div>
|
||||
<button id="themebtn">Daylight</button>
|
||||
@@ -625,10 +660,10 @@ const STAGE_TINT = { backlog: 'var(--dim)', 'to-do': 'var(--muted)',
|
||||
const STAGE_NOTE = { 'to-do': 'next up', review: 'your move' };
|
||||
const GLYPHS = { session: '●', end: '○', idle: '…', edit: '✎', read: '◔', search: '⌕',
|
||||
command: '$', test: '▶', check: '☑', git: '⎇', plan: '≡', subagent: '⑂', web: '∿',
|
||||
move: '⇢', new: '+', agent: '⚑', report: '▣', sync: '⇅', other: '·' };
|
||||
move: '⇢', new: '+', agent: '⚑', report: '▣', sync: '⇅', phase: '⟶', other: '·' };
|
||||
const FILTERS = [
|
||||
['all', 'All', null],
|
||||
['moves', 'Moves', new Set(['move', 'new', 'agent', 'sync'])],
|
||||
['moves', 'Moves', new Set(['move', 'new', 'agent', 'sync', 'phase'])],
|
||||
['edits', 'Edits', new Set(['edit'])],
|
||||
['reads', 'Reads', new Set(['read', 'search'])],
|
||||
['tests', 'Tests', new Set(['test', 'check'])],
|
||||
@@ -890,6 +925,82 @@ function renderSync() {
|
||||
`<span class="mono">${esc(detail.split(' — ')[0].replace(/^sync[^:]*:\s*/, ''))}</span>`;
|
||||
}
|
||||
|
||||
/* ── a phase in flight ────────────────────────────────────────────────── */
|
||||
|
||||
/* The phases the header has something to say about: one running, or one
|
||||
halted and not yet settled. A phase nobody has started, and one whose
|
||||
card has left in-progress/, are simply not here — with none of them the
|
||||
header is exactly what it was before phases existed.
|
||||
|
||||
A halt leads, because it is the one a person must not scroll past. */
|
||||
function phasesInFlight() {
|
||||
return Object.values(S.state?.phases || {})
|
||||
.filter(p => p.running || p.halted)
|
||||
.sort((a, b) => (b.halted ? 1 : 0) - (a.halted ? 1 : 0) ||
|
||||
a.file.localeCompare(b.file));
|
||||
}
|
||||
|
||||
/* Where a phase is up to, from the members the last pass read: how many
|
||||
have landed on the phase branch, and which one is in flight — an agent
|
||||
on it, or its checks still running. */
|
||||
function phaseProgress(p) {
|
||||
const members = p.members || [];
|
||||
return {
|
||||
done: members.filter(m => m.state === 'merged').length,
|
||||
total: members.length,
|
||||
on: members.find(m => m.state === 'running') ||
|
||||
members.find(m => m.state === 'waiting') || null,
|
||||
};
|
||||
}
|
||||
|
||||
function clip(text, n) {
|
||||
return text.length > n ? text.slice(0, n - 1) + '…' : text;
|
||||
}
|
||||
|
||||
/* What the chip says after the name: the halt and where it happened, or
|
||||
the progress and the card in flight. The whole of it is on the title —
|
||||
a header chip is not the place a reason gets to run long. */
|
||||
function phaseChipDetail(p) {
|
||||
if (p.halted) {
|
||||
return (p.haltedAt ? `halted at #${p.haltedAt} — ` : 'halted — ') +
|
||||
clip(p.haltedWhy || p.halted, 46);
|
||||
}
|
||||
const at = phaseProgress(p);
|
||||
const rest = at.on ? `on #${at.on.number || at.on.file}`
|
||||
: (p.waitingOn || []).length ? `waiting on #${p.waitingOn[0]}` : '';
|
||||
return `${at.done}/${at.total}` + (rest ? ` · ${rest}` : '');
|
||||
}
|
||||
|
||||
function renderPhases() {
|
||||
const el = $('#phasechips');
|
||||
const live = phasesInFlight();
|
||||
el.hidden = !live.length;
|
||||
el.innerHTML = live.map(p => {
|
||||
const at = phaseProgress(p);
|
||||
const detail = phaseChipDetail(p);
|
||||
// colour only means state: a run alive breathes in accent, a halt
|
||||
// holds in alarm until the phase is resumed or held
|
||||
const title = (p.halted
|
||||
? `${p.file} halted${p.haltedAt ? ` at ${p.haltedAt}` : ''} — ` +
|
||||
`${p.haltedWhy || p.halted}. It stays here until the phase is run again or held.`
|
||||
: `${p.file} is running on ${p.branch} — ${at.done} of ${at.total} cards merged`) +
|
||||
' · click to open the phase card';
|
||||
const dot = p.halted
|
||||
? '<span class="dot" style="background:var(--alarm)"></span>'
|
||||
: '<span class="dot live"></span>';
|
||||
return `<div class="livechip" data-phasechip="${esc(p.file)}" title="${esc(title)}">` +
|
||||
dot + `<span class="name${p.halted ? ' halted' : ''}">⟶ ${esc(phaseLabel(p))}</span>` +
|
||||
`<span class="mono${p.halted ? ' halted' : ''}">${esc(detail)}</span></div>`;
|
||||
}).join('');
|
||||
el.querySelectorAll('[data-phasechip]').forEach(chip =>
|
||||
chip.addEventListener('click', () => {
|
||||
const task = findTask(chip.dataset.phasechip);
|
||||
if (!task) return;
|
||||
setView('board');
|
||||
showDetail(task);
|
||||
}));
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
S.view = view;
|
||||
document.querySelectorAll('#views button').forEach(b => b.classList.toggle('on', b.dataset.view === view));
|
||||
@@ -932,6 +1043,7 @@ function render() {
|
||||
if (!S.state) return;
|
||||
renderTitle();
|
||||
renderChip();
|
||||
renderPhases();
|
||||
renderSync();
|
||||
if (S.view === 'board') renderBoard();
|
||||
else if (S.view === 'flight') renderFlight();
|
||||
@@ -1072,11 +1184,38 @@ function cardFor(task) {
|
||||
run: () => askCopilot(task) });
|
||||
}
|
||||
} else {
|
||||
if (task.stage === 'in-progress') {
|
||||
// someone else's card is never started by accident: the action says
|
||||
// whose it is, and firing it is the deliberate takeover
|
||||
const held = task.assignee && S.state.me && task.assignee !== S.state.me
|
||||
? task.assignee : null;
|
||||
// someone else's card is never started by accident: the action says
|
||||
// whose it is, and firing it is the deliberate takeover
|
||||
const held = task.assignee && S.state.me && task.assignee !== S.state.me
|
||||
? task.assignee : null;
|
||||
if (task.stage === 'in-progress' && task.isPhase) {
|
||||
// the phase's own launch, in the slot ▸ start work has on every other
|
||||
// card: moving it here was the commitment, this is the second half
|
||||
const ph = (S.state.phases || {})[task.file];
|
||||
const hold = { glyph: '‖', label: 'hold', confirm: 'hold it?', busy: 'holding…',
|
||||
title: 'Stop the phase — the phase branch, every card already merged '
|
||||
+ 'into it and every worktree stay exactly as they are',
|
||||
run: () => holdPhase(task) };
|
||||
const start = held
|
||||
? { glyph: '▸', label: 'take over', confirm: `take from ${held}?`,
|
||||
busy: 'starting…',
|
||||
title: `${held} holds this phase — running it takes the card over and `
|
||||
+ 'this board becomes the one that advances it',
|
||||
run: () => runPhase(task, { takeover: true }) }
|
||||
: { glyph: '▸', label: 'run phase', confirm: 'run it?', busy: 'starting…',
|
||||
title: ph && ph.halted
|
||||
? 'Run it again — the halt is cleared and the phase carries on from '
|
||||
+ 'where it stopped'
|
||||
: 'A branch of its own, each card in the list run on it in turn, and '
|
||||
+ 'one PR into main at the end',
|
||||
run: () => runPhase(task) };
|
||||
// a halt holds until the phase is run again or held: both are here,
|
||||
// because a person who has read it and does not want to carry on
|
||||
// needs a way to say so that is not walking the card backwards
|
||||
if (ph && ph.running) actions.push(hold);
|
||||
else if (ph && ph.halted) actions.push(start, hold);
|
||||
else actions.push(start);
|
||||
} else if (task.stage === 'in-progress') {
|
||||
actions.push(held
|
||||
? { glyph: '▸', label: 'take over', confirm: `take from ${held}?`, busy: 'starting…',
|
||||
title: `${held} holds this card — starting work takes it over and reassigns it to you`,
|
||||
@@ -1099,7 +1238,9 @@ function cardFor(task) {
|
||||
title: 'Push the branch and open its PR — the board does this when a card '
|
||||
+ 'enters review, and this is how you ask for it afterwards',
|
||||
run: () => openPR(task) });
|
||||
} else {
|
||||
} else if (actions.length < 2) {
|
||||
// two per state is the whole budget, and a halted phase has already
|
||||
// spent it on running it again and holding it
|
||||
actions.push(stillTrue);
|
||||
}
|
||||
}
|
||||
@@ -1443,6 +1584,35 @@ async function askCopilot(task) {
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
/* Running a phase, and holding it. Both are ordinary POSTs on the one
|
||||
action machine — armed, fired, and honest about failing — because a
|
||||
phase is a card and its actions are card actions. What follows either
|
||||
one is watched in the header chip and narrated in the ticker, so
|
||||
neither says more here than what it just asked for. */
|
||||
async function runPhase(task, extra) {
|
||||
const res = await fetch('/api/phase/run', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file: task.file, stage: task.stage, ...(extra || {}) }),
|
||||
});
|
||||
const data = await res.json();
|
||||
toast(res.ok ? `Phase ${task.file} running — the header chip follows it`
|
||||
: (data.error || 'the phase did not start'), !res.ok);
|
||||
await loadState();
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async function holdPhase(task) {
|
||||
const res = await fetch('/api/phase/stop', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file: task.file, stage: task.stage }),
|
||||
});
|
||||
const data = await res.json();
|
||||
toast(res.ok ? 'Held — the phase branch and everything merged into it stay as they are'
|
||||
: (data.error || 'could not hold it'), !res.ok);
|
||||
await loadState();
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async function move(file, from, to) {
|
||||
// finishing a task with work attached is a decision, not just a drag
|
||||
if (to === 'done') {
|
||||
@@ -1517,6 +1687,52 @@ function showDetail(task) {
|
||||
if (changed) $('#drawerbody').scrollTop = 0;
|
||||
}
|
||||
|
||||
/* The runner's word for a member, in the vocabulary the rest of the board
|
||||
uses. `pending` is the phase simply not having reached it, which the
|
||||
stage beside it already says — so it says nothing. */
|
||||
const MEMBER_STATE = { merged: 'merged in', running: 'working',
|
||||
waiting: 'checking', ready: 'ready to merge', halt: 'stopped here' };
|
||||
|
||||
/* A phase card answers "where is this up to" on its own face: its members
|
||||
in run order, each with the stage it is in, so the question costs no
|
||||
hunt across five columns. The runner's reading (merged in, working,
|
||||
stopped here) is added only while a phase is in flight, because only
|
||||
then is there one — and only it can tell a card that has run from one
|
||||
the phase has not reached. Each row opens that card. */
|
||||
function phaseMembers(t) {
|
||||
if (!t.isPhase) return '';
|
||||
if (!(t.members || []).length) {
|
||||
return `<div class="pmembers"><div class="phead">Cards</div>` +
|
||||
`<div class="pempty">This phase lists no cards yet — a <code>## Cards</code> ` +
|
||||
`section, one task number per line, in the order they run.</div></div>`;
|
||||
}
|
||||
const snap = (S.state.phases || {})[t.file];
|
||||
const read = {};
|
||||
for (const m of ((snap && snap.members) || [])) read[m.file] = m;
|
||||
const rows = t.members.map((m, i) => {
|
||||
const seen = read[m.file];
|
||||
const note = seen ? MEMBER_STATE[seen.state] || '' : '';
|
||||
const tint = seen && seen.state === 'halt' ? 'var(--alarm)'
|
||||
: seen && ['running', 'waiting'].includes(seen.state) ? 'var(--accent)'
|
||||
: seen && seen.state === 'merged' ? 'var(--calm)' : '';
|
||||
return `<button class="prow" data-member="${esc(m.file)}">` +
|
||||
`<span class="pn mono">${i + 1}</span>` +
|
||||
`<span class="pref mono">#${esc(m.number || '')}</span>` +
|
||||
`<span class="ptitle">${esc(m.title)}</span>` +
|
||||
`<span class="pstage mono">${esc(m.stage)}</span>` +
|
||||
(note ? `<span class="pstate mono"${tint ? ` style="color:${tint}"` : ''}` +
|
||||
`${seen.why ? ` title="${esc(seen.why)}"` : ''}>${esc(note)}</span>` : '') +
|
||||
`</button>`;
|
||||
}).join('');
|
||||
const halt = snap && snap.halted
|
||||
? `<div class="pempty" style="color:var(--alarm)">halted` +
|
||||
`${snap.haltedAt ? ` at #${esc(snap.haltedAt)}` : ''} — ` +
|
||||
`${esc(snap.haltedWhy || snap.halted)}</div>` : '';
|
||||
return `<div class="pmembers"><div class="phead">Cards` +
|
||||
`<span class="mono">${t.members.length} in run order</span></div>` +
|
||||
rows + halt + `</div>`;
|
||||
}
|
||||
|
||||
function renderDrawer() {
|
||||
const panel = $('#drawer');
|
||||
const body = $('#drawerbody');
|
||||
@@ -1557,9 +1773,15 @@ function renderDrawer() {
|
||||
`<span class="spacer"></span>` +
|
||||
`<button id="closeDrawer">Close</button></div>` +
|
||||
failBlock +
|
||||
phaseMembers(t) +
|
||||
`<div class="dbody">${md(t.body)}</div>` +
|
||||
`<div class="dmeta">${esc(t.stage)}/${esc(t.file)} · ${t.words} words · edited ${esc(when)}</div>`;
|
||||
panel.classList.add('open');
|
||||
body.querySelectorAll('[data-member]').forEach(row =>
|
||||
row.addEventListener('click', () => {
|
||||
const member = findTask(row.dataset.member);
|
||||
if (member) showDetail(member);
|
||||
}));
|
||||
$('#closeDrawer').addEventListener('click', () => { S.selected = null; renderBoard(); });
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,10 @@ def state_payload() -> dict:
|
||||
"sessions": sessions,
|
||||
"agents": agents.list_public(),
|
||||
"prs": github.public_state(),
|
||||
# what the last pass of the phase runner saw: per running phase
|
||||
# card, its branch and each member's state
|
||||
# what the last pass of the phase runner saw: per phase card in
|
||||
# in-progress/, its branch, whether a run is in force or halted (and
|
||||
# why, and where), and each member's state — the header chip and the
|
||||
# phase card's own list are both read from here
|
||||
"phases": phases.public_state(),
|
||||
"drive": drive.public(),
|
||||
"hasDriver": config.driver_path() is not None,
|
||||
@@ -201,6 +203,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._json(200, {"phase": phases.start_phase(
|
||||
payload["file"], payload["stage"],
|
||||
bool(payload.get("takeover")))})
|
||||
elif path == "/api/phase/stop":
|
||||
payload = self._read_body()
|
||||
# ‖ hold on a phase card: the run stops, nothing is unwound
|
||||
self._json(200, {"phase": phases.stop_phase(payload["file"],
|
||||
payload["stage"])})
|
||||
elif path == "/api/pr/open":
|
||||
payload = self._read_body()
|
||||
self._json(200, {"url": github.open_pr_now(payload["file"])})
|
||||
|
||||
+127
-26
@@ -64,6 +64,7 @@ LOG_LINE_RE = re.compile(r"^-\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+·\s+(.+?)\s*$
|
||||
STARTED_RE = re.compile(r"^(\d+) started\b")
|
||||
HALTED_RE = re.compile(r"^halted(?: at (\d+))? — (.*)$")
|
||||
RUN_RE = re.compile(r"^run started\b")
|
||||
STOPPED_RE = re.compile(r"^stopped — (.*)$")
|
||||
|
||||
# What the last computed pass saw, for the API to render without paying for
|
||||
# a git walk on every request. A display cache, not a decision: nothing here
|
||||
@@ -161,17 +162,37 @@ def _started(entries: list[str]) -> set[str]:
|
||||
(STARTED_RE.match(e) for e in _this_run(entries)) if match}
|
||||
|
||||
|
||||
def _halt_reason(entries: list[str]) -> str | None:
|
||||
"""The halt still in force, or None. A `run started` line after a halt
|
||||
clears it — that is what running the phase again means."""
|
||||
def run_state(entries: list[str]) -> dict:
|
||||
"""Where the phase stands, read off its own log — the whole of what a
|
||||
restarted board, and the chip in the header, know about a run.
|
||||
|
||||
Four answers, and the last line that says one of them wins: `running`
|
||||
(a run was started and nothing has ended it), `halted` (with the reason
|
||||
and the member it happened at), `stopped` (a person held it) and `idle`
|
||||
(a card whose phase has never been run). Running it again is what
|
||||
clears a halt or a stop, because that line is the person's decision.
|
||||
"""
|
||||
for entry in reversed(entries):
|
||||
halted = HALTED_RE.match(entry)
|
||||
if halted:
|
||||
return (f"{halted.group(1)}: {halted.group(2)}" if halted.group(1)
|
||||
else halted.group(2))
|
||||
return {"state": "halted", "reason": halted.group(2),
|
||||
"at": halted.group(1)}
|
||||
stopped = STOPPED_RE.match(entry)
|
||||
if stopped:
|
||||
return {"state": "stopped", "reason": stopped.group(1), "at": None}
|
||||
if RUN_RE.match(entry):
|
||||
return None
|
||||
return None
|
||||
return {"state": "running", "reason": "", "at": None}
|
||||
return {"state": "idle", "reason": "", "at": None}
|
||||
|
||||
|
||||
def _halt_reason(entries: list[str]) -> str | None:
|
||||
"""The halt still in force, as one line — the member it happened at and
|
||||
why, which is what the log's reader and the ticker both want."""
|
||||
where = run_state(entries)
|
||||
if where["state"] != "halted":
|
||||
return None
|
||||
return (f"{where['at']}: {where['reason']}" if where["at"]
|
||||
else where["reason"])
|
||||
|
||||
|
||||
# ── reading the board ──────────────────────────────────────────────────
|
||||
@@ -266,6 +287,7 @@ def _snapshot(phase: dict, by_file: dict[str, dict]) -> dict:
|
||||
"""The whole phase, recomputed. Nothing is remembered between passes."""
|
||||
branch = agents.phase_branch(phase["file"])
|
||||
entries = log_entries(phase["body"])
|
||||
where = run_state(entries)
|
||||
started = _started(entries)
|
||||
members = []
|
||||
for listed in phase["members"]:
|
||||
@@ -280,8 +302,20 @@ def _snapshot(phase: dict, by_file: dict[str, dict]) -> dict:
|
||||
"title": task["title"], "stage": task["stage"],
|
||||
"state": member_state, "why": why,
|
||||
"dependsOn": task["dependsOn"]})
|
||||
halted = where["state"] == "halted"
|
||||
return {"file": phase["file"], "branch": branch, "stage": phase["stage"],
|
||||
"members": members, "halted": _halt_reason(entries),
|
||||
"title": phase["title"], "number": phase["number"],
|
||||
"members": members,
|
||||
# the halt as one line for the log's reader, and in its parts for
|
||||
# anything that renders it — the same reading, said twice
|
||||
"halted": _halt_reason(entries),
|
||||
"haltedAt": where["at"] if halted else None,
|
||||
"haltedWhy": where["reason"] if halted else None,
|
||||
# is a run in force? The log answers it, so a restarted board and
|
||||
# the chip in the header read the same thing
|
||||
"running": where["state"] == "running",
|
||||
"stopped": where["state"] == "stopped",
|
||||
"stoppedBy": where["reason"] if where["state"] == "stopped" else None,
|
||||
# the branch is the whole of "this phase has been started"
|
||||
"started": _branch_exists(branch)}
|
||||
|
||||
@@ -428,6 +462,11 @@ def _launch(phase: dict, member: dict) -> None:
|
||||
def _merge_member(phase: dict, member: dict) -> None:
|
||||
branch = f"task/{member['file'][:-3]}"
|
||||
phase_branch = agents.phase_branch(phase["file"])
|
||||
# every advance is narrated, and finishing is its own half of one: what
|
||||
# the phase judged green is a different fact from what it then merged,
|
||||
# and a phase nobody can reconstruct afterwards is a phase nobody trusts
|
||||
_say(phase["file"], f"{phase['file']}: {member['number']} — "
|
||||
f"{member['title']} is green")
|
||||
if _branch_exists(branch):
|
||||
_merge_into_phase(phase, branch, f"{member['number']}'s branch", member)
|
||||
_push_phase(phase)
|
||||
@@ -449,11 +488,17 @@ def _finish(phase: dict) -> None:
|
||||
|
||||
|
||||
def _halt(phase: dict, member: dict | None, reason: str) -> None:
|
||||
"""Stop, and say so once. The log holds the halt from here on, so the
|
||||
next pass reads it rather than saying the same thing again."""
|
||||
"""Stop, and say so once — at three altitudes, like every other outcome
|
||||
a person must not miss. The log holds the halt from here on (so the next
|
||||
pass reads it rather than saying the same thing again), the ticker keeps
|
||||
the line, and a toast says it to whoever is looking: a halt is rare,
|
||||
actionable, and the whole argument for starting a phase and walking away.
|
||||
"""
|
||||
at = f" at {member['number']}" if member and member["number"] else ""
|
||||
_write_log(phase, f"halted{at} — {reason}") # best effort: never re-raise
|
||||
_say(phase["file"], f"{phase['file']} halted{at} — {reason}")
|
||||
state.broadcast({"type": "toast", "error": True,
|
||||
"message": f"phase {phase['file']} halted{at} — {reason}"})
|
||||
|
||||
|
||||
def _do_pass(phase: dict, snapshot: dict, by_number: dict[str, dict]) -> list[str]:
|
||||
@@ -490,7 +535,7 @@ def advance(phase: dict, by_file: dict[str, dict],
|
||||
"""
|
||||
snapshot = _snapshot(phase, by_file)
|
||||
SNAPSHOTS[phase["file"]] = snapshot
|
||||
if snapshot["halted"] or not snapshot["started"] or not _mine(phase):
|
||||
if not snapshot["running"] or not snapshot["started"] or not _mine(phase):
|
||||
return snapshot
|
||||
waiting_on: list[str] = []
|
||||
try:
|
||||
@@ -541,30 +586,33 @@ def beat() -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ── starting one ───────────────────────────────────────────────────────
|
||||
# ── starting one, and stopping it ──────────────────────────────────────
|
||||
|
||||
|
||||
def _phase_card(filename: str, stage: str) -> dict:
|
||||
"""The phase card an action names, or the reason it is not one."""
|
||||
if Path(filename).name != filename or not filename.endswith(".md"):
|
||||
raise ValueError("bad filename")
|
||||
if stage != "in-progress":
|
||||
raise ValueError("a phase runs from in-progress/ — move the card there first")
|
||||
if not (config.TASKS / stage / filename).is_file():
|
||||
raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
|
||||
phase = _cards()[0].get(filename)
|
||||
if phase is None or not phase["isPhase"]:
|
||||
raise ValueError(f"{filename} is not a phase — a phase card is "
|
||||
f"**Type:** Phase with a ## Cards section")
|
||||
return phase
|
||||
|
||||
|
||||
def start_phase(filename: str, stage: str, takeover: bool = False) -> dict:
|
||||
"""Run a phase — the first time, or again after a halt.
|
||||
"""Run a phase — the first time, or again after a halt or a hold.
|
||||
|
||||
Cutting `phase/<stem>` from the newest main is the whole of "starting":
|
||||
everything after it is the beat looking at what is there. Running a
|
||||
halted phase again is the person's decision that cleared the halt, so
|
||||
it appends the line that clears it and takes one pass immediately.
|
||||
"""
|
||||
if Path(filename).name != filename or not filename.endswith(".md"):
|
||||
raise ValueError("bad filename")
|
||||
if stage != "in-progress":
|
||||
raise ValueError("a phase runs from in-progress/ — move the card there first")
|
||||
path = config.TASKS / stage / filename
|
||||
if not path.is_file():
|
||||
raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
|
||||
|
||||
by_file, by_number = _cards()
|
||||
phase = by_file.get(filename)
|
||||
if phase is None or not phase["isPhase"]:
|
||||
raise ValueError(f"{filename} is not a phase — a phase card is "
|
||||
f"**Type:** Phase with a ## Cards section")
|
||||
phase = _phase_card(filename, stage)
|
||||
if phase["phaseDrift"]:
|
||||
raise ValueError(f"{filename} does not resolve: {phase['phaseDrift'][0]} "
|
||||
f"— fix the list before running it")
|
||||
@@ -603,3 +651,56 @@ def _start(phase: dict, filename: str) -> dict:
|
||||
|
||||
by_file, by_number = _cards() # the log just rewrote the card
|
||||
return advance(by_file.get(filename) or phase, by_file, by_number)
|
||||
|
||||
|
||||
def _agents_on(files: set[str]) -> list[dict]:
|
||||
with state.LOCK:
|
||||
return [dict(r) for r in state.AGENTS.values()
|
||||
if r["task"] in files and r["status"] == "running"]
|
||||
|
||||
|
||||
def stop_phase(filename: str, stage: str) -> dict:
|
||||
"""Hold a phase: stop, without unwinding anything.
|
||||
|
||||
`‖ hold` means here what it means on any other card — the work stops
|
||||
and nothing is lost. So the line goes into the log (which is what the
|
||||
beat reads, so the next pass stands down), and the agent the phase has
|
||||
in flight is held exactly as its own card's hold would hold it. The
|
||||
phase branch, every member already merged into it, and every worktree
|
||||
are left precisely as they were: a hold is not an undo, and the only
|
||||
way back to `main` is still the PR at the end.
|
||||
|
||||
A halted phase can be held too, and that is the other half of the
|
||||
halt's promise: it holds until the phase is run again *or* stopped,
|
||||
and a person who has read the halt and does not want to carry on needs
|
||||
a way to say so that is not walking the card backwards.
|
||||
|
||||
A held member's card keeps its own work and its own state, so running
|
||||
the phase again may well halt on it — that is the honest reading of a
|
||||
run that ended without reaching review/, and it is a person's to settle.
|
||||
"""
|
||||
phase = _phase_card(filename, stage)
|
||||
with _LOCK: # never alongside a pass of the beat
|
||||
where = run_state(log_entries(phase["body"]))
|
||||
if where["state"] not in ("running", "halted"):
|
||||
raise ValueError(f"{filename} is not running — nothing to hold "
|
||||
f"(its phase log says: {where['state']})")
|
||||
who = taskfiles.actor_name() or "you"
|
||||
if not _write_log(phase, f"stopped — held by {who}"):
|
||||
raise ValueError(f"could not record the hold on {filename} — its "
|
||||
f"phase log must be writable, or the next beat "
|
||||
f"would carry on regardless")
|
||||
held = []
|
||||
for record in _agents_on({m["file"] for m in phase["members"]}):
|
||||
try:
|
||||
agents.stop_agent(record["id"])
|
||||
except ValueError: # it ended between the read and the ask
|
||||
continue
|
||||
held.append(record["task"])
|
||||
_say(filename, f"phase {filename} held by {who}"
|
||||
+ (f" — {', '.join(held)} stopped with it" if held else "")
|
||||
+ f" — {agents.phase_branch(filename)} is left as it is")
|
||||
snapshot = _snapshot(_reread(phase) or phase, _cards()[0])
|
||||
SNAPSHOTS[filename] = snapshot
|
||||
state.broadcast({"type": "board"})
|
||||
return snapshot
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -709,7 +710,11 @@ class TheSyncChip(unittest.TestCase):
|
||||
|
||||
def test_sync_events_have_a_glyph_and_a_filter(self):
|
||||
self.assertIn("sync: '⇅'", self.html)
|
||||
self.assertIn("new Set(['move', 'new', 'agent', 'sync'])", self.html)
|
||||
# the board-level kinds share the Moves filter; what matters here is
|
||||
# that sync is one of them, not which others have joined it since
|
||||
moves = re.search(r"\['moves', 'Moves', new Set\((\[[^\]]*\])\)\]", self.html)
|
||||
self.assertIsNotNone(moves, "board.html lost its Moves filter")
|
||||
self.assertIn("'sync'", moves.group(1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
"""The board shows a phase running, and shows it stopping (task 50).
|
||||
|
||||
Card 49 built the runner; this one is the interface it does not inherit
|
||||
from being a card — an action to start and stop it, a header chip while it
|
||||
runs, and the same chip in `--alarm` when it halts.
|
||||
|
||||
Two halves, tested the way each can be: the runner's own half (a hold that
|
||||
records itself, a run-state read back off the phase log, the toast a halt
|
||||
fires) runs for real on a real repo through `tests.test_phase_runs`'
|
||||
harness; the page's half is source-level invariants over board.html, with
|
||||
its pure functions lifted out and run under node — board.html has no test
|
||||
runner, and a chip that quietly shows one of two running phases is exactly
|
||||
the kind of thing that would otherwise reach a person at 2am.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "manager" / "core"))
|
||||
|
||||
import config # noqa: E402
|
||||
import phases # noqa: E402
|
||||
import state # noqa: E402
|
||||
|
||||
from tests.test_phase_runs import (DIES, ONE, PHASE, PHASE_BRANCH, TWO, # noqa: E402
|
||||
PhaseCase, card, wait_for)
|
||||
|
||||
BOARD = REPO / "manager" / "core" / "board.html"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
# An agent that is still working when the hold arrives.
|
||||
SLEEPS = """#!/usr/bin/env python3
|
||||
import time
|
||||
time.sleep(60)
|
||||
"""
|
||||
|
||||
|
||||
class HoldingAPhase(PhaseCase):
|
||||
"""`‖ hold` means here what it means everywhere else: the work stops
|
||||
and nothing is lost."""
|
||||
|
||||
def running_agents(self) -> list[dict]:
|
||||
return [r for r in state.AGENTS.values() if r["status"] == "running"]
|
||||
|
||||
def test_the_hold_is_recorded_where_the_beat_reads_it(self):
|
||||
self.start()
|
||||
|
||||
snapshot = phases.stop_phase(PHASE, "in-progress")
|
||||
|
||||
self.assertTrue(any(e.startswith("stopped — held by") for e in self.log()))
|
||||
self.assertFalse(snapshot["running"])
|
||||
self.assertTrue(snapshot["stopped"])
|
||||
self.assertTrue(any("held by" in s for s in self.summaries()))
|
||||
|
||||
def test_the_beat_stands_down_afterwards(self):
|
||||
self.start()
|
||||
phases.stop_phase(PHASE, "in-progress")
|
||||
state.BOARD_EVENTS.clear()
|
||||
|
||||
snapshot = self.advance()
|
||||
|
||||
self.assertEqual(self.summaries(), [], "a held phase is quiet")
|
||||
self.assertEqual(self.merges_on(PHASE_BRANCH), [],
|
||||
"and merges nothing more")
|
||||
self.assertFalse(self.branch_exists(f"task/{TWO[:-3]}"),
|
||||
"and launches nothing more")
|
||||
self.assertTrue(snapshot["stopped"])
|
||||
|
||||
def test_the_phase_branch_and_what_landed_on_it_are_untouched(self):
|
||||
self.start()
|
||||
self.advance() # 31 merges, 32 runs
|
||||
merges, tip = self.merges_on(PHASE_BRANCH), self.tip(PHASE_BRANCH)
|
||||
|
||||
phases.stop_phase(PHASE, "in-progress")
|
||||
|
||||
self.assertEqual(self.merges_on(PHASE_BRANCH), merges)
|
||||
self.assertEqual(self.tip(PHASE_BRANCH), tip, "a hold is not an undo")
|
||||
self.assertTrue(self.branch_exists(f"task/{ONE[:-3]}"))
|
||||
self.assertTrue((config.WORKTREES / PHASE[:-3]).is_dir())
|
||||
|
||||
def test_the_member_in_flight_is_held_with_it(self):
|
||||
"""A phase that said it had stopped while the agent it launched
|
||||
kept working would be lying about the one thing it was asked."""
|
||||
self.adapter_is(SLEEPS)
|
||||
phases.start_phase(PHASE, "in-progress")
|
||||
self.assertTrue(wait_for(lambda: any(r["task"] == ONE for r in
|
||||
self.running_agents())),
|
||||
"the phase never launched its first member")
|
||||
|
||||
phases.stop_phase(PHASE, "in-progress")
|
||||
|
||||
self.assertTrue(wait_for(lambda: not self.running_agents()),
|
||||
"the member's agent was left running")
|
||||
self.assertTrue(any(ONE in s and "stopped with it" in s
|
||||
for s in self.summaries()),
|
||||
"and the ticker says which cards stopped with it")
|
||||
|
||||
def test_running_it_again_after_a_hold_carries_on(self):
|
||||
self.start()
|
||||
phases.stop_phase(PHASE, "in-progress")
|
||||
|
||||
phases.start_phase(PHASE, "in-progress")
|
||||
self.settle()
|
||||
|
||||
self.assertIn(f"31 merged into {PHASE_BRANCH}", self.log())
|
||||
self.assertTrue(self.branch_exists(f"task/{TWO[:-3]}"),
|
||||
"the next member started")
|
||||
|
||||
def test_a_halted_phase_can_be_held_rather_than_run_again(self):
|
||||
"""The halt holds until the phase is resumed *or* stopped: someone
|
||||
who has read it and does not want to carry on says so here."""
|
||||
self.adapter_is(DIES)
|
||||
self.start()
|
||||
self.advance()
|
||||
self.assertIsNotNone(self.advance()["halted"])
|
||||
|
||||
snapshot = phases.stop_phase(PHASE, "in-progress")
|
||||
|
||||
self.assertIsNone(snapshot["halted"], "the alarm is settled")
|
||||
self.assertTrue(snapshot["stopped"])
|
||||
|
||||
def test_holding_a_phase_that_never_ran_refuses(self):
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
phases.stop_phase(PHASE, "in-progress")
|
||||
|
||||
self.assertIn("not running", str(caught.exception))
|
||||
|
||||
def test_holding_it_twice_refuses(self):
|
||||
self.start()
|
||||
phases.stop_phase(PHASE, "in-progress")
|
||||
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
phases.stop_phase(PHASE, "in-progress")
|
||||
|
||||
self.assertIn("not running", str(caught.exception))
|
||||
|
||||
def test_an_ordinary_card_is_not_held_as_a_phase(self):
|
||||
self.write(ONE, card("31 — Stand up site/", status="In Progress"),
|
||||
"in-progress")
|
||||
(self.tasks / "backlog" / ONE).unlink()
|
||||
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
phases.stop_phase(ONE, "in-progress")
|
||||
|
||||
self.assertIn("not a phase", str(caught.exception))
|
||||
|
||||
|
||||
class WhatTheChipIsToldFrom(PhaseCase):
|
||||
"""The snapshot the API carries has to answer the chip's questions —
|
||||
which phase, how far, on which card, and what stopped it — without the
|
||||
page inferring any of them."""
|
||||
|
||||
def test_a_running_phase_names_itself_and_where_it_is(self):
|
||||
self.adapter_is(SLEEPS) # so the member is still in flight
|
||||
phases.start_phase(PHASE, "in-progress")
|
||||
self.addCleanup(phases.stop_phase, PHASE, "in-progress")
|
||||
|
||||
snapshot = phases.public_state()[PHASE]
|
||||
|
||||
self.assertTrue(snapshot["running"])
|
||||
self.assertIsNone(snapshot["halted"])
|
||||
self.assertEqual(snapshot["title"], "40 — Ship the site")
|
||||
self.assertEqual(snapshot["number"], "40")
|
||||
self.assertEqual(snapshot["branch"], PHASE_BRANCH)
|
||||
self.assertEqual([m["number"] for m in snapshot["members"]], ["31", "32"])
|
||||
self.assertEqual([m["state"] for m in snapshot["members"]],
|
||||
["running", "pending"])
|
||||
|
||||
def test_a_halt_arrives_in_its_parts(self):
|
||||
self.adapter_is(DIES)
|
||||
self.start()
|
||||
|
||||
snapshot = self.advance()
|
||||
|
||||
self.assertEqual(snapshot["haltedAt"], "31")
|
||||
self.assertIn("without reaching review/", snapshot["haltedWhy"])
|
||||
self.assertFalse(snapshot["running"])
|
||||
# the one line the log's reader gets, still said the old way
|
||||
self.assertTrue(snapshot["halted"].startswith("31: "))
|
||||
|
||||
def test_a_halt_is_a_toast_as_well_as_a_state_and_a_line(self):
|
||||
"""Rare and actionable: the person who is not watching the ticker
|
||||
is told once, the way a dead run tells them."""
|
||||
self.adapter_is(DIES)
|
||||
self.start()
|
||||
self.sent.clear()
|
||||
|
||||
phases.advance_all() # the pass halts; it launches nothing
|
||||
|
||||
toasts = [m for m in self.sent if m.get("type") == "toast"]
|
||||
self.assertEqual(len(toasts), 1, "said once, not once a beat")
|
||||
self.assertTrue(toasts[0]["error"])
|
||||
self.assertIn(PHASE, toasts[0]["message"])
|
||||
self.assertIn("halted at 31", toasts[0]["message"])
|
||||
|
||||
def test_a_second_beat_repeats_neither_the_toast_nor_the_line(self):
|
||||
self.adapter_is(DIES)
|
||||
self.start()
|
||||
self.advance()
|
||||
self.sent.clear()
|
||||
|
||||
phases.advance_all()
|
||||
|
||||
self.assertEqual([m for m in self.sent if m.get("type") == "toast"], [])
|
||||
|
||||
def test_two_phases_are_two_snapshots(self):
|
||||
"""Two phases could in principle run on one board — the API says so
|
||||
rather than leaving the header to show one of them."""
|
||||
self.write("41-second-phase.md",
|
||||
card("41 — A second phase", status="In Progress", kind="Phase",
|
||||
cards="- 77 — On its own\n"), "in-progress")
|
||||
self.write("77-alone.md", card("77 — On its own"))
|
||||
self.start()
|
||||
phases.start_phase("41-second-phase.md", "in-progress")
|
||||
self.settle()
|
||||
|
||||
live = phases.public_state()
|
||||
|
||||
self.assertEqual(sorted(live), [PHASE, "41-second-phase.md"])
|
||||
self.assertTrue(all(p["running"] for p in live.values()))
|
||||
|
||||
def test_a_phase_out_of_in_progress_leaves_the_header(self):
|
||||
self.start()
|
||||
self.advance()
|
||||
self.advance() # every member merged: the card moves to review/
|
||||
self.advance() # the next beat, with nothing in in-progress/
|
||||
|
||||
self.assertEqual(self.stage_of(PHASE), "review")
|
||||
self.assertEqual(phases.public_state(), {},
|
||||
"nothing is in flight, so there is nothing to show")
|
||||
|
||||
|
||||
class EveryAdvanceIsNarrated(PhaseCase):
|
||||
"""A phase that advances silently is a phase nobody can debug
|
||||
afterwards: what finished, what merged, what started."""
|
||||
|
||||
def test_the_ticker_names_all_three(self):
|
||||
self.start()
|
||||
state.BOARD_EVENTS.clear()
|
||||
|
||||
self.advance()
|
||||
|
||||
said = " | ".join(self.summaries())
|
||||
self.assertIn("31 — 31 — Stand up site/ is green", said)
|
||||
self.assertIn(f"merged 31 into {PHASE_BRANCH}", said)
|
||||
self.assertIn("started 32", said)
|
||||
|
||||
def test_the_events_are_phase_events(self):
|
||||
self.start()
|
||||
|
||||
mine = [e for e in state.BOARD_EVENTS if e["file"] == PHASE]
|
||||
|
||||
self.assertTrue(mine, "the phase narrated nothing at all")
|
||||
self.assertEqual({e["kind"] for e in mine}, {"phase"},
|
||||
"the runner's narration is its own kind of event")
|
||||
|
||||
|
||||
class TheLogSaysWhereAPhaseIs(unittest.TestCase):
|
||||
"""`run_state` is the whole of a restarted board's memory of a run, and
|
||||
now of the header's too. The last line that says something wins."""
|
||||
|
||||
def entries(self, *lines: str) -> list[str]:
|
||||
body = "\n".join(f"- 2026-08-01 09:0{i} · {line}"
|
||||
for i, line in enumerate(lines))
|
||||
return phases.log_entries(f"# 40\n\n## Phase log\n\n{body}\n")
|
||||
|
||||
def state_of(self, *lines: str) -> str:
|
||||
return phases.run_state(self.entries(*lines))["state"]
|
||||
|
||||
def test_a_card_that_has_never_run_is_idle(self):
|
||||
self.assertEqual(phases.run_state([])["state"], "idle")
|
||||
|
||||
def test_a_started_run_is_running(self):
|
||||
self.assertEqual(self.state_of("run started on phase/40-x", "31 started"),
|
||||
"running")
|
||||
|
||||
def test_a_halt_stops_it(self):
|
||||
where = phases.run_state(self.entries("run started on phase/40-x",
|
||||
"halted at 31 — its CI is red"))
|
||||
self.assertEqual(where["state"], "halted")
|
||||
self.assertEqual(where["at"], "31")
|
||||
self.assertEqual(where["reason"], "its CI is red")
|
||||
|
||||
def test_a_hold_stops_it(self):
|
||||
where = phases.run_state(self.entries("run started on phase/40-x",
|
||||
"stopped — held by ronald"))
|
||||
self.assertEqual(where["state"], "stopped")
|
||||
self.assertEqual(where["reason"], "held by ronald")
|
||||
|
||||
def test_a_hold_after_a_halt_settles_it(self):
|
||||
self.assertEqual(self.state_of("halted at 31 — its CI is red",
|
||||
"stopped — held by ronald"), "stopped")
|
||||
self.assertIsNone(phases._halt_reason(
|
||||
self.entries("halted at 31 — its CI is red", "stopped — held by ronald")))
|
||||
|
||||
def test_running_it_again_clears_either(self):
|
||||
self.assertEqual(self.state_of("stopped — held by ronald",
|
||||
"run started on phase/40-x"), "running")
|
||||
self.assertEqual(self.state_of("halted at 31 — its CI is red",
|
||||
"run started on phase/40-x"), "running")
|
||||
|
||||
|
||||
class TheHeaderChip(unittest.TestCase):
|
||||
"""board.html has no test runner, so these are source-level invariants:
|
||||
the chip exists, it is absent when there is nothing to say, it wears
|
||||
the design system's colours and it is one chip per phase."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = BOARD.read_text(encoding="utf-8")
|
||||
|
||||
def func(self, name: str) -> str:
|
||||
match = re.search(rf"^function {name}\(.*?\n\}}\n", self.html,
|
||||
re.M | re.S)
|
||||
self.assertIsNotNone(match, f"board.html lost its {name}()")
|
||||
return match.group(0)
|
||||
|
||||
def test_the_chip_has_a_home_beside_the_agents_chip(self):
|
||||
header = re.search(r"<header>.*?</header>", self.html, re.S).group(0)
|
||||
self.assertIn('id="phasechips"', header)
|
||||
self.assertLess(header.index('id="phasechips"'), header.index('id="livechip"'),
|
||||
"the phase chip sits with the header's other live chips")
|
||||
|
||||
def test_it_is_rendered_on_every_frame(self):
|
||||
self.assertIn("renderPhases();", self.func("render"))
|
||||
|
||||
def test_nothing_running_is_no_chip_at_all(self):
|
||||
body = self.func("renderPhases")
|
||||
self.assertIn("el.hidden = !live.length", body,
|
||||
"an empty chip or a placeholder is not the answer")
|
||||
|
||||
def test_only_a_run_or_a_halt_is_in_flight(self):
|
||||
self.assertIn("p.running || p.halted", self.func("phasesInFlight"))
|
||||
|
||||
def test_a_halt_leads(self):
|
||||
self.assertIn("sort(", self.func("phasesInFlight"))
|
||||
self.assertIn("b.halted", self.func("phasesInFlight"))
|
||||
|
||||
def test_every_phase_in_flight_gets_its_own_chip(self):
|
||||
body = self.func("renderPhases")
|
||||
self.assertIn("live.map(", body,
|
||||
"one chip per phase — never one of two, silently")
|
||||
|
||||
def test_the_colours_mean_what_they_mean(self):
|
||||
body = self.func("renderPhases")
|
||||
self.assertIn("dot live", body, "a run alive breathes in --accent")
|
||||
self.assertIn("var(--alarm)", body, "a halt holds in --alarm")
|
||||
|
||||
def test_the_chip_opens_the_phase_card(self):
|
||||
body = self.func("renderPhases")
|
||||
self.assertIn("data-phasechip", body)
|
||||
self.assertIn("showDetail(task)", body)
|
||||
|
||||
def test_the_page_still_parses(self):
|
||||
scripts = re.findall(r"<script[^>]*>(.*?)</script>", self.html, re.S)
|
||||
self.assertTrue(scripts, "board.html has no inline script")
|
||||
if not NODE:
|
||||
self.skipTest("node is needed to parse the page")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for index, script in enumerate(scripts):
|
||||
source = Path(tmp) / f"page-{index}.js"
|
||||
source.write_text(script, encoding="utf-8")
|
||||
out = subprocess.run([NODE, "--check", str(source)],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(out.returncode, 0, out.stderr)
|
||||
|
||||
|
||||
@unittest.skipUnless(NODE, "node is needed to run the page's own functions")
|
||||
class WhatTheChipSays(unittest.TestCase):
|
||||
"""`phaseChipDetail` is a pure function of the snapshot, so — as with
|
||||
`md()` and `phaseLabel()` — it is lifted out of the page and run."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
html = BOARD.read_text(encoding="utf-8")
|
||||
source = ""
|
||||
for name in ("phaseProgress", "clip", "phaseChipDetail", "phasesInFlight"):
|
||||
match = re.search(rf"^function {name}\(.*?\n\}}\n", html, re.M | re.S)
|
||||
assert match, f"board.html lost its {name}()"
|
||||
source += match.group(0)
|
||||
cls._dir = tempfile.TemporaryDirectory()
|
||||
cls.js = Path(cls._dir.name) / "chip.js"
|
||||
cls.js.write_text(
|
||||
"const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));\n"
|
||||
"const S = { state: { phases: input.phases || {} } };\n" + source +
|
||||
"process.stdout.write(JSON.stringify(input.phase "
|
||||
"? phaseChipDetail(input.phase) "
|
||||
": phasesInFlight().map(p => p.file)));\n", encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls._dir.cleanup()
|
||||
|
||||
def run_js(self, payload: dict):
|
||||
out = subprocess.run([NODE, str(self.js)], input=json.dumps(payload),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(out.returncode, 0, out.stderr)
|
||||
return json.loads(out.stdout)
|
||||
|
||||
def detail(self, **phase) -> str:
|
||||
return self.run_js({"phase": phase})
|
||||
|
||||
def members(self, *states) -> list[dict]:
|
||||
return [{"number": str(30 + i), "file": f"{30 + i}-c.md", "state": s}
|
||||
for i, s in enumerate(states, start=1)]
|
||||
|
||||
def test_a_run_says_how_far_and_which_card(self):
|
||||
"""Progress is what has landed on the phase branch, not where the
|
||||
pointer is — `2/5 · on #33` says two different things, which is the
|
||||
whole reason both are on the chip."""
|
||||
self.assertEqual(
|
||||
self.detail(running=True,
|
||||
members=self.members("merged", "merged", "running",
|
||||
"pending", "pending")),
|
||||
"2/5 · on #33")
|
||||
|
||||
def test_a_member_waiting_on_its_checks_is_still_the_card_in_flight(self):
|
||||
self.assertEqual(
|
||||
self.detail(running=True, members=self.members("merged", "waiting")),
|
||||
"1/2 · on #32")
|
||||
|
||||
def test_a_dependency_holding_the_phase_is_named(self):
|
||||
self.assertEqual(
|
||||
self.detail(running=True, waitingOn=["99"],
|
||||
members=self.members("merged", "pending")),
|
||||
"1/2 · waiting on #99")
|
||||
|
||||
def test_between_passes_the_progress_stands_alone(self):
|
||||
self.assertEqual(
|
||||
self.detail(running=True, members=self.members("merged", "pending")),
|
||||
"1/2")
|
||||
|
||||
def test_a_halt_names_the_card_and_the_reason(self):
|
||||
self.assertEqual(
|
||||
self.detail(halted="35: it is not ready", haltedAt="35",
|
||||
haltedWhy="it is not ready", members=self.members("merged")),
|
||||
"halted at #35 — it is not ready")
|
||||
|
||||
def test_a_long_reason_is_clipped_for_the_header(self):
|
||||
detail = self.detail(halted="x", haltedAt="35", members=self.members(),
|
||||
haltedWhy="its run ended without reaching review/ — "
|
||||
"API Error: 500 overloaded, the model said")
|
||||
self.assertTrue(detail.endswith("…"), detail)
|
||||
self.assertLessEqual(len(detail), 64, "a header chip is not the place "
|
||||
"a reason gets to run long")
|
||||
|
||||
def test_a_halt_with_no_member_still_says_what_stopped(self):
|
||||
self.assertEqual(
|
||||
self.detail(halted="the run could not continue: no", members=[],
|
||||
haltedWhy="the run could not continue: no"),
|
||||
"halted — the run could not continue: no")
|
||||
|
||||
def test_a_quiet_board_has_nothing_in_flight(self):
|
||||
self.assertEqual(self.run_js({"phases": {
|
||||
"40-a.md": {"file": "40-a.md", "running": False, "halted": None},
|
||||
}}), [])
|
||||
|
||||
def test_a_halted_phase_leads_the_running_one(self):
|
||||
self.assertEqual(self.run_js({"phases": {
|
||||
"40-a.md": {"file": "40-a.md", "running": True, "halted": None},
|
||||
"41-b.md": {"file": "41-b.md", "running": False, "halted": "31: red"},
|
||||
}}), ["41-b.md", "40-a.md"])
|
||||
|
||||
|
||||
class TheCardsActions(unittest.TestCase):
|
||||
"""▸ run phase sits in the slot ▸ start work has on every other card,
|
||||
and ‖ hold replaces it while the phase runs — both on the one action
|
||||
machine, so both arm before they fire."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = BOARD.read_text(encoding="utf-8")
|
||||
block = re.search(r"if \(task\.stage === 'in-progress' && task\.isPhase\)"
|
||||
r" \{.*?\n \} else if \(task\.stage === 'in-progress'\)",
|
||||
cls.html, re.S)
|
||||
assert block, "board.html lost the phase card's actions"
|
||||
cls.block = block.group(0)
|
||||
|
||||
def test_the_action_only_exists_on_an_in_progress_phase_card(self):
|
||||
self.assertIn("task.stage === 'in-progress' && task.isPhase", self.html)
|
||||
|
||||
def test_running_a_phase_arms_like_every_other_launch(self):
|
||||
self.assertIn("label: 'run phase'", self.block)
|
||||
self.assertIn("confirm: 'run it?'", self.block)
|
||||
self.assertIn("busy: 'starting…'", self.block)
|
||||
|
||||
def test_a_phase_someone_else_holds_is_taken_over_deliberately(self):
|
||||
self.assertIn("label: 'take over'", self.block)
|
||||
self.assertIn("takeover: true", self.block)
|
||||
|
||||
def test_hold_is_the_word_while_it_runs(self):
|
||||
self.assertIn("ph.running) actions.push(hold)", self.block)
|
||||
self.assertIn("label: 'hold'", self.block)
|
||||
self.assertIn("confirm: 'hold it?'", self.block)
|
||||
|
||||
def test_a_halt_offers_both_ways_out(self):
|
||||
self.assertIn("ph.halted) actions.push(start, hold)", self.block)
|
||||
|
||||
def test_the_slot_never_holds_three(self):
|
||||
self.assertIn("else if (actions.length < 2) {", self.html,
|
||||
"the relevance check gives way rather than stacking")
|
||||
|
||||
def test_both_go_through_the_api(self):
|
||||
run = re.search(r"async function runPhase.*?\n\}", self.html, re.S).group(0)
|
||||
hold = re.search(r"async function holdPhase.*?\n\}", self.html, re.S).group(0)
|
||||
self.assertIn("'/api/phase/run'", run)
|
||||
self.assertIn("'/api/phase/stop'", hold)
|
||||
for body in (run, hold):
|
||||
self.assertIn("return res.ok", body,
|
||||
"fireAction can only unlock on error if it is told")
|
||||
self.assertIn("toast(", body)
|
||||
|
||||
|
||||
class ThePhaseCardsSheet(unittest.TestCase):
|
||||
"""Opening a phase card answers "where is this up to" without a hunt
|
||||
across five columns."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = BOARD.read_text(encoding="utf-8")
|
||||
cls.block = re.search(r"function phaseMembers\(t\) \{.*?\n\}\n",
|
||||
cls.html, re.S).group(0)
|
||||
|
||||
def test_the_sheet_lists_the_members(self):
|
||||
self.assertIn("phaseMembers(t) +", self.html,
|
||||
"the list belongs in the drawer, above the card's text")
|
||||
|
||||
def test_only_a_phase_card_gets_one(self):
|
||||
self.assertIn("if (!t.isPhase) return '';", self.block)
|
||||
|
||||
def test_run_order_is_the_order_it_lists(self):
|
||||
self.assertIn("t.members.map((m, i)", self.block)
|
||||
self.assertIn("${i + 1}", self.block)
|
||||
|
||||
def test_each_member_carries_its_stage(self):
|
||||
self.assertIn("m.stage", self.block)
|
||||
|
||||
def test_the_runners_reading_is_shown_when_there_is_one(self):
|
||||
self.assertIn("S.state.phases || {}", self.block)
|
||||
self.assertIn("MEMBER_STATE", self.block)
|
||||
for word in ("merged in", "working", "stopped here"):
|
||||
self.assertIn(word, self.html)
|
||||
|
||||
def test_a_row_opens_that_card(self):
|
||||
self.assertIn("data-member", self.block)
|
||||
self.assertIn("findTask(row.dataset.member)", self.html)
|
||||
|
||||
|
||||
class TheTickerReadsPhaseEvents(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = BOARD.read_text(encoding="utf-8")
|
||||
|
||||
def test_a_phase_event_has_a_glyph_of_its_own(self):
|
||||
glyphs = re.search(r"const GLYPHS = \{.*?\};", self.html, re.S).group(0)
|
||||
self.assertIn("phase: '⟶'", glyphs)
|
||||
|
||||
def test_phase_events_are_in_the_moves_filter(self):
|
||||
moves = re.search(r"\['moves', 'Moves', new Set\((\[[^\]]*\])\)\]", self.html)
|
||||
self.assertIsNotNone(moves, "board.html lost its Moves filter")
|
||||
self.assertIn("'phase'", moves.group(1),
|
||||
"a phase's narration is board-level, like a move or a sync")
|
||||
|
||||
|
||||
class TheApiHasAStop(unittest.TestCase):
|
||||
def test_the_route_exists(self):
|
||||
source = (REPO / "manager" / "core" / "httpd.py").read_text(encoding="utf-8")
|
||||
self.assertIn('"/api/phase/stop"', source)
|
||||
self.assertIn("phases.stop_phase", source)
|
||||
|
||||
|
||||
class TheDocumentedPromise(unittest.TestCase):
|
||||
"""What the board does is documented in AGENTS.md; an interface that is
|
||||
not written down is one the next reader has to find by clicking."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.doc = (REPO / "AGENTS.md").read_text(encoding="utf-8")
|
||||
|
||||
def test_the_actions_are_named(self):
|
||||
self.assertIn("▸ run phase", self.doc)
|
||||
|
||||
def test_the_chip_is_described(self):
|
||||
self.assertIn("halted", self.doc)
|
||||
self.assertIn("header", self.doc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user